ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文文本分类实践:CNN+RNN双通道模型从预处理到调参

中文文本分类实践:CNN+RNN双通道模型从预处理到调参 简介基于卷积神经网络与循环神经网络的中文文本分类项目面向自然语言处理初学者及需要快速搭建文本分类模型的开发者并在TensorFlow框架下采用字符级输入提供完整源码与数据集涵盖数据预处理、词汇表构建、模型训练与预测等环节。压缩包共18个文件以Python脚本为主9个py文件包含运行脚本与模型定义文件等核心模块另有数据整合脚本、词汇表生成器、测试集与说明文档整体约410KB。内部附带训练集、验证集、测试集三个数据文件分别对应50000条、5000条和10000条样本并配有准确率与损失曲线图及网络结构示意图便于理解模型原理与调参。资源还提供数据子集生成脚本支持从原始分类中批量拷贝并合并文件可灵活控制训练数据规模预处理模块会自动构建并缓存词汇表省去重复处理时间。目前已有170人学习下载适合用于课程设计、毕业设计或入门中文文本分类的实践参考。1. 中文文本分类为什么我把 CNN 和 RNN 放进同一个模型第一次把基于 CNN 和 RNN 的中文文本分类模型跑通是在整理一批电商评论四个类别好评、差评、中性、退款意图。当时用朴素贝叶斯准确率卡在 71%换成单条 RNN 只有小幅提升而短文本上 CNN 的表现反而更好。最后把两者做进同一个分类网络联合训练测试集 F1 涨到 0.87。这套方案解决的核心问题是CNN 擅长抓局部词组特征RNN 擅长捕捉句内顺序信息两者互补之后中文短文本分类的准确率和鲁棒性都有明显提升。项目自带源码和数据集适合已经会用 Python、想跑通第一个深度学习文本分类模型但不想从零造轮子的开发者。不要急着把代码跑完就关掉先把每一层为什么这么设计弄清楚后面换数据集才不会翻车。2. 数据预处理先洗好语料再谈模型三个逃不掉的步骤2.1 中文分词用 jieba 切词但别默认开全模式中文文本分类和英文最大的区别是英文天然按空格分词中文没有。所以第一步用 jieba 把句子切成一串词。这里最常犯的错是直接jieba.cut(text, cut_allTrue)开全模式。全模式会把「中文文本分类」切成「中文」「文本」「分类」「文本分类」等多个重叠结果看起来信息丰富了实际上喂给模型的是大量冗余片段卷积核在词序列上做 n-gram 时特征会被切碎的词干扰。分词用默认的精确模式jieba.lcut(text)就行。import re import jieba import pandas as pd from collections import Counter STOP_WORDS set() with open(stopwords.txt, encodingutf-8) as f: for line in f: STOP_WORDS.add(line.strip()) def clean_text(text): # 去掉 URL 和 用户保留中文、英文和数字 text re.sub(rhttp\S|www\.\S, , text) text re.sub(r\w, , text) text re.sub(r\s, , text) return text.strip() def tokenize(text): words jieba.lcut(clean_text(text)) return [w for w in words if w not in STOP_WORDS and len(w.strip()) 0] df pd.read_csv(data/train.csv) # 至少包含 text, label 两列 df[tokens] df[text].apply(tokenize)这里几个参数是实际调过的。停用词表我用的是网上常见的中文停用词表加上自己业务里收集的几十个无意义词比如「好的」「嗯嗯」这类口头语。分词结果里如果频繁出现长度大于 10 的「词」多半是英文整句没切分可以单独处理。过滤条件是len(w.strip()) 0目的是把纯空格和空串剔除减少无意义特征。补一句血泪经验分词阶段不要追求「分得对」而要追求「分得稳」。同一个词在不同句子里的切法必须一致否则同一语义特征被拆到不同位置CNN 的 n-gram 卷积核很难学到稳定模式。默认精确模式在绝大多数场景下够用如果语料里领域新词多再去维护自定义词典。2.2 从词列表到张量构建词表、定长截断与 padding模型吃的是数字不是词。分词之后要建词表给每个词一个 id。词表的构建原则是低频词直接丢弃映射到UNK。低频词在训练集里只出现一两次模型根本学不到可靠表示反而会把训练集里的偶发现象当规律导致过拟合。我一般把min_freq设到 3语料不到一万条时可以考虑设成 2。min_freq 3 counter Counter() for words in df[tokens]: counter.update(words) vocab {PAD: 0, UNK: 1} for word, freq in counter.items(): if freq min_freq: vocab[word] len(vocab) MAX_LEN 128 def encode(words): ids [vocab.get(w, vocab[UNK]) for w in words] if len(ids) MAX_LEN: return ids[:MAX_LEN] # 超过最大长度直接截断头部 return ids [vocab[PAD]] * (MAX_LEN - len(ids)) X df[tokens].apply(encode).to_list() y df[label].astype(int).to_list()MAX_LEN是定长序列长度这个参数直接影响 GPU 显存和模型效果。我习惯先统计语料长度分布取 90 分位左右的值。中文短文本分类一般句子在 50 字上下128 是很稳的选择如果语料是长文档得分段截取而不是硬截断。请留意代码里的截断策略只保留头部 128 个 token这是最简单但也最粗糙的做法。如果你的分类依据主要出现在句子后半段比如「前面全是铺垫最后一句才是观点」建议改成保留首尾各一部分或者是在截断时优先保留末尾。代码里PAD的 id 是 0这个细节后面模型定义里要用到。PyTorch 的nn.Embedding支持padding_idx0embedding 层会自动把 id 为 0 的位置梯度置零padding 不会参与训练。如果这里不统一后面会出现「padding 位置学出了诡异特征」这类很难排查的问题。2.3 训练/验证/测试集划分按标签分层抽样别直接随机切文本分类数据十有八九是不均衡的。好评远多于差评、正常样本远多于异常样本这是常态。如果直接用train_test_split(X, y, test_size0.1)随机切运气差点验证集里可能一个差评都没有模型在训练集上看着收敛了验证集指标一团乱。正确做法是打开stratify参数按标签比例分层抽样。from sklearn.model_selection import train_test_split X_tr, X_val, y_tr, y_val train_test_split( X, y, test_size0.1, stratifyy, random_state42 ) X_tr, X_test, y_tr, y_test train_test_split( X_tr, y_tr, test_size0.1, stratifyy_tr, random_state42 )stratifyy会让训练集、验证集、测试集里的类别比例和原始数据保持一致。random_state42固定随机种子保证每次跑出来的划分一致不然你以为自己在调模型其实是在调随机种子。这里我习惯再往深想一层如果原始数据的标签本身标注质量差分层抽样解决不了问题只能保证分布一致。所以拿到数据集先看一眼每个类别的样本量再决定要不要做类别加权或数据增强。预处理做到这里X 已经是[样本数, 128]的二维整数列表y 是整数标签。接下来要做的是把它封装成 PyTorch 的 DataLoader。这一步不用绕弯直接用torch.utils.data.TensorDataset和DataLoader组合batch_size64是短文本分类比较稳妥的起步值。数据这块最大的提醒是不要在预处理阶段省时间分词、建词表、分层抽样这三件事任何一件做糙了后面模型再漂亮也救不回来。3. CNN RNN 模型结构双通道怎么搭、参数怎么设3.1 Embedding 层随机初始化还是预训练词向量模型输入是词 id 序列第一层必然是 Embedding把每个词映射成稠密向量。这里有个经典选择直接用随机初始化的 Embedding还是加载 word2vec / GloVe 预训练向量。我的结论是语料量低于 10 万条时随机初始化 训练中更新效果通常不输冻结的预训练向量语料量够大时预训练向量能带来小幅提升但代价是内存占用和加载时间。import torch import torch.nn as nn class CNNRNNClassifier(nn.Module): def __init__(self, vocab_size, embed_dim256, num_classes4, filter_sizes(2, 3, 4), num_filters256, hidden_size128, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k, paddingk // 2) for k in filter_sizes ]) self.gru nn.GRU(embed_dim, hidden_size, batch_firstTrue, bidirectionalTrue, num_layers1) self.fc nn.Linear(len(filter_sizes) * num_filters hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch_size, seq_len] emb self.embedding(x) # [b, seq_len, embed_dim] emb self.dropout(emb) # CNN 通道 conv_in emb.transpose(1, 2) # [b, embed_dim, seq_len] conv_outs [] for conv in self.convs: act torch.relu(conv(conv_in)) # [b, num_filters, seq_len] pooled act.max(dim-1).values # 全局最大池化 conv_outs.append(pooled) cnn_feat torch.cat(conv_outs, dim1) # [b, num_filters * 3] # RNN 通道 rnn_out, _ self.gru(emb) # [b, seq_len, hidden_size * 2] rnn_feat rnn_out[:, -1, :] # 取最后一个时间步 feats torch.cat([cnn_feat, rnn_feat], dim1) return self.fc(self.dropout(feats))embed_dim256是兼顾效果和显存的选择降到 128 也能跑但短文本分类里 256 通常比 128 高两到三个点。padding_idx0必须和词表里的PADid 对应这个前面强调过。这里还有个细节随机初始化 Embedding 时nn.Embedding默认的初始化分布是标准正态不用手动调整。如果你的语料特别小比如只有几千条可以考虑加载预训练词向量并冻结防止小数据把预训练知识冲掉。3.2 CNN 通道卷积核大小怎么选CNN 通道的作用是提取局部 n-gram 特征。filter_sizes(2, 3, 4)对应 bigram、trigram、4-gram这是文本 CNN 流传最广的组合。2-gram 捕捉「不/好」这种紧邻搭配3-gram 捕捉「非/常/好」这种三词短语4-gram 覆盖更长的固定搭配。每个尺寸的卷积核数量num_filters256意味着 3 个尺寸一共 768 个卷积核扫过序列得到 768 维特征。代码里我给卷积加了paddingk//2保持卷积输出的长度和输入一致这样最大池化之前序列长度不变可以放心取max(dim-1)。如果去掉 padding序列长度会逐层缩减最后一个词的特征在池化时会丢失位置信息。全局最大池化的含义是对每个卷积核只保留它在整条序列上响应最强的那个位置。这对分类任务非常合适因为分类只看「有没有出现某个关键模式」不关心它出现在第几个词。参数调整的经验是句子平均长度小于 20 时4-gram 意义不大改成(2, 3)类别数很多、任务复杂时num_filters可以加到 512但训练时间会明显变长。顺便说一下这里不能默认把池化换成平均池化。平均池化会把「只有一个位置强烈响应」的特征稀释掉CNN 在短文本上的优势主要就靠最大池化撑着。3.3 RNN 通道双向 LSTM 还是 GRURNN 通道负责捕捉顺序信息。大多数人第一反应是 LSTM实际用下来文本分类场景 GRU 和 LSTM 效果非常接近但 GRU 参数更少、训练更快、显存占用更低。所以我一般直接用双向 GRU。bidirectionalTrue的含义是一个 GRU 从左往右读另一个从右往左读两个方向的输出在最后一个维度上拼接最终输出维度是hidden_size * 2。这样句子开头的信息经过正向 GRU 会留到最后句子结尾的信息经过反向 GRU 也能留到最后等于每个位置都同时看到了上下文。代码里rnn_feat rnn_out[:, -1, :]只取了最后一个时间步。这个写法有一半是方便展示有一半是偷懒。细想一下因为序列做了 padding最后一个时间步很可能是PAD不是真正的句子结尾。GRU 读入 padding 后输出的 hidden state 并不是「整句压缩表示」而是一个被 padding 污染的状态。所以这里更稳的做法是 mean pooling对rnn_out在序列维度上取平均让整句所有位置的隐状态都参与表示。如果你想用 attention 加权也可以但实现复杂度高不少。我自己的项目里用的是rnn_out.mean(dim1)比rnn_out[:, -1, :]稳定两到三个点。3.4 特征融合concat 之后不能直接接分类头CNN 通道输出 768 维GRU 通道输出 256 维torch.cat拼接成 1024 维然后经过 Dropout 和 Linear 得到 4 类 logits。这里有个新手容易忽视的点两个通道的特征分布差异很大CNN 特征是经过最大池化的「强响应值」数值偏高GRU 特征是隐状态序列的聚合数值偏小。直接拼接后Linear 层要同时适应两种分布训练初期会比较挣扎。解决方法是先对每个通道分别LayerNorm再拼接或者干脆先各自过一个小的瓶颈层统一到相同维度。从实际效果讲这个融合层的设计比很多人想的更关键。我见过一个翻车案例把 concat 换成相加模型训练始终不收敛。因为 CNN 和 GRU 特征空间不一致强行相加等于往向量里注入噪声。先拼接再用全连接层学习组合权重是最可靠的做法。模型的最后一层分类头在 4 分类任务里就是nn.Linear(1024, 4)不需要再加额外的隐藏层小数据加隐藏层只会放大过拟合风险。4. 训练配置与调参从 loss 不降到测试集 90% 以上的关键参数4.1 损失函数与类别加权别忽视少数类分类任务的默认损失是CrossEntropyLoss但如果类别不均衡直接用它会导致模型偏向样本量大的类别。处理方式有两种一是给损失函数加weight参数让少数类的错误贡献更大二是在数据层面做重采样。我对文本分类的态度是数据重采样容易改变文本分布不如用类别加权来得干净。from sklearn.utils.class_weight import compute_class_weight import numpy as np classes np.array(sorted(set(y_tr))) weights compute_class_weight(class_weightbalanced, classesclasses, yy_tr) class_weight torch.tensor(weights, dtypetorch.float32) criterion nn.CrossEntropyLoss(weightclass_weight)compute_class_weight会按「总样本数 / (类别数 * 类别样本数)」自动计算每个类别的权重。少数类样本越少权重越高。这里有个细节class_weight传进CrossEntropyLoss后当某个类别的样本数特别少时权重可能把损失放大好几倍。如果训练时 loss 一直震荡先检查权重是否过大再按比例缩小所有权重。类别加权解决的是「模型忽略少数类」的问题但它不能创造信息个别类样本数少于几十条时还是先去收集数据。4.2 优化器与学习率AdamW、warmup 和梯度裁剪是标配优化器我直接选AdamW它在 Adam 的基础上把权重衰减从梯度里分开处理正则效果更干净。学习率是1e-3起步但这不是死值。当你有预训练 Embedding 时Embedding 层的学习率要调低因为预训练特征已经被训好了过大的学习率会把它冲掉而随机初始化的部分需要大学习率才能快速收敛。这种情况我这边的做法是把参数分组Embedding 一组、其他参数一组分别设学习率。model CNNRNNClassifier(len(vocab), num_classeslen(classes)) no_decay [embedding] optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], lr: 5e-4}, {params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], lr: 1e-3}, ] optimizer torch.optim.AdamW(optimizer_grouped_parameters, weight_decay1e-4)训练循环里必须加clip_grad_norm_。RNN 在时间步上反向传播梯度容易爆炸一个异常样本就能让 loss 跳到 NaN。max_norm5.0是常用值意思是把梯度 L2 范数截断到 5超过就按比例缩放。这个参数不是玄学是保护训练稳定性的保险丝。如果你发现 loss 突然变成 nan 或者 inf第一件事就是检查有没有做梯度裁剪。for epoch in range(20): model.train() total_loss 0 for xb, yb in train_loader: optimizer.zero_grad() logits model(xb) loss criterion(logits, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item()batch_size我建议从 64 开始。文本分类不像图像batch 太大反而让梯度方向过于平滑模型在验证集上容易欠拟合。内存够的情况下优先调大MAX_LEN而不是batch_size因为长序列的信息增益通常比大 batch 的梯度平滑更有价值。4.3 训练循环里该盯哪些指标准确率会骗人看 macro-F1训练时盯着 loss 是应该的但评估模型不能只看准确率。4 个类别里如果有 3 个是好评、1 个是差评模型全预测成好评就有 75% 准确率看起来不错实际一个差评都没找出来。这也正是分类任务里的典型陷阱。所以我在每个 epoch 结束后算验证集的macro-F1这个指标把每个类别的 F1 打了平均少数类表现差会直接拉低整体分数。from sklearn.metrics import f1_score best_f1 0 patience 0 model.eval() val_preds [] with torch.no_grad(): for xb, _ in val_loader: logits model(xb) val_preds.extend(logits.argmax(dim-1).numpy()) macro_f1 f1_score(y_val, val_preds, averagemacro) print(fepoch {epoch}: macro_f1 {macro_f1:.4f}) if macro_f1 best_f1: best_f1 macro_f1 torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 3: break代码里顺手加了 early stopping验证集 macro-F1 连续 3 个 epoch 没有刷新纪录就停止训练。这个机制配合torch.save保存最优模型能防止最后几个 epoch 的过拟合把你的最优参数覆盖掉。很多人习惯训练结束后再保存模型大概率会存到一个过拟合版本。用这个套路训练 20 轮以内的文本分类模型一般都能稳定收敛测试集 macro-F1 到 90% 以上在干净的公开数据集上不是什么难事。5. 中文文本分类常见问题排查5 个实测翻车现场5.1 现象模型从第一个 epoch 开始就一直输出同一个类别原因类别严重不均衡时如果CrossEntropyLoss没有设置类别权重模型会迅速发现「全部预测成多数类损失也不高」的捷径。另外学习率设到1e-2以上也会导致模型在训练初期就收敛到局部最优。解决先打印每个类别的预测分布。如果预测全是类别 0给CrossEntropyLoss加weight参数再把学习率降到5e-4重新训。还有一个细节验证集划分时没用stratify也会让验证集的类别分布失真表现为「模型在训练集上正常在验证集上全预测成一类」。5.2 现象训练集 loss 下降很快但验证集 macro-F1 卡在一个低位不涨原因词表建得太大且min_freq1大量只出现一次的词被单独编码模型在训练集上死记这些偶发词根本学不到泛化规律。另一个常见原因是MAX_LEN设太大序列里全是PADRNN 读了一大段 padding真实特征被稀释。解决把min_freq提到 3 以上重建词表重新训练。检查序列里PAD的占比如果平均超过 30%把MAX_LEN调小到 64 或 48。我排查过的一个 case 就是MAX_LEN256但语料平均长度只有 30模型练了 10 个 epoch 都没动静调小后第二轮就出效果了。5.3 现象GPU 显存占用随 epoch 递增最后 OOM原因训练循环里每个 batch 的 loss 累加到了 Python 变量total_loss loss.item()没问题但如果你写的是total_loss lossTensor 的计算图会一直保留显存随着 epoch 增长越滚越大。这不是模型写错是代码写错。解决只累加loss.item()或者用total_loss loss.detach()。另外检查是否在验证集上也开了梯度验证阶段要包在torch.no_grad()里。这两个地方检查完显存曲线应该是一条平线。5.4 现象模型在验证集上效果好换一批来源不同的文本就崩原因训练语料和线上语料分布不一致比如训练集是新闻、线上是用户评论。新闻语言规范评论里全是口语缩写和错别字词表里的UNK比例一下子升到百分之二三十模型对着未知词束手无策。解决把UNK占比当作一个监控指标写进评估脚本。如果线上数据 UNK 率超过 5%先补充领域词表再考虑是否收集线上真实数据微调。另一个思路是分词阶段不做严格过滤保留部分语气词和口语词让模型见得多一点但这个方法治标不治本。5.5 现象新词和错别字把模型带偏分类结果莫名其妙原因jieba 对不在词典里的新词会切成单字比如「绝绝子」被切成「绝」「绝」「子」。而 CNN 的 2-gram 卷积核看到「绝」「绝」会当普通叠词处理完全丢失了「绝绝子」在网络语境里的真实含义。这个问题本质是词表覆盖度不够。解决维护一个业务自定义词典用jieba.load_userdict(user_dict.txt)加载。词典格式是「词 词频 词性」比如「绝绝子 100 n」。如果你处理的是社媒文本这一步几乎是必须的。还有一条路是退而求其次把字符级特征作为扩展输入和词级特征拼接但模型复杂度会明显上升前置做好词典更省钱。6. 验证模型的泛化能力混淆矩阵和 bad case 分析先别急着上线训练完要做两件事。第一步是打印混淆矩阵看哪些类别互相打架。其次分析 bad case找出预测错的句子连同原始文本、预测标签、预测置信度一起打出来逐个看错误原因。from sklearn.metrics import classification_report, confusion_matrix model.eval() y_pred [] with torch.no_grad(): for xb, _ in test_loader: logits model(xb) y_pred.extend(logits.argmax(dim-1).numpy()) print(classification_report(y_test, y_pred, target_names[好评, 差评, 中性, 退款意图])) print(confusion_matrix(y_test, y_pred))这是评估的第 1 步整体指标。第 2 步是我自己反复用的方法专门找预测错的样本对每个错误样本打印真实标签、预测标签和 softmax 概率最高的 3 个类别。如果模型在「退款意图」和「差评」之间反复横跳说明这两个类别的定义在数据标注阶段就有重叠如果错的全是UNK占比很高的短句说明预处理词典没覆盖好。一个我踩过的教训我在某个项目里只盯着 macro-F1忽略了混淆矩阵结果模型把「中性」全部偏向「好评」F1 还很漂亮。后来加了 bad case 分析才发现因为标注者把很多中性评论标成了好评。之后我养成的习惯是每轮实验都保存一份错误预测清单对比不同轮次之间 bad case 的共性。这比盯着训练曲线更接近问题本质。希望这套流程能帮你在自己的数据上少走几趟弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进