第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可能更稳。

实际建议:不要拍脑袋选聚合方式。先跑一个小网格搜索,用验证集对比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 核心流程可视化

说了这么多,咱们用一张图来梳理整个流程。

图神经网络消息传递-聚合-更新 核心流程 节点A(源) 节点B(源) 节点C(源) 消息MA 消息MB 消息MC 聚合 Sum/Mean/Max 更新节点特征 迭代K次 = 聚合K阶邻居信息

这张图的逻辑很直白。左边是三个源节点,它们各自生成消息。消息汇聚到中间进行聚合操作。聚合结果再传下去,更新目标节点的特征。这个目标节点更新完后,又成为下一轮消息传递的源节点。

反复迭代K次,节点就能感知到K跳邻居的信息。这就是GNN感受野的由来。

3.5 避坑指南与个人经验

做GNN特征提取,有几个坑我踩过,分享给你:

  • 消息维度爆炸。我曾在一个项目中,把节点特征和边特征直接拼接,结果消息维度飙到512。如果你的MLP层数不够深,根本学不动。我习惯先用一个线性层降维到64或128,再拼接。
  • 过平滑。层数堆到5层以上,所有节点特征趋同。我建议:如果你的分子不大(比如几十个原子),2-3层足够了。别盲目堆层数。
  • 聚合方式固化。不要死磕一种聚合。我试过一个场景,用Max聚合对毒性预测好,用Sum却连baseline都跑不过。灵活切换,交叉验证。
注意:消息传递 + 聚合 + 更新,这个三步曲是GNN的核心骨架。但很多框架把这三步封装在了一个函数里。初学者容易忽视底层逻辑,以为只是调个API。我建议你用PyTorch Geometric(PyG)的 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'也很简单。我习惯在实验配置里把它们作为可调参数,方便对比。

核心要记住的:消息传递不是玄学,就是三步——生成消息 → 聚合消息 → 更新节点。理解了这个,你就能看懂绝大多数GNN的论文了。