尧图精选

基于Python与TensorFlow的电影推荐系统:从双塔召回到NCF排序

🕒 发布时间:2026/9/28 2:01:50 📁 来源:尧图网络
简介基于Python与TensorFlow的电影推荐系统设计与实现是一份面向推荐系统学习者和深度学习初学者的完整项目资源。核心价值在于演示如何利用TensorFlow搭建协同过滤、矩阵分解乃至LSTM等模型并结合真实电影评分数据完成从数据清洗、特征工程到模型评估的闭环流程。压缩包内共10个文件以Python脚本、CSV数据文件、ZIP压缩包及XML项目配置为主整体大小仅2.34MB包含处理后的评分数据、模型训练脚本及TensorBoard可视化事件日志便于直接运行学习。目前已有1246人学习下载适合需要可运行代码模板与预处理数据的学习者。通过对照源码与数据流程可深入理解用户和物品相似度计算、矩阵分解原理及深度模型在推荐任务中的实际应用同时复用项目目录结构与配置快速迁移到自己的数据集上。1. 用Python和TensorFlow搭电影推荐系统它到底在解决什么问题你如果负责过一个小型观影社区大概率经历过这个场景上线一个按热度排序的“大家都在看”用户点了一两部就不再有兴趣后来换成基于物品的协同过滤效果有提升但对新用户和冷门电影依然无能为力。我自己的体会是这类瓶颈不是算法思路错了而是你还没有把用户和物品放进同一个向量空间里去建模。标题里的“基于Python与TensorFlow的电影推荐系统”本质上就是做两件事第一把用户的历史行为评分、点击、收藏编码成稠密向量第二用这些向量做两阶段的筛选——先快速召回一批候选电影再精细排序把最合适的十部推给用户。这个方向尤其适合两类人一类是拿它做毕业设计或者简历项目需要一套能讲清楚原理又能跑通的完整链路另一类是已经写过基于规则的推荐想上手深度模型但被各种教程里的“半篇代码一个模型图”劝退的工程师。这篇笔记会按数据、召回、排序、部署的顺序走一遍每一段都落到能直接改着用的代码和参数上。2. 数据准备与特征构造从原始评分表到可训练样本推荐系统的建模一开始就卡在数据上。你手里的电影数据通常长成三个文件用户表、电影表、评分记录表。以公开的电影数据集为例评分记录里一般是四列user_id、movie_id、rating、timestamp。用户表里有年龄、性别、职业这类属性电影表里有标题和分类标签。这三个文件本身不复杂但从它们变成可训练样本中间有几个容易被忽略的决定。第一个决定是你到底预测什么。评分预测和点击预测是两套思路电影推荐标题里最常见的做法是把评分行为当作隐式反馈来用——用户打了高分就代表他喜欢这部电影没打过分的电影不能直接当成不喜欢因为可能是没看过。常见做法是把评分大于等于某个阈值的记录标记为正样本通常我会先用4.0作为初始阈值再看正负样本比例是否失衡数据稀疏就放宽到2.5。2.1 把评分转成隐式反馈别直接回归评分如果你用回归去做评分预测模型学到的其实是“打3.5分的人可能打4分”这种近似但这离“推荐哪部电影”的目标很远。把评分映射成0/1标签后问题就变成点击率预估式的二分类损失函数、评估指标都更贴近推荐场景。这里有一个小优化评分高低还携带着置信度信息。同样是正样本打5分和打4.5分的置信度不一样。我一般会给置信度赋一个权重小于3分的样本权重压到0.7左右大于等于3分的样本权重按评分线性增加。这个权重在后面的损失函数里直接作用比单纯把阈值二值化多保留一点信息又不至于把问题复杂化成回归。python import pandas as pd import tensorflow as tf读取评级数据按自己的列名做对应ratings pd.read_csv(ratings.dat, sep::, names[user_id, movie_id, rating, timestamp], enginepython)4.0分及以上视为正样本评分作为置信度权重ratings[label] (ratings[rating] 4.0).astype(int8) ratings[confidence] ratings[rating].map( lambda r: 0.7 if r 3.0 else min(1.5, 1.0 (r - 3.0) / 2.0) )对原始ID做从0开始的连续编码稀疏embedding表需要user_ids ratings[user_id].unique() movie_ids ratings[movie_id].unique() u_map {u: i for i, u in enumerate(user_ids)} m_map {m: i for i, m in enumerate(movie_ids)} ratings[u_id] ratings[user_id].map(u_map) ratings[m_id] ratings[movie_id].map(m_map)这段代码里有几个参数值得停下来解释。阈值4.0不是固定的如果你的数据集本身评分偏慷慨比如平均分就在4.2以上那应该往4.5甚至5.0提反过来评分普遍偏低就降到3.0。目标很简单让正样本是“用户明确喜欢”的行为而不是“用户随便给了个分”的行为。置信度权重最大值设1.5是想让高分行为对损失的贡献比普通行为大50%但又不至于让几个5分样本主导整个训练。连续ID编码这步很多人会偷懒直接用原始ID这在小数据集上没问题但一旦用户数或电影数上万原始ID中间的大空洞会让Embedding表膨胀白白浪费内存。映射后的u_id和m_id才是模型真正吃进去的输入。2.2 负样本怎么抽随机采样和难负样本的平衡正样本是用户明确喜欢的电影负样本则需要自己造。直接随机抽用户没看过的电影来当负样本模型会很快学会区分“熟悉的电影”和“完全没听过的电影”这不是我们想要的。更好的是混合采样一部分负样本完全随机一部分从热门电影里挑——因为这些电影用户大概率见过但没点击是真正需要模型去区分的难负样本。我记得第一次搭这套系统时负样本全用随机结果模型只学到了“给看过的电影打高分”对候选池里那些用户没看过但可能喜欢的电影毫无分辨力。后来把负样本调整成“70%随机 30%热门”验证集上的Hit Rate才真正动起来。混热门样本的道理很直白用户被曝光的电影里没被点击的那些才是最有效的负反馈信号。2.3 按时间戳切分训练集和验证集别随机切分切分数据时最大的坑是随机切分。电影推荐有强时效性——一个人去年喜欢的类型和今年喜欢的类型可能完全不同。随机切分会把同一天内的行为同时放进训练集和验证集模型在验证集里“见过未来”指标虚高一上线就现原形。我习惯按时间戳排序后前85%做训练后15%做验证。这样验证集模拟的是“用过去预测未来”的真实场景。切分完可以用tf.data把两个数据集包装成Pipeline注意训练集要repeat并配合steps_per_epoch验证集不shuffle。python positive ratings[ratings[label] 1]正负样本比例控制在1:4附近负样本多了模型会过度偏向预测负类negative ratings[ratings[label] 0].sample(nlen(positive) * 4, random_state42) data pd.concat([positive, negative]).sort_values(timestamp)split_point int(len(data) * 0.85) train_df, valid_df data.iloc[:split_point], data.iloc[split_point:]def to_dataset(df, shuffleTrue, repeatFalse): ds tf.data.Dataset.from_tensor_slices(( {user_id: df[u_id].values, movie_id: df[m_id].values}, {label: df[label].values} )) ds ds.batch(1024) if shuffle: ds ds.shuffle(4096) if repeat: ds ds.repeat() return dstrain_ds to_dataset(train_df, shuffleTrue, repeatTrue) valid_ds to_dataset(valid_df, shuffleFalse) steps_per_epoch len(train_df) // 1024注意这里正负样本比例1:4是经验值。负样本太少模型会把几乎所有电影都预测成正样本负样本太多模型会趋向于保守推荐列表变得平淡。1:3到1:5之间通常都值得试。batch大小1024在双塔和NCF这类模型上表现都比较稳显存紧张可以降到512但in-batch负采样相关的设计会受batch大小影响后面召回章节会再提到。3. 召回层设计双塔模型把候选集从几万压到几百很多人第一次写推荐系统脑子里想的是把所有电影挨个算一遍分数取前10个推给用户。这个直觉在几百部电影的玩具数据集上是成立的但到真实场景就卡住了——你的排序模型如果有两层特征交叉对三万部电影逐条推理单用户推理耗时可能到秒级线上根本扛不住。所以工业界标准的做法是拆两层召回负责从全量电影里快速筛出几百部排序再在这几百部里精挑细选。双塔模型就是召回层最常见的一套方案。用户塔输入用户ID输出用户向量电影塔输入电影ID输出电影向量。两个塔各自独立最后用内积或者余弦相似度计算匹配分。因为电影塔的输出不依赖用户输入所有电影的向量可以预先算好存起来在线只需要用用户向量去做一次矩阵乘几万部电影的候选集生成能压到几十毫秒。3.1 双塔模型的TensorFlow实现Embedding L2归一化写双塔模型用Keras函数式API就够了。用户ID经过Embedding层变成一个稠密向量电影ID同理。这里有个细节Embedding输出的shape是(batch, 1, dim)需要Flatten后才能和其他层对接。如果你后面要拼用户属性特征比如年龄、性别可以在Flatten之后用Concatenate拼进去这也是双塔模型扩展性好的原因。训练双塔模型时最常遇到的问题是怎么准备负样本。最简单的做法是把上一章构造好的正负样本直接喂给模型损失函数用BinaryCrossentropy。但这样一来模型学的是“区分用户看过的和随机抽的”并没有真正做对比学习。更贴近真实场景的做法是in-batch负采样同一个batch里把其他用户的正样本电影当成当前用户的负样本。这个做法实现起来就是矩阵相乘后对行做softmaxTensorFlow原生的代码会绕一些新手在这里容易翻车我建议第一次跑通还是先用已经构造好的负样本把流程跑通后再优化采样方式。python import tensorflow as tfdef two_tower_model(num_users, num_movies, dim32): user_id tf.keras.Input(shape(1,), nameuser_id) movie_id tf.keras.Input(shape(1,), namemovie_id)user_emb tf.keras.layers.Embedding(num_users, dim, nameuser_embedding) movie_emb tf.keras.layers.Embedding(num_movies, dim, namemovie_embedding) u_vec tf.keras.layers.Flatten()(user_emb(user_id)) m_vec tf.keras.layers.Flatten()(movie_emb(movie_id)) # L2归一化让内积等价于余弦相似度后续统一阈值和距离都好解释 u_vec tf.keras.layers.Lambda(lambda x: tf.math.l2_normalize(x, axis-1))(u_vec) m_vec tf.keras.layers.Lambda(lambda x: tf.math.l2_normalize(x, axis-1))(m_vec) score tf.keras.layers.Dot(axes1, normalizeFalse)([u_vec, m_vec]) model tf.keras.Model(inputs{user_id: user_id, movie_id: movie_id}, outputsscore) return modelmodel two_tower_model(len(u_map), len(m_map), dim32) model.compile(optimizertf.keras.optimizers.Adam(learning_rate1e-3), losstf.keras.losses.BinaryCrossentropy(from_logitsTrue), metrics[tf.keras.metrics.AUC()])Embedding维度dim32是我跑百万级交互数据时的常用起点。维度太低表达不了用户的多样性太高在稀疏数据上过拟合风险大。你可以这样理解维度选择一个用户的历史平均行为次数如果有几十条32维够用如果有几百条可以提高到64维。这个维度本质上决定了你能建模多少个“隐语义”而电影推荐场景里几十个语义维度通常已经够描述类型偏好、导演偏好、年代偏好这些核心因素。3.2 用预计算的电影向量做全量召回模型训练好之后电影塔的Embedding权重就是所有电影的向量表示。先把它取出来做个L2归一化然后在线推理时就变成了一个非常纯粹的向量检索问题拿用户向量和全量电影向量矩阵做矩阵乘法取TopK。这个操作TensorFlow原生就能跑不需要额外引向量数据库。python取出训练好的电影向量并归一化movie_vecs model.get_layer(movie_embedding).get_weights()[0] movie_vecs tf.math.l2_normalize(movie_vecs, axis-1)def recall(user_id, top_k50): # 取对应用户的向量同样归一化 u_vec model.get_layer(user_embedding)(tf.constant([user_id])).numpy() u_vec tf.math.l2_normalize(u_vec, axis-1) scores tf.matmul(u_vec, movie_vecs, transpose_bTrue) return tf.math.top_k(scores, ktop_k)如果电影数量到几十万级纯矩阵乘的耗时开始明显那时再考虑用专门的向量索引。但在一万部电影这个量级直接矩阵乘完全够用。TopK的K值一般设50到200这个参数直接影响召回覆盖率和排序层的计算量K太小真正感兴趣的候选电影可能没进来K太大排序层要处理太多无关电影延迟上去了收益却不大。我自己会先用50跑通再看排序结果的多样性做调整。3.3 双塔召回的评估别只看准确率召回层的评估指标要单独看不能拿它跟排序层比。常见做法是对验证集里每个用户把他真正看过的高分电影拿出来看模型有没有把它召回到TopK里。这个指标叫RecallK公式就是“被召回到TopK的正样本数 / 该用户全部正样本数”。我在代码里留的top_k50对应的是先保证不漏掉感兴趣的把精确筛选的任务交给排序层。4. 排序层实现NCF模型把特征交叉做深召回层的问题在于双塔的交互太浅——两个向量直到最后才做一次点积这种交互方式只能建模线性关系学不到“喜欢科幻片的人往往也喜欢小众动画”这类非线性模式。所以召回的几百部电影需要进一步精排。这里用的算法是NCF也就是Neural Collaborative Filtering的思路它的核心是让用户向量和电影向量在网络中间层提前相遇靠全连接层做充分的特征交叉。4.1 NCF的模型结构GMF路径加MLP路径NCF结构上分成两条路GMF路径做逐元素乘积MLP路径做拼接后全连接。GMF这路保留协同过滤的内积能力MLP这路学非线性交叉最后把两者拼接后过一个sigmoid输出点击率。实现时会用两套独立的Embedding——一套给GMF一套给MLP而不是共用原因很简单两个路径要学的东西不一样GMF需要的是线性语义匹配MLP需要的是高阶组合特征共享参数会让两边互相牵制。python def ncf_model(num_users, num_movies, dim32, mlp_dims[64, 32, 16]): user_id tf.keras.Input(shape(1,), nameuser_id) movie_id tf.keras.Input(shape(1,), namemovie_id)# GMF路径使用dim维embedding user_emb tf.keras.layers.Embedding(num_users, dim, namencf_user_emb) movie_emb tf.keras.layers.Embedding(num_movies, dim, namencf_movie_emb) # MLP路径使用2*dim维因为它要承担更多特征交叉 user_mlp_emb tf.keras.layers.Embedding(num_users, dim * 2, namencf_user_mlp_emb) movie_mlp_emb tf.keras.layers.Embedding(num_movies, dim * 2, namencf_movie_mlp_emb) u_gmf tf.keras.layers.Flatten()(user_emb(user_id)) m_gmf tf.keras.layers.Flatten()(movie_emb(movie_id)) gmf tf.keras.layers.Multiply()([u_gmf, m_gmf]) # 逐元素乘 u_mlp tf.keras.layers.Flatten()(user_mlp_emb(user_id)) m_mlp tf.keras.layers.Flatten()(movie_mlp_emb(movie_id)) mlp tf.keras.layers.Concatenate()([u_mlp, m_mlp]) # 先拼接 for units in mlp_dims: # 再逐层全连接 mlp tf.keras.layers.Dense(units, activationrelu)(mlp) x tf.keras.layers.Concatenate()([gmf, mlp]) out tf.keras.layers.Dense(1, activationsigmoid)(x) return tf.keras.Model(inputs{user_id: user_id, movie_id: movie_id}, outputsout)ncf ncf_model(len(u_map), len(m_map), dim32) ncf.compile(optimizertf.keras.optimizers.Adam(learning_rate3e-4), losstf.keras.losses.BinaryCrossentropy(), metrics[tf.keras.metrics.AUC(), tf.keras.metrics.Precision(), tf.keras.metrics.Recall()])mlp_dims这里我常用[64, 32, 16]也就是逐层减半的结构。这个递减排不是随便定的前几层宽一些能容纳更高维的组合特征后几层收窄起到信息蒸馏的作用。注意MLP路径的输入维度是concatenate之后的2dim2——两个2*dim向量拼起来所以第一层64在dim32时能顺利接住。如果你把dim调成64mlp_dims第一层最好也对应改成128让信息在进入第一层全连接时不至于被强行压扁。4.2 训练NCF的参数设置与回调NCF的训练比双塔更敏感。学习率我从1e-3降到3e-4原因是网络层数深了之后过大的学习率容易出现loss震荡。如果你观察到训练过程中AUC忽高忽低大概率是学习率偏大降到1e-4再观察一轮。Embedding层建议加一点L2正则TensorFlow里的写法是Embedding(..., embeddings_regularizertf.keras.regularizers.l2(1e-5))这个正则系数也让模型别把向量模长拉得太大。早期停止和TensorBoard是省心组合。EarlyStopping的patience设3到5轮盯着验证集AUC连续几轮不涨就停下来既省时间也防止过拟合。TensorBoard记录的不只是曲线还能看每一层权重的分布对排查“哪层梯度消失”这类问题很有用。python callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_auc, patience3, modemax, restore_best_weightsTrue), tf.keras.callbacks.TensorBoard(log_dir./logs/ncf, histogram_freq1) ]ncf.fit(train_ds, validation_datavalid_ds, steps_per_epochsteps_per_epoch, epochs30, callbackscallbacks)restore_best_weightsTrue的意思是训练结束后自动把验证集上表现最好的那轮权重装回来这相当于给你留了一颗后悔药不用在训练完再手动回滚。TensorBoard的histogram_freq1每个batch结束后记录权重分布训练慢的话改成5减少额外开销。4.3 排序模型怎么跟召回模型配合两条模型各司其职召回模型先掏出500部候选电影排序模型再对这500部逐条打分按分数从高到低截断前10部。这里要注意的是召回模型的分数和排序模型的分数不能混着比。召回给的是“这部电影可能感兴趣吗”排序给的是“用户到底会点哪一部”它们的分布完全不同。在拼推荐列表时我一般会加一道多样性重排连续出现两部同系列电影时跳过第二部补一个不同类型但分数接近的。比如用户刚看完《钢铁侠》紧接着又给他推《钢铁侠2》连续两个同质推荐会让用户觉得推荐系统只会炒冷饭。最常见的做法是滑动窗口内限制同一个类型占比不超过一半。5. 训练与部署中的高频踩坑现象、原因与解决模型从能跑到跑得好中间隔着好多个坑。这一章我把这几年在电影推荐上踩过的典型问题按现象、原因、解决的顺序列出来。你照着复现时如果遇到类似的别怀疑是自己的代码写错了大概率是这几个常规问题之一。5.1 TensorFlow 2.x里还在用老教程的Session如果你找资料时看到教程开头写着tf.Session()或者tf.placeholder直接关掉。现象很典型代码在模型定义时一切正常一执行训练就报错或者打印出个tensor对象而不是数值。原因很简单TensorFlow 2.x默认Eager执行1.x那种先建计算图再在Session里run的方式已经被淘汰了。解决方法是统一用tf.keras函数式API写模型把tf.keras.layers作为唯一入口已经写在1.x代码里的模型结构也不用全推翻——大多数层在tf.keras.layers里都有同名对应改一下导入就行。这类坑排查起来特别耗时间因为报错信息不会直接告诉你“别用Session了”。5.2 负样本采样策略不对离线指标全是虚高现象是训练AUC冲到0.9以上验证集上Precision也不错但上线后点击率远低于预期。最隐蔽的原因是负样本构造得“太容易”——全用随机抽取的用户没看过电影模型很快学会一个偷懒策略给热门电影打高分因为正样本也大多是热门电影。于是它根本没学用户偏好只学了“热门 可能喜欢”。正确的做法是把负样本来源混合起来。我的习惯是60%到70%的随机负样本30%到40%的热门电影负样本偶尔还会掺一点和正样本同一类别的相似电影来提高区分难度。验证集和测试集的负样本分布也必须跟着变否则模型在验证集上的AUC是虚的。5.3 Embedding维度设得过大训练集上过拟合这个问题常见于用NCF时看到论文里dim64或者128就直接照抄。现象是训练loss一直下降验证AUC却停滞甚至掉头向下。原因是你手头的有效行为数据可能只有几十万条而64维的Embedding要把几万个ID全部表示成64维向量参数量远超数据能支撑的容量。我在1M左右的评分数据上dim32已经有不错的表现只有数据到千万级才考虑64。你可以做一个简单判断用户数加电影数乘以维度得到的就是Embedding层参数量。这个数量控制在训练样本总量的2到5倍以内比较安全。如果已经训练到一半发现过拟合先别急着删维度给Embedding加L2正则和早停往往能救回来。5.4 时间切分没做模型偷偷看到了未来现象是离线验证时各项指标都惊艳得不可思议但部署后对新上映的推荐很差冷启动效果也糟糕。原因是训练和验证样本没有按时间切随机切分让同一部热门电影的记录同时出现在训练与验证集里模型在验证时等于开了卷考试。解决方法是回到第二章的代码把评分表按timestamp排序后先切分再抽样。最后一批行为做验证前面批次做训练。这里还有一个额外收益按时间切分后的模型表现和上线后的表现通常相关度高这也让线下指标的参考价值变大。5.5 线上召回打全量延迟直接爆炸我在最开始那版系统里犯过一个错排序模型不召回直接把全量电影逐条传给排序模型打分。几万部电影每条都要过一遍NCF的前向计算结果单用户请求的延迟从几十毫秒涨到几秒。现象是压测时服务一直飘红响应时间直接击穿。解决方法是严格分层召回用双塔预计算好的向量做矩阵乘候选集控制在300到500部。记住一个原则向量的内积计算适合海量筛选深度网络的特征交叉适合少量精排。两层各用各的长处整套系统的性能才能稳定在线上可接受范围。6. 上线前最后一件事离线验证、模型导出与进阶方向推荐系统的验证是分阶段的。离线验证回答“模型学得怎么样”在线验证回答“用户买不买账”。这一步的重点是用Hit Rate和NDCG这类推荐指标衡量排序结果而不是只盯着AUC。Hit Rate10的意思是用户真正喜欢的电影有没有出现在推荐列表前10位里NDCG10则带上了位置惩罚——排第3和第9都算命中但排第3显然更好。python import numpy as npdef evaluate_recall(user_ids, gt_dict, model, movie_pool, top_k10): hit, ndcg 0, 0 for uid in user_ids: # 对候选池批量打分 scores model.predict({user_id: np.array([uid] * len(movie_pool)), movie_id: np.array(movie_pool)}) top_items np.argsort(scores.reshape(-1))[::-1][:top_k] gt gt_dict.get(uid) if gt is not None and gt in top_items: hit 1 rank np.where(top_items gt)[0][0] ndcg 1.0 / np.log2(rank 2) return hit / len(user_ids), ndcg / len(user_ids)这段评估代码里有两个容易忽略的点。movie_pool不能是“全部电影”因为把用户已经看过的玩过但没标记为正样本的电影评分算进去会干扰指标含义建议对验证集的每个用户从候选池里剔除已知正样本后再评估。另外gt_dict的构建要严格基于验证集的打分记录不能拿训练集的高分电影当标准答案否则又把时间泄露问题带回来了。评估通过之后就是导出模型。TensorFlow 2.x导出SavedModel格式只需要一行命令导出目录带上版本号目的就是给线上服务加载一个固定版本避免训练新模型后接口无感知变化导致线上推荐结果抖动。我的习惯是导出后再写一个几十行的推理脚本把导出目录重新加载一遍验证输入输出的shape和训练时一致。最后说说进阶方向。如果你发现排序模型的AUC不错但线上点击率提升有限可以把pointwise的二分类损失换成pairwise的排序损失比如BPR Loss随机抽一个正样本和负样本对让模型学会“正样本的打分比负样本高”。这个改变对推荐场景通常比单纯调模型结构更有效果因为它不再要求模型输出一个绝对准确的点击率而是逼迫它学会正确的相对顺序。训练时的做法是构造三元组(user_id, pos_movie_id, neg_movie_id)损失写为python def bpr_loss(y_true, y_pred): pos_score, neg_score tf.split(y_pred, 2, axis-1) return -tf.reduce_mean(tf.math.log(tf.sigmoid(pos_score - neg_score)))我自己的习惯是每次拿到新数据先花十分钟看一下样本分布和正负比例再开始调模型。训练头几轮只盯验证集AUC和loss曲线不去看训练集指标能省下大量自我怀疑的时间。这套从数据到双塔召回再到NCF排序的流程是我用过的最稳妥的第一步实现方案照着走一遍你对整个电影推荐系统的设计就有一个完整的手感了。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →