ARTICLE DETAIL

资讯详情

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

深度学习文本分类实战:CNN与RNN双路线实现指南

深度学习文本分类实战:CNN与RNN双路线实现指南 简介面向毕业设计与课程作业场景这份压缩包聚焦基于深度学习的文本分类使用卷积神经网络CNN与循环神经网络RNN完成情感分析、新闻分类等典型任务适合需要快速上手NLP模型搭建、训练与评估的学生参考。包体共30个文件、约72.36MB主要包括9个Python源码、6个PyTorch权重、4个TSV数据集和2个Markdown说明文档可覆盖数据预处理、模型训练、验证及结果分析等完整流程。已有180人学习下载。资源内同时提供文本CNN/RNN模型实现、训练脚本、分词与数据加载工具并配有README与Graduation Design文档便于梳理毕设报告的写作思路和实验复现步骤对理解词向量、池化、LSTM/GRU长期依赖等关键知识点也很有帮助。1. 深度学习文本分类CNN 和 RNN 两条路线一次打通做毕设或者课程作业时最怕的不是模型不会写而是数据、词向量、模型、评估这四件事第一次串起来全乱套。这个项目把基于深度学习的文本分类按 CNN 和 RNN 两条路线完整实现了一遍从 TSV 数据读取、GloVe 词向量加载到模型训练与验证都有对应代码。它不依赖 BERT 这类大模型只靠 textCNN 和 textRNN 就能在新闻分类、情感分析这类任务上跑出可用结果。对第一次接触 NLP 深度学习的学生来说这份资源最大的价值是让你能看到一个完整、干净的基线系统长什么样。对已经写过分类模型的人来说它提供了一套可以直接改数据、调参数换任务的现成骨架省掉从零搭工程的时间。2. 数据管线从 TSV 原始文本到可训练的 Batch2.1 拿到数据先看格式TSV 文件里到底有什么项目根目录下放着 train.tsv、train_data.tsv、val_data.tsv、test_data.tsv 这几个文件还有 split.py 负责数据切分。TSV 的全称是 Tab-Separated Values也就是字段之间用制表符分隔。先用 pandas 读进来看看实际长什么样import pandas as pd # 不确定有没有表头时先不带 names 读前几行 df pd.read_csv(train.tsv, sep\t, nrows5) print(df.head()) print(df.shape)如果读出来第一行是列名比如text label那么训练的时候要设置names[text, label]重新加载如果读出来直接就是文本和标签说明文件本身没有表头。项目里同时出现 train.tsv 和 train_data.tsv一般前者是原始全量训练集后者是 split.py 切分后生成的训练文件val_data.tsv 和 test_data.tsv 则是验证集和测试集。这一步的关键是搞清楚两件事文本列和标签列分别在第几列标签是整数还是字符串。分类任务的标签如果是字符串比如 positive/negative后面训练时要手动映射成从 0 开始的整数。我一般会顺手打印一下标签分布df pd.read_csv(train_data.tsv, sep\t, names[text, label]) print(df[label].value_counts())标签分布直接决定后面要不要处理类别不平衡。如果某一个类占比超过 80%准确率这个指标基本就没参考价值了得换成 macro-F1。2.2 构建词表max_vocab_size 和 min_freq 怎么定深度学习模型不认识原始字符串需要先把句子拆成 token再把每个 token 映射成整数 ID。中文文本可以直接按字切也可以先用 jieba 分词英文文本按空格和标点切就行。这个项目的数据集从文件结构看是英文文本分类所以分词直接用split()就够了。词表构建的逻辑是统计所有 token 的出现频次按频次从高到低取前 N 个。这里有两个参数直接决定词表大小from collections import Counter def build_vocab(texts, max_vocab_size50000, min_freq2): counter Counter() for text in texts: tokens text.strip().split() counter.update(tokens) vocab {pad: 0, unk: 1} for word, freq in counter.most_common(max_vocab_size - 2): if freq min_freq: vocab[word] len(vocab) return vocab vocab build_vocab(df[text].tolist(), max_vocab_size50000, min_freq2)代码里pad用来把同一 batch 里的句子补齐到相同长度unk用来替代没进词表的低频词。max_vocab_size 设 50000 是因为 GloVe 预训练词向量通常也是按词频截断过的词表范围大致匹配。min_freq 设 2 是为了过滤掉只出现一次的噪声 token不然词表会被大量生僻词撑大训练时 embedding 矩阵大部分行都学不到有效梯度。句子长度方面先统计训练集所有样本的 token 数量分布用 95 分位数作为 max_len。不要拍脑袋定 512很多短文本分类任务 95% 的样本都在 100 个 token 以内max_len 设 128 就够了。定大了不仅浪费显存CNN 的池化层反而会被大量 padding 区域干扰。2.3 加载 GloVe 预训练词向量命中率决定模型下限项目里专门放了一个 glove 目录。GloVeGlobal Vectors for Word Representation是一套预训练词向量常见的有 50 维、100 维、200 维、300 维几个版本。用预训练向量的原因是模型能直接从词向量里继承单词的语义相似性比如 king 和 queen 的向量距离很近这样训练时只需要微调而不是从零学习语义。加载 GloVe 的核心逻辑是遍历词表把每个词对应的向量从 GloVe 文件里找出来填进 embedding 矩阵。找不到的词随机初始化padding 位置保持全零import numpy as np import torch.nn as nn def load_glove_embeddings(glove_path, vocab, embedding_dim100): # 初始化矩阵pad 保持全零unk 随机初始化 embeddings np.random.normal(0, 0.1, (len(vocab), embedding_dim)).astype(np.float32) embeddings[0] np.zeros(embedding_dim, dtypenp.float32) hit_count 0 with open(glove_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! embedding_dim 1: continue word parts[0] idx vocab.get(word) if idx is not None: embeddings[idx] np.asarray(parts[1:], dtypenp.float32) hit_count 1 print(fGloVe 命中率: {hit_count}/{len(vocab)}) return torch.tensor(embeddings)注意hit_count只统计词表里能匹配上的词命中率通常应该到 70% 以上。如果命中率太低说明词表和 GloVe 文件的词汇来源差异太大比如文本里有大量数字、特殊符号、缩写这种情况下要么扩大词表要么换一个更匹配的预训练向量版本。加载好之后用nn.Embedding.from_pretrained(embeddings, freezeTrue)创建 embedding 层freezeTrue 表示训练时不更新词向量适合数据量小的场景。2.4 DataLoader一条样本从文本到 Tensor 的完整流程数据处理的最后一步是把变长文本统一转成固定长度的 ID 序列再打包成 batch。这里我习惯写一个 Dataset 类把分词、映射、padding 都收进去import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): 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].strip().split() ids [self.vocab.get(t, self.vocab[unk]) for t in tokens[:self.max_len]] # 长度不足 max_len 时补 pad ids ids [self.vocab[pad]] * (self.max_len - len(ids)) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.long)ids ids [self.vocab[pad]] * (self.max_len - len(ids))这行有个隐性假设句子长度不超过 max_len因为前面已经用tokens[:self.max_len]截断了。超过部分直接丢弃是短文本分类的常见做法长文本场景则要考虑截断策略比如保留开头和结尾各一段。DataLoader 再加batch_size64, shuffleTrue就能喂给模型了。3. textCNN 实现一维卷积在文本上是怎么工作的3.1 为什么 CNN 能做文本分类一维卷积当作 N-gram 过滤器CNN 最初是为图像设计的但把图像里的二维卷积换成一维卷积就能直接用在文本上。核心思路是embedding 层把每个词变成一个向量一维卷积核在序列长度方向上滑动每次覆盖连续 k 个词的向量等价于抽取一个 k-gram 的局部特征。比如 kernel_size3卷积核每次看到的是第 i 个词到第 i2 个词的向量拼接权重矩阵对这 3 个词做加权求和得到的结果可以理解成这个局部片段在某个特征维度上的得分。多个不同的卷积核就对应不同的特征模式有的关注否定词组合有的关注形容词搭配。相比全连接网络卷积核在序列上平移共享权重参数数量小得多训练更快也不容易过拟合。textCNN 的经典结构就三层embedding、一维卷积池化、全连接分类。它不会像 RNN 那样按时间步逐步读入句子而是并行扫描整个序列所以训练速度远快于 RNN。实践里对句子级情感分析、新闻标题分类这种短文本任务textCNN 用很小的代价就能达到接近 LSTM 的效果。3.2 核心参数kernel_size 和 num_filters 怎么选textCNN 里最重要的超参数是卷积核尺寸。单个尺寸只能覆盖一种长度的局部片段所以标准做法是用多个尺寸并行扫描。项目里 textCNN_model.py 的实现我一般会按这个结构写import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, num_filters, kernel_sizes, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 每个 kernel_size 对应一套独立的卷积层 self.convs nn.ModuleList([ nn.Conv1d(in_channelsembedding_dim, out_channelsnum_filters, kernel_sizek) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embedding_dim) emb emb.transpose(1, 2) # (batch, embedding_dim, seq_len) pooled [] for conv in self.convs: c torch.relu(conv(emb)) # (batch, num_filters, seq_len-k1) p c.max(dim2)[0] # (batch, num_filters) pooled.append(p) out torch.cat(pooled, dim1) # (batch, num_filters * len(kernel_sizes)) out self.dropout(out) return self.fc(out)代码里关键点在emb.transpose(1, 2)nn.Conv1d的输入格式是(batch, channels, length)embedding 层输出的(batch, seq_len, embedding_dim)需要把 embedding_dim 换到 channel 维seq_len 换到 length 维卷积才能在时间维度上滑动。kernel_sizes传[2, 3, 4]是最常见的组合分别对应二元、三元、四元词组的局部模式2-gram 能抓短语搭配3-gram 能抓动宾结构4-gram 能抓更长的习惯表达。num_filters每个卷积核配 128 个三个尺寸共 384 个特征全连接层输入维度就是 384。padding_idx0在 embedding 里指定 0 号位置的向量不参与梯度更新对应词表里的pad。这样 padding 区域不会产生无意义的梯度信号。max-pooling 取每个特征图上的最大值保留了整条序列里最强烈的模式信号这种强位置不变性对短文本分类很管用。3.3 cnn_model_freeze 和 textCNN_model.py 有什么区别项目 model 目录下有两个子目录cnn_model_freeze 和 textCNN_model.py。从命名看freeze 版本把 embedding 冻住也就是加载 GloVe 后不再更新词向量只用预训练的语义表示textCNN_model.py 则是可训练版本embedding 随训练一起优化。这两个版本的选择逻辑很直接训练数据少于几万条时模型自己学 embedding 很容易过拟合冻结词向量等于把语义知识锁死让模型只学卷积层和分类层数据量充足时可训练版本能针对具体任务微调词向量效果通常更好。实际操作中我会先把 freeze 版本跑通验证管线再对比两个版本的验证集指标。freeze 的实现方式就是在加载预训练向量后设置embedding.weight.requires_grad False或者用nn.Embedding.from_pretrained(embeddings, freezeTrue)。值得留意的是即便冻结了 embedding训练时传入的 ID 仍然需要经过 embedding 层做查表只是查出来的向量不回传梯度。3.4 训练循环与过拟合监控CNN 训练速度很快但过拟合也很容易。监控过拟合最直接的做法是每个 epoch 在验证集上计算准确率和 loss观察两者是否出现 divergencedef evaluate(model, val_loader, criterion): model.eval() total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for inputs, labels in val_loader: logits model(inputs) loss criterion(logits, labels) total_loss loss.item() * inputs.size(0) preds logits.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total如果训练 loss 持续下降但验证 loss 开始回升说明模型开始背训练数据了。textCNN 的 dropout 一般设 0.5如果过拟合明显就降 batch size 或者减少 num_filters。训练初期加一个「先跑 3 个 epoch 看 loss 有没有下降趋势」的检查比盲目调参效率高很多。4. textRNN 实现LSTM 怎么处理序列依赖4.1 从标准 RNN 到 LSTM为什么必须引入门控机制RNN 的核心是一个循环单元每个时间步接收当前词的向量和上一个时间步的隐状态输出新的隐状态。理论上它能记住整个历史信息但当序列变长时反向传播的梯度要沿着时间方向连乘如果每一步的梯度范数小于 1连乘后梯度会指数级衰减到零模型完全学不到长距离依赖这就是梯度消失问题。LSTM 的解决方案是引入门控机制遗忘门决定要丢掉多少旧信息输入门决定新信息写多少进来输出门决定当前时刻对外输出什么。门控让梯度在记忆单元上有一条相对畅通的通道梯度可以跨多个时间步直接传播而不被逐次衰减。项目里的 textRNN 模型实际用的就是 LSTM而不是标准 RNN 单元。对文本分类来说LSTM 的意义在于它能按顺序读完整个句子把关键信息累积到最后一个时间步的隐状态里再交给分类层。这种序列建模能力让它在需要考虑语序和长距离指代的任务上比 CNN 更有优势。4.2 TextRNN 模型实现hidden_size、num_layers、bidirectional 的真实含义PyTorch 里直接用nn.LSTM就能完成全部序列计算不需要手动写门控公式。模型结构比 textCNN 还简洁import torch.nn as nn class TextRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, num_classes, bidirectionalTrue, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(input_sizeembedding_dim, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalbidirectional, dropoutdropout if num_layers 1 else 0) self.fc nn.Linear(hidden_size * (2 if bidirectional else 1), num_classes) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embedding_dim) out, (h_n, c_n) self.lstm(emb) # out: (batch, seq_len, hidden*2) if self.lstm.bidirectional: h_last torch.cat([h_n[-2], h_n[-1]], dim1) # (batch, hidden*2) else: h_last h_n[-1] # (batch, hidden) return self.fc(h_last)h_n的形状是(num_layers * 2, batch, hidden_size)其中双向时每层有两个方向的隐状态。取最后一个隐状态时单向就取最后一层的h_n[-1]双向则要把正方向最后一个时刻和反方向最后一个时刻拼起来对应h_n[-2]和h_n[-1]。hidden_size 取 128 到 256 比较常见翻倍到 512 以上在小数据集上容易过拟合。num_layers 取 2 就够了大于 3 层的收益很小训练成本和过拟合风险却涨得很快。bidirectionalTrue的直观解释是正向 LSTM 从左往右读句子反向 LSTM 从右往左读拼接两者最后时刻的隐状态让分类器同时看到「以当前词为结尾的上下文」和「以当前词为开头的上下文」两个方向的摘要。4.3 CNN 和 RNN 在实践中的取舍我实际跑过不少分类任务经验是短文本无脑先上 textCNN长文本再用 textRNN 做对比。句子级情感分析、新闻标题分类这种 max_len 都在 100 以内的任务textCNN 训练一轮的时间和 textRNN 相比几乎快一个数量级效果却差不多。RNN 必须按时间步顺序计算每一步依赖上一步的输出并行度低batch 内的长序列会让训练时间线性拉长。反过来看如果任务是长文档分类比如整篇新闻正文几千词CNN 的卷积核只能看到局部窗口关键信息可能分散在文章各个段落RNN 把全文档压缩成最后一个隐状态的方式能捕捉跨句关联这时候 RNN 的理论上限更高。项目把两个模型都写了正好可以分别跑一遍在报告里做一个 CNN vs RNN 的对比实验这也是毕设答辩时最容易加分的内容。5. 避坑与排查训练过程中最容易翻车的五个细节写分类模型最耗时间的往往不是模型定义而是看起来莫名其妙的问题。下面这些坑都是我实际踩过的按「现象 → 原因 → 解决」列出来对号入座能省很多时间。5.1 验证集指标虚高测试集却崩了现象验证集准确率 95%测试集只有 60%差距大到不像是正常波动。原因数据切分之前没有做去重。同一个文本内容在原始数据里出现多次切分时一条进了训练集一条进了验证集模型在训练时已经把这条文本背下来了验证时再遇到它指标当然好看。这种数据泄漏是 NLP 任务里最容易忽视的问题。解决先对全部数据按文本内容做去重再按类别比例切分而不是先切分再去重。df df.drop_duplicates(subsettext, keepfirst) # 然后再做 train/val 切分如果去掉重复样本后数据量骤减要检查是不是爬虫数据里混入了大量模板化文本比如「查看更多」「点击进入」这类导航文字。这种情况要做更细的清洗把模板片段过滤掉。5.2 GloVe 加载时报 size mismatch现象用nn.Embedding.from_pretrained加载时报size mismatch for embedding.weight: copying a param with shape torch.Size([400000, 100]) from checkpoint, the shape in current model is torch.Size([50000, 100])。原因加载方式用错了。from_pretrained期望传入的是一个完整的 embedding 权重矩阵而不是 GloVe 原始文件。报错通常是直接把 GloVe 的(400000, 100)矩阵塞给了词表大小为 50000 的模型。解决按 2.3 节的load_glove_embeddings函数先构建一个形状为(len(vocab), embedding_dim)的矩阵只拷贝词表中命中词的行再传给from_pretrained。另外注意检查 vocab 里 padding_idx0 对应行是零向量不要给pad分配随机向量否则 padding 区域会持续产生噪声梯度。5.3 同一个代码两次训练结果不一样现象固定了随机种子重新运行训练准确率还是差了 0.5 到 1 个百分点。原因PyTorch 默认的 cuDNN 会在卷积和 LSTM 里选用非确定性算法同一个输入可能得到微小不同的浮点结果。另外只有torch.manual_seed不够torch.cuda.manual_seed_all和 cuDNN 的两个开关也要一起设置才能把算法固定成确定性的。解决训练脚本开头统一设置import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)benchmarkFalse会关闭 cuDNN 的自动调优速度略有下降但换来了可复现性。做毕设实验时这一条尤其重要不然每次跑出来的数字不一样写报告的时候没法交代。5.4 准确率 92% 但 F1 只有 0.3现象训练日志里准确率一路涨到 92%但用 sklearn 里的classification_report一算少数类的 F1 惨不忍睹。原因类别分布极不平衡。比如二分类任务里 92% 的样本属于类 A模型把全部样本都预测成 A 就能拿到 92% 准确率。CNN 和 RNN 的默认交叉熵损失在这种数据下会被多数类主导少数类几乎没有有效梯度。解决训练时给交叉熵加类别权重推理时用 macro-F1 而不是 accuracy 作为主指标。具体做法from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( balanced, classesnp.unique(labels), ylabels ) class_weights torch.tensor(class_weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightclass_weights)加了权重之后少数类每个样本的 loss 会放大模型被迫更认真地学它。如果加了权重还是上不去说明少数类样本本身就不够得考虑扩充数据而不是继续调模型。5.5 max_len 设得太大CNN 效果反而变差现象max_len 从 128 改成 512 后准确率掉了 2 个百分点训练时间还翻了三倍。原因textCNN 的卷积核只覆盖局部窗口padding 到 512 的样本大部分区域是pad卷积层在 padding 区域提取到的特征全是无效信号max-pooling 还可能把噪声特征当成最强信号选出来。RNN 同样受影响padding 区域占据大量时间步隐状态在无意义的 pad 上反复更新浪费计算且引入噪声。解决统计训练集所有样本的 token 长度取 95 分位数作为 max_len而不是取最大值。比如 95% 的样本都在 150 个 token 以内max_len 定 160 就够不需要覆盖那 5% 的长尾样本。尾部超长样本做截断处理对整体指标影响很小。6. 复现与验证想让别人跑出一样的结果这三件事必须做6.1 固定随机种子和 cuDNN 设置第 5 章已经给了set_seed的完整代码。真正实操时要注意一件事光在脚本开头调用一次还不够如果在训练中途切换了 PyTorch 版本或者换了 GPU 型号之前的种子设置也要重新验证一遍。跨设备可复现性的前提是同一 PyTorch 版本、同一 CUDA 版本、同一 cuDNN 版本。写 README 时把这三个版本号写清楚同学和老师复现时能少踩一大半坑。6.2 评估脚本只用 accuracy 会漏掉一半信息验证阶段除了准确率要跑一份完整的分类报告含 precision、recall、macro-F1from sklearn.metrics import classification_report # 收集验证集所有预测和真实标签 all_preds, all_labels [], [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: logits model(inputs) preds logits.argmax(dim1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(classification_report(all_labels, all_preds, digits4))digits4让报告里每个指标保留四位小数写进毕设表格时不用再四舍五入一次。macro-F1 才是类别不均衡时真正值得汇报的指标加了类别权重之后两个模型的 macro-F1 对比才有意义。6.3 超参备忘与训练曲线记录超参数textCNN 推荐值textRNN 推荐值embedding_dim100100kernel_sizes / hidden_size[2, 3, 4]128num_filters / num_layers1282dropout0.50.5batch_size6464learning_rate1e-31e-3max_len128128epoch2020训练时把每个 epoch 的 train loss、val loss、val accuracy 写到一个 csv 文件后面画 loss 曲线和准确率曲线直接用这个文件。比只靠终端日志靠谱得多也方便答辩时展示训练过程。这个项目里两个模型的训练入口分别是 CNN 和 RNN 目录下的 train.py换数据集时只需要改 dataLoad.py 里的文件路径和类别数。想起我自己第一次做文本分类毕设时拿着加载好的 GloVe 就直接from_pretrained报了一堆 size mismatch 才意识到要先建矩阵再拷贝。从那以后我每次复现代码永远是先定数据去重、随机种子、评估指标这三件事再动模型结构。数据、种子、指标三个东西定住了剩下怎么改超参数都有据可依。希望这份项目笔记能帮你在毕设或者课程作业里少走一遍这些弯路。本文还有配套的精品资源点击获取
返回列表