首页 / 模块 2 · 概率统计、信息论与最优化 / 第 8 课(共 10 课)

现代优化器——SGD、动量、Adam 与调度

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:现代优化器——把损失变成训练好的模型(AdamW+warmup 是大模型标配)

上一课我们看清了梯度下降的几何:在病态(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\) 是百万、上亿时,光算一步就要把整个数据集过一遍——慢到无法接受。

直觉:你想知道"全国人的平均身高",难道要量每一个人吗?随机抽 100 个人量一量,平均值就已经很接近真值了。随机梯度下降(Stochastic Gradient Descent, SGD)就是这个"抽样"思想用在梯度上:随机抽一个(或一小批)样本,用它们的梯度当作全体梯度的近似,省钱地往前走一步。

关键在于这个近似是无偏的(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)。

full-batch 平滑下降 vs SGD 带噪但更快取得进展的 loss 曲线对比full-batch(平滑) vs SGD(带噪但快)迭代步数 (iteration)lossfull-batch:平滑、近单调SGD:带噪抖动、每步便宜
图1:full-batch(蓝色,平滑近单调下降)vs SGD(橙色,带噪抖动但更快取得进展)的 loss 曲线对比。数值取自正文实验3的真实运行趋势。
关键结论:full-batch 的 loss 曲线平滑、几乎单调下降;SGD 的 loss 曲线带噪、上下抖动,但单位时间内进展更快,因为每步便宜太多。实践中我们几乎总用 mini-batch(典型 \(B=32\sim 8192\)),在"每步成本"和"梯度方差"之间取折中。

厘清三个被滥用的词:epoch / batch / 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 的麻烦在于:在陡峭方向(大特征值)上步子太大、来回弹跳;在平缓方向(小特征值)上步子太小、推进缓慢。每一步只看当前梯度,没有记忆,于是被陡峭方向的反复横跳主导。

直觉:想象一个小球从山谷壁上滚下。它不会每一步都听任当前坡度乱拐,而是带着惯性(momentum)——往谷底方向积累的速度会保留下来,左右横跳的分量则因为方向反复、正负相消而被抵消。结果:横向震荡被压平,纵向(指向谷底)的速度越滚越快。

把这个"惯性"写成公式,就是维护梯度的累加作为"速度" \(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\) 不是"平均",是"加权和"。注意上式没有 \(1-\beta\) 因子(这叫无归一化(un-normalized)的指数滑动平均,是 SGD-momentum 的默认写法)。在方向一致的稳态下,\(v\) 会趋于约 \(\dfrac{1}{1-\beta}=10\) 倍的单步梯度——它是"把最近约 10 步的梯度按几何权重累加",量级被放大了约 10 倍,而不是对它们取平均。所以配套的学习率要相应调小:这正是后面实验 1 里动量用 \(\eta=0.01\)、而 GD 用 \(\eta=0.035\) 差了约一个量级的原因。下一节 RMSProp / Adam 用的是带 \(1-\beta\) 因子的归一化形式,那里的 \(v\) 才是真正的"平均",量级与单步梯度相当,无需为此另调学习率。
病态狭长山谷中普通 GD 之字形横跳与带动量直冲谷底的对比动量 = 惯性:之字形 vs 直冲谷底谷底起点普通 GD:横向梯度反复,之字形横跳带动量:EMA 抵消横向、累加纵向,更直冲谷底
图2:病态狭长山谷(椭圆等高线)里,普通 GD(橙色)来回之字形横跳,带动量(蓝色)靠惯性更直地冲向谷底。横向反复的梯度被 EMA 平均抵消,纵向梯度累加加速。

为什么能压震荡?在横向(陡峭)方向,相邻梯度正负交替,加权平均后大量抵消,\(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}\),下同。)

易错 / 缺陷:\(G\) 是单调递增、永不减小的累加。训练久了 \(G\to\infty\),于是有效学习率 \(\to 0\),参数过早地停止更新,哪怕还远没收敛。这是 AdaGrad 的致命伤。

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 三件套结构图:一阶矩动量定方向、二阶矩 RMSProp 定逐参数步长,经偏差修正后合成最终参数更新Adam = 动量 + 自适应 + 偏差修正一阶矩 m(动量)m←β₁m+(1−β₁)g定方向二阶矩 v(RMSProp)v←β₂v+(1−β₂)g²定逐参数步长偏差修正m̂ = m /(1−β₁ᵗ)v̂ = v /(1−β₂ᵗ)最终更新θ ← θ −η·m̂/(√v̂+ε)m 提供方向 · v 提供逐参数缩放 · 修正消除初期偏向 0 的偏差
图3:Adam 三件套结构图。一阶矩 m(动量,定方向)+ 二阶矩 v(RMSProp,定逐参数步长)→ 偏差修正 m̂, v̂ → 合成最终更新 θ ← θ − η·m̂/(√v̂+ε)。

