优化器:从 SGD 到 AdamW 本节摘要:梯度下降告诉你往哪个方向走,却不说走多远、走多快。SGD 是个指南针,Adam 是带实时路况的 GPS。朴素的梯度下降对每个参数、每一步都用同一个学习率:w = w − lr × 梯度。这带来三个让人抓狂的问题:其一,震荡——损失面更像狭长山谷,梯度指向横跨山谷的陡向而非顺向,梯度下降在窄维上来回弹,在有用方向上寸步难行;其二,所有参数一个学习率是错的——有的权重还在欠拟合需大步,有的已近最优需微调,一个学习率无法两全;其三,鞍点——高维损失面有大片梯度近乎零的平坦区,SGD 在里面以梯度速度(几乎为零)爬行,看着卡住其实没卡,只是平板另一头有有效下降,但 SGD 没机制穿过去。
本节摘要:梯度下降告诉你往哪个方向走,却不说走多远、走多快。SGD 是个指南针,Adam 是带实时路况的 GPS。朴素的梯度下降对每个参数、每一步都用同一个学习率:w = w − lr × 梯度。这带来三个让人抓狂的问题:其一,震荡——损失面更像狭长山谷,梯度指向横跨山谷的陡向而非顺向,梯度下降在窄维上来回弹,在有用方向上寸步难行;其二,所有参数一个学习率是错的——有的权重还在欠拟合需大步,有的已近最优需微调,一个学习率无法两全;其三,鞍点——高维损失面有大片梯度近乎零的平坦区,SGD 在里面以梯度速度(几乎为零)爬行,看着卡住其实没卡,只是平板另一头有有效下降,但 SGD 没机制穿过去。Adam 解决了这三点:它为每个参数维护两个滑动平均——梯度均值(动量,治震荡)与梯度平方均值(自适应速率,治尺度不一),配上开头几步的偏差修正,一个默认超参数就能搞定 80% 的问题。本节从零实现 SGD、动量 SGD、Adam、AdamW,讲透偏差修正与解耦权重衰减,并给出按模型类型选优化器的决策图。
阅读完本节,你应当能够:
你算出了梯度,知道权重 #4721 该减 0.003 来降损失。但 0.003 是什么单位?按什么缩放?第 1 步和第 1000 步该走同样多吗?
朴素梯度下降对每个参数每步都用同一学习率:w = w − lr × 梯度。这带来三大痛点。
第一,震荡。损失面很少像光滑的碗,更像一条狭长山谷。梯度指向横跨山谷(陡向)而非顺向(缓向),梯度下降在窄维上来回弹,有用方向上挪一点点。你见过这种:损失猛降后进入平台,不是模型收敛了,是它在震荡。
第二,所有参数一个学习率是错的。有的权重需大更新(还在早期欠拟合),有的需微调(已近最优)。适合前者的学习率毁了后者,反之亦然。
第三,鞍点。高维损失面有大片梯度近乎零的平坦区,朴素 SGD 以梯度速度(几乎零)爬过去。模型看着卡住,其实没卡——只是平坦区另一头有有效下降,但 SGD 没机制推过去。
Adam 三者全解。它为每个参数维护两个滑动平均——梯度均值(动量,治震荡)与梯度平方均值(自适应速率,治尺度不一),配上开头几步的偏差修正,默认超参数就能搞定 80% 的问题。本节从零搭它,让你真正懂它在另外 20% 上何时、为何失败。
最简优化器:在小批量上算梯度,朝反方向走一步。
w = w - lr * gradient
「随机」指你用数据的随机子集(小批量)而非全量来估梯度。这噪声其实有用——帮跳出尖锐局部最小,但也引起震荡。学习率是唯一旋钮:太高损失发散,太低训练龟速,最优值依赖架构、数据、batch 大小与训练阶段。现代网络上朴素 SGD 典型值 0.01~0.1,但即便单次训练,理想学习率也在变。
「球滚下山」的比喻被用滥了,但很准。不再只按梯度走,而是维护一个累积历史梯度的速度。
m_t = beta * m_{t-1} + gradient w = w - lr * m_t
beta(典型 0.9)控制保留多少历史。beta = 0.9 时,动量大致是最近 10 步梯度的平均(1 / (1 − 0.9) = 10)。
它为何治震荡:同向梯度累积,反向梯度抵消。在狭长山谷里,「横跨」分量每步变号被阻尼,「顺向」分量保持一致被放大,结果是在有用方向上平滑加速。实测:在病态损失面上,纯 SGD 可能要 10000 步,动量 SGD(beta=0.9)通常 3000~5000 步,提速不止一星半点。
第一个真正有效的逐参数自适应学习率方法,由 Hinton 在 Coursera 课程里提出(从未正式发表)。
s_t = beta * s_{t-1} + (1 - beta) * gradient^2 w = w - lr * gradient / (sqrt(s_t) + epsilon)
s_t 跟踪梯度平方的滑动平均。梯度持续大的参数被大数除(有效学习率小),梯度小的参数被小数除(有效学习率大)。这解决了「一个学习率打天下」的问题——一直在接收大更新的权重可能已接近目标,该减速;一直小更新的权重可能欠训,该加速。epsilon(典型 1e-8)防止参数未被更新时除零。
Adam 合二为一,为每个参数维护两个指数滑动平均:
m_t = beta1 * m_{t-1} + (1 - beta1) * gradient (一阶矩:均值) v_t = beta2 * v_{t-1} + (1 - beta2) * gradient^2 (二阶矩:方差)
偏差修正是多数解释略过的关键细节。第 1 步时 m_1 = (1 − beta1) × 梯度,beta1 = 0.9 时即 0.1 × 梯度——小了十倍,滑动平均还没热身。偏差修正补偿:
m_hat = m_t / (1 - beta1^t) v_hat = v_t / (1 - beta2^t)
第 1 步 beta1 = 0.9 时:m_hat = m_1 / 0.1 = 真实梯度;第 100 步时(1 − 0.9^100)≈ 1.0,修正消失。偏差修正在前约 10 步要紧,50 步后无关。更新:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon)
Adam 默认:lr = 0.001,beta1 = 0.9,beta2 = 0.999,epsilon = 1e-8。这套默认值搞定 80% 问题。搞不定时先调 lr,再调 beta2,几乎从不调 beta1 或 epsilon。
L2 正则在损失里加 lambda × w²。在朴素 SGD 里,这等价于权重衰减(每步从权重减 lambda × w)。在 Adam 里,这个等价性被打破。
Loshchilov 与 Hutter 的洞见:当你把 L2 加进损失、再让 Adam 处理梯度时,自适应学习率会连带缩放正则项——梯度方差大的参数正则更少,方差小的参数正则更多,这恰恰不是你要的(你要的是不管梯度统计如何都均匀正则)。AdamW 的修法是把权重衰减直接作用在权重上,接在 Adam 更新之后:
w = w - lr * m_hat / (sqrt(v_hat) + epsilon) - lr * lambda * w
权重衰减项(lr × lambda × w)不被 Adam 的自适应因子缩放,每个参数获得相同的比例收缩。看着像细节,其实不是——AdamW 在几乎所有任务上都比「Adam + L2」收敛到更好的解,它是 PyTorch 训练 Transformer、扩散模型及多数现代架构的默认优化器。BERT、GPT、LLaMA、Stable Diffusion——全用 AdamW 训练。
只调一个超参数,就调学习率——10 倍的学习率变化比任何架构决策都重要。常见默认:
完整代码见原课程 phases/03-deep-learning-core/06-optimizers/code/ 相应文件。
class SGD: def __init__(self, lr=0.01): self.lr = lr def step(self, params, grads): for i in range(len(params)): params[i] -= self.lr * grads[i]
class SGDMomentum: def __init__(self, lr=0.01, beta=0.9): self.lr = lr self.beta = beta self.velocities = None def step(self, params, grads): if self.velocities is None: self.velocities = [0.0] * len(params) for i in range(len(params)): self.velocities[i] = self.beta * self.velocities[i] + grads[i] params[i] -= self.lr * self.velocities[i]
import math class Adam: def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8): self.lr = lr self.beta1 = beta1 self.beta2 = beta2 self.epsilon = epsilon self.m = None self.v = None self.t = 0 def step(self, params, grads): if self.m is None: self.m = [0.0] * len(params) self.v = [0.0] * len(params) self.t += 1 for i in range(len(params)): self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i] self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2 m_hat = self.m[i] / (1 - self.beta1 ** self.t) v_hat = self.v[i] / (1 - self.beta2 ** self.t) params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
注意 self.t 从第 1 步起累加,(1 − beta^t) 在前几步小、补偿大,这正是偏差修正的精髓。
class AdamW: def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01): # ... 同 Adam ... self.weight_decay = weight_decay def step(self, params, grads): # ... 同 Adam 的 m_hat / v_hat 更新 ... for i in range(len(params)): params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon) params[i] -= self.lr * self.weight_decay * params[i] # 解耦权重衰减
权重衰减项不被自适应因子缩放,每个参数获得相同比例收缩。
用第 05 节的圆形数据集 + 两层网络,配四种优化器分别训练,比较收敛曲线。Adam/AdamW 通常远快于 SGD,而 AdamW 在正则强度高时泛化更稳。
PyTorch 优化器接管参数组、梯度裁剪、学习率调度:
import torch import torch.optim as optim model = torch.nn.Sequential( torch.nn.Linear(784, 256), torch.nn.ReLU(), torch.nn.Linear(256, 10), ) optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) for epoch in range(100): optimizer.zero_grad() output = model(torch.randn(32, 784)) loss = torch.nn.functional.cross_entropy(output, torch.randint(0, 10, (32,))) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step()
模式永远是:zero_grad、forward、loss、backward、(clip)、step、(schedule)。把这个顺序背下来。搞错顺序(比如 scheduler.step() 放在 optimizer.step() 之前)是隐蔽 bug 的高发地。CNN 上不少人仍偏爱 SGD + 动量(lr=0.1, momentum=0.9, weight_decay=1e-4)配 step 或余弦调度——SGD 找到的最小更平、往往泛化更好。Transformer 和 LLM 上,AdamW 配预热 + 余弦衰减是通用默认,没有实测理由别逆共识。
本节产出(位于原课程 outputs/):
prompt-optimizer-selector.md:一个决策提示,帮你在任意架构上选对优化器与学习率。下一节,我们讲正则化——训练精度 99%、测试精度 60%,模型在背书而非学习,正则化就是逼它泛化的税。