ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HuggingFace Trainer微调BERT完整指南:从数据准备到排错

HuggingFace Trainer微调BERT完整指南:从数据准备到排错 聊到BERT微调不少同学第一反应就是“用HuggingFace的Trainer”。这套API确实省事几行代码就能把训练循环跑起来。但我在评论区看到的反馈往往是两极分化一边是“照着示例改一改就能跑”另一边是“报错不知道从哪查感觉Trainer是个黑盒”。这篇我就把Trainer微调BERT的完整链路从头拆到尾不只贴代码而是把数据怎么准备、参数为什么这么设、报错怎么排查、换任务怎么改一次讲清楚。适合刚接触HuggingFace、想用Trainer完成BERT微调、之后甚至想扩展到其他模型的读者。1. 为什么要用Trainer从手写训练循环到“套餐式”训练先看一段最原始的训练循环for epoch in range(3): for batch in train_dataloader: batch {k: v.to(device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()这段代码本身没错但它只是“能跑”的版本。真实项目里你要面对的东西还有一堆梯度累积、混合精度、梯度裁剪、学习率调度、分布式采样、评估逻辑、checkpoint保存、断点续训、日志记录。这些东西如果全部手写光调试就要花掉不少时间而且很容易在某个细节上踩坑。1.1 手写训练循环的痛点举个最常见的场景你想用梯度累积模拟一个较大的batch size。手写时通常要自己维护一个累积计数器每隔几步才执行一次optimizer.step()和optimizer.zero_grad()还得保证梯度没提前清掉。这里有非常多的边界情况尤其加上混合精度之后scale loss的操作可能会让梯度累积的逻辑变得更难调。再比如评估。你训练到一半想看看验证集效果手写时就要单独写一个评估函数要处理model.eval()、torch.no_grad()、预测结果和labels的收集、指标计算最后还要再切回model.train()。这些操作本身不复杂但每写一次都会浪费不少精力。1.2 Trainer替你干的六件事Trainer本质上是一个标准监督学习的“套餐式”训练器它帮你封装了下面这些基础工作完整的训练循环和评估循环自动管理train/eval模式切换自动搬数据到device不需要手动to(device)也不需要写dataloader和sampler混合精度训练fp16和bf16只需在参数里打开多卡和分布式训练启动方式从单卡切换为torchrun即可checkpoint保存与断点续训训练中断后能自动从中断点恢复还支持load_best_model_at_end这种“训练完自动加载验证集最优模型”的操作训练日志、TensorBoard、WandB实验追踪以及模型上传Hub这些能力对于一个微调任务来说非常够用。你从“写循环”变成了“配置参数”省下来的时间可以花在真正重要的事情上数据处理、特征工程、模型结构、业务指标。1.3 用Trainer不等于放弃控制很多人误以为用了Trainer就不能改损失函数、不能换优化器。其实不是这样。Trainer的核心入口有三个地方可以做自定义自定义compute_loss比如改成Focal Loss、多标签的BCEWithLogitsLoss自定义DataCollator控制每个batch怎么拼装这在NER、多模态任务里非常常用自定义Callback在训练、评估、保存的不同阶段插入自己的逻辑后面我讲多标签分类和CLIP微调时会具体体现这两个入口的作用。先记住一个结论Trainer是一个“默认策略很好的框架”它允许你破坏默认策略但前提是你知道自己在改什么。2. 环境准备先解决HuggingFace下载与依赖安装的问题很多同学微调BERT卡在第一步不是代码写不对而是模型下载不下来。这一节先把环境弄干净。2.1 依赖包组合我用的是新版transformers建议这样安装pip install -U transformers datasets evaluate accelerate scikit-learn四个包各干一件事transformers模型、Tokenzier、Trainer的核心库datasets数据集加载和预处理Dataset.map原生效率很高evaluateHuggingFace官方的指标库accuracy、f1等指标从这里加载accelerateTrainer在分布式、混合精度场景下的底层加速库新版Trainer默认依赖它顺带多说一句transformers的版本号更新很快网上很多老教程还在用evaluation_strategy这个参数。从4.41版本开始推荐用eval_strategy老参数虽然兼容会提示弃用。我下面统一用新版写法如果你的版本太老自动改成evaluation_strategy即可。2.2 国内网络环境下的模型下载配置HuggingFace官方域名在国内网络环境下经常超时一个最省事的方案是使用社区维护的镜像域名。设置环境变量即可export HF_ENDPOINThttps://hf-mirror.com如果不想每次终端都设置可以在Python脚本最前面写import os os.environ[HF_ENDPOINT] https://hf-mirror.com设置之后from_pretrained下载模型、分词器、数据集都会自动走镜像代码本身完全不需要改。这种实现很优雅因为你不必在代码里去拼一个“镜像版模型名”。此外还可以自定义缓存目录避免默认写满用户目录export HF_HOME/data/huggingface2.3 下载验证模型和分词器能跑起来环境配好后先做一次最小验证保证模型能正常加载from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name, num_labels4) print(tokenizer.tokenize(今天天气不错)) print(model.config.num_labels)如果这里没报错说明模型下载和缓存基本通了。常见的报错是OSError: Couldnt reach checkpoint检查HF_ENDPOINT是否设置、缓存目录是否有写权限。注意bert-base-chinese是中文BERT它的词表是按中文字切分的所以tokenizer.tokenize(今天天气不错)会输出五个单字这是正常现象不是bug。3. 数据准备BERT微调中80%的坑都出在这一步如果让我统计用户最常见的问题排在首位的不是训练参数而是数据准备。尤其是dataset.map这一步很多人完全不理解它到底做了什么。3.1 数据格式与label映射假设你做的是新闻分类任务手上有这样结构的CSV或JSONL数据data [ {text: 某球队在主场击败对手成功晋级决赛, label: sports}, {text: 某公司发布新一代芯片性能大幅提升, label: tech}, {text: 某电影上映首周票房破亿观众口碑良好, label: entertainment}, {text: 某上市公司公布季度财报净利润同比增长, label: finance}, ]BERT模型的输出层是一个分类头不能直接接收字符串标签所以要先做一个映射label2id { sports: 0, tech: 1, entertainment: 2, finance: 3, } id2label {v: k for k, v in label2id.items()}这里有一个容易出错的地方映射关系必须全局一致训练、评估、推理都要用同一份文件。我习惯把label2id单独保存成一个JSON部署时直接加载避免以后“线下模型用自己的映射、线上代码里又写了一份”导致的灾难。3.2 用Dataset.map做分词而不是手动循环把Python列表转成HuggingFace的Datasetfrom datasets import Dataset news_dataset Dataset.from_list(data)然后用map批量处理def preprocess(examples): model_inputs tokenizer( examples[text], truncationTrue, max_length128, ) model_inputs[label] [label2id[x] for x in examples[label]] return model_inputs news_dataset news_dataset.map( preprocess, batchedTrue, remove_columns[text], )三个细节值得展开。第一batchedTrue表示每次传入一个批量比逐条处理快很多内存开销也小。Tokenizer本身按batch处理时速度优势很明显。第二truncationTrue只做截断不pad。为什么不在这里paddingTrue如果分词阶段padding每条样本都会被pad到max_length数据集中全是无意义的[PAD]占用存储和内存。正确做法是分词阶段只出input_ids真正组batch时再由DataCollatorWithPadding动态填充到当前批量内的最大长度。这个设计在训练阶段能省至少20%的内存。第三remove_columns[text]只把原始文本删掉label列必须保留因为评估和计算loss都要用它。如果写成remove_columnsnews_dataset.column_nameslabel也会被删掉评估时直接报错。3.3 划分数据集小数据集直接用train_test_splitsplit news_dataset.train_test_split(test_size0.2, seed42) train_dataset split[train] eval_dataset split[test]真实项目里建议按时间或业务规则划分随机划分适合演示。3.4 一个必须理解的坑Trainer怎么找到labelsTrainer训练时会把数据集里的每一行当成模型forward的kwargs来用。BERT分类模型的forward参数里有input_ids、attention_mask、token_type_ids、labels没有text这个参数。如果数据集合里还残留text列Trainer默认的remove_unused_columnsTrue会自动帮忙移除但如果你自定义了compute_loss或模型结构比较复杂很可能触发“unexpected keyword argument text”这类报错。所以我的习惯是在预处理阶段就把不需要的列删干净不依赖Trainer默认清理逻辑。4. Trainer核心训练参数、回调函数与评估指标Trainer的参数体系由两部分组成TrainingArguments负责训练配置Trainer负责模型的组装和执行。这一节把最常用的配置逐项讲明白。4.1 TrainingArguments常用参数逐项解析from transformers import TrainingArguments training_args TrainingArguments( output_dir./bert-news-classifier, eval_strategysteps, eval_steps5, save_steps5, save_total_limit2, per_device_train_batch_size2, per_device_eval_batch_size2, num_train_epochs3, learning_rate5e-5, weight_decay0.01, warmup_ratio0.1, logging_steps1, load_best_model_at_endTrue, metric_for_best_modelaccuracy, fp16True, report_tonone, )下面这张表是我平时最关注的参数参数默认值作用我的建议output_dir无必填保存checkpoint和训练产物的目录每个任务单独一个目录方便管理eval_strategyno何时做评估可选steps或epoch调试阶段用steps更及时eval_steps500每隔多少step评估一次小数据集设5~10大数据量设几百save_steps500每隔多少step保存checkpoint与eval_steps保持一致最省心save_total_limit无最多保留几个checkpoint设2~3避免磁盘被撑爆per_device_train_batch_size8每张卡上的batch大小显存不足就调小靠累积补gradient_accumulation_steps1几个batch累积一次更新等价于增大batch size不影响显存learning_rate5e-5学习率BERT微调常用2e-5~5e-5weight_decay0.0L2正则化系数分类任务推荐0.01warmup_ratio0.0预热步数占总步数比例小数据量推荐0.1logging_steps500每隔多少step打日志调试期设1load_best_model_at_endFalse训练结束时加载最优checkpoint配合metric_for_best_model使用metric_for_best_modelloss判断最优模型的指标分类任务通常用accuracy或f1fp16False开启混合精度V100/T4/A100上显存和速度都受益report_toall日志输出到哪不喜欢用量子积分就设none这里有几个容易忽略的点。第一load_best_model_at_endTrue时必须保证save_strategy和eval_strategy频率一致。否则训练器不知道在哪里找到与评估对应的保存点经常报错或者找不到最优模型。第二fp16在CPU环境不可用。想在小笔记本上用CPU实验宁可不开。第三report_tonone不是必需但如果环境里没有装TensorBoard或WandB启动时可能因为尝试连接外部工具而报一些无关紧要的错误。调试阶段直接关了干净。4.2 Trainer初始化和compute_metrics基本初始化from transformers import Trainer, DataCollatorWithPadding data_collator DataCollatorWithPadding(tokenizertokenizer) import evaluate accuracy_metric evaluate.load(accuracy) def compute_metrics(eval_pred): logits, labels eval_pred predictions logits.argmax(-1) return accuracy_metric.compute(predictionspredictions, referenceslabels) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, data_collatordata_collator, compute_metricscompute_metrics, )compute_metrics接收到的是一个EvalPrediction对象展开后是logits和labels。logits通常是(batch_size, num_labels)的浮点数先argmax(-1)得到预测类别id再和真实标签算指标。这是BERT文本分类里最标准的评估写法。注意如果你需要更多指标比如宏平均F1可以直接用sklearn算from sklearn.metrics import accuracy_score, f1_score def compute_metrics(eval_pred): logits, labels eval_pred predictions logits.argmax(-1) return { accuracy: accuracy_score(labels, predictions), macro_f1: f1_score(labels, predictions, averagemacro), }这样绕开了evaluate.load的网络依赖后面排错部分我还会细说。4.3 Callback与早停早期停止在微调里很有用尤其当验证集指标在几个epoch后开始下滑时。from transformers import EarlyStoppingCallback trainer.add_callback( EarlyStoppingCallback(early_stopping_patience3) )这样连续3次评估指标不提升训练自动停止。也有很多人更愿意自己盯着loss曲线手动决定训练轮数。二者都可以的没有哪个绝对正确。5. 完整微调代码BERT新闻分类从零到训练完成现在把上面所有内容拼起来给你一份能直接跑的完整代码。5.1 完整可运行代码# -*- coding: utf-8 -*- import os os.environ[HF_ENDPOINT] https://hf-mirror.com from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, DataCollatorWithPadding, ) from sklearn.metrics import accuracy_score, f1_score # 1. 准备数据 data [ {text: 某球队在主场击败对手成功晋级决赛, label: sports}, {text: 某篮球队客场险胜拿到季后赛门票, label: sports}, {text: 某公司发布新一代芯片性能大幅提升, label: tech}, {text: 某科技企业推出在线办公新产品, label: tech}, {text: 某电影上映首周票房破亿观众口碑良好, label: entertainment}, {text: 某歌手发布新专辑多首歌曲进入榜单, label: entertainment}, {text: 某上市公司公布季度财报净利润同比增长, label: finance}, {text: 某消费品牌宣布完成新一轮融资, label: finance}, {text: 某球队引进强援备战新赛季, label: sports}, {text: 某公司展示新型人工智能助手, label: tech}, ] label2id {sports: 0, tech: 1, entertainment: 2, finance: 3} id2label {v: k for k, v in label2id.items()} dataset Dataset.from_list(data) split dataset.train_test_split(test_size0.2, seed42) train_dataset, eval_dataset split[train], split[test] # 2. 加载tokenizer和模型 model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labelslen(label2id), label2idlabel2id, id2labelid2label, ) # 3. 预处理 def preprocess(examples): model_inputs tokenizer( examples[text], truncationTrue, max_length128, ) model_inputs[label] [label2id[x] for x in examples[label]] return model_inputs train_dataset train_dataset.map(preprocess, batchedTrue, remove_columns[text]) eval_dataset eval_dataset.map(preprocess, batchedTrue, remove_columns[text]) # 4. 训练参数 training_args TrainingArguments( output_dir./bert-news-classifier, eval_strategysteps, eval_steps5, save_steps5, save_total_limit2, per_device_train_batch_size2, per_device_eval_batch_size2, num_train_epochs3, learning_rate5e-5, weight_decay0.01, warmup_ratio0.1, logging_steps1, load_best_model_at_endTrue, metric_for_best_modelaccuracy, fp16True, report_tonone, ) # 5. 评估函数 def compute_metrics(eval_pred): logits, labels eval_pred predictions logits.argmax(-1) return { accuracy: accuracy_score(labels, predictions), macro_f1: f1_score(labels, predictions, averagemacro), } # 6. Trainer trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, data_collatorDataCollatorWithPadding(tokenizertokenizer), compute_metricscompute_metrics, ) # 7. 开始训练 trainer.train()示例数据只有10条只是为了演示流程真实任务里建议至少几百条起步。模型分类效果和数据量直接相关这个不多说。5.2 训练过程日志怎么看训练时终端会打印类似这样的内容{loss: 1.3863, learning_rate: 4.999e-05, epoch: 0.02} {eval_loss: 1.04, eval_accuracy: 0.333, eval_runtime: 0.35, epoch: 0.04}loss在下降eval_accuracy在上升基本说明流程是对的。如果loss一直在1.386附近不降二分类熵值大约是0.693、四分类大约是1.386先不要怀疑Trainer先检查数据标签是不是几乎只有一个类别、label映射是否写对、学习率是否太小。5.3 模型保存与推理训练结束后这样保存trainer.save_model(./best_model)trainer.save_model()会同时保存模型权重、配置文件、tokenizer。如果你用旧版本或者不放心可以再显式保存一次tokenizer没有副作用tokenizer.save_pretrained(./best_model)推理时直接从目录加载loaded_model AutoModelForSequenceClassification.from_pretrained(./best_model) loaded_tokenizer AutoTokenizer.from_pretrained(./best_model) test_text 某球队夺得联赛冠军 inputs loaded_tokenizer(test_text, return_tensorspt, truncationTrue, max_length128) outputs loaded_model(**inputs) pred_id outputs.logits.argmax(-1).item() print(id2label[pred_id])因为保存模型时传了id2label所以配置文件里已经有映射新环境里同样能直接取到类别名。6. 实战排错文档里没写的报错和处理方法下面这几个问题都是我在实际微调过程中遇到过、或者被问过很多次的统一整理出来。6.1 CUDA out of memoryOOM是微调BERT最经典的报错。第一种解法调小per_device_train_batch_size从8降到4或2。第二种解法如果batch size调小导致收敛变差用梯度累积凑回等效 batch sizetraining_args TrainingArguments( per_device_train_batch_size2, gradient_accumulation_steps8, # 等效batch size为16 ... )第三种解法开混合精度fp16True显存占用能显著降低。第四种解法打开梯度检查点gradient_checkpointingTrue同时在初始化模型时设置model.config.use_cacheFalse。梯度检查点用计算换显存适合GPU显存紧张但不想牺牲batch size的情况。6.2 evaluate.load(accuracy)连不上网络evaluate.load(accuracy)第一次运行时需要下载指标脚本。如果你已经设置了镜像一般没问题。但如果你用的内网环境或者当前网络环境连接不顺畅可能报错。我的建议是不做特殊处理直接用sklearn。分类任务的accuracy、f1在sklearn里都有计算逻辑还透明。这不是退而求其次而是很多生产项目里本来就优先用sklearn。只要把compute_metrics里的逻辑换成纯本地计算即可。6.3 训练完predict结果不对这类问题通常有三个原因。第一个原因模型加载时没有恢复id2label导致输出数字无法对应类别名。解决办法是在保存模型前就通过AutoModelForSequenceClassification.from_pretrained(..., label2idlabel2id, id2labelid2label)把映射写进config。第二个原因推理时没有做truncationTrue遇到长文本超过512个token直接报错。推理代码里务必要带上truncationTrue, max_length128。第三个原因验证时随机打乱了顺序预测结果和真实标签对不上。Trainer内部评估时不会重新shuffle但你自己写评估脚本时要留意。6.4 训练loss不下降把训练配置挨个过一遍。learning_rate太高或太低。BERT微调实在不建议超过1e-4低于1e-6又基本不更新。warmup_ratio开太大。如果训练数据很少、总共没几个stepwarmup比例过大会让模型前期几乎不学习建议小数据量时直接设warmup_ratio0.1或干脆为0。label映射写错。多个原始label映射到了同一个id模型学到的是“合并类别的边界”表现自然不好。数据里的文本没有被正确的tokenizer处理导致input_ids几乎全是[PAD]。出现这种情况时打印一条预处理后的数据看两眼很快能发现问题。6.5 断点续训如果训练中断想在原基础上继续trainer.train(resume_from_checkpointTrue)它会自动识别output_dir下最新的checkpoint。配合save_total_limit3既不会丢失进度也不会占用大量磁盘空间。7. 从BERT到其他任务Trainer能不能“一鱼多吃”很多用户跑通文本分类后会问能不能用同一套流程做句子对分类、NER、多标签分类、生成任务甚至去微调CLIP。这一节逐个说清楚边界。7.1 句子对分类句子对分类和单文本分类的代码几乎一样变化只在预处理和模型调用上。以文本蕴含、语义相似度任务为例def preprocess_pair(examples): model_inputs tokenizer( examples[text1], examples[text2], truncationTrue, max_length128, ) model_inputs[label] examples[label] return model_inputs模型还是AutoModelForSequenceClassification数据和训练流程都不变。Tokenizer会自动生成token_type_ids用于区分两个句子。7.2 多标签分类多标签分类不能照搬单标签的代码。BERT分类模型默认使用交叉熵损失而多标签任务通常使用BCEWithLogitsLoss。Trainer默认不会帮你切换损失函数需要自定义import torch.nn as nn from transformers import Trainer class MultiLabelTrainer(Trainer): def compute_loss(self, model, inputs, return_outputsFalse): labels inputs.pop(labels).float() outputs model(**inputs) logits outputs.logits loss nn.BCEWithLogitsLoss()(logits, labels) return (loss, outputs) if return_outputs else loss评估时也要改预测结果不再用argmax而是用sigmoid后按阈值判断哪些类别命中import torch def compute_metrics_ml(eval_pred): logits, labels eval_pred probs torch.sigmoid(torch.tensor(logits)) preds (probs 0.5).int().numpy() # 然后按自己的方式计算指标比如子集准确率或多标签F1这是Trainer很典型的一个扩展点。7.3 Token分类任务NERNER任务用AutoModelForTokenClassification预处理时每个token都要有一个label且被截断后要处理标签对齐。即使不做实体级指标至少要知道label_ids中对[PAD]等特殊token的label必须是-100这样计算loss时会被忽略。数据整理时准备两个列input_ids和对应的labels。Tokenizer的输出可能比原文本长一个[CLS]所以标签序列前面通常补一个-100。这里有一个专门的DataCollatorForTokenClassificationfrom transformers import DataCollatorForTokenClassification data_collator DataCollatorForTokenClassification(tokenizertokenizer)它会自动处理padding时标签的-100对齐。手动写很容易错位建议直接用。7.4 为什么CLIP微调不能照搬这套热搜词里经常看到“CLIP模型微调”这里补充说明一下。CLIP的结构是双塔一个文本编码器、一个图像编码器通过对比学习把图文表征拉近。它的训练损失不是BERT微调用的交叉熵而是Image-Text Contrastive Loss需要在一个batch内同时构造正样本和负样本。这个逻辑无法通过AutoModelForSequenceClassification这种“单塔加分类头”的默认结构来实现。直接用Trainer来跑CLIP的端到端对比学习不是不行但要重写compute_loss、自定义DataCollator把图像和文本拼到一个batch里还要处理图文对的双流forward。付出的工作量基本等于自己写一套训练框架。所以开源项目里极少有人用Trainer微调CLIP本体更多是借助OpenCLIP这种专门库。如果只是用CLIP做特征提取然后在上面加个分类层那Trainer依然好用因为你微调的已经不是CLIP的对比学习结构了。7.5 Seq2Seq任务换个Trainer如果是翻译、摘要这种生成任务同样是HuggingFace生态但要用Seq2SeqTrainer和Seq2SeqTrainingArguments并且评估时要设置predict_with_generateTrue。原因是生成任务的评估需要真正decode出文本不能只看logits。虽然名字带Seq2Seq但使用套路和Trainer一致数据准备、metric函数逻辑仍然类似换汤不换药。我自己的习惯是接到一个新任务后先不要急着写复杂配置。先用最小数据集把Trainer跑通只保留预处理和最简单的compute_metrics记录一次train和eval然后逐步加重参数。90%的报错都能控制在这个最小范围内。模型保存时也一定要把tokenizer一起保存这一步能让你在部署阶段少踩很多坑。希望这篇能把你在BERT微调路上绕的几个弯省掉。
返回列表