☰
深度学习文本分类实战:CNN与RNN双路线实现指南
2026/10/7 13:07:03 网站建设 项目流程

简介:面向毕业设计与课程作业场景,这份压缩包聚焦基于深度学习的文本分类,使用卷积神经网络(CNN)与循环神经网络(RNN)完成情感分析、新闻分类等典型任务,适合需要快速上手NLP模型搭建、训练与评估的学生参考。包体共30个文件、约72.36MB,主要包括9个Python源码、6个PyTorch权重、4个TSV数据集和2个Markdown说明文档,可覆盖数据预处理、模型训练、验证及结果分析等完整流程。已有180人学习下载。资源内同时提供文本CNN/RNN模型实现、训练脚本、分词与数据加载工具,并配有README与Graduation Design文档,便于梳理毕设报告的写作思路和实验复现步骤,对理解词向量、池化、LSTM/GRU长期依赖等关键知识点也很有帮助。

1. 深度学习文本分类:CNN 和 RNN 两条路线一次打通

做毕设或者课程作业时,最怕的不是模型不会写,而是数据、词向量、模型、评估这四件事第一次串起来全乱套。这个项目把基于深度学习的文本分类按 CNN 和 RNN 两条路线完整实现了一遍,从 TSV 数据读取、GloVe 词向量加载到模型训练与验证都有对应代码。它不依赖 BERT 这类大模型,只靠 textCNN 和 textRNN 就能在新闻分类、情感分析这类任务上跑出可用结果。对第一次接触 NLP 深度学习的学生来说,这份资源最大的价值是让你能看到一个完整、干净的基线系统长什么样。对已经写过分类模型的人来说,它提供了一套可以直接改数据、调参数换任务的现成骨架,省掉从零搭工程的时间。

2. 数据管线:从 TSV 原始文本到可训练的 Batch

2.1 拿到数据先看格式:TSV 文件里到底有什么

项目根目录下放着 train.tsv、train_data.tsv、val_data.tsv、test_data.tsv 这几个文件,还有 split.py 负责数据切分。TSV 的全称是 Tab-Separated Values,也就是字段之间用制表符分隔。先用 pandas 读进来看看实际长什么样:

import pandas as pd # 不确定有没有表头时,先不带 names 读前几行 df = pd.read_csv('train.tsv', sep='\t', nrows=5) print(df.head()) print(df.shape)

如果读出来第一行是列名,比如text label,那么训练的时候要设置names=['text', 'label']重新加载;如果读出来直接就是文本和标签,说明文件本身没有表头。项目里同时出现 train.tsv 和 train_data.tsv,一般前者是原始全量训练集,后者是 split.py 切分后生成的训练文件,val_data.tsv 和 test_data.tsv 则是验证集和测试集。

这一步的关键是搞清楚两件事:文本列和标签列分别在第几列,标签是整数还是字符串。分类任务的标签如果是字符串,比如 positive/negative,后面训练时要手动映射成从 0 开始的整数。我一般会顺手打印一下标签分布:

df = pd.read_csv('train_data.tsv', sep='\t', names=['text', 'label']) print(df['label'].value_counts())

标签分布直接决定后面要不要处理类别不平衡。如果某一个类占比超过 80%,准确率这个指标基本就没参考价值了,得换成 macro-F1。

2.2 构建词表:max_vocab_size 和 min_freq 怎么定

深度学习模型不认识原始字符串,需要先把句子拆成 token,再把每个 token 映射成整数 ID。中文文本可以直接按字切,也可以先用 jieba 分词;英文文本按空格和标点切就行。这个项目的数据集从文件结构看是英文文本分类,所以分词直接用split()就够了。

词表构建的逻辑是:统计所有 token 的出现频次,按频次从高到低取前 N 个。这里有两个参数直接决定词表大小:

