Skip to content

필사 모드: 图神经网络(GNN)完全指南:从 GCN、GAT、GraphSAGE 到分子设计

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

图神经网络(GNN)完全指南

社交网络、分子结构、知识图谱、推荐系统 — 现实世界中无数的数据都以图的形式呈现。图神经网络(Graph Neural Network, GNN)正是用深度学习处理这类非欧几里得(non-Euclidean)数据的核心工具。本指南将系统梳理从图论基础到最新 GNN 架构、再到基于 PyTorch Geometric 的实战实现的完整内容。

1. 图论基础

图的定义

图 G 由节点(Node)集合 V 与边(Edge)集合 E 构成,记作 G = (V, E)。节点表示实体(entity),边表示实体之间的关系。

  • 节点(Vertex/Node):表示实体。例:用户、原子、论文
  • 边(Edge):表示关系。例:好友关系、化学键、引用关系
  • 节点特征(Node Feature):连接到每个节点的特征向量
  • 边特征(Edge Feature):连接到每条边的特征向量

有向图/无向图

import networkx as nx
import matplotlib.pyplot as plt
import numpy as np

# 无向图 (Undirected Graph)
G_undirected = nx.Graph()
G_undirected.add_edges_from([(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)])

# 有向图 (Directed Graph)
G_directed = nx.DiGraph()
G_directed.add_edges_from([(0, 1), (1, 2), (2, 0), (0, 3)])

print(f"无向图 - 节点数: {G_undirected.number_of_nodes()}, 边数: {G_undirected.number_of_edges()}")
print(f"有向图 - 节点数: {G_directed.number_of_nodes()}, 边数: {G_directed.number_of_edges()}")

邻接矩阵与边列表

import torch
import numpy as np

# 邻接矩阵 (Adjacency Matrix)
# A[i][j] = 1 表示节点 i 与 j 之间存在边
adj_matrix = torch.tensor([
    [0, 1, 1, 0],
    [1, 0, 1, 0],
    [1, 1, 0, 1],
    [0, 0, 1, 0]
], dtype=torch.float32)

# 边列表 (Edge Index) - PyG 使用的格式
# shape: (2, num_edges) - 第一行: 源节点, 第二行: 目标节点
edge_index = torch.tensor([
    [0, 0, 1, 1, 2, 2, 2, 3],  # 源节点
    [1, 2, 0, 2, 0, 1, 3, 2]   # 目标节点
], dtype=torch.long)

print(f"邻接矩阵大小: {adj_matrix.shape}")  # (4, 4)
print(f"边列表大小: {edge_index.shape}")  # (2, 8)

# 邻接矩阵 -> 边列表 转换
def adj_to_edge_index(adj):
    """将邻接矩阵转换为边索引"""
    row, col = torch.where(adj > 0)
    return torch.stack([row, col], dim=0)

converted = adj_to_edge_index(adj_matrix)
print(f"转换后的边列表:\n{converted}")

图的特性

import networkx as nx
import numpy as np

def analyze_graph(G):
    """分析图的主要特性"""

    # 度数 (Degree)
    degrees = dict(G.degree())
    avg_degree = np.mean(list(degrees.values()))

    # 聚类系数 (Clustering Coefficient)
    clustering = nx.average_clustering(G)

    # 平均路径长度 (Average Path Length)
    if nx.is_connected(G):
        avg_path = nx.average_shortest_path_length(G)
    else:
        # 使用最大的连通分量
        largest_cc = max(nx.connected_components(G), key=len)
        subgraph = G.subgraph(largest_cc)
        avg_path = nx.average_shortest_path_length(subgraph)

    # 中心性 (Centrality)
    betweenness = nx.betweenness_centrality(G)
    pagerank = nx.pagerank(G)

    print(f"节点数: {G.number_of_nodes()}")
    print(f"边数: {G.number_of_edges()}")
    print(f"平均度数: {avg_degree:.2f}")
    print(f"聚类系数: {clustering:.3f}")
    print(f"平均路径长度: {avg_path:.2f}")

    return {
        "degrees": degrees,
        "clustering": clustering,
        "avg_path": avg_path,
        "betweenness": betweenness,
        "pagerank": pagerank
    }

# 社交网络示例 (Karate Club)
G = nx.karate_club_graph()
stats = analyze_graph(G)

现实世界中的图

领域节点任务
社交网络用户好友关系社区发现
分子结构原子化学键分子性质预测
知识图谱实体关系链接预测
引用网络论文引用关系节点分类
交通网络路口道路路径预测
推荐系统用户/物品交互推荐

2. 图机器学习的动机

为什么 CNN/RNN 力不从心?

传统的 CNN 以网格(grid)结构为前提。图像的像素规则地排列在 2D 网格上,因此卷积运算能够自然地发挥作用。RNN 则假设数据具有序列(sequence)结构。

但图具有以下特性:

  • 非规则结构:每个节点的邻居数各不相同
  • 没有固定顺序:节点具有排列不变性(Permutation Invariance)
  • 全局依赖性:距离较远的节点之间也可能相互影响
# 图数据的特性说明
# 图像: 固定大小的网格
image = torch.randn(3, 224, 224)  # 通道, 高度, 宽度

# 序列: 有顺序的数据
sequence = torch.randn(100, 512)  # 序列长度, 特征维度

