Python实战:CNN+RNN双路模型实现中文文本分类

发布时间:2026/10/26 23:19:57
Python实战:CNN+RNN双路模型实现中文文本分类 简介这份资源面向具备一定深度学习基础、希望动手实践中文文本分类的开发者与学习者提供基于TensorFlow的字符级CNN与RNN完整实现方案可用于新闻标题分类等场景的入门与对比实验。压缩包共18个文件约410KB以9个Python脚本为核心涵盖CNN与RNN两套模型定义、训练与预测入口及数据预处理模块另含4张网络结构与准确率曲线图、1个Shell数据整理脚本、1份说明文档及依赖清单便于快速理解项目结构与运行流程。资源已吸引170人学习。数据侧提供训练集5万条、验证集5000条、测试集1万条的划分方案并配套字符级词表构建、类别映射与批量读取逻辑读者可据此复现从数据生成、模型搭建到训练评估的完整链路并对比两种网络在中文分类任务上的表现差异适合作为课程设计或自学练手项目。1. 中文文本分类为什么值得用 CNNRNN 双路做一遍中文文本分类是 NLP 落地里最容易被低估的一类任务情感分析、垃圾评论识别、工单自动路由、新闻频道归类背后都是同一套东西——把一段变长文本映射到一个离散标签。很多人第一反应是直接上 BERT 微调效果确实好但在数据量只有几千到几万条、算力只有一张消费级显卡、还要控制推理延迟的场景里CNN 和 RNN 的组合依然是性价比最高的方案之一。CNN 擅长抓局部 n-gram 特征比如「不」「太」「满意」这种连续片段RNN 擅长建模词序和长距离依赖比如否定词出现在句尾时对整句情感的翻转。把两路特征拼起来再分类往往比单路稳定几个点。这篇笔记就围绕 Python 基于卷积神经网络以及循环神经网络实现的中文文本分类这条线把数据预处理、词向量、双路模型结构、训练参数和踩坑点讲清楚让你能照着复现一套能跑通、能调参、能上线的中文文本分类项目。适合已经会 Python、装过 PyTorch 或 TensorFlow、但还没系统做过中文分类的工程师。2. 中文文本分类的数据准备与词向量选型2.1 中文和英文预处理的本质差别英文按空格切词就行中文不行。中文没有天然分隔符必须先分词。常见做法是用 jieba 做精确模式分词再过滤停用词和标点。这里有个容易被忽略的点分词粒度直接决定 CNN 卷积核看到的是什么。如果分得太碎「人工智能」被切成「人工」「智能」CNN 的 3-gram 窗口就抓不到完整术语如果分得太粗词表爆炸嵌入层参数量失控。我一般会先用 jieba 默认模式跑一版统计词频分布把出现次数少于 2 次的词统一映射到UNK词表控制在 2 万到 5 万之间。这个区间在几万条数据上既能覆盖大部分语义又不会让嵌入矩阵大到训练不动。标签体系也要在预处理阶段定死。中文分类常见的有二分类好评/差评和多分类体育/财经/科技。多分类时标签必须从 0 开始连续编号后面 CrossEntropyLoss 直接吃这个整数不要用字符串标签在训练循环里现转容易在 batch 拼接时出错。import jieba import re from collections import Counter def clean_text(text): # 去掉 URL、邮箱、多余空白保留中文和基本标点 text re.sub(rhttp\S|www\.\S, , text) text re.sub(r\s, , text) return text.strip() def tokenize(text, stopwords): text clean_text(text) words jieba.lcut(text) # 过滤停用词、单字标点、纯数字 words [w for w in words if w not in stopwords and not re.match(r^[\d\W]$, w)] return words def build_vocab(tokenized_corpus, max_size30000, min_freq2): counter Counter() for tokens in tokenized_corpus: counter.update(tokens) # 按频率排序保留高频词 vocab {PAD: 0, UNK: 1} for word, freq in counter.most_common(max_size): if freq min_freq: vocab[word] len(vocab) return vocab这段代码里max_size和min_freq是两个必须调的参数。max_size设 30000 是经验值数据量小于 1 万条时可以降到 15000否则嵌入层大部分行永远训练不到。min_freq2是为了砍掉只出现一次的词这些词在训练集里学不到有效向量在验证集里又大概率是UNK留着只会增加噪声。PAD必须占 0 号位后面 padding 到固定长度时用 0 填充嵌入层要设padding_idx0让模型不更新这个位置的梯度。2.2 词向量从零训练还是加载预训练这是中文文本分类里最影响效果的一个决策。从零训练 Embedding 层在数据量小于 5 万条时词向量质量很差相近语义的词在向量空间里并不靠近。加载预训练词向量比如腾讯 AI Lab 的中文词向量、或自己用 Word2Vec 在领域语料上训一版通常能带来 3 到 8 个点的准确率提升。但预训练词向量有个坑它的词表和你的分词结果必须对齐。如果预训练词表里是「人工智能」你分词切成「人工」「智能」那这个词向量就加载不进去等于白搭。所以要么调整分词粒度去匹配词表要么在加载时对未登录词做随机初始化并标记出来。我一般会这样做先确定分词方案再去找词表覆盖度高的预训练向量。加载时统计命中率命中率低于 70% 就说明分词和词表不匹配需要重新调整。命中率在 85% 以上才算可用。import numpy as np def load_pretrained_embeddings(vocab, embedding_path, embed_dim200): # 读取预训练词向量文件格式通常是 词 向量值... pretrained {} with open(embedding_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! embed_dim 1: continue pretrained[parts[0]] np.array(parts[1:], dtypenp.float32) # 构建嵌入矩阵未命中的词用正态分布随机初始化 embedding_matrix np.random.normal(0, 0.1, (len(vocab), embed_dim)) embedding_matrix[0] np.zeros(embed_dim) # PAD 位置置零 hit, miss 0, 0 for word, idx in vocab.items(): if word in pretrained: embedding_matrix[idx] pretrained[word] hit 1 else: miss 1 print(f命中率: {hit / (hit miss):.2%}) return embedding_matrixembed_dim一般选 200 或 300和预训练向量维度保持一致不要自己改。命中率打印出来是为了让你判断分词方案是否合理。如果命中率低优先检查分词粒度而不是换词向量文件。嵌入矩阵构建完后训练时可以选择冻结freezeTrue还是微调。数据量小于 2 万条时建议冻结否则嵌入层会过拟合数据量大于 5 万条时可以放开微调让词向量适配你的领域。3. CNN 与 RNN 双路模型的搭建与参数设置3.1 一维卷积在文本上到底卷什么一维卷积神经网络在文本上的操作和图像里不一样。图像是二维卷积在宽高上滑文本是一维卷积在词序列上滑。假设嵌入后序列长度是 128每个词是 200 维向量那么输入形状是(batch, 128, 200)。一维卷积核的大小通常设为 2、3、4、5分别对应 bigram、trigram、4-gram、5-gram。每个卷积核在序列上滑动提取局部 n-gram 特征然后做最大池化把每个卷积核的输出压成一个标量。多个卷积核拼接后就是 CNN 路提取的句子表示。这里的关键参数是卷积核数量和池化方式。卷积核数量一般设 128 或 256太少特征提取不充分太多容易过拟合。池化用全局最大池化而不是平均池化因为文本里关键特征往往只出现在局部平均会把信号稀释掉。激活函数用 ReLU后面接 DropoutDropout 率设 0.3 到 0.5 之间。import torch import torch.nn as nn import torch.nn.functional as F class CNNEncoder(nn.Module): def __init__(self, embed_dim, num_filters128, kernel_sizes(2, 3, 4, 5), dropout0.4): super().__init__() # 每个 kernel_size 对应一个卷积层 self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len, embed_dim) - 转成 (batch, embed_dim, seq_len) x x.transpose(1, 2) conv_outputs [] for conv in self.convs: c F.relu(conv(x)) # (batch, num_filters, L) c F.max_pool1d(c, c.size(2)) # 全局最大池化 - (batch, num_filters, 1) conv_outputs.append(c.squeeze(2)) out torch.cat(conv_outputs, dim1) # (batch, num_filters * len(kernel_sizes)) return self.dropout(out)kernel_sizes(2,3,4,5)覆盖了从二元到五元的局部组合基本能抓住中文里常见的短语模式。num_filters128配合 4 个卷积核输出维度是 512。如果分类类别多、数据量大可以把num_filters提到 256。transpose(1,2)这一步很容易写错Conv1d 要求通道维在中间输入必须是(batch, channel, length)而嵌入层输出是(batch, length, embed_dim)所以必须转置。忘了转置会直接报维度错误或者更隐蔽地训练不收敛。3.2 循环神经网络路怎么接才不拖后腿RNN 路负责建模词序。中文里「我不觉得好」和「我觉得不」这种语序差异CNN 的局部窗口抓不住但 RNN 可以。常见选择是 LSTM 或 GRU。GRU 参数少、训练快在文本分类这种不需要超长依赖的任务上通常够用LSTM 表达能力强一点但更容易过拟合。我一般先用 GRU 跑基线如果验证集准确率比 CNN 单路低再换 LSTM 试。RNN 的输出取最后一个时间步的隐藏状态或者对所有时间步做最大池化/平均池化。对于变长序列取最后有效时间步更合理但实现时要处理 padding。简单做法是用pack_padded_sequence但要求序列按长度降序排列工程上麻烦。更省事的做法是对 RNN 输出做掩码最大池化把 padding 位置置为负无穷再取 max。class RNNEncoder(nn.Module): def __init__(self, embed_dim, hidden_dim128, num_layers1, dropout0.4, rnn_typegru): super().__init__() rnn_cls nn.GRU if rnn_type gru else nn.LSTM self.rnn rnn_cls( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): # x: (batch, seq_len, embed_dim) outputs, _ self.rnn(x) # (batch, seq_len, hidden_dim * 2) if mask is not None: # mask: (batch, seq_len)1 表示有效词0 表示 padding outputs outputs.masked_fill(mask.unsqueeze(-1) 0, -1e9) out outputs.max(dim1)[0] # 掩码最大池化 return self.dropout(out)bidirectionalTrue是必须的单向 RNN 只能看到左侧上下文分类任务里右侧上下文同样重要。hidden_dim128配合双向输出维度是 256。num_layers设 1 就够设 2 以上在小数据上几乎必然过拟合。掩码最大池化比取最后时间步更稳因为中文短文本里 padding 比例可能很高取最后时间步容易取到 padding 位置。masked_fill里的-1e9是负无穷的近似保证 padding 位置不会被 max 选中。3.3 双路拼接与分类头CNN 路和 RNN 路各自输出一个向量拼接后过全连接层分类。拼接方式有直接 concat 和加权求和两种。直接 concat 更常用让模型自己学两路特征的权重。分类头一般是一层全连接加 Dropout输出维度等于类别数。如果类别多且不平衡可以在全连接前加一层 BatchNorm 稳定训练。class CNNRNNClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, embedding_matrix, num_classes, num_filters128, hidden_dim128, dropout0.4): super().__init__() self.embedding nn.Embedding.from_pretrained( torch.tensor(embedding_matrix, dtypetorch.float32), freezeFalse, padding_idx0 ) self.cnn CNNEncoder(embed_dim, num_filters, dropoutdropout) self.rnn RNNEncoder(embed_dim, hidden_dim, dropoutdropout) fusion_dim num_filters * 4 hidden_dim * 2 self.classifier nn.Sequential( nn.Linear(fusion_dim, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, num_classes) ) def forward(self, x, maskNone): emb self.embedding(x) # (batch, seq_len, embed_dim) cnn_feat self.cnn(emb) # (batch, num_filters * 4) rnn_feat self.rnn(emb, mask) # (batch, hidden_dim * 2) fused torch.cat([cnn_feat, rnn_feat], dim1) return self.classifier(fused)fusion_dim的计算要和前面两路的输出维度严格对齐CNN 路 4 个卷积核各 128 维共 512RNN 路双向 128 维共 256合计 768。写错这个数会在nn.Linear处报维度不匹配。freezeFalse表示嵌入层参与微调如果数据量小改成True。分类头里加一层 256 维的隐藏层是为了增加非线性如果数据量很小可以直接从 fusion_dim 映射到 num_classes减少参数。4. 训练流程、超参数与效果验证4.1 数据加载与 padding 策略中文文本长度差异大短的两三个词长的几百个词。padding 到固定长度是必须的但长度选多少有讲究。选最大长度会导致大量 padding浪费计算且稀释特征选太短会截断长文本。常见做法是统计训练集长度分布取 95 分位数作为 max_len。比如 95% 的文本在 128 个词以内就设max_len128超出的截断不足的补 0。from torch.utils.data import Dataset, DataLoader import torch class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len128): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens self.texts[idx] ids [self.vocab.get(w, self.vocab[UNK]) for w in tokens] ids ids[:self.max_len] mask [1] * len(ids) # padding 到 max_len pad_len self.max_len - len(ids) ids ids [0] * pad_len mask mask [0] * pad_len return (torch.tensor(ids, dtypetorch.long), torch.tensor(mask, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.long))mask要单独返回因为 RNN 路需要它做掩码池化。ids[:self.max_len]是截断注意是保留前 max_len 个词而不是随机截取因为中文文本开头通常包含更多主题信息。如果任务对结尾敏感比如情感分析里结尾常有总结性表达可以改成头尾各取一半。UNK的 id 从 vocab 里取不要硬编码成 1虽然通常就是 1但写死容易在换词表时翻车。4.2 超参数设置与训练循环优化器用 Adam学习率设 1e-3这是文本分类的常用起点。如果加载了预训练词向量且嵌入层微调学习率要降到 1e-4 到 5e-4否则嵌入层会被快速破坏。batch_size 设 64 或 128太小训练不稳定太大泛化变差。训练轮数设 10 到 20配合早停验证集准确率连续 3 轮不提升就停。from torch.optim import Adam from torch.nn import CrossEntropyLoss def train(model, train_loader, val_loader, epochs15, lr1e-3, patience3): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) optimizer Adam(model.parameters(), lrlr) criterion CrossEntropyLoss() best_acc, wait 0.0, 0 for epoch in range(epochs): model.train() total_loss 0 for ids, mask, labels in train_loader: ids, mask, labels ids.to(device), mask.to(device), labels.to(device) optimizer.zero_grad() logits model(ids, mask) loss criterion(logits, labels) loss.backward() # 梯度裁剪防止 RNN 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for ids, mask, labels in val_loader: ids, mask, labels ids.to(device), mask.to(device), labels.to(device) logits model(ids, mask) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f}, Val Acc: {acc:.4f}) if acc best_acc: best_acc, wait acc, 0 torch.save(model.state_dict(), best_model.pt) else: wait 1 if wait patience: print(f早停于 Epoch {epoch1}) break return best_accclip_grad_norm_的max_norm5.0是 RNN 训练里几乎必加的中文文本里长序列遇到梯度爆炸的概率不低不加这个损失会突然变成 nan。早停的patience3是经验值数据量小可以设 2数据量大可以设 5。保存best_model.pt而不是最后一轮模型因为最后一轮往往已经过拟合。验证时model.eval()和torch.no_grad()都要加前者关掉 Dropout后者省显存。4.3 效果验证准确率之外还要看什么准确率在类别不平衡时会骗人。比如 90% 是正面、10% 是负面模型全预测正面也有 90% 准确率。所以必须看每个类别的精确率、召回率和 F1。用 sklearn 的classification_report一行就能输出。如果某个类别 F1 明显低先检查训练样本数是否太少再检查该类别文本是否和别的类别高度混淆。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, test_loader, device): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for ids, mask, labels in test_loader: ids, mask ids.to(device), mask.to(device) logits model(ids, mask) preds logits.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, digits4)) print(混淆矩阵:) print(confusion_matrix(all_labels, all_preds))混淆矩阵能看出具体是哪两个类别在互相误判。如果体育和娱乐混淆严重说明模型没学到区分性特征可以尝试增加 CNN 的卷积核数量或者检查分词是否把关键实体切碎了。验证集和测试集要严格分开不要用测试集调参否则报出来的指标没有参考价值。5. 中文文本分类的避坑与排查清单5.1 损失不下降或变成 nan现象训练几个 batch 后 loss 变成 nan或者一直停在 2.3 左右不动。原因通常是学习率太大、梯度爆炸、或者输入里有空序列。解决先把学习率降到 1e-4 试一轮加上clip_grad_norm_检查数据里有没有长度为 0 的样本空序列经过 RNN 会输出全零经过 CNN 会报错。另外检查标签是否从 0 开始连续编号如果标签是 1 到 5 而模型输出 5 类CrossEntropyLoss 会报 index 越界。5.2 验证集准确率远低于训练集现象训练集准确率 98%验证集只有 70%。原因几乎一定是过拟合。解决先加 DropoutCNN 和 RNN 后面都加率设 0.4 到 0.5再减小模型把num_filters从 256 降到 128hidden_dim从 256 降到 128如果还不行冻结嵌入层不让预训练词向量被微调破坏。数据量小于 1 万条时模型参数量控制在 100 万以内比较安全。5.3 分词结果和预训练词向量对不上现象加载预训练词向量后命中率只有 50% 多模型效果还不如随机初始化。原因分词粒度太细或者用了领域外的词向量。解决先打印未命中的高频词看是不是被切碎了。比如「深度学习」被切成「深度」「学习」那就把 jieba 的自定义词典加上这个词。或者换一个词表更大的预训练向量。命中率低于 70% 时加载预训练词向量的收益可能为负不如从零训练。5.4 GPU 显存不够现象batch_size 设 64 就 OOM。原因序列长度设太长或者模型参数量太大。解决先把max_len从 256 降到 128显存占用几乎减半再把batch_size降到 32配合梯度累积模拟大 batch。如果还不够把嵌入层冻结减少优化器状态显存。CNN 的卷积核数量也是显存大户从 256 降到 128 能省不少。5.5 推理时单条预测结果和批量预测不一致现象测试集整体准确率正常但单条推理时结果乱跳。原因推理时忘了加model.eval()Dropout 还在起作用或者单条推理时 mask 没构造对。解决推理前必须model.eval()并且用torch.no_grad()。单条推理时序列长度是 1mask 要构造成[[1]]padding 逻辑要和训练时完全一致。如果训练时用了 mask推理时也必须传否则 RNN 路的池化行为会变。6. 把双路模型压到可上线的几个技巧训练出模型只是第一步真正上线还要考虑推理速度和模型体积。我一般会做三件事。第一把嵌入层和 CNN 层做量化用 PyTorch 的动态量化把 Linear 和 LSTM 转成 int8模型体积能压到原来的四分之一推理速度提升 2 到 3 倍准确率掉不到 1 个点。第二把词表里低频词砍掉只保留训练时真正更新过梯度的词嵌入矩阵能小一半。第三如果延迟要求极高可以把 RNN 路换成轻量级的一维卷积加门控机制牺牲一点长距离建模能力换推理速度。# 动态量化示例在 CPU 推理场景下效果明显 import torch.quantization model.eval() quantized_model torch.quantization.quantize_dynamic( model, {torch.nn.Linear, torch.nn.LSTM, torch.nn.GRU}, dtypetorch.qint8 ) torch.save(quantized_model.state_dict(), quantized_model.pt)量化后的模型只能在 CPU 上跑GPU 不支持动态量化。如果服务端是 GPU 推理跳过量化改用 ONNX 导出加 TensorRT 优化。导出 ONNX 时注意 mask 要作为输入一起导出否则 RNN 路的池化结果会不对。验证量化模型时不要只看准确率还要看预测分布的偏移。我习惯抽 200 条测试样本对比量化前后预测标签不一致的比例超过 5% 就说明量化损失太大需要回退到浮点模型或者只量化部分层。这个习惯帮我避免过两次线上事故一次是量化后负面评论被大量误判为正面原因是嵌入层量化后相似词向量被合并了。最后说一个我踩过的坑双路模型拼接后如果 CNN 路和 RNN 路的输出量级差太多分类头会偏向量级大的一路。解决办法是在拼接前各自加一层 LayerNorm把输出归一化到相近尺度。这个改动很小但在几个项目里都带来了 1 到 2 个点的提升。模型调参没有银弹把每一路的输出尺度对齐、把 mask 传对、把早停加上这三件事做到位中文文本分类的基线就不会差。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询