☰
基于PyTorch的TextCNN电影推荐系统:从影评中提取语义指纹
2026/10/26 23:09:12 网站建设 项目流程

简介:基于PyTorch实现的电影推荐系统完整项目,面向毕业设计、课程设计与项目开发场景。方案采用卷积神经网络(CNN)提取影评文本特征,并与PMF(概率矩阵分解)模型融合完成个性化推荐,源码经过严格测试,可直接运行并支持在此基础上进行功能拓展与算法改进。资源包共15个文件,主体为Python脚本,覆盖数据管理、文本分析、模型构建、工具函数及主程序等模块;另有Shell运行脚本用于一键预处理与训练,附参考论文、说明文档和配置文件,帮助理解理论细节。整个压缩包仅有17KB,体积轻量、结构清晰,适合快速定位核心代码。目前已有67人学习下载,对于希望掌握深度推荐系统实现、开展课题研究或在短时间内搭建基线的学习者而言,是较为实用的参考资料。

1. 从影评文本到推荐结果:为什么要用 CNN 解决电影的“冷启动”推荐

打开任意一个视频网站,首页推荐都是“看过这个的人也在看那个”。可一部新片刚上映,评分和点播行为都是零,协同过滤立刻失明。换个视角:影评本身就是电影最直接的文本画像。“悬疑烧脑但节奏拖沓”“摄影比剧情好看”“适合全家一起看”这些碎片交给卷积神经网络,让它自动抽取主题、情感和类型信号,压成一条固定长度的语义指纹;再做指纹之间的余弦距离排序,就能得到不依赖行为日志的电影推荐系统。这套方案用 Python 实现、用 PyTorch 训练,正好覆盖毕业设计和课程设计所需的数据处理、模型搭建、训练推理和结果评估全流程。下面的内容按我能直接照着做的方式来讲,代码能跑,坑也会提前标出来。

2. 原理与架构拆解:CNN 是怎么从影评里“读出”语义指纹的

2.1 为什么是 CNN 而不是 LSTM:短文本里的关键信号都藏在 n-gram 里

刚接触文本深度学习的人,第一反应往往是“序列数据当然用 LSTM 或 Transformer”。但放在影评场景下,CNN 的优势非常实在:影评通常在几十到几百个词之间,决定一条评论情绪和主题的,往往是几个关键词的共现组合——“经典”“全程无尿点”“逻辑硬伤”。这些组合本质上就是 n-gram,而一个卷积核可以被理解成一组可学习的 n-gram 匹配模板。

卷积在时间步维度上滑动时,它对“局部窗口内有没有出现特定词组合”极其敏感,并且通过最大池化把窗口内的最强响应挑出来。这正好符合影评判断的需求:不需要记住整句的复杂语法,只要抓住最有区分度的几个片段就能判断态度。相比之下,LSTM 按顺序逐步编码虽然保留全局依赖,但参数更多、训练更慢,而且在一个几千条样本的数据集上,很容易过拟合。

从实践角度看,TextCNN 这类结构在文本分类任务里一直是性价比极高的基线,也被大量推荐系统项目当作特征提取器使用。它不要求你懂序列建模里的梯度裁剪、门控机制,只靠嵌入层 + 卷积 + 池化 + 全连接四条主线就能推起来。对课程设计来说,这意味着调参、写论文和答辩演示都有清晰的抓手。

2.2 数据流拆解:评论 → 词向量 → 卷积 → 池化 → 指纹

一条原始影评从字符串变成最终特征向量,中间经过五个环节。下表把每一步的形状变化列出来,后面写代码时所有维度问题都能回到这张表核对。

阶段输入形状示例输出形状示例作用
Token ID 化(batch, seq_len)的整数序列同左每个词变成词表里的编号
嵌入层(batch, seq_len)(batch, seq_len, embed_dim)每个编号映射成稠密词向量
升维(batch, seq_len, embed_dim)(batch, 1, seq_len, embed_dim)因为 PyTorch 的 Conv2d 需要通道维
卷积 + 激活(batch, 1, seq_len, embed_dim)(batch, num_filters, conv_len, 1)每个卷积核提取一种 n-gram 特征
全局最大池化(batch, num_filters, conv_len, 1)(batch, num_filters)把每个卷积核最强烈的激活保留下来
拼接 + 全连接多个核的池化结果(batch, num_classes)输出情感或类型预测