# 图: 可变的邻居结构
# 节点特征: (num_nodes, feature_dim)
node_features = torch.randn(34, 16)  # 34个节点, 16维特征
# 边: (2, num_edges) - 稀疏连接
edge_index = torch.randint(0, 34, (2, 78))

消息传递范式

所有 GNN 的基本原理都是消息传递(Message Passing)。每个节点从邻居节点接收消息,并以此更新自己的表示。

消息传递神经网络(MPNN)框架:

  1. 消息计算:针对边 (u, v),计算从节点 u 传递给 v 的消息
  2. 聚合:每个节点将所有邻居消息合并
  3. 更新:用聚合后的消息更新节点表示
m_v^(l) = AGGREGATE({h_u^(l-1) : u in N(v)})
h_v^(l) = UPDATE(h_v^(l-1), m_v^(l))

其中 N(v) 是节点 v 的邻居集合。

3. GNN 基础公式

聚合(Aggregation)与更新(Update)

import torch
import torch.nn as nn
from torch_scatter import scatter_mean, scatter_sum, scatter_max

def manual_message_passing(node_features, edge_index, aggregation="mean"):
    """
    手动实现的消息传递
    node_features: (N, F) - N个节点, F维特征
    edge_index: (2, E) - E条边
    """
    src, dst = edge_index[0], edge_index[1]
    num_nodes = node_features.size(0)

    # 使用源节点的特征作为消息
    messages = node_features[src]  # (E, F)

    if aggregation == "mean":
        # 按目标节点求平均
        aggregated = scatter_mean(messages, dst, dim=0, dim_size=num_nodes)
    elif aggregation == "sum":
        aggregated = scatter_sum(messages, dst, dim=0, dim_size=num_nodes)
    elif aggregation == "max":
        aggregated, _ = scatter_max(messages, dst, dim=0, dim_size=num_nodes)

    # 更新: 原始特征 + 聚合后的消息
    updated = node_features + aggregated
    return updated

# 示例
N, F = 6, 8
node_features = torch.randn(N, F)
edge_index = torch.tensor([[0,1,2,3,4,0,1], [1,2,3,4,0,3,4]])

output = manual_message_passing(node_features, edge_index, "mean")
print(f"Input shape: {node_features.shape}")
print(f"Output shape: {output.shape}")

4. 主要 GNN 架构

GCN (Graph Convolutional Network)

Kipf & Welling (2017) 提出的 GCN 从谱图理论(spectral graph theory)出发,推导出了高效的逐层传播规则。

逐层传播规则:

使用归一化邻接矩阵的传播: tilde A = D^(-1/2) _ (A + I) _ D^(-1/2)

H^(l+1) = sigma(tilde A _ H^(l) _ W^(l))

其中 D 是度矩阵(degree matrix),I 是单位矩阵,W 是可学习的权重。

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
from torch_geometric.data import Data
from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures

# 加载数据集
dataset = Planetoid(root='/tmp/Cora', name='Cora', transform=NormalizeFeatures())
data = dataset[0]

print(f"节点数: {data.num_nodes}")
print(f"边数: {data.num_edges}")
print(f"节点特征维度: {data.num_node_features}")
print(f"类别数: {dataset.num_classes}")
print(f"训练节点数: {data.train_mask.sum().item()}")
print(f"验证节点数: {data.val_mask.sum().item()}")
print(f"测试节点数: {data.test_mask.sum().item()}")


class GCN(nn.Module):
    """Graph Convolutional Network"""

    def __init__(self, in_channels, hidden_channels, out_channels, dropout=0.5):
        super().__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, out_channels)
        self.dropout = dropout

    def forward(self, x, edge_index):
        # 第一个 GCN 层
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, p=self.dropout, training=self.training)

        # 第二个 GCN 层
        x = self.conv2(x, edge_index)
        return x


# 设置模型与优化器
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GCN(
    in_channels=dataset.num_features,
    hidden_channels=64,
    out_channels=dataset.num_classes
).to(device)
data = data.to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)


def train_gcn():
    model.train()
    optimizer.zero_grad()
    out = model(data.x, data.edge_index)
    loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask])
    loss.backward()
    optimizer.step()
    return loss.item()


def test_gcn():
    model.eval()
    with torch.no_grad():
        out = model(data.x, data.edge_index)
        pred = out.argmax(dim=1)

    results = {}
    for split, mask in [("train", data.train_mask),
                         ("val", data.val_mask),
                         ("test", data.test_mask)]:
        correct = pred[mask].eq(data.y[mask]).sum().item()
        results[split] = correct / mask.sum().item()
    return results


# 训练循环
best_val_acc = 0
for epoch in range(200):
    loss = train_gcn()
    accs = test_gcn()

    if accs["val"] > best_val_acc:
        best_val_acc = accs["val"]

    if (epoch + 1) % 50 == 0:
        print(f"Epoch {epoch+1:03d} | Loss: {loss:.4f} | "
              f"Train: {accs['train']:.4f} | Val: {accs['val']:.4f} | "
              f"Test: {accs['test']:.4f}")

GCN 手动实现

import torch
import torch.nn as nn
import torch.nn.functional as F

