☰
从零实现电影推荐系统:Python+TensorFlow深度协同过滤实战
2026/9/28 2:38:33 网站建设 项目流程

简介:基于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)只需交互记录差低,但稀疏场景泛化弱
深度协同过滤(Embedding+MLP)只需交互记录差中高,但表达能力强

如果业务里几乎没有用户行为数据,那就老老实实走基于内容;只要有几十万条交互记录,深度协同过滤的性价比就开始体现。

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="::", engine="python", names=["userId", "movieId", "rating", "timestamp"], encoding="latin-1", ) print(df["rating"].value_counts().sort_index()) print("用户数:", df["userId"].nunique(), "电影数:", df["movieId"].nunique())

这里sep是文件里的实际分隔符,MovieLens的ratings.dat用的是双冒号,read_csv默认的单分隔符会解析失败;engine="python"是为了支持多字符分隔符,否则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,查表得到NaN,fillna(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_dim=32): 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, activation="relu"), layers.Dense(32, activation="relu"), ]) self.out = layers.Dense(1) def call(self, inputs, training=False): 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层本质是查表,输入必须是整数ID,num_users和num_movies决定表长,也就是行数,embed_dim决定每个ID被表示成多长的向量。u * m是逐元素乘法而不是点积,点积会把多维向量压缩成一个标量,丢信息;逐元素乘保留每个维度的交互信息。MLP分支的Dense(64)、Dense(32)是隐层,激活函数用relu,隐单元从大到小让信息逐步浓缩。最后一层Dense(1)不加sigmoid,配合后面损失函数里的from_logits=True,数值上比“输出层加sigmoid再算交叉熵”更稳。

embed_dim默认给32,这是电影数量在几千到几万量级时的一个稳妥起点。不要一上来就设128,维度太大在长尾电影上容易过拟合。数据量真的大了,再往64、128方向调。

3.3 损失函数到底该用MSE还是BCE:评分预测和TopK推荐是两件事

这个标题没有明说是“预测评分”还是“做TopK推荐”,但写代码前必须定下来,因为损失函数和评估方式完全不同。

目标输出层损失评估
评分预测,比如预测这部电影会被打4.2分不加激活的Dense(1)MSERMSE / MAE
TopK推荐,预测用户会不会喜欢不加激活的Dense(1) + sigmoidBCEHit@K / NDCG@K

我的选择是做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_neg=4): 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, dtype=np.int64), np.array(batch_movies, dtype=np.int64), ), np.array(batch_labels, dtype=np.float32)

user_items在训练前构造一次,是“用户ID -> 已交互电影集合”的查表,用set保证成员判断是O(1)。负采样从1到num_movies-1之间随机取,避开0号未知位;while循环防止抽到用户已经看过或者这一轮里已经抽过的电影,否则会把正样本误标成负样本,干扰模型。返回的label长度等于batch_size乘以(1+num_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, dtype=tf.float32) with tf.GradientTape() as tape: logits = model(x, training=True) loss = tf.reduce_mean( tf.keras.losses.binary_crossentropy(y, logits, from_logits=True) ) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss model = NCF(num_users, num_movies, embed_dim=32) optimizer = tf.keras.optimizers.Adam(learning_rate=1e-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(f"epoch {epoch} loss {loss.numpy():.4f}")

binary_crossentropy配合from_logits=True,意思是模型输出的是未经过sigmoid的logit,损失函数内部做数值稳定的sigmoid和交叉熵计算;如果模型输出层已经加了sigmoid,这里就要改成from_logits=False,两头都做会让梯度出问题。loss用reduce_mean取平均,因为binary_crossentropy返回的是逐样本损失。sample_batch本身每次随机抽样,所以不需要额外对全量数据做shuffle。

4.2 三个必调参数:embedding维度、学习率、负采样个数

模型能跑起来之后,真正花时间的是调参。三个参数最值得先动:

超参起步值怎么调
embedding维度32太小欠拟合,太大过拟合;按数据量级取16到64
学习率1e-3(Adam)loss震荡就降到3e-4,收敛太慢暂时加到3e-3
负采样个数41到2区分度太弱,8到16训练变慢且正样本被稀释

embedding维度要参考num_movies的数量级。几百部电影用16维就够了,几千到几万部用32到64。维度过大时每个ID学出一段过度个性化的向量,高频电影还能学明白,长尾电影基本是在硬记。调整策略是固定其它超参,只改维度,在验证集上对比Hit@10,不要凭感觉拍板。

学习率这块,Adam默认的1e-3大多数情况不用动。但推荐数据稀疏、batch又小,loss震荡很常见。先确认负采样和标签配对没问题,再动学习率,否则容易把模型调成“看起来在收敛但推荐结果完全不可用”的状态。负采样个数影响的是正负样本比例。K太大时一个batch里全是负样本,模型倾向预测“不喜欢”,推理时整体分数偏低但排序通常还能用,只是训练时间白白拉长。

还有一个常被忽略的正则化问题:不要在模型初期加Embedding的L2正则。先让模型正常收敛到这个数据集上的合理水平,再考虑防过拟合。真要加,系数从1e-5起步,加在Embedding层的embeddings_regularizer参数上。

4.3 离线评估:Hit@10与NDCG@10的实现和解读

推荐系统离线评估不能用准确率,因为负样本是你自己抽的,准确率会被负样本数量带着跑。常见做法是对测试集里每个用户取一条真实正样本,再随机抽99条该用户没交互过的电影作负样本,让模型对这100条打分;真实正样本的排名越靠前,模型越好。Hit@10看的是正样本有没有进前10,NDCG@10还会把排名的先后折算进去。

def evaluate_ranking(model, test_df, k=10, num_neg=99): 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, dtype=np.int64) scores = model.predict( [users, np.array(candidates)], verbose=0 ).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保证可复现。Hit@10能到0.3这个量级,我会认为模型学出了有效信号;低于0.2,先查数据泄漏和负采样逻辑,而不是急着加模型层数。

