多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深度学习文本分类实战:从数据预处理到模型部署全解析

深度学习文本分类实战:从数据预处理到模型部署全解析 简介面向希望入门自然语言处理的学生和开发者这份资源基于深度学习完成文本分类任务覆盖从文本预处理、词嵌入到模型构建、训练与评估的完整项目流程。压缩包共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 venvWindows 下同理只是路径分隔符不同。装依赖时注意 PyTorch 的版本要和本机 CUDA 匹配如果requirements.txt里写的是torch1.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 不会出现在预测数据里直接删掉反而会切断语义。常见做法是保留业务中出现频率最高的那部分符号其余统一替换。分词策略直接决定词表大小和 OOVout-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), HMMTrue) if w.strip()] df pd.read_csv(data/raw.csv, header0) df[title] df[title].apply(tokenize) # 按标签分层划分保证训练/验证集的类别分布一致 X_train, X_val, y_train, y_val train_test_split( df[title], df[label], test_size0.2, stratifydf[label], random_state42 )这段代码里最容易忽略的是stratify参数。如果原始数据里正样本只占 10%不做分层抽样训练集可能只有 5% 的正样本验证集却有 20%模型训练时看到的正样本分布波动很大直接表现为 F1 分数忽高忽低。random_state42是固定随机种子方便复现——同一份数据每次跑出来的数应该完全一致不一致说明有隐含的随机因素没被固定。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: i2 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_lenMAX_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, dtypetorch.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_size64, shuffleTrue)这里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_dim128, num_filters256, filter_sizes(3, 4, 5), num_classes10, dropout0.5, pad_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpad_idx) self.convs nn.ModuleList() for size in filter_sizes: # 每个卷积核尺寸对应一个 Conv2d 分支 self.convs.append(nn.Conv2d( in_channels1, out_channelsnum_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, dim1) out self.dropout(out) return self.fc(out)这段实现里有三处边界细节值得说明。embedding指定了padding_idx0PAD 位置在反向传播时梯度恒为 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_classeslen(set(y_train))) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-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(fepoch {epoch1}, 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, dim1) all_preds.extend(preds.tolist()) all_labels.extend(batch_y.tolist()) f1 f1_score(all_labels, all_preds, averagemacro) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) print(fbest macro F1 updated: {f1:.4f})4.3 早停与模型保存让训练跑得稳早停的 patience 值取 3 到 5。文本分类的验证 F1 曲线波动比较大patience 太小容易误停太大浪费算力。配合 ReduceLROnPlateau 学习率调度效果更好——验证 F1 连续 2 轮不升就降学习率连续 4 轮不升就提前停。学习率调度器的参数设置要谨慎。modemax表示监控验证 F1 这样的指标越大越好factor0.5表示学习率减半patience2表示容忍 2 轮不提升才降。这套组合在大多数文本分类工程里都能让训练过程稳定收敛。5. 避坑文本分类最常见的 5 个翻车现场5.1 中文编码与分词不一致现象训练时 loss 正常下降但验证集的 F1 骤降或者同一句话预测结果和训练时完全不一致还伴随乱码。原因训练数据读进来的时候是 GBK 编码预处理脚本里用的是 UTF-8分词结果全是错乱的「锟斤拷」。另一个常见原因是训练时 jieba 用的是默认词典推理时加载了业务词典两次分出来的词序列不一致模型看到的输入完全不同。解决在数据处理入口统一声明编码pd.read_csv(..., encodingutf-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 memorybatch 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, dtypetorch.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% 以下。这种问题根本查不出来只能整体回滚。从那以后我把分词逻辑找到的每个版本都单独归档绝不因为「只是改个词典」就放任不管。希望这个习惯对你有帮助。本文还有配套的精品资源点击获取
返回列表