class ManualGCNLayer(nn.Module):
    """GCN 层的手动实现 - 用于理解内部运作原理"""

    def __init__(self, in_features, out_features):
        super().__init__()
        self.weight = nn.Parameter(torch.FloatTensor(in_features, out_features))
        self.bias = nn.Parameter(torch.FloatTensor(out_features))
        self.reset_parameters()

    def reset_parameters(self):
        nn.init.xavier_uniform_(self.weight)
        nn.init.zeros_(self.bias)

    def forward(self, x, adj):
        """
        x: 节点特征 (N, F_in)
        adj: 归一化后的邻接矩阵 (N, N)
        """
        # 线性变换: X * W
        support = x @ self.weight
        # 图卷积: A_hat * X * W
        output = adj @ support + self.bias
        return output

    @staticmethod
    def normalize_adjacency(adj):
        """D^(-1/2) * A * D^(-1/2) 归一化"""
        # 添加自环
        N = adj.size(0)
        adj_hat = adj + torch.eye(N, device=adj.device)

        # 计算度矩阵
        deg = adj_hat.sum(dim=1)
        d_inv_sqrt = torch.diag(deg.pow(-0.5))

        # 归一化
        adj_normalized = d_inv_sqrt @ adj_hat @ d_inv_sqrt
        return adj_normalized

GraphSAGE (归纳式学习)

GraphSAGE 专为归纳式(inductive)学习而设计。它不使用整张图,而是通过采样邻居实现小批量(mini-batch)训练。

from torch_geometric.nn import SAGEConv
import torch
import torch.nn as nn
import torch.nn.functional as F

class GraphSAGE(nn.Module):
    """GraphSAGE - 归纳式表示学习"""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 num_layers=3, dropout=0.5, aggr="mean"):
        super().__init__()
        self.dropout = dropout

        self.convs = nn.ModuleList()
        self.convs.append(SAGEConv(in_channels, hidden_channels, aggr=aggr))
        for _ in range(num_layers - 2):
            self.convs.append(SAGEConv(hidden_channels, hidden_channels, aggr=aggr))
        self.convs.append(SAGEConv(hidden_channels, out_channels, aggr=aggr))

        self.bns = nn.ModuleList([
            nn.BatchNorm1d(hidden_channels)
            for _ in range(num_layers - 1)
        ])

    def forward(self, x, edge_index):
        for i, conv in enumerate(self.convs[:-1]):
            x = conv(x, edge_index)
            x = self.bns[i](x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)

        x = self.convs[-1](x, edge_index)
        return x


# 使用邻居采样进行小批量训练
from torch_geometric.loader import NeighborLoader

# NeighborLoader: 每一层采样 num_neighbors 个邻居
train_loader = NeighborLoader(
    data,
    num_neighbors=[25, 10],  # 2-hop: 第一跳采样 25 个, 第二跳采样 10 个
    batch_size=256,
    input_nodes=data.train_mask,
    shuffle=True
)

model_sage = GraphSAGE(
    in_channels=dataset.num_features,
    hidden_channels=64,
    out_channels=dataset.num_classes
).to(device)

optimizer_sage = torch.optim.Adam(model_sage.parameters(), lr=0.001)

def train_sage():
    model_sage.train()
    total_loss = 0

    for batch in train_loader:
        batch = batch.to(device)
        optimizer_sage.zero_grad()
        out = model_sage(batch.x, batch.edge_index)
        # 只有批次最前面的 batch_size 个节点是训练节点
        loss = F.cross_entropy(out[:batch.batch_size], batch.y[:batch.batch_size])
        loss.backward()
        optimizer_sage.step()
        total_loss += loss.item()

    return total_loss / len(train_loader)

GAT (Graph Attention Network)

GAT 使用注意力机制,为每个邻居赋予不同的权重,体现了「并非所有邻居都同等重要」这一直觉。

注意力系数计算:

注意力分数: e_ij = LeakyReLU(a^T [Wh_i || Wh_j])

Softmax 归一化: alpha_ij = exp(e_ij) / sum_k(exp(e_ik))

更新: h_i' = sigma(sum_j alpha_ij _ W _ h_j)

from torch_geometric.nn import GATConv, GATv2Conv
import torch
import torch.nn as nn
import torch.nn.functional as F

class GAT(nn.Module):
    """Graph Attention Network"""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 heads=8, dropout=0.6):
        super().__init__()
        self.dropout = dropout

        # 第一层: 多头注意力
        self.conv1 = GATConv(
            in_channels,
            hidden_channels,
            heads=heads,
            dropout=dropout,
            concat=True  # 拼接(concatenate)多个头
        )

        # 第二层: 对多个头取平均
        self.conv2 = GATConv(
            hidden_channels * heads,
            out_channels,
            heads=1,
            dropout=dropout,
            concat=False  # 对头取平均
        )

    def forward(self, x, edge_index):
        x = F.dropout(x, p=self.dropout, training=self.training)
        x = self.conv1(x, edge_index)
        x = F.elu(x)
        x = F.dropout(x, p=self.dropout, training=self.training)
        x = self.conv2(x, edge_index)
        return x


class GATv2(nn.Module):
    """
    GATv2 - 改进的注意力机制
    GATv2 计算动态注意力, 表达能力更强
    """

    def __init__(self, in_channels, hidden_channels, out_channels,
                 heads=8, dropout=0.6):
        super().__init__()
        self.conv1 = GATv2Conv(
            in_channels,
            hidden_channels,
            heads=heads,
            dropout=dropout,
            concat=True
        )
        self.conv2 = GATv2Conv(
            hidden_channels * heads,
            out_channels,
            heads=1,
            dropout=dropout,
            concat=False
        )
        self.dropout = dropout

    def forward(self, x, edge_index):
        x = F.dropout(x, p=self.dropout, training=self.training)
        x = F.elu(self.conv1(x, edge_index))
        x = F.dropout(x, p=self.dropout, training=self.training)
        return self.conv2(x, edge_index)