这里有一个多数教程不会强调的点:全局最大池化之后得到的向量,就是我们说的“语义指纹”。在(batch, num_filters)这一刻,每个维度代表一个卷积核在整句里激活的强度,也就是“这句话更像哪种 n-gram 模式”。如果用了三种卷积核(尺寸 2、3、4),每类 128 个过滤器,拼接后就是 384 维的向量。它不像最后的类别输出那样只保留少量信息,而是保留了足够丰富的中间语义,非常适合拿去做相似度匹配。

2.3 指纹怎么变成推荐:靠的不是分类结果,而是隐层向量

很多人第一次做这类项目,会直接把模型预测的电影类型或情感分数拿去当推荐依据,结果发现推荐质量很差。原因很好理解:类型标签只有几类,情感只有正负,信息量太小。正确做法是把中间层向量当作电影的内容表示。

具体来说,训练完一个“评论 → 类型标签”的多分类模型后,去掉最后的全连接分类头,只保留嵌入层、卷积层和池化层。对一部电影的多条影评分别过这个模型,得到多条指纹,再按维度取平均,就得到这部电影的最终指纹。推荐时计算目标电影指纹与库里其他电影指纹的余弦相似度,按相似度从高到低取 Top-K。

选择余弦相似度而不是欧氏距离,原因是训练后的指纹向量模长可能随文本长度浮动,而余弦只看方向夹角,对文本长度的变化更鲁棒。实际项目里如果某个电影只有一条短评,模长天然会小,欧氏距离会把它错判成异类。这个选择在后续做评估时差距非常明显。

3. 数据准备与预处理:把中英文影评统一成 CNN 能吃下的数字矩阵

3.1 选数据集:IMDB、豆瓣短评、TMDB 的取舍

公开数据集里最省事的是 IMDB 50K 影评集,英文,自带正负情感标签,类别比例均衡,网上直接能下到 CSV 或 tsv 版本。每条影评长度足够,适合直接当模型训练数据。缺点是电影信息字段少,如果推荐系统要求“按电影聚合多评”,还需要动手把 review 映射到 movie。

豆瓣短评是中文场景的首选,但有几个麻烦:一是爬虫接口反爬严格,正常课程设计不建议把时间花在采集上;二是短评没有现成标签,需要自己构造监督信号。常见做法是把“看过之后评分 ≥ 4”视为好评、≤ 2 视为差评,或者用电影的流派标签作为分类目标。严格说这不完美,但作为推荐项目已经够用。

TMDB 提供结构化的电影概览和流派标签,适合做“内容画像”而不是“影评特征”,如果标题强调影评,数据还是以评论文本为主。我的建议是:时间紧就 IMDB 子集,想要中文展示效果就豆瓣短评加流派标签,采样 5000 到 20000 条即可,不用一味追求全量数据。

3.2 清洗与分词:中文用 jieba,英文直接按空格切

清洗是数据流程里最枯燥但回报最高的一步。文本里残留的 URL、@用户、表情符号和多余空白,对 CNN 特征提取没有任何帮助,反而会扩大词表、稀释有效词频,必须提前去掉。

中文分词我一般用 jieba,精确模式配默认词典就够。英文不需要分词器,直接split()得到的就是符合词边界的结果,但要注意统一小写和把数字归一化成占位符,防止“10”和“100”被当成两个完全不同的词。停用词的处理要克制:去掉“的、了、和”这类纯语法词没问题,但像“不”“没”“太”这种能翻转语义的词必须保留,否则“不太好看”会被模型读成“好看”。

import jieba import re STOP_WORDS = set(["的", "了", "是", "在", "和", "也", "都", "与", "及"]) def clean_text(text: str) -> str: text = re.sub(r"https?://\S+|www\.\S+", "", text) text = re.sub(r"@\w+", "", text) text = re.sub(r"\d+", " NUM ", text) text = re.sub(r"[^\u4e00-\u9fffA-Za-z0-9 ]", " ", text) return text.strip().lower() def tokenize(text: str, lang: str = "zh") -> list[str]: text = clean_text(text) if lang == "zh": words = [w for w in jieba.cut(text) if w.strip() and w not in STOP_WORDS] else: words = [w for w in text.split() if w.strip() and w not in STOP_WORDS] return words

