多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python电影推荐系统实战:协同过滤落地与冷启动优化

Python电影推荐系统实战:协同过滤落地与冷启动优化 简介这是一套面向计算机专业本科生的Python毕业设计实战项目聚焦协同过滤推荐算法在电影推荐场景中的工程落地帮助学习者掌握推荐系统核心原理与全栈开发流程。资源包含62个文件主体为16个Java源码与16个编译后class文件构成后端推荐引擎辅以6个JS前端交互脚本、3个JSP页面及2个XML配置文件支撑Web应用运行另有MovieLens数据集data.zip、推荐结果可视化图slope1.png、user1.png等及完整README.md说明文档压缩包共18.41MB。目前已有134人学习下载。读者可直接部署运行获得含用户-电影矩阵构建、余弦相似度计算、基于用户的协同过滤推荐、实时推荐接口及结果展示的完整闭环方案并通过源码结构清晰理解前后端协作逻辑与数据库集成方式。1. 为什么用协同过滤做电影推荐不是“跑通就行”而是要让冷启动用户也能点开第一推荐你手头这个.zip文件里装的不只是“Python毕业设计”四个字——它是一套能真实跑起来、调得动、改得动的电影推荐系统最小闭环从用户打分数据建模到实时生成 Top-N 推荐列表再到 Flask 封装成可交互网页。很多人解压后直接python app.py结果卡在ImportError: No module named sklearn或数据库连接失败就以为“协同过滤太难”。其实问题不在算法本身而在于协同过滤对数据稀疏性极度敏感而电影评分数据天然稀疏一个普通用户只评过 520 部电影但 IMDb 公开库有超 100 万部影片99% 的用户, 电影组合是空值。这时候硬套公式推荐结果要么全是热门片丧失个性化要么直接报nan矩阵分解崩了。本篇不讲“什么是协同过滤”只讲怎么用 Python 把它稳稳落地在电影场景里选哪种协同过滤基于用户的基于物品的还是 SVD、为什么 SQLite 就够用别一上来就 MySQL、如何用pandas做预处理防稀疏陷阱、Flask 路由怎么接推荐逻辑而不阻塞主线程、以及最关键的——当新用户没打过任何分时怎么靠“相似用户热门补充”给出第一条可信推荐。适合正在写毕设、想交一份能演示、能答辩、能现场改参数的实操者也适合想补全推荐系统工程链路的转行工程师。2. 从原始数据到可训练矩阵清洗、填充与稀疏度控制三步法协同过滤不是“有数据就能训”电影评分数据的脏、稀、偏会直接让surprise库里的SVD()收敛失败或推荐结果发散。我一般不碰原始 MovieLens 数据集直接上模型而是先做三层过滤去噪 → 补缺 → 降维。下面每一步都对应data_preprocess.py中可复现的代码块参数值来自我在 3 个不同规模数据集ml-100k / ml-1m / 自采豆瓣影评上的实测阈值。2.1 过滤低频用户与冷门电影砍掉 30% 的无效交互MovieLens 的ratings.dat或ratings.csv里常混入大量只评 12 部电影的用户以及只有 13 条评分的电影。这些样本不仅不提供协同信号还会大幅拉高稀疏率sparsity 98%导致相似度计算失真。我的做法是用户至少评过 10 部电影电影至少被 20 人评过分再构建用户-物品矩阵。import pandas as pd # 加载原始评分数据以 ml-100k 为例 df pd.read_csv(data/ratings.csv, names[user_id, movie_id, rating, timestamp]) # 统计用户和电影的交互频次 user_counts df[user_id].value_counts() movie_counts df[movie_id].value_counts() # 筛选高频用户 高频电影 valid_users user_counts[user_counts 10].index valid_movies movie_counts[movie_counts 20].index df_filtered df[ df[user_id].isin(valid_users) df[movie_id].isin(valid_movies) ].copy() print(f原始数据量: {len(df)} 条) print(f过滤后数据量: {len(df_filtered)} 条 ({len(df_filtered)/len(df)*100:.1f}%)) print(f最终用户数: {df_filtered[user_id].nunique()}) print(f最终电影数: {df_filtered[movie_id].nunique()})逻辑说明value_counts()统计频次后取布尔索引比groupby().size().reset_index()更省内存copy()防止 SettingWithCopyWarning输出打印不是摆设——若过滤后只剩 500 用户说明原始数据质量差需换数据源如 ml-1m或放宽阈值用户≥5电影≥10。参数说明user_counts 10是经验值。低于 5相似用户找不准高于 20可能剔除大量长尾用户影响冷启动能力。movie_counts 20同理太严≥50会丢掉小众佳作太松≥5引入噪声。2.2 处理缺失评分不插值而是用“隐式反馈热度加权”替代很多教程教用均值/中位数填充NaN但在电影推荐里这是致命错误用户没评某部电影不等于不喜欢更可能是没看过。强行填 3.5 分会让模型误判“该用户对所有未看影片持中立态度”破坏偏好结构。我的方案是完全不填充显式评分而是将原始评分矩阵转为二值交互矩阵1评过分0未评再叠加电影热度作为权重。import numpy as np from scipy.sparse import csr_matrix # 构建用户-电影交互矩阵二值化 user_ids df_filtered[user_id].astype(category).cat.codes movie_ids df_filtered[movie_id].astype(category).cat.codes # 创建二值矩阵有评分即为 1 interaction_matrix csr_matrix( (np.ones(len(df_filtered)), (user_ids, movie_ids)), shape(user_ids.max()1, movie_ids.max()1) ) # 计算电影热度被评分次数归一化到 [0.1, 1.0] movie_popularity np.array(interaction_matrix.sum(axis0)).flatten() movie_popularity 0.1 0.9 * (movie_popularity - movie_popularity.min()) / (movie_popularity.max() - movie_popularity.min() 1e-8) # 构建带热度权重的稀疏矩阵用于基于物品的协同过滤 weighted_matrix interaction_matrix.multiply(movie_popularity)逻辑说明csr_matrix比numpy.ndarray节省内存 10 倍以上对 1000×5000 矩阵内存从 40MB 降到 3MBmultiply()是逐元素乘把每列电影乘上其热度权重1e-8防止除零。这个加权矩阵不参与 SVD 训练但用于后续物品相似度计算让热门电影在相似度中自然占优。参数说明热度范围缩放到[0.1, 1.0]是为了防止热门电影权重过大淹没长尾。0.1 是下限确保冷门片仍有基础权重1.0 是上限避免《阿凡达》一类影片主导全部推荐。2.3 生成训练/测试集时间感知切分拒绝随机打乱协同过滤必须模拟真实场景——用户未来的行为不能基于未来数据预测。MovieLens 数据含timestamp但很多毕设代码直接train_test_split(random_state42)这会导致数据泄露模型看到用户未来评的分再预测“过去”的推荐指标虚高RMSE 0.8 但上线后全不准。正确做法是按时间排序取最后 20% 作为测试集。# 按时间戳排序确保测试集是用户最新行为 df_sorted df_filtered.sort_values([user_id, timestamp]) # 对每个用户取最后 20% 的评分作为测试 test_mask [] for uid in df_sorted[user_id].unique(): user_data df_sorted[df_sorted[user_id] uid] n_test max(1, int(len(user_data) * 0.2)) # 至少留 1 条测试 test_idx user_data.index[-n_test:] test_mask.extend(test_idx.tolist()) df_test df_sorted.loc[test_mask] df_train df_sorted.drop(test_mask) print(f训练集大小: {len(df_train)}) print(f测试集大小: {len(df_test)}) print(f测试集覆盖用户数: {df_test[user_id].nunique()}/{df_sorted[user_id].nunique()})逻辑说明sort_values([user_id, timestamp])保证同用户内按时间升序max(1, ...)防止单评分用户被剔出测试集drop(test_mask)比~df_sorted.index.isin(test_mask)更快。参数说明0.2可调但不要低于 0.15测试样本太少指标抖动大不要高于 0.25训练数据不足模型欠拟合。若数据无 timestamp如部分豆瓣爬虫数据则按用户 ID 分组后对每组sample(frac0.2, random_state42)虽非最优但比全局随机好。3. 协同过滤选型实战基于物品的 KNN 为何比 SVD 更适合作为毕设核心很多毕设文档写“采用 SVD 矩阵分解”但实际跑起来发现surprise.SVD(n_factors100)在 i5 笔记本上训 2 小时predict()响应延迟 800ms且新用户冷启动时推荐全是《泰坦尼克号》。这不是算法不行而是没匹配场景。电影推荐系统毕设的三个硬约束① 本地运行无 GPU② 演示流畅响应 300ms③ 冷启动友好新用户注册即有推荐。SVD 在①②上吃亏而基于物品的协同过滤Item-Based CF天然满足。下面对比三种主流实现并给出可直接抄的ItemKNN最小可行代码。3.1 三种协同过滤的实测性能对比ml-100k 数据方法训练时间i5-8250U单次预测耗时冷启动支持RMSE测试集是否需要 sklearnUser-Based KNNsurprise.KNNBasic12s420ms❌需历史评分0.94✅Item-Based KNNsurprise.KNNWithZScore8s180ms✅用热门补0.91✅SVDsurprise.SVD48min850ms❌需历史评分0.87✅LightFM隐式反馈3min210ms✅支持内容特征0.89✅✅关键结论Item-Based KNN 是唯一同时满足“快、准、冷启动可用”的选项。它的核心思想是用户喜欢 A 片大概率也喜欢与 A 相似的 B 片。相似度用余弦计算不依赖用户共现User-Based 需找“和你一起看《教父》的 200 人”Item-Based 只需算“《教父》和《低俗小说》有多像”因此稀疏数据下更鲁棒。3.2 用 Surprise 库实现 Item-Based KNN5 行代码完成训练与预测Surprise 是专为推荐系统设计的 Python 库比 sklearn 的NearestNeighbors更贴合场景内置交叉验证、评分预测、Top-N 推荐。以下代码直接对应model_train.py中的核心段from surprise import Dataset, Reader, KNNWithZScore from surprise.model_selection import train_test_split # 定义数据格式评分范围 0.5~5.0步长 0.5 reader Reader(rating_scale(0.5, 5.0)) data Dataset.load_from_df(df_train[[user_id, movie_id, rating]], reader) # 划分训练集Surprise 内置无需自己 split trainset data.build_full_trainset() # 配置 Item-Based KNN相似度用余弦邻居数 40基于物品 sim_options { name: cosine, user_based: False, # 关键设为 False 即 Item-Based k: 40, # 邻居数40 是 ml-100k 最优值 } algo KNNWithZScore(sim_optionssim_options) algo.fit(trainset) # 预测用户 1 对电影 100 的评分 pred algo.predict(uid1, iid100) print(f预测评分: {pred.est:.2f})逻辑说明KNNWithZScore比KNNBasic多一步 Z-score 标准化消除用户打分习惯差异有人习惯打 4~5 分有人只打 2~3 分user_basedFalse是开关设错就变成 User-Basedk40需实测k20 时推荐太窄k60 时引入噪声。参数说明rating_scale必须和数据一致否则predict()返回nanbuild_full_trainset()自动处理索引映射不用手动编码 user/movie ID。3.3 生成 Top-N 推荐列表绕过 Surprise 的 predict()直接用相似度矩阵加速Surprise 的get_neighbors()只返回相似物品 ID不带相似度值而predict()是单点预测生成 Top-10 要调 10 次慢。高效做法是提取物品相似度矩阵对用户已评电影的相似物品加权聚合一次算出所有候选电影得分。import numpy as np from scipy.sparse import csr_matrix # 获取物品相似度矩阵shape: n_movies × n_movies sim_matrix algo.similarities # 获取用户 1 已评电影列表及评分 user_ratings df_train[df_train[user_id]1][[movie_id, rating]].set_index(movie_id)[rating].to_dict() # 初始化推荐得分字典 scores {} for mid, rating in user_ratings.items(): if mid len(sim_matrix): continue # 防越界 # 获取与 mid 相似的 top-k 片排除自身 sim_scores sim_matrix[mid].toarray().flatten() top_sim_ids np.argsort(sim_scores)[::-1][1:21] # top-20跳过自己 for sim_mid in top_sim_ids: if sim_mid not in user_ratings: # 只推荐未看过的 scores[sim_mid] scores.get(sim_mid, 0) sim_scores[sim_mid] * rating # 按得分排序取 top-10 top10 sorted(scores.items(), keylambda x: x[1], reverseTrue)[:10] print(Top-10 推荐电影 ID:, [x[0] for x in top10])逻辑说明sim_matrix[mid].toarray().flatten()提取第mid行相似度向量np.argsort(...)[::-1]降序索引[1:21]跳过自身索引 0取前 20scores.get(..., 0)避免 KeyError。此方法比循环predict()快 12 倍实测 15ms vs 180ms。参数说明top-20是平衡精度与速度的折中若要更高精度可扩大到 50但耗时增加若要更快缩到 10但推荐多样性下降。4. Flask Web 服务搭建轻量化部署的关键是“异步加载缓存兜底”毕设答辩时最怕什么——导师说“我点一下推荐等了 5 秒才出来”。不是算法慢是 Flask 默认同步阻塞一个请求卡住后面全排队。而电影推荐又不能像电商那样预生成所有用户推荐1000 用户 × 10000 电影 10M 条必须按需计算。我的方案是用concurrent.futures.ThreadPoolExecutor异步执行推荐前端用 Skeleton 骨架屏过渡后端用functools.lru_cache缓存热门用户结果。三者结合首屏加载 800ms后续点击 300ms。4.1 Flask 路由改造分离计算与响应避免主线程阻塞标准 Flask 写法是app.route(/recommend)里直接调get_top_recommendations()这会让 HTTP 请求等待计算完成。改成路由立即返回 HTMLJS 发起 AJAX 获取推荐后端用线程池并行计算。from flask import Flask, render_template, request, jsonify from concurrent.futures import ThreadPoolExecutor import time app Flask(__name__) executor ThreadPoolExecutor(max_workers4) # 4 线程足够应付毕设并发 app.route(/) def index(): return render_template(index.html) app.route(/recommend, methods[POST]) def recommend(): user_id int(request.form.get(user_id, 1)) # 提交计算任务到线程池立即返回任务 ID future executor.submit(get_top_recommendations, user_id) task_id id(future) # 存储 future 到全局字典生产环境请用 Redis app.config[TASKS][task_id] future return jsonify({task_id: task_id, status: processing}) app.route(/task_status/task_id) def task_status(task_id): future app.config[TASKS].get(int(task_id)) if future and future.done(): result future.result() app.config[TASKS].pop(int(task_id), None) # 清理 return jsonify({status: done, recommendations: result}) return jsonify({status: pending})逻辑说明ThreadPoolExecutor比multiprocessing更轻量无进程开销max_workers4是笔记本安全值超过 4 线程 CPU 反而争抢app.config[TASKS]是简易任务队列生产环境必须换 Redisid(future)作 task_id 是临时方案实际可用uuid.uuid4()。参数说明max_workers不要设为 CPU 核数i5 是 4 核 8 线程但推荐计算是 I/O CPU 混合4 工作线程已饱和若部署到树莓派建议max_workers2。4.2 前端 AJAX 轮询用 setTimeout 替代 setInterval防请求堆积很多毕设前端用setInterval每秒查一次状态结果用户连点 3 次后台积压 3 个 pending 请求。正确做法是每次成功获取结果后再设下一次查询形成链式调用。!-- templates/index.html -- script function startRecommendation(userId) { fetch(/recommend, { method: POST, headers: {Content-Type: application/x-www-form-urlencoded}, body: user_id${userId} }) .then(res res.json()) .then(data { if (data.status processing) { pollTask(data.task_id); // 开始轮询 } }); } function pollTask(taskId, attempt 1) { if (attempt 30) { // 最多查 30 秒 alert(推荐生成超时请重试); return; } fetch(/task_status/${taskId}) .then(res res.json()) .then(data { if (data.status done) { displayRecommendations(data.recommendations); } else { // 成功后才设下次查询避免堆积 setTimeout(() pollTask(taskId, attempt 1), 1000); } }); } /script逻辑说明setTimeout 递归比setInterval更可控attempt 30是熔断机制防无限等待displayRecommendations()是渲染函数此处省略。参数说明轮询间隔1000ms是平衡及时性与服务器压力的值若网络差可改为2000ms不要低于500msHTTP 开销占比过高。4.3 LRU 缓存热门用户用 3 行代码降低 70% 的重复计算统计发现毕设演示时 80% 请求集中在用户 1、2、5、10。对这些用户首次计算后缓存结果后续请求直接返回无需重算。Python 内置lru_cache即可实现from functools import lru_cache lru_cache(maxsize100) # 缓存最近 100 个用户的结果 def get_top_recommendations_cached(user_id): return get_top_recommendations(user_id) # 原始计算函数 # 在路由中调用 app.route(/recommend, methods[POST]) def recommend(): user_id int(request.form.get(user_id, 1)) # ... 其他逻辑 future executor.submit(get_top_recommendations_cached, user_id) # ...逻辑说明lru_cache是线程安全的maxsize100意味着最多存 100 个(user_id,)元组的结果get_top_recommendations_cached是包装函数原函数保持不变。参数说明maxsize100是经验值ml-100k 有 943 用户缓存 100 个覆盖高频用户若内存紧张如树莓派可设为50设为None表示无限制但可能 OOM。5. 冷启动与边界问题新用户、新电影、评分突变的三类避坑指南协同过滤最让人头疼的不是“算不准”而是“算不了”——新用户没评分、新电影没交互、用户突然狂打 1 分。这些不是算法缺陷而是数据流中的必然边界。下面三条是我踩过的血泪坑每条都附现象、原因、解决代码。5.1 现象新用户注册后点击“推荐”页面空白或报 500 错原因algo.predict(uidnew_id, iidany)时Surprise 找不到 new_id 的训练记录抛KeyError: User unknown。解决对新用户不走协同过滤改用“热门电影 类型偏好”混合推荐。先从数据库读取当前月热度 Top-50 电影再根据用户注册时选的类型动作、爱情、科幻过滤def get_new_user_recommendations(): # 从 SQLite 读热门电影按评分人数排序 conn sqlite3.connect(movies.db) top_movies pd.read_sql_query( SELECT movie_id, title, genre FROM movies ORDER BY popularity DESC LIMIT 50 , conn) # 用户注册时提交的偏好示例action,romance user_prefs request.form.get(genres, action).split(,) # 按类型筛选取交集最多的 10 部 mask top_movies[genre].str.contains(|.join(user_prefs), caseFalse, naFalse) recs top_movies[mask].head(10)[movie_id].tolist() if len(recs) 10: recs top_movies[~mask].head(10-len(recs))[movie_id].tolist() return recs关键点popularity字段需在数据库中预先计算SELECT movie_id, COUNT(*) as popularity FROM ratings GROUP BY movie_idgenres字段用TEXT存用str.contains()模糊匹配比精确匹配更鲁棒。5.2 现象用户给《奥本海默》打 1 分后所有推荐变成恐怖片原因Item-Based CF 中《奥本海默》与《闪灵》相似度高都是高分严肃片但用户打 1 分导致算法误判“用户讨厌所有严肃片”连带压制《寄生虫》《小丑》等相似片。解决对极端评分1 或 5 分降权用rating_weight 1.0 - 0.3 * abs(rating - 3.0)动态调整贡献度# 在 Top-N 计算循环中替换原权重 for mid, rating in user_ratings.items(): weight 1.0 - 0.3 * abs(rating - 3.0) # 1分→0.4, 5分→0.4, 3分→1.0 # ... 后续用 weight * rating 替代 rating关键点系数0.3是调参结果0.2降权不足0.5过度削弱abs(rating - 3.0)假设 3 分是中性符合 MovieLens 5 分制惯例。5.3 现象管理员导入新电影后推荐列表消失原因新电影 ID 超出sim_matrix索引范围如原矩阵 5000×5000新片 ID5001sim_matrix[mid]报IndexError。解决入库新电影时同步更新相似度矩阵——不重训而是用“热度相似”快速补全def add_new_movie(movie_id, genre_list): # 插入新电影到数据库 conn.execute(INSERT INTO movies (movie_id, title, genre) VALUES (?, ?, ?), (movie_id, New Movie, ,.join(genre_list))) # 计算与现有电影的热度相似度Jaccard on genres existing_genres get_all_genres() # {mid: [action,drama]} new_genre_set set(genre_list) sim_scores [] for mid, glist in existing_genres.items(): inter len(new_genre_set set(glist)) union len(new_genre_set | set(glist)) jaccard inter / union if union 0 else 0 sim_scores.append((mid, jaccard)) # 取 top-10 相似电影存入相似度表供推荐时查 conn.executemany( INSERT OR REPLACE INTO item_similarity (item1, item2, similarity) VALUES (?, ?, ?), [(movie_id, mid, score) for mid, score in sorted(sim_scores, keylambda x: x[1], reverseTrue)[:10]] )关键点用Jaccard比余弦更适配类别型特征INSERT OR REPLACE避免重复插入相似度只存 top-10节省空间。6. 验证推荐效果不用 RMSE用“人工可读的 Top-3 准确率”代替毕设答辩时导师不会看你RMSE0.87而是会说“给我看看用户 123 的推荐为什么推《流浪地球》不推《独行月球》”——因为后者也是国产科幻且评分更高。这说明数值指标不能替代可解释性验证。我坚持用“Top-3 准确率”人工检查 20 个用户的真实最新评分看推荐 Top-3 中有多少部他们刚评过分且评分 ≥4.0。这个指标直白、可演示、能暴露算法盲区。6.1 构建验证集从测试集中抽样 20 个“高活跃用户”不是随机抽而是选最近 7 天内至少评 5 部电影的用户确保他们有新鲜偏好# 从 df_test 中筛选活跃用户 df_test[date] pd.to_datetime(df_test[timestamp], units) recent_users df_test[ df_test[date] (df_test[date].max() - pd.Timedelta(days7)) ].groupby(user_id).size().sort_values(ascendingFalse) active_users recent_users[recent_users 5].index[:20].tolist() print(验证用户:, active_users)6.2 生成推荐并比对自动化脚本输出可读报告def validate_top3_accuracy(user_list, top_n3): results [] for uid in user_list: # 获取该用户在测试集中的最新 3 条高分记录 user_test df_test[df_test[user_id]uid].sort_values(timestamp, ascendingFalse) true_positives user_test[user_test[rating] 4.0][movie_id].head(top_n).tolist() # 获取系统推荐 Top-3 recs get_top_recommendations_cached(uid) pred_top3 recs[:top_n] if len(recs) top_n else recs # 计算命中数 hits len(set(true_positives) set(pred_top3)) accuracy hits / min(len(true_positives), top_n) results.append({ user_id: uid, true_positive_movies: true_positives, predicted_movies: pred_top3, hits: hits, accuracy: accuracy }) # 输出 Markdown 表格答辩时直接截图 df_report pd.DataFrame(results) print(df_report[[user_id, true_positive_movies, predicted_movies, hits, accuracy]]) print(f\n平均 Top-3 准确率: {df_report[accuracy].mean():.3f}) validate_top3_accuracy(active_users)输出示例user_id true_positive_movies predicted_movies hits accuracy 0 123 [1001, 2005] [1001, 3002] 1 0.500 1 456 [5007, 6001] [5007, 6001] 2 1.000 ... 平均 Top-3 准确率: 0.6856.3 优化方向当准确率 0.6 时优先调这 3 个参数参数当前值调整建议验证效果sim_options[k]邻居数40若准确率低且推荐重复→ 20若推荐太冷门→ 60影响多样性与精准度平衡user_based是否用户基False仅当用户行为极丰富100 评时试 TrueUser-Based 对长尾用户更准但冷启动差热度权重系数0.9若热门片过多→ 0.7若冷门片推荐不出→ 0.95控制“马太效应”强度我带过 7 届毕设学生最容易卡在“调参无感”——改了k50指标没变就放弃。其实准确率变化常滞后 12 次验证因为推荐列表受缓存、热度、相似度共同影响。我的习惯是每次只调一个参数跑完 20 用户验证记录结果再调下一个。宁可花 3 小时验证也不盲目堆参数。这套流程跑下来Top-3 准确率稳定在 0.650.75答辩时导师点开任意用户都能说出“这个推荐合理因为用户喜欢 A 类型而 B 片和 A 片在题材/导演上相似”。希望帮到你。本文还有配套的精品资源点击获取
返回列表