news 2026/9/17 10:34:13

PyTorch实现协同过滤矩阵分解:MovieLens实战与评估

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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", names=columns) # 清洗:评分必须是 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_size=0.2, random_state=42) print("train size:", len(train), "test size:", len(test))

这段代码里最关键的是先对全量数据做factorize,再切分。如果不这样,而是把训练集和测试集分开各自factorize,测试集中一旦出现训练集没见过的新用户ID,编码结果就会超出训练时Embedding表的行数,模型前向传播直接报索引越界。这也是我见过最多的协同过滤初学者翻车点。

切分比例我习惯用test_size=0.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, dtype=torch.long), torch.tensor(train["item_idx"].values, dtype=torch.long), torch.tensor(train["rating"].values, dtype=torch.float), ) train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True)

train_dataset里的三个张量分别对应用户索引、物品索引和评分,DataLoader每次迭代会返回一个batch的元组(u, i, r)。batch_size=256在100K数据上是稳妥的起点,显存不是瓶颈时可以提到512或1024。shuffle=True保证每个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.Module:

import torch import torch.nn as nn class MatrixFactorization(nn.Module): def __init__(self, n_users, n_items, n_factors=32): 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, std=0.1) nn.init.normal_(self.item_emb.weight, std=0.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(dim=1, keepdim=True) pred += self.user_bias(user_ids) + self.item_bias(item_ids) return pred.squeeze()

n_users和n_items分别对应第2章factorize后得到的唯一用户数和物品数。std=0.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_factors=32) optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-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(f"epoch {epoch + 1:02d}, train rmse = {train_rmse:.4f}")

optimizer.zero_grad()放在loss.backward()之前,是为了避免梯度累加到上一步的参数上。weight_decay=1e-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, dtype=torch.long) test_i = torch.tensor(test["item_idx"].values, dtype=torch.long) test_r = torch.tensor(test["rating"].values, dtype=torch.float) pred = model(test_u, test_i) rmse = (pred - test_r).pow(2).mean().sqrt().item() mae = (pred - test_r).abs().mean().item() print(f"test 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_factors=32的前提下先调学习率,再从正则项开始扫,这样组合爆炸规模能控制在十几次实验以内。

4.4 训练过程中常见的翻车点

梯度累积问题上面提过,另一个常被忽略的是Embedding查不到索引。如果测试集出现训练阶段没见过的用户ID,PyTorch不会报错但会返回一个随机初始化向量,导致评估结果不可复现,这也是第2章强调先全量编码再切分的原因。还有一个细节是初始化的std,设成1.0在100K上会让loss直接跳到几十,之后很难收敛到正常区间,保持std=0.1能显著减少预热期。

5. 冷启动用户与超参数:协同过滤落地的最后一公里

新注册用户没有评分数据,user_emb用户向量是随机初始化的,模型会给出一个不可靠的预测。我常用的做法是为这类用户准备一个流行度兜底列表:统计训练集里每个物品的评价人数和平均评分,综合得分等于平均评分乘以log(评价人数加1),按综合得分取TopN。这个启发式公式兼顾了口碑和热度,能撑住冷启动阶段的体验。如果u.item里有电影类型信息,还可以把电影类型的平均评分再叠加进去,实现简单的内容兜底。冷启动本质上已经超出纯协同过滤能解决的问题,但兜底策略能让模型在线上不至于变成随机推荐。

再补一个训练环节的技巧:给训练循环加验证集早停。把训练集再切出5%作为验证集,每个epoch结束后计算验证RMSE,连续三个epoch不下降就停止训练并保存当前模型。PyTorch里保存检查点只需要一行:

torch.save(model.state_dict(), "mf_best.pt")

这样做的直接好处是避免在调试超参数时反复重放整个训练过程。上面给出的参数表只是划定合理的搜索范围,实际使用时先固定embedding=32、lr=1e-3、batch=256,构建一个小型网格,每轮实验只改一个参数。把训练RMSE和测试RMSE记录成CSV,用两条曲线对比判断是欠拟合还是过拟合:两者都高说明模型容量不够,训练低测试高说明正则不够。日志记录里一定要固定随机种子,并且把种子写进文件名,否则同一组参数在不同机器上训练出来的RMSE会有抖动,我在脚本里通常先执行torch.manual_seed(42)再划分数据,这样每个实验的对比基线才是干净的。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 10:33:46

Jetson Nano嵌入式AI部署实战:供电散热与TensorRT优化

1. 这不是“又一个树莓派教程”&#xff1a;Jetson Nano教学视频到底教什么、为什么值得花时间学Jetson Nano不是一块会跑AI的树莓派&#xff0c;它是一台被精心压缩进7045mm PCB里的边缘计算工作站。我第一次把YOLOv5s模型烧进Nano板载eMMC时&#xff0c;用的是官方SD卡镜像&a…

作者头像 李华
网站建设 2026/9/17 10:31:18

STM32CubeMX与Keil协同开发:工程配置、编译下载与调试避坑

1. 先搞懂这套组合&#xff1a;CubeMX 与 Keil Vision 各管什么刚上手 STM32 的朋友&#xff0c;最容易犯的一个错是把 STM32CubeMX 和 Keil Vision 当成两个能互相替代的东西。不是的。这两个工具在整条开发链路里扮演的角色完全不同&#xff0c;一旦这个概念没理顺&#xff0…

作者头像 李华
网站建设 2026/9/17 10:26:23

6G服务化RAN:从基站拆分到端到端重构的演进之路

简介&#xff1a;《2022年6G服务化RAN白皮书》由中国移动通信研究院发布&#xff0c;是一份面向通信研究者、网络架构师及高校通信专业师生的技术文献&#xff0c;系统回应了5G核心网已服务化但RAN仍以集成单体为主的发展痛点。白皮书提出基于云原生技术的端到端服务化RAN总体构…

作者头像 李华
网站建设 2026/9/17 10:26:21

phpstudy搭建MySQL开发环境:从建库建表到增删改查实战教程

1. 为什么要用phpstudy玩数据库先说说我自己的情况。这几年帮人搭课程设计、带新人入门&#xff0c;见过太多人卡在第一步&#xff1a;数据库装好了连不上&#xff0c;装到一半报错&#xff0c;配置改了以后服务起不来。很多刚接触Web开发的朋友&#xff0c;一上来就被MySQL原版…

作者头像 李华