尧图精选

基于CNN与RNN的文本分类毕设实战:从数据加载到模型对比

🕒 发布时间:2026/10/1 6:10:38 📁 来源:尧图网络
简介本资源为面向高校学生与深度学习入门者的文本分类实战项目包围绕卷积神经网络与循环神经网络两条技术路线展开可用于毕业设计、课程作业及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, encodingutf-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_freq2, max_size20000): 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 给 padding1 给未知词 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_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): 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_freq2过滤只出现一次的词max_size20000控制词表上限避免 embedding 层过大。TextDataset把每条文本转成等长索引序列max_len128是截断长度短补长截。参数说明min_freq调大能减少词表噪声但会增加unk中文任务一般设 1 到 2max_len要看数据里文本长度的分布取 95 分位数比较稳设太小会丢信息设太大浪费显存。pad必须占 0 号位因为后面nn.Embedding的padding_idx0要靠它。3.2 用 GloVe 初始化 embedding 矩阵词表建好后embedding 层不该从随机数开始训尤其是数据量不大的毕设场景。glove目录就是干这个的。加载逻辑通常长这样import numpy as np def load_glove(glove_path, vocab, embed_dim100): # 先建一个随机初始化的矩阵行数等于词表大小 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, encodingutf-8) as f: for line in f: parts line.rstrip().split( ) word parts[0] if word in vocab: vec np.asarray(parts[1:], dtypefloat32) # 维度对不上就跳过防止报错 if vec.shape[0] embed_dim: embedding_matrix[vocab[word]] vec hit 1 print(fGloVe 命中 {hit} / {len(vocab)}) return embedding_matrix逻辑说明先随机初始化整个矩阵再把 GloVe 里能对上的词覆盖进去。命中率打印出来很关键如果只有 30% 命中说明分词方式和 GloVe 的词表对不上比如中文没分词直接按字切而 GloVe 是英文词级。参数说明embed_dim必须和 GloVe 文件维度一致glove.6B.100d.txt就是 100glove.6B.300d.txt就是 300。维度不一致时vec.shape[0] embed_dim会拦住不会静默出错。命中率低于 50% 时预训练词向量的收益就很有限了不如直接随机初始化。3.3 DataLoader 的 batch 与 shuffle 设置数据封装好后用DataLoader分批。训练集要shuffleTrue验证和测试集shuffleFalse这是铁律。batch_size在文本分类里一般设 32 或 64显存小就 16。num_workers在 Windows 上设 0 避免多进程报错Linux 上可以设 2 到 4 加速。train_loader DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers0, drop_lastTrue # 丢掉最后一个不满的 batch避免 BN 层报错 )drop_lastTrue是个容易被忽略的点。如果模型里用了 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_filters128, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 每个核尺寸对应一个卷积层 self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek) 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, dim1) # [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_filters128是每个核的输出通道数调大增加容量但容易过拟合。dropout0.5在全连接前是防过拟合的主要手段。padding_idx0让 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_layers1, dropout0.5, bidirectionalTrue): super().__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) 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_firstTrue让输入输出第一维是 batch符合直觉。双向 LSTM 把正向和反向的最后隐藏状态拼起来factor2。out[:, -1, :]取最后一个时间步但如果序列有 padding这个位置可能是 pad更严谨的做法是用pack_padded_sequence。参数说明hidden_dim一般设 128 或 256太大容易过拟合。num_layers1对毕设够用加到 2 层时dropout才生效。bidirectionalTrue在分类任务里通常比单向好因为分类看的是整句而不是预测下一个词。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_norm5.0) optimizer.step() total_loss loss.item() * x.size(0) pred logits.argmax(dim1) 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_norm5.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 降到 128hidden_dim从 256 降到 128batch_size从 64 降到 32。如果还不行用pack_padded_sequence按实际长度处理能省不少显存。5.4 现象GloVe 命中率极低预训练等于没加原因分词方式和 GloVe 词表不匹配。中文按字切GloVe 里全是英文词或者大小写没统一The和the被当成两个词。解决英文任务统一转小写再查词表中文任务要么换中文预训练词向量要么接受低命中率直接用随机初始化。命中率低于 30% 时预训练词向量的收益基本被噪声抵消。5.5 现象测试集准确率比验证集低一大截原因在验证集上反复调参相当于把验证集当训练集用了模型对验证集过拟合。毕设里为了刷高数字反复调很容易掉进这个坑。解决验证集只用来选模型和早停最终指标以测试集为准。如果测试集和验证集差距超过 5 个点说明调参过头了回到默认超参重新训一遍更诚实。6. 把双模型对比做成能写进论文的实验跑通单模型只是起点这个包真正的价值在于 CNN 和 RNN 两条线可以横向对比。我一般会固定数据切分和随机种子让两个模型在完全相同的训练集、验证集上跑然后记录三组数验证集准确率曲线、测试集准确率、每轮训练耗时。下面这个记录表是我习惯用的格式你可以直接抄。模型词向量max_lenbatch_sizelr验证准确率测试准确率单轮耗时TextCNNGloVe 100d128641e-3待填待填待填TextRNNGloVe 100d128321e-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()), lr1e-3 )最后说个我自己的习惯每次改完超参先把随机种子固定死torch.manual_seed(42)、np.random.seed(42)、random.seed(42)三行都写上再跑。不固定种子的话同一份代码两次跑出来的准确率能差三四个点你根本分不清是改动生效了还是玄学波动。从那以后我每次做对比实验都强制走一遍种子固定不然调参就是自欺欺人。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →