qinghuan
← 回到知识探索

AI 直觉 / 动手实验

梯度已经是零,Adam 和 AdamW 为什么还会走出不同一步?

固定一个参数,把 L2 项放进梯度,或在自适应更新外单独衰减;手算第一步,再检查两套历史状态。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看普通梯度下降为什么容易让人混淆
  4. 只算第一步,就能看见 0.9 与 0.99
  5. 两种方式真正分叉的位置
  6. 运行完整小例子
  7. 把 λ 缩小十倍,先别急着推断整个训练过程
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 知道梯度表示参数改变的方向
  • 了解学习率、Adam 的一阶与二阶统计量
  • 先读 从 SGD 到 AdamW:理解优化器状态:先认识学习率、梯度历史与偏差校正,再比较两种衰减路径。
遇到不熟悉的词,先看这 2 个解释
解耦权重衰减
按一定比例缩小参数时,单独算这一步,不把它当作任务梯度记进 Adam 的历史。
耦合 L2 项
把参数平方的惩罚加到目标函数里,让它的导数和任务梯度一起参与更新。
打开完整术语小抄 ↗

令参数 θ=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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

相同梯度,相同 λ,为什么参数走得不同?

正在准备实验控件…

先保持任务梯度为 0,把 λ 从 0.1 改成 0.01。比较第一步的参数与进入 m、v 的梯度。

Adam + L2:θ
0.900000
AdamW:θ
0.990000
Adam + L2:进入动量的梯度
0.100000
AdamW:进入动量的梯度
0.000000

第 1 步:Adam + L2 的 m=0.010000、v=0.000010;AdamW 的 m=0.000000、v=0.000000。L2 项进入了第一条路径的梯度历史;AdamW 的衰减在自适应更新之外单独计算。

同一个任务梯度序列,两套独立状态
步Adam + L2 参数AdamW 参数AdamW 单独衰减量
10.9000000.9900000.010000

一个标量参数 θ₀=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 来自本轮实际执行。

展开收起代码 · 小实验python · 51 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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、ε 的位置或偏差校正,导致手算与代码比较了不同算法。
  • 把初始第一步的近似推广为所有步数、所有 λ,或推广为真实模型质量结论。

自己试一小步

保持其他条件相同,构造两条路径重合与分叉的例子。

  1. 先设置 λ=0,给定非零任务梯度,逐步比较两边的 θ、m、v。
  2. 恢复 λ=0.1、g=0,手算第二步 Adam + L2 接收的有效梯度。
  3. 把 g 改为负数,观察单独衰减项与任务梯度步是否沿同一个方向。

做到这些,就算完成

  • 写出每一步使用的是旧参数还是新参数。
  • 把任务梯度和进入 m、v 的有效梯度分开记录。
  • 至少用一个不依赖递推代码的闭式结果作为核对依据。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。