ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度学习文本分类实战:PyTorch下CNN与RNN对比全流程

深度学习文本分类实战:PyTorch下CNN与RNN对比全流程 简介这是一份面向毕业设计与课程作业的深度学习文本分类项目完整实现基于卷积神经网络CNN与循环神经网络RNN的文本分类流程覆盖情感分析、新闻分类等自然语言处理常见场景适合高校学生、NLP入门者或需要快速搭建分类任务的开发者参考。压缩包共有30个文件以9个Python源码文件为核心另含6个模型权重文件、4个TSV格式的训练/验证数据、2个Markdown说明文档及词向量相关辅助文件整体大小约72.36MB。项目中CNN与RNN两条实现路线都给出了数据加载、模型定义、训练和验证脚本并包含数据预处理与训练入口还涉及LSTM/GRU等变体的扩展思路可帮助学习者梳理从文本清洗、词向量到模型评估的完整流程。文件组织清晰源码与数据分离便于对照实验。已有180人学习下载既可用于复现实验完成课程作业也可在此基础上调整参数、替换数据集或进一步做对比分析完成毕业设计。1. 基于深度学习的文本分类毕设到底要做成什么样CNN 与 RNN 同题对比的意义每年毕业设计和课程作业里「基于深度学习的文本分类」都是出现频率最高的题目之一而且老师往往点名要 CNN 和 RNN 各做一版做对比。这两个模型一个擅长抓局部 n-gram 特征一个擅长建模序列依赖都不是新架构但真正把数据预处理、两套模型、训练评估放进同一条流水线里跑通才是这份交付物的核心。这篇文章按我带项目习惯的顺序来写先搭环境、再备数据然后分别跑通 TextCNN 和 LSTM最后把最容易翻车的几个现场逐个拆开。适合刚过完 Python 基础、想用两周交出一份能讲清楚的项目的人。2. 先把环境和数据这条流水线搭稳conda 建环境、中文分词与 DataLoader2.1 用 conda 创建独立环境并锁定 PyTorch 版本CPU 还是 GPU 先想清楚拿到项目第一步不是写模型而是把环境钉死。我见过太多同学在全局 Python 环境里装了一堆包版本互相打架最后跑出来的结果和报告对不上。常见做法是先用 conda 建一个独立环境Python 版本选 3.9不要追新。PyTorch 在 3.9 下的兼容性最稳很多教程的依赖也是按这个版本锁的。conda create -n textcls python3.9 -y conda activate textcls pip install torch2.0.1 --index-url https://download.pytorch.org/whl/cpu先判断自己有没有可用的 NVIDIA 显卡。没有显卡就装 CPU 版一个十类中文文本分类的小数据集CPU 训练也能在十几分钟内跑完一个 epoch有显卡的话先运行nvidia-smi看 Driver Version 支持的 CUDA 版本再决定装cu118还是cu121的 wheel。不要无脑装最新版驱动跟不上会出现「torch.cuda.is_available() 返回 False」这种让人怀疑人生的现象。# GPU 版本示例cu118 对应 CUDA 11.8装之前先确认驱动支持 pip install torch2.0.1 --index-url https://download.pytorch.org/whl/cu118 python -c import torch; print(torch.__version__, torch.cuda.is_available())除了 torch还需要 numpy、pandas、scikit-learn、jieba、tqdm、matplotlib 这几个。建议全部装在一个 requirements.txt 里方便后面复现。环境这块最大的教训是把版本写死在代码仓库里不要用「最新版」这种模糊描述。同一个模型在 torch 1.13 和 2.0 下LSTM 的默认初始化行为就有差异直接影响你报告里的对比曲线。2.2 选数据集与预处理以中文新闻分类为例的分词、词表与 padding文本分类的数据集不需要自己爬公开的中文数据集够用了。THUCNews 的十类子集是这类毕设最常见的选型每个类别几千条样本规模和难度都合适想省时间可以做 ChnSentiCorp 情感二分类样本更少跑起来更快。数据格式统一整理成每行一条标签\t文本标签用整数编号文本是原始中文句子。import jieba from collections import Counter def build_vocab(texts, vocab_size5000): counter Counter() for line in texts: words jieba.cut(line.strip()) counter.update(words) vocab {pad: 0, unk: 1} for word, _ in counter.most_common(vocab_size - 2): vocab[word] len(vocab) return vocab def encode(text, vocab, max_len64): words jieba.cut(text.strip()) ids [vocab.get(w, vocab[unk]) for w in words] if len(ids) max_len: ids ids[:max_len] else: ids ids [0] * (max_len - len(ids)) return ids这里有几个参数是后续两个模型必须统一的。vocab_size设 5000意思是只保留训练集里出现频率最高的 4998 个词其余全部映射到unk。新闻分类任务里 5000 基本够用词表太大模型参数膨胀太小则unk泛滥导致信息丢失。max_len设 64中文新闻句子分词后大部分在 20 到 50 个词之间64 能覆盖绝大多数样本对 RNN 来说序列越长训练越慢这个值不宜拍脑袋可以先统计一下训练集长度分布取能覆盖 95% 样本的分位数。分词选 jieba 是因为它对中文新闻类文本的切分效果稳定而且毕业设计答辩时「用 jieba 做了预分词」是一个能讲清楚的点。注意 jieba.cut 默认返回生成器Counter.update能直接接收处理长文本时比一次性list(jieba.cut(text))更省内存。2.3 用 DataLoader 装数据batch_size、shuffle 与 num_workers 的取舍数据预处理最后一步是把文本变成 PyTorch 能吃的 Tensor并封装成 Dataset 和 DataLoader。这里最容易踩的坑是直接用line.split(\t)切分数据一旦正文里出现制表符数组长度就对不上训练时直接报错。from torch.utils.data import Dataset, DataLoader import torch class TextClsDataset(Dataset): def __init__(self, filepath, vocab, max_len): self.data [] with open(filepath, r, encodingutf-8) as f: for line in f: parts line.strip().split(\t, maxsplit1) # 只切第一个 tab if len(parts) ! 2: continue label, text parts self.data.append((encode(text, vocab, max_len), int(label))) def __len__(self): return len(self.data) def __getitem__(self, idx): x, y self.data[idx] return torch.tensor(x, dtypetorch.long), torch.tensor(y, dtypetorch.long) train_loader DataLoader(TextClsDataset(data/train.txt, vocab, max_len), batch_size64, shuffleTrue, num_workers2)batch_size64是一个比较保守的起点。CNN 显存占用小可以往上加到 128LSTM 因为有时间步展开显存占用大64 更稳。shuffleTrue只在训练集开验证集和测试集必须设False否则评估指标每个 epoch 都在变没法判断模型是否真的收敛。num_workers在 Windows 上经常因为多进程启动方式报错如果报错直接设 0用主进程加载数据速度慢一点但省心。训练集和验证集的划分不要在 DataLoader 里做建议先把原始数据用sklearn.model_selection.train_test_split按类别分层划分参数stratifyy保证每个类别的样本在训练和验证集里的比例一致。类别不平衡后面专门讲但划分这步是第一个要注意的地方。3. 从零跑通 TextCNN 与 RNN/LSTM两种模型的代码、结构与调参点3.1 TextCNN 的最小实现embedding、多尺度卷积核与池化怎么配合TextCNN 的思路是把句子当作一组词向量序列用多个不同宽度的卷积核去扫描抓取局部 n-gram 特征再用全局最大池化把每个特征图压成一个标量。这里的「n-gram」不是统计词频而是卷积核在词向量序列上滑动的窗口。核宽度 2 对应二元词组3 和 4 对应三元四元三个尺度拼接在一起相当于同时看了不同长度的局部搭配。import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters128, num_classes10): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, kernel_sizek, paddingk // 2) for k in (2, 3, 4) ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(num_filters * 3, num_classes) def forward(self, x): emb self.embedding(x) # [B, L, E]先查词向量 emb emb.transpose(1, 2) # [B, E, L]Conv1d 要求的布局 pooled [] for conv in self.convs: c torch.relu(conv(emb)) # [B, num_filters, L] p c.max(dim2).values # 全局最大池化取最强特征 pooled.append(p) out torch.cat(pooled, dim1) # [B, num_filters * 3] out self.dropout(out) return self.fc(out)paddingk // 2这个细节很关键。如果不对卷积输出做 padding序列长度会从 L 变成 L - k 1三个卷积核的输出长度不一致拼接前就出问题。加上 padding 后每个卷积输出都是 L池化后维度完全对齐。padding_idx0让pad对应的 embedding 始终是零向量并且在反向传播时不更新这能避免 padding 位置引入噪声特征。num_filters128表示每个卷积核宽度输出 128 个特征图。特征图太少抓不到足够特征太多则全连接层参数膨胀训练变慢。embed_dim128对词向量来说是一个性价比很高的维度word2vec 常用 100 或 128可以和预训练向量对齐。3.2 用 LSTM 做文本分类单向、双向与句子长度上限的选择RNN 家族的原始形态在长文本上梯度消失严重所以实际实现都是 LSTM 或 GRU。LSTM 通过门控机制让信息能跨越多步传递对文本分类来说是更稳的选择。这一版我用双向 LSTM因为句子的语义经常要看后文才能确定比如「这部电影不怎么样」里的「不怎么样」要结合后面的评价词才能判断情感极性。class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_size128, num_layers2, num_classes10, bidirectionalTrue): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalbidirectional) self.dropout nn.Dropout(0.5) hidden_factor 2 if bidirectional else 1 self.fc nn.Linear(hidden_size * hidden_factor, num_classes) def forward(self, x): emb self.embedding(x) # [B, L, E] out, (h_n, _) self.lstm(emb) # h_n: [layers*2, B, H] last_h h_n[-2:].transpose(0, 1).reshape(x.size(0), -1) return self.fc(self.dropout(last_h))取最后一层双向 hidden 的方法是这里最容易写错的地方。h_n[-2:]取的是最后一层前向和后向两个方向的隐藏状态transpose(0, 1)把维度变成[B, 2, H]reshape(x.size(0), -1)拼成[B, H*2]。不要直接用out[:, -1, :]取序列最后一个时刻的输出因为样本做了 padding最后一个时刻很可能落在pad上取到的是填充位置的向量信息价值很低。用h_n取最后一层状态能避开 padding 的干扰。num_layers2是层数加深能建模更复杂的依赖但训练时间和显存都会涨课程作业两层足够。hidden_size128和 TextCNN 的embed_dim保持一致方便对比实验时排除「模型容量不同导致结果差异」的质疑。如果显存紧张可以换成 GRU参数量更少训练更快效果通常只差一点点。3.3 训练循环与早期停止统一评估口径不然对比实验没意义两个模型共用同一套训练循环这是做对比实验的基本纪律。常见的错误是 CNN 用 Adam 跑到 10 个 epochRNN 用 SGD 跑到 20 个 epoch最后报告「CNN 比 RNN 好」这个结论根本不成立。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() * x.size(0) correct (logits.argmax(dim1) y).sum().item() total x.size(0) return total_loss / total, correct / totalcriterion用nn.CrossEntropyLoss()它内部已经做了 softmax所以模型输出层不需要额外套 softmax评估时直接argmax(dim1)取概率最大的类别。clip_grad_norm_是 RNN 类模型必加的一步LSTM 在长序列上梯度范数容易暴涨裁剪到 5.0 能防止损失变成 NaNTextCNN 加了也无害属于统一口径的一部分。优化器我一般两个模型都用 Adam学习率 1e-3weight_decay 设 1e-4。训练时记录每一轮的训练损失和验证集准确率验证准确率连续 3 个 epoch 不上升就提前停止恢复最好的一次参数。这个机制在毕设里能帮你省下大量调参时间也是答辩时一个不错的加分点。注意两个模型必须用相同的早停条件否则对比仍不公平。4. 避坑文本分类项目最容易翻车的 5 个现场与排查顺序4.1 损失不降反升往往不是模型写错是学习率和词表出了问题现象训练几轮后 loss 一直挂在 2.3 左右不动或者直接变成 NaN。这时候很多人第一反应是模型结构写错了反复检查 forward 也没发现毛病。原因大概率出在两个地方学习率太大导致梯度震荡甚至溢出或者词表里unk占比过高模型看到的输入大部分是未知词。解决方式是先把学习率降到 5e-4 或 3e-4然后统计训练集里映射到unk的 token 比例如果超过 30%说明vocab_size太小或分词不规范需要扩大词表或检查分词器。排查顺序永远是先看数据再看模型模型结构反而是最不容易错的部分。4.2 显存溢出与训练过慢batch、序列长度与 pad 的连锁反应现象LSTM 训练到第二个 batch 直接报CUDA out of memory或者 CPU 上跑一个 epoch 要半小时。原因不是单一方面而是 batch_size、max_len、hidden_size、num_layers 几个参数叠在一起把显存撑爆了。LSTM 的时间步展开会保存每个时刻的中间状态max_len 从 64 涨到 128显存占用接近翻倍。解决方式是按优先级依次降先降 batch_size 到 32再降 max_len 到 48最后考虑把bidirectionalFalse临时关掉验证流程。如果用了 GPU还可以排查是不是没开 cudnn benchmarktorch.backends.cudnn.benchmark True在输入长度固定时能明显提速。另外不要在 DataLoader 里做动态 padding没对齐的 batch 会让 cuDNN 反复重新选择算法速度更慢。4.3 中文分词不一致导致训练和预测两套结果现象验证集准确率有 85%拿几条新数据手动预测却发现结果乱来而且每次预测结果还不一样。原因非常典型训练时对文本做了 jieba 分词写预测脚本时却直接按字符切分或者把预处理逻辑复制了一份但没复制全词表对不上。这是文本分类项目里最常见的「黑匣子」现场问题不在模型在预处理流程没有统一封装。解决方式是把分词、编码、padding 这三个步骤封装成同一个函数训练、验证、预测全部调用它并且用同一份 vocab 文件加载词表绝不在预测脚本里重新构建。我一般会把encode()放进一个单独的preprocess.py谁都不许在别处重写一遍。4.4 准确率很高但像「作弊」类别不平衡与全输出多数类现象训练完看准确率有 90%但打开混淆矩阵发现某一类别的召回率是 0模型把所有样本都预测成了样本量最大的那个类。原因很简单数据集类别分布严重倾斜模型学到「全猜多数类」也能拿到高准确率。解决方式分两步划分数据集时用stratifyy保持类别比例训练时给CrossEntropyLoss传weight参数权重按类别样本数的倒数归一化让少数类的错误惩罚更大。另外报告里不要只写准确率要给出宏平均 F1 和每个类别的召回率这几个指标才能暴露真实问题。4.5 随机性导致两次训练结果差很多复现也是评分点现象同一个模型、同一个数据集跑两次验证集准确率差了三个百分点写进报告显得很不严谨。原因是没有固定随机种子PyTorch、NumPy、Python 三个层面的随机源各自独立GPU 上的某些操作本身还有非确定性。解决方式是在训练脚本开头统一设置随机种子并开启 cuDNN 的确定性模式。import numpy as np import random 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这段代码必须在导入模型、创建 DataLoader 之前执行。开了确定性模式后训练速度会略降但换来的是可复现的结果毕设答辩时老师现场跑一遍能对上数字这比什么都重要。5. 验证与进阶从一份能跑的毕设到能讲清楚的项目5.1 混淆矩阵、宏平均与单类召回率论文里更值钱的三张表准确率只是最浅的一层阅卷老师更想看到的是你能说清楚模型在哪一类上强、在哪一类上弱。测试集上输出混淆矩阵按类别看召回率新闻分类里「体育」和「娱乐」经常互相混因为两者都大量出现人名「星座」这种类别文本特征集中两个模型都容易拿高分。用sklearn.metrics.classification_report直接生成每个类别的 precision、recall、F1然后对比 CNN 和 RNN 两组宏平均 F1。这里有个常见误区只看微平均 F1它会被多数类主导和准确率区别不大宏平均对每个类一视同仁才是衡量两个模型真实差异的指标。这张表放进报告比贴十条 loss 曲线有用得多。5.2 三个低成本进阶点预训练 Embedding、注意力与模型集成如果时间和算力都允许有投入产出比最高的三个进阶方向。第一用预训练词向量初始化 Embedding 矩阵常见做法是用 gensim 加载一份中文 word2vec 或 fastText 向量把训练集词表里能查到的词替换掉随机初始化的那一行。这通常能给两个模型各涨一到两个点而且实现只要十几行代码。第二在 LSTM 输出后加一个简单的注意力层把每个时刻的 hidden state 按权重加权求和替代直接取最后一层状态。第三集成学习把 TextCNN 和 LSTM 的 softmax 输出取平均作为最终预测你会发现结果几乎肯定比单独任一个模型好这是最简单的集成收益。我自己的习惯是先把 5.1 的评估做完整再考虑进阶。因为如果混淆矩阵都讲不清楚加再多的注意力机制在答辩时也容易被问倒。跑完对比实验后把两个模型的参数量、训练时间、宏平均 F1、每类召回率整理成一张大表这份材料基本能支撑起整个毕设的核心章节。文本分类这个方向模型反而是最不需要焦虑的部分。真正拉开差距的是数据预处理是否严谨、对比实验是否公平、评估是否全面。把这套流程走完你会发现自己收获的不只是两份能跑的代码而是一套「拿到任何文本分类任务都知道怎么下手」的方法。希望帮到你。本文还有配套的精品资源点击获取
返回列表