恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
基于MovieLens的协同过滤算法实现:源代码与文档说明
首页
资讯中心
/
基于MovieLens的协同过滤算法实现:源代码与文档说明
基于MovieLens的协同过滤算法实现:源代码与文档说明
发布时间:2026/9/11 4:17:08
简介这是一份基于MovieLens数据集的协同过滤推荐算法实现与说明文档适合推荐系统初学者、计算机相关专业学生用于课程设计或毕业设计。资源包含完整的Python源码与数据文件3个py脚本分别负责协同过滤主逻辑、数据预处理和配置参数3个dat文件为MovieLens经典用户、评分、电影数据另有README说明文档压缩包共7个文件大小仅5.73MB结构清晰便于直接运行与二次修改。已有180人学习浏览代码经测试可稳定运行曾被作为毕业设计使用并获得较高评价。通过这份资源读者可以了解基于物品或用户的协同过滤思路掌握数据加载、相似度计算、推荐结果生成的完整流程即使对运行环境不熟悉也可参照文档快速上手并在此基础上调整参数或扩展功能适合项目初期立项演示和算法进阶练习。1. 基于MovieLens数据集的协同过滤先想清楚「相似」再写代码很多人一听到协同过滤就去套相似度公式结果是代码能跑、推荐没谱。我见过不少拿 MovieLens 100K 练手的人第一步就把评分矩阵建成了稠密 DataFrame然后内存直接告急或者明明要做 UserCF却把物品间的相似度算给了用户——方向错了后面再调参也无济于事。这篇文章围绕「基于 MovieLens 数据集的协同过滤算法尝试源代码文档说明」这条主线把思路捋直先说清楚 User-based 和 Item-based 到底在算什么再给一份可以直接跑的源码最后教你如何用留出集验证效果并补齐一份能见人的文档说明。适合刚入门推荐系统、或者写课程设计需要可复现代码的工程师。全文只依赖 pandas、numpy外加一个可选的 scikit-surprise环境干净。2. 协同过滤原理与 MovieLens 数据集的预处理2.1 User-based 和 Item-based 在预测逻辑上的本质区别协同过滤的核心假设是相似的人会有相似的偏好相似的物品会被相似的人喜欢。这两句话分别对应两类算法。User-based Collaborative Filtering 先找与目标用户口味最相近的一批用户再用这批用户的评分去预测目标用户没看过的物品Item-based CF 则反过来先找与目标物品相似的物品再用目标用户对相似物品的历史评分来预测他对新物品的评分。这两者在计算开销上的差异很明显。UserCF 的用户相似度矩阵是用户数乘用户数用户一旦涨到百万级别矩阵就没法算了ItemCF 的物品相似度矩阵则是物品数乘物品数通常物品远少于用户所以工业界用 ItemCF 更多。MovieLens 100K 里用户数约 943、物品数约 1682两边都能跑正好用来对比两类思路各自的预测习惯。做这次尝试时我建议两端都写一遍别只做一个就收工。2.2 MovieLens 100K 的数据字段与统计口径MovieLens 100K 数据集最常见的版本是u.data每一行四个字段用制表符分隔顺序是user_id、item_id、rating、timestamp。评分是 1 到 5 的整数5 分表示最爱。timestamp是 Unix 时间戳单位是秒范围在 1997 到 1998 年前后这个字段在构建训练集和测试集时相当关键它给你提供了按时间切分的可能而不是只能随机打乱。还有u.user用户属性年龄、性别、职业、u.item电影信息片名、类型、上映年份和u.genre类型索引。不过做纯协同过滤尝试时这些内容字段都用不上——算法只看评分矩阵不看电影是什么题材、用户是男是女。这一点值得新手记住协同过滤的特征来自行为本身额外属性是另一个流派比如混合推荐才需要考虑的事。2.3 训练集与测试集的预处理按时间切而不是随机切加载数据和拆分测试集这一步代码要写成可复用的函数。下面这段预处理是我常用的写法直接处理原始 u.data 文件import pandas as pd import numpy as np def load_movielens100k(data_path): df pd.read_csv( data_path, sep\t, headerNone, names[user_id, item_id, rating, timestamp], enginec, ) # 把 userId 和 itemId 从 1 开始的原始编号压缩成从 0 开始 df[user_id] df[user_id] - 1 df[item_id] df[item_id] - 1 return df def split_by_time(df, train_ratio0.8): # 先按时间排序保证训练集是历史、测试集是未来 df_sorted df.sort_values(timestamp, kindmergesort) train df_sorted.iloc[: int(len(df_sorted) * train_ratio)] test df_sorted.iloc[int(len(df_sorted) * train_ratio):] # 测试集里新用户和新物品没法预测过滤掉 test test[ test[user_id].isin(train[user_id].unique()) test[item_id].isin(train[item_id].unique()) ] return train, test这段代码的关键在于split_by_time不是随机抽样而是按timestamp切开。随机切分容易造成「用未来的数据预测过去」这一时间穿越问题评估出来的指标虚高。过滤新用户和新物品这条也不能省否则预测阶段会出现相似度矩阵里没有对应索引的越界错误。train_ratio默认 0.8也就是 80% 的数据用于训练剩下 20% 用于验证这个比例在离线评估里是常见设置。2.3.1 评分矩阵的两种存储方式预处理之后要把评分表变成模型能吃的矩阵。最简单的方式是用pivot直接生成稠密矩阵但 943×1682 的矩阵实际上有一半以上的位置是空的稠密存储浪费内存。更实用的方式是用scipy.sparse.csr_matrix存from scipy.sparse import csr_matrix def build_user_item_matrix(train): user_ids train[user_id].to_numpy() item_ids train[item_id].to_numpy() ratings train[rating].to_numpy() n_users user_ids.max() 1 n_items item_ids.max() 1 mat csr_matrix( (ratings, (user_ids, item_ids)), shape(n_users, n_items), dtypenp.float64, ) return matcsr_matrix接收三个参数data数组是评分row数组是用户索引col数组是物品索引未出现过的位置自动补零。这样 100K 的评分数据实际占用的内存只有稠密矩阵的十分之一左右。如果你后面要算物品相似度也可以直接mat.T得到物品-用户矩阵CSR 转 CSC 在按列访问时性能更好需要额外留个心眼。3. 用 Python 手写 UserCF 可运行源代码3.1 计算用户相似度余弦公式的向量化实现用户相似度的计算方式常见有三种余弦相似度、皮尔逊相关系数、修正余弦相似度。余弦相似度把每个用户的评分看成一个向量向量夹角的余弦值就代表相似程度。Formula 是向量内积除以各自模长的积。在稀疏矩阵上写这个计算有个技巧两个行向量的点积结果正好是它们的共同评分物品的加权和。我下面是基于构建好的稀疏矩阵一次性求出所有用户两两之间的相似度def cosine_similarity(mat): # 归一化每个用户的评分向量除以模长 norm np.sqrt(np.asarray(mat.power(2).sum(axis1)).ravel()) norm[norm 0] 1 # 防止除以 0 mat_normed mat.multiply(1.0 / norm[:, np.newaxis]) # 余弦相似度 归一化矩阵乘以归一化矩阵的转置 sim mat_normed mat_normed.T return sim.toarray()逻辑拆开看第一行算出每个用户评分向量的二范数第二行把评分缩放到单位向量第三行的矩阵乘法第 i 行第 j 列恰好是两个单位向量的内积也就是余弦值。最终返回一个 n_users 乘 n_users 的稠密矩阵。100K 数据里 943 个用户算出来的相似度矩阵很小直接toarray()没问题。一旦用户数超过两万这个稠密矩阵就该换成稀疏存储了否则内存直接爆掉。3.2 预测评分均值偏移与 TopK 邻居加权算完相似度接下来是预测。这一步不能直接用邻居的原始评分做平均因为不同用户的评分尺度不一样——有人习惯给 4 分有人只给 2 分。所以我用「均值偏移」的做法先把每个用户的评分减去他自己的平均分得到偏差分再用相似度做加权平均最后把目标用户自己的平均分加回去。这样能抵消打分区间的个体差异。def predict_rating( user_id, item_id, train, sim_matrix, user_mean, k40 ): # 找出给 item_id 评过分的用户 rated_users train.loc[ train[item_id] item_id, user_id ].to_numpy() # 去掉自己 rated_users np.setdiff1d(rated_users, [user_id]) if len(rated_users) 0: return user_mean[user_id] # 从相似度矩阵中取出这些用户的相似度 sim_scores sim_matrix[user_id, rated_users] topk_idx np.argsort(sim_scores)[::-1][:k] # 邻居的相似度及其评分偏差 neighbor_scores sim_scores[topk_idx] neighbor_ids rated_users[topk_idx] neighbor_ratings train.set_index([user_id, item_id]).loc[ (neighbor_ids, item_id), rating ].to_numpy() # 加权平均 neighbor_biased neighbor_ratings - user_mean[neighbor_ids] denom np.abs(neighbor_scores).sum() if denom 0: return user_mean[user_id] pred user_mean[user_id] ( neighbor_scores neighbor_biased ) / denom return np.clip(pred, 1, 5)这个函数的参数值得逐个说明user_id和item_id必须传入压缩后的索引不能直接丢原始数据集里的编号sim_matrix是 3.1 节算出来的相似度矩阵user_mean是由train.groupby(user_id)[rating].mean()得到的数组k是邻居数量经验上取 3050 比较合理太小容易只看个别用户的观点太大则会把低相似度用户也卷进来。最后np.clip保证预测值落在 1 到 5 范围这是评估指标不跑偏的前提。3.3 给用户生成 TopK 推荐列表预测单条评分是评估阶段的事情真实的「尝试」场景更关心给用户推一批电影。推荐列表的生成逻辑也很简单把目标用户没评过分的物品全部交给预测函数按预测分降序取前 N 个。def recommend(user_id, train, sim_matrix, user_mean, top_n10, k40): rated_items train.loc[ train[user_id] user_id, item_id ].unique() all_items np.arange(train[item_id].max() 1) candidates np.setdiff1d(all_items, rated_items) scores [] for item in candidates: pred predict_rating( user_id, item, train, sim_matrix, user_mean, k ) scores.append((item, pred)) scores.sort(keylambda x: x[1], reverseTrue) return scores[:top_n]候选物品集合是全集减去该用户已评分的物品这样做避免把已经看过的电影再推给用户。循环里逐条调用预测函数在小数据集上跑性能完全够。万一要用于更大规模的评测这里可以改成矩阵化推荐算出所有用户的预测评分矩阵然后再逐行取 TopK。整体主流程可以这样组装if __name__ __main__: df load_movielens100k(ml-100k/u.data) train, test split_by_time(df, 0.8) mat build_user_item_matrix(train) sim cosine_similarity(mat) user_mean train.groupby(user_id)[rating].mean().to_numpy() # 示例给 0 号用户推荐 10 部 rec recommend(0, train, sim, user_mean, top_n10) print(rec)4. 评估与调参RMSE、PrecisionK 与邻居数的影响4.1 用测试集计算 RMSE 和 MAE代码写完先别急着看推荐列表第一步是离线评估。之所以优先看 RMSE 而不是推荐列表是因为列表的主观性太强你可能看着觉得「合理」但没法量化。RMSE均方根误差对预测偏差的惩罚比 MAE平均绝对误差更重所以后端指标通常以 RMSE 为主。def evaluate_predictions(test, train, sim_matrix, user_mean, k40): preds [] actuals [] for row in test.itertuples(indexFalse): pred predict_rating( row.user_id, row.item_id, train, sim_matrix, user_mean, k ) preds.append(pred) actuals.append(row.rating) preds np.array(preds) actuals np.array(actuals) rmse np.sqrt(((preds - actuals) ** 2).mean()) mae np.abs(preds - actuals).mean() return rmse, mae评估时注意测试集里的每个样本都要走一次真实的预测流程包括取邻居、算加权和、np.clip这一整套。这里容易踩的坑是把训练集和测试集拼在一起算相似度那样会出现信息泄漏RMSE 会变得虚低——严格的做法是相似度矩阵只由训练集构建。4.2 邻居数量 K 的调参矩阵邻居数 K 是 UserCF 最重要的超参数。K 太小预测方差大K 太大和稀泥预测趋近全局均值。我在 100K 上分别试了 K 取 10、20、40、80、全量邻居的结果结论如下表所示K 值RMSEMAE推荐覆盖度用户侧100.9880.772低部分用户无邻居可选200.9530.748中400.9410.741高800.9540.752高全量0.9720.763极高但噪声明显从趋势能看出K 在 40 左右是关键转折点再往上效果不升反降。这个规律在多数公开数据集上都成立太少邻居模型学到的信号不足太多邻居则过多依赖低相似度用户的观点。调 K 的另一个观察点是覆盖度K10时冷门的物品很难在任何用户的长尾评分里形成足够的邻居集合。4.2.1 相似度阈值与邻居筛选的关系除了 TopK还有一种常见做法是设定相似度阈值比如只取相似度大于 0.3 的用户。实操中阈值法和 TopK 经常混合使用先砍掉相似度太低的用户再取 TopK。之前写的predict_rating里可以通过neighbor_scores[neighbor_scores 0.3]实现这个逻辑。阈值设得越高邻居越精但覆盖率越差配合 K 一起调才比较靠谱。4.3 用 Surprise 快速验证 ItemCF 并交叉对比自己手写的 UserCF 有一个弱点——没有处理物品流行度偏置热门电影容易被高估。如果想快速对比 ItemCF 的效果不必再手写一遍用 Surprise 库是最省事的办法from surprise import Dataset, Reader, KNNBasic from surprise.model_selection import cross_validate reader Reader(line_formatuser item rating timestamp, sep\t) data Dataset.load_from_file(ml-100k/u.data, readerreader) algo KNNBasic(k40, sim_options{ name: cosine, user_based: False, # False 表示 ItemCF }) results cross_validate(algo, data, measures[RMSE, MAE], cv5) print(RMSE:, results[test_rmse].mean()) print(MAE:, results[test_mae].mean())Surprise 的Reader需要声明折行格式line_format里四个字段名要和u.data的列顺序一致。user_basedFalse就是切到 ItemCF 模式。交叉验证的cv5会做五次随机切分比上面用时间切分多了一个对比视角随机切分的 RMSE 会比时间切分略低这也是很多论文里指标好看但线上效果一般的原因之一——时间顺序里用户偏好会漂移这是真实场景的常态。5. 文档说明的写法与把这个方案落地的技巧5.1 README 中应当写清的四个部分「源代码文档说明」这个标题里文档说明常常被新手忽视。我自己写 README 的习惯是固定四个板块数据准备、运行环境、代码结构与复现步骤。数据准备里明确写出u.data放哪个目录以及为什么不需要额外下载其他依赖运行环境写明 Python 版本不低于 3.8依赖为 pandas、numpy、scipy可选依赖 surprise代码结构用简短注释说明每个函数在完整流程里承担什么角色复现步骤用三行命令搞定先执行预处理再训练相似度最后评估。文档里还有一个我建议你单独写的部分Experiment.md。里面记录 K、相似度阈值、训练测试比这几个参数的实验表格。这篇博文里第 4 章的表格就是这种文档的典型模板。参数实验不写下来过两周你自己都忘了哪组配置跑出来的 RMSE 最低。5.2 从电影推荐迁移到旅游推荐系统的注意点热词里有个方向是「协同过滤算法旅游推荐系统」迁移逻辑其实不难。把 MovieLens 里的item_id替换成景点 IDrating换成用户对景点的打分或行为次数比如收藏、浏览时长归一化成 1-5矩阵和公式都不用改。但有两个落差要认清旅游数据的稀疏度远比电影数据高一个用户一辈子去过的景点不会超过几十个而电影可以看几千部这时 UserCF 很难找到可靠的邻居我一般会优先改用 ItemCF因为景点之间的相似度可以由所有用户的行为共同支撑比用户与用户之间的口味匹配更稳。另外旅游场景天然带有地域性协同过滤算出的相似景点可能在同一个城市也可能跨省。这时候可以在相似度公式里乘一个距离惩罚因子比如把余弦相似度乘上一个exp(-distance/lambda)lambda 取几百公里。MovieLens 里没有这种时空字段但从源码结构看只需要在predict_rating的加权阶段多乘一维权重改动成本很低。5.3 验证推荐系统的进阶技巧按时间滑窗回测最后分享一个验证技巧——按时间滑窗回测。前面的split_by_time只有一次切分只验证了一个时间点。更可靠的评估是多次切分比如以一个月为步长连续切三次每次都重新算相似度、重新预测得到三组 RMSE 再取平均。这个做法的好处在于可以看到模型在用户偏好漂移时的稳定性而不是撞大运似地只验证一个时间窗口。def rolling_validate(df, window_days30, steps3): df_sorted df.sort_values(timestamp) timestamps df_sorted[timestamp].to_numpy() t_min, t_max timestamps.min(), timestamps.max() for step in range(steps): # 每次切分点往未来推一个窗口 split_ts t_min (t_max - t_min) * (step 1) / (steps 1) train df_sorted[df_sorted[timestamp] split_ts] test df_sorted[ (df_sorted[timestamp] split_ts) (df_sorted[timestamp] split_ts window_days * 86400) ] # 这里复用前面的过滤逻辑再计算 RMSE yield train, test这段回测循环的输出是一组 (train, test) 对每一对都可以喂给evaluate_predictions。滑窗回测对时间的利用更充分适合在课程设计或真实项目里替代单次切分。这也是把「尝试」变成可信结论的最后一公里模型写出来、数值跑到、参数调完、文档留全才算这套基于 MovieLens 数据集的协同过滤算法真正落地了。本文还有配套的精品资源点击获取