尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于MovieLens的协同过滤Python实现:从相似度到SVD

基于MovieLens的协同过滤Python实现:从相似度到SVD 简介这是一份基于Python实现的MovieLens协同过滤电影推荐系统完整源码包面向推荐算法初学者、数据挖掘研究者以及正在做课程设计或毕业设计的开发者可帮助你快速搭建一套可运行的电影推荐实验环境。项目不仅实现了UserCF与ItemCF两大经典协同过滤算法还提供LFM隐语义模型、随机预测、热门推荐等对比策略代码覆盖了数据加载与清洗、相似度矩阵计算、训练集/测试集划分、评分预测、结果排序和评估等完整流程。资源共58个文件包含9个Python源代码脚本、8个Python字节码编译文件、7个测试文件、6个XML配置文件以及pickle序列化模型和MovieLens 100k、1M两套评分数据压缩包整体约51.37MB目录组织清晰方便按模块研读或复用。目前已有1098人学习下载无论是入门推荐系统还是用于课程项目实战都能借此获得一套可运行的参考实现直观对比不同协同过滤算法在同一数据上的推荐效果。1. MovieLens协同过滤从哪开始一张评分表、两种相似度、三行核心代码把一份 MovieLens 数据丢到你面前任务是让系统学会预测“用户会给一部没看过的电影打几分”然后把预测分最高的 10 部电影推荐给他。这套基于协同过滤的 Python 电影推荐系统源码拆开看就三件事MovieLens 是明尼苏达大学 GroupLens 团队维护的电影评分公开数据集也是推荐系统领域用了几十年的基准数据协同过滤是它背后最朴素的算法思想——和你品味相似的用户打了高分的电影你大概率也喜欢Python 则是把这份思想落到代码里的最短路径。这套源码适合三类人正在做推荐系统课程设计的学生、想给业务搭一个可离线评测的召回基线的工程师、以及想搞清楚相似度计算到底哪里容易翻车的新手。先说结论这个项目 80% 的代码量花在数据清洗和评估上真正的核心算法不超过 50 行难点全在细节。2. MovieLens 数据加载与预处理把 ratings.csv 变成能算相似度的矩阵2.1 数据集版本怎么选100K 与 1M 的取舍MovieLens 有多个版本做协同过滤源码最先要定的就是拿哪份数据跑。ml-100k 是 10 万条评分、943 个用户、1682 部电影文件里自带 u1.base、u1.test 这种五折划分适合课程设计和算法验证ml-1m 是 100 万条评分、6040 个用户规模翻了十倍适合测性能和调参ml-latest-small 大约 10 万条评分但覆盖 9700 多部电影时间跨度长还带 tags 和 links适合做内容特征扩展。我一般建议第一版源码跑 ml-100k理由很实际它自带的五折划分让评测结果可以和其他论文横向对比而且 943 个用户、1682 部电影这个规模用 pandas 直接处理不会卡调试迭代速度快。ml-1m 适合等你把 pipeline 跑通之后验证一下自己的实现能不能扛住更大规模的数据。三个版本的文件名和列名不完全一致切换时要记得改读取参数。版本评分量用户数电影数适合场景ml-100k10 万9431682课程设计、算法对比ml-1m100 万6040约 3700性能验证、调参ml-latest-small约 10 万6109742特征工程、tag 扩展注意 ml-100k 的 u.data 是 tab 分隔、无表头四列分别是 userId、movieId、rating、timestamp。u.item 用了竖线分隔存电影信息u.user 存用户年龄性别职业。很多新手在这里踩坑用逗号分隔读 u.data 会把整行读成一个字段后面全部白做。2.2 加载评分数据与用户-物品矩阵pandas 与 scipy 的两种写法加载部分不复杂但表头名必须自己指定这是最容易忽略的一步。import pandas as pd def load_ratings(data_pathml-100k/u.data): ratings pd.read_csv( data_path, sep\t, names[userId, movieId, rating, timestamp], ) return ratings ratings load_ratings() print(ratings.head()) print(ratings[rating].describe())sep 必须传 \t 而不是默认的逗号names 不传的话第一行评分记录会被当成表头。读完后先看 rating 分布和缺失情况100K 数据的评分集中在 3 到 5 分这是后续做阈值评估的重要参考。接下来是把长表转成用户-物品矩阵。两种写法各有适用场景# 方式一pivot_table 生成稠密矩阵适合 100K 规模 user_item_dense ratings.pivot_table( indexuserId, columnsmovieId, valuesrating ) print(user_item_dense.shape) # (943, 1682) # 方式二scipy 稀疏矩阵1M 以上数据建议用这种 from scipy.sparse import csr_matrix rows ratings[userId].astype(category).cat.codes.to_numpy() cols ratings[movieId].astype(category).cat.codes.to_numpy() vals ratings[rating].to_numpy() user_item_sparse csr_matrix((vals, (rows, cols)))pivot 出来的矩阵行是用户、列是电影没评过分的位置是 NaN注意是 NaN 不是 0。稀疏矩阵方式先用astype(category)把 userId 和 movieId 压缩成连续整数编码再交给 csr_matrix1M 数据下内存占用只有稠密矩阵的几十分之一。代价是丢失了原始 ID 映射所以要把 category 对象保存下来后面预测完要反查电影 ID 时用。提示不要把 NaN 直接填 0 去算皮尔逊相关系数会把“没看过”当成“打了 0 分”相似度直接崩掉。正确做法是先做均值中心化再填 0。2.3 稀疏度与冷门物品为什么矩阵里大多数格子是空的算一下稀疏度你会有直观感受n_user ratings[userId].nunique() n_movie ratings[movieId].nunique() num_ratings len(ratings) sparsity 1 - num_ratings / (n_user * n_movie) print(f稀疏度: {sparsity:.2%}) # 100K 数据集约 94%这意味着矩阵里 94% 的格子是空的。这个数字决定了协同过滤的命门UserCF 找邻居时两个用户共同评过的电影可能只有个位数ItemCF 算电影相似度依靠的是“同一用户给两部电影都评过分”的共现关系。冷门电影评过的用户太少共现次数趋近于零相似度算出来全是噪声。理解了稀疏度你就知道为什么这个项目里相似度计算要用“减去用户均值再算余弦”而不是直接算原始评分余弦也为什么矩阵分解类算法在稀疏场景下往往更能打。不管是哪种算法预处理阶段把用户均值、电影均值、评分频次这三样统计量算好存下来后面会反复用到。3. 协同过滤的 Python 实现User-Based 和 Item-Based 谁更适用3.1 基于用户的协同过滤找邻居、加权评分、去掉平均分基于用户的协同过滤UserCF的流程是三步找到和目标用户口味最像的 k 个用户取这些邻居对某部电影的评分加权平均算出预测分。核心逻辑一句话物以类聚人以群分。但直接拿原始评分算相似度会翻车因为每个用户的打分尺度不同——有人习惯全打 5 分有人很少给 4 分以上。所以第一步必须做均值中心化每个用户的评分减去他自己的平均分再填 0 算余弦相似度。这一步做完算出来的余弦相似度其实就等价于皮尔逊相关系数。import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity # 1. 均值中心化每个用户减去自己的平均打分习惯 user_mean user_item_dense.mean(axis1) centered user_item_dense.sub(user_mean, axis0).fillna(0) # 2. 用户相似度矩阵shape 是 (n_user, n_user) user_sim cosine_similarity(centered) np.fill_diagonal(user_sim, 0) # 自己和自己不算邻居 def predict_user_based(user_id, movie_id, k20): if movie_id not in user_item_dense.columns: return user_mean.loc[user_id] rated user_item_dense[movie_id].dropna() if rated.empty: return user_mean.loc[user_id] sim user_sim[user_id - 1] # 目标用户与所有人的相似度 cand pd.Series(sim, indexuser_item_dense.index).drop(user_id) cand cand.reindex(rated.index).dropna() cand cand.sort_values(ascendingFalse).head(k) weights cand.values # 邻居对这部电影的中心化评分 center_rating (rated.loc[cand.index] - user_mean.loc[cand.index]).values pred user_mean.loc[user_id] (weights center_rating) / weights.sum() return preduser_sim[user_id - 1]这里有个容易忽略的细节user_item_dense 的索引是原始 userId从 1 开始但 user_sim 的行索引从 0 开始取相似度向量时必须减 1。候选邻居只选“给 movie_id 评过分的人”然后按相似度取前 k 个用相似度作为权重对中心化评分做加权平均最后加回目标用户的平均分。k 一般取 20 到 50太小相似度噪声大太大会把不相关的用户拉进来稀释信号。3.2 基于物品的协同过滤Item-Item 相似度与评分预测基于物品的协同过滤ItemCF换了主角不找相似用户而是找相似电影。预测用户对某部电影的评分时看他历史上喜欢的那些电影里哪些和这部最像加权平均。在电影推荐场景ItemCF 通常比 UserCF 稳原因很朴素——用户的兴趣会变但电影之间的相似关系相对稳定。物品相似度要用修正余弦相似度和用户相似度的区别只在矩阵转置一下# 基于物品的相似度先中心化再转置后按电影向量算余弦 centered user_item_dense.sub(user_mean, axis0).fillna(0) item_sim cosine_similarity(centered.T) # shape 是 (n_movie, n_movie) np.fill_diagonal(item_sim, 0) def predict_item_based(user_id, movie_id, k20): user_ratings user_item_dense.loc[user_id].dropna() if user_ratings.empty: return user_mean.loc[user_id] if movie_id not in user_item_dense.columns: return user_mean.loc[user_id] # 与目标电影最相似的 k 部电影 sim item_sim[list(user_item_dense.columns).index(movie_id)] cand pd.Series(sim, indexuser_item_dense.columns).drop(movie_id) cand cand.reindex(user_ratings.index).dropna() cand cand.sort_values(ascendingFalse).head(k) weights cand.values center_rating (user_ratings.loc[cand.index] - user_mean.loc[user_id]).values pred user_mean.loc[user_id] (weights center_rating) / weights.sum() return pred两个函数结构几乎一样区别只在相似度矩阵的构建方向。这里同样做了中心化所以叫“修正余弦相似度”。实践中 ItemCF 还有一个优化点物品相似度矩阵可以在离线阶段算好存起来线上预测时直接查表这也是 ItemCF 比 UserCF 更适合生产环境的原因之一。提示ItemCF 的 item_sim 矩阵是 n_movie × n_movieMovieLens 100K 下是 1682×1682可以接受但到了 ml-1m 就是 3700×3700 的浮点矩阵约 110MB还能忍。如果换更大数据集建议只在相似度计算时用稀疏矩阵或者设置共现阈值过滤掉低质量物品对。3.3 两种算法的时间复杂度差异与适用场景跑通两个函数后要做的第一件事不是对比 RMSE而是想清楚你的场景适合哪个。看复杂度之前先看用户数和物品数的关系维度UserCFItemCF相似度矩阵规模用户数²物品数²实时性用户新行为即时反映在相似度里但重算代价高物品相似度可离线算好线上查表可解释性“品味跟你相似的人也喜欢”“因为你喜欢某部电影所以推荐这部”适用场景新闻、社交用户少内容多电商、电影物品相对稳定MovieLens 100K 里是 943 个用户、1682 部电影UserCF 的矩阵反而更小。但现实业务里用户量通常远大于物品量比如一个视频平台几十万用户和几千部电影ItemCF 的相似度矩阵小得多而且电影属性变化慢离线算好之后能抗住线上高并发。所以电影推荐领域ItemCF 是更常见的基线选择。如果你要做毕业设计答辩或者技术分享建议两种都实现然后对比同一份测试集上的 RMSE再结合“相似物品是否符合直觉”做人工抽检这个对比本身就是很好的材料。4. 矩阵分解与 SVD用 surprise 库调参把 RMSE 压到 0.95 以下4.1 为什么传统协同过滤扛不住稀疏矩阵UserCF 和 ItemCF 都依赖共现用户之间要有共同评分才谈得上相似电影之间要有共同用户才谈得上相关。遇到冷门电影或者新注册用户共现数为零预测就只能回退到全局均值。这是协同过滤的先天缺陷。矩阵分解的思路是完全不同的路径把用户-物品评分矩阵 R 拆成两个低维矩阵的乘积R 近似等于 P 乘 Q 的转置。P 是用户隐因子矩阵每一行代表一个用户在一组隐含主题上的偏好Q 是物品隐因子矩阵每一行代表一部电影在这组主题上的属性。预测评分就是两个向量做点积。这个“隐因子”虽然不能直接命名但它实际捕捉的是类似“动作片、剧情片、老片、文艺片”这种潜在维度。surprise 库里的 SVD 实现的是 FunkSVD 的随机梯度下降版本不是严格线性代数意义的奇异值分解。更新公式的核心思想是预测分减去真实分得到误差 e然后沿着误差的反方向微调 P 和 Q同时加一个正则化项防止过拟合。这层原理理解透了调参才不玄学。4.2 用 surprise 跑 SVD数据集划分、五折交叉验证surprise 是 Python 生态里做推荐系统离线评测最顺手的库封装了 SVD、KNNBasic、NMF 等算法和交叉验证工具。注意安装包名是 scikit-surpriseimport 时用 surprise。from surprise import SVD, Dataset, Reader, accuracy from surprise.model_selection import cross_validate reader Reader( line_formatuser item rating timestamp, sep\t, rating_scale(1, 5), ) data Dataset.load_from_file(ml-100k/u.data, readerreader) algo SVD(n_factors50, n_epochs30, lr_all0.005, reg_all0.02, random_state42) cv_result cross_validate(algo, data, cv5, measures[RMSE, MAE], verboseTrue)Reader 的 line_format 必须和文件列顺序对齐u.data 的四列正好是 user item rating timestamp。rating_scale 告诉模型评分的上下界对预测结果做截断。cross_validate 内部会做五折划分每一折训练一次、评测一次最后输出平均值和标准差这比手动 train_test_split 更可靠因为单次划分的结果很容易被运气影响。4.3 必调参数n_factors、n_epochs、lr_all、reg_allSVD 的四个参数决定模型效果调参顺序比调参本身更重要。先固定其他参数扫 n_factors找到拐点后再固定 n_factors 扫 lr 和 reg。参数常见范围作用调大后的后果n_factors10 ~ 200隐因子个数决定模型容量过拟合RMSE 先降后升n_epochs20 ~ 50全量数据迭代轮数训练变慢后期收益递减lr_all0.002 ~ 0.02SGD 学习率太大 loss 震荡太小收敛慢reg_all0.02 ~ 0.1正则化强度太大欠拟合太小过拟合经验上MovieLens 100K 用 n_factors50、lr_all0.005、reg_all0.02 是一个能稳定跑到 RMSE 0.94 左右的组合但这不是银弹ml-1m 上最优的 n_factors 可能到 100 以上。注意每次实验都固定 random_state否则两次跑出来的差异可能超过调参带来的收益你会完全分不清是调参有效还是随机噪声。4.4 三种模型在同一数据集上的 RMSE 对比把前面实现的 UserCF、ItemCF 和 surprise 的 SVD 放到同一套数据上看效果。评测方式统一用五折交叉验证UserCF 和 ItemCF 自己写一个简单的 RMSE 计算from surprise.model_selection import train_test_split def rmse_of_predictions(predictions): errors [(true_r - est) ** 2 for _, _, true_r, est, _ in predictions] return np.sqrt(np.mean(errors)) trainset, testset train_test_split(data, test_size0.2, random_state42)在 100K 数据上三类模型的效果大致落在这样的区间全局均值基线 RMSE 约 1.10UserCF 皮尔逊版本约 0.98 到 1.02ItemCF 约 0.95 到 0.99SVD 调好参数后在 0.93 到 0.95。这个排名符合直觉矩阵分解利用全局结构补全了稀疏项而 UserCF 在用户行为稀疏时邻居质量差。跑对比时有个细节需要提醒不要用你自己实现的 UserCF 去和 surprise 的 SVD 比速度这不公平因为 surprise 内部用 Cython 加速。另外记录结果时要把相似度计算方式、k 值、是否中心化写清楚否则过两天回头看完全不知道当时跑的什么配置。我就是因为偷懒不记配置吃过好几次复盘时一头雾水的亏。5. 推荐系统最容易翻车的 5 个坑现象、原因与排查5.1 冷启动新用户和新电影的评分预测全是默认值现象给一个没有任何评分记录的新用户做推荐预测函数直接返回了全局平均分推荐列表变成全站热门排行。给一部刚上线的电影算相似度所有电影的相似度都是 0 或者 NaN。原因协同过滤是纯行为驱动的算法没有行为就没有信号。相似度矩阵里新用户那一行全是 0加权平均时分子分母都是 0代码只能 return 平均分兜底。解决最实用的兜底策略是热门榜——用评分数量加平均分做加权热度排序先把推荐位填上。代码层面在预测函数入口判断“用户没有历史评分”或“电影没有共现记录”直接返回热门榜。冷启动要根治还得引入内容画像用电影的类型、导演、演员特征做基于内容的推荐这部分后面第 6 章展开。5.2 热门物品绑架大热电影让所有相似度都失真现象ItemCF 算出来的相似电影列表里《教父》和《阿甘正传》这种全民热片同时出现在一大堆电影的相似列表里冷门好片反而找不到相似项。UserCF 里所有用户的相似度都被共同评过的大热电影抬高。原因热门电影的评分数量可能是冷门电影的上百倍在余弦相似度和皮尔逊公式里过高的共现频次意味着这些物品对相似度的贡献被无限放大。算法分不清“真的相似”还是“只是都看过”。解决经典手段是对热门物品降权在预测排序阶段加一个流行度惩罚项# 按评分人数计算电影流行度 item_pop ratings[movieId].value_counts() def adjust_score_by_popularity(score, movie_id, alpha1.0): pop item_pop.get(movie_id, 1) return score / (1 np.log1p(pop)) ** alphaalpha 控制惩罚强度0 表示不惩罚1 是常见取值。这样冷门但相关度高的电影才有机会进 top-N 推荐列表。另外可以在算物品相似度之前过滤掉共现次数低于阈值比如 5 次的物品对减少噪声。5.3 评分偏差不减用户平均分相似度全是假的现象一个用户对看过的电影全打 5 分另一个用户全部打 3 分两个用户的余弦相似度高达 0.99但他们的真实喜好可能完全不重合。用这套相似度做推荐结果莫名其妙。原因原始评分尺度不同余弦相似度把“打分尺度相似”误判成“口味相似”。全打 5 分的用户和全打 3 分的用户在数值走势上确实完全一致但绝对水平差了 2 分。解决先对每个用户做均值中心化再用中心化后的评分算余弦这就是皮尔逊相关系数。预测时加权平均用的也是中心化评分最后再加回目标用户的均值。代码在第 3 章已经实现这里的教训是任何相似度计算之前先问自己一句“原始评分能不能直接比”。不能直接比先中心化。5.4 隐式反馈缺失只看评分推荐结果看起来“对”但用户不点现象离线 RMSE 很漂亮线上点击率惨淡。用户给某部电影打了 5 分但他可能只看过一遍再也没回访用户反复看了十遍的喜剧片因为从没评过分直接被算法忽略。原因评分是稀疏、延迟的显式反馈大多数人不会认真打分。浏览、收藏、观看时长、点击这些隐式反馈才是更密集的行为信号但它们没进模型。解决在评分矩阵之外把隐式行为也变成特征。最轻量的做法是构造一个“行为权重矩阵”用户看过就记 1然后作为评分矩阵的补充项和显式评分加权融合。MovieLens 的 ml-latest-small 里带了 tags 数据可以统计用户打过的标签频次作为侧面画像弥补评分的空白。注意这一步不要放在模型里做而是在预处理阶段生成辅助特征否则模型复杂度会失控。5.5 矩阵分解的随机种子同一份代码、同一个参数两次结果不一样现象surprise 里 SVD 同一份参数跑了两次RMSE 差了 0.015。你以为是你改了某个配置实际只是随机性在作怪白白浪费一晚上调参。原因SVD 的随机梯度下降初始化是随机的样本训练顺序也随机两次运行结果天然不同。在 100K 数据上这个波动幅度通常远超你以为的调参收益。解决所有涉及随机的地方都要固定种子。surprise 的 SVD 支持 random_state 参数UserCF 和 ItemCF 如果自己实现的相似度计算不涉及随机但 train_test_split 要传 random_state。报告结果时把 Python 版本、numpy 版本、surprise 版本一起记录下来——库升级带来的数值差异有时候比换个参数还大。这也是推荐系统实验可复现的基本功。提示固定随机种子不是让你忽略模型本身的方差。五折交叉验证能反映模型稳定性所以正式对比时用 cross_validate 而不是单次划分这是判断“调参有效还是运气好”的唯一可靠办法。6. 让推荐结果更可用离线评估指标与冷启动补救6.1 RMSE 之外还要看什么Hit Rate 与 PrecisionKRMSE 衡量的是“打分准不准”但推荐系统真正关心的是“推荐的 10 部电影里用户会看几部”。这两个指标经常不一致模型把评分预测得误差很小但 top-10 里全是用户没兴趣的冷门片。所以要补一个命中率指标代码很短from collections import defaultdict def hit_rate_and_precision(predictions, k10, threshold4.0): user_recs defaultdict(list) user_actual defaultdict(set) for uid, iid, true_r, est, _ in predictions: user_recs[uid].append((est, iid)) if true_r threshold: # 实际打了 4 分以上才算“真的喜欢” user_actual[uid].add(iid) hr_sum, precision_sum 0, 0 for uid, recs in user_recs.items(): top_k [iid for _, iid in sorted(recs, keylambda x: x[0], reverseTrue)[:k]] hits set(top_k) user_actual[uid] hr_sum 1 if hits else 0 precision_sum len(hits) / k n len(user_recs) return hr_sum / n, precision_sum / n用法是在 surprise 的 testset 预测结果上直接调用threshold 取 4.0 是因为 MovieLens 的评分分布中 4 分以上才算正向反馈。Hit Rate 表示“至少命中一部”的用户比例PrecisionK 是“top-10 里命中的占比”两个指标一起看才能判断推荐列表质量。6.2 一份可复现的源码组织习惯代码能跑只是第一步能复现才是科研和工程的基本要求。我这几年做推荐系统养成的习惯是一个实验一个目录data 目录放原始数据禁止改动preprocess.py 产出中间特征models 目录按日期存档模型参数evaluate.py 统一输出 RMSE、Hit Rate、PrecisionK 到 CSV。参数配置用一个 dict 集中管理而不是散落在代码里。这套源码如果只给你一个 predict 函数本质上只是算法片段不是工程有了这套组织方式才谈得上迭代和排错。6.3 一个验证推荐是否靠谱的小技巧抽检相似电影你跑完模型之后不要急着看指标先把 ItemCF 的相似电影列表拉出来人工扫一眼——取《玩具总动员》的 top-5 相似电影如果出现《小猪宝贝》《虫虫危机》这种同类动画说明相似度计算是健康的如果出现《教父》这种完全不搭边的片子基本就是热门物品没降权指标再好看也别信。这个“肉眼抽检”只需要十秒钟但它能救你无数次。我现在每跑一个推荐模型第一件事不是看 RMSE而是把相似列表导出来扫一遍确认算法学到的不是我喂进去的噪声。这个习惯是从无数次翻车里捡回来的希望帮到你。本文还有配套的精品资源点击获取
返回列表