# 训练 GAT
model_gat = GAT(
    in_channels=dataset.num_features,
    hidden_channels=8,
    out_channels=dataset.num_classes,
    heads=8
).to(device)

optimizer_gat = torch.optim.Adam(model_gat.parameters(), lr=0.005, weight_decay=5e-4)

Graph Transformer

Graph Transformer 将 Transformer 的全局注意力机制应用到图上。

from torch_geometric.nn import TransformerConv
import torch
import torch.nn as nn
import torch.nn.functional as F

class GraphTransformer(nn.Module):
    """Graph Transformer Layer"""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 heads=4, num_layers=3, dropout=0.3):
        super().__init__()
        self.dropout = dropout

        self.convs = nn.ModuleList()
        self.convs.append(
            TransformerConv(in_channels, hidden_channels // heads, heads=heads,
                           dropout=dropout, beta=True)
        )

        for _ in range(num_layers - 2):
            self.convs.append(
                TransformerConv(hidden_channels, hidden_channels // heads,
                               heads=heads, dropout=dropout, beta=True)
            )

        self.convs.append(
            TransformerConv(hidden_channels, out_channels // heads,
                           heads=heads, dropout=dropout, beta=True)
        )

        self.norms = nn.ModuleList([
            nn.LayerNorm(hidden_channels) for _ in range(num_layers - 1)
        ])

    def forward(self, x, edge_index):
        for i, conv in enumerate(self.convs[:-1]):
            x = conv(x, edge_index)
            x = self.norms[i](x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)

        return self.convs[-1](x, edge_index)

5. 图级别预测

如果说节点分类是对单个节点的预测,那么图分类就是对整张图的预测。例如:预测某个分子是否有毒。

Global Pooling

from torch_geometric.nn import (
    global_mean_pool,
    global_max_pool,
    global_add_pool
)
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

class GraphClassifier(nn.Module):
    """图分类模型"""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 num_layers=3, dropout=0.5, pooling="mean"):
        super().__init__()
        self.dropout = dropout
        self.pooling = pooling

        self.convs = nn.ModuleList()
        self.convs.append(GCNConv(in_channels, hidden_channels))
        for _ in range(num_layers - 1):
            self.convs.append(GCNConv(hidden_channels, hidden_channels))

        self.bns = nn.ModuleList([
            nn.BatchNorm1d(hidden_channels) for _ in range(num_layers)
        ])

        # 图级别分类器
        self.classifier = nn.Sequential(
            nn.Linear(hidden_channels, hidden_channels),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_channels, out_channels)
        )

    def forward(self, x, edge_index, batch):
        """
        batch: 表示每个节点属于哪张图的索引向量
        """
        # 节点嵌入
        for conv, bn in zip(self.convs, self.bns):
            x = conv(x, edge_index)
            x = bn(x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)

        # 图级别池化
        if self.pooling == "mean":
            x = global_mean_pool(x, batch)
        elif self.pooling == "max":
            x = global_max_pool(x, batch)
        elif self.pooling == "sum":
            x = global_add_pool(x, batch)

        # 分类
        return self.classifier(x)

DiffPool (Differentiable Pooling)

from torch_geometric.nn import dense_diff_pool
import torch
import torch.nn as nn
import torch.nn.functional as F

class DiffPoolLayer(nn.Module):
    """层次化图池化"""

    def __init__(self, in_channels, hidden_channels, num_clusters):
        super().__init__()
        # GNN for node embedding
        self.gnn_embed = nn.Sequential(
            nn.Linear(in_channels, hidden_channels),
            nn.ReLU()
        )
        # GNN for cluster assignment
        self.gnn_pool = nn.Sequential(
            nn.Linear(in_channels, num_clusters),
        )

    def forward(self, x, adj, mask=None):
        embed = self.gnn_embed(x)
        # Cluster assignment matrix
        s = torch.softmax(self.gnn_pool(x), dim=-1)
        # DiffPool
        out, out_adj, link_loss, entropy_loss = dense_diff_pool(embed, adj, s, mask)
        return out, out_adj, link_loss, entropy_loss

6. 链接预测

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
from torch_geometric.utils import negative_sampling
from torch_geometric.transforms import RandomLinkSplit

class LinkPredictor(nn.Module):
    """链接预测模型"""

    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        # 节点嵌入编码器
        self.encoder = nn.ModuleList([
            GCNConv(in_channels, hidden_channels),
            GCNConv(hidden_channels, out_channels)
        ])

        # 边解码器
        self.decoder = nn.Sequential(
            nn.Linear(out_channels * 2, out_channels),
            nn.ReLU(),
            nn.Linear(out_channels, 1)
        )

    def encode(self, x, edge_index):
        for i, conv in enumerate(self.encoder):
            x = conv(x, edge_index)
            if i < len(self.encoder) - 1:
                x = F.relu(x)
        return x

    def decode(self, z, edge_index):
        # 拼接源/目标节点嵌入
        src, dst = edge_index
        edge_feat = torch.cat([z[src], z[dst]], dim=1)
        return self.decoder(edge_feat).squeeze()

    def forward(self, x, edge_index, pos_edge_index, neg_edge_index):
        z = self.encode(x, edge_index)

        pos_pred = self.decode(z, pos_edge_index)
        neg_pred = self.decode(z, neg_edge_index)

        return pos_pred, neg_pred


