AI ENGINEERING阅读地图
8个主要章节
1段代码示例
7组图片与图表

沿定义、计算与实验边界阅读,分开模型原理、教学验证和实际系统效果。

章节速览8 节 · 展开选择

反向传播与优化器已经区分了“求梯度”和“使用梯度”。这篇继续追踪梯度交给 Adam 以后经历的几次变换:为什么要保存两份状态,为什么除以偏差修正项,以及权重衰减究竟在哪一步参与。

Dropout在训练前向里引入随机性,LayerNorm改变特征的归一化。AdamW 则直接规定参数如何更新。这几件事可以同时出现,却不能彼此替代,也不能仅凭某次参数变小就认定泛化改善。

先跟踪状态

m 平滑梯度,v 平滑平方梯度;v 不是中心化方差。

再固定约定

epsilon 在根号外;学习率也乘在衰减项上。

最后分清路径

L2 进入梯度和矩估计;解耦衰减直接作用于旧参数。

固定这一篇的更新约定

讨论实数、稠密的一组参数,最小化可微数据损失 fₜ;gₜ=∇fₜ(θₜ₋₁) 在更新前的参数上计算。各坐标独立进行下面的算术,共享时间步 t 与超参数。初始 m₀=v₀=0;0≤β₁,β₂<1、学习率 αₜ≥0、衰减系数 λ≥0、epsilon ε>0。

采用 PyTorch 2.14 AdamW 的非 AMSGrad、非 maximize 公式:

mₜ = β₁mₜ₋₁ + (1−β₁)gₜ;vₜ = β₂vₜ₋₁ + (1−β₂)gₜ²。

m̂ₜ = mₜ/(1−β₁ᵗ);v̂ₜ = vₜ/(1−β₂ᵗ)。

θₜ = (1−αₜλ)θₜ₋₁ − αₜm̂ₜ/(√v̂ₜ+ε)。

平方、开根号、除法都按坐标执行。这里的 weight_decay=λ 与学习率相乘;不能把别处记号中“每一步直接减去 λθ”的 λ 原样搬来。AdamW 原论文的 Algorithm 2 使用调度倍数 ηₜ,把更新写成 −ηₜ(α·自适应方向+λ_paper·θ)。当 αₜ=ηₜα 时,匹配本文约定需要 λ_paper=α·λ,而非假定同名符号总有相同数值。

本文不实现混合精度、稀疏梯度、AMSGrad、参数组或框架内核,也不声称这些公式验证了具体训练任务。生产实现的状态保存、跳步与调度顺序仍需单独处理。

零初始化修正了什么偏差

把一阶递推展开,可得 mₜ=(1−β₁)Σₖ₌₁ᵗ β₁ᵗ⁻ᵏgₖ。这些系数的和为 1−β₁ᵗ,初期小于 1;除以它,就把已有梯度的指数权重归一化为和为 1。

若各步梯度的期望均为同一个 μ,则 E[mₜ]=(1−β₁ᵗ)μ,于是 E[m̂ₜ]=μ。同理,若平方梯度的期望恒为 ν,E[v̂ₜ]=ν。这说明Adam 原论文 Algorithm 1中修正项的来历;不表示真实训练中变化的梯度分布也被变成了平稳分布。

v 保存的是二阶原始矩,即平方梯度的平滑值,不是 E[g²]−E[g]²。即使每步梯度恒为 c,修正后仍有 m̂=c、v̂=c²,因此方向为 c/(|c|+ε)。ε 很小时,不同幅值的恒定梯度可能给出相近的单步位移,但全局学习率仍然决定尺度。

数据梯度分别进入一阶矩与平方梯度二阶矩,经偏差修正得到自适应位移;旧参数另乘1减学习率乘衰减系数,二者合成新参数,衰减不直接进入矩状态。
原创数据流示意。两条路径在参数更新处合并;后续梯度仍可能因新参数不同而改变,不代表 λ 对未来训练轨迹没有影响。查看原图

epsilon 不是可以随手移动的括号

本文分母是 √v̂+ε,而非 √(v̂+ε)。例如 v̂=4、ε=0.5 时,前者为 2.5,后者约为 2.1213,两者产生不同更新。手算故意用较大的 ε 展示差异,它不是默认超参数建议。

若把偏差修正合进学习率,也要连同 epsilon 一起变换。令 c₁=1−β₁ᵗ、c₂=1−β₂ᵗ,则同一个自适应位移可以写成:

αₜmₜ/[c₁(√(vₜ/c₂)+ε)]

= [αₜ√c₂/c₁] · mₜ/[√vₜ+ε√c₂]。

在后一式中继续使用未缩放的 ε,一般不与前一式完全相同。阅读不同库或论文时,应把完整分母和偏差项对齐,再判断只是重排还是改变了算法。

L2 为什么没有自然变成解耦衰减

对没有动量、没有自适应缩放的普通 SGD,把 λ‖θ‖²/2 加入损失后,梯度变为 g+λθ;一步为 θ−α(g+λθ)=(1−αλ)θ−αg。在这个特定约定下,它与同因子的直接衰减相同。