例题:手算一步 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 收缩。

易错:一旦更新变成"有状态"的,两者就不再等价!问题的根源是:把 \(\lambda\theta\) 塞进梯度,它就会被卷进优化器的内部状态里被扭曲。在带动量的 SGD 上已经萌芽——\(\lambda\theta\) 会进入动量缓冲被一起滑动平均,而真正的权重衰减不会。到了 Adam 上最严重:\(\lambda\theta\) 会一起被 \(\sqrt{\hat v}\) 除一遍,于是梯度大(\(\hat v\) 大)的参数其衰减被削弱、梯度小的参数衰减被放大——衰减力度被自适应分母扭曲,不再是我们想要的"对所有参数一视同仁地往 0 拉"。

AdamW 的修复叫解耦权重衰减(decoupled weight decay):把权重衰减从梯度里拿出来,经过任何优化器状态(动量、自适应分母),直接作用在参数上:

\[ \theta \leftarrow \theta - \eta\Big(\frac{\hat m}{\sqrt{\hat v}+\epsilon} + \lambda\,\theta\Big). \]

这样衰减项 \(\lambda\theta\) 干净地、按统一比例把每个参数往 0 收缩,与自适应步长互不干扰。

关键结论:AdamW 是当今 Transformer / 大语言模型(LLM)训练的事实标准。凡是看到 Transformer 训练脚本,优化器几乎一定是 AdamW 而非朴素 Adam。记住一句话:只要优化器是有状态的(动量或自适应分母),就别把 L2 塞进梯度,改用解耦权重衰减(AdamW)。

五、学习率调度:warmup 与 cosine 衰减

学习率 \(\eta\) 不必是常数,让它随训练过程变化往往更好。两个最常见的部件:

线性 warmup(预热)

训练最开始,参数是随机初始化的,\(\hat v\) 的统计量还没积累准、梯度可能很离谱。这时若直接用大学习率,一步走太远,loss 容易直接变成 NaN(爆掉)。warmup 让 \(\eta\) 从 0(或很小)线性爬升到目标值,给优化器一段"热身"时间稳住统计量。

直觉:冷启动的发动机别一脚地板油。warmup 就是先怠速几分钟再加速。Transformer 训练几乎必备 warmup——这是踩过无数 NaN 后的经验共识。

cosine 衰减

热身结束后,让 \(\eta\) 按余弦曲线平滑地从峰值降到接近 0。前期大步快速探索,后期小步精细收敛(在极小值附近别再乱跳)。设 warmup 步数 \(T_w\)、总步数 \(T\)、峰值 \(\eta_{\max}\):

\[ \eta(t)=\begin{cases}\eta_{\max}\,\dfrac{t}{T_w}, & t线性 warmup + cosine 衰减的学习率调度曲线学习率调度:线性 warmup + cosine 衰减η_max0训练步数 t学习率 ηwarmup 结束峰值 η_max线性 warmup(防初期 NaN)cosine 衰减(精细收敛至 0)
图4:warmup + cosine 学习率调度曲线。先线性 warmup 从 0 爬升到峰值 η_max(防初期 NaN),再按 cosine 平滑衰减到 0(后期精细收敛)。竖虚线标出 warmup 结束点。

梯度裁剪(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\))。

易错:裁剪只保证"不爆",不保证"收敛得好"。它把梯度范数硬压到不超过 \(c\),如果 \(c\) 相对真实梯度范数太小,步子会被掐得过死、收敛变慢(你在练习 4 里会亲眼看到:救回来了,但 200 步还远没到谷底)。所以梯度裁剪是防爆炸的保险,不是调步长的手段——别指望它替你把学习率调对。

调一调,观察现象

下面三个小实验都用同一个病态二次型 \(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

动手练习

  1. 手算第二步 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\)。
  2. 实现 RMSProp 并和 Adam 比。在实验 1 的病态二次型上加一个 rmsprop(eta, rho, steps)(无动量、无偏差修正),观察它收敛比 Adam 慢一些——体会"动量 + 偏差修正"带来的增益。
  3. 实现 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。
  4. 梯度裁剪实验。在实验 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、远没到谷底,亲身体会"防爆 ≠ 收敛好"这个权衡。
  5. (选做)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 在何种意义上是"廉价的对角近似二阶"?

下一课预告:优化器我们就讲到这。接下来两课转入强化学习速成——状态 \(s\)、动作 \(a\)、奖励 \(r\)、策略 \(\pi(a|s)\)、价值 \(V^\pi,Q^\pi\) 这套语言,正是后面理解 RLHF(基于人类反馈的强化学习)对齐大模型的地基。你会看到,策略梯度(policy gradient)本质上又是一次"用采样做无偏估计 + 梯度上升",和这一课的 SGD 一脉相承。