def train_link_prediction(model, data, optimizer):
    model.train()
    optimizer.zero_grad()

    # 节点嵌入
    z = model.encode(data.x, data.edge_index)

    # 正样本边
    pos_edge = data.train_pos_edge_index

    # 负样本边采样
    neg_edge = negative_sampling(
        edge_index=pos_edge,
        num_nodes=data.num_nodes,
        num_neg_samples=pos_edge.size(1)
    )

    pos_pred = model.decode(z, pos_edge)
    neg_pred = model.decode(z, neg_edge)

    # Binary cross-entropy loss
    pred = torch.cat([pos_pred, neg_pred])
    labels = torch.cat([
        torch.ones(pos_pred.size(0)),
        torch.zeros(neg_pred.size(0))
    ]).to(pred.device)

    loss = F.binary_cross_entropy_with_logits(pred, labels)
    loss.backward()
    optimizer.step()

    return loss.item()

7. PyTorch Geometric (PyG) 完全指南

安装

pip install torch-geometric
pip install torch-scatter torch-sparse -f https://data.pyg.org/whl/torch-2.1.0+cu121.html

Data 对象

from torch_geometric.data import Data
import torch

# 创建图数据
x = torch.randn(6, 3)          # 6个节点, 3维特征
edge_index = torch.tensor([
    [0, 1, 2, 3, 4, 0],
    [1, 2, 3, 4, 0, 3]
], dtype=torch.long)
y = torch.tensor([0, 1, 0, 1, 0, 1])  # 节点标签
edge_attr = torch.randn(6, 2)  # 边特征

data = Data(
    x=x,
    edge_index=edge_index,
    y=y,
    edge_attr=edge_attr
)

print(data)
print(f"节点数: {data.num_nodes}")
print(f"边数: {data.num_edges}")
print(f"节点特征维度: {data.num_node_features}")
print(f"边特征维度: {data.num_edge_features}")
print(f"是否存在 self-loop: {data.has_self_loops()}")
print(f"是否为有向图: {data.is_directed()}")

# 有效性检查
print(f"数据是否有效: {data.validate()}")

DataLoader 与小批量

from torch_geometric.data import Data, DataLoader
import torch

# 创建图数据集
dataset = []
for _ in range(100):
    n = torch.randint(5, 20, (1,)).item()  # 5~20个节点
    e = torch.randint(10, 40, (1,)).item()  # 10~40条边
    data = Data(
        x=torch.randn(n, 8),
        edge_index=torch.randint(0, n, (2, e)),
        y=torch.randint(0, 3, (1,))  # 图标签
    )
    dataset.append(data)

# DataLoader: 将多张图打包成一张不连续的大图批次
loader = DataLoader(dataset, batch_size=32, shuffle=True)

for batch in loader:
    print(f"批次中的图数: {batch.num_graphs}")
    print(f"节点总数: {batch.num_nodes}")
    print(f"边总数: {batch.num_edges}")
    print(f"batch 向量: {batch.batch.shape}")  # 每个节点所属的图索引
    break

内置数据集

from torch_geometric.datasets import (
    Planetoid,    # Cora, Citeseer, PubMed
    TUDataset,    # 分子数据集 (MUTAG, ENZYMES 等)
    OGB,          # Open Graph Benchmark
)
from torch_geometric.transforms import NormalizeFeatures, RandomNodeSplit

# Cora 引用网络
cora = Planetoid(root='/tmp/Cora', name='Cora', transform=NormalizeFeatures())
print(f"Cora - 节点数: {cora[0].num_nodes}, 边数: {cora[0].num_edges}")

# MUTAG 分子数据集
mutag = TUDataset(root='/tmp/TUDataset', name='MUTAG')
print(f"MUTAG - 图数: {len(mutag)}, 类别数: {mutag.num_classes}")

# Open Graph Benchmark (大规模)
try:
    from ogb.nodeproppred import PygNodePropPredDataset
    dataset_ogb = PygNodePropPredDataset(name='ogbn-arxiv')
    split_idx = dataset_ogb.get_idx_split()
    data_ogb = dataset_ogb[0]
    print(f"OGB-Arxiv - 节点数: {data_ogb.num_nodes}, 边数: {data_ogb.num_edges}")
except ImportError:
    print("未安装 ogb 包。pip install ogb")

完整的节点分类流水线

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv, GATConv, SAGEConv
from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures
import matplotlib.pyplot as plt

# 加载数据
dataset = Planetoid(root='/tmp/Cora', name='Cora', transform=NormalizeFeatures())
data = dataset[0]
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data = data.to(device)


class MultiLayerGNN(nn.Module):
    """组合多种 GNN 层的模型"""

    def __init__(self, in_channels, hidden_channels, out_channels,
                 gnn_type="gcn", num_layers=3, dropout=0.5):
        super().__init__()
        self.dropout = dropout
        self.gnn_type = gnn_type

        self.convs = nn.ModuleList()
        self.bns = nn.ModuleList()

        # 输入层
        self.convs.append(self._make_conv(in_channels, hidden_channels, gnn_type))
        self.bns.append(nn.BatchNorm1d(hidden_channels))

        # 中间层
        for _ in range(num_layers - 2):
            self.convs.append(self._make_conv(hidden_channels, hidden_channels, gnn_type))
            self.bns.append(nn.BatchNorm1d(hidden_channels))

        # 输出层
        self.convs.append(self._make_conv(hidden_channels, out_channels, gnn_type))

    def _make_conv(self, in_ch, out_ch, gnn_type):
        if gnn_type == "gcn":
            return GCNConv(in_ch, out_ch)
        elif gnn_type == "sage":
            return SAGEConv(in_ch, out_ch)
        elif gnn_type == "gat":
            return GATConv(in_ch, out_ch, heads=1)
        else:
            raise ValueError(f"Unknown GNN type: {gnn_type}")

    def forward(self, x, edge_index):
        for i, conv in enumerate(self.convs[:-1]):
            x = conv(x, edge_index)
            x = self.bns[i](x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)
        return self.convs[-1](x, edge_index)


