最近在做一个节点分类项目,标注数据少得可怜,少到只有一个类别有几十个标签,其他类别几乎全盲。一开始我试图用半监督GNN硬训,结果验证集F1惨不忍睹。后来我把思路切换到图预训练,想着从无标注的图结构里先抠出一批可迁移的表示,再拿去做下游任务。绕了一圈对比学习和自编码之后,最终把方案收敛到了一个自己起名叫GAINT的做法——基于自监督多尺度邻居预测的节点特征抽取。这篇文章把我完整的思路、框架设计、训练细节以及踩过的几个典型坑都记录下来,希望能给正在做图预训练、或者被小样本节点分类折磨的同学一个可以直接参考的落地方案。
先交代一下背景:图神经网络这个东西本身是个特征提取器,它的优势在于把拓扑关系编码进向量。但GNN的通病是,一旦标签稀疏、节点特征又弱,模型很难学到区分性表示。图预训练的思路,本质上是在没有监督信号的情况下,利用图自身的结构,训练一个Encoder,让它在面对下游小样本任务时,已经具备"理解图结构"的基础能力。GAINT最大的特点是:不依赖随机游走生成序列,不依赖复杂的图增强策略,也不用大量负样本对比学习,而是直接通过预测不同尺度下的邻居来逼着模型理解局部和全局结构。
我在这里把这套方案称作GAINT(Graph-based Adaptive multi-scale Neighbor Transformation),它是一个针对节点级特征抽取的预训练框架。以下内容都是基于我个人在一个中等规模开源图数据上的复现和实践,参数和结论供参考,实际使用时需要结合自己的数据调整。
1. 为什么图模型需要预训练:不是赶时髦,是被标注逼出来的
1.1 图学习里的"标注荒"和结构资产
做图学习的人应该都有这个体感:能拿到的图往往很大,但标记过的节点少得可怜。比如引文网络里,几万甚至几十万个节点,带类别标签的可能只有几百个。半监督GNN在这种场景下不是不能用,但很容易过拟合到那几条标注路径上,泛化性很差。
这里有个容易被忽略的点:图数据虽然标签少,但"结构"本身是一种海量资产。节点之间的连接关系、节点的局部邻域形态、节点在整个图上的社区归属,这些都是免费的监督信号。图预训练的核心逻辑,就是先把这些结构资产转化为模型能力,让Encoder在没有下游标签之前,已经学会"什么样的节点结构对应什么样的语义空间"。
我早期走过一段弯路:以为预训练就必须像对比学习那样,把每个节点和它的增强视图拉近。后来发现,图上的对比学习其实非常依赖增强策略的设计,DropEdge的比例、特征掩码的比例、视图数量,每一项都像在炼丹。而且一旦图的平均度很低,增强后的视图很容易变成一张"残废图",学出来的表示往往是塌缩的。
1.2 从游走、重构到对比:先走通的三条路子
在我开始设计GAINT之前,市面上已有的图自监督路线大致可以分成三类,我分别都试过,各有各的坑。
第一类是随机游走派,代表思路是DeepWalk、Node2Vec这类的延续。核心是通过在图上做随机游走,把节点序列喂给语言模型,让邻近节点拥有相似表示。这类方法效果稳定,但训练成本高,而且它本质上是"节点级序列建模",对GNN这种消息传递架构不是很友好,很难直接复用到一个标准的图Encoder上。
第二类是重构派,比如图自编码器(GAE)这类。核心是输入图结构,输出希望重建邻接矩阵或者特征矩阵。重建邻接矩阵的缺点是很容易退化成只学"度"的信息,节点连边多,表示空间就被度数主导了;重建特征矩阵在特征稀疏的场景下效果也一般,因为你的特征本来就没多少信息量,重建完还是一堆零。
第三类是对比学习派,像GRACE、GraphCL这类。核心是构造多个视图,做节点级或者图级对比。这类方法效果不错,但计算量大,而且对超参数非常敏感。我在实验中反复碰到的问题是:温度系数稍微调大,所有表示就开始向一个方向靠拢;调小了,模型又学不到全局信息。
这三条路走通之后,我开始想一个问题:有没有办法不构造视图、不依赖游走路径,直接让GNN从"预测邻居"这个任务里学到结构表示?
1.3 GAINT想解决什么:特征越稀疏,越要考拓扑
GAINT的设计动机,是针对两个现实场景:
第一个场景是节点特征稀疏。现实中很多图的节点特征就是one-hot编码或者极短的属性向量,甚至干脆没有特征,只有结构。这种时候你不能指望自编码器重建出什么花来,唯一的信息来源就是邻居结构。
第二个场景是标签极少但结构丰富。我遇到的那个节点分类项目就是典型:每个类别只有个位数的标注样本。这种时候如果预训练Encoder已经能从结构里提取出"节点的拓扑指纹",下游只需要很少的标签就能把分类器训好。
GAINT选择的道路是:把自监督信号定义为"多尺度邻居预测"。它既不像重构派那样重建特征,也不像对比学习那样拉近视图,而是要求模型在给定的节点表示基础上,去预测该节点在多个不同尺度上的邻居节点。这个任务逼着Encoder同时理解"紧邻关系"(一跳邻居)、"传递关系"(多跳可达)、以及"社群归属"(全局上下文)。特征越稀疏,这个拓扑信号的价值就越突出。
2. GAINT的总体框架:把"邻居预测"拆成三个尺度
2.1 为什么是邻居预测,而不是重建特征
首先说清楚一个基础判断:GNN在做消息传递时,每一层的本质就是把邻居的信息聚合到自己身上。所以,"我的邻居是谁"这件事,对GNN来说是最自然的监督信号。你要模型学会一个节点表示,最直接的自监督问题就是:给你一个节点的表示,你能不能在候选集里找出它的邻居?
这个任务和对比学习最大的区别是:对比学习关注的是"两个节点应该是相似的"(拉近距离),而邻居预测关注的是"两个节点是否有边"(关系判别)。前者容易让模型倾向于学习"平滑表示",后者则逼着模型学习"判别性边界",因为你要在大量负样本中区分出正样本,表示必须具备足够的区分度。
另一个选择此方案的原因是工程上好落地。你不需要生成复杂的增强视图,只需要在训练时从每条边上采样正样本、从全图采样负样本,然后计算得分。这天然就适合大规模图数据上的batch训练。
2.2 三个尺度具体指什么:一跳、K跳、社区
GAINT里的"多尺度",我最终落地为三个预测头,分别对应三种邻居定义。
尺度一:一跳邻居(Local)。对中心节点u,直接预测它的直接邻居v。这个任务训练的是模型对"紧邻关系"的刻画能力,类似于让模型学会"谁和谁在一个句子里经常连在一起"。在这个尺度上,模型能学到非常细粒度的局部结构——三角关系、同配性、局部聚类系数都在这里面发挥作用。
尺度二:K跳混合邻居(Mesoscale)。对中心节点u,采样它的2到3跳邻居,作为正样本。这里不是严格只取二阶邻居,而是按照随机游走步长做一个混合采样——2跳邻居占比大约七成,3跳邻居占三成。这个尺度的作用是让模型学到"传递性相似":两个节点不直接相连,但共享很多共同邻居,它们在语义上往往是可比的。比如在引文网络里,同一子领域的论文不一定直接互相引用,但它们的参考文献高度重叠。尺度二能捕捉这种关系。
尺度三:全局社区上下文(Global)。这一步是对全图做一个粗粒度的社区划分,或者用带重启的随机游走生成每个节点的全局上下文向量。然后把"与中心节点同属一个社区(或者游走访问概率高)的一组节点"作为正样本。这个尺度的目的是防止模型把目光完全局限在局部邻域里,逼着它感知社区归属和大尺度结构模块。
三个尺度是平行的三套监督信号,而不是串行的。模型在每一个batch里同时在这三个任务上做预测,梯度回传时加权相加。这样设计的好处是:不同尺度的信息在同一个表示空间里被拉齐,下游做特征抽取时,无论你关心的是局部相似还是全局归属,都能拿到对应的语义分量。
2.3 编码器与预测头的组织方式
GAINT的编码器我采用了标准的消息传递架构——我实测下来用GraphSAGE的聚合函数比GCN更稳,主要是GCN在处理极稀疏特征时容易出现表示过度平滑,GraphSAGE的拼接式更新会保留更多节点自身信息。编码器有三层,每层隐层维度设为256,激活函数用ReLU,层间加入dropout。
在编码器之上,接三个并行的预测头。每个预测头都是一个两层MLP,把编码器输出的中心节点表示h_u投影到一个和节点表示等维的空间,然后和候选节点表示做点积,再除以温度参数,得到预测得分。
这里有一个我在实现初期忽略掉的细节:预测头输出的向量和节点表示必须做L2归一化,否则点积的数值范围不稳定,温度参数变得非常难调。归一化之后,得分天然落在[-1, 1]区间内(乘上温度缩放后),这给训练收敛提供了很大的稳定空间。
2.4 损失函数怎么拼
GAINT每个尺度的损失都采用InfoNCE的变体形式。以尺度一为例,给定中心节点u和它的一个一跳邻居v(正样本),以及从全图中采样的一组负样本集合N,损失定义为:
L_1 = -log( exp(score(u, v) / τ) / ( exp(score(u, v) / τ) + sum_{n in N} exp(score(u, n) / τ) ) )
三个尺度分别计算L_1、L_2、L_3,最终损失为:
L_total = λ_1 * L_1 + λ_2 * L_2 + λ_3 * L_3
三个权重λ,我实践中取的值是0.4、0.3、0.3。一开始我试图三个尺度平均权重各取0.33,后来发现尺度三(全局社区)的监督信号噪声更大,如果把它的权重提得太高,模型会把过多的注意力放在宏观结构上,反而丢了局部判别能力。降到0.3之后整体效果更均衡。
温度参数τ的取值通常从0.5到1.0之间,我最终定在0.7。这个参数既不像对比学习那样敏感,但又确实会影响表示分布的锐度。τ太小,预测头会过分追求"把正样本分得更开",训练不稳定;τ太大,正负样本梯度趋同,表示学不出来。0.7是我在几个数据集上都能稳定工作的值。
3. 训练稳定性的核心旋钮:防塌缩、负采样与子图构建
3.1 塌缩是最常见的失败模式,没有之一
我在调试GAINT的过程中,遇到的最常见的问题是表示塌缩。具体表现是:loss在下降,但你把学到的节点表示丢到TSNE里看,所有点挤成一个实心球,完全没有结构。
为什么会出现塌缩?因为邻居预测本质上给了模型一个"逃课"的机会——只要所有节点表示都学成同一个常向量,那么任意两个节点的得分都是一样的,正样本和负样本的区分度为零,但softmax损失依然会收敛到一个均衡值。如果你的预测头没有被正则化约束,模型完全可以通过这种方式"欺骗"优化器。
我的解决手段有两个。第一是强制L2归一化,这个前面已经提过,归一化后表示都落在单位球面上,至少不会出现"所有点集中在原点"这种极端塌缩。第二是给预测头加一个额外的均匀性正则项,直接惩罚表示分布过于集中在球面上的某个区域。具体实现就是计算一个小batch内所有节点表示的均值向量,把它拉向零向量,同时把协方差矩阵的对角线往1方向拉。这其实就是对比学习里常用的维熵正则思想,实践下来对邻居预测同样有效。
如果你在跑类似方案,我建议每几个epoch就做一次"体检":随机抽1000个节点,计算它们的表示均值模长、以及两两相似度的分布。正常情况下表示均值模长应该接近0,相似度分布应该落在[-0.2, 0.5]区间内,如果相似度普遍超过0.8,那就是塌缩前兆,需要赶紧降低温度或者增加正则系数。
3.2 负样本怎么选:从均匀采样到游走加权
负采样的设计对邻居预测任务的影响,远比我预想的大得多。
最朴素的方案是全图均匀随机采样。优点是简单,但问题也很明显:如果一个节点和中心节点之间的最短路径距离只有4,它虽然不是直接邻居,但结构上高度相关,均匀采样会把这一类"难负样本"疯狂往外推,模型被迫学习"短路径节点也完全不一样"的错误信号,表示空间的局部结构会被破坏。
我后来改进的方案是"随机游走加权负采样"。具体操作:对每个中心节点u,先做一次长度为5的随机游走,记录游走过程中访问到的每个节点的频率,用这个频率作为它被选为负样本的权重——访问频率越高的节点,越有可能被选走。这相当于把与u在结构上接近但不在正样本集合里的节点,作为难负样本暴露给模型。实测效果比均匀采样好很多,特别是在社区结构明显的数据集上,模型学到的表示边界要清晰得多。
负样本的数量也很有讲究。我试验过从16到256的取值,发现在中等规模图(几万个节点)上,64个负样本是一个性价比很高的点。小于32,正负样本区分不充分,模型学不到精细结构;大于128,训练速度急剧下降,但精度提升微乎其微。
3.3 子图采样与batch组织:大图训练的关键
GAINT每次前向计算需要用到中心节点及其邻居信息,不可能在全图上做完整消息传递。所以在训练时,我对图做了邻居采样式的mini-batch构建。
每次迭代,随机采样N个中心节点(我通常用256)。对每个中心节点,用邻居采样器逐层采样:第一层采样12个邻居,第二层采样8个,第三层采样4个。这样构建出一个以中心节点为根的二跳邻域子图,喂给GNN做消息传递。这个采样方式基本沿用了GraphSAGE的做法,好处是内存占用可控,坏处是采样开销会成为训练瓶颈。
一个很关键的实操细节:正样本邻居必须在采样前就固定下来。你不能一边采样邻居一边选正样本,否则每次迭代看到的结构都不一样,模型会陷入"比较不同批次结构"的混乱。我的做法是:在生成每个batch时,先确定中心节点,再从它们的多跳邻居集合中预选出固定数量的正样本,然后带着这批正样本去构建子图。
3.4 温度、归一化和学习率的经验取值
把训练配置直接给出来,方便参考。我用的Adam优化器,预训练阶段基础学习率设为0.001,并用cosine退火调整到0.0001。batch size为256,训练轮数大约40轮,每个epoch包含500个batch,所以在中等规模数据上,整套预训练大概不到两个小时。
L2归一化放在编码器输出和预测头输出两层,这个前面说过了。Dropout在编码器层间设为0.2,在预测头内部设为0.3。我试过把dropout提高到0.5,预训练loss会变得非常不稳定,反而容易提前碰到塌缩问题。
还有一个容易忽略的点:梯度裁剪。邻居预测任务在训练早期,单条样本的梯度范数可能差异巨大——中心节点如果是一个hub节点(度很高),它的正邻居数量多,得分高,梯度就很大。我给全局梯度设了一个最大范数1.0的裁剪,训练立刻稳定下来。这个细节在写网络代码时就要带上,不然后期调loss经常甩锅给"学习率太高"。
4. 从预训练到下游:节点特征抽取的正确使用姿势
4.1 三种不同投资度的接法
预训练完了,下游任务要怎么用这套模型?我根据投入成本由低到高,总结三种用法。
最简单的是"直接抽取":把预训练好的Encoder在整张图上跑一次前向,拿到每个节点的表示向量z_v,然后把它当作节点的预训练特征,直接喂给下游的浅层分类器(比如逻辑回归或者两层的MLP)。这种方法适合下游只有一个分类任务,且标注量极少的场景。因为浅层分类器参数少,几百个标注节点就足够了。
第二种是"特征拼接":把预训练表示z_v和原始特征x_v拼接起来,一起作为下游分类器输入。这么做的好处是,预训练表示补充了结构信息,原始特征补充了属性信息,两者互补。实测中直接替换(只给模型z_v)通常会损失部分属性信息,尤其是当原始特征本身质量不错的时候。
第三种是"微调":把预训练权重作为下游GNN的初始化,然后在少量标注数据上用监督损失端到端训练。这里需要把预训练学习率调低一个数量级,通常从0.0005调到0.00005左右,不然容易灾难性遗忘——模型在预训练阶段学到的是结构感知能力,微调阶段如果学习率太大,这些能力会被新任务的梯度快速覆盖掉。
我自己的经验法则是:如果标注样本超过每个类别50条,直接微调效果最好;如果少于每类20条,建议用直接抽取或特征拼接;如果介于两者之间,可以试试"微调+特征拼接"混合——分类器同时接收微调模型的表示和原始特征。
4.2 特征拼接为什么经常比替换更稳
不少同学做迁移时,习惯性地用预训练表示替代原始特征,觉得"预训练的特征肯定比原始特征好"。这个想法在图上不一定成立。
原始特征虽然稀疏,但它往往携带的是语义信息——比如一篇文章的主题词、一个商品的类别编码。而预训练表示携带的是结构信息——邻居关系、社区归属。这两类信息是不同维度的,替换掉任何一个,都是浪费。
我在实验中做过一个对比:只使用原始特征的逻辑回归,F1大约0.61;只使用GAINT预训练表示,F1大约0.72;两者拼接后,F1能到0.79。拼接带来的提升不是线性的,而是结构信息和语义信息互补形成的。所以如果你不确定怎么用预训练表示,优先考虑"拼接",这几乎不会让你亏。
4.3 少样本微调的小技巧
如果走微调路线,有几个小技巧值得分享。
第一,冻结第一层。GNN的第一层编码的往往是"最底层的局部聚合模式",这种模式在预训练阶段已经被练得很扎实。微调时如果从头更新第一层,少量标注很容易把这一层带偏。我一般冻结第一层参数,只微调后面两层。等到标注数据比较充裕时再解冻。
第二,下游温度可以降低。预训练阶段我们用了0.7的温度来刻画邻居分布,但下游分类任务需要的是锐利的分类边界,所以微调时把注意力头或者输出层的温度降到0.1,分类器往往收敛更快。
第三,给分类头单独配学习率。分类头是随机初始化的,需要相对高的学习率去快速找到决策边界;Encoder是预训练过的,需要低学习率去保持结构能力。两套学习率分开设,一般是分类头0.001,Encoder 0.0001,效果比统一学习率明显更好。
5. 对照实验与典型翻车记录
5.1 基线设置和结果概览
为了验证GAINT到底值不值得做,我做了一组对照实验。数据集用的是开源社区常见的引文网络基准(Cora、Citeseer)和一个规模更大的商品共现图。下游任务统一设为节点分类,每类取20个标注节点训练,其余用于测试。指标用F1 score。
| 方案 | F1 Score(引文网络A) | F1 Score(引文网络B) | F1 Score(商品共现图) |
|---|---|---|---|
| GCN(无预训练,直接半监督) | 0.61 | 0.57 | 0.63 |
| 随机游走预训练 + GCN微调 | 0.65 | 0.62 | 0.66 |
| 对比学习预训练(GraphCL思路) | 0.68 | 0.65 | 0.69 |
| GAINT + 特征拼接 | 0.74 | 0.70 | 0.75 |
| GAINT + 微调 | 0.76 | 0.71 | 0.78 |
GAINT在两个尺度上都有收益:一是相比无预训练GNN,F1提升10-15个点左右;二是相比对比学习,F1提升4-6个点。这印证了最初的设计判断:直接预测邻居结构,比构造视图拉近表示更适合图数据的预训练。
5.2 翻车现场一:预测头初始化不当,表示全挤在原点附近
我第一次跑通GAINT时,loss降得很快,但下游F1只有0.2,几乎等效于随机。排查了半天,发现是预测头MLP的初始化出了问题。MLP输出层的权重非常大,导致预测头直接把中心向量映射到一个非常大的范数域,L2归一化之后,所有的输入向量被压到球面上同一个点附近。
修复方案是把预测头输出层的初始化改成零均值、小方差的正态分布(标准差设为0.01),同时把归一化放在MLP的输出层之后,而不是之前。改了之后立刻生效,表示分布开始呈现明显的聚类结构。
这个坑说明:在自监督预训练里,loss曲线只是表象,真正的疗效要看你表示的分布质量。改完初始化之后,我特意又检查了一遍表示均值模长和相似度分布,确认没有偏差才继续往下走。
5.3 翻车现场二:负样本数照搬大模型方案,显存和效果双双崩掉
我在设计负采样数量时,一开始参考了某个大规模对比学习项目的经验,直接设成了1024。结果是显卡内存直接被打爆,把batch size从256降到64才跑起来,但效果居然比64个负样本的版本还差。
后来想明白了:邻居预测任务和对比学习任务对负样本的需求不一样。对比学习的正样本是"同一节点的不同视图",本身就不多,所以需要更多负样本提供结构压力;而GAINT的正样本是"真实存在的邻居",邻居关系是多对多的,一个中心节点可能有几十个正样本,因此模型的监督压力比对比学习充足得多。这种情况下,负样本超过一定数量后,边际收益会变成负值——过多的负样本把表示空间的决策边界推得过于激进,反而损伤了局部邻域的精细关系。
如果要做大图,我建议把负样本数设在64到128之间,同时使用难负样本加权采样来弥补数量上的不足。
5.4 翻车现场三:邻居采样顺序影响结果,但影响是可控的
还有一次让我困惑了很久的现象:同样的代码、同样的参数,只是调整了batch内中心节点的排列顺序,下游F1居然从0.73波动到0.70。一开始以为采样种子问题,反复测了三轮才发现,真正的影响因素是邻居采样器的工作顺序——因为采样器是逐层进行的,如果中心节点在一个batch内排布太集中(比如全部来自某个稠密社区),那么采样到的邻居子图重叠度很高,梯度更新的多样性降低。
解决方式是加一个batch内shuffle:每次迭代对中心节点索引做一次随机置换,同时把采样器的随机种子也一并更换。这样即使多个中心节点在原始图上本来就邻近,它们在batch内部也会被随机打散,子图重叠度维持在合理水平。加了这一步之后,实验结果的方差显著下降。
顺带一提:如果你发现几次实验差距很大,先不要怀疑模型结构,先检查采样和batch的随机性是否到位。图上的自监督训练随机性来源多,必须把种子管理当成正式代码逻辑来写,否则实验结论根本不敢信。
最后想说的
整个GAINT做下来,我最大的感受是:图预训练不需要那么多花哨的模块,关键在于把"预测什么"这个问题想清楚。对比学习在图上之所以难调,是因为它对增强策略太敏感;而邻居预测直接把监督信号挂在"结构本身"上,简单、直接、不容易欺骗模型。当然,GAINT也不是万能的——如果你的图平均度极低,或者节点特征非常鲁棒、标签又足够多,那直接训练GNN就是最优解。预训练的收益,本质上集中在"结构信息量大但标注信号稀薄"的场景里。这套方案我现在已经沉淀成自己处理图项目时的默认前置步骤,先预训练拿表示,再根据标注量决定接法,整个流程稳定了许多。如果你正在小样本图任务里挣扎,不妨试试这个思路,特别是"多尺度邻居预测"这个信号设计,我觉得迁移到很多图数据上都值得一试。