☰
MovieLens数据处理与协同过滤推荐实战:从入门到避坑
2026/10/19 6:25:13 网站建设 项目流程

MovieLens 这个数据集在推荐系统领域,几乎算是教科书级的入门素材了。但很多新手第一次拿到数据包,还是会被它的文件结构、评分稀疏度、时间戳格式这类问题搞得一头雾水,本来是想练手,结果大半时间耗在数据整理上。这篇就用 Python 完整过一遍 MovieLens 数据的处理流程,从数据加载、清洗、探索性分析到构建一个最简单的协同过滤推荐,过程中会穿插实际踩过的坑和效率技巧,希望能给正在入门推荐系统或想拿真实数据练手的朋友一些参考。

1. 先搞清楚 MovieLens 数据包里到底装了什么

很多教程上来就直接pd.read_csv,其实这是个大坑。MovieLens 不是一个单一文件,而是一个结构化数据包,不同版本字段和文件组织方式差别不小,拿到手先别急着写代码,花几分钟把目录结构摸清楚,后面能省掉大量返工时间。

1.1 常见版本的数据组织和差异

目前网络上最容易下载到的是 ml-latest-small 和 ml-25m 这两个版本。ml-latest-small 大概只有 1MB 左右,包含 600 个用户对 9000 多部电影的 10 万条评分记录,非常适合做算法验证和快速迭代。ml-25m 则是 25 万用户对 6 万多部电影的 2500 万条评分,光加载就要占用几个 GB 内存,适合做分布式计算或者深入研究用户行为,但不适合普通笔记本直接全量处理。

数据包解压后,核心文件就这几个:movies.csv存的是电影 ID、标题和类型标签,ratings.csv存的是用户 ID、电影 ID、评分和时间戳,tags.csv存的是用户自定义标签。这里有个小细节容易被忽略,movies.csv里的电影 ID 和ratings.csv里的 movieId 是对应的,但不同版本的数据集之间 ID 不具备一致性。比如说 ml-latest-small 里的 movieId 1 和 ml-25m 里的 movieId 1,虽然大概率是同一部电影,但没法保证,所以混用版本的时候一定要重新做映射。

1.2 字段类型的隐含信息

ratings.csv的四个字段里,userId、movieId、timestamp 看起来都是整数,但实际处理时性质完全不同。userId 和 movieId 是类别特征,虽然用整数存储,但绝对不能当数值型数据去算均值或者距离,必须要转成字符串或者 category 类型。timestamp 是 Unix 时间戳,要转成 datetime 才能分析时间维度上的行为规律。rating 在 ml-latest-small 里是 0.5 到 5.0 的十分位浮点数,而在一些历史版本里是 1 到 5 的整数,清洗前要先用describe()看一下分布,别想当然。

2. 环境准备和数据加载的细节

2.1 只需要 pandas 和 numpy 就能起步

处理 MovieLens 这个体量的数据,不需要一开始就上 Spark 或者 Dask。pandas 加 numpy 足够覆盖 95% 的场景,等到了 ml-25m 级别,再用 dask 或者 modin 做并行化也不迟。我个人的建议是先把 pandas 用熟,因为无论是做特征工程还是后续接入 sklearn 或 surprise 库,pandas 的 DataFrame 都是标准接口。

安装环境用 conda 或者 venv 都行,建议单独建一个环境,不要和系统 Python 混在一起。推荐用 conda 创建,因为 numpy、pandas 这些科学计算库的二进制依赖多,conda 能避免不少编译问题。

conda create -n recsys python=3.10 conda activate recsys pip install pandas numpy matplotlib seaborn scikit-learn

版本方面,pandas 用 2.x 以上,旧版本在处理 string dtype 和 timezone 相关操作时会有一些行为差异,没必要给自己添堵。

2.2 读取时的编码和 dtype 陷阱

