ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

文本分类实战:CNN与RNN的原理、实现与选型指南

文本分类实战:CNN与RNN的原理、实现与选型指南 简介面向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_size50000): 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_idx0可以让填充位的梯度始终为零不参与训练。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_firstTrue, padding_value0) 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 实现一个可运行的 TextCNNimport torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, filter_sizes[2, 3, 4], num_classes10, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) 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, dim1) # (batch, num_filters * len(filter_sizes)) return self.fc(self.dropout(merged))这段代码有几个参数需要重点说明。embed_dim100是一个性价比很高的默认值——如果后续要加载 GloVe 或腾讯词向量100 维是最容易找到预训练权重的档位。num_filters128是效果与算力的平衡点再往上加收益会明显递减。filter_sizes的选择与文本长度相关短文本用[2, 3, 4]长文本可以加到[3, 4, 5]。卷积核宽度等于embed_dim意味着卷积只在序列长度方向上滑动不做词向量维度的混合这是 TextCNN 的标准做法。3.3 训练 CNN 时的三个关键问题第一个是学习率。文本分类任务里 Adam 优化器的默认学习率1e-3通常偏大尤其是当使用了预训练 embedding 时。更稳的配置是Adam lr1e-3 每两轮降一半或者直接换用AdamW加weight_decay1e-4。第二个是 dropout 的位置——nn.Dropout在训练时随机屏蔽神经元但评估时必须关闭PyTorch 的model.eval()会自动处理这一点可很多人忘了在验证循环里切换模式导致验证集准确率忽高忽低。第三个是类别不平衡。如果你的标签分布是长尾的交叉熵损失会把头部类别的梯度主导整个训练常见做法是用torch.nn.CrossEntropyLoss(weightclass_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_dim100, hidden_size128, num_layers2, num_classes10, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue) 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_firstTrue, enforce_sortedTrue) packed_out, _ self.lstm(packed) lstm_out, _ nn.utils.rnn.pad_packed_sequence(packed_out, batch_firstTrue) # 注意力打分 attn_scores torch.tanh(self.attention_query(lstm_out)) # (batch, seq_len, 2*hidden) attn_weights F.softmax(attn_scores.sum(dim2, keepdimTrue), dim1) context (lstm_out * attn_weights).sum(dim1) # (batch, 2*hidden) return self.fc(self.dropout(context))需要注意lengths必须是 CPU 张量且按降序排列。数据如果经过 DataLoader 的 shuffle你必须在 collate 函数里完成排序否则pack_padded_sequence的enforce_sortedTrue会直接报错。hidden_size128和num_layers2是中小规模数据集的常见配置。层数超过 2 在文本分类上很少带来显著收益——更深的 LSTM 意味着要学习的门控参数成倍增加而文本分类的标注数据通常只有几万条过拟合风险远大于拟合能力不足的风险。4.3 长文本的处理策略截断还是分块上面实现里pack_padded_sequence只能解决“变长输入”的问题解决不了“超长输入”的问题。LSTM 的复杂度是 O(seq_len) 的串行计算512 个词的句子在 CPU 上比 CNN 慢一个数量级。常见的做法是设定最大长度——对于情感分类、主题分类这类任务200 到 300 个词通常已经覆盖了绝大多数有用信息如果句子实在太长先把文本按句号切分再对每个句子预测后做投票是成本最低的降级方案。5. CNN 和 RNN 的对决选型依据、调参对比与训练陷阱5.1 从三个维度对比两种架构的差异选型这件事不能靠信仰要看数据、算力和延迟约束。下面的对比来自我在实际项目中的经验值适用于中等规模万级到十万级样本的中文文本分类任务维度TextCNNBiLSTM 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大约需要半天而省掉这些检查直接跑往往是跑完一步报一个错时间翻倍不止。本文还有配套的精品资源点击获取
返回列表