简介:面向电影推荐系统的卷积神经网络特征提取实战资源,基于PyTorch实现ConvMF模型,将卷积神经网络与矩阵分解技术结合,从用户评论中提取局部n-gram特征,并通过多层卷积抽象深层语义,从而刻画用户与电影之间的隐式关系,适合具备一定深度学习基础、希望深入掌握文本特征建模与推荐算法融合的开发者。压缩包共15个文件,以8个Python源码文件为主,覆盖数据管理、文本预处理、CNN模型定义、矩阵分解模型、训练与评估等模块;另有2个Shell脚本分别用于预处理和模型运行,配合说明文档、配置文件、缓存文件,整体约15KB,结构简洁、易于阅读与二次开发。目前已有85人学习,可作为理解ConvMF原理和PyTorch实现的参考项目。通过阅读代码与运行脚本,可快速复现从评论清洗、分词、向量化到卷积特征提取、模型训练调参的完整流程,掌握利用评论文本增强推荐系统准确性与个性化的实践思路,有效节省从零搭建模型的时间。
1. 用卷积神经网络(CNN)提取电影评论特征,在PyTorch里构建电影推荐系统
一句话概括这个标题:把卷积神经网络(CNN)当作评论编码器,在PyTorch里把电影评论映射成稠密特征向量,再用向量之间的相似度搭出电影推荐系统。用户刚给《沙丘2》写了六百字短评,系统要判断该不该推《银翼杀手2049》,靠的就是这条短评的CNN特征和候选影片评论特征之间的余弦距离。这方案最大的价值是绕开了手工设计关键词规则,也不用一上来就上BERT级别的大模型,一个轻量文本CNN在这类短文本上就能拿到能用的特征。适合谁?手头有评论和评分数据、想从零把“数据清洗—模型训练—离线评估—部署”全链路走通,又希望在深度学习CNN这条路上用PyTorch练一遍的从业者。
2. 评论数据怎么喂给CNN:分词、词表构建与DataLoader的完整PyTorch实现
2.1 评论文本和图像不一样:先过一层embedding才能进入卷积层
CNN最早在图像上站稳脚跟,因为图像天生就是规则的三维张量:高、宽、通道数,卷积核滑过去就是一个局部感知野。评论文本没有这种规则性。一句话的长度可以从三五个词拉到上千词,词与词之间也不像像素那样有均匀的空间间距,所以文本要想走CNN这条路,必须先做两次转换。第一步把每个词映射成整数索引,第二步再查embedding表,把整数索引变成稠密向量,最后组织成卷积层能吃的形状。
embedding层说穿了就是一张大查找表,行数等于词表大小,列数是你指定的embedding维度。它的特殊之处在于:不负责清洗文本,也不做正则化,只负责把离散的词语id放进一个连续向量空间。训练开始后,这一层会跟着反向传播一起更新,经常出现在相似评论里的词,向量会慢慢靠拢。这也是很多人把训练好的embedding层单独抽出来当词向量用的原因。
这里最容易误解的一点是:卷积层的输入通道数不是词表大小,而是embedding维度;词表大小只决定embedding矩阵有多少行。这个概念没理清,后面遇到维度报错时会在排查上多花半个小时。
2.2 从原始评论到batch:分词、词表、填充的代码与参数解释
常见工程做法是把数据准备拆成读取、分词、建词表、包装成DataLoader四块。先看前两块:
import pandas as pd import re from collections import Counter def tokenize(text): # 英文评论用正则做粗粒度分词;中文评论需要换成 jieba.cut return re.findall(r"[a-z']+|[.,!?;]", text.lower()) df = pd.read_csv("reviews.csv") # 至少包含 user_id, movie_id, text, rating 四列 df["text"] = df["text"].fillna("") df["tokens"] = df["text"].apply(tokenize) def build_vocab(token_lists, vocab_size=30000, min_freq=2): counter = Counter() for tokens in token_lists: counter.update(tokens) vocab = {"<PAD>": 0, "<UNK>": 1} for word, freq in counter.most_common(vocab_size - 2): if freq < min_freq: # 只出现一次的词直接丢进 UNK,减少噪声 break vocab[word] = len(vocab) return vocab vocab = build_vocab(df["tokens"])逻辑说明:tokenize这一步决定了下游特征的质量上限,英文评论用正则切分能保留常见标点,标点对情绪表达有作用;直接按空格切分会在“doesn't”“well-made”这类词上留下脏token。build_vocab里做了两件事,一是按词频截断到vocab_size,二是把出现次数低于min_freq的词挡在词表外,让它们统一落到 。注意 被安排在id=1,而不是0,因为0要留给 ,后面embedding层要用padding_idx=0让填充位恒为0向量。
参数说明:vocab_size取30000是短评场景的常见起点,太小会让OOV比例过高,太大则embedding矩阵膨胀;min_freq=2可以过滤掉拼写错误和刷评论产生的噪音词。这些参数在训练完看OOV占比时还要回来调。
再看Dataset和批量填充部分:
import torch from torch.nn.utils.rnn import pad_sequence from torch.utils.data import Dataset, DataLoader def encode(tokens, vocab, max_len=256): ids = [vocab.get(t, vocab["<UNK>"]) for t in tokens[:max_len]] return torch.tensor(ids, dtype=torch.long) class ReviewDataset(Dataset): def __init__(self, samples): self.samples = samples def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx] def collate_fn(samples): texts = [encode(s["tokens"], vocab) for s in samples] labels = torch.tensor([s["label"] for s in samples], dtype=torch.long) # 按当前 batch 内最长序列填充,PAD 的 id 固定为 0 padded = pad_sequence(texts, batch_first=True, padding_value=vocab["<PAD>"]) return {"text": padded, "label": labels} train_loader = DataLoader(train_samples, batch_size=128, shuffle=True, collate_fn=collate_fn)collate_fn是这批数据包装的核心。每个样本先被encode成不等长的LongTensor,pad_sequence会找出当前batch的最长序列,把短的补齐到同样长度,返回形状为[batch, seq_len]的张量。batch_first=True意味着第一维是batch,如果不设这个参数,返回的维度顺序和DataLoader默认的取法不一致,训练循环里会多一次转置。
2.3 词表大小与最大长度怎么定:三个参数影响模型体积和推荐效果
| 参数 | 常用范围 | 过小/过大带来的后果 |
|---|---|---|
| vocab_size | 20000 - 50000 | 过小:OOV比例高,大量评论词被压成同一个UNK,特征区分度下降;过大:embedding矩阵膨胀,训练变慢 |
| max_len | 128 - 256 | 过小:长评论后半段被直接截掉,丢失转折信息;过大:填充位太多,卷积在无效区域空转 |
| batch_size | 32 - 128 | 过小:梯度更新太频繁,loss抖动;过大:显存压力大,收敛速度反而变慢 |
这三个参数是联动的。词表越大,embedding层参数量越大,batch_size就要相应调小。通常30000词表配256长度、128的batch,在8G显存左右的卡上能稳定跑起来。判断截断长度是否合理,可以先看评论长度的分布,把95分位长度作为max_len的候选值;我一般会再留20%余量,而不是直接取最大值。
填充这件事还有个容易忽略的副作用:pad_sequence按实际batch内最长序列填充,所以不同batch的文本张量第二维可能不一样。这对训练没影响,因为Conv1d和MaxPool都不要求固定长度;但后面做ONNX导出时,输入形状必须固定,这个问题留到第6章处理。
3. TextCNN模型结构拆解:卷积核尺寸、通道数与池化方式一起定
3.1 把卷积想象成短语探测器:1维卷积与n-gram的关系
TextCNN的起点是把卷积用在句子分类上,核心观点一句话讲完:评论里的连续词窗口,可以被当成独立的“短语模式”来检测。kernel_size=3的一维卷积核,每次扫过三个相邻词的embedding,等价于一个trigram探测器;如果某个trigram经常出现在“视听语言”“节奏拖沓”这类短语里,对应卷积核的通道输出就会被激活。把2、3、4三种尺寸并排放,等于在同一层里同时做bigram、trigram和4-gram三种粒度的探测。
细节在于卷积核扫过的是embedding向量拼接后的矩阵,同一个短语在不同评论里即使换了上下文,只要词没变,激发模式就基本一致。这就是CNN做文本特征比词袋模型强的地方——它保留了局部词序信息。代价是感受野受限于kernel_size,“前面夸配乐后面骂剧本”这类跨句转折,TextCNN天然抓不到,这是选型时要接受的边界。
3.2 用PyTorch写出可复用的TextCNN模块:代码与逐层输出维度
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=100, num_filters=128, filter_sizes=(2, 3, 4), num_classes=2, dropout=0.5): super().__init__() # padding_idx=0 让 <PAD> 对应的向量恒为 0,不参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_size=ks) for ks in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): # x: [batch, seq_len] emb = self.embedding(x) # [batch, seq_len, embed_dim] emb = emb.permute(0, 2, 1) # [batch, embed_dim, seq_len] pooled = [] for conv in self.convs: out = torch.relu(conv(emb)) # [batch, num_filters, seq_len - ks + 1] pooled.append(out.max(dim=2).values) # 全局最大池化,取每个通道最强响应 feat = torch.cat(pooled, dim=1) # [batch, 3 * num_filters] feat = self.dropout(feat) logits = self.fc(feat) return logits, feat逐层过一遍形状。输入x是[batch, seq_len]的整数id;embedding之后变成三维,但顺序是[batch, seq_len, embed_dim]。Conv1d期望的输入是[batch, channels, length],所以必须用permute(0, 2, 1)把embed_dim换到通道位。每个卷积核尺寸下输出[batch, num_filters, seq_len - kernel_size + 1],然后沿长度维取最大值,得到[batch, num_filters]。三种尺寸各自池化后拼起来,形状是[batch, 3 * num_filters],也就是384维向量——这个向量就是推荐系统要用的评论特征feat。
参数说明:embed_dim=100是速度和表达力的平衡点,换300会有明显收益但训练时间也翻倍;num_filters=128意味着每个卷积核尺寸下同时有128个“短语探测器”,调大能提升模型容量,但过拟合风险跟着涨;dropout=0.5只在训练时随机屏蔽一半神经元,推理时Dropout自动关闭,所以提取特征前不需要手动切换,但必须确保model调用了eval()。
3.3 为什么先用CNN做特征提取而不是LSTM或BERT:三类方案的取舍
| 方案 | 参数量/显存 | 训练速度 | 对短评的建模能力 | 适合阶段 |
|---|---|---|---|---|
| TextCNN | 小 | 快 | 局部词序,够用 | 快速验证、构建基线 |
| LSTM/GRU | 中 | 慢 | 全序列顺序,但串行计算 | 需要捕捉整句递进关系时 |
| Transformer/BERT | 大 | 很慢 | 全局上下文 | 资源充足后的增强方案 |
推荐系统的特征质量对模型复杂度的边际收益是递减的。几百字影评里,风格和情感信息高度依赖局部表达,TextCNN的2/3/4窗口刚好覆盖这条主线;LSTM的优势在长距离依赖,用在短评上有点大炮打蚊子;BERT效果最好,但微调成本集中在显存和训练时间上。等CNN方案跑通、离线指标稳定,再考虑用蒸馏后的BERT特征做融合是更划算的路径。
4. 训练与推荐打分:在PyTorch里把评论特征变成推荐列表
4.1 先跑通一个情感分类辅助任务,让CNN学会“看懂”评论
提取评论特征的前提,是模型得先理解评论内容。最自然的监督信号是评分:把星级的正负倾向作为分类目标。常见做法是先把评分二值化,4星及以上算正面,2星及以下算负面,3星可以丢掉或归入离训练集。这样构造的label噪声不大,CNN在情感分类上能快速收敛,中间特征向量会顺带学会编码情感倾向和部分主题风格。
# 二值化评分:>=4 为正面,<=2 为负面,3 分样本可选择性剔除 df["label"] = df["rating"].apply(lambda r: 1 if r >= 4 else 0)这里不追求星级回归,原因有两点。一是二分类loss更平滑,训练更稳定;二是推荐系统要的不是精确星级,而是能区分“喜欢/不喜欢”的特征空间。等模型跑通后,如果想让特征更贴合推荐目标,可以把二分类头换成回归头,直接预测评分,训练目标从交叉熵换成均方误差。
4.2 训练循环与L2正则化:优化器、损失、checkpoint的实用写法
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = TextCNN(len(vocab)).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for batch in loader: x = batch["text"].to(device) y = batch["label"].to(device) optimizer.zero_grad() logits, _ = model(x) loss = criterion(logits, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * len(y) return total_loss / len(loader.dataset) for epoch in range(1, 11): loss = train_epoch(model, train_loader, optimizer, criterion, device) print(f"epoch {epoch}: train loss {loss:.4f}") if epoch % 5 == 0: torch.save(model.state_dict(), f"textcnn_epoch{epoch}.pt")weight_decay=1e-4就是PyTorch里常用的L2正则化实现,它让参数在每次更新时向零靠近一点,抑制噪声特征对模型的拉扯;clip_grad_norm_防的是梯度爆炸,文本CNN一般用不上,但加上能避免个别batch出现异常值把loss带到NaN。打印训练集loss而不是训练集准确率,是为了观察收敛趋势时更平滑,准确率在训练集上会虚高,loss方差更小。
保存checkpoint时只存state_dict(),不存整个model对象。这样做的好处是加载时结构变化不敏感,换embedding维度或者改卷积核数量后还能部分复用权重;代价是要保证加载前已经用相同参数实例化了模型。
4.3 从模型里抽取特征向量:用户画像与余弦相似度推荐打分
训练完成后,把模型切到eval模式再提取特征,这一步很多人会忘,导致特征向量每次推理都不一样。原因是Dropout在训练模式下仍在随机置零神经元。
model.eval() def collect_features(loader, model, device): feats, user_ids, movie_ids = [], [], [] with torch.no_grad(): for batch in loader: x = batch["text"].to(device) _, feat = model(x) feats.append(feat.cpu()) user_ids.extend(batch["user_id"]) movie_ids.extend(batch["movie_id"]) return torch.cat(feats, dim=0), user_ids, movie_ids # 用户画像:该用户写过评论的特征向量取均值 def build_user_vector(all_feats, user_ids): user_vecs = {} for feat, uid in zip(all_feats, user_ids): user_vecs.setdefault(uid, []).append(feat) return {uid: torch.stack(f).mean(dim=0) for uid, f in user_vecs.items()}注意collect_features里用“_, feat”抓住模型返回的第二个值,这个feat是池化后经过dropout层的384维向量,不是最后的分类logits。这里有个容易走偏的地方:特征向量应该在dropout之前取还是之后取?我习惯取dropout之后,因为推理时dropout关闭,等价于特征接近“多个子网络的均值”,稳定性更好;代价是训练和推理时特征分布略有偏移,这属于玄学范畴,两种取法都有人用,建议各跑一轮离线指标自己对比。
打分阶段用余弦相似度而不是点积,原因在于用户评论数量的差异:点积结果会受向量模长影响,评论写得多的用户天然得分偏高;余弦只保留方向,等价于只看“风格像不像”。
from torch.nn.functional import cosine_similarity def recommend(user_vector, item_vectors, item_ids, topk=10): # user_vector: [dim], item_vectors: [num_items, dim] scores = cosine_similarity(user_vector.unsqueeze(0), item_vectors) top_indices = scores.topk(topk).indices.numpy() return [item_ids[i] for i in top_indices]4.4 推荐效果怎么验证:TopN命中率离线评估方法
离线评估最直接的指标是recall@k:把每个用户最后一条评论的电影当作“应命中”目标,用之前几条评论的特征向量构建画像,从候选池里推荐k部电影,看目标在不在推荐列表里。
def recall_at_k(user_vector, item_vectors, item_ids, heldout_items, k=10): scores = cosine_similarity(user_vector.unsqueeze(0), item_vectors) top_indices = scores.topk(k).indices.numpy() hit = len(set(item_ids[i] for i in top_indices) & set(heldout_items)) return hit / min(len(heldout_items), k)评估时最忌讳的是把训练集里的电影放进候选池,模型见过这些电影的评论语义,相似度会虚高,导致指标整体偏乐观。候选池应该由“该用户没看过且不在训练集中”的电影组成。对照基线用“按评论数量排序”的热门推荐,recall@10能打过热门基线,说明CNN特征确实带上了个性化信息,而不只是回忆出热门片。
5. 环境与模型避坑:PyTorch安装、文本填充与特征向量的5个翻车现场
5.1 conda命令找不到与CUDA版本对不上:PyTorch环境搭建两大坑
现象:在Windows PowerShell里输入conda activate pytorch,直接报无法将“conda”项识别为cmdlet、函数、脚本文件或可运行程序的名称;或者nvidia-smi能看到显卡,但PyTorch里torch.cuda.is_available()永远返回False。
原因:第一个问题的根子在Anaconda的初始化脚本没在当前终端生效。装完Anaconda直接开PowerShell,PATH里根本没有conda.exe。第二个问题更隐蔽:PyTorch安装时绑定了一个CUDA编译版本,它要求显卡驱动的CUDA版本大于等于这个编译版本;驱动新不代表conda环境里装了匹配的cudatoolkit,很多人只看nvidia-smi的版本号就下结论,实际环境里缺的是CUDA运行时。
解决:先where conda确认位置,找不到就手动去Anaconda安装目录下的Scripts目录确认,然后执行conda init powershell并重启终端;GPU验证不能只看is_available,还要打印torch.version.cuda和torch.cuda.get_device_name()双重确认。搭环境时直接用conda加入pytorch和对应cudatoolkit,别用pip补装,版本穿插最容易把环境搞乱。
5.2 pad_sequence和permute:两个让新手反复报错的张量操作
现象:collate时直接torch.stack(texts)报RuntimeError,提示每个张量尺寸不一致;或者Conv1d报Expected 3D input,但收到的却是三维里维度顺序不对。
原因:不同评论的词数不同,stack要求所有输入形状一致,硬凑必炸;embedding层输出的形状是[batch, seq_len, embed_dim],而Conv1d要求的输入是[batch, channels, length],不转置就报维度错误。
解决:先pad_sequence(texts, batch_first=True, padding_value=0)把所有样本对齐,再在embedding后加permute(0, 2, 1)。这两步是TextCNN最常见的高频报错点。排查时在forward里临时打印x.shape,确认卷积层拿到的是[batch, embed_dim, seq_len]而不是别的排列。这种错误是张量形状问题,不涉及数据质量,加打印比硬背报错信息快得多。
5.3 分类准确率与推荐指标不共振:别把softmax输出当特征向量
现象:训练时验证集准确率从72%涨到85%,但离线recall@10基本不动,甚至来回震荡。
原因:拿错了数据。如果直接把模型最后的logits或者softmax概率当特征去算相似度,信息维度只有正负两个自由度的投影,情感极性相同的电影会被挤成一团,推荐需要的主题、风格差异全被丢掉。这是混淆了“分类模型的预测结果”和“分类模型的中间表示”。
解决:从forward里返回的feat取特征,这个向量在分类头之前,保留了卷积层提取的384维语义结构。检查方法很简单:打印一下feat和logits的形状,feat维度远大于logits说明取对了。另外一个连带坑是推理时忘了model.eval(),Dropout还在随机置零,导致同一句话每次提取的特征向量都不一样,相似度排名当然抖。
5.4 同一batch内序列长短差异过大:填充把特征向量带偏
现象:loss能降,但训练速度很慢。把某条特别长的评论单独过一遍模型,发现池化后的特征几乎被 主导,真正的内容反而没激活。
原因:pad_sequence按batch内最长序列填充。如果一条评论800词,其他都不到100词,卷积核会在这个batch里扫过大量占位符;全局最大池化又只取最大值,虽然PAD位置的卷积输出接近0,但长序列中有效内容被数百个PAD稀释,多次池化后有效信号占比下降。
解决:训练时给max_len=256的硬截断,限制每个batch的填充上限;更讲究一点的做法是按长度分桶采样,把相似长度的样本放进同一个batch,把填充率压到20%以下。截断本身对推荐指标影响很小,因为电影评论的核心信息高度集中在开头和结尾,中间的展开段落对风格判定的贡献有限。
5.5 OOV与词表截断:低频词处理影响特征区分度
现象:词表建了30000,推理时发现测试文本里超过15%的词都变成了 ;推荐结果对“好看”和“精彩”这类近义词完全没有区分能力。
原因:低频词在训练集里出现次数太少,学习不到稳定表示,即使保留在词表里也是噪声;但如果只按top-k截断,被截掉的不只是噪声,还有一批在测试集里出现、训练集里恰好低频的真实词汇。
解决:第一道防线是 必须存在且参与训练,不能把未知词扔出去;第二道是把min_freq设为2,至少出现过两次才进词表;第三道是观察OOV比例,超过10%就要扩大词表或换预处理策略。有条件的话,加载公开预训练词向量初始化embedding层,只对 和词表外词做随机初始化,再让embedding层在微调时自己更新。最终目标是把OOV压到10%以内,这个比例对特征区分度是安全线。
6. 把TextCNN特征接入推荐服务的验证技巧:冻结权重与ONNX导出
训练好的模型要在服务端跑推荐,我通常不会直接加载PyTorch模型做在线推理。PyTorch的eager模式在CPU上处理单条文本有额外调度开销,更常用的做法是冻结权重、导出ONNX,把推荐服务里的“预测”变成一次纯向量计算。
model.eval() for param in model.parameters(): param.requires_grad = False dummy_ids = torch.randint(2, len(vocab), (1, 256)).long() torch.onnx.export( model, dummy_ids, "textcnn.onnx", input_names=["input_ids"], output_names=["logits", "feature"], dynamic_axes={"input_ids": {0: "batch"}, "feature": {0: "batch"}}, opset_version=17, )这里dummy_ids的seq_len必须和训练时的max_len对齐,因为导出时输入是固定长度;dynamic_axes只放开batch维,评论长度仍然要由服务端负责截断和填充。导出后要验证一次,确认ONNX输出和PyTorch原模型一致再上线。
import onnxruntime as ort sess = ort.InferenceSession("textcnn.onnx", providers=["CPUExecutionProvider"]) ids = torch.randint(2, len(vocab), (1, 256)).numpy().astype(np.int64) outs = sess.run(None, {"input_ids": ids}) feat = outs[1] # 形状 [1, 384],logits 可以忽略推荐服务端只要复刻同一套tokenize和vocab编码逻辑,把用户历史评论全部编码成特征向量并缓存,新电影入库时只算一次item_vector,打分阶段就是纯向量点积或余弦计算,单次相似度耗时可以忽略。这个方案最大的收益不是性能,而是把“模型推理”和“业务计算”解耦:特征向量可以落库、可以增量更新、可以做向量检索,PyTorch只承担离线训练这一个职责。
最后说一个我踩过的教训:第一次做这个方向时,我图省事直接拿softmax概率当用户偏好向量,离线recall@10连续两周涨不上去,从数据清洗到超参数调了一遍都没用,最后回到模型结构里检查才发现是特征取错了层。改成从池化层输出取特征之后,同样的训练曲线推荐指标直接上了一个台阶。如果你也按这个路线做文本CNN推荐,特征来源是第一件要确认的事。希望帮到你。
本文还有配套的精品资源,点击获取