深度学习文本分类:卷积与循环神经网络的实战对比
2026/9/16 4:15:58 网站建设 项目流程

简介:面向高校毕设与课程作业场景的深度学习文本分类项目包,系统实现卷积神经网络与循环神经网络两种经典模型在情感分析、新闻分类等自然语言处理任务中的应用。项目从数据加载、预处理到模型训练与评估形成完整链路,适合自然语言处理入门学习者、毕业设计选题学生以及需要快速搭建文本分类基线的开发者参考。资源共30个文件,涵盖9个Python源代码、6个预训练模型权重文件、4个制表符分隔的数据集文件、2个Markdown说明文档及编译生成的字节码文件,压缩包大小72.36MB;目录按两个模型及公共数据模块划分,结构清晰。已有180人学习下载。代码基于PyTorch或TensorFlow等深度学习框架实现,并配有实验报告与GloVe词向量等辅助数据,可帮助读者理解卷积核局部特征提取与循环网络时序建模机制,掌握词向量嵌入、模型调参、准确率与召回率评估等关键环节,是完成课程设计或快速复现经典文本分类方法的实用参考。

1. 基于深度学习的文本分类:CNN 与 RNN 各自的适用面

“毕设&课程作业_基于深度学习的文本分类,实现基于CNN和RNN的文本分类”几乎是中文 NLP 课程作业里被选得最多的一个题目。它便宜、自包含、又好展示:数据集公开,模型结构在教科书里都有图,训练时间按分钟计,最后画一条训练曲线就能写进报告。但恰恰因为它常见,基于深度学习的文本分类想拿高分,靠的不是把代码跑通,而是有没有真正弄懂 CNN 和 RNN 在文本建模上的差异,以及验证集上的分数是从哪一步调上去的。下面按课程设计里最常用的一套流程来讲:先说明文本数据怎么变成矩阵,再分别实现 TextCNN 和 BiLSTM 两个最小可运行版本,最后落到训练对比和调优技巧。代码统一用 PyTorch,数据用 THUCNews 的中文子集,不依赖任何第三方封装好的模型库。

2. CNN 做文本分类:句子矩阵上的滑动窗口

2.1 为什么卷积能处理一维文本序列

图像卷积之所以有直觉,是因为像素在平面上存在空间局部性:一个物体的边缘和纹理只跟它周围的几个像素有关,不需要看全图。句子也有类似的局部性,只是它的轴只有一个,也就是词的左右顺序。把句子里每个词替换成稠密向量后,一个[seq_len, embed_size]的词嵌入矩阵就构成了文本的“图像”:横轴是词位置,纵轴是向量维度。卷积核沿着词位置方向滑动,本质上是在提取“连续 k 个词窗口内”的特征模式。核大小为 2 时观察到的是二元搭配,大小为 3 或 4 时观察到的是更长一些的局部短语。这与检索场景里二元语法、三元语法的思路一致,区别在于卷积参数是从数据里学出来的,不是统计出来的。

TextCNN 的核心假设是:一个句子是否属于某个类别,往往由几个局部关键词组决定,而不需要理解词语之间长距离的依赖关系。体育新闻里出现“进球”“比分”,财经新闻里出现“涨停”“板块”,这些信号在句子任意位置被某个卷积核捕获,经过最大池化后就能成为分类依据。这也是 CNN 在短文本上效果好、训练又快的原因。

2.2 数据集与训练前的标准化处理

课程作业里最省事的做法是从 THUCNews 里取出 4 万条新闻样本,切成 10 个类别。这批数据的长度差异很大,短的两三个词,长的几千字,如果不做长度约束,第一个被拖垮的不是准确率,而是训练速度。我一般把句子截断到 200 个 token,超过直接切掉,不足的部分在尾部补一个专门的<pad>标记。截断方向是取前 200 个 token,这对新闻标题类样本影响很小。如果关键信息经常出现在句尾,比如商品评论里的“但是配送太慢”,就要改成取末尾,或者首尾各取一半拼接。

