第 0 周 · Day 2:优化与训练动力学

Transformers and Large Language Models · Week 0 · Day 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓打开 PDF ↗下载 PDF ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

时长 7–8 小时:§1(梯度下降在做什么)2h · §2–§3(优化器)1.5h · §4(归一化)2h · §5–§7(初始化、调度、诊断)1h · §A–§C 1.5h

配套代码 day2_checks.py(纯 numpy):最速方向与范数、最优步长与牛顿法、GD 的 O(κ)O(\kappa) vs 动量的 O(κ)O(\sqrt\kappa)、LayerNorm 雅可比的投影结构、Pre-LN/Post-LN 随深度的梯度缩放、三种初始化的 10 层对比、Adam 偏差修正、AdamW vs L2

§B 参考实现(code/ 目录):d2_layernorm_autograd.py、d2_diagnostics.py、d2_optimizers_numpy.py。先自己写,再对照;一键自检 python code/run_selftests.py,说明见 code/README.md。


§0 今天的主线

0.1 一个问题

Day 1 回答了”梯度怎么算”。今天回答:

有了梯度之后,怎么把参数更新到好的地方?

听起来只是一行 θ←θ−ηg\theta\leftarrow\theta-\eta g。但这一行有四种独立的失败方式,现代训练的四大组件正好各治一种:

失败方式组件本文
梯度方向本身不好使(地形病态)优化器§1–§3
每层输出的尺度失控,连乘爆炸或消失归一化§4
出发点就错了,第一步就崩初始化§5
训练不同阶段该用不同步长学习率调度§6

0.2 贯穿全天的一个视角

今天的核心不是记住五六个优化器的公式,而是建立一个统一视角(§1.5):

每一个优化器,都是在某一把”尺子”下做最速下降。

SGD 用欧氏尺子,牛顿法用 Hessian 当尺子,Adam 用一把对角尺子,符号下降用 L∞L_\infty 尺子。有了这个视角,AdaGrad、RMSProp、Adam、甚至 2024 年的 Muon,都不再是一堆独立的技巧,而是同一个问题(“尺子该怎么选”)的不同回答。

归一化和初始化也能放进这个视角:它们不改尺子,而是改地形本身,让地形对任何尺子都更友好。

0.3 今天会用到的 Day 1 结论

  • Day 1 §7.2:梯度是矩阵连乘,增益偏离 1 就指数爆炸或消失 → 归一化和初始化的动机
  • Day 1 §3.1:梯度与参数同形状 → Adam 为什么吃 2 倍参数量的显存
  • Day 1 §7.2:残差的恒等路径 → Pre-LN 为什么好训

0.4 和正课的关系

  • Lecture 1–2:LayerNorm、RMSNorm、Pre-LN(§4)
  • Lecture 4:AdamW 的显存、混合精度、ZeRO(§3、§A.5)
  • 所有 Transformer 训练:warmup + cosine、残差缩放初始化(§5.3、§6)

§1 梯度下降到底在做什么

1.1 梯度为什么是”最陡方向”:取决于你用什么尺子量

动机。 人人都说”梯度是下降最快的方向”,但很少有人追问:“最快”是相对什么说的? 这个追问是今天整个统一视角的起点。

推导。 一阶泰勒展开:沿方向 dd 走一小步 ϵ\epsilon,

L(θ+ϵd)≈L(θ)+ϵ g⊤dL(\theta+\epsilon d)\approx L(\theta)+\epsilon\,g^\top d

要找让 LL 下降最多的方向,就是在”长度为 1 的方向”里最小化 g⊤dg^\top d。注意”长度为 1”需要一把尺子。 用欧氏长度 ∥d∥2=1\|d\|_2=1,由柯西-施瓦茨不等式,

g⊤d ≥ −∥g∥2∥d∥2=−∥g∥2g^\top d\ \ge\ -\|g\|_2\|d\|_2=-\|g\|_2

等号当且仅当 d=−g/∥g∥2d=-g/\|g\|_2 时成立。所以在欧氏尺子下,最陡方向是负梯度。

换一把尺子,答案就变了。

  • 用 L∞L_\infty 尺子(max⁡i∣di∣=1\max_i|d_i|=1):每个坐标可以独立取到 ±1\pm1,最小化 ∑igidi\sum_ig_id_i 就是让每一项都取负,于是 d=−sign(g)d=-\mathrm{sign}(g)。这叫符号下降。
  • 用由正定矩阵 PP 定义的尺子(d⊤Pd=1\sqrt{d^\top Pd}=1):令 e=P1/2de=P^{1/2}d,则 g⊤d=(P−1/2g)⊤eg^\top d=(P^{-1/2}g)^\top e,∥e∥2=1\|e\|_2=1,由上面的结论 e∝−P−1/2ge\propto-P^{-1/2}g,换回来 d∝−P−1gd\propto-P^{-1}g。这叫预条件梯度下降。

day2_checks.py 在 40 万个随机方向里暴力搜索最优方向,三种尺子下都和公式方向对上了(余弦 0.990–0.998,差距来自有限采样)。

意义。 “梯度是最陡方向”这句话只在欧氏几何下成立。欧氏尺子是一个选择,而且通常不是好的选择(§1.3 会看到为什么)。所有优化器的差别,本质上都是尺子的差别。 §1.5 会把这件事讲完整。

1.2 步长该多大:由曲率决定

动机。 §1.1 只给了方向,没说走多远。一阶展开里 ϵ\epsilon 越大下降越多,显然不对,因为一阶近似只在局部成立。要知道走多远,得看二阶。

推导。 二阶泰勒展开,HH 是 Hessian:

L(θ−ηg)≈L(θ)−η g⊤g+12η2 g⊤HgL(\theta-\eta g)\approx L(\theta)-\eta\,g^\top g+\tfrac12\eta^2\,g^\top Hg

这是 η\eta 的二次函数,对 η\eta 求导令其为 0:

−g⊤g+η g⊤Hg=0⇒ η∗=g⊤gg⊤Hg -g^\top g+\eta\,g^\top Hg=0\qquad\Rightarrow\qquad\boxed{\ \eta^*=\frac{g^\top g}{g^\top Hg}\ }

day2_checks.py 用网格搜索验证了这个公式(0.013170 vs 0.013166)。

直觉。 如果 gg 恰好沿着 HH 的某个特征向量(特征值 λ\lambda),那么 η∗=1/λ\eta^*=1/\lambda。最优步长就是那个方向上曲率的倒数:地形越陡越弯,步子越小;越平越直,步子越大。

问题在于:不同方向的曲率可以差几个数量级,而梯度下降只有一个 η\eta。 下一节看这会导致什么。

1.3 病态条件:一个能算到底的模型

用二次型分析,因为它能算到底,而且任何光滑损失在极小点附近都近似二次:

L(θ)=12θ⊤Hθ,H=QΛQ⊤ (对称正定)L(\theta)=\tfrac12\theta^\top H\theta,\qquad H=Q\Lambda Q^\top\ \text{(对称正定)}

梯度 ∇L=Hθ\nabla L=H\theta,梯度下降是 θt+1=(I−ηH)θt\theta_{t+1}=(I-\eta H)\theta_t。在特征基下(θ~=Q⊤θ\tilde\theta=Q^\top\theta)各方向完全解耦:

θ~i,t=(1−ηλi)t θ~i,0\tilde\theta_{i,t}=(1-\eta\lambda_i)^t\,\tilde\theta_{i,0}

收敛要求对每个 ii 都有 ∣1−ηλi∣<1|1-\eta\lambda_i|<1,即

 0<η<2λmax⁡ \boxed{\ 0<\eta<\frac{2}{\lambda_{\max}}\ }

这就是那个要命的矛盾:

  • 学习率上界被最大曲率 λmax⁡\lambda_{\max} 卡死:超过它,最陡的方向就开始震荡发散
  • 收敛速度被最小曲率 λmin⁡\lambda_{\min} 拖住:最平的方向每步只挪一点点