Adam 加 L2 则把 ĝₜ=gₜ+λθₜ₋₁ 放入 m、v 两个递推,L2 的影响同时进入方向、尺度和历史状态。AdamW 只把数据梯度 g 放入这两个状态,额外从旧参数扣除 αλθ。自适应缩放和状态递推使普通 SGD 的那一步代数拆分无法直接照搬。

固定 θ₀=(2,2)、数据梯度 g₁=(1,4)、β₁=β₂=0.5、α=0.1、λ=0.2、ε=0.5,两种算法的第一步可以完整算出:

路径 进入矩估计的梯度 第一对修正矩 m̂、v̂ 更新后参数
AdamW (1,4) m̂=(1,4),v̂=(1,16) (1.8933333, 1.8711111)
Adam 加 L2 (1.4,4.4) m̂=(1.4,4.4),v̂=(1.96,19.36) (1.9263158, 1.9102041)

AdamW 的两个坐标都先乘 0.98,再减去各自的自适应位移;Adam 加 L2 没有再额外乘一次 0.98。两者在相同初值和同名 λ 下已经不同,这个反例足以说明它们不普遍等价,并不是比较某个任务的好坏。

初值2和2、数据梯度1和4。AdamW用原梯度估计状态并独立乘0.98,结果1.8933333和1.8711111;Adam加L2将梯度改成1.4和4.4,状态及结果不同,得到1.9263158和1.9102041。
原创确定性算术对照,所有数值来自上表。框与箭头表示更新顺序,不是损失曲面、训练曲线或泛化测试。查看原图

两步手算:零梯度还会继续移动

现在只看一个坐标 θ₀=2,超参数仍为 β₁=β₂=0.5、α=0.1、λ=0.2、ε=0.5,给定数据梯度序列 [2,0]。这是用来审查更新公式的外部输入,不是声称某个模型恰好产生这些梯度。

t 与 gₜmₜ,vₜ修正除数 c₁=c₂m̂ₜ,v̂ₜ衰减后的旧参数自适应位移新参数 θₜ
1;21,21/22,42×0.98=1.960.1×2/(2+0.5)=0.081.88
2;01/2,13/42/3,4/31.88×0.98=1.84240.1×(2/3)/(√(4/3)+0.5)≈0.0402893≈1.8021107

第二步 g₂=0,历史 m₂ 仍为正,因此自适应位移没有立刻消失。只要本步修正一阶矩 m̂ₜ 为零,自适应位移就为零;全部历史梯度均为零是一种充分情形,历史正负贡献抵消也可能得到同样结果。例如 β₁=β₂=0.5、梯度为 [2,−1] 时,第二步 m₂=0、v₂=1.5,仍然只有衰减贡献,并不要求 v₂ 为零。

自检:AdamW 解耦后,改变 λ 会永远不影响 m、v 吗?

不成立。若外部固定给定相同梯度序列,m、v 的公式确实不含 λ;但实际训练的参数路径会随 λ 改变,下一步重新计算的梯度也可能不同,继而改变后续状态。

自检:当前数据梯度为零,能断定本步只有权重衰减吗?

不能。还要看一阶矩是否保留了过去的梯度。两步手算的第二步仍扣除约 0.0402893 的自适应位移;零梯度与缺失梯度也不能混为一谈。

一个可直接运行的标准库实现

代码用不可变状态返回新值,不原地改参数或旧状态;同时提供 Adam 加 L2 的对照入口。状态是同一组参数连续更新的状态,β 应沿该序列保持不变。若中途更换 β,简单使用当前 βᵗ 不再代表此前全部权重的修正,因此应重新定义状态协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
from dataclasses import dataclass
import json
import math