MovieLens 的 CSV 文件都是标准的 UTF-8 编码,但pd.read_csv有时还是会因为文件路径包含中文或者特殊字符而报编码错误。保险起见,读取时显式指定encoding='utf-8'。另外要重点处理的是movies.csv里的类型字段,它是以竖线分隔的字符串,比如"Adventure|Animation|Children|Comedy",这个后面做分类特征的时候要拆开。

读取评分数据时,一个常见的性能问题是 pandas 默认会尝试推断所有列的类型,这在数据量大时特别耗时。直接指定列类型可以显著加速:

import pandas as pd ratings = pd.read_csv( 'ml-latest-small/ratings.csv', encoding='utf-8', dtype={ 'userId': 'int32', 'movieId': 'int32', 'rating': 'float32', 'timestamp': 'int64' } )

int32 和 float32 相比默认的 int64 和 float64,内存占用直接减半,对 25M 数据集来说就是 500MB 和 250MB 的差别。加载完成后顺手看一眼ratings.head()和ratings.info(),确认数据形状和字段类型符合预期。

注意:如果后续接 sklearn 的模型,float32 有时会被部分算法拒绝,需要转成 float64。在做这类转换时提前规划好,避免临时抱佛脚。

3. 数据清洗和探索性分析的实操环节

3.1 空值、重复值和异常值的筛查思路

MovieLens 的数据质量其实很高,毕竟是学术界长期维护的数据集,但也不能完全信任。主要的脏数据来源反而是用户自己导出的副本搞乱了,或者合并其他数据集时产生的问题。常规筛查步骤是:

先查空值。ratings.isnull().sum()正常结果应该是全 0,如果出现空值,多半是合并错了行。再查重复值。评分记录理论上同一个用户对同一部电影最多出现一次,用ratings.duplicated(['userId', 'movieId'], keep=False).sum()查重复对,如果发现有重复,说明数据源有问题,需要决定保留最后一次评分还是取均值。最后查异常值。评分范围是 0.5 到 5.0 的十分位浮点数,用ratings['rating'].min()和max()快速验证。

还有个隐蔽的问题:movies.csv里某些电影的标题年份是缺失的,比如"A Night at the Opera (1935)"这种字符串,真正处理时是从标题里正则提取年份的,但有些条目根本没有年份后缀。这种情况在后续分析制作时间趋势图时会直接报错,处理方式是保留 NaN 而不是强转 0。

3.2 评分分布和用户活跃度分析是必做项

数据清洗之后先别急着建模,花十分钟看一眼基本分布,能帮你理解这个数据集到底长什么样,也能发现很多算法层面的问题。

评分分布是最直观的。用ratings['rating'].value_counts().sort_index()画出直方图之后,你会看到一个非常典型的右偏分布,4 分左右是峰,1 分和 0.5 分相对较少。这说明用户倾向于打分偏正面,直接训练回归模型的话,即使什么都不做直接预测均值,也能拿到还不错的 RMSE。这也是为什么在推荐系统领域,排序指标(Precision/Recall)往往比回归指标(RMSE/MAE)更有指导意义。

用户活跃度分布同样值得深挖。每个用户的评分次数直接决定了这个用户在协同过滤里的行为可信度,只看过三五部电影的用户,你通过他很难找到可靠的邻居。实操时可以用ratings.groupby('userId').size()得到每个用户的评分数量,然后看分位数。一般来说,评分次数少于 20 的用户贡献了数据集里相当比例的记录,但个体信噪比极低。做线下评估时,可以考虑过滤掉这部分用户来验证算法,让评估结果更能反映真实场景。

3.3 稀疏度计算:一句话解释推荐难度

得分率 = 已有评分记录数 / (总用户数 * 总电影数),这个指标在 MovieLens 里差不多就是 1% 到 2% 的水平,取决于版本。10 万条评分放在 600 用户乘以 9000 部电影的表达矩阵里,意味着矩阵里 98% 以上的格子是空的。

