基于TextCNN与TextRNN的文本分类实战:从数据到模型
简介这份资源是面向高校学生与深度学习入门者的文本分类项目实战包围绕卷积神经网络与循环神经网络两条技术路线展开可用于毕业设计、课程作业或NLP入门练习覆盖情感分析、新闻分类等典型场景。压缩包共30个文件约72.36MB以Python源码与编译文件为主辅以PyTorch模型权重、TSV格式数据集、Markdown说明文档及少量C相关模块整体按CNN与RNN两个子目录组织便于对照阅读。目前已有180人学习下载。项目内含数据加载、模型定义、训练与验证脚本以及预训练词向量与冻结模型等资源读者可据此复现文本预处理、模型搭建、参数调优与准确率、召回率、F1分数评估的完整流程并理解CNN局部特征提取与RNN长短期依赖建模的差异适合作为课程设计参考或进一步改进的基线方案。1. 拆开这个毕设包CNN 与 RNN 文本分类到底能跑出什么结果如果你正在做 NLP 方向的课程设计或毕设大概率绕不开「文本分类」这个任务。情感分析、新闻分类、垃圾邮件过滤本质上都是把一段文本映射到一个预定义类别。这个资源包给了一套完整的、基于深度学习的文本分类实现核心是两个模型TextCNN 和 TextRNN。它不是一个只贴了模型定义的半成品而是包含了数据加载、词向量加载、训练脚本、验证与测试数据的可运行工程。目录里能看到CNN和RNN两个独立文件夹各自有main.py、train.py、dataLoad.py和模型定义文件根目录下放着train.tsv、val_data.tsv、test_data.tsv以及glove词向量目录。适合谁适合已经学过深度学习基础、知道卷积和循环网络大概原理但还没亲手把一个文本分类任务从数据读到模型评估完整跑通的人。你不需要从零写数据管道但需要能看懂 Python 和 PyTorch 的基本操作。这个包最大的价值在于它把「文本怎么变成模型能吃的张量」「CNN 和 RNN 分别怎么处理变长序列」「训练循环里哪些参数真正影响收敛」这些工程细节摊开给你看。下面我会按实际复现顺序从数据格式、模型结构、训练配置到踩坑排查一层层拆。2. 数据管道与词向量加载从 tsv 到 embedding 张量的完整链路2.1 数据文件格式与字段含义包里的数据以.tsv格式存放分别是train.tsv、val_data.tsv、test_data.tsv另外还有一个train_data.tsv。tsv 是制表符分隔文件常见做法是每行一条样本第一列是标签第二列是文本。但不同来源的 tsv 列顺序可能不同有的把文本放第一列、标签放第二列。拿到手第一件事不是直接跑train.py而是先确认列顺序和标签分布。我一般会先用 pandas 快速看一眼import pandas as pd # 先读前5行确认列名和分隔符 df pd.read_csv(train.tsv, sep\t, headerNone, nrows5) print(df.head()) print(df.shape) # 统计标签分布判断是否类别不平衡 full pd.read_csv(train.tsv, sep\t, headerNone) print(full[0].value_counts())这段代码的逻辑很简单sep\t指定制表符分隔headerNone表示文件没有表头行。如果读出来第一列是数字且取值集中在小范围整数那第一列就是标签如果第一列是长文本那标签可能在第二列。value_counts()用来检查类别是否均衡如果某一类占了 90% 以上后续训练时 accuracy 会虚高需要看 F1 或者做重采样。参数上nrows5只是预览正式统计时要去掉。常见坑是文件编码不是 UTF-8读出来乱码这时加encodingutf-8或encodinggbk试一下。2.2 构建词表与加载 GloVe 词向量glove目录下放的是预训练词向量文件。GloVe 的常见格式是每行一个词加一串浮点数第一行可能是「词数 维度」的头部也可能没有。加载逻辑一般分三步读词向量文件构建词到向量的映射遍历训练集文本统计词频按词频保留前 N 个词给每个词分配一个整数 id同时生成 embedding 矩阵。下面是一个可抄的加载函数import numpy as np def load_glove(glove_path, embedding_dim100): 加载GloVe词向量返回词到向量的字典 embeddings {} with open(glove_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() # 跳过可能的头部行词数和维度 if len(parts) ! embedding_dim 1: continue word parts[0] vector np.array([float(x) for x in parts[1:]], dtypenp.float32) embeddings[word] vector return embeddings def build_vocab(texts, glove_embeddings, max_vocab20000): 根据训练文本构建词表返回word2id和embedding矩阵 from collections import Counter counter Counter() for text in texts: counter.update(text.split()) # 按词频排序只保留在GloVe中存在的词 words [w for w, _ in counter.most_common() if w in glove_embeddings] words words[:max_vocab - 2] # 预留PAD和UNK word2id {PAD: 0, UNK: 1} for i, w in enumerate(words): word2id[w] i 2 # 构建embedding矩阵 embedding_dim len(next(iter(glove_embeddings.values()))) matrix np.random.normal(0, 0.1, (len(word2id), embedding_dim)).astype(np.float32) matrix[0] np.zeros(embedding_dim) # PAD用零向量 for w, idx in word2id.items(): if w in glove_embeddings: matrix[idx] glove_embeddings[w] return word2id, matrix逻辑说明load_glove里用len(parts) ! embedding_dim 1来过滤头部行和异常行因为正常词向量行是「词 维度个浮点数」。build_vocab先统计词频只保留在 GloVe 中出现的词这样能避免大量低频词占用 embedding 矩阵。max_vocab默认 20000对于中小型文本分类任务够用太大反而增加过拟合风险。embedding 矩阵用随机正态初始化然后把 GloVe 中有的词覆盖进去没有的词保持随机。PAD 的 id 固定为 0向量设为零向量这样 padding 不会引入额外语义。参数上embedding_dim必须和 GloVe 文件实际维度一致常见有 50、100、200、300读之前先看一行确认。2.3 把文本转成定长张量padding 与截断策略CNN 和 RNN 对输入长度的处理方式不同。CNN 需要固定长度做卷积RNN 理论上可以处理变长但批量训练时同一 batch 内也要对齐。常见做法是设定一个max_seq_len超过的截断不足的补 PAD。这个值怎么定先统计训练集文本长度的分布lengths [len(text.split()) for text in texts] print(np.percentile(lengths, [50, 90, 95, 99]))如果 95 分位数是 80那max_seq_len设 100 左右比较合理既能覆盖绝大多数样本又不会让 padding 太多。设太大RNN 会浪费计算在 PAD 上设太小长文本信息丢失严重。转换函数如下def text_to_ids(text, word2id, max_seq_len): tokens text.split() ids [word2id.get(w, word2id[UNK]) for w in tokens] if len(ids) max_seq_len: ids ids[:max_seq_len] else: ids ids [word2id[PAD]] * (max_seq_len - len(ids)) return ids注意截断策略直接取前max_seq_len个词还是取头尾各一半对于情感分析结尾往往有总结性词汇取尾部可能更好对于新闻分类开头信息密度高取头部更合理。这个包里没有明确说明我一般会先按取头部跑一版再对比取尾部的结果。参数max_seq_len在 CNN 和 RNN 两个目录下可能各自定义改的时候要分别确认。3. TextCNN 模型拆解一维卷积核怎么在文本上做特征提取3.1 卷积核尺寸与通道数的选择逻辑TextCNN 的核心思想是用多个不同尺寸的一维卷积核在 embedding 序列上滑动每个卷积核捕捉一种局部 n-gram 模式。比如 kernel_size3 的卷积核相当于看三个连续词kernel_size5 相当于看五个连续词。模型定义通常在textCNN_model.py里结构大致是embedding 层 → 多个并行的一维卷积 → 全局最大池化 → 全连接 → 输出。下面是一个典型实现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, kernel_sizes[2, 3, 4], num_filters128, dropout0.5): super(TextCNN, self).__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 每个尺寸的卷积核各num_filters个 self.convs nn.ModuleList([ nn.Conv1d(in_channelsembedding_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, embedding_dim) emb emb.permute(0, 2, 1) # (batch, embedding_dim, seq_len) conv_outs [] for conv in self.convs: c F.relu(conv(emb)) # (batch, num_filters, seq_len - k 1) c F.max_pool1d(c, c.size(2)).squeeze(2) # (batch, num_filters) conv_outs.append(c) out torch.cat(conv_outs, dim1) # (batch, num_filters * len(kernel_sizes)) out self.dropout(out) return self.fc(out)逻辑说明nn.Conv1d的输入通道是 embedding 维度输出通道是num_filters卷积核在序列维度上滑动。permute是因为 PyTorch 的 Conv1d 要求通道在第二维。每个卷积后接 ReLU 和全局最大池化池化把每个卷积核的最强响应保留下来这样不同长度的文本经过池化后都变成固定长度的向量。最后把所有卷积核的输出拼接过 dropout 再进全连接。参数上kernel_sizes常见取 [2,3,4] 或 [3,4,5]覆盖二元到五元短语num_filters一般 64 到 256太小欠拟合太大过拟合且显存吃紧dropout在 0.3 到 0.5 之间调文本分类任务上 0.5 是常见起点。3.2 训练脚本关键参数与冻结 embedding 的取舍train.py里通常包含优化器、学习率、batch size、epoch 数等配置。CNN 目录下有一个cnn_model_freeze文件说明作者可能提供了冻结 embedding 的版本。冻结 embedding 意味着训练时不更新词向量只训练卷积层和全连接层。什么时候冻结当训练数据很少比如几千条而词向量是在大规模语料上预训练的时候冻结可以防止过拟合也能加快训练。什么时候不冻结数据量足够大几万条以上微调 embedding 能让词向量适配当前任务通常效果更好。我一般会先跑冻结版本作为 baseline再跑微调版本对比验证集 F1。学习率方面如果微调 embedding学习率要设小一些常见 1e-4 到 1e-3如果冻结学习率可以设 1e-3 到 1e-2。batch size 受显存限制TextCNN 本身参数量不大64 或 128 都可以。epoch 数看验证集损失什么时候不再下降通常 10 到 30 轮足够早停策略是验证集 F1 连续 3 轮不提升就停。3.3 从 main.py 看推理流程与评估指标main.py一般负责串联数据加载、模型初始化、训练和测试。推理阶段会把test_data.tsv读进来做同样的预处理然后逐 batch 送入模型取 argmax 得到预测类别。评估指标通常有 accuracy、precision、recall、F1。对于类别均衡的数据集accuracy 够用对于不平衡数据要看 macro-F1 或 weighted-F1。下面是一个评估片段from sklearn.metrics import classification_report model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in test_loader: logits model(batch_x) preds torch.argmax(logits, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, digits4))model.eval()会关闭 dropout 和 batch norm 的训练行为torch.no_grad()节省显存和计算。classification_report会输出每个类别的 precision、recall、F1 和支持数比只看 accuracy 更能发现问题。如果某个类别 F1 明显低可能是该类样本少或者文本特征不明显需要针对性分析。4. TextRNN 模型拆解LSTM 与 GRU 在长文本上的表现差异4.1 RNN 单元的选型LSTM 还是 GRURNN 目录下的textRNN_model.py定义了循环网络结构。标准 RNN 在长序列上会有梯度消失问题所以实际项目里几乎都用 LSTM 或 GRU。LSTM 有三个门输入门、遗忘门、输出门参数量比 GRU 多但表达能力强一些GRU 只有两个门重置门、更新门参数少、训练快在中小规模数据上往往和 LSTM 效果相当。这个包里具体用的是哪种需要打开模型文件确认。如果是 LSTM关注hidden_size和num_layers如果是 GRU同样关注这两个参数。下面是一个双向 LSTM 的实现class TextRNN(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, num_layers1, dropout0.5, bidirectionalTrue): super(TextRNN, self).__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalbidirectional, dropoutdropout if num_layers 1 else 0) self.dropout nn.Dropout(dropout) direction 2 if bidirectional else 1 self.fc nn.Linear(hidden_size * direction, num_classes) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embedding_dim) out, (h, c) self.lstm(emb) # out: (batch, seq_len, hidden*direction) # 取最后一个时间步的输出 out out[:, -1, :] # (batch, hidden*direction) out self.dropout(out) return self.fc(out)逻辑说明batch_firstTrue让输入维度是 (batch, seq_len, embedding_dim)。双向 LSTM 会分别从前往后和从后往前处理序列然后把两个方向的最后隐状态拼接。取out[:, -1, :]是取最后一个时间步对于双向来说这个位置包含了前向的最后状态和后向的最后状态。如果文本末尾有 PAD直接取最后一步会取到 PAD 对应的输出更好的做法是用 mask 取实际最后一个非 PAD 位置。参数上hidden_size常见 128 或 256num_layers1 到 2 层足够再深容易过拟合且训练慢。dropout在多层 LSTM 中才生效单层时设了也没用。4.2 变长序列的 mask 处理与最后时间步选取RNN 和 CNN 最大的不同是它对序列顺序敏感PAD 位置如果参与计算会影响隐状态。虽然 LSTM 的门控机制能一定程度上忽略无关输入但更严谨的做法是传入lengths给pack_padded_sequence。下面是一个带 mask 的取最后有效步的写法def forward(self, x, lengths): emb self.embedding(x) packed nn.utils.rnn.pack_padded_sequence(emb, lengths, batch_firstTrue, enforce_sortedFalse) out, (h, c) self.lstm(packed) out, _ nn.utils.rnn.pad_packed_sequence(out, batch_firstTrue) # 取每个样本最后一个有效时间步 idx (lengths - 1).view(-1, 1).expand(out.size(0), out.size(2)).unsqueeze(1) last_out out.gather(1, idx).squeeze(1) last_out self.dropout(last_out) return self.fc(last_out)pack_padded_sequence会跳过 PAD 位置enforce_sortedFalse允许 batch 内序列不按长度排序。pad_packed_sequence把输出还原成 padded 形式。gather根据每个样本的实际长度取最后一个有效时间步的输出。这个细节在短文本分类上影响可能不大但在长文本或者 PAD 比例高的时候能明显提升效果。如果嫌麻烦至少要在取最后一步之前确认 batch 内没有全 PAD 的样本。4.3 训练配置差异RNN 比 CNN 更容易过拟合吗从经验上看RNN 在文本分类任务上比 CNN 更容易过拟合尤其是数据量不大时。原因在于 LSTM 参数量更大且对序列顺序敏感容易记住训练集中的噪声模式。应对手段有几个增大 dropout尤其是 embedding 层和 LSTM 层之间可以加 dropout减小 hidden_size从 256 降到 128 甚至 64使用双向 LSTM 时两个方向的输出拼接后维度翻倍全连接层输入变大可以加一层降维。学习率方面RNN 对学习率更敏感太大容易梯度爆炸常见做法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)这行放在loss.backward()之后、optimizer.step()之前。max_norm设 5 或 10 都可以作用是限制梯度范数防止更新步长过大。如果训练过程中 loss 突然变成 nan大概率是梯度爆炸先加裁剪再调小学习率。5. 避坑与排查跑通这个包最容易翻车的五个地方5.1 现象运行 main.py 报「FileNotFoundError: train.tsv」原因脚本里的文件路径是相对路径而你的工作目录不是包根目录。比如你在CNN文件夹下运行python main.py但train.tsv在上一级目录。解决要么cd到包根目录再运行要么把脚本里的路径改成绝对路径或../train.tsv。我一般会在dataLoad.py开头加一行import os; os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))把工作目录强制切到包根目录这样不管从哪运行都不会找不到文件。5.2 现象GloVe 加载后词表为空embedding 矩阵全是随机值原因GloVe 文件的维度参数和代码里写的embedding_dim不一致。比如文件是 300 维代码里写 100len(parts) ! embedding_dim 1会把所有行都过滤掉。解决先打开 GloVe 文件看第一行有效数据的列数减一就是维度。或者把过滤条件改成len(parts) 2然后在构建矩阵时动态取维度。另外注意 GloVe 文件可能有头部行「400000 300」这种行会被过滤掉不影响。5.3 现象训练 loss 不下降accuracy 一直在随机水平原因可能有三个学习率太大导致震荡或者太小导致几乎不更新标签和文本列搞反了模型在学噪声embedding 没有正确加载所有词都是随机向量且冻结了。排查顺序先打印一个 batch 的输入和标签确认文本转 id 后不是全 0 或全 1再检查学习率CNN 用 1e-3 试RNN 用 1e-4 试最后确认 embedding 矩阵是否被正确赋值可以打印matrix[word2id[the]]看是不是零向量。5.4 现象验证集 F1 比训练集低很多过拟合明显原因模型参数量相对数据量太大或者训练轮数太多。解决先加 dropoutCNN 的 dropout 从 0.5 提到 0.6RNN 在 embedding 后和 LSTM 后都加 dropout再减小模型CNN 的num_filters从 128 降到 64RNN 的hidden_size从 256 降到 128最后加早停验证集 F1 连续 3 轮不提升就停。如果数据量实在少考虑冻结 embedding 或者用预训练模型提取特征后接一个简单分类器。5.5 现象测试集评估时显存溢出CUDA out of memory原因测试时没有用torch.no_grad()或者 batch size 设得和训练时一样大但测试数据更长。解决评估代码外面包with torch.no_grad():并且把测试的 batch size 减半。如果还是溢出检查max_seq_len是不是设得过大测试集里有没有超长文本。另外RNN 的pack_padded_sequence在测试时也要用否则 PAD 位置也会参与计算浪费显存。6. 进阶技巧用混淆矩阵和错误样本定位模型短板跑通训练和测试只是第一步真正让这个毕设出彩的是能说清楚模型在哪些类别上容易混淆、为什么混淆。我一般会在测试集评估后加一段混淆矩阵和错误样本分析。先看代码import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix import seaborn as sns cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.title(Confusion Matrix) plt.savefig(confusion_matrix.png, dpi150) plt.close() # 找出预测错误的样本 wrong_idx [i for i, (p, l) in enumerate(zip(all_preds, all_labels)) if p ! l] for i in wrong_idx[:10]: print(fTrue: {all_labels[i]}, Pred: {all_preds[i]}) print(fText: {test_texts[i][:200]}) print(---)混淆矩阵能直观看到哪两个类别互相误判最多。比如情感分析里「中性」和「正面」容易混新闻分类里「体育」和「娱乐」容易混。找到混淆对之后把错误样本的原文打印出来看通常能发现标注噪声、反讽表达、或者类别定义边界模糊。这些分析写进毕设报告里比只报一个 accuracy 有说服力得多。另一个技巧是对比 CNN 和 RNN 的错误样本重叠度。如果两个模型在同一批样本上都错说明这些样本本身难分类或者标注有问题如果一个模型错另一个对说明两个模型的归纳偏置不同可以考虑集成。集成方法很简单把两个模型对每个类别的 softmax 概率平均再取 argmax。代码上就是加载两个模型分别推理然后(prob_cnn prob_rnn) / 2。我试过在几个文本分类任务上CNN 和 RNN 集成的 F1 比单模型高 1 到 3 个点代价是推理时间翻倍。最后说一个我踩过的坑这个包里的train_data.tsv和train.tsv可能是不同版本的数据一个用于调试一个用于正式训练。跑之前先对比两个文件的行数和标签分布确认用哪个。从那以后我每次拿到新数据包都强制先跑一遍wc -l和标签统计确认数据版本一致再开始训练。希望帮到你。本文还有配套的精品资源点击获取