基于CNN和RNN的文本分类实战:从课程作业到项目落地

📅 发布时间:2026/10/9 6:46:39
基于CNN和RNN的文本分类实战:从课程作业到项目落地
简介这份资源是面向高校学生与深度学习入门者的文本分类实战项目包围绕卷积神经网络与循环神经网络两条技术路线展开可用于毕业设计、课程作业或NLP入门练手覆盖情感分析、新闻分类等典型场景。包内共30个文件以py源码、pyc编译文件、pt模型权重、tsv数据集和md说明文档为主压缩包约72.36MBCNN与RNN两套代码分目录组织便于对照阅读。目前已有180人学习下载。项目包含数据加载、模型定义、训练与验证脚本以及预训练词向量与训练/验证/测试数据读者可据此复现文本分类全流程理解一维卷积提取局部特征、LSTM/GRU捕捉长距离依赖的实现细节并参考报告与实验结果评估准确率、召回率等指标适合作为模型搭建与调参的参考模板。1. 从一份课程作业压缩包说起CNN 和 RNN 做文本分类到底怎么落地很多同学拿到「基于深度学习的文本分类实现基于 CNN 和 RNN 的文本分类」这类课程作业或毕设题目时第一反应是去搜「深度学习入门」「深度学习项目」然后被一堆环境配置、GPU 驱动、PyTorch 版本兼容问题劝退。这个标题真正要解决的事情其实很具体给你一批带标签的文本新闻标题、商品评论、邮件内容都行训练两个模型——一个用卷积神经网络CNN提取局部 n-gram 特征一个用循环神经网络RNN建模词序依赖——最后对比它们在测试集上的准确率、F1 和训练耗时。适合谁适合刚学完吴恩达深度学习课后题、想找一个能跑通全流程的深度学习项目练手的人也适合需要交课程作业但不想只调包、想真正理解参数含义的本科生。下面我按自己带学生做这类作业的顺序把数据准备、CNN 实现、RNN 实现、避坑和调参技巧拆开讲每一步都给可复现的代码和参数说明。2. 数据准备与词向量层把原始文本变成模型能吃的张量2.1 选数据集和标签体系先确定分类粒度做文本分类第一步不是写模型而是确定「分几类」。课程作业常见的数据集有 THUCNews 子集10 类新闻、IMDB 影评2 类情感、AG News4 类主题。我一般建议选 4 到 10 类、每类至少 1000 条、文本长度在 20 到 200 词之间的数据集这样 CNN 和 RNN 的差异才能体现出来。如果类别太少比如 2 类两个模型都能轻松到 90% 以上对比就没意义类别太多超过 20 类又需要更大模型和更长训练时间课程作业的算力扛不住。确定类别后要划分训练集、验证集、测试集比例按 8:1:1 或 7:1:2。注意验证集用来调超参数测试集只在最后跑一次不要混用。下面这段代码用 PyTorch 的random_split做划分同时统计类别分布防止某类样本过少导致模型偏向多数类。import torch from torch.utils.data import Dataset, DataLoader, random_split from collections import Counter class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len128): 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): # 把词转成 id未知词用 unk 的 id tokens self.texts[idx].split()[:self.max_len] ids [self.vocab.get(t, self.vocab[unk]) for t in tokens] # padding 到固定长度不足补 0 ids ids [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx]) # 假设 texts 和 labels 已经加载好 vocab {pad: 0, unk: 1} counter Counter() for text in texts: counter.update(text.split()) # 只保留出现次数 2 的词减少词表大小 for word, freq in counter.items(): if freq 2: vocab[word] len(vocab) dataset TextDataset(texts, labels, vocab) train_size int(0.8 * len(dataset)) val_size int(0.1 * len(dataset)) test_size len(dataset) - train_size - val_size train_set, val_set, test_set random_split(dataset, [train_size, val_size, test_size]) train_loader DataLoader(train_set, batch_size64, shuffleTrue) val_loader DataLoader(val_set, batch_size64) test_loader DataLoader(test_set, batch_size64)这段代码的关键参数有三个max_len128控制截断长度太长会增加计算量太短会丢失信息一般取数据中 95% 分位数的长度batch_size64在 8GB 显存下比较稳显存小就降到 32词表只保留频次大于等于 2 的词能把词表从几万压到一两万减少嵌入层参数量。注意pad的 id 必须是 0因为后面 padding 补的就是 0如果搞混了模型会把填充符当成真实词。2.2 嵌入层随机初始化还是用预训练词向量嵌入层负责把词 id 映射成稠密向量。课程作业里两种做法都常见随机初始化让模型自己学或者加载预训练词向量如 GloVe、Word2Vec、腾讯词向量。如果数据集只有几千条我强烈建议用预训练词向量因为随机初始化在少量数据上很难学到好的语义表示准确率可能差 5 到 10 个百分点。如果数据集有几十万条以上随机初始化也能跑出不错的结果而且省去下载词向量文件的麻烦。用预训练词向量时要注意维度对齐。比如 GloVe 常见的是 100 维或 300 维你的嵌入层embedding_dim必须设成一样的值否则加载权重会报错。另外预训练词表和你自己的词表对不上的词要么随机初始化要么统一用unk代替。下面是一个加载示例假设你已经把 GloVe 转成了字典格式pretrained_vectors。import torch.nn as nn class EmbeddingLayer(nn.Module): def __init__(self, vocab_size, embedding_dim, pretrainedNone, freezeFalse): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) if pretrained is not None: # pretrained 是 {word: vector} 字典需要按 vocab 顺序对齐 weight torch.randn(vocab_size, embedding_dim) * 0.1 for word, idx in vocab.items(): if word in pretrained: weight[idx] torch.tensor(pretrained[word]) self.embedding.weight.data.copy_(weight) # freezeTrue 时冻结词向量不参与训练 self.embedding.weight.requires_grad not freeze def forward(self, x): return self.embedding(x)padding_idx0告诉 PyTorch 第 0 号向量不参与梯度更新避免填充符影响训练。freeze参数控制是否微调词向量数据少时设为 True 防止过拟合数据多时设为 False 让词向量适配任务。我一般先冻结训练几轮再解冻微调效果比一直冻结或一直解冻都好。3. CNN 文本分类用卷积核抓 n-gram 特征3.1 一维卷积为什么能处理文本CNN 在图像里是二维卷积在文本里用的是一维卷积。把一句话看成[序列长度, 嵌入维度]的矩阵一维卷积核在序列方向上滑动每次覆盖连续几个词相当于在提取 bigram、trigram 特征。比如卷积核大小为 3就是在看「三个连续词」的组合模式这和传统文本分类里的 n-gram 特征思路一致但 CNN 能自动学习哪些 n-gram 重要不用人工设计特征。具体做法是用多个不同尺寸的卷积核比如 2、3、4分别卷积每个尺寸取多个通道比如 128 个然后对每个通道做最大池化把所有通道池化后的结果拼接起来接全连接层分类。这样模型既能捕捉「不 好看」这种二字组合也能捕捉「非常 值得 推荐」这种三字组合。池化用最大池化而不是平均池化是因为文本里往往只有少数关键 n-gram 决定类别最大池化能把这些强信号挑出来。3.2 用 PyTorch 搭一个可跑的 TextCNN下面这个 TextCNN 结构参考了经典论文的做法但做了简化适合课程作业的算力。代码里卷积核尺寸设为 2、3、4每个尺寸 128 个通道dropout 设为 0.5 防止过拟合。import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, num_classes, filter_sizes[2, 3, 4], num_filters128, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 每个卷积核尺寸对应一个 Conv1d self.convs nn.ModuleList([ nn.Conv1d(in_channelsembedding_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, seq_len] emb self.embedding(x) # [batch, seq_len, emb_dim] emb emb.permute(0, 2, 1) # [batch, emb_dim, seq_len] conv_outs [] for conv in self.convs: c F.relu(conv(emb)) # [batch, num_filters, seq_len - fs 1] p F.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] conv_outs.append(p) out torch.cat(conv_outs, dim1) # [batch, num_filters * len(filter_sizes)] out self.dropout(out) return self.fc(out)permute(0, 2, 1)是因为Conv1d要求输入维度是[batch, channel, length]而嵌入层输出是[batch, length, dim]所以要把后两维换一下。max_pool1d的池化窗口大小设为c.size(2)也就是把整个序列长度池化成一个值这样不管输入句子多长输出维度都是固定的。filter_sizes和num_filters是最关键的两个超参数尺寸太小抓不到长距离组合尺寸太大在短文本上卷积核会滑不动通道数太少模型容量不够太多容易过拟合。我一般从[2,3,4]和 128 开始调。3.3 训练循环与参数设置训练时用交叉熵损失和 Adam 优化器学习率设 1e-3训练 10 到 20 轮。每轮在验证集上算准确率保存验证集最好的模型。下面是一个最小训练循环。device torch.device(cuda if torch.cuda.is_available() else cpu) model TextCNN(vocab_sizelen(vocab), embedding_dim100, num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_acc 0.0 for epoch in range(15): model.train() for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() model.eval() correct, total 0, 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) pred model(x).argmax(dim1) correct (pred y).sum().item() total y.size(0) acc correct / total print(fepoch {epoch1}, val_acc {acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), textcnn_best.pt)学习率 1e-3 是 Adam 的常用起点如果损失震荡就降到 5e-4如果下降太慢就升到 2e-3。torch.save保存的是state_dict加载时先实例化同样结构的模型再load_state_dict。注意验证集准确率不再提升时就可以停不一定跑满 15 轮课程作业里早停能省不少时间。4. RNN 文本分类用循环结构建模词序依赖4.1 LSTM 和 GRU 怎么选RNN 的核心是隐藏状态沿序列传递理论上能记住任意距离的依赖但普通 RNN 有梯度消失问题长序列上效果很差。实际用的都是 LSTM 或 GRU。LSTM 有三个门输入门、遗忘门、输出门参数量大但表达能力强GRU 只有两个门重置门、更新门参数少、训练快在小数据集上往往和 LSTM 差不多甚至更好。课程作业里我一般先用 GRU 跑基线如果效果不够再换 LSTM。另一个选择是单向还是双向。文本分类不像机器翻译那样有严格的因果顺序双向 RNN 能同时看上下文通常比单向高 2 到 5 个百分点。做法是把正向和反向的隐藏状态拼接起来再送全连接层。下面代码用nn.GRU加双向。4.2 用 PyTorch 搭一个双向 GRU 分类器class TextRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes, num_layers1, bidirectionalTrue, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.rnn nn.GRU( input_sizeembedding_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalbidirectional, dropoutdropout if num_layers 1 else 0 ) self.dropout nn.Dropout(dropout) factor 2 if bidirectional else 1 self.fc nn.Linear(hidden_dim * factor, num_classes) def forward(self, x): emb self.embedding(x) # [batch, seq_len, emb_dim] out, h self.rnn(emb) # out: [batch, seq_len, hidden*factor] # 取最后一个时间步的输出作为句子表示 out out[:, -1, :] # [batch, hidden*factor] out self.dropout(out) return self.fc(out)batch_firstTrue让输入维度是[batch, seq_len, dim]和嵌入层输出一致不用像 CNN 那样转置。num_layers1是单层层数多了容易过拟合课程作业一般 1 到 2 层够用。取out[:, -1, :]是取最后一个时间步因为双向 GRU 的最后一个时间步已经包含了正向的末尾和反向的开头实际上融合了整句信息。如果觉得这样太粗暴也可以对所有时间步做平均池化或最大池化效果有时更好。4.3 训练差异梯度裁剪和序列长度RNN 训练比 CNN 多两个注意点。第一是梯度裁剪因为 RNN 反向传播沿时间步展开梯度容易爆炸用torch.nn.utils.clip_grad_norm_把梯度范数限制在 5 以内。第二是序列长度RNN 对长序列的计算是串行的max_len设太大训练会非常慢一般 128 到 256 就够超过 256 的文本可以先截断或分段。optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(15): model.train() for x, y in train_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()max_norm5.0是经验值如果训练损失出现 NaN先检查是不是梯度爆炸把 max_norm 降到 1.0 试试。另外 RNN 的隐藏维度hidden_dim一般设 128 或 256太小记不住信息太大参数多容易过拟合。学习率和 CNN 一样从 1e-3 开始调。5. 避坑与排查课程作业里最容易翻车的五个地方5.1 现象训练准确率一直涨验证准确率不动甚至下降原因典型过拟合。课程作业数据集通常只有几千到几万条模型参数量却很大尤其是嵌入层和全连接层。解决先加 dropoutCNN 和 RNN 都设 0.5再考虑减小隐藏维度或卷积核通道数还可以对嵌入层做权重衰减Adam 的weight_decay1e-4。如果还不行就冻结词向量不训练。5.2 现象损失变成 NaN原因学习率太大或 RNN 梯度爆炸。解决先把学习率降到 1e-4 跑几轮看损失是否正常下降同时加上梯度裁剪。如果 CNN 也 NaN检查输入里有没有超出词表范围的 id或者 padding 的 id 不是 0 导致嵌入层取到了随机初始化的向量。5.3 现象CNN 报错Expected input to have 3 dimensions原因忘了permute。嵌入层输出是[batch, seq_len, emb_dim]Conv1d要的是[batch, emb_dim, seq_len]必须转置。解决在卷积前加emb.permute(0, 2, 1)池化后再把维度还原。5.4 现象RNN 训练速度极慢一个 epoch 要几十分钟原因max_len设得太大或者batch_size太小导致 GPU 利用率低。解决把max_len从 512 降到 128batch_size从 16 升到 64。另外确认batch_firstTrue设了否则 PyTorch 会按[seq_len, batch, dim]处理虽然结果对但容易和后续层维度搞混。5.5 现象测试集准确率比验证集低很多原因用测试集调了超参数或者测试集和验证集分布不一致。解决严格按 8:1:1 划分调参只看验证集测试集只在最后跑一次。如果测试集准确率还是低检查数据划分时有没有把同一类样本集中到某一个集合用random_split前先打乱索引。6. 进阶技巧让 CNN 和 RNN 的对比更有说服力课程作业如果只跑两个模型报个准确率很容易被问「为什么 CNN 比 RNN 好」或者「RNN 是不是没调好」。我一般会补三件事让对比更扎实。第一是统一参数量级。CNN 和 RNN 的参数量差很多直接比不公平。可以调整num_filters和hidden_dim让两个模型参数量接近比如都在 100 万到 200 万之间再比准确率和训练时间。下面这个表格是我在 10 类新闻数据集上的典型结果供参考。模型参数量验证准确率单 epoch 耗时TextCNN1.2M92.3%18s双向 GRU1.5M90.8%45s双向 LSTM1.8M91.1%52s第二是做错误分析。把测试集里预测错的样本打印出来看 CNN 错在哪、RNN 错在哪。我自己的经验是 CNN 容易在否定句上翻车比如「不 是 很 好」被分成正面因为卷积核看到「很 好」就激活了RNN 因为能看到词序这类错误少一些但在长文本上容易丢失开头信息。把这个观察写进报告比单纯报准确率有说服力得多。第三是试一个混合结构。把 CNN 的输出接一个 GRU或者把 RNN 的输出再接一层卷积有时能比单模型高 1 到 2 个百分点。下面是一个最小混合模型的核心部分。class CNNRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.conv nn.Conv1d(embedding_dim, hidden_dim, kernel_size3, padding1) self.gru nn.GRU(hidden_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): emb self.embedding(x).permute(0, 2, 1) # [batch, emb, seq] c torch.relu(self.conv(emb)).permute(0, 2, 1) # [batch, seq, hidden] out, _ self.gru(c) return self.fc(out[:, -1, :])这个结构先用卷积提取局部特征再用 GRU 建模序列依赖padding1保证卷积后序列长度不变。注意参数量会比单模型大训练时间也更长课程作业里作为加分项跑一下就行不用死磕。最后说一个我自己的习惯每次跑完实验把超参数、随机种子、验证准确率记在一个表格里不要只靠脑子记。我见过太多人调了半天参数最后写报告时忘了哪个配置对应哪个结果只能重新跑一遍。随机种子固定成 42 或 2024保证结果可复现。这个习惯看起来笨但能省下大量后悔药。希望帮到你。本文还有配套的精品资源点击获取