3. 基础回顾(二):经典图神经网络入门——图卷积网络(GCN)原理与消息传递范式
各位好,欢迎来到第三章。上一章我们聊了图的基本表示和性质。今天,我们来点硬核的——图卷积网络,也就是 GCN。这玩意儿在分子性质预测、靶点筛选里,可以说是主力模型。我个人习惯把 GCN 拆成两部分来看:一个是它怎么定义“邻居”并聚合信息,另一个是它怎么在图上做“卷积”。
3.1 消息传递范式
先别急着看公式。我们想想看,一个原子(节点)的性质,是不是很大程度上受它周围的原子影响?GCN 的核心思想就是这个——邻域聚合。把邻居的信息“传递”过来,然后“更新”自己。这就是消息传递范式。
嗯,这里要注意。消息传递不是一个统一的模型,而是一种框架。几乎所有主流的图神经网络,比如 GCN、GAT、GraphSAGE,都能用这个框架来描述。
消息传递一般分三步走:
- 消息生成:每个节点根据自己和邻居的特征,生成一个消息。
- 消息聚合:把邻居发来的所有消息,用一种函数(比如求和、求平均)汇总起来。
- 节点更新:用聚合后的消息,结合自己上一层的特征,生成新的节点表示。
核心观点:消息传递范式把图上的学习问题,转化成了定义“消息”和“聚合”函数的问题。你只要能定义清楚这两点,就是在设计一个新的图神经网络。
为了直观理解,我们来看一个简单的例子。假设有一个分子,我们只关心其中三个原子 A、B、C。A 连着 B,B 连着 C。我们想把 A 的更新表示算出来。
你看这个图,节点 B 和 C 分别生成消息,然后“流”向节点 A。A 在聚合函数里把这两个消息加起来,再结合自己的特征,最终更新成 A'。这就是一次标准的消息传递。
3.2 GCN 的核心原理
3.2.1 从谱域到空间域
GCN 最早其实是从图信号处理里的谱方法推导出来的,涉及到拉普拉斯矩阵、傅里叶变换什么的。但我个人觉得,从空间域的角度理解更直观,而且现在的实现基本都是基于空间域的。
空间域 GCN 说白了,就是消息传递的一种特例。它的消息生成函数,就是邻居的特征向量乘上一个共享的权重矩阵。它的聚合函数,就是归一化的求和。
我这里给出一个最经典的 GCN 层更新公式,大家感受一下:
H^{(l+1)} = σ( D̂^{-1/2} Ã D̂^{-1/2} H^{(l)} W^{(l)} )
其中:
- Ã = A + I,是加了自环的邻接矩阵。
- D̂ 是 Ã 的度矩阵。
- D̂^{-1/2} Ã D̂^{-1/2} 是归一化操作,防止聚合时节点度高的特征值过大。
- H^{(l)} 是第 l 层的节点特征矩阵。
- W^{(l)} 是可学习的权重矩阵。
- σ 是非线性激活函数,比如 ReLU。
为什么会需要这个归一化?我简单解释一下。如果你直接对邻居特征求和,那么一个连了10个原子的碳原子,它的特征之和肯定比只连了1个原子的氢原子大得多。这会导致数值不稳定,模型也难以收敛。归一化之后,每个节点的聚合值就会在一个合理的范围内。
3.2.2 堆积多层 GCN
单层 GCN 只能看到直接邻居。如果你想看更远的原子(比如相隔两键、三键的原子),就得堆叠多层。层数越多,感受野越大。
不过这里有个陷阱。不是层数越多越好。我做靶点筛选项目时,就踩过这个坑。层数一多(比如6层以上),节点特征反而变得差不多,谁跟谁都分不清了。这个现象叫“过平滑”。
避坑指南:我曾经在一个激酶靶点的活性预测任务中,固执地把 GCN 堆到了 8 层,结果模型 loss 死活降不下去。后来发现,对于大部分分子图,2到4层已经足够。过深的图神经网络反而会丢失节点间的差异性。
3.3 GCN vs. 传统 CNN
我们做个对比,看看 GCN 和传统卷积网络在思路上的不同。
| 对比维度 | 传统 CNN | GCN |
|---|---|---|
| 输入结构 | 规则网格(图像像素) | 不规则图(原子连接) |
| 邻居定义 | 固定大小的滑动窗口 | 动态的邻接表 |
| 权重共享 | 卷积核参数全图共享 | 所有节点共享 W^{(l)} |
| 顺序不变性 | 依赖像素排列顺序 | 聚合函数是排列不变的 |
| 感受野 | 由卷积核大小和层数决定 | 由层数决定 |
你想想看,CNN 在图像上用一个 3x3 的核扫过整张图。而 GCN 呢?它是把每个节点周围结构不同的邻居聚在一起学。这也是为什么 GCN 在分子图上如此自然——一个苯环的碳原子和一个烷基链的碳原子,它们的局部结构完全不同,GCN 能自适应地处理。
3.4 代码实现:一个简单的 GCN 层
理论讲完,我们看看代码。我习惯用 PyTorch Geometric 来做,因为它把消息传递的接口封得特别好。
下面是一个最简化的 GCN 层实现:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree
class SimpleGCNLayer(MessagePassing):
def __init__(self, in_channels, out_channels):
super().__init__(aggr='add') # 聚合方式:求和
self.lin = nn.Linear(in_channels, out_channels)
def forward(self, x, edge_index):
# x: [节点数, 输入特征维度]
# edge_index: [2, 边数]
# 1. 添加自环
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
# 2. 线性变换
x = self.lin(x)
# 3. 计算归一化系数
row, col = edge_index
deg = degree(col, x.size(0), dtype=x.dtype)
deg_inv_sqrt = deg.pow(-0.5)
norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
# 4. 开始消息传递
return self.propagate(edge_index, x=x, norm=norm)
def message(self, x_j, norm):
# 消息生成:邻居特征乘以归一化系数
return norm.view(-1, 1) * x_j
def update(self, aggr_out):
# 更新:直接返回聚合后的结果,这里可以加激活函数
return aggr_out
代码很短,但包含了 GCN 的全部要素。
add_self_loops就是在每个节点上加了条指向自己的边,对应公式里的 Ã。norm计算的就是 D̂^{-1/2} Ã D̂^{-1/2}。propagate会自动调用message和update,完成消息传递的全流程。
小技巧:在调试 GCN 时,可以把 aggr 从 'add' 改成 'mean'。对于分子性质预测,均值聚合往往比求和更稳定,尤其是当你处理不同大小的分子时。
3.5 在靶点筛选中的应用思路
说了这么多,GCN 在靶点筛选里到底怎么用?我给大家梳理一个典型流程:
- 分子图构建:把小分子化合物转化成图,原子是节点,化学键是边。每个原子的特征可以用原子类型、杂化方式、电荷等。
- 多层 GCN 编码:堆叠 2~3 层 GCN,把每个原子的局部化学环境编码成一个稠密的向量。
- 读出操作:把所有原子的向量通过某种池化(比如全局平均池化、最大池化、或者加注意力)合并成一个分子整体表示。
- 下游预测:把这个分子表示接一个 MLP,做分类(比如是否对靶点有活性)或者回归(比如 IC50 值)。
说白了,GCN 在这里的作用就是把分子结构里的“形状”和“功能团”信息,自动学出来。你不需要手动设计分子指纹,模型自己会从图结构中提取特征。