ARTICLE DETAIL

资讯详情

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

深度学习优化器配置与调参实战:从损失不收敛到自动搜索

深度学习优化器配置与调参实战:从损失不收敛到自动搜索 Model-Optimizer 这个名字听起来像是要再造一个优化器轮子但真正让我动手写它的原因是一段让我印象极深的训练事故。当时我维护的那个 Transformer 分类模型已经连续两轮实验不收敛loss 在前 200 步疯狂爬升最后直接变成 NaN。排查了两天问题根本不在模型结构也不在数据 pipeline而是三个实验脚本里的优化器配置各写各的一个用了 AdamW 但权重衰减设得极度激进一个切回 Adam 却没改 betas还有一个干脆用 SGDmomentum动量参数复制错了还没发现。从那时候起我就决定把优化器这一环从训练脚本里抽出来做成一个统一管理、可复现、能自动调参的独立模块。这篇文章就是我把这个模块慢慢搭起来、并在各种模型上反复验证之后的全套经验适合刚入门深度学习、以及正在被各种 loss 曲线折磨的算法工程师参考。1. 为什么我会自己写一个 Model-Optimizer1.1 一次训练事故把我逼到了墙角先说那次事故的具体经过。模型是一个标准的 12 层 Transformer encoder做文本分类数据量不大按道理随便调一调都能收敛。但第一次实验跑起来之后loss 在 50 步左右开始上涨到 200 步直接变成 NaN。我第一次反应是学习率太大把 lr 从 3e-4 降到 1e-4结果 loss 还是在涨。第二次怀疑数据里有脏标签清洗了一遍没用。真正定位到问题是因为我把三个实验脚本的optimizer部分并排放在一起对比# 脚本 A optimizer AdamW(model.parameters(), lr3e-4, weight_decay0.1) # 脚本 B optimizer Adam(model.parameters(), lr3e-4, weight_decay0.01) # 脚本 C optimizer SGD(model.parameters(), lr3e-4, momentum0.99)三个脚本看起来都挺正常但组合在一起就完全不可比。脚本 A 用的是 AdamWweight_decay0.1对 12 层 Transformer 来说已经偏大配合预训练模型微调场景很容易把参数拉得过紧脚本 B 用 Adam 理解weight_decay的方式是 L2 正则和 AdamW 的解耦衰减根本不是一回事脚本 C 的 momentum 设成 0.99接近边界收敛稳定性很差。我把这三个脚本的配置统一到一个配置文件中修好之后第一件事不是继续调参而是把项目里所有optimizer相关逻辑全部抽出来准备做一个独立的模块。1.2 工具定位不重复造轮子只把优化器这件事管明白Model-Optimizer 不是一个要替代 PyTorch、TensorFlow 的框架它的定位非常窄只负责训练循环中优化器这一环的决定、创建、调度、监控和搜索。它做的事情可以用一句话概括给你一个干净的配置入口把 optimizer、lr scheduler、梯度裁剪、混合精度、状态保存这些零零碎碎的东西统一管理起来。我见过太多团队代码库里每个训练脚本都有一份自己手写的 optimizer 初始化代码换个人就换一种写法。有的把betas写成(0.9, 0.99)有的写(0.9, 0.999)有的干脆不写。这些细节在没有对比实验的时候都看不出来一旦要做消融实验或者调参对比光是统一基准就要花上大半天。Model-Optimizer 想解决的就是这个问题让优化器配置像一份可以签名的合同一样从训练脚本里独立出来。这个工具的核心能力包括统一配置所有优化器参数在一个OptimizerConfig里支持 SGD、Momentum、Adam、AdamW、LAMB、Lion 等常见选择。调度器组合内置 linear warmup cosine decay、polynomial decay、常量学习率等方案直接和优化器搭配。训练钩子梯度裁剪、AMP 梯度缩放、梯度日志全都在optimizer.step()前后自动处理。状态管理断点续训时能完整保存 optimizer state、scheduler state、随机种子并且在配置变更时给出明确告警。它也有明确不做的事情不接管模型架构、不处理数据加载、不替你选择 loss 函数。边界收得越窄模块就越容易在不同项目里复用。1.3 谁适合直接抄这个思路如果你是以下三种情况可以把这套思路直接搬走一个人维护多个实验项目每个项目都用不同的数据集、不同的模型规模最怕的就是每个项目里 optimizer 配置风格不统一。用一份标准配置可以少很多心智负担。团队协作开发训练代码被多个人改来改去optimizer 参数写在哪里的都有。把优化器配置锁在一个独立模块里code review 会轻松很多。刚入门的同学想搞懂优化器当你能把配置从训练脚本里抽出来的时候就不得不去理解每一个参数的含义这个过程本身就是最好的学习。如果只是单次比赛或者一次性模型训练那确实没有必要专门搭一个模块。但当实验次数超过 20 次、模型规模跨过千万参数之后统一管理的收益会非常明显。2. 优化器的底层逻辑先搞清楚它们在做什么2.1 SGD 与动量最朴素的参数更新我们平时说优化器本质是在回答一个问题给定当前梯度和历史梯度下一步参数应该往哪走、走多远。最简单的答案是w w - lr * grad这就是 SGD。它的问题很明显如果 loss landscape 像个窄长的山谷SGD 会在山谷两侧来回震荡收敛很慢如果某个维度梯度非常小SGD 需要很久才能沿着该方向移动。Momentum 引入了一个速度变量v每一轮先让速度累计一部分历史梯度再用来更新参数v_t momentum * v_{t-1} lr * grad_t w_t w_{t-1} - v_t这就好比推一个沉重的球下山虽然每一瞬的方向可能有波动但球整体会沿着谷底方向滚动。Momentum 的典型值是 0.9越大越平滑但太大容易发飘。之前脚本 C 把 momentum 设成 0.99就是踩了这个边界。2.2 Adam 为什么能自适应一阶矩与二阶矩Adam 在 Momentum 之外增加了一个每个参数自适应学习率的机制。它维护一阶矩m_t梯度的指数滑动平均和二阶矩v_t梯度平方的指数滑动平均然后更新参数时用m_t / sqrt(v_t)作为有效方向。直观理解是如果一个参数的梯度一直很大v_t就很大更新步长会被自动压小如果梯度很稀疏v_t变小更新步长会被放大。这也是 Adam 在很多任务上不太挑学习率的原因。它内部把每个参数的历史梯度都归一化了所以全局学习率不需要像 SGD 那样细腻地调整。但代价是二阶矩的估计在小批量上往往有偏训练初期v_t太小会导致步长偏大所以 Adam 论文里用了偏差修正。也正因如此Adam 对初始学习率依然有敏感区间只是这个区间比 SGD 宽一些。2.3 AdamW 和 Adam 的微妙差别权重衰减的语义变了AdamW 是 ICLR 2019 那篇《Decoupled Weight Decay Regularization》里的工作核心变化是把 L2 正则和自适应学习率解耦。在标准 Adam 里weight_decay通过往梯度里加weight_decay * w实现所以衰减量会被二阶矩缩放。这会导致一个很反直觉的现象某个参数梯度很小二阶矩也小L2 正则的衰减反而被放大。AdamW 则直接把衰减作用在参数更新之后也就是w w - lr * (m_t / sqrt(v_t)) - lr * weight_decay * w和梯度无关。这个区别在 Transformer 上尤其关键因为预训练模型里大量参数长期处于梯度很小、但范数很大的状态。如果用 Adam 带着较大的weight_decay那些参数会被持续压缩切到 AdamW 之后同样的weight_decay会表现为更规范、更可控的衰减。这也是后来绝大多数大模型预训练都选择 AdamW 的原因。下面这张表可以快速对比优化器核心机制weight_decay 语义典型场景SGD直接按梯度更新L2 正则直接加梯度CV 小模型、resnet 系列Momentum历史梯度累积速度L2 正则比 SGD 快容易震荡Adam一阶矩 二阶矩自适应L2 正则被二阶矩缩放通用 NLP/CV参数不敏感AdamWAdam 解耦权重衰减直接的参数衰减预训练、微调、大模型LAMBAdamW 逐层自适应缩放解耦权重衰减大批量 BERT 训练Lion符号函数更新省内存解耦权重衰减大模型的轻量替代2.4 LAMB、Lion 这类新面孔在解决什么LAMB 的出发点是解决大批量训练下学习率缩放失灵的问题。它把优化器更新量按层归一化每层的更新范数被限制在固定比例内使得 64K batch size 上可以直接把学习率放大很多倍。如果你是在 8 卡以上做预训练或者做超大批量对比学习LAMB 值得试如果是单卡常规训练收益不明显。Lion 则更激进它把一阶矩的更新用sign()函数替代保留二阶梯矩的估计。因为sign()不涉及乘法计算显存占用和计算量都更小。Google 在 2023 年的论文里用它训练 Vision Transformer 和扩散模型效果不错。但要注意Lion 的学习率一般要比 AdamW 小大约 10 倍直接沿用旧学习率几乎必炸。Model-Optimizer 里我并没有把所有优化器做成黑盒而是保留了从 PyTorch、timm、HuggingFace 等标准库直接创建的机会。因为优化器这个领域进化太快今天有一个新的 LAMB 变体明天可能就有个 TAM 之类的封装越薄越容易跟进。3. Model-Optimizer 的核心设计配置、调度与状态管理3.1 用一份配置管住所有优化器Config 与注册表我在设计配置层的时候参考的是 PyTorch 生态里常见的数据类风格。一个最小可用的配置长这样from dataclasses import dataclass, field dataclass class OptimizerConfig: name: str adamw # adam, adamw, sgd, lamb, lion lr: float 1e-4 # 基础学习率 weight_decay: float 0.01 # 权重衰减 betas: tuple (0.9, 0.999) # Adam 系列的一阶/二阶动量系数 eps: float 1e-8 # 数值稳定项 momentum: float 0.9 # SGD/Momentum 使用 fused: bool False # 是否使用 fused 版本 scheduler: str cosine # constant, cosine, polynomial warmup_ratio: float 0.06 # warmup 步数占总步数比例 max_grad_norm: float | None 1.0 # 梯度裁剪阈值创建优化器的逻辑用一个注册表实现。这个设计来自大部分框架里常见的 pattern非常实用OPTIMIZER_BUILDER { adam: Adam, adamw: AdamW, sgd: SGD, lamb: LAMB, lion: Lion, } def build_optimizer(model, config: OptimizerConfig): # 根据 name 找到类再把配置转换成对应的 kwargs kwargs { lr: config.lr, weight_decay: config.weight_decay, } if config.name in (adam, adamw): kwargs[betas] config.betas kwargs[eps] config.eps if config.fused: kwargs[fused] True if config.name in (sgd,): kwargs[momentum] config.momentum return OPTIMIZER_BUILDER[config.name](model.parameters(), **kwargs)为什么说注册表而不是一堆 if-else因为新优化器进来时只需要注册一行已有的配置签名完全不动。旧实验的config.name也仍然有效不会因为加了新优化器就破坏可复现性。3.2 学习率调度器warmup 与 cosine 的固定搭配配置里我把学习率调度器和优化器放在同一条 pipeline 里管理。Linear warmup cosine decay 是我在几乎所有 Transformer、ViT 以及扩散模型实验里的首选组合原因有两层第一层是 warmup 的必要性。训练初期优化器的动量项还没有积累历史信息二阶矩对梯度幅度的估计也不准此时直接上大学习率容易让参数一步跨出合理区域。尤其是 Adam/AdamW训练前几步的更新步长可能因二阶矩偏小而被放大所以常见做法是先用一个很小的学习率热身若干步。按照论文实践语言模型的 warmup 比例常见是 6%~10%。第二层是 cosine decay 的性质。它从一个初始学习率平滑降低到接近 0后期学习率变小时参数更新越来越精细比 step decay 更容易做消融。配合 warmup整条曲线是升-平-降的形态很少出现策略性跳变带来的 loss spike。在 Model-Optimizer 里调度器不需要用户单独创建它会根据total_steps自动生成def build_scheduler(optimizer, config, total_steps): if config.scheduler constant: return ConstantLR(optimizer) warmup_steps int(total_steps * config.warmup_ratio) if config.scheduler cosine: return SequentialLR( optimizer, schedulers[ LinearLR(optimizer, start_factor0.01, total_iterswarmup_steps), CosineAnnealingLR(optimizer, T_maxtotal_steps - warmup_steps), ], milestones[warmup_steps], ) return None这样训练脚本里只剩一个scheduler.step()调用不会再出现warmup 忘记开、scheduler 用了但 step 位置不对这类低级问题。3.3 梯度裁剪与混合精度官方文档不讲的顺序梯度裁剪进入训练循环之后最容易踩的坑是裁剪后的梯度不再匹配缩放的问题。如果在混合精度 AMP 场景里用了 PyTorch 的GradScaler原始流程是scaler.scale(loss).backward()此时梯度是被放大的。直接在放大后的梯度上做clip_grad_norm_()阈值语义就变了而且可能触发数值问题。正确顺序是先让scaler.unscale_(optimizer)把梯度恢复成真实值再做裁剪最后再调用scaler.step(optimizer)scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), config.max_grad_norm) scaler.step(optimizer) scaler.update()如果把clip_grad_norm_放在unscale_之前要么梯度值被放大导致裁剪比例失真要么在某些后端下直接得到放大数倍的 grad norm排查时特别迷惑。Model-Optimizer 把这一组操作封装成了一个钩子默认按上面顺序执行同时对外暴露一个after_unscale回调方便在某些特殊场景里插入自定义逻辑。3.4 断点续训优化器状态也要做版本管理断点续训不只是保存模型权重优化器里的state同样关键。Adam/AdamW 的动量项不保存重启后优化器会丢失历史梯度信息等于一个全新优化器从第 0 步开始跑对训练影响很大。Model-Optimizer 的 checkpoint 保存接口会组合保存以下内容checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), scaler: scaler.state_dict() if scaler is not None else None, config: asdict(optimizer_config), epoch: epoch, step: global_step, }还有一个细节加载 checkpoint 时如果发现当前配置和保存的配置不一致我选择直接报错而不是静默继续。因为优化器类型、学习率、权重衰减变了之后optimizer state 里的step、动量缓存等依然存在容易让训练在错误假设下继续。宁可把状态清空重来也不要带病训练。训练脚本里只保留一个load_checkpoint(ckpt_path)调用避免手写 state_dict 的嵌套索引越写越乱。4. 实战调参我踩过的坑和验证过的经验4.1 loss spike 的前 200 步问题出在 warmup 曲线有段时间我在调一个 3 亿参数的生成模型固定学习率 3e-4warmup_ratio设了 0.01总共 4 万步即 400 步 warmup结果第 180 步附近出现明显的 loss spike峰值直接涨了几十倍。一开始我怀疑是数据 pipeline 混入了异常样本清洗了一遍没变化。后来单独把前 500 步的 lr、grad norm、loss 画在一条时间线上才看出来warmup 结束那一刻的学习率刚好从 3e-6 跳到接近 3e-4而这个模型在第 200 步左右梯度范数本来就偏高。两者叠加正好在同一个 step 上触发了一次参数更新的急转弯。修复方式是放大warmup_ratio到 0.06并且把 scheduler 的 warmup 类型从线性改成多项式power2。曲线平滑之后同样的 lr 和 batch size 下 loss spike 完全消失。经验是loss spike 不一定发生在训练全程的中间更多时候发生在两个阶段的交界处。看到 spike 不要急着降学习率先把 lr、grad norm、warmup 结束点对齐看一眼。4.2 从 Adam 切 AdamW权重衰减差点毁掉模型另一个案例是我把一个老实验从 Adam 迁移到 AdamW直接保留了weight_decay1e-4和旧学习率 2e-4。结果模型在验证集上比原来低了 2 个百分点我以为代码有 bug排查了半天。后来我把训练日志里每层的参数范数都打印出来才发现最上层 mlp 的权重范数在 10000 步内缩小了将近 50%。原因很清晰旧配置用 Adam 时weight_decay是 L2 正则加到梯度上会被二阶矩缩放实际衰减效果被削弱切到 AdamW 后同样的数值变成了直接参数衰减力度大得多。尤其对最后一层分类头这种梯度频繁、参数范数大的地方影响最明显。建议是从 Adam 切到 AdamWweight_decay需要重新标定。常见做法是把 Adam 的weight_decay直接乘一个 0.1~0.5 的系数作为起点或者干脆从 AdamW 官方默认值 0.01 开始做一次小范围搜索。如果你看到实验迁移之后参数范数曲线明显下滑基本都是这个原因。4.3 梯度裁剪阈值的经验法则为什么不靠谱网上大量文章告诉你max_grad_norm1.0是万能设置这个说法在预训练大模型里能成立但在一些多模态模型和 GAN 上完全不是那么回事。有个朋友的项目训练一个视频生成模型加上max_grad_norm1.0之后 loss 稳定下降但生成效果反而变差。我猜测是他的模型大部分参数更新步长被 clip 压得太狠参数实际学习率远低于配置值。判断方法其实很简单先跑 50~100 步不加裁剪记录 grad norm 的分布再选一个阈值。比如中位数是 2.3那从 5.0约两倍中位数开始试而不是直接照抄 1.0。如果加了裁剪后发现几乎所有 step 的 grad norm 都被压到阈值附近说明阈值过小训练变成了以固定步长蠕动。还有一个常见误区是梯度裁剪和 AMP 的顺序问题。我在上一节写过clip_grad_norm_必须在unscale_之后执行。很多人抄了代码但没理解顺序导致在梯度被放大时裁剪实际裁剪比例比预期小好几倍。这类问题不会让训练崩溃但会拖慢收敛速度很难被直接发现。4.4 DDP 下 AMP 与梯度裁剪的隐藏顺序问题多卡 DDP 训练里还有另一个坑。DDP 在backward之后会自动执行梯度 all-reduce把各卡上的梯度同步为平均值。所以 gradient clipping 时每张卡拿到的是聚合后的梯度grad norm 在所有 rank 上是一致的。此时在某一张卡上计算一次 grad norm 再做裁剪理论上没问题但如果你用的是 AMP仍然要先unscale_再 clip。更多时候问题出在 DDP 和torch.cuda.amp.GradScaler的配合上。有人会把scaler.scale(loss),scaler.step(optimizer),scaler.update()放在 DDP 的no_sync()上下文里某个 rank 上执行导致各个 rank 的 optimizer state 不同步。真正安全的做法是所有 rank 都走同一条forward-backward-step路径只在需要累积梯度时才用no_sync()且optimizer.step()仍然在所有 rank 上执行。另外如果在 DDP 里使用 LAMB大家通常期望逐层自适应可以和 DDP 的梯度同步兼容但实测中发现 LAMB 在 batch size 很小时分层归一化会让更新尺度整体变小学习率需要略微调大。这也是 Model-Optimizer 里天梯图配置项存在的原因——没有万能参数每个优化器都要留出微调余地。5. 自动超参搜索Model-Optimizer 的另一半工作5.1 为什么手动调优总会漏掉一些组合一个人的精力有限手动调参时通常只会在当前最优参数附近做小幅扰动很容易困在局部最优。更麻烦的是优化器参数之间是强耦合的学习率对 AdamW 的影响会被权重衰减和 warmup 比例放大或抵消。手动遍历这些组合效率极低。Model-Optimizer 的自动搜索部分我选的是 Optuna。原因很简单它支持并行 trial、支持提前停止、对新手也足够友好。相比网格搜索Optuna 的 TPESampler 能更快地把采样点集中到有希望的区域相比全随机搜索它又不容易漏掉一些关键边界。5.2 把 Optuna 接进优化器配置一个最小实现接入思路非常直接把优化器配置变成搜索空间的一部分每次 trial 里生成一个新的OptimizerConfig然后在这个配置下训练固定步数用验证集 loss 作为目标函数。一个减到最精简的例子import optuna from model_optimizer import OptimizerConfig, build_optimizer, build_scheduler def objective(trial): config OptimizerConfig( nametrial.suggest_categorical(name, [adamw, lion]), lrtrial.suggest_float(lr, 1e-5, 3e-3, logTrue), weight_decaytrial.suggest_float(weight_decay, 1e-6, 0.1, logTrue), warmup_ratiotrial.suggest_float(warmup_ratio, 0.0, 0.2), schedulercosine, ) model build_model() optimizer build_optimizer(model, config) scheduler build_scheduler(optimizer, config, total_steps2000) for step in range(2000): loss, grad_norm train_one_step(model, optimizer, scheduler) # 每 100 步试一次验证集 if step % 100 0: val_loss evaluate(model) trial.report(val_loss, step) if trial.should_prune(): raise optuna.TrialPruned() return val_loss有一个重要细节每个 trial 必须固定随机种子。如果数据加载顺序、模型初始化权重的随机性没有固定两次 trial 之间的差异会被噪声覆盖搜索结果极不靠谱。Model-Optimizer 在搜索入口里默认重置所有种子并且固定 DataLoader 的worker_init_fn。5.3 搜索参数迁移到大模型时要注意缩放在小数据集上搜出来的最优参数不能直接放到大模型上尤其是学习率。常见的线性缩放法则说batch size 翻倍学习率翻倍这个法则在普通 CNN 上可以近似成立但到了 Transformer 和预训练大模型上会很快失效。很多研究表明大批量场景下学习率应该按平方根比例缩放或者干脆用 LAMB 这类自动层归一化的优化器减少手动缩放。我的经验是先用小模型在小 batch 下搜出lr、weight_decay、betas的合理区间然后在大模型上做一次小范围的 fine-search主要只搜索lr和warmup_ratio。因为你搜出来的weight_decay往往对大模型相对稳定而lr的迁移性最差。Model-Optimizer 的搜索配置里专门加了一个fixed_params字段可以把某些参数固定住只搜索剩余部分避免一次性搜索空间过大导致 trial 数量爆炸。6. 写在最后一些仍然有效的土办法6.1 每跑一组实验把优化器配置哈希存下来我在 Model-Optimizer 里加了一个很土但很有效的功能每次训练开始前把OptimizerConfig序列化成 JSON再计算一个 SHA-256写入 checkpoint 文件名。这样每次实验的优化器配置都被永久留痕实验对比时不会出现这个跑的是 3e-4 还是 5e-4 忘了的情况。6.2 看训练曲线时多关注 grad_norm 和 update_ratio除了 loss我强烈建议在日志里额外记录三个量grad_norm、param_norm、以及update_ratio也就是梯度范数除以参数范数的比值。这组指标能告诉你模型到底是在认真学还是在原地抖动。update_ratio长期大于 0.1大概率学习率偏高长期小于 1e-4那模型可能根本在踏步。手动调参很多时候不是在调 loss而是在调这三个量的平衡。6.3 优化器日志记录每次 step 的真实参数变化最后一个建议来自我做过最值的一次排查。当时模型训练稳定但效果迟迟不涨我加了优化器日志把每 500 步的第一个 layer 权重范数和每组参数的实际更新量打印出来这才发现有个分支代码把optimizer.step()和optimizer.zero_grad()的顺序写反了导致梯度累积了两倍。这类 bug 不看更新量日志光盯着 loss 曲线几乎不可能发现。Model-Optimizer 这个项目发展到现在已经不只是一个配置文件仓库更像是我和训练过程之间的一份契约所有和优化器相关的东西都摆在一个地方不搞暗坑。如果你也经常被不收敛、loss spike、复现不出来这些问题折磨我建议先从统一配置开始把每个实验的优化器状态完整记下来再进阶到自动搜索。这套东西不一定适合所有人但对需要长期跑实验的人来说早点把优化器管起来绝对是省时间最高的一笔投入。
返回列表