聊模型训练的调参绕不开一个话题学习率优化方法。前两年我有一段时间把精力全放在网络结构上模型越改越复杂结果在某个文本分类任务上死活不收敛训练 loss 稳稳停在 0.7 附近验证集指标更是纹丝不动。换了别人的代码一跑同样的模型同样的数据第一轮就能掉到 0.3。逐行对比之后发现问题根本不在结构而在学习率别人用 1e-4我用了 3e-4就是这个看起来不大的差距让整个训练在损失曲面边缘反复横跳。从那之后我才意识到学习率不是一个普通的超参数它是整个训练过程的油门踏板。这篇内容就围绕学习率优化方法展开把我实际用过的调度策略、自适应优化器的原理、batch size 与梯度累积的联动调整以及围绕调参建立的可复现验证闭环完整梳理一遍。适合正在被loss 不降训练发散恢复训练后指标崩了这些问题困扰的工程师和研究者内容偏工程实践不堆数学推导。1. 学习率是训练的油门踏板先搞清楚它为什么这么关键1.1 一次小改动引发的血案先复盘开头说的那次事故。我用的是 Transformer 结构的分类模型优化器选的是 AdamW初始学习率 3e-4batch size 32训练 20 个 epoch。前 3 个 epoch 还能看到 loss 从 2.3 降到 0.7之后就进入僵尸状态。我当时的第一反应是模型容量不够于是加层、加 head、调 dropout折腾了整整一周。后来把学习率降到 1e-4同一个脚本loss 平稳下探最终验证准确率提升了 2.4 个百分点。问题出在哪3e-4 这个值在不少开源项目里都能跑得很好但它对当时的数据量、batch size、模型初始化分布来说就是偏大。训练初期梯度方向噪声大学习率大步往前迈很容易越过最优点附近的有效区域在损失曲面的山谷里来回震荡宏观表现就是 loss 卡住不动。这件事给了我很深的一个教训学习率选多少不是拍脑袋决定的它取决于优化器状态、batch size、损失曲面形态和训练阶段。任何一个环节变了原来的最优学习率都可能失效。1.2 步长、方向与损失曲面一张图看懂学习率梯度下降的更新公式非常朴素w_{t1} w_t - lr * gradient学习率就是每次沿着梯度反方向迈出的步长。想象你把一个铁球放在一个高低不平的地形上地形的高度代表 loss 值梯度就是球体感受到的坡度和方向学习率则决定了球每次滚动多远。学习率太大球会跨过最低点在山谷两侧来回弹跳甚至滚出边界发散学习率太小球每步只挪一毫米可能还没走到最低点训练预算就花完了。更麻烦的是实际训练中的损失曲面不是均匀的碗状。它有平坦的高原、狭窄的峡谷、零星的局部极小坑。在平坦区域梯度可能极小学习率不够大根本推不动在峡谷区域学习率稍大就会发散。这也是为什么单纯的固定学习率很难在所有阶段都表现良好——它要求整个训练过程的每段路况都一样但现实显然不是这样。1.3 学习率优化方法的完整版图既然固定学习率不现实就有了学习率优化方法这个领域。我在实际项目里把相关方法分成五类后面几节逐一展开手动调度Step Decay、MultiStep、Warmup 等人为预先设定学习率随迭代次数的变化曲线。自适应优化器内部机制AdaGrad、RMSProp、Adam、AdamW 对每个参数自动调整更新幅度但全局学习率仍然是一个需要设的外部系数。循环与退火策略余弦退火、热重启SGDR让学习率周期性地增大和缩小主动利用探索-利用的节奏。与训练配置联动batch size 变化、梯度累积步数变化时学习率相应的缩放规则。分层与细粒度调法微调场景下 backbone 和 head 使用不同学习率甚至是逐层不同的学习率。这套版图覆盖了我在训练视觉模型、NLP 模型以及微调预训练模型时遇到的大部分情况。下面从最常用的手动调度开始讲。2. 手动调度策略给训练过程设计速度节奏2.1 Step 与 MultiStep最常见但最容易把周期设错手动调度里最经典的做法是Step Decay训练过程中每隔固定的 epoch 数把学习率乘以一个衰减系数比如乘以 0.1。在 PyTorch 里用现成的调度器就能实现import torch.optim as optim from torch.optim.lr_scheduler import MultiStepLR optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9) scheduler MultiStepLR(optimizer, milestones[60, 90, 120], gamma0.1)MultiStep 比 Step 灵活的地方在于它可以指定多个不等的下降点。很多经典论文就是这么干的先在高学习率下快速下降然后把学习率降低让模型在小步长下精调。这里最大的坑是milestones 按 epoch 还是按 iteration 设置。如果你换了一批数据或者调整了 batch size同样的 epoch 数对应的实际优化步骤数完全不同。打个比方原来 100 个 epoch、每轮 500 个 iteration总共 5 万步你把数据量扩大一倍后同样 100 个 epoch 变成 10 万步。如果 milestones 还是写死在 [60, 90, 120] epoch原来的前 60 个 epoch 高学习率对应的步数翻倍了整个训练节奏全部错位。我的经验是milestones 尽量基于 iteration 总数来换算也就是先估算出总训练步数再把百分比映射成具体的 step。比如计划在训练进度的 60%、75%、90% 处衰减先用total_steps epochs * steps_per_epoch算出总步数再让调度器按 step 触发。这样即使 batch size 变了调度节奏也不会跟着变形。2.2 Warmup为什么我建议所有模型都加上Warmup 就是训练开始的一小段时期学习率从一个很小的值线性或者非线性地升到目标学习率。最早在 Transformer 论文里它是标准配置后来我发现它在 CNN、甚至强化学习里都有奇效。原理其实很朴素。训练刚开始时模型参数完全随机梯度方向受噪声影响很大一阶和二阶梯度统计量都不可靠。此时给一个大学习率相当于在信息不足的情况下猛踩油门大概率会冲歪。先用小学习率跑几百上千步相当于让模型先确认大致的坡度和路况再逐步提速。我自己常用的实现是线性 warmup 加余弦衰减from torch.optim.lr_scheduler import LambdaLR import math def warmup_cosine_schedule(optimizer, warmup_steps, total_steps, min_lr_ratio0.01): def lr_lambda(step): if step warmup_steps: return step / max(1, warmup_steps) progress (step - warmup_steps) / max(1, total_steps - warmup_steps) return min_lr_ratio 0.5 * (1 - min_lr_ratio) * (1 math.cos(math.pi * progress)) return LambdaLR(optimizer, lr_lambda)这个函数返回的调度器前warmup_steps步线性升到目标学习率之后按余弦曲线平滑降到min_lr_ratio倍的学习率后面第四节还会细讲。在大多数 CV 和 NLP 任务上加 warmup 之后训练初期的 loss 曲线明显更平稳最终精度也通常比不加热启动版本高一点。Warmup 的步数设置没有万能值。微调小模型时可以只用几百步大规模预训练场景往往需要几千步甚至上万步。我一般把 warmup 设置在总步数的 1% 到 5% 之间再通过 loss 曲线微调如果前几步 loss 突然飙高说明 warmup 太短或者初始学习率仍然太大。2.3 恢复训练时调度器状态丢失的坑分布式训练或长时间训练经常要中断续跑。很多人保存模型时会顺手保存model.state_dict()却忘了保存optimizer.state_dict()和scheduler.state_dict()。恢复训练时 optimizer 重新初始化Adam 的二阶动量全部清零学习率调度器也从头开始。这个坑的表现非常迷惑训练看起来从 checkpoint 继续但 loss 会突然出现一个尖峰然后重新下降最终模型效果明显变差。因为 Adam 的动量状态丢了相当于把已经磨合好的优化器格式化了。恢复时一定要带上这些状态checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), scheduler: scheduler.state_dict(), step: global_step, best_metric: best_metric, }另外要检查一下调度器的last_epoch或内部 step 是否和全局 step 对齐。有次我从 checkpoint 恢复后模型指标一直比正常训练低一个点排查了好久才发现是调度器从 0 重新计步后面的学习率整体比预期高。自那以后我每次保存 checkpoint 都会把当前 step 写进日志恢复时先打印一次 optimizer 和 scheduler 的关键数值确认无误再继续训练。3. 自适应优化器里的学习率从 AdaGrad 到 AdamW3.1 自适应学习率到底自适应了什么很多人以为 Adam 自带学习率自适应就不需要再管全局学习率了。这是最容易踩的误区。要理解这个问题得先看看自适应优化器到底在做什么。以 AdaGrad 为例它对每个参数维护一个历史梯度平方的累积和 $G_t$每次更新的学习率除以 $\sqrt{G_t \epsilon}$。梯度大的参数累积值大更新幅度被压低梯度小的稀疏参数累积值小更新幅度相对被放大。RMSProp 和 Adam 则用指数滑动平均来替代从零开始的累积避免学习率单调递减到零。Adam 的更新核心是同时维护一阶矩 $m_t$ 和二阶矩 $v_t$m_t beta1 * m_{t-1} (1 - beta1) * g_t v_t beta2 * v_{t-1} (1 - beta2) * g_t^2 m_hat m_t / (1 - beta1^t) v_hat v_t / (1 - beta2^t) params params - lr * m_hat / (sqrt(v_hat) epsilon)这一步相当于给每个参数单独计算了一个等效学习率梯度变化剧烈时分母大更新幅度自动减小梯度平稳时更新幅度自动增大。但注意所有参数的分母外面还乘一个全局的lr。这个全局 lr 的作用更像一个总阀门。自适应机制解决的是不同参数之间更新尺度的不均衡而总阀门解决的是整体更新强度是否适合当前任务。我实测过一组对比同样一个文本分类任务Adam 全局 lr 用 3e-4 和 3e-3前者验证准确率 88.3%后者几乎不收敛只有 72.1%。这说明即便 Adam 能做到逐参数自适应全局学习率仍然需要认真调不能迷信默认值。3.2 既然有自适应为什么还要做 Warmup很多人疑惑Transformer 论文里 Adam 配 warmup是不是因为当时实现不完善的妥协我倾向于认为不是。Adam 在训练初期同样会失明一阶矩和二阶矩都是从零开始用指数滑动平均估计的头几百步的估计值严重偏离真实梯度统计。此时二阶矩偏小可能导致名义上的自适应学习率非常大模型在随机初始化状态下被一把推向错误方向。所以当前主流框架里预训练和微调任务普遍还是Adam/AdamW warmup的组合。如果你用的是 SGDwarmup 的效果也很明显但原理不太一样SGD 没有二阶矩修正大学习率在随机初始化附近更容易发散warmup 相当于给参数一个适应损失曲面的缓冲期。在实现上还要注意AdamW 和 Adam 的权重衰减方式不同。AdamW 把权重衰减从梯度中解耦直接在做参数更新时减去weight_decay * param。这个修改让 AdamW 在 Transformer 训练中显著更稳。如果你还在用带 L2 正则的 Adam迁移到 AdamW 时建议把weight_decay的取值也重新验证一遍两个优化器的等价权重并不完全一致。3.3 微调场景下的分层学习率微调预训练模型时我一般不用全局统一学习率。原因在于预训练模型的底层网络已经学到了大量通用特征全量更新时把底层打乱会导致灾难性遗忘而新加的分类头或任务头是随机初始化的需要更大学习率才能快速适应新任务。实际操作中把参数分成两个 group分别设置学习率optimizer torch.optim.AdamW([ {params: backbone.parameters(), lr: 2e-5}, {params: head.parameters(), lr: 2e-4}, ], weight_decay0.01)这个backbone 小学习率、head 大学习率的组合在大多数迁移学习任务上都能比统一学习率提升半个到一个点的指标。更极端一点的做法是linear probing先冻结 backbone只用高学习率训练 head等 head 稳定后再解冻 backbone用极低学习率微调全部参数。这个流程对数据量比较小的任务尤其友好能有效防止预训练特征被破坏。如果任务更复杂还可以逐层递减学习率靠近输入的层学习率最低靠近输出的层学习率最高。实现时需要按模块名字给每个 layer 分配param_group逻辑不复杂但代码稍长。我的建议是先从 backbone/head 两段式开始收益已经非常明显没有必要一上来就上逐层精细调参。4. 余弦退火与热重启跳出局部最优的实战记录4.1 余弦退火的衰减曲线设计手动调度里除了阶梯式衰减另一大主流是连续衰减。余弦退火让学习率按余弦函数从一个较高值平滑地降到较低值公式长这样lr_t lr_min 0.5 * (lr_max - lr_min) * (1 cos(pi * t / T))其中 T 是总步数t 是当前步数。曲线特征是先快速下降中段平缓最后又快速逼近最小值。相比 step decaay 那种一直高学习率然后突然掉一截的做法余弦退火的优势是全程没有突变不会因为学习率瞬间变化造成 loss 尖峰。用 PyTorch 时可以直接基于CosineAnnealingLR实现或者用我前面贴的LambdaLR手动算。要注意一个细节不要把 lr_min 设成 0。训练快结束时如果学习率直接归零参数可能停在一个不够好的位置。我通常把lr_min设置为lr_max的 1% 到 5%让训练最后仍然有微弱的调整能力。4.2 热重启为什么会有效热重启Warm Restart / SGDR是余弦退火的进阶版来自 Ilya Loshchilov 和 Frank Hutter 的论文。核心思路是不把总训练时间当成一个衰减周期而是每隔一定步数就把学习率重新拉高之后再做一次余弦衰减。注意这里的重启不是把模型参数重置而是把学习率跳回去。模型参数继续保留所以叫热重启。思想其实很像模拟退火里的升温再降温学习率拉高时模型具备更强的探索能力有机会跳出当前局部极小点之后再降温在新区域精细搜索。实际训练中热重启会带来一个让很多人紧张的现象每次重启后 loss 会突然升高。这不是 bug而是学习率变大导致的正常波动。我曾在一个图像分类任务上看到 loss 从 0.42 跳到 0.55当时差点把它当成发散直接停掉。多观察几个周期后发现重启后的 loss 下降速度明显更快最终收敛到了 0.31 附近比不重启低了 0.03。PyTorch 提供的CosineAnnealingWarmRestarts可以直接实现from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts scheduler CosineAnnealingWarmRestarts(optimizer, T_020, T_mult2, eta_min1e-6)T_0是第一个完整周期长度T_mult是周期放大倍数。设成 2 时周期长度依次为 20、40、80、160……这样前期重启频繁后期重启间隔变长符合先多探索、后多利用的训练节奏。4.3 几个模型上的实测效果我在三类任务上都对比过 cosine warm restart 和传统 multi-step 的性能。第一个是 ResNet 在 CIFAR-10 上的图像分类cosine 比 multi-step 高了约 0.5 个点而且收敛曲线更顺滑第二个是 Transformer 在文本分类任务上cosine restart 比 step 高 1.2 个点第三个是 BERT 微调此时重启收益变小因为预训练模型本身已经处于一个比较好的区域太强的探索反而可能破坏已有特征。这给我的启发是周期性重启不是万能的。它更适合从头训练模型或者模型确实容易陷入局部极小的场景对于已经从大量数据中预训练好的模型低学习率精细微调通常更稳妥。做实验时不要一键套用某个调度器而是应该结合当前模型从哪里出发来判断该不该加重启。5. batch size 与梯度累积下的学习率联动5.1 线性缩放法则batch size 翻倍学习率要不要翻倍很多人在调大赛时会遇到一个问题单卡能跑 batch size 32换到 8 卡分布式总 batch 变成 256学习率还用原来的结果 loss 直接飞了。这里涉及一个经验法则——线性缩放法则Linear Scaling Rule当 batch size 从 B 变为 kB 时学习率也乘以 k。直觉层面的解释是这样的batch size 变大后每次梯度估计使用的样本更多梯度方差变小相当于前进方向更靠谱。既然方向更靠谱单个步长就可以迈得更大一些整体收敛效率才会匹配。如果 batch 变大而学习率不变相当于每一步的方向更准但走得和原来一样慢理论上最终结果可能还行但训练吞吐的收益没有被充分利用。但这个法则有边界。学习率不是可以无限放大的。损失曲面不是光滑的二次函数过大的学习率照样会把模型踢出有效区域。我在 8 卡训练时把 batch 从 64 扩到 512按线性法则把 lr 从 1e-3 提到 8e-3结果前几步 loss 就冲到了初始值的两倍。后来退回 4e-3并把 warmup 步数从 500 增加到 2000训练才稳定下来。5.2 梯度累积时的学习率缩放细节显存不够时常用梯度累积来模拟大 batch。所谓梯度累积就是连续跑若干个小 batch先不清空梯度把梯度累加之后再做一次优化器更新。比如显存只能放 batch size 32想模拟 128 的等效 batch就设累积步数为 4accumulation_steps 4 scaler torch.cuda.amp.GradScaler() for step, (inputs, labels) in enumerate(train_loader): with torch.cuda.amp.autocast(): loss model(inputs, labels) loss loss / accumulation_steps scaler.scale(loss).backward() if (step 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这里我建议直接把 loss 除以累积步数再 backward确保等效 batch 下的梯度均值和真实大 batch 更接近。学习率要不要跟着等效 batch 放大我在实践中的结论是如果只是解决显存不足建议先保持不变优先把 warmup 和总步数对齐只有当你想真正模拟更大 batch 的训练行为时再按线性法则适当放大学习率。原因比较现实真实大 batch 训练时一个 batch 内的样本是独立同分布的梯度累积虽然近似但用的是若干连续小 batch数据分布可能存在局部相关性梯度估计不一定完全等价。放大学习率之后的风险比收益更大。5.3 我的复盘一次不缩放导致的精度倒退去年有一次我做对比实验训练配置从单卡 batch 32 切到 4 卡总共 batch 128但脚本里只改了 DataLoader 的 batch size忘了动学习率。训练完之后发现验证集准确率比小 batch 版本还低了 1.1 个百分点。一开始我以为是大 batch 本身有问题后来单独做了控制变量实验才发现是学习率没跟着缩放的锅。那次经验之后我给自己定了一个流程每次改动 batch size 或梯度累积步数先检查学习率相关的所有配置并把等效 batch、lr、warmup 步数记录到实验配置里。我还养成了一个习惯用可视化的方式把每次实验的学习率曲线和 loss 曲线画在一起一旦发现曲线形态和以前差异过大就能立刻定位是不是学习率配置没同步。训练不收敛时问题往往不是单点原因而是整套配置内部的联动关系被破坏了。6. 工具侧技巧用 C 语言日期计算检验你的调参闭环6.1 日期特征预处理中的两种实现方法聊完学习率本身我想分享一个看起来不相关、但实际帮助很大的小工具用 C 语言实现输入年、月、日计算这是一年中的第几天。为什么要写它因为在做时序预测任务时我经常需要把每个样本的日期转成第几天、周几、季度这类特征数据量常常有几千万行纯 Python 逐行处理太慢于是我把这段逻辑写成 C 扩展。第一种方法是逐月累加并在闰年时调整二月天数#include stdio.h int is_leap(int y) { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } int day_of_year_loop(int y, int m, int d) { int days[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int res 0; for (int i 0; i m - 1; i) { res days[i]; } if (m 2 is_leap(y)) { res 1; } return res d; }第二种方法是预计算一个累积天数表省掉循环int cum_days[] {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; int day_of_year_table(int y, int m, int d) { int res cum_days[m - 1] d; if (m 2 is_leap(y)) { res 1; } return res; }两段代码在逻辑上是等价的但第二种减少了一次 12 次的循环和分支预测。在单次调用时性能差距几乎可以忽略但在亿级样本预处理时后者能省下明显的 CPU 时间。这是很典型的先写对、再优化、最后用基准测试说话的过程。6.2 为什么我坚持把日期转换写进 C 扩展直接调用 C 标准库里的mktime或者localtime也能算出一年的第几天但生产环境里我一般不这么用。原因是mktime涉及系统时区和本地化设置而且不是纯函数多线程环境下还牵扯锁和全局状态在数据预处理这种对确定性要求极高的场景里我宁愿用一个自包含的纯函数。上面这段代码没有任何外部依赖输入输出完全可预测测试也简单。写过 C 扩展的人都知道把一个纯 CPU 热点的计算从 Python 换成 C效果有时候不是提升百分之几十而是数量级的差距。Python 处理一行日期要经过整数解析、datetime 对象创建、方法调用分发而 C 循环里只是几条整数运算。对需要反复迭代数据管道的实验来说这个优化能节省大量等待时间。更重要的是这种小工具的验证方式让我对调参闭环有了更直观的理解。日期计算的两种方法正确性都用同一组测试用例验证学习率调参也一样不能只凭感觉说这次效果变好了而是要通过可重复的验证集、固定的随机种子、完整的训练日志来验证。工具和调参方法论其实是同一套工程素养。6.3 把所有实验配置当成可复现资产管理我在做过几十轮学习率对比实验之后建立了一套自己的配置管理习惯。每次实验启动前把以下内容完整记录到配置文件或者实验管理平台里数据集版本、预处理种子、数据顺序模型结构 hash、权重初始化种子优化器类型、全局学习率、weight decay、epsilon调度器类型、warmup 步数、总步数、衰减周期等效 batch size、梯度累积步数、是否使用混合精度模型、optimizer、scheduler 的完整 checkpoint 路径。这套习惯是从 C 语言日期计算工具上得到的启发那个工具的正确性依赖于输入输出可预测、可反复测试一次深度学习实验的可复现性则依赖于所有影响训练轨迹的配置都被记录且版本化。学习率优化方法表现再好如果实验本身不可复现结论就没有意义。我在实际项目中还特别喜欢画学习率曲线 loss 曲线对照图。同样是验证集指标下降一看曲线就知道是 warmup 不够、调度器步数错位还是初始学习率过大。比起直接盯着最终指标瞎猜曲线的形态会告诉你问题的根源。这就像日期计算里对比两种实现的基准测试结果一样——有了可量化的对比优化才有方向。最后说一个花了很长时间才接受的现实学习率优化方法里没有永远正确的组合。同一个模型换一个数据集最优学习率和调度策略就可能完全变掉。与其到处抄别人的配置不如把自己的实验数据沉淀成一套可以快速试错的流程。调参的本质不是找魔法数字而是建立一套可验证、可比较、可复现的方法论。