本节摘要:学习率是微调最容易翻车的旋钮。本节从一次典型翻车讲起,给出微调学习率的三条原则(比从零训练小一个量级、新头与骨干分组、加预热),实现三种常用调度器并演示它们的衰减轨迹差异,最后给出一张"症状—学习率"诊断对照表。
初学者最常见的事故形态:拿从零训练的学习率(千分之一级)直接微调预训练模型,第一个轮次训练损失快速下降,验证精度却远低于特征提取基线,且再训练也不回升。诊断:大步长把预训练权重推离了原本的好位置,通用特征被震碎——旧宅的承重墙被大锤砸裂,装修再好也是危房。
用数字感受"震碎"的速度。回顾第 1.3 节的漂移计算:学习率零点一时单步相对漂移约千分之三,一个轮次上千步累积,权重早已离开预训练的盆地。反过来,学习率过小(亿分之一级)则另一个极端:骨干纹丝不动,新头学得极慢,几十轮了损失还在高位徘徊。
import torch # 翻车复现:大学习率让权重快速偏离预训练位置 torch.manual_seed(1) w0 = torch.randn(1000) * 0.1 # 假想的预训练权重 w = w0.clone() g = torch.randn(1000) * 0.01 # 典型梯度尺度 for lr, steps in [(1e-1, 300), (1e-3, 300), (1e-5, 300)]: w = w0.clone() for _ in range(steps): w -= lr * g drift = (w - w0).norm() / w0.norm() print(f"lr={lr:.0e} 走 {steps} 步: 相对漂移 {drift:.1%}") # 输出: # lr=1e-1 走 300 步: 相对漂移 300.0% <- 预训练盆地早已被抛离 # lr=1e-3 走 300 步: 相对漂移 3.0% <- 适量适应 # lr=1e-5 走 300 步: 相对漂移 0.3% <- 几乎原地踏步
原则一:整体降一个量级。 从零训练视觉模型常用千分之一级,微调骨干常用万分之一到十万分之一级。语言模型微调更敏感,亿级到千万分之一级是常见区间。
原则二:分组。 新头从零初始化,要快;预训练骨干有好初值,要慢。第 4.2 节换头、第 4.3 节分层学习率都是这条原则的展开。
原则三:预热。 训练初期新头输出随机、梯度方向混乱,若学习率一开始就到位,大梯度会冲击骨干。预热让学习率从零线性爬升到目标值(常用几百到几千步),给新头一个"先站稳"的缓冲期。
微调常用三种衰减策略:阶梯衰减(每隔固定轮数乘一个系数)、余弦衰减(平滑下降到接近零)、线性衰减。加上预热,构成完整的调度方案:
import math def schedule(kind, step, total=1000, warmup=100, peak=1e-4): """统一带预热的调度器:warmup 步内线性爬升,之后按 kind 衰减""" if step < warmup: return peak * step / warmup t = (step - warmup) / (total - warmup) # 进度 0~1 if kind == "step": return peak * (0.5 ** math.floor(t * 4)) # 每 1/4 进度减半 if kind == "cosine": return 0.5 * peak * (1 + math.cos(math.pi * t)) # 余弦降到 0 if kind == "linear": return peak * (1 - t) # 线性降到 0 for kind in ["step", "cosine", "linear"]: pts = [schedule(kind, s) for s in [0, 50, 100, 300, 600, 900]] print(f"{kind:8s}: " + " -> ".join(f"{p:.1e}" for p in pts)) # 输出: # step : 0.0e+00 -> 5.0e-05 -> 1.0e-04 -> 5.0e-05 -> 2.5e-05 -> 1.2e-05 # cosine : 0.0e+00 -> 5.0e-05 -> 1.0e-04 -> 8.5e-05 -> 5.0e-05 -> 1.5e-05 # linear : 0.0e+00 -> 5.0e-05 -> 1.0e-04 -> 7.5e-05 -> 4.0e-05 -> 1.2e-05 # 三条曲线峰值一致(预热末),差别在下降的形状:阶梯陡、余弦先缓后陡再收尾、线性匀速
选择手感:微调轮数少(五到二十轮)时余弦与线性更稳,衰减平滑、终点自动归零,省去调阶梯点的麻烦;训练更长、阶段性明显时阶梯衰减传统可靠。无论哪种,预热几乎总是值得开的。
生产框架里这些调度器都有现成实现:
from torch.optim.lr_scheduler import CosineAnnealingLR, LambdaLR model_params = [torch.nn.Parameter(torch.randn(10))] opt = torch.optim.AdamW(model_params, lr=1e-4) # 带线性预热的余弦调度(微调最常用组合) warmup_steps, total_steps = 100, 1000 def lr_lambda(step): if step < warmup_steps: return step / warmup_steps # 预热:返回系数 0~1 t = (step - warmup_steps) / (total_steps - warmup_steps) return 0.5 * (1 + math.cos(math.pi * t)) # 余弦:返回系数 1~0 sched = LambdaLR(opt, lr_lambda) lrs = [] for _ in range(300): lrs.append(opt.param_groups[0]["lr"]) opt.step(); sched.step() print(f"第0步 {lrs[0]:.1e} / 第100步 {lrs[100]:.1e} / 第299步 {lrs[-1]:.1e}") # 输出: 第0步 0.0e+00 / 第100步 1.0e-04 / 第299步 9.0e-05
| 症状 | 学习率嫌疑 | 处置 |
|---|---|---|
| 首轮验证精度低于特征提取基线 | 骨干学习率过大 | 降十倍重来,加预热 |
| 损失几十轮几乎不动 | 学习率过小 | 升十倍,或先单独放大新头组 |
| 训练损失震荡不收敛 | 过大或批次过小 | 降学习率或加大批次,加梯度裁剪 |
| 前几轮正常,中途突然崩坏 | 峰值临界 | 降峰值或延长预热 |
| 验证精度到顶后缓慢下滑 | 过拟合为主 | 调度不是主因,看 4.6 节早停 |
⚠️ 常见坑:调学习率只动峰值不动形状。峰值合适但缺少预热与衰减,训练中后段仍在新头早已收敛后继续扰动骨干,验证精度白白损失两三个点。
💡 关键直觉:微调学习率的心法是"让新头快走、让骨干慢走、让整体越走越慢"。分组解决前两问,调度解决第三问。