ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

协同过滤算法实战:从零构建电影推荐系统

协同过滤算法实战:从零构建电影推荐系统 简介一份面向Python学习者与计算机专业毕业设计/课程设计的电影推荐系统完整项目。系统基于协同过滤算法涵盖用户与物品相似度计算、评分预测、Top-N推荐等核心环节并给出基于用户和基于物品两类实现思路算法层涉及余弦相似度、皮尔逊相关系数等相似度度量同时考虑缺失值处理、异常值清洗等数据预处理步骤可帮助读者理解推荐系统从数据清洗、特征处理到算法落地与界面展示的全流程。压缩包共227个文件约6.77MB主要包括17个Python源码文件、14个pyc编译文件、7个CSV评分与电影数据集、1个SQL数据库脚本、148张JPG截图及CSS/JS/HTML前端页面另附README说明文档便于按模块学习并快速部署运行。已有41人学习下载项目结构清晰适合作为毕业设计选题参考、课程设计答辩展示或推荐系统入门实战练习。1. 协同过滤不是唯一答案但它是电影推荐系统最稳的起点做推荐系统的人容易犯一个毛病一上来就上深度学习把双塔、FM、Transformer 全堆上去结果数据量连一万条评分都没有模型学出来的东西还不如直接给用户推热映大片。电影推荐系统设计这个题目尤其容易掉进这个陷阱——因为电影评分的公开数据集太容易拿到大家默认数据够多、特征够全却忽略了协同过滤本身的价值边界在哪里。协同过滤的逻辑其实非常朴素用户 A 和用户 B 看过相似的电影那 A 没看过但 B 喜欢的电影大概率 A 也会喜欢。它不关心电影是什么类型、导演是谁、海报长什么样只关心“谁和谁像”。这种“以人推物”的思路在冷启动不严重、用户行为稠密的场景下往往比内容推荐更直接有效。这也是为什么在毕业设计、课程项目和企业内部 Demo 里“基于协同过滤的电影推荐系统”始终是出现频率最高的题目——它既涵盖了推荐系统最核心的算法思想又不至于复杂到一个人做不完。我需要先讲清楚一件事协同过滤不是银弹但它是理解推荐系统的最佳入口。当你把基于用户的协同过滤UserCF和基于物品的协同过滤ItemCF都实现一遍再踩一遍相似度计算、稀疏矩阵、冷启动这些坑你后面学矩阵分解、图神经网络推荐、甚至大模型推荐都会有坐标系。这篇文章从数据、算法、工程落地三个层面把一个完整可运行的电影推荐系统拆开讲透。2. 协同过滤算法的数学原理与选型判断2.1 从评分矩阵说起协同过滤到底在算什么所有协同过滤算法的起点都是一个用户-物品评分矩阵。行是用户列是电影矩阵里的值代表用户对电影的评分。现实中这个矩阵极度稀疏——一个用户看过几百部电影已经很活跃了但电影总数可能是几万部矩阵稀疏度通常在 99% 以上。协同过滤算法的任务就是把这个矩阵中缺失的值预测出来然后按照预测评分从高到低为用户生成推荐列表。两个用户之间的相似度计算常见的有三种方式我实践下来各有适用场景皮尔逊相关系数Pearson适用于评分尺度不一致的场景。有的用户习惯打 34 分有的用户习惯打 15 分如果直接用余弦相似度用户本身的评分习惯会干扰相似度计算。皮尔逊相关系数通过减去用户自己的平均分来消除这个偏差公式如下# 皮尔逊相关系数 import numpy as np def pearson_sim(user1_ratings, user2_ratings): # 只取两个用户都有评分的电影 common (user1_ratings 0) (user2_ratings 0) if common.sum() 0: return 0.0 r1 user1_ratings[common] r2 user2_ratings[common] if len(r1) 2: return 0.0 # 减去各自均值消除评分尺度偏差 r1_mean r1.mean() r2_mean r2.mean() numerator ((r1 - r1_mean) * (r2 - r2_mean)).sum() denominator np.sqrt(((r1 - r1_mean) ** 2).sum() * ((r2 - r2_mean) ** 2).sum()) if denominator 0: return 0.0 return numerator / denominator这段代码的关键在于先做“共同评分项”的筛选common.sum() 0时直接返回 0再去均值、算协方差。denominator 为 0 的情况在真实数据里很容易出现——比如两个用户共同评分的电影只有一部或者某一方评分完全一致此时相关系数无意义直接返回 0 是稳妥做法。余弦相似度Cosine实现最简单、计算最快适用于评分矩阵已经做过归一化处理的场景比如把评分减掉全局均值。它的缺陷是不考虑用户评分习惯差异两个都爱打高分的人会被算成相似即使他们对具体电影的口味并不一致。调整余弦相似度Adjusted Cosine在 ItemCF 里比标准余弦相似度效果更好。计算物品 i 和物品 j 的相似度时先减去每个用户对该物品评分的均值再做余弦计算。原因在于不同用户对同一部电影的评分尺度不同减去用户均值相当于做了一次用户维度的标准化。2.2 UserCF 和 ItemCF 的取舍什么时候用哪个选 UserCF 还是 ItemCF不能拍脑袋要看业务场景。UserCF 的思路是找到与目标用户最相似的 K 个用户把这 K 个用户喜欢的、目标用户没看过的电影按加权分数推荐给他。它社交属性更强适合新闻推荐、社区内容推荐这类用户兴趣变化快的场景。但在电影推荐里UserCF 有个明显问题用户数量远大于电影数量时用户相似度矩阵的计算和存储成本都很高而且电影推荐面对的是几百万用户量级实时计算用户相似度的延迟很难压下来。ItemCF 的思路则是计算电影之间的相似度给用户推荐他看过电影的相似电影。Amazon 最早使用 ItemCF 做“买了 A 的人也买了 B”它胜在物品数量通常远小于用户数量物品相似度矩阵可以离线算好存起来线上查询的时候只需要查表加聚合。对电影推荐来说ItemCF 更合理——用户的兴趣会漂移但电影之间的内容相关性是相对稳定的。我一般会这样选型维度UserCF基于用户ItemCF基于物品适用场景用户少、物品多、实时性要求高物品少、用户多、准确性优先冷启动表现新物品容易冷启动只要被少数人评分就能推荐新物品冷启动严重需要积累足够评分可解释性较弱“和你相似的人喜欢”较强“因为你喜欢《星际穿越》推荐《盗梦空间》”离线计算成本用户相似度矩阵 O(U²)U 大时不可行物品相似度矩阵 O(I²)I 通常可控实时更新用户新行为需要重算相似度物品相似度可低频更新线上增量简单电影推荐场景下我几乎总是先做 ItemCF因为物品数量可控矩阵容易离线维护线上推理只有两步查表和一次加权求和工程上非常干净。2.3 相似度计算的三个必踩的坑第一个坑是零均值处理缺失。直接用原始评分算余弦相似度会导致所有“高评分用户”互相相似所有“低评分用户”互相相似与电影内容无关。实践中至少要做中心化处理减去用户均值或全局均值。第二个坑是共同评分数量过少。两个用户只看过同一部电影且都打了 5 分皮尔逊系数算出来是 1.0相关系数满分——但这个相似度完全没有统计意义。规避方法是设置一个最小共同评分阈值我一般取 5~10小于阈值直接判为不相似。第三个坑是数据稀疏下的计算性能。暴力实现的时候算一个用户相似度要遍历所有其他用户复杂度是 O(U² × I)。数据量到了几十万用户就没法看了必须用倒排索引先为每个物品建立评分用户列表计算相似度时只遍历共同评分过的用户对复杂度降一个量级。3. 从 MovieLens 数据到可运行的最小推荐系统3.1 数据准备用 MovieLens 1M 还是 100K做电影推荐系统MovieLens 数据集是事实标准。它由明尼苏达大学 GroupLens 研究组维护包含用户 ID、电影 ID、评分1~5 分、时间戳和用户画像信息。两个常用版本MovieLens 100K10 万条评分、943 个用户、1682 部电影适合快速验证算法单机跑无压力用来调试相似度计算的边界条件很方便。MovieLens 1M100 万条评分、6040 个用户、3952 部电影更接近真实分布训练集/测试集切分的统计意义更明显推荐质量评估结果可参考性更高。我一般在开发阶段先用 100K 跑通流程确认无 bug 后再切 1M 出最终结果。不要直接上 1M 调试每次跑一遍 ItemCF 还要等计算相似度矩阵太浪费时间。数据下载后是zip压缩包解压后会得到三个文件ratings.dat、users.dat、movies.dat。MovieLens 1M 的字段分隔符是::需要注意# 解压 data.zip 并查看数据文件结构 unzip data.zip -d ml-1m/ head -5 ml-1m/ratings.dat # 输出示例 # 1::1193::5::978300760 # 1::661::3::978302109 # 1::914::3::978301968 # 1::3408::4::978300275 # 1::2355::5::978824291各字段依次是用户 IDUserID、电影 IDMovieID、评分Rating取值 1~5、时间戳Timestamp。head看到的时间戳是 Unix 时间戳如果要转成可读时间可以用date -d 978300760做验证但推荐系统的特征工程阶段一般用不到这个字段。3.2 加载与预处理pandas 完成评分矩阵构建用 pandas 加载评分数据并转换成用户-物品矩阵代码很直接。关键点是处理好缺失值没有评分的位子填 0 还是留 NaN取决于后面相似度计算用的是 numpy 还是 scipy。import pandas as pd import numpy as np # 读取评分数据注意分隔符是 :: ratings pd.read_csv( ml-1m/ratings.dat, sep::, names[userId, movieId, rating, timestamp], enginepython, encodinglatin-1 ) # 过滤掉评分数量过少的用户和电影降低矩阵稀疏度 user_count ratings[userId].value_counts() movie_count ratings[movieId].value_counts() ratings ratings[ratings[userId].isin(user_count[user_count 20].index)] ratings ratings[ratings[movieId].isin(movie_count[movie_count 10].index)] # 构建用户-电影评分矩阵缺失值填 0 rating_matrix ratings.pivot_table( indexuserId, columnsmovieId, valuesrating ).fillna(0)这里的过滤逻辑是实践里的关键。原始 MovieLens 1M 矩阵稀疏度已经很高如果不把评分次数太少的用户和电影去掉相似度计算结果会被大量噪声干扰。我只保留评分次数不少于 20 的用户和不少于 10 的电影这是一种通用做法具体阈值可以根据数据量调整逻辑是先降稀疏度再算相似度。3.3 ItemCF 核心代码完整可跑的推荐流程下面这段代码是我在本地验证 ItemCF 的完整实现直接复制就能跑注释里标注了每个环节的作用。from sklearn.metrics.pairwise import cosine_similarity import numpy as np # 1. 计算电影-用户倒排矩阵转置评分矩阵行变成电影 # 目的是计算电影之间的相似度 movie_user_matrix rating_matrix.T # 2. 计算电影之间的余弦相似度矩阵 # 矩阵形状为 (n_movies, n_movies)[i][j] 表示电影 i 和电影 j 的相似度 item_sim_matrix cosine_similarity(movie_user_matrix.values) # 把相似度矩阵转成 DataFrame方便按电影 ID 索引 item_sim_df pd.DataFrame( item_sim_matrix, indexmovie_user_matrix.index, columnsmovie_user_matrix.index ) def recommend_itemcf(user_id, top_n10, k_neighbors20): 基于物品协同过滤为用户生成推荐列表 参数说明 - user_id: 目标用户 ID - top_n: 最终返回的推荐电影数量 - k_neighbors: 给每部电影取多少个最相似的近邻电影参与打分 # 当前用户已经评过分的电影评分大于 0 的列 user_rated rating_matrix.loc[user_id] rated_movies user_rated[user_rated 0].index # 对用户没看过的电影打分 candidate_scores {} for movie_id in rating_matrix.columns: if movie_id in rated_movies: continue # 对每个候选电影找到用户已看过且与其相似的电影 sim_sum 0.0 score_sum 0.0 for rated_movie in rated_movies: sim item_sim_df.loc[movie_id, rated_movie] if sim 0: continue sim_sum sim score_sum sim * user_rated[rated_movie] if sim_sum 0: candidate_scores[movie_id] score_sum / sim_sum # 按预测分数降序排序取前 top_n 个 sorted_scores sorted(candidate_scores.items(), keylambda x: x[1], reverseTrue) top_items [movie_id for movie_id, _ in sorted_scores[:top_n]] return top_items这个实现对干代码最重要的三个点做个拆解。第一它先构建了电影-用户倒排矩阵这一步决定了相似度计算的对象是电影而不是用户。第二评分预测用的是加权平均——用户对已看过的电影的评分乘以两部电影的相似度再除以相似度之和做归一化这比直接累加更合理因为不同电影的近邻数量不一样直接累加会导致高分电影优势过大。第三if sim 0: continue这个判断过滤掉了不相似的电影避免负相似度把预测分数拉向错误方向。3.4 小规模验证让输出结果可解释算法跑完不是终局你要能解释“为什么给用户推荐了这部片子”。我在评测时常用的做法是抽出推荐结果的相似来源def explain_recommendation(user_id, movie_id, top_k5): 解释电影 movie_id 为什么被推荐给用户 user_id user_rated rating_matrix.loc[user_id] rated_movies user_rated[user_rated 0] # 找出已看电影中与 movie_id 最相似的 k 部 sims [(m, item_sim_df.loc[movie_id, m], user_rated[m]) for m in rated_movies.index] sims.sort(keylambda x: x[1], reverseTrue) res [] for movie, sim, score in sims[:top_k]: res.append(f看过《{movie}》(评分{score:.0f})相似度{sim:.3f}) return f推荐《{movie_id}》理由: ; .join(res) # 示例解释用户 1 看到的第 1 个推荐 recs recommend_itemcf(1, top_n5) for m in recs[:3]: print(explain_recommendation(1, m))这个可解释性模块在答辩和项目展示中非常加分也符合业务侧对推荐系统的基本要求——不能只会给结果还要能讲清楚推荐理由。虽然上面的代码直接用 movieId 显示电影名称实际项目中要 joinmovies.dat把 ID 映射成片名。4. 系统架构与工程化从单机脚本到可复用服务4.1 推荐系统各模块的职责拆分单机脚本谁都会写但要称为“系统设计”至少要能在模块层面讲清楚数据从哪来、计算结果存在哪、线上推理如何响应。一个可维护的电影推荐系统通常分四层数据存储层、离线计算层、在线服务层、评估与监控层。数据存储层评分数据存在 MySQL 里做业务回源物品相似度矩阵和推荐结果存到 Redis 或 HBase 供线上快速读取。开发阶段用 pandas 生成的中间结果落到本地文件即可。离线计算层定时任务每天或每周跑一次 ItemCF 相似度矩阵更新。注意不是每次用户产生新评分都重算全量矩阵那样计算成本太高。常见做法是离线全量重算 在线增量更新每天凌晨跑一次全量白天新产生的评分只更新该用户的候选列表。在线服务层接收用户 ID从 Redis 读取该用户的推荐列表直接返回。如果该用户是新用户冷启动降级为热门电影榜单。如果该用户有实时行为但还没进离线更新窗口追加一个轻量的“实时相似推荐”逻辑。4.2 相似度矩阵的存储与定时更新方案物品相似度矩阵在电影数量为 1 万时矩阵大小是 1 亿个浮点数约 800MB 内存。直接全量存 Redis 有点浪费实际工程中我一般只保留每部电影 Top-K 相似邻居比如每部电影只存相似度最高的 50 个邻居这样存储量直接降两个量级。# 只保存每部电影 Top-K 相似邻居压缩存储量 k 50 top_neighbors {} for movie_id in item_sim_df.index: # 排除自身取相似度最高的 K 个 neighbors item_sim_df.loc[movie_id].sort_values(ascendingFalse) neighbors neighbors.drop(movie_id).head(k) top_neighbors[movie_id] neighbors.to_dict() # 序列化后存入 Rediskey 用 itemcf:sim:{movieId} import json import redis r redis.Redis(hostlocalhost, port6379, db0) for movie_id, neighbors in top_neighbors.items(): r.set(fitemcf:sim:{movie_id}, json.dumps(neighbors))这段存储逻辑有一个容易被忽略的设计决策只保留 Top-K 而非全量相似度这背后是精度与延迟的权衡。相似度很低的电影对预测贡献极小还拖慢推理速度。K 取多少我的经验是 2080 之间电影领域 50 是比较稳的默认值。定时更新可以用 APScheduler 加到主进程也可以在独立机器上用 Crontab 跑 Python 脚本我更倾向于后者因为离线任务失败不影响线上服务。# 伪代码定时任务入口每天凌晨 2 点执行全量重算 def update_itemcf_job(): ratings load_ratings_from_db() rating_matrix build_matrix(ratings) sim_matrix compute_item_sim(rating_matrix) top_neighbors extract_top_k(sim_matrix, k50) write_to_redis(top_neighbors) # 写日志并发送告警如果矩阵数据量异常说明上游数据有问题 logger.info(fitemcf updated, movies: {len(top_neighbors)})4.3 接口设计推荐 API 的参数和返回格式前端或客户端要接入推荐能力需要一个标准化的 HTTP 接口。我常用的设计是/api/recommend参数包括用户 ID、推荐数量还可选传入过滤条件。返回格式采用 JSON便于联调核心字段如下参数名类型必填说明user_idint是目标用户 IDtop_nint否返回条数默认 10建议不超过 50exclude_seenbool否是否过滤已看过的电影默认 truerecommend_reasonbool否是否返回推荐解释默认 false排错时开启from flask import Flask, request, jsonify app Flask(__name__) app.route(/api/recommend, methods[GET]) def recommend_api(): user_id int(request.args.get(user_id)) top_n int(request.args.get(top_n, 10)) exclude_seen bool(request.args.get(exclude_seen, True)) # 在线服务只做查表和聚合不实时计算相似度 recs get_recommend_list(user_id, top_n, exclude_seen) if not recs: # 冷启动降级返回热门电影 recs get_hot_movies(top_n) return jsonify({code: 0, data: recs, user_id: user_id}) if __name__ __main__: app.run(host0.0.0.0, port8000)这个接口的一个关键设计是冷启动降级逻辑当用户没有历史评分或算法没有产出时不是报错而是返回热门榜单兜底。线上推荐系统最怕的不是推荐不准而是接口不可用。5. 评估指标与参数调优不只是算法能跑就行5.1 离线评估召回率、精确率、覆盖率、多样性推荐系统不仅有准确率。评估一个协同过滤模型我一般看五个指标精确率PrecisionK推荐的前 K 部电影里有多少是用户真正喜欢评分 4的。公式是用户喜欢且被推荐的电影数 / 推荐电影总数衡量的是推荐列表的“含金量”。召回率RecallK用户所有喜欢的电影里有多少被推荐出来了。公式是用户喜欢且被推荐的电影数 / 用户喜欢的电影总数。覆盖率Coverage系统推荐出的不同电影数量占总电影数量的比例。如果覆盖率低说明推荐集中在那几部热门电影上长尾物品没有被利用。多样性推荐列表中不同类型、不同导演的电影占比。实现上是计算推荐物品之间的平均相似度平均相似度越低越好。# 一个简洁的评估函数 def evaluate_itemcf(test_set, train_matrix, k10): test_set 是用户实际的行为数据; train_matrix 是训练集评分矩阵 precision_sum 0.0 recall_sum 0.0 user_count 0 for user_id, liked_movies in test_set.items(): # 在训练矩阵上生成推荐 recs recommend_itemcf(user_id, top_nk) if not recs: continue hit len(set(recs) set(liked_movies)) precision_sum hit / k recall_sum hit / len(liked_movies) user_count 1 return { precision10: precision_sum / user_count, recall10: recall_sum / user_count }评估时要注意时间切分问题不能用随机切分训练集和测试集那会造成“未来数据预测过去行为”的泄漏。正确做法是按时间序列切分——用前 80% 时间段的评分训练用后 20% 时间段的评分测试。推荐系统是一个时间敏感场景用户当下的行为受之前行为影响时序切分才能模拟真实线上环境。5.2 必调参数K 近邻数、相似度阈值、归一化方式ItemCF 最核心的参数是近邻数 K它对推荐结果的影响显著。K 太小候选电影的特征表达不充分K 太大噪声会被带进预测。近邻数 K表现原因5 ~ 10精确率高但召回率低候选池太小大量相关电影未被覆盖20 ~ 50综合表现最好平衡了候选池规模与噪声影响100精确率下降低相似度邻居对得分的干扰逐渐增大第二个参数是相似度阈值即只保留相似度大于某值的邻居参与打分。我一般会在0.1 ~ 0.3之间做试验根据最终推荐结果的好坏反向调整。第三个参数是评分归一化。原始 1~5 分的评分预测容易受到用户评分习惯影响常见的做法是做 z-score 归一化# z-score 归一化 def normalize_ratings(rating_matrix): 对每行用户做均值方差归一化 user_mean rating_matrix[rating_matrix 0].mean(axis1) user_std rating_matrix[rating_matrix 0].std(axis1) # 避免除零用 epsilon 兜底 user_std user_std.replace(0, 1e-8) normalized rating_matrix.sub(user_mean, axis0).div(user_std, axis0) # 把原来的 0 保持为 0无评分项不参与归一化 normalized[rating_matrix 0] 0 return normalized归一化后的评分在做加权平均时不同用户的评分尺度被拉齐预测值会更接近“这个用户相对自己平均水平的偏好”而不是绝对分值。5.3 冷启动问题新用户新电影怎么推荐协同过滤有个天生的短板它依赖历史行为。新用户没有任何评分算法无法计算他和其他用户的相似度新电影没有评分算法无法计算它和其他电影的相似度。冷启动的常见解法按顺序从简单到复杂对于新用户直接推荐全局热门电影这个方案简单有效因为热门电影被喜欢的概率较高保证第一个接口不空返回即可。用户产生几条评分后再切换到 ItemCF 推荐效果平滑串接。对于新电影收集电影的内容属性导演、演员、类型标签在 ItemCF 计算相似度的时候如果新电影没有评分就回退到基于内容的相似度——比如类型标签相同的电影直接赋予一个先验相似度。这个方案不需要训练模型就是查电影元数据表。这些方案都不是完美的但你在答辩或者项目汇报时能把冷启动的解法讲清楚说明你真的理解推荐系统工程落地而不只是背了一个算法公式。6. 用 visual 评估和 zip 项目交付时的一个提速技巧当你要交付这个电影推荐系统给别人运行时把项目打包成zip安装包是常见做法。这里有一个经常踩坑的地方zip 包解压后路径里带空格或不兼容字符导致导入数据zip文件时路径拼接出错。我一般习惯在做最终交付前跑一次 7-Zip 或 unzip 的完整校验确保压缩包里文件完整。# 校验 zip 包完整性 unzip -t movie_recommendation.zip # 输出包含 No errors detected 才算完整另一个提升交付质量的技巧是把数据集和代码分开打包。MovieLens 数据文件较大且不会频繁变动代码会多次修改。合在一个 zip 里每次改代码都要整个包重新传递。我一般打成两个包code.zip代码 requirements.txt README.md和data.zip解压后的数据文件。README 里必须有且仅有这几行能让人不读代码就能跑起来# 一键运行 python main.py --mode itemcf --dataset data/ml-1m # 评估模式 python main.py --mode eval --k 10最后一个提速技巧是用来验证 ItemCF 结果是否合理的简单方法取一部电影输出它最相似的 5 部电影人工看一下结果是否合理。比如《星球大战1977》的相似电影应该是《帝国反击战》《绝地归来》如果算法推荐出的是毫无关联的喜剧片那大概率是数据预处理环节出了问题——特征没有清洗干净或者相似度公式选错了。这个“单点验证 人工判断”的流程比跑完整评估指标快得多适合在每次改代码后做快速回归。这种数据质量审查习惯比调参本身更能决定最终系统的好坏。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进