取最优固定步长 η=2λmax⁡+λmin⁡\eta=\frac{2}{\lambda_{\max}+\lambda_{\min}},最慢方向每步收缩 κ−1κ+1\frac{\kappa-1}{\kappa+1},其中 κ≡λmax⁡/λmin⁡\kappa\equiv\lambda_{\max}/\lambda_{\min} 是条件数。所需步数正比于 κ\kappa。day2_checks.py 实测:

κ\kappa梯度下降步数(误差降到 10−610^{-6})
1066
100609
10006,471
1000062,586

κ\kappa 每乘 10,步数就乘 10。 深度网络的条件数动辄 10410^4 以上。

几何图像:一个极其扁长的峡谷。梯度几乎垂直于峡谷轴线,于是你在陡峭的两壁之间来回横跳,沿谷底(真正要去的方向)几乎不动。

诊断句式:训练不动 = 病态条件 + 学习率被最陡方向卡住。

1.4 牛顿法:理想答案,以及它为什么用不了

直觉。 §1.2 说最优步长是曲率的倒数,§1.3 说麻烦在于不同方向曲率不同。那给每个方向各配一个步长,不就好了?

推导。 直接最小化整个二阶模型 g⊤Δ+12Δ⊤HΔg^\top\Delta+\frac12\Delta^\top H\Delta,对 Δ\Delta 求导令其为 0:

g+HΔ=0⇒ Δ=−H−1g g+H\Delta=0\qquad\Rightarrow\qquad\boxed{\ \Delta=-H^{-1}g\ }

在二次型上它一步到位(day2_checks.py:一步之后 ∥θ∥=1.1×10−14\|\theta\|=1.1\times10^{-14})。在特征基下看得最清楚:H−1H^{-1} 把第 ii 个方向的步长恰好设为 1/λi1/\lambda_i,所有方向同时被”拉平”,条件数变成 1。

和 §1.1 连起来:牛顿法就是用 P=HP=H 这把尺子做最速下降。 用地形自己的曲率去量长度,陡的方向”尺子上的一格”很短,平的方向很长,病态条件就消失了。

为什么用不了? 三个理由,每一个都致命:

  1. 存不下:70 亿参数的 Hessian 有 4.9×10194.9\times10^{19} 个元素。
  2. 求逆太贵:O(n3)O(n^3)。
  3. 非凸时方向是错的:深度网络的 Hessian 有负特征值(鞍点),−H−1g-H^{-1}g 在负曲率方向上会往上爬,被鞍点吸过去。

意义。 牛顿法给出了一个”理想”,而后面所有实用优化器都可以理解为:用便宜的办法近似 HH 这把尺子。对角近似(AdaGrad、Adam)、分块近似(K-FAC、Shampoo)、低秩近似(L-BFGS),都是在存储、计算和效果之间找平衡。

1.5 统一视角:每个优化器都是在选一把尺子

把 §1.1 和 §1.4 合起来:

Δθ=−η P−1g\Delta\theta=-\eta\,P^{-1}g

选不同的 PP,就得到不同的优化器:

优化器尺子 PP代价
SGDII(欧氏)无,但对病态条件毫无办法
牛顿法HH存不下、算不起、非凸时方向错
AdaGrad / Adam对角阵,由梯度历史估计O(n)O(n),只能纠正坐标轴方向上的尺度差异
符号下降L∞L_\infty 范数无,每个坐标只看符号
Shampoo / Muon按矩阵结构(行、列)构造几倍于 Adam 的计算,能纠正相关方向上的差异

最后一行是近年的方向。以 Muon(2024)为例,它把参数看成矩阵而不是一串数字,用矩阵的谱范数当尺子。在谱范数下,§1.1 那个”最小化 tr(G⊤D)\mathrm{tr}(G^\top D) s.t. ∥D∥≤1\|D\|\le1“的解是 D=−UV⊤D=-UV^\top(G=UΣV⊤G=U\Sigma V^\top 是梯度的 SVD),也就是把梯度矩阵的所有奇异值拉成 1。Muon 实际做的是用几步 Newton-Schulz 迭代近似地把(带动量的)更新矩阵正交化,因为每步都做精确 SVD 太贵。day2_checks.py 验证了这个解的取值恰好等于 −∥G∥∗-\|G\|_*(核范数),20 万个随机方向都没能超过它。

这个视角的价值:以后看到任何新优化器,先问一句”它用的是什么尺子”,就能迅速定位它在解决什么问题、会付出什么代价。

1.6 随机性:mini-batch 梯度的方差

真实训练用 mini-batch 梯度,它是全量梯度的无偏估计,但有方差:

E[gB]=g,Var[gB]∝1B\mathbb{E}[g_B]=g,\qquad\mathrm{Var}[g_B]\propto\frac1B

方差有两面:靠近极小点时噪声让参数在附近抖而不收敛(所以需要学习率衰减,§6.2);但噪声也帮助逃离鞍点和尖锐极小点,普遍认为这是 SGD 隐式正则效果的来源之一。

这也解释了 batch size 和学习率的关系:BB 增大、噪声变小,可以用更大的学习率。SGD 下”噪声尺度”约为 η/B\eta/B,要保持它不变就是线性缩放 η∝B\eta\propto B;Adam 的更新已经被 v^\sqrt{\hat v} 归一化过,经验上更接近 η∝B\eta\propto\sqrt B。这是经验规律,大 batch 下两者都会失效。


§2 优化器的演化链

每一步只解决上一步的一个具体问题。带着 §1.5 的”尺子”视角看,这条链会非常清楚。

2.1 SGD

θt+1=θt−ηgt\theta_{t+1}=\theta_t-\eta g_t

尺子是 II。§1.3 的病态条件问题原封不动。

2.2 Momentum:不换尺子,也能把 κ\kappa 降成 κ\sqrt\kappa

动机。 换尺子要估计曲率,贵。有没有不估曲率也能对付峡谷的办法?

直觉:重球。 想象一个有质量的球滚进峡谷。在两壁之间,它每次撞壁都被反弹,来回的冲量互相抵消;沿谷底,每一步的推力方向都相同,冲量不断累积。惯性自动压掉了横向震荡,放大了纵向前进。

bt=μ bt−1+gt,θt+1=θt−η btb_t=\mu\,b_{t-1}+g_t,\qquad\theta_{t+1}=\theta_t-\eta\,b_t

(这是 PyTorch SGD(momentum=μ) 的实际形式。注意 gtg_t 前没有 (1−μ)(1-\mu),和 EMA 形式差一个缩放。)

推导一:有效步长的放大。 假设梯度恒为 gg,稳态时 b=μb+gb=\mu b+g,解得 b=g1−μb=\frac{g}{1-\mu},于是

有效学习率=η1−μ\text{有效学习率}=\frac{\eta}{1-\mu}

μ=0.9\mu=0.9 时是 10 倍,μ=0.99\mu=0.99 是 100 倍。开动量时必须相应调小学习率,否则直接发散,这是新手最常踩的坑之一。

推导二:为什么是 κ\sqrt\kappa(Polyak 1964)。 这一段回答”动量到底强在哪”,不只是”平滑”那么简单。

在二次型的第 ii 个特征方向上,重球法的迭代是(写成 xx 以免下标太多):

xk+1=(1+β−αλ) xk−β xk−1x_{k+1}=(1+\beta-\alpha\lambda)\,x_k-\beta\,x_{k-1}

这是一个二阶线性递推(梯度下降是一阶的)。它的特征方程是

r2−(1+β−αλ) r+β=0r^2-(1+\beta-\alpha\lambda)\,r+\beta=0

关键观察:当判别式 ≤0\le0,两个根是一对共轭复数,模长 ∣r∣=β|r|=\sqrt\beta,与 λ\lambda 无关。也就是说,只要所有特征方向的判别式都 ≤0\le0,所有方向以同一个速率 β\sqrt\beta 收敛,曲率差异被抹平了。

判别式 ≤0\le0 等价于 ∣1+β−αλ∣≤2β|1+\beta-\alpha\lambda|\le2\sqrt\beta,即