这段代码里,clean_text负责把 URL、@用户、数字和标点统一处理;tokenize根据语言选择分词方式。两个函数是整个数据流程的入口,后面构建词汇表时的每一个 token 都从这儿来。语言参数建议在项目里显式写死,不要按每一条文本猜测,否则分词结果不稳定,复现时容易翻车。

3.3 词汇表与嵌入层:随机初始化还是预训练词向量

词汇表的大小直接决定嵌入层参数规模。一般做法是只保留出现频率前 20000 到 50000 的词,低频词统一映射到<UNK>标记。中文语料里很多专有名词只出现一两次,硬塞进去只会让嵌入层高维稀疏,训练时这些行几乎更新不到。

嵌入层用随机初始化还是预训练词向量网上的争论很多,我的经验是:如果你的训练数据量小于 5000 条,随机初始化加多层卷积很容易学不出语义差别,这时候用预训练词向量效果更稳;反之数据量上万,随机初始化配合足够的训练轮次也能达到接近的效果,而且省去加载外部词表的麻烦。要注意的是,预训练词向量和当前任务的词表必须对齐,词表里出现但预训练里缺失的词,只能随机初始化,后续训练自然会修正。

序列长度建议统一截断到 100 到 200 个 token。IMDB 影评普遍较长,中文短评较短,少部分超长文本直接截断即可,强行保留全量只会让 padding 比例过高,计算浪费。

from collections import Counter UNK, PAD = "<UNK>", "<PAD>" def build_vocab(tokenized_texts: list[list[str]], max_vocab: int = 30000): counter = Counter() for tokens in tokenized_texts: counter.update(tokens) most_common = counter.most_common(max_vocab - 2) vocab = {PAD: 0, UNK: 1} for word, _ in most_common: vocab[word] = len(vocab) return vocab

PAD固定放到索引 0,UNK放到索引 1,这要和嵌入层的padding_idx=0保持一致,否则填充位置的向量也会参与梯度更新,影响训练收敛。这个细节是 90% 新手会踩的坑,后面避坑部分再展开。

3.4 数据集切分与类别平衡:别让模型只学到“好评”

如果数据来自 IMDB,情感标签天然均衡,直接按 8:1:1 切分成训练、验证、测试即可。豆瓣短评按评分构造标签时,很容易出现好评占比超过 70% 的情况,模型会学成“无脑输出好评”。处理办法有两个:一是对差评样本做上采样,二是给损失函数传入类别权重torch.tensor,让少数类的错误贡献更大。推荐系统后续要按特征聚合,类别不平衡对质量影响不大,但会影响训练速度,因为模型一开始梯度方向就偏了。

切分一定要保证按电影聚合,不能把同一部电影的影评同时丢进训练集和验证集,否则验证阶段会看到训练时见过的同主题表达,分数虚高,答辩时一追问就露馅。我用的是先把movie_id分组,再按组切分,而不是直接按行切分。

4. PyTorch 实现:TextCNN 训练、特征抽取与 Top-K 推荐生成

4.1 TextCNN 模型定义:多尺寸卷积核抓不同长度的语义片段

模型结构直接采用经典 TextCNN 布局,三个卷积核尺寸分别作用于 2-gram、3-gram 和 4-gram 窗口。嵌入维度设为 128,每个卷积核输出 128 个通道,拼接后得到 384 维的特征向量。对课程设计来说,这个参数组合在数据和显存之间比较平衡。

import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128, filter_sizes=(2, 3, 4), num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (size, embed_dim)) for size in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): x = self.embedding(x) # (batch, seq_len, embed_dim) x = x.unsqueeze(1) # (batch, 1, seq_len, embed_dim) conv_feats = [] for conv in self.convs: c = conv(x) # (batch, num_filters, conv_len, 1) c = F.relu(c.squeeze(3)) # (batch, num_filters, conv_len) c = F.max_pool1d(c, c.size(2)) # (batch, num_filters, 1) conv_feats.append(c.squeeze(2)) # (batch, num_filters) x = torch.cat(conv_feats, dim=1) # (batch, 384) x = self.dropout(x) return self.fc(x), x

模型里的padding_idx=0必须和词汇表里PAD的索引一致,否则填充位会产生随机的梯度信号。卷积层输入升到 4 维是因为nn.Conv2d要求(batch, channel, height, width),文本里 height 是序列长度,width 是嵌入维度。这里的fc层只在训练阶段使用,推荐系统实际用的是forward返回的第二个值,也就是经过池化拼接后的 384 维特征向量。

