用PyTorch实现协同过滤与矩阵分解:MovieLens数据集实战指南
发布时间:2026/10/5 15:43:53 锦皓数字建站

简介一份共29页的推荐系统入门PDF技术文档面向希望掌握PyTorch并动手实现协同过滤算法的学习者。资源围绕MovieLens数据集清晰拆解了从推荐系统概述、协同过滤原理基于用户与基于物品、PyTorch基础环境搭建到MovieLens数据下载、清洗与预处理、矩阵分解模型定义、损失函数与优化器选择、模型训练含梯度清零、模式切换等注意事项与评估RMSE/MAE/R²再到正则化与超参数调优等完整链路。文档大小约2.02MB包内仅有1个PDF文件支持目录章节跳转和阅读器大纲快速定位内容完整、图表清晰。目前已有70人学习浏览。读者既可据此理解协同过滤算法核心思想也能获得可直接借鉴的实现步骤、训练注意事项与优化策略非常适合高校学生、推荐算法入门者以及需要快速上手PyTorch的开发者参考。1. 推荐系统基础课里为什么PyTorch协同过滤MovieLens是绕不开的第一站打开任何一个推荐算法岗位的招聘JD几乎都写着“熟悉协同过滤、矩阵分解等经典算法”。但很多新人一上来就用深度模型刷榜单被问到“你用PyTorch实现过协同过滤吗”就卡住了——这是推荐系统基础训练里最典型的断层。这篇文章要补齐的正是这个断层上的最小完整路径基于MovieLens数据集用PyTorch把协同过滤算法从数据读取、模型手写、训练循环到指标评估完整实现一遍。目标读者是想入行推荐算法、或者手里已有深度模型却缺一个可信基线的工程师。文章少讲抽象概念多放能直接跑的命令与参数读完你手里会有一个能解释、能调参、能当作对照靶子的矩阵分解基线。2. 协同过滤算法选型用户型、物品型、矩阵分解MovieLens上该选谁2.1 三条技术路线从KNN到矩阵分解的演进逻辑协同过滤算法的核心假设很简单相似的人会对相似的东西给出相近评价。围绕“相似”的度量方式业界衍生出三条主流路线基于用户的协同过滤UserCF、基于物品的协同过滤ItemCF和基于模型的矩阵分解MF。这三条路线不是替代关系而是适用场景各不相同的平级方案。路线原理优点缺点MovieLens 100K 上的定位UserCF找与你打分习惯最像的 k 个用户聚合他们的评分容易解释、新物品冷启动友好用户量大时计算开销高、稀疏场景相似度噪声大适合做小规模对照实验ItemCF找与目标物品最相似的 k 个物品用你对这些物品的评分做预测离线可预计算、可解释性好新物品没有历史行为、覆盖度受限最常用的 top-K 基线矩阵分解把交互矩阵拆成用户/物品两个低维隐向量做点积能处理稀疏矩阵、泛化性强、可结合属性特征结果不易直接解释、调参空间大本文主线方案为什么特意先说 UserCF 和 ItemCF因为很多新手一上来就跳到神经网络连“协同过滤到底在解决什么问题”都没建立概念。UserCF 和 ItemCF 都是基于记忆的方法——它们不对数据分布做假设直接利用用户-物品共现关系算相似度矩阵分解则基于模型假设交互可以由低维隐向量解释。理解这条演进链你才清楚自己什么时候该用相似度硬算什么时候该训练一个模型。相似度计算里有一个典型误区MovieLens 的评分是 1 到 5 的连续值不少人直接拿 cosine 去算忽略了评分尺度的偏移——有人习惯打 3 分有人习惯打 4 分。常见做法是先做均值中心化每个用户的评分先减去该用户的个人均分再算 cosine得到的结果才不会被打分习惯干扰。ItemCF 同理对每个物品的评分中心化后再算物品间相似度Hit Rate 往往能提升一到两个百分点这个细节值得一上来就做对。选型还应该考虑到扩展方向。矩阵分解跑通之后常见的下一个台阶是 SVD它在隐向量之外把用户的历史交互物品也编码进去再往后是神经协同过滤NCF。但这几个变体在 100K 数据量级上的收益通常很小——SVD 相对普通 MF 的 RMSE 提升经常在 0.02 以内却要多调一批参数。入门阶段先别急着上变体把基础矩阵分解吃透后续迁移成本很低。2.2 矩阵分解凭什么比 KNN 方法更抗稀疏MovieLens 100K 包含 943 个用户对 1682 部电影的 10 万条评分。如果填成评分矩阵总格子数是 943×1682约 158.7 万个位置有评分的位置只占 6.3%——这是典型的稀疏矩阵。UserCF 和 ItemCF 在这种稀疏度下最头疼的问题在于很多用户只对几十部电影打过卡他们与其他用户的共同评分物品极少cosine 相似度算出来要么是 0要么是噪声主导的随机值。矩阵分解的思路是把“用户-物品交互”压缩到几十维的隐空间里。SVD 的数学形式写作 R ≈ U·V^TU 是用户隐向量矩阵V 是物品隐向量矩阵点积结果就是预测评分。它比 KNN 泛化更强的原因在于低维约束迫使模型从全局结构里捕捉“战争片 男性用户”这类跨样本模式而不是依赖局部的共同评分邻居。你不需要完整理解奇异值分解的推导只要记住在 PyTorch 里它就是两张 Embedding 表做点积。这里还需要跨过一个认知坎传统 SVD 要求评分矩阵完整才能做分解真实数据几乎不满足这个条件因此现代实现都是“只用已观测位置做随机梯度下降”。这正好是 PyTorch 天然擅长的场景——按 batch 取用户的交互记录算损失更新两张 Embedding 表。这也是后面第四章模型能稳定收敛的根本前提。动手选型时我习惯按三步走如果目标是快速上线且数据量在十万级直接用 ItemCF 离线算相似度表如果目标是追求精度且还要迭代特征工程选矩阵分解如果数据量到了亿级交互再考虑双塔模型加向量检索。隐向量维度不要一上来就调参固定一个 dim32 先跑通训练脚本再对比 16、32、64 三档的验证集 RMSE单次只改一个变量。“先跑通、再调参、后对比”这个顺序比任何参数技巧都重要。3. 从PyTorch安装到MovieLens数据预处理跑通最小数据管线3.1 推荐系统实验环境conda建环境、CPU版torch就够用做推荐系统实验第一步是把环境隔离干净直接在系统全局 pip install 会导致后续项目互相污染。我平时在 Ubuntu 和 Debian 上跑推荐实验最稳的组合是 conda 加 CPU 版 PyTorch。MovieLens 100K 全量只有几 MB矩阵分解训练一次也就几十秒完全没有必要上 GPU显卡资源留给特征维度高、数据量大的真实场景才合理。# 创建独立环境避免污染系统全局 conda create -n recsys python3.10 -y conda activate recsys pip install torch numpy pandas scikit-learn参数说明python 版本选 3.10 或 3.11 都可以当前主流 PyTorch 版本对这两个版本支持最好numpy 和 pandas 负责数据读取与转换scikit-learn 只在评估阶段用来算 precision 和 recall。如果你的机器有 NVIDIA 显卡且 nvidia-smi 能正常输出驱动信息可以再按 PyTorch 官网给出的命令安装对应 CUDA 版本驱动和 CUDA 版本不匹配是 pytorch 环境搭建最常见的报错来源但我们的实验用不到没必要在这一步耗时间。装完一定要验证一下python -c import torch; print(torch.__version__)能输出版本号说明 PyTorch 安装成功。这一步看着简单实际是翻车高发区最常见的坑是 conda 在创建环境时自动装了 CPU 版后来需要 GPU 才发现训练慢十倍其次是 pip 和 conda 混用把包装到了 base 环境里。我一般全程只用 pip或者全程只用 conda中途不换手。3.2 MovieLens 100K数据字段u.data是训练和验证的主文件MovieLens 100K 有多个发布版本这里用的是基础 100K 数据集。解压之后核心文件是 u.data每一行是一条评分记录字段之间用制表符分隔。u.user 和 u.item 是用户属性与电影属性表做特征实验时才会用到训练评分预测模型用 u.data 一个文件就够了。字段含义类型示例user id用户编号int196item id电影编号int242rating评分int3timestampUnix 时间戳int881250949用 pandas 读取并检查数据是第一步import pandas as pd # u.data没有表头用names显式指定列名 ratings pd.read_csv( ml-100k/u.data, sep\t, headerNone, names[user_id, item_id, rating, timestamp], ) print(ratings.shape) # (100000, 4) print(ratings[rating].value_counts())逻辑说明headerNone 是因为 u.data 没有列名sep\t 指定制表符分隔。rating 的取值范围是 1 到 5且分布明显偏斜——4 分和 5 分占比远高于 1 分和 2 分。这个分布特点会影响损失函数的选择第五章会专门展开。timestamp 是 Unix 时间戳第四章做时间切分会用到它。3.3 训练/验证切分用时间戳划分不用随机划分协同过滤里最大的隐性坑往往不在模型而在数据划分。如果直接随机切分同一个用户的评分会被同时分到训练集和验证集模型等于见过该用户的行为模式验证分数虚高。仓库里更接近线上真实情况的方案是按用户维度做时间切分每个用户的最近 N 条评分进验证集其余进训练集。train_list, valid_list [], [] for user_id, group in ratings.groupby(user_id): # 按时间排序后每个用户最近20%作为验证 group group.sort_values(timestamp) boundary int(len(group) * 0.8) train_list.append(group.iloc[:boundary]) valid_list.append(group.iloc[boundary:]) train_df pd.concat(train_list).reset_index(dropTrue) valid_df pd.concat(valid_list).reset_index(dropTrue) print(ftrain: {train_df.shape[0]}, valid: {valid_df.shape[0]})这段代码按用户逐个做时间切分保证验证集里每一条记录都晚于该用户在训练集里的所有记录。boundary0.8 表示 80% 做训练、20% 做验证如果数据量更小可以调到 0.9。要注意的是一旦确定切分比例之后所有模型对比都要沿用同一份切分否则指标差异可能来自数据分布变化而不是模型能力强弱。注意固定随机种子同样重要。PyTorch、numpy 里的随机过程都要在训练前固定 seed否则每次实验的负样本和 dropout 都不一样无法判断指标波动来自模型还是来自随机性。4. 用PyTorch从零写协同过滤矩阵分解模型、损失设计与完整训练脚本4.1 模型结构Embedding层做隐向量、偏置项做基线现在进入核心实现。矩阵分解的预测公式可以写成pred global_bias user_bias item_bias user_emb 与 item_emb 的点积global_bias 是全部评分的均值user_bias 描述某个用户打分整体偏严还是偏松item_bias 描述某部电影的口碑基线。加上三个偏置项不是锦上添花——模型可以在全局均值附近做小幅度修正收敛快RMSE 通常能下降 0.05 以上。我最早写模型时图省事省掉了 bias验证集预测均值比真实均值低 0.15排序也跟着偏移这个教训让我后来写预测公式永远先补偏置项。import torch import torch.nn as nn class MatrixFactorization(nn.Module): def __init__(self, num_users, num_items, dim32): super().__init__() self.user_emb nn.Embedding(num_users, dim) # 用户隐向量 self.item_emb nn.Embedding(num_items, dim) # 物品隐向量 self.user_bias nn.Embedding(num_users, 1) # 用户偏置 self.item_bias nn.Embedding(num_items, 1) # 物品偏置 self.global_bias nn.Parameter(torch.zeros(1)) # 小标准差初始化避免embedding值过大导致训练震荡 nn.init.normal_(self.user_emb.weight, std0.05) nn.init.normal_(self.item_emb.weight, std0.05) def forward(self, users, items): pred self.global_bias pred pred self.user_bias(users).squeeze() # 用户打分习惯偏移 pred pred self.item_bias(items).squeeze() # 物品口碑偏移 pred pred (self.user_emb(users) * self.item_emb(items)).sum(dim1) return pred参数说明dim32 是隐向量维度对应模型容量。在 MovieLens 100K 这种规模上16 到 64 之间的差异不大32 是稳妥起点调大并不能带来明显提升反而更容易过拟合。nn.init.normal_ 把初始值约束在 0.05 标准差附近是梯度稳定传导的关键——太大会震荡太小会在前几个 epoch 学得特别慢。4.2 损失函数与优化器回归问题用MSE正则化用L2电影评分预测是回归任务最直接的损失是 MSE。不少新手会把 5 档评分当成 5 个类别去套 CrossEntropy这种做法在实验里会让验证集 RMSE 停在 1.1 以上——因为模型无法表达“2.9 分已经很接近 3 分”这种连续语义。回归任务要用回归损失预测 2.9 分对真实 3 分来说就是一次合理预测。def mse_loss_with_l2(pred, rating, model, reg_lambda1e-3): mse nn.MSELoss()(pred, rating) # 对隐向量做L2约束防止过拟合 l2 sum(p.pow(2).sum() for p in [model.user_emb.weight, model.item_emb.weight]) return mse reg_lambda * l2逻辑说明l2 项把两个 Embedding 矩阵的平方和加到损失里惩罚数值过大的隐向量防止模型把某个用户的特征学成极端值去死记训练集中的个别点。reg_lambda 建议从 1e-4 到 1e-3 之间起步如果训练集 RMSE 持续下降而验证集不降反升把 reg_lambda 调大一个数量级是最快的止血操作。优化器直接用 Adam学习率 1e-3 附近即可。Adam 的自适应步长对 Embedding 这种稀疏输入非常友好SGD 在这类任务上要对学习率精确调节才可能追平。你可以在同一套数据上对比两种优化器结论通常是 Adam 基本不用调就有不错效果。4.3 完整训练脚本mini-batch、早停与RMSE验证训练脚本的主流程是把训练集包装进 DataLoader每个 epoch 跑若干 mini-batch验证集上计算 RMSE。MovieLens 100K 只有 10 万条记录内存里即可完成全部操作。下面这段代码是可以直接复制的训练主循环from torch.utils.data import TensorDataset, DataLoader train_tensor torch.LongTensor(train_df[[user_id, item_id]].values) train_label torch.FloatTensor(train_df[rating].values) train_ds TensorDataset(train_tensor, train_label) train_loader DataLoader(train_ds, batch_size256, shuffleTrue) model MatrixFactorization( num_usersint(ratings[user_id].max()) 1, num_itemsint(ratings[item_id].max()) 1, dim32, ) optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0 for (user_ids, item_ids), labels in train_loader: optimizer.zero_grad() pred model(user_ids, item_ids) loss mse_loss_with_l2(pred, labels, model, reg_lambda1e-3) loss.backward() optimizer.step() total_loss loss.item() * len(labels) model.eval() with torch.no_grad(): val_pred model( torch.LongTensor(valid_df[user_id].values), torch.LongTensor(valid_df[item_id].values), ) val_rmse ((val_pred - torch.FloatTensor(valid_df[rating].values)) ** 2).mean().sqrt() print(fepoch{epoch} train_loss{total_loss/len(train_df):.4f} val_rmse{val_rmse:.4f})参数说明batch_size256 在 CPU 上足够高效shuffleTrue 保证每个 epoch 的梯度估计不偏。epoch 设为 30 是合理起点通常 15 到 20 轮后验证集 RMSE 就不再下降此时应保存最优模型并停止训练继续跑只会过拟合。这里没有引入负采样因为 MovieLens 是显式评分数据未交互的空白位置不该被当作 0 分负例负采样是隐式反馈场景点击、购买才需要的策略两者语义不能混用。5. 协同过滤翻车避坑5个让我在MovieLens上返工的真实问题5.1 坑一随机切分导致验证集分数虚高现象我用随机切分时验证集 RMSE 漂亮地收敛到 0.88换成按时间戳切分后同一套模型参数直接跌到 0.95差距肉眼可见。原因随机切分把同一个用户的评分随机分到训练和验证相当于模型已经看过该用户大部分行为验证阶段是在“背答案”而不是在“测泛化”。尤其 MovieLens 里用户评分数少则几十条随机切分很容易让训练集覆盖到该用户几乎所有物品验证失去意义。解决所有实验统一用第 3.3 节的时间切分方案禁止随机切分进入对比流程。做一个实验前先把切分方式写进配置模型对比时共享同一份 train_df 和 valid_df。5.2 坑二评分预测当分类做用CrossEntropy训练现象我早期版本用 CrossEntropyLoss 把 5 档评分当 5 个类别训练损失降得很低但验证 RMSE 一直挂在 1.1 以上怎么调参都下不来。原因评分是有序连续的量预测 2 分和预测 4 分对真实 3 分来说都是错CrossEntropy 把误差平摊到类别上模型学不到“接近真实评分”的连续关系。分类损失的优化目标不符合回归任务的评估指标。解决换成 MSE 回归直接让模型输出连续值。这个改动让验证 RMSE 从 1.1 掉到 0.93是投入产出比最高的一次修正。顺带提一句如果以后做隐式反馈再把损失换成 BCE 或 BPR那是另一套任务语义。5.3 坑三偏置项缺失全局预测值偏移现象网上很多精简版矩阵分解不写 bias我照着抄了一份训练结束后预测均值比真实均值低 0.15推荐排序整体偏高或偏低。原因隐向量点积正负抵消后模型缺少一个稳定的基线通道来拟合评分整体水平只能靠增大 Embedding 幅度去硬顶结果隐向量数值异常大且对单个样本特别敏感。解决在 forward 里补上 global_bias、user_bias、item_bias并把 Embedding 初始化标准差设为 0.05。改完模型收敛更快预测均值也回到真实均值附近。这个修复成本极低收益却很稳定。5.4 坑四负样本和正样本混在一起变成四不像现象为了模拟隐式反馈场景我曾把未交互的user, item对当成评分 0 加进训练集结果验证集指标反而下降。原因MovieLens 是显式评分数据空白位置表示“未评分”不等于“用户讨厌这部电影”。把大量未知当 0 分模型会把大量真实的正反馈预测目标稀释掉评分分布也被严重带偏。解决显式评分数据集只做评分预测不引入负采样。真要做隐式反馈实验换一个点击/购买数据集并且负样本比例控制在 1:3 到 1:5 之间采样时优先选热门未交互物品避免把冷门长尾误判成负例。5.5 坑五验证集出现训练集没见过的用户和物品现象早期版本在全部评分的随机子集上训练用剩余评分做验证验证集里混进了完全没训练过的用户。模型对这些人只能输出随机向量验证分数带着严重噪声。原因Embedding 的本质是查表没有参与训练的用户和物品在表中只有随机初始化向量预测值没有信息量。如果验证集里这类样本占比过高整体指标就被拖垮且不同模型受影响程度不同导致不公平对比。解决用户和物品的 id 映射只建立在训练集上验证集只保留训练集出现过的 id。数据泄露不只是特征工程的问题样本划分的边界也会泄露。养成“先建 id 映射再切数据”的习惯能替后续所有实验省下大量重复劳动。6. 用好Hit Rate与NDCG推荐质量不能被RMSE单独定义RMSE 衡量的是“评分预测准不准”但推荐列表的排序质量需要另外两个指标。Hit Rate10 的定义是验证集里每个用户真实交互过的物品有多少能被模型排进前 10。NDCG10 在 Hit Rate 基础上加入位置权重——正反馈出现在第 1 位比出现在第 10 位贡献更大。两者的计算都基于模型对所有物品的打分排序import math def evaluate_hit_ndcg(model, valid_df, train_items_by_user, all_items, k10): hits, ndcg_scores, total_users 0, 0, 0 for user_id, group in valid_df.groupby(user_id): known set(train_items_by_user[user_id]) # 训练集已交互物品要屏蔽 candidates [item for item in all_items if item not in known] with torch.no_grad(): user_tensor torch.LongTensor([user_id] * len(candidates)) item_tensor torch.LongTensor(candidates) scores model(user_tensor, item_tensor).reshape(-1).tolist() ranked sorted(zip(candidates, scores), keylambda x: -x[1])[:k] ranked_items [item for item, _ in ranked] true_items set(group[item_id]) hit len(set(ranked_items) true_items) if hit 0: hits 1 for i, item in enumerate(ranked_items): if item in true_items: ndcg_scores 1.0 / math.log2(i 2) break total_users 1 return hits / total_users, ndcg_scores / total_users这段评估代码的核心是对每个验证用户从全部物品里剔除训练集已交互项再对剩余物品打分取 top10。屏蔽已交互项是关键否则推荐出来的都是用户已经看过的电影指标毫无区分度。全部物品约 1682 个时每个用户做一次前向CPU 上几十秒能跑完整个验证集成本可以接受。早停设置我一般这样配验证集 RMSE 连续 5 个 epoch 不下降就回退到最佳模型。比起固定 30 个 epoch这种策略能省下不必要的训练时间也让最终参数更稳。再配合固定随机种子、固定切分比例、固定超参数三件事模型之间的对比才有可信度。这五六年做推荐系统的血泪经验是评估口径没统一之前任何模型效果的差异都可能是噪声。希望这个从 MovieLens 起步的 PyTorch 协同过滤基线能帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。