简介:面向希望入门自然语言处理的学生和开发者,这份资源基于深度学习完成文本分类任务,覆盖从文本预处理、词嵌入到模型构建、训练与评估的完整项目流程。压缩包共6个文件,全部为Python脚本,整体仅11KB,分别承担数据加载、TextCNN/TextRNN模型定义、训练与预测等功能,代码结构紧凑,适合配合入门教程边读边练。资源已有224人学习,借助训练与预测脚本可直接对比卷积网络与循环网络在文本分类中的表现,并快速验证新样本。项目实践中的预处理环节涉及分词、去停用词等操作,模型训练则采用交叉熵损失与准确率、F1等评估指标,代码可作为理解反向传播、梯度下降与注意力机制的直观示例。除基础模型外,资源还涵盖预训练模型(如BERT)的微调思路,有助于进一步扩展实验,适合作为课程作业、入门实践或论文复现的参考基础。
1. 这个 zip 里装的不是代码,是一整套文本分类的落地思路
搜索「基于深度学习的文本分类.zip」的人,大多不是来找论文的,而是想看看能不能直接解压、跑通、改改参数用在自己的场景里。这份压缩包如果整理得规范,里面应该是一个完整工程:训练脚本、模型定义、数据集样本、README 和依赖清单。深度学习文本分类的入门门槛不在模型本身,而在数据预处理、训练流程和踩坑排查这三件事上,恰好这些也是 zip 里最容易乱的部分。这篇笔记按「包有什么 → 数据怎么预处理 → 模型怎么选怎么练 → 参数怎么调 → 坑在哪 → 怎么落地部署」的顺序,把整个链路拆开讲,适合准备做舆情分类、工单自动打标、评论情感判定的从业者照着复现。
2. 从压缩包到能跑的模型:环境准备与数据预处理
2.1 解压后的目录结构:先确认包里有什么再动手
拿到 zip 后第一步不是急着跑训练脚本,而是把目录结构摸清楚。常见的深度学习文本分类项目会分成这几个部分:data/放原始数据和预处理脚本,models/放网络结构定义,train.py是训练入口,predict.py是推理入口,requirements.txt列依赖库。有的包还会带config.py或config.yaml,所有超参数集中在这里,方便调参。
先看依赖清单是不是完整。比较省事的做法是建一个干净的虚拟环境再安装依赖,避免把系统 Python 环境搞乱。在 Linux 或 macOS 下用python3 -m venv,Windows 下同理,只是路径分隔符不同。装依赖时注意 PyTorch 的版本要和本机 CUDA 匹配,如果requirements.txt里写的是torch==1.13.1,但你的显卡驱动只支持 CUDA 11.7,那就要手动指定对应版本,否则后面训练时会报 CUDA 不可用的错。
# 创建虚拟环境并激活(Windows 去掉 source 前缀) python3 -m venv text_cls_env source text_cls_env/bin/activate pip install -r requirements.txt提示:如果
requirements.txt不存在,先装上五个基础库再逐个补:numpy、pandas、scikit-learn、torch、transformers。跑起来缺什么补什么,比一次性装全更省心。
依赖装完后,先跑一句python -c "import torch; print(torch.__version__)"确认 PyTorch 能正常导入。很多所谓的「环境问题」其实只是 conda 和 venv 的 Python 路径互相干扰,进入虚拟环境后用which python看一眼解释器路径是不是在虚拟环境里。
2.2 数据预处理:清洗、分词、标签编码与训练/验证集划分
文本分类的数据预处理决定了模型上限。即便是同一个模型,预处理方式不同,效果能差出三到五个百分点。核心步骤是四件事:清洗、分词、标签编码、数据集划分。
清洗这步,中英文场景差别很大。英文要处理大小写和词形还原,中文则需要考虑繁简转换和全半角归一。比较普适的是把 URL、邮箱、连续数字替换成特殊占位符,因为这些 token 不会出现在预测数据里,直接删掉反而会切断语义。常见做法是保留业务中出现频率最高的那部分符号,其余统一替换。
分词策略直接决定词表大小和 OOV(out-of-vocabulary,未登录词)比例。中文用jieba是默认选择,但词典需要按业务定制。做财经舆情分类时,「降准」「北向资金」这种词 jieba 默认词库里没有,就会被拆成「降」和「准」,模型学到的是错误语义。建议在用 jieba 分词前,把业务词表加载进去,并且开启HMM参数来处理新词发现。
import jieba import pandas as pd from sklearn.model_selection import train_test_split jieba.set_dictionary('data/jieba_dict.txt') jieba.load_userdict('data/biz_words.txt') # 业务词表 def clean_text(text): """清洗:去HTML标签、统一空白、保留中文英文数字""" import re text = re.sub(r'<.*?>', '', text) text = re.sub(r'\s+', ' ', text) text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text) return text.strip() def tokenize(text): return [w for w in jieba.cut(clean_text(text), HMM=True) if w.strip()] df = pd.read_csv('data/raw.csv', header=0) df['title'] = df['title'].apply(tokenize) # 按标签分层划分,保证训练/验证集的类别分布一致 X_train, X_val, y_train, y_val = train_test_split( df['title'], df['label'], test_size=0.2, stratify=df['label'], random_state=42 )这段代码里最容易忽略的是stratify参数。如果原始数据里正样本只占 10%,不做分层抽样,训练集可能只有 5% 的正样本,验证集却有 20%,模型训练时看到的正样本分布波动很大,直接表现为 F1 分数忽高忽低。random_state=42是固定随机种子,方便复现——同一份数据每次跑出来的数应该完全一致,不一致说明有隐含的随机因素没被固定。
2.3 词向量与序列填充:模型输入前最后一步
分词完成后,下一步是构建词表并把文本转成索引序列。这个环节有三个关键决策:词表大小上限、序列最大长度、OOV 词的处理策略。
词表上限通常设在 5 万到 10 万之间。词表设太大,低频词太多,模型会在训练时严重过拟合这些出现一两次的词;设太小,OOV 比例升高,句子信息损失严重。比较稳妥的做法是统计训练集词频,保留出现次数 top 5 万的词,词频为 1 的词直接标为 OOV。
序列长度选择上,既看业务也看模型。做短文本分类(标题、评论),长度在 64 到 128 之间就覆盖了绝大多数场景。做长文本分类(工单描述、公告全文),可以到 512。但序列变长,TextCNN 和 Transformer 的显存占用会呈线性到平方级增长,不要一上来就设 512,先用 128 跑通,看长度分布再调。
from collections import Counter import torch from torch.utils.data import Dataset, DataLoader MAX_VOCAB_SIZE = 50000 MAX_SEQ_LEN = 128 def build_vocab(tokenized_texts): freq = Counter(w for tokens in tokenized_texts for w in tokens) vocab = {w: i+2 for i, (w, c) in enumerate(freq.most_common(MAX_VOCAB_SIZE))} vocab['<PAD>'] = 0 vocab['<UNK>'] = 1 return vocab def encode(tokens, vocab): return [vocab.get(w, vocab['<UNK>']) for w in tokens] def pad_sequence(ids, max_len=MAX_SEQ_LEN): if len(ids) >= max_len: return ids[:max_len] return ids + [0] * (max_len - len(ids)) class TextDataset(Dataset): def __init__(self, tokenized_texts, labels, vocab): self.data = [torch.tensor(pad_sequence(encode(t, vocab))) for t in tokenized_texts] self.labels = torch.tensor(labels, dtype=torch.long) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx], self.labels[idx] vocab = build_vocab(X_train) train_ds = TextDataset(X_train.tolist(), y_train.tolist(), vocab) val_ds = TextDataset(X_val.tolist(), y_val.tolist(), vocab) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True)这里<PAD>和<UNK>分别占了索引 0 和 1,从 2 开始才是真正的词索引。有个别实现会从 1 开始编号,把 0 留给 PAD,但 UNK 就没位置了,会导致 OOV 词直接被编码成 PAD,模型学不到「这个词不在词表里」这个信号。处理类别时用torch.long保证和 CrossEntropyLoss 的输入类型匹配。
注意:
build_vocab只应该在训练集上执行。如果先在整个数据集上建词表再做划分,词表里会混入验证集和测试集的词频信息,这就是典型的数据泄露,后面会专门展开讲。
3. 模型选型:TextCNN、RNN 还是 BERT,你的数据量说了算
3.1 三种模型的原理差异和适用场景
文本分类的模型选型,本质是「你有多少数据」和「你的推理延迟预算多少」之间的权衡。TextCNN 用多个尺寸的卷积核提取 n-gram 级别的局部特征,计算量小、训练快,适合数据量在几万到几十万条、延迟要求高的场景。RNN(包括 LSTM、GRU)按时间步处理序列,能建模长距离依赖,但训练速度慢且难以并行,适合序列长度较长且数据量中等的场景。BERT 这类预训练模型通过大规模语料预训练获得语义表示,在小样本(几千条)条件下表现远好于前两者,但推理慢、显存占用高。
有个反直觉的结论:数据量超过 50 万条时,TextCNN 微调后的效果不一定比 BERT 差多少。因为 CNN 的归纳偏置是局部窗口,对词序不敏感,但从数据中学习的效率更高。数据量小的时候,预训练模型大幅领先,因为你没有足够的数据让模型从零学会语义。
3.2 用 PyTorch 实现一个 TextCNN 基线
TextCNN 是最适合做基线的模型。结构清晰、训练快、调参空间明确。核心思路是用多个不同宽度的卷积核并行提取 n-gram 特征,然后做全局池化,接全连接层分类。
import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=256, filter_sizes=(3, 4, 5), num_classes=10, dropout=0.5, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx) self.convs = nn.ModuleList() for size in filter_sizes: # 每个卷积核尺寸对应一个 Conv2d 分支 self.convs.append(nn.Conv2d( in_channels=1, out_channels=num_filters, kernel_size=(size, embed_dim) )) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) emb = emb.unsqueeze(1) # 加通道维,变成 (batch, 1, seq_len, embed_dim) conv_outputs = [] for conv in self.convs: c = conv(emb) # (batch, num_filters, conv_seq_len, 1) c = F.relu(c).squeeze(3) # 去掉末尾的 1 c = F.max_pool1d(c, c.size(2)).squeeze(2) # 全局最大池化 conv_outputs.append(c) out = torch.cat(conv_outputs, dim=1) out = self.dropout(out) return self.fc(out)这段实现里有三处边界细节值得说明。embedding指定了padding_idx=0,PAD 位置在反向传播时梯度恒为 0,不会参与词向量更新;如果不设置这个参数,PAD 词会被模型当成真实的共同上下文学到离谱的噪声。kernel_size=(size, embed_dim)里第二个维度必须等于词向量维度,因为卷积核要在整个 embedding 宽度上滑动,不能只覆盖部分维度。max_pool1d对每个卷积输出取最大值,得到固定长度的向量,这样不管输入序列多长,全连接层的输入维度都是确定的。
3.3 模型训练循环:损失函数、优化器与学习率
文本分类的损失函数用交叉熵即可,但要注意三个细节。第一是类别权重,如果数据集类别不平衡,给少数类更高的权重能显著提升 F1;第二是标签平滑,文本分类很容易过拟合,标签平滑让模型不那么自信,训练更稳;第三是优化器选择,AdamW 比 Adam 多了权重衰减的修正,微调 BERT 和从头训练 CNN 都推荐用 AdamW。
import torch.optim as optim from torch.nn import CrossEntropyLoss model = TextCNN(len(vocab), num_classes=len(set(y_train))) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = CrossEntropyLoss() model.train() for epoch in range(10): total_loss = 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪 optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1}, loss: {total_loss/len(train_loader):.4f}")clip_grad_norm_是训练稳定性法宝。文本分类里最容易出现 loss 突然变成 NaN 的情况,大部分原因是某个 batch 里出现极端长的序列,梯度范数爆掉。裁剪到 1.0 之后,梯度方向不变但长度受限,loss 曲线平滑很多。学习率的选择上,从头训练的模型可以用 1e-3 起手,BERT 微调则必须降到 2e-5 到 5e-5 之间——预训练模型的参数已经很好了,学习率太大会直接把语义表示冲坏。
4. 训练参数怎么设:从过拟合到欠拟合的排查路径
4.1 必调的五个参数及其合理范围
文本分类真正影响结果的参数其实只有五个:embedding 维度、卷积核数量(或隐层维度)、dropout 率、学习率、batch size。embedding 维度一般取 128 或 256,太小语义表示能力不足,太大带来过拟合和显存开销但收益递减。conv 核数量取 256 是常见值,和 filter_sizes 的三个尺寸配合后,全连接层输入是 768 维,这个宽度足够表达但不会过度参化。
dropout 通常 0.3 到 0.6 之间。数据量大可以降到 0.3,数据量小应该升到 0.5 以上。一个比较实用的判断方法:看训练集和验证集的 loss 曲线。验证集 loss 开始回升而训练集还在下降,说明过拟合,加大 dropout 或减小模型复杂度;两边都高说明欠拟合,增大 embedding 维度或加深网络。
batch size 对最终效果影响不大,但影响训练效率和显存占用。batch size 翻倍,学习率也应翻倍(linear scaling rule),这是实践中经常忽略的细节。如果 batch size 从 32 调到 128 但学习率还是原来的值,收敛会变慢且不稳定。
4.2 评估指标:准确率不够用,F1 才是真尺子
文本分类里只打印准确率是自欺欺人。当数据集中 90% 是负样本时,全部预测为负类,准确率就是 90%,但这个模型毫无价值。真正的评估指标要看 macro F1 和每个类别的 precision / recall。macro F1 对类别不平衡更敏感,能反映模型在少数类上的表现。
训练代码里需要添加验证逻辑,每轮训练结束后在验证集上计算 F1,同时记录最佳模型状态。这里最容易踩的坑是「模型截止」的时机。很多实现会在验证 F1 连续 N 轮不提升时保存模型,但最佳验证 F1 可能出现在第 3 轮,之后一直在过拟合。要在训练过程中保存 F1 最高的那一次 checkpoint,而不是最后一步的模型。
from sklearn.metrics import f1_score, classification_report best_f1 = 0.0 model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: logits = model(batch_x) preds = torch.argmax(logits, dim=1) all_preds.extend(preds.tolist()) all_labels.extend(batch_y.tolist()) f1 = f1_score(all_labels, all_preds, average='macro') if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), 'best_model.pt') print(f"best macro F1 updated: {f1:.4f}")4.3 早停与模型保存:让训练跑得稳
早停的 patience 值取 3 到 5。文本分类的验证 F1 曲线波动比较大,patience 太小容易误停,太大浪费算力。配合 ReduceLROnPlateau 学习率调度,效果更好——验证 F1 连续 2 轮不升就降学习率,连续 4 轮不升就提前停。
学习率调度器的参数设置要谨慎。mode='max'表示监控验证 F1 这样的指标,越大越好;factor=0.5表示学习率减半;patience=2表示容忍 2 轮不提升才降。这套组合在大多数文本分类工程里都能让训练过程稳定收敛。
5. 避坑:文本分类最常见的 5 个翻车现场
5.1 中文编码与分词不一致
现象:训练时 loss 正常下降,但验证集的 F1 骤降;或者同一句话预测结果和训练时完全不一致,还伴随乱码。
原因:训练数据读进来的时候是 GBK 编码,预处理脚本里用的是 UTF-8,分词结果全是错乱的「锟斤拷」。另一个常见原因是训练时 jieba 用的是默认词典,推理时加载了业务词典,两次分出来的词序列不一致,模型看到的输入完全不同。
解决:在数据处理入口统一声明编码,pd.read_csv(..., encoding='utf-8')并在读入后做一次标准化;把 jieba 的词典和 tokenize 逻辑封装成一个单独的模块,训练和推理都调用同一份代码,永远不要在两处各写一份。
5.2 数据泄露:标签混进了特征
现象:训练集 F1 高达 0.99,验证集也不错,但上线后效果崩盘。
原因:预处理时在整份数据集上做了 fit(比如 build_vocab),验证集和测试集的词频信息通过词表泄露给了模型。更隐蔽的情况是清洗过程中把标签字段当作特征输入了——有些文本数据本身包含「已投诉」「已解决」等业务状态字段,这些字段和标签高度相关,但线上预测时这些字段根本不存在。
解决:先划分数据集再做任何统计类操作。清理字段时明确区分输入特征和标签列,建议写死列名清单,防止后续迭代时新增字段被误当成特征。验证方法是把训练好的模型拿来预测训练集样本,如果 F1 接近 1.0,多半有特征泄露。
5.3 类别极端不平衡:模型全猜多数类
现象:训练 loss 还在下降,但少数类的 recall 是 0,精确率也没意义。
原因:CrossEntropyLoss 默认给每个类别相同的权重,模型学到的最优策略是全猜数量最多的那个类别。工单分类里「咨询」类占 85%,「投诉」类占 3%,模型直接把所有样本都预测为「咨询」,宏观 F1 惨不忍睹。
解决:给 CrossEntropyLoss 传入类别权重。权重比例按样本数倒数算,比如多数类权重为 1,少数类权重为多数类样本数除以少数类样本数。另一个思路是用 Focal Loss,让模型把注意力集中在难分类的样本上,但要注意超参数调优成本。
5.4 解压失败或文件损坏
现象:解压时提示 CRC 校验失败、文件缺失,或者 README 里提到的文件在目录里找不到。
原因:zip 文件在传输过程中被截断,或者用的是非标准压缩工具产生兼容问题。「zip 伪加密」也会导致解压异常——文件的加密标志位被改动,但实际内容并未加密。
解决:先验证文件完整性。对比压缩包附带的 MD5 或 SHA256 校验值,如果哈希对不上就重新下载;用 7-Zip 打开,看文件列表是否能正常预览。伪加密的情况可以先查看压缩包详情,确认加密标志位;如果确实无法解压,直接放弃这个文件,向作者索要重新打包的版本。
5.5 显存不足与 OOM
现象:训练跑到第 3 个 epoch 突然报CUDA out of memory,batch size 从 64 降到 32 还是爆。
原因:绝大多数情况是序列太长。TextCNN 输入是三维张量,batch size 乘以序列长度乘以词向量维度再乘参数规模决定显存占用。如果数据里存在几千字的超长文本,padding 到统一长度后整批样本大多是无意义的 PAD 填充。
解决:先看训练数据的序列长度分布,用pd.Series([len(t) for t in X_train]).describe()统计 p95 和 p99 长度,把 max_seq_len 设在 p95 附近即可,没必要覆盖 p100。另一个办法是梯度累积:小 batch 多次前向传播,积累梯度后统一做一次反向更新,效果等效于大 batch,显存却小得多。
6. 进阶:从单模型到可部署的文本分类系统
6.1 用 ONNX 导出模型做推理验证
模型在你本地跑得好,不代表能顺利进服务。PyTorch 的推理链路依赖 Python 运行时,部署环境未必装得了整套依赖。通常做法是把模型导出为 ONNX 格式,用 ONNX Runtime 做推理,进一步还可以转成 TensorRT 在 GPU 上提速。
import torch.onnx import onnxruntime as ort model.load_state_dict(torch.load('best_model.pt')) model.eval() dummy_input = torch.zeros(1, 128, dtype=torch.long) torch.onnx.export( model, dummy_input, 'text_cnn.onnx', input_names=['input_ids'], output_names=['logits'], dynamic_axes={'input_ids': {0: 'batch'}, 'logits': {0: 'batch'}} ) ort_session = ort.InferenceSession('text_cnn.onnx') result = ort_session.run( ['logits'], {'input_ids': dummy_input.numpy()} ) print(result[0].shape)导出 ONNX 时最关键的参数是dynamic_axes。如果不设置这个参数,导出的模型会固化为固定的 batch size,线上接口一次只能预测一条样本或固定条数,很不灵活。设置 batch 为动态后,任意批量都能跑。导出的模型数值和 PyTorch 原模型可能有一点点浮点误差,需要准备几个真实样本对比两者的 softmax 输出差异,偏差在 1e-4 以内就能放心上线。
6.2 增量训练与版本管理
模型上线后最大的问题是数据漂移。线上真实文本的风格和训练集存在分布差异,刚开始效果还行,跑一两个月后准确率逐周下降。应对方法是建立回流机制:把线上预测置信度低于阈值的样本积累下来,人工标注后再做增量训练。
增量训练不是简单地在原模型上继续跑几个 epoch,那样很容易灾难性遗忘。常见做法是降低新数据的学习率——原模型所有参数用一个很小的学习率,比如 5e-5,新数据上只训练 3 到 5 个 epoch,然后用验证集检验原类别和新类别的 F1 变化。如果旧类别 F1 下降超过 2 个百分点,说明学习率太大或新数据占比失衡。我一般会给旧样本保留部分采样,新数据按召回策略重采样后混合训练,这样两个分布都能兼顾。
模型版本管理上,固定打包输入预处理逻辑、词表、模型权重三个文件为一个版本号,文本分类系统的线上问题排查非常依赖版本可回滚。曾经在生产环境踩过一次坑:新版本改了 jieba 词典,导致线上分词结果变了,老版本的模型输入分布错乱,准确率掉到 30% 以下。这种问题根本查不出来,只能整体回滚。从那以后我把分词逻辑找到的每个版本都单独归档,绝不因为「只是改个词典」就放任不管。希望这个习惯对你有帮助。
本文还有配套的精品资源,点击获取