GraphSAGE 原理:采样邻居与归纳式学习
咱们正式开始啃 GraphSAGE。说实话,我第一次看论文标题的时候还在想:这名字又是 Graph 又是 SAGE,听起来像什么魔法棒?后来做项目调了几个模型才发现,它最大的魔法就藏在两个词里——采样邻居和归纳式学习。
1. 采样邻居:为什么不全拿?
全图邻居计算在工业图上有多贵?我刚开始做推荐系统时,有个用户节点的度超过 10 万。如果每个节点都去聚合所有邻居,一张 1080Ti 显卡瞬间爆显存。所以 GraphSAGE 的做法很粗暴——固定采样。它给每个目标节点随机抽 1 跳、2 跳的邻居,数量固定,比如第一跳 10 个,第二跳 25 个。
为什么要固定大小?你想想看,如果采样数不一致,批处理的时候没法拼成张量。图神经网络的高效训练全靠矩阵运算,形状不对全白搭。我个人习惯把采样数设成 第一跳 5~10,第二跳 15~25,这样既覆盖主要邻居,又不会把内存撑爆。
采样的具体逻辑很简单:对每个节点,如果不带替换抽样,邻居不够就补重复;如果带替换抽满。代码实现就像这样:
def sample_neighbors(nodes, adj_list, sample_size):
sampled = []
for node in nodes:
neighbors = adj_list[node]
if len(neighbors) < sample_size:
# 重复采样
chosen = random.choices(neighbors, k=sample_size)
else:
chosen = random.sample(neighbors, sample_size)
sampled.append(chosen)
return torch.tensor(sampled)
这里有个小细节:采样要在每轮训练时重新做,不是只采样一次。随机性本身就是一种正则化,能防止过拟合。我在一个文档分类项目里试过固定邻居集,AUC 掉了 2%,换成每 epoch 重新采样就回来了。
2. 归纳式学习:从训练集到新节点
传统 GCN 是直推式(Transductive)——训练时整个图都得拿到,新节点出现就要重新训。GraphSAGE 换了个玩法:学一个函数,这个函数输入节点特征和邻居特征,输出节点嵌入。新节点来了,直接拿它的邻居跑一遍函数就行。
说白了,GraphSAGE 学的是「如何聚合」,而不是背「每个节点长什么样」。这带来的好处很明显——
| 特性 | 直推式 (GCN) | 归纳式 (GraphSAGE) |
|---|---|---|
| 泛化到新节点 | 需要重新训练 | 直接推理 |
| 图结构依赖 | 强依赖全图 | 仅依赖局部邻居 |
| 大规模部署 | 困难,全图拉不下来 | 容易,可拆分小 batch |
| 适合场景 | 静态小图 | 动态大规模图 |
我记得之前在社交网络推荐场景里,每天新增上千万用户。如果每次新用户进来都要重训整个模型,那别想上线了。用 GraphSAGE 的归纳式学习,新用户来了直接跑一次聚合函数,嵌入秒出,爽得不行。
3. 聚合函数:一个核心模块
采样完邻居之后,怎么把一堆邻居特征变成一条表示?GraphSAGE 给了三种选择:
- Mean:邻居特征取平均。简单粗暴,适合对称关系。
- LSTM:把邻居打乱顺序丢进 LSTM。我试过在小数据集上效果略好,但训练慢不少。
- Pooling:对每个邻居特征过一层 MLP,再逐元素最大池化。这是我个人用得最多的,效果稳定,速度还快。
如何选?我在项目里一般这么判断:
- 如果邻居节点特征噪声大,Mean 比较容易把噪声平均掉,适合。
- 如果想捕捉邻居的不同模式,用 Pooling 能通过 MLP 做变换,再取最大值保留最强信号。
- 除非数据量不大且对顺序有隐含意义(比如时间序列),否则慎用 LSTM——它计算量太大。
embed_dim设成相同,然后做特征拼接。我的经验是这会带来 3~5% 的提升,代价只是增加一倍参数。
好,我们把采样、聚合、归纳式学习串起来,就是下面这张图的核心流程:
这张图从左到右:先锁定目标节点 v,从它的邻居池里固定抽 K 个;然后把这些邻居的特征喂进聚合函数;最后把聚合结果跟节点自身特征做拼接,过一层非线性,得到更新后的表示。整个过程不依赖全局图,新节点来了照做不误——这就是「归纳」的实质。