GraphSAGE 核心原理:采样与聚合
讲 GraphSAGE,绕不开两个动作:采样和聚合。我把这叫作 GraphSAGE 的「两条腿」。你想想看,一个图神经网络如果要在工业级图上跑起来,全靠这两条腿走路。我刚开始接触时也困惑过——为什么不能像 GCN 那样一次算完?原因其实很简单。
1. 从邻居采样说起
GCN 做的是全图卷积,每个节点都要聚合所有邻居。小图还行,图一上百万节点,邻接矩阵你根本存不下。这时候 GraphSAGE 站出来了:我不算全部,我只算一部分。
采样的核心思路是:对每个目标节点,固定抽取 S 个邻居。邻居不够?重复抽。邻居多了?随机截断。这样就把每个节点的计算复杂度锁死在 O(S) 上,不再随图规模膨胀。
关键参数:采样数量 S 和采样层数 K 决定了模型的感受野。一层采样看到 1 阶邻居,两层看到 2 阶,K 层看到 K 阶。但层数不是越多越好——我之前有个项目堆到 4 层,结果训练时间翻了 8 倍,效果反而不如 2 层。经验是:K=2 通常够用,K=3 是上限。
采样策略上,GraphSAGE 用的是 均匀随机采样。没有加权,没有偏好,就是纯随机。为什么这么简单?因为实验发现加权策略带来的收益微乎其微,反而增加了实现复杂度。嗯,这里有个教训——
我曾经在一个社交推荐项目里自己加了个「按度数加权采样」,觉得高邻居节点更重要。结果模型收敛变慢,AUC 还没涨。后来复盘发现:采样随机性本身就是一种正则化,破坏了随机性反而过拟合。
2. 聚合函数的艺术
采样完了,邻居特征拿过来了,怎么合并?这就是聚合函数的事了。GraphSAGE 提出了三种聚合方式,各有各的脾气。
| 聚合器 | 核心思想 | 适用场景 |
|---|---|---|
| Mean 聚合器 | 对邻居特征逐元素取均值,再与目标节点拼接 | 通用场景、节点特征分布均匀时 |
| LSTM 聚合器 | 将邻居特征排序后输入 LSTM,取最后状态 | 邻居有顺序依赖或时序关系时 |
| Pooling 聚合器 | 每个邻居特征过 MLP,再取 max-pooling | 邻居特征差异大、需要突出关键邻居时 |
Mean 聚合器是最朴素的做法。把邻居特征求个均值,跟目标节点特征拼起来,过一层全连接。简单,稳定,不出错。我个人的习惯是:初次建模,无脑用 Mean。先跑通 baseline,再考虑换别的。
LSTM 聚合器有意思。它要求邻居有顺序,但图里邻居天然是无序的。所以用之前得先给邻居排个序——按 ID、按度数、按特征值都行。排序本身没有语义,但 LSTM 对顺序敏感,所以它其实是把「排列不变性」这个问题抛给了训练。我建议非必要不用 LSTM 聚合,除非你的数据本身就有时序属性。
Pooling 聚合器是三种里表达能力最强的。每个邻居单独过一个 MLP,然后取最大值。这样做的好处是:不管邻居数量多少,最终都能提取出最重要的信号。我在一个风控项目里用过它——当时欺诈节点的邻居特征很杂,Pooling 能抓到那几个「高危邻居」的信号,效果明显优于 Mean。
补一句:Pooling 聚合器的 MLP 是共享权重的。也就是说所有邻居过同一个 MLP,参数量不会随邻居数增长。这是它实用的关键。
3. 前向传播流程
理解了采样和聚合,前向传播就是把这俩串起来。我习惯把它想成一个「从外向里逐层收窄」的过程。
流程图说明:下方 SVG 展示了 GraphSAGE 的两层前向传播。目标节点先向 1 阶邻居采样,再向 2 阶邻居采样;然后从最外层(2 阶)开始聚合,逐层向内,最终生成目标节点的嵌入。
前向传播的代码实现其实很紧凑。这是两层的伪码——
def forward(graph, features, sample_sizes=[10, 5]):
# 第1层:采样1阶邻居,然后聚合
neighbors_1 = sample(graph, nodes, sample_sizes[0])
h_1 = aggregate(features, neighbors_1, agg_type='mean')
h_1 = ReLU(W_1 @ h_1 + b_1)
# 第2层:采样2阶邻居,然后聚合
neighbors_2 = sample(graph, neighbors_1, sample_sizes[1])
h_2 = aggregate(h_1, neighbors_2, agg_type='mean')
h_2 = ReLU(W_2 @ h_2 + b_2)
return h_2 # 最终节点嵌入
注意顺序:先采样,后聚合。从最外层(2 阶)开始聚合,结果传给内层(1 阶),最后到目标节点。这个顺序不能乱——
我曾经在实现时把聚合顺序写反了,先聚合 1 阶再去采 2 阶,结果 2 阶邻居的 embedding 根本没更新。排查了一天,最后发现是循环里的索引错了。嗯,这种低级错误,犯过一次就记住了。
4. 模型训练目标
GraphSAGE 的训练目标分两派:无监督和有监督。选择哪派,取决于你手里有没有标签。
无监督损失用的是图结构本身作为监督信号。核心思想是:相邻节点的嵌入应该相似,不相邻的节点应该区分开。损失函数长这样——
L = -log(σ(z_v^T z_u)) - Q * log(σ(-z_v^T z_n))
其中 u 是 v 的邻居,n 是随机采样的负样本,Q 是负样本数量。说白了就是:让正样本对距离近,负样本对距离远。我在一个用户画像项目里用过这个方案——没有标签,全靠图结构训练 embedding,最后聚类效果居然还不错。
有监督损失就常规了。节点分类就用交叉熵,链接预测就用 BCE。GraphSAGE 的顶层输出接一个分类器就行。这里有个小细节——
我建议:如果标签稀疏,先用无监督预训练一波,再用少量标签做微调。这种方式在工业场景下特别实用。预训练阶段学到的图结构信息,微调阶段不会丢掉。
训练时的几个坑——
- batch size 别太大:GraphSAGE 每个 batch 都要重新采样子图,batch 太大内存扛不住。我一般设 256~1024。
- 负样本数量 Q 不是越多越好:Q=5 通常就够,再多收益递减。
- 层数 K 和采样数 S 要一起调:K=2 时 S=10~15 效果好;K=3 时 S=5~8 就够了,否则感受野膨胀太厉害。
最后补一句——GraphSAGE 的训练目标和采样聚合是绑定在一起的。你设计损失函数时,心里始终要想着:采样控制了模型看到什么,聚合决定了模型记住什么,损失函数引导了模型学到什么。三者环环相扣,缺一不可。