from collections import Counter def build_vocab(texts, min_freq=2): counter = Counter() for text in texts: counter.update(text.split()) vocab = {'<pad>': 0, '<unk>': 1} for word, freq in counter.most_common(): if freq >= min_freq: vocab[word] = len(vocab) return vocab def encode(text, vocab, max_len=200): ids = [vocab.get(w, 1) for w in text.split()][:max_len] if len(ids) < max_len: ids += [0] * (max_len - len(ids)) return ids

这里有两个细节需要说明。<pad>的 id 固定为 0,后续在 Embedding 层要把padding_idx=0传进去,这样填充位置的向量不会参与梯度更新,避免模型学习到无意义信息。<unk>固定为 1,是为了让验证集和测试集中没出现过的词有一个统一去处。min_freq的取值值得注意:在新闻数据上取 1 会得到约 20 万大小的词表,Embedding 层的参数量会明显膨胀;取 2 或 3 可以把只出现过一次的错别字、数字串全部清掉,通常带来轻微的效果提升。

2.3 一个最少行数跑通训练的 TextCNN 实现

TextCNN 的实现比预想简单,因为 PyTorch 已经把卷积和池化都封装好了。网络本体分为三层:Embedding 把词 id 映射成向量,Conv1d 在序列方向上滑动,最终把不同核尺寸的池化结果拼接,再接一层全连接分类。

import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_size=128, num_classes=10, kernel_sizes=(2, 3, 4), num_filters=256, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embed_size, num_filters, k, padding=k // 2) for k in kernel_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embed_size] emb = emb.transpose(1, 2) # [batch, embed_size, seq_len] features = [] for conv in self.convs: c = torch.relu(conv(emb)) # [batch, num_filters, seq_len] c = torch.max(c, dim=2).values # 全局最大池化 features.append(c) out = torch.cat(features, dim=-1) return self.fc(self.dropout(out))

逻辑说明:transpose(1, 2)这一步是 Conv1d 的数据格式要求,它期望输入是[batch, channels, length],而 Embedding 输出的是[batch, length, channels]torch.max(c, dim=2).values实现了理论里的全局最大池化,不需要额外写 AdaptiveMaxPool 层。每个核尺寸的卷积输出经过池化后都被压成[batch, num_filters],三个核拼接后是全连接层的输入。整个网络输入变长没问题,因为池化后输出维度只取决于滤波器数量,不取决于句子长度。

参数说明:kernel_sizes=(2, 3, 4)覆盖两词、三词、四词的局部窗口,在中文文本里两个相邻词往往能构成关键搭配,所以这是最常用的一个范围。给核尺寸加了padding=k // 2是为了让卷积输出长度与输入保持一致,保证句首和句尾的词也能参与窗口计算——中文句尾经常出现“了、呢、吗”这类对情感极性有影响的词,直接丢弃会导致边界信息丢失。num_filters=256表示每个核尺寸学习 256 个过滤器,总特征数为 768;升到 512 会有微小提升但训练时间接近翻倍。dropout=0.5施加在全连接之前,抑制过拟合;如果你发现收敛明显变慢,可以降到 0.3。

2.4 文本分类训练的关键参数怎么设

参数推荐值作用调整方向
embed_size128词向量维度调到 300 略有提升,小数据集收益递减
kernel_sizes(2, 3, 4)提取局部 n-gram 模式加 (5,) 提高长短语捕获能力
num_filters256每类卷积核数量512 提升有限,显存占用翻倍
dropout0.5防止过拟合数据量只有几千条时建议升到 0.7
pooling全局最大保留最显著特征换成平均池化掉点明显
max_len200控制序列长度短文本任务可减到 100

