简介:这份资源是面向计算机相关专业在校学生、教师及企业员工的学习资料,核心为基于深度学习神经网络协同过滤模型(NCF)的视频推荐系统Python实现,适合用作毕业设计、课程设计、作业或项目初期立项演示,也便于基础较好的读者在此基础上二次修改以扩展功能。压缩包共3个文件,包含1个py源码文件、1个csv数据集和1个md说明文档,整体约3KB,体量轻便,便于快速阅读与运行调试。目前已有293人学习下载,说明该方向具备一定关注度。源码经过测试运行成功后才上传,答辩评审平均分达到96分,读者可从中获取NCF模型在TensorFlow下的完整实现思路、推荐系统数据处理流程以及配套文档说明,帮助理解神经网络协同过滤的建模逻辑与工程落地方式,为推荐系统相关课题提供可参考的代码骨架与实验基础。
1. 从一份毕设源码说起:NCF 视频推荐系统到底能跑出什么
如果你正在做推荐系统方向的课程设计或毕业设计,大概率会遇到一个尴尬局面:协同过滤的矩阵分解代码跑通了,但答辩老师一句“你这和深度学习有什么关系”就能把你问住。这份基于神经协同过滤(Neural Collaborative Filtering,NCF)的视频推荐系统 Python 源码,恰好卡在这个痛点上——它用 TensorFlow 把传统矩阵分解的内积操作换成了多层神经网络,让“深度学习”四个字不再只是论文里的装饰。资源包里包含ncf_tensorflow.py主脚本、data与test-data.csv数据文件、README.md说明文档,结构干净,没有多余的工程脚手架。适合计算机、人工智能、通信工程等专业的在校学生拿来做毕设或课设,也适合刚接触推荐系统、想找一个能跑通的最小 NCF 实现来拆解学习的人。它解决的不是“工业级推荐”问题,而是“让你在答辩时能讲清楚 NCF 到底比矩阵分解强在哪”这个问题。
2. NCF 的模型结构与数据流转:从 one-hot 到预测分数
2.1 为什么 NCF 不是“矩阵分解加个激活函数”
很多人第一次看 NCF 论文时会有个误解,觉得它就是在矩阵分解的 user/item 隐向量内积后面加了个 ReLU。这个理解偏差会导致你在读代码时找不到重点。NCF 的核心改动在于:它把交互函数从固定的内积换成了可学习的多层感知机(MLP)。矩阵分解的本质是假设用户和物品的隐空间交互是线性的,内积就是这种线性关系的度量。但现实中的用户行为——比如“因为喜欢 A 视频所以推荐 B 视频”——往往是非线性的。NCF 的 GMF 部分保留了内积的线性建模能力,MLP 部分则负责捕捉非线性交互,两者拼接后输出最终预测分数。
这份源码里ncf_tensorflow.py的实现走的是 GMF + MLP 的融合路线,而不是纯 MLP。这个选择是有讲究的:纯 MLP 在小数据集上容易过拟合,而 GMF 分支相当于给模型加了一个线性先验,训练更稳。你在答辩时如果被问到“为什么不用纯 MLP”,这就是标准答案。
2.2 数据格式与预处理:test-data.csv 里有什么
打开data目录下的test-data.csv,你会看到典型的隐式反馈数据格式:用户 ID、物品 ID、交互标签(0 或 1)。这里有个容易翻车的地方——很多同学拿到数据直接往模型里灌,结果发现 loss 不下降。原因是 NCF 做的是二分类任务,标签必须是 0/1,而且负采样比例要控制好。源码里默认的负采样策略是每个正样本配 4 个负样本,这个比例在视频推荐场景下比较合理,因为视频的曝光-点击转化率通常不高,负样本太少会导致模型学不到“不感兴趣”的信号。
数据预处理的另一个关键是 ID 重映射。原始数据里的用户 ID 和物品 ID 可能是稀疏的、不连续的,直接做 embedding lookup 会浪费大量内存。源码里用LabelEncoder做了重映射,把原始 ID 压缩到[0, num_users)和[0, num_items)的连续区间。这一步不做,后面 embedding 矩阵会大到让你怀疑人生。
# 数据加载与 ID 重映射的核心逻辑 import pandas as pd from sklearn.preprocessing import LabelEncoder def load_and_preprocess(filepath): df = pd.read_csv(filepath) # 用户和物品 ID 重映射,压缩到连续区间 user_enc = LabelEncoder() item_enc = LabelEncoder() df['user'] = user_enc.fit_transform(df['user_id']) df['item'] = item_enc.fit_transform(df['item_id']) num_users = df['user'].nunique() num_items = df['item'].nunique() # 标签必须是 0/1,否则二分类交叉熵会报错 df['label'] = df['label'].astype('float32') return df, num_users, num_items这段代码的逻辑很直白:先读 CSV,然后用LabelEncoder把原始 ID 转成连续整数。参数说明方面,fit_transform返回的是 numpy 数组,直接赋值给 DataFrame 列即可。注意num_users和num_items要在重映射之后统计,否则拿到的是原始 ID 的数量,可能对不上。如果你换自己的数据集,只要保证 CSV 里有user_id、item_id、label三列,这段代码就能直接复用。
2.3 模型定义:GMF 与 MLP 的融合实现
源码里的模型定义部分是整个文件的核心。GMF 分支做的是 element-wise product,也就是两个 embedding 向量逐元素相乘;MLP 分支则是把两个 embedding 拼接后过几层全连接。最后两个分支的输出拼接,再过一个输出层得到预测分数。
import tensorflow as tf from tensorflow.keras import layers, Model class NCF(Model): def __init__(self, num_users, num_items, gmf_dim=8, mlp_dim=8, mlp_layers=[64, 32, 16]): super(NCF, self).__init__() # GMF 分支的 embedding self.gmf_user_emb = layers.Embedding(num_users, gmf_dim) self.gmf_item_emb = layers.Embedding(num_items, gmf_dim) # MLP 分支的 embedding self.mlp_user_emb = layers.Embedding(num_users, mlp_dim) self.mlp_item_emb = layers.Embedding(num_items, mlp_dim) # MLP 全连接层 self.mlp_fc = [layers.Dense(dim, activation='relu') for dim in mlp_layers] # 输出层 self.output_layer = layers.Dense(1, activation='sigmoid') def call(self, inputs): user, item = inputs # GMF 分支:逐元素相乘 gmf_vec = self.gmf_user_emb(user) * self.gmf_item_emb(item) # MLP 分支:拼接后过全连接 mlp_vec = tf.concat([self.mlp_user_emb(user), self.mlp_item_emb(item)], axis=-1) for fc in self.mlp_fc: mlp_vec = fc(mlp_vec) # 融合两个分支 concat = tf.concat([gmf_vec, mlp_vec], axis=-1) return self.output_layer(concat)参数说明:gmf_dim和mlp_dim分别控制两个分支的 embedding 维度,默认都是 8。mlp_layers定义了 MLP 分支的隐藏层结构,默认是[64, 32, 16],逐层递减。输出层用 sigmoid 是因为要做二分类,输出值在 0 到 1 之间,可以解释为“用户对物品感兴趣的概率”。如果你把mlp_layers改成[128, 64, 32, 16],模型容量会变大,但在小数据集上更容易过拟合,建议先跑默认配置看效果。
3. 训练流程与参数调优:让 loss 真正降下来
3.1 编译与训练:损失函数和优化器的选择
NCF 做的是二分类,损失函数用binary_crossentropy是标准做法。优化器方面,源码里用的是 Adam,学习率默认 0.001。这个组合在大多数推荐数据集上都能跑出合理的结果。但有个细节容易被忽略:binary_crossentropy在 TensorFlow 里有from_logits参数,如果你在输出层已经加了 sigmoid,那from_logits要设为 False(默认值),否则会重复做一次 sigmoid,导致梯度消失。
# 模型编译与训练 model = NCF(num_users, num_items) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss=tf.keras.losses.BinaryCrossentropy(from_logits=False), metrics=['accuracy'] ) # 训练时注意 batch_size 和 epochs 的配合 history = model.fit( x=[train_user, train_item], y=train_label, batch_size=256, epochs=20, validation_split=0.1, verbose=1 )参数说明:batch_size=256是经验值,太小会导致训练震荡,太大则收敛慢。epochs=20在默认数据集上通常够用,你可以通过观察val_loss是否还在下降来决定要不要加。validation_split=0.1表示从训练集里切 10% 做验证,这个比例在数据量不大时比较合适。如果你发现训练集 accuracy 很高但验证集 accuracy 很低,那就是过拟合了,需要减小mlp_layers的层数或者加 Dropout。
3.2 评估指标:Hit Rate 和 NDCG 怎么算
分类准确率在推荐系统里其实是个误导性指标。因为负样本是采样出来的,准确率高不代表推荐结果好。真正该看的是 Hit Rate@K 和 NDCG@K。Hit Rate@K 衡量的是“给用户推荐 K 个物品,有多少个是用户真正交互过的”,NDCG@K 则进一步考虑了推荐位置的影响——排在越前面的命中,得分越高。
import numpy as np def hit_rate_at_k(model, test_data, k=10): hits = 0 for user_id in test_data['user'].unique(): user_items = test_data[test_data['user'] == user_id]['item'].values # 对所有物品打分 all_items = np.arange(num_items) user_array = np.full_like(all_items, user_id) scores = model.predict([user_array, all_items], verbose=0).flatten() # 取 top-K top_k_items = np.argsort(scores)[-k:] if len(set(top_k_items) & set(user_items)) > 0: hits += 1 return hits / test_data['user'].nunique()这段代码的逻辑是:对每个用户,给所有物品打分,取分数最高的 K 个,看这 K 个里有没有用户实际交互过的物品。参数k=10表示推荐列表长度,你可以改成 5 或 20 来观察指标变化。注意model.predict在循环里调用会比较慢,实际跑的时候可以把所有用户的打分批量算出来,这里为了逻辑清晰用了逐用户循环。
3.3 超参数调整:embedding 维度和负采样比例
embedding 维度是 NCF 里最敏感的超参数之一。维度太低,模型表达能力不够,loss 降不下去;维度太高,参数量暴涨,小数据集上直接过拟合。源码默认的gmf_dim=8和mlp_dim=8偏小,但在数据量不大的毕设场景下反而更稳。如果你换一个稍大的数据集,可以试着把这两个值调到 16 或 32。
负采样比例同样关键。默认的 1:4 是论文里的推荐值,但在视频推荐场景下,用户-物品交互矩阵非常稀疏,1:4 可能还不够。你可以试着调到 1:8 甚至 1:10,观察 Hit Rate 的变化。但要注意,负采样比例太高会导致训练时间线性增长,而且模型可能学到“大部分物品都是负样本”的先验,反而降低推荐多样性。
提示:调参时每次只改一个参数,改完跑完整训练再对比指标。同时改多个参数,你根本不知道是哪个起了作用。
4. 避坑与排查:那些让 loss 不降、指标不涨的坑
4.1 现象:loss 从第一个 epoch 开始就不降
原因:最常见的原因是标签没转成 float32。binary_crossentropy要求标签是浮点数,如果 CSV 读进来是 int 或 object 类型,TensorFlow 会在计算 loss 时静默出错,表现为 loss 一直卡在 0.693 左右(也就是 ln2)。另一个可能原因是 embedding 的输入维度对不上——比如num_users统计的是重映射前的数量,但输入的是重映射后的 ID,导致越界。
解决:在数据预处理阶段强制df['label'] = df['label'].astype('float32'),并且在模型定义时打印num_users和num_items,确认和实际输入的最大 ID 一致。如果还是不对,在model.fit之前加一行print(train_user.max(), train_item.max()),看看有没有超出 embedding 维度。
4.2 现象:训练集 accuracy 到 0.9 但验证集只有 0.6
原因:典型的过拟合。NCF 的 MLP 分支参数量不小,如果mlp_layers设得太深(比如[256, 128, 64, 32]),在小数据集上很容易记住训练样本。另外,如果负采样比例太低(比如 1:1),模型见到的负样本太少,泛化能力会变差。
解决:先减小mlp_layers的层数和每层维度,比如从[64, 32, 16]降到[32, 16]。然后在 MLP 分支的每个全连接层后面加 Dropout,比例设 0.2 到 0.5 之间。如果还不行,提高负采样比例到 1:8,让模型见到更多负样本。
4.3 现象:Hit Rate@10 算出来是 0
原因:model.predict的输出是 sigmoid 后的概率值,但如果你在输出层用了 sigmoid 又在 loss 里设了from_logits=True,预测值会全部接近 0.5,排序后取 top-K 相当于随机取。另一个可能是测试集里的用户 ID 没有在训练集里出现过,embedding 查不到对应的向量。
解决:检查 loss 的from_logits参数和输出层激活函数是否匹配。如果输出层有 sigmoid,from_logits必须是 False。然后在算 Hit Rate 之前,先过滤掉测试集中用户 ID 不在训练集里的记录,或者给未知用户返回随机推荐。
4.4 现象:训练到一半 loss 突然变成 NaN
原因:学习率太大导致梯度爆炸。Adam 默认学习率 0.001 在大多数情况下没问题,但如果你的数据里有一些极端值(比如某个用户交互了上万次),梯度可能会异常大。另一个可能是 embedding 的初始化方差太大,导致前向传播的输出爆炸。
解决:把学习率降到 0.0001 试试。如果还不行,在 embedding 层加embeddings_initializer='glorot_uniform',并且在全连接层后面加 BatchNormalization。另外,检查数据里有没有重复计数的问题——同一个用户-物品对出现多次,会导致标签累加,loss 计算异常。
4.5 现象:换了数据集后模型完全跑不通
原因:不同数据集的 ID 格式、标签定义、列名都不一样。源码里的test-data.csv用的是user_id、item_id、label三列,如果你的数据集列名不同,或者标签是 1/2 而不是 0/1,预处理代码就会出错。
解决:在load_and_preprocess函数里加一层列名映射,把不同数据集的列名统一成user_id、item_id、label。标签如果是 1/2,做一次df['label'] = df['label'] - 1。另外,检查数据集里有没有缺失值,dropna()一下再送进模型。
5. 从跑通到讲清楚:答辩演示与代码修改的实用技巧
答辩时最容易被问到的不是“你的模型结构是什么”,而是“你怎么证明 NCF 比矩阵分解好”。我的习惯是准备两组对比实验:一组用 NCF,一组用纯矩阵分解(把 MLP 分支去掉,只保留 GMF),在同一个测试集上跑 Hit Rate@10 和 NDCG@10。如果 NCF 在两个指标上都高出一截,那你的答辩就有了硬支撑。如果差距不明显,也不要慌——你可以解释“在小数据集上 NCF 的优势需要更多数据才能体现”,这本身就是对模型边界的正确认知。
代码修改方面,如果你想在现有基础上加功能,最稳妥的切入点是换损失函数。比如把binary_crossentropy换成BPR损失(Bayesian Personalized Ranking),这是推荐系统里另一种常用的 pairwise 损失。改动不大,只需要把训练数据组织成三元组(用户、正样本、负样本),然后在train_step里自定义 loss 计算。这个改动在答辩时是个很好的加分项,因为它说明你不只是跑通了代码,还理解了不同损失函数背后的假设。
# 自定义 BPR 损失的简化实现 class BPRModel(NCF): def train_step(self, data): user, pos_item, neg_item = data with tf.GradientTape() as tape: pos_score = self([user, pos_item], training=True) neg_score = self([user, neg_item], training=True) # BPR 损失:最大化正样本和负样本的分数差 loss = -tf.reduce_mean(tf.math.log(tf.sigmoid(pos_score - neg_score) + 1e-8)) gradients = tape.gradient(loss, self.trainable_variables) self.optimizer.apply_gradients(zip(gradients, self.trainable_variables)) return {"loss": loss}这段代码的关键在于pos_score - neg_score这个差值,BPR 的假设是正样本的预测分数应该高于负样本。tf.sigmoid把差值映射到 0 到 1 之间,再取 log 和负号,就得到了可最小化的损失。加1e-8是防止 log(0) 出现 NaN。如果你要跑这个版本,训练数据的组织方式要从(user, item, label)改成(user, pos_item, neg_item),负样本从用户未交互的物品里随机采。
还有一个实用技巧是模型保存和加载。答辩演示时如果现场训练来不及,可以提前把训练好的权重存下来,演示时直接加载。model.save_weights('ncf_weights.h5')和model.load_weights('ncf_weights.h5')就够了。但要注意,加载权重之前必须先构建好相同结构的模型,否则会报维度不匹配。
从那以后我每次跑推荐系统实验,都会在训练脚本里强制加一段“指标打印”逻辑——每个 epoch 结束后不仅输出 loss,还输出验证集上的 Hit Rate@10。这样即使 loss 看起来在降,如果 Hit Rate 不涨,我也能立刻发现模型在“假学习”。希望这份源码和上面的拆解能帮到你,至少让你在答辩时不再被“你这和深度学习有什么关系”问住。
本文还有配套的精品资源,点击获取