图神经网络入门:GCN、GAT、GraphSAGE核心思想
我们先从最基础的问题说起。图神经网络要解决的核心问题,说白了就是——怎么让计算机像理解图像或文本那样,去理解一个图结构的数据?
我刚开始接触图神经网络时,第一个困惑就是:图像有固定的像素网格,文本有顺序的词序列。但图呢?它的邻居数量不一样,顺序也不固定。这种不规则的拓扑结构,才是真正的难点。
我个人习惯把图神经网络想象成一场「信息聚会」。每个节点是一个参会者,边就是他们之间的对话关系。我们需要设计一套规则,让每个人听完邻居的发言后,更新自己的观点。这个观点,就是节点的特征表示。
下面这张图,展示了今天要讲的三种经典图神经网络,它们各自不同的聚合方式:
GCN:最简单的消息传递方式
GCN 的全称是 Graph Convolutional Network,中文叫图卷积网络。说实话,这个名字有点吓人。但它的核心思想非常简单——对邻居节点的特征做归一化平均,然后用一个共享的权重矩阵做线性变换。
我刚开始学 GCN 时,觉得这不就是加权求和嘛。但真正在项目中跑起来才发现,关键在于那个「归一化」操作。
GCN 的更新公式可以写成:
h_i^(l+1) = σ( W * ( Σ_j∈N(i) h_j^(l) / √(d_i * d_j) ) )
其中 h_i 是节点 i 的特征向量,N(i) 是它的邻居集合,d_i 是节点度数。这个除以 √(d_i*d_j) 就是归一化操作。为什么要做归一化?
你想想看,如果一个节点有 100 个邻居,另一个节点只有 1 个邻居。如果不做归一化,前者聚合的信息量是后者的 100 倍。这会导致节点特征被度数的数量级主导,而不是真正有用的信息。
关键点:GCN 的核心假设是「我告诉你的邻居,和你自己,共同决定你的新状态」。这个假设虽然简单,但非常有效。
我记得第一次用 GCN 做分子性质预测时,模型收敛速度非常快。但有个问题——GCN 会把所有邻居一视同仁。这在分子图里可能不太合理。比如一个原子和碳原子连接,和与氧原子连接,影响显然不同。这就是 GCN 的局限性。
用代码实现一个简单的 GCN 卷积层,大概长这样:
import torch
import torch.nn as nn
import torch.nn.functional as F
class GCNLayer(nn.Module):
def __init__(self, in_features, out_features):
super().__init__()
self.W = nn.Linear(in_features, out_features)
def forward(self, x, adj):
# x: [N, in_features], adj: [N, N] 归一化的邻接矩阵
support = self.W(x) # 线性变换
output = torch.mm(adj, support) # 消息传递
return F.relu(output)
个人经验:实际项目中,GCN 的归一化方式有几种变体。我建议先用对称归一化(即上面公式里的 √(d_i*d_j)),它比行归一化更稳定。如果遇到梯度爆炸,可以试试加上自环。
GAT:让邻居「说话」有轻重
GCN 的等权聚合,说白了就是不分青红皂白。GAT 解决了这个问题——它引入了一个注意力机制,让模型自己学习每个邻居的重要性。
GAT 的思路特别直观:对于中心节点 i 和它的每个邻居 j,先计算一个注意力系数:
e_ij = LeakyReLU( a^T [ W h_i || W h_j ] )
这里 || 表示向量拼接。然后对 e_ij 做 softmax 得到权重 α_ij,最后用这些权重对邻居做加权求和。
我在项目里用过 GAT 做分子图里的官能团识别。效果比 GCN 好不少,因为注意力机制能自动关注到那些重要的化学键。比如判断一个碳原子是不是羰基碳时,模型学会了对氧原子邻居给予更高的注意力权重。
GAT 还有一个很实用的特性——多头注意力。就像 Transformer 里那样,多个注意力头各自学习不同的关系模式,然后拼接或平均:
# 伪代码示例:多头GAT
heads = 4
head_outputs = []
for i in range(heads):
head_outputs.append( gat_attention(x, adj) )
output = torch.cat(head_outputs, dim=-1) # 拼接
# 如果最后一层,用平均
为什么会这样设计?单一注意力头可能只关注一种关系,比如距离。多个头就能同时关注距离、角度、电负性等多种因素。我在分子生成任务里常用 8 个头,效果比较稳定。
避坑指南:曾经我在一个含噪声的数据集上直接跑 GAT,模型完全不收敛。后来发现是因为注意力系数的分布过于极端——某些邻居权重趋近于 0,某些趋近于 1。解决办法是加一个 dropout 在注意力权重上,强制模型不依赖少数邻居。嗯,这一点很容易被忽略。
GraphSAGE:搞定大规模图
GCN 和 GAT 都有一个共同的问题——它们需要整张图的邻接矩阵参与计算。这意味着,图有多大,计算开销就有多大。对于分子生成这种场景,虽然单个分子很小,但一次要处理上百万个分子时,全图计算就吃不消了。
GraphSAGE 的思路完全不同。它不做全图批量更新,而是对每个节点采样出固定数量的邻居,然后只用这些采样到的邻居来聚合。
具体来说就是三步:
- 采样:为每个节点采样 K 层邻居,每层固定采样 S 个
- 聚合:用聚合函数(均值、LSTM、池化)处理采样到的邻居特征
- 更新:将聚合结果与当前节点特征拼接,过一层 MLP
其中聚合函数有多种选择:
| 聚合函数 | 特点 | 适用场景 |
|---|---|---|
| Mean 聚合 | 对邻居取平均,最简单 | 特征分布均匀时 |
| LSTM 聚合 | 考虑邻居顺序,但有偏 | 邻居有序列关系时 |
| Pool 聚合 | 对邻居特征做 Max/Mean 池化 | 需要提取显著特征时 |
我建议新手从 Mean 聚合开始,效果已经足够好。GraphSAGE 的实际实现代码,以 NeighborLoader 的方式采样:
from torch_geometric.loader import NeighborLoader
# 对一个分子图进行2层采样
loader = NeighborLoader(
data,
num_neighbors=[10, 5], # 第一层采样10个,第二层5个
batch_size=32,
shuffle=True
)
for batch in loader:
out = model(batch.x, batch.edge_index)
# 只取 batch 中的目标节点做 loss
loss = criterion(out[batch.n_id], batch.y)
GraphSAGE 的优势:训练时只使用采样得到的子图,内存开销从 O(|V|) 降到 O(S^K)。这让我们可以处理百万甚至上亿节点的图。更重要的是,聚合函数可以灵活替换,这使得 GraphSAGE 成了一个框架而不是单一模型。
我记得有一个项目要把 1000 万个分子的图特征提取出来。用 GCN 直接算,显存直接爆了。换成 GraphSAGE 后,采样邻居数为 5,深度为 2,单个 batch 只处理几百个节点,妥妥跑下来了。这就是工程中的实际选择。
三种模型的选型指南
说了这么多,到底该用哪个?我直接给一个对比表,大家根据场景选:
| 维度 | GCN | GAT | GraphSAGE |
|---|---|---|---|
| 聚合方式 | 归一化均值 | 学习注意力权重 | 采样 + 自定义聚合 |
| 邻居范围 | 全部邻居 | 全部邻居 | 采样邻居 |
| 计算复杂度 | O(|E|) | O(|E| * d) | O(S^K * B) |
| 小图性能 | ★★★★ | ★★★★★ | ★★★ |
| 大规模图性能 | ★ | ★★ | ★★★★★ |
| 分子生成适用度 | 基础分子性质 | 官能团识别 | 大规模预训练 |
拿分子生成来说:
- 如果你只是做个基础的性质预测,比如 logP、溶解度,GCN 就够了
- 如果需要模型感知不同化学键的重要性,用 GAT 效果更好
- 如果要做图级的大规模预训练,GraphSAGE 是目前最实用的选择
工程建议:我个人习惯是先跑一个 GCN 基线,验证数据流程没问题。然后换成 GAT 看提升幅度。如果数据量很大,直接上 GraphSAGE。这样迭代的成本最低,问题暴露得也最早。
三种方法其实都是在解决同一个问题——如何用邻居信息更新节点特征。区别只在于「如何选择邻居」和「如何聚合它们的特征」。深层的东西,都是信息传递框架下的不同实现。理解了这个本质,后面学更复杂的模型,比如 Graph Isomorphism Network 或者消息传递神经网络,就顺理成章了。