(1−β)2 ≤ αλ ≤ (1+β)2(1-\sqrt\beta)^2\ \le\ \alpha\lambda\ \le\ (1+\sqrt\beta)^2

要对所有 λ∈[λmin⁡,λmax⁡]\lambda\in[\lambda_{\min},\lambda_{\max}] 成立,两端相除:

κ=λmax⁡λmin⁡ ≤ (1+β1−β)2⇒β ≥ κ−1κ+1\kappa=\frac{\lambda_{\max}}{\lambda_{\min}}\ \le\ \Big(\frac{1+\sqrt\beta}{1-\sqrt\beta}\Big)^2\qquad\Rightarrow\qquad\sqrt\beta\ \ge\ \frac{\sqrt\kappa-1}{\sqrt\kappa+1}

取等号得到最快速率 κ−1κ+1\frac{\sqrt\kappa-1}{\sqrt\kappa+1},所需步数正比于 κ\sqrt\kappa(对应的 α=4/(λmax⁡+λmin⁡)2\alpha=4/(\sqrt{\lambda_{\max}}+\sqrt{\lambda_{\min}})^2)。

day2_checks.py 实测:

κ\kappaGD 步数重球步数倍数
1066262.5
100609807.6
10006,47130321.4
1000062,58693367.1

κ\kappa 每乘 10,GD 步数乘 10,重球只乘约 10≈3.2\sqrt{10}\approx3.2。

意义。 动量不是”平滑一下噪声”的小技巧,它改变了收敛的阶:从 O(κ)O(\kappa) 到 O(κ)O(\sqrt\kappa),而且一分钱曲率信息都没用。这也是为什么后面的 Adam 保留了动量这一项。(上面是凸二次型上的理论;深度网络非凸、有噪声,不会严格满足,但”动量对病态地形特别有效”这个结论在实践中非常稳健。)

2.3 Nesterov:先看一步再走

bt=μbt−1+∇L(θt−ημbt−1)b_t=\mu b_{t-1}+\nabla L(\theta_t-\eta\mu b_{t-1})

在”动量将要把我带到的位置”求梯度,一种前瞻修正。凸优化里理论更好,深度学习里提升有限。知道它存在即可。

2.4 AdaGrad:用梯度历史估一把对角尺子

动机。 回到 §1.5:想要一把比 II 好、又比 HH 便宜的尺子。对角阵是最便宜的选择,只需要 nn 个数。问题是:对角线上该填什么?

直觉。 真正的曲率算不起,但梯度是免费的。一个坐标的梯度一直很大,说明这个方向上损失变化剧烈,步子该小;梯度一直很小或很稀疏(比如 embedding 里的低频词),步子该大。于是用梯度的历史幅度来当尺子:

vt=vt−1+gt2,θt+1=θt−ηvt+ϵ gt(逐元素)v_t=v_{t-1}+g_t^2,\qquad\theta_{t+1}=\theta_t-\frac{\eta}{\sqrt{v_t}+\epsilon}\,g_t\qquad(\text{逐元素})

也就是 P=diag(vt)P=\mathrm{diag}(\sqrt{v_t})。开根号的严格理由来自在线凸优化的 regret 分析(Duchi et al. 2011),直观理解是:把每个坐标的梯度除以它的典型幅度,让所有坐标的步长回到同一量级。

致命缺陷:vtv_t 单调累加,只增不减。训练久了 vt\sqrt{v_t} 无限增大,学习率单调衰减到 0,训练提前停死。凸问题里这是个特性(保证收敛),深度学习里是个 bug。

2.5 RMSProp:让尺子会”遗忘”

一行修正,把累加改成指数移动平均:

vt=β2vt−1+(1−β2)gt2v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2

vtv_t 只反映”最近一段时间”的梯度幅度,不会无限增长。深度学习的地形一直在变,尺子也该跟着变。

2.6 Adam:动量 + 自适应尺子 + 偏差修正

mt=β1mt−1+(1−β1)gt一阶矩:方向(§2.2 的动量)vt=β2vt−1+(1−β2)gt2二阶矩:尺子(§2.5)m^t=mt1−β1t,v^t=vt1−β2t偏差修正θt+1=θt−η m^tv^t+ϵ\begin{aligned} m_t&=\beta_1m_{t-1}+(1-\beta_1)g_t &&\text{一阶矩:方向(§2.2 的动量)}\\ v_t&=\beta_2v_{t-1}+(1-\beta_2)g_t^2 &&\text{二阶矩:尺子(§2.5)}\\ \hat m_t&=\frac{m_t}{1-\beta_1^t},\quad\hat v_t=\frac{v_t}{1-\beta_2^t} &&\text{偏差修正}\\ \theta_{t+1}&=\theta_t-\eta\,\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon} \end{aligned}

默认 β1=0.9, β2=0.999, ϵ=10−8\beta_1=0.9,\ \beta_2=0.999,\ \epsilon=10^{-8}。

偏差修正从哪来

动机。 m0=0m_0=0 是一个人为的、错误的初值,它会把早期的估计拉向零。拉了多少?

推导。 把递推展开:

mt=(1−β1)∑i=1tβ1 t−igim_t=(1-\beta_1)\sum_{i=1}^{t}\beta_1^{\,t-i}g_i

假设各步梯度同分布、期望为 gˉ\bar g,用等比数列求和:

E[mt]=(1−β1) gˉ∑i=1tβ1 t−i=(1−β1) gˉ⋅1−β1 t1−β1=gˉ (1−β1 t)\mathbb{E}[m_t]=(1-\beta_1)\,\bar g\sum_{i=1}^{t}\beta_1^{\,t-i}=(1-\beta_1)\,\bar g\cdot\frac{1-\beta_1^{\,t}}{1-\beta_1}=\bar g\,(1-\beta_1^{\,t})

偏差因子正是 (1−β1t)(1-\beta_1^t),除掉它就无偏。t=1t=1 时估计只有真值的 10%;β2=0.999\beta_2=0.999 那一路更是只有 0.1%。偏差修正不是可有可无的小修补,前几百步没它就是错的。(day2_checks.py:修正后每个 tt 都精确等于真值。)

Adam 的第一步

t=1t=1 时 m^1=g1\hat m_1=g_1,v^1=g12\hat v_1=g_1^2,于是

Δθ=−η g1∣g1∣+ϵ≈∓η\Delta\theta=-\eta\,\frac{g_1}{|g_1|+\epsilon}\approx\mp\eta

无论梯度多大多小,第一步长度都约等于 η\eta。 day2_checks.py 把梯度从 10−210^{-2} 调到 10310^{3},第一步都是 η\eta;只有当 ∣g∣|g| 小到和 ϵ\epsilon 可比(如 10−610^{-6})时才略小于 η\eta。

这个”步长与梯度尺度无关”的性质,既是 Adam 好用的原因(不用手调各层学习率),也是它早期不稳的原因(一上来就是满幅、方向近乎随机的大步)。§6.1 的 warmup 就是来治这个的。

Adam 的 v^\sqrt{\hat v} 到底是不是曲率?(诚实版)

常见说法是”Adam 用梯度二阶矩近似 Hessian 对角”。这个说法只对了一半,要说清楚:

  • 对于对数似然型损失,梯度外积的期望 E[gg⊤]\mathbb{E}[gg^\top] 是 Fisher 信息矩阵,在最优点附近它近似 Hessian(Gauss-Newton 近似)。所以 vv 确实和曲率有关。
  • 但 Adam 用的是 v\sqrt v,不是 vv。 真正的牛顿法对角近似应该除以 vv,Adam 除以的是它的平方根。所以 Adam 不是对角牛顿法。
  • 更好的理解方式:令 β1=β2=0\beta_1=\beta_2=0,Adam 退化为 g/∣g∣=sign(g)g/|g|=\mathrm{sign}(g),正是 §1.1 的 L∞L_\infty 最速下降。Adam 最好被理解成”带平滑的符号下降”:β1\beta_1 平滑方向,β2\beta_2 平滑尺度。

