1. 为什么我建议用Trainer而不是手写训练循环
做NLP这两三年,微调BERT几乎是家常便饭。很多人第一次动手都会习惯性地照着PyTorch的标准写法,自己搭一个训练循环:for epoch in range(3),里面再套一个for batch in dataloader,一会儿要处理梯度清零,一会儿又要搞eval、保存checkpoint、恢复训练……代码写到一半就没心情调参了。后来我切到HuggingFace的Trainer之后,整个人就轻松很多——这个工具其实相当于把一套经过实战检验的训练样板给你封装好了,你要做的只是填上模型、数据和训练参数,剩下的事它自己处理。
这篇文章只讲一件事:怎么用HuggingFace生态里的Trainer,把BERT模型稳稳地微调一次。用一句话概括就是“面向对象地微调”,跟手写训练循环相比,最大的价值不是代码量变少,而是所有跟训练生命周期相关的细节都被统一标准化了:分布式训练、混合精度、断点续训、日志回传、评估流程、checkpoint选择……你不用再跟这些坑较劲。
什么场景下适合读这篇?如果你是第一次微调BERT,或者之前只会Copy网上的训练脚本但一改就报错,又或者你手里已经有一个PyTorch的旧项目想往HuggingFace这套上迁移,这篇文章都能帮你少走不少弯路。我会用一个IMDB情感二分类作为贯穿案例,把从数据准备到训练评估的完整链路拆开讲。
1.1 手写训练循环的隐藏成本
先算一笔账。一个“看似完整”的手写微调脚本里,你至少需要自己实现这几块:
- 学习率和warmup调度(BERT微调对warmup很敏感,直接用常数学习率效果经常很差)
- 梯度累积、梯度裁剪、AMP混合精度
- 验证集的定时评估,以及best model的记录
- 断点保存与恢复,多GPU下还要处理主进程的日志逻辑
- 分布式训练的初始化、数据sampler的适配、reduce操作
这些每一块单独都不难,但凑在一起极其耗时间。我见过不少团队,花在“把训练脚本跑通”上的时间比真正调模型的时间还多。Trainer存在的意义,就是把这些工程层面的重复劳动全部接管,让研究者把精力放回到模型和数据上。
1.2 Trainer帮你做了什么
Trainer不是一个花架子,它在底层就是基于PyTorch的一层封装,不过把很多细节做得非常成熟。简单列几个我最常用的能力:
- 传入TrainingArguments之后,它会自动生成训练器配置,处理设备分配;
- 内置Optimizer、LR Scheduler、warmup逻辑,默认AdamW配linear schedule;
- 在训练过程中按提前设好的step间隔执行eval,并把指标记录下来;
- 可以自动加载历史checkpoint,断电了也不怕;多卡训练、TPU训练都能用一个接口启动;
- 自带
train(),evaluate(),predict()三个核心调用,整个使用体验非常清晰。
很多人担心Trainer是不是太“黑盒”了。实际上它完全支持自定义:数据加载器、优化器、计算指标、回调函数、模型forward的包装,它都有对应的入口。你完全可以把它当成一个可以修改的开源项目来用,而不是只能接受的封闭框架。接下来,我会从一个可复现的角度,把每一步都落地给你看。
2. 环境准备与数据处理,别急着写trainer
开始写代码之前,先把环境打稳。Trainer的依赖其实很明确,绝大部分深度学习绕不开的那些包它都要。我的建议是用conda建一个干净的环境,避免掉进“某个依赖版本冲突,一跑就崩”的坑。
2.1 安装与镜像配置
创建环境后,建议一次性把下面这些装齐:
conda create -n bert-nlp python=3.10 conda activate bert-nlp pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install transformers datasets evaluate acceleratetransformers提供模型和Trainer,datasets负责数据集的加载与缓存,evaluate用来加载像accuracy、f1这类指标,accelerate是Trainer在分布式和混合精度场景下要用的后端。版本选择上,建议transformers>=4.30,新版对Trainer的稳定性有明显提升,太低的老版本某些参数名都对不上。
国内环境下载预训练模型经常卡住,这里有个很实用的经验:设置HuggingFace镜像站点为官方镜像地址,模型、tokenizer、数据集都会走国内可达的下载通道。具体做法是给你当前的shell设置一个环境变量:
export HF_ENDPOINT=https://hf-mirror.com注意,这个只是替换下载源,跟代码本身没有任何关系,也不需要修改你的from_pretrained路径。实测下来,BERT这种size的模型,镜像下载几分钟就能完成,比起默认源动不动超时要舒服太多。以后每次新开终端都要记得重新设,别嫌麻烦,可以写进~/.bashrc里。
2.2 数据集加载与tokenize
我用IMDB影评情感分类来做演示,原因是这个数据集够经典、规模合适、能说明问题又不会等太久。用datasets库加载只需要一行:
from datasets import load_dataset raw_datasets = load_dataset("imdb") print(raw_datasets) # DatasetDict({ # train: Dataset({features: ['text', 'label'], num_rows: 25000}) # test: Dataset({features: ['text', 'label'], num_rows: 25000}) # unsupervised: ... # })IMDB的train和test各有25000条,都是以text列存储影评。我们用电影评论来微调一个bert-base-uncased做情感分类,这个案例既简单又贴近真实工作流。
接下来是tokenize。这里最容易踩的坑是不知道truncation和padding该在哪里做。我的建议是:只在dataset样本上做truncation,把padding留给后面的data collator。
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") def tokenize_func(examples): return tokenizer(examples["text"], truncation=True, max_length=512) tokenized_datasets = raw_datasets.map(tokenize_func, batched=True)map函数默认会对整个数据集执行,而且会缓存到磁盘,第二次运行就能直接复用。用batched=True可以显著加速,一条条处理太慢。处理完之后train和test每条样本都会多出input_ids、token_type_ids和attention_mask这三列,后面做模型输入就靠它们。
2.3 DataCollator的作用
Padding很讲究,如果在这里一股脑把整条样本都padding到512,训练内存几乎会翻倍,尤其对IMDB这种长评论场景非常不划算。正确写法是用DataCollator,在组成batch时动态把当前batch内最长的样本填充到一致长度。
from transformers import DataCollatorWithPadding data_collator = DataCollatorWithPadding(tokenizer=tokenizer)这个collator会根据当前batch的长度分布决定最终pad到哪儿,短样本不会为长样本买单。我实测过,同样的batch size下,用动态padding比固定512长度的内存占用有明显的下降,整体训练速度也要快不少。如果你的任务是句子对分类,需要keep的字段会多一些,但核心思路一样:能动态就别预设。
3. 核心训练参数与Trainer搭建
数据准备好了,接下来就是Trainer的重头戏。很多人一上来就复制网上的TrainingArguments参数,但光一个save_strategy就有好几种值,用错会让你辛辛苦苦跑的模型到最后选不到最好的score。这一节我会把关键参数讲透。
3.1 TrainingArguments参数解读
TrainingArguments是Trainer的灵魂,它控制训练轮数、学习率、batch size、保存策略、日志间隔、是否加载最优模型等一大堆选项。下面给一个我自己常用、适合在单卡或双卡上微调BERT的参数清单:
from transformers import TrainingArguments args = TrainingArguments( output_dir="./bert-imdb-checkpoints", evaluation_strategy="steps", eval_steps=500, save_strategy="steps", save_steps=500, logging_steps=100, num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, learning_rate=2e-5, weight_decay=0.01, warmup_ratio=0.1, load_best_model_at_end=True, metric_for_best_model="eval_accuracy", greater_is_better=True, fp16=True, dataloader_num_workers=4, report_to="none", )逐项拆开说几个容易忽略的:
evaluation_strategy和save_strategy:建议都用"steps",然后用eval_steps和save_steps指定间隔。训练过程中持续记录eval指标,将来画曲线也方便。如果你把两个strategy设成不同步的,会出现“模型保存了但eval指标却没更新”的迷惑局面。load_best_model_at_end: 这个参数要跟metric_for_best_model配合使用。训练结束时它会基于指定的指标,从checkpoint里把得分最高的那个模型重新加载回来,用于后面的推理。fp16=True: 在支持半精度的GPU上非常好用,显存占用直接减半,速度也会有提升。Ampere架构之后的卡基本都能开,如果数据敏感或出现loss抖动,再考虑关掉。report_to="none": 很多人忽略这一项。不设置的话Trainer默认会尝试连接tensorboard或wandb,如果本地没有对应环境,会在训练前打印一堆警告甚至报错。
这里有个计算warmup的小技巧。我上面用了warmup_ratio=0.1,它表示训练总步数的前10%做学习率线性爬坡,这对BERT类模型来说是一套很稳妥的默认值。如果你更习惯精确控制,也可以用warmup_steps直接指定,比如训练共1000步就设100。两者别同时设,以warmup_steps优先。
3.2 评估指标与compute_metrics
Trainer默认不会自己评判模型好坏,需要手动传入一个compute_metrics函数。这个函数接收一个EvalPrediction对象,里面有predictions和label_ids,返回一个指标字典。
import numpy as np from evaluate import load accuracy_metric = load("accuracy") f1_metric = load("f1") def compute_metrics(eval_pred): predictions, labels = eval_pred preds = np.argmax(predictions, axis=-1) accuracy = accuracy_metric.compute(predictions=preds, references=labels)["accuracy"] f1 = f1_metric.compute(predictions=preds, references=labels, average="binary")["f1"] return {"accuracy": accuracy, "f1": f1}如果想偷懒,完全可以把accuracy和f1的加载放在函数外面,这样每次eval只会执行计算而不会重复加载指标。Trainer在预测时会输出原始logits,所以必须用np.argmax先转成预测类别,再和labels比对。
3.3 实例化Trainer并开始训练
实例化Trainer之前,需要先加载模型。注意这里要用任务对应的带分类头的模型类,不能直接加载BertModel,否则你得不到一个可以直接做分类输出的模型。对于二分类任务:
from transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( "bert-base-uncased", num_labels=2, )from_pretrained第一次运行的时候会下载模型权重,大概400多MB,下载完之后会缓存到本地,下次加载就不用再拉了。
接下来把前面准备的东西一次性塞进Trainer:
from transformers import Trainer trainer = Trainer( model=model, args=args, train_dataset=tokenized_datasets["train"], eval_dataset=tokenized_datasets["test"], data_collator=data_collator, tokenizer=tokenizer, compute_metrics=compute_metrics, )当你执行trainer.train()之后,训练就正式开始了。我建议第一次跑的时候把训练集稍微切小一点,比如只用2000条,目的是先把流程跑通,再上全量数据。全量IMDB在单张消费级GPU上大约要十几分钟到半小时,如果是从没跑过的代码,一上来就全量很容易把时间浪费在调试日志上。先把速度拉起来、确认loss在下降,再谈规模。
实际训练过程中,你会看到Trainer打印类似这样的日志:
{'loss': 0.412, 'learning_rate': 1.6e-05, 'epoch': 0.8}训练结束时如果load_best_model_at_end=True,它会提示已经加载了最优checkpoint,这时trainer.model就是整个训练过程中表现最好的那一版。
4. 训练收尾:指标、模型保存与推理
模型训练完了,后面这几步很多人会忽略,导致模型白跑。比如评估要把结果落盘、保存模型要把tokenizer一起存、推理时要搞清楚Trainer的predict到底返回什么。下面一次说清楚。
4.1 evaluate与predict
用Trainer做评估很简单:
eval_result = trainer.evaluate() print(eval_result) # {'eval_loss': 0.221, 'eval_accuracy': 0.912, 'eval_f1': 0.912}它的输出是一个字典,key名称由你传入的compute_metrics里返回的键决定。比如这里我们返回了accuracy和f1,前面的eval_是Trainer自动加上的前缀,方便后续和metric_for_best_model对准。
trainer.predict(test_dataset)同样可以用来产出预测结果,返回的对象里有三个属性:predictions是logits矩阵,label_ids是真实label,metrics是测试集指标。如果我们只是要一张提交表或者做线下分析,直接从predictions里argmax即可。
这里有个实际用过的细节:如果你在训练时没有传入eval数据集,evaluate()会直接报错,因为它根本不知道要评估谁。遇到这种情况就在TrainingArguments里把evaluation_strategy设成"no",然后手动指定eval_dataset来调predict()。
4.2 模型保存与重新加载
Trainer训练结束后会自动在output_dir里保存checkpoint,但最后一个checkpoint不一定是你想要的那版。如果开了load_best_model_at_end=True,推荐用下面这套姿势来保存:
trainer.save_model("./bert-imdb-final") tokenizer.save_pretrained("./bert-imdb-final")第一行会把模型结构和权重一起存下来,第二行把tokenizer的词典和配置一起存下来。为什么tokenizer也要存?因为推理时重新加载数据,tokenizer如果版本或词表不一致,输入token都会被编码错,结果自然对不上。我见过有人在生产环境只存模型不存tokenizer,下游一换环境全乱套的例子。
之后无论何时要重新做推理,只需要:
from transformers import AutoTokenizer, AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained("./bert-imdb-final") tokenizer = AutoTokenizer.from_pretrained("./bert-imdb-final")加载回来之后,model.eval()和torch.no_grad()别忘了,否则模型参数还处于training状态,推理行为会有差异。
4.3 训练过程日志怎么看
训练过程中那一串loss和learning_rate的日志不是摆设。我一般重点关注三点:
- loss下降趋势:前几十个step如果loss不降反升,先检查学习率和warmup配置,大多数时候是学习率太大。
- eval指标与loss是否同步:如果loss下降但accuracy不涨,大概率是过拟合信号,或者标签噪声太大。
- learning_rate的曲线:Trainer的scheduler默认是linear decay加warmup,日志里如果能确认learning_rate从极小的初始值爬升再下滑,说明调度正常。
另外一个很多新手不知道的地方:如果你设置了save_steps=500,训练到第500、1000步时会自动创建checkpoint文件夹。这些文件夹里的内容就是完整可用的模型,你完全可以在训练中断时直接把路径传给Trainer的resume_from_checkpoint=True来续训练。实际我做过一次实验,断电后恢复训练,loss曲线能很好地接上,几乎零损失。
5. 踩坑记录与排查思路
写到这里,训练流程基本闭环了。但任何框架在真实环境里都会有几个高频坑,这一节我用FAQ的形式把遇到过的和同事遇到过的问题列出来,每个都附排查思路,省得你再去搜索引擎里翻。
5.1 显存不足怎么办
最常见的就是CUDA out of memory。BERT base的模型本身不大,但序列长、batch一加大就容易爆。排查顺序建议如下:
- 先把
per_device_train_batch_size降到8、4,确认能跑通再往上加; - 打开
fp16=True,显存几乎能砍半; - 用
gradient_accumulation_steps保持“等效batch size”不变,比如原来batch size 16,现在batch size 4 + accumulation 4,等效训练效果接近但峰值显存大幅下降; - 检查是不是被固定padding坑了,用了
DataCollatorWithPadding之后,短样本的batch实际tensor会小很多,这也是为什么我在第2节反复强调动态padding。
假如是序列本身太长,比如文本平均长度超过400,那还得考虑max_length截断策略。直接截断尾部对情感分析这类任务影响通常不大,但如果是阅读理解,可能需要更精细的stride策略。
5.2 过拟合与精度异常
微调BERT在小数据集上特别容易过拟合。我有一阵用几千条数据做意图分类,训练loss降到0.1以下,验证loss却越走越高,然后精度在0.8附近震荡。那一次的经历让我把几个技巧刻进DNA里:
- 增大
weight_decay,从0.01往0.05试; - 减少训练轮数,BERT微调往往3轮以内就够,传统“训20轮”的思路不要套过来;
- 在
TrainingArguments里使用EarlyStoppingCallback,这是我最喜欢的功能之一:
from transformers import EarlyStoppingCallback, IntervalStrategy args = TrainingArguments( output_dir="./bert-imdb-checkpoints", evaluation_strategy="steps", eval_steps=300, save_strategy="steps", save_steps=300, num_train_epochs=5, load_best_model_at_end=True, metric_for_best_model="eval_accuracy", greater_is_better=True, ) trainer = Trainer( ..., callbacks=[EarlyStoppingCallback(early_stopping_patience=2)], )early_stopping_patience=2表示连续2个eval_steps间隔,评估指标都没有改善时就会提前终止训练。用这个回调还有个好处:不用为了“碰运气多训几轮”而浪费算力。
另外,如果你的任务类别极度不均衡,accuracy会变得“虚高”,光看accuracy根本判断不了好坏。这种情况建议用F1、AUC之类的指标,或者在compute_metrics里同时输出混淆矩阵相关的统计值。
5.3 环境与下载问题
Run时报错里,多数人会碰到两类比较典型的:
- 加载模型时连接超时:上面已经提过,用
HF_ENDPOINT=https://hf-mirror.com。 module 'transformers' has no attribute 'Trainer':这通常是transformers版本太老。升级到新版本即可:pip install -U transformers。还有一小部分情况是环境里同时存在旧版安装路径,建议重新建conda环境而不是原地覆盖。
有些朋友喜欢把checkpoint直接传到网盘,再从网盘下载到别的机器加载。但跨机器加载时,如果两边的transformers版本不一样,偶而会出现“weights key mismatch”的告警。正式部署前最好统一依赖版本,或者至少保证from_pretrained时torch_dtype、device_map等参数符合目标环境。
还有一个很容易被忽略的怪问题:训练正常但eval时卡住不动。我排查过几次,最后发现是dataloader_num_workers设得过高,机器内存不够,数据加载线程一直排队。降到0或2就能恢复。这个参数不是越高越好,数据小或单机环境用默认值更稳。
如果后续要扩展到更大模型,比如把BERT换成RoBERTa、DeBERTa,甚至做Llama这类生成模型的SFT,Trainer这套流程依然是通用的。只想微调部分参数、节省显存的话,可以在模型外再套一层PeftModel(LoRA),Trainer同样能配合使用。我记得在HuggingFace的PEFT生态里,一个peft_model = get_peft_model(model, lora_config)就能把模型包装好,剩下的Trainer调用完全不变。这一点也很适合在做完普通全参微调后,进一步探索低资源微调方案。
我个人现在的工作习惯是:凡是标准分类/回归/抽取类任务,一律用Trainer起步;只有在对训练流程有定制化需求、或者需要逐层控制梯度时,才会手写训练循环。用Trainer并不是因为它“高级”,而是因为它把一个可复现、可维护的训练流程标准化了。团队协作里,新人拿到这样一个代码结构,一眼就能看出训练参数、数据、指标在哪,省掉的沟通成本远超那点封装带来的“黑盒感”。
最后再分享一个小技巧:微调之前先用原始BERT在自己任务的小样本上跑一次,甚至不微调直接拿[CLS]特征做逻辑回归,得到一个“下限分数”。有了这个baseline,你微调后如果提升不大,至少能判断是不是任务本身太难,而不是代码写得不对。这个习惯帮我避免了很多次“明明在跑代码,其实在浪费时间”的尴尬。