☰
PyTorch实战:TextCNN中文电影评论情感分类全流程
2026/10/15 8:34:29 网站建设 项目流程

1. 项目背景与整体设计思路

中文电影评论的情感分类,说白了就是让机器判断一条影评到底是“好评”还是“差评”。这件事听起来简单,但真正动手做过的人都知道,中文文本的坑远比英文多——分词边界模糊、网络用语层出不穷、情感表达含蓄委婉,再加上电影评论本身带有强烈的个人主观色彩,同一个词在不同语境下可能表达完全相反的意思。我在实际做这个项目的过程中,踩过的坑比想象中多得多,所以这篇文章会把整个思路、代码实现、调参经验和排查技巧都摊开来讲。

这个项目的核心技术路线是PyTorch + TextCNN。TextCNN 是 2014 年提出的一种经典文本分类模型,结构不复杂,但效果在短文本分类任务上非常能打。它的核心思想是用不同尺寸的卷积核去捕捉文本中不同长度的局部特征,比如“非常好看”是一个 2-gram 特征,“完全不值得一看”是一个 5-gram 特征,卷积核通过滑动窗口把这些局部模式提取出来,再通过池化操作保留最强的信号。相比 RNN 系列模型,TextCNN 训练速度快、并行度高,对于电影评论这种长度适中、情感信号集中的文本来说,性价比极高。

整个项目的设计思路可以拆成四个层次来理解。第一层是数据层,需要解决中文分词、词表构建、序列截断与填充等问题;第二层是模型层,需要设计嵌入层、多尺寸卷积层、池化层和全连接分类层;第三层是训练层,涉及损失函数选择、优化器配置、学习率调度和早停策略;第四层是评估与推理层,需要关注准确率、F1 值以及实际预测时的输出格式。这四个层次环环相扣,任何一层出问题都会导致最终效果大打折扣。

为什么选择 TextCNN 而不是 BERT?这是很多人会问的问题。我的回答很直接:看你的数据量和算力。如果你手头只有几万条标注数据,用 BERT 微调不仅训练慢,而且容易过拟合;TextCNN 在这种场景下反而更稳。另外,TextCNN 的推理速度极快,单条评论的预测时间可以控制在毫秒级别,非常适合需要实时响应的场景。当然,如果你有几十万条以上的标注数据并且显卡资源充足,BERT 系列确实能带来明显的效果提升,这是后话。

注意:TextCNN 对词向量的质量非常敏感。如果你用随机初始化的嵌入层,效果可能比预训练词向量差 5 到 10 个百分点。建议至少使用在中文语料上预训练过的词向量,或者在下游任务上先冻结嵌入层训练几轮再解冻微调。

适合阅读这篇文章的读者包括:刚入门 PyTorch 想找一个完整 NLP 项目练手的同学、需要快速搭建文本分类基线模型的工程师、以及想了解 TextCNN 在实际中文任务中表现如何的从业者。文章会从数据预处理一直讲到模型部署推理,每个环节都附带可直接运行的代码和参数解释。

2. 数据预处理与词表构建的核心细节

2.1 中文分词工具选型与实操对比

中文和英文最大的区别在于:英文单词天然由空格分隔,而中文需要先做分词。分词的质量直接影响后续词表的构建和模型的输入质量。我试过好几种分词工具,这里把实际体验列出来供你参考。

工具优点缺点适用场景
jieba速度快、社区活跃、支持自定义词典对网络新词识别一般通用场景首选
pkuseg领域适配好、支持多领域模型速度较慢、安装依赖多对精度要求高的场景
HanLP功能全面、支持多种任务体积大、配置复杂企业级全流程
THULAC分词粒度细更新频率低学术研究

我最终选了 jieba,原因很简单:够用、够快、够稳定。电影评论里的词汇虽然有一些网络用语,但 jieba 配合自定义词典基本能覆盖 95% 以上的情况。自定义词典的添加方式也很简单:

import jieba # 添加电影领域专有词汇 custom_words = ["燃爆", "催泪", "演技派", "剧情党", "五毛特效", "全程无尿点"] for word in custom_words: jieba.add_word(word) # 测试分词效果 text = "这部电影的特效简直是五毛特效,但剧情全程无尿点,演技派撑起了整部片子" words = jieba.lcut(text) print(words) # 输出: ['这部', '电影', '的', '特效', '简直', '是', '五毛特效', ',', '但', '剧情', '全程无尿点', ',', '演技派', '撑起', '了', '整部', '片子']

这里有个细节值得注意:标点符号要不要保留?我的建议是去掉大部分标点,但保留感叹号和问号。因为感叹号往往携带强烈的情感信号,“太好看了!”和“太好看了”在情感强度上是有差异的。不过这个差异对最终效果的影响很小,如果你嫌麻烦,全部去掉也不会有太大问题。