这个理解能解释很多现象:为什么 Adam 对梯度尺度不敏感(符号不关心幅度)、为什么它在不同层之间自动平衡步长、以及为什么纯符号方法(如 Lion,2023)能和 Adam 打平。

2.7 AdamW:把 weight decay 从尺子里解放出来

面试高频题,而且很多人只会背结论。

动机。 用 L2 正则的方式做 weight decay:损失加上 λ2∥θ∥2\frac\lambda2\|\theta\|^2,梯度变成 g′=g+λθg'=g+\lambda\theta。这个 g′g' 会被送进 Adam 的尺子,实际衰减量变成

η⋅λθv^+ϵ\eta\cdot\frac{\lambda\theta}{\sqrt{\hat v}+\epsilon}

衰减被 v^\sqrt{\hat v} 除了。 梯度大的参数 v^\sqrt{\hat v} 大,几乎不被衰减;梯度小的参数反而被过度衰减。这违背了 weight decay 的初衷:对所有参数一视同仁地往 0 收缩。

直觉。 问题出在 weight decay 和梯度走了同一个通道。把它从梯度通道里拿出来,不经过尺子,直接作用在参数上:

θt+1=θt−η(m^tv^t+ϵ+λθt)\theta_{t+1}=\theta_t-\eta\Big(\frac{\hat m_t}{\sqrt{\hat v_t}+\epsilon}+\lambda\theta_t\Big)

day2_checks.py 对比(λ=0.1, η=0.1, θ=1\lambda=0.1,\ \eta=0.1,\ \theta=1):

∥g∥=0.01\|g\|=0.01∥g∥=1\|g\|=1∥g∥=10\|g\|=10
Adam + L2 实际衰减1.0000.0100.001
AdamW 衰减0.0100.0100.010

Adam+L2 在两端之间差 1000 倍,AdamW 完全一致。

意义。 对 SGD 来说 L2 和 weight decay 是一回事(尺子是 II,除了等于没除);只有在自适应尺子下两者才分道扬镳。这就是为什么 torch.optim.Adam(weight_decay=...) 和 AdamW(weight_decay=...) 不是同一件事,以及为什么现代 Transformer 训练全部用 AdamW。

实践补充:weight decay 通常不施加在 bias 和 LayerNorm 的 γ,β\gamma,\beta 上。这些参数不是”权重”,收缩它们没有正则意义反而有害。实现方式见 §A.1。

2.8 汇总

优化器尺子 PP解决了什么留下了什么
SGDII—病态条件
+ MomentumIIκ→κ\kappa\to\sqrt\kappa,治震荡仍是全局同一尺度
AdaGraddiag∑g2\mathrm{diag}\sqrt{\sum g^2}逐坐标自适应学习率单调衰减到 0
RMSPropdiagEMA(g2)\mathrm{diag}\sqrt{\mathrm{EMA}(g^2)}尺子会遗忘没有动量
Adam同上 + 偏差修正合体,≈带平滑的符号下降weight decay 被尺子污染
AdamW同上解耦 weight decay当前标准

§3 优化器的显存账

接 Day 1 §9。AdamW 为每个参数维护 mm 和 vv,各自与参数同形状(Day 1 §3.1),fp32 各 4 字节:

优化器状态=8 字节/参数\text{优化器状态}=8\ \text{字节/参数}

这是 Day 1 §9.1 那张 16 字节表里最大的一块。7B 模型光优化器状态就是 56 GB。由此理解两个工程方向:

  • 8-bit 优化器(bitsandbytes):m,vm,v 量化到 8-bit,8→28\to2 字节/参数,7B 省 42 GB。QLoRA 的 paged optimizer 属于这一类。
  • ZeRO Stage 1:把优化器状态切到 NN 张卡上,每张只存 1/N1/N。ZeRO 三阶段里最先做、收益最大的一步,因为它本来就是最大头。

SGD+Momentum 只要 4 字节/参数(一个动量),是 AdamW 的一半;符号类优化器(Lion)也只存一个动量。超大规模训练里偶尔有人为这个理由考虑它们,省下的显存是实打实的。


§4 归一化

4.1 它到底在解决什么

动机。 原始 BatchNorm 论文给的理由是”减少 internal covariate shift”(层输入分布随训练漂移)。这个解释后来被实验否定了:Santurkar 等人(2018)人为往归一化后注入分布漂移,训练照样又快又稳。

目前广泛接受的解释有两条,都能放进今天和昨天的框架里:

  1. 改善条件数:归一化让损失曲面更平滑,也就是 §1.3 里的 λmax⁡\lambda_{\max} 变小,可用的学习率上界变大。用 §0.2 的话说,它不换尺子,而是改地形,让欧氏尺子也够用。
  2. 控制连乘增益:Day 1 §7.2 说梯度是矩阵连乘,增益偏离 1 就指数爆炸或消失。归一化把每层输出的尺度钉在 O(1)O(1),等于给连乘装了限幅器。

第二条是理解 Transformer 的关键:100 层网络没有归一化根本训不起来,不是因为分布漂移,而是因为连乘。

4.2 BatchNorm,以及它为什么不适合 NLP

BatchNorm 在 batch 维上统计:对每个特征通道,算这一批样本的均值方差。

三个问题,任何一个在 NLP 里都是致命的:

  1. 依赖 batch size:batch 小时统计量噪声大,batch=1 时根本没法算。
  2. 训练与推理不一致:推理时没有 batch,只能用训练期间的滑动平均。多了一份状态,也多了一个 bug 温床(Day 1 §A.3 坑 2)。
  3. 变长序列直接崩:一个 batch 里句子长短不一,padding 会污染统计量;而且同一个位置在不同句子里语义完全不同,凭什么放在一起统计?

4.3 LayerNorm:在特征维上归一化

直觉。 BatchNorm 的三个问题全都来自”跨样本统计”。那就不跨样本,只在单个样本自己的特征维内部统计。

μ=1d∑ixi,v=1d∑i(xi−μ)2,σ=v+ϵ,x^i=xi−μσ,yi=γix^i+βi\mu=\frac1d\sum_ix_i,\qquad v=\frac1d\sum_i(x_i-\mu)^2,\qquad\sigma=\sqrt{v+\epsilon},\qquad\hat x_i=\frac{x_i-\mu}{\sigma},\qquad y_i=\gamma_i\hat x_i+\beta_i

与 batch 无关、训练推理完全一致、变长序列毫无影响。NLP 用 LayerNorm 不是习惯,是被逼的。

反向推导:三条路径,索引级

动机。 这里是 Day 1 §7.1 多路径规则的一次正式演练。xjx_j 影响 x^i\hat x_i 有三条路:

  1. 直接影响(仅当 i=ji=j)
  2. 通过 μ\mu(μ\mu 依赖所有 xjx_j)
  3. 通过 σ\sigma(同上)

三条路,所以会有三项。下面把它们一项一项推出来。

零件一:∂μ∂xj=1d\dfrac{\partial\mu}{\partial x_j}=\dfrac1d

零件二:vv 对 xjx_j 的导数。对 v=1d∑k(xk−μ)2v=\frac1d\sum_k(x_k-\mu)^2 用链式法则,注意 μ\mu 也依赖 xjx_j:

∂v∂xj=2d∑k(xk−μ)(δkj−1d)=2d(xj−μ)−2d2∑k(xk−μ)⏟=0=2d(xj−μ)\frac{\partial v}{\partial x_j}=\frac2d\sum_k(x_k-\mu)\Big(\delta_{kj}-\frac1d\Big)=\frac2d(x_j-\mu)-\frac2{d^2}\underbrace{\sum_k(x_k-\mu)}_{=0}=\frac2d(x_j-\mu)

中心化之后的和为 0,第二项直接消失。这一步经常被跳过,它是整个推导能化简的关键。

零件三:∂σ∂xj=12σ∂v∂xj=xj−μd σ\dfrac{\partial\sigma}{\partial x_j}=\dfrac{1}{2\sigma}\dfrac{\partial v}{\partial x_j}=\dfrac{x_j-\mu}{d\,\sigma}