这个数字的意义是:用一个稠密矩阵去存这玩意,空间浪费极其严重,直接用 600 x 9000 的普通二维数组在 ml-latest-small 里还算能撑住,但到了 ml-25m 就是 25 万乘 6 万的规模,需要 150 亿个浮点数,算下来上百 GB,完全没有可行性。所以后续构建协同过滤,要么用稀疏矩阵格式(scipy.sparse),要么用基于 dict 的结构,不要直接上 numpy 二维数组。

4. 构建一个最简单的协同过滤推荐模型

4.1 基于物品的协同过滤(ItemCF)的完整实现

协同过滤的核心假设是:喜欢过同一批物品的人,品味相似。ItemCF 换了个角度,转而挖掘物品之间的共现关系:如果两个电影经常被同一批用户高评分,那它们就是相似的。这个思路在用户量远大于物品量时特别实用,因为你需要在用户之间做相似度计算的复杂度会很高。MovieLens 的场景下,用户数量多、电影数量相对少,用 ItemCF 在计算效率上明显优于 UserCF。

完整实现的步骤可以拆成四步:

第一步:构建用户-物品评分矩阵。

不需要搞一个 DataFrame 的透视表,透视表处理 10 万级以上的数据时性能很差。直接用 scipy.sparse 的 COO 格式构建,然后转成 CSR 格式方便后续做行/列计算:

import numpy as np from scipy.sparse import coo_matrix, csr_matrix users = ratings['userId'].astype('category').cat.codes.values movies = ratings['movieId'].astype('category').cat.codes.values scores = ratings['rating'].values matrix = coo_matrix((scores, (users, movies)), shape=(users.max() + 1, movies.max() + 1)) matrix = matrix.tocsr()

第二步:计算物品之间的相似度矩阵。

ItemCF 中,相似度计算最常用的是余弦相似度。两个电影向量的余弦相似度公式是:两个评分向量的点积除以两个向量模长的乘积。向量是哪些要素呢?在原始 Rating 矩阵中,每部电影就是一个列向量,行是用户对该电影的评分,没有评分的用户位置是 0。

实际应用中为了避免热门电影被过度放大(因为热门电影的模长大,余弦相似度天然偏高),通常会加一个热门惩罚逻辑。加惩罚后的公式里多了一个 IDF(逆文档频率)因子,计算公式为1 / log(1 + 电影被评分的用户数)。这个因子能显著降低那些人人都看过的电影的权重,让它不容易和所有电影都产生高相似度。这种加权逻辑有点类似搜索引擎的 TF-IDF,但惩罚方向略有不同。

代码实现思路是:先归一化评分矩阵的每个列向量,然后做矩阵乘法,每列乘以对应的 IDF 权重,再乘以其转置。用 numpy 和 scipy 可以一两行解决:

n_movies = matrix.shape[1] item_popularity = np.asarray(matrix.astype(bool).sum(axis=0)).flatten() idf = np.log(1.0 + (matrix.shape[0]) / (1.0 + item_popularity)) diag_idf = np.diag(idf) item_sim = diag_idf @ matrix.T @ matrix @ diag_idf

注意:这种写法的话,item_sim的行数和列数等于电影数,在 ml-latest-small 里是 9000 乘 9000,浮点数存储大概是 600MB,勉强可接受。到了 ml-25m 上就不能用这种稠密矩阵方式了,得想别的办法,留到后面第五节讨论。

第三步:为每位用户生成 Top-N 推荐列表。

有了相似度矩阵,给用户推荐的做法是:找到该用户评分过的所有电影,对每部电影找出与其最相似的 K 部电影,把候选电影的得分加权汇总,剔除已评分的电影,最后按得分为用户排 Top-N。权重就是用户对原电影的评分乘以两部电影的相似度,这样跟该电影相似度越高、用户本身对它评分也越高,推荐出来的电影产品就越贴近偏好。

4.2 评估推荐效果时到底该看哪些指标

