用CNN从电影评论提取特征,构建内容感知推荐系统
简介一份基于卷积神经网络CNN的电影推荐系统PyTorch实现面向推荐系统与NLP学习者演示如何从电影评论文本中提取局部特征并通过与矩阵分解结合构建ConvMF模型让用户评论转化为可操作的偏好向量提升电影推荐准确度。压缩包共15个文件、约15KB以8个Python源文件为核心覆盖数据管理、文本分析、CNN模型、训练及评估等环节另附2个Shell运行脚本、编译缓存与说明文档便于快速理解项目结构并复用代码。内容包括数据预处理流程、CNN特征提取网络、矩阵分解融合模块以及端到端训练脚本配合README和运行命令可在PyTorch环境中复现实验也可作为课程设计或入门推荐系统的实战参考。目前已有85人学习下载适合希望掌握CNN文本特征提取与推荐系统结合思路的开发者参考。1. 用 CNN 把电影评论变成推荐特征先解决“没评分怎么推荐”的问题一部刚上映的电影在推荐系统里往往只有几百人评分协同过滤矩阵那一行几乎是空的但如果把影评文本丢给卷积神经网络CNN做特征提取不用等用户攒评分也能把“宏大叙事、导演构图、配乐风格”这些语义压缩成一个向量再拿去和用户偏好向量做交互。这就是用 CNN 提取电影评论特征构建电影推荐系统的核心思路评分矩阵负责记住“谁喜欢什么”CNN 负责理解“电影为什么值得被喜欢”。这篇文章面向有 PyTorch 基础、想自己复现一套“评论特征 → 电影向量 → 打分预测”流程的从业者。我会给出可以直接搬用的数据预处理、TextCNN 编码器、双塔训练循环以及我实际踩过的坑。整体链路不复杂但维度转置、填充掩码、正负样本比例这几处做不好就是模型原地打转的那类翻车点。2. 为什么用 CNN 抽取电影评论特征比 LSTM 更适合生产环境的三个理由文本特征抽取的选项很多把 CNN 放到电影评论上是很务实的选择评论句子通常几十到上百词局部短语对情绪影响最大CNN 的小卷积核正好扫这几个词窗口并行度高训练也远快于 LSTM。在推荐系统这种“每天要重新召回全量物品”的场景里这个优势会被放大。推荐链路一般是四段评论文本 → CNN 编码器 → 电影语义向量 → 与用户向量做交互打分。先把这个链路拆清楚后面写 PyTorch 代码时才不会把“文本分类”和“推荐召回”两件事混在一起。2.1 先明确特征提给谁用把“一条条评论”压缩成“一部电影的内容向量”如果你把每一条用户评论当独立样本CNN 提取的是这条评论的情绪向量但如果目标是推荐最终要做的是预测“某个用户会不会喜欢某部电影”电影特征和用户偏好特征必须落在同一个向量空间里。常见做法有两种一是把同一部电影的所有评论拼接成长文档一条样本过 CNN二是每条评论单独过 CNN再对同一部电影的所有向量做均值或加权平均。我一般选后者因为电影长评和短评的信息密度差很大直接拼接文档会让长评主导整部电影的表达分开编码再聚合能保留“短评一句话爆点”的特点。这里要预留一个 attention 池化的位置等数据量上来后再决定是简单平均还是按评论质量加权。2.2 一维卷积为什么合适看 2-gram、3-gram不做全句建模CNN 处理文本时把词嵌入序列当作一维信号。卷积核 size3就是滑动查看三个连续词的组合相当于快速扫过一组词窗口。“not good”是 2 元词组“a must watch”是 3 元词组“visually stunning but slow”是 4 元词组多尺寸卷积核并行就是在不同短语粒度上同时提取信号。推荐项目里我习惯用下面这组基线参数先跑通再调组件常用取值对电影评论的作用嵌入维度128词向量表达能力与显存消耗的折中卷积核尺寸[2, 3, 4]覆盖 2/3/4 元词组窗口每尺寸卷积核数量256每个窗口大小能提取多少种局部特征池化方式全局最大池化保留每个特征通道里响应最强的位置输出维度128与用户嵌入同维度方便做内积交互全局最大池化在这里不是随便选的。影评里“这部电影的摄影美到失语”这种情绪可能只出现在整条评论的一处max pooling 能抓住最强信号average pooling 会把大量中性词拉平反而把情绪峰值抹掉。这也解释了为什么 TextCNN 在短文本情感和偏好建模上一直没被淘汰。2.3 推荐层用双塔而不是裸余弦结构与基线文本向量化完成后最后一步是把电影向量和用户向量做交互。直接用余弦相似度最省事但它默认“语义空间”和“用户偏好空间”已经几何对齐实际没训练过的特征并不满足这个假设。我更推荐双塔结构电影塔就是 CNN 编码器用户塔是 Embedding 或历史行为池化两个向量点积做召回concat 进 MLP 做排序。两个塔被同一个监督信号评分或点击校正CNN 提取的就不是“单纯语义”特征而是“能解释用户行为”的特征。这里先记住结论第 4 章会给完整代码。3. 用 PyTorch 搭建 TextCNN 特征提取器数据处理与 Conv1d 转置进入代码前先说数据来源。常见做法是用 MovieLens 评分集合做交互标签再对到 IMDb 上的用户评论文本没有现成文本时用各自的公开子集也能跑通。字段至少需要user_id、movie_id、rating、text。3.1 预处理词表、随机截断与 Dataset第一步是分词和建词表。英文 IMDb 评论直接用空格切分就行中文评论则换成 jieba 之类工具切词。词表大小我一般压到 3 万左右只保留出现 3 次以上的词低频词大多是拼写错误或人名留一个unk占位就好。import random import pandas as pd from collections import Counter from torch.utils.data import Dataset, DataLoader # 假设 CSV 字段: user_id, movie_id, rating, text df pd.read_csv(movie_reviews.csv) df df.dropna(subset[text, user_id, movie_id]) def tokenize(text: str): # 英文评论按空格切中文换成 jieba.lcut 即可 return text.lower().split() def build_vocab(texts, max_size30000, min_count3): freq Counter() for t in texts: freq.update(tokenize(t)) vocab {pad: 0, unk: 1} for w, c in freq.most_common(max_size): if c min_count: # 只出现 1~2 次的词基本是噪声 break vocab[w] len(vocab) return vocab这段代码里max_size控制最终词表体积min_count是低频词过滤阈值。两个参数不要贪大词表越大Embedding 层越难训练绝大多数冷门词学不到有效向量过滤太狠又会丢一些人名和导演名。3 万词表、最低 3 次共现是电影评论任务里比较稳的起点。接着做定长样本。长评论直接截开头会让模型只见过“开头模板”我一般对超过长度的部分做随机截断短评论则在尾部补pad。这个步骤决定了后面 Conv1d 的输出形状必须在 Dataset 里完成。MAX_LEN 128 def encode_review(text: str, vocab): toks tokenize(text) if len(toks) MAX_LEN: # 随机截一段而不是只保留开头 start random.randint(0, len(toks) - MAX_LEN) toks toks[start:start MAX_LEN] ids [vocab.get(w, 1) for w in toks] # 未登录词映射到 unk1 ids [0] * (MAX_LEN - len(ids)) # pad0 return ids class ReviewDataset(Dataset): def __init__(self, df, vocab): self.data df.values self.vocab vocab def __len__(self): return len(self.data) def __getitem__(self, i): uid, mid, rating, text self.data[i] label 1.0 if rating 4.0 else 0.0 # 先把评分二值化细节见第 4 章 return (int(uid), torch.tensor(encode_review(text, self.vocab), dtypetorch.long), torch.tensor(label, dtypetorch.float32))这里把输出结构固定成(user_id, token_ids, label)对应后面推荐模型的三个输入。MAX_LEN128对 IMDb 评论够用如果你接的是短评数据可以压到 64训练更快。要注意随机截断的随机种子否则每次 epoch 同一评论的切片都不同会变相增加样本方差调试时很困惑。3.2 TextCNN 编码器embedding 后要 permute池化用 maxTextCNN 的 PyTorch 实现不长但有一个非常容易翻车的点nn.Conv1d期望输入是(batch, channel, length)而nn.Embedding出来是(batch, length, channel)。中间必须做一次permute(0, 2, 1)否则一定报维度错。import torch import torch.nn as nn class TextCNNEncoder(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters256, kernel_list(2, 3, 4)): super().__init__() self.embed nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k) for k in kernel_list ]) self.relu nn.ReLU() self.proj nn.Linear(len(kernel_list) * num_filters, 128) def forward(self, token_ids): x self.embed(token_ids) # (B, L, E) x x.permute(0, 2, 1) # (B, E, L)Conv1d 要求的形状 pooled [] for conv in self.convs: h self.relu(conv(x)) # (B, F, L - k 1) pooled.append(h.max(dim2).values) # 每个通道取最强响应 x torch.cat(pooled, dim1) # (B, F * 3) return self.proj(x) # (B, 128)逐行看参数embed_dim128是词向量维度也是 Conv1d 的输入通道数num_filters256控制每种核尺寸提取多少种局部特征kernel_list(2,3,4)决定扫描的词窗口大小。三个卷积输出各自是(B, 256, L-k1)全局 max pooling 后变成(B, 256)拼接成 768 维最后投影到 128与用户 Embedding 同维度。投影层不是可有可无。768 维直接去做内积训练初期高维冗余会把有监督信号稀释压到 128 相当于强迫 CNN 把“情绪”、“类型”、“叙事结构”这些信息浓缩到低维空间。这个做法在双塔召回里很常见也方便后续做 ANN 检索。4. 从评论特征到电影推荐系统双塔评分模型与训练循环CNN 编码器只是“电影塔”推荐系统还需要一个“用户塔”和交互层。用户塔可以是一张 Embedding 表也可以是用用户历史电影向量的均值池化先用 Embedding 表跑通再换复杂塔。4.1 交互模型点积召回与 concatMLP 排序交互层我一般做两层召回阶段用用户向量和电影向量的内积排序阶段把两个向量拼起来过一层 MLP。内积快能支撑全量候选打分MLP 能建模向量之间的非线性关系但只用来精排。import torch.nn.functional as F class HybridRecommender(nn.Module): def __init__(self, cnn_encoder, num_users, user_dim128): super().__init__() self.movie_tower cnn_encoder self.user_emb nn.Embedding(num_users, user_dim) # 精排塔用户向量和电影向量拼接后做非线性交互 self.score_head nn.Sequential( nn.Linear(user_dim * 2, 64), nn.ReLU(), nn.Linear(64, 1), ) def forward(self, user_id, movie_text): movie_vec self.movie_tower(movie_text) # (B, 128) user_vec self.user_emb(user_id) # (B, 128) x torch.cat([user_vec, movie_vec], dim1) return self.score_head(x).squeeze(-1)前向里把用户 ID 和评论文本同时喂进去。训练时用score_head输出做损失计算上线召回时只取movie_tower输出的电影向量和user_emb做内积或 ANN 近邻。需要注意的是如果数据量只有几万样本concatMLP很容易过拟合把模型逼成“按流行度排榜”的状态我一般先把点积版本跑通确认特征有效后再上 MLP。4.2 把显式评分变成隐式偏好正样本与负采样直接拿 1-5 分做 MSE 回归不是不行但推荐系统里更适合把它当成“用户是否会喜欢”的二分类问题。因为评分带有用户个体偏差有人给所有电影打 4 分以上有人只给自己钟爱的电影打高分。二值化能消除一部分尺度偏差。def build_pair_dataset(seen_pairs, all_items, item_comment_map, num_neg1): pairs [] for uid, pos_mid, pos_text, label in seen_pairs: pairs.append((uid, pos_mid, pos_text, label)) # 每条正样本配 1~4 条负样本负样本从全量电影里随机抽 for _ in range(num_neg): neg_mid random.choice(all_items) neg_text item_comment_map[neg_mid] pairs.append((uid, neg_mid, neg_text, 0.0)) return pairs这个函数的关键是负样本抽取。随机负采样最简单也最稳适合起步进阶可以用“用户看过但没评分”的电影做负样本但那需要额外行为日志。num_neg1是最低配置我实际跑电影评论任务时常用num_neg3正负样本比接近 1:3模型 AUC 提升明显也不会把训练拖慢太多。4.3 训练循环BCE、梯度裁剪和几个常用超参推荐模型的训练和一般分类任务没有本质差异但有两个细节值得单独说一是文本 Embedding CNN 在 batch 内容易出现梯度爆炸需要clip_grad_norm二是损失用binary_cross_entropy_with_logits不要在模型里先sigmoid。model HybridRecommender(cnn_encoder, num_userslen(user_vocab)) optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max10) for epoch in range(20): model.train() running_loss 0.0 for uid, text, label in loader: pred model(uid, text) loss F.binary_cross_entropy_with_logits(pred, label) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防止梯度爆炸 optimizer.step() running_loss loss.item() scheduler.step() # 每个 epoch 后在验证集上构造同样的负采样对盯 AUC 或 Hit50学习率1e-3、余弦退火、梯度裁剪1.0是我在这类小规模文本推荐上常用的组合。如果你的数据量很大学习率可以放到2e-3如果发现 loss 在第一个 epoch 就掉到接近 0先检查是不是数据泄漏别急着调模型。5. 复现期间最容易翻车的 4 个坑维度、填充、样本比例与冷启动这一节写的是我自己跑这个方案时被绊过的四个位置现象和解决办法都比较具体照着查能省半天。5.1 Conv1d 报维度错Embedding 出来跟卷积要求的形状不是一回事现象RuntimeError: Expected 3D input with 1D conv代码逻辑看起来没问题但模型就是跑不起来。原因nn.Conv1d要求的输入是(batch, channels, length)而nn.Embedding的下一步永远是(batch, length, embedding_dim)也就是 channel 和 length 两个维度对调了。解决x self.embed(token_ids) x x.permute(0, 2, 1) # (B, L, E) - (B, E, L)这行代码应该在每个 TextCNN 实现里出现。如果你看过不少 PyTorch 卷积实现会发现有人写成x x.transpose(1, 2)效果一样。关键是要形成条件反射看见 Embedding 接 Conv1d先转置。5.2 padding 变成“假特征”全局最大池化选到了无意义位置现象训练 loss 正常下降验证集 Hit10 也还行但把命中结果拿出来看不少短评论电影靠“高频词”混进前排语义上完全说不通。原因固定长度填充后卷积在 padding 位置也会产生响应全局最大池化只挑最大响应一旦真实文本太短最大响应可能落在 padding 边界上模型等于学到了“这里被填充过”这种假信号。解决第一道防线是随机截断而不是无脑补零第二道防线更严格在池化前把超出真实长度的位置掩成-inf# conv 输出形状: (B, F, L - k 1) out self.relu(conv(x)) valid_len seq_len - k 1 mask torch.arange(out.size(2), deviceout.device) valid_len[:, None] out out.masked_fill(mask[:, None, :], float(-inf)) pooled.append(out.max(dim2).values)这段代码里valid_len是去掉 padding 后有效卷积输出的长度。要注意seq_len必须是每条样本的真实长度不是MAX_LEN。想到这一步你的模型才真正在学评论语义而不是在学 padding 位置。5.3 正样本占到 80%模型学会“预测高分”现象所有样本的预测值都集中在 0.7 附近AUC 只有 0.5等于没学。原因电影评分分布天然偏向高分很多数据集里 rating4 的比例超过 70%。直接把评分二值化成标签正负样本严重失衡BCE 损失的梯度被数量占优的正样本主导。解决方案分两步第一步正样本阈值不要拍脑袋用 4.0看评分分布用前 30% 分位切第二步负采样让每批正负比例至少 1:1建议 1:3。数据侧的事不要指望改损失函数兜底。5.4 语义相似不等于行为相似余弦召回结果“可解释但不可用”现象用 CNN 电影向量做余弦相似度召回给喜欢《星际穿越》的用户推荐《银翼杀手 2049》都是科幻片语义上说得通用户就是不看。原因CNN 学到的语义相近和“用户愿意看”之间还隔着一层个人偏好。同是科幻片《火星救援》和《银翼杀手 2049》的受众重合度并不高没有用户行为信号校正纯语义向量无法自动对齐到用户兴趣空间。解决不要直接用电影向量做最终推荐向量把用户 Embedding 塔训练起来让语义向量和用户向量在同一个打分函数里被监督信号校正。第 4 章的双塔结构就是这个目的。遇到这类坑老实加交互层比换更大模型更重要。6. 验证特征质量用近邻检索与消融实验别让 CNN 变黑匣子模型训练完先别看排行榜我会先做一次近邻检索确认电影向量“看得懂”再谈线上指标。6.1 近邻检索检查电影向量是否“看得懂”model.movie_tower.eval() with torch.no_grad(): movie_vec model.movie_tower(torch.tensor(all_review_ids)) movie_vec F.normalize(movie_vec, dim-1) query movie_vec[inception_index] # 比如《盗梦空间》 sims movie_vec query top sims.argsort(descendingTrue)[:10]这里用余弦相似度排出《盗梦空间》的最近邻。如果 top 10 里出现《星际穿越》《信条》这类导演或叙事风格接近的影片说明 CNN 抓到的确实是电影语义如果近邻完全东拼西凑结构上有 bug 而 loss 又正常立刻回去检查 5.2 节的填充掩码。查询电影理想近邻对应特征《盗梦空间》《星际穿越》《信条》导演风格、多层叙事《寄生虫》《燃烧》《杀人回忆》韩国现实主义、社会议题6.2 消融实验看 CNN 特征到底贡献了多少指标下一步做三组对照只用协同过滤 Embedding、只用 CNN 内容特征、两者 concat 进 MLP。我这里的结果通常符合一个规律冷启动电影上 CNN 内容特征明显占优老片热片上协同过滤更高拼接后总体 Hit10 最高冷门电影不至于永久沉底。所以现在我的习惯很固定先跑通近邻检查再开负采样训练最后才看排行榜指标。第一版如果只有显式评分 MSE我会优先改成二值偏好加负采样这个改动对真实收益往往比换更大的预训练模型更明显。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →