1. 为什么我最终选择了 Transformers 做文本分类
文本分类这个任务,说起来简单,做起来坑多。我最早接触文本分类是在一个内容审核项目里,当时用的是 TF-IDF 加朴素贝叶斯的组合,几万条数据跑得飞快,准确率也能到 85% 左右。但后来业务方提了新需求:要识别更细粒度的语义类别,比如“投诉建议”和“咨询求助”这种字面高度重叠但意图完全不同的标签。传统词袋模型一下就露馅了,因为这两类文本共享大量词汇,区分全靠上下文语义,而 TF-IDF 根本捕捉不到这种信息。
那段时间我试过不少方案,从 Word2Vec 平均词向量到 TextCNN,效果有提升但始终不够稳定。直到把目光转向 Transformers 架构,用预训练语言模型做微调,才真正把准确率拉到了 93% 以上,而且跨类别的 F1 值分布也均匀了很多。这就是我写这篇东西的原因——把我自己数据集上训练文本分类模型的完整流程、踩过的坑、以及那些文档里不会写的细节,一次性讲清楚。
这篇文章适合谁看?如果你手头有一批标注好的文本数据,想做分类但不知道从哪下手;或者你已经用过一些传统方法,效果卡在瓶颈上不去;再或者你试过 Transformers 但训练过程各种报错、显存爆炸、过拟合严重——那这篇内容应该能帮到你。我会从数据准备讲到模型部署,每个环节都给出可复现的操作和参数选择的理由。
核心关键词就一个:在你自己的数据集上训练文本分类模型。不是跑官方 Demo,不是用现成 API,而是真正把预训练模型适配到你的业务数据上。这件事的门槛比想象中低,但细节比想象中多。
2. 整体方案设计与技术选型思路
2.1 为什么是微调而不是从头训练
预训练语言模型已经在海量通用语料上学习过语言的基本规律——词法、句法、甚至一些常识推理能力。你要做的文本分类任务,本质上是在这个通用能力之上加一层“任务适配”。从头训练一个 Transformer 需要千万级甚至亿级的标注数据,而微调通常几千到几万条就能有不错的效果。
我做过一个对比实验:同样 8000 条标注数据,从头训练的 Transformer 准确率只有 72%,而微调预训练模型能到 91%。差距就是这么明显。原因在于预训练模型已经学会了“理解语言”,你只需要教它“这个任务要关注什么”。
注意:微调不是万能药。如果你的数据量少于 500 条,或者类别极度不平衡(比如正负样本 1:100),微调效果也会打折扣。这种情况下要么先做数据增强,要么考虑少样本学习方案。
2.2 模型选型的几个关键考量
选哪个预训练模型,取决于你的语言、数据量、算力预算和延迟要求。我整理了一个选型对照表:
| 模型类型 | 参数量级 | 适合场景 | 显存需求(微调) | 推理速度 |
|---|---|---|---|---|
| 小型中文预训练模型 | 几千万 | 数据量小、延迟敏感 | 4-6GB | 快 |
| 标准中文预训练模型 | 1亿左右 | 通用场景、效果优先 | 8-12GB | 中等 |
| 多语言预训练模型 | 1-3亿 | 多语言混合数据 | 12-16GB | 中等偏慢 |
| 大型预训练模型 | 3亿以上 | 数据充足、追求极致效果 | 16GB+ | 慢 |
我的建议是:先从标准尺寸的中文预训练模型开始。它在效果和资源消耗之间取得了很好的平衡,社区支持也最完善。如果你的数据是英文为主,对应的英文预训练模型选择更多,从轻量级到大型都有。
2.3 训练框架的选择
目前主流的选择是 Hugging Face 的 Transformers 库配合 PyTorch。我试过 TensorFlow 版本,但 PyTorch 生态在 NLP 领域更活跃,调试也更方便。Transformers 库提供了统一的接口,加载预训练模型、分词器、训练器都是一行代码的事。
不过要注意版本兼容性。我遇到过 Transformers 升级后 API 变动导致旧代码跑不通的情况。建议在项目开始时锁定版本,比如:
pip install transformers==4.36.0 torch==2.1.0 datasets==2.15.0datasets库也建议一起装上,它处理数据加载和预处理非常方便,尤其是当你需要做动态填充(dynamic padding)的时候。
3. 数据准备:从原始文本到模型可读格式
3.1 数据清洗的四个关键步骤
拿到原始数据后别急着喂给模型。我见过太多人跳过清洗直接训练,结果模型学了一堆噪声。文本分类的数据清洗至少要做四件事:
第一,去除无关字符。HTML 标签、特殊符号、多余空白,这些对分类没有帮助,反而会增加分词器的负担。用正则表达式批量处理就行:
import re def clean_text(text): text = re.sub(r'<[^>]+>', '', text) # 去HTML标签 text = re.sub(r'\s+', ' ', text) # 合并空白 text = re.sub(r'[^\w\s\u4e00-\u9fff]', '', text) # 保留中英文和数字 return text.strip()第二,处理长度异常样本。统计一下文本长度分布,把那些只有一两个字的、或者超过模型最大长度的样本挑出来单独看。太短的可能是标注错误,太长的可能需要截断或分段。
第三,检查标签一致性。同一个标签下如果有明显不属于该类的样本,要么修正标签,要么删除。我习惯用关键词抽取快速扫一遍每个类别的 top 词汇,如果某个类别里出现了完全不相关的词,那大概率有脏数据。
第四,去重。完全重复的样本会导致模型过拟合,尤其是当重复样本集中在某个类别时。用简单的哈希去重就能解决。
3.2 标签体系的设计原则
标签体系的设计直接决定了任务的上限。我踩过的坑是:一开始把标签分得太细,结果每个类别只有几十条数据,模型根本学不动。后来合并了一些语义相近的类别,效果立刻好转。
几个经验法则:
- 每个类别至少 200 条标注数据,低于这个数就要考虑合并或增强
- 类别之间的语义边界要清晰,如果人工标注时都经常混淆,模型更不可能学好
- 类别数量控制在 2 到 20 之间比较合适,太多类别会导致训练困难
如果你的标签有层级关系,比如“电子产品 > 手机 > 智能手机”,可以考虑先做粗粒度分类再做细粒度,而不是一次性预测所有层级。
3.3 数据集划分的注意事项
训练集、验证集、测试集的划分不是随便切一刀就行。我建议按 8:1:1 的比例划分,但要注意几点:
第一,分层采样。确保每个类别在三个集合中的比例一致,否则验证集和测试集的评估结果会有偏差。用sklearn的train_test_split加上stratify参数就能做到。
第二,如果数据有时间属性,按时间划分比随机划分更合理。比如你用历史评论训练模型去预测新评论,随机划分会导致数据泄露——模型可能记住了未来才出现的表达方式。
第三,测试集要留到最后再用。我见过有人在训练过程中反复用测试集调参,这本质上是在测试集上过拟合。验证集用来调参和早停,测试集只在最终评估时用一次。
4. 模型训练的核心细节与实操要点
4.1 分词器的正确使用方式
分词器是很多人忽略的环节,但它直接影响模型输入的质量。Transformers 的分词器有几个关键参数:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-pretrained-model") encoded = tokenizer( texts, padding=True, truncation=True, max_length=128, return_tensors="pt" )max_length怎么定?统计你的文本长度分布,取 95 分位数作为参考。比如 95% 的文本都在 100 个 token 以内,那max_length=128就够用了。设太大浪费显存,设太小会截断重要信息。
padding=True会在一个批次内对齐长度,但更高效的做法是动态填充——每个批次只填充到该批次的最大长度,而不是全局最大长度。这能显著减少显存占用,尤其是当文本长度差异很大时。
提示:中文分词器和英文分词器的行为不同。中文预训练模型通常按字或子词切分,一个汉字可能对应一个 token,所以中文的
max_length要比英文设得大一些。128 对英文可能够用,对中文可能只覆盖 100 个字左右。
4.2 训练超参数的设置逻辑
超参数没有万能公式,但有一些经验起点:
| 超参数 | 推荐范围 | 说明 |
|---|---|---|
| 学习率 | 1e-5 到 5e-5 | 太大导致训练不稳定,太小收敛慢 |
| 批次大小 | 16 到 64 | 受显存限制,可用梯度累积模拟大批次 |
| 训练轮数 | 3 到 10 | 配合早停机制,防止过拟合 |
| 权重衰减 | 0.01 左右 | 正则化,防止过拟合 |
| 预热比例 | 0.1 左右 | 学习率从 0 线性增加到目标值 |
学习率是最关键的。我一般从 2e-5 开始试,如果训练损失震荡就调小,如果下降太慢就调大。批次大小方面,如果显存不够,可以用梯度累积:
training_args = TrainingArguments( per_device_train_batch_size=8, gradient_accumulation_steps=4, # 等效批次大小 32 ... )这样每次前向传播只处理 8 条数据,但梯度累积 4 次后再更新参数,效果等同于批次大小 32。
4.3 训练过程中的监控指标
训练时不要只看准确率。我习惯同时监控以下几个指标:
- 训练损失和验证损失:如果训练损失持续下降但验证损失开始上升,说明过拟合了
- 验证集 F1 值:比准确率更能反映类别不平衡时的真实表现
- 学习率变化:确认预热和衰减按预期执行
- 梯度范数:如果梯度范数突然变大,可能有异常样本
Transformers 的Trainer默认会输出这些信息,但你可以通过回调函数自定义监控。我一般会加一个EarlyStoppingCallback,当验证集指标连续 3 轮不提升就停止训练。
4.4 显存优化的几个实用技巧
显存不够是微调时最常见的障碍。除了梯度累积,还有几个方法:
第一,混合精度训练。用fp16=True开启半精度,显存占用能减少近一半,训练速度也有提升。但要注意有些模型在 fp16 下会出现数值不稳定,这时可以用bf16替代。
第二,梯度检查点。用gradient_checkpointing=True以计算时间换显存,适合显存极度紧张的情况。
第三,冻结部分层。如果数据量不大,可以只微调模型的上层,冻结底层的参数:
for param in model.base_model.parameters(): param.requires_grad = False这样可训练参数大幅减少,显存占用和训练时间都会下降。但效果可能略差于全量微调,需要根据实际情况权衡。
5. 常见问题与排查技巧实录
5.1 训练不收敛或损失震荡
这是最常见的问题。原因通常有几个:学习率太大、批次大小太小、数据没有打乱、或者存在异常样本。
排查步骤:先把学习率降到 1e-5 试试,如果还是震荡,检查数据加载时有没有shuffle=True。然后随机抽几条样本打印出来,看看有没有空文本、超长文本或标签错误的样本。我遇到过一次损失震荡,最后发现是某条样本的文本长度超过了max_length十倍,截断后包含了大量重复字符,导致梯度异常。
5.2 过拟合的识别与应对
过拟合的信号很明确:训练集准确率接近 100%,但验证集准确率停滞甚至下降。应对方法按优先级排列:
- 增加数据量(最有效但往往不现实)
- 数据增强(同义词替换、回译、随机插入删除)
- 增加正则化(提高权重衰减、增加 Dropout)
- 早停(在验证集指标不再提升时停止)
- 减小模型尺寸(换更小的预训练模型)
我通常先加早停和权重衰减,如果还不够就做数据增强。同义词替换对中文效果不错,但要注意别替换了关键实体词,否则会改变标签语义。
5.3 类别不平衡的处理
当某些类别样本很少时,模型会倾向于预测多数类。解决方法有:
- 重采样:对少数类过采样,或对多数类欠采样
- 类别权重:在损失函数中给少数类更高的权重
- focal loss:降低易分类样本的权重,让模型关注难样本
在 Transformers 中,可以通过自定义损失函数实现类别权重:
from torch.nn import CrossEntropyLoss class_weights = torch.tensor([1.0, 2.0, 3.0]) # 根据类别频率设置 loss_fn = CrossEntropyLoss(weight=class_weights)权重的设置原则是:类别权重与类别频率成反比。比如类别 A 有 1000 条,类别 B 有 100 条,那 B 的权重可以是 A 的 10 倍。
5.4 推理速度慢的优化
训练完之后,推理速度直接影响上线体验。几个优化方向:
第一,模型量化。把 fp32 转成 int8,模型体积缩小 4 倍,推理速度提升 2-3 倍,精度损失通常在 1% 以内。
第二,ONNX 导出。把 PyTorch 模型转成 ONNX 格式,配合 ONNX Runtime 推理,速度比原生 PyTorch 快不少。
第三,批处理推理。如果是在线服务,把多个请求攒成一个批次一起推理,吞吐量能大幅提升。但要注意延迟和吞吐量的权衡。
第四,模型蒸馏。用大模型教一个小模型,小模型推理快但效果接近大模型。适合对延迟要求极高的场景。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 损失不下降 | 学习率太小、数据有问题 | 调大学习率、检查数据 |
| 损失震荡 | 学习率太大、批次太小 | 调小学习率、增大批次 |
| 验证集效果差 | 过拟合、数据分布不一致 | 早停、正则化、检查数据划分 |
| 显存不足 | 批次太大、序列太长 | 梯度累积、混合精度、截断 |
| 推理慢 | 模型太大、未优化 | 量化、ONNX、批处理 |
| 某类别效果差 | 样本太少、标签噪声 | 数据增强、检查标签质量 |
6. 从训练到部署的完整链路
6.1 模型保存与加载的最佳实践
训练完成后,用save_pretrained保存模型和分词器:
model.save_pretrained("./my-classifier") tokenizer.save_pretrained("./my-classifier")这样保存的不只是权重,还有模型配置,加载时直接from_pretrained就行。我建议同时保存标签映射表(label2id 和 id2label),否则推理时还得手动对应。
加载时注意设备映射:
model = AutoModelForSequenceClassification.from_pretrained( "./my-classifier", num_labels=len(label2id) ) model.to("cuda" if torch.cuda.is_available() else "cpu") model.eval()推理时一定要加model.eval(),否则 Dropout 层会继续生效,导致每次推理结果不一致。
6.2 构建推理管道的几个要点
生产环境的推理管道要考虑批处理、超时、异常处理。我一般会封装一个类:
class TextClassifier: def __init__(self, model_path, max_length=128): self.tokenizer = AutoTokenizer.from_pretrained(model_path) self.model = AutoModelForSequenceClassification.from_pretrained(model_path) self.model.eval() self.max_length = max_length def predict(self, texts, batch_size=32): results = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] inputs = self.tokenizer( batch, padding=True, truncation=True, max_length=self.max_length, return_tensors="pt" ) with torch.no_grad(): outputs = self.model(**inputs) probs = torch.softmax(outputs.logits, dim=-1) results.extend(probs.tolist()) return resultstorch.no_grad()能显著减少推理时的显存占用和计算量。批处理大小根据实际延迟要求调整,一般 32 到 128 之间比较合适。
6.3 效果监控与迭代
模型上线不是终点。你需要监控线上推理的输入分布和输出分布,如果发现输入文本的长度、词汇分布和训练数据差异很大,说明数据漂移了,模型效果会下降。
我习惯定期抽样线上数据做人工评估,如果准确率下降超过 5%,就触发重新训练。重新训练时把新标注的数据加入训练集,同时保留一部分旧数据防止灾难性遗忘。
另外,收集线上 bad case 是提升模型最有效的方式。把预测错误的样本挑出来,修正标签后加入训练集,下一轮训练通常能解决大部分问题。
7. 一些个人体会和实用建议
微调预训练模型做文本分类这件事,门槛真的不高,但细节决定成败。我见过太多人卡在数据清洗、显存不足、过拟合这些环节上,其实每个问题都有成熟的解决方案,只是需要有人告诉你坑在哪里。
如果让我给一个最简上手路径:先用 1000 条数据跑通全流程,确认代码没问题,再逐步增加数据量和模型复杂度。不要一上来就追求 SOTA 效果,先把 baseline 建起来,然后有针对性地优化。
还有一个容易被忽略的点:标注质量比模型选择重要得多。我做过实验,同样的模型,用高质量标注数据训练比用噪声数据训练,准确率能差 15 个百分点。所以在数据标注上多花时间,回报率远高于调参。
最后分享一个小技巧:训练时用wandb或tensorboard记录实验,每次调参都记录配置和结果。我一开始觉得麻烦,后来发现没有实验记录根本记不清哪个参数组合效果最好。现在每次训练自动记录,对比实验效率高了很多。
这个内容后续还可以这样扩展:如果你有多标签分类需求,可以把num_labels设为标签总数,损失函数换成BCEWithLogitsLoss;如果需要处理超长文本,可以试试层次化模型或滑动窗口切分;如果标注数据极少,可以探索提示学习或零样本分类方案。每个方向都有成熟的实现路径,核心思路是一样的——让预训练模型学会你的任务。