简介:本资源为面向高校学生与深度学习入门者的文本分类实战项目包,围绕卷积神经网络与循环神经网络两条技术路线展开,可用于毕业设计、课程作业及NLP入门练习,覆盖情感分析、新闻分类等典型场景。压缩包共30个文件,约72.36MB,以Python源码与编译缓存为主,辅以PyTorch模型权重文件、TSV格式数据集、Markdown说明文档及少量配置文件,CNN与RNN两套代码分目录组织,便于对照阅读。项目包含数据加载、模型定义、训练与验证等完整模块,并配有预训练词向量与已训练模型,读者可据此复现文本分类流程,理解一维卷积提取局部特征、LSTM/GRU捕捉长距离依赖的实现细节,同时参考评估指标与调参思路。目前已有180人学习下载,适合希望快速搭建可运行基线并深入理解模型原理的读者。
1. 拆开这个毕设包:CNN 与 RNN 文本分类到底能跑出什么结果
带过几届毕设之后,我对「基于深度学习的文本分类」这类题目的判断标准变得很直接:能不能在半小时内把数据喂进去、把模型跑起来、把准确率打印出来。这个压缩包给的就是这样一套东西——CNN 和 RNN 两条独立实现路线,各自带train.py、dataLoad.py、模型定义文件和main.py,数据是train.tsv、val_data.tsv、test_data.tsv三件套,外加一份 GloVe 词向量目录。它解决的不是「从零教你深度学习」,而是「你已经有 PyTorch 基础,现在需要一个能改、能跑、能写进论文的实验骨架」。适合谁?正在做文本分类毕设、课程设计,或者想拿一个干净的双模型对比基线去改造成自己课题的人。不适合指望开箱即出 SOTA 的人,因为这套代码的定位是教学级复现,不是工业级调优。
2. 先看清目录结构:CNN 与 RNN 两条线是怎么拆的
2.1 两个模型目录的职责划分
把压缩包解开,根目录下最显眼的是CNN和RNN两个文件夹,外加一个glove目录和若干 tsv 数据文件。这种拆法在毕设里很常见,好处是两条实验线互不干扰,坏处是公共逻辑被复制了两份,改数据预处理时容易只改一边。
先看RNN目录,里面是main.py、textRNN_model.py、dataLoad.py、train.py,还有一个model子目录用来存训练好的权重。CNN目录结构基本对称:main.py、dataLoad.py、train.py、textCNN_model.py,另外多了一个cnn_model_freeze和split.py。split.py的存在说明数据划分可能是脚本生成的,cnn_model_freeze大概率是冻结部分层做微调的实验产物。
dataLoad.py在两个目录里各有一份,负责把 tsv 读成张量、建词表、做 padding。train.py是训练循环,main.py是入口。这种「入口 + 数据 + 模型 + 训练」四文件结构,是 PyTorch 教学项目的标准骨架,读起来不费劲。
2.2 数据文件与 GloVe 词向量的配合方式
根目录的train.tsv、val_data.tsv、test_data.tsv、train_data.tsv四个文件,命名上有点乱:train.tsv和train_data.tsv同时存在,需要打开看才知道哪个是原始、哪个是切分后的。常见做法是train.tsv是完整训练集,train_data.tsv是切分脚本产出的训练子集,val_data.tsv和test_data.tsv是验证和测试。
tsv 格式意味着每行是「标签 + 制表符 + 文本」,这是文本分类最省事的存储方式,不用额外解析 JSON 或 CSV 引号。glove目录放的是预训练词向量,dataLoad.py里应该有加载逻辑,把词表里的词映射到 GloVe 向量作为 embedding 初始化。这一步是 CNN 和 RNN 共享的关键,词向量质量直接影响小数据集上的收敛速度。
提示:先确认
glove目录里到底是glove.6B.100d.txt这类完整文件,还是已经转成.npy的矩阵。前者加载慢但通用,后者快但和词表绑定,换数据集要重新生成。
2.3 环境依赖与版本判断
代码里没有requirements.txt,这是毕设包的典型特征。从textRNN_model.py和textCNN_model.py的写法能反推依赖:torch、torch.nn、numpy,可能还有sklearn做指标计算。判断 PyTorch 版本有个土办法——看模型里用的是nn.Embedding还是nn.EmbeddingBag,看训练循环里是loss.backward()还是scaler.scale(loss).backward()。前者说明是常规训练,后者说明用了混合精度。
我一般会先建一个干净环境,装 PyTorch 2.x 加 numpy,然后直接跑main.py,报什么错补什么。比对着代码猜版本快得多。如果报ModuleNotFoundError: No module named 'torch',那就是没装;如果报RuntimeError: Expected all tensors to be on the same device,那是设备不一致,和版本无关。
3. 把数据喂进模型:dataLoad.py 里的词表与 padding 逻辑
3.1 读 tsv、建词表、转索引的完整链路
文本分类的第一步永远是把人看的字变成模型看的数。dataLoad.py干的就是这件事,链路是:读 tsv → 分词 → 统计词频 → 建词表 → 转索引 → padding。下面是我按这套代码的常见写法还原的核心逻辑,你可以对照自己的dataLoad.py看差异在哪。
import torch from torch.utils.data import Dataset, DataLoader from collections import Counter def load_tsv(path): labels, texts = [], [] with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue parts = line.split('\t') # 约定第一列是标签,第二列是文本 labels.append(int(parts[0])) texts.append(parts[1]) return labels, texts def build_vocab(texts, min_freq=2, max_size=20000): counter = Counter() for text in texts: # 中文按字切,英文按空格切,这里按项目实际分词方式调整 counter.update(text.split()) # 保留频率达标的词,按频次降序截断 words = [w for w, c in counter.most_common(max_size) if c >= min_freq] # 0 给 padding,1 给未知词 vocab = {'<pad>': 0, '<unk>': 1} for w in words: vocab[w] = len(vocab) return vocab class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=128): 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].split() ids = [self.vocab.get(t, self.vocab['<unk>']) for t in tokens] # 截断或补齐到固定长度 if len(ids) > self.max_len: ids = ids[:self.max_len] else: ids = ids + [self.vocab['<pad>']] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx])逻辑说明:load_tsv按制表符切分,第一列标签第二列文本,这是 tsv 的通用约定。build_vocab用Counter统计词频,min_freq=2过滤只出现一次的词,max_size=20000控制词表上限,避免 embedding 层过大。TextDataset把每条文本转成等长索引序列,max_len=128是截断长度,短补长截。
参数说明:min_freq调大能减少词表噪声但会增加<unk>,中文任务一般设 1 到 2;max_len要看数据里文本长度的分布,取 95 分位数比较稳,设太小会丢信息,设太大浪费显存。<pad>必须占 0 号位,因为后面nn.Embedding的padding_idx=0要靠它。
3.2 用 GloVe 初始化 embedding 矩阵
词表建好后,embedding 层不该从随机数开始训,尤其是数据量不大的毕设场景。glove目录就是干这个的。加载逻辑通常长这样:
import numpy as np def load_glove(glove_path, vocab, embed_dim=100): # 先建一个随机初始化的矩阵,行数等于词表大小 embedding_matrix = np.random.normal(0, 0.1, (len(vocab), embed_dim)) # padding 位置强制为 0 embedding_matrix[vocab['<pad>']] = 0 hit = 0 with open(glove_path, 'r', encoding='utf-8') as f: for line in f: parts = line.rstrip().split(' ') word = parts[0] if word in vocab: vec = np.asarray(parts[1:], dtype='float32') # 维度对不上就跳过,防止报错 if vec.shape[0] == embed_dim: embedding_matrix[vocab[word]] = vec hit += 1 print(f'GloVe 命中 {hit} / {len(vocab)}') return embedding_matrix逻辑说明:先随机初始化整个矩阵,再把 GloVe 里能对上的词覆盖进去。命中率打印出来很关键,如果只有 30% 命中,说明分词方式和 GloVe 的词表对不上,比如中文没分词直接按字切,而 GloVe 是英文词级。
参数说明:embed_dim必须和 GloVe 文件维度一致,glove.6B.100d.txt就是 100,glove.6B.300d.txt就是 300。维度不一致时vec.shape[0] == embed_dim会拦住,不会静默出错。命中率低于 50% 时,预训练词向量的收益就很有限了,不如直接随机初始化。
3.3 DataLoader 的 batch 与 shuffle 设置
数据封装好后,用DataLoader分批。训练集要shuffle=True,验证和测试集shuffle=False,这是铁律。batch_size在文本分类里一般设 32 或 64,显存小就 16。num_workers在 Windows 上设 0 避免多进程报错,Linux 上可以设 2 到 4 加速。
train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=0, drop_last=True # 丢掉最后一个不满的 batch,避免 BN 层报错 )drop_last=True是个容易被忽略的点。如果模型里用了 BatchNorm,最后一个 batch 只有一条样本时,BN 会因方差为 0 报错。文本分类的 CNN 常用 BN,所以这个参数建议加上。
4. 两条模型线怎么跑:textCNN 与 textRNN 的训练入口
4.1 textCNN 的卷积核设计与前向传播
textCNN_model.py的核心是一维卷积。文本被表示成[batch, seq_len, embed_dim]后,卷积核在 seq_len 方向滑动,每个核捕捉不同长度的 n-gram 特征。典型配置是三种核尺寸 2、3、4,每种 128 个,卷积后做最大池化,拼起来过全连接。
import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes=(2, 3, 4), num_filters=128, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 每个核尺寸对应一个卷积层 self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embed_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): # x: [batch, seq_len] emb = self.embedding(x) # [batch, seq_len, embed_dim] emb = emb.permute(0, 2, 1) # [batch, embed_dim, seq_len] # 每个卷积层输出做 ReLU 后全局最大池化 pooled = [] for conv in self.convs: c = F.relu(conv(emb)) # [batch, num_filters, L] p = F.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] pooled.append(p) out = torch.cat(pooled, dim=1) # [batch, num_filters * len(kernels)] out = self.dropout(out) return self.fc(out)逻辑说明:permute(0, 2, 1)把维度从[batch, seq_len, embed_dim]换成[batch, embed_dim, seq_len],因为nn.Conv1d要求通道在第二维。每个卷积核在整条序列上滑动,max_pool1d取每个通道的最大值,相当于「这条序列里有没有出现这个特征」,位置不重要。最后拼接所有核的输出过全连接。
参数说明:kernel_sizes=(2,3,4)对应二元、三元、四元词组,中文短文本分类这个范围够用。num_filters=128是每个核的输出通道数,调大增加容量但容易过拟合。dropout=0.5在全连接前,是防过拟合的主要手段。padding_idx=0让 padding 位置的 embedding 不参与梯度更新。
4.2 textRNN 的 LSTM 结构与最后时间步取法
textRNN_model.py用的是 LSTM 或 GRU。和 CNN 不同,RNN 按时间步顺序处理,最后取最后一个有效时间步的隐藏状态,或者对所有时间步做平均池化。取最后时间步时要注意 padding 的影响,最好用pack_padded_sequence处理变长序列。
class TextRNN(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, num_layers=1, dropout=0.5, bidirectional=True): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( input_size=embed_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=bidirectional, dropout=dropout 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, embed_dim] out, (h, c) = self.lstm(emb) # out: [batch, seq_len, hidden*factor] # 取最后一个时间步的输出 last = out[:, -1, :] last = self.dropout(last) return self.fc(last)逻辑说明:batch_first=True让输入输出第一维是 batch,符合直觉。双向 LSTM 把正向和反向的最后隐藏状态拼起来,factor=2。out[:, -1, :]取最后一个时间步,但如果序列有 padding,这个位置可能是 pad,更严谨的做法是用pack_padded_sequence。
参数说明:hidden_dim一般设 128 或 256,太大容易过拟合。num_layers=1对毕设够用,加到 2 层时dropout才生效。bidirectional=True在分类任务里通常比单向好,因为分类看的是整句而不是预测下一个词。
4.3 train.py 的训练循环与验证指标
train.py是两条线共用的训练骨架,差异只在模型实例化。核心循环是:前向 → 算 loss → 反向 → 更新 → 验证。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0, 0, 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() # 梯度裁剪,防止 RNN 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * x.size(0) pred = logits.argmax(dim=1) correct += (pred == y).sum().item() total += x.size(0) return total_loss / total, correct / total逻辑说明:optimizer.zero_grad()清空上一步梯度,loss.backward()反向传播,clip_grad_norm_裁剪梯度范数,optimizer.step()更新参数。准确率用argmax取预测类别后和标签比对。
参数说明:max_norm=5.0是梯度裁剪阈值,RNN 尤其需要,CNN 可以放宽或去掉。优化器常用 Adam,学习率 1e-3;如果 loss 震荡,降到 1e-4。验证时记得model.eval()加torch.no_grad(),否则 dropout 和 BN 行为不一致,指标会偏低。
5. 避坑与排查:这套代码最容易翻车的五个地方
5.1 现象:loss 一直是 nan,训练几步就崩
原因:学习率太大,或者 GloVe 加载时维度没对齐导致 embedding 出现异常值,也可能是 tsv 里有空行让标签解析成 NaN。
解决:先把学习率降到 1e-4 试;在load_glove里打印命中率和矩阵的np.isnan检查;读 tsv 时加if not line: continue跳过空行。RNN 还要确认梯度裁剪生效。
5.2 现象:验证集准确率远高于训练集
原因:验证集太小,或者验证集和训练集有重叠样本。毕设里常见的是split.py切分时没打乱,导致同一类样本集中在一段。
解决:检查split.py是否先random.shuffle再切。验证集至少占总数据 10%,且类别分布要和训练集接近。如果数据本身只有几百条,交叉验证比固定切分更靠谱。
5.3 现象:CNN 跑得动,RNN 报显存不足
原因:RNN 的max_len设得太大,或者hidden_dim过大。LSTM 的显存占用和seq_len × hidden_dim × num_layers成正比,比 CNN 敏感。
解决:把max_len从 256 降到 128,hidden_dim从 256 降到 128,batch_size从 64 降到 32。如果还不行,用pack_padded_sequence按实际长度处理,能省不少显存。
5.4 现象:GloVe 命中率极低,预训练等于没加
原因:分词方式和 GloVe 词表不匹配。中文按字切,GloVe 里全是英文词;或者大小写没统一,The和the被当成两个词。
解决:英文任务统一转小写再查词表;中文任务要么换中文预训练词向量,要么接受低命中率直接用随机初始化。命中率低于 30% 时,预训练词向量的收益基本被噪声抵消。
5.5 现象:测试集准确率比验证集低一大截
原因:在验证集上反复调参,相当于把验证集当训练集用了,模型对验证集过拟合。毕设里为了刷高数字反复调,很容易掉进这个坑。
解决:验证集只用来选模型和早停,最终指标以测试集为准。如果测试集和验证集差距超过 5 个点,说明调参过头了,回到默认超参重新训一遍更诚实。
6. 把双模型对比做成能写进论文的实验
跑通单模型只是起点,这个包真正的价值在于 CNN 和 RNN 两条线可以横向对比。我一般会固定数据切分和随机种子,让两个模型在完全相同的训练集、验证集上跑,然后记录三组数:验证集准确率曲线、测试集准确率、每轮训练耗时。下面这个记录表是我习惯用的格式,你可以直接抄。
| 模型 | 词向量 | max_len | batch_size | lr | 验证准确率 | 测试准确率 | 单轮耗时 |
|---|---|---|---|---|---|---|---|
| TextCNN | GloVe 100d | 128 | 64 | 1e-3 | 待填 | 待填 | 待填 |
| TextRNN | GloVe 100d | 128 | 32 | 1e-3 | 待填 | 待填 | 待填 |
填这张表时有个细节:两个模型的batch_size如果不同,单轮耗时不可比,要么统一 batch_size,要么按「每条样本耗时」换算。我一般统一到 32,牺牲一点 CNN 的速度换可比性。
进阶玩法是冻结 embedding 层做对比。CNN目录里那个cnn_model_freeze就是干这个的——把embedding.weight.requires_grad设成False,只训卷积和全连接。冻结后训练更快,小数据集上反而不容易过拟合,但数据量大时效果不如微调。这个对比做出来,论文里「预训练词向量微调策略的影响」这一节就有实打实的数据了。
# 冻结 embedding 的写法 model.embedding.weight.requires_grad = False # 优化器只传需要梯度的参数 optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3 )最后说个我自己的习惯:每次改完超参,先把随机种子固定死,torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三行都写上,再跑。不固定种子的话,同一份代码两次跑出来的准确率能差三四个点,你根本分不清是改动生效了还是玄学波动。从那以后我每次做对比实验都强制走一遍种子固定,不然调参就是自欺欺人。希望帮到你。
本文还有配套的精品资源,点击获取