简介:面向推荐系统研究者和机器学习开发者的PyTorch实现资源,复现SIGIR'19论文《神经图协同过滤》(NGCF)。该实现以原版TensorFlow代码为基础,提供与官方一致的评估指标,支持Gowalla、Amazon-book等数据集训练与测试,适合希望将图神经网络应用于协同过滤场景、进行对比实验或二次开发的读者。压缩包共21个文件,包括8个Python源码、8个文本说明、4个Markdown文档及1个README,整体约11.71MB,目录结构包含NGCF主模块、工具函数及数据目录。Python脚本覆盖模型构建、训练与评估流程,Markdown文档提供使用说明与目录导览,文本文件用于存储配置及运行记录。资源已有1888人学习浏览,说明在推荐系统社区具有一定参考价值。下载后可获得可直接运行的PyTorch版本NGCF模型,以及基于Gowalla数据集的示例评测结果(如Recall、Precision、Hit、NDCG等指标),便于复现论文性能并在此基础上调整模型结构。
1. 神经图协同过滤的 PyTorch 实现:为什么传统协同过滤丢了图结构
你如果只把用户和物品各学一个 ID 嵌入,再拿内积做召回,那你就丢掉了交互数据里最值钱的结构信息——用户和物品之间的高阶连通性。神经图协同过滤(NGCF)的核心思路,是把 user-item 交互矩阵显式建成二部图,用图神经网络的消息传播机制让每个节点的嵌入反复吸收邻居信息,从而把"谁和谁间接相关"编码进向量里。这份资源就是 NGCF 的 PyTorch 工程实现,适合已经跑通过基础推荐模型、想把手伸向图神经网络的从业者,也适合做 graph-algorithms 的人看推荐系统怎么落地。
2. NGCF 的核心原理:消息传播与嵌入聚合
2.1 从协同过滤到图协同过滤:为什么 ID 嵌入不够
传统矩阵分解把用户 u 和物品 i 映射成两个向量 e_u、e_i,预测分数就是内积。问题在于这个嵌入是"孤立"学出来的——每个节点只对自己的交互历史负责,建模不了用户和物品之间的传递关系。
举个具体例子:用户 u 买过物品 i1,物品 i1 被用户 v 买过,用户 v 又买过物品 i2。传统 MF 里,u 和 i2 在嵌入空间里没有任何直接联系,除非训练数据里恰好有 u-i2 的交互。但二部图结构告诉我们的信息是:u 和 i2 之间存在一条长度为 3 的路径,这种路径在社交推荐、电商召回里非常常见。NGCF 要做的就是把这种多跳关系显式建模出来。
NGCF 的做法不是把路径特征提出来拼进特征工程,而是把这个"消息传递"过程直接做成神经网络的一个层。每一层做的事情是:每个节点收集邻居传来的信息,和自己原来的嵌入融合,得到新嵌入。堆 L 层,就能让信息沿着二部图传播 L 跳。
2.2 一阶消息传播:贡献度归一化与消息组装
NGCF 的单层传播公式(以用户侧为例)长这样:
e_u^(l+1) = LeakyReLU( W1 · e_u^(l) + Σ_{i ∈ N(u)} 1/√(|N(u)||N(i)|) · ( W2 · e_i^(l) + W2 · (e_i^(l) ⊙ e_u^(l)) ) )
拆开看有三个关键点。第一,消息来自邻居集合 N(u),也就是用户 u 交互过的所有物品。第二,每条消息的权重不是可学习参数,而是归一化系数 1/√(deg(u)·deg(i)),度大的节点贡献会被压低,这跟 GraphSAGE 里用的平均聚合不一样,它同时考虑了发送方和接收方的度。第三,消息内容由两部分组成:邻居本身的嵌入 e_i,以及邻居嵌入和自身嵌入的逐元素乘积 e_i ⊙ e_u。这个乘积项是 NGCF 区别于 LightGCN 的关键,它建模了"用户和物品在某个特征维度上的交互强度",信息量比单纯传邻居嵌入更丰富。
对应代码实现的时候,可以把 Σ 操作统一成矩阵乘法。设邻接矩阵 A 是 (N+M)×(N+M) 的二部图邻接矩阵,D 是对角度矩阵,归一化邻接矩阵 L = D^(-1/2) A D^(-1/2)。那么所有节点的一阶邻居聚合可以一次性算完:
E^(l+1) = LeakyReLU( (L + I) E^(l) W1 + L E^(l) ⊙ E^(l) W2 )
这里 ⊙ 是逐元素乘法,L E^(l) 算的是邻居嵌入求和,再和 E^(l) 逐元素乘,等价于对每个节点算 Σ (e_i ⊙ e_u)。
2.3 多层聚合与矩阵化传播:层数怎么影响感受野
堆两层意味着什么?第一层每个节点拿到直接邻居的信息,第二层每个节点再拿一次邻居的信息——但此时邻居的嵌入已经包含它自己邻居的信息了。所以第二层输出的嵌入里,实际包含了原始二部图上距离为 2 的节点信息。推广一下,L 层传播对应 L 跳感受野。
工程实现上有个细节:每次传播都要乘归一化邻接矩阵,如果直接拿 PyTorch 的 dense 矩阵做乘法,内存开销是 O((N+M)^2),一万用户一万物品就直接爆显存。常规做法是把归一化邻接矩阵存成稀疏张量,用 torch.sparse.mm 做稀疏矩阵乘法,这一步能省掉 90% 以上的内存。
层数也不是越多越好。我的经验是 3 层是个甜点值:1 层欠拟合,2 层够用,3 层在多数数据集上指标最好,4 层以上开始出现过平滑——所有节点的嵌入趋于一致,Recall@K 反而下降。NGCF 原论文里用的也是 3 层。
3. 工程实现:数据构建、NGCF 层与 BPR 采样
3.1 数据流与二部图构建:从 rating 文件到归一化邻接矩阵
拿到一份 rating 数据,第一步不是建模型,而是建图。常见的数据格式是每行一个交互记录,包含用户 ID、物品 ID、评分、时间戳。NGCF 只关心"有没有交互",不关心评分值,所以先把数据过滤成隐式反馈。
import numpy as np import scipy.sparse as sp import torch def load_ratings(path): """读取 rating 文件,返回交互列表和用户/物品数。""" ratings = [] users_set, items_set = set(), set() with open(path, "r") as f: for line in f: parts = line.strip().split() u, i = int(parts[0]), int(parts[1]) users_set.add(u) items_set.add(i) ratings.append((u, i)) return ratings, users_set, items_set def build_adjacency(ratings, num_users, num_items): """构建 user-item 二部图邻接矩阵,物品节点偏移到 [num_users, num_users+num_items) 区间。""" rows = [u for u, _ in ratings] cols = [i + num_users for _, i in ratings] # 加上反向边,让用户和物品互传消息 rows = rows + cols cols = cols + [u for u, _ in ratings] data = [1.0] * len(rows) adj = sp.coo_matrix((data, (rows, cols)), shape=(num_users + num_items, num_users + num_items)) return adj.tocsr() def normalize_adj(adj): """D^{-1/2} A D^{-1/2} 对称归一化。""" deg = np.array(adj.sum(axis=1)).flatten() d_inv_sqrt = np.power(deg, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0 d_mat = sp.diags(d_inv_sqrt) return (d_mat @ adj @ d_mat).tocoo()逻辑说明:物品节点的索引统一加了 num_users 偏移,这样用户节点落在 [0, num_users),物品节点落在 [num_users, num_users+num_items),共享同一个嵌入矩阵,索引直接对齐。归一化系数 d_inv_sqrt 里出现 inf 是因为有些节点度为 0,比如冷启动用户没有任何交互记录,这类节点的度是 0,倒数就是无穷大,必须替换成 0,否则后面稀疏矩阵乘法会得到 NaN。
参数说明:邻接矩阵 shape 是 (N+M, N+M),对二部图来说左上和右下两个块永远是零,因为用户不跟用户相连、物品不跟物品相连,这是正常的。如果你看到左上块有非零值,说明数据里有用户-用户关系字段,那就要换一种构图方式了。
3.2 NGCF 层的 PyTorch 实现:消息组装与逐元素交互
NGCF 层的实现核心是两个线性变换加一个交互项。为了效率,我把归一化邻接矩阵转成 PyTorch 稀疏张量,整个前向过程只需要做两次稀疏矩阵乘法和一次逐元素乘法。
import torch.nn as nn import torch.nn.functional as F def to_torch_sparse(coo_adj): """scipy 稀疏矩阵转 PyTorch 稀疏张量。""" coo = coo_adj.tocoo().astype(np.float32) idx = torch.LongTensor(np.vstack([coo.row, coo.col])) val = torch.FloatTensor(coo.data) return torch.sparse_coo_tensor( idx, val, torch.Size(coo.shape) ).coalesce() class NGCFLayer(nn.Module): def __init__(self, in_dim, out_dim, dropout=0.1): super().__init__() self.w1 = nn.Linear(in_dim, out_dim, bias=False) self.w2 = nn.Linear(in_dim, out_dim, bias=False) self.dropout = nn.Dropout(dropout) def forward(self, emb, norm_adj_sparse): # emb: (N+M, in_dim),norm_adj_sparse: 稀疏归一化邻接矩阵 neigh_emb = torch.sparse.mm(norm_adj_sparse, emb) # 聚合邻居嵌入 interaction = neigh_emb * emb # 逐元素交互项 out = self.w1(emb) + self.w2(neigh_emb) + self.w2(interaction) out = self.dropout(F.leaky_relu(out, negative_slope=0.2)) return out逻辑说明:neigh_emb 是 L E^(l),也就是每个节点所有邻居嵌入的加权和,权重由归一化邻接矩阵提供。interaction 是 L E^(l) ⊙ E^(l),对应公式里的逐元素乘积项。注意这里的*不是矩阵乘,是逐元素乘,PyTorch 里对两个同 shape 张量用*就是 Hadamard 积。
参数说明:w1 作用在目标节点自己的嵌入上,w2 同时作用在邻居嵌入和交互项上。论文里两个线性层是独立初始化的,dropout 我习惯放在激活之后,因为 LeakyReLU 对负半轴的梯度很小,先激活再 dropout 会把梯度置零的比例控制得更稳定。negative_slope=0.2 是 LeakyReLU 的常规取值。
3.3 BPR 损失与采样器:如何构造正负样本对
NGCF 的优化目标用的是贝叶斯个性化排序(BPR)损失,核心思想是:用户 u 对交互过的正样本 i 的偏好分数,要高于未交互的负样本 j。损失函数对每个三元组 (u, i, j) 计算:
L = -ln σ( e_u^T e_i - e_u^T e_j ) + λ ||E||²
采样环节的朴素实现长这样:
def sample_batch(train_csr, num_users, num_items, batch_size): """BPR 三元组采样:每个用户抽 1 个正样本、1 个负样本。""" users = np.random.choice(num_users, batch_size, replace=True) pos_items = [] neg_items = [] for u in users: # 正样本:从该用户交互过的物品里随机挑一个 interact = train_csr.getrow(u).indices if len(interact) == 0: pos = np.random.randint(num_items) else: pos = np.random.choice(interact) # 负样本:拒绝采样,保证不是该用户交互过的物品 while True: neg = np.random.randint(num_items) if train_csr[u, neg] == 0: break pos_items.append(pos) neg_items.append(neg) return users, np.array(pos_items), np.array(neg_items)逻辑说明:正样本通过 train_csr.getrow(u).indices 直接取该用户的交互物品列表,这是 CSR 格式的优势,取一行不需要遍历全矩阵。负样本用拒绝采样,最坏情况下如果某个用户交互了几百个物品、总物品数只有几千,拒绝采样的效率会明显下降,可以考虑换成"先采样一批再过滤"的批量做法。
参数说明:batch_size 取 512 到 4096 之间比较常见。采样时 replace=True 允许同一个用户在一个 batch 里出现多次,这没有问题,BPR 是逐样本更新的,重复用户只是多贡献了几个梯度。注意这里暂时没有做难负样本挖掘,NGCF 这类图模型对难负样本比较敏感,如果想提点,后面可以换成按嵌入相似度采样。
4. 训练配置与评测:超参数、主循环、Recall@K
4.1 超参数速查表:照着填就能跑
NGCF 对超参数不算敏感,但有几个值直接影响收敛速度和最终指标。我整理了一份在实际实验中验证过的配置表:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| embedding size | 64 | 数据量 10 万级交互用 64 足够,百万级可以上 128 |
| 传播层数 | 3 | 2 层偏低、4 层以上过平滑 |
| 学习率 | 0.0005 | Adam 下 0.001 会震荡,0.0001 收敛太慢 |
| L2 正则系数 | 1e-4 | 只在 BPR 损失上对嵌入做约束 |
| batch size | 1024 | 显存不够就降到 512 |
| node dropout | 0.1 | 消息传播前对节点做随机丢弃 |
| message dropout | 0.1 | 对传播的消息做 dropout,增强泛化 |
用 Adam 优化器的时候有个坑:NGCF 的网络比较浅,梯度范数一般不大,但如果把 L2 系数设到 1e-2 以上,嵌入会被压到很小,内积分数区分度下降,Recall 会掉 3 到 5 个点。L2 系数在 1e-5 到 1e-4 之间是安全区间。
4.2 训练主循环:embedding 查表、BPR 损失与参数更新
NGCF 的前向传播不是把整个图喂进网络,而是把嵌入矩阵 E 过 L 层传播,得到最终嵌入矩阵,然后从矩阵里按索引取出对应的用户和物品嵌入。
class NGCF(nn.Module): def __init__(self, num_users, num_items, embed_dim, layers, norm_adj): super().__init__() self.num_users = num_users self.num_items = num_items self.embed_dim = embed_dim self.norm_adj = norm_adj # 稀疏张量 self.user_item_emb = nn.Embedding(num_users + num_items, embed_dim) self.layers = nn.ModuleList() in_dim = embed_dim for out_dim in layers: self.layers.append(NGCFLayer(in_dim, out_dim)) in_dim = out_dim def forward(self): emb = self.user_item_emb.weight embs = [emb] for layer in self.layers: emb = layer(emb, self.norm_adj) embs.append(emb) return torch.stack(embs, dim=0).sum(dim=0) # 各层嵌入求和 def get_embedding(self, ids): return self.forward()[ids] def bpr_loss(self, users, pos_items, neg_items): all_emb = self.forward() u_emb = all_emb[users] p_emb = all_emb[pos_items] n_emb = all_emb[neg_items] pos_score = (u_emb * p_emb).sum(dim=1) neg_score = (u_emb * n_emb).sum(dim=1) bpr = -torch.log(torch.sigmoid(pos_score - neg_score)).mean() l2 = self.user_item_emb.weight.norm(2).pow(2) * 1e-4 return bpr + l2逻辑说明:这里做了两个 NGCF 论文里推荐的工程决策。第一,每层传播后的嵌入都被保留下来,最后做逐层求和,而不是只取最后一层。理由是不同层编码了不同跳数的结构信息,求和相当于做了一次特征融合,在召回任务上比只取最后一层稳定。第二,L2 正则只用初始嵌入矩阵计算,不包含传播后的嵌入,这样梯度对传播过程的干扰更小。
训练主循环就简单了:
optimizer = torch.optim.Adam(model.parameters(), lr=0.0005) for epoch in range(50): model.train() total_loss = 0.0 step = 0 while step < train_steps: users, pos, neg = sample_batch(train_csr, num_users, num_items, 1024) loss = model.bpr_loss(torch.LongTensor(users), torch.LongTensor(pos), torch.LongTensor(neg)) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() step += 1 print(f"epoch {epoch} loss {total_loss / step:.4f}") if epoch % 5 == 4: evaluate(model, train_csr, test_ratings, top_k=20)4.3 评测指标:Recall@K 与 NDCG@K 的简洁实现
推荐系统的离线评测里,Recall@K 衡量"测试集里真实交互的物品有多少被召回",NDCG@K 额外考虑排序位置。实现评测时注意要用训练集做排除——用户已经在训练集交互过的物品,不应该出现在评测召回列表里。
def evaluate(model, train_csr, test_dict, top_k=20): model.eval() all_emb = model.forward().detach() recalls, ndcgs = [], [] with torch.no_grad(): for u in test_dict: u_emb = all_emb[u] # (dim,) scores = u_emb @ all_emb.T # (N+M,) # 排除训练集里交互过的物品,再排除用户自己 train_mask = train_csr.getrow(u).toarray().flatten() scores[:model.num_users] = -np.inf # 排除用户节点 scores[train_mask > 0] = -np.inf # 排除已交互物品 top_ids = scores.argsort(descending=True)[:top_k].cpu().numpy() top_items = top_ids - model.num_users rec = set(test_dict[u]) & set(top_items.tolist()) recalls.append(len(rec) / len(test_dict[u])) # NDCG: 命中位置越靠前权重越大,这里省略具体累加式 dcg = sum(1.0 / np.log2(idx + 2) for idx, iid in enumerate(top_items.tolist()) if iid in test_dict[u]) idcg = sum(1.0 / np.log2(idx + 2) for idx in range(min(len(test_dict[u]), top_k))) ndcgs.append(dcg / idcg if idcg > 0 else 0.0) print(f"Recall@{top_k}: {np.mean(recalls):.4f}, " f"NDCG@{top_k}: {np.mean(ndcgs):.4f}")逻辑说明:排除训练集已交互物品这一步非常关键,不然用户在训练集里买过的物品全部被放进去,Recall 会虚高七八个点。另外 scores 里包含物品节点和用户节点,必须把用户节点屏蔽掉,否则会出现"推荐了一个用户"这种荒谬结果。
5. 避坑指南:NGCF 训练中的五个典型翻车现场
5.1 归一化邻接矩阵出现全零行,训练直接 NaN
现象:模型在前几个 epoch 里 loss 迅速下降,然后突然变成 NaN,并且无法恢复。
原因:构建邻接矩阵时把冷启动用户也作为节点放进了矩阵。这种节点度为 0,对称归一化后整行都是零。传播时稀疏矩阵乘法把零向量传出去,但 L E 的梯度在零行位置会累积出无效值,几轮之后嵌入开始发散。
解决:在 normalize_adj 里把度为 0 的节点的 d_inv_sqrt 置为 0,这一步我在前面代码里做了。另外在建图前检查一遍数据,把训练集中完全没有交互的用户或物品直接过滤掉,反正它们也无法提供监督信号。还有一个细节:测试集里出现的新节点,在评测时要用全零嵌入或者训练集里最相似节点的嵌入代替,不能直接索引超出范围的 ID。
5.2 测试集出现新物品 ID,索引越界
现象:训练好好的,一跑 evaluate 就报 IndexError: index out of range in self。
原因:评测代码里直接拿了测试集原始物品 ID 去嵌入矩阵索引。但嵌入矩阵是在构建邻接矩阵时按训练集用户/物品重新编号的,测试集里如果出现了训练集没见过的物品,它的 ID 可能大于 num_items,超出了嵌入矩阵的行数。
解决:数据预处理阶段做一个统一 ID 映射字典,训练集和测试集都映射到 [0, num_users) 和 [num_users, num_users+num_items) 区间。测试集里那些映射不到的新物品,直接丢弃不参与评测。注意这个处理要在划分数据之前做,先全量 ID 映射,再按时间或随机切分,保证测试集 ID 一定落在合法区间内。
5.3 点积交互项引发梯度爆炸
现象:训练 loss 波动剧烈,从 0.6 掉到 0.1 又弹回 0.8,指标忽高忽低。
原因:NGCF 的消息包含 e_i ⊙ e_u 这一项,当某个节点度数很大、邻居嵌入的方差又高时,交互项的梯度范数会异常大。尤其当 embedding size 较大(比如 128)时,逐元素乘积的方差随维度线性增长,等效于把学习率放大了几十倍。
解决:最常见做法是在交互项后面加一个按维度缩放的系数 1/√dim,或者干脆把交互项拆成独立权重并在激活前做 LayerNorm。我一般会在 NGCFLayer 的 forward 里给 interaction 乘一个 0.5 的缩放因子,效果接近但代码改动最小。如果还想深调,可以把学习率降到 0.0002,损失函数换成带 margin 的 BPR 变体,也能缓解。
5.4 dropout 设成 0,训练集涨测试集狂跌
现象:训练集上 Recall@20 到了 0.35,测试集只有 0.08,明显过拟合。
原因:我把 node dropout 和 message dropout 都设成 0 跑了一版,发现 NGCF 这种图模型对过拟合非常敏感。原因在于二部图传播会让高频物品的信息在多层之间反复放大,模型记住了训练集里高热度物品的模式,泛化能力下降。
解决:node dropout 0.1、message dropout 0.1 起步,数据量越小 dropout 越大。我在一个模拟数据上试过,node dropout 从 0 提到 0.2,测试集 Recall@20 涨了 4 个点。注意 dropout 只加在消息传播阶段,不要加在最终嵌入求和之后,否则会破坏层之间的特征融合结构。
5.5 邻接矩阵太稀疏,torch.sparse.mm 反而不如 dense 快
现象:数据集只有几千个用户、几千个物品,用稀疏矩阵乘法训练一个 epoch 要 20 秒,换成 dense 只要 5 秒。
原因:torch.sparse.mm 的优势在于矩阵大且稀疏度极高时节省内存和计算量。小数据集下稀疏矩阵的存储开销和索引计算反而成了瓶颈,GPU 上 dense 矩阵乘法被底层库优化得很好,小规模场景下更占优。
解决:判断阈值大概是节点数超过 1 万、交互数超过 50 万时用稀疏实现,否则直接转 dense。转 dense 之后注意把矩阵放到 GPU 显存里,用普通的@运算符做矩阵乘法,代码几乎不用改。在中等规模数据集上如果还嫌慢,可以试试 SparseDenseMM 或者把邻接矩阵按行分块,但对绝大多数实验场景来说,稀疏转 dense 的判断足够用。
6. 进阶验证:嵌入质量检查与层数敏感性实验
模型训练完别急着看指标,先做两个体检。第一个是嵌入相似度检查。抽几个测试用户,取他们的最终嵌入,跟全部物品嵌入做余弦相似度,看 Top-10 结果里有没有明显不相关的热门物品。如果 Top-10 全被高热度物品占据,说明模型没有学好个性化信号,大概率是归一化邻接矩阵出了问题,或者 dropout 开太大把结构信息冲掉了。
第二个是层数敏感性实验。固定 embedding size 和 lr,分别跑 1 层、2 层、3 层、4 层,把每组的 Recall@20 和 NDCG@20 记录下来。正常情况是 3 层最优,2 层和 4 层差距在 1 到 2 个点以内。如果 1 层和 3 层差距达到 5 个点以上,说明数据里确实存在明显的高阶结构,值得加大 embedding size 再试一版。如果 4 层不降反升,说明数据集的 co-occurrence 关系非常长尾,这时候可以尝试把层间求和改成带权求和,或者直接用注意力权重分配各层贡献。
跟主流图协同过滤模型的对比也是一项值得做的验证。LightGCN 去掉了 NGCF 里的特征变换矩阵和逐元素交互项,只保留邻居聚合。实验对比时你会发现一个反直觉的结论:在多数数据集上 LightGCN 的 Recall 会略高于 NGCF,原因是 NGCF 的 W1、W2 两个线性层引入了额外的参数,在小数据集上容易被正则压制。但 NGCF 的价值在于它的设计可以平滑演进——把 W2 的交互项换成注意力权重,或者和 LightGCN 的纯聚合做加权融合,往往能得到比两者都好的结果。我在一个中等规模的开源数据集上试过把 NGCF 层的交互项权重减半,再叠加一份 LightGCN 聚合结果,Recall@20 比单独跑两者高出 1.5 个点左右。
从那以后我每次跑 NGCF 都会强制走一遍三个检查:邻接矩阵行和是否为 1、测试集 ID 是否越界、训练 loss 是否在合理区间震荡。这三步都通过,再去看指标。希望帮到你。
本文还有配套的精品资源,点击获取