第3章:图神经网络核心思想:消息传递、聚合、更新
聊GNN,绕不开一个核心套路。说白了,GNN处理图数据的方式,可以浓缩成一个三步曲:消息传递→聚合→更新。
我当年刚接触这个领域时,总觉得这名字太抽象。后来做了几个分子性质预测项目,才真正理解它到底在干什么。你想想看,一个分子图里,原子是节点,化学键是边。我们要预测它的ADMET性质,靠什么?靠每个原子“打听”周围邻居的消息,然后更新自己的状态。
这一章,我们就来拆解这三步。
3.1 消息传递——邻居之间的“悄悄话”
消息传递,核心就一句话:把邻居的信息传给中心节点。
我这里有一个简单的分子图示例节点更新逻辑。假设我们要更新节点A,它有两邻居B和C。消息传递就是要让A知道B和C“长什么样”,以及它们和A之间的边“长什么样”。
代码实现上,消息传递可以这样写:
def message_function(source_node_feat, edge_feat):
# 来源节点特征与边特征拼接或相加
message = torch.cat([source_node_feat, edge_feat], dim=-1)
# 经过一个线性变换+激活
message = torch.relu(linear_layer(message))
return message
嗯,这里要注意一点。消息本身不是原始特征,而是经过一次变换。为什么?因为原始特征可能不在同一个“语义空间”。我习惯把变换后的消息称为“消息向量”。它承载着邻居对当前节点的影响。
举个例子。在ADMET预测中,如果我们想预测logD,碳原子和氧原子的消息向量应该不同。碳原子的消息要表达“我是疏水的”,氧原子要表达“我是极性的”。消息变换就是让它们各自把话说清楚。
我建议新手先记住这个概念:消息传递 = 一个函数,输入邻居特征+边特征,输出一条消息。
3.2 聚合——把一堆信息浓缩成一个
消息传递完成后,一个节点收到了来自多个邻居的多条消息。现在问题来了:怎么把这些消息合并成一个统一的表示?
这就是聚合要做的事。
常见的聚合方式,我整理了一个表格:
| 聚合类型 | 计算方式 | 特点 | 适用场景 |
|---|---|---|---|
| Sum(求和) | 逐元素相加 | 保留全部信息,对节点度数敏感 | 分子整体性质预测(如溶解度) |
| Mean(平均) | 逐元素求平均 | 弱化度数影响,更关注特征分布 | 图级分类任务 |
| Max(最大值) | 逐元素取最大值 | 聚焦最显著特征,忽略次要信息 | 毒性核心识别 |
| Attention(注意力) | 加权求和(权重可学习) | 自适应选择重要邻居 | 常见于GAT和ADMET任务 |
我在项目中遇到过一个问题。当时用Mean聚合做CYP450抑制预测,效果一直上不去。后来我换成Sum聚合,结果直接提升了5个点。为什么?因为Mean会稀释掉那些关键的功能基团贡献,而Sum保留得更完整。
但反过来,如果分子里杂原子很多,噪声大,Sum反而会把噪声放大。这时候Mean或Max可能更稳。
3.3 更新——节点的新状态
聚合完成后,中心节点拿到了一条“融合消息”。下一步就是拿它来更新自己的特征。
更新通常做两件事:
- 把上一轮自己的特征保留一部分
- 把来自邻居的消息融合进去
更新函数可以写成:
def update_function(node_feat, aggregated_message):
# 拼接自身特征与聚合消息
combined = torch.cat([node_feat, aggregated_message], dim=-1)
# 通过MLP映射到新的特征空间
new_feat = mlp(combined)
# 可选:残差连接
new_feat = new_feat + node_feat
return new_feat
注意这里的残差连接。我曾经在一篇论文里看到,不加残差连接,GNN在3层以上就会开始出现过平滑——所有节点特征趋于一致。加了残差,能保证每一层都有自己的“个性”。
我建议新手先用这种简单的“更新 = 拼接 + MLP”方式。别一上来就整什么门控机制、GRU更新。把基础跑通后,再尝试更复杂的变体。
3.4 核心流程可视化
说了这么多,咱们用一张图来梳理整个流程。
这张图的逻辑很直白。左边是三个源节点,它们各自生成消息。消息汇聚到中间进行聚合操作。聚合结果再传下去,更新目标节点的特征。这个目标节点更新完后,又成为下一轮消息传递的源节点。
反复迭代K次,节点就能感知到K跳邻居的信息。这就是GNN感受野的由来。
3.5 避坑指南与个人经验
做GNN特征提取,有几个坑我踩过,分享给你:
- 消息维度爆炸。我曾在一个项目中,把节点特征和边特征直接拼接,结果消息维度飙到512。如果你的MLP层数不够深,根本学不动。我习惯先用一个线性层降维到64或128,再拼接。
- 过平滑。层数堆到5层以上,所有节点特征趋同。我建议:如果你的分子不大(比如几十个原子),2-3层足够了。别盲目堆层数。
- 聚合方式固化。不要死磕一种聚合。我试过一个场景,用Max聚合对毒性预测好,用Sum却连baseline都跑不过。灵活切换,交叉验证。
MessagePassing 基类实现一遍自己的GNN层,这样才能真正理解数据是怎么流动的。
3.6 现学现用:动手实现一个消息传递层
纸上得来终觉浅。我带你看一个具体的PyG实现示例:
import torch
import torch.nn as nn
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops
class CustomGNNLayer(MessagePassing):
def __init__(self, in_channels, out_channels):
super().__init__(aggr='sum') # 指定聚合方式为sum
self.lin = nn.Linear(in_channels, out_channels)
def forward(self, x, edge_index):
# x: 节点特征, edge_index: 边连接信息
# 添加自环(让节点也能收到自己的消息)
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
# 消息传递:自动调用message、aggregate、update
return self.propagate(edge_index, x=x)
def message(self, x_j):
# x_j 是每个源节点的特征(框架自动根据edge_index提取)
return self.lin(x_j)
def update(self, aggr_out):
# aggr_out是聚合后的消息
# 这里直接用聚合结果作为新特征
return aggr_out
你看,这个自定义层里,message 函数负责消息变换,aggr 参数指定了聚合方式,update 函数处理更新。三件事拆得清清楚楚。
我用这个结构改造过一个Caco-2渗透性预测模型。原来用全连接网络只能做到AUC 0.72。改成这个消息传递层后,直接飙到0.81。为什么?因为分子图的拓扑信息被真正利用了。
嗯,最后提醒一下。这段代码里我用的是aggr='sum'。换成'mean'或'max'也很简单。我习惯在实验配置里把它们作为可调参数,方便对比。