特别提醒一个常见的认知偏差:在课程作业规模的数据集上,为 TextCNN 加载大型预训练词向量带来的收益很小。因为卷积核更关注窗口内组合关系,而不是单个词的语义丰富度;Embedding 层在训练数据足够时自己学出来的组合模式往往更适配当前语料。省下加载词向量的时间,去调卷积核尺寸和 dropout 更划算。

3. RNN 建序列模型:BiLSTM 处理句子结构信息

3.1 从全序列压缩信息:为什么直接上 LSTM

CNN 的建模假设是局部性——一个特征只跟周边几个词相关。但有些语义信号不满足这个前提,比如评价主体和情感词离得很远:“这家店的装修和菜色都好得让人意外,唯独服务态度不行”里的“不行”在句尾,却要回过头来约束前面的“服务态度”。RNN 存在的意义,是让每个时间步都拿到从开头传递到当前的隐状态,这类模型把整句的历史信息压缩在一个向量里。在文本分类任务里,不需要关心每个时间步的输出,只需要最后一层最后时刻的隐状态作为整句表示。

不过实践中很少用普通 RNN,原因是反向传播时梯度要在时间维度上反复相乘,超过一定步数容易爆炸或消失。在隐层加门控的 LSTM 是实际主力结构,它通过输入门、遗忘门、输出门控制信息保留程度,让梯度可以沿着时间步直通更远的位置。因此在毕设或者课程作业里,标题写 RNN,实现上按 LSTM 写是完全合理的做法,报告里说明“以 LSTM 作为 RNN 的改进实现”即可。

3.2 双向结构:h_n 索引里的方向顺序

单向 LSTM 的问题是:第 20 个词看不到第 21 个词。中文里否定词“不”常常出现在被修饰词之前,如果只看前文,看不到后面被否定的名词,语义是残缺的。双向 LSTM 并行跑两个方向:正向从头到尾,反向从尾到头,然后把两个方向的最终隐状态拼接起来。拼接后的向量里,既包含从前到后对整句的压缩,也包含从后往前捕获的“摘要”。

这里有一个具体实现很容易踩坑。nn.LSTM返回的h_n形状是[num_layers * num_directions, batch, hidden_size]。在num_layers=2bidirectional=True的情况下,h_n[0]h_n[1]是第一层的正向和反向,h_n[2]h_n[3]是第二层的正向和反向。取最后一层时,正确写法是h_n[-2]h_n[-1],分别对应最后一层正向和最后一层反向。新手常犯的错误是直接h_n.mean(dim=0),这会把正反方向平均掉,等于丢掉了一半的结构信息;还有人用h_n[-1]单独表示正方向,这实际上拿到的是反向。

3.3 处理变长序列:排序、打包、还原缺一不可

RNN 的 padding 和 CNN 不一样。CNN 的 pad 位置参与计算是无害的,最大池化会自动忽略无效区域。RNN 则会把 pad 位置也一步步更新隐状态,既浪费算力,又会让不同长度的句子在 padding 区域产生不同的信息污染。此外,如果直接把填充后的序列送入 LSTM,梯度会从无意义的位置回流,干扰前面真实词的表示。

正确处理是使用pack_padded_sequence将真实词与填充词分离,循环只在真实词上运行。这里还要注意一个隐藏问题:enforce_sorted=False虽然可以自动排序,但它会打乱 batch 顺序,而分类任务需要隐状态和标签一一对应。我采用手动排序加还原的写法,确保输出顺序始终与输入一致。

from torch.nn.utils.rnn import pack_padded_sequence class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_size=128, hidden_size=128, num_classes=10, num_layers=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True, bidirectional=True) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x, lengths): emb = self.embedding(x) # [B, L, E] # 1. 按真实长度降序排序,保证 pack 后顺序可控 lengths_sorted, sort_idx = lengths.sort(descending=True) emb_sorted = emb[sort_idx] # 2. 打包:去掉 padding 位置,只保留真实 token packed = pack_padded_sequence( emb_sorted, lengths_sorted.cpu(), batch_first=True) # 3. 过 LSTM,取最后一层两个方向的最终隐状态 _, (h_n, _) = self.lstm(packed) h_final = torch.cat((h_n[-2], h_n[-1]), dim=-1) # [B, 2*hidden] # 4. 还原 batch 顺序,保证和标签对齐 unsort_idx = sort_idx.argsort() h_final = h_final[unsort_idx] return self.fc(self.dropout(h_final))

