开始前,会这些就够了
- 知道梯度表示参数改变的方向
- 了解学习率、Adam 的一阶与二阶统计量
- 先读 从 SGD 到
AdamW:理解优化器状态:先认识学习率、梯度历史与偏差校正,再比较两种衰减路径。
遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗令参数 θ=1,任务本身的梯度 g=0,学习率 α=0.1,衰减系数 λ=0.1。Adam 加 L2 后,第一步参数约为 0.9;AdamW 的第一步是 0.99。输入数字一样,结果却不一样。
站内的《从 SGD 到 AdamW:理解优化器状态》已经介绍解耦衰减。本篇把区别变成可检查的数值对照:同一组任务梯度、两套独立状态,查看 L2 项究竟是否进入了历史统计量。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- demo.py:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B demo.py解压后,在含 demo.py 的目录打开终端。命令分别回车执行。代码用标准库实现单个标量的两种递推,输入为固定的数值任务梯度,不模拟 grad=None;没有导入 PyTorch,也没有训练或比较模型精度。输出中的参数、m、v 来自本轮实际执行。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
相同梯度,相同 λ,为什么参数走得不同?
正在准备实验控件…
一个标量参数 θ₀=1,lr=0.1,β₁=0.9,β₂=0.999,ε=10⁻⁸,初始 m=v=0;每步使用同一个数值任务梯度,0 不代表 grad=None。不运行 PyTorch、训练数据或真实模型;无 AMSGrad、梯度裁剪、学习率调度或额外正则项。
先看普通梯度下降为什么容易让人混淆
先暂时不用 Adam。把目标函数写成 f(θ)+(λ/2)θ²,对参数求导就得到 g+λθ。普通梯度下降更新为 θ新=θ旧−α(g+λθ旧)=(1−αλ)θ旧−αg。
在没有动量、没有自适应缩放的这一步里,加上这个 L2 项,确实等价于按比例收缩旧参数再减去任务梯度步。系数约定不能省略:这里正则项是 (λ/2)θ²,若写成 λθ²,其导数会多一个 2。
但 Adam 还会根据梯度历史调整更新量。把 λθ 加进梯度,会同时改变当前分子、分母和下一步使用的历史。不能把普通梯度下降的代数等式直接搬过来。
只算第一步,就能看见 0.9 与 0.99
固定 β₁=0.9、β₂=0.999、ε=10⁻⁸,初始 m=v=0。Adam + L2 接收的有效梯度 q=g+λθ=0.1;于是 m=0.01、v=0.00001。第一步偏差校正后,m̂=0.1、v̂=0.01。
自适应步长为 αm̂/(√v̂+ε)=0.1×0.1/(0.1+10⁻⁸),约为 0.1。参数因此变成 0.900000010。这里的 g=0 指已经提供了数值为零的任务梯度;加入正则项后的有效梯度并不为零,也不讨论梯度缺失或 grad=None 的处理分支。
AdamW 的历史只接收任务梯度 g=0,所以 m=v=0,自适应部分为零。单独衰减旧参数 αλθ=0.01 后,得到 0.99。它不是先对新的梯度历史做一个同样的 L2 运算。
两种方式真正分叉的位置
把用于统计的梯度记为 qₜ。两种方式都更新 mₜ=β₁mₜ₋₁+(1−β₁)qₜ,vₜ=β₂vₜ₋₁+(1−β₂)qₜ²,再分别除以 1−β₁ᵗ 和 1−β₂ᵗ 做偏差校正。
Adam + L2 令 qₜ=gₜ+λθₜ₋₁,最后 θₜ=θₜ₋₁−αm̂ₜ/(√v̂ₜ+ε)。AdamW 令 qₜ=gₜ,最后 θₜ=θₜ₋₁−αλθₜ₋₁−αm̂ₜ/(√v̂ₜ+ε)。这里衰减取旧参数,ε 放在平方根外面。
本页按 PyTorch 2.14 文档的最小化、非 AMSGrad 伪代码比较。该版本 Adam 还提供 decoupled_weight_decay=True,可切到与 AdamW 等价的衰减方式;所以别只根据类名断言实际配置一定采用哪条路径。
运行完整小例子
代码用标准库实现单个标量的两种递推,输入为固定的数值任务梯度,不模拟 grad=None;没有导入 PyTorch,也没有训练或比较模型精度。输出中的参数、m、v 来自本轮实际执行。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Scalar Adam/AdamW recurrences, not a PyTorch training run."""
from math import isclose, isfinite, sqrt
def trace(gradient, weight_decay, steps, decoupled):
if (type(gradient) not in (int, float) or not isfinite(gradient)
or type(weight_decay) not in (int, float)
or not isfinite(weight_decay) or weight_decay < 0):
raise ValueError("finite gradient and nonnegative decay required")
if type(steps) is not int or not 1 <= steps <= 100:
raise ValueError("steps must be an integer from 1 to 100")
if type(decoupled) is not bool:
raise TypeError("decoupled must be bool")
lr, beta1, beta2, epsilon = 0.1, 0.9, 0.999, 1e-8
theta, m, v = 1.0, 0.0, 0.0
rows = []
for step in range(1, steps + 1):
before = theta
effective = gradient if decoupled else gradient + weight_decay * before
m = beta1 * m + (1 - beta1) * effective
v = beta2 * v + (1 - beta2) * effective * effective
m_hat, v_hat = m / (1 - beta1 ** step), v / (1 - beta2 ** step)
decay = lr * weight_decay * before if decoupled else 0.0
theta = before - decay - lr * m_hat / (sqrt(v_hat) + epsilon)
rows.append((theta, m, v))
return rows
def main():
print("theta0=1 lr=0.1 beta1=0.9 beta2=0.999 epsilon=1e-8")
for decay in (0.1, 0.01):
a = trace(0.0, decay, 1, False)[0]
b = trace(0.0, decay, 1, True)[0]
print(f"g=0 lambda={decay:g} Adam+L2 theta={a[0]:.9f} m={a[1]:.6f} v={a[2]:.8f}")
print(f"g=0 lambda={decay:g} AdamW theta={b[0]:.9f} m={b[1]:.6f} v={b[2]:.8f}")
a, b = trace(0.0, 0.1, 3, False), trace(0.0, 0.1, 3, True)
for step, (left, right) in enumerate(zip(a, b), 1):
print(f"step={step} Adam+L2={left[0]:.9f} AdamW={right[0]:.9f}")
# Independent closed form: with zero task gradients, AdamW only shrinks.
for step, row in enumerate(trace(0.0, 0.1, 20, True), 1):
assert isclose(row[0], 0.99 ** step, abs_tol=1e-12)
assert row[1:] == (0.0, 0.0)
for g in (-0.2, 0.0, 0.2):
assert trace(g, 0.0, 20, False) == trace(g, 0.0, 20, True)
first = trace(0.0, 0.1, 1, False)[0][0]
assert isclose(first, 1 - 0.1 * 0.1 / (0.1 + 1e-8), abs_tol=1e-12)
print("checks: geometric decay, lambda-zero equality, first-step oracle passed")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。代码用标准库实现单个标量的两种递推,输入为固定的数值任务梯度,不模拟 grad=None;没有导入 PyTorch,也没有训练或比较模型精度。输出中的参数、m、v 来自本轮实际执行。
运行后对照结果
theta0=1 lr=0.1 beta1=0.9 beta2=0.999 epsilon=1e-8
g=0 lambda=0.1 Adam+L2 theta=0.900000010 m=0.010000 v=0.00001000
g=0 lambda=0.1 AdamW theta=0.990000000 m=0.000000 v=0.00000000
g=0 lambda=0.01 Adam+L2 theta=0.900000100 m=0.001000 v=0.00000010
g=0 lambda=0.01 AdamW theta=0.999000000 m=0.000000 v=0.00000000
step=1 Adam+L2=0.900000010 AdamW=0.990000000
step=2 Adam+L2=0.800412248 AdamW=0.980100000
step=3 Adam+L2=0.701586303 AdamW=0.970299000
checks: geometric decay, lambda-zero equality, first-step oracle passed把 λ 缩小十倍,先别急着推断整个训练过程
让任务梯度仍为零,把 λ 从 0.1 改为 0.01。AdamW 的第一步变为 0.999,收缩量缩小十倍;Adam + L2 却仍约为 0.9,因为第一步分子与平方根分母一起缩小了十倍。
这个“几乎没变”只针对初始状态为零、正参数、零任务梯度的第一步,并依赖 λθ 相对 ε 足够大。它不是“Adam 的 L2 系数永远没用”。当 λ=0 时两条路径完全一致;当历史积累起来,参数符号或任务梯度变化时也不能套用第一步结论。
在本实验一直令任务梯度为零时,AdamW 有独立核对公式 θₜ=(1−αλ)ᵗθ₀,因为它的 m、v 始终为零。Adam + L2 的历史逐步变化,不服从这个简单几何递推。
实务中先记录优化器版本、是否解耦、目标函数是否已经额外加了 L2,以及衰减应用于哪些参数。这个实验解释更新机制,不给出哪一种配置必定精度更高的结论。
记住这三点
- 普通梯度下降的一步等价关系,不会自动扩展到带自适应梯度统计的 Adam。
- 耦合 L2 项进入 m、v;
AdamW把衰减放在自适应梯度更新之外。 - 相同的 λ 不代表相同参数轨迹;比较前还要固定系数约定、状态和实现选项。
别把例子用过头
- 把任务梯度 g=0 误写成加入 L2 后的有效梯度也为零。
- 忽略正则项中的 1
/2、ε 的位置或偏差校正,导致手算与代码比较了不同算法。 - 把初始第一步的近似推广为所有步数、所有 λ,或推广为真实模型质量结论。
自己试一小步
保持其他条件相同,构造两条路径重合与分叉的例子。
- 先设置 λ=0,给定非零任务梯度,逐步比较两边的 θ、m、v。
- 恢复 λ=0.1、g=0,手算第二步 Adam + L2 接收的有效梯度。
- 把 g 改为负数,观察单独衰减项与任务梯度步是否沿同一个方向。
做到这些,就算完成
- 写出每一步使用的是旧参数还是新参数。
- 把任务梯度和进入 m、v 的有效梯度分开记录。
- 至少用一个不依赖递推代码的闭式结果作为核对依据。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- torch.optim.Adam — PyTorch 2.14 (新标签页)↗
梯度中加入 λθ 的伪代码、偏差校正、ε 的位置,以及
decoupled_weight_decay选项。 - torch.optim.AdamW — PyTorch 2.14 (新标签页)↗
先按旧参数单独计算衰减,再执行仅使用任务梯度历史的自适应更新。