资讯动态

PyTorch协同过滤实战:MovieLens评分预测与Embedding实现

发布时间:2026/9/29 3:13:18 来源:尧图企业网站定制
简介一份面向推荐系统初学者与PyTorch使用者的实战型PDF文档以MovieLens公开数据集为载体系统讲解协同过滤算法的完整实现流程。文档共29页压缩包内仅含1个PDF文件约2.02MB轻量便携。内容从推荐系统基础与常见类型切入依次介绍基于用户和基于物品的协同过滤原理并结合PyTorch动态计算图特点给出张量操作、自动求导、模型定义、损失函数与优化器选择、训练循环及RMSE、MAE等评估指标的详细实现步骤。全文支持目录跳转和左侧大纲定位便于按章节反复查阅也可作为课程设计或入门实践参考。目前已有70人学习下载对希望快速上手推荐系统项目并理解算法细节的读者来说是一份结构清晰、可直接对照练习的实用资料。1. 协同过滤算法在MovieLens上的PyTorch实现为什么要自己写一遍在推荐系统面试和课程设计里标题这个组合几乎是标配任务把 MovieLens 的评分表拆开用 PyTorch 写一个协同过滤模型让用户向量和物品向量的内积去拟合真实评分。它把推荐系统最核心的五个环节——数据、模型、训练、评估、调参——压缩到一个 CPU 笔记本就能跑完的最小闭环里。适合刚学完 PyTorch 教程却不知道做什么项目的人也适合手推过矩阵分解公式、但没写过完整训练循环的候选人和学生。跑通这个项目你会发现Embedding、隐因子和评分预测是同一件事所谓协同过滤就是把用户和物品各学一条向量让它们的内积尽量逼近真实评分同时用户偏置和物品偏置解释掉大家打分手松手紧的问题。这个认知比背公式有用得多。2. 准备MovieLens数据和PyTorch环境从原始评分表到可训练的DataLoader在这个实现链路上数据集和环境准备往往被教程一笔带过但实际返工大多出在这一步索引对不上、测试集里出现训练集没见过的用户、环境版本不对导致装了 GPU 版却跑不起来。我一般先把数据看清楚再搭环境最后写 Dataset顺序反了容易越改越乱。2.1 MovieLens 100K到底有哪些文件先搞懂u.data和u.itemMovieLens 100K 是 GroupLens 发布的公开学术数据集解压后是一个 ml-100k 目录。最核心的文件是u.data里面是制表符分隔的评分记录四列分别是用户 ID、电影 ID、1 到 5 的整数评分、UNIX 时间戳。整个数据集包含 943 个用户对 1682 部电影的 10 万条评分稀疏度接近 93.7%。也就是说绝大多数用户和电影之间没有记录协同过滤要解决的就是从这里挑出用户可能会看的电影。同目录下的u.item负责电影信息映射比如电影 ID 对应什么标题、属于什么类型u.user是用户人口属性。如果只是复现评分预测这两个文件不是必需但做冷启动分析和结果解释时会用到。另外ua.base和ua.test是官方切分好的训练测试集训练集 8 万、测试集 2 万新手直接用这两个文件能省掉自己切分带来的偏差问题。需要强调一点这个数据集的标签是显式评分评分缺省不代表用户讨厌只是没看过。不要把没评分的条目当负样本填 0 去训练这个认知会直接影响后面 loss 函数的选择。2.2 用Anaconda配置PyTorch环境CPU版就能跑不必要为了这个题上GPU很多人在 PyTorch 安装这一步纠结半天实际上标题里这个项目 CPU 完全能跑。我常用的方式是先用 Anaconda 或 Miniconda 建一个独立的 Python 3.10 环境再按 PyTorch 官网生成的命令安装对应版本。官网会提供 Python 和 PyTorch 版本的对应关系不用手动去记conda 会帮你解析依赖。conda create -n rec python3.10 -y conda activate rec pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu这段命令的第三行装的是 CPU 版 PyTorch不需要 NVIDIA 驱动也不需要 CUDA。如果你已经装过 CUDA 版 PyTorch那就正常用如果手头是 AMD 显卡、人在 WSL 里想折腾 7900XTX那需要的是 ROCm 版 PyTorch配置成本远高于这个项目的收益。所以我的建议一直是用 Anaconda 先把 CPU 版跑通确认数据、模型、评估链路都没问题再考虑是否值得上 GPU。安装 PyTorch 不是必须配有 GPU这个体量的数据用 CPU 训练反而省心。装好之后用几行代码验证环境import torch print(torch.__version__) a torch.randn(100, 32) b torch.randn(32, 50) print((a b).shape)这段代码的逻辑很简单能打印出版本号、矩阵乘能正常出 shape说明 PyTorch 基础框架安装成功。CPU 版在 100K 数据上训练一个 epoch 一般是秒级到十几秒瓶颈不在算力而在你写的索引转换和数据加载是否合理。2.3 定义RatingDataset把评分表映射成Embedding能吃的索引写模型之前必须先做一件事把u.data里的用户 ID 和电影 ID 重新编码成从 0 开始的连续整数。原因在于nn.Embedding的输入索引必须在[0, size)区间内。MovieLens 100K 的用户 ID 恰好是 1 到 943 连续物品是 1 到 1682 连续直接减 1 也能用但一旦换数据集ID 往往不连续或有缺失所以统一用 pandas 的 category 编码最保险import pandas as pd df pd.read_csv(ml-100k/u.data, sep\t, names[uid, iid, rating, ts]) df[uid_idx] df[uid].astype(category).cat.codes df[iid_idx] df[iid].astype(category).cat.codes num_users df[uid_idx].nunique() num_items df[iid_idx].nunique()这里有个顺序上的坑先对全量数据做 category 编码再做训练测试切分。如果先切分再编码测试集里可能遇到训练集没有的用户 ID那部分样本在 predict 时根本查不到 Embedding 行程序要么报错要么悄悄出错。接着定义一个标准 PyTorch Dataset把每一行评分变成三元组import torch from torch.utils.data import Dataset, DataLoader class RatingDataset(Dataset): def __init__(self, df): self.users torch.tensor(df[uid_idx].values, dtypetorch.long) self.items torch.tensor(df[iid_idx].values, dtypetorch.long) self.ratings torch.tensor(df[rating].values, dtypetorch.float32) def __len__(self): return len(self.users) def __getitem__(self, idx): return self.users[idx], self.items[idx], self.ratings[idx]这个类的关键点在于 dtype。用户索引和物品索引必须用 long因为nn.Embedding只接受torch.long类型评分用 float32因为后续 MSE 损失要求预测和标签同类型。DataLoader 在取数据时默认会做 stack把一组三元组拼成 batch所以模型看到的是三个张量用户 ID 批、物品 ID 批、评分批。切分和装载我一般这样写from sklearn.model_selection import train_test_split train_df, test_df train_test_split( df, test_size0.2, random_state42 ) train_ds RatingDataset(train_df) test_ds RatingDataset(test_df) train_loader DataLoader(train_ds, batch_size256, shuffleTrue) test_loader DataLoader(test_ds, batch_size1024, shuffleFalse)这样得到的数据流是(users: (256,), items: (256,), ratings: (256,))batch_size256 在 CPU 上训练效率很高。random_state 固定成 42保证每次重跑结果一致这是后面调参对比的前提。如果你希望更接近官方协议直接用ua.base和ua.test零切分代码也能避免自己切分带来的评估纠纷。提示如果训练数据量变大比如换到 ml-1m记得给 DataLoader 加num_workers4和pin_memoryTrue否则数据加载会拖慢训练。3. 用PyTorch实现协同过滤模型从Embedding点积到带偏置的MF模型这一章是标题的核心。协同过滤在这个标题里指的就是矩阵分解MF把用户-物品评分矩阵近似分解成用户隐因子矩阵和物品隐因子矩阵的乘积。PyTorch 里实现起来非常直白nn.Embedding本身就是一张可学习的查找表行号就是索引。3.1 最小可行模型两个nn.Embedding求内积先写一个不掺任何额外花活的版本把框架立起来import torch.nn as nn class MatrixFactorization(nn.Module): def __init__(self, num_users, num_items, emb_dim32): super().__init__() self.user_emb nn.Embedding(num_users, emb_dim) self.item_emb nn.Embedding(num_items, emb_dim) def forward(self, users, items): u_vec self.user_emb(users) # (batch, emb_dim) i_vec self.item_emb(items) # (batch, emb_dim) return (u_vec * i_vec).sum(dim1) # (batch,)forward 里做的事对应公式R_pred P_u · Q_i^T。u_vec和i_vec是同一个 batch 里每个用户的隐向量和每个物品的隐向量逐元素相乘再求和得到的就是预测评分。embedding 维度 emb_dim 是隐因子个数通常取 16 到 64MovieLens 100K 这种整理过的评分矩阵32 作为起点最稳妥。矩阵分解怎么理解每个用户被压成一个 32 维向量每个物品也是 32 维。向量的每一维不像动作片得分那样有显式含义而是模型自己学出来的潜语义。两个向量方向越一致内积越大预测评分越高。这就是协同过滤里最核心的隐语义模型思想也是面试官最爱问的SVD 和 MF 的区别的落脚点。3.2 加入用户偏置、物品偏置和全局偏置评分预测更贴合现实最小模型有个明显短板它假设评分只由用户和物品的向量交互决定但人打分习惯差异很大有人给啥都是 4 星有人给啥都是 2 星。用向量去拟合这种习惯性偏差既浪费表达力又容易把向量学歪。所以工程上的 MF 几乎都会加三项偏置全局平均分、用户偏置、物品偏置。用户偏置回答这个用户比全局平均多打了几分物品偏置回答这部电影比全局平均高几分剩下解释不了的部分才交给隐向量交互。带偏置的模型代码class BiasMF(nn.Module): def __init__(self, num_users, num_items, emb_dim32): super().__init__() self.user_emb nn.Embedding(num_users, emb_dim) self.item_emb nn.Embedding(num_items, emb_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)) def forward(self, users, items): u_vec self.user_emb(users) i_vec self.item_emb(items) pred (u_vec * i_vec).sum(dim1) pred pred self.user_bias(users).squeeze(1) pred pred self.item_bias(items).squeeze(1) pred pred self.global_bias return predsqueeze(1)是把形状为(batch, 1)的偏置压成(batch,)方便与 pred 直接相加。global_bias是一个长度为 1 的可学习参数初始化成 0训练中会自动逼近训练集的评分均值。注意返回的 pred 是(batch,)而不是(batch, 1)后面 MSE 损失对这两种形状都能处理但建议保持和 rating 张量完全一致排查问题时少一个维度隐患。加了偏置之后RMSE 通常比纯点积模型好 0.05 到 0.1而且某个用户普遍打高分这类规律不再占用隐向量容量。这也是为什么工业界基础 MF 实现几乎都带偏置不带反而会让模型把简单规律和复杂偏好混在一个空间里学。3.3 Embedding的初始化和正则化默认参数会让loss一开始就飞直接用nn.Embedding不加任何处理模型能跑但 loss 曲线会很难看。nn.Embedding默认按标准正态分布 N(0,1) 初始化当 emb_dim32 时两个随机向量的内积期望能达到几十而评分范围只有 1 到 5。内积初始就比目标大一个数量级早期梯度会拉得很猛表现为 train_loss 忽高忽低。解决办法是手动把 Embedding 的初始化方差压小比如 std0.1同时配合 weight_decay 做 L2 正则def init_embedding(m): if isinstance(m, nn.Embedding): nn.init.normal_(m.weight, mean0.0, std0.1) model BiasMF(num_users, num_items, emb_dim32) model.apply(init_embedding) optimizer torch.optim.Adam( model.parameters(), lr0.01, weight_decay1e-5 )std0.1 意味着两个初始向量内积的期望大概在 0.1 左右落在评分区间附近训练一开始 loss 不会爆炸。weight_decay1e-5 让 Embedding 权重向 0 收缩避免模型把个别用户的一两个评分背下来。对 100K 数据集、约 8.4 万参数量的模型来说这个正则强度足够如果观察到验证 loss 反弹可以把 weight_decay 提到 1e-4。embedding 维度本身也是正则手段维度越小容量越小。我调参时习惯从 8 到 64 逐档试每次只动维度记录 train 和 test 的 RMSE而不是同时改学习率和 batch size。手动初始化 std 这个细节很多人忽略但它对训练稳定性影响很大尤其是你换用更大 emb_dim 时std 不跟着降loss 大概率飘。提示model.apply(init_embedding)会把三个 Embedding 表都初始化。如果你介意偏置表也从正态分布起跳可以在 apply 之后单独把user_bias.item_bias再置零实际影响很小。4. 训练与评估把MSE、RMSE和Top-K召回分别算清楚模型写好后训练循环和评估方式决定了你看到的结果是否有说服力。很多教程只贴一个 train 函数然后说loss 降了但推荐系统光看 loss 不够线上真正关心的是推荐头部有没有命中用户真实想看的物品。这一章把训练循环和最常用的两类评估指标都说清楚。4.1 训练循环的固定写法MSELoss配合Adam监控验证集训练循环是推荐系统项目里最模板化的代码差异只在数据加载和模型 forward。我通常把单 epoch 训练写成独立函数方便多次调用def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss 0.0 for users, items, ratings in loader: optimizer.zero_grad() pred model(users, items) loss criterion(pred, ratings) loss.backward() optimizer.step() total_loss loss.item() * len(ratings) return total_loss / len(loader.dataset) model BiasMF(num_users, num_items, emb_dim32) init_embedding(model) criterion nn.MSELoss() optimizer torch.optim.Adam(model.parameters(), lr0.01, weight_decay1e-5) for epoch in range(20): train_loss train_one_epoch(model, train_loader, optimizer, criterion) print(fepoch{epoch:02d} train_loss{train_loss:.4f})为什么用 MSELoss评分预测是回归任务MSE 对预测偏差做平方惩罚大偏差被放大梯度更平稳。使用 Adam 时lr0.01 在 100K 规模上是合适的起点如果训练 loss 来回抖动降到 0.005如果一直不降查数据编码或 Embedding 初始化是否生效。这里有个容易忽略的细节total_loss loss.item() * len(ratings)。loss.item()是当前 batch 的平均损失乘上 batch 里样本数再累加最后除以整个数据集样本数才得到 epoch 级别的平均 loss。如果直接对每个 batch 的 loss.item() 做算术平均结果会被小 batch 的权重拉偏。训练中真正要盯的不是 train_loss而是验证集上的 RMSE。我习惯每两个 epoch 在test_loader上跑一次评估连续三轮验证 RMSE 不降或者回升就保存当前模型或直接停止。这是最简单有效的防过拟合手段比任何花哨的正则都实用。4.2 评估指标RMSE之外按用户算Top-K命中率模型训练完之后不同指标回答的问题不同。RMSE 衡量预测分数和真实分数的平均偏差MAE 是同样的偏差但不用平方大误差惩罚小一些。而 Top-K 召回或命中率看的是用户真实看过的物品有多少出现在模型给出的前 K 个推荐里更贴近业务目标。如果只报 RMSE你要知道它与排序质量并不完全一致一个 RMSE 更低的模型不一定在 Top-K 推荐里表现更好。因为 Top-K 只关心头部顺序对预测绝对误差不那么敏感。在 MovieLens 100K 上一个常见且可复现的 Top-K 评估是对每个测试用户把所有未与该用户交互过的物品打一遍分取分数最高的 K 个看里面命中了多少测试集里该用户真正看过的物品。代码实现如下import numpy as np def evaluate_hit(model, train_df, test_df, item_ids, k10): model.eval() hits, total 0, 0 item_ids_np item_ids.cpu().numpy() with torch.no_grad(): for uid, group in test_df.groupby(uid_idx): seen set(train_df[train_df[uid_idx] uid][iid_idx].values) candidates [i for i in item_ids_np if i not in seen] if not candidates: continue uid_tensor torch.full((len(candidates),), uid, dtypetorch.long) cand_tensor torch.tensor(candidates, dtypetorch.long) scores model(uid_tensor, cand_tensor).cpu().numpy() top_k candidates[np.argsort(scores)[::-1][:k]] hits len(set(top_k) set(group[iid_idx].values)) total 1 return hits / max(total, 1)调用前需要准备好全量物品 ID 张量item_ids torch.tensor(df[iid_idx].unique(), dtypetorch.long) hit_rate evaluate_hit(model, train_df, test_df, item_ids, k10)这段评估代码有几个关键点。seen是训练集中该用户交互过的物品候选集里必须排除不然模型记住了训练集里已经看过的电影Top-K 命中会虚高。total是参与评估的测试用户数返回值是平均每个用户前 10 个推荐里有多少个真实物品。它不要求你预测出用户看过的所有物品只关心推荐头部有没有撞上真实行为。在 100K 规模下943 个用户、每个用户给 1682 个物品打分一次全量评估只要几秒到十几秒。如果日后换到 ml-1m这个写法会太慢届时的常见做法是负采样给每个用户随机抽 100 到 150 个未交互物品加上真实测试物品组成候选集打分速度和内存都能接受。4.3 一组能复现的默认参数和结果参考以下是我在这个项目上常用的起始配置按这个跑能稳定出一个合理基线参数建议值调参方向emb_dim328-64 逐档试lr0.01抖动时降到 0.005batch_size256太大收敛慢太小噪声大weight_decay1e-5过拟合时提高到 1e-4epochs20-40配合早停损失函数MSELoss预测评分用 MSE用 BiasMF 在 100K 随机切分 20% 测试集上验证 RMSE 大约在 0.95 到 1.05 之间MAE 在 0.74 到 0.78 左右。如果只预测全局均值RMSE 约 1.13 上下所以 0.95-1.0 的 RMSE 说明模型学到了明显信号但离完美还很远。网上有人晒 0.85 的 RMSE多半用了更复杂的 SVD 变体、更长的训练周期或者按用户切分的评估方式数字之间不一定能直接横向对比。到这里你已经有了数据、模型、训练和评估的完整链路。下一章是更值钱的哪些细节会让模型悄悄翻车。5. PyTorch协同过滤避坑指南五个常见的翻车细节这一章把复现类似项目时最常见的问题整理成五条每条按现象、原因、解决来写。踩过这些坑之后再去调参、展示结果会从容很多。5.1 索引错位DataFrame里的ID不等于Embedding的索引现象训练时偶尔报 IndexError或者模型评估结果完全反常。原因u.data里的用户 ID 从 1 开始物品 ID 也是从 1 开始而nn.Embedding的输入索引要从 0 开始。如果你直接拿原始 ID 喂给模型num_users943合法索引是 0 到 942用户 ID 等于 943 的那条样本就会越界。换到别的数据集更可能出现物品 ID 不连续、最大 ID 超过 Embedding size 的情况。解决在读取数据后用astype(category).cat.codes把原始 ID 重编码成 0 到 N-1 的整数而且要在全量数据上先编码再切分。模型里所有 Embedding 的num_users和num_items都取编码后的nunique()。这条做对了能避免一半以上的报错。5.2 随机切分造成的光环你的RMSE可能虚低现象同一份代码随机切分测试集下 RMSE 很漂亮换成官方ua.base/ua.test后 RMSE 涨了不少。原因随机切分允许同一个用户、同一部电影的评分同时出现在训练集和测试集模型在 train 阶段见过这个用户和这部电影的偏置预测自然占便宜。现实中你要推荐的是用户没看过的电影所以随机切分会把测试难度做小也让不同实现之间的对比失去公平性。解决用官方切分或者至少按用户划分整个用户的所有评分要么在训练集要么在测试集。如果是跟时间排序有关的场景按ts时间戳划分更贴近真实线上。作为课程项目随机切分也够用但汇报结果时务必说明切分方式否则你的 RMSE 和别人的不具可比性。5.3 过拟合训练loss一直降验证loss却开始反弹现象训练集 RMSE 降到 0.7 以下验证 RMSE 先降到 0.96 又开始往 1.05 走。原因模型参数主要来自 Embedding 表数十万级参数面对 8 万级训练样本完全有能力记住个别评分的噪声。一开始 train_loss 下降得快因为模型在拟合训练分布随后验证集不再受益因为模型开始背题目。解决第一优先加 weight_decay从 1e-5 调到 1e-4第二使用早停每两个 epoch 检查验证 RMSE连续三轮没有改善就停止第三降低 emb_dim让隐因子从 32 降到 16减少容量。推荐做法是三个手段都留一点不要靠一个参数硬扛。5.4 把评分缺省当负样本这是显式反馈任务的典型错位现象为了提高 Top-K 指标把用户没评分的物品全部当作负样本用 0 标签或 Binary Cross Entropy 训练结果 RMSE 和 MAE 完全没法看。原因MovieLens 的缺省值不代表用户讨厌。用户没看过这部电影和用户看了不喜欢是两个完全不同的信号。显式评分任务应该回归 1 到 5 的评分把它当成隐式反馈任务需要额外设计负采样策略并且不能只用 RMSE 作为主要评估方式。解决在这个标题的链路里直接用 MSE 回归评分即可不需要负样本。除非后续做隐式反馈方向比如预测用户是否会点击那才需要采样真实负样本一般每个正样本配 1 到 4 个负样本避免全量负样本导致训练太慢、分布太偏。5.5 不要迷信GPU100K数据上CPU反而更高效现象在只有 CPU 的环境上训练又快又稳把同一份代码搬到 GPU每个 epoch 反而慢了或者显存占用高得离谱。原因模型太小、数据量太小GPU 的 kernel launch 开销占比过大加上 WSL 或 AMD ROCm 环境还需额外配置收益为负。很多人一开始被深度学习必须 GPU带偏其实本任务 CPU 完全够用安装 PyTorch 不是必须装有 GPU。解决先把 CUDA 的念头放一边用 CPU 版 PyTorch 跑通整个项目。如果确实在 WSL 里配了 GPU 环境确认 PyTorch 版本能否被torch.cuda.is_available()或 ROCm 对应接口识别能识别就正常用不能识别就老实回 CPU 跑。像 7900XTX 这种 AMD 卡在 WSL 里折腾 ROCm 的时间足够这个项目优化十轮了。6. 进阶把评估换得更真实Embedding可视化才更有说服力能走到这一步说明你已经有了稳定跑的基线和评估函数。剩下值得花时间的不是无限调参而是把评估口径和可视化做扎实。6.1 按时间切分模拟预测未来的离线评估随机切分适合快速实验但对推荐系统的真实部署场景说服力有限。更真实的做法是按时间戳切分用前 80% 时间段的数据训练用后 20% 的数据测试。代码非常简单cutoff df[ts].quantile(0.8) train_df df[df[ts] cutoff] test_df df[df[ts] cutoff]这样做之后模型只能从历史交互里学规律再去预测之后发生的评分和线上场景基本一致。你可能会发现 RMSE 比随机切分略差这很正常时间靠后的样本里有大量新电影和活跃用户本来就更难预测。6.2 把Item Embedding用PCA投影出来训练完的item_emb是(num_items, 32)的矩阵每一行是一部电影在潜语义空间里的坐标。用 PCA 降到二维再按电影类型上色可以直观验证向量是否学到了语义from sklearn.decomposition import PCA item_emb model.item_emb.weight.detach().numpy() pca PCA(n_components2).fit_transform(item_emb)观察结论比想象中常见动作片和科幻片往往聚在一起经典老片也可能形成一个团。如果 cluster 完全看不出类别不一定代表训练失败也可能是 embedding 维度太短、epoch 太少或者评分偏好本身与电影类型维度不完全一致。把 PCA 散点图放进报告里比只贴一张 loss 下降图更有说服力。6.3 固定种子、单变量调参结果才有积累价值回看这个标题的整个链路最后要养成的习惯是每次实验固定 seed、固定切分、固定评估函数只改一个超参。我第一次跑通这个项目时最狼狈的一次调参是同时动了 embedding 维度、学习率和测试集切分跑了一晚上最后根本分不清是哪个调整让结果变好。从那以后每次改参只动一个变量复制一条实验记录seed、embedding 维度、lr、weight_decay、train RMSE、test RMSE、Top-K 命中率。靠这套笨办法后续每次实验都是对之前结果的小步改进而不是推倒重来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