简介:面向NLP初学者与深度学习实践者的文本分类实战项目,完整实现了基于CNN和RNN的两种经典模型,覆盖情感分析、主题分类等典型任务场景。压缩包共含28个文件,其中9个Python源码涵盖模型定义、数据加载与训练流程,6个pt权重文件为预训练模型结果,4个tsv文件包含训练、验证与测试数据,另有1个Markdown说明文档,包体大小约72MB,目录按CNN与RNN两大模块清晰组织。目前已有190人学习下载。资源提供了从文本预处理、glove词向量加载到模型构建、训练评估的完整闭环,脚本可直接运行或作为二次开发基础,适合课程设计、毕业设计以及NLP实战入门。通过研读代码,读者能直观理解卷积与循环网络在文本序列建模中的特性差异,掌握数据切分、超参数调整与结果评估的工程细节,并为后续学习注意力机制或BERT等进阶模型打下基础。
1. 这个标题在讲什么:为什么文本分类还值得用 CNN 和 RNN
如果把时间拨回 2018 年,BERT 还没全面接管自然语言处理任务时,“基于 CNN 和 RNN 的文本分类”几乎是每个 NLP 从业者的必修课。即便到了今天,预训练语言模型统治了绝大多数分类场景,CNN 和 RNN 这两条技术路线依然没有退出历史舞台——短文本分类的低延迟推理、小规模算力约束下的离线任务、以及教学场景中的原理验证,它们依然是最稳的起点。标题里这个.zip大概率就是一套包含数据预处理、模型定义、训练脚本和预测脚本的完整工程包,而你的任务不是解压后按 README 跑一遍,而是搞清楚里面每层网络在干什么、超参为什么这么设、出问题时该看哪一行。
文本分类的本质并不复杂:把一串不定长的文字映射到一个离散的标签集合上。难点在于“不定长”和“离散”这两个词——神经网络的输入必须是定长的数值张量,标签必须转成可微分的概率分布。深度学习方案解决的正是这两件事:用 embedding 把词变成稠密向量,用 CNN 或 RNN 把变长的词向量序列压成一个定长的句子表示,最后用全连接层加 softmax 输出类别分布。下面四章会沿着“数据怎么进模型 → CNN 怎么做分类 → RNN 怎么做分类 → 两者怎么选和怎么调”这条线,把整个工程拆开讲透。
适合读这篇文章的人有两类:一类是刚入门深度学习、想用代码验证 CNN 和 RNN 在文本上到底怎么工作的同学;另一类是有一定工程经验、需要在 CPU 环境或低延迟场景下快速落地一个分类服务的工程师。前者能从这里拿到完整的实现路径,后者能从这里找到参数设置的边界和坑。
2. 文本分类的数据管线:从原始文本到定长张量
2.1 分词与词典构建:中文场景下的第一个分岔路口
任何文本分类模型的第一步都是把原始字符串切成 token。英文天然以空格分词,而中文必须借助分词工具。常见的做法是直接用jieba做精确模式分词,但如果你的语料是商品标题、搜索词这类短文本,字符级切分(直接把每个汉字作为一个 token)往往效果更好——字符级可以避免分词错误被一路放大到模型输出,也能让词典规模控制在几千到几万之间。
分词之后要做两件事:构建词典和完成序列填充。词典的作用是把每个 token 映射到一个整数 id,这一步通常用collections.Counter统计词频后按频次排序完成:
from collections import Counter import jieba def build_vocab(texts, max_vocab_size=50000): counter = Counter() for text in texts: tokens = jieba.lcut(text) # 精确模式分词 counter.update(tokens) # 保留最高频的 max_vocab_size-2 个词, 0 留给 <PAD>, 1 留给 <UNK> vocab = {word: idx + 2 for idx, (word, _) in enumerate(counter.most_common(max_vocab_size - 2))} vocab['<PAD>'] = 0 vocab['<UNK>'] = 1 return vocab这里把0固定留给<PAD>是工程上的惯例,因为在后面查 embedding 表时,padding_idx=0可以让填充位的梯度始终为零,不参与训练。<UNK>则用来覆盖训练集以外的词,杜绝预测阶段出现“index out of range”的崩溃。
2.2 序列填充与掩码:长度到底截到多少
词序列转成 id 序列后,长度依然参差不齐。常见做法是截断加填充:超过max_len的直接截断,不足的用0补齐。max_len的选择直接影响模型效果——设太短丢信息,设太长浪费算力。我的经验是先统计训练集长度分布,取 95 分位数,而不是拍脑袋定一个值。
填充之后有一个容易被忽略的问题:RNN 会真实地“读过”填充位置的向量,而 CNN 的卷积核也会扫过填充位。虽然<PAD>的 embedding 在训练中会被压到接近零向量,但卷积的偏置项依然会让填充区域产生非零响应。所以要么在卷积或 RNN 之后用 mask 把填充位对应的输出清零,要么干脆用“全局池化只对有效位取均值”的方式来规避。PyTorch 里通常用pack_padded_sequence处理 RNN,而 CNN 这边则通过torch.nn.utils.rnn或手动 mask 解决。
import torch from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence def collate_fn(batch): texts, labels = zip(*batch) lengths = torch.tensor([len(t) for t in texts]) # 已经是 id 序列的 batch padded = torch.nn.utils.rnn.pad_sequence([torch.tensor(t) for t in texts], batch_first=True, padding_value=0) return padded, lengths, torch.tensor(labels)注意pack_padded_sequence要求 batch 内的样本按长度降序排列,否则会静默出错或者给出错误结果。这个坑非常隐蔽,我建议在collate_fn里直接按lengths排序,省得后面 debug 到怀疑人生。另外提醒一点:embedding 矩阵的行数必须是max_vocab_size,否则加载预训练词向量时会因为词表对不上而错位。
3. CNN 文本分类:用卷积核捕捉 n-gram 特征
3.1 TextCNN 的结构拆解:为什么卷积能处理文本
CNN 最初是为图像设计的,但 Yoon Kim 在 2014 年提出的 TextCNN 说明了一个事实:文本的局部 n-gram 特征也可以用卷积核来提取。一张图的局部是一个像素块,一段文本的局部是一个窗口内的若干 token。当 embedding 把每个 token 映射为d维向量后,一个长度为seq_len的句子就变成一个(seq_len, d)的矩阵,卷积核在这个矩阵上从左到右滑动,本质上就是在扫描连续的词窗口,比如“非常 棒”和“不太 好”这种局部搭配。
TextCNN 的经典配置是使用多个不同尺寸的卷积核并行扫描:卷积核高度分别取 2、3、4,对应 bigram、trigram、4-gram 特征。每个尺寸的卷积核通常设 128 或 256 个,输出经过 ReLU 后送入最大池化层,把每个特征图压成一个标量。最大池化的含义是“只要这个窗口模式在句子任意位置出现过,就认为该特征被激活”,这天然契合文本分类的平移不变性需求——一个关键词出现在句首还是句尾不应影响分类结果。
3.2 用 PyTorch 实现一个可运行的 TextCNN
import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=100, num_filters=128, filter_sizes=[2, 3, 4], num_classes=10, 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, (fs, embed_dim)) for fs in filter_sizes ]) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x).unsqueeze(1) # (batch, 1, seq_len, embed_dim) conv_out = [] for conv in self.convs: c = conv(emb) # (batch, num_filters, seq_len - fs + 1, 1) c = F.relu(c).squeeze(3) # 去掉最后一维 pooled = F.max_pool1d(c, c.size(2)).squeeze(2) # (batch, num_filters) conv_out.append(pooled) merged = torch.cat(conv_out, dim=1) # (batch, num_filters * len(filter_sizes)) return self.fc(self.dropout(merged))这段代码有几个参数需要重点说明。embed_dim=100是一个性价比很高的默认值——如果后续要加载 GloVe 或腾讯词向量,100 维是最容易找到预训练权重的档位。num_filters=128是效果与算力的平衡点,再往上加收益会明显递减。filter_sizes的选择与文本长度相关:短文本用[2, 3, 4],长文本可以加到[3, 4, 5]。卷积核宽度等于embed_dim,意味着卷积只在序列长度方向上滑动,不做词向量维度的混合,这是 TextCNN 的标准做法。
3.3 训练 CNN 时的三个关键问题
第一个是学习率。文本分类任务里 Adam 优化器的默认学习率1e-3通常偏大,尤其是当使用了预训练 embedding 时。更稳的配置是Adam + lr=1e-3 + 每两轮降一半,或者直接换用AdamW加weight_decay=1e-4。第二个是 dropout 的位置——nn.Dropout在训练时随机屏蔽神经元,但评估时必须关闭,PyTorch 的model.eval()会自动处理这一点,可很多人忘了在验证循环里切换模式,导致验证集准确率忽高忽低。第三个是类别不平衡。如果你的标签分布是长尾的,交叉熵损失会把头部类别的梯度主导整个训练,常见做法是用torch.nn.CrossEntropyLoss(weight=class_weights)传入每个类别的样本占比倒数。
关于池化方式还有一个值得讨论的变体。最大池化只保留响应最强的位置,但对某些任务来说,特征出现的“次数”也有意义。Kimi 在原文里比较了 max-pooling 和 average-pooling,结论是 max 更好,但在长文本的粗粒度分类上,max 和 avg 拼接也能带来微小提升。我倾向于在一开始用纯 max,等 baseline 跑通后再实验拼接。
4. RNN 文本分类:用循环网络建模序列依赖
4.1 为什么 RNN 拿下的是 CNN 够不着的长程依赖
CNN 受限于卷积核尺寸,感受野只覆盖窗口内的词。如果你想捕捉“虽然……但是……”这种跨句子的转折关系,或者意图分类中“我不认为这个方案很差”里的双重否定,CNN 的 n-gram 就无能为力了。RNN 的优势在于它的循环结构让信息沿着时间步传递——第 t 时刻的隐状态携带了从第 1 个词到第 t 个词的全部压缩信息。
但经典的 RNN 存在梯度消失问题。反向传播时梯度要沿着时间步连乘,超过 10 步就基本归零,模型学不到远距离依赖。所以实际工程中直接使用nn.RNN的情况极少,真正的主角是 LSTM(长短期记忆网络)和 GRU(门控循环单元)。LSTM 通过输入门、遗忘门、输出门三条控制通路决定“记住什么、忘掉什么、输出什么”,GRU 把三条门精简成两条,效果几乎不减,参数更少、训练更快。
4.2 BiLSTM + Attention:文本分类的经典配置
单向 LSTM 只能从前向后读句子,但一句“这部电影一点都不无聊”里的“不”修饰的是后面的“无聊”,单向网络在读到“不”时并不知道后面会发生什么。双向 LSTM 通过正向和反向两个独立的隐状态序列拼接在一起,让每个 token 的表示同时包含上下文信息,这是文本分类里更推荐的配置。
注意力机制的作用则是解决“最后一个时间步的隐状态作为句子表示时信息瓶颈”的问题——句子越长,末尾隐状态承载的信息越拥挤。注意力做的是加权求和:每个时间步的隐状态分配一个权重,权重由该隐状态与一个可学习的查询向量(或与任务相关的上下文向量)的相似度决定。完整实现如下:
import torch import torch.nn as nn import torch.nn.functional as F class BiLSTMAttention(nn.Module): def __init__(self, vocab_size, embed_dim=100, hidden_size=128, num_layers=2, num_classes=10, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers, batch_first=True, bidirectional=True) self.attention_query = nn.Linear(hidden_size * 2, hidden_size * 2) self.fc = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x, lengths): emb = self.dropout(self.embedding(x)) # (batch, seq_len, embed_dim) packed = nn.utils.rnn.pack_padded_sequence(emb, lengths.cpu(), batch_first=True, enforce_sorted=True) packed_out, _ = self.lstm(packed) lstm_out, _ = nn.utils.rnn.pad_packed_sequence(packed_out, batch_first=True) # 注意力打分 attn_scores = torch.tanh(self.attention_query(lstm_out)) # (batch, seq_len, 2*hidden) attn_weights = F.softmax(attn_scores.sum(dim=2, keepdim=True), dim=1) context = (lstm_out * attn_weights).sum(dim=1) # (batch, 2*hidden) return self.fc(self.dropout(context))需要注意lengths必须是 CPU 张量且按降序排列。数据如果经过 DataLoader 的 shuffle,你必须在 collate 函数里完成排序,否则pack_padded_sequence的enforce_sorted=True会直接报错。
hidden_size=128和num_layers=2是中小规模数据集的常见配置。层数超过 2 在文本分类上很少带来显著收益——更深的 LSTM 意味着要学习的门控参数成倍增加,而文本分类的标注数据通常只有几万条,过拟合风险远大于拟合能力不足的风险。
4.3 长文本的处理策略:截断还是分块
上面实现里pack_padded_sequence只能解决“变长输入”的问题,解决不了“超长输入”的问题。LSTM 的复杂度是 O(seq_len) 的串行计算,512 个词的句子在 CPU 上比 CNN 慢一个数量级。常见的做法是设定最大长度——对于情感分类、主题分类这类任务,200 到 300 个词通常已经覆盖了绝大多数有用信息;如果句子实在太长,先把文本按句号切分再对每个句子预测后做投票,是成本最低的降级方案。
5. CNN 和 RNN 的对决:选型依据、调参对比与训练陷阱
5.1 从三个维度对比两种架构的差异
选型这件事不能靠信仰,要看数据、算力和延迟约束。下面的对比来自我在实际项目中的经验值,适用于中等规模(万级到十万级样本)的中文文本分类任务:
| 维度 | TextCNN | BiLSTM + Attention |
|---|---|---|
| 训练速度 | 快(可并行,GPU 利用率高) | 慢(时间步串行) |
| 推理延迟 | 低,适合高并发在线服务 | 高,CPU 上长度超过 100 有明显延迟 |
| 长距离依赖 | 无法捕捉,受卷积核尺寸限制 | 可以捕捉,但超过 200 步仍会衰减 |
| 小样本表现 | 更好,参数少不易过拟合 | 容易过拟合,需要更强的正则化 |
| 超参数敏感度 | 对 filter_size 和 num_filters 敏感 | 对 hidden_size 和 num_layers 敏感 |
| 可解释性 | 卷积核对应 n-gram,相对直观 | 注意力权重可以可视化,但门控机制较难解释 |
一句话总结:数据量在 5 万以下、延迟要求高、文本是短句,直接选 TextCNN;数据量在 10 万以上、需要捕捉复杂语义关联、对推理延迟不敏感,选 BiLSTM + Attention。两个都跑一遍取平均集成,也完全可以接受。
5.2 训练过程中的五个高频坑
第一个是 embedding 是否冻结的问题。加载预训练词向量后,很多人会把embedding.weight.requires_grad = False当作默认配置。这在数据量大时其实会限制模型的下游适配能力——预训练向量是通用的,但“苹果”在水果分类和手机分类里的语义完全不同,训练中微调 embedding 才能让向量向当前任务偏移。建议只在数据量极小(千级)时冻结,否则始终设为可训练。
第二个是学习率 warmup。如果使用预训练 embedding 加上较大学习率,前几个 batch 的梯度会粗暴地破坏预训练向量的语义结构。常见做法是前 10% 的迭代步数把学习率从 0 线性升到目标值。PyTorch 里用torch.optim.lr_scheduler.LambdaLR就可以实现。
第三个是类别不平衡的处理。除了给损失函数加weight,更有效的手段是训练时做类别采样——构造 DataLoader 时让每个 batch 里各个类别的样本数尽量接近。Pytorch 的WeightedRandomSampler传num_samples参数就能直接实现。
第四个是同一份数据反复打乱后,验证集的分布和训练集高度重叠,导致本地准确率虚高。文本分类数据集里常有大量重复或近重复样本(比如新闻标题),最好在划分数据集之前做一次去重,否则验证集的指标会骗人。
第五个是模型保存时最容易忽略的细节——直接把整个模型对象torch.save(model)会导致加载时依赖模型类的定义路径,换个环境就可能报错。推荐的保存方式:
checkpoint = { 'model_state_dict': model.state_dict(), 'vocab': vocab, 'label_map': label_map, 'config': {'embed_dim': 100, 'num_filters': 128, 'filter_sizes': [2, 3, 4]} } torch.save(checkpoint, 'textcnn_ckpt.pt')加载时用torch.load读取后,再根据config重建模型结构。把vocab和label_map一起保存是为了在预测阶段处理新样本时保持一致的 token 到 id 映射,否则线上推理会出现词表错位的 bug。这个坑我见过不止一次:训练时的准确率是 92%,上线后预测结果完全混乱,最后发现是加载模型时重新调用了build_vocab导致 id 映射变化。
6. 一个实战技巧:用训练曲线快速判断模型状态
训练完模型后先别急着看测试集准确率,花两分钟画一张训练曲线就能把问题定位得七七八八。做法是在每个 epoch 结束时记录训练损失、验证损失和验证准确率,最后用 matplotlib 画出来。判断逻辑并不复杂:训练损失下降而验证损失升高,说明过拟合了,优先加大 dropout 或减小模型容量;两个损失都不下降且训练准确率徘徊在随机水平附近,说明模型没学进去,要检查数据 pipeline 而不是调参——把labels打出来看一眼,或者用一个小 batch 做单步调试。
还有一个提高效率的小技巧:训练过程中不要只用最后一个 epoch 的权重做测试,而是保存验证集准确率最高的那个 checkpoint。实现上是每次验证时比较best_acc,一旦刷新就覆盖保存:
best_acc = 0.0 for epoch in range(epochs): train_one_epoch() acc = evaluate(val_loader) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_model.pt')这个策略在文本分类任务里几乎总是优于“训练到固定 epoch 后用最后一版权重”。数据量越少,验证集准确率的波动越大,不加选择地保留最后一个 epoch 往往会错过早停点的最优参数。
最后说一条关于.zip工程的验收建议:解压后先看它的数据预处理脚本,确认vocab的构建逻辑和验证集的划分方式;再看模型定义里有没有nn.Embedding的padding_idx设置;最后跑一个 200 条样本的小训练循环,如果 loss 能稳定下降,再放开全量数据。以我自己的经验,一个能跑通的 TextCNN 分类工程,从写好数据处理到在 GPU 上拿到一个像样的 baseline,大约需要半天;而省掉这些检查直接跑,往往是跑完一步报一个错,时间翻倍不止。
本文还有配套的精品资源,点击获取