from collections import Counter def build_vocab(texts, max_vocab_size=50000, min_freq=2): counter = Counter() for text in texts: tokens = text.strip().split() counter.update(tokens) vocab = {'<pad>': 0, '<unk>': 1} for word, freq in counter.most_common(max_vocab_size - 2): if freq >= min_freq: vocab[word] = len(vocab) return vocab vocab = build_vocab(df['text'].tolist(), max_vocab_size=50000, min_freq=2)

代码里<pad>用来把同一 batch 里的句子补齐到相同长度,<unk>用来替代没进词表的低频词。max_vocab_size 设 50000 是因为 GloVe 预训练词向量通常也是按词频截断过的,词表范围大致匹配。min_freq 设 2 是为了过滤掉只出现一次的噪声 token,不然词表会被大量生僻词撑大,训练时 embedding 矩阵大部分行都学不到有效梯度。

句子长度方面,先统计训练集所有样本的 token 数量分布,用 95 分位数作为 max_len。不要拍脑袋定 512,很多短文本分类任务 95% 的样本都在 100 个 token 以内,max_len 设 128 就够了。定大了不仅浪费显存,CNN 的池化层反而会被大量 padding 区域干扰。

2.3 加载 GloVe 预训练词向量:命中率决定模型下限

项目里专门放了一个 glove 目录。GloVe(Global Vectors for Word Representation)是一套预训练词向量,常见的有 50 维、100 维、200 维、300 维几个版本。用预训练向量的原因是:模型能直接从词向量里继承单词的语义相似性,比如 king 和 queen 的向量距离很近,这样训练时只需要微调而不是从零学习语义。

加载 GloVe 的核心逻辑是遍历词表,把每个词对应的向量从 GloVe 文件里找出来,填进 embedding 矩阵。找不到的词随机初始化,padding 位置保持全零:

import numpy as np import torch.nn as nn def load_glove_embeddings(glove_path, vocab, embedding_dim=100): # 初始化矩阵:<pad> 保持全零,<unk> 随机初始化 embeddings = np.random.normal(0, 0.1, (len(vocab), embedding_dim)).astype(np.float32) embeddings[0] = np.zeros(embedding_dim, dtype=np.float32) hit_count = 0 with open(glove_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) != embedding_dim + 1: continue word = parts[0] idx = vocab.get(word) if idx is not None: embeddings[idx] = np.asarray(parts[1:], dtype=np.float32) hit_count += 1 print(f'GloVe 命中率: {hit_count}/{len(vocab)}') return torch.tensor(embeddings)

注意hit_count只统计词表里能匹配上的词,命中率通常应该到 70% 以上。如果命中率太低,说明词表和 GloVe 文件的词汇来源差异太大,比如文本里有大量数字、特殊符号、缩写,这种情况下要么扩大词表,要么换一个更匹配的预训练向量版本。加载好之后用nn.Embedding.from_pretrained(embeddings, freeze=True)创建 embedding 层,freeze=True 表示训练时不更新词向量,适合数据量小的场景。

2.4 DataLoader:一条样本从文本到 Tensor 的完整流程

数据处理的最后一步是把变长文本统一转成固定长度的 ID 序列,再打包成 batch。这里我习惯写一个 Dataset 类,把分词、映射、padding 都收进去:

import torch from torch.utils.data import Dataset class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): 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): tokens = self.texts[idx].strip().split() ids = [self.vocab.get(t, self.vocab['<unk>']) for t in tokens[:self.max_len]] # 长度不足 max_len 时补 <pad> ids = ids + [self.vocab['<pad>']] * (self.max_len - len(ids)) return torch.tensor(ids, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long)

ids = ids + [self.vocab['<pad>']] * (self.max_len - len(ids))这行有个隐性假设:句子长度不超过 max_len,因为前面已经用tokens[:self.max_len]截断了。超过部分直接丢弃是短文本分类的常见做法,长文本场景则要考虑截断策略,比如保留开头和结尾各一段。DataLoader 再加batch_size=64, shuffle=True就能喂给模型了。