组装(商法则,x^i=(xi−μ)/σ\hat x_i=(x_i-\mu)/\sigma):

∂x^i∂xj=(δij−1d) σ−(xi−μ)∂σ∂xjσ2=1σ(δij−1d)−(xi−μ)(xj−μ)d σ3\frac{\partial\hat x_i}{\partial x_j}=\frac{(\delta_{ij}-\frac1d)\,\sigma-(x_i-\mu)\frac{\partial\sigma}{\partial x_j}}{\sigma^2}=\frac1\sigma\Big(\delta_{ij}-\frac1d\Big)-\frac{(x_i-\mu)(x_j-\mu)}{d\,\sigma^3}

用 x^\hat x 改写,三项清清楚楚:

 ∂x^i∂xj=1σ(δij⏟直接−1d⏟经 μ−x^ix^jd⏟经 σ)\boxed{\ \frac{\partial\hat x_i}{\partial x_j}=\frac1\sigma\Big(\underbrace{\delta_{ij}}_{\text{直接}}-\underbrace{\frac1d}_{\text{经 }\mu}-\underbrace{\frac{\hat x_i\hat x_j}{d}}_{\text{经 }\sigma}\Big)}

收缩上游梯度:记 g=yˉ⊙γg=\bar y\odot\gamma(对 x^\hat x 的梯度),

∂L∂xj=∑igi∂x^i∂xj=1σ[gj−g‾−x^j g⊙x^‾]\frac{\partial L}{\partial x_j}=\sum_ig_i\frac{\partial\hat x_i}{\partial x_j}=\frac1\sigma\Big[g_j-\overline{g}-\hat x_j\,\overline{g\odot\hat x}\Big]

( ⋅ ‾\overline{\,\cdot\,} 表示在特征维上取均值。)参数的梯度简单得多,γ,β\gamma,\beta 被所有样本和位置共享,所以求和(Day 1 §6.3):

∂L∂γ=∑yˉ⊙x^,∂L∂β=∑yˉ\frac{\partial L}{\partial\gamma}=\sum\bar y\odot\hat x,\qquad\frac{\partial L}{\partial\beta}=\sum\bar y

day2_checks.py 逐项验证,误差 10−1010^{-10}。

几何意义:雅可比是一个投影

把上面的雅可比写成矩阵:

J=1σ(I−11⊤d−x^x^⊤d)J=\frac1\sigma\Big(I-\frac{\mathbf1\mathbf1^\top}{d}-\frac{\hat x\hat x^\top}{d}\Big)

看括号里的东西:

  • 1⊤x^=∑ix^i=0\mathbf1^\top\hat x=\sum_i\hat x_i=0,所以 1\mathbf1 和 x^\hat x 正交
  • ∥1∥2=d\|\mathbf1\|^2=d,∥x^∥2=d⋅vv+ϵ≈d\|\hat x\|^2=d\cdot\frac{v}{v+\epsilon}\approx d

所以 11⊤d\frac{\mathbf1\mathbf1^\top}{d} 和 x^x^⊤d\frac{\hat x\hat x^\top}{d} 分别是到 1\mathbf1 方向和 x^\hat x 方向的正交投影,II 减去它们就是到这两个方向的正交补空间的投影。day2_checks.py 验证了 σJ\sigma J 近似幂等、σJ1=0\sigma J\mathbf1=0、σJx^≈0\sigma J\hat x\approx0。

意义。 这不是巧合,背后有一条普遍规律:

输出对哪个方向的扰动不敏感,梯度在那个方向上就为零。

LayerNorm 的输出对”整体平移输入”(沿 1\mathbf1)和”整体缩放输入”(沿 x−μx-\mu,也就是 x^\hat x 方向)都不变。所以反向时,这两个方向上的梯度分量被直接丢掉。

还有一个后面马上要用的推论:JJ 前面有个 1/σ1/\sigma。输入的尺度越大,穿过 LayerNorm 的梯度就越小。§4.5 会看到,这正是 Pre-LN 在深层”变温和”的原因。

4.4 RMSNorm:只管缩放,不管平移

r=1d∑ixi2+ϵ,yi=γixirr=\sqrt{\frac1d\sum_ix_i^2+\epsilon},\qquad y_i=\gamma_i\frac{x_i}{r}

动机。 LayerNorm 做了两件事:重新中心化(减 μ\mu)和重新缩放(除 σ\sigma)。Zhang & Sennrich(2019)的问题是:两件事都必要吗? 他们的实验表明好处主要来自缩放,中心化可以砍掉。

推导。 只剩两条路径(直接、经 rr),同样的方法推出:

∂L∂x=1r[g−x⋅g⊙x‾r2],J=1r(I−xx⊤d r2)\frac{\partial L}{\partial x}=\frac1r\Big[g-x\cdot\frac{\overline{g\odot x}}{r^2}\Big],\qquad J=\frac1r\Big(I-\frac{xx^\top}{d\,r^2}\Big)

对照 §4.3:少掉的正是 11⊤/d\mathbf1\mathbf1^\top/d 那一项,也就是”平移不变”那一条。RMSNorm 的雅可比只投影掉缩放方向。(已数值验证。)

意义与代价。 少一次求均值、少一次减法、反向少一项,实测快 7–15%,质量无明显损失。Llama、Qwen、Mistral、Gemma 等现代模型全部采用 RMSNorm,Lecture 2 看架构对比时到处都是它。和 Day 4 的 GRU 简化 LSTM 是同一种方法论:先问每个组件是否必要,砍掉不必要的。

4.5 Pre-LN vs Post-LN:为什么现代模型都用前者

Lecture 1–2 会直接考。

Post-LN (2017 原版): xl+1=LN(xl+fl(xl))Pre-LN (现代): xl+1=xl+fl(LN(xl))\textbf{Post-LN}\ (2017\ \text{原版}):\ x_{l+1}=\mathrm{LN}\big(x_l+f_l(x_l)\big)\qquad\textbf{Pre-LN}\ (\text{现代}):\ x_{l+1}=x_l+f_l\big(\mathrm{LN}(x_l)\big)

看起来只是括号位置不同。用 Day 1 §7.2 的残差分析和 §4.3 的 1/σ1/\sigma,差别就清楚了。

Pre-LN 的推导。 把 LL 层展开,残差流是所有分支输出的累加:

xL=xl+∑k=lL−1fk(LN(xk))⇒∂xL∂xl=I+∑k(⋯ )x_L=x_l+\sum_{k=l}^{L-1}f_k\big(\mathrm{LN}(x_k)\big)\qquad\Rightarrow\qquad\frac{\partial x_L}{\partial x_l}=I+\sum_{k}(\cdots)

那个 II 是一条从第 LL 层直通第 ll 层、一次 LayerNorm 都不经过的路径。梯度高速公路完全畅通。

但累加有一个副作用:每个分支输出 O(1)O(1),LL 个近似独立的 O(1)O(1) 向量相加,范数按 L\sqrt L 增长。现代 Pre-LN 模型在输出头之前还有一个末端 LayerNorm,而由 §4.3,穿过它的梯度要乘 1/σ∝1/L1/\sigma\propto1/\sqrt L。于是所有参数的梯度都被缩小了 L\sqrt L 倍:网络越深,Pre-LN 越”温和”。

Post-LN 的情况。 每层都重新归一化,残差流范数被钉死在 d\sqrt d,靠近输出的参数梯度不会随深度缩小。

day2_checks.py 在随机初始化的 d=64d=64 残差网络上实测(8 个随机种子平均):

层数 LLPre-LN 残差流范数Pre-LN 末层参数梯度Post-LN 残差流范数Post-LN 末层参数梯度
615.241.28.063.3
2426.223.78.064.1
9646.813.38.064.2
38495.96.58.065.3

层数每乘 4,Pre-LN 的残差流范数翻倍(∝L\propto\sqrt L)、梯度减半(∝1/L\propto1/\sqrt L);Post-LN 两者都不变。

