消息传递机制:GNN 的核心范式
如果你让我只用一句话概括图神经网络,我会说:GNN 就是不断在图结构上「收发消息」的过程。这种思想并不新鲜——图论中的信念传播、标签传播早就用过了。但 GNN 把消息传递做成了可学习的函数,一下子让图数据有了表达力。
我个人习惯把消息传递拆成三个环节:消息函数、聚合函数、更新函数。你想想看,就像你在公司里收到一堆邮件(消息),你先看每封邮件的正文(消息函数),然后把所有邮件里提到的要点汇总(聚合函数),最后更新你的待办清单(更新函数)。GNN 干的也是一样的事。
消息传递通用公式
h_v^{(t+1)} = Update( h_v^{(t)}, Aggregate({ Message(h_v^{(t)}, h_u^{(t)}, e_{uv}) | u ∈ N(v) }) )
其中 h_v^{(t)} 是节点 v 在第 t 层的特征,N(v) 是它的邻居集合,e_{uv} 是边特征。
消息函数:决定邻居「说什么」
消息函数就是每个邻居节点对自己特征的「加工」+「打包」。最简单的形式就是直接把邻居特征拿过来:Message(h_u) = h_u。但实际项目中这太糙了——我曾在处理分子图时遇到过,直接用原始特征做消息,模型连原子类型都分不清。
更常见的做法是把源节点、目标节点甚至边特征融合在一起:
class MessageFunction(nn.Module):
def __init__(self, in_dim, out_dim):
super().__init__()
self.linear = nn.Linear(in_dim * 2 + edge_dim, out_dim) # 拼合源、目标、边
def forward(self, h_src, h_dst, e):
return self.linear(torch.cat([h_src, h_dst, e], dim=-1))
注意:消息函数输出维度不一定非要跟输入一样。很多模型会让每条消息「降维」后再聚合,省计算量。
聚合函数:把一堆消息「拧成」一条
聚合函数是整个流程里最讲究「图结构不变性」的地方。邻居的顺序 shuffle 了,结果必须一样。所以常用的聚合算子都是对称的:求和、均值、最大值、最小值。
我建议你在小规模图上优先试 求和。为什么呢?我在一个社交网络项目中对比过,求和能保留邻居的数量信息——对于度比较大的节点,求和得到的信号更强,模型更容易区分中心节点和边缘节点。而均值会「稀释」大度节点的信息,最大最小值又会丢失很多中间信号。
| 聚合函数 | 特性 | 适用场景 |
|---|---|---|
| 求和 (Sum) | 保留邻居数量信息 | 度分布差异大的图 |
| 均值 (Mean) | 对度数不敏感 | 节点度数分布均匀 |
| 最大值 (Max) | 只关注最显著特征 | 异常检测、关键点识别 |
| 最小值 (Min) | 关注底线特征 | 安全约束类任务 |
小技巧:聚合前通常要对消息做「归一化」或「加权」。GAT 用的注意力权重就是聚合前的动态加权,效果比均分强很多。记住:聚合方式越「智能」,模型表达能力越强。
更新函数:怎么把老特征和新消息结合
更新函数接收两个东西:节点当前的特征 h_v^{(t)},以及聚合后的消息 m_v。最简单的更新就是直接把两者拼起来过一层 MLP:
h_v^{(t+1)} = σ( W * [h_v^{(t)} || m_v] )
但这里有个坑:如果直接拼接,参数量会膨胀,而且层数深了容易梯度消失。我在 GCN 的复现 里吃过这个亏——3 层之后 loss 就不降了。后来我换成了 残差连接:
h_v^{(t+1)} = h_v^{(t)} + MLP(m_v)
这样消息只负责「补充增量」,主体靠残差流传递,训练稳得多。
踩坑记录:我曾经在更新函数里忘了加激活函数,导致多层消息传递后特征全部线性相关。后来发现 GNN 的深度优势完全没发挥出来。记住:每层更新 至少带一个非线性,ReLU 或 LeakyReLU 都行。
手工实现:从零搭建一轮消息传递
空谈概念没意思,咱直接动手。下面我用 PyTorch 实现一个最简单的消息传递层——就做一件事:每个节点收集邻居的特征,求和,然后和自己拼接,再通过一个线性层。
先构建一个迷你图:3 个节点,边为 (0→1, 1→2, 2→0)。节点特征分别设为 [1,0,0]、[0,1,0]、[0,0,1]。
import torch
import torch.nn as nn
# 节点特征:3个节点,各3维
x = torch.tensor([[1.,0.,0.],
[0.,1.,0.],
[0.,0.,1.]])
# 边索引:source -> target
edge_index = torch.tensor([[0, 1, 2],
[1, 2, 0]]) # 0→1, 1→2, 2→0
# 自定义消息传递层
class SimpleMessagePassing(nn.Module):
def __init__(self, in_dim, out_dim):
super().__init__()
self.linear = nn.Linear(in_dim * 2, out_dim)
def forward(self, x, edge_index):
src, dst = edge_index
# 1. 消息函数:直接取出源节点特征
messages = x[src] # [num_edges, in_dim]
# 2. 聚合函数:按目标节点求和
m_agg = torch.zeros_like(x)
m_agg.index_add_(0, dst, messages) # 汇总到目标节点
# 3. 更新函数:拼接自身特征和聚合消息
h_new = self.linear(torch.cat([x, m_agg], dim=-1))
return h_new
layer = SimpleMessagePassing(3, 3)
out = layer(x, edge_index)
print(out)
跑完你会发现,每个节点不仅保留了自己的特征,还学到了邻居的信息。比如节点 0 的邻居是节点 2,所以它的输出里混了节点 2 的 [0,0,1] 分量。这就是消息传递的本质。
消息传递全过程一览
为了让你对整个过程有个视觉化的理解,我画了张图。左边是输入图,右边是经过一轮消息传递后的输出。中间三个方块分别是消息、聚合、更新。
看到这张图你就明白了:每一轮消息传递都让节点「感知」到更远的邻居。A 本来只连着 B,一轮之后 A 的新特征里包含了 B 的特征;第二轮时,A 就能通过 B 间接「看到」C 了。这就是 GNN 层数代表感受野的原因。
我的实战建议
- 消息函数别太复杂:一开始用简单的线性层甚至恒等映射,等 baseline 跑通了再往上加注意力、门控。我在一个知识图谱项目里,消息函数加了三层 MLP,直接过拟合了。
- 聚合函数记得归一化:跟邻居个数成反比的归一化(比如 GCN 的对称归一化)能避免大度节点主导特征。尤其是分子图,碳原子邻居多,信息容易被淹没。
- 更新函数里的残差是必需品:超过 3 层消息传递,没有残差几乎必退化加死。你可以试试把第一层的输出和最后一层的输出做加法。
- 注意边特征:如果你的图有边属性(如距离、时间、类型),一定要在消息函数里用上。我曾经忽略边特征,模型直接忽略了最重要的空间位置信息。
一个小实验:把上面代码中的 index_add_ 换成 scatter_add 或者用 PyG 的 MessagePassing 基类,你会发现手工实现和框架实现本质上是一样的。但手动敲一遍,你才能真正理解每一行在做什么。
消息传递机制是整个 GNN 大厦的地基。你学会了这三个函数,后续的 GCN、GAT、GIN 无非是在这三个位置上做文章。下一层我们就会看到,这些「做文章」的方法是怎么演化成各种主流模型的。