3. textCNN 实现:一维卷积在文本上是怎么工作的

3.1 为什么 CNN 能做文本分类:一维卷积当作 N-gram 过滤器

CNN 最初是为图像设计的,但把图像里的二维卷积换成一维卷积,就能直接用在文本上。核心思路是:embedding 层把每个词变成一个向量,一维卷积核在序列长度方向上滑动,每次覆盖连续 k 个词的向量,等价于抽取一个 k-gram 的局部特征。

比如 kernel_size=3,卷积核每次看到的是第 i 个词到第 i+2 个词的向量拼接,权重矩阵对这 3 个词做加权求和,得到的结果可以理解成这个局部片段在某个特征维度上的得分。多个不同的卷积核就对应不同的特征模式,有的关注否定词组合,有的关注形容词搭配。相比全连接网络,卷积核在序列上平移共享权重,参数数量小得多,训练更快,也不容易过拟合。

textCNN 的经典结构就三层:embedding、一维卷积+池化、全连接分类。它不会像 RNN 那样按时间步逐步读入句子,而是并行扫描整个序列,所以训练速度远快于 RNN。实践里对句子级情感分析、新闻标题分类这种短文本任务,textCNN 用很小的代价就能达到接近 LSTM 的效果。

3.2 核心参数:kernel_size 和 num_filters 怎么选

textCNN 里最重要的超参数是卷积核尺寸。单个尺寸只能覆盖一种长度的局部片段,所以标准做法是用多个尺寸并行扫描。项目里 textCNN_model.py 的实现,我一般会按这个结构写:

import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, num_filters, kernel_sizes, num_classes, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) # 每个 kernel_size 对应一套独立的卷积层 self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embedding_dim, out_channels=num_filters, kernel_size=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): emb = self.embedding(x) # (batch, seq_len, embedding_dim) emb = emb.transpose(1, 2) # (batch, embedding_dim, seq_len) pooled = [] for conv in self.convs: c = torch.relu(conv(emb)) # (batch, num_filters, seq_len-k+1) p = c.max(dim=2)[0] # (batch, num_filters) pooled.append(p) out = torch.cat(pooled, dim=1) # (batch, num_filters * len(kernel_sizes)) out = self.dropout(out) return self.fc(out)

代码里关键点在emb.transpose(1, 2):nn.Conv1d的输入格式是(batch, channels, length),embedding 层输出的(batch, seq_len, embedding_dim)需要把 embedding_dim 换到 channel 维,seq_len 换到 length 维,卷积才能在时间维度上滑动。kernel_sizes传[2, 3, 4]是最常见的组合,分别对应二元、三元、四元词组的局部模式:2-gram 能抓短语搭配,3-gram 能抓动宾结构,4-gram 能抓更长的习惯表达。num_filters每个卷积核配 128 个,三个尺寸共 384 个特征,全连接层输入维度就是 384。

padding_idx=0在 embedding 里指定 0 号位置的向量不参与梯度更新,对应词表里的<pad>。这样 padding 区域不会产生无意义的梯度信号。max-pooling 取每个特征图上的最大值,保留了整条序列里最强烈的模式信号,这种强位置不变性对短文本分类很管用。

3.3 cnn_model_freeze 和 textCNN_model.py 有什么区别

项目 model 目录下有两个子目录:cnn_model_freeze 和 textCNN_model.py。从命名看,freeze 版本把 embedding 冻住,也就是加载 GloVe 后不再更新词向量,只用预训练的语义表示;textCNN_model.py 则是可训练版本,embedding 随训练一起优化。

这两个版本的选择逻辑很直接:训练数据少于几万条时,模型自己学 embedding 很容易过拟合,冻结词向量等于把语义知识锁死,让模型只学卷积层和分类层;数据量充足时,可训练版本能针对具体任务微调词向量,效果通常更好。实际操作中我会先把 freeze 版本跑通验证管线,再对比两个版本的验证集指标。

