文本分类这活儿,放在五年前,大家的第一反应多半是去调个 TF-IDF 加逻辑回归,或者干脆上 FastText 跑一版基线。那时候 Transformer 还是个"实验室里的贵东西",普通团队想都不敢想。但这两年情况完全反过来了——预训练模型多如牛毛,Hugging Face 的transformers库把加载、微调、推理的链路压到了几行代码,连一块消费级显卡都能跑得动。问题也随之而来:网上那些教程清一色拿 IMDB、AG News 这种"玩具数据集"演示,一旦换成自己手里那堆脏兮兮、类别不均衡、还带点行业黑话的文本,准确率立刻打回原形。
这篇东西就是冲着这个落差来的。我会把"用自己的数据集训练一个文本分类模型"这件事从头到尾拆开讲——数据怎么整理、模型怎么选、训练参数怎么调、跑完之后怎么判断它是真学会了还是在背答案。适合已经会写 Python、用过 PyTorch 或至少听说过它、但还没完整跑通过一次微调流程的人。如果你正卡在"数据集准备好了但不知道下一步干嘛"的阶段,那接下来的内容应该能帮你省掉不少翻文档的时间。
1. 先想清楚你的数据到底长什么样
很多人一上来就pip install transformers,然后卡在数据格式上。其实在碰代码之前,花二十分钟把数据摸清楚,后面能少走一大半弯路。文本分类看着简单,但数据的"形状"决定了你后面所有的技术选择。
1.1 分类任务的三种典型形态
文本分类不是一个单一任务,它至少有三种常见形态,处理方式差别不小。
第一种是单标签多分类,一条文本只属于一个类别,类别数通常在 2 到几十之间。情感分析、意图识别、工单归类都属于这类。这是最常见也最好处理的形态,transformers里的AutoModelForSequenceClassification直接就能用。
第二种是多标签分类,一条文本可以同时属于多个类别。比如一条新闻既算"科技"又算"财经"。这种任务的输出层不能用 softmax,得换成 sigmoid,损失函数也要从交叉熵换成二元交叉熵。很多人在这里翻车——用多分类的代码跑多标签数据,结果模型永远只预测一个标签。
第三种是层次分类,类别之间有父子关系,比如"电子产品 > 手机 > 安卓机"。这种任务要么把层次拍平成组合标签,要么用层次损失函数。除非你的类别体系特别复杂,否则我建议先拍平,跑通基线再说。
判断你的任务属于哪种,最简单的办法是看标注数据:如果每条样本的标签字段是一个字符串,那是单标签;如果是一个列表,那大概率是多标签。
1.2 数据量和类别分布的现实预期
网上教程动不动就说"几百条数据就能微调",这话对也不对。对的地方在于,预训练模型确实有很强的迁移能力,几百条高质量标注数据能跑出一个比随机猜好得多的模型。不对的地方在于,如果你的类别有二十个,每个类别只有几十条,那模型大概率学不到什么有用的东西。
我的经验是:每个类别至少准备 100 到 200 条标注数据,这是能看出效果的下限。类别越多,这个下限越高。如果实在凑不齐,优先保证类别均衡,宁可砍掉一些样本多的类别,也别让某个类别只有个位数样本。
类别不均衡是另一个绕不开的坑。真实业务数据里,热门类别可能占 80%,冷门类别只有 2%。直接训练的话,模型会倾向于全预测成热门类别,准确率看着挺高,但冷门类别的召回率惨不忍睹。处理办法后面会细讲,这里先记住一个原则:看准确率之前先看混淆矩阵。
1.3 文本长度对模型选择的影响
文本长度直接决定了你能用哪种模型。BERT 这类模型的输入上限通常是 512 个 token,超过就得截断。如果你的文本普遍在 100 字以内,那完全没问题;如果动辄几千字,就得考虑长文本方案,比如分段取平均、用 Longformer 这类支持长序列的模型,或者干脆先做摘要再分类。
这里有个容易被忽略的点:中文的 token 数和字数不是一回事。BERT 的中文分词器对常见词是一个 token 对应一到两个字,但遇到生僻词或专业术语,一个字可能被拆成好几个 token。所以估算长度时,最好实际用分词器跑一遍看看分布,别凭感觉。
我一般会先写个小脚本,统计一下训练集里 token 长度的分布,看看 95 分位数是多少。如果 95 分位数在 256 以内,那max_length设成 256 就够了,能省不少显存和训练时间。
2. 把原始数据整理成模型能吃的格式
数据摸清楚之后,下一步是把它整理成transformers能直接消费的格式。这一步看着琐碎,但做扎实了,后面调试会轻松很多。
2.1 从 CSV、JSON 到 Hugging Face Dataset
transformers的Trainer最舒服的搭档是datasets库里的Dataset对象。但你的原始数据大概率是 CSV 或 JSON,得先转一道。
假设你有一个 CSV,两列:text和label。转换的核心思路是先把数据读成 Python 的字典列表,再用Dataset.from_dict包起来。这里有个细节:label 必须是整数,不能是字符串。如果你的标签是"正面""负面"这种中文,得先建一个映射表,把它们转成 0、1、2。
import pandas as pd from datasets import Dataset df = pd.read_csv("my_data.csv") label2id = {"负面": 0, "中性": 1, "正面": 2} df["label"] = df["label"].map(label2id) dataset = Dataset.from_pandas(df[["text", "label"]])如果你有多个文件(训练集、验证集、测试集),可以分别转成 Dataset,再用DatasetDict组合起来。这样后面Trainer就能自动识别train、validation、test三个 split。
提示:
from_pandas有时候会因为 pandas 的索引问题报错,加个preserve_index=False参数通常能解决。
2.2 分词这一步最容易埋雷
分词是整条链路里最容易被低估的一步。很多人直接抄教程里的tokenizer(text, truncation=True, padding=True),跑是能跑,但里面有几个参数值得掰开说。
truncation=True是必须的,不然超过模型上限的文本会直接报错。但截断策略有讲究:默认是从右边截,也就是保留开头。对于新闻、评论这类"重点在前"的文本,这没问题;但对于一些"结论在后"的文本,比如法律文书,从右边截可能把关键信息切掉。这种情况可以考虑truncation="left",或者用滑动窗口分段。
padding=True会在一个 batch 内把短文本补齐到最长的那条。这在训练时是必要的,但要注意padding="max_length"和padding=True的区别:前者会把所有文本都补到模型上限(比如 512),浪费大量计算;后者只补到 batch 内最长的长度,效率高得多。除非你有特殊需求,否则用默认的padding=True就行。
还有一个隐藏参数是return_tensors。训练时Trainer会自己处理,但如果你要手动写训练循环,记得设成"pt"返回 PyTorch 张量。
分词完之后,Dataset 里会多出input_ids、attention_mask这些字段。这时候可以用dataset.map批量处理,记得开batched=True,速度能快好几倍。
2.3 划分验证集的正确姿势
验证集的作用是让你在训练过程中判断模型有没有过拟合。但很多人划分验证集的方式有问题——直接train_test_split随机切一刀。如果你的数据里有大量重复或高度相似的样本,随机切会导致验证集里出现训练集见过的内容,验证指标虚高。
更稳妥的做法是按业务维度分层切分。比如你的数据来自不同时间段,那就按时间切,用早期数据训练、后期数据验证;如果数据来自不同渠道,就按渠道切。这样验证集才能真正反映模型在"没见过的新数据"上的表现。
如果实在没有明显的分层维度,至少用stratify参数保证类别比例一致:
from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( texts, labels, test_size=0.2, stratify=labels, random_state=42 )random_state固定住,保证每次跑的结果可复现。这个习惯在调参阶段特别重要,不然你改了个参数,指标变了,你都不知道是参数起作用了还是数据切分变了。
3. 模型选型:不是越大越好
到了选模型这一步,很多人会陷入"参数崇拜",觉得越大越好。实际上,模型选型要综合考虑数据量、任务难度、推理延迟和硬件条件,盲目上大模型往往得不偿失。
3.1 中文场景下的几个务实选择
如果你的文本是中文,选模型时优先考虑在中文语料上预训练过的。bert-base-chinese是最经典的基线,稳但不算强;hfl/chinese-roberta-wwm-ext在中文任务上通常比原版 BERT 好一截,是很多人的默认选择;如果追求更好的效果,hfl/chinese-macbert-base也值得一试。
英文场景下,bert-base-uncased是基线,roberta-base效果更好但训练慢一些,distilbert-base-uncased是蒸馏版,速度快 40% 左右,效果损失不大,适合对延迟敏感的场景。
如果你的数据量特别小(每类几十条),可以考虑用textattack/bert-base-uncased这类已经在相似任务上微调过的模型做起点,相当于"二次迁移",收敛会快很多。
3.2 参数量、显存和训练时间的三角关系
选模型本质上是在参数量、显存占用和训练时间之间做权衡。我整理了一个粗略的对照表,基于单卡 16GB 显存、max_length=256、batch size 16 的配置:
| 模型 | 参数量 | 显存占用 | 单 epoch 时间(1万条) |
|---|---|---|---|
| DistilBERT | 66M | 约 4GB | 约 3 分钟 |
| BERT-base | 110M | 约 6GB | 约 5 分钟 |
| RoBERTa-base | 125M | 约 7GB | 约 6 分钟 |
| BERT-large | 340M | 约 14GB | 约 15 分钟 |
这张表是估算,实际会因硬件和数据长度浮动。但趋势很清楚:base 级别的模型是性价比最高的区间,large 级别的模型效果提升通常只有一两个点,但资源消耗翻倍还多。除非你的任务特别难、数据特别多,否则 base 级别足够用了。
3.3 分类头该怎么加
AutoModelForSequenceClassification会自动在预训练模型上面加一个分类头。这个分类头通常是一个 dropout 加一个线性层,输出维度等于你的类别数。这部分不用你操心,但有个参数要注意:num_labels。
num_labels必须等于你的实际类别数。设错了会怎样?设少了,模型输出维度不够,训练直接报错;设多了,模型会多输出几个永远用不上的类别,浪费一点参数,但不会报错,只是指标会难看。所以创建模型时一定要确认这个数字。
from transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( "hfl/chinese-roberta-wwm-ext", num_labels=3 )如果你做的是多标签分类,num_labels还是设成标签总数,但要在Trainer里把problem_type设成"multi_label_classification",损失函数会自动切换。
4. 训练参数:那些教程不会告诉你的细节
TrainingArguments里有几十个参数,教程通常只给一套默认值。但默认值不一定适合你的数据,调对了能让效果提升好几个点。
4.1 学习率是第一个要动的参数
微调预训练模型时,学习率通常要比从头训练小一到两个数量级。常见范围是 1e-5 到 5e-5。我的经验是:数据量小的时候用更小的学习率,比如 2e-5;数据量大、任务和预训练目标差异大时,可以用到 5e-5。
学习率太大会怎样?模型会"忘掉"预训练学到的知识,也就是灾难性遗忘,表现为训练损失下降很快但验证损失不降反升。学习率太小则收敛慢,可能跑完几个 epoch 都没什么变化。
有个实用技巧是分层学习率:给预训练模型的底层设更小的学习率,给顶层和分类头设更大的学习率。底层学的是通用语言特征,不需要大改;顶层学的是任务相关特征,需要多调整。不过Trainer默认不支持这个,得自己写优化器或者用callback实现,属于进阶操作,新手可以先跳过。
4.2 batch size 和梯度累积的配合
batch size 受显存限制,但太小会导致训练不稳定。一般建议至少 16,能上 32 更好。如果显存不够,可以用梯度累积:设gradient_accumulation_steps=4,相当于把 4 个小 batch 的梯度攒起来再更新一次,效果接近大 batch。
但要注意,梯度累积会改变有效的学习率。累积步数翻倍,等效 batch size 翻倍,学习率通常也要相应调大一点。这个关系不是线性的,实践中我一般保持学习率不变,先看效果,不行再微调。
4.3 epoch 数和早停策略
微调通常 3 到 5 个 epoch 就够了。跑太多会过拟合,跑太少欠拟合。怎么判断?看验证集损失:如果验证损失连续两个 epoch 不下降,基本可以停了。
Trainer支持EarlyStoppingCallback,可以自动在验证指标不再提升时停止训练。用法很简单:
from transformers import EarlyStoppingCallback trainer = Trainer( ..., callbacks=[EarlyStoppingCallback(early_stopping_patience=2)] )patience=2表示验证指标连续两次评估没提升就停。这个参数别设太小,因为验证指标本身有波动,设成 1 容易误停。
4.4 权重衰减和 warmup
weight_decay控制正则化强度,常用值是 0.01。它的作用是防止模型把权重学得太大,从而过拟合。数据量小的时候可以适当调大,比如 0.05。
warmup_ratio控制学习率预热比例,常用值是 0.1,也就是前 10% 的训练步数里学习率从 0 线性升到设定值。这个机制能防止训练初期梯度太大把模型带偏。如果你的训练步数很少(比如几百步),warmup 比例可以调小到 0.05,不然预热阶段就占掉一大半。
4.5 评估指标别只看准确率
Trainer默认只算 loss,要算准确率、F1 这些指标,得自己传compute_metrics函数。这里强烈建议至少算一下 macro F1,因为它在类别不均衡时比准确率靠谱得多。
import numpy as np from sklearn.metrics import f1_score, accuracy_score def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) return { "accuracy": accuracy_score(labels, preds), "macro_f1": f1_score(labels, preds, average="macro") }macro_f1是把每个类别的 F1 算出来再平均,冷门类别的表现会被同等对待。如果macro_f1远低于accuracy,说明模型在冷门类别上表现很差,需要处理类别不均衡。
5. 类别不均衡:绕不开的硬骨头
前面提过,真实数据里类别不均衡是常态。这一节专门讲怎么对付它,因为这是很多人训练完发现"准确率挺高但没法用"的根源。
5.1 先诊断,再开药
处理不均衡之前,先搞清楚不均衡到什么程度。算一下每个类别的样本数,看看最大类和最小类的比例。如果比例在 3:1 以内,影响不大,可以不管;如果在 10:1 以上,就必须处理了。
诊断的另一个维度是看混淆矩阵。如果模型把所有样本都预测成热门类别,那混淆矩阵会呈现明显的"一列独大"。这时候准确率可能还有 70%、80%,但模型其实什么都没学到。
5.2 重采样和加权损失怎么选
处理不均衡有两大流派:数据层面和损失层面。
数据层面的做法是重采样。过采样是复制冷门类别的样本,欠采样是丢弃热门类别的部分样本。过采样简单但容易过拟合,欠采样会损失信息。折中方案是用 SMOTE 这类方法生成合成样本,但文本数据的合成比数值数据难得多,效果不一定好。
损失层面的做法是给不同类别设不同的权重。Trainer支持传class_weights,或者用自定义的损失函数。权重通常设成类别频率的倒数,比如热门类别权重 1,冷门类别权重 5。
我的经验是:优先试损失加权,不行再考虑重采样。因为损失加权不改变数据分布,实现简单,而且效果通常够用。重采样会改变训练数据的分布,可能引入新的偏差。
5.3 一个容易被忽略的评估陷阱
处理完不均衡之后,评估方式也要跟着变。如果你用了重采样,验证集千万不能用重采样后的数据,必须用原始分布的验证集,否则指标会虚高。
另外,macro_f1虽然比准确率好,但它对每个类别一视同仁,如果你的业务里某些类别就是更重要,那应该用加权 F1,权重按业务重要性来定。这个没有标准答案,得结合具体场景。
6. 训练跑起来之后:监控与调试
代码写完了,trainer.train()一跑,看着 loss 往下掉,很多人就以为万事大吉了。实际上训练过程中的监控和调试才是决定最终效果的关键。
6.1 损失曲线的三种典型形态
训练时盯着 loss 曲线看,能提前发现很多问题。
健康形态:训练 loss 和验证 loss 都平稳下降,最后趋于平缓,两者差距不大。这说明模型学得不错,没有明显过拟合。
过拟合形态:训练 loss 持续下降,验证 loss 先降后升,出现明显的"U 形"。这说明模型开始背训练数据了。对策是加正则化、减 epoch、或者加更多数据。
欠拟合形态:训练 loss 和验证 loss 都很高,降不下去。这说明模型容量不够或者学习率不对。对策是换更大的模型、调大学习率、或者检查数据标注质量。
6.2 验证指标波动大怎么办
有时候验证指标会上下横跳,比如这个 epoch 是 0.82,下个 epoch 变成 0.79,再下个又回到 0.83。这种波动通常来自三个原因:验证集太小、batch size 太小、学习率太大。
验证集太小是最常见的。如果验证集只有几百条,指标波动一两个点是正常的。解决办法是扩大验证集,或者用交叉验证取平均。如果数据实在不够,那就多跑几个随机种子,看平均表现。
6.3 用混淆矩阵定位具体问题
指标只能告诉你"好不好",混淆矩阵能告诉你"哪里不好"。训练完之后,一定要画一下混淆矩阵,看看哪些类别容易被混淆。
比如一个情感分类任务,如果"中性"和"正面"经常混,可能是这两类的边界本身模糊,标注标准不清晰。这时候与其调模型,不如回去检查标注数据,看看是不是标注员自己都分不清。
还有一种情况是某个类别的召回率特别低,几乎全被预测成别的类别。这通常是不均衡导致的,回到上一节的处理方法。
7. 训练完之后:保存、加载和推理
模型训练完,Trainer会把最好的 checkpoint 存下来。但保存和加载这一步也有讲究,处理不好会导致推理结果和训练时不一致。
7.1 保存的不只是模型权重
用trainer.save_model()保存的时候,除了模型权重,还会保存配置文件。但分词器不会自动保存,得手动调tokenizer.save_pretrained()。如果忘了这一步,加载模型时就得重新指定分词器,容易出错。
trainer.save_model("./my_model") tokenizer.save_pretrained("./my_model")保存到一个目录里,加载时一个路径就够了:
from transformers import AutoModelForSequenceClassification, AutoTokenizer model = AutoModelForSequenceClassification.from_pretrained("./my_model") tokenizer = AutoTokenizer.from_pretrained("./my_model")7.2 推理时的 batch 处理和设备管理
推理时最容易犯的错误是忘了把模型切到 eval 模式,导致 dropout 还在起作用,结果每次推理结果都不一样。记得加model.eval()。
另一个坑是设备管理。如果训练用了 GPU,推理时也要把模型和数据放到同一个设备上。用model.to("cuda")和inputs.to("cuda")保证一致。如果显存不够,可以用torch.no_grad()关掉梯度计算,能省不少显存。
批量推理时,max_length要和训练时保持一致。训练时截断到 256,推理时却用 512,会导致输入分布不一致,效果下降。
7.3 一个完整的推理函数
把上面的点串起来,一个稳妥的推理函数大概长这样:
import torch def predict(texts, model, tokenizer, device="cuda", max_length=256): model.eval() model.to(device) inputs = tokenizer( texts, truncation=True, padding=True, max_length=max_length, return_tensors="pt" ).to(device) with torch.no_grad(): logits = model(**inputs).logits preds = torch.argmax(logits, dim=-1) return preds.cpu().numpy()这个函数支持批量输入,返回预测的类别 id。如果要返回概率,加个 softmax 就行。
8. 几个我踩过的坑和对应的解法
最后分享几个实际项目里踩过的坑,都是文档里不会写、但一踩就卡半天的类型。
第一个坑是标签映射不一致。训练时把"正面"映射成 2,推理时忘了这个映射,直接拿模型输出的 2 去查表,结果查出来是"负面"。这种错误特别隐蔽,因为模型输出看着正常,就是结果全错。解法是把label2id和id2label都存进模型配置里,加载时自动带出来。
第二个坑是分词器的特殊 token。有些模型的分词器会自动加[CLS]和[SEP],有些不会。如果你手动拼接过输入,可能会重复加或者漏加。最稳妥的办法是永远用分词器处理原始文本,不要自己拼。
第三个坑是验证集泄露。有一次我把数据预处理写在了划分验证集之前,结果标准化参数用到了验证集的信息,指标虚高了好几个点。正确顺序永远是:先划分,再分别处理。
第四个坑是显存碎片。长时间训练时,PyTorch 的显存分配会产生碎片,导致明明显存够却报 OOM。解法是设PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128,或者定期torch.cuda.empty_cache()。不过后者会拖慢训练,别频繁调。
第五个坑是随机种子没固定。transformers的训练涉及好几处随机性:数据打乱、dropout、权重初始化。只设random_state不够,还得设torch.manual_seed和numpy.random.seed。要完全复现,还得设transformers.set_seed。
这些坑单看都不复杂,但凑在一起能让一个本来一天能跑完的实验拖成三天。希望你看完能少走点弯路。模型训练这事儿,说到底是个手艺活,参数调多了自然就有感觉了。