上一课我们看清了梯度下降的几何:在病态(ill-conditioned)的损失面上——也就是 Hessian \(H\) 的最大、最小特征值相差悬殊、条件数(condition number)很大时——普通梯度下降会在狭长山谷里来回"之字形"震荡,走得又慢又痛苦。这一课我们就来补上工程上真正在用的那套工具:用随机性换便宜、用惯性压震荡、用自适应步长对付病态,再用调度稳住训练。学完这一课,你就能看懂任何一份现代深度学习训练脚本里的优化器配置。
读完这一课,你将能够
- 解释 full-batch / mini-batch / SGD 的区别,并说清为什么"一小批样本的梯度"是真梯度的无偏估计,噪声为什么有用。
- 手算一步动量(momentum)更新和一步Adam更新(含偏差修正),并说明它们各自压住了什么问题。
- 梳理 AdaGrad → RMSProp → Adam → AdamW 这条自适应学习率的演化线,并说清 AdamW 为何取代了"Adam + L2"。
- 设计一条 warmup + cosine 学习率调度曲线,并解释梯度裁剪(gradient clipping)防爆炸的作用。
- 用 numpy 在病态二次型上复现 GD / 动量 / Adam 的收敛差异,并观察动量如何减少之字形。
一、为什么不用全部数据算梯度:SGD 的便宜与噪声
先回到最朴素的设定。我们要最小化训练集上的平均损失:
\[ L(\theta)=\frac1N\sum_{i=1}^N \ell_i(\theta),\qquad \nabla L(\theta)=\frac1N\sum_{i=1}^N \nabla\ell_i(\theta). \]这叫 全批梯度下降(full-batch GD):每走一步,都要扫一遍全部 \(N\) 个样本求平均梯度。当 \(N\) 是百万、上亿时,光算一步就要把整个数据集过一遍——慢到无法接受。
关键在于这个近似是无偏的(unbiased)。呼应模块2第1课讲的蒙特卡洛与无偏估计:如果随机均匀抽一个样本下标 \(i\sim \mathrm{Uniform}\{1,\dots,N\}\),那么单样本梯度 \(\nabla\ell_i(\theta)\) 是随机变量,它的期望恰好是真梯度
\[ \mathbb{E}_{i}\big[\nabla\ell_i(\theta)\big]=\frac1N\sum_{i=1}^N\nabla\ell_i(\theta)=\nabla L(\theta). \]也就是说,SGD 走的方向平均而言是对的,只是每一步带了随机噪声。抽一小批 \(\mathcal B\)(大小 \(|\mathcal B|=B\))取平均,估计仍无偏,但方差按 \(\propto 1/B\) 缩小——批量越大越平稳,但每步越贵。这是个权衡(trade-off)。
厘清三个被滥用的词:epoch / batch / iteration
- batch(批):一次前向+反向用到的那一小撮样本,大小 \(B\)。
- iteration(迭代 / step):用一个 batch 完成的一次参数更新。
- epoch(轮):把整个训练集完整过一遍。一个 epoch 包含 \(\lceil N/B\rceil\) 个 iteration。
举例:\(N=50000\)、\(B=100\),则一个 epoch = 500 个 iteration。训练 10 个 epoch = 5000 次参数更新。注意 full-batch GD 里"一个 epoch = 一步",而 SGD/mini-batch 里"一个 epoch = 很多步"——这正是后者收敛快的直观原因。
ML 和 ML 的联系:噪声不是纯坏事
上一课担心的鞍点(saddle point)——梯度为零但既非极小也非极大的点,在高维里多得是。full-batch GD 一旦精确踩到鞍点附近、梯度趋零,就可能卡住。而 SGD 的梯度自带随机噪声,相当于不停地轻轻"踹"参数一脚,帮它抖出鞍点、逃离尖锐的坏极小值。所以在深度学习里,SGD 的噪声常被看作一种隐式的、有益的正则化,而非缺陷。
二、动量:给小球加上惯性,压住之字形
回到病态山谷的之字形问题。普通 GD 的麻烦在于:在陡峭方向(大特征值)上步子太大、来回弹跳;在平缓方向(小特征值)上步子太小、推进缓慢。每一步只看当前梯度,没有记忆,于是被陡峭方向的反复横跳主导。
把这个"惯性"写成公式,就是维护梯度的累加作为"速度" \(v\):
\[ v \leftarrow \beta\, v + \nabla L(\theta),\qquad \theta \leftarrow \theta - \eta\, v. \]其中 \(\beta\in[0,1)\)(典型 \(0.9\))是动量系数。从 \(v_0=0\) 展开看,\(v\) 是历史梯度的加权和 \(v_t=\sum_{k=0}^{t-1}\beta^{\,k}\nabla L(\theta_{t-k})\):越近的梯度权重越大(当前梯度系数恰为 1),越远的按 \(\beta^k\) 几何衰减。\(\beta=0\) 就退化回普通 GD。
为什么能压震荡?在横向(陡峭)方向,相邻梯度正负交替,加权平均后大量抵消,\(v\) 的横向分量很小;在纵向(指向谷底)方向,梯度方向一致,加权累加,\(v\) 的纵向分量越来越大。于是路径更"直"地冲向谷底——这正是我们在代码里会量化看到的:之字形的符号翻转次数大幅下降。
例题:手算一步动量更新
设某参数 \(\theta=2.0\),当前梯度 \(\nabla L=4.0\),上一时刻速度 \(v=1.0\),取 \(\beta=0.9\)、\(\eta=0.1\)。
先更新速度:\(v \leftarrow 0.9\times 1.0 + 4.0 = 4.9\)。
再更新参数:\(\theta \leftarrow 2.0 - 0.1\times 4.9 = 1.51\)。
对比:若用普通 GD(无动量),这一步只会走 \(2.0-0.1\times4.0=1.6\)。动量因为继承了上一步的速度 \(v=1.0\),走得更远了一点——在方向一致时这种"加速"正是我们想要的。
三、自适应学习率:从 AdaGrad 到 RMSProp 到 Adam
动量解决了"方向"问题,但还有个"步长"问题:不同参数该用不同大小的步子。比如 NLP 里高频词的梯度大、低频词的梯度小,用同一个 \(\eta\) 要么对前者太大、要么对后者太小。自适应学习率(adaptive learning rate)的思路是:逐参数地,让历史上梯度大的方向步子小一点、梯度小的方向步子大一点。
AdaGrad:累积梯度平方
给每个参数维护它历史梯度平方的累加 \(G\leftarrow G+g^2\),更新时除以 \(\sqrt G\):
\[ G \leftarrow G + g^2,\qquad \theta \leftarrow \theta - \frac{\eta}{\sqrt{G}+\epsilon}\,g. \]梯度一直很大的方向,\(G\) 累积得快,有效步长 \(\eta/\sqrt G\) 被压小;很少更新的方向步长保持较大。(这里的 \(\epsilon\) 是个防除零的极小数,典型 \(10^{-8}\),下同。)
RMSProp:把累加换成滑动平均
修复办法很自然:别让 \(G\) 无限累加,改成指数滑动平均,只记住"最近"的梯度平方规模:
\[ v \leftarrow \rho\, v + (1-\rho)\, g^2,\qquad \theta \leftarrow \theta - \frac{\eta}{\sqrt{v}+\epsilon}\,g. \]注意这里带了 \(1-\rho\) 因子(归一化 EMA),\(v\) 是梯度平方的平均而非累加。\(\rho\)(典型 \(0.99\))一衰减,旧的梯度平方就被遗忘,\(v\) 不再无限增长,学习率不会过早归零。这就是 RMSProp。
Adam = 动量 + RMSProp + 偏差修正
Adam(Adaptive Moment Estimation)把两条线合一:用一阶矩 \(m\)(梯度的 EMA,即动量)定方向,用二阶矩 \(v\)(梯度平方的 EMA,即 RMSProp)定每个参数的步长。注意这里的 \(m\) 用的是带 \(1-\beta_1\) 因子的归一化形式(与上一节无归一化的动量 \(v\) 略有差别,量级回到与单步梯度相当):
\[ m \leftarrow \beta_1 m + (1-\beta_1)\,g,\qquad v \leftarrow \beta_2 v + (1-\beta_2)\,g^2. \]但有个细节:\(m,v\) 都从 \(0\) 初始化,训练最初几步它们被"拉向零",系统性偏小(有偏估计)。Adam 用偏差修正(bias correction)除掉这个偏:
\[ \hat m = \frac{m}{1-\beta_1^{\,t}},\qquad \hat v = \frac{v}{1-\beta_2^{\,t}},\qquad \theta \leftarrow \theta - \eta\,\frac{\hat m}{\sqrt{\hat v}+\epsilon}. \]其中 \(t\) 是步数(从 1 开始)。\(t\) 很小时分母 \(1-\beta_1^t\) 远小于 1,把被低估的 \(m\) 放大回正常规模;\(t\) 变大后 \(\beta_1^t\to0\),修正因子 \(\to1\),自动失效。典型超参:\(\beta_1=0.9,\ \beta_2=0.999,\ \epsilon=10^{-8}\)。
例题:手算一步 Adam 更新(含偏差修正)
设第 1 步(\(t=1\)),某参数 \(\theta=1.0\),梯度 \(g=0.2\),初始 \(m=0,\ v=0\),取 \(\beta_1=0.9,\ \beta_2=0.999,\ \eta=0.1,\ \epsilon=10^{-8}\)。
一阶矩:\(m=0.9\cdot0+0.1\cdot0.2=0.02\)。
二阶矩:\(v=0.999\cdot0+0.001\cdot0.2^2=0.00004\)。
偏差修正:\(\hat m=\dfrac{0.02}{1-0.9^1}=\dfrac{0.02}{0.1}=0.2\);\(\quad\hat v=\dfrac{0.00004}{1-0.999^1}=\dfrac{0.00004}{0.001}=0.04\)。
更新量:\(\eta\dfrac{\hat m}{\sqrt{\hat v}+\epsilon}=0.1\times\dfrac{0.2}{\sqrt{0.04}+10^{-8}}=0.1\times\dfrac{0.2}{0.2}=0.1\)。
于是 \(\theta\leftarrow 1.0-0.1=0.9\)。
注意这个漂亮的现象:偏差修正后,第一步的更新量恰好约等于 \(\eta\)(步长)本身,几乎与梯度大小无关。这说明 Adam 把步长"归一化"了——梯度是 0.2 还是 200,第一步走的距离都差不多是 \(\eta\)。这让 Adam 对梯度尺度(scale)非常鲁棒,也是它在各种网络上"开箱即用"的原因。
但别误以为 Adam 每步都走满 \(\eta\)。更新量约等于 \(\eta\) 只在某方向上历史梯度方向一致时成立——这时 \(|\hat m|\) 与 \(\sqrt{\hat v}\) 同量级,比值约为 1。反过来,若某方向梯度反复变号、正负相消,一阶矩 \(\hat m\) 会被抵消得很小、而二阶矩 \(\hat v\)(用平方、永远为正)几乎不变,于是 \(\hat m/\sqrt{\hat v}\) 变小、该方向步长自动缩小。这种"噪声方向自动减速"正是 Adam 在带噪 mini-batch 上既快又稳的关键——它和上一节动量"正负相消压横向震荡"其实是同一个直觉的两种体现。
ML Adam 是"廉价的对角近似二阶方法"
理想的二阶方法是牛顿法:\(\theta\leftarrow\theta-H^{-1}\nabla L\),它用 Hessian \(H\) 拉正病态、一步到位。但 \(H\) 是 \(d\times d\) 矩阵,求逆要 \(O(d^3)\),而深度网络 \(d\) 动辄上亿——完全不可行。所以深度学习几乎全用一阶方法。Adam 里的 \(\sqrt{\hat v}\) 可以看作对 Hessian 对角线的一个廉价估计:用梯度平方近似曲率,逐参数地缩放步长。它不是真二阶,但抓住了二阶"按曲率调步长"的精髓,代价只有 \(O(d)\)。
四、AdamW:为什么不是"Adam + L2"
训练里常加权重衰减(weight decay)把参数往 0 拉,防过拟合。传统做法是在损失里加 L2 正则项 \(\frac\lambda2\|\theta\|^2\),它的梯度是 \(\lambda\theta\),直接并进 \(g\) 里。在最朴素的 SGD(无动量、定学习率)上,"L2 正则"和"权重衰减"完全等价——把 \(\lambda\theta\) 加进梯度,效果就等于每步把 \(\theta\) 按比例往 0 收缩。
AdamW 的修复叫解耦权重衰减(decoupled weight decay):把权重衰减从梯度里拿出来,不经过任何优化器状态(动量、自适应分母),直接作用在参数上:
\[ \theta \leftarrow \theta - \eta\Big(\frac{\hat m}{\sqrt{\hat v}+\epsilon} + \lambda\,\theta\Big). \]这样衰减项 \(\lambda\theta\) 干净地、按统一比例把每个参数往 0 收缩,与自适应步长互不干扰。
五、学习率调度:warmup 与 cosine 衰减
学习率 \(\eta\) 不必是常数,让它随训练过程变化往往更好。两个最常见的部件:
线性 warmup(预热)
训练最开始,参数是随机初始化的,\(\hat v\) 的统计量还没积累准、梯度可能很离谱。这时若直接用大学习率,一步走太远,loss 容易直接变成 NaN(爆掉)。warmup 让 \(\eta\) 从 0(或很小)线性爬升到目标值,给优化器一段"热身"时间稳住统计量。
cosine 衰减
热身结束后,让 \(\eta\) 按余弦曲线平滑地从峰值降到接近 0。前期大步快速探索,后期小步精细收敛(在极小值附近别再乱跳)。设 warmup 步数 \(T_w\)、总步数 \(T\)、峰值 \(\eta_{\max}\):
\[ \eta(t)=\begin{cases}\eta_{\max}\,\dfrac{t}{T_w}, & t梯度裁剪(gradient clipping)
即便有了 warmup,偶尔还会撞上一个异常大的梯度(尤其 RNN / Transformer),一步把参数甩飞。梯度裁剪给梯度范数设上限:若 \(\|g\|>c\),就把整个梯度按比例缩回到范数为 \(c\):
\[ g \leftarrow g\cdot\min\!\Big(1,\ \frac{c}{\|g\|}\Big). \]方向不变,只削掉过大的长度。它是防"梯度爆炸(gradient explosion)"导致 NaN 的廉价保险,几乎所有大模型训练都开着(典型 \(c=1.0\))。
调一调,观察现象
下面三个小实验都用同一个病态二次型 \(L(\theta)=\frac12(\theta_1^2+50\,\theta_2^2)\)(条件数 50,谷底在原点),亲手感受三种优化器的差异。改一个东西,先猜会发生什么,再跑验证。
实验 1:在病态山谷上比 GD / 动量 / Adam
改什么:对同一个起点 \((5,5)\) 跑三种优化器各 200 步。注意三者学习率不同:GD 用 0.035、动量用 0.01、Adam 用 0.2。动量学习率比 GD 小约一个量级,正是因为前面说过的——动量的 \(v\) 是梯度的"加权和(约 10 倍量级)"而非平均,所以配套 \(\eta\) 要相应调小。
预期看到:最终 loss 大致 GD ≈ \(8\times10^{-6}\) > 动量 ≈ \(2\times10^{-7}\) > Adam ≈ \(4\times10^{-8}\)。即动量和 Adam 都明显比普通 GD 收敛得更低。
为什么:病态方向 \(\theta_2\)(曲率 50)逼着 GD 用极小的 \(\eta\)(必须 \(<2/50\))才不发散,于是平缓方向 \(\theta_1\) 爬得奇慢;动量靠惯性加速平缓方向,Adam 靠 \(\sqrt{\hat v}\) 把两个方向的步长拉到同一量级,两者都绕开了病态的束缚。
import numpy as np
a, b = 1.0, 50.0 # 条件数 = 50(病态)
def grad(p): return np.array([a*p[0], b*p[1]])
def loss(p): return 0.5*(a*p[0]**2 + b*p[1]**2)
def gd(eta, steps):
p = np.array([5.0, 5.0])
for _ in range(steps): p = p - eta*grad(p)
return loss(p)
def momentum(eta, beta, steps):
p = np.array([5.0, 5.0]); v = np.zeros(2)
for _ in range(steps):
v = beta*v + grad(p); p = p - eta*v
return loss(p)
def adam(eta, steps):
p = np.array([5.0, 5.0]); m = np.zeros(2); v = np.zeros(2)
b1, b2, eps = 0.9, 0.999, 1e-8
for t in range(1, steps+1):
g = grad(p)
m = b1*m + (1-b1)*g
v = b2*v + (1-b2)*g*g
mh = m/(1-b1**t); vh = v/(1-b2**t)
p = p - eta*mh/(np.sqrt(vh)+eps)
return loss(p)
print("GD :", gd(0.035, 200)) # ~8e-6
print("Momentum:", momentum(0.01, 0.9, 200)) # ~2e-7
print("Adam :", adam(0.2, 200)) # ~4e-8实验 2:量化动量如何减少之字形
改什么:记录每步后病态坐标 \(\theta_2\) 的符号,数它在 60 步里翻转了多少次(翻转 = 越过谷底来回横跳)。
预期看到:普通 GD 约 59 次翻转(几乎每步都横跳一次),加动量后骤降到约 14 次。
为什么:动量把方向反复的横向梯度平均掉了,路径变"直",越过谷底的次数自然大减。
import numpy as np
a, b = 1.0, 50.0
def grad(p): return np.array([a*p[0], b*p[1]])
def flips(ys):
s = np.sign(ys)
return int(np.sum(s[1:]*s[:-1] < 0)) # 相邻符号相反 = 一次翻转
p = np.array([5.0,5.0]); ys=[]
for _ in range(60): p = p - 0.035*grad(p); ys.append(p[1])
print("GD 翻转次数:", flips(ys)) # ~59
p = np.array([5.0,5.0]); v=np.zeros(2); ys=[]
for _ in range(60): v=0.9*v+grad(p); p=p-0.01*v; ys.append(p[1])
print("动量 翻转次数:", flips(ys)) # ~14实验 3:full-batch 平滑 vs SGD 带噪
改什么:同一个线性回归问题,一次用全部样本算梯度,一次每步只随机抽 1 个样本。打印 loss 序列。
预期看到:full-batch 的 loss 单调平滑下降;SGD 的 loss 上下抖动但总体也在降。再验证一行:单样本梯度的平均精确等于全梯度(无偏)。
为什么:SGD 每步用的是真梯度的无偏但带噪估计,方向平均对、单步有抖动。
import numpy as np
rng = np.random.default_rng(0)
N, d = 200, 5
X = rng.normal(size=(N,d)); w_true = rng.normal(size=d)
y = X @ w_true + 0.1*rng.normal(size=N)
def full_loss(w): r = X@w - y; return 0.5*np.mean(r**2)
w = np.zeros(d) # full-batch
for t in range(30):
g = X.T@(X@w - y)/N; w = w - 0.1*g
if t%6==0: print("full ", round(full_loss(w),3))
w = np.zeros(d) # SGD, batch=1
for t in range(30):
i = rng.integers(N); xi = X[i]
g = xi*(xi@w - y[i]); w = w - 0.1*g
if t%6==0: print("sgd ", round(full_loss(w),3))
# 无偏性:逐样本梯度的平均 == 全梯度
w = rng.normal(size=d)
full_g = X.T@(X@w - y)/N
samp = np.mean([X[i]*(X[i]@w - y[i]) for i in range(N)], axis=0)
print("无偏(两者相等):", np.allclose(full_g, samp)) # True动手练习
- 手算第二步 Adam。接正文 Adam 例题(第 1 步后 \(\theta=0.9,\ m=0.02,\ v=0.00004\))。设第 2 步梯度仍是 \(g=0.2\),手算 \(t=2\) 的 \(m,v,\hat m,\hat v\) 与新的 \(\theta\),再写代码验证。提示:偏差修正分母用 \(1-\beta_1^2,\ 1-\beta_2^2\);你应得到 \(m=0.038,\ v\approx8.0\times10^{-5},\ \hat m=0.2,\ \hat v=0.04,\ \theta=0.8\)。
- 实现 RMSProp 并和 Adam 比。在实验 1 的病态二次型上加一个
rmsprop(eta, rho, steps)(无动量、无偏差修正),观察它收敛比 Adam 慢一些——体会"动量 + 偏差修正"带来的增益。 - 实现 warmup + cosine 调度。写一个
lr(t, warm, total, base)函数(用正文公式),打印 \(t=0,10,\dots,100\)(\(\text{warm}=10,\ \text{total}=100,\ \text{base}=0.1\))的学习率,确认它在 \(t=10\) 达到峰值 0.1、在 \(t=100\) 降到 0。 - 梯度裁剪实验。在实验 1 的 GD 里把 \(\eta\) 调到 0.05(接近发散阈值 \(2/50=0.04\) 之上)让它爆掉,然后加一行梯度裁剪 \(g\leftarrow g\min(1, c/\|g\|)\),\(c=1\),观察是否被救回(loss 不再发散为 inf)。提示:起点 \((5,5)\) 的真实梯度范数约 250,被 \(c=1\) 压成约 \(1/250\),步子极小——打印裁剪后的 loss 看看,它确实不再爆掉,但 200 步只爬到约 0.03、远没到谷底,亲身体会"防爆 ≠ 收敛好"这个权衡。
- (选做)Rosenbrock 香蕉谷。把损失换成 \(L(x,y)=(1-x)^2+100(y-x^2)^2\)(最小值在 \((1,1)\)),起点 \((-1.5,1.5)\),比较 GD 与 Adam 谁更快接近 \((1,1)\)。提示:手算梯度 \(\partial_x L=-2(1-x)-400x(y-x^2),\ \partial_y L=200(y-x^2)\)。
掌握自检
- 能否用一句话说清"为什么 mini-batch 梯度是无偏估计",并说出 batch 大小如何影响方差与单步成本?
- 给定 \(v,\beta,\nabla L,\eta\),能否口算一步动量更新,并解释它为何压住病态山谷的之字形?能否说清动量的 \(v\) 是"加权和"而非"平均",以及这为什么影响配套学习率?
- 能否默写 Adam 的 \(m,v,\hat m,\hat v\) 四式和更新式,说出偏差修正在第一步起了什么作用,并解释为什么"反复变号的方向步长会自动缩小"?
- 能否解释"为什么在有状态优化器(动量 / Adam)上要用解耦权重衰减,而不是把 L2 加进梯度"?
- 能否说出 warmup 防的是什么(初期 NaN)、cosine 衰减解决什么(后期精收敛)、梯度裁剪防的是什么(梯度爆炸),以及为什么裁剪"防爆不等于收敛好"?
- 能否解释为什么牛顿法(\(O(d^3)\))在深度学习不可行,以及 Adam 在何种意义上是"廉价的对角近似二阶"?
可以先放过的点
- 动量的精确收敛速率分析(Polyak heavy-ball、Nesterov 加速的 \(\sqrt\kappa\) 改进):现在只需记住"惯性压震荡、加速病态方向",等你需要证明收敛界时再回来。
- Adam 的理论收敛性争议(AMSGrad 等修正):知道 Adam 工程上好用、个别情形不收敛即可,深入留到读优化论文时。
- 各种学习率调度变体(one-cycle、带 restart 的 cosine、阶梯衰减):先吃透 warmup+cosine 这一主力,其余是同一思想的换皮。
- 二阶方法的实用近似(L-BFGS、K-FAC、Shampoo):等你训练超大模型、对 Adam 不满意时再了解。
下一课预告:优化器我们就讲到这。接下来两课转入强化学习速成——状态 \(s\)、动作 \(a\)、奖励 \(r\)、策略 \(\pi(a|s)\)、价值 \(V^\pi,Q^\pi\) 这套语言,正是后面理解 RLHF(基于人类反馈的强化学习)对齐大模型的地基。你会看到,策略梯度(policy gradient)本质上又是一次"用采样做无偏估计 + 梯度上升",和这一课的 SGD 一脉相承。