当 Netflix 决定下一个推荐时,它不会只看你的观看历史。背后是一张关系网:哪些电影共享演员、哪些用户口味重叠、评分如何在目录里层层传播。“图"在这里不是比喻——每一张交互矩阵 本身就是 一张图,而把它当成图来处理,会打开一片纯 user/item embedding 不能表达的空间。
图神经网络( Graph Neural Networks,GNN) 就是让我们能在这张图上推理的工具。它把"每个用户和物品独立学一个向量"换成一句更简单的话:你的表示由你身边的邻居塑造。 这一个视角的转变催生了 Pinterest 的十亿节点 PinSage、在协同过滤上击败重模型的极简 LightGCN,以及把"你看了什么"和"朋友看了什么"融合的社交推荐系统。
本文按"先建直觉、再看实现"的顺序把这片地图走一遍:从用户-物品二部图出发,搭起消息传递框架,依次解剖 GCN、GAT、GraphSAGE、PinSage、LightGCN、NGCF、社交 GNN,以及让一切在大规模下仍能跑动的采样技巧。每个模型都配可运行的 PyTorch 代码。
你将学到 推荐数据为什么天然是图,以及这能换来什么 三大基础块 GCN、GAT、GraphSAGE 的原理与取舍 工业模型(PinSage、LightGCN、NGCF)如何把 GNN 推上生产 社交信号能带来 5–15% 提升,以及它什么时候反而会拖后腿 让 GNN 从 MovieLens 跑到 Pinterest 规模的小批量邻居采样 用归纳式聚合干净地处理新用户冷启动 前置知识 Python 与 PyTorch 基础(张量、nn.Module、训练循环) 嵌入与协同过滤(第 2 篇
) 矩阵记号;不需要谱图论,文中所有公式都从直觉推起 为什么用图神经网络做推荐? 推荐数据本来就是一张图
任意推荐系统的交互矩阵:行是用户,列是物品,每个非零格表示一次点击/观看/购买/评分。把它重画为 二部图(bipartite graph) :
节点 是用户和物品。边 把用户连向他交互过的每个物品。协同信号 藏在拓扑里:交互过相似物品的用户,会通过这些共享物品被隐式地连在一起。上图中,Alice 和 Bob 素未谋面,但被高亮的边告诉我们他们共享两部电影。一个 GNN 可以沿着多跳传播这个信号:“Bob 喜欢《银翼杀手》;Bob 通过共同电影与 Alice 在图上接近;所以 Alice 也很可能喜欢《银翼杀手》。” 这是矩阵分解永远问不到的问题——它只看见单个格子。
传统方法漏掉了什么 方法 它捕捉了什么 它漏掉了什么 矩阵分解 用户/物品的潜在因子,点积打分 每个嵌入孤立学习;没有"邻居的邻居” 神经协同过滤 / 自编码器 非线性交互 仍然按 (user, item) 对处理,没有沿图传播 GNN 节点嵌入由其 $L$ 跳邻域塑造 (随之而来的扩展性、过平滑——后面会修)
一句话:传统方法孤立地学 embedding;GNN 在上下文中学 embedding。
GNN 带来的能力 显式建模图结构 —— 直接在拓扑上算。邻域聚合 —— 协同信号作为架构的副产品自然传播。多跳推理 —— 叠两层就能表达"喜欢与你喜欢的物品相似的物品的用户"。归纳式(GraphSAGE) —— 不重训也能给新用户/新物品出向量。异构图 —— 把社交关系、知识图谱、属性塞进同一张图。图神经网络基础 图的基本概念 图 $G=(V,E)$ 由顶点集合 $V=\{v_1,\dots,v_n\}$ 和边集合 $E\subseteq V\times V$ 组成。邻接矩阵 $A\in\{0,1\}^{n\times n}$ 满足:$A_{ij}=1$ 当且仅当 $v_i$ 和 $v_j$ 之间有边。
推荐里常见的三种图:
类型 描述 例子 二部图 两类节点,边只跨类 用户 ↔ 物品 加权图 边权代表强度 评分、点击次数 属性图 节点带特征向量 用户画像、物品类目
一张图说清"消息传递"
无论包装多花哨,所有 GNN 都是同一个三步配方的变体:
生成消息(Message)。 每个邻居算一份要发出去的消息。聚合(Aggregate)。 目标节点把所有传入的消息合并(求和、平均、注意力加权……)。更新(Update)。 目标节点把聚合结果与自己上一时刻的状态融合。形式化地,在第 $l$ 层,节点 $v$ 的更新为:
$$\mathbf{m}_v^{(l)} = \mathrm{AGGREGATE}^{(l)}\!\bigl(\{\mathbf{h}_u^{(l-1)} : u\in\mathcal{N}(v)\}\bigr)$$$$\mathbf{h}_v^{(l)} = \mathrm{UPDATE}^{(l)}\!\bigl(\mathbf{h}_v^{(l-1)},\; \mathbf{m}_v^{(l)}\bigr)$$
口语化版本:“看看邻居都知道什么,把它总结一下,再和自己已经知道的拌一拌。” 叠 $L$ 层,每个节点就能看见自己的 $L$ 跳邻域。
电话游戏的类比。 想象一种电话游戏:每个人同时悄悄告诉所有朋友自己知道的事。一轮之后,你知道朋友们的想法;两轮之后,你知道朋友的朋友的想法。GNN 用可学习、可微的函数做的就是同一件事。
图卷积网络(GCN) 核心思想 GCN(Kipf & Welling,2017)在图上定义了一种类卷积操作。CNN 把图像里局部窗口的像素聚合起来;GCN 则把节点图邻域里的特征向量聚合起来。
GCN 层 一层 GCN 计算:
$$\mathbf{H}^{(l+1)} = \sigma\!\Bigl(\tilde{D}^{-1/2}\, \tilde{A}\, \tilde{D}^{-1/2}\, \mathbf{H}^{(l)}\, \mathbf{W}^{(l)}\Bigr)$$
公式看起来沉,逐项拆开就轻了:
符号 含义 $\tilde{A} = A + I$ 加了自环的邻接矩阵(每个节点也"听自己说话") $\tilde{D}$ $\tilde{A}$ 的度矩阵 $\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}$ 对称归一化 —— 防止度大的节点淹没其他人 $\mathbf{H}^{(l)}$ 第 $l$ 层节点特征矩阵(每行是一个节点的嵌入) $\mathbf{W}^{(l)}$ 可学习权重矩阵 $\sigma$ 非线性,通常是 ReLU
对单个节点 $v$ 展开就是:
$$\mathbf{h}_v^{(l+1)} = \sigma\!\left(\sum_{u\in\mathcal{N}(v)\cup\{v\}} \frac{1}{\sqrt{d_v\,d_u}}\; \mathbf{h}_u^{(l)}\, \mathbf{W}^{(l)}\right)$$
收集 所有邻居(包括自己)的嵌入。归一化 :每个邻居的贡献乘以 $1/\sqrt{d_v\cdot d_u}$,热门节点的声音被压低。变换 :乘上共享权重矩阵 $\mathbf{W}$。激活 :ReLU。为什么对称归一化有效? 不归一化时,度高的节点(邻居多)每层都会把数值越拽越大,几层之后梯度爆炸或消失。$\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}$ 让每条边的权重为 $1/\sqrt{d_v d_u}$,热门节点收发的信息都被均衡,对应于图拉普拉斯的谱分解,理论上数值稳定。
实现:GCN 层 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.utils import add_self_loops , degree
class GCNLayer ( nn . Module ):
"""单层图卷积。"""
def __init__ ( self , in_channels , out_channels ):
super () . __init__ ()
self . linear = nn . Linear ( in_channels , out_channels )
def forward ( self , x , edge_index ):
# 1. 加自环,让每个节点也听自己
edge_index , _ = add_self_loops ( edge_index , num_nodes = x . size ( 0 ))
# 2. 对称归一化系数
row , col = edge_index
deg = degree ( col , x . size ( 0 ), dtype = x . dtype )
deg_inv_sqrt = deg . pow ( - 0.5 )
deg_inv_sqrt [ deg_inv_sqrt == float ( 'inf' )] = 0
norm = deg_inv_sqrt [ row ] * deg_inv_sqrt [ col ]
# 3. 线性变换
x = self . linear ( x )
# 4. scatter-add 把归一化后的邻居特征聚合到目标节点
out = torch . zeros_like ( x )
out . scatter_add_ ( 0 , col . unsqueeze ( 1 ) . expand_as ( x ), norm . unsqueeze ( 1 ) * x [ row ])
return out
多层 GCN 叠层会扩大感受野:两层 = 两跳邻域,三层 = 三跳。但要警惕 过平滑(over-smoothing) :层太多时所有节点的嵌入会塌缩到同一个值。推荐场景的甜蜜点是 2~3 层。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class GCN ( nn . Module ):
"""多层 GCN。"""
def __init__ ( self , num_nodes , in_channels , hidden_channels ,
out_channels , num_layers = 2 , dropout = 0.5 ):
super () . __init__ ()
self . num_layers = num_layers
self . dropout = dropout
# 节点没有原始特征时,用 ID 嵌入兜底
self . embedding = None
if in_channels == 0 :
self . embedding = nn . Embedding ( num_nodes , hidden_channels )
in_channels = hidden_channels
self . convs = nn . ModuleList ()
if num_layers == 1 :
self . convs . append ( GCNLayer ( in_channels , out_channels ))
else :
self . convs . append ( GCNLayer ( in_channels , hidden_channels ))
for _ in range ( num_layers - 2 ):
self . convs . append ( GCNLayer ( hidden_channels , hidden_channels ))
self . convs . append ( GCNLayer ( hidden_channels , out_channels ))
def forward ( self , x , edge_index ):
if self . embedding is not None :
x = self . embedding . weight
for i , conv in enumerate ( self . convs ):
x = conv ( x , edge_index )
if i < self . num_layers - 1 :
x = F . relu ( x )
x = F . dropout ( x , p = self . dropout , training = self . training )
return x
图注意力网络(GAT) 为什么需要注意力 GCN 把每个邻居视作(按度归一化后)同等重要。但邻居并不一样——你最好的朋友的电影口味,比一个普通点头之交的更值得参考。GAT(Veličković 等,2018)通过为每条边学习一个 自适应的注意力权重 来解决这件事。
GAT 怎么算 对从 $j$ 到 $i$ 的每条边,GAT 算一个注意力 logit:
$$e_{ij} = \mathrm{LeakyReLU}\!\bigl(\mathbf{a}^\top [\mathbf{W}\mathbf{h}_i \,\|\, \mathbf{W}\mathbf{h}_j]\bigr)$$
其中 $\mathbf{W}$ 把节点特征投到共享空间,$\|$ 是拼接,$\mathbf{a}$ 是可学习的注意力向量。然后用 softmax 在邻居间归一化:
$$\alpha_{ij} = \frac{\exp(e_{ij})}{\sum_{k\in\mathcal{N}(i)} \exp(e_{ik})}$$
新嵌入是加权和:
$$\mathbf{h}_i^{(l+1)} = \sigma\!\left(\sum_{j\in\mathcal{N}(i)\cup\{i\}} \alpha_{ij}\, \mathbf{W}^{(l)}\, \mathbf{h}_j^{(l)}\right)$$
口语化:“对每个邻居,先学一个相关度分数,再用这个分数加权求邻居特征的均值。”
多头注意力 和 Transformer 一样,GAT 用多头来稳定训练。每个头独立算注意力和聚合,中间层做拼接、最后一层做平均:
$$\mathbf{h}_i^{(l+1)} = \big\|_{k=1}^{K}\; \sigma\!\left(\sum_{j\in\mathcal{N}(i)\cup\{i\}} \alpha_{ij}^{(k)}\, \mathbf{W}^{(k)}\, \mathbf{h}_j^{(l)}\right)$$
实现:GAT 层 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops , softmax
class GATLayer ( MessagePassing ):
"""带多头注意力的 GAT 层。"""
def __init__ ( self , in_channels , out_channels , heads = 1 ,
dropout = 0.0 , concat = True ):
super () . __init__ ( aggr = 'add' , node_dim = 0 )
self . in_channels = in_channels
self . out_channels = out_channels
self . heads = heads
self . concat = concat
self . dropout = dropout
self . lin = nn . Linear ( in_channels , heads * out_channels , bias = False )
self . att = nn . Parameter ( torch . empty ( 1 , heads , 2 * out_channels ))
nn . init . xavier_uniform_ ( self . lin . weight )
nn . init . xavier_uniform_ ( self . att )
def forward ( self , x , edge_index ):
edge_index , _ = add_self_loops ( edge_index , num_nodes = x . size ( 0 ))
# 投到多头空间: [N, heads, out_channels]
x = self . lin ( x ) . view ( - 1 , self . heads , self . out_channels )
out = self . propagate ( edge_index , x = x )
return out . view ( - 1 , self . heads * self . out_channels ) if self . concat else out . mean ( dim = 1 )
def message ( self , x_i , x_j , index ):
# x_i 是目标节点, x_j 是源节点
alpha = ( torch . cat ([ x_i , x_j ], dim =- 1 ) * self . att ) . sum ( dim =- 1 )
alpha = F . leaky_relu ( alpha , negative_slope = 0.2 )
alpha = softmax ( alpha , index ) # 按目标节点归一化
alpha = F . dropout ( alpha , p = self . dropout , training = self . training )
return x_j * alpha . unsqueeze ( - 1 )
GraphSAGE:可扩展的归纳式学习 GCN 的痛点 GCN 是 transductive(直推式) 的:训练时需要看见整张图,对训练后才出现的节点没有 embedding。明天注册的新用户,GCN 只能等下一次重训。
GraphSAGE(Hamilton 等,2017)用 inductive learning(归纳式学习) 解决:不再为每个节点死记一个 embedding,而是学 如何聚合邻居 。新节点出现时,对它的邻居跑一遍同样的聚合函数,就能现场算出 embedding。
Sample and Aggregate GraphSAGE 有两个关键想法:
邻居采样。 不用全部邻居(贵),每跳采一个固定数量 $k$。可学习的聚合。 用可训练的函数(mean、LSTM、max-pool)处理采到的邻居。对节点 $v$,第 $l$ 层:
$$\mathbf{h}_{\mathcal{N}(v)}^{(l)} = \mathrm{AGGREGATE}^{(l)}\!\bigl(\{\mathbf{h}_u^{(l-1)} : u\in\mathcal{N}_{\text{sampled}}(v)\}\bigr)$$$$\mathbf{h}_v^{(l)} = \sigma\!\bigl(\mathbf{W}^{(l)}\cdot[\mathbf{h}_v^{(l-1)} \,\|\, \mathbf{h}_{\mathcal{N}(v)}^{(l)}]\bigr)$$
口语化:“采几个邻居,把他们的特征汇总,和自己的拼起来,再过一层线性。”
聚合器 怎么算 Mean 邻居 embedding 平均:$\tfrac{1}{ Max-pool 共享 MLP 后逐元素取最大:$\max(\sigma(\mathbf{W}_{\text{pool}}\mathbf{h}_u + \mathbf{b}))$ LSTM 把邻居当作序列喂给 LSTM(对顺序敏感)
三种聚合方式一览
GCN 给所有邻居(按度归一化后)同等的发言权;GAT 学着把重要的邻居权重调高;GraphSAGE 干脆只看一部分邻居,让计算量在大图上仍可控。
实现:GraphSAGE 层 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops
class SAGEConv ( MessagePassing ):
"""采用均值聚合的 GraphSAGE 层。"""
def __init__ ( self , in_channels , out_channels , normalize = False ):
super () . __init__ ( aggr = 'mean' )
self . normalize = normalize
self . lin_self = nn . Linear ( in_channels , out_channels ) # 自身特征
self . lin_neigh = nn . Linear ( in_channels , out_channels ) # 邻居特征
def forward ( self , x , edge_index ):
edge_index , _ = add_self_loops ( edge_index , num_nodes = x . size ( 0 ))
neigh_agg = self . propagate ( edge_index , x = x )
out = self . lin_self ( x ) + self . lin_neigh ( neigh_agg )
return F . normalize ( out , p = 2 , dim = 1 ) if self . normalize else out
def message ( self , x_j ):
return x_j
能为新节点直接出向量,是 GraphSAGE 在生产中流行的最大原因。Pinterest 的 PinSage 直接是它的后继。
PinSage: Pinterest 的十亿级图推荐 PinSage(Ying 等,2018)是 Pinterest 的生产 GNN,跑在 30 亿节点、180 亿边 的图上。三个创新让它跑得动:
1. 随机游走采样 不再均匀采邻居:对每个节点跑短随机游走,按访问频次取 top-$k$。这样选出的不是"最近"的邻居,而是 最重要 的邻居。
2. 重要性加权聚合 邻居特征按随机游走的访问计数加权:
$$\mathbf{h}_v^{(l)} = \sigma\!\Bigl(\mathbf{W}^{(l)}\cdot \bigl[\mathbf{h}_v^{(l-1)} \,\big\|\, \mathrm{AGG}\bigl(\{\alpha_{uv}\, \mathbf{h}_u^{(l-1)} : u\in\mathcal{N}_{\text{top-}k}(v)\}\bigr)\bigr]\Bigr)$$
3. 困难负样本挖掘 训练时 PinSage 采 hard negatives —— 那些得分高但不是真正正样本的物品,逼模型去做更细的区分,而不是只学会区分明显无关的物品。
实现:PinSage 卷积 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import torch
import torch.nn as nn
import numpy as np
from collections import Counter , defaultdict
class PinSageConv ( nn . Module ):
"""带重要性加权聚合的 PinSage 卷积。"""
def __init__ ( self , in_channels , out_channels ):
super () . __init__ ()
self . aggregator = nn . Sequential (
nn . Linear ( in_channels , out_channels ),
nn . ReLU (),
nn . Linear ( out_channels , out_channels ),
)
self . combine = nn . Linear ( in_channels + out_channels , out_channels )
def forward ( self , x , edge_index , importance_weights = None ):
row , col = edge_index
neighbor_feats = x [ col ]
if importance_weights is not None :
neighbor_feats = neighbor_feats * importance_weights . unsqueeze ( 1 )
aggregated = torch . zeros ( x . size ( 0 ), x . size ( 1 ), device = x . device )
aggregated . scatter_add_ (
0 , row . unsqueeze ( 1 ) . expand_as ( neighbor_feats ), neighbor_feats
)
aggregated = self . aggregator ( aggregated )
return self . combine ( torch . cat ([ x , aggregated ], dim = 1 ))
def compute_random_walk_importance ( adj_list , num_nodes , num_walks = 10 ,
walk_length = 5 , top_k = 10 ):
"""用短随机游走估计邻居重要性。"""
visit_counts = Counter ()
for start in range ( num_nodes ):
if start not in adj_list :
continue
for _ in range ( num_walks ):
current = start
for _ in range ( walk_length ):
neighbors = adj_list . get ( current , [])
if not neighbors :
break
current = np . random . choice ( neighbors )
visit_counts [( start , current )] += 1
per_source = defaultdict ( list )
for ( src , tgt ), cnt in visit_counts . items ():
per_source [ src ] . append (( tgt , cnt ))
scores = {}
for src , neighbors in per_source . items ():
neighbors . sort ( key = lambda t : t [ 1 ], reverse = True )
top = neighbors [: top_k ]
total = sum ( c for _ , c in top )
for tgt , cnt in top :
scores [( src , tgt )] = cnt / total if total > 0 else 0
return scores
LightGCN:少即是多 一个出人意料的发现 LightGCN(He 等,2020)抛了一个挑衅性的问题:“如果我把 GCN 里所有可学习权重、非线性、自环都拿掉,只保留邻居聚合,会怎样?”
答案是:在协同过滤上 效果反而更好 。图结构本身就携带了足够多的信号;标准 GCN 里的特征变换和激活函数其实在 拖后腿 ——它们带来不必要的复杂度和过拟合风险。
架构 LightGCN 用最朴素的聚合:
$$\mathbf{e}_u^{(l+1)} = \sum_{i\in\mathcal{N}(u)} \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\; \mathbf{e}_i^{(l)}$$$$\mathbf{e}_i^{(l+1)} = \sum_{u\in\mathcal{N}(i)} \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\; \mathbf{e}_u^{(l)}$$
口语化:“邻居 embedding 的(按度归一化)平均。没有激活,没有权重矩阵。这就是一层。”
最终 embedding 是各层的组合:
$$\mathbf{e}_u = \sum_{l=0}^{L} \alpha_l\, \mathbf{e}_u^{(l)}, \qquad \mathbf{e}_i = \sum_{l=0}^{L} \alpha_l\, \mathbf{e}_i^{(l)}$$
通常取 $\alpha_l = \tfrac{1}{L+1}$(等权)。第 0 层是原始 embedding,第 1 层是一跳,第 2 层是两跳……组合起来就是一种多尺度表示,不必死磕单一深度。
为什么有效 平滑效果。 聚合让相似节点的 embedding 互相靠近,恰好是协同过滤想要的。层组合。 把各层混合起来,平衡了直接信号(第 0 层)与高阶协同信号(深层),同时缓解过平滑。简洁即正则。 参数少,意味着在稀疏交互数据上更不易过拟合。实现:LightGCN 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import torch
import torch.nn as nn
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import degree
class LightGCNLayer ( MessagePassing ):
"""LightGCN 的一层:归一化邻居聚合,仅此而已。"""
def __init__ ( self ):
super () . __init__ ( aggr = 'add' )
def forward ( self , x , edge_index ):
row , col = edge_index
deg = degree ( col , x . size ( 0 ), dtype = x . dtype )
deg_inv_sqrt = deg . pow ( - 0.5 )
deg_inv_sqrt [ deg_inv_sqrt == float ( 'inf' )] = 0
norm = deg_inv_sqrt [ row ] * deg_inv_sqrt [ col ]
return self . propagate ( edge_index , x = x , norm = norm )
def message ( self , x_j , norm ):
return norm . unsqueeze ( 1 ) * x_j
class LightGCN ( nn . Module ):
"""完整的 LightGCN 协同过滤模型。"""
def __init__ ( self , num_users , num_items , embedding_dim = 64 , num_layers = 3 ):
super () . __init__ ()
self . num_users = num_users
self . num_items = num_items
self . num_layers = num_layers
self . user_embedding = nn . Embedding ( num_users , embedding_dim )
self . item_embedding = nn . Embedding ( num_items , embedding_dim )
nn . init . normal_ ( self . user_embedding . weight , std = 0.1 )
nn . init . normal_ ( self . item_embedding . weight , std = 0.1 )
self . convs = nn . ModuleList ([ LightGCNLayer () for _ in range ( num_layers )])
def forward ( self , edge_index ):
x = torch . cat ([ self . user_embedding . weight , self . item_embedding . weight ], dim = 0 )
layer_embeddings = [ x ] # 第 0 层 = 原始 embedding
for conv in self . convs :
x = conv ( x , edge_index )
layer_embeddings . append ( x )
# 各层等权组合
final = torch . stack ( layer_embeddings , dim = 0 ) . mean ( dim = 0 )
return final [: self . num_users ], final [ self . num_users :]
def predict ( self , user_emb , item_emb , user_ids , item_ids ):
return ( user_emb [ user_ids ] * item_emb [ item_ids ]) . sum ( dim = 1 )
NGCF:神经图协同过滤 与 LightGCN 的取舍 NGCF(Wang 等,2019)走的是另一条路:在消息传递里显式做特征变换和用户-物品交互项,能学到更丰富的 embedding。LightGCN 在做减法,NGCF 在做加法。
消息构造 每层的消息包含一个 特征交互 项:
$$\mathbf{m}_{u\leftarrow i} = \frac{1}{\sqrt{|\mathcal{N}(u)|\cdot|\mathcal{N}(i)|}}\;\bigl(\mathbf{W}_1\, \mathbf{e}_i^{(l)} + \mathbf{W}_2\, (\mathbf{e}_i^{(l)} \odot \mathbf{e}_u^{(l)})\bigr)$$
其中 $\odot$(逐元素乘)刻画用户与物品在每个维度上的"对得上不对得上"。如果用户在"动作片"维度上分高,物品在同一维度上也分高,相乘就会放大这个信号。
聚合后:
$$\mathbf{e}_u^{(l+1)} = \mathrm{LeakyReLU}\!\bigl(\mathbf{m}_{u\leftarrow u} + \sum_{i\in\mathcal{N}(u)} \mathbf{m}_{u\leftarrow i}\bigr)$$
实现:NGCF 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import degree
class NGCFLayer ( MessagePassing ):
"""带特征变换与交互的 NGCF 层。"""
def __init__ ( self , in_channels , out_channels , dropout = 0.0 ):
super () . __init__ ( aggr = 'add' )
self . W1 = nn . Linear ( in_channels , out_channels , bias = False )
self . W2 = nn . Linear ( in_channels , out_channels , bias = False )
self . W_self = nn . Linear ( in_channels , out_channels , bias = False )
self . dropout = dropout
def forward ( self , x , edge_index ):
row , col = edge_index
deg = degree ( col , x . size ( 0 ), dtype = x . dtype )
deg_inv_sqrt = deg . pow ( - 0.5 )
deg_inv_sqrt [ deg_inv_sqrt == float ( 'inf' )] = 0
norm = deg_inv_sqrt [ row ] * deg_inv_sqrt [ col ]
self_emb = self . W_self ( x )
neighbor_emb = self . propagate ( edge_index , x = x , norm = norm )
out = F . leaky_relu ( self_emb + neighbor_emb , negative_slope = 0.2 )
return F . dropout ( out , p = self . dropout , training = self . training )
def message ( self , x_i , x_j , norm ):
# W1 * 邻居 + W2 * (用户 . 物品) —— 特征交互
msg = self . W1 ( x_j ) + self . W2 ( x_i * x_j )
return norm . unsqueeze ( 1 ) * msg
class NGCF ( nn . Module ):
"""神经图协同过滤。"""
def __init__ ( self , num_users , num_items , embedding_dim = 64 ,
num_layers = 3 , dropout = 0.1 ):
super () . __init__ ()
self . num_users = num_users
self . num_items = num_items
self . user_embedding = nn . Embedding ( num_users , embedding_dim )
self . item_embedding = nn . Embedding ( num_items , embedding_dim )
nn . init . normal_ ( self . user_embedding . weight , std = 0.1 )
nn . init . normal_ ( self . item_embedding . weight , std = 0.1 )
self . convs = nn . ModuleList ([
NGCFLayer ( embedding_dim , embedding_dim , dropout )
for _ in range ( num_layers )
])
def forward ( self , edge_index ):
x = torch . cat ([ self . user_embedding . weight , self . item_embedding . weight ], dim = 0 )
for conv in self . convs :
x = conv ( x , edge_index )
return x [: self . num_users ], x [ self . num_users :]
LightGCN vs NGCF:怎么选? LightGCN NGCF 参数量 仅 embedding embedding + 权重矩阵 效果 稀疏数据上常更优 特征丰富时更优 过拟合风险 低 较高(参数多) 训练速度 快 慢一些 推荐用于 大多数 CF 任务 特征密集的任务
默认选 LightGCN。 只有在用户/物品特征足够丰富、数据量也大到能撑起额外参数时,再考虑 NGCF。
为什么要叠层?多跳聚合
把跳数画出来,“为什么推荐要叠层"就一目了然了:
第 1 层 —— 目标用户从交互过的物品收集特征。第 2 层 —— 信号到达相似用户(这就是经典协同过滤的那一跳)。第 3 层 —— 信号到达相似用户喜欢的物品:候选推荐物。两层就足以表达"喜欢相似物品的用户"这种矩阵分解写不出来的模式。三层在稀疏图上偶尔有用,但接近过平滑的边缘——若想要更深,就交给 LightGCN 风格的"层组合”。
社交推荐 基本想法 朋友影响你的购买、观影、听歌选择。社交推荐在用户-物品图上加上 社交边 (好友、关注、信任)。底层赌注:社交相连的用户口味相似。
图结构 社交推荐图有两类边:
交互边 $(u_i, i_j)$:用户 $u_i$ 与物品 $i_j$ 有交互。社交边 $(u_i, u_j)$:用户 $u_i$ 与 $u_j$ 是好友。两种机制 同质性(homophily)。 朋友口味相似 是因为 他们是朋友(共享背景、文化)。
社会影响(social influence)。 朋友 会变得 越来越相似,因为他们彼此影响。
对模型来说两者结论一样:沿着社交边传播偏好。
实现:Social GCN 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
class SocialGCNLayer ( MessagePassing ):
"""同时处理用户-物品边与社交边的 GCN 层。"""
def __init__ ( self , in_channels , out_channels ):
super () . __init__ ( aggr = 'mean' )
self . linear = nn . Linear ( in_channels , out_channels )
def forward ( self , x , edge_index_ui , edge_index_social , num_users ):
x_ui = self . propagate ( edge_index_ui , x = x )
x_social = self . propagate ( edge_index_social , x = x [: num_users ])
x_combined = x_ui . clone ()
x_combined [: num_users ] = x_combined [: num_users ] + x_social
return self . linear ( x_combined )
class SocialGCN ( nn . Module ):
"""基于 GCN 的社交推荐。"""
def __init__ ( self , num_users , num_items , embedding_dim = 64 , num_layers = 2 ):
super () . __init__ ()
self . num_users = num_users
self . user_embedding = nn . Embedding ( num_users , embedding_dim )
self . item_embedding = nn . Embedding ( num_items , embedding_dim )
self . convs = nn . ModuleList ([
SocialGCNLayer ( embedding_dim , embedding_dim )
for _ in range ( num_layers )
])
def forward ( self , edge_index_ui , edge_index_social ):
x = torch . cat ([ self . user_embedding . weight , self . item_embedding . weight ], dim = 0 )
for conv in self . convs :
x = conv ( x , edge_index_ui , edge_index_social , self . num_users )
x = F . relu ( x )
return x [: self . num_users ], x [ self . num_users :]
实证研究里,当社交关系本身有信息量时,NDCG 普遍能拿到 5–15% 的提升。失败模式是:噪声大的社交关系(一堆"互不熟"的关注列表)反而会拖后腿。常用对策是用注意力把无关社交边的权重压低,或者在训练前用交互重叠预过滤社交图。
大规模图采样 问题 全批量训练意味着每次 forward 都要把 整张图 装进显存、给 所有 节点算 embedding。MovieLens(10 万次交互)没问题;Pinterest(180 亿条边)做不到。
采样策略 策略 怎么做 例子 邻居采样 每层为每个节点采固定数量邻居 GraphSAGE:第一跳 10 个,第二跳 5 个 节点采样 采一批节点,用它们的邻域 FastGCN 子图采样 采连通子图 Cluster-GCN、GraphSAINT 随机游走采样 用随机游走找重要邻居 PinSage
邻居采样是工作马:把每个节点的感受野卡住,显存可预测;每个 epoch 重新采样还顺带做了方差削减。
实现:邻居采样器 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import numpy as np
import torch
class NeighborSampler :
"""GraphSAGE 风格的邻居采样器,用于小批量训练。"""
def __init__ ( self , edge_index , num_nodes , num_neighbors = ( 10 , 5 )):
"""num_neighbors: (一跳采样数, 二跳采样数, …)"""
self . num_nodes = num_nodes
self . num_neighbors = num_neighbors
self . adj = { i : [] for i in range ( num_nodes )}
row , col = edge_index . cpu () . numpy ()
for r , c in zip ( row , col ):
self . adj [ r ] . append ( c )
def sample ( self , target_nodes ):
"""为目标节点采样多跳邻域。"""
current_nodes = set ( target_nodes . tolist ())
all_layers = []
for num_nbrs in self . num_neighbors :
edges , next_nodes = [], set ()
for node in current_nodes :
neighbors = self . adj [ node ]
if not neighbors :
continue
if len ( neighbors ) > num_nbrs :
sampled = np . random . choice (
neighbors , num_nbrs , replace = False
) . tolist ()
else :
sampled = neighbors
for nbr in sampled :
edges . append ([ nbr , node ])
next_nodes . add ( nbr )
if edges :
all_layers . append ( torch . tensor ( edges ) . t () . contiguous ())
else :
all_layers . append ( torch . empty (( 2 , 0 ), dtype = torch . long ))
current_nodes = next_nodes
return all_layers
训练技巧 BPR 损失(贝叶斯个性化排序) 隐式反馈推荐的默认损失:
$$\mathcal{L}_{\text{BPR}} = -\sum_{(u,i,j)} \ln\, \sigma(\hat{r}_{ui} - \hat{r}_{uj}) + \lambda \|\Theta\|^2$$
口语化:“对每个用户 $u$,让正样本 $i$(交互过)的得分比负样本 $j$(没交互过)的得分高。sigmoid 加 log 把它变成一个光滑可微的目标。”
$(u,i,j)$:(用户, 正物品, 负物品) 三元组 $\hat{r}_{ui} = \mathbf{e}_u^\top \mathbf{e}_i$:点积得分 $\lambda \|\Theta\|^2$:L2 正则 负采样策略 策略 描述 适用 均匀采样 随机选未交互物品 基线 基于流行度 按流行度比例采样 解长尾问题 困难负样本 高分负样本 训练后期精修
训练循环 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import torch
import torch.nn as nn
import torch.optim as optim
class BPRLoss ( nn . Module ):
"""带 L2 正则的 BPR 损失。"""
def __init__ ( self , reg_lambda = 1e-4 ):
super () . __init__ ()
self . reg_lambda = reg_lambda
def forward ( self , pos_scores , neg_scores , * embeddings ):
bpr = - torch . log ( torch . sigmoid ( pos_scores - neg_scores ) + 1e-10 ) . mean ()
reg = self . reg_lambda * sum ( emb . norm ( 2 ) . pow ( 2 ) for emb in embeddings )
return bpr + reg
def train_lightgcn ( model , edge_index , train_pairs , num_items ,
num_epochs = 100 , lr = 0.001 , batch_size = 1024 ):
"""训练 LightGCN 。 train_pairs: [user, pos_item] 张量。"""
optimizer = optim . Adam ( model . parameters (), lr = lr )
criterion = BPRLoss ()
for epoch in range ( num_epochs ):
model . train ()
perm = torch . randperm ( len ( train_pairs ))
total_loss , num_batches = 0.0 , 0
for start in range ( 0 , len ( train_pairs ), batch_size ):
batch = train_pairs [ perm [ start : start + batch_size ]]
user_ids , pos_items = batch [:, 0 ], batch [:, 1 ]
neg_items = torch . randint ( 0 , num_items , ( len ( batch ),))
optimizer . zero_grad ()
user_emb , item_emb = model ( edge_index )
pos_scores = ( user_emb [ user_ids ] * item_emb [ pos_items ]) . sum ( 1 )
neg_scores = ( user_emb [ user_ids ] * item_emb [ neg_items ]) . sum ( 1 )
loss = criterion ( pos_scores , neg_scores ,
user_emb [ user_ids ],
item_emb [ pos_items ],
item_emb [ neg_items ])
loss . backward ()
optimizer . step ()
total_loss += loss . item ()
num_batches += 1
if ( epoch + 1 ) % 10 == 0 :
print ( f "Epoch { epoch + 1 } / { num_epochs } , Loss: { total_loss / num_batches : .4f } " )
评估指标 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import numpy as np
import torch
def evaluate_topk ( model , edge_index , test_user_items , train_user_items , k = 10 ):
"""计算 Recall@K 和 NDCG@K 。"""
model . eval ()
recalls , ndcgs = [], []
with torch . no_grad ():
user_emb , item_emb = model ( edge_index )
for user_id , test_items in test_user_items . items ():
scores = ( user_emb [ user_id ] * item_emb ) . sum ( dim = 1 )
# 屏蔽训练集物品
train_items = train_user_items . get ( user_id , [])
scores [ train_items ] = - float ( 'inf' )
_ , top_k = torch . topk ( scores , k )
top_k = set ( top_k . cpu () . numpy ())
hits = len ( top_k & set ( test_items ))
recalls . append ( hits / len ( test_items ) if test_items else 0 )
# NDCG:奖励排位靠前的命中
dcg = sum ( 1.0 / np . log2 ( idx + 2 )
for idx , item in enumerate ( top_k ) if item in test_items )
idcg = sum ( 1.0 / np . log2 ( idx + 2 )
for idx in range ( min ( k , len ( test_items ))))
ndcgs . append ( dcg / idcg if idcg > 0 else 0 )
return np . mean ( recalls ), np . mean ( ndcgs )
冷启动:图模型的高光时刻
一个新用户出现时只有一次交互。矩阵分解帮不上忙——它没有这个用户的行,要加就得重训。而像 GraphSAGE 这样的归纳式 GNN 直接对新用户的邻居跑一遍同样的聚合函数:哪怕只有一次交互,也能拿到一个有意义的 embedding,因为聚合把这件物品的其他爱好者的信息全部带了进来。
工程上处理冷启动用户的实用配方:
归纳式聚合。 用 GraphSAGE/PinSage 这类模型,让任何至少有一个邻居的节点都自动获得 embedding。特征初始化。 用画像特征(人口属性、设备、注册渠道)给冷启动节点一个起点 embedding,再让聚合接管。相似用户均值兜底。 在交互足够多之前,把新用户的 embedding 与"同画像用户均值"做混合。元学习(MAML)。 如果资源允许,让模型本身就具备"几步交互即可适应新用户"的能力。完整示例:在类 MovieLens 数据上跑 LightGCN 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import torch
import numpy as np
from sklearn.model_selection import train_test_split
# --- 1. 合成类 MovieLens 数据 ---
num_users , num_items = 1000 , 2000
num_interactions = 10000
users = np . random . randint ( 0 , num_users , num_interactions )
items = np . random . randint ( 0 , num_items , num_interactions )
train_u , test_u , train_i , test_i = train_test_split (
users , items , test_size = 0.2 , random_state = 42
)
# 二部图边索引(无向)
edge_src = np . concatenate ([ train_u , train_i + num_users ])
edge_dst = np . concatenate ([ train_i + num_users , train_u ])
edge_index = torch . tensor ([ edge_src , edge_dst ], dtype = torch . long )
# --- 2. 训练 LightGCN ---
model = LightGCN ( num_users , num_items , embedding_dim = 64 , num_layers = 3 )
optimizer = torch . optim . Adam ( model . parameters (), lr = 0.001 )
criterion = BPRLoss ()
train_pairs = torch . tensor ( np . stack ([ train_u , train_i ], axis = 1 ), dtype = torch . long )
for epoch in range ( 50 ):
model . train ()
perm = torch . randperm ( len ( train_pairs ))
for start in range ( 0 , len ( train_pairs ), 512 ):
batch = train_pairs [ perm [ start : start + 512 ]]
user_ids , pos_items = batch [:, 0 ], batch [:, 1 ]
neg_items = torch . randint ( 0 , num_items , ( len ( batch ),))
optimizer . zero_grad ()
user_emb , item_emb = model ( edge_index )
pos_scores = ( user_emb [ user_ids ] * item_emb [ pos_items ]) . sum ( 1 )
neg_scores = ( user_emb [ user_ids ] * item_emb [ neg_items ]) . sum ( 1 )
loss = criterion ( pos_scores , neg_scores ,
user_emb [ user_ids ],
item_emb [ pos_items ],
item_emb [ neg_items ])
loss . backward ()
optimizer . step ()
if ( epoch + 1 ) % 10 == 0 :
print ( f "Epoch { epoch + 1 } , Loss: { loss . item () : .4f } " )
print ( "训练完成!" )
常见问题 GNN 相对矩阵分解到底强在哪? 矩阵分解每个 embedding 是孤立学习的——它从不问"我的邻居长什么样"。GNN 显式地让协同信号在图上传播,所以"通过共享物品相连的相似用户"会自然地得到相似的 embedding,这是架构本身的副产品。多层 GNN 还能捕捉"喜欢与你喜欢相似物品的用户喜欢的物品"这种高阶模式,单层点积写不出来。
GCN vs GAT vs GraphSAGE,什么场景用哪个? GCN GAT GraphSAGE 强项 简单高效 自适应邻居加权 可扩展,能处理新节点 弱项 需要全图,权重固定 大邻域时贵 采样带来方差 最适合 中小型静态图 异构边 大规模、动态图
经验法则:协同过滤先选 LightGCN(简化版 GCN);要归纳式学习用 GraphSAGE;不同邻居重要性差异大用 GAT。
GNN 该叠几层? 推荐场景的标准答案是 2~3 层:
1 层 —— 只看直接邻居,缺协同模式。2 层 —— 朋友的朋友,大多数任务的甜蜜点。3 层 —— 三跳模式,提升小且要警惕过平滑。4 层及以上 —— 过平滑主导,embedding 区分度坍塌。想要"既深又不塌",用 LightGCN 风格的层组合。
怎么处理 GNN 里的冷启动用户? 归纳式聚合。 任何至少有一个邻居的节点都能直接出 embedding。特征初始化。 用人口学/设备等画像特征,或同画像用户的均值,作为 embedding 起点。元学习。 用 MAML 思路,让模型从少量交互里快速适配新用户。混合模型。 GNN embedding 与基于内容的特征结合,让冷节点也有有用的表示。可参考上方的冷启动示意图。
计算成本怎么算? 模型 前向 显存 GCN / LightGCN $O(L\cdot E GAT $O(L\cdot E GraphSAGE(采样) $O(L\cdot V
其中 $L$ 层数、$|E|$ 边数、$d$ embedding 维度、$H$ 注意力头数、$k$ 采样邻居数。十亿级边规模下,采样(GraphSAGE/PinSage)或子图划分(Cluster-GCN)是必选项。
社交推荐一定提升吗? 只在社交关系真的反映共同偏好时才提升。实证研究里能拿到 5–15% 的 NDCG 提升;但噪声社交(“互不熟"的关注列表)反而会拖后腿。常用做法是用注意力压低无关边的权重,或在训练前按交互重叠预过滤社交图。
GNN 能处理动态图吗? 可以,需要适配:
时间感知聚合 —— 给最近的邻居更高的权重。时间编码 —— 在边上加时间嵌入。增量更新 —— 在新边上微调,而不是从头重训。专用时序 GNN —— 比如 TGN,原生处理流式图。1
2
3
4
5
6
def temporal_aggregate ( neighbor_embs , timestamps , current_time , decay_rate = 0.1 ):
"""按时近度给邻居加权。"""
time_diffs = current_time - timestamps
weights = torch . exp ( - decay_rate * time_diffs )
weights = weights / weights . sum ()
return ( neighbor_embs * weights . unsqueeze ( 1 )) . sum ( dim = 0 )
怎么防止过平滑? 限制深度 在 2~3 层。层组合 (LightGCN 风格) —— 各层等权,最终表示里仍保留原始 embedding。残差连接 —— $\mathbf{h}^{(l+1)} = \mathbf{h}^{(l)} + \mathrm{GNN}(\mathbf{h}^{(l)})$。边 dropout —— 训练时随机丢边,降低对结构的过度依赖。关键要点 推荐数据天然就是图。 GNN 通过邻域聚合让协同信号沿图传播,这是矩阵分解做不到的。LightGCN 证明简洁就是赢。 把 GCN 剥到只剩聚合,反而在协同过滤上常常打败更重的架构。GraphSAGE 让归纳式学习成为可能。 在用户/物品不停变动的生产环境里至关重要——也是冷启动最干净的路径。PinSage 证明十亿级图可行。 随机游走采样 + 重要性加权让 Pinterest 规模的 GNN 跑得起来。社交信号能带来 5–15% 提升 —— 但只有当社交关系真的反映共同偏好时。2~3 层是甜蜜点。 更深风险过平滑;用层组合既要深度又避坍塌。系列导航
本文是推荐系统系列的第 7 篇 ,共 16 篇。