逻辑说明:sort_idx记录了从原 batch 到降序排列的映射,unsort_idx = sort_idx.argsort()是它的逆映射,还原后h_final的第 i 行仍然对应输入 batch 的第 i 条样本。pack_padded_sequencelengths参数只接受 CPU 张量,所以代码里显式调用了.cpu(),否则在 GPU 训练时会直接报错。

参数说明:hidden_size=128是每个方向的隐层维度,双向拼接后实际送入全连接层的是 256 维。num_layers=2是常用选择,LSTM 层数加深到 3 层以上,在几万条数据上过拟合会明显加快,而且训练时间成倍增长。.cpu()这个细节容易被忽略,数据在 GPU 上时 tensor 默认也在 GPU,pack 函数不接受 CUDA 长度张量,这是新手遇到频率最高的一个报错。

3.4 模型对比:CNN 快、LSTM 深,准确率差距很小

TextCNN 和 BiLSTM 在文本分类上的差异,体感最直观的是训练速度和最终准确率。TextCNN 是并行计算,GPU 利用率高,一个 epoch 只要几十秒。BiLSTM 存在时间步依赖,只能串行,慢几倍,参数量也更大。但在 THUCNews 这个量级的数据上,两者的准确率接近,差距通常在 1 到 2 个百分点以内。CNN 在局部关键词强烈的样本上占优,LSTM 在处理需要依赖上下文的复杂句子上略强。这个结论和多数公开实验一致:当文本足够长、类别边界依赖远距离语义时,LSTM 的优势才真正体现;短文本分类任务直接选 CNN 是更务实的做法。

4. 训练与对比实验:同一套流程同时跑两个模型

4.1 损失函数与优化器:交叉熵加 Adam 的默认搭配

文本分类是标准的多分类问题,输出维度等于类别数。损失函数用交叉熵,PyTorch 的nn.CrossEntropyLoss()内部已经把 softmax 包含进去了,所以模型最后一层直接输出原始 logits,不要再手动加 softmax,否则等于在 loss 里做了两遍归一化,梯度会偏。

优化器选择 Adam,学习率 1e-3。这个组合在 4 万条数据的中等规模任务里基本不需要太多调整就能收敛。Adam 对每个参数做了自适应缩放,Embedding 层和卷积层之间的学习率差异都由它自动消化。一个容易被忽略的参数是weight_decay:在文本分类的小数据集上,给 Adam 加weight_decay=1e-4通常能带来稳定的小幅提升,本质上是对权重做 L2 约束,防止少数参数取值过大。

4.2 统一训练循环与三类验证指标

训练循环不区分模型结构,只需要注意 BiLSTM 额外接收lengths参数,TextCNN 的调用去掉即可。下面以 BiLSTM 为例展示一个可直接复用的训练函数。

def train_epoch(model, loader, optimizer, criterion): model.train() total_loss, total_correct = 0, 0 for texts, labels, lengths in loader: texts, labels = texts.cuda(), labels.cuda() optimizer.zero_grad() logits = model(texts, lengths) # TextCNN 写 model(texts) loss = criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() * texts.size(0) total_correct += (logits.argmax(-1) == labels).sum().item() return total_loss / len(loader.dataset), total_correct / len(loader.dataset)

逻辑说明:clip_grad_norm_是 RNN 训练里必加的一步,把梯度范数裁剪到 1.0 以内,防止长序列反向传播时梯度爆炸。CNN 不裁剪也能跑,但统一加上没有坏处。texts.size(0)是当前 batch 的样本数,loss 乘样本数再除以总数,得到的是整个数据集的平均损失,而不是 batch 平均损失的简单平均。