4.2 训练循环:损失函数、优化器与验证的写法

训练这块没有太多花哨的地方,优化器选 Adam,初始学习率 1e-3,交叉熵损失。重点在于每轮训练结束要在验证集上算准确率,不要只在训练集上看 loss,否则你根本分不清模型是在记忆还是泛化。

def train_epoch(model, iterator, optimizer, criterion, device): model.train() epoch_loss, epoch_acc = 0.0, 0.0 for batch_x, batch_y in iterator: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits, _ = model(batch_x) loss = criterion(logits, batch_y) loss.backward() optimizer.step() epoch_loss += loss.item() * batch_x.size(0) preds = logits.argmax(dim=1) epoch_acc += (preds == batch_y).sum().item() return epoch_loss / len(iterator.dataset), epoch_acc / len(iterator.dataset)

注意每个 batch 都要在optimizer.zero_grad()之后才算损失,不然梯度会跨 batch 累积。model(batch_x)返回两个值,训练时第一个二元组是分类输出,第二个是特征向量,这里只用第一个。如果验证集准确率一直不涨,优先检查学习率和数据切分,而不是换模型。

4.3 重训完成后的特征抽取:去掉分类头,保留语义指纹

训练完成后,把模型里的分类头fc剥掉,输入同样的 batch 文本,过嵌入、卷积、池化,得到每条评论的指纹。推荐系统不需要保留原始训练状态,直接导出一份 pickle 或 npy 文件即可。

def extract_review_features(model, dataloader, device): model.eval() all_features, all_movie_ids = [], [] with torch.no_grad(): for movie_ids, batch_x in dataloader: batch_x = batch_x.to(device) _, features = model(batch_x) # 注意这里拿的是第二个返回值 all_features.append(features.cpu().numpy()) all_movie_ids.extend(movie_ids) import numpy as np return np.concatenate(all_features, axis=0), all_movie_ids

模型切换成eval模式后,Dropout 会被自动关闭,保证每条评论的特征向量稳定可复现。no_grad是必须的,否则推理时仍会构建计算图,GPU 显存会被撑爆。拿到所有评论指纹后,按movie_id分组求平均,就是电影级的语义指纹。

def build_movie_fingerprint(features, movie_ids): movie_dict = {} for feat, mid in zip(features, movie_ids): if mid not in movie_dict: movie_dict[mid] = [] movie_dict[mid].append(feat) movie_fp = {} for mid, feats in movie_dict.items(): import numpy as np movie_fp[mid] = np.mean(feats, axis=0) return movie_fp

这里的“平均池化”是指对同属一部电影的多条评论指纹按维度求均值,用来去掉单条评论的偶然偏差。如果某个电影的评论文本特别少,只有一两条,采集不稳定,最终入库前建议设置一个最低评论数阈值,低于阈值的电影不参与推荐。

4.4 Top-K 推荐函数与完整主流程

最后一步是相似度检索,把余弦相似度算出来,按分数排序取前 K。小数据量直接暴力循环没问题,电影多了就要换成 FAISS 这类向量检索库。

import numpy as np def cos_sim(a, b): return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)) def recommend_by_movie(target_mid, movie_fp, top_k=5): if target_mid not in movie_fp: return [] target_vec = movie_fp[target_mid] scores = [] for mid, vec in movie_fp.items(): if mid == target_mid: continue scores.append((mid, cos_sim(target_vec, vec))) scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_k]

1e-8是防止零向量的保护项,避免除零报错。整条主流程拆成四步:读取影评数据 → 清洗分词建词表 → 训练 TextCNN → 提取指纹算相似度。每一步的输出都落盘成一个中间文件,模型训练中途挂了不用从头跑数据预处理,这是节省时间的核心习惯。

5. 避坑实战:从维度崩溃到 CUDA OOM 的五条排障经验

5.1 嵌入层索引越界:词表编号和 Embedding 行数对不上

典型现象是训练跑到一半报IndexError: index out of range in self,或者模型能推理但 Loss 不下降。

原因多数发生在构建词汇表之后,模型传入的vocab_size用了原始文本里出现的最大编号,而没按词汇表长度传。还有一个隐蔽原因:build_vocab里max_vocab限制后,编号确实到30000为止,但模型初始化时用了包含全量词表的len(vocab),两边数字差一点就会在遇到冷门词时崩掉。