意义:为什么 Post-LN 离不开 warmup。 用同一个学习率,深的 Post-LN 在初始化时靠近输出的参数梯度比 Pre-LN 大 L\sqrt L 量级(384 层时大 10 倍),第一批更新步子太大,训练不稳。Xiong 等人(2020)从理论上分析了这个机制,并显示 Pre-LN 可以去掉 warmup 或大幅缩短它。(另有一条互补的解释:Liu 等人(2020)认为 Post-LN 对残差分支的依赖过强,会放大参数扰动对输出的影响。两种机制都存在。)

Pre-LN 的代价:表里左边那列同时说明了一件坏事。残差流按 L\sqrt L 增长,每个新分支只贡献 O(1)O(1),越深的层对残差流的相对影响越小,深层的边际贡献递减。这是 Pre-LN 的已知问题,也是 DeepNorm、Sandwich-LN、以及 §5.3 残差分支缩放这类改进的动机。

实践结论:绝大多数 LLM 用 Pre-LN(通常是 RMSNorm)加末端归一化。能稳定训起来,比最后那一点质量更重要。


§5 初始化:出发点决定生死

5.1 二阶矩传播的推导

动机。 Day 1 §7.2 说梯度是连乘。连乘里每一层的增益,在训练开始时完全由初始化决定。初始化的目标就是让每层的增益恰好是 1,前向信号和反向梯度都不放大也不缩小。

推导。 考虑一层 zi=∑j=1nWijajz_i=\sum_{j=1}^{n}W_{ij}a_j(nn 是 fan_in,忽略偏置)。WijW_{ij} 独立同分布、均值 0、方差 σW2\sigma_W^2,且与 aa 独立。

E[zi2]=∑j,kE[WijWik] E[ajak]=∑jE[Wij2] E[aj2]=n σW2 E[a2]\mathbb{E}[z_i^2]=\sum_{j,k}\mathbb{E}[W_{ij}W_{ik}]\,\mathbb{E}[a_ja_k]=\sum_j\mathbb{E}[W_{ij}^2]\,\mathbb{E}[a_j^2]=n\,\sigma_W^2\,\mathbb{E}[a^2]

j≠kj\neq k 的交叉项因为 E[WijWik]=0\mathbb{E}[W_{ij}W_{ik}]=0 全部消失。

注意这里出现的是 E[a2]\mathbb{E}[a^2](二阶矩),不是 Var[a]\mathrm{Var}[a]。 很多教材写成方差,这在 aa 均值为 0 时没区别,但 ReLU 的输出全是非负数,均值不为 0,此时必须跟踪二阶矩。(day2_checks.py:Var[ReLU(z)]=0.99\mathrm{Var}[\mathrm{ReLU}(z)]=0.99,而 12Var[z]=1.45\frac12\mathrm{Var}[z]=1.45,两者不等。)

ReLU 那一步:若 zz 关于 0 对称,

E[ReLU(z)2]=∫0∞z2p(z) dz=12∫−∞∞z2p(z) dz=12 E[z2]\mathbb{E}[\mathrm{ReLU}(z)^2]=\int_0^\infty z^2p(z)\,dz=\frac12\int_{-\infty}^{\infty}z^2p(z)\,dz=\frac12\,\mathbb{E}[z^2]

(对称分布的正半边恰好贡献一半。已数值验证:1.4459 vs 1.4458。)

合起来,相邻两层激活前的二阶矩满足

E[z(l)2]=n σW2⋅12 E[z(l−1)2]\mathbb{E}[z^{(l)2}]=n\,\sigma_W^2\cdot\tfrac12\,\mathbb{E}[z^{(l-1)2}]

要让增益为 1:

 σW2=2n (He 初始化,ReLU 家族)\boxed{\ \sigma_W^2=\frac2n\ }\qquad\text{(He 初始化,ReLU 家族)}

那个 2 就是用来补偿 ReLU 砍掉的一半。对线性或 tanh(在 0 附近 a≈za\approx z,均值为 0)没有这个一半,就是 σW2=1/n\sigma_W^2=1/n(LeCun / Xavier 的 fan_in 版本)。

反向也有一份镜像。 反向时 aˉ=W⊤zˉ\bar a=W^\top\bar z,求和是沿输出维进行的,同样的推导给出 σW2=2/nout\sigma_W^2=2/n_{\text{out}}。前向要 fan_in、反向要 fan_out,两者不能同时满足(除非方阵)。Xavier 的 2nin+nout\frac{2}{n_{\text{in}}+n_{\text{out}}} 就是两者的折中;PyTorch 的 mode='fan_in'/'fan_out' 让你选保哪一边(§A.7)。

5.2 数值演示

10 层 ReLU 网络(day2_checks.py),看”第 1 层之后每层乘多少”:

初始化每层增益(理论)第 10 层 / 第 1 层(实测)理论 (⋅)9(\cdot)^9
He,N(0,2/n)\mathcal N(0,2/n)11.151
Xavier,N(0,1/n)\mathcal N(0,1/n)1/21.8×10−31.8\times10^{-3}2.0×10−32.0\times10^{-3}
PyTorch nn.Linear 默认,U(±1/n)\mathcal U(\pm1/\sqrt n)1/61.2×10−71.2\times10^{-7}9.9×10−89.9\times10^{-8}

(实测和理论有 ±20% 左右的偏差,是有限宽度 n=512n=512 下每层增益的随机涨落连乘 9 次放大的结果,数量级对上即可。)

第三行值得专门记住。 nn.Linear 的默认初始化是 kaiming_uniform_(a=√5),算出来是 U(±1/n)\mathcal U(\pm1/\sqrt n),方差 1/(3n)1/(3n),只有 He 的 1/6。这个默认值主要出于历史兼容,并非为深 ReLU 网络设计。浅网络无所谓,深网络必须自己初始化,否则 10 层就把信号压到 10−710^{-7}。

这是一个”一个常数因子写错就训不起来”的例子,也说明初始化不是玄学,是可以推导的。

5.3 Transformer 的残差分支缩放

动机。 §4.5 看到 Pre-LN 的残差流是分支输出的累加,范数按 L\sqrt L 增长。能不能在初始化时就把它摁住?

推导。 LL 层 Transformer 每层有两个残差分支(注意力、FFN),共 2L2L 个。若各分支输出近似独立、二阶矩都是 s2s^2,累加后

E[∥xL∥2]≈∥x0∥2+2L⋅s2\mathbb{E}\big[\|x_L\|^2\big]\approx\|x_0\|^2+2L\cdot s^2

要让它不随 LL 增长,就把每个分支的输出投影按 1/2L1/\sqrt{2L} 缩放,使 s2∝1/(2L)s^2\propto1/(2L)。

这是 GPT-2 起的标准做法。第 1 周你手写 mini-GPT 时,如果层数一多就训练不稳,八成是漏了这个缩放。


§6 学习率调度

6.1 Warmup:为什么 Transformer 几乎必须要

两个独立原因,前面都埋好了伏笔。

原因一(优化器侧,§2.6):训练初期 tt 很小,v^t\hat v_t 是用极少的样本估出来的,方差极大,自适应步长 m^/v^\hat m/\sqrt{\hat v} 跟着剧烈抖动。§2.6 算过 t=1t=1 时步长恰好是 η\eta、方向却近乎随机,一上来就是满幅乱走。warmup 在 v^\hat v 稳定之前把步长压住。(RAdam 论文把这个机制形式化了。)

原因二(架构侧,§4.5):深的 Post-LN 在初始化时靠近输出的梯度比 Pre-LN 大 L\sqrt L 量级,不 warmup 就发散。Pre-LN 好得多,但大模型训练仍普遍保留 warmup 当保险。

典型配置:前 1–5% 的步数线性 warmup(0 → 峰值)。

6.2 Cosine decay

ηt=ηmin⁡+12(ηmax⁡−ηmin⁡)(1+cos⁡πtT)\eta_t=\eta_{\min}+\tfrac12(\eta_{\max}-\eta_{\min})\Big(1+\cos\frac{\pi t}{T}\Big)

