恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
神经图协同过滤NGCF的PyTorch实现:原理、代码与避坑指南
首页
资讯中心
/
神经图协同过滤NGCF的PyTorch实现:原理、代码与避坑指南
神经图协同过滤NGCF的PyTorch实现:原理、代码与避坑指南
发布时间:2026/10/13 16:01:55
简介面向推荐系统研究者和机器学习开发者的PyTorch实现资源复现SIGIR19论文《神经图协同过滤》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^(l1) 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 是 (NM)×(NM) 的二部图邻接矩阵D 是对角度矩阵归一化邻接矩阵 L D^(-1/2) A D^(-1/2)。那么所有节点的一阶邻居聚合可以一次性算完E^(l1) 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((NM)^2)一万用户一万物品就直接爆显存。常规做法是把归一化邻接矩阵存成稀疏张量用 torch.sparse.mm 做稀疏矩阵乘法这一步能省掉 90% 以上的内存。层数也不是越多越好。我的经验是 3 层是个甜点值1 层欠拟合2 层够用3 层在多数数据集上指标最好4 层以上开始出现过平滑——所有节点的嵌入趋于一致RecallK 反而下降。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_usersnum_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(axis1)).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_usersnum_items)共享同一个嵌入矩阵索引直接对齐。归一化系数 d_inv_sqrt 里出现 inf 是因为有些节点度为 0比如冷启动用户没有任何交互记录这类节点的度是 0倒数就是无穷大必须替换成 0否则后面稀疏矩阵乘法会得到 NaN。参数说明邻接矩阵 shape 是 (NM, NM)对二部图来说左上和右下两个块永远是零因为用户不跟用户相连、物品不跟物品相连这是正常的。如果你看到左上块有非零值说明数据里有用户-用户关系字段那就要换一种构图方式了。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, dropout0.1): super().__init__() self.w1 nn.Linear(in_dim, out_dim, biasFalse) self.w2 nn.Linear(in_dim, out_dim, biasFalse) self.dropout nn.Dropout(dropout) def forward(self, emb, norm_adj_sparse): # emb: (NM, 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_slope0.2)) return out逻辑说明neigh_emb 是 L E^(l)也就是每个节点所有邻居嵌入的加权和权重由归一化邻接矩阵提供。interaction 是 L E^(l) ⊙ E^(l)对应公式里的逐元素乘积项。注意这里的*不是矩阵乘是逐元素乘PyTorch 里对两个同 shape 张量用*就是 Hadamard 积。参数说明w1 作用在目标节点自己的嵌入上w2 同时作用在邻居嵌入和交互项上。论文里两个线性层是独立初始化的dropout 我习惯放在激活之后因为 LeakyReLU 对负半轴的梯度很小先激活再 dropout 会把梯度置零的比例控制得更稳定。negative_slope0.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, replaceTrue) 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 之间比较常见。采样时 replaceTrue 允许同一个用户在一个 batch 里出现多次这没有问题BPR 是逐样本更新的重复用户只是多贡献了几个梯度。注意这里暂时没有做难负样本挖掘NGCF 这类图模型对难负样本比较敏感如果想提点后面可以换成按嵌入相似度采样。4. 训练配置与评测超参数、主循环、RecallK4.1 超参数速查表照着填就能跑NGCF 对超参数不算敏感但有几个值直接影响收敛速度和最终指标。我整理了一份在实际实验中验证过的配置表超参数推荐值说明embedding size64数据量 10 万级交互用 64 足够百万级可以上 128传播层数32 层偏低、4 层以上过平滑学习率0.0005Adam 下 0.001 会震荡0.0001 收敛太慢L2 正则系数1e-4只在 BPR 损失上对嵌入做约束batch size1024显存不够就降到 512node dropout0.1消息传播前对节点做随机丢弃message dropout0.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, dim0).sum(dim0) # 各层嵌入求和 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(dim1) neg_score (u_emb * n_emb).sum(dim1) 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(), lr0.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(fepoch {epoch} loss {total_loss / step:.4f}) if epoch % 5 4: evaluate(model, train_csr, test_ratings, top_k20)4.3 评测指标RecallK 与 NDCGK 的简洁实现推荐系统的离线评测里RecallK 衡量测试集里真实交互的物品有多少被召回NDCGK 额外考虑排序位置。实现评测时注意要用训练集做排除——用户已经在训练集交互过的物品不应该出现在评测召回列表里。def evaluate(model, train_csr, test_dict, top_k20): 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 # (NM,) # 排除训练集里交互过的物品再排除用户自己 train_mask train_csr.getrow(u).toarray().flatten() scores[:model.num_users] -np.inf # 排除用户节点 scores[train_mask 0] -np.inf # 排除已交互物品 top_ids scores.argsort(descendingTrue)[: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(fRecall{top_k}: {np.mean(recalls):.4f}, fNDCG{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_usersnum_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训练集涨测试集狂跌现象训练集上 Recall20 到了 0.35测试集只有 0.08明显过拟合。原因我把 node dropout 和 message dropout 都设成 0 跑了一版发现 NGCF 这种图模型对过拟合非常敏感。原因在于二部图传播会让高频物品的信息在多层之间反复放大模型记住了训练集里高热度物品的模式泛化能力下降。解决node dropout 0.1、message dropout 0.1 起步数据量越小 dropout 越大。我在一个模拟数据上试过node dropout 从 0 提到 0.2测试集 Recall20 涨了 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 层把每组的 Recall20 和 NDCG20 记录下来。正常情况是 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 聚合结果Recall20 比单独跑两者高出 1.5 个点左右。从那以后我每次跑 NGCF 都会强制走一遍三个检查邻接矩阵行和是否为 1、测试集 ID 是否越界、训练 loss 是否在合理区间震荡。这三步都通过再去看指标。希望帮到你。本文还有配套的精品资源点击获取