尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CNN矩阵分解协同过滤:内容特征融合的电影推荐系统

CNN矩阵分解协同过滤:内容特征融合的电影推荐系统 简介面向电影推荐场景的算法学习与毕设项目资料包围绕“CNN矩阵分解协同过滤”构建完整推荐流程适合人工智能、电子信息、计算机等相关专业学生用于课程设计、毕业设计或项目初期演示。压缩包共28个文件、约7.4MB包含6个Python脚本、2个Jupyter Notebook、4个CSV数据文件、6个TXT说明文件以及DAT模型参数、可视化图像等辅助材料其中脚本与Notebook负责模型训练、评估和结果展示CSV/DAT是MovieLens评分数据TXT/MD则记录数据集说明与改进过程。包内收录ml-latest-small与ml-1m两类数据集并提供模型代码、“问题、优化、改进”等过程记录可帮助理解CNN与矩阵分解如何融入协同过滤推荐也便于在此基础上做二次开发或直接用于毕设、课设。目前已有54人学习下载代码经测试可运行适合不同基础的学习者快速上手并继续扩展。1. 基于CNN矩阵分解的协同过滤的电影推荐系统内容与协同的缝合点推荐系统里协同过滤是最早被验证有效、工业落地最广的范式矩阵分解是其中最核心的隐因子实现。但纯矩阵分解只依赖用户-物品评分矩阵遇到没有评分记录的新电影预测就退化成随机猜测。基于CNN矩阵分解的协同过滤算法来实现电影推荐系统正是把两条线焊在一起用CNN卷积神经网络从电影内容里抽取标题、类型、简介等特征再注入矩阵分解的隐向量让评分同时吃到协同信号和内容信号。CNN不是替代协同过滤而是给矩阵分解补上内容感知这是它与纯MF在架构上的根本差异。适合两类人一是做推荐方向毕业设计、需要能跑通讲清原理的完整源码的学生二是面对新物品冷启动、想用较轻模型把内容特征用起来的工程师。2. 协同过滤与矩阵分解的原理CNN卷积神经网络如何接入隐向量2.1 MF在拟合什么评分矩阵的低秩近似与矩阵特征值分解的边界协同过滤的基本假设是相似的人喜欢相似的东西。矩阵分解把它变成代数问题把用户-物品评分矩阵 Rm 个用户 × n 部电影近似拆成两个低秩矩阵的乘积R ≈ U V^T。U 是 m×k 的用户隐因子矩阵V 是 n×k 的物品隐因子矩阵k 远小于 m 和 n。第 i 个用户对第 j 部电影的预测评分就是两个隐向量的内积r_hat_ij u_i · v_j。训练目标是最小化已观测评分上的平方误差并对 U、V 做 L2 正则写成损失函数就是L Σ_(i,j)∈Ω (r_ij - u_i^T v_j)² λ_u‖U‖² λ_v‖V‖²其中 Ω 是所有有评分记录的集合。矩阵分解和线性代数里的矩阵特征值分解、SVD 是同一族思路特征值分解只能作用于方阵评分矩阵显然是长方形所以 MF 一般沿 SVD 的低秩近似路线走。但完整的稠密 SVD 在推荐场景里没法直接用——评分矩阵高度稀疏大部分元素缺失直接分解的计算代价也高。实际落地通常用两种方式一是交替最小二乘ALS固定 V 解 U 的闭式解再固定 U 解 V二是直接梯度下降把 U、V 当参数训练。这个标题下推荐第二种因为后面要接 CNN整个模型统一走反向传播最省事。基础 MF 在 PyTorch 里可以短成这样import torch.nn as nn class PlainMF(nn.Module): def __init__(self, num_users, num_items, k32): super().__init__() self.U nn.Embedding(num_users, k) # 用户隐因子表每行一个用户向量 self.V nn.Embedding(num_items, k) # 物品隐因子表每行一部电影向量 def forward(self, user_ids, item_ids): u self.U(user_ids) v self.V(item_ids) return (u * v).sum(dim1) # 逐元素相乘再求和等价于内积forward 里用(u * v).sum(dim1)实现内积而不是 torch.matmul是因为这里只需要批量样本的逐对内积不需要完整的 U V^T 矩阵乘法。Embedding 层本质是一张可训练的查找表每个用户或每部电影对应一行向量训练时只有被采样到的行会收到梯度。整个模型只有约 (mn)×k 个参数对 10 万级评分数据来说非常轻。2.2 CNN卷积神经网络的原理内容侧为什么选CNN而不是全连接这个标题里 CNN 负责的事情是把电影的内容信息编码成稠密向量。输入可以是三种形态类型标签组成的多热向量、标题简介组成的词序列、海报图像。前两种是文本任务第三种是图像任务但 CNN 的结构逻辑一致用固定宽度的卷积核扫过局部窗口把窗口内的信息组合成特征。以文本为例把电影的标题和类型拼成一句 Toy Story (1995) Animation Childrens Comedy分词后经过词嵌入层每个词变成一个向量句子变成 (max_len, embed_dim) 的矩阵。卷积核宽度对应 n-gram 的长度宽度 3 的核扫过连续三个词的组合宽度 4、5 同理。每个卷积核在整个序列上滑动权重共享卷积核数量决定提取多少种局部模式。接着对每个特征图做最大池化把该模式在文本里出现的最强响应保留下来最后把不同宽度的池化结果拼接成一条特征向量。整个 CNN 结构图可以概括为嵌入 → 卷积 → 池化 → 拼接 → 投影五个环节。这也是深度学习 CNN 分支在这个项目里的标准形态。为什么不用全连接网络全连接把整段文本拍平成一个大向量第一个隐含层的每个神经元都要看到全部词位参数量随序列长度线性膨胀而且对哪些词挨在一起能表达类型语义这种局部结构不敏感。CNN 的局部窗口天然对应 n-gram 模式池化保证输入长度不一致时输出维度固定。对电影推荐这种短文本内容3 个不同宽度的卷积核通常就够用这也是为什么这个方向的开源实现和毕业设计源码几乎都采用 TextCNN 而不是 LSTM——训练更快、并行度高短文本上效果持平。2.3 内容特征与隐向量的三种融合方式为什么推荐相加式CNN 抽出的内容特征要进入矩阵分解的评分公式必须和物品向量对齐。常见代码里能看到三种融合方式直接决定 forward 的写法融合方式物品向量构造优点代价拼接[v_j ‖ cnn(x_j)]用户侧也对齐拼接后内积信息保留完整逻辑直观向量维度翻倍需要额外全连接映射回 k 维相加v_j cnn(x_j)评分 u_i^T (v_j cnn(x_j))保持 MF 公式不变冷启动时 v_j 无信号仍可预测CNN 输出必须投影到 k 维内容信号可能被均值化初始化用 cnn(x_j) 初始化 v_j再正常训练完全复用纯 MF 训练代码训练中内容特征被评分梯度覆盖冷启动收益衰减快这个标题下最值得采用的是相加式。理由有三条第一模型输出仍然是内积形式纯 MF 的评估、推荐、检索代码全部可以复用第二冷启动时 v_j 只有随机初始化值而 cnn(x_j) 由内容决定两者相加后至少内容部分是稳定的第三只要加一个 Linear 投影把 CNN 输出对齐到 k 维就能在不同内容源之间切换换成海报图像分支也不用动主模型。如果想往非负约束方向走可以给投影层加 softplus 激活让内容分支的输出保持非负这相当于半非负矩阵分解的深度化简化版特征维度会更容易解释。3. 搭一个能跑的CNN矩阵分解推荐系统数据流水线与核心源码3.1 MovieLens数据加载评分表和电影内容表的对齐做电影推荐系统最常用的公开数据是 MovieLens 100K。它包含 943 个用户对 1682 部电影的 10 万条评分评分范围 1 到 5。u.data 是制表符分隔的评分表列顺序是 user id、item id、rating、timestampu.item 是电影属性表列顺序是电影 id、标题、上映日期、视频发行日期、IMDb URL后面跟着 unknown 和 19 个电影类型的 0/1 标记。项目源码里无论文档怎么组织最终都要还原出这两张表。import pandas as pd def load_movielens(data_dirml-100k): ratings pd.read_csv(f{data_dir}/u.data, sep\t, names[uid, iid, rating, ts]) genre_cols [unknown, Action, Adventure, Animation, Children, Comedy, Crime, Documentary, Drama, Fantasy, FilmNoir, Horror, Musical, Mystery, Romance, SciFi, Thriller, War, Western] items pd.read_csv(f{data_dir}/u.item, sep|, encodinglatin-1, names[iid, title, date, video, url] genre_cols) items[text] items.apply( lambda r: r[title] .join( [g for g in genre_cols if r[g] 1]), axis1) return ratings, items两个细节容易踩坑u.item 是 ISO-8859-1 编码read_csv 必须传encodinglatin-1按 UTF-8 读会直接抛 UnicodeDecodeErrorgenre_cols 里 unknown 也算一列u.item 一共 24 列少写一列就会出现对齐错位。构建 text 时把标题和命中的类型名拼在一起是因为 MovieLens 没有剧情简介字段标题加类型是最低成本的内容信号。如果项目文档里带了外部抓取的 IMDb 或 TMDB 剧情简介直接把 text 这一列替换成简介文本即可后面所有代码不用改。3.2 文本序列化词典构建与批量数据集的封装CNN 吃的是整数序列不是字符串。需要做两件事把 text 分词并映射成 id再按固定长度 padding 成等长序列。import re import torch from collections import Counter from torch.utils.data import Dataset def build_vocab(texts, min_count2): counter Counter() for t in texts: counter.update(re.findall(r[a-z0-9], t.lower())) vocab {w: i 2 for w, i in counter.items() if counter[w] min_count} vocab[pad] 0 # 填充位Embedding 的 padding_idx 对应位置不更新 vocab[unk] 1 # 低频词统一映射到 unk return vocab class RatingDataset(Dataset): def __init__(self, ratings, item_text, vocab, max_len64): self.uids (ratings[uid].values - 1).astype(int) # id 从 0 开始 self.iids (ratings[iid].values - 1).astype(int) self.rates ratings[rating].values.astype(float) self.item_text item_text self.vocab vocab self.max_len max_len def __len__(self): return len(self.uids) def __getitem__(self, idx): tokens re.findall(r[a-z0-9], self.item_text[self.iids[idx]].lower()) ids [self.vocab.get(t, self.vocab[unk]) for t in tokens][: self.max_len] ids ids [self.vocab[pad]] * (self.max_len - len(ids)) return (torch.tensor(self.uids[idx]), torch.tensor(self.iids[idx]), torch.tensor(ids), torch.tensor(self.rates[idx]))build_vocab 里min_count2表示只保留出现 2 次以上的词把拼写噪声挡在词典外。RatingDataset 里 uid 和 iid 减 1是因为 MovieLens 的 id 从 1 开始而 Embedding 的索引从 0 开始。padding 放在序列末尾而不是开头对 CNN 影响不大但如果换成 LSTM 就应该考虑头部 padding。getitem返回四个张量用户 id、物品 id、文本 id 序列、评分DataLoader 会自动把它们堆成 batch。3.3 核心源码TextCNN编码器与矩阵分解主模型模型部分分两块TextCNN 编码器把文本序列变成内容向量矩阵分解分支把用户隐向量和物品向量融合成预测评分。import torch.nn as nn import torch.nn.functional as F class TextCNNEncoder(nn.Module): def __init__(self, vocab_size, embed_dim200, num_filters100, filter_sizes(3, 4, 5)): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.out_dim num_filters * len(filter_sizes) def forward(self, x): emb self.embedding(x).unsqueeze(1) # (B, 1, L, D) pooled [] for conv in self.convs: c F.relu(conv(emb)).squeeze(3) # (B, F, L-fs1) p F.max_pool1d(c, c.size(2)).squeeze(2) # (B, F) pooled.append(p) return torch.cat(pooled, dim1) # (B, F*3) class ConvMF(nn.Module): def __init__(self, num_users, num_items, k32, encoderNone): super().__init__() self.user_factors nn.Embedding(num_users, k) # 用户隐因子矩阵 self.item_factors nn.Embedding(num_items, k) # 物品基础隐向量 v_j self.encoder encoder self.proj nn.Linear(encoder.out_dim, k) # 内容向量对齐到 k 维 self.dropout nn.Dropout(0.2) def forward(self, uid, iid, text_ids): u self.user_factors(uid) # (B, k) v self.item_factors(iid) # (B, k) content self.proj(self.dropout(self.encoder(text_ids))) # (B, k) return (u * (v content)).sum(dim1) # 预测评分TextCNNEncoder 里每个卷积核形状是 (fs, embed_dim)等价于对连续 fs 个词的嵌入做加权组合卷积核第二个维度设成 embed_dim卷积只沿序列长度方向滑动。max_pool1d 把每个特征图压成一个标量取整个序列上的最强响应所以序列长度不等于 max_len 时输出维度也是固定的。ConvMF 的 forward 是相加式融合的落地v content得到物品最终向量u 与它做内积作为预测评分。如果想做纯 MF 基线对比把 content 直接置零主链路完全不变。4. 训练循环、RMSE评估与超参数调优的落地细节4.1 训练循环里的损失函数与正则化写法评分预测是回归任务损失用 MSE正则项按 PMF 的习惯对用户矩阵和物品矩阵整体做 L2。注意只对有评分的样本计算损失评分矩阵里大量空缺是未观测而不是零分不能当成负样本填 0 参与 MSE否则模型会把缺失值学成低分。import torch import torch.nn.functional as F def train_model(model, train_loader, valid_loader, device, epochs30, lr1e-3, reg_u0.01, reg_v0.01): opt torch.optim.Adam(model.parameters(), lrlr) scheduler torch.optim.lr_scheduler.StepLR(opt, step_size10, gamma0.5) for epoch in range(epochs): model.train() total 0.0 for uid, iid, text_ids, rating in train_loader: uid, iid, text_ids, rating (uid.to(device), iid.to(device), text_ids.to(device), rating.to(device)) pred model(uid, iid, text_ids) mse F.mse_loss(pred, rating) reg (reg_u * model.user_factors.weight.norm(2) ** 2 reg_v * model.item_factors.weight.norm(2) ** 2) loss mse reg opt.zero_grad() loss.backward() opt.step() total mse.item() * len(uid) rmse, mae evaluate(model, valid_loader, device) print(fepoch {epoch1}, train_mse {total/len(train_loader.dataset):.4f}, fval_rmse {rmse:.4f}, val_mae {mae:.4f})正则的粒度有两种写法对整张 Embedding 权重做 L2实现简单适合 10 万评分的小数据集对每个样本对应的隐向量子集做 L2更贴合 PMF 的推导但要自己按索引拼向量代码更啰嗦。这个规模用前者即可。StepLR 每 10 个 epoch 把学习率减半避免后期在最优值附近震荡。提示正式训练前先打印一个 batch 的预测 shape 和评分 shape确认都是 (B,) 再开跑id 索引越界是最常见的崩溃来源。4.2 RMSE/MAE评估函数与训练曲线判读def evaluate(model, loader, device): model.eval() preds, trues [], [] with torch.no_grad(): for uid, iid, text_ids, rating in loader: pred model(uid.to(device), iid.to(device), text_ids.to(device)) preds.append(pred.cpu()) trues.append(rating) preds torch.cat(preds) trues torch.cat(trues) rmse ((preds - trues) ** 2).mean().sqrt().item() mae (preds - trues).abs().mean().item() return rmse, mae数据划分建议按时间而不是随机把 u.data 按 timestamp 排序前 80% 做训练、后 20% 做测试训练部分再切出最后 10% 当验证集。推荐系统的预测方向永远是从过去到未来随机划分会把未来的评分泄露进训练集让任何模型都显得比真实效果好。训练曲线要盯验证集 RMSE如果它先降后升而训练 RMSE 一直降就是过拟合取验证集最低点的 checkpoint 即可如果两个 RMSE 都高先查数据拼接和 id 对齐有没有 bug而不是急着调参。4.3 超参数调优从默认值出发的调整顺序下面这组参数是这个方向源码里最常见的起点直接在默认环境就能跑参数尝试范围推荐起点影响隐向量维度 k16 ~ 12832k 太小欠拟合太大在 10 万评分上过拟合词嵌入维度 embed_dim100 ~ 300200与 k 独立受词典规模影响卷积核数量 num_filters50 ~ 200100特征图数量翻倍训练时间近似线性增长卷积核宽度 filter_sizes(2,3,4) ~ (3,4,5)(3,4,5)短文本用更窄的窗口正则系数 reg_u / reg_v0.001 ~ 0.10.01越大越压制隐向量范数缓解过拟合dropout0.1 ~ 0.50.2加在内容分支投影前batch_size128 ~ 512256影响收敛稳定性调整顺序应该是先固定 batch_size 和 lr用 k32、reg0.01 跑通全流程拿到验证 RMSE 基线然后调 k观察验证集误差的 U 形曲线再调正则系数让验证 RMSE 向训练 RMSE 靠近最后才动卷积核和 dropout。文本分支的超参数对结果的影响通常小于 k 和正则系数优先把矩阵分解侧调好。提示对比模型前在脚本顶部固定 torch.manual_seed(42)隐向量初始化不同会让两个模型的 RMSE 差出 0.01 量级不固定种子结论不可复现。5. 冷启动评估与隐向量检索的进阶用法5.1 用留出法模拟物品冷启动量化CNN分支的收益整份源码的价值最后要落在加了 CNN 分支到底值不值上。常规留出集评估测不出这个差距因为测试集里每部电影都有评分纯 MF 也能靠协同信号猜得不错。需要在数据层面人为制造冷启动随机抽 20% 的电影删掉它们的全部评分后做训练集用这些电影的评分做测试集。def cold_start_split(ratings, cold_ratio0.2, seed42): cold_items pd.Series(ratings[iid].unique()).sample( fraccold_ratio, random_stateseed).values train ratings[~ratings[iid].isin(cold_items)] test ratings[ratings[iid].isin(cold_items)] return train, test用同一份测试集分别评估 PlainMF 和 ConvMF纯 MF 对冷启动物品只能输出随机初始化的隐向量RMSE 会明显高于它的全量评估结果ConvMF 的物品向量是 v content内容部分由 CNN 决定预测质量下降幅度小得多。如果两个模型在冷启动集上的差距不明显先检查 text 拼接是否正常、vocab 里是不是大多数词都掉进了 这两种情况都会让 CNN 分支退化成常数向量。5.2 隐向量相似度检索与预测得分的归因拆解训练完成后把 v content 作为物品的最终向量保存下来用余弦相似度做相似电影检索。这一步既是推荐功能的组成部分也是验证内容分支是否学到语义的探针——Toy Story 的最近邻应该出现其他动画喜剧片而不是随机电影。torch.no_grad() def item_embeddings(model, item_ids, text_map, vocab, device, max_len64): model.eval() vecs [] for iid in item_ids: tokens re.findall(r[a-z0-9], text_map[iid].lower())[:max_len] ids torch.tensor([vocab.get(t, vocab[unk]) for t in tokens] [vocab[pad]] * (max_len - len(tokens))).unsqueeze(0) v model.item_factors(torch.tensor([iid]).to(device)) c model.proj(model.encoder(ids.to(device))) vecs.append((v c).cpu()) return torch.nn.functional.normalize(torch.cat(vecs), dim1) def topk_similar(mat, query_idx, k10): scores mat mat[query_idx] return scores.topk(k 1).indices.tolist() # 第 0 个是自身跳过推荐的解释能力也可以从同一个模型里拆出来预测评分 u_i^T (v_j content) 写成 u_i^T v_j 与 u_i^T content 两部分之和前者是协同过滤的贡献后者是内容匹配的贡献。对一部没有历史行为的冷门电影内容部分占比通常更高说明推荐理由是这部电影和你看过的类型相近这句话可以直接作为推荐解释文案的数据支撑。如果想让内容分支的语义更可控把 proj 的激活函数换成 softplus内容向量保持非负特征维度就能往动作含量喜剧含量这样的方向去解释这是半非负矩阵分解思路在 CNN 分支里的简化应用。本文还有配套的精品资源点击获取
返回列表