ARTICLE DETAIL

资讯详情

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

Python中文文本分类实战:CNN与RNN融合模型从零到评估

Python中文文本分类实战:CNN与RNN融合模型从零到评估 简介这是一套基于TensorFlow的中文文本分类项目源码与配套数据集采用字符级卷积神经网络CNN和循环神经网络RNN实现分类适合自然语言处理初学者、文本挖掘爱好者及需要快速搭建基线模型的开发者。项目提供训练集五万条、验证集五千条和测试集一万条整体分类效果较好。资源压缩包共十八个文件以Python脚本为主覆盖数据预处理、词汇表构建、CNN与RNN模型定义、训练及预测等核心模块另含网络结构示意图、运行依赖清单和说明文档便于对照代码理解原理。包体约四百一十KB轻量易用目前已有170人浏览学习。读者可拿到从原始数据生成子集、合并分类文件到构建词汇表与类别映射、训练字符级模型并预测的完整流程并配有准确率与损失变化图适合在此基础上复现实验或做进一步改进。1. 用 CNN 和 RNN 做中文文本分类这条技术路线现在还能打吗如果只看榜单中文文本分类的首选早该是预训练模型可打开一个真实项目Python 基于 CNN 和 RNN 的中文文本分类仍是被翻出来最多的一套骨架。卷积网络抓短距离关键词循环网络抓上下文顺序两者拼起来几万条新闻、评论、留言都能在分钟级训练里拿到够用的准确率。它不需要大显存每一层都能单独解释适合手里有几千到几万条带标签文本、想从数据处理写到模型评估的初学者也适合先做强基线再决定要不要上 Transformers 的从业者。标题里的源码和数据集价值不在跑通而在把数据格式、模型参数和踩坑位置一次暴露给你。下面按数据、模型、训练、排错的顺序拆开讲。2. 数据预处理从原始语料到 Embedding 输入三个必写模块无论标题里的数据集用什么格式存第一步都是把它读进来、摸清结构、转成索引。先别急着建模型这类项目里 80% 的报错发生在数据形状对不上模型输入而不是模型本身写错。预处理链路固定为三件事读文件成两列、分词建词表、padding 成定长张量。2.1 读原始文件先统一成“标签 文本”两列常见的数据集格式有三种TXT 每行一条“标签 分隔符 文本”、CSV 两列、Excel 单列表格。后两种用 pandas 直接读第一种最需要小心因为正文里可能本身就带制表符。第一步统一转成两列 DataFrame并打印类别分布import pandas as pd rows [] with open(data/train.txt, encodingutf-8, errorsignore) as f: for line in f: line line.rstrip(\n) if not line: continue # 用 partition 只切第一个分隔符避免正文里出现 \t 导致列错位 label, _, text line.partition(\t) rows.append([label.strip(), text.strip()]) df pd.DataFrame(rows, columns[label, text]) print(df.shape) print(df[label].value_counts())逻辑说明partition 按第一个 \t 切分切出来永远是三部分正文里再多 \t 也不会拆出多余列而直接用 split(\t) 遇到正文含制表符就会多出列读进来后 DataFrame 列数对不上。label 和 text 都做 strip去掉行尾换行和首尾空格。value_counts 打印每个类别的样本数这一步直接决定后面要不要做类别平衡。参数说明encodingutf-8 出现乱码时优先换 gbk这是中文数据集最常见的两种编码errorsignore 只能在摸底的时候用它会静默丢字正式建模前应该修好源文件。另外一个隐蔽问题是 CSV 带 BOM 头如果第一列标签名显示成“\ufefflabel”用 pandas.read_csv(sep\t, encodingutf-8-sig) 读一遍就行。2.2 分词与词表字级优先词级作为对比实验中文没有天然空格必须先决定 token 粒度。常见做法是字级或词级字级把每个汉字当成一个 token词级用 jieba 先分词。很多 rnn 讲解文章默认词级但我的经验是短文本分类先走字级词表小、没有 OOV、训练稳定几千条数据也能学出不错的 embedding词级更适合长文本和小词表场景。两种粒度都可以用一个函数支持import jieba from collections import Counter def tokenize(text, char_levelTrue, use_jiebaFalse): if char_level: return list(text) if use_jieba: return jieba.lcut(text) return text.split() def build_vocab(texts, min_count2, max_vocab50000, char_levelTrue): counter Counter() for text in texts: counter.update(tokenize(text, char_levelchar_level)) # 过滤低频词再按频次排序 vocab [w for w, c in counter.items() if c min_count] vocab sorted(vocab, keylambda w: counter[w], reverseTrue)[:max_vocab] word2idx {w: i 1 for i, w in enumerate(vocab)} word2idx[UNK] len(word2idx) 1 return word2idx, counter逻辑说明0 留作 padding 的 id真实 token 从 1 开始编号这是 PyTorch Embedding 层里 padding_idx0 能生效的前提。词表最后补一个UNK测试阶段遇到没见过的字直接映射到它不会 KeyError。低频过滤放在排序之前min_count2 能去掉只出现一次的字这些大多是噪声max_vocab50000 对字级来说用不上词级长尾严重时靠它截断。参数说明停用词表在这个方向收益不大我一般不去。手动删“的、了”可能把“不是不好”这类否定结构拆坏高频字对分类的干扰可以靠 dropout 和权重自己压下去。想跑对比实验时把 tokenize 的 char_level 设为 False 且 use_jiebaTrue词表规模会从几千涨到几万后面要检查UNK占比如果超过 5%说明词级方案在你的语料上并不划算。2.3 序列化与 Padding先截断再补零顺序不能反模型需要定长输入而文本长短不一。max_len 别拍脑袋先统计 token 数分布取 95 分位。短文本评论、标题通常 64 到 128 就够长文本再放宽到 256。编码函数如下import numpy as np def encode_texts(texts, word2idx, max_len128, char_levelTrue): unk_id word2idx.get(UNK, len(word2idx)) data [] for text in texts: ids [word2idx.get(w, unk_id) for w in tokenize(text, char_levelchar_level)] ids ids[:max_len] # 先截断 ids ids [0] * (max_len - len(ids)) # 再补零 data.append(ids) return np.asarray(data, dtypenp.int64)逻辑说明先截断再补零顺序反了要么截断后长度对不上要么把补零位误当成真实文本。ids[:max_len] 是从头部截断保留开头信息——新闻和评论的结论大多在前半段保头比保尾实用。标签用 sklearn 的 LabelEncoder 转成 0 到类别数减一的整数和文本 id 一起保存。参数说明max_len 太小会让长文本丢关键信息太大让 padding 占比高、训练变慢、显存浪费。先对 text 列跑一遍 len(tokenize(text)) 拿到长度分布取 95 分位作为 max_len这批数据里只有 5% 的样本会被截断是性价比最高的选择。最后把 ids 和 labels 存成 npz 文件预处理阶段就结束了。提示这里没用 pack_padded_sequence直接定长 padding 在小项目里误差可以忽略。如果数据里文本长度差异极大再考虑按长度分组 batch能省不少显存。3. 模型构建TextCNN、BiLSTM 与融合写法很多 cnn 和 rnn 的对比帖把它们讲成两条对立路线落到 torch 里其实只是两个模块真正难的是维度对齐。下面把两个子模块和最常见的并行融合结构写出来代码基于 PyTorchKeras 思路同理只是换 API。3.1 TextCNN 的 PyTorch 写法非对称卷积核与全局池化TextCNN 的核心假设是关键词是局部信号不需要看全文。例如“垃圾”出现基本就定调卷积核沿序列方向滑动抽取 n-gram 特征。实现如下import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters128, kernel_sizes(2, 3, 4), num_classes10, 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 kernel_sizes ]) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # [B, L, E] emb emb.permute(0, 2, 1) # Conv1d 要求 [B, C, L] pooled [] for conv in self.convs: c torch.relu(conv(emb)) # [B, F, L] c, _ c.max(dim2) # 全局最大池化 pooled.append(c) out torch.cat(pooled, dim1) # [B, F * len(ks)] return self.fc(self.dropout(out))逻辑说明embedding 输出是 [batch, seq_len, embed_dim]而 Conv1d 期望 [batch, channels, length]所以必须 permute 把维度换过来这是新手最常卡住的地方。三个卷积核分别看 2、3、4 个 token 的窗口对应中文里的双字词、三字词和四字词短语。paddingk//2 让输出长度和输入近似一致再用 max(dim2) 把每个滤波器压成一个数表示“这个短语在句子任意位置出现过”。参数说明num_filters128 是每个卷积核的滤波器数量数据量小可降到 64kernel_sizes 一般不用超过 5窗口太大反而抓不到短语特征。全局最大池化的含义是只关心最强信号位置信息本来就不是 CNN 要保留的东西这也是 CNN 对短文本分类效果好的原因——关键词很直接。3.2 BiLSTM 的 PyTorch 写法双向最后时刻与 Maskrnn 循环神经网络解决的是顺序和长依赖“不是不好”和“不好”差了三个字位置一变情感反转。单向 LSTM 只能看到上文双向把“从头到尾”和“从尾到头”两个隐状态拼起来对否定、转折更敏感。实现如下class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_size128, num_layers2, num_classes10, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # [B, L, E] out, _ self.lstm(emb) # [B, L, 2*H] mask (x ! 0).unsqueeze(-1) # [B, L, 1] out (out * mask).sum(dim1) / mask.sum(dim1) # 平均池化 return self.fc(self.dropout(out))逻辑说明直接取 out[:, -1, :] 在无 padding 时没问题但第 2 章补了一堆 0最后一个位置很可能是 pad所以这里用 mask 把 pad 位置的输出置零再做平均池化。这是 rnn pytorch 接口里最容易翻车的地方LSTM 的输出长度永远等于输入长度padding 位照样吐向量。参数说明batch_firstTrue 让输入输出统一为 [B, L, H]初学者少晕一次num_layers2 时 dropout 才在层间生效单层写 dropout 会被 PyTorch 静默忽略。想换成 GRU只把 nn.LSTM 改成 nn.GRU 就行其余参数不用动GRU 参数更少、训练更快效果通常持平。3.3 融合写法并行 concat 与串行喂入CNN 抓局部强信号RNN 抓时序上下文两个特征互补。最稳的融合方式是并行共享 embedding两路各自出向量concat 后接一个全连接分类头。完整类如下class TextCNNBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters128, kernel_sizes(2, 3, 4), hidden_size128, num_layers2, num_classes10, 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 kernel_sizes ]) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout) self.fc nn.Linear(num_filters * len(kernel_sizes) hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # [B, L, E] pooled [] for conv in self.convs: c torch.relu(conv(emb.permute(0, 2, 1))) c, _ c.max(dim2) pooled.append(c) cnn_vec torch.cat(pooled, dim1) # 局部特征 out, _ self.lstm(emb) # [B, L, 2*H] mask (x ! 0).unsqueeze(-1) rnn_vec (out * mask).sum(dim1) / mask.sum(dim1) # 上下文特征 vec torch.cat([cnn_vec, rnn_vec], dim1) # [B, F*len(ks) 2*H] return self.fc(self.dropout(vec))逻辑说明cnn_vec 是静态局部特征rnn_vec 是动态上下文特征两者拼起来后分类头能同时利用“出现了关键词”和“这句话顺着读下来是什么语气”。共享 embedding 层让两路从同一份词向量出发训练更稳分开建两个 embedding 也可以但小数据集上收益不明显。参数说明num_layers 从 2 降到 1可以省一大截显存短文本场景损失很小。还有一种串行变体先 CNN 卷积但不去池化把输出 permute 回 [B, new_L, num_filters] 喂给 LSTM让循环网络在局部特征序列上再读一遍。串行结构表达能力更强但梯度路径深、更容易过拟合我的建议是先并行跑通瓶颈明显再试串行。4. 训练与调参优化器、学习率与评估指标怎么配合模型结构定了后面就是让 loss 听话的过程。下面把训练循环、评估函数和默认参数表一次给全照着改即可。4.1 训练主循环DataLoader、梯度裁剪与最佳模型保存训练循环本身不复杂复杂的是什么时候保存模型、什么时候降学习率。常见做法是每个 epoch 在验证集上算 macro-F1只保存最高分对应的 state_dict。代码如下import torch from torch.utils.data import DataLoader, TensorDataset # ids 和 labels 来自第 2 章预处理结果 dataset TensorDataset(torch.LongTensor(ids), torch.LongTensor(labels)) loader DataLoader(dataset, batch_size64, shuffleTrue) model TextCNNBiLSTM(vocab_sizelen(word2idx), num_classesn_classes) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience1) criterion torch.nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_num 0.0, 0 for xs, ys in loader: xs, ys xs.to(device), ys.to(device) optimizer.zero_grad() logits model(xs) loss criterion(logits, ys) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() * xs.size(0) total_num xs.size(0) return total_loss / total_num逻辑说明TensorDataset 要求输入已经 padding 成定长所以第 2 章的 encode 结果直接能喂进来。optimizer.zero_grad() 必须在 backward 之前否则梯度会跨 batch 累加。clip_grad_norm_ 把整个模型的梯度范数截断到 5防止 LSTM 梯度爆炸导致 loss 突然变 nan这是新手最容易漏的一行。参数说明AdamW 比 Adam 在 weight_decay 上更规范weight_decay1e-4 相当于给大权重做惩罚压过拟合。ReduceLROnPlateau 按验证指标自动降学习率modemax 表示指标涨才不动连续 patience1 个 epoch 没涨就减半。保存模型只存 state_dict不存整个 model否则换 Python 版本或改结构后加载必炸。4.2 评估指标准确率会骗人宏平均 F1 才可信分类任务最常被忽略的问题是类别不平衡。假设 90% 样本是负类模型全猜负类准确率也有 90%所以评估必须看每个类别的精确率和召回率。评估函数如下from sklearn.metrics import classification_report, f1_score def evaluate(model, loader, device): model.eval() preds, labels [], [] with torch.no_grad(): for xs, ys in loader: logits model(xs.to(device)) preds.extend(logits.argmax(dim1).cpu().numpy()) labels.extend(ys.numpy()) macro_f1 f1_score(labels, preds, averagemacro, zero_division0) print(classification_report(labels, preds, digits4)) return macro_f1逻辑说明classification_report 会输出每个类别的 P、R、F1 和 support。先看 support 少的类别再看它们的 F1如果多数类 F1 0.98、少数类 0.1优化方向就不是调模型结构而是处理数据。macro-F1 是各类别 F1 的算术平均对少数类敏感比 accuracy 和二元 F1 都更适合中文文本分类这种类别天然不均衡的场景。参数说明zero_division0 让某个类别被完全预测错时返回 0 而不是报警告日志更干净。digits4 控制小数位写报告时直接复制输出不用重跑。4.3 参数默认值先按这张表跑再按现象调参数起步值出现什么情况时修改embed_dim128数据量大到 10 万级可升 200否则别动max_len128长文本丢关键信息时按长度分布 p95 加大num_filters128过拟合明显降到 64欠拟合升到 256kernel_sizes(2,3,4)文本以四字词为主时加 5hidden_size128训练慢或过拟合时降到 64num_layers2小数据集用 1 层更稳2 层吃显存dropout0.3train F1 远高于 val 时升到 0.5batch_size64OOM 时降到 32配合梯度累积lr1e-3loss 震荡换 5e-4F1 卡住用 schedulerepoch/patience10 / 2验证集还在涨就继续早停优于硬跑这张表是这类项目最常见的起点。loss 在前几个 epoch 不降不代表模型坏了先看是不是 lr 太大导致反复横跳验证 F1 涨到 0.8 以后不动了通常不是模型上限而是学习率需要调小。epoch 不是越多越好保存最佳模型的逻辑天然带早停训练 20 个 epoch 但 best model 在第 8 个 epoch最后取的是第 8 个而不是第 20 个。5. 避坑指南5 个常见的翻车现场与排查路径模型结构、训练循环都写对了项目依然可能在细节上翻车。下面 5 条是按“现象 → 原因 → 解决”写成的排查路径来自这个方向最常见的血泪经验。5.1 数据处理阶段的两个坑OOV 和截断位置现象一加载预训练词向量时 KeyError或者很多词“假装”有向量但实际是随机初始化模型训练时看着正常测真实数据时效果很差。原因一预训练词表的词和语料分词后的词对不上。词表构建顺序不同、分词器版本不同都会导致 30% 以上的词落回随机初始化相当于预训练知识没用上。解决一要么以预训练词表为准重建 vocab未登录词统一用均值为 0、方差 0.1 的正态分布初始化并保持 trainableTrue 让模型自己微调要么直接走字级输入彻底绕开 OOV。字级别在短文本分类里损失很小是性价比最高的后悔药。现象二验证集 F1 不错但真实长文本上线后明显掉点。原因二max_len 设得太短或者截断时从尾部硬切把结论所在的后半段切没了。解决二先统计文本长度分布max_len 取 95 分位而不是平均值截断保头部因为中文新闻、评论的结论大多在开头。如果是“前面铺垫、结尾反转”的文本改用双向模型并在预测阶段把文本按句切分再聚合。5.2 训练阶段的常见坑类别不平衡和随机种子现象三loss 正常下降准确率 0.93但 classification_report 里少数类 F1 是 0。原因三CrossEntropyLoss 默认各类权重相等样本占比悬殊时模型发现全猜多数类也能把 loss 压得很低于是少数类被放弃。解决三给 CrossEntropyLoss 传 weight按“样本数倒数归一化”设置权重或者用 WeightedRandomSampler 重采样让每个 batch 里少数类占比合理。评估必须看 macro-F1别再用 accuracy 判断好坏。现象四同一份代码、同一份数据两次训练结果差 3 个百分点以上怀疑模型有 bug 或者说结构不稳定。原因四没有固定随机种子。torch、numpy、random 三个库各自有全局随机状态不固定的话 dropout 初始化、数据打乱顺序每次都不同。解决四在 main 入口加固定种子代码这是最容易被忽略的复现基础import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False逻辑说明cudnn.benchmarkFalse 是让 cuDNN 不再自动选最快的卷积算法代价是少量速度损失换来确定性。只有三行 seed 不够PyTorch 的 CUDA 卷积在 benchmark 模式下仍可能引入随机性所以这两行要和 seed 一起写。参数说明seed 取 42 只是惯例固定成任何整数都行关键是同一份代码固定同一个值。5.3 硬件相关的一个坑显存溢出和梯度爆炸现象五训练到一半 CUDA out of memory或者 loss 突然变成 nan。原因五显存溢出通常是 batch_size、max_len、num_layers 三者乘积超了显存。loss 变 nan 常见于学习率过大或 LSTM 梯度爆炸而不是模型写错了。解决五显存溢出先降 batch_size 到 32 或 16再考虑把 num_layers 从 2 降到 1最后才是换更小的 hidden_size。gradient clipping 从 max_norm5.0 开始若还炸就降到 1.0。nan 出现时优先把 lr 降到 5e-4并检查标签里有没有脏数据把 loss 推到 inf。如果训练速度莫名变慢先检查是不是文本 pad 得太长batch 内长短差异大时同样的样本数计算量可能翻倍。6. 最后一招用交叉验证给模型一个可信的 F1再决定动不动结构把训练流程跑通、避坑都过了一遍最该做的不是继续调参而是用交叉验证确认当前方案的真实水平。单次划分的验证集 F1 有运气成分尤其是自带小数据集的项目一次 0.86 可能换一次只有 0.82。我用 StratifiedKFold 固定 5 折每一折只训练少量 epoch拿每折的 macro-F1 看均值和标准差。均值代表方案的真实水平标准差告诉你要不要担心稳定性。代码很短from sklearn.model_selection import StratifiedKFold skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) for fold, (tr_idx, va_idx) in enumerate(skf.split(ids, labels)): train_ids, val_ids ids[tr_idx], ids[va_idx] train_labels, val_labels labels[tr_idx], labels[va_idx] # 建立 DataLoader跑与第 4 章相同的 train_one_epoch / evaluate # 记录本折 macro_f1最后看均值和标准差逻辑说明StratifiedKFold 保证每折里各类别比例和全集一致少数类不会在某折里消失随机种子固定别人也能复现你的结果。5 折之后如果均值 0.84、标准差 0.01说明方案稳定可以拿去上线或写报告如果标准差 0.05说明数据划分的影响大于模型改进这时候调参是白费力气。应该先查数据标注有没有错、类别有没有重叠、有没有重复样本串到训练和验证里。重复样本是中文文本分类项目里隐藏最深的坑去重之后再跑交叉验证标准差通常会明显变小。交叉验证稳定后还有一个值得做的检查错误样本抽样。把验证集里预测错的样本打印出来人工看 50 条你会发现大部分错误属于“标注本身有争议”或者“类别定义模糊”而不是模型能力不足。这时候与其继续堆模型复杂度不如回去修数据。我自己的习惯是每次实验跑完把超参数、classification_report、交叉验证均值存成文本文件放在实验目录里半年后翻出来还能对上当初的改动比翻聊天记录靠谱。希望帮到你。本文还有配套的精品资源点击获取
返回列表