def number(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("expected a finite real number")
try:
result = float(value)
except (OverflowError, ValueError):
raise ValueError("number is outside float range") from None
if not math.isfinite(result):
raise ValueError("expected a finite real number")
return result


def vector(values):
if not isinstance(values, (list, tuple)) or not values:
raise ValueError("expected a nonempty vector")
return tuple(number(value) for value in values)


@dataclass(frozen=True)
class AdamState:
step: int
m: tuple
v: tuple
betas: tuple


def adamw_step(params, gradients, state=None, *, lr=0.001,
betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01):
x, g = vector(params), vector(gradients)
if len(x) != len(g):
raise ValueError("gradient shape mismatch")
if not isinstance(betas, (list, tuple)) or len(betas) != 2:
raise ValueError("betas must have two elements")
b1, b2 = (number(value) for value in betas)
lr, eps, wd = number(lr), number(eps), number(weight_decay)
if not (0 <= b1 < 1 and 0 <= b2 < 1):
raise ValueError("betas must lie in [0, 1)")
if lr < 0 or eps <= 0 or wd < 0:
raise ValueError("invalid lr, epsilon or decay")
if state is None:
state = AdamState(0, (0.0,) * len(x), (0.0,) * len(x), (b1, b2))
if not isinstance(state, AdamState):
raise ValueError("invalid optimizer state")
if type(state.step) is not int or state.step < 0:
raise ValueError("invalid step counter")
old_m, old_v = vector(state.m), vector(state.v)
if len(old_m) != len(x) or len(old_v) != len(x):
raise ValueError("state shape mismatch")
if vector(state.betas) != (b1, b2) or any(value < 0 for value in old_v):
raise ValueError("inconsistent state")
if state.step == 0 and any(value != 0 for value in old_m + old_v):
raise ValueError("initial state must have zero moments")
t = state.step + 1
m = tuple(number(b1 * a + (1 - b1) * b) for a, b in zip(old_m, g))
v = tuple(number(b2 * a + (1 - b2) * (b * b)) for a, b in zip(old_v, g))
c1, c2 = 1 - b1 ** t, 1 - b2 ** t
mh = tuple(number(value / c1) for value in m)
vh = tuple(number(value / c2) for value in v)
adaptive = tuple(number(lr * (a / (math.sqrt(b) + eps)))
for a, b in zip(mh, vh))
factor = number(1 - number(lr * wd))
updated = tuple(number(number(value * factor) - delta)
for value, delta in zip(x, adaptive))
return updated, AdamState(t, m, v, (b1, b2)), {
"m_hat": mh, "v_hat": vh,
"adaptive_step": adaptive, "decay_factor": factor,
}


def adam_l2_step(params, gradients, state=None, *, lr=0.001,
betas=(0.9, 0.999), eps=1e-8, l2=0.01):
x, g, coefficient = vector(params), vector(gradients), number(l2)
if len(x) != len(g) or coefficient < 0:
raise ValueError("invalid L2 comparison inputs")
augmented = tuple(number(a + coefficient * b) for a, b in zip(g, x))
return adamw_step(x, augmented, state, lr=lr, betas=betas,
eps=eps, weight_decay=0)


if __name__ == "__main__":
config = dict(lr=0.1, betas=(0.5, 0.5), eps=0.5)
x, state = (2.0,), None
trace = []
for gradient in [(2.0,), (0.0,)]:
x, state, info = adamw_step(x, gradient, state,
weight_decay=0.2, **config)
trace.append({"step": state.step, "theta": x,
"m": state.m, "v": state.v, **info})
decoupled = adamw_step([2, 2], [1, 4], weight_decay=0.2, **config)[0]
coupled = adam_l2_step([2, 2], [1, 4], l2=0.2, **config)[0]
print(json.dumps({"trace": trace, "adamw": decoupled,
"adam_l2": coupled}, ensure_ascii=False))

第一段轨迹的参数应依次为 1.88、约 1.8021107;二维对照与前表一致。实现拒绝非有限数值及会产生非有限中间值的输入,不承诺任意极端 float 都能稳定更新。返回的额外信息用于核对手算,不是训练监控系统。

调度、参数分组与恢复状态的边界

需要明确的事 本文约定或应检查的结果
学习率变化 每步的 αₜ 同时乘自适应项与 λ;λ 固定不表示每步收缩比例固定
想把它叫作收缩 需有 0≤αₜλ≤1;大于 1 时因子为负,不能继续描述为非负比例缩小
λ=0 退化为本文 epsilon 约定下的 Adam
αₜ=0 本实现参数不变,但仍更新矩状态与步数,不等于整步跳过
某些坐标不衰减 可按模型目标划分参数组,不把“排除所有偏置或归一化参数”当作无条件定理
没有梯度与梯度为零 本实现只接受完整数值向量;不模拟框架对 grad=None 的跳过行为
暂停后恢复 参数、m、v、步数、超参数与调度位置应一起恢复;只存 θ 不足以续接相同轨迹

PyTorch 2.14 Adam 还提供 decoupled_weight_decay=True。因此阅读当前配置不能只看类名,应连同这个开关与参数组一起核对。不同实现的浮点执行顺序可能产生微小差异,本文没有声称已经逐内核验证 PyTorch。

验证范围与继续阅读

tests/verify-adamw-article.cjs 提取上面的原样 Python 并实际执行。专门参考在高精度 Decimal 中把历史梯度展开为有限加权和,不沿用待测 m、v 的递推;另检查增广二次损失的数值梯度、epsilon 重排、零梯度历史和状态恢复。原图中的两组参数与手算表同时接受数值核验。

600 组预定种子的随机轨迹共 5020 步、17245 个坐标通过 70 位 Decimal 参考,并核对坐标置换、固定梯度序列下的解耦状态和 600 次断点恢复。另有 100 组恒定梯度的 2000 步、1406 个数值导数、500 次 epsilon 等价重排、历史梯度抵消反例和 33 类非法输入。两图共 33 个文字节点均位于画布内,原图已逐张查看。

这些检查验证更新算术和输入约定,没有训练真实神经网络,没有测量速度、泛化、正则化收益或证明一般非凸问题的收敛。继续阅读可回到反向传播检查数据梯度的来源,或比较Dropout 的期望损失与LayerNorm 的特征梯度,把前向随机性、归一化与优化器状态放在各自的位置。