直觉。 训练前期要探索,步子大;后期要在极小点附近精细收敛,还要压住 §1.6 的梯度噪声,步子小。cosine 前期衰减慢、后期衰减快,正好符合这个节奏。经验上比线性和阶梯衰减都稳。

陷阱:cosine 的形状绑死在总步数 TT 上,中途想延长训练,曲线就错位了。这是 WSD(warmup-stable-decay)这类调度受欢迎的原因之一:中间是恒定段,可以随时延长,最后再衰减。

6.3 现代 LLM 的标准配方

优化器   AdamW, β1=0.9, β2=0.95(大模型常用 0.95 而非 0.999), wd=0.1
调度     linear warmup (1-5% steps) → cosine decay 到峰值的 10%
裁剪     clip_grad_norm = 1.0
精度     bf16 混合精度(Day 1 §10.2:不需要 loss scaling)
归一化   RMSNorm + Pre-LN + 末端归一化
初始化   正态 std=0.02,残差输出投影再乘 1/√(2L)
wd 排除  bias 和 norm 层的 γ/β 不做 weight decay

现在你能逐条说出每一项为什么长这样。 β2=0.95\beta_2=0.95 是让二阶矩估计更”近视”,对大模型训练中偶发的梯度尖峰反应更快,减少不稳定。这是今天最值钱的产出。


§7 五个诊断场景

训练出问题时,按 §1–§6 的框架排查。当真题来做,面试和实际工作都会遇到。

场景 1:loss 一开始就是 NaN。 学习率超过 2/λmax⁡2/\lambda_{\max}(§1.3);初始化方差错(§5);fp16 溢出(Day 1 §10.2);损失里有 log⁡0\log0(Day 1 §5.2.5,自己写 softmax+log 的典型后果)。

场景 2:loss 前几百步就发散。 没有 warmup(§6.1);Post-LN 架构(§4.5);开了动量但没调小学习率(§2.2 的 1/(1−μ)1/(1-\mu) 放大)。

场景 3:loss 一直平,完全不降。 忘了 zero_grad() 或 optimizer.step();参数没注册进优化器(Day 1 §A.1);学习率太小;激活塌缩或死亡(§5.2 的 PyTorch 默认初始化、dead ReLU)。

场景 4:loss 剧烈震荡但总体下降。 学习率略大或 batch 太小(§1.6);没有梯度裁剪(Day 1 §10.3);该上动量了(§2.2)。

场景 5:训练 loss 很好,验证 loss 早早上翘。 过拟合。weight decay(要用 AdamW 而不是 Adam+L2,§2.7)、dropout、数据量、早停。


§A 优化器 / 调度器 / 混合精度:API 用法与边界

每一条都对应前面的某个原理。理论错了训练慢;这些 API 错了训练直接废掉,而且常常不报错。

A.1 参数分组:把 bias 和 norm 排除出 weight decay

§2.7 结尾说过 bias 和 LayerNorm 的 γ,β\gamma,\beta 不该被衰减。实现靠参数分组:

decay, no_decay = [], []
for name, p in model.named_parameters():
    if not p.requires_grad:
        continue
    if p.ndim <= 1 or name.endswith(".bias"):     # bias、norm 的 γ/β 都是 1 维
        no_decay.append(p)
    else:
        decay.append(p)

opt = torch.optim.AdamW([
    {"params": decay,    "weight_decay": 0.1},
    {"params": no_decay, "weight_decay": 0.0},
], lr=3e-4, betas=(0.9, 0.95))

p.ndim <= 1 这个判据很实用:所有 bias 和 norm 的仿射参数都是 1 维,所有权重矩阵都是 2 维以上。nanoGPT 等开源实现用的就是它。

边界:每个参数只能属于一个组,重复加入会报 “some parameters appear in more than one parameter group”。

A.2 zero_grad(set_to_none=True)

PyTorch 2.x 起是默认值。带动量的优化器在 grad is None 时完全跳过该参数,而”零梯度”时仍会用历史动量更新它,冻结部分参数时两者结果不同。老代码的 p.grad.zero_() 在新默认下会 AttributeError。详见 Day 1 §A.6。

A.3 调度器:调用位置和频率

for step in range(total_steps):
    ...
    opt.step()
    sched.step()          # ★ 必须在 opt.step() 之后
  1. sched.step() 必须在 opt.step() 之后,反了的话第一个 step 用的学习率是错的。
  2. 按 step 调度就每步调用,按 epoch 调度就每 epoch 调用。 CosineAnnealingLR(T_max=100) 里的 100 是你调用 step() 的次数,它不知道你在什么粒度上调用。搞混的后果是曲线被压缩或拉伸几十倍。
  3. 读当前学习率用 sched.get_last_lr()(每个 param_group 一个值)或直接读 opt.param_groups[0]['lr'],不要用 get_lr(),那是内部方法,在 step() 之外调用会给出错误的值并警告。

A.4 warmup + cosine 的两种写法

写法一:组合现成调度器

from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR
warm = LinearLR(opt, start_factor=0.01, total_iters=WARMUP)
cos  = CosineAnnealingLR(opt, T_max=TOTAL - WARMUP, eta_min=PEAK_LR * 0.1)
sched = SequentialLR(opt, [warm, cos], milestones=[WARMUP])

milestones 是切换发生的 step 序号,个数比调度器少 1。

写法二:LambdaLR 手写(大模型训练脚本常用,完全可控)

import math
def lr_lambda(step):
    if step < WARMUP:
        return step / max(1, WARMUP)                       # ★ max(1,·) 防 WARMUP=0 除零
    prog = min(1.0, (step - WARMUP) / max(1, TOTAL - WARMUP))   # ★ 夹住,防止超出总步数
    return 0.1 + 0.45 * (1 + math.cos(math.pi * prog))      # 从 1.0 衰减到 0.1
sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)

LambdaLR 返回的是”乘在初始 lr 上的系数”,不是学习率本身。 这是最常见的误用:返回 3e-4 会得到 3×10−4×3×10−43\times10^{-4}\times3\times10^{-4}。

OneCycleLR 的坑:total_steps 必须和实际步数精确匹配,多跑一步就抛 “Tried to step N+1 times. The specified number of total steps is N”。用 DataLoader 时注意 drop_last 会改变每 epoch 的步数。

A.5 混合精度:顺序错一步就白干

from torch.amp import autocast, GradScaler
scaler = GradScaler()                        # fp16 需要;bf16 不需要

for x, y in loader:
    opt.zero_grad(set_to_none=True)
    with autocast(device_type="cuda", dtype=torch.float16):
        loss = criterion(model(x), y)        # ★ 只包前向和 loss
    scaler.scale(loss).backward()            # 放大 loss 再反传(Day 1 §10.2)
    scaler.unscale_(opt)                     # ★★ 裁剪前必须先还原梯度尺度
    nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(opt)                         # 内部检查 inf/nan,有则跳过这一步
    scaler.update()                          # 动态调整放大倍数

scaler.unscale_(opt) 是整段最容易漏的一行。 不写的话,clip_grad_norm_ 看到的是被放大几百上千倍的梯度,范数远超 1.0,每一步都被狠狠裁剪,实际学习率被砍到约 1/scale1/\text{scale}。训练看起来在跑,几乎不动,完全不报错。

  • autocast 只包前向和 loss,不要把 backward() 和 step() 包进去。
  • unscale_ 对同一个 optimizer 每步只能调一次。
  • scaler.step() 遇到 inf/nan 会跳过这次更新,所以日志里偶尔看到 scale 下降是正常的,持续下降才有问题。
  • 用 bf16 时整个 GradScaler 都不需要(Day 1 §10.2:bf16 的指数位和 fp32 一样宽)。直接 autocast(dtype=torch.bfloat16),省掉上面一半的复杂度。这是 bf16 胜出的又一个实际理由。

A.6 nn.LayerNorm 的 normalized_shape

nn.LayerNorm(512)          # 对最后一维(512)归一化 —— NLP 标准用法
nn.LayerNorm([8, 512])     # 对最后两维一起归一化

