ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

电影推荐系统课程设计全解析:协同过滤、评分矩阵与Flask实践

电影推荐系统课程设计全解析:协同过滤、评分矩阵与Flask实践 简介基于Python的电影推荐系统源码专为计算机相关专业课程设计与期末大作业场景打造适合需要完整可运行项目进行参考、改造与答辩准备的学生。压缩包共十个文件体积仅2.37MB包含Python主程序、电影与评分CSV数据文件、项目配置文件及依赖数据压缩包等各类型分工明确py脚本承载推荐逻辑和入口流程csv提供原始数据支撑xml与iml面向IDE工程结构可直接导入使用。整体经过严格调试下载解压后即可运行免去环境配置与数据清洗的重复工作。源码涵盖基于机器学习方法的电影推荐系统实现附有处理后的电影数据和评分数据便于理解协同过滤或基于内容的推荐思路同时项目目录结构清晰适合快速定位关键模块。该资源已有一百六十二人学习下载对正在完成课程设计、期末大作业或希望积累推荐系统实战经验的初学者具有较高参考价值。1. 电影推荐系统课程设计源码能跑通不算完答辩才见真章每年毕业季都会有人拿着一份解压后能直接运行的 Python 电影推荐系统源码走进答辩教室然后被一句“你的用户相似度矩阵是 N×N 还是稀疏存储”当场问住。课程设计里的电影推荐系统核心并不玄无非是协同过滤、评分矩阵、Top-N 推荐这三件事但它同时踩了数据处理、算法选型和工程落地三个领域任何一个环节没想透代码能跑答辩照样翻车。这篇笔记围绕一份典型的基于 Python 的电影推荐系统课程设计源码展开从 MovieLens 数据怎么读、UserCF 和 ItemCF 怎么手写、评估指标怎么算一直写到把推荐结果用 Flask 暴露成接口把每一步的原理、参数和血泪坑一次讲清楚。适合正在做课程设计或毕设、想快速把推荐系统跑通并验证思路的 Python 开发者。2. 协同过滤选型先想清楚数据形态决定你写 UserCF 还是 ItemCF拿到一份推荐系统课程设计源码第一眼不要盯着算法函数看先看读数据的那几行。推荐系统所有的后续逻辑都取决于数据的形态有多少用户、多少部电影、评分稀疏到什么程度。MovieLens 100K 数据集默认是 943 个用户、1682 部电影、10 万条评分缺失率高达 93% 左右。这种规模的数据用传统协同过滤是绝对主流也最适合课堂展示但如果你把数据集换成 1M 甚至 20M同样的代码可能跑几分钟就内存爆炸这是选型前最先要建立的直觉。2.1 基于用户的协同过滤找口味相似的人K 个邻居怎么定基于用户的协同过滤UserCF逻辑非常直白把每个用户的评分向量看成他的“口味签名”计算签名之间的相似度找到最像的 K 个邻居然后用邻居对某部电影的评分来预测当前用户的评分。课程设计里最常用的是皮尔逊相关系数它比余弦相似度多做了一步均值中心化能抵消掉“有人打分手松、有人手紧”这种个人习惯带来的偏差。相似度计算的核心代码通常长这样import numpy as np import pandas as pd def pearson_similarity(user1, user2, rating_matrix): # 只取两个用户都评过分的电影缺失值用 notna() 判断 common rating_matrix.loc[user1].notna() rating_matrix.loc[user2].notna() if common.sum() 2: # 共同评分少于 2 部相似度视为 0 return 0.0 vec1 rating_matrix.loc[user1, common].values.astype(float) vec2 rating_matrix.loc[user2, common].values.astype(float) return np.corrcoef(vec1, vec2)[0, 1] # np.corrcoef 自带均值中心化这里有两个容易被忽略的点。第一个是common.sum() 2这个阈值如果两个人只共同看过一部片子皮尔逊系数算出来是 1.0但这完全不能说明两个人口味一致只能说明他们恰好都看过同一部热门电影阈值设成 2 或 3 能让相似度分布更可信。第二个是判断缺失值必须用notna()不能用! 0。很多初版源码把评分矩阵中缺失的位置填成了 0再用! 0去筛共同评分结果两个都不爱打高分、经常留下“未评分”的用户在计算时反而因为共同的 0 被判定为相似——这是典型的“把没看过当成打了 0 分”的错误。拿到相似度之后预测评分就是加权和def predict_rating(user_id, movie_id, rating_matrix, similarity_matrix, k20): sim similarity_matrix[user_id].drop(user_id, errorsignore) sim sim.sort_values(ascendingFalse).head(k) weighted_sum, sim_sum 0.0, 0.0 for neighbor, weight in sim.items(): r rating_matrix.loc[neighbor, movie_id] if not np.isnan(r): # 只累加真正评过分且相似度不为 0 的邻居 weighted_sum weight * r sim_sum weight return weighted_sum / sim_sum if sim_sum 0 else float(nan)参数 k 的取值是这门课里最值得做实验的地方。MovieLens 100K 上常见的经验区间是 10 到 50k20 算是稳妥起步。k 太小预测会被一两个口味极端的邻居带偏k 太大把相似度很低的用户也拉进来预测值会趋于全局平均分失去个性。我一般会让学生把 k 设计成命令行参数跑一组误差曲线给答辩老师看那种“我用 20 个邻居效果最好”的结论比空口说强得多。2.2 基于物品的协同过滤电影场景里更平顺的选择ItemCF 的思路和 UserCF 相反它先算电影之间的相似度然后做这样的推荐“你给《盗梦空间》打了高分那么和它最像的《星际穿越》也值得看”。在电影推荐场景里 ItemCF 通常比 UserCF 更可靠原因很朴素一部电影被几千个用户评过分统计上比一个用户只看过几十部电影要扎实且电影相似度矩阵可以在离线阶段一次性算好在线推荐时只查表不用实时遍历全体用户。ItemCF 的标准做法是调整余弦相似度adjusted cosine核心区别在于先减去每个用户的平均评分def adjusted_cosine(movie_i, movie_j, rating_matrix): # 找出同时给这两部电影评过分的用户 common rating_matrix[movie_i].notna() rating_matrix[movie_j].notna() if common.sum() 2: return 0.0 common_df rating_matrix.loc[common, [movie_i, movie_j]] # 对共同评分用户按行取均值实现均值中心化 user_avg common_df.mean(axis1, skipnaTrue) vec_i common_df[movie_i] - user_avg vec_j common_df[movie_j] - user_avg num (vec_i * vec_j).sum() den np.sqrt((vec_i ** 2).sum() * (vec_j ** 2).sum()) return num / den if den ! 0 else 0.0这段代码里中心化那一步是关键。如果不减掉用户均值一个平时只打 4 到 5 星的“慷慨用户”和一个只打 1 到 2 星的“严苛用户”即使口味完全一致余弦相似度也会被拉得很低减掉均值之后大家就都在“相对自己的平均分高多少”这个尺度上比较了。另外我特别强调一下这里只用了共同评分的用户子集来算点积和模长没有把整列向量拖进来。很多课程设计源码图省事用fillna(0)把全向量直接丢给np.dot结果就是那批“都没看过这部片”的用户也贡献了相似度分数数学意义完全变了。2.3 内容推荐与混合策略什么时候别硬上深度学习除了协同过滤课程设计里也常见基于内容的推荐把电影的类别Genres、年份、导演甚至标签拆成特征向量再计算你喜欢的电影和新电影之间的相似度。这种做法在冷启动阶段特别有价值——一个新用户没有任何评分协同过滤给不出任何推荐内容推荐却可以用“你刚才选了科幻”来兜底。混合策略最常见的做法是线性加权比如score 0.7 * 协同过滤预测分 0.3 * 内容相似度分权重就是课程设计里可以大谈特谈的“调参点”。至于为什么不在课程设计里硬上深度学习我的理解很简单10 万条评分对一个神经网络来说太少过拟合会让离线指标虚高但实际推荐结果却可能不如一个 100 行的 ItemCF。更关键的是深度模型是个黑匣子答辩问“为什么推荐这部片子”时你很难像协同过滤那样指着邻居说“因为你喜欢 A 的人里有 80% 也喜欢 B”。可解释性这东西在课程设计里比你想的值钱得多。3. 数据加载与评分矩阵构建把 10 万条评分变成能算相似度的矩阵绝大多数课程设计源码的报错都出在数据加载和矩阵构建这两步而不是算法本身。zip 包解压后你会发现 data 目录里躺着ratings.csv、movies.csv有的还带u.data老式格式。不要一上来就pd.read_csv先花一分钟确认列名、分隔符和编码能省掉后面一整晚的排错。下面所有操作都以 MovieLens 100K 的ratings.csv为例字段只有 userId、movieId、rating、timestamp 四个。3.1 字段与加载ratings.csv 与 movies.csv 里藏着哪些坑import pandas as pd ratings pd.read_csv(ratings.csv, encodingutf-8) movies pd.read_csv(movies.csv, encodingutf-8) # 观察数据形态和缺失值 print(ratings.head()) print(ratings.isna().sum()) print(movies.head())这段代码逻辑很简单但有两个容易踩坑的点一是 MovieLens 100K 的老格式u.data通常用sep\t分隔且没有表头和ratings.csv的逗号分隔完全不同加载前要看清后缀二是movies.csv的标题字段里年份是放在括号里的比如 Toy Story (1995)如果你后面做内容推荐按年份过滤一定要先把年份单独提取出来否则字符串匹配会出各种幺蛾子。下面给出一个带年份提取的加载方式movies[year] movies[title].str.extract(r\((\d{4})\)) movies[clean_title] movies[title].str.replace(r\s*\(\d{4}\), , regexTrue)用正则把年份拆出来同时保留干净标题。extract返回的是带一列的正则捕获组replace里的\s*\(\d{4}\)会吃掉标题末尾的年份和它前面可能存在的空格注意regexTrue必须显式声明这是 pandas 2.0 之后的要求省略会直接抛 FutureWarning。3.2 构建用户—物品评分矩阵pivot_table 之后思考内存协同过滤需要一个二维矩阵行是用户列是电影值是评分。最直接的方式是pivot_tablerating_matrix ratings.pivot_table( indexuserId, columnsmovieId, valuesrating ) print(rating_matrix.shape) # (943, 1682) print(rating_matrix.isna().mean().mean()) # 约 0.93即 93% 缺失pivot_table会把没有评分的单元格自动填成 NaN这正是我们要的“缺失”语义。但注意它生成的 DataFrame 是稠密二维结构943×1682 在内存里也就十几兆感觉不到压力一旦换成 MovieLens 1M6040 用户 × 3706 电影稠密数组就接近 2200 万个元素float64 下约 180MB机器已经会开始卡顿。课程设计如果被要求跑大规模数据常见的做法是转成scipy.sparse.csr_matrixfrom scipy.sparse import csr_matrix user_ids rating_matrix.index movie_ids rating_matrix.columns sparse_matrix csr_matrix(rating_matrix.fillna(0).values) # 用 fillna(0) 只是为了构造稀疏矩阵后续计算时仍要区分“未评分”和“评分为 0”注意fillna(0)只是配合稀疏矩阵的存储结构不代表“没看过 0 分”。转换成csr_matrix后相似度计算里取某一行或某一列会快很多但手写循环时千万不要用sparse[user, :]这样的行切片去遍历csr按行切片会触发大量拷贝性能反而比稠密矩阵更差。3.3 切分训练集/测试集按时间切别按随机切推荐系统的评估有个特殊约束不能用未来的数据去预测过去。很多课程设计源码直接train_test_split(ratings, test_size0.2, random_state42)这在分类任务里没问题在推荐里却相当于“让模型看了明天的评分再去预测昨天”会造成数据泄露。正确做法是按时间戳切分用每个用户最后 20% 的评分作为测试前面的 80% 作为训练ratings[timestamp] pd.to_numeric(ratings[timestamp]) ratings_sorted ratings.sort_values([userId, timestamp]) train_list, test_list [], [] for uid, group in ratings_sorted.groupby(userId): split_idx int(len(group) * 0.8) train_list.append(group.iloc[:split_idx]) test_list.append(group.iloc[split_idx:]) train pd.concat(train_list, ignore_indexTrue) test pd.concat(test_list, ignore_indexTrue)按用户分组、组内按时间排序、再按比例切这是既能防止时间穿越又能保持每个人数据完整性的常见做法。但这里有个边界要处理如果一个用户只有 1 条评分int(1 * 0.8)得到 0训练集为空。实际处理时我一般会设一个最低门槛比如用户评分数大于等于 5 才参与切分否则整组进训练集让评估指标不至于因为极小样本抖动得离谱。这类边界条件在答辩时提一句老师会认为你想过数据分布问题。4. 手写 UserCF 与 ItemCF从相似度矩阵到 Top-N 推荐到这一步数据已经变成评分矩阵和训练集/测试集剩下的是把所有模块拼成完整流程。很多课程设计源码喜欢把推荐写成一个大函数但我建议按“建相似度矩阵 → 预测评分 → 生成 Top-N → 评估”四段拆开每一段都可独立测试。下面把 UserCF 和 ItemCF 的完整路径各走一遍。4.1 UserCF 完整流程相似度矩阵、预测、Top-N 一步不缺构建用户相似度矩阵时最朴素的做法是双层循环对每个用户对调一次pearson_similarity这种 O(N²) 的代价对于 943 个用户尚可忍受但别把相似度矩阵全量存成DataFrame后丢进内存不管。课程设计里更常见的做法是只保存上三角或直接存成一个稀疏的字典结构。下面是配合 2.1 节相似度函数的完整训练与推荐流程def build_user_similarity(rating_matrix, threshold2): users rating_matrix.index sim_dict {} for i, u in enumerate(users): for v in users[i1:]: # 只看上三角对称矩阵减半计算 s pearson_similarity(u, v, rating_matrix) if s 0: # 负相似度在 Top-N 里没有意义直接滤掉 sim_dict[(u, v)] s sim_dict[(v, u)] s return sim_dict def recommend_top_n(user_id, rating_matrix, sim_dict, k20, n10, exclude_seenTrue): scores {} for movie in rating_matrix.columns: if exclude_seen and not pd.isna(rating_matrix.loc[user_id, movie]): continue scores[movie] predict_rating(user_id, movie, rating_matrix, sim_dict, k) rec pd.Series(scores).dropna().sort_values(ascendingFalse).head(n) return rec这里有两个参数值得展开threshold2是最小共同评分电影数调大它会让相似度更可信但矩阵更稀疏exclude_seenTrue表示推荐列表只包含用户没看过的电影这是课程设计里最容易忘记的一步——不排除已看过的电影推荐的 Top-N 里会混进用户早就打过分的老片显得没有意义。注意我在recommend_top_n里对每个候选电影都调了一次predict_rating这意味着它会扫描全部 1682 部电影一次推荐的复杂度是 O(N×K)N 是电影数、K 是邻居数这种粗暴方式在 100K 数据集上跑一次约几秒能接受但如果换成 1M 数据集就要用物品相似度的索引来加速这就是 ItemCF 的优势。4.2 ItemCF 完整流程离线算好物品相似度在线只查表ItemCF 的构建和 UserCF 对称同样先算相似度矩阵。不过物品对约 1682×1682比用户对 943×943 大近三倍更关键的是很多电影之间共同评分用户极少大部分物品对的相似度是 0。因此课程设计里实现 ItemCF 时直接用一个全量稠密矩阵存 1682×1682 的浮点数大约 22MB尚可接受但再往上到 1M 数据集就必须用稀疏格式。def build_item_similarity(rating_matrix, threshold2): movie_ids rating_matrix.columns sim_dict {} for i, m1 in enumerate(movie_ids): for m2 in movie_ids[i1:]: s adjusted_cosine(m1, m2, rating_matrix) if s 0: sim_dict[(m1, m2)] s sim_dict[(m2, m1)] s return sim_dict def item_based_predict(user_id, movie_id, rating_matrix, sim_dict, k20): # 找到用户评分过的电影用它们的评分和目标电影相似度做加权 user_rated rating_matrix.loc[user_id].dropna() sim_sum, weighted_sum 0.0, 0.0 for rated_movie, rating in user_rated.items(): if rated_movie movie_id: continue s sim_dict.get((movie_id, rated_movie), 0.0) if s 0: weighted_sum s * rating sim_sum s return weighted_sum / sim_sum if sim_sum 0 else float(nan)ItemCF 里一个常见的优化是“相似度归一化”。比如《玩具总动员》和《狮子王》在数据里同时被很多人看过相似度天然比某些冷门电影对高直接把原始相似度当权重会让热门电影霸榜。简单做法是对每个电影的相似度向量做最大值归一化sim[i][j] sim[i][j] / max(sim[i])把所有相似度压缩到 0~1这样每条推荐理由都是“跟你刚看过的 X 最相似的是什么”而不是“跟全站最热的电影相似”。我在实际项目里会把归一化开关做进函数参数默认开启答辩时可以演示开/关两种推荐的差别。4.3 评估指标RMSE 和 PrecisionN 要分开看课程设计的评分预测能力用 RMSE均方根误差衡量推荐列表质量用 PrecisionN 衡量。RMSE 只关心数值预测准不准PrecisionN 关心排出来的前 N 个东西里用户真正会喜欢的比例。很多源码只打印 RMSE答辩时被问“你这个推荐列表到底准不准”就答不上来。def evaluate_rmse(predict_func, test_ratings): errors [] for row in test_ratings.itertuples(): pred predict_func(int(row.userId), int(row.movieId)) if pred is not None and not np.isnan(pred): errors.append((pred - row.rating) ** 2) return np.sqrt(np.mean(errors)) if errors else float(nan)计算 PrecisionN 时通常先把测试集的评分按阈值比如 ≥4二值化把评分≥4 的电影视为用户“真正喜欢的”然后在你推荐的前 N 部电影里数命中的比例def precision_at_n(rec_list, test_ratings, n10, threshold4.0): rec_list rec_list.head(n) if len(rec_list) 0: return 0.0 liked set(test_ratings.loc[ test_ratings[rating] threshold, movieId ]) hits sum(1 for movie_id in rec_list.index if movie_id in liked) return hits / len(rec_list)这里有个必须说明的约定用“测试集里评分≥4”当作真实喜欢的金标准本身就是个近似。因为用户没看过的电影不代表不喜欢只看过且打高分的有限几部所以 PrecisionN 在离线评估里往往偏低。课程设计写到这一步你能解释清楚这个近似就已经超过大部分只会跑代码的同学了。5. 避坑清单课程设计里最常见的 5 个翻车现场写推荐系统课程设计代码跑通不是终点跑出来的结果合理才是。这一章把自己踩过和帮别人改过的常见问题整理成五条记录每一条都按“现象 → 原因 → 解决”展开值得投影到答辩 PPT 上。5.1 预测分数全一样推荐列表像复制粘贴预测分数全一样推荐列表像复制粘贴的现象通常是评分矩阵里的缺失值被填成了 0导致皮尔逊或余弦相似度把所有用户都“拉平”了。两个用户没看过同一堆电影却因为共同的 0 被算成相似预测时又把这些 0 当作低分加权最后每个人的预测分都收敛到同一个值。解决把填充 0 改成保留 NaN所有相似度函数和预测函数里都用notna()判断有效评分。如果为了构造稀疏矩阵必须填充 0那就在填充前先用.notna()把有效评分掩码单独存好后续所有计算都带上掩码。我见过一份源码在pivot_table之后加了句df.fillna(0)整整改了一晚上才找到这一行。5.2 数据量一大内存直接爆炸现象很直白跑 100K 很流畅换成 1M 开始卡死。原因通常有两个一是pivot_table生成的稠密 DataFrame 太大二是相似度矩阵抱着DataFrame不放。UserCF 的 6040×6040 矩阵存 float64 约 292MBItemCF 的 3706×3706 也有 110MB看起来不大但如果程序里同时保留了评分矩阵、相似度矩阵、多个临时副本很容易翻倍到 GB 级。解决评分矩阵用scipy.sparse.csr_matrix存相似度矩阵用字典只存非零相似度或者用scipy.sparse.lil_matrix构建后再转csr。另外注意 pandas 的groupby会保留副本用完之后del train_list, test_list及时释放。调内存这件事有时候真讲玄学但大多数情况下是没释放临时对象导致的。5.3 皮尔逊相似度大面积出现 NaN这两个用户所有共同评分完全相同比如都只给同一部电影打了 5 分皮尔逊公式的分母为 0np.corrcoef返回 NaN。NaN 一旦进入加权和整个预测结果都会被污染程序还不一定报错最后推荐列表里全是空值。解决在相似度函数里分母接近 0 时直接返回 0.0更稳妥的做法是在公共评分判断上增加common.sum() 2就跳过。也可以把np.corrcoef替换成手动实现并加一个极小值epsilon1e-8。写完后跑一个小测试随便抽 100 个用户对打印相似度分布看到 NaN 就说明你的过滤条件没兜住。5.4 随机切分训练测试集评估结果好得离谱如果只用train_test_split随机打乱用户在过去看的电影会出现在测试集而模型在训练集里已经见过同一用户的评分习惯预测误差当然虚低。结果就是你看着 RMSE 0.85 沾沾自喜答辩时老师一句“你这切分有数据泄露”就哑口无言没有后悔药可吃。解决按 3.3 节的方式按时间戳分组切分。如果数据集没有时间戳也至少要按 userId 分组后随机选一部电影进测试集并且同一个 userId 不能同时出现在训练和测试里。这是推荐系统评估里最底线的一条原则也是答辩老师最爱问的点。5.5 读入 CSV 时中文电影名乱码如果使用老式的u.item文件它的编码通常不是 UTF-8读进来全是乱码直接导致内容推荐匹配失败。最初的坑在于解码用的编码选错utf-8直接报UnicodeDecodeError换成gbk也报错。解决拿到的老式数据集先看第一行常见做法是encodinglatin-1或encodingiso-8859-1movies.csv这类新格式一般是 UTF-8。另外pandas 2.x 里read_csv对编码错误比较严格可以在加载之前用二进制方式打开文件看一眼前几行的字节再决定编码不要一个个试到心态崩。顺带一提如果要在 Windows 终端里打印中文电影名可能还要把sys.stdout.reconfigure(encodingutf-8)加上否则控制台先花掉。6. 把推荐系统接到 Flask 上用一百行代码换来答辩加分项最后这个技巧不复杂却经常能让课程设计的完成度上一个台阶把离线推荐逻辑包成一个 Flask 接口前端拿 JSON 就能渲染。很多课程设计的对接方会要求“纯后端即可”那你返回 JSON 就比在终端里打印推荐列表更有说服力。6.1 Flask 暴露 /recommend 接口from flask import Flask, jsonify, request app Flask(__name__) app.route(/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, default1, typeint) top_n request.args.get(top_n, default10, typeint) rec recommend_top_n(user_id, item_matrix, sim_dict, ntop_n) return jsonify({user_id: user_id, recommendations: rec.index.tolist()})注意typeint让 Flask 自动做类型转换避免前端传?user_idabc时让函数崩溃。把rating_matrix和sim_dict设计成模块级全局变量在if __name__ __main__之前加载一次避免每次请求都重新构建相似度矩阵。6.2 给推荐结果加一句“人话”可解释性是加分项。基于 ItemCF推荐《星际穿越》可以说“因为你给《盗梦空间》打了高分”代码里只需要保存相似度最高的那部已看电影 IDdef explain_rec(movie_id, user_id, sim_dict, rating_matrix): seen rating_matrix.loc[user_id].dropna() if seen.empty: return 新用户暂无评分先推荐热门高分电影 best_movie, best_sim None, 0.0 for m, rating in seen.items(): if m movie_id: continue s sim_dict.get((movie_id, m), 0.0) if s best_sim: best_sim, best_movie s, m return movies.loc[best_movie, clean_title] if best_movie is not None else 6.3 验证把推荐列表和评分预测分开看最后给一个验证习惯不要只跑一遍评估指标就结束。把预测分最高的 20 部电影随机挑 5 部打印出来人工核对如果平时看科幻片见多推荐列表里却全是爱情片多半是数据或相似度计算出了问题合理的列表至少有一半和直觉一致。我习惯在推荐列表里顺手把 explain 的结果也返回这样前端展示时每条推荐都带着理由答辩时演示效果会好很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表