2.2 词表构建与低频词处理策略

分词完成后,下一步是构建词表。词表本质上是一个从词到索引的映射表,模型只能处理数字,所以需要把每个词转换成一个整数 ID。构建词表的流程如下:

  1. 遍历所有训练集文本,统计每个词的出现频率
  2. 按频率从高到低排序
  3. 保留频率最高的 N 个词(通常 N 取 10000 到 50000)
  4. 添加特殊标记:<PAD>用于填充,<UNK>用于表示未登录词
from collections import Counter def build_vocab(texts, max_size=20000, min_freq=2): # 统计词频 all_words = [] for text in texts: all_words.extend(jieba.lcut(text)) word_counts = Counter(all_words) # 过滤低频词 filtered_words = [word for word, count in word_counts.items() if count >= min_freq] # 按频率排序 sorted_words = sorted(filtered_words, key=lambda x: word_counts[x], reverse=True) # 截取前 max_size 个 sorted_words = sorted_words[:max_size - 2] # 构建词表 vocab = {"<PAD>": 0, "<UNK>": 1} for idx, word in enumerate(sorted_words, start=2): vocab[word] = idx return vocab

min_freq 这个参数很关键。如果设得太低(比如 1),词表会非常大,嵌入层参数量爆炸,而且低频词学不到好的向量表示;如果设得太高(比如 5),很多有用的领域词汇会被过滤掉。我的经验是:数据量在 5 万条以下时 min_freq 取 2,数据量在 10 万条以上时取 3 到 5。这个范围是经过多次实验验证的,你可以根据实际情况微调。

2.3 序列截断与填充的工程实践

TextCNN 要求输入序列长度固定,但电影评论的长度差异很大——短的只有几个字,长的可能有几百字。所以需要设定一个最大长度max_len,超过的截断,不足的用<PAD>填充。

max_len怎么选?我的方法是:统计训练集文本长度的分布,取 95 分位数。比如你统计下来 95% 的评论都在 200 个字以内,那max_len就设 200。这样既能覆盖绝大多数样本,又不会因为个别超长文本导致计算浪费。

import numpy as np def encode_text(text, vocab, max_len=200): words = jieba.lcut(text) # 转换为索引,未登录词用 <UNK> 代替 ids = [vocab.get(word, vocab["<UNK>"]) for word in words] # 截断 if len(ids) > max_len: ids = ids[:max_len] # 填充 else: ids = ids + [vocab["<PAD>"]] * (max_len - len(ids)) return ids # 统计长度分布 lengths = [len(jieba.lcut(text)) for text in train_texts] print(f"平均长度: {np.mean(lengths):.1f}") print(f"95分位数: {np.percentile(lengths, 95):.0f}") print(f"最大长度: {np.max(lengths)}")

提示:填充操作建议放在 collate_fn 里动态执行,而不是在数据集加载时就全部填充好。动态填充可以节省内存,尤其是在数据量很大的时候效果明显。具体做法是每个 batch 内取该 batch 的最大长度进行填充,而不是全局统一长度。

这里还有一个容易忽略的点:填充的 ID 必须是 0,并且在嵌入层中要把 0 对应的向量固定为零向量。否则模型会把填充位置也当作有效信息来处理,引入噪声。在 PyTorch 中可以通过padding_idx=0参数来实现:

self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)

3. TextCNN 模型架构拆解与代码实现

3.1 嵌入层:从离散词到稠密向量

嵌入层的作用是把每个词的整数 ID 映射成一个稠密向量。这个向量可以是随机初始化的,也可以加载预训练好的词向量。我强烈建议使用预训练词向量,原因前面已经说过——随机初始化在小数据集上效果很差。

class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, filter_sizes, num_filters, dropout=0.5): super(TextCNN, self).__init__() # 嵌入层 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 多尺寸卷积层 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) # Dropout 层 self.dropout = nn.Dropout(dropout) # 全连接分类层 self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: (batch_size, max_len) embedded = self.embedding(x) # (batch_size, max_len, embed_dim) embedded = embedded.unsqueeze(1) # (batch_size, 1, max_len, embed_dim) # 多尺寸卷积 + 池化 conv_outputs = [] for conv in self.convs: conv_out = F.relu(conv(embedded)) # (batch_size, num_filters, L, 1) conv_out = conv_out.squeeze(3) # (batch_size, num_filters, L) pooled = F.max_pool1d(conv_out, conv_out.size(2)) # (batch_size, num_filters, 1) pooled = pooled.squeeze(2) # (batch_size, num_filters) conv_outputs.append(pooled) # 拼接所有卷积核的输出 cat = torch.cat(conv_outputs, dim=1) # (batch_size, num_filters * len(filter_sizes)) cat = self.dropout(cat) # 分类 logits = self.fc(cat) return logits