验证指标建议同时记录三个:整体准确率、每个类别的 F1、macro-F1。当样本不平衡时,比如体育类样本是星座类的十倍,整体准确率会被大类带偏,此时只看 accuracy 会高估模型。macro-F1 是把每个类别的 F1 单独算完再取平均,能更公平地暴露少数类上的表现。

4.3 同一份数据上两套模型的超参差异

超参数TextCNNBiLSTM说明
embed_size128128两组保持相同,控制变量
kernel_sizes(2, 3, 4)仅 CNN 使用
num_filters256每类核的数量
hidden_size128/层双向后拼接为 256
num_layers2超过 3 层过拟合风险大增
dropout0.50.5统一设置
learning rate1e-31e-3Adam 默认
weight_decay1e-41e-4小数据集可稳定提升
batch_size12864BiLSTM 显存占用更高

batch_size故意不对称是有实际原因的:BiLSTM 需要在时间维度上展开序列,反向传播也要保存每个时间步的中间状态,因此同一块显卡上无法和 CNN 使用同样大的 batch。做对比实验时参数不同是合理的,但在分析结论时要意识到,RNN 的慢不完全来自结构本身,还有显存限制下 batch 变小带来的吞吐损失。

4.4 训练曲线的解读与早停点

不要在跑完固定 epoch 后直接拿最终测试集准确率下结论。一份有说服力的实验至少应该包含三样东西:训练集准确率随 epoch 上升的曲线、验证集 loss 从下降到回升的全过程、早停后最佳模型在测试集上的评估结果。早停的常见做法是:每个 epoch 结束后计算验证集 macro-F1,连续 5 个 epoch 没有提升就停止训练,并恢复验证集分数最高那一轮的模型权重。PyTorch 里可以用ModelCheckpoint的思路手动实现,把每轮的最优 state_dict 单独保存下来。

5. 错误分析与学习率衰减:把验证分数再抬一个点

5.1 打印错例,先判断错得有没有道理

训练结束后,不要立刻去调模型结构。把验证集里预测错误的样本按类别打印出来,通常看 20 条就能发现规律。

model.eval() with torch.no_grad(): for texts, labels, lengths in loader: logits = model(texts, lengths) # TextCNN 写 model(texts) preds = logits.argmax(-1) for i in range(labels.size(0)): if preds[i] != labels[i]: print(f"真实:{id2label[labels[i]]} 预测:{id2label[preds[i]]} " f"文本:{texts[i][:50]}")

打印出来的误分类样本通常分成两类:一类是类别本身语义重叠,比如“家居”和“房产”在新闻数据里经常出现同一条新闻被标成两个类的情况;另一类是模型置信度低、标签本身合理但模型没学会。如果是前者,加大模型不会解决问题,需要考虑合并类别或者补充对应训练样本。如果是后者,才轮到调模型。错误分析的价值在于,它把抽象的“准确率 91%”转成一个一个可操作的具体问题。

5.2 验证 loss 平台期的学习率调度

大多数课程作业的训练过程会在某个 epoch 后进入平台期:训练准确率还在缓慢上升,验证集 loss 却开始震荡甚至回升。这时候与其改结构,不如先试试学习率衰减。ReduceLROnPlateau是最省事的方案:

from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3, cooldown=2) for epoch in range(30): train_loss, train_acc = train_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc = evaluate(model, val_loader, criterion) scheduler.step(val_loss)

逻辑说明:patience=3表示连续 3 轮验证集 loss 没有下降才触发衰减;factor=0.5表示触发后学习率减半;mode='min'表示我们监控的是 loss,值越小越好。进入平台期后,学习率每降一次,验证集 loss 往往会继续往下走一小段,然后再进入下一个平台。连续两轮以上衰减都无效时,可以判断模型容量已经是瓶颈,再往后调就是换结构的问题了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询