freeze 的实现方式就是在加载预训练向量后设置embedding.weight.requires_grad = False,或者用nn.Embedding.from_pretrained(embeddings, freeze=True)。值得留意的是,即便冻结了 embedding,训练时传入的 ID 仍然需要经过 embedding 层做查表,只是查出来的向量不回传梯度。

3.4 训练循环与过拟合监控

CNN 训练速度很快,但过拟合也很容易。监控过拟合最直接的做法是每个 epoch 在验证集上计算准确率和 loss,观察两者是否出现 divergence:

def evaluate(model, val_loader, criterion): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for inputs, labels in val_loader: logits = model(inputs) loss = criterion(logits, labels) total_loss += loss.item() * inputs.size(0) preds = logits.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total

如果训练 loss 持续下降但验证 loss 开始回升,说明模型开始背训练数据了。textCNN 的 dropout 一般设 0.5,如果过拟合明显就降 batch size 或者减少 num_filters。训练初期加一个「先跑 3 个 epoch 看 loss 有没有下降趋势」的检查,比盲目调参效率高很多。

4. textRNN 实现:LSTM 怎么处理序列依赖

4.1 从标准 RNN 到 LSTM:为什么必须引入门控机制

RNN 的核心是一个循环单元,每个时间步接收当前词的向量和上一个时间步的隐状态,输出新的隐状态。理论上它能记住整个历史信息,但当序列变长时,反向传播的梯度要沿着时间方向连乘,如果每一步的梯度范数小于 1,连乘后梯度会指数级衰减到零,模型完全学不到长距离依赖,这就是梯度消失问题。

LSTM 的解决方案是引入门控机制:遗忘门决定要丢掉多少旧信息,输入门决定新信息写多少进来,输出门决定当前时刻对外输出什么。门控让梯度在记忆单元上有一条相对畅通的通道,梯度可以跨多个时间步直接传播而不被逐次衰减。项目里的 textRNN 模型实际用的就是 LSTM,而不是标准 RNN 单元。

对文本分类来说,LSTM 的意义在于它能按顺序读完整个句子,把关键信息累积到最后一个时间步的隐状态里,再交给分类层。这种序列建模能力让它在需要考虑语序和长距离指代的任务上比 CNN 更有优势。

4.2 TextRNN 模型实现:hidden_size、num_layers、bidirectional 的真实含义

PyTorch 里直接用nn.LSTM就能完成全部序列计算,不需要手动写门控公式。模型结构比 textCNN 还简洁:

import torch.nn as nn class TextRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, num_classes, bidirectional=True, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(input_size=embedding_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=bidirectional, dropout=dropout if num_layers > 1 else 0) self.fc = nn.Linear(hidden_size * (2 if bidirectional else 1), num_classes) def forward(self, x): emb = self.embedding(x) # (batch, seq_len, embedding_dim) out, (h_n, c_n) = self.lstm(emb) # out: (batch, seq_len, hidden*2) if self.lstm.bidirectional: h_last = torch.cat([h_n[-2], h_n[-1]], dim=1) # (batch, hidden*2) else: h_last = h_n[-1] # (batch, hidden) return self.fc(h_last)

h_n的形状是(num_layers * 2, batch, hidden_size),其中双向时每层有两个方向的隐状态。取最后一个隐状态时,单向就取最后一层的h_n[-1],双向则要把正方向最后一个时刻和反方向最后一个时刻拼起来,对应h_n[-2]和h_n[-1]。hidden_size 取 128 到 256 比较常见,翻倍到 512 以上在小数据集上容易过拟合。num_layers 取 2 就够了,大于 3 层的收益很小,训练成本和过拟合风险却涨得很快。

bidirectional=True的直观解释是:正向 LSTM 从左往右读句子,反向 LSTM 从右往左读,拼接两者最后时刻的隐状态,让分类器同时看到「以当前词为结尾的上下文」和「以当前词为开头的上下文」两个方向的摘要。

