第4章 消息函数设计:线性变换、MLP与共享参数

消息函数是MPNN的核心。说白了,它决定了邻居信息怎么编码、怎么流进当前节点。我刚开始做图网络的时候,总觉得消息函数随便选个线性层就行。后来在分子性质预测项目里吃了几次亏,才明白这里头的门道不少。

这一章我们聚焦消息函数的三种主流设计:线性变换、MLP和共享参数。每种方案各有优劣,适用场景也不同。你想想看,同样是做消息传递,换个函数设计,模型效果可能天差地别。

4.1 消息函数在MPNN中的角色

在MPNN的每一层,每个节点会聚合邻居的信息来更新自己的表示。消息函数负责把源节点的特征、目标节点的特征、边特征这三者组合起来,生成一条"消息"。

用公式表达就是:

m_{vw} = M(h_v, h_w, e_{vw})

其中 h_v 是源节点特征,h_w 是目标节点特征,e_{vw} 是边特征。M 就是消息函数。你设计什么样的 M,直接决定了模型能学到什么级别的结构信息。

核心原则:消息函数越灵活,模型表达能力越强,但参数量和过拟合风险也越高。这是个 trade-off。

4.2 线性变换:最朴素的方案

线性变换是最基础的消息函数。直接对源节点特征做一次线性映射,加上偏置,完事。

m_{vw} = W * h_v + b

这种做法简单、速度快、参数量少。我在早期的一个小规模社交网络项目里用过,训练效率确实高。但问题是——它没有考虑目标节点特征,也没有非线性能力。表达能力有限。

什么时候用线性变换?我觉得有两种场景比较合适:

  • 图规模超大(百万级节点以上),参数量必须压得很低
  • 作为 baseline,快速验证整体流程是否跑通

注意:线性变换无法捕捉特征之间的交互关系。如果你的数据中存在复杂的非线性依赖,线性方案基本不够用。

4.3 MLP:表达能力的核心武器

MLP(多层感知机)是目前最主流的消息函数实现。它一般会把源节点特征、目标节点特征、边特征拼接起来,再送入一个多层全连接网络。

m_{vw} = MLP( concat( h_v, h_w, e_{vw} ) )

这个方案的优势很明显:非线性激活让模型能学到更复杂的模式。我在分子性质预测项目里试过,用两层MLP做消息函数,效果比线性变换提升了将近12%。

我的经验:MLP的层数一般设2~3层就够了。太深反而容易梯度消失,训练不稳定。我通常第一层宽一点(256或128),第二层窄一点(64或32)。

但MLP也有坑。我曾经在一个图分类任务里把MLP堆到5层,结果训练loss死活降不下去。后来查了半天,发现是梯度消失了。加了残差连接和LayerNorm才救回来。

MLP的典型代码结构:

class MessageMLP(nn.Module):
    def __init__(self, in_dim, hidden_dim, out_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(in_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, out_dim)
        )

    def forward(self, h_src, h_dst, e):
        x = torch.cat([h_src, h_dst, e], dim=-1)
        return self.net(x)

这段代码里我把拼接放在forward里做,这样更灵活。你可以在外部控制输入特征的组合方式。

4.4 共享参数:效率与泛化的平衡

共享参数的核心思路是:不同类型的边或者不同位置的层,共用同一套消息函数参数。

常见的共享方式有两种:

共享方式 做法 适用场景
边类型共享 同一种边类型共用一套参数 异构图、关系图
跨层共享 不同MPNN层共用同一套MLP 深层网络、参数敏感场景

我记得在做一个知识图谱推理项目时,图里有30多种关系。如果每种关系都单独学一套MLP,参数量直接爆炸。后来改用边类型共享,把相似关系归到同一组,参数量降了60%,效果只掉了不到3%。值。

共享参数的关键判断:当你发现参数量已经撑不住了,或者模型在验证集上开始过拟合,就该考虑共享参数了。

跨层共享的实现也很直接:

shared_mlp = MessageMLP(in_dim=128, hidden_dim=64, out_dim=64)

# 第1层消息
msg_1 = shared_mlp(h_src_1, h_dst_1, e)
# 第2层消息
msg_2 = shared_mlp(h_src_2, h_dst_2, e)

你看,同一个MLP对象在不同层重复使用。这样参数总量不变,层数可以加深。

避坑提醒:跨层共享后,不同层的输入分布可能差异很大。我建议加上LayerNorm或者BatchNorm来稳定训练。我曾经没加Norm,结果训练了200轮loss还在震荡。

4.5 三种方案对比与选型

下面这张表是我自己的实践经验总结,供你参考:

方案 表达能力 参数量 训练速度 推荐场景
线性变换 低 少 快 超大图、baseline
MLP 高 中 中 通用、中小规模图
共享参数 中高 少 快 异构图、深层网络

你选哪种?我的建议是:默认用MLP(2层),如果参数量扛不住再切共享参数。线性变换只做baseline或极端大图场景。

4.6 消息函数设计路径总览

下面这张图把三种方案放在一起对比,你可以直观地看到它们的输入输出关系和核心差异:

消息函数设计路径对比 输入特征组合 h_v + h_w + e_{vw} 线性变换 y = Wx + b MLP 多层非线性变换 共享参数 跨层 / 跨边复用 消息输出 m_{vw} 选择建议:默认用MLP(2层),参数量敏感时切共享参数,线性变换仅用于baseline或超大规模图

4.7 避坑总结

最后,我把这些年踩过的坑集中说一下:

  • MLP层数别贪多——3层以上收益递减,还容易梯度问题。一定要加深的话,加上残差和Norm。
  • 共享参数别忘了做分布适配——不同层/不同边的输入分布差异可能很大,Norm是必需品。
  • 线性变换不是万能的——它做不了特征交互。如果你发现模型效果怎么调都上不去,先检查消息函数是不是太简单了。
  • 输入拼接顺序要统一——concat(h_v, h_w, e) 和 concat(h_w, h_v, e) 是不同的。定好规矩,别混用。

小技巧:刚开始不确定选哪种消息函数时,先跑一个线性变换的baseline,再换MLP看提升幅度。如果MLP提升不到5%,说明你的图任务本身可能简单,线性方案就够了。