1. 深度学习文本分类:从问题定义到方案选型
做文本分类这几年,我最大的感受是:这个任务入门容易,做好很难。你以为拿个预训练模型跑一跑就能交差,但真正上线之后,准确率掉点、数据倾斜、标签不均衡、预测延迟超标……各种问题全冒出来了。所以这篇文章我想从实际问题出发,把深度学习文本分类的完整链路拆开讲清楚,包括数据怎么处理、模型怎么选、训练怎么调、坑怎么避,尽量给出一套可以直接参考的落地思路。
先说文本分类是什么。它就是把一段自然语言文本映射到一个或多个预先定义的类别标签上,比如情感分类(好评/差评)、主题分类(体育/财经/娱乐)、意图识别(订机票/查天气/投诉)、垃圾信息识别等等。深度学习在这里面干的事,就是用神经网络自动学习文本中的模式和特征,替代传统的人工特征工程加分类器的做法。
适合读这篇文章的人,包括刚接触NLP的算法工程师、做数据挖掘但想切换技术栈的同学,以及需要把分类模型落地到业务系统里的后端开发。我会尽量少用教科书式的表述,多用实际踩坑的经验来讲。
在动手之前,先搞清楚几个核心选型问题:
1.1 什么时候该用深度学习,什么时候该用传统方法
很多人一上来就直接上BERT,但实际业务里未必每个场景都适合深度学习。我的判断标准有三个:
- 数据量。深度学习尤其是预训练模型,在小数据上确实能靠迁移学习苟住,但如果只有几百条标注样本,传统方法加正则表达式往往更稳,迭代也快。
- 数据分布稳定性。如果分类对象的表达方式变化很频繁,比如电商商品短标题,传统词向量加分类器可能要频繁重训,而深度学习至少在语义泛化上更好一些。
- 计算资源和延迟约束。纯CPU推理环境下,BERT-base跑一次前向可能几十毫秒到几百毫秒,如果是高并发场景,可能根本扛不住,这时候要么蒸馏,要么换轻量模型。
我见过一个典型的项目:某个舆情系统的短文本分类,一开始工程师用BERT-base,单次预测40毫秒,压测时每秒100个请求就把CPU打满,后来换成了textCNN加蒸馏后的embedding,准确率只掉了1.2个百分点,延迟降到8毫秒。这个案例说明,选型一定要结合部署环境,不能只看榜单分数。
1.2 完整链路的总览
整个文本分类流程可以分成六个环节:
- 数据采集与标注:明确分类体系、收集原始文本、设计标注规范。
- 数据清洗与预处理:去噪、去重、分词、构建词汇表。
- 特征表示与Embedding:从词向量到预训练模型的向量化。
- 模型构建与训练:选择网络结构、设计损失函数、调参。
- 评估与调优:用准确率、召回率、F1等指标衡量,分析错误样本。
- 部署与监控:导出模型、上线推理、持续收集badcase。
这篇文章会依次走一遍,但重点放在第2到第5步,因为这部分是决定模型精度上限的关键,也是我自己被坑最多的地方。
2. 数据准备与预处理:决定分类精度的隐形天花板
很多初学者以为模型结构是决定精度的核心,但实际上在工业界,数据的质量和处理方式对最终指标的贡献往往超过模型本身。有人开玩笑说“垃圾进,垃圾出”,现实中是真的。一次我在做一个商品评论分类项目,初期谁都没有做规范处理,直接用原始文本训练了一个BERT模型,F1只有0.78。后来把文本规整、标签修正、样本去重这些环节做好,同样的模型F1直接提到0.87。所以这章值得花大篇幅讲。
2.1 分类体系设计是第一步,别急着找模型
分类体系决定了问题的复杂度。你需要明确是单标签还是多标签,层级分类还是平铺分类。这里有一个非常容易犯的错误:类别数量设计不均衡。比如我见过一个工单分类项目,初期定义了80个类别,其中超过50个类别的样本量不足100条,模型根本学不好;另外有几个大类动不动上万条。这种极不均衡的情况,即使模型再强也很难兼顾。
我的建议是,在设计分类体系前,先抽样统计一遍数据的分布:对每条样本打初始标签,然后统计频次。如果一个类别的样本占比不到整体数据的1%,要么合并到相近类别,要么降到二级类别处理,除非这个类别的要求是在低资源下也要识别,那就要专门做数据增强和少样本学习。
举个例子,在新闻主题分类里,“科技”和“数码”很容易混淆。如果标注人员没有明确的判定规则,同一个句子“苹果发布新款手机”可能被不同标注员标成不同类。所以分类体系不只是定义标签名,还要写清楚判定边界。可以整理一份标注手册,包含正例、反例、边界情况。别嫌麻烦,这套手册后面评估badcase时也会用到。
2.2 文本清洗与去噪:不是洗得越干净越好
文本分类的输入中经常包含各种噪音,比如HTML标签、URL、特殊符号、@用户、emoji等。在预处理阶段,一般要把这些噪音处理掉。但这里有个坑:不要把所有特殊符号都删掉。某些文本中,符号本身有含义。比如“哈哈哈”后面的感叹号数量可能表达强烈情绪,股票评论中的“$”后面跟股票代码也是一种特殊模式。
我的做法是分场景接收化处理。举个例子:
- 如果是评论情感分类,保留标点符号和表情,因为emoji和连续标点对情感判断有帮助。
- 如果是新闻标题分类,直接删掉所有HTML标签、URL和@提及,因为标题中的这些信息几乎没有语义价值。
具体操作可以分这几个步骤:
- 统一文本编码为UTF-8。
- 去掉HTML标签,用正则表达式匹配
<[^>]+>并替换为空格。 - 去掉无意义的URL、邮箱、电话号码,换成特殊占位符,比如
[URL]、[EMAIL]。这里要注意,用占位符而不是直接删掉,是为了保留位置信息,让模型知道这个位置出现过某种实体,这有时候会产生帮助。 - 处理重复标点:连续三个以上的感叹号或者问号,压缩成一个,但保留感叹号和问号的类型。
- 全角半角转换,统一数字格式。
- 对于中文,还需要处理繁体转简体。
如果你用的是预训练模型,比如BERT,那么这些清洗操作不宜过度,因为BERT本身的tokenizer能处理很多字符。但在训练词向量或者textCNN时,清洗就更为重要。
2.3 分词、Tokenization与词汇表构建
中文文本分类绕不开的一个话题是分词。如果用传统词向量方式,一般用某个分词工具把句子切分成词序列,然后映射到词索引。而如果直接用预训练模型(如BERT),一般用WordPiece或者BPE子词分词,不需要中文分词器,模型内部会把中文切分成字或者词片段。
我自己在实际项目里,两种模式的做法都经历过,这里给出经验总结:
| 模式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 传统分词+Word2Vec/Glove | 可解释性较强,模型训练快 | 无法解决未登录词,词边界误差会传播 | 小数据、CPU训练、对可解释性有要求 |
| 预训练模型(BERT类) | 子词建模,不需要显式分词,泛化能力强 | 计算量大,显存消耗高 | 数据量大、GPU资源充足、追求精度 |
| 字级别建模(字向量) | 避免分词错误,容易上手 | 丢失词的组合信息 | 短文本、计算资源有限 |
这里的“字级别建模”,其实很多比赛里的“甜腻”模型也用,因为中文里单字也有一定语义。但纯字级别没有词的边界信息,所以长文本效果往往不如词级别或子词级别。
构建词汇表时要注意两点:一是设置最小频次阈值,比如词频低于2或者3的词直接丢弃,不然词汇表很大,训练数据稀疏;二是保留特殊的token,比如<PAD>、<UNK>、<CLS>、<SEP>,模型处理需要这些特殊标记。
2.4 类别不平衡问题:不能只用准确率
文本分类的常见老问题是类别不平衡。比如垃圾邮件识别的正样本可能只有5%,或者某个细分类样本很少。这时候如果你直接优化准确率,模型会倾向于把所有样本都预测成大类,准确率却很高,但少数类完全废掉。
解决思路有几个:
- 数据层面:对少数类做复制、回译、同义词替换、EDA增强(随机替换、插入、交换等);对多数类做下采样,但要注意不要丢掉太多信息。
- 损失函数层面:使用加权交叉熵,按类别频率的倒数分配权重;或者用Focal Loss,让模型更关注难分类的样本。
- 评估层面:少看准确率,多看各类别的召回率和F1,尤其是宏平均F1。
我在一个工单分类项目中采用的方案是:先按类别数量设置样本权重,权重=总样本数/(类别数*该类样本数),然后对少数类额外做回译扩充。最终少数类的F1从0.52涨到了0.69,整体效果能看了。
2.5 构建训练集、验证集和测试集的坑
很多人习惯随机划分数据,但是在文本分类里,随机划分有一个隐蔽问题:如果同一用户或者同一来源的文本同时出现在训练集和测试集,模型在测试集上的成绩会很虚高。因为模型可能记住了用户习惯,而不是学到了一般规律。
在工业界更合理的划分方法是按照业务维度划分。比如按用户ID划分、按时间切分、按来源域名划分。这个思想在时间序列预测中很常见,但在文本分类中容易被忽略。
我经历过一个项目:训练集和测试集随机划分时,模型F1有0.92,后来改成按作者划分,F1立刻掉到0.83。这才发现模型压根是“抄作业”,遇到新用户就不行了。所以划分策略很重要,最好能模拟真实线上环境。
3. 模型选型与核心网络结构解析
到了模型阶段,深度学习文本分类主要有几条路线:用经典的textCNN、RNN/LSTM、HAN,还是直接用预训练语言模型,或者把CNN和注意力机制结合。下面我会结合实际业务场景分析每类模型的优缺点。
3.1 经典文本分类模型:textCNN与它的变体
textCNN是入门必学的模型,思路非常简单:用多个不同尺寸的卷积核在词向量序列上滑动,提取n-gram级别的局部特征,然后通过全局池化得到固定维度的句子表示,最后接全连接分类器。
我把一个textCNN的PyTorch实现结构写在这里(基于常见实践):
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, num_filters=100, filter_sizes=[2,3,4], dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (size, embed_dim)) for size in filter_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) self.relu = nn.ReLU() def forward(self, x): # x: [batch_size, seq_len] emb = self.embedding(x) # [batch, seq_len, embed_dim] emb = emb.unsqueeze(1) # [batch, 1, seq_len, embed_dim] convs = [] for conv in self.convs: c = self.relu(conv(emb)) # [batch, num_filters, conv_seq_len, 1] c = c.squeeze(3) c = F.max_pool1d(c, c.size(2)) # [batch, num_filters, 1] convs.append(c.squeeze(2)) out = torch.cat(convs, dim=1) # [batch, num_filters * len(filter_sizes)] out = self.dropout(out) logits = self.fc(out) return logits这个模型的好处是训练速度极快、显存占用小、能捕捉短文本的局部关键短语。对于中短文本(比如一句话或者一小段),textCNN效果往往很能打。它的缺点是感受野有限,长依赖关系很难建模,长文本语义容易丢。
实操里有一个提升textCNN效果的技巧:不直接用随机初始化的embedding,而是使用预训练的词向量,比如在大型语料上训练好的Word2Vec。在训练时可以选择冻结embedding层,或者微调。我的经验是,如果数据量足够,微调embedding层效果更好,但要注意学习率不能太大,否则会把预训练语义冲掉;如果数据量很小,冻结embedding层可以沿用词向量的语义,降低过拟合风险。
3.2 RNN/LSTM与注意力机制:适合序列依赖较强的文本
RNN天生适合建模序列数据,尤其LSTM和GRU能缓解长期依赖问题。在文本分类里,我们可以把整个句子编码成一个向量,但末尾隐状态往往携带了太多末尾信息,中间信息会被遗忘。常见的改进方式有:
- 双向LSTM:分别从左到右和从右到左读取句子,拼接前后向最后一步的隐状态作为文本表示。
- LSTM + 注意力:对每个时间步的隐状态计算注意力权重,然后加权求和得到文档表示。
- 层级注意力网络(HAN):对句子做词级注意力,再对文档做句子级注意力,适合长文档分类。
从我个人经验看,对于文本长度在几十到几百的段落,双向LSTM加注意力机制仍然是一个性价比很高的方案。但在超长文本上,训练速度明显慢于textCNN,如果计算资源有限,建议先用textCNN或预训练模型。
有一个容易踩的坑:LSTM在训练时梯度爆炸或消失。建议设置梯度裁剪,一般max_grad_norm设为1.0或者5.0。另外,如果训练过程中loss波动剧烈,可以将学习率从0.001调低到0.0005,或者采用warmup策略。
3.3 预训练模型:BERT及其轻量级变体
近几年的所有文本分类任务榜单,几乎都被预训练语言模型霸占。BERT的架构是Transformer encoder,双向上下文建模能力极强。它通过在大规模无监督语料上预训练,学到了丰富的语言知识,下游分类任务只需要在模型顶上加一个分类头并轻微微调即可。
以我常用的Hugging Face的transformers库为例,一个文本分类模型的定义和训练流程可以这样组织:
from transformers import AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=num_classes)这看起来很简单,但真正要用好BERT有不少细节:
- 输入长度:BERT默认最大长度512,实际使用中你要权衡截断策略。如果你的文本平均长度不到100,截断到128通常能保留绝大部分信息且训练更快;如果文本很长,可以考虑头部+尾部拼接或者分段投票。
- 数据增强与对抗训练:在BERT微调时加入一些数据增强(比如随机token mask、文本回译)可以提升鲁棒性。
- 动态学习率:通常用带warmup的linear schedule,学习率从2e-5到5e-5之间,具体需要尝试。
- 类别权重:如果类别不均衡,可以在损失函数上给少数类更高的权重。
BERT的问题是模型大、推理慢。解决方案有四种:
- 知识蒸馏:用小模型(如TextCNN、BiLSTM)去模仿大模型的软标签输出,精度损失较小,速度提升大。
- 使用轻量级变体:比如ALBERT、DistilBERT、TinyBERT,适合资源受限场景。
- 模型剪枝:去掉不重要的注意力头或权重,但实现成本高。
- 提前截断 + 优化推理框架:比如TensorRT加速、ONNX Runtime量化。
在我负责的一个客服意图分类项目中,最初使用BERT-base,GPU推理单次大约20ms,准确率0.91。后来通过蒸馏到一个四层Transformer结构的轻量模型,准确率0.89,CPU推理单次约15ms,整体吞吐提升了将近4倍。这个效果是可接受的,因为业务指标优先看的是延迟和吞吐。
3.4 文本分类模型的比较总结
| 模型 | 语义建模能力 | 训练速度 | 推理速度 | 显存/内存 | 适合场景 |
|---|---|---|---|---|---|
| TextCNN | 中 | 快 | 快 | 低 | 短文本、高热场景 |
| BiLSTM + Attention | 中上 | 中 | 中 | 中 | 中短文本、序列依赖明显 |
| BERT | 强 | 慢 | 慢 | 高 | 追求精度、算力充足 |
| DistilBERT/TinyBERT | 较强 | 中 | 中快 | 中 | 精度和速度折中 |
| 蒸馏后的四层Transformer | 较强 | 中 | 快 | 中低 | 线上推理性价比高 |
选型没有绝对答案。我的建议是:如果你的数据是短文本、类别不多、需要低延迟,先试textCNN,效果好就不必上BERT;如果你的应用场景对语义理解要求高,比如长尾类别的意图识别,或者新闻标题这种需要深层语义的任务,直接用预训练模型,并且做好蒸馏部署准备。
4. 训练过程与调参实战:从裸模型到稳定模型
这一章是很多教程容易一笔带过的地方。官方代码跑通了不代表训练稳定,真正放到数据上训练时,各种问题接踵而至:loss不降、过拟合、标签泄漏、验证集震荡等等。我来逐个拆解实际处理过程。
4.1 超参数选择策略:先粗后细
我习惯的做法是,任何新模型都先做少量epoch的试探,比如跑5个epoch,观察loss曲线和验证集指标上升趋势,然后再决定是否继续。
关键超参数的经验值:
- 学习率:AdamW + warmup,BERT类建议2e-5到5e-5;textCNN/BiLSTM从0.001开始调,下降到0.0001区间。
- Batch size:BERT依赖较大batch才能稳定,常见16或32;如果单卡显存不够,可以梯度累积。
- Epoch:BERT一般3到5个epoch就足够,多了会过拟合;textCNN可以训练10到20个epoch,配合early stopping。
- 权重衰减:0.01是BERT的常见默认值,对textCNN可以设为1e-5到1e-3。
- Dropout:0.3到0.5,防止过拟合。
这里我想强调一下warmup的作用。BERT类模型在初始阶段直接使用较大学习率,可能会因为模型参数偏离预训练分布导致“灾难性遗忘”。warmup的意思是让学习率从很小的值线性增长到设定的峰值,然后再线性衰减。一般warmup step设为总步数的6%到10%。
from transformers import get_linear_schedule_with_warmup num_train_steps = int(len(train_dataset) / batch_size) * epochs warmup_steps = int(num_train_steps * 0.1) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=warmup_steps, num_training_steps=num_train_steps)4.2 训练过程的监控与验证
训练过程中一定要同时观察训练集和验证集的指标。如果训练集loss持续下降,验证集指标停滞甚至上升,说明过拟合,需要减小模型容量或者提高dropout。如果训练集和验证集loss都不下降,考虑学习率过大、数据预处理有问题或者模型结构有bug。
一个我经常用的监控方法是,每个epoch保存一次模型检查点,并记录每个epoch的准确率、宏平均F1、各类别的支持度。这样如果某个epoch效果最好,可以回滚,也能定位是哪一类开始变差。
还要记住一点:不只在验证集上看单次预测结果,最好做一个混淆矩阵分析。很多错误是由相近类别造成的。比如“查询余额”和“查询交易记录”两个意图,模型总是搞混,这时候你要看样本特征,可能需要补充特征或调整分类体系。
4.3 不平衡样本和难样本的处理实战
当一个类别只有几十条样本,另一个类别有上万条时,模型天然会偏爱多类。处理方法我刚才提过,实际操作时要有优先级:
- 先做类别加权损失函数。这个改动最小,通常能快速提升少数类。
- 再做数据增强。回译是中文文本增强里很稳的方法,把“这个手机电池续航很好”翻译成英文再翻译回中文,得到“这个电话电池续航不错”,虽然不完美,但语义保持得很好。
- 最后考虑使用Focal Loss。它的公式是在交叉熵基础上加上调制因子,让模型更关注难以分类的样本。如果少数类中存在大量难样本,Focal Loss效果比简单加权更好。
我用加权交叉熵的PyTorch代码比较常见:
weights = torch.tensor([0.5, 2.0, 1.0]) # 根据逆频率设定 loss_fn = nn.CrossEntropyLoss(weight=weights)但要注意,类别权重的值不能极端,如果少数类权重是多数类的一百倍,模型可能过度矫正,把所有样本都预测成少数类。一般权重范围在0.1到10之间比较合理。
4.4 微调预训练模型时的一些细节
使用BERT微调分类任务,标准的做法是在[CLS]位置的输出上接一个线性分类层。训练时,整个模型(BERT参数+分类头)一起参与梯度更新。这里有几个容易被忽视的地方:
- 不要使用过大的学习率。BERT里的嵌入层对学习率很敏感,一般不需要单独区分参数分组,但整体学习率不能超过5e-5。
- 把文本截断到合适长度,尽量保留更多关键信息。中文字符一个词可能只占1到2个token,所以128长度可以覆盖多数短文本。
- 对多标签分类,把模型输出层的sigmoid改为多个二元分类头,损失函数用BCEWithLogitsLoss,而不是Softmax交叉熵。
多标签分类是另一个常见需求。其实方法也不复杂,只需要在模型输出层改成每个标签一个logit,损失函数用多标签分类损失。但这里有个业务匹配问题:如果标签之间有互斥关系(比如“垃圾邮件”和“正常邮件”),那么用softmax多分类更合理;如果标签可以共存(比如“娱乐”和“搞笑”),则必须用sigmoid多标签。
4.5 从训练到验证:全流程代码骨架
我给出一个简化的BERT微调训练流程,便于你复现整体步骤:
import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer, BertForSequenceClassification from torch.optim import AdamW class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = str(self.texts[idx]) inputs = self.tokenizer.encode_plus( text, add_special_tokens=True, max_length=self.max_len, padding='max_length', truncation=True, return_tensors='pt', ) return { 'input_ids': inputs['input_ids'].flatten(), 'attention_mask': inputs['attention_mask'].flatten(), 'labels': torch.tensor(self.labels[idx], dtype=torch.long), }然后按照常规流程:
model = BertForSequenceClassification.from_pretrained('bert-base-chinese', num_labels=num_classes) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) model.train() for epoch in range(epochs): for batch in train_loader: inputs = {k: v.to(device) for k, v in batch.items() if k != 'labels'} labels = batch['labels'].to(device) outputs = model(**inputs, labels=labels) loss = outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()这个流程看起来简单,但实际训练时你还会遇到一个细节:padding='max_length'会使得所有样本都被填充到128,计算开销略大。如果数据长短不一比较明显,建议用动态batch(按长度排序后放入同一个batch),可以提速20%甚至更多。
5. 常见猫腻与问题排查实录
这一章是用血泪换来的。每一个问题我基本都遇到过,并且有对应的排查方法。
5.1 训练集准确率很高,验证集准确率很低
典型过拟合。首先是数据量不足,模型把训练集里的噪音也背下来了。解决思路包括:增大数据量(或做数据增强)、降低模型复杂度、提高dropout、使用早停、加正则化(L2或权重衰减)。
还有一个经常被忽略的原因:训练集和验证集分布不一致。例如训练集是某个时间窗口的数据,验证集是另一个时间窗口的数据,而不同时间段的文本风格完全不同,比如新闻语速、网络热词随着时间变化。这时候就算不过拟合,跨时间验证也会降点。应该尽量让训练集和验证集分布一致,或者接受这种差距作为泛化性能的代价。
5.2 Loss不断下降但指标不变
如果loss下降而准确率/F1不变,可能性很多。最常见的是类别不平衡:模型在持续优化最大类别,多数类预测的置信度越来越高,但少数类完全没有被正确识别。这时候看每个类别的F1,多半会发现少数类F1极低。
另外,如果学习率设置的太小,loss下降很慢,但指标无起伏。这时候可以用较大的学习率先跑到一个可行区域,再降低学习率精调。
还有一种诡异的场景:如果你在文本中混入了大量无关的噪声(比如HTML标签没有清干净),模型可能学到了用某个标签去分类的“捷径”,表现为loss降低,但测试集不泛化。这种“捷径学习”在深度学习里很常见,需要检查输入是否存在泄漏。
5.3 模型对输入的微小改动非常敏感
比如“这部电影很好看”预测positive,但把“很”改成“有点”就变成negative,或者加一个无关的“啊”字结果完全不同。这其实是模型的鲁棒性问题。
对抗训练和文本数据增强可以有效缓解。我自己用过一个比较简单的手段:随机删除句中的某个非关键词(dropout token),或者随机替换同义词,把这个作为训练时的数据增强,能让模型的注意力更加分散,不至于被某个特定词带偏。
你也可以用attention visualization去看模型到底在关注哪些token。如果模型只盯着“好看”这两个字,其他一概不看,那必然会导致对同义替换不鲁棒。
5.4 线上推理与离线评估指标差异大
很多模型在测试集上F1有0.9,上线后真实流量中只有0.7。产生差异的原因通常有三个:
一、线上数据分布和训练集不同。比如训练数据是历史工单,线上进来了很多新业务类型,模型没见过。 二、标签定义在产品侧发生了变化。线上标注和线下标注标准不一致。 三、特征或预处理不一致。例如线下清洗了URL,线上没有清洗,或者分词方式不一致导致tokenizer切分不同。
解决方案是建立线上badcase回流机制。每次线上预测后,增加用户反馈或者人工抽检渠道,把低置信度预测和错误预测收集起来,定期加入训练集重新迭代。我见过做得好的团队,每天回流500条badcase,每两周更新一次模型,精度能稳定往上走。
5.5 训练显存不足怎么办
BERT模型显存占用大。如果你只有单卡6GB甚至4GB,可以采取以下手段:
- 减小batch_size,比如从16减到8或4。
- 使用梯度累积:把多个小batch梯度累积后再更新一次,等效于更大的batch_size,但显存不增加。
- 使用混合精度训练:FP16能减少一半显存,还能加速训练。
- 使用模型并行或者CPU offload,但复杂度高。
- 直接换更小的预训练模型,比如
distilbert-base-chinese,或者albert-chinese-tiny。
混合精度训练在PyTorch里的实现可以用torch.cuda.amp:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for batch in train_loader: with autocast(): outputs = model(**batch) loss = outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()使用混合精度要注意loss scale的处理,谨慎起见先在小数据集上测试,看loss能否正常下降。
5.6 常见问题速查表
| 问题 | 可能原因 | 解决方向 |
|---|---|---|
| 训练集loss不降 | 学习率过大或过小 | 尝试3e-5到5e-5或0.001区间 |
| 验证集指标震荡 | batch size过小 | 增大batch或梯度累积 |
| 多类不均衡 | 类别权重缺失 | 加权loss,数据增强 |
| 效果始终上不去 | 数据质量差/标签噪声高 | 人工清洗,重新标注边界样本 |
| 推理过慢 | 模型太大 | 蒸馏、量化、换轻量模型 |
6. 部署落地与持续迭代
模型训出来只是第一步,上线后的工程化才是真正的考验。
6.1 模型导出与推理优化
对于PyTorch模型,常见导出方式是转换为ONNX或TorchScript。ONNX可以在CPU和GPU上通过ONNX Runtime推理,通常比PyTorch原生推理快一些,并且支持量化。
ONNX导出的示例代码大致如下:
import torch from transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained("your_model_dir") model.eval() dummy_input = { "input_ids": torch.ones((1, 128), dtype=torch.long), "attention_mask": torch.ones((1, 128), dtype=torch.long), } torch.onnx.export( model, tuple(dummy_input.values()), "model.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={"input_ids": {0: "batch_size", 1: "seq_len"}}, )但要注意,如果模型包含动态形状或复杂控制流,ONNX导出可能失败,关键要检查导出的模型输出是否一致。
在部署时还需要考虑一个工程细节:tokenizer和模型版本的绑定。训练时用的是哪个tokenizer版本,部署时就要用同一个版本,否则输入id映射可能错位,导致结果完全混乱。
6.2 推理服务的吞吐与延迟优化
推理服务的优化可以从多个层面开展:
- 并发优化:用异步推理、批处理。
- 输入预处理优化:把tokenizer应用到GPU或其他加速库上,减少CPU负担。
- 量化:对ONNX Runtime执行动态量化或者静态量化,可以大幅降低模型体积和推理耗时,但精度可能损失一到两个点。
- 模型蒸馏:将BERT蒸馏成一个较小的集成模型。
我习惯先压测后优化。压测的目标是了解当前模型的QPS和P99延迟。如果P99延迟不达标,优先做batch流式处理,把一个批内的多个样本一次性送到模型,能大幅提高GPU利用率。
6.3 持续监控与迭代闭环
上线之后,持续监控指标不只是accuracy,还要包括:
- 各类别预测结果的置信度分布。如果某个类别置信度越来越低,说明线上数据在漂移。
- 人工抽检准确率。定期抽样预测结果,由标注人员评估是否准确。
- 模型输入长度分布。如果线上文本越来越长,原有截断策略可能导致信息丢失。
我见过一个失败案例:一个垃圾信息分类模型上线后效果很好,两个月后准确率开始下降,老大让大家调参,结果有一个同学发现线上出现了大量新式的广告文案,句子结构、用词风格完全和训练集不同。后来我们建立了基于embedding分布漂移的监控,一旦发现漂移信号,自动触发重训流程,才避免效果持续恶化。
这里推荐一个相对简单的手工迭代流程:
- 每周自动抽取预测置信度低于阈值的样本。
- 人工或者弱监督方式标注正确标签。
- 将这些样本连同标注结果加入训练集。
- 重新训练模型(增量训练或者全量重训)。
- 在气泡测试集上做回归评估,确认没有回退。
很多团队都认为训练一次模型就能管半年,这是不现实的。文本分类的边界永远在扩展,持续的迭代闭环是保持线上效果稳定的唯一办法。
7. 一些经验和后续可扩展的方向
最后再分享几个我在项目里反复验证过的观点,算是给你一个收尾参考。
文本分类不是一个单纯的算法问题,它由数据、模型、工程三部分共同构成。在数据上下功夫,比无脑换大模型收获更多。如果你只能做三件事,我的建议是:第一,把数据清洗和标签规范做好;第二,用一个强baseline模型(BERT或者textCNN)跑通全链路;第三,建立badcase回流闭环。
千万不要一上来就追求SOTA模型。很多时候,一个简单的textCNN在精调后就能满足业务指标,而BERT带来的收益并没有想象中那么大,反而增加了部署和运维成本。我的原则是“先简单,再复杂”,在简单方案无法满足需求时,再升级到复杂模型。如果textCNN能达到0.85的F1而业务要求是0.8,那就不必非上BERT不可。
关于后续扩展,一个值得尝试的方向是结合外部知识或者领域词典。比如金融、法律、医疗等垂直领域,预训练模型缺乏领域专有知识,可以在Embedding层注入领域词向量,或者在微调时添加领域相关的数据增强。另一个方向是模型集成,但要注意线上开销;如果必须做集成,用蒸馏把集成模型压缩成一个小的学生模型,既能保留集成效果又控制推理成本。
如果你负责的系统有大量的未标注数据,还可以利用半监督方法做自训练:先用已有标注数据训练教师模型,再对未标注数据进行预测,选取置信度高的样本加入训练集,重复几轮。这个方法我在一个冷启动项目里用过,将标注成本降低了将近一半,因为来自教师模型的高置信度样本虽然存在一定误差,但在整体分布和一般特征上已经能显著提升模型效果。
最后想提醒的是,文本分类永远存在“未知类别”和“边界模糊”的问题。分类体系不可能穷尽所有表达,所以上线时最好增加一个“其他”类别,或者设置置信度阈值,对于低置信度预测直接转人工处理。这个思路比硬塞一个错误标签更符合实际业务需要。
深度学习做文本分类,入门门槛不高,但做到稳定、高效、可持续迭代,需要的是数据和工程的耐心。希望这篇内容能让你少走一些弯路。