4.3 CNN 和 RNN 在实践中的取舍

我实际跑过不少分类任务,经验是:短文本无脑先上 textCNN,长文本再用 textRNN 做对比。句子级情感分析、新闻标题分类这种 max_len 都在 100 以内的任务,textCNN 训练一轮的时间和 textRNN 相比几乎快一个数量级,效果却差不多。RNN 必须按时间步顺序计算,每一步依赖上一步的输出,并行度低,batch 内的长序列会让训练时间线性拉长。

反过来看,如果任务是长文档分类,比如整篇新闻正文几千词,CNN 的卷积核只能看到局部窗口,关键信息可能分散在文章各个段落,RNN 把全文档压缩成最后一个隐状态的方式能捕捉跨句关联,这时候 RNN 的理论上限更高。项目把两个模型都写了,正好可以分别跑一遍,在报告里做一个 CNN vs RNN 的对比实验,这也是毕设答辩时最容易加分的内容。

5. 避坑与排查:训练过程中最容易翻车的五个细节

写分类模型最耗时间的往往不是模型定义,而是看起来莫名其妙的问题。下面这些坑都是我实际踩过的,按「现象 → 原因 → 解决」列出来,对号入座能省很多时间。

5.1 验证集指标虚高,测试集却崩了

现象:验证集准确率 95%,测试集只有 60%,差距大到不像是正常波动。

原因:数据切分之前没有做去重。同一个文本内容在原始数据里出现多次,切分时一条进了训练集一条进了验证集,模型在训练时已经把这条文本背下来了,验证时再遇到它,指标当然好看。这种数据泄漏是 NLP 任务里最容易忽视的问题。解决:先对全部数据按文本内容做去重,再按类别比例切分,而不是先切分再去重。

df = df.drop_duplicates(subset='text', keep='first') # 然后再做 train/val 切分

如果去掉重复样本后数据量骤减,要检查是不是爬虫数据里混入了大量模板化文本,比如「查看更多」「点击进入」这类导航文字。这种情况要做更细的清洗,把模板片段过滤掉。

5.2 GloVe 加载时报 size mismatch

现象:用nn.Embedding.from_pretrained加载时报size mismatch for embedding.weight: copying a param with shape torch.Size([400000, 100]) from checkpoint, the shape in current model is torch.Size([50000, 100])。

原因:加载方式用错了。from_pretrained期望传入的是一个完整的 embedding 权重矩阵,而不是 GloVe 原始文件。报错通常是直接把 GloVe 的(400000, 100)矩阵塞给了词表大小为 50000 的模型。

解决:按 2.3 节的load_glove_embeddings函数,先构建一个形状为(len(vocab), embedding_dim)的矩阵,只拷贝词表中命中词的行,再传给from_pretrained。另外注意检查 vocab 里 padding_idx=0 对应行是零向量,不要给<pad>分配随机向量,否则 padding 区域会持续产生噪声梯度。

5.3 同一个代码,两次训练结果不一样

现象:固定了随机种子,重新运行训练,准确率还是差了 0.5 到 1 个百分点。

原因:PyTorch 默认的 cuDNN 会在卷积和 LSTM 里选用非确定性算法,同一个输入可能得到微小不同的浮点结果。另外只有torch.manual_seed不够,torch.cuda.manual_seed_all和 cuDNN 的两个开关也要一起设置,才能把算法固定成确定性的。

解决:训练脚本开头统一设置:

import random import numpy as np import torch def set_seed(seed=42): 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)

benchmark=False会关闭 cuDNN 的自动调优,速度略有下降,但换来了可复现性。做毕设实验时这一条尤其重要,不然每次跑出来的数字不一样,写报告的时候没法交代。

5.4 准确率 92% 但 F1 只有 0.3

现象:训练日志里准确率一路涨到 92%,但用 sklearn 里的classification_report一算,少数类的 F1 惨不忍睹。

