简介基于Python与TensorFlow的电影推荐系统项目资料面向机器学习初学者、算法工程师及推荐系统研究者覆盖从原始数据清洗、特征工程到模型评估与部署的完整链路。压缩包共10个文件约2.34MB包含3个xml工程配置、2个csv预处理数据、2个zip数据集与代码包、1个Python主脚本及TensorBoard事件文件等结构紧凑便于直接运行与二次开发。目前已有1246人学习下载。项目中实现了协同过滤用户/物品与矩阵分解SVD/NMF算法并引入LSTM等深度学习模型捕捉用户时序偏好同时涵盖数据清洗、缺失值处理、超参数调优、正则化及MAE/RMSE/召回率等评估指标并讨论离线与在线评估方法。读者可据此理解推荐系统整体架构包括数据流处理、服务部署与扩展性设计并针对稀疏数据和冷启动问题获得实践思路对课程设计、毕业设计或算法原理学习均有较高参考价值。1. 为什么一个电影推荐系统的标题值得你完整走一遍链路第一次看到“基于python与TensorFlow的电影推荐系统设计与实现”这个标题很多人第一反应是拉一份开源代码跑通再说。跑通不难难的是一旦换数据集、换业务场景你还能讲清楚每一步为什么这么做。这个标题真正要你做的是一条从原始评分表到可上线推荐服务的完整链路ID类特征的清洗与映射、神经网络模型的构建、训练负样本的设计、离线评估口径的校准。它适合两类人一类是想把推荐系统作为求职方向、需要一个能讲明白原理的项目的开发者另一类是已经把模型跑起来却在离线指标和线上效果之间找不到平衡的工程师。下面用一种常见的工程做法把这条链路逐段拆开给出可以直接抄的代码和参数也把最容易掉进去的五个坑提前标出来。2. 先锁推荐范式再准备数据为什么协同过滤才是这里的核心2.1 三种推荐范式怎么选内容、协同过滤还是混合做电影推荐第一件事不是写代码而是回答“推荐依据是什么”。基于内容的做法靠电影的类型、导演、演员、简介这类元数据来算相似度好处是冷启动友好坏处也很明显元数据不干净导演有多个演职员列表经常缺漏而且它只能推荐和你已经看过的电影相似的片子探索性很差。协同过滤则完全绕开元数据只用“谁给什么电影打了分”这个交互矩阵用户A和用户B的观影历史高度重合就把B喜欢的电影推给A。既然标题里带了TensorFlow我建议把落脚点放在深度协同过滤也就是用神经网络学user和item各自的Embedding表示再用网络结构去拟合两者之间的交互关系。这不只是为了用上框架而是因为ID类特征天然适合作为Embedding的输入而且这套做法在公开评分数据集上可复现性很强。混合路线当然也可以做比如把“电影类型”作为辅助特征拼进去但对一个主题锁在“电影推荐系统”上的项目来说先把协同过滤做透比一上来就堆特征有价值得多。路线数据要求冷启动能力落地成本基于内容需要干净完整的物品元数据好低相似度计算即可传统协同过滤UserCF/ItemCF只需交互记录差低但稀疏场景泛化弱深度协同过滤EmbeddingMLP只需交互记录差中高但表达能力强如果业务里几乎没有用户行为数据那就老老实实走基于内容只要有几十万条交互记录深度协同过滤的性价比就开始体现。2.2 用pandas清理MovieLens评分表字段含义、分隔符与时间戳切分常见做法是用MovieLens 100k或1M数据集不去网上爬评分。原因很简单爬下来的评分来源不明、用户ID不连续、没有统一的时间戳清洗成本可能比你训练模型还高。MovieLens的rating文件里每一行是userId、movieId、rating、timestamp字段之间用“::”分隔读进来之前需要先确认分隔符和编码。import pandas as pd df pd.read_csv( ratings.dat, sep::, enginepython, names[userId, movieId, rating, timestamp], encodinglatin-1, ) print(df[rating].value_counts().sort_index()) print(用户数:, df[userId].nunique(), 电影数:, df[movieId].nunique())这里sep是文件里的实际分隔符MovieLens的ratings.dat用的是双冒号read_csv默认的单分隔符会解析失败enginepython是为了支持多字符分隔符否则pandas会告警并且解析出三列。encoding用latin-1是因为老数据集里可能有非UTF-8字符直接读会抛UnicodeDecodeError。nunique用来确认ID数量级这决定后面Embedding表的长度也顺带完成了一轮数据质量检查。做这一步的时候我习惯顺手用pandas看一眼评分分布和交互次数这套数据分析和可视化的动作能帮你提前发现“某个用户刷了上千条评分”这类脏数据。数据读进来之后第一个关键决策是训练集和测试集怎么切。建议按时间切不要随机切df df.sort_values(timestamp) cutoff int(len(df) * 0.8) train df.iloc[:cutoff].copy() test df.iloc[cutoff:].copy()按时间切分的意思是前80%的交互用来训练后20%用来评估。如果你随机shuffle再切模型等于“偷看”了用户未来的行为离线指标会虚高得离谱。这是推荐系统里最容易踩的坑比模型选错还致命。timestamp字段在这里就是给你切分用的别在特征工程阶段随手丢掉。2.3 ID映射成连续索引embedding查表的前置条件不要直接把userId和movieId扔给Embedding层。MovieLens的用户ID不是从0开始连续的中间有空号直接用原始ID会撑大Embedding矩阵、浪费参数还会在推理时遇到查表越界。工程里统一做法是各自做一个连续映射并且从1开始编号把0号位留给“未知ID”。user_ids sorted(df[userId].unique()) movie_ids sorted(df[movieId].unique()) user2idx {u: i 1 for i, u in enumerate(user_ids)} movie2idx {m: i 1 for i, m in enumerate(movie_ids)} train[user_idx] train[userId].map(user2idx).fillna(0).astype(int) train[movie_idx] train[movieId].map(movie2idx).fillna(0).astype(int) test[user_idx] test[userId].map(user2idx).fillna(0).astype(int) test[movie_idx] test[movieId].map(movie2idx).fillna(0).astype(int) num_users len(user_ids) 1 num_movies len(movie_ids) 1这段代码的逻辑user2idx和movie2idx是两个字典训练数据里的原始ID经过map变成0到N-1的连续整数。从1开始编号0号位置常年空着将来线上来了一个训练集里没见过的电影ID查表得到NaNfillna(0)会把它接住不会再让Embedding层抛越界错误。num_users和num_movies各加1就是为了把0号占位符算进表长。这个防御式设计的代价几乎为零但能避免上线后最难看的一类崩溃。3. 用TensorFlow搭建深度协同过滤模型Embedding是理解这个系统的钥匙3.1 GMF与MLP双分支为什么不能只做向量内积最简单的矩阵分解是让用户向量和电影向量做内积再经过sigmoid输出一个“喜欢概率”。这个结构在TensorFlow里十行就能写完但内积是线性操作表达力有限。用户和电影的交互往往是非线性的比如一个用户喜欢科幻片但只接受某几位导演这种“组合条件”靠单一内积学不出来。所以常见工程做法是采用NCF思路拆两条分支。GMF分支把两个Embedding逐元素相乘保留内积式线性关系MLP分支把两个Embedding拼接起来过全连接网络学习非线性交互最后把两条分支拼到一起再过一层输出。这样设计不是追求论文指标而是让模型的结构本身能表达两类关系训练时也更容易收敛。3.2 NCF模型核心代码从Embedding层到打分输出这里给出一个可以直接用的TensorFlow模型类import tensorflow as tf from tensorflow.keras import layers class NCF(tf.keras.Model): def __init__(self, num_users, num_movies, embed_dim32): super().__init__() self.user_emb layers.Embedding(num_users, embed_dim) self.movie_emb layers.Embedding(num_movies, embed_dim) self.mlp tf.keras.Sequential([ layers.Dense(64, activationrelu), layers.Dense(32, activationrelu), ]) self.out layers.Dense(1) def call(self, inputs, trainingFalse): user_idx, movie_idx inputs u self.user_emb(user_idx) m self.movie_emb(movie_idx) gmf u * m mlp_out self.mlp(tf.concat([u, m], axis-1)) return self.out(tf.concat([gmf, mlp_out], axis-1))逻辑不算复杂值得说明的有四点。Embedding层本质是查表输入必须是整数IDnum_users和num_movies决定表长也就是行数embed_dim决定每个ID被表示成多长的向量。u * m是逐元素乘法而不是点积点积会把多维向量压缩成一个标量丢信息逐元素乘保留每个维度的交互信息。MLP分支的Dense(64)、Dense(32)是隐层激活函数用relu隐单元从大到小让信息逐步浓缩。最后一层Dense(1)不加sigmoid配合后面损失函数里的from_logitsTrue数值上比“输出层加sigmoid再算交叉熵”更稳。embed_dim默认给32这是电影数量在几千到几万量级时的一个稳妥起点。不要一上来就设128维度太大在长尾电影上容易过拟合。数据量真的大了再往64、128方向调。3.3 损失函数到底该用MSE还是BCE评分预测和TopK推荐是两件事这个标题没有明说是“预测评分”还是“做TopK推荐”但写代码前必须定下来因为损失函数和评估方式完全不同。目标输出层损失评估评分预测比如预测这部电影会被打4.2分不加激活的Dense(1)MSERMSE / MAETopK推荐预测用户会不会喜欢不加激活的Dense(1) sigmoidBCEHitK / NDCGK我的选择是做TopK推荐把场景定为“用户是否值得看这部电影”。原因很现实评分数据稀疏3分和4分之间的差异对排序没有直接帮助MSE会被1分和5分这种极端评分拉偏而产品最终要的是“推什么”不是“把星级估准”。训练时只需要把原始评分映射成0/1标签例如4分及以上算正样本或者干脆把“有交互”当正样本配合随机负样本做二分类。如果只是为了答辩展示两个损失都实现也不难但上线我更推荐BCE。4. 训练与评估负采样、超参和离线指标一个都不能少4.1 构造训练批次正样本加K个负样本的采样器用户行为数据天然只有“发生过交互”的正样本没有“用户没看过这部电影”的负样本标签。神经网络的训练又必须两类样本都有于是要构造负样本。常见做法是对每个正样本随机抽K个该用户没有交互过的电影作为负样本组成一个大小为1:K的训练批。import numpy as np user_items ( train.groupby(user_idx)[movie_idx] .apply(set) .to_dict() ) def sample_batch(df, batch_size, num_neg4): batch_users, batch_movies, batch_labels [], [], [] pos df.sample(batch_size) for u, m in zip(pos[user_idx], pos[movie_idx]): batch_users.append(u) batch_movies.append(m) batch_labels.append(1) for _ in range(num_neg): neg np.random.randint(1, num_movies) while neg in user_items.get(u, set()): neg np.random.randint(1, num_movies) batch_users.append(u) batch_movies.append(neg) batch_labels.append(0) return ( np.array(batch_users, dtypenp.int64), np.array(batch_movies, dtypenp.int64), ), np.array(batch_labels, dtypenp.float32)user_items在训练前构造一次是“用户ID - 已交互电影集合”的查表用set保证成员判断是O(1)。负采样从1到num_movies-1之间随机取避开0号未知位while循环防止抽到用户已经看过或者这一轮里已经抽过的电影否则会把正样本误标成负样本干扰模型。返回的label长度等于batch_size乘以(1num_neg)也就是一个正样本配K个负样本。训练循环我用TensorFlow的标准写法tf.function def train_step(model, optimizer, x, y): x (tf.convert_to_tensor(x[0]), tf.convert_to_tensor(x[1])) y tf.convert_to_tensor(y, dtypetf.float32) with tf.GradientTape() as tape: logits model(x, trainingTrue) loss tf.reduce_mean( tf.keras.losses.binary_crossentropy(y, logits, from_logitsTrue) ) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss model NCF(num_users, num_movies, embed_dim32) optimizer tf.keras.optimizers.Adam(learning_rate1e-3) EPOCHS 10 STEPS_PER_EPOCH 200 BATCH_SIZE 256 for epoch in range(EPOCHS): for _ in range(STEPS_PER_EPOCH): x, y sample_batch(train, BATCH_SIZE) loss train_step(model, optimizer, x, y) print(fepoch {epoch} loss {loss.numpy():.4f})binary_crossentropy配合from_logitsTrue意思是模型输出的是未经过sigmoid的logit损失函数内部做数值稳定的sigmoid和交叉熵计算如果模型输出层已经加了sigmoid这里就要改成from_logitsFalse两头都做会让梯度出问题。loss用reduce_mean取平均因为binary_crossentropy返回的是逐样本损失。sample_batch本身每次随机抽样所以不需要额外对全量数据做shuffle。4.2 三个必调参数embedding维度、学习率、负采样个数模型能跑起来之后真正花时间的是调参。三个参数最值得先动超参起步值怎么调embedding维度32太小欠拟合太大过拟合按数据量级取16到64学习率1e-3Adamloss震荡就降到3e-4收敛太慢暂时加到3e-3负采样个数41到2区分度太弱8到16训练变慢且正样本被稀释embedding维度要参考num_movies的数量级。几百部电影用16维就够了几千到几万部用32到64。维度过大时每个ID学出一段过度个性化的向量高频电影还能学明白长尾电影基本是在硬记。调整策略是固定其它超参只改维度在验证集上对比Hit10不要凭感觉拍板。学习率这块Adam默认的1e-3大多数情况不用动。但推荐数据稀疏、batch又小loss震荡很常见。先确认负采样和标签配对没问题再动学习率否则容易把模型调成“看起来在收敛但推荐结果完全不可用”的状态。负采样个数影响的是正负样本比例。K太大时一个batch里全是负样本模型倾向预测“不喜欢”推理时整体分数偏低但排序通常还能用只是训练时间白白拉长。还有一个常被忽略的正则化问题不要在模型初期加Embedding的L2正则。先让模型正常收敛到这个数据集上的合理水平再考虑防过拟合。真要加系数从1e-5起步加在Embedding层的embeddings_regularizer参数上。4.3 离线评估Hit10与NDCG10的实现和解读推荐系统离线评估不能用准确率因为负样本是你自己抽的准确率会被负样本数量带着跑。常见做法是对测试集里每个用户取一条真实正样本再随机抽99条该用户没交互过的电影作负样本让模型对这100条打分真实正样本的排名越靠前模型越好。Hit10看的是正样本有没有进前10NDCG10还会把排名的先后折算进去。def evaluate_ranking(model, test_df, k10, num_neg99): hits, ndcgs [], [] rng np.random.default_rng(42) for u, group in test_df.groupby(user_idx): pos int(group[movie_idx].iloc[0]) seen user_items.get(u, set()) | {pos} candidates [pos] while len(candidates) num_neg 1: m int(rng.integers(1, num_movies)) if m not in seen and m not in candidates: candidates.append(m) users np.full(len(candidates), u, dtypenp.int64) scores model.predict( [users, np.array(candidates)], verbose0 ).flatten() pos_rank int(np.argsort(-scores).tolist().index(0)) 1 hits.append(1 if pos_rank k else 0) ndcg np.log(2) / np.log(pos_rank 1) if pos_rank k else 0 ndcgs.append(ndcg) return float(np.mean(hits)), float(np.mean(ndcgs))每个测试用户只取一条正样本是为了先快速跑完一轮严谨做法是多抽几组负样本取平均但那样评估时间会乘以组数第一轮先看趋势足够。代码里index(0)依赖一个事实candidates列表第0个元素就是真实正样本排序后它掉到第几位就是真实电影的名次。换列表顺序时这行注释要同步改。随机种子42保证可复现。Hit10能到0.3这个量级我会认为模型学出了有效信号低于0.2先查数据泄漏和负采样逻辑而不是急着加模型层数。5. 避坑指南电影推荐系统从能跑到能用的5个常见问题5.1 训练loss在降推荐列表却全被热门电影霸占现象训练几轮后loss从0.7降到0.4看起来在收敛。把测试集里某个用户的候选电影丢进模型打分排名靠前的全是《肖申克的救赎》《阿甘正传》这类高评分热门片。原因评分数据天然是长尾分布热门电影出现在正样本里的次数远超长尾电影。模型只要学到“流行度偏置”就能把整体loss压下去它不需要理解用户个性化偏好。另一个放大器是负采样如果负样本没有排除用户历史交互过的电影模型会把“没评过分”简单等同于“不喜欢”热门电影同时出现在正负样本里表示就被拉乱了。解决负采样前先过滤掉用户所有历史交互过的电影代码里user_items已经做了这件事。更彻底的做法是把“是否热门”作为特征接进模型。最便宜的验证方法看不同用户推荐列表的重叠率如果两个人的前10有8部一样基本实锤。把这条修复之后Hit10通常会明显上涨。5.2 模型在新用户和新电影上直接崩溃现象模型训练完成后线上来一个刚上映的电影ID调用predict时TensorFlow直接报“indices越界”或者服务端返回500。原因Embedding层的输入范围被num_movies锁死训练时没有给未见ID预留位置ID映射表也只存在于训练侧的代码里线上推理根本没有这层转换。解决映射时从1开始编号0号位固定给未知项线上查映射表查不到就填0。代码就是两行user_idx user2idx.get(user_id, 0) movie_idx movie2idx.get(movie_id, 0)但要清楚0号位只是兜底它学出来的是“所有未知电影的平均水平”不解决真正的冷启动。要处理新电影后期得把电影类型、上映年份这些内容特征再接一路Embedding。先用0号位把线上请求接住是第一步不然模型连正常运行都保证不了。5.3 换一台机器就报错TensorFlow环境不一致的排查思路现象A机器跑得好好的项目拷贝到B机器pip install后运行直接报“AttributeError: module tensorflow has no attribute keras”或者模型加载时报“Unknown layer”。原因TensorFlow的1.x和2.x API差异非常大网上大量旧代码是1.x的session式写法。更隐蔽的是环境问题tensorflow装进了conda的base环境而VSCode选中的Python解释器是另一个venv于是vscode python环境配置里看着装好了实际import的模块根本不是同一份。这也是python安装和tensorflow安装里最常见的翻车现场。解决项目根目录放requirements.txt把tensorflow那一行锁成同一个大版本别用不带版本约束的pip install tensorflow。排查时先看两件事当前Python解释器的绝对路径以及tf.keras是否能正常import。解释器路径选错了后面所有依赖都是白装的。5.4 在线预测太慢模型加载与请求路径的问题现象离线predict一条只要几毫秒用Flask包一层之后并发一上来就超时单次请求经常超过100ms。原因最常见的是每个请求都执行了类似load_model的操作或者每个请求都把几千部候选电影全量过一遍模型。TensorFlow的图初始化开销远大于单次推理模型加载放请求里会让延迟完全失控没有GPU时全量打分也不划算。解决模型在服务启动时加载一次之后所有请求复用同一个session或model实例。候选集不要全量打分先用规则召回比如热门前300部、用户最近看过的类型、上映时间近三个月的电影把范围缩到100部以内再交给模型排序。电影几千部时全量打分勉强能接受到了几十万部必然要换向量检索。先把“加载一次”和“缩小候选集”做掉延迟通常能降一个数量级。5.5 离线指标漂亮线上业务指标不动现象Hit10从0.25提升到0.35上线做A/B实验点击率、观影时长都没明显变化。原因离线评估用的是随机负采样线上用户看到的是“被展示出来”的候选集两者分布根本不同。离线负样本是均匀随机抽的模型只需要把“绝不可能被用户看到的电影”排除掉就能拿高分线上候选集本来就是过滤后的排序难度完全不同。另外单条正样本评估法只抽了一组负样本指标方差很大5个点的提升可能只是噪声。解决评估集尽量用真实曝光日志没有曝光日志就按时间切分负样本从“该用户之前被曝光过但没点击”的池子里抽。调参时不要只看一轮Hit10跑至少3组随机负采样取平均。这是我做过最贵的踩坑把时间全花在调离线指标上不如先确认评估口径和线上一致。6. 上线前的最后一公里导出SavedModel并做两级缓存6.1 导出模型与最小推理接口训练结束后把模型导出成SavedModel格式这是TensorFlow的标准发布形态。目录里包含saved_model.pb和variables文件夹线上加载它就行不需要再把训练代码搬过去。model.save(recommender_model, save_formattf)加载时注意要在服务进程启动时执行不要在请求函数里加载。路径不要带中文服务器上尽量用绝对路径。加载一次之后所有请求复用这时单次predict的耗时就是真实的模型推理耗时不再包含图构建。6.2 热门兜底与用户结果缓存两级缓存是我在这类系统里一定会做的最后一件事。第一级是热门列表训练集里按交互次数倒序取前200部电影遇到新用户直接返回。第二级是用户结果缓存每个userId的推荐结果缓存10分钟数据量不大时用进程内dict加过期时间就够没必要一上来就上Redis。还有一个部署习惯Linux服务器上部署Python服务时用venv建独立环境别和系统自带的python混用。很多人在这上面栽过跟头问题根本不是代码而是解释器路径和依赖被装乱了。电影量级只有几千到几万时模型排序本身就是最快路径不用急着把Embedding表推到外存做向量检索。我最早做这个题目时总想把模型结构做得越复杂越好最后发现数据泄漏和负采样才是决定成败的地方模型反而很朴素。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?