图神经网络核心思想:消息传递与聚合

说实话,很多人在初学图神经网络(GNN)时,很容易被各种花哨的变体带走——GCN、GAT、GraphSAGE……但你要问我,这些东西最本质的东西是什么?两个字:消息传递。再加上一个词:聚合。

我记得刚接触图神经网络那会儿,看了不少论文,越看越懵。直到有一天,我把一个社交网络推荐项目里的图数据画在纸上,用箭头标出了节点之间的信息流动……嗯,一下子就通了。

说白了,图神经网络做的事儿很简单:每个节点不断从邻居那里“打听”消息,然后把消息汇总,更新自己的特征。这个过程反复做几轮,节点就能学会整个图的模式。

消息传递范式的三个步骤

我们先把这个过程拆开来看。我习惯把消息传递分成三步:

  1. 消息生成——每个邻居节点把自己的特征打包成一个“消息”。
  2. 消息聚合——把从所有邻居收到的消息合并成一个固定长度的特征。
  3. 节点更新——用聚合后的消息和节点自己的旧特征,计算出新特征。

你看,是不是很直观?每轮迭代,节点都会“看”到更远范围的邻居信息。第一轮只看直接邻居,第二轮能看两跳距离的邻居,依此类推。

小经验
我刚开始做项目时,总想把每个邻居的信息独立保留,结果模型参数量爆炸,还容易过拟合。后来才明白:聚合的目的就是压缩信息,保留最核心的模式就够了。

消息生成:向邻居“打招呼”

消息生成这一步,其实就是用函数把邻居的特征变换一下。最简单的做法是什么都不做,直接把邻居特征 h_v 当作消息传过来。但这样太粗暴了,你想想看,不同的邻居可能重要性不同,而且特征维度可能不匹配。

所以一般我们会定义一个消息函数,比如一个线性层加激活函数:

m_{uv} = W · h_v + b

这里 W 和 b 是模型学习的参数。有的变体还会把边上的特征(比如好友关系的亲密度)也加进去:

m_{uv} = φ(h_u, h_v, e_{uv})

其中 φ 可以是拼接后过 MLP,也可以是和操作。我在一个分子性质预测项目中遇到过,加上边特征后模型收敛速度快了将近一倍。

消息聚合:把一堆消息“揉”成一个

这一步是关键中的关键。邻居数量不固定,有的节点有上百个邻居,有的节点只有一两个。我们必须把任意数量的消息变成固定长度的向量。

主流的聚合方法有三种:

聚合方式 核心思想 特点
Mean 对邻居特征求平均 平滑,鲁棒,对度数不敏感
Sum 对邻居特征求和 能区分不同度数,但可能被大度数节点主导
Max 取每个维度上的最大值 对异常值不敏感,适合捕捉关键特征
注意坑
我曾经在社交网络数据上直接用 Sum 聚合,结果发现大 V 节点(几十万粉丝)的特征爆炸了,小节点的信息完全被淹没。后来换成 Mean 才稳定下来。具体选哪种,得看你的图结构和任务。

除了这三种,还有更高级的:注意力聚合。它给每个邻居学习一个权重系数,再加权平均:

α_{uv} = softmax( a^T · [W h_u || W h_v] )
h_u' = σ( Σ α_{uv} · W h_v )

这就是 GAT(Graph Attention Network)的核心。我在做代码依赖图分析时,注意力机制帮我们自动找到了关键依赖模块,效果比均聚好了不少。

节点更新:总结陈词

聚合完邻居消息后,要把这个“综合意见”和节点自己的旧特征结合起来。常见的更新方式:

h_u^{(t+1)} = σ( W_self · h_u^{(t)} + W_agg · agg_result + bias )

或者更简洁的残差形式:

h_u^{(t+1)} = h_u^{(t)} + σ( W · agg_result + bias )

这种方式能缓解深层 GNN 的过平滑问题。我建议你优先尝试带残差的版本,训练更稳定。

消息传递的循环与深度

一次消息传递只让节点看到直接邻居。要捕获更全局的结构,得循环多次。每次循环称为一层(或者一轮)。比如一个 3 层的 GNN,最终每个节点的特征包含了 3 跳范围内的信息。

但这里有个陷阱:层数越多,节点特征越趋于一致(过平滑)。我在一个招聘和职位匹配项目中,试了 8 层的 GCN,结果所有节点的嵌入都几乎一样,完全失去了区分度。后来用 2~3 层配上残差连接才解决问题。

实用建议
对于大多数任务,2~3 层消息传递已经足够。如果数据规模大且连通性强,1~2 层就够。别盲目加层数。

整体流程可视化

下面我把消息传递与聚合的完整流程画了一张图。你可以看到:节点从邻居收集消息,聚合后更新自身,然后进入下一轮。

消息传递与聚合流程(单节点视角) 目标节点 u 邻居 v₁ 邻居 v₂ 邻居 v₃ 消息 m₁ 消息 m₂ 消息 m₃ 聚合 (Mean/Sum/Max) 节点更新 (MLP + 残差) 新特征 h_u' 循环 T 轮,每轮重复以上过程

消息传递的数学形式

如果你见过 GNN 公式,多半是这个样子:

h_u^{(k+1)} = σ( W^{(k)} · AGGR( { h_v^{(k)} : v ∈ N(u) } ) + B^{(k)} · h_u^{(k)} + b^{(k)} )

其中 AGGR 是聚合函数,σ 是非线性激活。这个公式把消息生成、聚合、更新三步压缩在了一行里。但你别被公式吓到,本质就是我上面画的那张图。

小技巧
写代码时,我习惯把消息生成、聚合、更新分成三个函数。你调试时能单独看每一步的输出,快速定位问题。别嫌麻烦,这个习惯帮我节省了大量 debug 时间。

常见聚合方式代码示例

我用 PyTorch 风格写下几种聚合的伪代码。实际实现时注意处理不同邻居数量的批量化。

import torch

def mean_aggregation(neighbor_features):
    # neighbor_features: [num_neighbors, feat_dim]
    return torch.mean(neighbor_features, dim=0)

def sum_aggregation(neighbor_features):
    return torch.sum(neighbor_features, dim=0)

def max_aggregation(neighbor_features):
    return torch.max(neighbor_features, dim=0)[0]

# 注意力聚合示例(简化)
def attention_aggregation(self_feat, neighbor_features):
    scores = torch.matmul(neighbor_features, self_feat)  # 点积
    weights = torch.softmax(scores, dim=0)
    return torch.sum(weights.unsqueeze(-1) * neighbor_features, dim=0)

这段代码里,我用了点积注意力做演示。实际 GAT 中会学一个可训练的权重向量 a,原理类似。

消息传递与聚合的局限性

没有银弹。消息传递范式也有硬伤:

  • 过平滑:层数多了,所有节点特征趋同。我在 20 层的 GraphSAGE 上遇到过。
  • 表达能力有限:标准的消息传递 GNN 不能区分某些同构图结构(比如两个节点有相同的 1 跳邻居等)。
  • 计算开销:邻居数量爆炸时(比如社交网络大 V),聚合操作很慢。

补救方法:加残差、用跳连、或者引入 Position Encoding。但这些都是后话了,先把基本功吃透。

嗯,消息传递与聚合就是 GNN 的 DNA。你理解了这个,后面学 GCN、GAT、GIN 这些变体都会很快——因为它们只是在“怎么生成消息”“怎么聚合”“怎么更新”上做了不同的设计而已。

一句话总结
图神经网络 = 消息生成 → 消息聚合 → 节点更新。循环 T 轮,最终每个节点的嵌入包含了 T 跳邻域的结构信息。