基于Python协同过滤的电影推荐系统:评分矩阵、相似度计算与参数调优 📅 发布时间:2026/9/14 23:12:50 👁 浏览次数: 简介基于Python与协同过滤算法的电影推荐系统毕业设计项目面向计算机相关专业的学生可作为毕设或课程设计的完整参考。项目采用Django框架和MySQL数据库区分管理员与用户两个角色覆盖电影分类、电影信息、电影评分、个人中心及系统管理等模块实现个性化推荐逻辑。资源共689个文件包含Python源码、Vue页面、CSS/JS脚本、SQL数据库脚本、Word论文及mp4演示视频等压缩包仅21.77MB结构清晰便于查阅。已有73人学习下载。源码均经本地编译调试可运行并附带一键安装与启动脚本文档详细讲解系统设计、数据库结构与核心算法有助于快速理解协同过滤在真实业务场景中的应用适合作为毕业设计参考、功能扩展或二次开发基础。1. 为什么毕业设计选电影推荐系统几乎是不会踩空的选择打开毕设交易平台或者 GitHub 搜索页用「推荐系统」做关键词能翻出大量同名项目。但把这个标题拆开看——「基于 Python 和协同过滤算法的电影推荐系统」真正能在答辩时讲明白评分矩阵怎么构造、相似度为什么这么算、邻居数 K 怎么定、离线评估怎么做的人反而不多。毕业设计要的不是「跑通」而是每一个环节都能被追问、被复现、被写进论文。我为什么说这个选题不容易踩空因为协同过滤不依赖复杂网络结构和大量算力数据集用 MovieLens 公开数据就能撑起完整实验数据规模不大普通笔记本可以顺畅跑完而且评分稀疏、长尾分布、冷启动这些真实问题都会遇到恰好是论文里能展开写的素材。加上标题里提到的源码、万字论文、视频演示和文档目标很明确用最可控的工作量把推荐系统从数据处理到效果评估整条链路走通。本文就按这个顺序把每一步怎么做、参数怎么调、坑在哪讲清楚。2. 协同过滤的底座评分矩阵、相似度计算与邻居选择2.1 评分矩阵稀疏性是第一个必须理解的现实协同过滤的输入本质上是一张二维表行是用户列是电影单元格是评分。用 MovieLens 的数据举例ml-latest-small 包含 600 多个用户对 9000 多部电影的约 10 万条评分如果把这组数据展开成完整的用户-物品矩阵绝大多数单元格都是空的。这个空不是「没数据」这么简单它决定了后续所有相似度计算的写法。import pandas as pd import numpy as np ratings pd.read_csv(ml-latest-small/ratings.csv) movies pd.read_csv(ml-latest-small/movies.csv) print(ratings.shape) # (100836, 4) print(ratings.head()) # userId, movieId, rating, timestamp print(ratings[rating].value_counts().sort_index())输出里 rating 的分布一般集中在 3.0、4.0、5.02.0 以下很少。这个分布特征很重要高分段密集低分段稀疏直接按原始评分做相似度会被「高分偏好」主导。后面做基于用户的协同过滤时通常要把评分做均值中心化也就是减掉用户自己的平均打分再去算相似度否则打分普遍偏高的用户会和谁都像。构建评分矩阵的代码很简单但要意识到 pivot_table 之后的 NaN 才是真正的处理重点user_item ratings.pivot_table( indexuserId, columnsmovieId, valuesrating ) print(user_item.shape) # (610, 9724) 左右 print(user_item.iloc[:5, :5])这一步产生的是一个 610 行、9724 列的矩阵矩阵里绝大多数位置是 NaN。很多教程接着就写fillna(0)这是最省事但最需要警惕的操作把「没看过」当成「打了 0 分」会让两个只看过完全不同电影的用户因为大量的共同 0 而显得相似。这里我一般建议自己实现一个只基于共同评分的相似度函数或者至少在做相似度之前认真想清楚 0 的含义。2.2 相似度度量余弦相似度与皮尔逊相关系数的取舍相似度计算是协同过滤的心脏。最常见的两个指标是余弦相似度和皮尔逊相关系数二者本质上是一回事皮尔逊相关系数就是把向量各自减去均值之后再算余弦相似度。对评分数据来说用户 A 平均给 4 分、用户 B 平均给 3 分如果他们口味一致原始评分向量之间会有一个固定偏移余弦相似度会因此被压低而皮尔逊相关系数不受影响。相似度度量计算思路适用条件主要缺点余弦相似度向量点积除以模长向量本身已是同尺度、无系统偏移用户打分的整体尺度差异会被放大皮尔逊相关系数中心化后余弦用户打分尺度不一致只评过一部电影的用户会出现除零调整后余弦按物品中心化后余弦基于物品的协同过滤需要额外维护物品均值向量在基于用户的协同过滤里我优先用皮尔逊系数因为可以消掉用户打分习惯的差异在基于物品的协同过滤里因为要比较的是「同一部电影在不同用户那里的得分分布」直接用调整后余弦或原始余弦都可以。真正要避开的坑是fillna(0)之后直接套用现成的余弦函数这么做在用户维度上会引入大量虚假的相似关系。自己实现一个只考虑共同评分的余弦相似度并不复杂后面第 3 章会给出完整代码。理解这个自定义函数为什么存在比抄任何现成代码都重要。2.3 基于用户 vs 基于物品选型逻辑和适用场景协同过滤分两派基于用户UserCF找「和我口味像的人」把这些人看过而我没看过的电影推荐给我基于物品ItemCF找「和我看过的电影像的电影」把相似电影推荐出来。两者的根本区别在于相似度的计算方向UserCF 对用户向量两两做相似度ItemCF 对物品向量两两做相似度。选型的经验是数据规模小、用户量在几千以内UserCF 可解释性强适合论文里讲「相似用户」这个直觉概念用户量很大或者物品更新频繁的线上场景ItemCF 更稳定因为它抓住了「物品被哪些人喜欢」这个长期稳定的属性。毕业设计的语境下两种都能用但更推荐基于物品原因是电影数量相对用户数量更少物品相似度矩阵的计算量更小而且「看过《盗梦空间》的人还看了《星际穿越》」这种解释比「有一个和你相似的用户喜欢它」更容易写进论文和讲给评委听。矩阵的稀疏度是另一个决定因素。用户增长比物品增长快得多UserCF 的相似度矩阵规模随着用户量平方增长跑起来明显吃力。ItemCF 的矩阵规模只跟电影数量相关MovieLens 数据集下物品大约几千到一万矩阵规模可控普通电脑完全能算。下文按基于物品的路线展开。3. 用 Python 把协同过滤跑起来从 MovieLens 到 Top-N 推荐的最小实现3.1 数据准备读入评分表并按需过滤先做两件准备工作确认评分数据有没有重复记录以及过滤掉评分数量过少的用户。后者直接影响相似度质量——一个只打过 2 分的用户无论和谁算相似度都不可靠。数据清洗代码# 检查是否有完全重复的评分记录 dup ratings.duplicated(subset[userId, movieId]).sum() print(f重复评分记录数: {dup}) # 过滤评分数量少于 5 条的用户 user_count ratings[userId].value_counts() valid_users user_count[user_count 5].index ratings ratings[ratings[userId].isin(valid_users)] # 同理过滤被评分次数少于 5 的电影降低矩阵稀疏度 movie_count ratings[movieId].value_counts() valid_movies movie_count[movie_count 5].index ratings ratings[ratings[movieId].isin(valid_movies)]代码逻辑说明duplicated检查同一用户对同一电影的重复打分这在真实爬取的数据里很常见合并去重需要在分组后取均值或时间戳最新的那条value_counts加阈值过滤是控制矩阵稀疏度的最直接手段。过滤后用户数和电影数都会明显下降但相似度质量会提高。这里阈值取 5 不是定死的可以改成 10 或 1 对比实验论文里写清楚过滤策略对最终指标的影响即可。3.2 构建物品相似度矩阵先实现一个不吃 NULL 的余弦函数如前面所说直接用fillna(0)再调用 sklearn 的cosine_similarity是最常见的偷懒写法。这里先给出一个只考虑共同评分的自定义余弦函数它能作为最小验证逻辑让你直观看到相似度到底怎么算出来的def cosine_sim(a, b): # 只保留两者都有评分的维度 mask ~(np.isnan(a) | np.isnan(b)) if mask.sum() 0: return 0.0 a a[mask] b b[mask] return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))参数说明a和b是评分矩阵中两列或两行的全量向量含 NaNmask找出了两者都有评分的电影位置mask.sum() 0表示没有共同评分返回 0 而不是报错。这个函数的好处是清晰展示「相似度只建立在共同评价过的物品上」论文里可以直接引用这一段解释原理。但它的缺点也很明显双重循环算物品两两相似度在物品数量超过 3000 时会很慢所以实际项目里我的做法是先用这个函数跑一个小规模的子集验证逻辑确认推荐效果合理后再切到 sklearn 的向量化实现。批量计算相似度矩阵的工程写法from sklearn.metrics.pairwise import cosine_similarity # 先做均值中心化每个用户减去自己的平均评分 user_mean user_item.mean(axis1) user_item_centered user_item.sub(user_mean, axis0) # 中心化之后仍保留 NaN填充 0 才比较安全 item_sim cosine_similarity(user_item_centered.fillna(0).T)逻辑说明先减掉用户均值再做填充 0可以把「没看过」近似理解为「与该用户平均打分持平」这比直接填 0 更合理。.T转置后矩阵行是电影列是用户cosine_similarity计算行与行之间的余弦相似度得到形状为(n_items, n_items)的对称矩阵对角线上是 1。3.3 实现基于物品的推荐函数得到物品相似度矩阵后推荐逻辑是找到用户评分较高的电影作为种子从每个种子的相似物品中累加得分去掉用户已经看过的电影按总分排序取 Top-N。一个可以直接运行的版本items user_item.columns # 所有 movieId def recommend_for_user(user_id, k10, top_n10, min_rating3.0): watched_rows ratings[ratings[userId] user_id] seed_movies watched_rows[watched_rows[rating] min_rating][movieId].values # 用字典累加每个候选物品的相似度得分 score {} for mid in seed_movies: idx list(items).index(mid) sim_scores item_sim[idx] # 取相似度最高的 k 个物品作为邻域 neighbor_idx np.argsort(sim_scores)[::-1][1:k1] for nidx in neighbor_idx: cand items[nidx] if cand in seed_movies: continue score[cand] score.get(cand, 0) sim_scores[nidx] ranked sorted(score.items(), keylambda x: x[1], reverseTrue) return [int(mid) for mid, _ in ranked[:top_n]]参数说明k是邻居数决定每个种子电影带来多少个候选物品top_n是最终返回的推荐数量min_rating3.0用来过滤掉用户打 1、2 分的电影避免把不喜欢的电影作为推荐起点。np.argsort(sim_scores)[::-1]得到相似度从高到低的索引序列[1:k1]去掉自身后取前 k 个。这个实现刻意省略了「相似度乘以物品平均分」这步因为对于排序推荐场景直接用相似度累加得到的顺序已经足够稳定但如果要预测具体评分就应该用加权平均公式把相似度作为权重、邻居物品的评分作为值去计算预测分。3.4 评估指标Hit Rate 和 PrecisionN 怎么算推荐系统做得好不好不能靠肉眼感觉。毕业设计论文里最常用的评估方式是把评分数据按时间或随机拆成训练集和测试集对测试集中的每个用户隐藏其一部分评分用训练集计算推荐再看推荐的电影是否命中了被隐藏的那些。命中率 Hit Rate 是最直接的指标from sklearn.model_selection import train_test_split train, test train_test_split(ratings, test_size0.2, random_state42) def hit_rate(test, train, k10, top_n10, sample_size200): test_users test[userId].unique()[:sample_size] hits 0 total 0 for uid in test_users: actual set(test[test[userId] uid][movieId].values) recs recommend_for_user(uid, kk, top_ntop_n) hits len(set(recs) actual) total len(actual) return hits / total参数说明sample_size200限制评估用户数量避免全量评估太慢actual是用户在测试集里看过的电影set(recs) actual计算推荐列表和真实观看集合的交集。Hit Rate 的分母是所有测试记录数含义是「每一条真实观看记录里有多少比例被推荐中了」。这里有个很容易写错的地方评分数据不是独立同分布的同一个用户的不同评分之间有强相关性随机切分会导致训练集里已经包含了用户对某些电影的偏好评估结果偏乐观。严格的做法是按时间切分用用户前 80% 的观看历史预测后 20%。毕业设计做到时间切分已经能体现工作量而且可以在论文里专门写一节「为什么不用随机切分」。4. 落地成能答辩的系统Flask 接口、离线在线拆分与参数调试4.1 系统架构离线算相似度在线只做查询推荐系统最容易犯的设计错误是把相似度计算放到每次请求里实时算。物品数量 9000 时相似度矩阵是 9000×9000 的浮点矩阵单次全面计算已经要几十秒用户量一上来服务器直接扛不住。正确的拆分方式是离线阶段算好物品相似度矩阵并缓存在线阶段只做查表、累加和排序。模块计算时机主要工作耗时量级数据清洗离线数据更新或首次启动时读 CSV、去重、过滤低频用户秒级相似度矩阵离线数据清洗后构建用户-物品矩阵、计算物品两两相似度分钟级推荐接口在线每次请求查相似度矩阵、累加排序、返回 Top-N毫秒级效果评估离线定期时间切分、计算 Hit Rate 和 Precision分钟级这种离线/在线拆分的架构不复杂但写进论文就是很扎实的「系统设计」章节素材。答辩时评委看到你能分清楚哪些计算可以预热、哪些计算必须实时印象分会明显不同。4.2 用 Flask 包一个推荐接口系统接口用 Flask 写最小可用版本代码量很少而且便于演示from flask import Flask, jsonify, request app Flask(__name__) # 内存里缓存相似度矩阵进程启动时加载一次 item_sim None app.route(/api/recommend, methods[GET]) def recommend(): user_id int(request.args.get(user_id, 1)) k int(request.args.get(k, 10)) top_n int(request.args.get(top_n, 10)) recs recommend_for_user(user_id, kk, top_ntop_n) return jsonify({ user_id: user_id, count: len(recs), movies: recs }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)参数说明user_id是必须参数视频演示时可以带不同的用户 ID 展示不同结果k和top_n作为查询参数暴露出来答辩现场可以直接改 URL 里的参数观察推荐变化这个细节非常有用。item_sim放在全局变量里而不是每次请求计算这就是 4.1 说的离线在线拆分在代码层面的体现。启动服务后浏览器访问http://localhost:5000/api/recommend?user_id1k10top_n8就能看到 JSON 形式的推荐结果。如果需要可视化界面可以用 Flask 渲染一个简单的 HTML 页面把返回的电影标题用表格展示出来整个耗时不超过半小时这部分可以作为视频演示的内容。4.3 三个必调参数邻居数 K、相似度阈值、热门物品降权邻居数 K 是最敏感的参数。K 太小每个种子电影只看最相似的少数几部推荐结果局限在种子电影的小圈子里K 太大把相似度很低甚至无关的电影也拉进来得分被大量弱信号稀释。经验值在 10 到 30 之间但必须通过评估确定不能拍脑袋定第 5 章会给出具体方法。相似度阈值的作用是过滤掉明显不相似的邻居。例如只在sim 0.1的条件下累加得分可以避免那些基于极少共同评分算出的虚假相似度进来干扰排序。在自实现的余弦函数里这个阈值尤其重要因为共同评分很少的两部电影也可能碰巧算出一个较高的余弦值。热门物品降权是让推荐「不那么无聊」的关键。MovieLens 数据的评分分布长尾特征明显少数热门电影拿到了大部分评分不做处理的话推荐列表会被《肖申克的救赎》《低俗小说》这类大众高分片霸占。简单的降权做法是对每个物品的流行度取对数再取倒数作为得分权重popularity ratings[movieId].value_counts() # 在 recommend_for_user 的累加循环里加一步 pop popularity.get(int(cand), 1) score[cand] score.get(cand, 0) sim_scores[nidx] / np.log1p(pop)参数说明popularity统计每部电影被评分的次数np.log1p(pop)把流行度压缩到对数尺度/ np.log1p(pop)让流行度高的电影得分被压下去。注意这里score加的是相似度除以流行度对数而不是直接乘相似度两者效果接近但除法的衰减更平滑。热门降权一定要配合 Hit Rate 一起看过度降权会降低命中率因为用户确实更可能看热门电影实践里通常会让热门推荐和长尾推荐各占一部分。5. 答辩和演示的关键技巧用一张折线图确定邻居数 K并备好「为什么不用深度学习」的答案5.1 用 Hit Rate 随 K 变化的折线图定稿参数论文实验部分如果只写「经实验确定 K20」评委大概率会追问一句「怎么确定的」。最好的办法是把整个搜索过程可视化出来K 从 5 取到 50横轴是邻居数纵轴是 Hit Rate折线图一放结论自然呈现。import matplotlib.pyplot as plt ks [5, 10, 15, 20, 30, 50] hrs [] for k in ks: hr hit_rate(test, train, kk, top_n10) hrs.append(hr) print(fK{k}, Hit Rate{hr:.4f}) plt.plot(ks, hrs, markero) plt.xlabel(邻居数 K) plt.ylabel(Hit Rate10) plt.title(邻居数 K 对推荐效果的影响) plt.grid(alpha0.4) plt.tight_layout() plt.savefig(k_curve.png, dpi150)参数说明hit_rate函数里固定的top_n10表示只评估前 10 个推荐结果的命中情况ks列表覆盖了从过小到过大的范围。曲线一般呈现出先上升后平稳或先上升后下降的趋势峰值的 K 就是当前数据集下的最优邻居数。实际运行时 matplotlib 需要中文字体才能正常显示图中的中文标签如果环境里没有中文字体可以把图表标签换成英文避免乱码影响演示效果。这张图装进论文可以答辩 PPT 里也可以直接截取出来用。保存成k_curve.png后配合一段话「当 K 小于 10 时推荐结果受单个种子电影影响过大随着 K 增大覆盖率提升Hit Rate 在 K20 附近达到峰值继续增大 K 会引入低相似度邻居导致指标回落。」这一段就是论文实验部分最好的素材。5.2 把权重调参写进论文并备好评委常问的三个问题论文里的参数部分要写的不只是最终参数值而是你观察到的变化趋势。除了 K建议把相似度阈值也做一组对比实验例如阈值分别取 0.05、0.1、0.2、0.3记录对应的 Hit Rate 和推荐列表平均热度。这组实验能回应一个关键问题过滤低相似度邻居到底带来正收益还是负收益。评委最常问的问题这里预演一下第一个问题是「协同过滤和基于内容的推荐有什么区别」。回答思路是协同过滤完全不用电影内容信息只依赖用户的评分行为发现的是「喜欢电影 A 的人群也喜欢电影 B」这种集体智慧的规律基于内容的推荐则需要电影的题材、导演、演员等特征推荐逻辑是「你喜欢诺兰所以推荐诺兰的其他作品」。两者各有限制协同过滤的局限是冷启动内容推荐则无法发现跨类型的惊喜。第二个问题是「为什么不用深度学习」。这个问题要正面回应不能含糊。合理的回答是本项目数据集只有几百个用户和近万部电影评分行为极其稀疏深度模型需要大数据量才能发挥优势在毕业设计的时间约束下容易变成调包侠、解释不清参数含义而协同过滤的计算过程完全可解释、可复现、可手工推导更符合本科或硕士阶段对算法理解深度的考核要求。顺带可以承认深度学习在特征交互建模上的优势但指出那是另一个工作量级别的工作。第三个问题是「推荐结果里出现一部用户完全不感兴趣的电影怎么办」。回答要点协同过滤本质上只能推荐「与历史行为相似的物品」无法真正理解兴趣缓解手段已经在系统里实现包括评分阈值过滤、相似度阈值、热门物品降权等。如果评委深入追问「这会对精度有什么影响」就把第 5.1 节的折线图拿出来指出降权后的 Hit Rate 基本不变但推荐列表的平均电影热度明显下降说明推荐结果从「保守热门」变成了「个性化长尾」这是有价值的工作量。本文还有配套的精品资源点击获取