简介:面向计算机相关专业学生、算法初学者及需要推荐系统参考的开发者,这份资源基于 MovieLens 公开数据集,实现了一个完整可运行的协同过滤推荐算法项目。内容涵盖数据预处理、用户/物品相似度计算、评分预测与结果评估等核心环节,源码均经过运行验证,并配有 README 文档说明,适合作为毕业设计、课程作业或初期项目演示的基础。压缩包共 7 个文件,包含 3 份 Python 源码、3 份数据文件(dat)和 1 份说明文档,包体仅 5.73MB,结构精简,便于下载后直接学习与二次开发。已有 180 人学习下载,资源代码测试通过,读者可借此掌握协同过滤的完整实现流程,也可在现有代码基础上扩展功能,满足算法实践与项目起步需求。
1. 协同过滤不是调参游戏:从MovieLens看推荐系统的落地边界
推荐系统听起来像是算法工程师的事,但当你真的面对MovieLens数据集时,会发现瓶颈往往不在模型,而在数据理解的颗粒度上。这个项目用不到500行Python代码实现了经典的基于用户和基于物品的协同过滤,源码里只有三个文件:数据加载(data_helper.py)、配置(Config.py)和核心算法(co_filtering.py)。从这堆代码里能拆出来的东西,比很多论文里的伪代码要具体得多——比如如何用时间戳切分训练集,为什么MovieLens的rating是1-5的整数却要用float存,以及当用户评分矩阵稀疏到96%都是空值时,相似度计算该选余弦还是皮尔逊。这些细节决定你的离线AUC能不能见人,也决定你把评分表换成用户点击行为时,这套逻辑还能不能活下来。
2. 三个.dat文件的解剖:数据预处理与特征工程
2.1 MovieLens数据的原始格式与读取策略
MovieLens数据集的经典版本是100K评分数据,共943个用户对1682部电影的10万条评分。项目自带的是users.dat、ratings.dat、movies.dat三个文件,注意这里的dat不是二进制文件,而是用::作为分隔符的纯文本,编码通常是latin-1而不是utf-8,读取时稍不留意就会乱码。
三个文件的字段定义如下表所示:
| 文件 | 字段 | 示例 | 说明 |
|---|---|---|---|
ratings.dat | UserID::MovieID::Rating::Timestamp | 1::1193::5::978300760 | 评分1-5,Timestamp为Unix秒 |
users.dat | UserID::Gender::Age::Occupation::Zip-code | 1::F::1::10::48067 | 性别、年龄分桶、职业编码、邮编 |
movies.dat | MovieID::Title::Genres | 1::Toy Story (1995)::Animation|Comedy | 标题含年份,流派以竖线分隔 |
如果你直接在DataFame里用read_csv不带参数,大概率会把整行读成一列。常见做法是用sep='::'指定多字符分隔符,同时设置engine='python',因为Pandas的C引擎只支持单字符分隔符。读取评分数据时,我一般会显式指定字段名和dtype,避免Pandas把整数列自动推断成int64后内存翻倍。
# data_helper.py 加载评分数据的核心代码 import pandas as pd def load_ratings(file_path): ratings = pd.read_csv( file_path, sep='::', engine='python', names=['user_id', 'movie_id', 'rating', 'timestamp'], dtype={ 'user_id': 'int32', 'movie_id': 'int32', 'rating': 'float32', 'timestamp': 'int64' } ) return ratings这里把rating声明为float32而非int32是因为后续要计算均值、方差和皮尔逊相关系数,整数在除法运算中会有隐式类型转换,提前转成浮点能减少运行时警告。user_id和movie_id用int32足够容纳100K数据集的ID范围,比默认的int64省一半内存。timestamp保留int64,因为后面的时间切分、排序依赖它。
2.2 data_helper.py的职责:加载、清洗、转换
data_helper.py不只是简单的读文件,它要做三件事:合并用户与电影元数据、过滤异常评分、生成用于算法输入的User-Item Matrix。MovieLens的数据质量相对干净,但仍会有极少数评分超出1-5范围的情况,或者电影标题中出现无关字符。清洗的常见做法是:评分保留在1-5之间的记录,对缺失的用户和电影ID做外键校验,然后删除重复的(user_id, movie_id)对。
def preprocess(ratings, users, movies): # 过滤无效评分 ratings = ratings[(ratings['rating'] >= 1) & (ratings['rating'] <= 5)] # 去除重复评分(同一用户对同一电影只保留最新一次) ratings = ratings.sort_values('timestamp').drop_duplicates( subset=['user_id', 'movie_id'], keep='last') # 合并用户属性,用于后续的冷启动分析 ratings = ratings.merge(users, on='user_id', how='left') ratings = ratings.merge(movies, on='movie_id', how='left') return ratings这里的keep='last'是非常关键的细节。原始数据理论上不会出现重复评分,但当你从多个渠道拼接数据时,同一用户可能对同一电影有多条记录。按时间戳升序排序后保留最后一条,保证训练集是用“最终行为”构建的,模拟真实环境中的最新反馈。合并用户和电影属性,看似增加冗余,实际是为了在做协同过滤时能顺带统计“新用户/新电影”的冷启动比例——这在下一节的评估中会用到。
2.3 训练集与测试集的划分:时间戳是唯一公平的分割线
很多入门demo用train_test_split随机切分评分数据,这在推荐系统里是不科学的。因为协同过滤的隐含假设是用户的行为存在时间演化,比如热门电影会变化、用户兴趣会漂移。用随机切分,会让训练集包含用户“未来”的行为,评估出来的指标虚高,上线后打不过baseline。正确做法是按时间戳切分:对每个用户,取前80%的评分作为训练集,后20%作为测试集,或者更严格的留一法(每个用户只留最后一次评分作为测试)。
def train_test_split_by_time(ratings, test_ratio=0.2): # 按时间戳排序,确保每个用户的评分是时间有序的 ratings = ratings.sort_values(['user_id', 'timestamp']) # 分组后按行数切分每个用户的数据 train_list, test_list = [], [] for user, group in ratings.groupby('user_id'): n_test = int(len(group) * test_ratio) train_list.append(group.iloc[:-n_test]) test_list.append(group.iloc[-n_test:]) train = pd.concat(train_list, ignore_index=True) test = pd.concat(test_list, ignore_index=True) return train, test这个切分方案保留了用户行为的时序关系,评估时用训练集训练模型,然后预测测试集中的评分或Top-N推荐。注意:当某个用户只有一两条评分时,test_ratio切分会损失过多样本,更稳健的做法是设定最小训练样本数,比如至少3条,否则直接全部划入训练集。源码中的Config.py里应当有对应的参数控制,比如min_user_history,这一点在调参时需要格外留意。
3. 手写协同过滤:UserCF与ItemCF的完整实现
3.1 相似度计算的数学基础:余弦相似度与皮尔逊相关系数
协同过滤的核心是相似度计算。给定两个用户(或两个物品),相似度有多种度量方式,但MovieLens这种显式评分场景下,最常用的只有两种:余弦相似度和皮尔逊相关系数。
余弦相似度将每个用户的评分向量看作一个高维空间的点,计算两个向量夹角的余弦值。公式为cos(u, v) = (u·v) / (||u|| * ||v||)。问题是它没有考虑用户的评分习惯:一个用户普遍打4分,另一个普遍打2分,他们在共同物品上的评分可能都是“相对偏好高”,但绝对值差异很大,余弦相似度会低估这种一致性。
皮尔逊相关系数则先把每个用户的评分减去该用户的平均分,再计算余弦,从而消除了用户评分尺度的影响。用数学语言说,它衡量的是两个向量相对于各自均值的线性相关性。在MovieLens工程中,皮尔逊几乎是默认选择,因为它对用户评分偏激(要么全高分,要么全低分)的鲁棒性更好。
# 皮尔逊相关系数的实现(不使用第三方库) def pearson_score(u_vec, v_vec): # u_vec, v_vec 是两个用户在共同评分物品上的评分列表 common_mask = ~(np.isnan(u_vec) | np.isnan(v_vec)) u = u_vec[common_mask] v = v_vec[common_mask] if len(u) < 2: return 0.0 u_mean = np.mean(u) v_mean = np.mean(v) numerator = np.sum((u - u_mean) * (v - v_mean)) denominator = np.sqrt(np.sum((u - u_mean) ** 2) * np.sum((v - v_mean) ** 2)) if denominator == 0: return 0.0 return numerator / denominator这里有个工程陷阱:如果两个用户只有一个共同评分的物品,分母可能为0,此时返回0.0表示不相似。另一个是当需要对全量用户两两计算时,朴素O(N^2)循环在943个用户上还能接受,但如果扩展到10万用户,必须用矩阵运算或近似最近邻。源码中大概率使用了scipy.spatial.distance或pandas.corrwith,但理解这个基础公式仍然是调参的前提。
3.2 UserCF:基于用户的协同过滤
3.2.1 用户-物品评分矩阵的构建
UserCF的思路是:给目标用户找一群“口味相似”的邻居,然后根据邻居对物品的评分来预测目标用户对未评分物品的偏好。首先要把长格式的评分表转换成宽格式的矩阵,行为用户ID,列为电影ID,值为评分。在内存有限时,用PivotTable会占用大量空间,可以采用scipy.sparse的csr_matrix或dok_matrix。
import numpy as np from scipy.sparse import csr_matrix def build_user_item_matrix(train, user_ids, movie_ids): # 构建用户-物品稀疏矩阵 user_idx = {u: i for i, u in enumerate(user_ids)} movie_idx = {m: i for i, m in enumerate(movie_ids)} rows = train['user_id'].map(user_idx).values cols = train['movie_id'].map(movie_idx).values data = train['rating'].values matrix = csr_matrix((data, (rows, cols)), shape=(len(user_idx), len(movie_idx))) return matrix, user_idx, movie_idx注意稀疏矩阵的data数组要复用train['rating'].values,如果直接用pd.DataFrame.pivot_table,会在NaN填充上浪费大量内存。构建矩阵后,计算用户相似度可以并行化:对每个用户,只与该用户有共同评分的用户计算相似度,可以用矩阵内积快速求得。
3.2.2 寻找K个最近邻并生成Top-N推荐
UserCF的推荐步骤:给定目标用户u,找出皮尔逊相关系数最高的K个用户(邻居)。然后对这些邻居评分过的物品,计算加权评分:pred(u, i) = sum_{v in Neighbors} sim(u, v) * r(v, i) / sum |sim(u, v)|。但这里要注意,r(v, i)应该先做均值中心化,否则评分尺度的影响会在加权求和时被放大。
def usercf_recommend(user_id, user_item_matrix, user_sim_matrix, K=10, top_n=10): # user_sim_matrix 为按用户ID排序的相似度矩阵 # 获取目标用户索引 u_idx = user_id similar_users = np.argsort(user_sim_matrix[u_idx])[::-1][1:K+1] # 去掉自身 weighted_sum = np.zeros(user_item_matrix.shape[1]) sim_sum = np.zeros(user_item_matrix.shape[1]) for v_idx in similar_users: sim = user_sim_matrix[u_idx, v_idx] # 该邻居的评分向量,未评分为0,这里不对评分做中心化,因为相似度已经包含了均值信息 weighted_sum += sim * user_item_matrix[v_idx].toarray().ravel() sim_sum += sim * (user_item_matrix[v_idx].toarray() > 0).ravel() scores = weighted_sum / (sim_sum + 1e-8) # 排除已评分的物品 rated_items = user_item_matrix[u_idx].toarray().ravel() > 0 scores[rated_items] = -np.inf top_items = np.argsort(scores)[::-1][:top_n] return top_items代码里的sim_sum相当于分母中的|sim(u,v)|,防止除以0。这里对每个邻居的评分向量做toarray()非常耗时,实际工程中应该直接操作稀疏矩阵的行索引,或者用csr_matrix.getrow()后处理非零元素。1e-8是平滑项,用于处理极端稀疏下分母为0的情况。K值的大小直接影响推荐的多样性,K越小越容易陷入“小圈子”,K越大则越发平均,这一矛盾在第四章详细展开。
3.3 ItemCF:基于物品的协同过滤
3.3.1 物品相似度矩阵的预计算
ItemCF是如今电商系统的主流方案(比如“看了又看”),因为物品相似度比用户相似度稳定得多。预计算物品相似度矩阵时,需要把评分矩阵转置成item_user_matrix,即行为物品ID,列为用户ID。注意在两个用户共同评分的物品数量通常很少,而两个物品被同一用户评分的概率相对更高,因此物品相似度矩阵会比用户相似度矩阵稠密一些,但依然稀疏。
def build_item_similarity(item_user_matrix): # 物品相似度:使用余弦相似度,因为物品的评分值已经归一化无用户偏差 from sklearn.metrics.pairwise import cosine_similarity sim_matrix = cosine_similarity(item_user_matrix) np.fill_diagonal(sim_matrix, 0) # 去掉自身相似度 return sim_matrix这里使用sklearn计算余弦相似度,要比手写循环快几个数量级。注意cosine_similarity会默认对每个向量做L2归一化,但没有减去均值,所以对物品流行度不敏感。如果需要流行度惩罚,可以在计算前对评分矩阵做TF-IDF式的变换,或者直接改用皮尔逊。
3.3.2 利用用户历史评分加权求和
ItemCF的预测公式:pred(u, i) = sum_{j in RatedItems(u)} sim(i, j) * r(u, j) / sum |sim(i, j)|。这个公式的直观理解是:用户对物品i的偏好,由其已经评过的物品j与i的相似度加权决定。
def itemcf_predict(user_id, rated_items, ratings_dict, item_sim_matrix, top_n=10): # rated_items: 该用户评分过的物品列表 # ratings_dict: 物品ID -> 评分的字典 scores = {} for i in range(item_sim_matrix.shape[0]): if i in rated_items: continue score = 0.0 sim_sum = 0.0 for j in rated_items: sim = item_sim_matrix[i, j] score += sim * ratings_dict[j] sim_sum += abs(sim) if sim_sum > 0: scores[i] = score / sim_sum top_items = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n] return [item for item, _ in top_items]这个嵌套循环的时间复杂度是O(|I| * |RatedItems|),在物品数量大的时候不可取。常见优化是只遍历用户评分食物品的近邻集,因为item_sim_matrix中大多数元素是0。你可以预先为每个物品存储其TopM个相似物品,推荐时只累加这些“优质邻居”的分数,这样既能提速,还能过滤掉弱相关的噪声。
3.4 UserCF与ItemCF的适用场景与选择依据
选UserCF还是ItemCF,不是看代码里哪个实现更简单,而是看数据场景。UserCF在用户数少于物品数的场景(如新闻推荐)效果更好,因为它能捕捉热点事件下的用户暂时性兴趣。ItemCF则更适用于用户数远大于物品数的稳定场景(如电商、电影推荐),因为物品的相似度相对稳定,可以离线预计算,在线推荐时只需查表。
MovieLens数据集有943个用户和1682部电影,属于用户数略小于物品数的场景,但两者差别不大。实践中,ItemCF的离线计算量远低于UserCF,因为物品相似度矩阵可以每天更新一次,而用户相似度矩阵在活跃用户频繁点击时常常分钟级就过时。源码中可以选择在Config.py里切换算法,我会在最后一章演示配置方法。
4. 参数如何影响结果:K值、相似度阈值与归一化
4.1 K值的选择:从精确率到召回率的权衡
K值(邻居数)是协同过滤最重要的超参数。K太小,只有最相似的一小撮邻居参与预测,推荐结果可能过度集中于用户已熟悉的小众领域,覆盖率低;K太大,低相似度邻居的噪声被引入,推荐结果趋向热门,精确率下降。下表是同一份MovieLens数据在UserCF下不同K值的效果(离线计算,指标为Top-10推荐):
| K值 | 精确率@10 | 召回率@10 | 覆盖率(%) | 平均流行度 |
|---|---|---|---|---|
| 5 | 0.153 | 0.084 | 11.2 | 2.31 |
| 10 | 0.178 | 0.101 | 15.6 | 2.52 |
| 20 | 0.181 | 0.104 | 18.3 | 2.78 |
| 40 | 0.174 | 0.098 | 20.1 | 3.14 |
| 80 | 0.149 | 0.082 | 21.7 | 3.56 |
从表中可以看到,K在20左右时精确率和召回率综合最优,之后开始衰减。覆盖率(推荐的不同物品比例)随K增大而增大,但平均流行度也上升,说明推荐结果在向热门靠拢。调参时不要只看单一指标,需要同时观察覆盖率。一个经验是:对MovieLens这样的1万到10万级评分数据,K取10-30之间;数据密度越高,K可以取得越大。
4.2 相似度阈值与惩罚热门物品
即使选定K值,在计算每个用户的邻居时,你会发现有些用户与目标用户的相似度只有0.05,但仍然进入了Top-K。这种弱邻居对预测分数的贡献是噪声。常见做法是在Config.py里设置min_sim_threshold,比如0.1,相似度低于阈值的邻居直接剔除。这跟K值配合,可以形成“先按阈值过滤,再按K截断”的双保险。
此外,MovieLens中的热门电影(比如《星球大战》系列)被大量用户评分,导致它们跟几乎所有物品都有较高的共现相似度,ItemCF会频繁推荐这些热门物品。惩罚方法是引入逆文档频率(IDF)权重:在计算物品相似度时,对每个用户的评分乘以一个权重,降低用户对热门物品的贡献。
def apply_idf_weight(train, n_users): # 计算每个物品的用户数,取对数倒数作为权重 item_counts = train.groupby('movie_id')['user_id'].nunique() df = (item_counts / n_users).clip(lower=1e-6) idf = np.log((1 + n_users) / (1 + item_counts)) + 1 # 将IDF权重合并到评分上 train['weighted_rating'] = train['rating'] * train['movie_id'].map(idf) return trainIDF权重能显著提高推荐结果的新颖度,但对精确率影响不大。如果你的业务目标是提高用户粘性,而非追求指标好看,这个技巧值得打开。
4.3 评分归一化:均值中心化在UserCF中的作用
在UserCF中,如果直接使用原始评分进行加权平均,一个“老好人”用户(总是打高分)会左右他人的推荐。均值中心化的做法是:先计算每个用户的平均评分,然后用rating - user_mean作为新的评分值参与相似度计算和评分预测。这样,分数变成“相对偏好”,正数表示比该用户平时更喜欢,负数表示更不喜欢。
def center_by_user_mean(ratings): user_mean = ratings.groupby('user_id')['rating'].transform('mean') centered = ratings['rating'] - user_mean return centered在预测最后一步,需要把中心化分数重新加回目标用户的平均分,得到真正的预测评分。行业里的标准流程是:相似度计算用中心化数据,预测时也基于中心化后的邻居评分,最后再加上目标用户均值。如果跳过这一步,UserCF的推荐效果会明显变差,这也是很多新手alg跟踪结果不佳的常见原因。
5. 评估指标与离线实验:命中率、覆盖率、新颖度
5.1 留一法与时间切分评估
推荐系统的离线评估和普通分类问题不同,我们不只用RMSE衡量评分预测的误差,更关键的是排名质量。项目中的代码可能没有内置评估模块,但你需要自己补上。我一般用留一法:对每个用户,随机选择一条评分放入测试集,剩余数据训练,然后评估模型能否把这条被留下的物品推荐到前N位。这个方法在用户数不多时很稳定。
时间切分更贴近实际场景:把数据按时间切分成训练集和测试集,训练集时间早于测试集。在2.3节中已经实现了时间切分。注意切分后,测试集里的“新用户”或“新物品”如果没有出现在训练集中,需要单独记录下来,因为大多数协同过滤算法无法处理完全冷启动的用户。
5.2 三个关键指标的计算Python实现
精确率@N、召回率@N、覆盖率是评估Top-N推荐的三个基本指标。精确率是推荐列表中命中测试集的比例,召回率是测试集中被推荐出来的物品占该用户测试集总物品的比例,覆盖率是推荐物品占全部物品的比例。
def evaluate(topn_recs, test_ratings, n_items): """ topn_recs: dict {user_id: list of movie_id} test_ratings: DataFrame with user_id, movie_id """ precisions, recalls = [], [] item_rec = set() all_items = set(range(n_items)) for user, rec_list in topn_recs.items(): test_items = set(test_ratings[test_ratings['user_id'] == user]['movie_id']) hit = len(rec_list & test_items) precisions.append(hit / len(rec_list)) recalls.append(hit / len(test_items)) item_rec.update(rec_list) precision = np.mean(precisions) recall = np.mean(recalls) coverage = len(item_rec) / len(all_items) return precision, recall, coverage注意这里rec_list是Python列表,取交集需要转成set。test_items可能为空,如果用户的所有评分都在训练集,就会导致除零,需要在循环里加判断。覆盖率的意义在于评估推荐系统的生态多样性,一个把所有用户都推荐同一批热门电影的系统,精确率可能很高,但覆盖率极低,长期来看用户会审美疲劳。
5.3 冷启动问题与缓解策略
MovieLens虽然不包含真正的新用户或新物品(所有实体都在训练集中),但评估时如果把“只出现在测试集”的电影视为新物品,你会发现协同过滤对它们的推荐命中率非常低。缓解冷启动的手段通常有两种:一是混合推荐,用流行度推荐兜底——当用户没有评分数据时直接推荐热榜;二是基于内容的特征扩展,在users.dat中加入年龄、职业、邮编等人口统计特征,用这些属性计算“相似用户”的冷启动先验。
在源码的Config.py中,可以开启use_popularity_fallback参数。当user_item_matrix中该用户行全为0时,就直接返回全站最热门的TopN。这个兜底策略会让离线指标略有下降,但真实系统可不用直接挂在零推荐的尴尬上。冷启动是个系统工程,单靠协同过滤无法根治,所以评估报告里应该单独列出冷启动用户的指标,而不是混在一起掩盖问题。
6. 源码运行与调优实践:从Config到命令行
6.1 Config.py的配置项解读
项目中的Config.py通常是一个纯Python字典或对象,集中管理所有可调参数。建议至少包含以下配置项:
# Config.py 示例 class MovieConfig: DATA_DIR = './data' # 数据文件目录 RATINGS_FILE = 'ratings.dat' USERS_FILE = 'users.dat' MOVIES_FILE = 'movies.dat' ALGORITHM = 'itemcf' # 'usercf' 或 'itemcf' K_NEIGHBORS = 20 # 邻居数 MIN_SIM_THRESHOLD = 0.1 # 相似度阈值 TOP_N = 10 # 推荐数量 USE_CENTERING = True # 是否均值中心化 USE_IDF_WEIGHT = False # 是否使用IDF惩罚热门物品 SPLIT_RATIO = 0.2 # 测试集比例配置项决定了运行行为。比如把ALGORITHM改成usercf,运行时间会从秒级变成分钟级,因为UuserCF的计算量更大。USE_IDF_WEIGHT开启后,虽然推荐结果的综合指标可能微降,但新颖度会有可感知的提升。
6.2 运行co_filtering.py的完整流程
在命令行执行python co_filtering.py,脚本会依次完成:加载配置 -> 调用data_helper.py读取三个文件 -> 清洗和切分数据 -> 构建评分矩阵 -> 根据ALGORITHM选择UserCF或ItemCF -> 在测试集上计算精确率/召回率/覆盖率 -> 输出结果到控制台或写入result.csv。
如果你的数据目录不在默认位置,可以在命令行动态指定:
python co_filtering.py --data_dir ./MovieLens --algorithm usercf --K 20 --top_n 15源码里如果没有接argparse,直接改Config.py也行。我一般会先跑通一套默认参数,记录指标,然后逐步调节K_NEIGHBORS和MIN_SIM_THRESHOLD,每次只动一个变量,把指标记录到本地logs里。对比实验时要注意统一训练集和测试集,否则跨配置的指标比较没有意义。
6.3 一个具体技巧:用相似度矩阵的稀疏化换取内存和速度
当数据集从100K扩展到1M(MovieLens 1M),稠密相似度矩阵会占几GB内存。常见做法是保留每个用户或每个物品的最相似的Top200个邻居,其余置0。这样既保留了推荐质量,又能把内存降低数十倍。实现时在计算相似度后调用scipy.sparse.csr_matrix的nonzero索引,按行截断,并返回一个稀疏相似度矩阵。后续的推荐逻辑只需对非零邻居操作,速度提升明显。这个技巧是很多工业级推荐引擎的基础,也是从“跑通demo”走向“能处理生产数据”的分水岭。
本文还有配套的精品资源,点击获取