ARTICLE DETAIL

资讯详情

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

Transformers Trainer 超参数搜索实战:从手动调参到自动优化

Transformers Trainer 超参数搜索实战:从手动调参到自动优化 训练脚本最磨人的部分从来不是模型结构怎么搭而是那一堆超参数到底定多少。learning_rate 用 2e-5 还是 3e-5batch size 能不能上 32warmup ratio 0.06 是不是太保守每次都是改一个数、跑一轮、看曲线、再改一个数大模型一跑就是几小时手动调参基本等于加班。直到我把 transformers 里 Trainer 自带的 hyperparameter_search 方法认真用起来才把调参这件事从碰运气变成自动化。这篇文章就围绕这个方法讲清楚调用方式、参数空间设计、搜索后端选型以及在真实项目里踩过的几个坑适合已经在用 Trainer 跑训练、但还没接触过自动搜索的读者。1. 为什么非要用 hyperparameter_search手动调参的血泪教训1.1 手动调参到底在消耗什么先说一个我自己的例子。去年做一个文本分类任务基础模型是 bert-base训练脚本早就写得能一键跑了但每次上线前都要过一遍调参。learning_rate 从 3e-5 调到 2e-5eval_loss 降了一点点再把 warmup_ratio 从 0.1 改成 0.06又降了一点点。每改一次就要重新训一个完整模型单卡 A100 也要跑四十分钟到一个小时。一个下午过去可能只试了四到六组参数而且这些参数之间还互相影响。超参数最让人头疼的就是它们不是独立起作用的。学习率和 warmup 配合不好前期 loss 会抖动batch size 变了之后学习率的最优区间也会跟着移动。手动调参时你永远是在上一个参数的基础上再试下一个但实际上参数空间是高维的沿着一条线走很容易走进局部最优的胡同里。hyperparameter_search 解决的就是这个问题让搜索算法在参数空间里主动采样跑完一组后根据历史结果修正采样方向再试下一组。你只需要定义好哪些参数可以变、允许在什么范围变、用什么指标评价好坏剩下的采样、重试、记录都由框架完成。1.2 它和网格搜索、随机搜索的本质区别很多人一听到超参数搜索第一反应是 grid search就是每组参数都列出来挨个跑一遍。但 grid search 的问题是维度爆炸4 个参数、每个参数 5 个候选值就是 5 的 4 次方等于 625 次训练。哪怕每次只跑 10 分钟也是四天半。hyperparameter_search 的实际行为更接近智能抽样。以默认比较常用的 optuna 后端为例它用的是 TPETree-structured Parzen Estimator这类贝叶斯优化算法先随机跑几轮热热身然后每一轮都基于已有 trial 的评估结果在看起来更有希望的参数区域里采样。直观理解就是它像一个有经验的调参师傅越试越知道该往哪个方向走而不是傻傻地把所有组合都跑一遍。1.3 使用边界不是所有场景都该上搜索有一点要先说清楚hyperparameter_search 不是万能药。训练一次要十几个小时、或者单次训练成本极高的情况下与其开一个 n_trials50 的大搜索不如先想办法缩短单轮训练时间比如用子集数据预热搜索确认趋势后再全量验证。另外如果模型和数据几乎不怎么变已经有了一套被验证过很多次的参数组合也不需要天天搜。所以我的判断标准很简单单次训练时间在 10 到 40 分钟左右、超参数之间有明显耦合、而且你愿意让脚本自己在后台跑上一到两晚的时候hyperparameter_search 的价值最大。整个过程下来你实际是在用机器时间换人的判断时间。2. hyperparameter_search 的完整调用链路从 hp_space 到 best_run2.1 方法签名与最小可用示例要理解这个方法先看它的核心参数hp_space一个函数输入是搜索后端给的 trial 对象输出是一个超参数名字到建议值的字典。compute_objective一个函数输入是每次 trial 结束时的评估指标字典输出一个用于比较的标量。n_trials最多尝试多少组参数。direction标量目标的方向minimize还是maximize。backend搜索后端常用optuna、ray、sigopt。hp_name可选用来给每个 trial 生成唯一名字。下面是我通常跑通的最简形式from transformers import Trainer, TrainingArguments def hp_space(trial): return { learning_rate: trial.suggest_float(learning_rate, 1e-6, 5e-5, logTrue), per_device_train_batch_size: trial.suggest_categorical(per_device_train_batch_size, [8, 16, 32]), num_train_epochs: trial.suggest_int(num_train_epochs, 2, 5), weight_decay: trial.suggest_float(weight_decay, 0.0, 0.3), warmup_ratio: trial.suggest_float(warmup_ratio, 0.0, 0.2), } def compute_objective(metrics): return metrics[eval_loss] training_args TrainingArguments( output_dir./search_results, evaluation_strategyepoch, logging_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_ds, eval_dataseteval_ds, tokenizertokenizer, ) best_run trainer.hyperparameter_search( hp_spacehp_space, compute_objectivecompute_objective, n_trials20, directionminimize, backendoptuna, ) print(Best run id:, best_run.run_id) print(Best hyperparameters:, best_run.hyperparameters)这段代码跑完best_run.hyperparameters里就是搜索到的最优参数组合。你可以直接拿这个大括号覆盖 TrainingArguments 里的对应值。2.2 hp_space 参数空间的设计细节hp_space 是整个搜索的候选池子怎么定义直接决定结果质量。第一个原则是分布形状要贴合参数性质。learning_rate 这种跨越几个数量级的参数一定要用logTrue让采样在对数空间里进行。否则你在 1e-6 到 5e-5 之间均匀采样大部分样本会集中在 2e-5 到 3e-5 这段数值密集区1e-6 附近几乎不会被选中小学习率区域被白白浪费掉。第二个原则是控制搜索维度。一开始我习惯把能用到的参数全塞进去learning_rate、batch size、epochs、weight_decay、warmup_ratio、gradient_accumulation_steps甚至有段时间还加了 optimizer 类型结果 trial 一多单轮训练又长整个搜索看得人着急。现在我的习惯是先固定住那些影响不明显或者必须由业务决定的参数只让 4 到 6 个高敏感度参数参与搜索。参数维度少搜索算法收敛得更快每个维度的采样密度也更高。第三个原则是 batch size 和梯度累积步数要联动。如果你允许 batch size 变化最好同时允许 gradient_accumulation_steps 变化让两者的乘积大致维持在一个稳定范围附近。否则一个 trial 的实际全局 batch size忽大忽小损失函数的形态也跟着变搜出来的学习率经验很难迁移到后续手工调整的场景中。2.3 compute_objective 与 direction 的搭配compute_objective 接收的 metrics 字典来自 Trainer 的 evaluate()里面会有eval_loss、epoch、当前模型相关的指标如果你设置了额外的compute_metrics回调还会有 accuracy、f1 之类的结果。这里最容易翻车的地方是 direction 和指标方向不一致。你想让 accuracy 尽可能高但 compute_objective 里写的却是metrics[eval_loss]同时 direction 设成了maximize那结果就是算法在拼命把 loss 往上推完全反着来。建议固定一套习惯凡是用 loss 类指标direction 就写minimize凡是用 accuracy、f1 这类指标direction 就写maximize并且 compute_objective 里明确返回这个指标。另外如果 eval 集很小或者评估波动很大不建议拿单次 eval 值直接当目标。我遇到过一个三千条数据的验证集loss 在相邻两个 epoch 之间能抖出 0.05 的幅度而真正好的参数之间的差距可能也就 0.03。这种情况下单次评估的噪声会覆盖参数差异搜索算法很容易被带偏。可以考虑用多次评估的平均值或者采用验证集上的平滑指标。2.4 三种后端的取舍transformers 支持三个主要的搜索后端区别如下后端安装方式特点适合场景optunapip install optuna轻量、单机友好TPE 算法开箱即用支持 trial 剪枝绝大多数单机/单卡场景我最推荐ray tunepip install ray[tune]分布式能力强支持大规模并行和资源调度多机多卡集群、几百上千次 trial 的大规模搜索sigopt官方服务 SDK需要账号和 API token托管式优化服务企业内部有 sigopt 平台、需要集中管理实验时我平时 90% 的场景都用 optuna。它不需要额外的服务端不需要复杂集群配置pip install optuna之后配合 Trainer 就能跑。ray tune 在同等功能下配置成本明显更高除非你真的需要跨机器并行否则先别折腾它。3. 日志上报与 trial 命名一次同名配置冲突的完整排查3.1 报错现场与触发场景有段时间我在搜索时用到了 aim 做实验跟踪同时给 TrainingArguments 手工指定了一个固定的run_nameaimv2。第一次跑搜索没问题但第二次把同一个脚本重新跑起来时训练直接在中途报错提示内容大致是aimv2 is already used by a transformers config, pick another name.这个报错看起来像模型加载问题一开始我也是朝那个方向去查的结果排查了半天发现根本和模型权重无关。它是说你在当前环境中试图注册的 transformers 配置名称已被占用必须换一个名字。触发场景其实很明确每次都复用了同一个run_name。第一次搜索跑完后output_dir和日志目录里已经留下了这个名称对应的配置记录第二次搜索的第一个 trial 再尝试用一模一样的名称去注册新的配置就撞上了。3.2 逐层排查过程我当时先做了三步第一步确认环境里有没有~/.cache/huggingface/transformers下面残留的同名配置文件。简单扫一眼 cache 目录名字确实对得上。第二步检查 TrainingArguments 里的output_dir和run_name。发现脚本里run_name是写死的字符串没有按 trial 区分。而 hyperparameter_search 每次 new trial 虽然会更新模型参数但 TrainingArguments 对象本身是从外部传入的里面的 run_name 一直是同一个值。第三步看 aim 的日志记录。它记录实验也是按 run 名来区分的同一个名字第二次出现时目标平台会拒绝重复注册。这一步基本就确认了根因不仅是 transformers 的配置名连实验跟踪平台的 run 名也要求唯一。3.3 根治方案解决分两条线短期是让每个 trial 都有唯一名字长期是避免在搜索脚本里写死任何 run 标识。对应到代码上用hp_name参数为每个 trial 生成带编号的名字def hp_name(trial): return ftrial-{trial.number} best_run trainer.hyperparameter_search( hp_spacehp_space, hp_namehp_name, n_trials20, directionminimize, backendoptuna, )同时把 TrainingArguments 里的run_name去掉或改成动态值让 Trainer 按 trial 自己生成。如果你确实需要保留 run_name也要确保里面带上 trial 编号training_args TrainingArguments( output_dir./search_results, run_namefsearch-{time.time()}, ... )另外有条件的话把 output_dir 也按搜索批次区分开比如./search_results/202405。我固定下来这个习惯之后同类报错再也没有出现过。3.4 这一类名字冲突的共同特征这个坑给我的最大感受是超参数搜索天然会重复执行同一个训练流程很多次任何在普通单次训练里不敏感的静态配置到了搜索场景都会变成冲突源。除了 run_name还有 checkpoint 目录、日志文件、实验跟踪平台的项目名全部要确保按 trial 隔离。我现在写搜索脚本前的检查清单就三条训练产物目录是否带唯一标识、run_name 是否动态、各个监控面板上是否会出现同名列。如果都是动态生成的问题概率能下降一大截。另外如果确定是残留配置导致的冲突也可以直接清理目标 cache 目录但要注意清理前备份避免误删其他在用模型。4. 搜索效率的掌控早停、指标设计与 trial 数量的平衡4.1 剪枝与中途停止别把时间烧在明显没希望的 trial 上n_trials 设成 20 或者 30不代表每个 trial 都必须完整训练到最后一个 epoch。optuna 本身支持剪枝transformers 的 OptunaBackend 也会在训练过程中把中间指标上报给 study。只要你在 study 层面配置了合适的 pruner比如 MedianPruner那些前几个 epoch 就明显落后于历史中位数的 trial 会被提前终止把算力留给更有潜力的组合。我自己实测的效果是加上中间剪枝之后20 个 trial 的搜索总耗时能比不加剪枝时省出差不多三分之一。因为很多参数组合在第二个 epoch 就看得出不行了硬撑到第五个 epoch 纯属浪费。需要留意的是剪枝判断依赖的是评估指标。所以 TrainingArguments 里的evaluation_strategy不能设得太过稀疏最少也得一个 epoch 评估一次否则上报点太少剪枝器没有足够的信息做判断。4.2 指标设计上的一些分寸上面提过单次评估噪声的问题这里再展开一点。我在搜索文本分类模型时实际上最关心的是 f1但 f1 在验证集上波动比较大。后来我的做法是 compute_objective 返回 eval_loss 和 f1 的组合或者是直接返回 f1但对验证集做了多次采样求平均。具体用哪种要看业务目标你要是上线前只需要一个相对靠谱的模型search 阶段用 loss 做目标通常足够稳定如果你的验收标准就是 f1那还是得让搜索算法直接对着 f1 优化。另一个分寸是 epochs 的搜索范围。很多人喜欢把num_train_epochs也放进搜索空间这本身没问题但要注意和早停配合。因为 epochs 多的 trial 天然有机会把 loss 压得更低如果不用早停或者评估机制拦住它搜索最后容易偏向训练更久而不是参数更好。我在 Trial 级别会配合一个最大 patience比如连续两个 epoch 评估指标没有改善就停止该 trial这样搜索出来的 epoch 数才真正反映模型收敛的位置。4.3 从搜索结果里读什么best_run.hyperparameters是一个字典我把每个 key 打出来之后不只是直接拿它去跑最终训练。我会把它和搜索过程中其他表现比较好的 trial 参数放在一起看特别是看参数的稳定区域。比如如果五个好 trial 的 learning_rate 都落在 2e-5 到 3e-5 之间那说明这个区间对当前任务是稳定的如果好 trial 的 warmup_ratio 从 0.02 到 0.3 都有那说明这个参数对结果不敏感后续可以随便取中值不用再浪费算力去搜它。这种学了参数分布规律的价值其实比那一组 best 参数本身更有意义。5. 我这套能直接抄的配置与过程中的体会5.1 一套完整的搜索配置参考下面是我现在比较常用的一整套配置可以直接改改模型名和数据集使用import time from transformers import ( Trainer, TrainingArguments, AutoModelForSequenceClassification, AutoTokenizer, ) def hp_space(trial): return { learning_rate: trial.suggest_float(learning_rate, 1e-6, 5e-5, logTrue), per_device_train_batch_size: trial.suggest_categorical(per_device_train_batch_size, [8, 16]), gradient_accumulation_steps: trial.suggest_categorical(gradient_accumulation_steps, [2, 4, 8]), num_train_epochs: trial.suggest_int(num_train_epochs, 2, 5), weight_decay: trial.suggest_float(weight_decay, 0.0, 0.3), warmup_ratio: trial.suggest_float(warmup_ratio, 0.0, 0.2), } def hp_name(trial): # 确保每个 trial 有独立名字避开同名配置冲突 return ftrial-{trial.number} def compute_objective(metrics): return metrics[eval_loss] args TrainingArguments( output_dirf./search_{int(time.time())}, evaluation_strategyepoch, logging_strategyepoch, save_strategyepoch, save_total_limit2, load_best_model_at_endTrue, report_tonone, # 不想接任何实验平台时可以关掉 ) trainer Trainer( modelmodel, argsargs, train_datasettrain_ds, eval_dataseteval_ds, tokenizertokenizer, ) best_run trainer.hyperparameter_search( hp_spacehp_space, hp_namehp_name, compute_objectivecompute_objective, n_trials24, directionminimize, backendoptuna, )这套配置里我故意保留了gradient_accumulation_steps让它在固定显存下等价于更大的全局 batch size这样 batch size 和累积步数的乘积不至于偏离太多搜索出来的学习率也更可复用。5.2 几个容易翻车的小细节先说说显存问题。per_device_batch_size如果设了 32而机器显存只够 8那么 trial 一启动就会 OOM 崩溃。我的处理是先手工测一下当前模型在目标设备上最大能跑多大 batch再把这个上限作为 categorical 候选的最高档宁可保守一点。然后是数据集加载。如果你在每次 trial 里都对全量数据集做一次重新 map 或 tokenize那搜索的绝大多数时间会花在数据预处理上。正确做法是先统一把数据处理好、缓存成 Arrow 文件trial 里只用datasets.load_from_disk加载现成结果。这个优化通常能让搜索总时间缩短一半以上。还有一点搜索过程中要定期看一眼 output_dir 里的日志。我遇到过某个 trial 因为数据里混入异常样本loss 直接跳到 nan如果没盯着日志后面十几个 trial 可能全都白跑。遇到这种情况优先检查数据清洗而不是调搜索参数。5.3 我个人的几点体会用了一年多 hyperparameter_search我觉得它最大的价值不是帮你找到那一组绝对最优参数而是把调参这个环节从玄学变成了可以审计、可以回放、可以复现的实验流程。每个 trial 的参数、指标、日志都有记录谁用了什么配置、跑出了什么结果一目了然。团队协作时这份记录比聊天记录里一句我试过 2e-5 效果好要可靠得多。另外再分享一个小技巧搜索完拿到 best_run 之后不要急着直接全量训练。拿 best 参数在验证集上再跑一个 5 次随机种子的平均值确认稳定性后再上生产。一次搜索只能证明这组参数在这个 eval 划分上不错种子不同可能结果差异明显。多算几次平均心里才踏实。如果后续你想扩展也可以把 hyperparameter_search 的结果写进配置文件再套一层循环做交叉验证。搜索本身省下来的时间足够你再做很多真正重要的事情。
返回列表