推荐系统评估和分类任务不同,不能只看一个准确率。线下评估最常用的是 Precision@K 和 Recall@K。具体做法是:把每个用户的评分记录按时间戳排序,取前 80% 做训练,后 20% 作为测试集。推荐模型只用训练集数据训练,然后验证集里出现过的电影,判断它是否出现在推荐列表的前 K 位中。Precision@K 是推荐列表里有多少是用户实际看过的,Recall@K 是用户实际看过的电影有多少被推荐出来了。

这里要注意一个细节:MovieLens 的评分是显式反馈,用户看电影之后才打分,所以测试集里的电影代表用户确实看过且打了分,但训练集里没被观察到的电影不代表用户一定不喜欢,可能只是没看过。这就导致 Recall@K 天然被低估,一定要记住这个解释局限。

实际操作时,用 Python 写整套评估流程不算难,核心逻辑如下:

train_users, test_labels = [], [] for user, group in ratings.groupby('userId'): group = group.sort_values('timestamp') split_idx = int(len(group) * 0.8) train_users.append((user, group.iloc[:split_idx])) test_labels.append((user, set(group.iloc[split_idx:]['movieId'])))

然后对每个用户跑推荐,看推荐列表里有多少电影命中了测试集。整个过程可以并发,用多进程可以显著加速。

写的时候有两个坑务必避开。第一个坑是相同电影 ID 在训练集和测试集中的表示必须一致,上面用astype('category')编码时要注意统一编码表。第二个坑是物品相似度矩阵要按相似度降序只保留每个电影的 Top-50 相似电影,否则做推荐时会引入大量相似度很低的噪声,而且计算量大增。Top-50 过滤后不仅精度通常会变好,内存占用也会大幅下降。

4.3 评分预测和排序推荐是两条路线

co 协同过滤能做的事情分为两类:预测用户会对某部电影打出多少分,以及为用户排出一个最适合看的电影序列。前者用均方根误差(RMSE)评估,后者用上面提到的 Precision/Recall 评估。这两个目标的侧重点完全不同,甚至可能互相矛盾。预测分数很准的模型不一定会推荐出用户真正想看的电影,因为用户给 3 分的电影也可能愿意看,给 5 分的电影也可能因为已经看过而不再适合推荐。做推荐系统时建议明确自己的目标:如果做的是“猜你喜欢”,以排序指标为准更合理。

5. 踩坑记录和调优经验速查

5.1 新手最容易翻车的几个操作

错误一:电影类型多标签字段直接当字符串存入模型。

movies['genres']是竖线分隔的多标签字符串,直接拿来算距离或训练是行不通的。常见的处理方式是拆分成多个 One-hot 特征列,每个类型一列,0/1 表示是否包含。但 MovieLens 的类型之间是有层级关系的,比如 Film-Noir 也算是某种类型的子类,直接拆可能丢失语义信息。好在 MovieLens 的数据相对干净,不需要做层级推断,直接用 pandas 的str.get_dummies('|')就够了。

错误二:把所有用户都纳入训练集。

前面提到过,评分数量极少的用户对模型的提升有限,反而会带来两类问题。第一,ItemCF 里这些用户的评分记录构建出的相似关系很稀疏,容易形成错误的邻接关系。第二,评估阶段这些用户几乎没有测试集数据,对整体指标的贡献主要是噪声。建议做一个基础过滤,只保留评分次数大于 20 的用户,训练之前跑一下groupby('userId').size() > 20看一眼过滤前后的用户数和评分覆盖情况再决定。

错误三:不做时间维度的划分就随机切分训练测试集。

推荐系统里的数据有时序性,用户的品味会漂移。很多人习惯直接train_test_split(random_state=42),但这样做等于默认用户今天是喜欢什么明天还喜欢什么,这基本不成立。正确做法是按时间戳排序后再划分,而且划分边界要加一个小的时间间隔用来模拟真实推荐场景的时效性。我在实际做评估时,会刻意让训练集和测试集之间隔开一段时间,这样线上部署时用户看到的推荐,才可能跟线下的评估结果接近。