解决办法是在模型初始化前打印一次len(vocab),然后把这个数显式传给TextCNN。不要在代码里写vocab_size=50000这种魔数,用同一个变量传递。你只要记住“词汇表长度就是模型嵌入层行数”,这一条至少能避开 30% 的运行时报错。

5.2 卷积输出尺寸对不上:池化时长度是奇数

使用nn.Conv2d搭配全局最大池化时,conv_len会因为卷积核尺寸和 padding 设置而缩短。如果文本长度固定为 100,卷积核尺寸为 3,不设 padding,输出长度就是 98。max_pool1d(c, c.size(2))对这种非对称长度没问题,但如果有些长度是奇数,某些层的输出尺寸不同,后续torch.cat就会报维度不匹配。

规避办法是一律用c.size(2)作为池化窗口大小,而不是在代码里写死一个数字。这样无论输入长度怎么变化,池化后维度始终是(batch, num_filters, 1),拼接更安全。

5.3 一个 batch 里句子长短不一:PAD 处理不当导致特征被稀释

不填充直接打包,PyTorch 会报stack错;填充太多长尾样本,又会让真正有效的 token 占比过低,指纹向量的每个维度都被无意义的 PAD 向量拉低。

关键点是必须使用padding_idx=0,同时把每条文本截断到固定长度。截断长度不宜贪长,IMDB 用 200 词,中文短评用 100 词已经足够。另一个容易被无视的点是读取数据时对文本先做 tokenize 再截断,按字符长度截出来的数据往往刀口位置不齐,语义碎片化。

5.4 小样本数据过拟合:验证集 Acc 停在 90% 以上但推荐结果离谱

出现这种情况,大概率是训练集和验证集没有按电影分组,同一部电影的相似影评泄漏到了两边,导致模型“背题”。另一个原因是训练轮次太多,模型把训练集里的噪音也学进去了。

解决方式是切分时按movie_id分组,并给模型加 Dropout,训练时一般设0.5。如果验证准确率比训练准确率高出一截,优先怀疑数据泄漏而不是模型能力。这项排查要在答辩前一星期就做,不然演示现场翻车很难救。

5.5 CUDA 显存溢出:batch 太大加序列太长

RuntimeError: CUDA out of memory几乎是早晚的事。文本 CNN 本身不算重,但如果你把batch_size设到 256,序列长度又拉到 300,嵌入层输出瞬间占满显存。

降batch_size是最直接的办法,能从 256 降到 32 通常就能撑住。另一个更节省的做法是在特征抽取阶段用小 batch 分批处理,避免一次性把全量评论全部加载进显存。训练阶段也可以用torch.cuda.empty_cache()在每轮验证后清一次缓存,但这只是短期止血,根本解法还是根据显存上限反推 batch 大小。

6. 验证与进阶:从“能跑”到“能答辩”的最后一公里

推荐系统的验收不能只看离线准确率。对这类课程项目,我一般会准备三个维度:情感分类任务的准确率、指纹检索的召回率、以及人工抽测的推荐合理性。

分类准确率用验证集就能看到;指纹检索质量需要构造测试集,比如留出 20% 电影,对每部留出电影挑一条影评作为查询,看该影评的特征向量能否在库里召回同电影的其他评论。这一步在网上能找到现成的评估工具包,核心指标就是 Precision@K 和 Recall@K。

调参顺序建议从学习率开始。先用 1e-3 跑 5 轮看 Loss 走向,Loss 震荡就降到 3e-4。然后固定学习率,增大 filters 数量,从 64 到 128 再到 256,观察验证集收益曲线,边际收益变平就停。最后才动卷积核尺寸组合,不要一上来就调核尺寸,否则几个因素混在一起,你看不到任何单一变量的效果。

如果想把这个项目做成混合推荐,最稳的做法是用这个 CNN 指纹做召回,再用评分行为做精排。我在自己的小项目里常用这种方式,比单用行为或者单用文本效果都稳,而且两部分逻辑可以完全解耦。

最后提醒一句,做这类系统最忌讳的就是“调了个最好的数字然后开始写论文”。把每一次实验的准确率、Loss 曲线和指纹向量可视化结果存档,答辩时把 t-SNE 聚类图放出来,比贴十行代码有说服力得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询