文本分类这活儿,说起来简单,做起来坑多。你可能已经跑通过 BERT 的官方示例,在 IMDB 或 THUCNews 上拿到了 95% 以上的准确率,感觉不过如此。但一旦换成自己的数据——比如客服工单、用户反馈、行业资讯、内部文档——准确率可能直接掉到 70% 以下,甚至模型根本不收敛。这不是模型不行,而是从“跑通 demo”到“落地业务”之间,有一整套细节需要处理。这篇文章就是把我自己在多个文本分类项目里踩过的坑、试过的方案、验证过的参数,完整地梳理一遍。无论你是刚接触 Transformers 的新手,还是已经用过但效果不理想的开发者,都能从中找到可以直接复用的操作步骤和排查思路。
1. 项目整体设计与思路拆解
1.1 为什么选择 Transformers 做文本分类
文本分类是 NLP 里最基础也最实用的任务之一。传统方案用 TF-IDF 加逻辑回归或 SVM,在数据量小、类别区分明显时效果尚可,但遇到语义复杂、类别边界模糊的场景就力不从心。比如“这个功能太难用了”和“这个功能我搞了半天才会用”,字面重叠度高但情感倾向完全不同,词袋模型很难区分。
Transformers 的核心优势在于预训练语言模型已经学到了通用的语义表示。你只需要在特定任务上做微调,就能把通用语义能力迁移到自己的分类任务上。这就像招了一个已经读过海量文本的实习生,你只需要教他你的业务规则,而不需要从认字开始教。
具体到文本分类,Transformers 提供了AutoModelForSequenceClassification这个类,它会在预训练模型顶部加一个分类头(通常是线性层),输出每个类别的 logits。你只需要准备好数据、选好预训练模型、设置训练参数,就能得到一个可用的分类器。
1.2 整体流程设计
一个完整的文本分类项目,从原始数据到上线可用,大致分为以下几个阶段:
- 数据准备与清洗:把原始数据整理成“文本-标签”对,处理缺失值、重复值、标签噪声。
- 数据划分:按比例划分训练集、验证集、测试集,确保类别分布一致。
- 模型选型:根据语言、数据量、算力选择合适的中文或英文预训练模型。
- 数据预处理与 Tokenization:把文本转成模型能吃的 input_ids、attention_mask。
- 训练配置:设置学习率、batch size、epochs、优化器、学习率调度等。
- 训练与评估:跑训练循环,监控验证集指标,保存最优模型。
- 测试与错误分析:在测试集上评估,分析混淆矩阵,找出错分原因。
- 推理与部署:把模型封装成可调用的服务或脚本。
这个流程看起来线性,但实际操作中经常需要回到上一步调整。比如训练效果不好,可能是数据问题,也可能是模型选型问题,还可能是超参数问题。所以每一步都要有记录,方便回溯。
1.3 方案选型背后的考量
预训练模型的选择是第一个关键决策。中文场景下,常见的选择有:
| 模型 | 参数量 | 特点 | 适用场景 |
|---|---|---|---|
| BERT-base-chinese | 110M | 经典稳定,社区资源多 | 通用中文分类 |
| RoBERTa-wwm-ext | 110M | 全词掩码,效果通常更好 | 对精度要求高的场景 |
| ERNIE | 110M | 知识增强 | 需要外部知识的场景 |
| MacBERT | 110M | 纠错预训练 | 文本有噪声的场景 |
| DistilBERT | 66M | 轻量,速度快 | 算力有限或需要低延迟 |
如果你的数据量在几千到几万条,BERT-base 级别的模型通常够用。数据量超过十万条,可以考虑更大的模型或继续预训练。数据量少于一千条,建议用更小的模型或冻结部分层,避免过拟合。
分类头的设计也有讲究。默认是取[CLS]位置的向量接一个线性层。对于多标签分类,需要把损失函数换成 BCEWithLogitsLoss,并在推理时用 sigmoid 而不是 softmax。对于类别不平衡问题,可以在损失函数里加类别权重,或者用 focal loss。
训练策略上,常见的有全量微调、冻结底层只训练顶层、以及分层学习率。全量微调效果最好但需要更多显存;冻结底层适合小数据;分层学习率是折中方案,底层用更小的学习率,顶层用正常学习率。
2. 核心细节解析与实操要点
2.1 数据准备:别急着喂给模型
我见过太多人直接把原始数据丢给模型,然后抱怨效果差。数据准备至少要做以下几件事:
标签清洗。检查标签是否有拼写错误、大小写不一致、多余空格。比如“正面”和“正面 ”会被当成两个类别。用df['label'].value_counts()看一眼分布,如果某个类别只有几条数据,要么合并,要么补充。
文本清洗。去掉 HTML 标签、特殊符号、多余空白。但要注意,不是所有符号都要去掉。比如情感分类里,“!”和“?”可能携带情感信息,去掉反而损失信号。我的经验是:先保留,训练一版看效果,如果过拟合再考虑去掉。
重复值处理。完全相同的文本如果标签不同,说明标注有矛盾,需要人工复核。完全相同的文本标签也相同,可以去掉重复,避免训练集和验证集泄漏。
长度分析。统计文本长度分布,决定max_length。如果 95% 的文本在 128 个 token 以内,就设 128;如果长尾很长,可以设 256 或 512,但要注意显存和速度。截断时优先保留头部,因为分类任务的关键信息通常在开头。
import pandas as pd df = pd.read_csv('data.csv') print(df['label'].value_counts()) print(df['text'].str.len().describe()) df = df.drop_duplicates(subset=['text']) df = df.dropna(subset=['text', 'label'])2.2 数据划分:别小看这一步
划分训练集、验证集、测试集时,最常见的错误是随机划分导致类别分布不一致。比如某个类别只有 50 条数据,随机划分后验证集里可能只有 2 条,评估结果波动极大。
正确做法是分层抽样(stratified split),用sklearn.model_selection.train_test_split的stratify参数。通常按 8:1:1 或 7:1.5:1.5 划分。如果数据量很大(十万以上),可以 9:0.5:0.5。
from sklearn.model_selection import train_test_split train_df, temp_df = train_test_split( df, test_size=0.2, stratify=df['label'], random_state=42 ) val_df, test_df = train_test_split( temp_df, test_size=0.5, stratify=temp_df['label'], random_state=42 )注意:
random_state要固定,保证每次划分一致。否则调参时验证集变了,指标不可比。
2.3 Tokenization:细节决定成败
Tokenization 是把文本转成模型输入的关键步骤。用AutoTokenizer可以自动匹配模型对应的分词器。
from transformers import AutoTokenizer model_name = 'bert-base-chinese' tokenizer = AutoTokenizer.from_pretrained(model_name) def tokenize(batch): return tokenizer( batch['text'], padding='max_length', truncation=True, max_length=128, return_tensors='pt' )几个关键参数:
padding='max_length':统一填充到 max_length。也可以用padding='longest'按 batch 内最长填充,更省显存,但需要 DataCollator 动态处理。truncation=True:超长截断。max_length:根据长度分析结果设定。return_tensors='pt':返回 PyTorch 张量。
中文分词器的坑:BERT-base-chinese 是按字分词的,每个汉字一个 token。RoBERTa-wwm-ext 也是按字,但做了全词掩码。如果你用 GPT 系列的分词器,它是按 subword 分词的,中文可能被切成奇怪的片段。所以分词器必须和模型匹配,不能混用。
2.4 模型加载与分类头配置
from transformers import AutoModelForSequenceClassification num_labels = df['label'].nunique() model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=num_labels )num_labels必须和实际类别数一致。如果标签是字符串,需要先转成数字 ID。可以用LabelEncoder或手动映射。
label2id = {label: i for i, label in enumerate(df['label'].unique())} id2label = {i: label for label, i in label2id.items()} df['label_id'] = df['label'].map(label2id)加载模型时,分类头的权重是随机初始化的。如果数据量小,可以考虑先冻结预训练模型的主体,只训练分类头几个 epoch,再解冻全量微调。这样能避免随机初始化的分类头在初期产生大梯度,破坏预训练权重。
2.5 训练参数:学习率是重中之重
Transformers 微调的学习率通常比从头训练小一到两个数量级。BERT-base 常用 2e-5 到 5e-5。学习率太大,损失震荡不收敛;太小,收敛慢且容易陷入局部最优。
| 参数 | 常用值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 ~ 5e-5 | 全量微调 |
| batch_size | 16 ~ 32 | 受显存限制 |
| epochs | 3 ~ 5 | 太多会过拟合 |
| warmup_ratio | 0.1 | 预热比例 |
| weight_decay | 0.01 | 正则化 |
| max_grad_norm | 1.0 | 梯度裁剪 |
warmup很重要。训练初期模型还没稳定,用大学习率容易发散。warmup 让学习率从 0 线性增加到设定值,再逐渐衰减。warmup_ratio=0.1表示前 10% 的训练步数用于预热。
梯度累积可以在显存不足时模拟大 batch。比如实际 batch_size=8,累积 4 步,等效 batch_size=32。
from transformers import TrainingArguments training_args = TrainingArguments( output_dir='./results', num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, warmup_ratio=0.1, weight_decay=0.01, max_grad_norm=1.0, evaluation_strategy='epoch', save_strategy='epoch', load_best_model_at_end=True, metric_for_best_model='accuracy', logging_dir='./logs', logging_steps=50, gradient_accumulation_steps=1, fp16=True )fp16=True在支持 GPU 的机器上能显著减少显存占用并加速训练。但如果你的 GPU 不支持混合精度,或者数据里有极端值,可能会遇到 NaN 损失。这时先关掉 fp16 试试。
2.6 评估指标:准确率不是唯一
类别不平衡时,准确率会骗人。比如 95% 的样本是负类,模型全预测负类也有 95% 准确率,但正类一个都没抓到。这时要看F1 分数、精确率、召回率。
from sklearn.metrics import accuracy_score, f1_score, classification_report def compute_metrics(eval_pred): logits, labels = eval_pred preds = logits.argmax(axis=-1) return { 'accuracy': accuracy_score(labels, preds), 'f1_macro': f1_score(labels, preds, average='macro'), 'f1_weighted': f1_score(labels, preds, average='weighted') }f1_macro对每个类别平等看待,适合类别不平衡场景。f1_weighted按类别样本数加权,更接近整体表现。两个都看,结合业务需求决定优化方向。
3. 实操过程与核心环节实现
3.1 完整训练脚本拆解
把上面的碎片拼起来,一个完整的训练脚本如下:
import pandas as pd import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import LabelEncoder from sklearn.metrics import accuracy_score, f1_score from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, DataCollatorWithPadding ) import torch from torch.utils.data import Dataset # 1. 加载数据 df = pd.read_csv('data.csv') df = df.dropna(subset=['text', 'label']) df = df.drop_duplicates(subset=['text']) # 2. 标签编码 le = LabelEncoder() df['label_id'] = le.fit_transform(df['label']) num_labels = len(le.classes_) # 3. 划分数据 train_df, temp_df = train_test_split( df, test_size=0.2, stratify=df['label_id'], random_state=42 ) val_df, test_df = train_test_split( temp_df, test_size=0.5, stratify=temp_df['label_id'], random_state=42 ) # 4. 加载分词器和模型 model_name = 'bert-base-chinese' tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=num_labels ) # 5. 自定义 Dataset class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_length = max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding = self.tokenizer( self.texts[idx], truncation=True, max_length=self.max_length, padding='max_length', return_tensors='pt' ) return { 'input_ids': encoding['input_ids'].squeeze(), 'attention_mask': encoding['attention_mask'].squeeze(), 'labels': torch.tensor(self.labels[idx], dtype=torch.long) } train_dataset = TextDataset(train_df['text'].tolist(), train_df['label_id'].tolist(), tokenizer) val_dataset = TextDataset(val_df['text'].tolist(), val_df['label_id'].tolist(), tokenizer) test_dataset = TextDataset(test_df['text'].tolist(), test_df['label_id'].tolist(), tokenizer) # 6. 评估函数 def compute_metrics(eval_pred): logits, labels = eval_pred preds = np.argmax(logits, axis=-1) return { 'accuracy': accuracy_score(labels, preds), 'f1_macro': f1_score(labels, preds, average='macro') } # 7. 训练参数 training_args = TrainingArguments( output_dir='./results', num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, warmup_ratio=0.1, weight_decay=0.01, max_grad_norm=1.0, evaluation_strategy='epoch', save_strategy='epoch', load_best_model_at_end=True, metric_for_best_model='f1_macro', logging_steps=50, fp16=torch.cuda.is_available() ) # 8. Trainer trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, compute_metrics=compute_metrics ) # 9. 训练 trainer.train() # 10. 测试 test_results = trainer.evaluate(test_dataset) print(test_results) # 11. 保存 trainer.save_model('./final_model') tokenizer.save_pretrained('./final_model')这个脚本可以直接跑,但有几个地方需要根据实际情况调整。
3.2 参数选择与计算过程
max_length 怎么定。先统计训练集文本的 token 长度分布。用分词器对全部文本做一次 tokenize,看 95 分位数和 99 分位数。如果 95 分位数是 120,99 分位数是 200,可以设 128,牺牲少量长尾样本换取速度。如果长尾样本很重要,设 256。
lengths = [len(tokenizer.encode(t)) for t in df['text']] print(np.percentile(lengths, [50, 90, 95, 99]))batch_size 怎么定。看显存。BERT-base 在 128 长度下,batch_size=16 大约占 4-6GB 显存。如果显存不够,减小 batch_size 并增加 gradient_accumulation_steps。比如 batch_size=8,gradient_accumulation_steps=2,等效 batch_size=16。
学习率怎么定。如果训练损失下降很慢,可以适当调大,比如从 2e-5 到 3e-5。如果损失震荡或变成 NaN,调小到 1e-5。我的经验是,BERT-base 在大多数中文分类任务上,2e-5 是个安全的起点。
epochs 怎么定。看验证集指标。通常 3 个 epoch 就能看到是否过拟合。如果验证集损失在第二个 epoch 后开始上升,说明过拟合了,减少 epoch 或加正则化。如果验证集指标还在提升,可以加到 4 或 5。
3.3 训练过程监控
训练时重点关注几个信号:
- 训练损失:应该稳步下降。如果震荡剧烈,学习率太大;如果下降极慢,学习率太小或模型没加载对。
- 验证集损失:应该先降后升。如果一直降但验证集指标不涨,可能过拟合了。
- 验证集 F1:主要优化目标。如果 F1 波动大,可能是验证集太小或类别分布不均。
- GPU 利用率:如果利用率低,可能是数据加载瓶颈,可以增加
dataloader_num_workers。
training_args = TrainingArguments( ... dataloader_num_workers=4, ... )提示:
dataloader_num_workers在 Windows 上可能有问题,设 0 或 2 比较稳。Linux 上可以设 4 或 8。
3.4 模型保存与加载
trainer.save_model()保存的是模型权重和配置。tokenizer.save_pretrained()保存分词器。推理时重新加载:
from transformers import pipeline classifier = pipeline( 'text-classification', model='./final_model', tokenizer='./final_model', device=0 if torch.cuda.is_available() else -1 ) result = classifier('这个产品非常好用') print(result)pipeline会自动处理 tokenization、推理、softmax。如果要做批量推理,直接用模型和分词器更高效。
4. 常见问题与排查技巧实录
4.1 损失不下降或变成 NaN
这是最常见的问题。排查顺序:
- 检查标签是否正确编码。标签必须是 0 到 num_labels-1 的整数。如果标签是 1 到 num_labels,模型会报错或产生错误梯度。
- 检查学习率是否太大。从 2e-5 降到 1e-5 试试。
- 检查是否用了 fp16。关掉 fp16 再跑一次。
- 检查输入是否有空文本。空文本 tokenize 后只有特殊 token,可能产生异常梯度。
- 检查模型是否加载正确。
num_labels是否和实际类别数一致。
# 检查标签范围 print(df['label_id'].min(), df['label_id'].max()) print(df['label_id'].nunique())4.2 验证集指标远低于训练集
这是过拟合的典型表现。解决方案:
- 增加数据。最有效但最费时。
- 减小模型。从 BERT-base 换成 DistilBERT 或 ALBERT。
- 冻结底层。只训练最后几层和分类头。
- 加正则化。增大 weight_decay,加 dropout。
- 早停。用
EarlyStoppingCallback在验证集指标不再提升时停止。
from transformers import EarlyStoppingCallback trainer = Trainer( ... callbacks=[EarlyStoppingCallback(early_stopping_patience=2)] )4.3 类别不平衡导致少数类 F1 极低
如果某个类别样本很少,模型会倾向于预测多数类。解决方案:
- 重采样。对少数类过采样,或对多数类欠采样。
- 类别权重。在损失函数里给少数类更高权重。
- focal loss。降低易分类样本的权重,聚焦难分类样本。
- 调整阈值。推理时对少数类降低判定阈值。
from torch import nn class_weights = torch.tensor([1.0, 5.0, 3.0]).to(device) loss_fn = nn.CrossEntropyLoss(weight=class_weights)如果自定义损失函数,需要继承Trainer并重写compute_loss。
4.4 推理速度慢
如果上线后推理延迟高,可以考虑:
- 模型量化。用
torch.quantization把 FP32 转成 INT8,速度提升 2-4 倍,精度损失通常小于 1%。 - ONNX 导出。把模型导出为 ONNX 格式,用 ONNX Runtime 推理,速度更快。
- 模型蒸馏。用大模型教小模型,小模型推理快。
- 批处理。把多个请求攒成 batch 一起推理。
# ONNX 导出示例 from transformers.convert_graph_to_onnx import convert convert( framework='pt', model='./final_model', output='./model.onnx', opset=12, tokenizer='./final_model' )4.5 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 损失 NaN | 学习率太大、fp16 溢出 | 降低学习率、关闭 fp16 |
| 损失不降 | 标签错误、模型未加载 | 检查标签范围、重新加载模型 |
| 过拟合 | 数据少、模型大 | 加数据、换小模型、加正则化 |
| 少数类 F1 低 | 类别不平衡 | 类别权重、重采样、focal loss |
| 推理慢 | 模型大、无优化 | 量化、ONNX、蒸馏、批处理 |
| 显存不足 | batch 太大、长度太长 | 减小 batch、梯度累积、缩短长度 |
| 验证集波动大 | 验证集太小 | 增大验证集、交叉验证 |
4.6 几个容易被忽略的细节
随机种子。训练前固定所有随机种子,保证可复现。
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)标签映射的保存。推理时需要把预测的 ID 转回原始标签,所以label2id和id2label要保存下来。
import json with open('./final_model/label_map.json', 'w') as f: json.dump({'label2id': label2id, 'id2label': id2label}, f)测试集只用一次。调参时用验证集,测试集只在最终评估时用一次。反复在测试集上调参会导致过拟合测试集,上线后效果打折扣。
错误分析。训练完不要只看指标,要抽样看错分的样本。很多时候错分是因为标注错误或文本本身有歧义。把错分样本整理出来,人工复核,往往能发现数据问题。
preds = trainer.predict(test_dataset) pred_labels = np.argmax(preds.predictions, axis=-1) test_df['pred'] = le.inverse_transform(pred_labels) errors = test_df[test_df['label'] != test_df['pred']] print(errors[['text', 'label', 'pred']].head(20))我在实际项目里发现,错误分析花的时间往往比调参还值。有一次一个类别的 F1 一直上不去,看了错分样本才发现,那个类别的标注标准前后不一致,早期标注和后期标注对同一类文本的判断不同。重新清洗标注后,F1 直接涨了 8 个点。
最后再分享一个小技巧:如果你的数据量在几千条左右,可以试试先用bert-base-chinese跑一版基线,再用RoBERTa-wwm-ext跑一版对比。通常 RoBERTa 会好 1-2 个点,但训练时间也更长。如果差距不大,用 BERT 就够了,没必要为了 1 个点增加部署成本。模型选型没有绝对的最优,只有最适合当前数据和算力约束的方案。