参数是”要归一化的那几个末尾维度”。 传 [seq_len, d] 会把序列维卷进统计量,变成一个和序列长度绑定的操作,变长输入直接崩,也丢掉了 §4.3 说的 LayerNorm 全部优点。

eps 默认 1e-5(加在方差上,§4.3 的 σ=v+ϵ\sigma=\sqrt{v+\epsilon});elementwise_affine=False 去掉 γ,β\gamma,\beta;新版 PyTorch 有 nn.RMSNorm,老版本要自己写(§4.4 的公式)。

A.7 nn.init:几个容易记错的默认值

nn.init.kaiming_normal_(w, mode="fan_in", nonlinearity="relu")   # He(§5.1)
nn.init.xavier_uniform_(w, gain=nn.init.calculate_gain("tanh"))
nn.init.normal_(w, mean=0.0, std=0.02)                             # Transformer 常用
  • mode="fan_in" 保持前向二阶矩,mode="fan_out" 保持反向(§5.1 末尾的镜像推导)。默认 fan_in。
  • kaiming_* 默认 nonlinearity="leaky_relu"、a=0,此时增益 2/(1+02)=2\sqrt{2/(1+0^2)}=\sqrt2,和 ReLU 相同,所以默认值对 ReLU 是对的。激活是 tanh 或线性时必须显式改,否则增益差 2\sqrt2 倍。
  • nn.Linear 的默认初始化是 kaiming_uniform_(a=√5),结果是 U(±1/fan_in)\mathcal U(\pm1/\sqrt{\text{fan\_in}}),方差 1/(3 fan_in)1/(3\,\text{fan\_in}),只有 He 的 1/6(§5.2 实测 10 层缩到 10−710^{-7})。浅网络够用,深网络必须自己初始化。

A.8 可复现性

torch.manual_seed(0); np.random.seed(0); random.seed(0)
torch.backends.cudnn.deterministic = True     # 可复现,但慢
torch.backends.cudnn.benchmark = False        # 与上面互斥
  • benchmark=True 在输入尺寸固定时能明显加速(为该尺寸挑最快的卷积算法),尺寸变化频繁时反而更慢。
  • 完全确定性还需要 torch.use_deterministic_algorithms(True),某些算子没有确定性实现会直接报错,并提示设 CUBLAS_WORKSPACE_CONFIG。
  • DataLoader 的 num_workers>0 有独立的随机状态,要复现得传 worker_init_fn 和 generator。调试时先设 num_workers=0 排除这个变量。

§B 实操

  1. 跑通 day2_checks.py,九组结论逐一看懂。特别留意第 3 组(κ\kappa vs κ\sqrt\kappa)和第 6 组(Pre-LN/Post-LN 随深度的变化),这两组最能说明”为什么”。
  2. 从零实现 LayerNorm 的前向和反向(不用 nn.LayerNorm),按 §4.3 的三项公式写反向,和 autograd 对拍。参考:code/d2_layernorm_autograd.py(autograd.Function 手写反向 + gradcheck,顺带 RMSNorm 和雅可比投影的数值验证)。
  3. 复现五个诊断场景(§7):拿 Day 1 附录的 MLP 脚本,逐一制造:lr 调到 10、初始化 std 改成 100、用 PyTorch 默认初始化堆 10 层 ReLU、注释掉 zero_grad()、把 AdamW 换成 Adam(weight_decay=0.1)。每个病症亲手制造一遍,以后一眼就认得出。 参考:code/d2_diagnostics.py --scenario all,七个场景各有”病”和”治”两个版本,判定由实际曲线给出。
  4. 加分题:在一个二维病态二次型上画出 GD、Momentum、Adam 的轨迹,看峡谷里的”横跳”和动量的”顺谷而下”。参考:code/d2_optimizers_numpy.py(8 个优化器 + 牛顿法);再跑 --compare-angles,看把峡谷转 45° 后 AdaGrad/Adam 的优势为什么消失(§1.5 的”对角尺子”)。

全部参考实现和一键自检见 code/README.md。


§C 自测

动机与直觉层

  1. “梯度是最陡方向”在什么前提下成立?在 L∞L_\infty 范数和 PP 范数下,最陡方向分别是什么?
  2. 为什么梯度下降的学习率上界由 λmax⁡\lambda_{\max} 决定,而收敛速度由 κ\kappa 决定?画出峡谷的几何图像。
  3. 牛顿法为什么是”理想”?它的三个致命问题是什么?用”尺子”视角说明 Adam 在近似什么。
  4. 动量为什么能把 κ\kappa 降成 κ\sqrt\kappa?(提示:二阶递推、复根、模长与 λ\lambda 无关)
  5. Adam 的 v\sqrt v 是不是 Hessian 对角的近似?为什么说它更像”带平滑的符号下降”?
  6. BatchNorm 的三个问题分别是什么?为什么它们在 NLP 里都是致命的?
  7. Pre-LN 和 Post-LN 的梯度随深度怎么变化?为什么 Post-LN 更依赖 warmup?Pre-LN 付出了什么代价?

推导层

  1. 从二阶泰勒展开推出沿梯度方向的最优步长 η∗=g⊤g/g⊤Hg\eta^*=g^\top g/g^\top Hg。
  2. 推出 E[mt]=gˉ(1−β1t)\mathbb{E}[m_t]=\bar g(1-\beta_1^t),说明偏差修正因子的来历。
  3. 证明 Adam 的第一步长度约等于 η\eta,与梯度大小无关。什么时候这个结论失效?
  4. 推导 He 初始化的 2/n2/n。为什么必须跟踪二阶矩而不是方差?因子 2 从哪来?
  5. 索引级推出 LayerNorm 的反向,指出三项分别对应哪条路径。为什么 ∂v/∂xj\partial v/\partial x_j 里的第二项会消失?
  6. 说明 LayerNorm 的雅可比为什么是一个投影,它投影掉了哪两个方向,为什么。
  7. 写出 Adam+L2 的实际衰减量,说明它为什么依赖梯度大小而 AdamW 不。

工程与 API 层

  1. 7B 模型用 AdamW,优化器状态占多少显存?SGD+Momentum 呢?8-bit 优化器呢?
  2. torch.optim.Adam(weight_decay=0.1) 和 AdamW(weight_decay=0.1) 有什么区别?
  3. 混合精度训练里 scaler.unscale_() 漏写会怎样?为什么不报错?bf16 为什么不需要它?
  4. LambdaLR 的函数应该返回什么?CosineAnnealingLR 的 T_max 是什么单位?
  5. nn.Linear 的默认初始化方差是多少?深网络直接用它会怎样?
  6. §7 的五个场景,各说出至少三个排查方向。

出关标准

  • 能用柯西-施瓦茨推出”欧氏尺子下梯度最陡”,并说出 L∞L_\infty、PP 范数下的最陡方向
  • 能从二阶泰勒推出最优步长和牛顿步,说清牛顿法为什么用不了
  • 能用”尺子”视角把 SGD、Adam、符号下降、牛顿法、Muon 放进同一张表
  • 能推出动量把 κ\kappa 降为 κ\sqrt\kappa 的关键步骤
  • 能沿 SGD → AdamW 讲出每一步解决的具体问题
  • 能推出 Adam 的偏差修正,并说明第一步长度为什么约等于 η\eta
  • 能说清 AdamW 和 Adam+L2 的区别
  • 能索引级推出 LayerNorm 反向,并说出其雅可比是投影、投影掉了什么
  • 能说出 RMSNorm 少掉了哪一项
  • 能用实测数据解释 Pre-LN 与 Post-LN 的差异、warmup 的依赖、以及 Pre-LN 的代价
  • 能推出 He 初始化,并说出 PyTorch 默认初始化的问题
  • 能说出 warmup 的两个独立理由
  • 能把现代 LLM 训练配方逐条还原成它要解决的问题
  • §A 的 API 坑能说出现象和正确写法(尤其 AMP 的 unscale_ 顺序)

疑问清单

← 返回课程目录