def run_experiment(gnn_type, epochs=200):
    model = MultiLayerGNN(
        in_channels=dataset.num_features,
        hidden_channels=64,
        out_channels=dataset.num_classes,
        gnn_type=gnn_type,
        num_layers=3
    ).to(device)

    optimizer = torch.optim.Adam(
        model.parameters(), lr=0.01, weight_decay=5e-4
    )

    train_losses = []
    val_accs = []

    for epoch in range(epochs):
        # 训练
        model.train()
        optimizer.zero_grad()
        out = model(data.x, data.edge_index)
        loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask])
        loss.backward()
        optimizer.step()
        train_losses.append(loss.item())

        # 评估
        model.eval()
        with torch.no_grad():
            out = model(data.x, data.edge_index)
            pred = out.argmax(dim=1)
            val_acc = pred[data.val_mask].eq(data.y[data.val_mask]).sum().item()
            val_acc /= data.val_mask.sum().item()
            val_accs.append(val_acc)

    # 最终测试
    model.eval()
    with torch.no_grad():
        out = model(data.x, data.edge_index)
        pred = out.argmax(dim=1)
        test_acc = pred[data.test_mask].eq(data.y[data.test_mask]).sum().item()
        test_acc /= data.test_mask.sum().item()

    return test_acc, train_losses, val_accs


# 比较不同的 GNN
results = {}
for gnn_type in ["gcn", "sage", "gat"]:
    test_acc, losses, val_accs = run_experiment(gnn_type)
    results[gnn_type] = test_acc
    print(f"{gnn_type.upper():10s}: Test Accuracy = {test_acc:.4f}")

图分类完整示例

from torch_geometric.datasets import TUDataset
from torch_geometric.loader import DataLoader
from torch_geometric.nn import (
    GINConv, global_mean_pool, global_add_pool
)
import torch
import torch.nn as nn
import torch.nn.functional as F

# 加载 MUTAG 数据集
dataset = TUDataset(root='/tmp/TUDataset', name='MUTAG')
dataset = dataset.shuffle()

# 划分训练/测试集
n = len(dataset)
train_dataset = dataset[:int(0.8 * n)]
test_dataset = dataset[int(0.8 * n):]

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=32)


class GIN(nn.Module):
    """
    Graph Isomorphism Network (GIN) - 具有最大表达能力的 GNN
    比 GCN 拥有更强的区分能力
    """

    def __init__(self, in_channels, hidden_channels, out_channels,
                 num_layers=5, dropout=0.5):
        super().__init__()
        self.dropout = dropout

        self.convs = nn.ModuleList()
        self.bns = nn.ModuleList()

        for i in range(num_layers):
            in_ch = in_channels if i == 0 else hidden_channels
            # GIN 的 MLP
            mlp = nn.Sequential(
                nn.Linear(in_ch, hidden_channels),
                nn.BatchNorm1d(hidden_channels),
                nn.ReLU(),
                nn.Linear(hidden_channels, hidden_channels)
            )
            self.convs.append(GINConv(mlp, train_eps=True))
            self.bns.append(nn.BatchNorm1d(hidden_channels))

        # 图分类器
        self.classifier = nn.Sequential(
            nn.Linear(hidden_channels * num_layers, hidden_channels),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(hidden_channels, out_channels)
        )

    def forward(self, x, edge_index, batch):
        # 保存每一层的输出
        xs = []
        for conv, bn in zip(self.convs, self.bns):
            x = conv(x, edge_index)
            x = bn(x)
            x = F.relu(x)
            x = F.dropout(x, p=self.dropout, training=self.training)
            xs.append(global_add_pool(x, batch))  # 图级别聚合

        # 拼接所有层的图表示
        out = torch.cat(xs, dim=1)
        return self.classifier(out)


device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model_gin = GIN(
    in_channels=dataset.num_features,
    hidden_channels=64,
    out_channels=dataset.num_classes
).to(device)

optimizer = torch.optim.Adam(model_gin.parameters(), lr=0.01)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=50, gamma=0.5)

