基于Python与TensorFlow的电影推荐系统:从召回、排序到特征工程

📅 发布时间:2026/10/14 13:26:55
基于Python与TensorFlow的电影推荐系统:从召回、排序到特征工程
简介一套基于Python与TensorFlow的电影推荐系统设计与实现工程面向想入门推荐系统、深度学习及协同过滤算法的开发者与学习者。项目完整覆盖数据清洗、特征工程、模型训练与评估流程涉及矩阵分解、用户/物品协同过滤等经典思路适合作为课程设计或实战参考。压缩包内共10个文件以Python源码、CSV数据集、ZIP工程包及IDE配置为主整体仅2.34MB轻量便于快速下载与本地复现其中CSV为预处理后的评分数据ZIP内含ml-latest-small原始数据集与完整项目工程Python脚本则集中了模型构建与TensorBoard可视化逻辑。目前已有1247人学习下载。从这份资源中可以清晰看到从数据处理到模型调优的完整代码组织方式也能直接导入主流开发环境对照学习是理解推荐系统落地细节的实用资料。1. 基于Python与TensorFlow的电影推荐系统为什么值得自己动手做打开任何一个视频平台首页推荐里十部有八部是你想看的这个「猜你喜欢」背后就是推荐系统。电影推荐系统是入门推荐算法最合适的载体数据规模适中、用户行为语义清晰、效果好坏一眼就能看出。用Python配合TensorFlow来做核心是把「用户看过什么、电影有什么属性、你点击了什么」这些原始行为转成模型能吃的特征再用神经网络去学「什么组合能带来一次点击」。这个方案能解决的不只是毕设或实训需求更是把召回、排序、特征工程、离线评估这条工业链路完整走一遍的最好方式。适合刚学完Python基础、想接触深度学习的开发者也适合已经用Spark或SQL做过统计类推荐、想升级成模型化推荐的工程师。2. 推荐系统的整体架构先想清楚召回和排序再动手2.1 为什么电影推荐必须分成召回和排序两个阶段很多第一次做推荐系统的人上来就写一个模型把全量电影库丢进去预测评分结果要么训练慢得离谱要么预测结果全是热门电影。原因在于全量电影可能上万部对每个用户计算每一部电影的得分再排序取TopN计算量呈线性爆炸。工业界的标准解法是把流程拆成召回和排序两段。召回阶段的目标是从全量电影里快速捞出一两百部候选强调「快」和「全」。排序阶段的目标是对候选集逐一点击率或评分预测强调「准」。电影推荐场景里召回通常用协同过滤或基于规则的粗筛完成排序用TensorFlow搭建的深度模型完成。这两段各司其职整体架构才算立得住。另外电影数据有天然的稀疏性。用户看过的电影占全量比例可能不到5%如果直接训练一个深度模型做全量预测正负样本比例严重失衡模型会倾向于把所有电影都预测成「不感兴趣」。召回阶段把候选集缩小到用户可能感兴趣的范围再在这个小范围里做精细排序正好规避了这个失衡问题。这也是所有推荐系统落地时都要遵守的第一条规则。2.2 电影推荐的数据基础评分、行为日志和电影画像动手前先梳理数据。电影推荐系统至少需要三类数据用户历史行为观看、评分、收藏、电影属性标题、类型、导演、演员、简介、用户画像年龄、性别、注册时间等。如果是真实业务前三者通常存在MySQL或日志系统里如果是学习项目建议直接使用公开的MovieLens风格数据集包含用户ID、电影ID、评分、时间戳四元组。一个容易被忽略但很重要的点评分数据不等于点击数据。评分是显式反馈代表用户的明确态度点击或观看时长是隐式反馈代表用户的真实行为。实际项目中隐式反馈量级远大于显式反馈且噪音更低。做毕设或Demo优先用显式评分数据起步做真实系统务必把隐式行为纳入特征。字段说明方面我一般这样约定user_id和movie_id必须做ID化处理转成连续整数方便Embedding查询rating归一化到0到1区间genres保留原始文本用于构建多热编码。下面是一个标准的数据预处理框架把原始评分表转成模型训练样本。import pandas as pd import numpy as np from sklearn.preprocessing import LabelEncoder # 原始评分数据user_id, movie_id, rating, timestamp ratings pd.read_csv(ratings.csv) # ID化让user_id和movie_id变成从0开始的连续整数 user_enc LabelEncoder() movie_enc LabelEncoder() ratings[user_idx] user_enc.fit_transform(ratings[user_id]) ratings[movie_idx] movie_enc.fit_transform(ratings[movie_id]) # 评分归一化到[0, 1]作为点击率模型的近似标签 ratings[label] ratings[rating] / 5.0 # 按用户划分训练/验证集避免同一用户出现在两边 users ratings[user_idx].unique() np.random.seed(42) val_users np.random.choice(users, sizeint(len(users) * 0.2), replaceFalse) train ratings[~ratings[user_idx].isin(val_users)] val ratings[ratings[user_idx].isin(val_users)]这段代码里最关键的是最后按用户划分数据集。很多初学者直接按行随机切分同一个用户的历史行为会同时出现在训练集和验证集里模型等于见过答案去考试离线指标虚高。LabelEncoder把ID压缩成稠密连续整数是为了后面查Embedding表时直接拿整数下标索引这一步做不好TensorFlow里Embedding层会报维度错误。2.3 候选集召回基于物品的协同过滤实现排序模型之前需要一份候选集。基于物品的协同过滤ItemCF是最稳妥的召回方案思路是用户看过电影A那和电影A最相似的电影B值得推荐。相似度用「同时被哪些用户看过」来度量。这个方案实现简单、可解释性强是后续排序模型的兜底。from collections import defaultdict # 构建 电影 - 看过它的用户集合 movie_users defaultdict(set) for uid, mid in zip(ratings[user_idx], ratings[movie_idx]): movie_users[mid].add(uid) # 计算电影相似度Jaccard相似度 # 只统计被至少50个用户看过的电影过滤长尾噪音 def compute_movie_sim(movie_users, min_users50): movie_list [m for m, u in movie_users.items() if len(u) min_users] sim_matrix defaultdict(dict) for i, m1 in enumerate(movie_list): users1 movie_users[m1] for m2 in movie_list[i1:]: users2 movie_users[m2] inter len(users1 users2) if inter 5: # 共现用户太少相似度不可靠 continue union len(users1 | users2) sim inter / union if sim 0.1: sim_matrix[m1][m2] sim sim_matrix[m2][m1] sim return sim_matrixJaccard相似度的特点是只看交集占比不看评分高低。它更适合做召回而非排序因为召回阶段要的是「相关」排序阶段要的是「优质」。min_users和inter这两个阈值是召回质量的生死线阈值太高相似电影都捞不出来太低相似关系全是噪音。我一般先统计电影被评次数的分布取中位数附近的整数。3. 特征工程决定模型上限的关键一步3.1 特征分箱与连续特征处理模型效果的上限在特征工程深度学习只是把特征组合的方式变聪明了。电影推荐场景的特征可以分三类类别型特征用户ID、电影ID、类型、数值型特征电影平均评分、用户历史行为次数、文本型特征标题、简介。三类特征的处理方式完全不同。数值型特征最需要小心。直接扔进神经网络量纲差异会导致训练震荡比如「电影时长140分钟」和「用户评分4.5」数值范围差异很大。常见处理是标准化或分箱。我习惯用分箱因为分箱后的特征可以走Embedding能学到非线性的边界效应而标准化只能做线性缩放。def bucketize(feature, bins, labels): # 把连续特征分箱返回类别索引 return pd.cut(feature, binsbins, labelslabels, include_lowestTrue).astype(int) # 示例把电影平均评分分成10个箱 ratings[avg_rating_bucket] bucketize( ratings[avg_rating], bins[0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0], labelslist(range(8)) ) # 把用户历史行为次数取对数压平长尾 ratings[log_hist_cnt] np.log1p(ratings[user_hist_count])分箱的bin边界不是随便定的要看特征分布。先用describe()看分位数再按分位数的3到5个关键点划分保证每个箱里样本量不悬殊。最后一个log1p是对计数型特征的经典操作用户行为次数往往呈长尾分布几百条行为的大户和几条行为的小白差异太大取对数能拉近距离。3.2 文本特征把电影简介变成向量电影的标题、类型、简介包含大量语义信息。类型字段适合做多热编码简介文本要用TF-IDF或预训练向量。对于电影推荐我推荐TF-IDF起步训练快、可解释性强不需要额外下载几十GB的预训练模型。from sklearn.feature_extraction.text import TfidfVectorizer # 电影数据movie_id, title, genres, overview movies pd.read_csv(movies.csv) # 把类型和简介拼接成一个文本字段 movies[text] movies[genres].fillna() movies[overview].fillna() tfidf TfidfVectorizer( max_features2000, stop_wordsenglish, ngram_range(1, 2) ) text_matrix tfidf.fit_transform(movies[text]) # text_matrix 是稀疏矩阵后续与ID特征拼接时注意保留稀疏性max_features设2000是因为电影语料本身不大词表超过2000后大部分是低频噪音词。ngram_range里的(1,2)同时保留单词和双词短语比如「science fiction」作为一个整体才有意义。TF-IDF矩阵是稀疏的后续如果直接转成稠密数组喂给模型内存和训练开销都会暴涨正确的做法是保留稀疏矩阵做Embedding的加权或者用SVD降维到128维再进模型。3.3 用户-电影交叉特征为什么用FM思维做特征组合单看用户特征或单看电影特征模型学不到「某用户喜欢某类型电影」这样的交互语义。传统做法是手动构造交叉特征比如「用户的常看类型 × 当前电影类型」但人工交叉的维度爆炸且漏掉大量隐式组合。因子分解机FM的思路给了更好的方案让每个特征的Embedding向量自动学习与其他特征的交互。DeepFM这类模型把这个思路做到了极致底层用一个Embedding矩阵存储所有稀疏特征的向量特征交互在一阶线性部分和二阶FM部分分别建模。这也是我用TensorFlow做主模型的根本原因——TensorFlow原生支持稀疏特征和Embedding不需要自己写大量分布式框架代码。交叉特征的设计思路是不手工拼特征名而是把所有类别特征拼成一个稀疏ID列表让模型自动学交互权重。4. TensorFlow模型搭建从Wide部分到Deep部分的落地实现4.1 用TensorFlow构建输入Pipeline让稀疏特征优雅进入模型TensorFlow的数据管线设计直接决定训练效率和代码可维护性。推荐系统特征几乎全是稀疏的最忌讳的是把稀疏特征转成稠密数组再塞进模型。标准做法是用tf.ragged或tf.sparse结构传特征配合Embedding层自动查表。下面是我常用的特征输入定义。import tensorflow as tf # 定义稀疏特征列每个特征对应一个ID空间 feature_columns { user_idx: tf.feature_column.categorical_column_with_identity( user_idx, num_buckets5000), movie_idx: tf.feature_column.categorical_column_with_identity( movie_idx, num_buckets10000), genres_mhot: tf.feature_column.categorical_column_with_identity( genres_mhot, num_buckets20), } # 嵌入维度经验法则取特征唯一值数量的4次方根 embedding_dim 16 deep_columns [ tf.feature_column.embedding_column(fc, dimensionembedding_dim) for fc in feature_columns.values() ] # 数值特征直接进Dense层 numeric_columns [ tf.feature_column.numeric_column(avg_rating_bucket), tf.feature_column.numeric_column(log_hist_cnt), ]num_buckets要大于实际唯一值数量否则训练时碰到未见ID会抛错。Embedding维度取特征基数的4次方根是一个稳妥起点用户ID有5000个Ex16电影ID有10000个Ex16左右。维度太小表达力不够太大容易过拟合且撑大模型体积。4.2 Wide Deep模型线性记忆与深度泛化的组合2016年提出的Wide Deep结构是推荐排序模型的经典基线思路是Wide部分学「记忆」——用户之前点过什么就再推什么Deep部分学「泛化」——从未见过的特征组合也能推断出兴趣。两条分支输出相加最后过Sigmoid得到点击概率。我第一次实践时踩过一个坑Wide部分直接塞进原始ID特征没有做任何交叉结果Wide部分退化成纯偏置项。后来补了手动交叉特征才有效果。# 构建Wide Deep模型 def build_wide_deep(deep_columns, wide_columns, numeric_columns): inputs {} # 所有特征都要有对应的Input层 all_columns deep_columns wide_columns numeric_columns feature_layer tf.keras.layers.DenseFeatures(all_columns) deep_input tf.keras.Input(shape(len(deep_columns) len(numeric_columns),)) # 实际项目中deep部分输入是DenseFeatures的输出 x tf.keras.layers.Dense(128, activationrelu)(deep_input) x tf.keras.layers.Dense(64, activationrelu)(x) wide_input tf.keras.Input(shape(len(wide_columns),)) wide_out tf.keras.layers.Dense(1, activationNone)(wide_input) concat tf.keras.layers.concatenate([x, wide_out]) output tf.keras.layers.Dense(1, activationsigmoid)(concat) model tf.keras.Model(inputs[deep_input, wide_input], outputsoutput) model.compile(optimizeradam, lossbinary_crossentropy, metrics[tf.keras.metrics.AUC()]) return model上面是简化版实际工程中DenseFeatures层会负责把稀疏ID转成Embedding再拼接。一个关键参数是Deep部分隐藏层的宽度和深度电影推荐的数据量通常在百万级以内两层12864足够数据量上亿再考虑三到四层。层数过深会导致参数量远超样本量验证集AUC不升反降。4.3 DeepFM把二阶特征交互显式建模Wide Deep的Wide部分需要手工构造交叉特征DeepFM把交叉特征换成了FM的二阶交互层自动化完成「特征乘法」。FM部分会对每对特征Embedding做内积再求和等价于模型自己发现「用户是科幻迷 当前电影是科幻片」这类组合信号。对电影推荐场景DeepFM的命中率明显高于纯Wide Deep原因就在于电影的类型偏好和演员偏好是天然的二阶交互信号。class FM_Layer(tf.keras.layers.Layer): def __init__(self): super().__init__() def call(self, embeddings): # embeddings: [batch, num_features, embedding_dim] # 二阶项sum((sum(vi*x)^2 - sum(vi^2*x^2))) / 2 sum_square tf.square(tf.reduce_sum(embeddings, axis1)) square_sum tf.reduce_sum(tf.square(embeddings), axis1) return 0.5 * tf.reduce_sum(sum_square - square_sum, axis-1) # 用法把稀疏特征查表后的Embedding拼成三维张量接入FM层 # embeddings tf.stack([embed_1, embed_2, ...], axis1) # fm_out FM_Layer()(embeddings)FM层的公式是推荐系统入门必推的经典推导。它的复杂度从O(n^2)降到O(n)因为利用了和的平方减去平方的和这个恒等变换。实现时要注意embeddings的维度排列axis1必须是特征维度写错了FM层算出来就是负值且无法收敛。4.4 训练配置与效果验证AUC不够还要看召回率模型训练配置直接影响最终效果。我的基准配置是batch size 256优化器Adam学习率0.001早停patience3。训练时要同时监控AUC和LogLossAUC只看排序能力LogLoss能反映预测概率校准度。还有一个容易被忽略的指标是召回率K排序结果里用户真的点击了多少。线上推荐系统更关心这个指标。# 训练与早停配置 early_stop tf.keras.callbacks.EarlyStopping( monitorval_auc, modemax, patience3, restore_best_weightsTrue) model.fit( train_dataset, validation_dataval_dataset, epochs50, batch_size256, callbacks[early_stop] ) # 离线评估算TopN召回率 def recall_at_k(model, user_features, ground_truth, k10): scores model.predict(user_features) pred_topk np.argsort(scores.squeeze())[::-1][:k] hit len(set(pred_topk) set(ground_truth)) return hit / len(ground_truth)早停的restore_best_weights参数很多人忘记加导致训练结束后模型权重停在最后一轮而不是最优一轮。验证AUC波动时没这个参数就等于白早停了。召回率K的ground_truth建议用用户后30%时间内的真实观看记录做测试避免把训练集里的行为当正样本评估。5. 推荐模型踩坑清单从数据泄漏到效果翻车5.1 数据泄漏模型在训练集上AUC0.99验证集却一塌糊涂现象训练AUC高达0.99验证AUC只有0.72差距悬殊。原因我在构造用户历史行为特征时把包含预测时刻之后的行为统计也加了进去。比如用全量数据算用户的平均评分这个平均值包含了未来的信息模型等于提前看到了答案。这是新手最容易犯的错误。推荐系统是严格的时间序列场景任何特征都不能使用预测时刻之后的数据。解决按时间切分特征。用户历史统计特征只计算预测时间点之前的数据并在验证集上保持同样的口径。代码上可以按时间戳排序分别对训练窗口和验证窗口独立做特征聚合绝不让两个窗口共享任何统计量。这条规矩不光管特征工程也管样本构建——正样本是用户真实点击的电影负样本必须是用户曝光却没点的电影不能随机采样没曝光过的。5.2 Embedding维度过大训练慢且过拟合现象把用户ID的Embedding维度设为128训练速度明显变慢验证Loss在10轮后开始反弹。原因Embedding表的大小是「特征基数 × 维度」。用户数5000、维度128光用户Embedding就是64万个参数。如果训练样本只有50万条参数量和样本量几乎一样模型必然过拟合。解决把维度降到16同时增加L2正则。我现在的习惯是先用16维跑通再试32维对比选择验证集AUC最高的。不要盲目相信「维度越大信息越多」在推荐系统里Embedding维度过大导致的过拟合风险远大于信息损失风险。另外观察Embedding层的梯度范数也能辅助判断——范数异常大的特征列往往有问题。5.3 正负样本不平衡模型全预测负类现象预测概率全部集中在0.1以下几乎没有正样本被召回。原因电影推荐场景里用户真正会看的电影占候选集比例极低。如果用随机负采样构造训练集负样本占比超过99%模型学到的就是「全部预测为负」。这和前面说的召回阶段过滤是两层关系召回阶段缩小候选空间但排序阶段内部还需要精细控制正负比例。解决负采样比例控制在1比2到1比5之间。每1条正样本配2到5条负样本负样本从曝光未点击的池子里采样。还要注意不是随机采负样本而是倾向选「难负样本」——比如热门但用户没点过的电影这样的负样本信息量更大。代码里做一个采样器保证batch内部正负比例稳定不要在全局混完后随机打乱。5.4 线上效果翻车离线AUC很高线上没有变化现象离线AUC 0.85上线后点击率纹丝不动。原因离线评估用的是历史数据模型学会了历史偏好但没有学到用户的实时兴趣漂移。电影推荐尤其明显——用户上周爱看科幻片这周可能因为一部热门喜剧改变了口味。离线评估天然无法捕捉这种时效性。解决线上做小流量AB实验时不要只测整体点击率要按用户活跃度分层看指标。高活跃用户受新模型影响小低活跃用户才能体现召回能力的提升。另外给模型加一个时间衰减权重训练样本按时间越近权重越大让模型更重视近期行为。这个操作在数据层面就能完成不需要改动模型结构。6. 进阶玩法用TensorFlow Serving上线和Embedding可视化检验模型训练完不等于系统做完真正值得投入的是上线路径和模型诊断。TensorFlow Serving是最成熟的上线方案把训练好的模型导出成SavedModel格式启动一个HTTP服务即可提供在线预测。推荐系统的在线推理和离线训练有一个关键区别离线是批量预测在线是单请求预测延迟必须控制在几十毫秒以内这要求输入Pipeline保持轻量特征查询要缓存。Embedding可视化是我建议每个做推荐系统的人都做一步的诊断工具。把用户和电影的Embedding用t-SNE降维到2D平面用散点图画出来。效果好的模型电影Embedding会自然聚成类型簇科幻片和科幻片挨着动画片和动画片挨着效果差的模型所有点混成一团说明特征没学到有效语义。from sklearn.manifold import TSNE import matplotlib.pyplot as plt # 提取电影Embedding movie_embedding_layer model.get_layer(movie_embedding) movie_vectors movie_embedding_layer.get_weights()[0] # [num_movies, dim] # t-SNE降维到2D tsne TSNE(n_components2, perplexity30, random_state42) movie_2d tsne.fit_transform(movie_vectors) # 按类型着色检查聚类效果 plt.scatter(movie_2d[:, 0], movie_2d[:, 1], cmovie_genre_labels, s5, cmaptab20)perplexity30是个稳妥默认值数据量超过几千点时适当调大。t-SNE的结果每次运行都会略有差异不要因为形状不同而怀疑模型只看聚类分布是否和电影类型语义一致。如果聚类明显说明Embedding学到了有意义的语义特征如果没有聚类先回去检查特征工程而不是调模型结构。最后一个习惯我每次做完推荐模型都会做一次「特征重要性反向验证」。方法是把某个特征的值随机打乱重新预测观察AUC下降幅度。下降越大说明特征贡献越大。这个操作能帮你找准模型依赖的核心信号也能识别是否误用了未来信息。电影推荐系统看似简单真正把数据、特征、模型、评估这条链路走通之后你会发现任何推荐场景的落地都遵循同一套方法论。希望这篇笔记能帮你在自己的项目里少走几步弯路把推荐系统从「玄学调参」变成「有条理的工程实践」。本文还有配套的精品资源点击获取