5. 避坑指南:电影推荐系统从能跑到能用的5个常见问题

5.1 训练loss在降,推荐列表却全被热门电影霸占

现象:训练几轮后loss从0.7降到0.4,看起来在收敛。把测试集里某个用户的候选电影丢进模型打分,排名靠前的全是《肖申克的救赎》《阿甘正传》这类高评分热门片。

原因:评分数据天然是长尾分布,热门电影出现在正样本里的次数远超长尾电影。模型只要学到“流行度偏置”就能把整体loss压下去,它不需要理解用户个性化偏好。另一个放大器是负采样:如果负样本没有排除用户历史交互过的电影,模型会把“没评过分”简单等同于“不喜欢”,热门电影同时出现在正负样本里,表示就被拉乱了。

解决:负采样前先过滤掉用户所有历史交互过的电影,代码里user_items已经做了这件事。更彻底的做法是把“是否热门”作为特征接进模型。最便宜的验证方法:看不同用户推荐列表的重叠率,如果两个人的前10有8部一样,基本实锤。把这条修复之后,Hit@10通常会明显上涨。

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 离线指标漂亮,线上业务指标不动

现象:Hit@10从0.25提升到0.35,上线做A/B实验,点击率、观影时长都没明显变化。

原因:离线评估用的是随机负采样,线上用户看到的是“被展示出来”的候选集,两者分布根本不同。离线负样本是均匀随机抽的,模型只需要把“绝不可能被用户看到的电影”排除掉就能拿高分;线上候选集本来就是过滤后的,排序难度完全不同。另外单条正样本评估法只抽了一组负样本,指标方差很大,5个点的提升可能只是噪声。

解决:评估集尽量用真实曝光日志;没有曝光日志,就按时间切分,负样本从“该用户之前被曝光过但没点击”的池子里抽。调参时不要只看一轮Hit@10,跑至少3组随机负采样取平均。这是我做过最贵的踩坑:把时间全花在调离线指标上,不如先确认评估口径和线上一致。

6. 上线前的最后一公里:导出SavedModel并做两级缓存

6.1 导出模型与最小推理接口

训练结束后把模型导出成SavedModel格式,这是TensorFlow的标准发布形态。目录里包含saved_model.pb和variables文件夹,线上加载它就行,不需要再把训练代码搬过去。

model.save("recommender_model", save_format="tf")

加载时注意要在服务进程启动时执行,不要在请求函数里加载。路径不要带中文,服务器上尽量用绝对路径。加载一次之后,所有请求复用,这时单次predict的耗时就是真实的模型推理耗时,不再包含图构建。

6.2 热门兜底与用户结果缓存

两级缓存是我在这类系统里一定会做的最后一件事。第一级是热门列表:训练集里按交互次数倒序取前200部电影,遇到新用户直接返回。第二级是用户结果缓存:每个userId的推荐结果缓存10分钟,数据量不大时用进程内dict加过期时间就够,没必要一上来就上Redis。

还有一个部署习惯:Linux服务器上部署Python服务时用venv建独立环境,别和系统自带的python混用。很多人在这上面栽过跟头,问题根本不是代码,而是解释器路径和依赖被装乱了。电影量级只有几千到几万时,模型排序本身就是最快路径,不用急着把Embedding表推到外存做向量检索。我最早做这个题目时,总想把模型结构做得越复杂越好,最后发现数据泄漏和负采样才是决定成败的地方,模型反而很朴素。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询