3.2 卷积核尺寸选择:为什么是 2、3、4、5

filter_sizes决定了卷积核在文本上滑动的窗口大小。窗口为 2 的卷积核捕捉的是二元词组特征(比如“很好”“太差”),窗口为 3 的捕捉三元词组特征(比如“非常好看”“完全不值”),以此类推。

我试过不同的组合:只用 3 的效果一般,用 2、3、4 的组合比用 3、4、5 略好,最终选了2、3、4、5四个尺寸。原因是电影评论的情感表达既有短词组合(“烂片”),也有长短语(“完全不值得浪费时间”),多尺寸覆盖更全面。

num_filters是每个尺寸卷积核的数量,我设的是 128。这个值太小会导致特征提取不充分,太大则容易过拟合。128 是一个比较稳妥的中间值,你可以根据数据量在 64 到 256 之间调整。

3.3 池化策略与 Dropout 配置

TextCNN 用的是最大池化,也就是在每个卷积核的输出序列上取最大值。这个操作的直觉是:只要文本中出现了任何一个强情感信号,就应该被捕捉到。比如一条评论里出现了“惊艳”这个词,不管它在句子的哪个位置,最大池化都会把这个信号保留下来。

Dropout 我设的是 0.5,加在池化层之后、全连接层之前。这个位置加 Dropout 可以有效防止过拟合,尤其是在训练数据不够多的时候。如果你发现训练集准确率远高于验证集准确率,可以适当提高 Dropout 比例到 0.6 甚至 0.7。

注意:Dropout 只在训练时生效,推理时要关闭。PyTorch 中通过model.train()和model.eval()来切换模式,别忘了在验证和测试前调用model.eval(),否则结果会不稳定。

4. 训练流程与关键参数调优实录

4.1 损失函数与优化器选择

情感分类是一个二分类任务(好评/差评),所以用交叉熵损失最合适。PyTorch 的nn.CrossEntropyLoss内部已经包含了 Softmax 操作,所以模型输出直接是 logits 即可,不需要额外加 Softmax 层。

criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=2 )

优化器我选的是Adam,学习率 1e-3。Adam 的优点是自适应学习率,对初始学习率不敏感,适合大多数场景。weight_decay设 1e-4 是为了加一点 L2 正则化,防止过拟合。

学习率调度器用的是ReduceLROnPlateau,监控验证集准确率,如果连续 2 个 epoch 没有提升就把学习率减半。这个策略比固定学习率效果好很多,尤其是在训练后期,降低学习率可以让模型更精细地收敛。

4.2 训练循环与早停策略

训练循环的代码框架如下:

def train(model, train_loader, val_loader, epochs=20, patience=3): best_val_acc = 0 patience_counter = 0 for epoch in range(epochs): # 训练阶段 model.train() train_loss = 0 for batch_x, batch_y in train_loader: 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() train_loss += loss.item() # 验证阶段 model.eval() correct = 0 total = 0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) logits = model(batch_x) preds = torch.argmax(logits, dim=1) correct += (preds == batch_y).sum().item() total += batch_y.size(0) val_acc = correct / total scheduler.step(val_acc) print(f"Epoch {epoch+1}: train_loss={train_loss/len(train_loader):.4f}, val_acc={val_acc:.4f}") # 早停判断 if val_acc > best_val_acc: best_val_acc = val_acc patience_counter = 0 torch.save(model.state_dict(), "best_model.pt") else: patience_counter += 1 if patience_counter >= patience: print(f"Early stopping at epoch {epoch+1}") break return best_val_acc

早停的 patience 设多少?我的经验是 3 到 5。设太小容易在模型还没收敛时就停掉,设太大则浪费训练时间。3 是一个比较激进的设置,适合快速实验;5 更稳妥,适合最终训练。

4.3 批次大小与学习率的配合关系

批次大小和学习率是一对需要配合调整的参数。一般来说,批次大小翻倍,学习率也应该相应增大,因为更大的批次意味着更稳定的梯度估计,可以用更大的步长。

批次大小推荐学习率训练速度内存占用
165e-4慢低
321e-3中等中等
642e-3快高
1283e-3很快很高

我最终用的是批次大小 64、学习率 1e-3 的组合。这个组合在单卡 8GB 显存的机器上跑起来很舒服,训练一个 epoch 大概几十秒,20 个 epoch 几分钟就能跑完。

提示:如果你发现训练 loss 震荡很厉害,先检查学习率是不是太大了。把学习率降到 1e-4 试试,如果 loss 下降变得平滑但速度太慢,再逐步往上调。这个“先降后升”的调参策略比盲目试错高效得多。