原因:类别分布极不平衡。比如二分类任务里 92% 的样本属于类 A,模型把全部样本都预测成 A 就能拿到 92% 准确率。CNN 和 RNN 的默认交叉熵损失在这种数据下会被多数类主导,少数类几乎没有有效梯度。

解决:训练时给交叉熵加类别权重,推理时用 macro-F1 而不是 accuracy 作为主指标。具体做法:

from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight( 'balanced', classes=np.unique(labels), y=labels ) class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

加了权重之后少数类每个样本的 loss 会放大,模型被迫更认真地学它。如果加了权重还是上不去,说明少数类样本本身就不够,得考虑扩充数据而不是继续调模型。

5.5 max_len 设得太大,CNN 效果反而变差

现象:max_len 从 128 改成 512 后,准确率掉了 2 个百分点,训练时间还翻了三倍。

原因:textCNN 的卷积核只覆盖局部窗口,padding 到 512 的样本大部分区域是<pad>,卷积层在 padding 区域提取到的特征全是无效信号,max-pooling 还可能把噪声特征当成最强信号选出来。RNN 同样受影响,padding 区域占据大量时间步,隐状态在无意义的 pad 上反复更新,浪费计算且引入噪声。

解决:统计训练集所有样本的 token 长度,取 95 分位数作为 max_len,而不是取最大值。比如 95% 的样本都在 150 个 token 以内,max_len 定 160 就够,不需要覆盖那 5% 的长尾样本。尾部超长样本做截断处理,对整体指标影响很小。

6. 复现与验证:想让别人跑出一样的结果,这三件事必须做

6.1 固定随机种子和 cuDNN 设置

第 5 章已经给了set_seed的完整代码。真正实操时要注意一件事:光在脚本开头调用一次还不够,如果在训练中途切换了 PyTorch 版本或者换了 GPU 型号,之前的种子设置也要重新验证一遍。跨设备可复现性的前提是同一 PyTorch 版本、同一 CUDA 版本、同一 cuDNN 版本。写 README 时把这三个版本号写清楚,同学和老师复现时能少踩一大半坑。

6.2 评估脚本:只用 accuracy 会漏掉一半信息

验证阶段除了准确率,要跑一份完整的分类报告,含 precision、recall、macro-F1:

from sklearn.metrics import classification_report # 收集验证集所有预测和真实标签 all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: logits = model(inputs) preds = logits.argmax(dim=1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(classification_report(all_labels, all_preds, digits=4))

digits=4让报告里每个指标保留四位小数,写进毕设表格时不用再四舍五入一次。macro-F1 才是类别不均衡时真正值得汇报的指标,加了类别权重之后,两个模型的 macro-F1 对比才有意义。

6.3 超参备忘与训练曲线记录

超参数textCNN 推荐值textRNN 推荐值
embedding_dim100100
kernel_sizes / hidden_size[2, 3, 4]128
num_filters / num_layers1282
dropout0.50.5
batch_size6464
learning_rate1e-31e-3
max_len128128
epoch2020

训练时把每个 epoch 的 train loss、val loss、val accuracy 写到一个 csv 文件,后面画 loss 曲线和准确率曲线直接用这个文件。比只靠终端日志靠谱得多,也方便答辩时展示训练过程。这个项目里两个模型的训练入口分别是 CNN 和 RNN 目录下的 train.py,换数据集时只需要改 dataLoad.py 里的文件路径和类别数。

想起我自己第一次做文本分类毕设时,拿着加载好的 GloVe 就直接from_pretrained,报了一堆 size mismatch 才意识到要先建矩阵再拷贝。从那以后我每次复现代码,永远是先定数据去重、随机种子、评估指标这三件事,再动模型结构。数据、种子、指标三个东西定住了,剩下怎么改超参数都有据可依。希望这份项目笔记能帮你在毕设或者课程作业里少走一遍这些弯路。

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

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

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

立即咨询