PyTorch实现协同过滤矩阵分解:MovieLens实战与评估
简介一份聚焦推荐系统基础与实践的中文 PDF 文档面向希望用 PyTorch 实现协同过滤算法的学习者也适合推荐系统入门者快速建立知识框架。文档共 29 页内容完整、结构紧凑从推荐系统与协同过滤概述、PyTorch 基础与环境搭建到 MovieLens 数据集介绍与预处理、基于用户/物品的协同过滤原理再进入用 PyTorch 定义模型、数据加载、损失函数与优化器、训练循环和评估指标RMSE、MAE、R²最后分析结果并给出优化策略章节层次分明。资源包体积约 2.02 MB仅包含 1 个 PDF 文件轻量实用下载后可利用目录跳转或阅读器大纲快速定位。当前已有 70 人学习下载。通过这份文档读者能完整理解“数据准备—模型构建—训练评估—优化迭代”的端到端流程无论是完成课程设计还是入门推荐系统都能获得清晰指引与可直接参考的实现思路。1. 推荐系统里被低估的协同过滤实现细节协同过滤是我在推荐系统项目里最常用的一类基线模型它不依赖任何内容特征只靠用户评分矩阵就能把排序效果做到可接受的范围。但很多入门资料把协同过滤停留在“算相似度、取TopK”的层面真正落地时会撞上三个坎评分矩阵稀疏到近95%是空洞时相似度怎么算才有区分度用户ID和物品ID要不要重映射评测指标该看RMSE还是MAE。我这次的完整过程会落在MovieLens 100K上从数据管线讲到训练循环和评估指标适合刚学完PyTorch基础、想用推荐系统练手或者正在搭推荐基线又不想直接上深度模型的工程师。整个流程用CPU版本的PyTorch就能跑完环境搭建不再展开默认你已经装好了torch和pandas。2. MovieLens 100K的准备先编码还是先切分决定模型能否跑通拿到ml-100k压缩包解压后不会有现成的DataFrame在等你。最核心的是u.data文件TAB分隔的四列user id、item id、rating、timestamp。很多新手第一反应是直接读进来开干结果走到建模那一步才发现nn.Embedding要求索引从0开始且连续于是又回头重做。我的做法是先把u.item和u.user放到一边只用u.data训练协同过滤模型这两个副表只在冷启动兜底时才有用。先清理u.data还有个隐藏收益能先统计每个用户的评分条数决定测试集该按行随机切还是按用户切。2.1 先把ml-100k的文件结构看明白在写代码之前值得花两分钟确认每个文件是什么。很多人拿到的压缩包是一样的但把u.item当成评分表读的案例并不少见。下表是我根据目录文件整理的对照文件内容分隔方式说明u.data用户对电影的评分TAB每行一条评分总计100000条u.item电影元信息竖线含电影ID、标题、上映时间、类型编码u.user用户人口统计竖线含年龄、性别、职业、邮编u.genre电影类型表竖线类型名与ID的映射u.occupation职业列表换行用户职业的枚举值u.data的四列分别是user_id、item_id、rating、timestamp。rating是1到5的整数timestamp在纯协同过滤里我通常会直接丢弃因为静态模型不考虑评分时间加了反而引入噪声。u.item和u.user在矩阵分解阶段用不上但之后做冷启动兜底时电影类型和用户职业能当特征用。2.2 数据清洗与编码先全量factorize再切分这里有一个非常关键的顺序问题。很多人习惯先train_test_split再各自做编码这在协同过滤场景里会埋雷。正确的做法是先对全量数据编码再切分代码如下import pandas as pd from sklearn.model_selection import train_test_split columns [user_id, item_id, rating, timestamp] df pd.read_csv(ml-100k/u.data, sep\t, namescolumns) # 清洗评分必须是 1~5 的整数过滤掉边界外的脏数据 ratings df[(df[rating] 1) (df[rating] 5)] # 先对全量数据做编码再切分保证训练集和测试集共用同一套索引 user_idx, user_codes pd.factorize(ratings[user_id]) item_idx, item_codes pd.factorize(ratings[item_id]) ratings[user_idx] user_idx ratings[item_idx] item_idx train, test train_test_split(ratings, test_size0.2, random_state42) print(train size:, len(train), test size:, len(test))这段代码里最关键的是先对全量数据做factorize再切分。如果不这样而是把训练集和测试集分开各自factorize测试集中一旦出现训练集没见过的新用户ID编码结果就会超出训练时Embedding表的行数模型前向传播直接报索引越界。这也是我见过最多的协同过滤初学者翻车点。切分比例我习惯用test_size0.2。100K数据量下按行随机切是安全的因为评分记录彼此独立随机采样不会显著破坏某个用户的评分分布。如果你追求更严格的评估可以按用户分组切分把每个用户的最后一条评分留作测试但代价是测试集变小RMSE波动会变大。2.3 用DataLoader把样本喂给PyTorch编码完成后评分矩阵已经变成了三列user_idx、item_idx、rating。不需要再手动构造稠密矩阵。用稀疏三元组训练是矩阵分解的标准姿势内存占用小也方便扩展。下面是把DataFrame转成PyTorch数据管线的写法import torch from torch.utils.data import DataLoader, TensorDataset train_dataset TensorDataset( torch.tensor(train[user_idx].values, dtypetorch.long), torch.tensor(train[item_idx].values, dtypetorch.long), torch.tensor(train[rating].values, dtypetorch.float), ) train_loader DataLoader(train_dataset, batch_size256, shuffleTrue)train_dataset里的三个张量分别对应用户索引、物品索引和评分DataLoader每次迭代会返回一个batch的元组(u, i, r)。batch_size256在100K数据上是稳妥的起点显存不是瓶颈时可以提到512或1024。shuffleTrue保证每个epoch里样本顺序不同避免模型学到样本顺序。为什么不把评分矩阵补全成943用户乘以1682电影的稠密矩阵再训练因为完整矩阵里有大量未评分位置一旦补0就会被当作真实评分参与计算模型会错误地把缺失当成偏好低。三元组策略只让模型见过真实交互这也是协同过滤矩阵分解能跑得动的根本原因。3. 协同过滤原理辨析为什么矩阵分解比KNN更适合PyTorch把数据管线搭好之后先别急着写model类。搞清楚两件事会让你少走弯路第一基于用户的协同过滤和基于物品的协同过滤各自在什么条件下可用第二为什么我建议用矩阵分解而不是KNN式的相似度计算。这两件事决定了模型结构和损失函数比盲目堆Embedding维度重要得多。3.1 基于用户与基于物品的边界条件基于用户的协同过滤是找行为相似的邻居把邻居喜欢的物品推荐给目标用户基于物品的协同过滤则是反过来找物品之间的相似关系。在MovieLens 100K这种用户数接近物品数的场景里用户、物品都在千级规模两种方法的计算量差距没有那么大。但当数据规模上到百万级物品数通常远小于用户数基于物品的协同过滤在在线推荐时更快因为物品相似度矩阵可以先离线算好。但这两种方法都有通病相似度只在共同评分的物品上计算而评分矩阵极度稀疏时两个用户可能一部共同电影都没有相似度直接归零。我一般把这两种方法作为解释性基线用KNN验证数据质量再用矩阵分解作为主模型。矩阵分解不直接算相似度而是把用户和物品映射到同一低维空间用点积拟合评分天然能处理未观测的交互。3.2 相似度计算的稀疏陷阱如果你确实要用KNN做基线相似度公式的选择会直接影响结果。三种常见方法的差异如下相似度方法计算思路稀疏矩阵下的表现皮尔逊系数先对共同评分去均值再求相关共同评分太少时数值抖动大余弦相似度直接算向量夹角把未评分当0处理偏高估调整余弦先对物品评分去均值再算余弦能缓解用户打分尺度不同的问题皮尔逊公式里如果两个用户没有共同评分的物品分母为零代码里必须做保护。我在项目里一般会要求共同评分数量不少于3再参与计算否则直接跳过。这个阈值看着小实际能把不少噪声相似度过滤掉。余弦相似度把未评分的格子当作0在100K这种填充率只有6%的数据集上两个用户大部分维度都是0相似度被严重拉向一个固定区间区分度很差。如果想验证KNN基线的效果一个简单的余弦相似度函数长这样import numpy as np def cosine_similarity(u1, u2): # 任一向量为零向量时返回0避免除零错误 if np.linalg.norm(u1) 0 or np.linalg.norm(u2) 0: return 0.0 return np.dot(u1, u2) / (np.linalg.norm(u1) * np.linalg.norm(u2))u1和u2是两个用户的评分向量未评分的槽位用0填充。norm为零说明该用户没有任何评分记录这种用户不参与相似度计算。这个函数用来做教学演示没问题工程上直接对稠密向量跑循环会非常慢需要换成scipy的稀疏矩阵乘法。3.3 从相似度到嵌入矩阵分解的等价视角矩阵分解的基本假设是用户对物品的评分可以分解成用户向量与物品向量的内积再加一个全局偏置。你可以把它想象成把KNN里“相似用户”的概念替换成“用户和物品在同一个稠密向量空间里的距离”。训练目标是找到一组向量使预测评分尽量接近真实评分。用PyTorch实现时每个用户对应一行Embedding每个物品对应一行Embedding维度需要预先指定。维度太低表达不了复杂的用户偏好维度太高容易过拟合。内积加偏置的表达式在数学上等价于矩阵分解的目标让用户向量和物品向量的点积在向量空间中模拟评分矩阵的对应位置。比起直接维护一个943乘以1682的稠密矩阵Embedding的参数规模小得多泛化能力也更强。3.4 损失函数与优化目标的选择有了模型结构还要决定用什么样的目标来训练。最直接的是把评分当作回归问题用均方误差MSE训练目标是让预测分数贴近真实的1到5分。MSE的实现简单、收敛快能直接用RMSE来评估。另一种做法是把问题看成排序学习使用BPR损失它只关心用户是否更喜欢物品A而不是物品B不要求预测精确分数。100K上的评分都是显式反馈我建议先用MSE跑通全流程因为RMSE是推荐系统论文里最常出现的指标方便和其他实现对比。如果之后要处理点击、购买这类隐式反馈再改装BPR。切换损失函数时要注意BPR需要为正样本采样负样本训练代码的结构和MSE版本差异不小。4. PyTorch实现协同过滤Embedding层、训练循环与RMSE评估下面进入建模阶段。用PyTorch实现协同过滤核心就三件事定义带偏置的Embedding层、编写一个不会累积梯度的训练循环、用RMSE和MAE量化模型效果。代码都不长但每一处都有取舍下面逐个过。4.1 自定义nn.Module内积加偏置模型定义如下直接继承nn.Moduleimport torch import torch.nn as nn class MatrixFactorization(nn.Module): def __init__(self, n_users, n_items, n_factors32): super().__init__() self.user_emb nn.Embedding(n_users, n_factors) self.item_emb nn.Embedding(n_items, n_factors) self.user_bias nn.Embedding(n_users, 1) self.item_bias nn.Embedding(n_items, 1) nn.init.normal_(self.user_emb.weight, std0.1) nn.init.normal_(self.item_emb.weight, std0.1) nn.init.zeros_(self.user_bias.weight) nn.init.zeros_(self.item_bias.weight) def forward(self, user_ids, item_ids): user_vec self.user_emb(user_ids) item_vec self.item_emb(item_ids) pred (user_vec * item_vec).sum(dim1, keepdimTrue) pred self.user_bias(user_ids) self.item_bias(item_ids) return pred.squeeze()n_users和n_items分别对应第2章factorize后得到的唯一用户数和物品数。std0.1的初始化能避免训练初期嵌入值过大导致的梯度爆炸。forward里pred就是用户对物品的预测评分。为什么加偏置项不同用户的打分尺度差异明显有人习惯打4分有人习惯打3分偏置项把这类系统性差异吸收掉用户向量和物品向量就能专注于表达口味本身。4.2 训练循环的节奏控制实例化模型并开始训练from torch.optim import Adam n_users len(user_codes) n_items len(item_codes) model MatrixFactorization(n_users, n_items, n_factors32) optimizer Adam(model.parameters(), lr1e-3, weight_decay1e-5) loss_fn nn.MSELoss() for epoch in range(20): model.train() total_loss 0.0 for user_ids, item_ids, rating in train_loader: pred model(user_ids, item_ids) loss loss_fn(pred, rating) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * len(user_ids) train_rmse (total_loss / len(train_dataset)) ** 0.5 print(fepoch {epoch 1:02d}, train rmse {train_rmse:.4f})optimizer.zero_grad()放在loss.backward()之前是为了避免梯度累加到上一步的参数上。weight_decay1e-5对Embedding做L2正则能抑制过拟合。loss.item()拿到的是当前batch的平均loss乘上len(user_ids)再累加最后除以总样本数才能得到整个epoch的MSE开根号就是训练RMSE。训练时务必保持model.train()这里没有dropout影响不明显但一旦在模型里加入正则层忘记切换模式会让评估数值失真。4.3 评估RMSE与MAE的阈值参考测试集评估代码如下model.eval() with torch.no_grad(): test_u torch.tensor(test[user_idx].values, dtypetorch.long) test_i torch.tensor(test[item_idx].values, dtypetorch.long) test_r torch.tensor(test[rating].values, dtypetorch.float) pred model(test_u, test_i) rmse (pred - test_r).pow(2).mean().sqrt().item() mae (pred - test_r).abs().mean().item() print(ftest rmse {rmse:.4f}, mae {mae:.4f})model.eval()和torch.no_grad()必须同时出现。前者切换BN和Dropout层的状态后者禁止自动求导图的构建减少内存占用。对当前这个模型eval()没有实质影响但加上是一个好习惯。RMSE对预测偏差大的样本施加更大惩罚MAE则平均看待所有误差。在1到5分数据集上RMSE落在0.95到1.05之间属于正常范围低于0.9说明模型可能只记住了训练集高于1.1则要检查数据编码或学习率。超参数调试我习惯先固定一组基准再逐个放宽。参考范围如下参数建议范围需要调整的信号n_factors16~128训练RMSE低但测试RMSE高时降维lr1e-4~1e-2loss爆炸时降一个数量级weight_decay1e-5~1e-3测试RMSE持续偏高时加大batch_size128~1024收敛太慢时增大这一组参数之间没有固定公式我一般在固定n_factors32的前提下先调学习率再从正则项开始扫这样组合爆炸规模能控制在十几次实验以内。4.4 训练过程中常见的翻车点梯度累积问题上面提过另一个常被忽略的是Embedding查不到索引。如果测试集出现训练阶段没见过的用户IDPyTorch不会报错但会返回一个随机初始化向量导致评估结果不可复现这也是第2章强调先全量编码再切分的原因。还有一个细节是初始化的std设成1.0在100K上会让loss直接跳到几十之后很难收敛到正常区间保持std0.1能显著减少预热期。5. 冷启动用户与超参数协同过滤落地的最后一公里新注册用户没有评分数据user_emb用户向量是随机初始化的模型会给出一个不可靠的预测。我常用的做法是为这类用户准备一个流行度兜底列表统计训练集里每个物品的评价人数和平均评分综合得分等于平均评分乘以log(评价人数加1)按综合得分取TopN。这个启发式公式兼顾了口碑和热度能撑住冷启动阶段的体验。如果u.item里有电影类型信息还可以把电影类型的平均评分再叠加进去实现简单的内容兜底。冷启动本质上已经超出纯协同过滤能解决的问题但兜底策略能让模型在线上不至于变成随机推荐。再补一个训练环节的技巧给训练循环加验证集早停。把训练集再切出5%作为验证集每个epoch结束后计算验证RMSE连续三个epoch不下降就停止训练并保存当前模型。PyTorch里保存检查点只需要一行torch.save(model.state_dict(), mf_best.pt)这样做的直接好处是避免在调试超参数时反复重放整个训练过程。上面给出的参数表只是划定合理的搜索范围实际使用时先固定embedding32、lr1e-3、batch256构建一个小型网格每轮实验只改一个参数。把训练RMSE和测试RMSE记录成CSV用两条曲线对比判断是欠拟合还是过拟合两者都高说明模型容量不够训练低测试高说明正则不够。日志记录里一定要固定随机种子并且把种子写进文件名否则同一组参数在不同机器上训练出来的RMSE会有抖动我在脚本里通常先执行torch.manual_seed(42)再划分数据这样每个实验的对比基线才是干净的。本文还有配套的精品资源点击获取