5. 常见问题排查与避坑经验

5.1 模型不收敛或准确率卡在 50%

这是最常见的问题,表现是训练 loss 不下降,验证准确率一直在 50% 左右徘徊(二分类的随机水平)。排查思路按优先级排列:

第一,检查数据标签是否正确对齐。我遇到过好几次标签和文本错位的情况,原因是数据处理时用了 shuffle 但忘记同步打乱标签。这种问题很隐蔽,建议在训练前先打印几条样本人工核对。

第二,检查词表映射是否正确。如果词表构建时用了训练集的词频,但编码时用了另一份词表,就会出现大量<UNK>,模型学不到有效信息。解决办法是确保词表在训练集上构建后保存下来,验证集和测试集都用同一份词表。

第三,检查学习率是否过大。学习率过大会导致 loss 震荡甚至发散。可以先把学习率降到 1e-4 跑几个 epoch 看看 loss 是否下降。

第四,检查嵌入层是否被正确初始化。如果用了预训练词向量,确认加载逻辑没有 bug;如果用随机初始化,确认padding_idx=0设置正确。

5.2 过拟合的识别与缓解

过拟合的典型表现是:训练集准确率持续上升,验证集准确率在某个 epoch 后开始下降。缓解手段按效果排序:

  1. 增加 Dropout 比例:从 0.5 提到 0.6 或 0.7
  2. 增大 weight_decay:从 1e-4 提到 1e-3
  3. 减少模型参数量:降低 num_filters 或 embed_dim
  4. 数据增强:对文本进行同义词替换、随机删除等操作
  5. 早停:在验证集准确率不再提升时停止训练

我一般先用早停 + Dropout 的组合,如果效果还不够再考虑数据增强。文本数据增强比图像数据增强难做得多,因为替换一个词可能改变整个句子的情感极性,需要谨慎设计。

5.3 推理阶段的一致性问题

训练时用了model.train(),推理时忘了model.eval(),这是新手最容易犯的错误之一。Dropout 在推理时如果不关闭,会导致每次预测结果都不一样,看起来像是模型“不稳定”。

另一个常见问题是推理时的文本预处理和训练时不一致。比如训练时去了标点,推理时没去;训练时用了自定义词典分词,推理时忘了加载词典。这些不一致会直接导致预测结果偏差。我的做法是把预处理逻辑封装成一个函数,训练和推理都调用同一个函数,从根本上杜绝不一致。

问题现象可能原因排查方法解决方案
准确率卡在 50%标签错位/词表不匹配/学习率过大打印样本核对/检查 UNK 比例/降低学习率修正数据管道/统一词表/调整学习率
验证集准确率下降过拟合对比训练集和验证集准确率曲线增加 Dropout/早停/数据增强
推理结果不稳定未调用 model.eval()检查推理代码推理前调用 model.eval()
预测偏差大预处理不一致对比训练和推理的预处理逻辑封装统一预处理函数
训练速度慢批次太小/模型太大检查 GPU 利用率增大批次/减小模型/使用混合精度

5.4 提升效果的一些实用技巧

除了调参之外,还有一些工程上的技巧可以提升最终效果。第一,使用预训练词向量并微调。加载预训练词向量后,先冻结嵌入层训练 2 到 3 个 epoch,让卷积层和全连接层先适应任务,然后再解冻嵌入层一起微调。这个两阶段训练策略比直接端到端训练效果更好。

第二,尝试不同的池化组合。最大池化捕捉最强信号,平均池化捕捉整体趋势,两者拼接起来有时能带来 1 到 2 个百分点的提升。代码改动很小,值得一试。

第三,集成多个模型。训练 3 到 5 个不同随机种子的 TextCNN,推理时对 logits 取平均。这个方法简单粗暴但非常有效,通常能稳定提升 1 到 3 个百分点。代价是推理时间成倍增加,适合对精度要求高、对速度要求不苛刻的场景。

第四,关注 F1 值而不是只看准确率。如果好评和差评的样本数量不均衡,准确率会有误导性。比如 90% 是好评、10% 是差评,模型全部预测好评也能拿到 90% 的准确率,但 F1 值会很低。建议在验证时同时输出准确率和 F1 值,综合判断模型表现。

我在实际项目里还遇到过一个有意思的现象:加入注意力机制后效果反而下降了。原因是 TextCNN 本身已经通过最大池化实现了类似注意力的效果,额外加注意力层增加了参数量但没有带来信息增益,反而加剧了过拟合。所以不是越复杂的模型越好,适合任务特点的才是最好的。这个教训让我在后来的项目中更加注重“先跑通基线,再逐步优化”的原则,而不是一上来就堆砌复杂结构。

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

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

立即咨询