5.2 处理大版本数据时的降载技巧

ml-25m 的数据加载一次就需要几个 GB 内存,这时候不要把所有字段全载入 DataFrame。先只用pd.read_csv的usecols参数,只加载需要的列,正常情况下根本不需要 timestamp 的精度到秒级。更大的优化点是相似度矩阵的计算。25 万用户乘以 6 万电影,想要算出完整的物品相似度矩阵需要 6 万乘 6 万,约 36 亿个浮点数,占用约 14GB 内存,大部分电脑都扛不住。

应对方法有两种:第一种是降采样,比如只取评分次数大于某个阈值的用户或电影做子集计算。第二种是分布计算,确实需要全量相似度时,用分块矩阵乘法或者近似最近邻算法来算。对新手来说,优先用降采样,在 20 万到 30 万评分记录的子集上做算法验证,效果基本能代表全量,而计算时间能缩短一两个数量级。

5.3 冷启动问题的现实解法

MovieLens 数据里还有一个经典问题常被新手忽略:新用户或新电影完全没有评分记录。这会直接影响协同过滤模型,因为它完全没有可用的评分规律来工作。实际系统中解决冷启动的常规思路是引入内容特征或用户画像。比如新电影类型标签、导演等信息可以直接利用相似类型电影的历史表现做推荐;新用户还没产生评分时,可以让用户先选择偏好标签或做一些热门推荐。MovieLens 的数据集本身没有用户画像信息,但你可以用movies.csv里的类型特征构建一个基于内容的基线模型,跟协同过滤模型做加权融合,整个玩法就更接近工业级方案了。

6. 让推荐效果更贴近真实环境的改进方向

基础 ItemCF 跑通之后,你会发现效果还可以,但离“能上线的推荐系统”还有距离。几个个人认为值得继续深挖的方向,列出来供参考。

评分的时间衰减是第一个值得考虑的因素。两个用户在 2010 年都看过某部电影,和两个用户在昨天都看过某部电影,对当下兴趣的表达权重应该是不同的。给评分加上时间衰减因子,比如相似度计算时用exp(-delta_days / half_life)做加权,可以让推荐结果更贴近用户当前口味。

第二个方向是矩阵分解类的模型,比如 SVD、SVD++、ALS。基于用户的显式评分做矩阵分解,通常可以在 RMSE 上比传统的 ItemCF 提升 5% 到 10%。但需要强调的是,这些模型对稀疏表达和处理缺失值的假设比较敏感,训练时的调参成本比 ItemCF 要高很多,对新手来说容易陷入调参泥潭。

第三个方向是引入隐式反馈信号。MovieLens 的数据只有显式评分,但实际推荐场景里更多的信号是隐式的:用户看了多长时间、点了多少次、收藏了没有。如果手头有类似的数据,可以构建一个隐式反馈矩阵,单独做召回,再和显式排序模型做级联。在工业界这套组合拳才是主流。单靠 MovieLens 的显式评分做出来的算法,离直接上线通常还有一段距离,但作为理解流程的主线非常适合。

还有一个容易被忽略的工程细节是序列化保存模型结果。物品相似度矩阵计算完成后,不要每次都重新算,用np.save或者joblib.dump存下来,下次直接加载。MovieLens 的数据更新频率并不高,这种缓存策略能节省非常多的重复计算时间。

用 MovieLens 数据集练手,最大的好处是它足够干净,让你把注意力集中在算法和数据处理本身,而不是像真实业务数据那样需要花费 70% 以上时间在枯燥的脏数据治理上。但它也不是完全没有挑战,掌握了上述处理方式和避坑点,后面换到其他数据集时,你至少知道问题大概会出现在哪些地方,排查起来会有方向得多。就我个人的体会来说,把这一步做得足够扎实,比多刷十个模型要重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询