资讯动态

NCF视频推荐系统源码解析:GMF与MLP双塔结构、负采样与评估实战

发布时间:2026/10/18 12:08:30 来源:尧图企业网站定制
简介基于深度学习神经网络协同过滤模型NCF的视频推荐系统源码以zip压缩包的形式打包提供面向想快速上手推荐系统的Python开发者与深度学习初学者。资源包含完整可运行的NCF模型实现配套测试数据与说明文档读者可直接运行并观察训练与预测流程理解神经网络协同过滤在视频推荐场景中的应用。压缩包共3个文件分别为模型核心Python脚本、CSV格式测试数据集、Markdown项目说明整体仅4KB结构简单、依赖轻量适合作为课程设计或入门实战参考。源码已经本地编译运行通过项目难度适中内容经助教审定能够满足学习与使用需求。目前已有199人浏览学习借助该资源可快速理解NCF的嵌入层、多层感知机及交互建模思路并基于自带数据完成一次小型推荐实验是后续扩展真实推荐系统的良好起点。1. NCF视频推荐系统源码它能解决什么问题适合谁直接拿去用一套 Python 实现的、基于深度学习神经网络协同过滤模型NCF的视频推荐系统源码核心要解决的事情非常具体从用户观看视频的历史日志里训练出用户向量和视频向量让模型学会预测「某个用户会不会点开并看完某个视频」。相比传统 ItemCF 只算相似度、SVD 只能拟合线性关系NCF 用 GMF 和 MLP 两条分支分别建模线性与非线性交互最后拼到一起打分这也是它在视频这类高稀疏场景下能稳定胜过传统方法的原因。这套源码适合三类人正在做推荐方向课设或毕设、需要一份能跑通能写进论文代码的学生组内要快速搭离线推荐 baseline 的算法工程师刚接触深度推荐、想搞清楚 embedding、负采样、TopK 评估怎么串起来的新人。读完这套方案你能真正跑通一整套流程并且避掉我实际跑数据时踩过的五个大坑。2. 理解NCF双塔结构GMF与MLP为什么拼在一起打分2.1 GMF做线性、MLP做非线性两条分支各管什么NCF 不是一个单独的网络而是把通用矩阵分解GMF和多层感知机MLP两条分支并行训练再融合。GMF 分支做的操作是把用户向量和视频向量逐元素相乘得到一个新的向量再通过线性层压成一个分数。逐元素相乘意味着每一维都独立表达「用户在这个隐因子上的偏好 × 视频在这个隐因子上的属性」这正好是矩阵分解的神经网络版本参数比传统 SVD 更自由因为每个维度还可以通过后面的线性层分配不同权重。MLP 分支则完全不关心这种线性限定。它把用户向量和视频向量拼接成一个长向量再连续经过几层全连接和 ReLU 激活理论上可以拟合任意复杂的交互关系。比如「用户是动作片重度偏好者」和「当前视频是悬疑高分片」这两个信号单独看都不足以预测行为但它们在高层特征空间里组合出来的模式可能非常强这种交叉特征的捕捉能力正是 MLP 相对传统协同过滤的核心优势。两条分支为什么非要拼在一起GMF 收敛快、线性信号学得扎实但对非线性交互的拟合能力有限MLP 能学非线性却需要更大数据量和更精细的调参。把 GMF 输出向量和 MLP 最后一层隐藏输出拼接再接一个输出层既保住了线性部分的稳定性又给非线性留了空间。这也是 NCF 源码里最常见的实现形态——严格说完整模型叫 NeuMF但大家都习惯直接叫 NCF。模型变体结构适合场景GMF逐元素乘 线性输出数据量小、交互关系以线性为主MLP拼接 多层全连接特征交叉复杂、数据充足NeuMF完整NCFGMF 与 MLP 并行拼接默认选择绝大多数场景实际落地时我建议直接上完整 NeuMF因为它的两个分支可以分别设置不同维度的 embedding灵活性最大。如果数据量很小把 MLP 分支去掉只留 GMF 也能跑但别反过来只留 MLP——没有 GMF 兜底小数据集上收敛会非常慢。2.2 视频推荐的数据格式从观看日志到隐式反馈视频推荐和电商推荐最大的区别在于反馈类型电商有明确的购买或加购行为而视频平台最主要的信号就是「观看」。观看行为天然是隐式反馈——用户没点开一个视频不代表不喜欢可能只是没刷到它。所以 NCF 的训练标签不是评分而是 0/1看过的记为 1没看过的通过负采样记为 0。源码仓库里拿到的数据一般是原始观看日志字段通常长这样字段含义处理建议user_id用户标识重编码为 0 开始的连续整数video_id视频标识重编码watch_seconds观看时长秒低于 10 秒直接过滤video_seconds视频总时长秒用于计算观看比例timestamp观看时间按时间排序后切分训练/测试category视频类目冷启动和特征扩展时用处理观看时长有几个容易翻车的细节。第一观看比例 watch_seconds / video_seconds 大于 1 很常见因为存在倍速播放和拖动进度条这类异常值要用比例上限 2.0 控制住。第二正样本的界定阈值一般取 0.4也就是看完了 40% 才算一次有效观看低于这个比例的视频对推荐模型是噪音信号。第三低频用户和低频视频必须过滤否则 embedding 学不出来这个在第 3 章有完整代码。2.3 评价指标怎么选HR10与NDCG10的直觉含义NCF 源码里几乎不会用准确率或精确率评估因为隐式反馈没有真正的负样本你采的负样本可能只是用户没刷到而不是不喜欢。业界标准做法是 leave-one-out 评估每个用户只拿最后一条观看记录出来当正样本再从用户没看过的视频里随机抽 99 个当负样本让模型对这 100 个候选打分排序看正样本能排到第几。Hit Rate10HR10衡量的是正样本出现在前 10 名的比例它只关心有没有命中不关心排第 1 还是第 10。NDCG10 则加入位置折扣排第 1 名的得分远高于排第 10 名。两个指标配合着看——HR 高说明召回能力没问题NDCG 高说明排序质量好。如果 HR 还行但 NDCG 一直上不去说明模型把正样本的分数普遍压在中游这时候优先调 MLP 的层数和 dropout而不是急着加数据。3. 数据预处理与负采样跑通NCF训练的第一步3.1 观看日志清洗与id重编码先把原始日志读进来做四步清洗。顺序和实际项目里的处理流程是一一对应的别跳步import pandas as pd df pd.read_csv(view_log.csv) # 字段user_id, video_id, watch_seconds, video_seconds, timestamp, category # 1) 时长过滤不足 10 秒多半是滑动误触直接丢掉 df df[df[watch_seconds] 10].copy() # 2) 计算观看比例剔除异常值看完 40% 记为有效观看 df[watch_ratio] df[watch_seconds] / df[video_seconds] df df[df[watch_ratio] 2.0] # 倍速/拖动的异常比例 df[label] (df[watch_ratio] 0.4).astype(int) # 3) 低频过滤出现少于 5 次的用户/视频embedding 学不出来 uid_counts df[user_id].value_counts() vid_counts df[video_id].value_counts() df df[df[user_id].isin(uid_counts[uid_counts 5].index)] df df[df[video_id].isin(vid_counts[vid_counts 5].index)] # 4) 重编码为从 0 开始的连续整数和 Embedding 层索引对齐 df[u] df[user_id].astype(category).cat.codes df[i] df[video_id].astype(category).cat.codes n_users df[u].nunique() 1 n_items df[i].nunique() 1 # 5) 按时间排序为后面的 leave-one-out 切分做准备 df df.sort_values(timestamp).reset_index(dropTrue)第四步用astype(category).cat.codes是常见做法它会把字符串类型的 uid/vid 自动映射成连续整数比手动构造字典省事。注意n_users最后加 1 的原因负采样时可能采到边界索引Embedding 层留一个余量能避免越界报错。第五步的排序非常关键后面切训练集和测试集必须按时间切不能随机打乱。时间穿越是推荐系统实验里最隐蔽的 bug如果你发现 HR10 高得离谱先怀疑这里。3.2 负采样1:4固定比例评估时99个负候选NCF 的训练集不会把「看过 / 没看过」全量枚举出来那样稀疏矩阵太大内存直接爆炸。常见做法是每一条正样本配 4 条负样本负样本从该用户没看过的视频里随机采。这个 1:4 是经过大量实验验证的经验值比例太低模型欠拟合比例太高负样本太简单模型会退化成无脑输出低分。import numpy as np def build_train_set(train_df, n_items, neg_ratio4, seed0): 为训练集中每条正样本采样 neg_ratio 条负样本。 返回 (users, items, labels)label1 正样本label0 负样本。 rng np.random.default_rng(seed) user_items train_df.groupby(u)[i].apply(set).to_dict() users, items, labels [], [], [] all_items np.arange(n_items) for u, pos_i in zip(train_df[u], train_df[i]): # 正样本原样保留 users.append(u); items.append(pos_i); labels.append(1) # 从该用户没看过的视频里采样负样本 watched user_items.get(u, set()) candidates np.setdiff1d(all_items, np.array(list(watched))) if len(candidates) neg_ratio: continue # 候选不足直接跳过避免采样报错 negs rng.choice(candidates, sizeneg_ratio, replaceFalse) for neg_i in negs: users.append(u); items.append(int(neg_i)); labels.append(0) return np.array(users), np.array(items), np.array(labels)逻辑上最值得注意的点是setdiff1d这一步它保证负样本不会和该用户的历史观看记录撞车。如果负采样不排除已看过的视频模型会同时学到「这个视频既是正样本又是负样本」梯度互相打架loss 半天降不下去。还有一个细节replaceFalse表示同一批里不重复采样但不同 epoch 如果还用同一个 seed负样本永远一样模型很容易记住这批固定负样本第 5 章会专门讲怎么处理。评估时的负采样和训练不一样。训练用 1:4评估常用 1:99——每个用户保留最后一条真实观看当正样本再采 99 个没看过的视频当负候选构造 100 个候选的排序任务。这样做更接近线上真实场景推荐系统是从海量视频里挑 Top10而不是在二分类里随便猜。提示如果你拿到的源码里评估时只随机抽了 10 个负样本建议改成 99 个。负样本太少时 HR10 的方差非常大两个模型的指标差 2 个点根本无法判断谁更好。4. 模型定义与训练把NCF源码里的核心模块拆开讲4.1 PyTorch版NCF模型搭建GMF与MLP双分支模型定义是整个源码的核心别把它当黑匣子每一行都对应一个设计决策。下面这份是完整 NeuMF 结构用 PyTorch 实现TensorFlow 2.x 的 Keras 写法思路完全一样import torch import torch.nn as nn class NCF(nn.Module): GMF 分支用户/视频向量逐元素相乘拟合线性交互。 MLP 分支向量拼接后过全连接层拟合非线性交互。 两分支输出拼接后经线性层打分sigmoid 转概率。 def __init__(self, n_users, n_items, embed_dim32, mlp_embed_dim64, mlp_dims[128, 64], dropout0.2): super().__init__() # GMF 使用的用户/视频向量 self.user_gmf nn.Embedding(n_users, embed_dim) self.item_gmf nn.Embedding(n_items, embed_dim) # MLP 使用的用户/视频向量维度可以更大 self.user_mlp nn.Embedding(n_users, mlp_embed_dim) self.item_mlp nn.Embedding(n_items, mlp_embed_dim) # MLP 隐藏层Linear - ReLU - Dropout 循环堆叠 layers [] in_dim mlp_embed_dim * 2 # 用户向量和视频向量拼接后的长度 for dim in mlp_dims: layers.append(nn.Linear(in_dim, dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(dropout)) in_dim dim self.mlp nn.Sequential(*layers) # 最终打分层GMF 输出与 MLP 输出拼接 self.predict nn.Linear(embed_dim mlp_dims[-1], 1) def forward(self, user_ids, item_ids): # GMF 分支逐元素相乘每一维表达偏好×属性的线性交互 gmf_out self.user_gmf(user_ids) * self.item_gmf(item_ids) # MLP 分支拼接后过全连接学高维交叉特征 mlp_in torch.cat([self.user_mlp(user_ids), self.item_mlp(item_ids)], dim-1) mlp_out self.mlp(mlp_in) # 拼接后打一个分数sigmoid 变成点击概率 logits self.predict(torch.cat([gmf_out, mlp_out], dim-1)) return torch.sigmoid(logits).squeeze(-1)几个参数的选择逻辑embed_dim 设 32 是视频这种高稀疏场景的保守值数据量大再往上加mlp_embed_dim 设 64 是让 MLP 分支有更多自由参数学非线性mlp_dims[128, 64] 是典型塔式递减结构最后一层 64 和 GMF 的 32 拼接成 96 维输出层输入维度不至于太大。dropout0.2 是隐式反馈模型里比较稳的值设成 0 在小数据集上基本必过拟合。注意 GMF 分支用的是逐元素相乘*不是点积dot。逐元素相乘保留每个隐因子的独立交互信号再让后面的线性层自己决定每维权重点积会把所有维度压缩成一个标量信息量直接少一个数量级。这是新手最容易抄错的地方。4.2 训练循环与三个必调超参数训练循环本身不复杂难的是超参数怎么配。先看代码import torch.nn.functional as F from torch.utils.data import DataLoader, TensorDataset def train_model(model, users, items, labels, epochs30, batch_size256, lr1e-3, weight_decay1e-5, devicecpu): dataset TensorDataset(torch.from_numpy(users), torch.from_numpy(items), torch.from_numpy(labels.astype(np.float32))) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) optimizer torch.optim.Adam(model.parameters(), lrlr, weight_decayweight_decay) for epoch in range(epochs): model.train() total_loss 0.0 for u, i, y in loader: u, i, y u.to(device), i.to(device), y.to(device) pred model(u, i) loss F.binary_cross_entropy(pred, y) # 二分类交叉熵 optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * u.size(0) avg_loss total_loss / len(dataset) print(fepoch{epoch 1:02d} loss{avg_loss:.4f})优化器用 Adam 是深度推荐的默认选择它自带一阶动量和自适应学习率比 SGD 少调很多参数。损失函数用二元交叉熵因为 NCF 的本质是对「会看 / 不会看」做二分类。下面三个超参数是实际项目中翻车概率最高的超参数常见取值调参方向learning rate1e-3loss 震荡就降到 2e-4 或 5e-4batch size256显存紧张就 128数据量大可上 512epochs20~50配合早停看验证集 HR 决定learning rate 是这里面最值得花时间的。1e-3 在大多数推荐数据上能稳定收敛但如果发现前几个 epoch loss 下降后开始锯齿状震荡说明步子迈大了直接降一半。batch size 调到 256 以上时要注意评估阶段的内存占用——训练时 512 的 batch 在 GPU 上没问题全量预测打分那一步反而最容易爆显存。4.3 早停与模型保存防止白训一晚上很多初学者会让脚本固定跑完 50 个 epoch然后挑 loss 最低的模型拿去评估。问题是 loss 最低不等于指标最好NCF 这种隐式反馈模型太容易过拟合往往 30 轮之后 loss 继续降但 HR10 反而往下掉。我一般会在每个 epoch 结束时做一次验证集评估连续 5 轮 HR 没刷新就提前终止best_hr, no_improve 0.0, 0 patience 5 for epoch in range(epochs): # ... 上面的训练循环 ... hr, ndcg evaluate_loo(model, eval_users, eval_pos, eval_neg, K10, devicedevice) print(fepoch{epoch1:02d} HR10{hr:.4f} NDCG10{ndcg:.4f}) if hr best_hr: best_hr hr no_improve 0 torch.save(model.state_dict(), ncf_best.pt) # 只存最优权重 else: no_improve 1 if no_improve patience: print(fearly stop at epoch {epoch1}, best HR10{best_hr:.4f}) breaktorch.save 存的是 state_dict 而不是整个模型好处是换 PyTorch 小版本后加载更稳也省内存。patience 设 5 是平衡时间和效果的经验值设太小容易被评估集随机波动误杀设太大又失去早停意义。评估代码 evaluate_loo 的完整实现在下一章给出它是整个源码里最容易被写错的部分。还有一点早停判断的是验证集 HR不是训练集 loss。验证集和训练集必须在时间上严格切分否则早停会变成自我欺骗的机制。5. 避坑指南跑NCF视频推荐最常见的5个翻车现场5.1 现象loss 卡在 0.69 附近输出概率全在 0.5 左右原因0.69 正好是 -ln(0.5)也就是模型输出恒为 0.5 时交叉熵的值。第一嫌疑是正负样本比严重失衡负样本远多于正样本模型发现全输出低分就能拿到很低的 loss第二嫌疑是负样本没排除用户已看过的视频模型同时听到「这个视频要学成 0 又要学成 1」梯度互相打架。解决先检查 build_train_set 里 setdiff1d 是否生效再打印训练集的 label 分布正负比应该在 1:4 附近。如果正样本本身占比极低先把负采样比例降到 1:2 试跑一个 epoch 看 loss 是否立刻下降。也可以用一个小技巧把输出层的 bias 初始化为 log(正样本占比)让模型从正确的基础概率出发收敛会快很多。另外要甄别一种情况——loss 慢慢降到 0.5 以下但始终在 0.4 左右打转不算翻车隐式反馈的交叉熵本来就不会太低盯着 HR10 和 NDCG 才是正事。5.2 现象HR10 高到 0.8但换一批用户评估就崩了原因八成是评估集切分出了问题。最常见的是随机切分 train/test同一个用户既在训练集里学了自己的偏好又在测试集里拿自己的最后一条观看记录去考等于开卷考试HR 虚高很正常。第二个常见原因是评估时没排除训练集里的已观看视频正样本不是被模型「认出来」的而是被硬生生保送进 Top10。解决训练/测试切分必须按时间日志按 timestamp 排序后前 80% 时间做训练后 20% 做测试评估时每个用户的候选集要排除该用户在训练集里的全部交互记录。验证这个 bug 是否修好的办法很简单把测试集换成一批完全没有训练记录的新用户HR10 会立刻跌到接近随机水平那才是真实的召回能力。注意快速判断切分是否正确的统计方法是算训练集和测试集的用户重合度。理想情况是重合用户占测试集 60%~80%而不是 100%。5.3 现象评估代码比训练代码还慢甚至显存溢出原因很多初学者为了省事把全部用户×全部视频一次性喂给模型打分比如 1 万用户 × 10 万视频就是 10 亿条样本再大的 GPU 也扛不住。还有人用 for 循环逐用户打分每轮都做一次模型 forward速度慢到离谱。解决分 batch 预测是标准做法把候选集切成 1024 或 2048 一批循环里算完拼接结果。如果是 leave-one-out 评估每个用户只有 100 个候选直接用 for 循环都行但要确保候选构造用 numpy 批量采样而不是一层套一层的 Python 循环。显存实在不够就把评估放到 CPU 上跑10 万级候选集 CPU 也就几秒钟。5.4 现象embedding 维度调大后指标反而下降原因视频推荐数据天然稀疏多数用户只看过几十个视频embedding 维度越高需要学习的参数越多小数据上很容易过拟合。我见过一个 5 万用户×5 万视频的小数据集embedding 从 32 提到 128HR10 掉了近 8 个点训练集 loss 却一路走低典型的过拟合信号。解决视频这类高稀疏场景embedding 维度从 16 或 32 起步数据量翻十倍再往上加。同时把 dropout 从 0.2 提高到 0.3 或 0.4给训练过程加噪声。如果加完 dropout 指标还降检查是否给优化器加了 weight_decay1e-5 的 L2 正则能压住一部分过拟合。改维度后记得同步调整 mlp_dims 最后一层保证拼接后的向量维度搭配合理。5.5 现象同一份评估集两次训练结果差很多原因NCF 训练里有两个随机源一个是负采样的随机性一个是模型初始化和数据加载的随机性。如果都没固定种子两轮训练采到的负样本完全不同模型学到的决策边界自然不同。更麻烦的是评估时的 99 个负候选也是随机采样会让 HR10 的波动幅度上下浮动好几个点。解决在入口处固定所有随机源。PyTorch 里要同时设置 torch.manual_seed 和 numpy 的 default_rng 的 seedDataLoader 的 shuffle 也依赖 Python 随机种子三者缺一不可。更严谨的做法是把评估用的负候选集提前生成好存成文件所有对比实验用同一份候选集打分这样比出来的指标差异才是模型本身的差异而不是采样噪声。6. 进阶优化把离线模型变成视频召回服务的两个关键改造6.1 用embedding向量加近邻检索替代全量打分离线实验可以对全量视频打分但线上推荐不可能对几百万视频逐个算前向传播。常见做法是把训练好的 NCF 当 encoder导出视频向量建索引做召回。注意向量要拼接 GMF 和 MLP 两个分支的输出因为每条分支各自表达了一部分交互信号# 导出视频向量并归一化供后续余弦相似度检索 model.eval() video_ids torch.arange(n_items) with torch.no_grad(): gmf model.item_gmf(video_ids).cpu().numpy() mlp model.item_mlp(video_ids).cpu().numpy() video_vec np.concatenate([gmf, mlp], axis1) video_vec video_vec / np.linalg.norm(video_vec, axis1, keepdimsTrue) def similar_videos(video_id, top_k10): query video_vec[video_id] scores video_vec query # 归一化后点积即余弦相似度 return np.argsort(-scores)[:top_k]向量必须归一化否则点积受向量模长影响热门视频向量模长偏大会被无脑推给所有人。数据量大时把 video_vec 导入 ANN 索引建 HNSW 图召回几百个候选后再用 NCF 本身精排一轮是线上最稳的组合方式。但这一步召回结果偏「相似」而非「个性化」所以线上通常把用户向量也存下来用内积召回再叠加类别多样性规则。6.2 给冷启动视频一个类目均值向量NCF 的 embedding 只对训练中出现过的视频有效新视频没有任何观看记录在模型里根本没有向量。我的处理方式是借助视频类目信息做冷启动把同类目下已上线视频的向量均值作为新视频初始向量等它积累了几十条观看记录再进训练集正式学习。video_meta df.groupby(i)[category].first() cat_vec {} for cat in video_meta.unique(): ids video_meta[video_meta cat].index cat_vec[cat] video_vec[ids].mean(axis0) def init_cold_item(category): emb cat_vec.get(category, np.zeros_like(video_vec[0])) return emb / np.linalg.norm(emb)均值向量在视频场景的效果比电商明显因为视频类目粒度粗、同类视频的用户群体高度重叠类目均值已经能提供中等偏上的召回质量。等新视频进入训练集后它的 embedding 会随训练逐步偏离初始值所以冷启动向量只做临时占位。说到这想起来我之前吃过一次亏第一次做冷启动时直接把均值向量写回模型权重结果下一次全量训练把新视频的 embedding 也一并初始化线上效果忽上忽下。后来改成只存向量不写权重新视频只在召回阶段用正式训练照常全量跑问题才消停。这类细节看起来不起眼但往往就是线上和离线差距最大的地方。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