简介:一份面向计算机专业毕业设计、课程设计与期末大作业的电影推荐系统完整项目,基于Python协同过滤推荐算法构建,适合正在准备毕设的学生及需要推荐系统项目实战的入门学习者。压缩包内共包含1197个文件,其中22个Python源码文件为核心算法与业务逻辑,20个pyc文件为编译缓存,另有html、css、js、xml等前端展示资源,以及csv、sqlite3格式的电影与用户数据集,覆盖数据导入、相似度计算、推荐结果展示等环节。项目已经过严格调试,下载解压即可运行,可直接作为毕业设计使用,也可在此基础上进行二次开发与论文扩展。整体包大小为76.75MB,目录结构清晰,便于按模块查阅和学习。目前已有147人学习下载,适合希望快速搭建推荐系统并理解协同过滤原理的高校学生及开发者。
1. 基于协同过滤的电影推荐系统:毕设答辩需要的一整套数据闭环
选毕设题目时,很多人的第一反应是追深度学习、知识图谱,结果数据、环境、训练时间反复劝退。基于协同过滤推荐算法的电影推荐系统走的是另一条路:算法理论清晰、代码量适中、数据是现成的,随源码一起提供的 movie_info.csv 和 users_resulttable.csv 可以直接加载,构建“用户-电影”评分矩阵,通过相似度计算输出 Top-N 推荐列表,再配上一套 Bootstrap 前端页面做演示。
整套过程是“数据读取 → 矩阵构建 → 相似度计算 → 结果排序 → 页面展示”的完整闭环,既能跑通又能写进论文。这套源码最适合做毕设、课设、期末大作业的计算机专业学生,也适合想用最短时间跑通协同过滤全流程的初学者。它的价值不在于算法多新,而在于边界清晰、步骤完整,答辩现场能演示、能改参数、能回答提问。
2. 协同过滤原理与选型:UserCF 和 ItemCF 该选谁,相似度怎么算才不翻车
2.1 两种主流协同过滤:UserCF 与 ItemCF,毕设场景下怎么选
协同过滤的核心假设并不复杂:如果两个用户在历史行为上高度一致,那么其中一个用户喜欢的内容,另一个也大概率会喜欢;如果两部电影总是被同一批人高分评价,那么喜欢其中一部的人很可能也喜欢另一部。前者叫 UserCF(基于用户的协同过滤),后者叫 ItemCF(基于物品的协同过滤),两者都要从一个“用户-电影”评分矩阵出发。
UserCF 的完整计算路径是:先计算目标用户和其他所有用户的相似度,挑出相似度最高的 K 个用户作为邻居,再把邻居看过且目标用户没看过的电影按相似度加权汇总,排序取前 N 个结果。这套逻辑在用户规模不大、物品更新频繁的场景里很常见。它有一个天生的弱点:用户冷启动。新注册用户没有任何评分记录时,相似邻居根本凑不出来,推荐列表要么为空,要么退化成全站热门榜,论文的“实验结果”部分容易暴露这个问题。
ItemCF 的思路正好反过来:先算电影和电影之间的相似度,再以目标用户历史打高分或看过的电影为种子,去匹配与这些种子相似的电影,最后按加权得分汇总出候选列表。电影之间的相似关系相对稳定,不会因为新用户加入而频繁变化,所以 ItemCF 可以把相似度矩阵离线算好、缓存起来,线上只做查表和加权聚合。放到电影推荐这个场景里,ItemCF 还有一个不可替代的优势:可解释性。“因为你给《盗梦空间》打了高分,所以推荐《星际穿越》”,这句话在答辩现场比“与你相似的用户也看过”更有说服力。
对于这套源码的数据量,几百到几千条评分记录,两种算法都能在几十毫秒内出结果。如果论文需要做对比实验,我的建议是主推 ItemCF 做最终效果演示,UserCF 作为对照。要注意的是,UserCF 在用户评分记录很少时邻居数量根本凑不齐,推荐质量会明显下降,这一条很容易被答辩老师当场质疑,所以实现时一定要打印“最终参与投票的邻居数”,别让这个数字变成黑匣子。
2.2 相似度计算:余弦相似度与皮尔逊相关系数,稀疏数据下差别很大
相似度是整个协同过滤的地基,公式选错了,后面密密麻麻的推荐结果全是噪音。代码里最常用的两种相似度是余弦相似度和皮尔逊相关系数。
余弦相似度把两个用户的评分向量看成高维空间里的方向向量,计算两个向量夹角的余弦值。公式实现简单,numpy 几行就能算完,但它只比较方向,不比较长度。问题在于不同用户的评分习惯差异很大:用户 A 打分普遍在 3 分上下,用户 B 打分普遍在 5 分上下,即便两人口味完全一致,原始余弦相似度依然可能只有 0.7 左右。这是余弦公式天然存在的“评分基准偏移”问题。
皮尔逊相关系数则先让每个用户减去自己的评分均值,再做类似余弦的计算,等于把评分基准漂移给抹平了。对电影评分这种显式反馈数据,我一般更推荐皮尔逊,因为“有人只给 3 分,有人 4 分都嫌低”是真实行为,去均值恰好能抵消这一层偏差。皮尔逊实现时要特别注意一个边界条件:当某个用户对所有电影的评分完全相同,比如只给一部电影打了 5 分,他的评分标准差为 0,皮尔逊公式的分母是 0,计算结果会变成 nan。这种 nan 一旦进入相似度矩阵,后续所有加权计算都会被污染,所以代码里要显式处理:方差为 0 的用户相关系数直接置 0,不参与邻居选择。
2.3 推荐列表生成:Top-N 与评分预测,答辩时更好讲的是哪一条
相似度算完之后,从候选池里挑电影有两种常见方案:一种是评分预测,先预测目标用户对每部没看过的电影会打多少分,再按预测分排序输出;另一种是 Top-N 推荐,只统计邻居或相似电影中出现频次最高的候选,不去猜具体分数。
对毕设来说,Top-N 推荐更容易演示,也贴近真实推荐系统的交互习惯。页面基本上只展示“为你推荐 10 部电影”,不需要把每个预测分都亮出来。评分预测则需要多做一步加权平均:把候选电影在邻居那里的评分乘以相似度,再除以相似度总和。公式不小复杂,但有个隐蔽的坑:如果不对候选集做过滤,所有邻居的零散打分都参与计算,稀疏数据下大量电影的预测分会被拉向同一个均值,最终 Top-N 和“全站人气榜”几乎没区别,个性化就消失了。
我一般会给候选集加一个“最少覆盖数”的门槛:只有至少被 M 个邻居打过分、或与种子电影有 M 个共同评分用户的电影才进入排序候选,M 通常取 2 到 5。经过这一步,冷门但口碑好的电影才有机会浮上来,推荐结果的区分度会有肉眼可见的改善。答辩时把这一条作为“算法改进点”写进论文,评委基本挑不出毛病。
3. 数据与前端的工程边界:从 CSV 字段清洗到 Bootstrap 样式的角色分工
3.1 先摸清两个数据文件:CSV 字段结构比算法更难缠
拿到源码的第一件事不是跑主程序,而是先确认两个数据文件的字段结构。movie_info.csv 是电影信息表,一般包含电影 ID、标题、上映年份和类型;users_resulttable.csv 是用户评分表,常见字段是用户 ID、电影 ID、评分分数和时间戳。直接用 pandas 读进来以后,先用 head 和 info 做一次体检。
import pandas as pd movie_info = pd.read_csv('movie_info.csv', encoding='utf-8') ratings = pd.read_csv('users_resulttable.csv', encoding='utf-8') print(movie_info.head()) print(ratings.head()) print(movie_info.info()) print(ratings.info())encoding 参数值得单独说。从网上下载或从数据库导出的 CSV,经常是 utf-8、gbk、gb2312 三种编码混着的,先用 utf-8 读,报 UnicodeDecodeError 就换 gbk,别一上来就猜编码。info() 输出里每一列的非空数量很关键,如果某一列空值比例超过 30%,后续按电影 ID 做合并时这一列大概率会拖垮整个推荐结果。电影标题字段如果缺失太多,最后的推荐列表根本没法给人看,因为输出的全是 ID 数字。
3.2 数据清洗与矩阵构建:空值、重复评分与用户 ID 类型不一致
CSV 里的脏数据比想象中多。用户可能重复提交评分,同一部电影出现多条记录,或者用户 ID 在两张表里类型不一致,一张表是 int、另一张表是字符串,这些都会让后续的相似度计算翻车。常见做法是先做一轮标准化:
ratings = ratings.drop_duplicates(subset=['uid', 'mid']) ratings['uid'] = ratings['uid'].astype(str) ratings['mid'] = ratings['mid'].astype(str) movie_info['mid'] = movie_info['mid'].astype(str) ratings = ratings.dropna(subset=['uid', 'mid', 'score']) ratings['score'] = pd.to_numeric(ratings['score'], errors='coerce') ratings = ratings.dropna(subset=['score'])整个清洗顺序是有讲究的。先把 uid 和 mid 统一转成字符串,避免出现“1”和“1.0”是不是同一个用户这种灵异事件;再用 drop_duplicates 去掉完全重复的评分行,保留第一条;然后单独处理 score 字段,读不进数字的值先转成 NaN 再丢弃。记住不要在清洗之前就急着构造矩阵,否则后面调算法时经常会出现“明明有 300 个用户,矩阵里却只有 298 行”这种对不上账的情况,查起来非常头疼。
3.3 Bootstrap 前端只负责展示:CSS 样式不会参与推荐算法
源码目录里放了 bootstrap.css、bootstrap.min.css、Test.css、firstPage.css、main.css、star.css、headstyle.css、demo.css 这一堆样式文件,第一次打开项目时很容易被吓到,以为要看懂每一行 CSS 才能运行。实际上这些文件只负责页面长什么样,推荐算法的全部逻辑都在后端 Python 脚本里。Bootstrap 是现成的 CSS 框架,star.css 大概率只控制星星评分组件,demo.css 和 firstPage.css 管的是首页和示例页,它们跟协同过滤公式没有任何关系。
我的建议是把项目分成两条独立的调试线。第一条线是不开网页,直接在命令行跑“读取数据 → 构建矩阵 → 算相似度 → 输出推荐电影标题”,确认推荐链路本身是通的。第二条线才是启动 Web 服务,把后端返回的推荐结果渲染到页面。这样出问题时很容易定位:推荐结果不对是算法的锅,页面打不开或样式错乱才是前端的问题。很多同学花了大量时间调一个跟自己毫无关系的 CSS 浮动布局,纯属浪费时间。
4. 核心代码复现:构建用户-电影矩阵、计算相似度、生成 Top-N 的完整链路
4.1 构建用户-电影评分矩阵:pivot_table 的正确打开方式
有了干净的 DataFrame,第一步是把长表转成宽表,也就是把评分表变成“行是用户 ID、列是电影 ID、单元格是对应评分”的矩阵,这是协同过滤最标准的输入格式。
score_matrix = ratings.pivot_table( index='uid', columns='mid', values='score' ) print(score_matrix.shape)pivot_table 是构建评分矩阵的常用做法,index 参数指定行索引是用户 ID,columns 参数指定列索引是电影 ID,values 参数指定要被填充的评分列。注意这里我没写 fill_value=0,这是有意为之。fill_value=0 会把“用户没看过这部电影”填充成 0 分,但 0 和真实打出的低分在语义上完全不同。如果后续直接拿这个矩阵去算余弦相似度,0 值会被当成“用户很讨厌这部电影”,从而严重拉低用户之间的相似度。
为了避免语义污染,缺失评分要保持 NaN 状态。等真正需要稠密矩阵做矩阵乘法时再按场景决定补 0 还是补均值,不要在数据准备阶段就过早填充。这一步是很多相似度计算结果“看起来哪里不对”的源头。
4.2 计算电影之间的相似度:余弦公式与共同评分下限
ItemCF 的核心是电影相似度矩阵。对每一对电影,取出这两部电影在全部用户上的评分向量,只保留两个向量同时非 NaN 的维度,然后算余弦相似度。如果共同评分的用户太少,相似度没有统计意义,直接置 0。
import numpy as np def cosine_sim(a, b): mask = ~(np.isnan(a) | np.isnan(b)) if mask.sum() < 2: return 0.0 a_vec, b_vec = a[mask], b[mask] return np.dot(a_vec, b_vec) / (np.linalg.norm(a_vec) * np.linalg.norm(b_vec)) movie_ids = list(score_matrix.columns) sim_matrix = pd.DataFrame(0.0, index=movie_ids, columns=movie_ids) for i, mid1 in enumerate(movie_ids): for mid2 in movie_ids[i + 1:]: score = cosine_sim(score_matrix[mid1].values, score_matrix[mid2].values) sim_matrix.loc[mid1, mid2] = score sim_matrix.loc[mid2, mid1] = score双层循环是 O(n²) 复杂度,电影数量不多时完全没问题;如果电影数量超过 2000,建议先把评分矩阵转成 scipy.sparse.csr_matrix,再用 sklearn.metrics.pairwise.cosine_similarity 一次性算完整张矩阵。mask.sum() < 2 这个下限值得注意两个用户或两部电影之间共同评分的维度少于 2 时,计算结果偶然性太大,直接置 0,可以避免很多噪声相似度。
4.3 生成 Top-N 推荐:加权聚合候选电影并排序
得到相似度矩阵后,给一个目标用户生成推荐的步骤很固定:取出该用户打过分的电影集合作为种子,把与这些种子有相似度的电影做加权聚合,按得分排序取前 N 个。
def recommend(uid, score_matrix, sim_matrix, top_n=10, k=20): user_ratings = score_matrix.loc[uid].dropna() if len(user_ratings) == 0: return [] seed_ids = user_ratings.index.tolist() scores = {} for seed_id in seed_ids: sims = sim_matrix.loc[seed_id].sort_values(ascending=False) for candidate_id, sim_value in sims.head(k).items(): if candidate_id in seed_ids: continue if sim_value <= 0: continue scores[candidate_id] = ( scores.get(candidate_id, 0.0) + sim_value * user_ratings[seed_id] ) sorted_candidates = sorted(scores.items(), key=lambda x: x[1], reverse=True) return [mid for mid, _ in sorted_candidates[:top_n]]代码里有三个关键点。第一,sims.head(k) 只取每部种子电影相似度最高的前 k 部候选,避免把相似度极低的电影也纳入投票。第二,continue 条件先排掉用户已经看过的电影,这一步是硬约束,少了它推荐列表里全是用户看过的东西。第三,加权公式用的是“相似度 × 用户原评分”,用户打过分且与候选电影相似度高的种子,贡献的权重自然更大。返回结果是电影 ID 列表,最后拼上 movie_info 里的标题和类型就可以展示。
5. 避坑与排查:把源码跑通前必须看的 5 个翻车点
5.1 数据读取与矩阵构建阶段的三个翻车点
现象一:read_csv 直接抛 UnicodeDecodeError,或者电影标题读出来全是乱码。 原因:movie_info.csv 或 users_resulttable.csv 的编码是 gbk 或 gb2312,不是 Python 默认的 utf-8。 解决:优先用 chardet 探测文件编码,再把探测结果填进 read_csv 的 encoding 参数。别手动在编辑器里改文件编码另存,改坏了没有后悔药。
with open('movie_info.csv', 'rb') as f: raw = f.read(4096) import chardet print(chardet.detect(raw))现象二:pivot_table 生成矩阵后,算出来的相似度全是 0,推荐列表为空。 原因:构建矩阵时用了 fill_value=0,把大量没看过的电影填充成了 0 分。余弦相似度计算时,两个评分向量里的有效项太少,或者非零部分没有交集,结果全部趋近于 0。 解决:缺失评分用 NaN 表示,不要在数据准备阶段过早补 0。构建完矩阵后先打印一行非空比例,非空率低于 5% 基本可以断定数据清洗阶段出了问题。
现象三:皮尔逊相关系数计算结果出现 nan,推荐函数直接报 ValueError。 原因:存在评分方差为 0 的用户,比如只给一部电影打了 5 分。皮尔逊公式的分母是标准差,方差为 0 时除数为 0,结果就是 nan。 解决:在计算相似度之前先按用户评分的标准差做过滤,方差为 0 的用户相关系数直接置 0,不进邻居候选。
std = score_matrix.std(axis=1) zero_std_users = std[std == 0].index.tolist() for u in zero_std_users: score_matrix.loc[u] = np.nan这段代码把这些用户的所有评分置为 NaN,本质是让他们不参与相似度计算,但保留历史评分记录。逻辑简单,答辩时一句话能讲清楚。
5.2 算法运行与页面展示阶段的两个长尾问题
现象四:相似度矩阵计算太慢,几百部电影跑了十几分钟出不来,CPU 占满。 原因:双层循环往一个 n×n 矩阵里填相似度,复杂度是 O(n²),而且每对向量都在做 numpy 数组拷贝。 解决:电影数量多时改用 scipy.sparse.csr_matrix 加 sklearn 的 cosine_similarity 批量计算。更简单的做法是只对出现次数排名靠前的电影算相似度,冷门电影直接走热门兜底,对毕设演示效果影响很小。
现象五:推荐结果里有一半是用户自己看过的电影。 原因:聚合候选集时没有排除种子电影,自己投了自己一票,热门电影频繁出现在种子列表里,自我投票导致得分虚高。 解决:在候选遍历的 continue 条件里必须预先过滤掉 seed_ids。同时建议在推荐函数里打印一次“种子数”和“候选数”,两个数字相差太大说明评分记录被清洗删过头了,要回数据清洗阶段检查,而不是在算法里硬调参数。
6. 进阶玩法:给推荐结果加上推荐理由,用 Flask 把系统端上 Web 页面
6.1 用 Flask 把命令行结果搬进网页:薄封装就够
命令行里打印推荐电影 ID 只能证明“算法能跑”,答辩现场说服力不够。把推荐函数包一层 Web 接口后,页面上输入用户 ID 就能看到带电影标题的推荐列表,效果完全不同。集成方式保持精简,不碰算法核心:
from flask import Flask, request app = Flask(__name__) @app.route('/') def recommend_page(): uid = request.args.get('uid', default='U001') rec_list = recommend(uid, score_matrix, sim_matrix, top_n=10, k=20) titles = movie_info.set_index('mid').loc[rec_list]['title'].tolist() return {'uid': uid, 'recommendations': titles}request.args 负责从 URL 参数取 uid,recommend 直接复用第 4 章的函数,返回的电影 ID 再通过 movie_info 映射成可读标题。如果页面加载缓慢,优先检查相似度矩阵是否每次请求都在重新计算,正确做法是服务启动时只算一次,之后全局复用。
6.2 可解释推荐:返回一条“为什么推荐这部电影”的理由
ItemCF 天然适合带解释。在聚合候选时,记下每个候选得分最高的种子电影,返回时拼成一句话:“因为你给《盗梦空间》打了 5 分,所以推荐《星际穿越》。”这类推荐叫可解释推荐,近几年的推荐系统论文里都在讨论。答辩时主动讲出这一句,评委的追问方向基本会落到相似度公式上,正好呼应第 2 章的皮尔逊相关系数,形成完整逻辑闭环。
best_seed = max(seeds, key=lambda seed: sim_matrix.loc[seed, candidate_id]) seed_title = movie_info.set_index('mid').loc[best_seed]['title'] candidate_title = movie_info.set_index('mid').loc[candidate_id]['title'] print(f"因为你给《{seed_title}》打了高分,所以推荐《{candidate_title}》")选得分最高的种子做解释,比罗列所有种子更接近真实推荐产品的口径。从那以后我每次拿新数据,都强制走一遍“命令行跑通 → 打印矩阵非空比例 → 启动 Flask 页面刷新核对”,先在数据阶段拦住脏数据,再谈调参。这些细节希望能帮你在答辩前把系统打磨得更稳一点。
本文还有配套的精品资源,点击获取