简介本资源为面向高校学生与深度学习初学者的文本分类实战项目包围绕毕业设计与课程作业场景系统实现基于CNN与RNN的文本分类方案可应用于情感分析、新闻分类、垃圾邮件过滤等自然语言处理任务。包内共30个文件以Python源码、编译缓存、模型权重文件、TSV数据集及说明文档为主涵盖数据加载、模型搭建、训练与验证等完整流程压缩包约72.36MB目录按CNN与RNN两条技术路线分别组织便于对照学习。目前已有180人学习下载。读者可从中获取卷积神经网络与循环神经网络在文本分类中的具体实现思路理解词向量加载、模型冻结、训练脚本与评估指标等关键环节并借助预置数据集与模型文件快速复现实验适合作为入门深度学习文本分类的参考范例。1. 文本分类毕设翻车重灾区CNN 和 RNN 到底该选哪个很多同学拿到「基于深度学习的文本分类实现基于 CNN 和 RNN 的文本分类」这个题目时第一反应是打开搜索引擎找一份能跑的代码复制粘贴改改路径跑通截图写报告。然后答辩的时候老师问一句「你为什么用 CNN 不用 RNN」当场愣住。这不是个例我见过太多类似的翻车现场。这个题目的核心不是「跑通一个模型」而是「理解两种结构在文本分类任务上的本质差异并能用同一套数据管线把两者都跑出可对比的结果」。它适合正在做课程作业或毕设的本科生也适合想系统补齐 NLP 入门实战经验的开发者。你需要的不只是一份代码而是一条从数据预处理到模型对比的完整链路以及知道每一步为什么这么做、参数改了会发生什么、哪里最容易出错。这篇文章就按这个思路展开把 CNN 和 RNN 两条路线都讲透让你不仅能交差还能在答辩时把选型理由说清楚。2. 数据管线先搭稳从原始文本到模型可吃的张量2.1 为什么数据预处理决定了你后面 80% 的调试时间文本分类任务里模型结构其实就那么几种真正拉开差距的是数据管线的质量。我见过太多人直接把原始句子丢进模型然后抱怨 loss 不下降。常见做法是先把文本转成统一长度的整数序列再通过嵌入层映射为稠密向量。这个过程中有几个关键决策分词方式、词表大小、序列截断长度、填充策略。分词用 jieba 还是按字符切取决于你的语料是中文还是英文中文场景下按字符切往往比 jieba 更稳因为 jieba 的分词结果会引入额外的不确定性而字符级建模在短文本分类上表现并不差。词表大小一般控制在 10000 到 30000 之间太小会导致大量 OOV太大会让嵌入层参数量膨胀。序列长度取 128 或 256 是常见选择具体看你语料的长度分布用百分位数看一眼覆盖 95% 样本的长度就是合理上限。import jieba from collections import Counter import numpy as np def build_vocab(texts, max_vocab20000, min_freq2): 构建词表按字符切分统计频次过滤低频 counter Counter() for text in texts: # 中文按字符切英文按空格切这里统一按字符处理 for char in text.strip(): if char.strip(): # 跳过空白字符 counter[char] 1 # 按频次排序取前 max_vocab 个且频次不低于 min_freq vocab [w for w, c in counter.most_common(max_vocab) if c min_freq] # 预留 0 给 padding1 给 unknown word2idx {w: i 2 for i, w in enumerate(vocab)} word2idx[pad] 0 word2idx[unk] 1 return word2idx def encode(texts, word2idx, max_len128): 将文本转为固定长度的整数序列 sequences [] for text in texts: seq [word2idx.get(char, 1) for char in text.strip() if char.strip()] # 截断 if len(seq) max_len: seq seq[:max_len] # 填充 seq seq [0] * (max_len - len(seq)) sequences.append(seq) return np.array(sequences, dtypenp.int64)上面这段代码做了三件事统计字符频次、构建映射表、把变长文本转成定长序列。max_vocab控制词表上限min_freq过滤只出现一次的低频字符这两个参数直接影响嵌入层的规模和泛化能力。max_len决定了每条样本的序列长度短于它的补零长于它的截断。注意 padding 用 0unknown 用 1这两个特殊 token 的索引不要和实际字符冲突。编码后的数组可以直接喂给 PyTorch 或 TensorFlow 的嵌入层。2.2 用 PyTorch 的 Dataset 和 DataLoader 把管线串起来手工处理完数据后下一步是把它包装成 PyTorch 能批量加载的格式。很多人在这里踩坑忘记把数据转成 tensor或者 shuffle 设置不当导致训练集和验证集分布不一致。标准做法是继承torch.utils.data.Dataset实现__len__和__getitem__然后用DataLoader做批处理和打乱。import torch from torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, sequences, labels): self.sequences torch.LongTensor(sequences) self.labels torch.LongTensor(labels) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.sequences[idx], self.labels[idx] # 假设 train_seq, train_label 已经准备好 train_dataset TextDataset(train_seq, train_label) train_loader DataLoader( train_dataset, batch_size64, # 批大小显存不够就调小 shuffleTrue, # 训练集必须打乱 num_workers0 # Windows 下建议设为 0Linux 可设为 2 或 4 )batch_size是最常调的参数64 或 128 是常见起点显存不够就减半。shuffleTrue只在训练集上开验证集和测试集必须关掉否则评估结果没有意义。num_workers在 Windows 上经常出问题设 0 最稳。这些细节看起来琐碎但每年都有人的毕设卡在「DataLoader 报错」上。3. CNN 文本分类把卷积核当成 n-gram 特征探测器3.1 一维卷积在文本上到底在做什么CNN 处理文本的思路和图像完全不同。图像是二维卷积文本是一维卷积。你可以把每个卷积核理解为一个 n-gram 探测器卷积核宽度为 3 时它扫过嵌入序列每次看 3 个相邻词的向量提取局部特征。多个不同宽度的卷积核并行就相当于同时提取 2-gram、3-gram、4-gram 特征。然后做最大池化把每个特征图压缩成一个标量拼接后接全连接层分类。这个结构在短文本分类上非常高效因为短文本的关键信息往往集中在几个局部片段上。import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_classes2, filter_sizes[2, 3, 4], num_filters128, dropout0.5): super(TextCNN, self).__init__() # 嵌入层vocab_size 是词表大小embed_dim 是词向量维度 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 多组一维卷积每组对应一个卷积核宽度 self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizefs) for fs in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: [batch_size, seq_len] emb self.embedding(x) # [batch, seq_len, embed_dim] emb emb.permute(0, 2, 1) # [batch, embed_dim, seq_len] # 每组卷积后接 ReLU 和全局最大池化 conv_outs [] for conv in self.convs: c F.relu(conv(emb)) # [batch, num_filters, seq_len - fs 1] c F.max_pool1d(c, c.size(2)) # [batch, num_filters, 1] conv_outs.append(c.squeeze(2)) out torch.cat(conv_outs, dim1) # [batch, num_filters * len(filter_sizes)] out self.dropout(out) return self.fc(out)filter_sizes控制卷积核宽度常见取 2、3、4覆盖不同长度的 n-gram。num_filters是每个宽度下的卷积核数量128 或 256 都常见越大容量越强但容易过拟合。dropout放在全连接层前0.5 是经典值。注意padding_idx0让嵌入层对 padding 位置输出零向量避免填充影响卷积结果。这个模型结构简单训练快在情感分析、新闻分类等任务上通常能拿到不错的基础分。3.2 训练循环与关键参数设置模型定义好之后训练循环的写法直接影响收敛速度和最终效果。优化器选 Adam学习率 1e-3 是安全起点。损失函数用交叉熵。每个 epoch 后在验证集上算准确率保存最好的模型。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model TextCNN(vocab_sizelen(word2idx)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) best_acc 0.0 for epoch in range(10): model.train() total_loss 0 for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) logits model(batch_x) preds logits.argmax(dim1) correct (preds batch_y).sum().item() total batch_y.size(0) acc correct / total print(fEpoch {epoch1}, Loss: {total_loss:.4f}, Val Acc: {acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), best_cnn.pt)学习率 1e-3 配合 Adam 在大多数文本分类任务上都能稳定收敛。如果 loss 震荡厉害降到 5e-4 或 1e-4。batch_size和max_len共同决定显存占用显存不够优先降 batch_size。验证集准确率不再提升就可以停通常 5 到 10 个 epoch 足够。保存模型时用state_dict()加载时先实例化模型再load_state_dict()。4. RNN 文本分类用 LSTM 捕捉词序依赖4.1 LSTM 的门控机制为什么能缓解长距离依赖RNN 的核心问题是梯度消失序列长了之后前面的信息传不到后面。LSTM 通过输入门、遗忘门、输出门三个门控结构来控制信息流动让模型自己决定记住什么、忘掉什么。在文本分类里LSTM 把序列从左到右扫一遍最后一个时间步的隐藏状态就包含了整个句子的信息接一个全连接层就能分类。双向 LSTM 再扫一遍从右到左把两个方向的最后隐藏状态拼接效果通常比单向好因为分类任务不要求因果性双向能看到完整上下文。class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_classes2, num_layers1, dropout0.5, bidirectionalTrue): super(TextRNN, self).__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalbidirectional, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) # 双向的话 hidden 维度翻倍 fc_input_dim hidden_dim * 2 if bidirectional else hidden_dim self.fc nn.Linear(fc_input_dim, num_classes) def forward(self, x): emb self.embedding(x) # [batch, seq_len, embed_dim] out, (h_n, c_n) self.lstm(emb) # out: [batch, seq_len, hidden*dir] # 取最后一个时间步的输出 last_out out[:, -1, :] # [batch, hidden*dir] last_out self.dropout(last_out) return self.fc(last_out)hidden_dim控制 LSTM 隐藏状态维度128 或 256 是常见值。num_layers堆叠多层 LSTM 能增强表达能力但超过 2 层后收益递减且容易过拟合。bidirectionalTrue让模型同时看正向和反向上下文分类任务上通常比单向高 1 到 3 个百分点。batch_firstTrue让输入维度是[batch, seq_len, embed_dim]符合直觉。注意取的是最后一个时间步的输出因为 LSTM 把整句信息压缩到了最后那个隐藏状态里。4.2 RNN 训练时的梯度裁剪与学习率调整RNN 训练比 CNN 更容易遇到梯度爆炸表现是 loss 突然变成 NaN。解决办法是加梯度裁剪把梯度的范数限制在一个阈值内。同时学习率可以比 CNN 稍低因为 LSTM 参数更多对学习率更敏感。model TextRNN(vocab_sizelen(word2idx)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr5e-4) for epoch in range(10): model.train() for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() # 梯度裁剪限制梯度范数不超过 1.0 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()clip_grad_norm_的max_norm设 1.0 是经验值设太小会拖慢收敛设太大等于没裁。学习率用 5e-4 比 CNN 的 1e-3 更稳如果还是震荡就降到 1e-4。LSTM 的训练时间通常比 CNN 长两到三倍因为序列计算无法完全并行。如果显存或时间紧张可以把max_len从 128 降到 64对短文本分类影响不大。5. 避坑与排查那些让毕设卡三天的真实问题5.1 现象loss 不下降准确率停在 50% 左右原因通常是标签没对齐或者数据管线有 bug。最常见的是word2idx构建时把 padding 和 unknown 的索引搞混导致所有输入都映射到同一个值。另一个可能是标签编码时 0 和 1 反了但评估时又按正确顺序算结果看起来像随机猜。解决办法是打印前几条编码后的序列和对应标签人工核对。再检查padding_idx0是否和word2idx[pad]一致。5.2 现象训练集准确率很高验证集很低这是典型过拟合。CNN 和 RNN 都可能出现尤其是词表大、训练样本少的时候。解决手段有三个加大 dropoutCNN 从 0.5 提到 0.6 或 0.7减小模型容量降低num_filters或hidden_dim加 L2 正则化在优化器里设weight_decay1e-4。如果数据量实在少可以考虑冻结嵌入层不让它参与训练。5.3 现象RNN 训练时 loss 变成 NaN梯度爆炸的典型表现。先检查有没有加梯度裁剪max_norm1.0是底线。如果加了还炸把学习率降到 1e-4。还有一种可能是序列太长LSTM 在长序列上仍然会累积数值误差把max_len从 256 降到 128 试试。另外检查输入里有没有超出词表范围的索引word2idx.get(char, 1)里的默认值 1 是 unknown如果忘了设默认值会直接 KeyError。5.4 现象CNN 和 RNN 结果差不多不知道选哪个这其实不是 bug是正常现象。在短文本分类上CNN 和 RNN 的差距通常只有 1 到 3 个百分点有时候 CNN 还更快更稳。选型建议如果序列短、追求训练速度、显存有限选 CNN如果序列较长、需要捕捉词序依赖、对准确率要求更高选 RNN 或双向 LSTM。答辩时能说清这个权衡比单纯报一个高 0.5 个点的准确率更有说服力。5.5 现象换了随机种子结果波动很大深度学习模型对初始化敏感尤其是小数据集。解决办法是固定随机种子包括 Python、NumPy、PyTorch 的种子。但要注意固定种子后结果可复现不代表模型稳定。更可靠的做法是跑 3 到 5 次不同种子取平均准确率这样在报告里写的结果更可信。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)cudnn.deterministicTrue会让卷积运算确定化代价是速度略慢。benchmarkFalse关闭自动调优保证每次跑的结果一致。这些设置在做对比实验时尤其重要否则 CNN 和 RNN 的差异可能被随机波动淹没。6. 把两个模型放进同一个评估框架对比实验与调参技巧6.1 统一评估指标和早停策略要让 CNN 和 RNN 的对比有意义必须用同一套数据划分、同一个评估指标、同一种早停策略。我一般会写一个通用的训练函数把模型、数据加载器、优化器作为参数传进去返回验证集上的最佳准确率和对应的 epoch。这样两个模型跑的是完全相同的流程唯一变量就是模型结构。def train_and_eval(model, train_loader, val_loader, epochs10, lr1e-3, clipNone): device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr, weight_decay1e-4) best_acc, best_epoch 0.0, 0 for epoch in range(epochs): model.train() for bx, by in train_loader: bx, by bx.to(device), by.to(device) optimizer.zero_grad() loss criterion(model(bx), by) loss.backward() if clip: torch.nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() # 验证 model.eval() correct, total 0, 0 with torch.no_grad(): for bx, by in val_loader: bx, by bx.to(device), by.to(device) preds model(bx).argmax(dim1) correct (preds by).sum().item() total by.size(0) acc correct / total if acc best_acc: best_acc, best_epoch acc, epoch 1 return best_acc, best_epoch这个函数里weight_decay1e-4是 L2 正则化对两个模型都加保证公平。clip参数只对 RNN 传 1.0CNN 传 None。返回最佳准确率和对应 epoch方便在报告里写「CNN 在第 5 个 epoch 达到最佳验证准确率 89.2%RNN 在第 7 个 epoch 达到 90.1%」这样的具体结论。6.2 超参数搜索的实用策略毕设时间有限不可能做大规模网格搜索。我的习惯是先用默认参数跑一遍确定模型能正常收敛然后只调最影响结果的三个参数学习率、dropout、序列长度。学习率在 [1e-4, 5e-4, 1e-3] 里试dropout 在 [0.3, 0.5, 0.7] 里试序列长度在 [64, 128, 256] 里试。每个组合跑一次记录验证集准确率。通常 10 到 15 次实验就能找到不错的配置。注意每次实验都要固定随机种子否则结果没法比较。参数CNN 推荐范围RNN 推荐范围影响学习率1e-3 ~ 5e-45e-4 ~ 1e-4太大震荡太小收敛慢dropout0.3 ~ 0.70.3 ~ 0.7越大越抗过拟合太大欠拟合序列长度64 ~ 25664 ~ 256覆盖 95% 样本即可batch_size32 ~ 12832 ~ 128显存不够优先降这个嵌入维度128 ~ 300128 ~ 300预训练词向量可到 300这张表里的范围是我反复试出来的安全区间超出范围不是不能用但翻车概率明显上升。嵌入维度如果不用预训练词向量128 足够如果用 Word2Vec 或 GloVe 预训练可以设 300 并冻结前几个 epoch。6.3 一个容易被忽略的技巧用验证集做模型选择而不是测试集很多人习惯在测试集上反复跑看哪个模型高就用哪个这会导致测试集信息泄漏最终报告的结果虚高。正确做法是训练集训练验证集调参和早停测试集只在最后用一次。如果数据量少可以用 k 折交叉验证把训练集分成 k 份轮流做验证取平均准确率。这样得到的结论更可靠答辩时老师问起来也站得住脚。我自己的习惯是在代码里把测试集的加载单独放在最后前面所有实验都不碰它。等 CNN 和 RNN 的最佳配置都确定后再各跑一次测试集把两个数字写进报告。这个习惯让我在多次答辩中避免了「结果太好反而被质疑」的尴尬。希望帮到你。本文还有配套的精品资源点击获取