简介:基于深度学习神经网络协同过滤模型(NCF)的视频推荐系统源码,面向推荐系统学习者和深度学习实践者,便于理解如何用神经网络建模用户与视频之间的交互关系。资源完整覆盖从数据读取、特征构造、模型构建到训练评估的流程,能够直观展示NCF相对传统协同过滤的优势,适合课程练习、项目复现或算法对比。压缩包共三个文件,包括一个用Python编写的核心脚本、一个CSV格式的测试数据文件以及一个Markdown说明文档,整体仅四KB左右,结构精简,便于下载后快速阅读、运行和调试。所有源码均已在本地编译验证,难度适中,内容经助教老师审定,能够满足自主学习和教学使用需求。已有约两百人学习浏览,适合对照论文逐行理解模型细节,也可替换为自有视频观看或点击数据进行迁移实验,作为毕业设计或推荐系统方向的入门参考。
1. 基于深度学习的 NCF 视频推荐系统:这份源码到底解决什么问题
手里有几十万条视频观看日志,想让每个用户下次打开时看到自己真正想看的视频,传统的矩阵分解在数据跑到一定规模后指标几乎不再增长,而基于深度学习的神经网络协同过滤(NCF)是这类问题里投入产出比最高的升级路线之一。标题里的源码 zip,本质就是一套把 Python 数据处理、深度学习模型训练、视频推荐候选生成和离线评估串起来的完整工程骨架。你拿到手后不需要从零造轮子,改数据路径、调几个参数就能跑通从原始日志到 Top-N 推荐结果的闭环。这篇文章按“原理怎么看、代码怎么写、坑在哪、怎么上线验证”的顺序拆开讲,适合正在做视频站推荐、内容 App 冷启动,或者拿这个方向写课程设计的人。
2. 先看懂 NCF 再动手:神经网络协同过滤的原理与选型
很多人在跑通代码之后才发现,自己根本不知道为什么这么设计,遇到负采样比例不对、评估指标虚高这种问题也无从下手。所以我不急着贴训练代码,先把 NCF 的原理和选型理由讲透。
2.1 矩阵分解为什么到顶:内积拟合不了非线性交互
传统协同过滤把行为矩阵拆成用户因子和物品因子,预测分数等于两个向量的内积。但在视频场景里,一个用户的行为并不线性:有人只看健身视频,有人白天看装机教程、晚上刷美食,还有人会因为一部剧追完整个演员的作品集。这些跨类目的兴趣组合,靠一个点积很难描述清楚。矩阵分解相当于给每个用户压了一条兴趣直线,而实际兴趣往往是分叉的、带边界的曲面。
NCF 的核心改动,是把“内积”这个固定算子换成“神经网络拟合的交互函数”。模型仍然给用户和视频分别学 embedding,但预测分数改由网络输出,中间有非线性激活,于是能表达更复杂的用户-物品关系。刚开始跑 NCF 的人最容易有的错觉是“这不就是加了层 MLP 的矩阵分解”,实际差别在于交互层从线性变成非线性,模型容量和解空间都变了。
还有一点容易被忽略:视频推荐几乎没有显式评分,大部分行为是隐式的“看过/没看过”。矩阵分解那套预测 1 到 5 分的思路在这里并不自然,NCF 从设计上就是为隐式反馈服务的,输出经过 sigmoid 当作点击概率来建模,这更贴合视频站的真实信号。选型时不用纠结“要不要上注意力机制、要不要上 Transformer”,对大多数视频推荐的第一版,NCF 是性价比最高的选择。
2.2 GMF、MLP 与 NeuMF:NCF 模型结构拆分
NCF 论文里最经典的结构是双通道,后来的工业实现大多沿用下面这三块:
| 模块 | 交互方式 | 特点 | 在视频场景中的作用 |
|---|---|---|---|
| GMF | 用户向量与物品向量逐元素相乘 | 保留矩阵分解的逐维度匹配语义 | 快速捕捉“用户喜欢动作片”这种粗粒度偏好 |
| MLP | 拼接向量后过若干全连接层 | 非线性交叉,表达能力更强 | 捕捉跨类目、组合型兴趣,比如“科技评测+萌宠” |
| NeuMF | GMF 与 MLP 输出拼接后再映射 | 两类信息互补 | 最终打分统一由融合层完成,效果通常最好 |
GMF 分支把用户 embedding 和视频 embedding 做逐元素乘积,再通过一个线性层映射成分数,本质是“每个维度单独匹配再求和”。MLP 分支则把两个 embedding 拼接起来,过几层带 ReLU 的全连接,让网络自己学高阶交叉。NeuMF 在最后一层把两个分支的输出拼起来再过一个线性层,我用 concat 而不是手动加权相加,原因是让模型自己学两个分支的权重,省去人工调参。
embedding 维度这件事值得多说一句:用户数或视频数过百万时,embedding 维度取 16 到 64 就够,再大只会涨显存,效果提升很有限。MLP 层我常用[64, 32, 16]或[32, 16],两层也能跑出不错的结果。真正影响效果的是负采样个数、学习率和训练数据质量,而不是网络深度。
2.3 隐式反馈的样本构造:正样本、负样本与曝光偏差
训练 NCF 时,我们把“用户看过某个视频”记为正样本,需要同时构造负样本。最常见的做法是随机负采样:从用户没看过的视频里随机抽几个标成 0。但这里有两个隐藏问题:一是“没看过”不等于“不喜欢”,可能是没曝光过;二是随机负采样会把热门视频大量漏进负样本,导致模型倾向把所有视频都预测成正样本。
下面这段代码是我在视频推荐项目里最常用的负采样写法,直接给定一个行为日志 DataFrame,返回训练用的用户 ID、视频 ID 和标签数组:
import numpy as np import pandas as pd def build_train_samples(df, neg_ratio=4, seed=42): rng = np.random.default_rng(seed) df["label"] = 1 pos = df[["user_id", "video_id", "label"]].values neg_list = [] all_video = df["video_id"].unique() for u, v, _ in pos: # 找出该用户没看过的视频,作为负样本候选池 watched = df.loc[df["user_id"] == u, "video_id"].values candidates = all_video[~np.isin(all_video, watched)] neg = rng.choice(candidates, size=neg_ratio, replace=False) for nv in neg: neg_list.append((u, nv, 0)) neg = np.array(neg_list) X_u = np.concatenate([pos[:, 0], neg[:, 0]]).astype(np.int64) X_i = np.concatenate([pos[:, 1], neg[:, 1]]).astype(np.int64) y = np.concatenate([pos[:, 2], neg[:, 2]]).astype(np.float32) return X_u, X_i, y这里neg_ratio是负采样比例,视频推荐里常见取 4,数据量大时可以降到 2,数据稀疏就提到 6 到 8。代码里用np.isin过滤掉用户看过的视频,避免把正样本当成负样本。注意,它仍属于随机负采样,如果手里有曝光日志,应该优先用“曝光但未点击”作为负样本,那才是真实的负反馈信号。
3. 用 PyTorch 实现 NCF 视频推荐:数据处理、模型构建、训练评估一次跑通
原理说完,接下来按“最小可跑通”的方式,把这份视频推荐源码里最核心的三段代码拆开讲。环境方面,Python 3.8 以上加 PyTorch 2.x 就够。
3.1 行为日志清洗与用户、视频 ID 编码
第一步是把原始观看日志变成模型能吃的整数 ID。原始数据可能长这样:user_id, video_id, timestamp, watch_ratio。watch_ratio是观看时长占比,我一般把它作为有效交互的判定条件。
import pandas as pd log = pd.read_csv("view_log.csv") # 只看完一半以上的行为才记为正样本 log = log[log["watch_ratio"] >= 0.5] # 过滤低频用户和低频视频,低于 5 次交互的直接丢弃 user_cnt = log.groupby("user_id")["video_id"].count() video_cnt = log.groupby("video_id")["user_id"].count() log = log[log["user_id"].isin(user_cnt[user_cnt >= 5].index)] log = log[log["video_id"].isin(video_cnt[video_cnt >= 5].index)] # 重新编码为从 0 开始的整数 ID log["user_id"] = log["user_id"].astype("category").cat.codes log["video_id"] = log["video_id"].astype("category").cat.codes num_users = log["user_id"].nunique() num_videos = log["video_id"].nunique()这段代码把行为日志压缩成训练所需的三元组结构。watch_ratio >= 0.5这个阈值是经验值,如果你的场景是“点赞”或“收藏”,可以直接去掉这一行,把原始行为视为正样本。低频过滤很关键,否则会出现大量只出现过一次的用户和视频,让 embedding 矩阵变成一个稀疏的黑匣子,模型既学不稳也容易过拟合。
过滤后重新编码 ID,用astype("category").cat.codes能同时完成字符串或大整数 ID 的映射。但你要注意保存映射关系,否则上线推理时拿不到新用户 ID 对应的编号,这一步在正式系统里通常用两张字典表来完成。
3.2 定义 NCF 模型:embedding 层、GMF 分支、MLP 分支与融合输出
模型定义我用 PyTorch 写,结构就是前面原理部分说的三块:GMF 逐元素乘积、MLP 拼接、最后融合。
import torch import torch.nn as nn class NCF(nn.Module): def __init__(self, num_users, num_items, embed_dim=32, mlp_dims=[64, 32, 16]): super().__init__() self.user_emb = nn.Embedding(num_users, embed_dim) self.item_emb = nn.Embedding(num_items, embed_dim) # GMF 分支:用户向量与视频向量逐元素相乘后映射到 1 维 self.gmf_out = nn.Linear(embed_dim, 1) # MLP 分支:拼接后过两层全连接,中间用 ReLU mlp_layers = [] in_dim = embed_dim * 2 for d in mlp_dims: mlp_layers.append(nn.Linear(in_dim, d)) mlp_layers.append(nn.ReLU()) in_dim = d self.mlp = nn.Sequential(*mlp_layers) self.mlp_out = nn.Linear(in_dim, 1) # 融合两个分支的输出,让模型自己学权重 self.predict = nn.Linear(2, 1) def forward(self, user_ids, item_ids): u = self.user_emb(user_ids) v = self.item_emb(item_ids) gmf = self.gmf_out(u * v) mlp = self.mlp(torch.cat([u, v], dim=-1)) mlp = self.mlp_out(mlp) out = self.predict(torch.cat([gmf, mlp], dim=-1)) return torch.sigmoid(out).squeeze(-1)这里有几个参数直接影响模型行为和效果。
embed_dim=32是 embedding 维度。视频站用户量和物品量通常在几十万以上,32 维已经足够表达大部分偏好结构,再大收益甚微且显存开销翻倍。mlp_dims=[64, 32, 16]是 MLP 分支的隐藏层宽度,从 64 递减到 16,和常见推荐模型的习惯一致。如果你更在意推理速度,可以把embed_dim降到 16,mlp_dims改成[32, 16],效果损失通常在一个点以内。
注意最后torch.sigmoid,我们把它当作点击概率来优化。负样本占比高的场景下,预测值会普遍偏低,这很正常,不要因此怀疑代码写错了。
3.3 训练循环与 Top-N 评估:损失函数、学习率与命中率
训练阶段用 BCE 损失和 Adam 优化器。对隐式反馈来说,这就是一个二分类问题,正样本是“看过”,负样本是“没看过”。学习率我一般从1e-3起步,如果 loss 震荡就降到5e-4。
def train_one_epoch(model, opt, loss_fn, X_u, X_i, y, batch_size=1024): model.train() idx = torch.randperm(len(X_u)) total_loss = 0 for start in range(0, len(X_u), batch_size): batch = idx[start:start + batch_size] opt.zero_grad() pred = model(X_u[batch], X_i[batch]) loss = loss_fn(pred, y[batch]) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() total_loss += loss.item() return total_loss / max(1, len(X_u) // batch_size)评估函数重点看 Top-N 命中率,也就是每个测试用户真实看过的视频有没有出现在模型打分最高的前 N 个里。
@torch.no_grad() def evaluate_hr_ndcg(model, test_users, test_true, all_video_ids, k=10): model.eval() hits = 0 ndcg = 0.0 for user, true_item in zip(test_users, test_true): # 构造候选集:真实视频 + 随机 999 个负视频 cand = np.random.choice(all_video_ids, 1000, replace=False) cand = np.append(cand, true_item) scores = model( torch.full((len(cand),), user, dtype=torch.long), torch.tensor(cand, dtype=torch.long) ) topk = torch.topk(scores, k).indices.tolist() if true_item in topk: hits += 1 rank = topk.index(true_item) ndcg += 1.0 / np.log2(rank + 2) return hits / len(test_users), ndcg / len(test_users)这里的候选集取“1 个真实视频 + 999 个随机负视频”,是为了模拟线上“从全量视频池里挑一个”的难度。评估指标 HR@10(命中率)和 NDCG@10(排序质量)要一起看,HR 高只说明推荐列表里有对的,NDCG 高才说明对的排在前面。如果你用随机切分而不是按时间切分来划分训练测试集,这两个指标都会虚高,后面避坑章节会专门讲。
4. NCF 视频推荐避坑指南:五个高频翻车现场与排查路径
代码跑通不难,真正花时间的是调参和排错。这里整理五个我在视频推荐项目里反复遇到的坑,按“现象、原因、解决”写清楚。
4.1 训练 loss 一直在降,推荐结果却全是热门视频
现象:模型训练正常,loss 稳步下降,但打开推荐列表,前十个全是平台热门视频,每个用户的推荐结果几乎一样。
原因:这是隐式反馈数据里最常见的陷阱。热门视频天然拥有大量正样本,模型只要学会“预测热门视频为 1”就能拿到很低的 loss。此时个性化信号被头部流量淹没了。
解决:两步走。第一步,负采样时把热门视频的曝光概率降低,或者按物品热度做降采样,避免热门视频频繁出现在负样本里;第二步,评估时单独统计“非热门视频命中率”,只统计播放量在后 80% 的冷门视频有没有被推荐出来。如果这个指标有明显提升,说明模型学到的是真实偏好而不是热度偏置。
4.2 Embedding 维度一调大,显存直接爆掉
现象:为了追求效果,把embed_dim从 32 调成 128,结果训练还没开始,显卡就 OOM 了。
原因:NCF 最大的显存开销不是网络层,而是两个 embedding 表。100 万用户乘以 128 维再乘 4 字节,单用户表就占 512MB,两个表加上中间变量,小显存卡直接扛不住。
解决:不要盲目调大 embedding 维度。先看用户和物品数量,百万量级用 32 维、千万量级用 16 维是合理起点。如果确实需要更大容量,用 hash 分桶把用户 ID 映射到固定区间,或者把 embedding 切成多份做模型并行。实在不行就给 embedding 表单独开 CPU 内存,通过nn.Embedding.from_pretrained配合torch.no_grad做离线索引。
4.3 新用户没有任何行为,模型返回空列表
现象:线上接入后,注册当天的新用户请求推荐,接口直接抛异常,或者返回空列表。
原因:NCF 的输入是用户 ID,模型只为训练集中出现过的用户保存了 embedding。一个全新的user_id在 embedding 矩阵里根本找不到对应向量,拿不到任何排序结果。这是协同过滤类模型的通病,不是代码 bug。
解决:在模型外面包一层兜底逻辑。新用户直接返回热榜加分类榜,比如“全站热门 Top50”加“每个频道热门 Top10”;同时记录新用户的实时点击行为,攒够 5 到 10 条交互后重新请求 NCF 打分。工业系统里通常会单独训练一个用内容特征做冷启动的双塔模型,接在 NCF 前面,但对源码级项目来说,先把兜底接口写好比什么都重要。
4.4 离线评估 HR@10 虚高,上线后点击率反而下降
现象:测试集上 HR@10 做到 0.35,感觉效果不错,上线 A/B 测试却发现新推荐位的点击率还不如原来按播放量排序的版本。
原因:离线评估和线上环境不一致。最常见的问题是“随机切分数据”:把用户十天的行为随机分成训练集和测试集,模型在训练时已经见过测试集里视频的其它用户行为,相当于开卷考试。另一个问题是位置偏差:线上用户本来就更容易点排在前面和封面更醒目的视频,离线评估完全没有这个因素。
解决:评估协议改成按时间切分,用第 1 天到第 14 天的行为训练,用第 15 天到第 21 天的行为测试,模拟真实预测场景。这和你跑量化交易策略回测是一个道理,训练集和测试集错位越多,指标就越接近上线后的真实水平。上线后先切 10% 流量跑三天,对比点击率、人均完播时长和次留,再决定要不要全量。
4.5 训练到一半 loss 变成 NaN,或者模型输出全是一个值
现象:训练第 10 个 epoch 时 loss 突然变成nan,重启之后有时又变成模型对所有样本都输出相似分数。
原因:学习率过大导致梯度爆炸,或者 embedding 初始化方差太大,某些维度数值溢出到inf。负采样比例过大时样本类别严重不平衡,也会让输出层梯度不稳定。
解决:先用lr=1e-3以下的学习率,配合 Adam 优化器;在训练循环里加torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0),这行保护代码我已经写在前面训练函数里了。如果nan还是出现,检查数据里是否有空 ID 或缺失值,把负采样比例从 8 降到 4。模型输出全是一个值的另一常见原因是最后一层权重初始化过大,给self.predict换用nn.init.xavier_uniform_能缓解。
5. 从源码到正式视频推荐系统:召回、排序与冷启动
源码跑通只是第一步,真正要用在视频站上,需要把单模型扩展成“召回 + 排序 + 兜底”的完整链路。这一章讲清楚工程化的三个关键环节。
5.1 召回层:百万视频量级下没法全量过 NCF
如果把全量视频都塞给 NCF 打分,用户数 10 万、视频数 50 万时,一次推荐就要算 500 亿次前向,这在线上完全不可行。所以工业流做法是分两步:先用召回把候选视频从百万级缩小到几百级,再由 NCF 精排。
NCF 训练完的item_emb.weight本身就是现成的视频向量,可以直接用来构建近似最近邻索引。我一般用 faiss 建一个内积索引,因为视频向量的模长本身包含一定热度信息,用内积比纯余弦更合适。
import faiss import numpy as np item_vec = model.item_emb.weight.detach().numpy().astype("float32") index = faiss.IndexFlatIP(item_vec.shape[1]) index.add(item_vec) # 给某个视频找 50 个相似视频 scores, idx = index.search(item_vec[123:124], 50)这段代码把模型训练好的视频 embedding 灌进 faiss 索引,查询时按向量内积找相似视频。召回阶段还可以叠加多个通道:用户最近看过的 10 个视频各取 20 个相似视频,再合并去重,加上一个全局热门通道兜底,候选集就基本覆盖了用户可能感兴趣的范围。
5.2 排序层:NCF 打分和视频内容特征怎么融合
纯 NCF 只用到了用户 ID 和视频 ID,没用到视频标题、分类、上传时长这些内容特征。在正式视频系统里,排序阶段通常会把 NCF 的输出当成一个特征,再叠加人工设计的统计特征一起进入上层模型。
常见做法有三种。第一种最省事:把 NCF 预测分数、视频完播率、点赞率、和该用户频道的匹配度拼成一个特征向量,喂给一个浅层 DNN 或梯度提升树做最终排序。第二种是把视频侧信息直接加到 NCF 输入里,比如给视频 embedding 拼接类型 embedding 和热度 embedding。第三种是重排阶段再做一次规则干预,比如连续三个短视频后插入一条长视频,或者限制同一作者的视频最多出现两条。
深度强化学习算法近几年也开始用于重排阶段,目标是让整次会话的完播时长最大化,而不是只优化单次点击。但那是比较靠后的迭代方向,第一版先把 NCF 和统计特征融合跑稳,别急着上强化学习,否则模型复杂度和调参成本会一起失控。
5.3 冷启动与新内容:没有行为数据时怎么兜底
冷启动分两头:新用户没有行为,新视频没有曝光。对新用户,最实用的是分渠道热门榜,按“全站播放热度、分类热度、注册时间相似用户的热度”各取一个列表融合。对新视频,可以先用视频标题和封面抽文本向量、图像向量,把它和已有视频做相似度匹配,推荐给偏好该内容类型的用户。
在这种临时推荐里,我经常用 python 构建邻接矩阵,把视频之间的共现关系直接算成相似度表。所谓共现,就是“被同一个用户在短时间内连续观看的两个视频”,统计它们的共现次数,按用户数归一化后得到一张视频相似度图。这张图虽然粗糙,但用来给新视频做冷启动兜底比纯热榜精准得多。等新视频积累到一定曝光量,再让它进入 NCF 的正常训练流程。
日志回流是维持这个闭环的关键。线上每次曝光、点击、完播都要写回日志,模型每天或每周增量训练一次。增量训练时不要从零开始,而是在前一天模型参数上继续训练,这样既能吸收新数据,又不会让旧用户偏好被冲掉。
6. 离线评估与上线验证:NCF 源码里最值得先改的三处代码
最后说验证。一套视频推荐 NCF 源码拿到手,我通常先改三处:评估切分方式、指标报告维度、小流量实验开关。
6.1 把随机切分改成时间切分
这是改动优先级最高的一处。用timestamp排序后按前 70% 训练、后 30% 测试,而不是按行随机切。代码改动不大,但能直接挤掉离线指标里虚高的水分。
6.2 补齐三个被忽略的指标
表格里加三列:非热门命中率、覆盖率、单用户推荐列表去重率。非热门命中率衡量个性化强度,覆盖率看推荐结果有没有集中在热门视频上,去重率防止同一个作者的视频扎堆出现在一屏里。
| 指标 | 含义 | 合理范围参考 |
|---|---|---|
| HR@10 | 真实观看视频出现在前 10 的比例 | 0.1 到 0.4 之间视稀疏度而定 |
| NDCG@10 | 前 10 列表排序质量 | 通常比 HR 低 30% 到 50% |
| 非热门命中率 | 冷门视频被推荐出来的比例 | 低于 5% 说明模型退化成热榜 |
6.3 留好 A/B 测试接口
这是从源码到上线最关键的一步。在代码里预留一个is_ab开关,线上放量时按用户 ID 哈希分流,让 10% 流量走 NCF 推荐,其余继续走老策略,对比三天后的点击率和人均完播时长再决定是否全量。
我自己在视频推荐上踩过最大的坑,是把离线指标优化当成了目标,结果上线后才发现用户根本不买账。后来养成的习惯是,无论离线指标涨得多漂亮,每次改动都先切小流量跑三天,看数据再决定下一步。推荐系统是个反馈闭环,离线是起点,线上才是考场。希望帮到你。
本文还有配套的精品资源,点击获取