def train_gin():
    model_gin.train()
    total_loss = 0
    for batch in train_loader:
        batch = batch.to(device)
        optimizer.zero_grad()
        out = model_gin(batch.x, batch.edge_index, batch.batch)
        loss = F.cross_entropy(out, batch.y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(train_loader)

def test_gin(loader):
    model_gin.eval()
    correct = 0
    for batch in loader:
        batch = batch.to(device)
        with torch.no_grad():
            pred = model_gin(batch.x, batch.edge_index, batch.batch).argmax(dim=1)
        correct += pred.eq(batch.y).sum().item()
    return correct / len(loader.dataset)

for epoch in range(1, 201):
    loss = train_gin()
    train_acc = test_gin(train_loader)
    test_acc = test_gin(test_loader)
    scheduler.step()

    if epoch % 20 == 0:
        print(f"Epoch {epoch:03d} | Loss: {loss:.4f} | "
              f"Train: {train_acc:.4f} | Test: {test_acc:.4f}")

8. DGL (Deep Graph Library) 对比

# DGL 示例 - 与 PyG 对比
# pip install dgl

try:
    import dgl
    import dgl.nn as dglnn
    import torch
    import torch.nn as nn
    import torch.nn.functional as F

    class DGLGCN(nn.Module):
        """使用 DGL 实现的 GCN"""
        def __init__(self, in_feats, hidden_size, num_classes):
            super().__init__()
            self.conv1 = dglnn.GraphConv(in_feats, hidden_size)
            self.conv2 = dglnn.GraphConv(hidden_size, num_classes)

        def forward(self, g, features):
            x = F.relu(self.conv1(g, features))
            x = F.dropout(x, training=self.training)
            return self.conv2(g, x)

    # 创建 DGL 图
    src = torch.tensor([0, 1, 2, 3, 4])
    dst = torch.tensor([1, 2, 3, 4, 0])
    g = dgl.graph((src, dst))
    g.ndata['feat'] = torch.randn(5, 16)

    model_dgl = DGLGCN(16, 32, 4)
    out = model_dgl(g, g.ndata['feat'])
    print(f"DGL GCN output: {out.shape}")

except ImportError:
    print("未安装 DGL。pip install dgl")

PyG 与 DGL 对比:

特性PyTorch Geometric (PyG)Deep Graph Library (DGL)
API 风格PyTorch 原生框架无关
数据表示edge_index (COO)DGLGraph 对象
速度非常快
社区大规模大规模
模型数量非常多
学习曲线中等

9. 实战应用

分子性质预测 (OGB)

try:
    from ogb.graphproppred import PygGraphPropPredDataset, Evaluator
    from torch_geometric.loader import DataLoader
    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    from torch_geometric.nn import GINEConv, global_mean_pool

    # 加载 HIV 分子数据集
    dataset_mol = PygGraphPropPredDataset(name='ogbg-molhiv')
    split_idx = dataset_mol.get_idx_split()

    train_loader_mol = DataLoader(
        dataset_mol[split_idx["train"]],
        batch_size=32,
        shuffle=True
    )

    class MoleculeGNN(nn.Module):
        """分子性质预测模型"""
        def __init__(self, hidden_channels=300, num_layers=5):
            super().__init__()
            self.atom_encoder = nn.Embedding(100, hidden_channels)
            self.bond_encoder = nn.Embedding(10, hidden_channels)

            self.convs = nn.ModuleList()
            for _ in range(num_layers):
                mlp = nn.Sequential(
                    nn.Linear(hidden_channels, hidden_channels * 2),
                    nn.BatchNorm1d(hidden_channels * 2),
                    nn.ReLU(),
                    nn.Linear(hidden_channels * 2, hidden_channels)
                )
                self.convs.append(GINEConv(mlp))

            self.pool = global_mean_pool
            self.predictor = nn.Linear(hidden_channels, 1)

        def forward(self, x, edge_index, edge_attr, batch):
            x = self.atom_encoder(x.squeeze())
            edge_attr = self.bond_encoder(edge_attr.squeeze())

            for conv in self.convs:
                x = conv(x, edge_index, edge_attr)
                x = F.relu(x)

            graph_embed = self.pool(x, batch)
            return self.predictor(graph_embed)

    print("OGB 分子数据集加载成功")

except ImportError:
    print("未安装 ogb 包。pip install ogb")

推荐系统

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import LightGCN

class RecommendationSystem(nn.Module):
    """
    基于 LightGCN 的协同过滤
    在用户-物品二分图上学习嵌入
    """

    def __init__(self, num_users, num_items, embedding_dim=64, num_layers=3):
        super().__init__()
        self.num_users = num_users
        self.num_items = num_items
        self.embedding_dim = embedding_dim
        self.num_layers = num_layers

        # 用户/物品嵌入
        self.user_emb = nn.Embedding(num_users, embedding_dim)
        self.item_emb = nn.Embedding(num_items, embedding_dim)

        # LightGCN: 不做非线性变换, 只做简单聚合
        self.lightgcn = LightGCN(
            num_nodes=num_users + num_items,
            embedding_dim=embedding_dim,
            num_layers=num_layers
        )

        self._init_weights()

    def _init_weights(self):
        nn.init.normal_(self.user_emb.weight, std=0.01)
        nn.init.normal_(self.item_emb.weight, std=0.01)

    def forward(self, edge_index):
        # 全部节点嵌入
        x = torch.cat([self.user_emb.weight, self.item_emb.weight], dim=0)
        # LightGCN 传播
        embeddings = self.lightgcn(x, edge_index)
        return embeddings[:self.num_users], embeddings[self.num_users:]

    def predict(self, user_ids, item_ids, edge_index):
        user_embs, item_embs = self(edge_index)
        u = user_embs[user_ids]
        i = item_embs[item_ids]
        return (u * i).sum(dim=1)


# BPR 损失函数
def bpr_loss(pos_scores, neg_scores):
    """Bayesian Personalized Ranking Loss"""
    return -F.logsigmoid(pos_scores - neg_scores).mean()

10. 图生成模型

GraphVAE

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

class GraphVAE(nn.Module):
    """图变分自编码器"""

    def __init__(self, in_channels, hidden_channels, latent_dim):
        super().__init__()

        # 编码器 (GNN)
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv_mu = GCNConv(hidden_channels, latent_dim)
        self.conv_logvar = GCNConv(hidden_channels, latent_dim)

    def encode(self, x, edge_index):
        h = F.relu(self.conv1(x, edge_index))
        mu = self.conv_mu(h, edge_index)
        logvar = self.conv_logvar(h, edge_index)
        return mu, logvar

    def reparameterize(self, mu, logvar):
        if self.training:
            std = torch.exp(0.5 * logvar)
            eps = torch.randn_like(std)
            return mu + eps * std
        return mu

    def decode(self, z):
        # 通过内积计算边的概率
        adj_pred = torch.sigmoid(z @ z.t())
        return adj_pred

    def forward(self, x, edge_index):
        mu, logvar = self.encode(x, edge_index)
        z = self.reparameterize(mu, logvar)
        adj_pred = self.decode(z)
        return adj_pred, mu, logvar

    def loss(self, adj_pred, adj_target, mu, logvar):
        # 重构损失
        recon_loss = F.binary_cross_entropy(adj_pred, adj_target)

        # KL 散度
        kl_loss = -0.5 * torch.mean(
            1 + logvar - mu.pow(2) - logvar.exp()
        )

        return recon_loss + kl_loss

测验

Q1. GCN 与 GAT 最大的区别是什么?

答案:GCN 用固定权重(基于度数的归一化)聚合所有邻居节点,而 GAT 通过注意力机制为每个邻居动态学习不同的权重。

解析:GCN 的聚合权重由节点的度数(degree)固定决定。而 GAT 的注意力系数基于相连两个节点的特征向量动态计算,因此能让模型对更重要的邻居给予更多关注。用多头注意力提升稳定性,也是 GAT 的一个优点。

Q2. GraphSAGE 相比 GCN 在归纳式学习上更有优势的原因是什么?

答案:因为 GraphSAGE 学习的是聚合函数(aggregator),可以用它为新节点的邻居生成嵌入。

解析:GCN 在训练时需要用到整张图的邻接矩阵,一旦有新节点加入就得重新训练,属于直推式(transductive)方法。GraphSAGE 学习的是采样并聚合邻居的函数,因此即便是训练时从未见过的新节点,也可以套用这个函数来生成嵌入。这一特性在 Pinterest、LinkedIn 等动态变化的大规模图中被实际应用。

Q3. 消息传递(MPNN)框架的三个阶段是什么?

答案:Message(消息计算)、Aggregate(聚合)、Update(更新)三个阶段。

解析:Message 阶段针对每条边计算要从邻居节点传递过来的消息。Aggregate 阶段将节点收到的所有邻居消息通过求和、平均、取最大值等方式聚合起来。Update 阶段将聚合后的消息与当前节点的嵌入结合,生成新的节点嵌入。GCN、GAT、GraphSAGE、GIN 等大多数 GNN 都可以纳入这一框架统一描述。

Q4. 过平滑(Over-smoothing)问题是什么?如何解决?

答案:随着层数加深,所有节点的嵌入趋于相似的现象。可以用残差连接、JK-Net、DropEdge 等方法缓解。

解析:K 层 GNN 会聚合 K 跳邻居的信息。层数越多,覆盖的邻居范围就越广,最终所有节点都会收敛到同一个全局平均值。残差连接(Residual connections)通过直接传递前一层的信息来保留节点的独有信息。JK-Net(Jumping Knowledge Networks)在最终表示中利用所有层的嵌入。DropEdge 在训练时随机移除部分边。

Q5. GNN 的表达能力与 WL 测试等价,这意味着什么?

答案:标准 GNN 会将 Weisfeiler-Leman(WL)图同构测试也无法区分的两张图,嵌入为相同的表示。

解析:WL 测试是一种通过反复聚合并哈希邻居标签来判断两张图是否同构(isomorphic)的算法。Xu 等人(2019)通过 GIN(Graph Isomorphism Network)证明了标准 GNN 的表达能力至多与 1-WL 测试相当。在 WL 测试无法区分的图对上,GNN 同样无法区分。为了突破这一局限,目前正在研究对应更强的 k 阶 WL 测试的高阶 GNN。


结语

本指南梳理了图神经网络的完整生态:

  1. 图论基础:节点、边、邻接矩阵、图的特性
  2. 消息传递范式:GNN 的核心原理
  3. 主要架构:GCN、GraphSAGE、GAT、Graph Transformer、GIN
  4. 图级别预测:Global Pooling、DiffPool
  5. 链接预测:知识图谱、推荐系统
  6. PyTorch Geometric:节点分类、图分类完整示例
  7. 实战应用:分子设计、推荐系统、欺诈检测
  8. 图生成模型:GraphVAE

GNN 正在分子设计、药物发现、社交网络分析、交通预测、推荐系统等众多领域取得创新性的成果。得益于 PyTorch Geometric、DGL 等库,实现变得越来越简单,OGB 等基准测试也让公平比较成为可能。

参考资料

현재 단락 (1/852)

社交网络、分子结构、知识图谱、推荐系统 — 现实世界中无数的数据都以图的形式呈现。图神经网络(Graph Neural Network, GNN)正是用深度学习处理这类非欧几里得(non-Euclid...

작성 글자: 0원문 글자: 27,753작성 단락: 0/852