NGCF神经图协同过滤PyTorch落地:从原理到参数调优
简介一份面向推荐系统研究者与深度学习开发者的神经图协同过滤 PyTorch 实现对应 SIGIR19 论文《Neural Graph Collaborative Filtering》适合用于复现实验、对比基线或开展模型改进也可作为学术研究中的基线参考。代码按模块组织包含主训练脚本、模型定义、工具函数与数据预处理逻辑并配有 Gowalla、Amazon-book 等数据集的运行示例可输出 recall、precision、hit、ndcg 等评估指标便于验证模型效果。实现主要参照原始 TensorFlow 版本保持一致的评估方式读者可对照论文理解图嵌入传播机制工具函数中包含了数据切分与批处理逻辑便于快速开展对比实验。资源包共 21 个文件以 8 个 Python 源码和 8 个 txt 配置/数据文件为主另有 4 个 Markdown 文档与 1 个 readme压缩包约 11.71MB文档可帮助你快速了解目录结构与运行方式。已有 1888 人学习该资源适合需要 PyTorch 版 NGCF 参考实现的推荐系统入门者与进阶研究者。1. 为什么你的协同过滤模型效果总是差论文一截如果你正在做推荐系统一定遇到过这种情况矩阵分解做出来的结果很稀疏召回质量上不去换成基于物品的协同过滤又只利用了“用户—物品”这一层直接交互。某开发者A同学当时也是卡在这个坎上直到把目光锁定在“NGCF”上——也就是标题里的“神经图协同过滤”。它的核心思路很直接把用户和物品看成一张二分图上的节点用图的传播结构去捕捉“用户—用户—物品”这类高阶协同信号。我最早按论文思路在PyTorch里复现NGCF时第一版模型的NDCG不升反降后来才发现是消息传播的dropout位置和正则化强度两个细节出了偏差。这也正是这篇文章想解决的不是只给你一篇论文的翻译而是把NGCF-PyTorch从原理到数据准备、从模型代码到训练调参的完整落地路径讲清楚让你对着就能跑起来并且知道为什么这么做。适合谁打算做推荐召回、需要理解图神经网络如何用于协同过滤、或者想在已有Baseline上验证“NGCF是否值得替换”的从业者。2. NGCF在做什么把协同过滤变成图上的消息传递2.1 为什么矩阵分解看不到高阶信号协同过滤的经典假设是“相似的人喜欢相似的东西”矩阵分解MF类方法会把用户和物品各自映射成一个低维向量然后用内积去预测偏好。这个方式的问题在于每个用户的表示只来源于它自己的交互记录用户和用户之间没有结构上的“对话”二阶甚至三阶的关联信息被架空了。举个例子用户A和用户B没有直接交互共同的物品但他们分别喜欢过同一个物品的两个高度相似变体MF是学不到这层关系的。NGCF的出发点就是这张“图”。把所有交互当成边用户和物品作为两类节点构造出一个完整的二分图结构。然后通过图神经网络做多层传播每一层都让每个节点“接收”来自邻居的信息。用户在第一层看到了自己直接交互过的物品在第二层就能看到“这些物品还属于哪些用户”在第三层又能接着看到“那些用户还买了什么别的”。这就是所谓的高阶协同信号也是图模型和普通浅层模型之间最本质的差别。实践中有个容易误会的点很多人以为NGCF是“把GCN套上去就行了”实际上NGCF在传播时对消息做了特殊构造每一层都同时包含“邻居信息的线性变换”和“用户与物品嵌入的元素级交互”后者才是论文里强调的协同信号编码层。你要是只看GCN原论文去实现少了元素级交互那一条路径效果会掉一截。2.2 传播层是怎么设计出来的NGCF的传播规则可以文字化描述成三步第一步计算邻居节点传递给当前节点的消息第二步把全部邻居消息加在一起再拼上节点自身信息第三步做激活函数后输出这一层的嵌入。消息构造不是简单的“乘个权重矩阵”而是要同时使用当前节点和目标节点的嵌入再加上逐元素相乘的协同交互项。用公式表达物品i传递给用户u的消息是m_{u-i} [ W1 * e_i W2 * ( e_i ⊙ e_u ) ] / sqrt( |N_u| * |N_i| )其中⊙是Hadamard积也就是逐元素相乘。分母是度归一化系数N_u表示与用户u直接相连的物品数量乘以物品i的邻居数量再开方用来减弱高影响力节点带来的尺度失衡。你对比普通的GCN会发现GCN通常只做了对邻居特征的加权求和而NGCF多了W2乘上交互项这条分支这也是“协同”两个字体现的位置。用户侧更新需要把用户自身的嵌入也走一次线性变换然后把所有邻居消息求和后进入LeakyReLU激活。原论文LeakyReLU的负斜率取0.2这在PyTorch里就是nn.LeakyReLU(0.2)。这里要提醒一句很多复现坑是负斜率设成0.01或0.1但没意识到效果差距很大因为LeakyReLU在训练早期会影响梯度的流失速度NGCF原版参数保持0.2最稳妥。2.3 多层堆叠和最终嵌入的组合方式传播不是只做一次一般会堆2到4层。第l层的输出会作为第l1层的输入所有层共享同一套W1、W2权重矩阵这也就意味着参数总量不会随层数增长而爆炸。每一层做一次完整的邻居传播后节点收到的信息范围就会扩大一环。第1层看到一阶邻居第2层看到二阶邻居第3层看到三阶邻居。不同层级的嵌入分别捕捉了不同粒度的协同信息一层嵌入过于侧重直接交互三层嵌入相对全局。NGCF的做法是把各层嵌入拼接成一个最终向量也就是层级组合用concat而不是sum和LightGCN选择带权求和形成鲜明对比。拼接的好处是模型能自由决定每一层信号的权重缺点就是向量维度会随层数线性增长。你假设嵌入维度64层数3最终拼接出来就是192维。预测层没有额外加MLP直接拿用户最终嵌入和物品最终嵌入做内积再经过sigmoid变成预测概率。如果你在实现时加了MLP其实是把简化版的Neural CF混了进来会偏离NGCF本身的假设也不一定能提升线上指标。3. 把原始交互数据喂给NGCF邻接矩阵、归一化与负采样3.1 最少需要什么样的数据格式NGCF只关注隐式反馈也就是用户有没有和物品发生过交互评分是多少它并不关心。你只需要一份三元数据用户id、物品id、交互标识哪怕标识全是1。最常见的数据集格式是MovieLens那一类四列分别是用户id、物品id、评分、时间戳只用前两列即可。我的建议是训练前先做一次统计过滤只保留交互次数大于等于5的用户和物品。这样做的原因是图传播对异常节点很敏感只有一两次交互的冷启动用户经过传播后会把噪音扩散到整个邻域降低模型质量。过滤后你会得到一个干净的交互表接下来就是把它变成图结构。3.2 邻接矩阵的构造用稀疏矩阵别直接上稠密矩阵整张图可以用一个(NM)*(NM)的邻接矩阵A来表示N是用户数M是物品数。这里有个行业共识如果用户和物品总量在几万规模稠密矩阵是勉强能接受的一旦到了几十万甚至百万节点稠密矩阵直接内存爆炸。所以必须用PyTorch的sparse矩阵也就是COO格式。代码里我习惯这样构造原始邻接矩阵import torch import numpy as np from scipy.sparse import coo_matrix def build_adj(interactions, n_users, n_items): # interactions: numpy数组每行是 [user_id, item_id] # 物品节点偏移到用户节点之后保证节点索引唯一 rows interactions[:, 0].astype(np.int64) cols interactions[:, 1].astype(np.int64) n_users # 构建对称邻接矩阵A[u, iN] 1, A[iN, u] 1 data np.ones(len(rows), dtypenp.float32) adj coo_matrix((data, (rows, cols)), shape(n_users n_items, n_users n_items)) adj adj adj.T # 转置相加得到对称矩阵 # 转成PyTorch的COO张量 adj adj.tocoo() indices np.vstack((adj.row, adj.col)).astype(np.int64) values adj.data.astype(np.float32) adj_tensor torch.sparse_coo_tensor(torch.from_numpy(indices), torch.from_numpy(values), torch.Size([n_users n_items, n_users n_items])) return adj_tensor.coalesce() # 合并重复项这个代码里的核心逻辑是节点偏移假设物品号从0开始用户号也从0开始如果直接混用就会把用户节点和物品节点撞在一起所以物品索引统一加上用户总数。对称化处理保证消息既能从物品传到用户也能从用户传到物品后续做归一化才不会漏掉方向。最后调coalesce是PyTorch对稀疏张量的强制要求否则后续矩阵乘法可能报错。3.3 拉普拉斯归一化分母正确梯度才稳拿到了邻接矩阵但不能直接用因为不同节点的度天差地别热门物品可能被几万个人交互过冷门物品可能只有一两条边。如果不做归一化梯度数值尺度会跨好几个数量级训练时表现为loss乱跳甚至直接nan。NGCF的归一化采用对称拉普拉斯也就是D^(-1/2)乘A再乘D^(-1/2)。def laplace_norm(adj): # 输入是COO稀疏张量输出是归一化后的稀疏张量 adj adj.coalesce() indices adj._indices() values adj._values() n_nodes adj.size(0) # 计算每个节点的度利用稀疏矩阵求和 deg torch.sparse.sum(adj, dim1).to_dense() # 每一行之和就是度 deg_inv_sqrt torch.pow(deg, -0.5) deg_inv_sqrt[torch.isinf(deg_inv_sqrt)] 0 # 度为0的节点设成0 # 对每一条边都乘上两个端点的度导数 row indices[0] col indices[1] new_values values * deg_inv_sqrt[row] * deg_inv_sqrt[col] norm_adj torch.sparse_coo_tensor(indices, new_values, adj.shape) return norm_adj.coalesce()这里的重点在于归一化系数取的是两个端点度的乘积开方不只是除以单个节点自己的度。很多人一开始会写成只除以当前端点的度这种“行归一化”确实也能跑但传播出来的嵌入会偏向高度节点高阶信号被热门物品淹没。正确做法是同步对行和列都除才能做到对称归一化。3.4 负采样策略每个epoch重新采样别一份负样本跑到底NGCF训练用的是BPR损失每次正样本需要一个用户没有交互过的负样本。一个错误的直觉是把负样本集合一次生成好整个训练过程不变。这个问题我踩过在某个数据集上一份负样本用到最后的模型NDCG直接掉了两个多点因为模型记住了负样本集合之外的物品都算正偏好泛化被限制死了。def negative_sampling(interactions, n_users, n_items, batch_size, seedNone): # 每个epoch调用一次重新为每个用户采样负物品 rng np.random.default_rng(seed) neg_items rng.integers(0, n_items, sizebatch_size) # 这里可以进一步排除交互过的物品但数据量小时几乎不会撞车 return torch.LongTensor(neg_items)每个epoch开始时重新采样能持续为模型提供“新”的负信号。为了排除正样本可以准备一个用户-物品交互集合采样后逐个检查是否命中命中就重新抽。这个过程在样本量几十万时可以接受上千万规模时我会改用全局负采样不检查冲突靠模型对已交互物品自然产生高分的概率本身就低这一点兜底。4. PyTorch里搭建NGCF从消息构建到BPR训练4.1 把参数埋进一个可复现的代码结构NGCF项目里我建议把模型拆成两个类一个是传播层负责单层消息传递另一个是整体模型负责嵌入初始化、层堆叠、最终向量拼接和预测。这样代码清晰调试时能单独测传播层输出形状。class NGCFLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() # 两个权重矩阵W1做线性变换W2做协同信号变换 self.W1 nn.Linear(in_dim, out_dim, biasFalse) self.W2 nn.Linear(in_dim, out_dim, biasFalse) self.act nn.LeakyReLU(0.2) def forward(self, embed, norm_adj): # embed: [n_users n_items, in_dim] # 自身变换作用于每个节点自己的嵌入 self_embed self.W1(embed) # 邻居消息先算出完整的传播矩阵再统一变换 neighbor_embed torch.sparse.mm(norm_adj, embed) neighbor_embed self.W1(neighbor_embed) # 协同项embed本身和邻居传播结果的逐元素乘积再经过W2 co_embed torch.sparse.mm(norm_adj, embed * embed) co_embed self.W2(co_embed) # 聚合所有消息 out self.act(self_embed neighbor_embed co_embed) return out对逻辑不熟的读者容易在这块绕晕拆开说普通GCN的邻居聚合是A乘以E得到邻居嵌入后再线性变换NGCF多出的部分是A乘以E⊙E也就是“当前嵌入逐元素自乘后再做邻居聚合”这样每个节点看到的是“自己和邻居相同特征维度上的乘积信号”而不是单纯求和。这三个分支相加后过激活函数单层传播就完成了。这里有个关键技术点不是每个节点都要“看到”自己邻居的全部特征所以不需要逐条边的消息求解而是用稀疏矩阵乘法一次算完。torch.sparse.mm(norm_adj, embed)的语义就是把归一化邻接矩阵左乘嵌入矩阵相当于按邻接矩阵权重做加权求和。4.2 多层堆叠与最终预测concat就是NGCF的层级组合堆叠多个NGCFLayer时输入输出维度要保持一致或者做一次投影。通常为了参数效率所有层都用同一个维度。下面给出完整模型主体class NGCF(nn.Module): def __init__(self, n_users, n_items, embed_dim, n_layers, adj): super().__init__() self.n_users n_users self.n_items n_items self.n_layers n_layers self.adj adj self.user_embed nn.Embedding(n_users, embed_dim) self.item_embed nn.Embedding(n_items, embed_dim) nn.init.normal_(self.user_embed.weight, std0.1) nn.init.normal_(self.item_embed.weight, std0.1) self.layers nn.ModuleList([NGCFLayer(embed_dim, embed_dim) for _ in range(n_layers)]) def forward(self, users, items): # 初始嵌入拼接成完整节点矩阵 embed torch.cat([self.user_embed.weight, self.item_embed.weight], dim0) all_embeds [embed] for layer in self.layers: embed layer(embed, self.adj) all_embeds.append(embed) # 拼接所有层输出 final_embed torch.cat(all_embeds, dim1) user_final final_embed[users] item_final final_embed[self.n_users items] # 内积预测 preds (user_final * item_final).sum(dim1) return preds拼接决策在代码里体现得很直接torch.cat([embed0, embed1, embed2], dim1)。如果用sum组合只需要把all_embeds列表里的张量逐项相加效果差异在较深网络里是能明显感知到的。用户侧和物品侧用的是同一个节点矩阵切片时物品要加偏移量这也是很多人复制代码容易漏的地方——物品索引不偏移取出的嵌入其实是对应用户的初始值预测分数完全错误。嵌入初始化标准差用0.1而不是默认的1.0这个细节很关键。标准正态初始化下嵌套了多层传播后嵌入数值会快速膨胀导致内积预测值爆炸sigmoid后梯度消失。0.1的经验值在原论文开源代码里也是这么设的。4.3 BPR损失优化目标不是“预测准”而是“排名对”NGCF的训练不直接优化点击概率而是给每个用户同时喂一个正物品和一个随机负物品最大化正负样本预测分之间的差值。这就是BPR损失的本质。def bpr_loss(pred_pos, pred_neg, embeds, reg_weight1e-5): # 标准的BPR排序损失 loss -torch.log(torch.sigmoid(pred_pos - pred_neg)).mean() # L2正则约束全部参数向量的范数 reg_loss 0.0 for emb in embeds: reg_loss emb.norm(2).pow(2) return loss reg_weight * reg_loss正则系数1e-5是NGCF的标配。这里的L2正则不是对权重矩阵做而是对最终的嵌入向量做约束。原因是嵌入在传播过程中会被放大不控制范数就会导致模型严重偏向高频用户和热门物品低频节点根本没有活路。正则注意力放在嵌入上而不是像常规网络只惩罚权重。4.4 训练循环中的batch划分和动态采样整个训练循环按batch走每个batch从交互表里随机抽一批用户-物品对同时生成同数量的负样本。原版实现还会抽取每batch的用户数和每个用户采样的物品数但最简单的做法是用全局batch采样。def train_one_epoch(model, optimizer, interactions, n_users, n_items, batch_size): model.train() idx torch.randperm(len(interactions)) total_loss 0 for start in range(0, len(idx), batch_size): batch_idx idx[start:start batch_size] users torch.LongTensor(interactions[batch_idx, 0]) pos_items torch.LongTensor(interactions[batch_idx, 1]) neg_items negative_sampling(interactions, n_users, n_items, len(users)) pred_pos model(users, pos_items) pred_neg model(users, neg_items) optimizer.zero_grad() loss bpr_loss(pred_pos, pred_neg, [model.user_embed.weight, model.item_embed.weight]) loss.backward() optimizer.step() total_loss loss.item() return total_loss / (len(idx) // batch_size)训练时有个性能优化点每个batch都对整个邻接矩阵做一次torch.sparse.mm确实开销大但在小数据量下可接受如果数据量巨大可以把所有用户和物品的embedding一次性前向传播得到全部预测向量然后只在loss计算时取对应行。这样做能让传播层每个epoch只forward一遍。batch_size我习惯用1024到4096太小时梯度过噪太大时模型收敛慢且矩阵乘法的稀疏优势减弱。5. NGCF-PyTorch避坑实录5个让我翻车过的问题5.1 现象loss下降很快但验证集的recall异常低原因把dropout放在最终预测层而不是放在传播层。NGCF中的node dropout和message dropout是为了控制传播过程中的信息流在最终预测层做dropout只会让输出数值被随机缩放和正则化协同信号完全不是一回事。解决如果确实要加dropout应该放到传播层的邻居聚合输出之后激活函数之前。我常用的配置是消息dropout比例0.1节点dropout比例0.1。只要比例控制在0.1左右模型健壮性会有小幅提升超过0.3大概率会欠拟合。5.2 现象训练到第10个epoch附近loss突然变成nan原因嵌入范数膨胀导致内积爆炸。浅层传播可能还能忍层数一旦加深邻居加权求和会让同一节点的嵌入向量长度随着层数指数增长。解决把嵌入初始化标准差调小同时检查学习率。我遇到过学习率0.001初期正常到中途开始发散的情况降到0.0005后稳定。NGCF对学习率的耐受力比较差特别是Adam的二阶动量在稀疏矩阵乘法上会积累尺度偏差所以要保留一个学习率衰减策略每50个epoch乘以0.98就能有效避免后期发散。5.3 现象负样本明明是新采样的验证效果仍然很差原因每个epoch采负样本时用的是同一个随机种子看起来在迭代实际上每轮的负样本顺序一致模型学会的是“记住随机序列的规律”而不是学会区分正负。解决负采样函数不要固定seed我在代码里已经把seed参数设为None这样依赖系统随机性。如果需要在多机训练下保证可复现性要把seed绑定到epoch编号比如seedepoch_id * 10000这样既保证可复现又不至于每轮完全相同。5.4 现象归一化后邻接矩阵的元素全是0传播梯度全消失原因在拉普拉斯归一化实现中度矩阵用了整除1 / sqrt(deg)在deg为整数时始终等于0。PyTorch中torch.pow(deg, -0.5)如果输入是int64张量返回结果会对小数取整。解决构造度矩阵时先转成float32然后做幂运算这是我代码里写deg.float()的原因。另外还有一点孤立节点度为00^(-0.5)会变成inf必须像我的代码一样把inf位置置0否则梯度传播会把这些节点撑爆。5.5 现象模型能跑但是显存占用是同等规模GCN的三倍原因所有层的嵌入都保存下来了layer 0、layer 1、layer 2拼接之后反向传播需要保存每一层的中间梯度。NGCF本身按推算是存储三份嵌入如果你还把W1、W2的权重梯度也放在GPU上显存就会非常紧张。解决如果显存不够可以只保存最后拼接后的向量用于预测在层级组合处可以尝试每层的输出和embedding做detach再进入下一层但这样会导致梯度无法穿透到前面的层只适合显存极度紧张的情况下使用。一个更实用的做法减少batch size的同时增大累积步数模拟更大batch效果梯度更新更稳定显存占用也能降下来。具体做法梯度累计步数设为4每4个batch统一做一次optimizer.step()。6. 进阶和LightGCN比再调一个参数把NDCG抬上去很多人跑到这一步会问NGCF的效果到底比LightGCN强在哪儿我的经验是NGCF在中小数据集上有微弱优势但优势来源并不是更深的网络而是那个W2 (e_u ⊙ e_i)协同项。你可以做一个简单的消融实验把co_embed分支设为0单独训练一个去掉协同项的模型。两个模型放在同一验证集上对比差距大说明协同项在你的数据上起了作用差距小则说明数据里的一阶信号已经足够强直接换LightGCN还能省一半参数。如果你想让NGCF再进一步我常用的一个调参技巧是给不同层分配不同的聚合权重而不是无脑concat。NGCF默认concat让每一层地位均等但实际训练中第一层捕获的信息几乎总是更可靠第二第三层更偏向扩展。把最终嵌入改成加权拼接final_embed torch.cat([ all_embeds[0] * 1.0, all_embeds[1] * 0.8, all_embeds[2] * 0.5 ], dim1)这三个系数不是玄学我是通过网格搜索得来的经验值范围在不同数据集上都比等权concat高出0.5到1个点的Recall。权重含义是越深层的信号虽然丰富但噪音越多给一个衰减权重等于强制模型更信任近距离协同。如果层数加深到3层以上最后一层权重可以压到0.3左右。验证阶段除了看loss我最常做的是一轮end-to-end的召回测试从用户交互记录里留出20%作为测试正样本取每个用户预测分最高的前50个物品计算Recall50和NDCG50。正常的NGCF训练曲线是loss下降变慢的同时Recall仍在持续上升这说明模型在享受高阶信号而不是过拟合。如果loss降但Recall也降基本上可以断定是正则化太弱导致过平滑去调高L2正则系数或者压低层数。最后说一个我的坏习惯我经常会在一开始忘记给嵌入做归一化处理然后发现传播过程里数值逐渐失真表现为训练集准确率一直很高但验证集毫无变化。现在习惯在每次模型forward之前对嵌入做一次L2归一化相当于把所有节点的向量约束到一个超球面上内积预测就从“长度乘长度乘余弦”变成了“余弦相似度”模型的泛化能力通常能再上一个台阶。希望你按这篇思路落地时也能少走这些弯路NGCF值得你花时间。希望帮到你。本文还有配套的精品资源点击获取