news 2026/9/11 4:17:00

基于MovieLens的协同过滤算法实现:源代码与文档说明

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于MovieLens的协同过滤算法实现:源代码与文档说明

简介:这是一份基于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_iditem_idratingtimestamp。评分是 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', header=None, names=['user_id', 'item_id', 'rating', 'timestamp'], engine='c', ) # 把 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_ratio=0.8): # 先按时间排序,保证训练集是历史、测试集是未来 df_sorted = df.sort_values('timestamp', kind='mergesort') 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), dtype=np.float64, ) return mat

csr_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(axis=1)).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, k=40 ): # 找出给 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_iditem_id必须传入压缩后的索引,不能直接丢原始数据集里的编号;sim_matrix是 3.1 节算出来的相似度矩阵;user_mean是由train.groupby('user_id')['rating'].mean()得到的数组;k是邻居数量,经验上取 30~50 比较合理,太小容易只看个别用户的观点,太大则会把低相似度用户也卷进来。最后np.clip保证预测值落在 1 到 5 范围,这是评估指标不跑偏的前提。

3.3 给用户生成 TopK 推荐列表

预测单条评分是评估阶段的事情,真实的「尝试」场景更关心给用户推一批电影。推荐列表的生成逻辑也很简单:把目标用户没评过分的物品全部交给预测函数,按预测分降序取前 N 个。

def recommend(user_id, train, sim_matrix, user_mean, top_n=10, k=40): 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(key=lambda x: x[1], reverse=True) 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_n=10) print(rec)

4. 评估与调参:RMSE、Precision@K 与邻居数的影响

4.1 用测试集计算 RMSE 和 MAE

代码写完,先别急着看推荐列表,第一步是离线评估。之所以优先看 RMSE 而不是推荐列表,是因为列表的主观性太强,你可能看着觉得「合理」,但没法量化。RMSE(均方根误差)对预测偏差的惩罚比 MAE(平均绝对误差)更重,所以后端指标通常以 RMSE 为主。

def evaluate_predictions(test, train, sim_matrix, user_mean, k=40): preds = [] actuals = [] for row in test.itertuples(index=False): 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 的另一个观察点是覆盖度,K=10时,冷门的物品很难在任何用户的长尾评分里形成足够的邻居集合。

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_format='user item rating timestamp', sep='\t') data = Dataset.load_from_file('ml-100k/u.data', reader=reader) algo = KNNBasic(k=40, sim_options={ 'name': 'cosine', 'user_based': False, # False 表示 ItemCF }) results = cross_validate(algo, data, measures=['RMSE', 'MAE'], cv=5) print('RMSE:', results['test_rmse'].mean()) print('MAE:', results['test_mae'].mean())

Surprise 的Reader需要声明折行格式,line_format里四个字段名要和u.data的列顺序一致。user_based=False就是切到 ItemCF 模式。交叉验证的cv=5会做五次随机切分,比上面用时间切分多了一个对比视角:随机切分的 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替换成景点 ID,rating换成用户对景点的打分或行为次数(比如收藏、浏览时长归一化成 1-5),矩阵和公式都不用改。但有两个落差要认清:旅游数据的稀疏度远比电影数据高,一个用户一辈子去过的景点不会超过几十个,而电影可以看几千部;这时 UserCF 很难找到可靠的邻居,我一般会优先改用 ItemCF,因为景点之间的相似度可以由所有用户的行为共同支撑,比用户与用户之间的口味匹配更稳。

另外,旅游场景天然带有地域性,协同过滤算出的相似景点可能在同一个城市,也可能跨省。这时候可以在相似度公式里乘一个距离惩罚因子,比如把余弦相似度乘上一个exp(-distance/lambda),lambda 取几百公里。MovieLens 里没有这种时空字段,但从源码结构看,只需要在predict_rating的加权阶段多乘一维权重,改动成本很低。

5.3 验证推荐系统的进阶技巧:按时间滑窗回测

最后分享一个验证技巧——按时间滑窗回测。前面的split_by_time只有一次切分,只验证了一个时间点。更可靠的评估是多次切分:比如以一个月为步长,连续切三次,每次都重新算相似度、重新预测,得到三组 RMSE 再取平均。这个做法的好处在于可以看到模型在用户偏好漂移时的稳定性,而不是撞大运似地只验证一个时间窗口。

def rolling_validate(df, window_days=30, steps=3): 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 数据集的协同过滤算法真正落地了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 4:16:26

大模型流式输出前端实现:ReadableStream与性能优化实战

1. 从“打字机效应”说起&#xff1a;为什么大模型的回答总像在敲键盘&#xff1f;你有没有试过在 ChatGPT 或国内某款主流大模型网页端提问后&#xff0c;盯着输入框下方那行文字——它不是“唰”一下整段弹出来&#xff0c;而是像老式打字机一样&#xff0c;“嗒…嗒…嗒…”…

作者头像 李华
网站建设 2026/9/11 4:13:25

制造企业从传统报表到大数据分析的转型路径

我经常听到制造型企业的管理者说一句话&#xff1a;“报表不是没有&#xff0c;但总觉得差点意思。”工厂里ERP能导出库存报表&#xff0c;MES能拉出产量报表&#xff0c;财务部每个月还能做出一沓经营分析&#xff0c;但真碰上“设备为什么连续两周频繁停机”“这批产品报废率…

作者头像 李华
网站建设 2026/9/11 4:12:15

树莓派Pico ADC深度解析:校准、驱动与硬件定时实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华