3. 基础回顾(二):经典图神经网络入门——图卷积网络(GCN)原理与消息传递范式

各位好,欢迎来到第三章。上一章我们聊了图的基本表示和性质。今天,我们来点硬核的——图卷积网络,也就是 GCN。这玩意儿在分子性质预测、靶点筛选里,可以说是主力模型。我个人习惯把 GCN 拆成两部分来看:一个是它怎么定义“邻居”并聚合信息,另一个是它怎么在图上做“卷积”。

3.1 消息传递范式

先别急着看公式。我们想想看,一个原子(节点)的性质,是不是很大程度上受它周围的原子影响?GCN 的核心思想就是这个——邻域聚合。把邻居的信息“传递”过来,然后“更新”自己。这就是消息传递范式。

嗯,这里要注意。消息传递不是一个统一的模型,而是一种框架。几乎所有主流的图神经网络,比如 GCN、GAT、GraphSAGE,都能用这个框架来描述。

消息传递一般分三步走:

  1. 消息生成:每个节点根据自己和邻居的特征,生成一个消息。
  2. 消息聚合:把邻居发来的所有消息,用一种函数(比如求和、求平均)汇总起来。
  3. 节点更新:用聚合后的消息,结合自己上一层的特征,生成新的节点表示。

核心观点:消息传递范式把图上的学习问题,转化成了定义“消息”和“聚合”函数的问题。你只要能定义清楚这两点,就是在设计一个新的图神经网络。

为了直观理解,我们来看一个简单的例子。假设有一个分子,我们只关心其中三个原子 A、B、C。A 连着 B,B 连着 C。我们想把 A 的更新表示算出来。

消息传递示意图 邻居消息聚合过程示意 节点 A 节点 B 节点 C 消息 m_B→A 消息 m_C→A 聚合函数 更新 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 在靶点筛选里到底怎么用?我给大家梳理一个典型流程:

  1. 分子图构建:把小分子化合物转化成图,原子是节点,化学键是边。每个原子的特征可以用原子类型、杂化方式、电荷等。
  2. 多层 GCN 编码:堆叠 2~3 层 GCN,把每个原子的局部化学环境编码成一个稠密的向量。
  3. 读出操作:把所有原子的向量通过某种池化(比如全局平均池化、最大池化、或者加注意力)合并成一个分子整体表示。
  4. 下游预测:把这个分子表示接一个 MLP,做分类(比如是否对靶点有活性)或者回归(比如 IC50 值)。

说白了,GCN 在这里的作用就是把分子结构里的“形状”和“功能团”信息,自动学出来。你不需要手动设计分子指纹,模型自己会从图结构中提取特征。