ARTICLE DETAIL

资讯详情

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

CNN与RNN结合的中文文本分类实战:PyTorch模型搭建与避坑指南

CNN与RNN结合的中文文本分类实战:PyTorch模型搭建与避坑指南 简介面向中文文本分类入门者的 Python 项目基于卷积神经网络CNN与循环神经网络RNN在 TensorFlow 上实现字符级中文文本分类。资源适合希望熟悉文本预处理、词表构建、模型训练与验证流程的读者既可作为课程设计参考也能用于快速搭建分类基线。压缩包共 18 个文件以 Python 脚本为主涵盖数据加载与词汇表构建、CNN/RNN 模型定义、训练与预测入口附带预处理 shell 脚本和训练过程可视化图片整体仅 410KB便于本地复现。数据部分提供训练集 5 万条、验证集 5000 条、测试集 1 万条的中文新闻文本已按字符级方式完成预处理。已有 170 人学习下载。项目目录结构清晰包含 run_cnn.py、run_rnn.py 等可直接运行的脚本以及 acc_loss 曲线图可帮助读者对比两种网络在中文本分类任务上的效果快速掌握从原始数据生成子集到模型评估的完整实现思路。1. 为什么要同时用 CNN 和 RNN 做中文文本分类这套项目源码能解决的实际问题用 Python 同时接入卷积神经网络CNN和循环神经网络RNN做中文文本分类乍一看像给分类任务上了双重保险实际上去手跑两轮你会发现数据预处理和两个网络输出怎么拼才是决定效果好坏的命门。这类项目源码给到你的不只是一个能跑通的模型定义更是一条从中文语料到类别概率的完整链路分词、词表建立、序列填充、CNN 局部特征抽取、RNN 上下文建模、特征拼接和分类输出。适合要快速对新闻、评论、工单做打标的开发者不需要先精调大型预训练模型一张消费级显卡甚至 CPU 都能在可接受时间内完成小规模训练。我过去用 2 万样本做 10 分类简单模型在 CPU 上基本一顿饭功夫就能看到指标变化真正的成本都花在数据切分和排查模型翻车上。2. 拆开模型黑匣子CNN 和 RNN 在中文文本分类里分别抽取什么特征不先把两个网络的分工讲清楚后面调参就是玄学。CNN 看局部RNN 看顺序这句话人人都知道但中文文本里“局部”和“顺序”到底指什么决定了你卷积核尺寸、双向结构、特征拼接方式怎么选。2.1 卷积神经网络的局部视野多尺寸卷积核扫出中文短句里的 n-gram中文文本没有天然空格分词但只要切词完成词的邻接组合就是强特征。“性价比/很高”“质量/一般”这种三词片段已经能反映大部分情感倾向。卷积神经网络在文本分类里的角色就是用一个固定窗口去扫这些邻接词组合等价于一组可学习的 n-gram 探测器。常见做法是同时用多个尺寸的卷积核比如 2、3、4各自抓“双词搭配”“三词搭配”“四词短语”然后把每个卷积核输出的特征做全局最大池化取每个维度上最强烈的响应。最大池化让模型不关心这个特征出现在句子的哪个位置只关心它是否出现这对文本分类特别合适因为“电影很烂”和“很烂的电影”要表达的情绪一致位置不同不应影响类别判断。但也要记住 CNN 的边界卷积核窗口有限一旦判断依据分散在相隔十几个词的两个位置局部窗口拼不出完整语义。这就是短文本分类里 CNN 是主力、长文本里需要 RNN 或 Transformer 补全局的原因。所以这个项目把 CNN 和 RNN 放在一起不是堆砌模型而是让局部特征和全局顺序各管一段。2.2 循环神经网络的序列视野双向 GRU 为什么更适合上下文建模RNN 建模的是“从左读到右”的过程。中文里很多判断要看上下文关系比如“之前说不来现在又说要来”单独抽“不来”和“要来”都会误判只有把整句话按顺序读完才能知道最终态度。循环神经网络每一步把当前词和上一步的隐状态融合理论上能把前文信息带到当前位置。实际工程里我更喜欢用双向 GRU 而不是原始 RNN 或 LSTM 全上。双向结构等于同时从两个方向读句子把前文和后文都压缩进特征适合处理否定词、转折词、程度副词这类强依赖位置的词。GRU 比 LSTM 少一个门参数更少小数据上不容易过拟合训练速度也快。这个项目如果要替换成 LSTM只要把nn.GRU换成nn.LSTM并把返回值拆开即可但我在同样数据下没看到稳定收益。需要注意一点RNN 擅长捕捉上下文却不擅长把“某个词必须出现”这种强信号提取出来。很多情感分类样本里一个词就定生死这种情况卷积网络反应更快。两个结构互补顺理成章做成双分支拼接。2.3 让文本变成张量jieba 分词、词表构建与定长填充的最小实现无论模型结构多复杂第一步永远是把中文文本转成整型序列。常见做法是用 jieba 分词再构建词表最后填充成定长张量。下面这段代码是这个项目最核心的数据基础我一般会单独抽成一个 py 文件复用。import jieba from collections import Counter def build_vocab(texts, max_vocab50000, min_freq2): counter Counter() for text in texts: counter.update(jieba.lcut(text)) # 0 留给 pad1 留给 unk后续词从 2 开始编号 vocab {pad: 0, unk: 1} for word, freq in counter.most_common(): if len(vocab) max_vocab or freq min_freq: break vocab[word] len(vocab) return vocab def text_to_ids(text, vocab, max_len300): ids [vocab.get(w, 1) for w in jieba.lcut(text)][:max_len] if len(ids) max_len: ids [0] * (max_len - len(ids)) return ids这段逻辑里有三个容易出错的地方。第一min_freq2能过滤掉只出现一次的噪声词否则词表会被长尾词塞满所以对 2 万样本以下的数据很关键。第二pad必须用 0和后面 nn.Embedding 的padding_idx0对齐这样填充位在反向传播时梯度不会更新。第三长文本直接截断到max_len不要先全量处理再截断否则一次 epoch 的时间会翻倍。分完词后还要说明一点分类模型的输入是词在词表里的编号不是 one-hot。整型序列经过 Embedding 层变成稠密向量再进入 CNN 和 RNN。这样词与词之间的语义距离可以被向量空间表达也是后续加载预训练词向量的入口。3. 把项目落到本地跑通数据集整理、PyTorch 模型搭建和训练循环模型结构看懂之后下一步是把整条训练链路跑起来。这里我按最常用的 PyTorch 写法拆三个小节数据准备、网络定义、训练循环。新手可以直接照着搭熟手可以跳到参数说明看边界。3.1 原始语料怎么整理成分类数据标签映射和切分别想当然拿到的中文文本数据集通常有两种组织方式一种是一个 txt 文件一篇文章、文件夹名就是类别另一种是 CSV 里一列文本一列标签。无论哪种都要先统一成内存里的(text, label)样本列表再做分层切分。文件目录方式我一般这么扫import os import random data_root data label_names sorted([ d for d in os.listdir(data_root) if os.path.isdir(os.path.join(data_root, d)) ]) label_map {name: i for i, name in enumerate(label_names)} id_to_label {i: name for name, i in label_map.items()} samples [] for label_dir in label_names: dir_path os.path.join(data_root, label_dir) for file_name in os.listdir(dir_path): with open(os.path.join(dir_path, file_name), encodingutf-8) as f: content f.read().strip() if content: samples.append((content, label_map[label_dir])) random.seed(42) random.shuffle(samples) train_end int(len(samples) * 0.8) val_end int(len(samples) * 0.9) train_data samples[:train_end] val_data samples[train_end:val_end] test_data samples[val_end:]顺序切分之前必须固定随机种子并 shuffle否则同类文本可能密集堆在一起导致验证集和训练集分布不一致。还有一点容易被忽略如果数据来自某个平台最好按来源 ID 分组切分而不是按样本行切分否则同一条新闻改几个字同时出现在训练集和测试集指标虚高上线立刻打回原形。这个坑我放在下一章详细说。CSV 数据更简单用pandas.read_csv读进来后同样转成列表再做分层切分。注意存储时统一编码为 utf-8不然 Windows 下打开容易乱码。3.2 模型定义Embedding、一维卷积、双向 GRU 的特征拼接细节模型定义是整个项目里最值得抄作业的部分。我采用双分支结构Embedding 输出同时送进nn.Conv1d卷积分支和双向 GRU 分支两个分支的特征拼在一起再过全连接分类。这样做的理由是让模型在训练过程中同时优化“局部特征抽取”和“上下文建模”两套表征而不是像流水线那样前一层特征定死、后一层只能将就着用。import torch import torch.nn as nn class CNNRNNClassifier(nn.Module): def __init__(self, vocab_size, embed_dim200, filter_num128, filter_sizes(2, 3, 4), hidden_size128, num_classes10, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsfilter_num, kernel_sizeks, padding1) for ks in filter_sizes ]) self.rnn nn.GRU(input_sizeembed_dim, hidden_sizehidden_size, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(len(filter_sizes) * filter_num hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x, lengths): emb self.embedding(x) # B, L, E conv_in emb.transpose(1, 2) # B, E, L cnn_feats [] for conv in self.convs: out torch.relu(conv(conv_in)) out out.max(dim-1).values cnn_feats.append(out) cnn_out torch.cat(cnn_feats, dim-1) packed nn.utils.rnn.pack_padded_sequence( emb, lengths.cpu(), batch_firstTrue, enforce_sortedFalse) rnn_out, _ self.rnn(packed) rnn_out, _ nn.utils.rnn.pad_packed_sequence(rnn_out, batch_firstTrue) rnn_out rnn_out.sum(dim1) / lengths.unsqueeze(1).float() feat torch.cat([cnn_out, rnn_out], dim-1) feat self.dropout(feat) return self.fc(feat)几个参数说明。filter_sizes(2, 3, 4)是短文本分类的常见默认值对应二元文法、三元文法和四元文法。如果数据本身是长报告可以换成(3, 4, 5)让卷积核覆盖更长的短语。padding1会让不同卷积核输出长度略有差异但这里用的是全局最大池化长度差异最终被压缩成固定长度的特征向量不影响全连接层输入。pack_padded_sequence是关键细节它让 GRU 跳过填充位置的运算否则大量pad会污染隐状态尤其是长尾批次里 padding 比例很高时训练速度变慢、效果变差。双向 GRU 输出维度是hidden_size * 2因为正向和反向各一个隐状态。全连接输入维度必须是卷积核数量 * filter_num hidden_size * 2这里拼错会导致维度报错也是把模型封装成类的好处可以让 PyTorch 自己去推导。3.3 训练循环与验证早停、学习率衰减和模型保存的一次到位写法模型定义完训练循环里最容易踩的坑是 eval 模式下忘切model.eval()或者保存模型时只存最后一轮。判断分类效果要以验证集最优轮次为准最后一轮往往会过拟合。我常用的训练循环写法如下from torch.nn.utils.rnn import pad_sequence def collate_fn(batch): seqs [torch.tensor(item[0], dtypetorch.long) for item in batch] labels torch.tensor([item[1] for item in batch], dtypetorch.long) lengths torch.tensor([len(item[0]) for item in batch], dtypetorch.long) seqs pad_sequence(seqs, batch_firstTrue, padding_value0) return seqs, labels, lengths model CNNRNNClassifier(vocab_sizelen(vocab), num_classeslen(label_map)) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, patience2, factor0.5) best_acc 0.0 for epoch in range(15): model.train() for x, y, lengths in train_loader: optimizer.zero_grad() logits model(x, lengths) loss criterion(logits, y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 3.0) optimizer.step() model.eval() preds, labels [], [] with torch.no_grad(): for x, y, lengths in val_loader: logits model(x, lengths) preds.extend(torch.argmax(logits, dim-1).cpu().tolist()) labels.extend(y.tolist()) val_acc sum(1 for a, b in zip(preds, labels) if a b) / len(labels) scheduler.step(val_acc) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)clip_grad_norm_我强烈建议保留RNN 梯度容易爆炸把梯度的二范数限制在 3.0 左右能避免 loss 突然飞到 NaN。学习率调度器选择了ReduceLROnPlateau验证集准确率连续两个 epoch 不涨就把学习率减半这样比硬性每隔多少轮减一次更贴合实际训练曲线。这里没有做 test 集预测因为测试集指标只需要在最终模型上算一次反复用它来调超参会让测试集失去客观性。日常训练只盯验证集确定最优 epoch 后再拿测试集出最终数字。4. 中文文本分类项目避坑手册5 个亲手踩过的训练与数据坑下面这五条都来自真实训练翻车记录按“现象、原因、解决”整理。每一项单独拿出来都能省你半天排查时间。4.1 现象loss 一到第二个 epoch 变成 nan原因学习率过大或者 Embedding 层的初始值过大导致反向传播梯度爆炸另一种常见情况是文本里混入了异常字符分词后生成特殊 token词表未覆盖却让 ID 超出范围不过这种情况通常是报错而不是变成 nan。更多的 nan 案例是文本标签里有负数值或类别 ID 从 1 开始而损失计算时类别索引越界被 clamp 掉了。解决把初始学习率从1e-3降到3e-4并保留梯度裁剪。如果做了这两个调整还是出现 nan可以在第一个 epoch 后打印 Embedding 权重的min/std观察是否数值过大。我通常还会在数据清洗阶段把非中文字符、不可见字符统一替换成空格减少分词器意外输出。4.2 现象准确率曲线正常但实际上只猜准了那一个大类原因类别极度不均衡。比如 10 类数据里某一类占 60%另外几类各占 5%模型只要全猜第一类就能拿到 60% 的准确率验证集表现还很好看。损失函数在梯度下降时会偏向样本量大的类别少数类被直接忽略。解决切分数据时使用分层切分保证训练集、验证集、测试集的类别比例一致。训练阶段可以使用WeightedRandomSampler让每个 batch 里少数类样本占比提高或者给CrossEntropyLoss传weight参数小类别给更大权重。判断标准不能只看准确率要看每个类别的 F1尤其是数据不均衡时必须看 Macro F1。4.3 现象显存消耗离谱训练还没跑完就 OOM原因batch 内文本长度差异太大。比如一个 batch 里最长的样本有 2000 词最短的只有 20 词pad_sequence会把所有样本都填充到 2000Embedding 和 GRU 都对无效填充做计算显存和耗时同时翻倍。解决一是在text_to_ids里用max_len预先截断二是训练前按文本长度排序把长度相近的样本放在同一个 batch 里这就是常见的 bucketing 策略。排序后每组 batch 只填充到该组最大值显存占用能下降 30% 以上。如果还 OOM就降 batch size比如从 64 降到 32这比降隐藏层维度来得直接。4.4 现象组合模型效果不如单独用 CNN原因双向 GRU 收敛慢在相同 epoch 下还在欠拟合状态或者 RNN 输出分支的值域跨度比 CNN 池化后的特征大很多拼接时全连接层被 RNN 一侧主导。很多人以为组合模型必然更强其实两个模型拼接会增加训练难度数据集只有几千条时效果倒退非常正常。解决先用单独 CNN 跑通拿到一个基线准确率再打开 RNN 分支。如果加了 RNN 反而下跌尝试调低 RNN 的hidden_size或者在拼接后、分类前加一个LayerNorm把两个分支归一化到同一尺度。另一个有效做法是把 RNN 输出的mean pooling换成max pooling让模型更关注语义最强的隐状态而不是被平均掉。4.5 现象训练和验证准确率“双 99%”换一批真实数据马上打回原形原因数据泄漏。切分之前没有按数据来源分组或者做了不够严格的去重。同一篇新闻在不同文件里出现几个字节的差异就会被当成两条独立样本分到不同集合模型记住了具体内容而不是类别规律。测试集和训练集重复率越高指标就越失真。解决切分前先按数据来源 ID 或者文本的 MD5 去重。判断泄漏的方法是随意看验证集里预测正确但概率特别高的样本很多都是训练集见过的重复文本。正式项目里我习惯写一个文本相似度检查脚本用 jieba 分词后做 Jaccard 相似度超过 0.8 直接归为一组从源头保证训练和测试不相交。5. 上分与验证阶段超参数调整、预训练词向量和推理脚本模型能跑通只算完成了第一步后面值得投入的有三件事调超参、用预训练词向量、做可复用的推理验证。5.1 三个必调的超参数最大长度、卷积核数量与隐层维度MAX_LEN不要选全量文本最大值我一般统计训练集文本长度分布取 98 分位。短评论 80 到 150 足够新闻标题 100 到 200长公告才需要 300 以上。filter_num默认 128数据量小可以降到 64防止过拟合数据量大且区分度低可以升到 256。hidden_size默认 128但双向 GRU 实际隐状态是 256如果后续拼接特征总量较大Dropout 也需要加码。这些超参相互影响我习惯一次只动一个。超参常用区间调整方向MAX_LEN80~300看长度分布的 98 分位不要直接取最大长度filter_num64~256数据量大、类别相近就加大hidden_size64~256短文本 64 够用长文本加大5.2 加载预训练词向量让模型更懂未登录词随机初始化的 Embedding 需要大量数据才能学到词义中文场景下很多词出现次数很少。常见做法是加载公开的 word2vec 向量把词表中能对齐的词替换成预训练向量未登录词保留随机初始化。import numpy as np embedding_matrix np.random.uniform(-0.25, 0.25, (len(vocab), embed_dim)).astype(np.float32) for word, idx in vocab.items(): if word in w2v: embedding_matrix[idx] w2v[word] with torch.no_grad(): model.embedding.weight.copy_(torch.from_numpy(embedding_matrix))这里有两个边界要注意pad和unk都保留随机值后续训练中 pad 位置因为有padding_idx0不参与梯度更新unk会缓慢调整到合理的平均值对齐时要用词本身查预训练向量不要用词性标签或拼音替换否则语义方向会被带偏。加载预训练后建议把学习率稍微调小一点避免前几个 epoch 把预训练语义冲掉。5.3 验证不止看测试集保存模型并跑一段小样本推理最后写一个独立的推理函数把训练时的预处理原样搬过来。这样既方便看实际效果也方便排查训练和推理之间预处理不一致的问题。def predict_one(text, model, vocab, id_to_label, max_len150): model.eval() ids text_to_ids(text, vocab, max_len) x torch.tensor([ids], dtypetorch.long) lengths torch.tensor([len(ids)], dtypetorch.long) with torch.no_grad(): logits model(x, lengths) return id_to_label[int(torch.argmax(logits, dim-1).item())]我一般会在训练结束后把最优模型在测试集上算出每类的 precision、recall 和 F1再拿五六条真实业务文本做人工复核。这一步能发现很多测试集指标看不出的问题比如某个类别总是被预测成另一个相近类别或者标点符号和特殊字符被错误接入分词导致语义漂移。调参过程中我会保留每个版本的预测记录像给模型写实验日志一样之后如果分数回落也能沿着记录找到是哪一步改动引起的。这是我做文本分类项目养成的一个习惯训练前不急着改模型结构先把数据切分和评估脚本写好用一个小模型跑通全流程再逐步加 CNN、加 RNN、调超参。数据侧的问题不清理模型结构换得再花哨最终也只是在一套有泄漏的数据上自欺欺人。希望这几个踩坑记录和参数经验能帮到你少走一段我走过的弯路。本文还有配套的精品资源点击获取
返回列表