简介这是一份基于CNN矩阵分解与协同过滤的电影推荐系统完整项目适合人工智能、计算机等相关专业学生用于毕业设计、课程设计或算法学习。项目包含模型源码、说明文档、数据集及运行截图兼顾算法原理与工程实现可在ml-latest-small与ml-1m数据集上体验完整推荐流程。压缩包共28个文件以py脚本、ipynb分析笔记、txt说明为主配合csv、dat数据文件和png/jpeg图表能够帮助理解数据预处理、矩阵分解、CNN特征提取与Top-N推荐等环节。资源仅7.4MB结构紧凑便于快速下载使用文档中整理了问题、优化与改进思路对二次开发或答辩讲解都有参考价值。目前已有54人学习下载适合希望从代码层面掌握推荐系统实现细节的初学者和进阶者。1. 一个CNN矩阵分解的协同过滤推荐系统能解决什么问题你往一个电影推荐系统里丢进一部零评分的冷门片常规协同过滤模型在用户-物品评分矩阵上只能看到一列空值预测自然回落到均值。基于CNN矩阵分解的协同过滤算法把用户隐向量与物品内容特征放进同一个打分函数——卷积层从标题与题材文本中抽取语义向量矩阵分解负责捕捉评分矩阵里的集体偏好。这个项目正是这样落地的movieRecommendation-master源码里同时带有ml-1m、ml-latest-small两份基准集、两套 Notebook 和优化排错笔记适合需要完整推荐链路的毕业设计也适合想验证内容特征能否缓解冷启动的工程师。我复现时用的框架是 PyTorch下面按可运行顺序把原理、模块、参数和坑串起来讲。2. 矩阵分解与CNN融合从隐语义模型到内容特征加权2.1 显式评分矩阵的分解与隐向量空间矩阵分解协同过滤的核心假设评分矩阵 R 可以近似为两个低秩矩阵的乘积。把ml-1m拆开看6040 个用户对 3706 部电影理想情况写成 R≈PᵀQP 是用户隐向量Q 是物品隐向量k 是隐向量维度。但现实中的评分矩阵是稀疏的缺失位置并不是零分直接做 SVD 必须先补全矩阵补法却没有任何依据所以工程上转向最小化已观测评分误差的隐语义模型公式为r_hat(u,i) global_bias b_i p_uᵀq_i其中b_i是物品偏置p_u与q_i分别是用户和物品的 k 维向量。这个式子把打分拆成了「全局均值 物品自身热度 用户偏好与物品的匹配」它的表达能力取决于 k 是否足够容纳用户与物品之间的交互模式。正则项在这里不是可选配置没有 L2 约束时embedding 范数持续膨胀训练 loss 逼近 0验证集 RMSE 反而上升。损失函数与工程里对应的写法如下def mf_loss(pred, rating, reg, emb_list): mse F.mse_loss(pred, rating) l2 sum(torch.norm(w, 2) ** 2 for w in emb_list) return mse reg * l2这里的emb_list一般传入用户向量、物品向量与偏置项reg就是第 4 章参数表里的weight_decay。ML-1m 上常见取值在 0.01 到 0.1 之间太小起不到约束作用太大会把向量压扁预测全部向全局均值靠拢。2.2 为什么CNN来承载物品内容特征纯矩阵分解在零评分物品上能给出的物品向量只有随机初始化加正则拉回原点的结果对冷启动没有区分度。CNN矩阵分解的思路是在物品向量上叠加一个 CNN 提取的内容向量把电影标题、题材这类文本串联成字符序列用一维卷积得到定长语义表征。之所以用 CNN 而不是直接把文本 embedding 拼进 MLP有三个原因。第一是局部性。卷积核在字符序列上滑动捕捉到的本质是 n-gram 模式——「Comedy」这个词不管出现在标题还是题材字段相同的字符窗口会产生相同的特征。第二是权重共享。同一个卷积核处理序列中所有位置参数规模与文本长度无关换成等容量的全连接层输入维度固定 32参数量会大一个量级。第三是长度自适应。通过自适应最大池化任意长度的文本都被压成固定维度向量不需要对标题做二分截断并担心长度不齐导致的维度错位。2.3 融合公式与端到端训练将文本回路与评分矩阵回路合并最终打分函数为r_hat(u,i) global_bias b_i p_uᵀ (q_i α · TextCNN(seq_i))α是文本分支的权重系数。模型训练时用户 embedding、物品 embedding、TextCNN、投影层和 α 全部在同一张计算图上反向传播文本分支和评分矩阵互为约束——文本特征可以引导稀疏物品的向量方向评分数据又反过来修正卷积核的语义理解。这个结构与 ConvMF 的思路一致只是用字符级输入替代了预训练文档向量降低了对外部语料的依赖。训练前先跑一次前向确认形状demo_seq torch.randint(0, vocab_size, (2, 32)) content_vec model.text_encoder(demo_seq) print(content_vec.shape) # 期望输出 (2, 192)如果这里得到的不是(batch, 192)问题大多出在 vocab 索引越界或卷积核输出维度没对齐。确认分支形状正确之后再开始完整训练。3. 工程实现数据流水线与模型搭建3.1 数据集选型与预处理工程压缩包里的ml-1m与ml-latest-small都是公开的 MovieLens 数据集数据集描述.txt对两者的字段做了记录。前者是毕业设计常用的 100 万条评分后者适合快速跑通主流程再换大数据集。数据集用户数电影数评分条数格式用途ml-1m604037061,000,209::分隔 dat正式训练与实验ml-latest-small6109742100,836CSV快速调试与逻辑验证ml-1m的movies.dat一行形如1::Toy Story (1995)::Animation|Childrens|Comedy标题和题材字段都可能是非 ASCII 字符读取时要指定encodinglatin1。预处理第一步是把用户、物品的原始 id 映射为从 0 开始的稠密索引否则 embedding 表会为不连续的 id 保留空槽位显存和参数量都白白浪费。import pandas as pd import numpy as np def load_ml1m(path./ml-1m): ratings pd.read_csv(f{path}/ratings.dat, sep::, enginepython, names[user, item, rating, ts]) movies pd.read_csv(f{path}/movies.dat, sep::, enginepython, names[item, title, genres], encodinglatin1) return ratings, movies uniq_users sorted(ratings[user].unique()) uniq_items sorted(ratings[item].unique()) user2idx {u: i for i, u in enumerate(uniq_users)} item2idx {m: i for i, m in enumerate(uniq_items)} ratings[uid] ratings[user].map(user2idx) ratings[iid] ratings[item].map(item2idx)这段代码里的user2idx、item2idx是全局映射训练、验证、测试三个集合必须共用不能各自重新构建。实际工程里我踩过一次这个问题按天切分测试集后单独执行了unique()导致同一部电影在训练集和测试集中的索引不一致推荐结果看起来是负分其实是映射错位。3.1.1 标题与题材的字符序列化CNN 分支的输入是把title genres拼起来后的字符序列。字符级 token 的好处是不依赖外部词向量文件任何语言的题材词都能直接编码缺点是单个字符的信息量低所以序列长度要留足 32。做法与代码from collections import Counter def build_char_vocab(texts, max_vocab3000, seq_len32): counter Counter() for t in texts: counter.update(str(t).lower()) chars [PAD, UNK] [c for c, _ in counter.most_common(max_vocab)] return {c: i for i, c in enumerate(chars)} def text_to_seq(text, char2idx, seq_len32): seq [char2idx.get(c, char2idx[UNK]) for c in str(text).lower()[:seq_len]] seq [char2idx[PAD]] * (seq_len - len(seq)) return np.asarray(seq, dtypenp.int64)有三处容易踩坑。第一截断必须在映射之前进行否则seq_len里混入的标点会挤占有效字符位置。第二char2idx构建完成后直接冻结不要再往里面加新字符否则同一批数据在不同 epoch 的采样顺序下某个罕见字符可能只在后半程出现导致训练信号不稳定。第三PAD必须处于索引 0这样nn.Embedding(padding_idx0)才能保证 padding 位置在反向传播时梯度清零。3.2 TextCNN物品编码器实现3.2.1 卷积核与池化设计一维卷积核大小取 2、3、5分别覆盖 bigram、trigram 和 5-gram 窗口。题材字段里的 Childrens 这类带撇号的短语要跨 3 个以上字符窗口才能被完整描述因此 kernel 不能只设 2。实现如下import torch import torch.nn as nn import torch.nn.functional as F class TextCNNEncoder(nn.Module): def __init__(self, vocab_size, embed_dim64, num_filters64, kernel_sizes(2, 3, 5), dropout0.3): super().__init__() self.emb nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, paddingk // 2) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) def forward(self, seq): # seq: (B, L) x self.emb(seq).transpose(1, 2) # (B, embed_dim, L) pooled [] for conv in self.convs: act torch.relu(conv(x)) # (B, num_filters, L) pooled.append(F.adaptive_max_pool1d(act, 1).squeeze(2)) out torch.cat(pooled, dim1) # (B, num_filters * 3) return self.dropout(out)paddingk // 2是为保留序列边界处的字符。若 padding 为 0卷积后长度变为 L-k1虽然自适应池化仍能输出定长但标题首尾的年份括号这类字符会被丢弃题材串比较短边界信息占比高所以我把 padding 保留。adaptive_max_pool1d(act, 1)就是全局最大池化它把每个滤波器窗口内的最大激活取出来等价于找出该 n-gram 模式在文本中的最强响应。最终输出维度是num_filters * len(kernel_sizes)三个核各产生 64 维拼接成 192 维。3.3 融合预测模型 CNNMF完整模型在编码器外面包裹矩阵分解主体。文本向量通过text_proj投影到与p_u、q_i相同的 k 维空间再叠加到物品向量上class CNNMF(nn.Module): def __init__(self, n_users, n_items, vocab_size, k64, alpha0.3): super().__init__() self.user_emb nn.Embedding(n_users, k) self.item_emb nn.Embedding(n_items, k) self.item_bias nn.Embedding(n_items, 1) self.text_encoder TextCNNEncoder(vocab_size) self.text_proj nn.Linear(64 * 3, k, biasFalse) self.alpha nn.Parameter(torch.tensor(float(alpha))) self.global_bias nn.Parameter(torch.zeros(1)) def forward(self, uid, iid, seq): p self.user_emb(uid) # (B, k) q self.item_emb(iid) # (B, k) c self.text_proj(self.text_encoder(seq)) # (B, k) pred self.global_bias self.item_bias(iid).squeeze(1) pred pred (p * (q self.alpha * c)).sum(dim1) return predalpha初始化为 0.3 而不是 0。初始为 0 时文本分支在前几个 epoch 几乎没有有效梯度收敛速度会明显变慢给一个小的正初值能让卷积层从第一轮就开始学习。text_proj去掉 bias因为偏置可以被item_bias吸收留着只会增加调参难度。3.4 训练循环与评估数据划分采用 80/10/10 的随机切分。序列字段在进入DataLoader前统一转成torch.long评分保持浮点from torch.utils.data import DataLoader, TensorDataset train_df[seq] train_df[title_text].map(lambda s: text_to_seq(s, char2idx)) val_df[seq] val_df[title_text].map(lambda s: text_to_seq(s, char2idx)) to_long lambda a: torch.tensor(a, dtypetorch.long) train_ds TensorDataset( to_long(train_df[uid].values), to_long(train_df[iid].values), to_long(np.stack(train_df[seq].values)), torch.tensor(train_df[rating].values, dtypetorch.float32), )然后进入训练循环。评估指标选 RMSE每轮结束后在验证集上计算并保存最优权重model CNNMF(n_users, n_items, len(char2idx), k64) opt torch.optim.Adam(model.parameters(), lr2e-3, weight_decay1e-5) train_loader DataLoader(train_ds, batch_size512, shuffleTrue) val_loader DataLoader(val_ds, batch_size1024, shuffleFalse) best_rmse, bad_epochs float(inf), 0 for epoch in range(30): model.train() for uid, iid, seq, r in train_loader: opt.zero_grad() loss F.mse_loss(model(uid, iid, seq), r) loss.backward() opt.step() model.eval() with torch.no_grad(): for uid, iid, seq, r in val_loader: pred model(uid, iid, seq) val_rmse torch.sqrt(F.mse_loss(pred, r)).item() if val_rmse best_rmse: best_rmse val_rmse bad_epochs 0 torch.save(model.state_dict(), checkpoint.pt) else: bad_epochs 1 if bad_epochs 4: break print(fbest RMSE: {best_rmse:.4f})注意batch_size不要直接照搬图像任务的 128。ml-1m 有 100 万条评分512 是一个比较稳的折中——太大更新过于平滑embedding 在稀疏用户上收敛慢太小则每个 batch 覆盖用户过少梯度噪声明显。weight_decay这里给 1e-5 就够过大的正则会把预测整体压向均值验证集 RMSE 看着很低rank 信息却没了。4. 训练诊断与参数调优冷启动、发散和过拟合源码根目录下的优化和修改.txt记录了作者调参时踩过的几个点其中最值得说的是两条任何改动一次只动一个变量先固定嵌入维度把学习率和 α 调好再去动卷积核配置。本节按这个顺序展开。4.1 先做正交化CNN分支有没有贡献新代码到手不要直接调参先验证一个基本假设文本分支到底有没有用。方法很简单冻结 α 为 0把模型降级成纯矩阵分解model.alpha.data.fill_(0.0) model.alpha.requires_grad_(False)用同样的数据切分、同样的优化器和同样的随机种子训练一轮。然后将它与正常 α 的 CNNMF 结果对比一般会出现三种结果纯 MF 验证集 RMSE 反而更高且差距稳定存在文本分支提供了有效信息继续调参是有意义的两者几乎一样说明文本特征没有进入有效表征先检查seq_len是否把题材截断、检查num_filters是否太小加了 CNN 之后训练 loss 下降但验证集变差文本分支过拟合了稀疏物品需要提高dropout或降低alpha初值。这个对比必须严格控制变量。很多人在这里翻车是因为 α 为 0 时没有同时固定随机种子导致两个模型的 embedding 初始化不同最后把随机波动当成了模型差异。PyTorch 里设置torch.manual_seed(42)并在DataLoader上同样固定generator才能得到可解释的对比。4.2 冷启动场景的复现验证冷启动不只是产品问题它可以直接转化成可复现的实验从训练集里剔除一部分物品的 80% 评分保留它们指向的测试集看模型还能不能对这些物品给出合理预测。实际操作是先选 200 个物品作为 holdout 组再按行掩码删除训练数据np.random.seed(42) n_items len(item2idx) holdout_items np.random.choice(n_items, 200, replaceFalse) is_holdout train_df[iid].isin(holdout_items) drop_mask is_holdout (np.random.rand(len(train_df)) 0.8) train_drop train_df[~drop_mask].copy()训练结束后按组别分别评估 RMSEdef eval_rmse(model, df): seqs torch.tensor(np.stack(df[seq].values), dtypetorch.long) uid torch.tensor(df[uid].values, dtypetorch.long) iid torch.tensor(df[iid].values, dtypetorch.long) with torch.no_grad(): pred model(uid, iid, seqs) return torch.sqrt(F.mse_loss(pred, torch.tensor(df[rating].values, dtypetorch.float32))).item() print(holdout RMSE:, eval_rmse(model, test_df[test_df[iid].isin(holdout_items)])) print(普通物品 RMSE:, eval_rmse(model, test_df[~test_df[iid].isin(holdout_items)]))如果两者差距在 15% 以上说明模型对稀疏物品的泛化仍然不足。此时优先动两个位置alpha初值往上调到 0.6加大文本分支的贡献或者把kernel_sizes换成(1, 2, 3)针对较短题材序列做细粒度特征提取。若差距已经缩小到 5% 以内说明 CNN 分支在冷启动上确有增益可以进入参数表微调。4.3 关键参数表与调参逻辑下面是这套源码在 ml-1m 上最常用的参数组合以及各参数异常时的现象和处置方式参数建议值现象与处理学习率1e-3 ~ 3e-3前 2 个 epoch RMSE 不降则升到 5e-3震荡发散则降到 5e-4嵌入维度 k64 ~ 128k 翻倍验证集提升不足 0.1% 时问题不在表示能力num_filters64内容特征弱时可降到 32 验证信号显存有余再上 128kernel_sizes(2, 3, 5)标题题材很短时用 (1, 2,3)避免零填充占比过大seq_len32题材后半段被截断时调成 64alpha 初值0.3训练后落在 [0.5, 1.2] 说明文本分支贡献合理weight_decay1e-5 ~ 1e-4预测全部向均值靠拢时检查是不是这里过大调参顺序也有讲究。我一般先固定 k64只用纯 MF 做一次基线记下 RMSE 和运行时间再打开文本分支调 α。α 稳定后再考虑是不是需要更大的 kk 提升带来的收益通常比 α 小一个数量级优先处理贡献大的那个变量。优化和修改.txt里有一句类似的结论——「先让模型跑起来再让模型跑得好」说的就是这个顺序。5. 从评分回归到Top-N推荐输出层的实战切换评分预测只是回归任务推荐系统要交付的是排序列表。把 CNNMF 迁移到 Top-N 推荐核心是把 pointwise 预测值转换为列表排序。5.1 候选集生成与未看过的物品去重对目标用户把候选物品逐批送入模型打分再将用户已经评过分的物品分数替换成负无穷避免重复推荐def recommend_top_n(model, uid, seen_idx, item_ids, title_seqs, k10): model.eval() uid_t torch.full((len(item_ids),), uid, dtypetorch.long) iid_t torch.arange(len(item_ids), dtypetorch.long) with torch.no_grad(): scores model(uid_t, iid_t, title_seqs).cpu().numpy() scores[seen_idx] -np.inf top_idx np.argsort(scores)[-k:][::-1] return [item_ids[i] for i in top_idx]seen_idx必须是映射后的稠密 iid 索引。整个推荐列表的生成瓶颈不在模型打分而在 title_seqs 的构造因为每部电影都要过一次 TextCNN因此线上做法往往是预计算每部电影的c_i TextCNN(seq_i)推荐时只做p_uᵀ(q_i α c_i)的向量乘法。5.2 用 HitRateK 做验证测试时把每个用户的最后一次评分当作 ground truth从训练数据中移除再检查推荐列表是否命中def hit_rate(recs, ground_truth): hits sum(1 for r, g in zip(recs, ground_truth) if g in r) return hits / len(recs)经验上候选集不要全库遍历。我在 ml-1m 上把候选集限定为训练集 top-500 热门物品HitRate10 几乎不变但单次推荐耗时下降了一个数量级再往下压到 200 时命中率开始明显掉。原因是评分行为高度偏向头部内容全库遍历的边际收益很低——这个压缩策略在课程设计和生产环境里都适用。需要给足长尾曝光时再把超出热门池的新物品单独过一遍内容评分与 MF 得分加权合并权重按池外物品的冷启动置信度递减。这就是 CNN 分支在生产侧最有价值的一种用法让矩阵分解之外的冷门内容仍然有资格进入候选列表。本文还有配套的精品资源点击获取