
简介本资源面向高校学生与深度学习入门者提供一份基于CNN与RNN实现文本分类的完整毕设与课程设计项目可用于情感分析、新闻分类、垃圾邮件过滤等NLP场景的实战练习。压缩包共30个文件约72.36MB以Python源码、编译缓存、模型权重文件及TSV数据集为主另含Markdown说明文档覆盖数据加载、模型定义、训练与验证等模块并保留CNN与RNN两套独立实现路径。项目围绕一维卷积提取局部上下文特征、循环网络捕捉序列依赖展开涉及LSTM、GRU等改进结构同时包含词向量与预训练模型相关文件便于理解从原始词序列到分类输出的完整流程。目前已有180人学习下载适合希望掌握深度学习文本分类原理、复现模型并撰写实验报告的读者参考可从中获取模型搭建、参数调整与性能评估的实践思路。1. 从一份毕设压缩包说起CNN 和 RNN 做文本分类到底怎么选打开这个标题第一反应大概率是又是一份打包好的课程作业。但真正值得花时间的不是把 zip 解压后跑通main.py而是搞清楚一件事——同样一段中文影评、同样一条工单文本为什么有人用 CNN 就能跑到 92% 的准确率换成 RNN 反而掉到 88%而另一些任务里 RNN 又稳稳压过 CNN。这个标题背后其实藏着文本分类最经典的一组对照实验卷积神经网络靠 n-gram 式的局部窗口抓关键词循环神经网络靠时序状态记住上下文依赖。毕设和课程作业之所以反复选这两个模型不是因为它们最新而是因为它们足够小、足够快、足够让你在一台普通笔记本上把「词向量 → 特征提取 → 分类头 → 评估」这条链路完整走一遍。适合谁适合要交作业的学生、要快速搭一个文本分类 baseline 的工程师以及想真正理解「深度学习不是调包」的入门者。下面我按自己带人做这类项目的顺序把选型、实现、参数和踩坑一次讲透。2. 先把数据管线和词向量定下来文本分类的地基2.1 为什么文本分类的第一步永远不是搭模型很多人拿到 zip 就直奔model.py结果训练 loss 不降回头查半天才发现是标签没对齐、分词把标点全吃掉了、或者训练集里混进了测试集。文本分类的翻车八成发生在地基阶段。常见做法是先把原始语料整理成「文本 标签」两列再做清洗、分词、建词表、转 id、切分数据集最后才是模型。这个顺序不能乱因为词表大小、最大序列长度、类别数这三个参数会直接决定后面 CNN 和 RNN 的输入维度。我一般会先跑一段统计脚本把语料的基本盘摸清楚有多少条、类别是否均衡、句子长度分布如何。长度分布尤其关键它决定max_len设多少。设太小长文本被截断关键信息丢失设太大padding 一堆无效零训练变慢还容易过拟合。import numpy as np from collections import Counter # texts: List[str], labels: List[int] lengths [len(t.split()) for t in texts] print(样本数:, len(texts)) print(类别分布:, Counter(labels)) print(长度分位:, np.percentile(lengths, [50, 90, 95, 99])) # 经验max_len 取 95 分位兼顾覆盖率和显存 max_len int(np.percentile(lengths, 95))这段代码做三件事统计样本总量、看类别是否严重不均衡、用分位数定max_len。参数上max_len取 95 分位是折中覆盖绝大多数样本又不至于被极端长文本拖垮。如果类别分布里某一类占比超过 80%那准确率这个指标就不可信了得换 F1 或做重采样。2.2 词向量从零训练还是加载预训练词向量是文本分类里最容易被忽视、又最影响效果的一环。两条路一是用nn.Embedding从零训练二是加载预训练词向量做初始化。课程作业数据量小通常几千到几万条从零训练的词向量质量很差常见做法是加载预训练向量或者干脆用更小的词表加随机初始化先跑通。方案适用场景优点代价随机初始化数据量小、只求跑通无外部依赖效果一般预训练词向量数据量中等、追求精度收敛快、效果好需对齐词表微调预训练数据量较大效果最好显存和算力要求高从零训练时embedding_dim一般设 100 或 128加载预训练时维度必须和预训练文件一致常见是 100、200、300。这里有个血泪经验预训练词表和你自己分词后的词表对不上时别硬凑把未命中的词随机初始化即可命中率能到 70% 以上就够用。import torch import torch.nn as nn class TextClassifierBase(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, pretrainedNone): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) if pretrained is not None: # pretrained: np.array, shape [vocab_size, embed_dim] self.embedding.weight.data.copy_(torch.from_numpy(pretrained)) self.num_classes num_classespadding_idx0很关键它让 padding 位置的向量不参与梯度更新避免无意义的零向量被训练成噪声。vocab_size要和你建词表时的min_freq阈值配合低频词直接映射到unk能显著压缩词表。3. CNN 文本分类一维卷积怎么抓局部关键词3.1 TextCNN 的结构为什么是「多核并行」TextCNN 的核心思想很朴素一句话里的关键词比如「垃圾」「好评」「退款」往往由相邻几个词组成用不同宽度的卷积核去扫就能捕捉不同长度的 n-gram 特征。典型结构是嵌入层 → 多个不同 kernel_size 的一维卷积 → 全局最大池化 → 拼接 → 全连接 → softmax。为什么用最大池化而不是平均池化因为文本分类关心的是「这句话里有没有出现强特征词」最大池化保留每个卷积核最强烈的响应平均池化会把强信号稀释掉。这是 TextCNN 和图像 CNN 最大的直觉差异。class TextCNN(TextClassifierBase): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes(2, 3, 4), num_filters128, dropout0.5): super().__init__(vocab_size, embed_dim, num_classes) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k) 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): # x: [batch, seq_len] emb self.embedding(x) # [B, L, E] emb emb.transpose(1, 2) # [B, E, L] 卷积要求通道在前 feats [torch.relu(conv(emb)) for conv in self.convs] pooled [torch.max(f, dim2)[0] for f in feats] # 全局最大池化 out torch.cat(pooled, dim1) out self.dropout(out) return self.fc(out)逻辑说明transpose(1, 2)把[B, L, E]变成[B, E, L]因为Conv1d把第二维当通道。每个卷积核输出[B, num_filters, L-k1]最大池化后变成[B, num_filters]多个核拼接后进全连接。参数上kernel_sizes(2,3,4)覆盖 2 到 4 元词组num_filters128是常见起点dropout0.5防过拟合。如果句子很短比如标题分类kernel_size 可以缩到 (1,2,3)。3.2 训练循环里必须盯住的三个量CNN 训练看起来简单但有几个量不看就会翻车。第一是 loss 曲线如果前几个 epoch 就震荡剧烈多半是学习率太大第二是验证集准确率如果训练集一路涨、验证集不涨就是过拟合该加 dropout 或减参第三是梯度范数梯度爆炸在 CNN 里少见但学习率设错时会看到 loss 变 NaN。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 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() # 梯度裁剪防止偶发爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)clip_grad_norm_的max_norm5.0是经验值CNN 里通常用不到但加上是后悔药。优化器一般选 Adam学习率 1e-3 起步如果 loss 不降先降到 1e-4 试。batch_size 在 32 到 128 之间显存够就大一点梯度更稳。4. RNN 文本分类LSTM 和 GRU 怎么处理长依赖4.1 为什么 RNN 在长文本上更有优势CNN 的卷积窗口是固定的句子一长跨窗口的依赖就抓不到。RNN 不一样它按时间步逐个读词隐藏状态像一条记忆链理论上能记住任意远的上下文。文本分类里情感极性经常由句尾的转折词决定比如「前面夸了半天但是……」这种结构 CNN 容易漏LSTM 的门控机制就能把前面的信息保留到后面。LSTM 有三个门遗忘门决定丢多少旧记忆输入门决定写多少新信息输出门决定当前输出多少。GRU 把三个门简化成两个参数更少、训练更快效果在多数文本分类任务上和 LSTM 接近。课程作业里我一般先上 GRU跑通再换 LSTM 对比。class TextRNN(TextClassifierBase): def __init__(self, vocab_size, embed_dim, num_classes, hidden_dim128, num_layers1, cellgru, dropout0.5): super().__init__(vocab_size, embed_dim, num_classes) rnn_cls nn.LSTM if cell lstm else nn.GRU self.rnn rnn_cls(embed_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) # 双向拼接 def forward(self, x): emb self.embedding(x) # [B, L, E] out, _ self.rnn(emb) # [B, L, 2H] # 取最后一个时间步双向已拼接 feat out[:, -1, :] return self.fc(self.dropout(feat))逻辑说明batch_firstTrue让输入是[B, L, E]省去转置。bidirectionalTrue让正向和反向各读一遍拼接后维度翻倍所以全连接输入是hidden_dim * 2。取out[:, -1, :]是取最后时间步双向时它已经包含两个方向的末状态。参数上hidden_dim128、num_layers1是轻量起点层数加到 2 以上时记得开 dropout。4.2 变长序列的 padding 和 pack 处理RNN 和 CNN 最大的工程差异在于CNN 对 padding 不敏感RNN 会把 padding 也读进隐藏状态污染最后的表示。正确做法是用pack_padded_sequence把有效长度告诉 RNN让它跳过 padding。from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence def forward_packed(self, x, lengths): emb self.embedding(x) packed pack_padded_sequence(emb, lengths.cpu(), batch_firstTrue, enforce_sortedFalse) out, _ self.rnn(packed) out, _ pad_packed_sequence(out, batch_firstTrue) # 取每个样本真实最后一步 idx (lengths - 1).view(-1, 1, 1).expand(-1, 1, out.size(2)) feat out.gather(1, idx).squeeze(1) return self.fc(self.dropout(feat))enforce_sortedFalse允许 batch 内长度乱序省去手动排序。gather按每个样本的真实长度取最后一步而不是统一取out[:, -1, :]这是很多作业里被扣分的细节。如果偷懒不做 pack短句分类可能还行长文本混合时准确率会明显掉。5. 避坑与排查CNN 和 RNN 文本分类最常见的五个翻车点5.1 现象训练 loss 正常下降验证准确率始终 50% 上下原因标签和文本没对齐或者类别编码时把标签映射错了。常见于用LabelEncoder后又手动改过标签顺序。解决训练前打印前 10 条(文本, 标签)人工核对确认标签和语义一致。5.2 现象CNN 跑得挺好换 RNN 后 loss 变 NaN原因RNN 对学习率更敏感加上变长序列未 pack梯度在长序列上爆炸。解决学习率从 1e-3 降到 1e-4加clip_grad_norm_并启用pack_padded_sequence。5.3 现象模型在训练集 99%测试集 70%原因过拟合。小数据集上 CNN 参数多很容易记住训练样本。解决加大 dropout 到 0.5 以上减小num_filters或hidden_dim加 L2 正则或者做数据增强同义词替换、随机删除。5.4 现象预测结果全是一个类别原因类别严重不均衡模型学到「全猜多数类」就能拿高准确率。解决用加权交叉熵weight1/类别频率或对少数类过采样评估指标换成 macro-F1。5.5 现象换了预训练词向量后效果反而变差原因词表对齐时命中率太低大量词被随机初始化等于没用到预训练。解决打印命中率低于 60% 就检查分词方式是否和预训练词表一致必要时换分词器。6. 把 CNN 和 RNN 拼起来一个能写进毕设的进阶技巧单用 CNN 或 RNN 都能交作业但想让毕设有点亮点可以把两者串起来先用 CNN 提取局部 n-gram 特征再把特征序列喂给 RNN 捕捉时序依赖。这个结构在文本分类里叫 CNN-RNN 混合模型不算新但实现简单、提升稳定适合作为课程作业的加分项。class CNNRNN(TextClassifierBase): def __init__(self, vocab_size, embed_dim, num_classes, num_filters128, kernel_size3, hidden_dim128, dropout0.5): super().__init__(vocab_size, embed_dim, num_classes) self.conv nn.Conv1d(embed_dim, num_filters, kernel_size, padding1) self.rnn nn.GRU(num_filters, hidden_dim, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): emb self.embedding(x).transpose(1, 2) # [B, E, L] conv torch.relu(self.conv(emb)) # [B, F, L] conv conv.transpose(1, 2) # [B, L, F] out, _ self.rnn(conv) feat out[:, -1, :] return self.fc(self.dropout(feat))padding1保证卷积后序列长度不变方便接 RNN。卷积核宽度 3 提取三元组特征GRU 再在特征序列上建模顺序。这个结构参数量比纯 RNN 小收敛更快我在几个中文情感数据集上试过比单 TextCNN 高 1 到 2 个点。验证方法上别只看一个准确率数字。固定随机种子跑三次取平均同时记录 precision、recall、F1画混淆矩阵看错分集中在哪两类。如果两类互相错分严重说明特征区分度不够回去检查词向量或加更多卷积核。最后说个我自己的习惯每次改完模型先在一个 200 条的小子集上过拟合到 100%确认模型有能力学再上全量数据。这一步能省掉大量「到底是模型不行还是数据不行」的玄学排查。希望帮到你。本文还有配套的精品资源点击获取