第 6 章 JAX 中的状态管理

在 JAX 中借助函数式模式管理状态

JAX 的核心是纯函数,加上 jit、grad、vmap 这些函数变换。可实际计算里到处都是"状态":模型参数要在训练中不断更新,优化器要记住动量,循环要带着中间结果往下走,随机数生成器也要推进内部状态。这些需求和纯函数风格天生有冲突。本章讲 JAX 的解法:把状态当成普通数据,显式地传进函数,再作为返回值传出来,并用 PyTree 来组织结构复杂的状态。

学习目标

  • 说清纯函数的两个条件(确定性、无副作用),以及为什么 jit / grad / vmap 依赖它
  • 认识可变状态在 JAX 中造成的典型问题:追踪时只执行一次、结果陈旧、梯度错误、副作用不会按样本发生
  • 熟练使用"显式状态传递"模式:new_state, out = f(state, inputs)
  • 用 dict / list / tuple / NamedTuple / dataclass 组织 PyTree 状态,用 jax.tree.* 工具操作
  • 实现计数器、SGD、动量 SGD、Adam 等带状态的计算,并与 jit、grad、vmap、lax.scan 组合
  • 了解 Optax、Flax 等生态库如何在此基础上封装状态

小节目录

小节内容
6.1函数纯粹性与副作用
6.2函数式代码中的状态挑战
6.3模式:显式状态传递
6.4使用 PyTree 管理分层状态
6.5示例:有状态计数器
6.6例子:简单的优化器状态
6.7将状态管理与变换结合
6.8实践:实现有状态函数
测验第 6 章测验

原课程链接:https://apxml.com/zh/courses/getting-started-with-jax/chapter-6-managing-state-in-jax

运行环境:Python 3.14、JAX 0.12.0.1、CPU。文中的结论都以这个环境的实际运行结果为准。

阅读说明:讲解文字按课程的要点重新组织,所有代码都在本机实际运行过。有问题的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法;需要特别留意的地方用「⚠️ 注意」标出,章末「本章更新说明」做了汇总。

import sys
import warnings
from functools import partial
from typing import NamedTuple
from dataclasses import dataclass, field

import jax
import jax.numpy as jnp
from jax import lax

print("Python", sys.version.split()[0])
print("JAX   ", jax.__version__)
print("设备  ", jax.devices())
Python 3.14.7
JAX    0.12.0.1
设备   [CpuDevice(id=0)]

6.1 函数纯粹性与副作用

JAX 的 jit、grad、vmap、pmap 等变换要求被变换的函数尽量是纯函数。纯函数满足两个条件:

  1. 确定性:相同的输入永远得到相同的输出。
  2. 无副作用:函数除了计算返回值,不对外界产生任何可观察的影响。具体来说,它不会:
    • 修改函数局部作用域之外的变量(例如全局变量);
    • 原地修改传入的参数(例如对传入的列表 append);
    • 进行 I/O(打印、读写文件、网络请求);
    • 以其他隐式方式与外部系统交互。

先看几个普通 Python 的例子,区分纯与不纯。

# 纯函数:输出只取决于输入,也不影响外界
def pure_add(a, b):
    return a + b

# 不纯:打印是一种 I/O 副作用
def impure_add_and_print(a, b):
    result = a + b
    print(f"结果是 {result}")   # 副作用
    return result

# 不纯:修改了全局变量
call_count = 0
def impure_increment_counter(x):
    global call_count
    call_count += 1             # 副作用:修改全局状态
    return x + 1

# 不纯:原地修改了输入参数
def impure_append_to_list(data_list, value):
    data_list.append(value)     # 副作用:修改了调用方的列表
    return data_list

print(pure_add(2, 3))
impure_add_and_print(2, 3)
impure_increment_counter(1); impure_increment_counter(1)
print("call_count =", call_count)
lst = [1, 2]
impure_append_to_list(lst, 3)
print("调用方的列表被改掉了:", lst)
5
结果是 5
call_count = 2
调用方的列表被改掉了: [1, 2, 3]

为什么纯粹性对 JAX 这么重要?

  1. 追踪机制(tracing):jax.jit 第一次被调用时,并不是拿具体数值去跑你的 Python 代码。它用抽象的"追踪值"(tracer)把函数执行一遍,把里面的数组运算记录成中间表示 Jaxpr,再交给 XLA 编译。打印、修改全局变量这类副作用只在追踪时发生一次。之后命中缓存的调用直接跑编译好的程序,Python 层面的副作用不会再出现。所以 jit 函数里的 print 通常只打印一次。
  2. 缓存与优化:JAX 按输入的形状、dtype 等缓存编译结果,前提是"输出只由输入决定"。如果函数依赖全局状态,缓存的程序就可能用上过时的值。
  3. 自动微分:grad 需要一条清楚的"输入 → 输出"数据流。副作用和原地修改会打乱链式法则依赖的计算结构。
  4. vmap / pmap:被映射的函数同样只追踪一次,并不存在多个各自运行的 Python "实例"。函数里对共享状态的修改只执行一次,改的是代表整批数据的 tracer,而不是每个样本各改一次。想让每个样本(或设备)有自己的状态,就得把状态当成带批维度的输入和输出显式传递(6.2 有演示)。

状态管理的难题

训练模型时要更新权重、维护动量、记录步数。在普通 Python 或面向对象的代码里,这些通常写成"修改对象属性",比如下面的计数器类。这和纯粹性正面冲突。JAX 的解法是显式状态传递:把当前状态作为参数传进去,把新状态作为输出的一部分返回(见 6.3)。

# 典型的面向对象有状态写法:状态藏在对象内部,count() 每次都修改 self.n
class Counter:
    def __init__(self):
        self.n = 0

    def count(self):
        self.n += 1
        return self.n

    def reset(self):
        self.n = 0

my_counter = Counter()
print(my_counter.count())   # 1
print(my_counter.count())   # 2  —— 同样的调用,不同的结果:不是纯函数
1
2

🔍 补充:jit 中修改/读取全局变量的"只在追踪时执行一次"陷阱

🟢 必读

前面说过"副作用只在追踪时发生"。下面把三种最常见的坑实际跑一遍:

  • 写全局变量:只在追踪时执行,之后的调用不会再改它。只有输入的形状或 dtype 变了、触发重新追踪时,才会"偶尔"再执行一次,于是计数看起来毫无规律。
  • 读全局变量:追踪时读到的值会被当成常量写死在编译结果里。之后再改全局变量,已经缓存的版本感知不到;只有新形状触发重新编译时,才会读到新值。结果就是同一个函数对不同形状的输入给出"新旧不一"的结果。
  • 调试打印:想每次运行都打印数值,就用 jax.debug.print。它会被编进程序里,每次执行都会输出。
counter = 0

@jax.jit
def double(x):
    global counter
    counter += 1          # 副作用:只在追踪时执行
    return x * 2

for i in range(3):
    double(jnp.float32(i))
print("调用 3 次后 counter =", counter, "(只在第一次追踪时 +1)")
double(jnp.ones(3))       # 新形状 → 重新追踪
print("换一个形状再调用后 counter =", counter)
assert counter == 2

# 读全局变量:值在追踪时被固化
scale = 2.0
@jax.jit
def scaled(x):
    return x * scale

print("scale=2 时:", scaled(1.0))
scale = 10.0
print("改成 scale=10 后,标量输入(命中缓存):", scaled(1.0))
print("改成 scale=10 后,新形状输入(重新编译):", scaled(jnp.ones(2)))
assert float(scaled(1.0)) == 2.0 and float(scaled(jnp.ones(2))[0]) == 10.0   # 同一函数,新旧值并存

# 正确的调试打印方式
@jax.jit
def show(x):
    print("Python print:只在追踪时出现,x =", x)
    jax.debug.print("jax.debug.print:每次运行都出现,x = {}", x)
    return x + 1

show(1.0)
_ = show(2.0)
调用 3 次后 counter = 1 (只在第一次追踪时 +1)
换一个形状再调用后 counter = 2
scale=2 时: 2.0
改成 scale=10 后,标量输入(命中缓存): 2.0
改成 scale=10 后,新形状输入(重新编译): [10. 10.]
Python print:只在追踪时出现,x = JitTracer(~float32[])
jax.debug.print:每次运行都出现,x = 1.0
jax.debug.print:每次运行都出现,x = 2.0

结论:要让函数的行为随某个值变化,就把这个值作为参数传进去(必要时标为静态参数),而不是依赖全局变量。这正是"显式状态传递"的出发点。


6.2 函数式代码中的状态挑战

JAX 的 jit、grad 依靠对纯函数做符号追踪来工作。而在标准 Python 中,我们习惯用"就地修改"来维护状态:往列表里 append、改字典的键、改实例属性。例如:

# 标准 Python:原地修改字典
my_data = {'values': [10, 20]}

def update_data(data_dict, new_value):
    data_dict['values'].append(new_value)   # 原地修改列表
    data_dict['last_added'] = new_value     # 原地新增键
    # 不需要 return,调用方的对象已经被改了

update_data(my_data, 30)
print(my_data)   # {'values': [10, 20, 30], 'last_added': 30}
{'values': [10, 20, 30], 'last_added': 30}

这种写法在 JAX 变换下会带来三类问题:

  1. 破坏 jit:追踪时捕获的是那一刻的外部状态,编译后的版本可能一直复用这个旧值,结果就会出错或者不符合预期。
  2. 干扰 grad:原地修改让数据流变得模糊。可能直接报错,也可能悄悄算出数学上错误的梯度,给训练埋下隐患。
  3. 妨碍 vmap / pmap:被映射的函数只追踪一次,函数里对外部可变状态的修改也只执行一次,写进去的是 tracer,而不是"每个样本各改一次"。每个样本需要的独立状态,必须作为带批维度的参数传进去、再作为返回值传出来。

下面是课程中"用 JAX 数组,但原地修改对象属性"的例子。不加 jit 时一切正常;加了 jit,在 JAX 0.12 中一定会抛出 TypeError。原因并不在"修改属性"本身,而是 StateHolder 是一个普通的 Python 对象,既不是数组,也不是注册过的 PyTree,jit 不知道怎么把它转成追踪值。

⚠️ 注意:这段代码在 jit 下的表现并不含糊,也不随版本时好时坏:它每次都会报 TypeError: Error interpreting argument ... as an abstract array。真正会"悄悄出错"的,是把这个类注册成 PyTree 之后的情形(见本节后半部分)。

⚠️ 注意:vmap / pmap 下的问题,不是"多个并行实例读写同一份可变状态,产生冲突和竞态"。vmap 只追踪一次:函数体里修改外部列表的副作用,对 5 个样本只执行了 1 次,拿到的是一个 BatchTracer(本节后半部分用 assert 验证)。真正的问题是副作用根本不会按样本发生,还可能把 tracer 泄漏到函数外面。

# ❌ 错误写法:jit 一个原地修改对象属性的函数
class StateHolder:
    def __init__(self, value):
        self.value = jnp.array(value)

def update_state_impurely(state_obj, increment):
    # 不纯:原地修改对象的属性
    state_obj.value = state_obj.value + increment
    return state_obj.value

# 不加 JIT:按普通 Python 执行,看起来没问题
my_state = StateHolder(10.0)
print("非 JIT 第 1 次:", update_state_impurely(my_state, 5.0))   # 15.0
print("非 JIT 第 2 次:", update_state_impurely(my_state, 3.0))   # 18.0

# 加上 JIT:一定会报 TypeError
my_state_jit = StateHolder(10.0)
jitted_update = jax.jit(update_state_impurely)
stateholder_error = None
try:
    print("JIT 第 1 次:", jitted_update(my_state_jit, 5.0), my_state_jit.value)
except Exception as e:
    stateholder_error = e
    print(f"实测结果 → {type(e).__name__}: {str(e)[:200]}")

assert isinstance(stateholder_error, TypeError), "StateHolder 在 jit 下应当确定报 TypeError"
非 JIT 第 1 次: 15.0
非 JIT 第 2 次: 18.0
实测结果 → TypeError: Error interpreting argument to <function update_state_impurely at 0x11a2c97a0> as an abstract array. The problematic value is of type <class '__main__.StateHolder'> and was passed to the function at p
# ✅ 修正:显式状态传递 —— 状态是普通数组,函数返回新状态,不修改任何对象
def update_state_pure(value, increment):
    return value + increment

jitted_pure = jax.jit(update_state_pure)
v = jnp.array(10.0)
v = jitted_pure(v, 5.0); print("JIT 第 1 次:", v)
v = jitted_pure(v, 3.0); print("JIT 第 2 次:", v)
assert float(v) == 18.0
JIT 第 1 次: 15.0
JIT 第 2 次: 18.0

原因:jit 只认数组和 PyTree。把状态改成数组(或 PyTree),并通过返回值传出去,就同时解决了"没法追踪"和"副作用"这两个问题。

更隐蔽的"悄悄出错":如果把这个类注册成 PyTree(注册方法见 6.4),jit 能正常运行,但 jit 里面拿到的是根据叶子重新构造出来的副本。在副本上改属性,对调用方的对象毫无影响,不报错,结果却不对:下例中外部的 s.value 一直是 10,第二次的返回值是 13 而不是 18。

另外,JAX 数组本身是不可变的:x[0] = 1 会直接报错,要用函数式的 x = x.at[0].set(1)。它返回一个新数组(在 jit 里 XLA 通常能把它优化成原地更新)。

下面这个单元的最后,还演示了 vmap 下副作用的实际行为(对应上面关于"竞态"的那条注意)。

# 演示:注册为 PyTree 后,原地修改"静默失效"
@jax.tree_util.register_pytree_node_class
class StateHolderPytree:
    def __init__(self, value):
        self.value = value
    def tree_flatten(self):
        return (self.value,), None          # (子节点, 辅助数据)
    @classmethod
    def tree_unflatten(cls, aux, children):
        return cls(*children)

s = StateHolderPytree(jnp.array(10.0))
f = jax.jit(update_state_impurely)
r1 = f(s, 5.0); print("返回值:", r1, "  外部 s.value:", s.value)
r2 = f(s, 3.0); print("返回值:", r2, "  外部 s.value:", s.value, " ← 没有累加!静默错误")
assert float(s.value) == 10.0 and float(r2) == 13.0     # 外部对象从未被修改

# JAX 数组不可变
x = jnp.zeros(3)
try:
    x[0] = 1.0
except TypeError as e:
    print("TypeError:", str(e)[:110], "...")
print("函数式更新 x.at[0].set(1.0) →", x.at[0].set(1.0), ";原数组仍是", x)

# vmap 下的副作用:只在追踪时执行 1 次,拿到的是代表整批的 BatchTracer,并不存在"多个实例竞态"
side_effect_log = []
def append_and_double(v):
    side_effect_log.append(type(v).__name__)   # 副作用:修改外部列表
    return v * 2
jax.vmap(append_and_double)(jnp.arange(5.0))
print("vmap 处理 5 个样本后,外部列表被追加了", len(side_effect_log), "次:", side_effect_log)
assert side_effect_log == ["BatchTracer"]
返回值: 15.0   外部 s.value: 10.0
返回值: 13.0   外部 s.value: 10.0  ← 没有累加!静默错误
TypeError: JAX arrays are immutable and do not support in-place item assignment. Instead of x[idx] = y, use x = x.at[idx] ...
函数式更新 x.at[0].set(1.0) → [1. 0. 0.] ;原数组仍是 [0. 0. 0.]
vmap 处理 5 个样本后,外部列表被追加了 1 次: ['BatchTracer']

本节结论:训练中的参数更新、动量、RNN 隐藏状态等都需要换一种模式来管理:更新状态的函数接收当前状态作为输入,返回新状态作为输出,原状态保持不变。


6.3 模式:显式状态传递

JAX 处理状态的主要方式是显式状态传递(explicit state passing),分三步:

  1. 接收当前状态作为输入参数;
  2. 用状态和其他输入进行计算;
  3. 返回新的、更新后的状态(通常与其他计算结果一起返回)。

状态被当作普通数据传来传去,原始对象从不被修改。模型参数、优化器动量、RNN 隐藏状态都可以这样处理。

            ┌─────────────────────┐
 当前状态 ──▶│                     │──▶ 新状态 ──┐
            │      纯函数 f        │             │(调用方保存,下一次再传入)
 其他输入 ──▶│                     │──▶ 其他结果  │
            └─────────────────────┘             │
                 ▲                              │
                 └──────────────────────────────┘

用公式表示就是 $(s_{t+1},\ y_t) = f(s_t,\ x_t)$。

先对比命令式写法:

# 命令式:状态藏在对象里
class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1
        return self.count

c = Counter()
print(c.increment())   # 1
print(c.increment())   # 2
print("最终 count:", c.count)   # 2
1
2
最终 count: 2
# 函数式:状态显式传入、显式返回
def init_counter_state():
    """初始化状态。"""
    return 0

def increment(current_state):
    """接受当前状态,返回新状态。"""
    print(f"Running increment function with state: {current_state}")  # 用于演示
    new_state = current_state + 1
    return new_state

state = init_counter_state()
print("Initial state:", state)
state = increment(state)
print("State after first increment:", state)
state = increment(state)
print("State after second increment:", state)
Initial state: 0
Running increment function with state: 0
State after first increment: 1
Running increment function with state: 1
State after second increment: 2

要点:

  • 状态以参数形式显式传给 increment;
  • increment 返回新值,而不是修改什么东西;
  • 调用方负责保存返回的新状态,并在下一次调用时传回去;
  • increment 本身是纯函数(演示用的 print 除外)。

因为是纯函数,它可以直接被 jit 编译:

jitted_increment = jax.jit(increment)

state = init_counter_state()
# 第一次调用触发追踪和编译,会执行 Python 代码(包括 print)
state = jitted_increment(state)
print("State after JIT call 1:", state)
# 之后的调用使用编译好的版本,内部 print 不再执行
state = jitted_increment(state)
print("State after JIT call 2:", state)
state = jitted_increment(state)
print("State after JIT call 3:", state)
Running increment function with state: JitTracer(~int32[])
State after JIT call 1: 1
State after JIT call 2: 2
State after JIT call 3: 3

jit 第一次以某种类型和形状的状态被调用时,会追踪函数并用 XLA 编译;之后形状/类型兼容的调用直接使用缓存。状态从输入流到输出的过程本身就是追踪逻辑的一部分,所以编译后的更新依然正确。同样的模式也适用于 grad。

实际中的状态往往是嵌套结构,例如参数字典、优化器统计量(均值、方差)等,这正是下一节 PyTree 要解决的问题。


6.4 使用 PyTree 管理分层状态

神经网络参数通常包含多层的权重矩阵和偏置向量,按层组织;优化器状态还可能为每个参数维护动量或自适应学习率。如果把几十个数组一个个手动传进传出,既繁琐又容易出错。

PyTree 不是 JAX 里某个可以导入的类型,而是"由 Python 容器嵌套构成的树形结构":

  • 内部节点:容器,最常见的是 list、tuple、dict(以及 None、NamedTuple 等,见下方补充);
  • 叶子:通常是 JAX 数组,或其他非容器对象(Python 标量等)。

例如两层线性模型的参数:

params (dict)
├── layer1 (dict)
│   ├── weights : Array(3, 2)   ← 叶子
│   └── bias    : Array(2,)     ← 叶子
└── layer2 (dict)
    ├── weights : Array(2, 1)   ← 叶子
    └── bias    : Array(1,)     ← 叶子
params = {
    'layer1': {'weights': jnp.ones((3, 2)), 'bias': jnp.zeros((2,))},
    'layer2': {'weights': jnp.ones((2, 1)), 'bias': jnp.zeros((1,))},
}
print(jax.tree.map(lambda x: x.shape, params))
{'layer1': {'bias': (2,), 'weights': (3, 2)}, 'layer2': {'bias': (1,), 'weights': (2, 1)}}

JAX 变换天然支持 PyTree。jax.jit、jax.grad、jax.vmap、jax.pmap 接收或返回 PyTree 时,会自动遍历树结构,把逻辑作用在叶子上,同时保持容器结构不变:

  • jax.grad 返回的梯度和 params 的 PyTree 结构相同;
  • jax.vmap 会在输入 PyTree 的各个叶子上处理批维度;
  • jit 编译时自动处理容器结构,不用你手动展平、更新、再拼回字典。

更新参数时,用 jax.tree.map 把一个函数逐叶子地作用到一个或多个结构相同的 PyTree 上,返回一个结构相同的新 PyTree:

⚠️ 注意:旧代码里常见的是 jax.tree_util.tree_map。它在 0.12 中仍然可用,结果也完全一样,但官方推荐写 jax.tree.map,下面统一用新写法。完整的新旧对照见本节后面的迁移表。

def update_params(params, grads, learning_rate):
    """对 PyTree 中的每个叶子执行一次 SGD 更新。"""
    def sgd_update(param, grad):
        return param - learning_rate * grad
    return jax.tree.map(sgd_update, params, grads)

# 占位梯度(结构与 params 相同)
grads = {
    'layer1': {'weights': jnp.full((3, 2), 0.1), 'bias': jnp.full((2,), 0.01)},
    'layer2': {'weights': jnp.full((2, 1), 0.2), 'bias': jnp.full((1,), 0.02)},
}
learning_rate = 0.01

updated_params = update_params(params, grads, learning_rate)
print("layer1 weights:\n", updated_params['layer1']['weights'])
print("layer2 bias:", updated_params['layer2']['bias'])

# 对 update_params 直接 jit,结构和数值与未编译版本一致
jit_update_params = jax.jit(update_params)
updated_jit = jit_update_params(params, grads, learning_rate)
same = jax.tree.all(jax.tree.map(jnp.allclose, updated_params, updated_jit))
print("jit 结果与非 jit 结果一致:", bool(same))
assert same
layer1 weights:
 [[0.999 0.999]
 [0.999 0.999]
 [0.999 0.999]]
layer2 bias: [-0.0002]
jit 结果与非 jit 结果一致: True

jax.tree 模块的常用函数(jax.tree_util 里有同名的函数,两者等价):

  • jax.tree.map(f, tree, *rest):逐叶子应用函数;
  • jax.tree.leaves(tree):把所有叶子取出来,放进一个扁平列表;
  • jax.tree.unflatten(treedef, leaves):用结构定义和叶子重建树。

通常只有在你自己的函数里要显式操作叶子时(比如上面的 update_params),才会直接用到它们;其他时候 JAX 变换会默默处理好结构。

小结:用标准的 dict、list、tuple 组织复杂状态,JAX 会在变换中自动处理结构。这样你能写出简洁的函数式代码,同时享受编译、自动微分和向量化(vectorization,注意不是 quantization,那是"量化")。

🔍 补充:PyTree API 迁移表(旧 → 新,以 0.12 实测为准)

⚪ 选读:迁移旧代码、查某个 tree API 的新旧写法时查阅。

JAX 现在推荐用 jax.tree 模块。jax.tree_util.* 仍然保留,完全可用(注册自定义节点、keystr 等功能只在 tree_util 里有)。真正被移除的是挂在顶层 jax 命名空间上的旧别名(jax.tree_map 等):它们先被标记为弃用,到 0.12 已经没有了,调用会报 AttributeError。6.7 节就会碰到用 jax.tree_map 的旧代码。

旧写法0.12 中的状态新写法(推荐)
jax.tree_map(f, t, *rest)❌ 已移除jax.tree.map
jax.tree_leaves(t)❌ 已移除jax.tree.leaves
jax.tree_structure(t)❌ 已移除jax.tree.structure
jax.tree_flatten(t) / jax.tree_unflatten(d, l)❌ 已移除jax.tree.flatten / jax.tree.unflatten
jax.tree_transpose(o, i, t)❌ 已移除jax.tree.transpose
jax.tree_reduce(f, t)❌ 已移除jax.tree.reduce
jax.tree_util.tree_multimap(f, t, *rest)❌ 已移除(更早)jax.tree.map 传多棵树
jax.tree_util.tree_map✅ 可用jax.tree.map
jax.tree_util.tree_leaves / tree_structure✅ 可用jax.tree.leaves / jax.tree.structure
jax.tree_util.tree_flatten / tree_unflatten✅ 可用jax.tree.flatten / jax.tree.unflatten
jax.tree_util.tree_all / tree_reduce✅ 可用jax.tree.all / jax.tree.reduce
jax.tree_util.tree_transpose✅ 可用jax.tree.transpose
jax.tree_util.tree_map_with_path / tree_leaves_with_path✅ 可用jax.tree.map_with_path / jax.tree.leaves_with_path
jax.tree_util.tree_broadcast✅ 可用jax.tree.broadcast

下面的单元先用 assert 验证这张表的"状态"一列,再逐个演示新 API。

# 自证迁移表:顶层旧别名全部不存在,tree_util 版本和 jax.tree 版本都存在
removed_top_level = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten",
                     "tree_unflatten", "tree_transpose", "tree_reduce"]
for name in removed_top_level:
    assert not hasattr(jax, name), f"jax.{name} 应已移除"
assert not hasattr(jax.tree_util, "tree_multimap")

still_in_tree_util = ["tree_map", "tree_leaves", "tree_structure", "tree_flatten", "tree_unflatten",
                      "tree_all", "tree_reduce", "tree_transpose", "tree_map_with_path",
                      "tree_leaves_with_path", "tree_broadcast"]
for name in still_in_tree_util:
    assert hasattr(jax.tree_util, name), f"jax.tree_util.{name} 应仍可用"

new_api = ["map", "leaves", "structure", "flatten", "unflatten", "all", "reduce",
           "transpose", "map_with_path", "leaves_with_path", "broadcast"]
for name in new_api:
    assert hasattr(jax.tree, name), f"jax.tree.{name} 应存在"
print("迁移表验证通过;jax.tree 模块提供:", [n for n in dir(jax.tree) if not n.startswith("_")])
迁移表验证通过;jax.tree 模块提供: ['all', 'broadcast', 'flatten', 'flatten_with_path', 'leaves', 'leaves_with_path', 'map', 'map_with_path', 'reduce', 'reduce_associative', 'static', 'structure', 'transpose', 'unflatten']
# ❌ 旧写法(过时):顶层别名 jax.tree_map 在 0.12 中已移除
try:
    jax.tree_map(lambda x: x * 2, {'a': 1.0})
except AttributeError as e:
    print("实测 → AttributeError:", e)
实测 → AttributeError: module 'jax' has no attribute 'tree_map'
# ✅ 推荐写法:jax.tree.* 逐个演示
import operator
tree = {'b': jnp.arange(3.), 'a': (1.0, None, [2.0, 3.0])}

# (1) map:单棵树、多棵树(多棵树结构必须一致)
print("map 单棵树:", jax.tree.map(lambda x: x * 2, {'w': 1.0, 'b': (2.0, 3.0)}))
summed = jax.tree.map(lambda a, b, c: a + b + c, {'w': 1.0, 'b': 2.0}, {'w': 10.0, 'b': 20.0}, {'w': 100.0, 'b': 200.0})
print("map 三棵树:", summed)
assert summed == {'w': 111.0, 'b': 222.0}
try:
    jax.tree.map(lambda a, b: a + b, {'w': 1.0}, {'w': 1.0, 'b': 2.0})
except ValueError as e:
    print("结构不一致 → ValueError:", str(e).splitlines()[0])

# (2) leaves:None 是"空子树"不是叶子;dict 按键排序展平('a' 在 'b' 之前)
lv = jax.tree.leaves(tree)
print("leaves:", lv)
assert len(lv) == 4 and lv[0] == 1.0

# (3) structure
print("structure:", jax.tree.structure(tree))

# (4) flatten / unflatten:往返重建
leaves, treedef = jax.tree.flatten(tree)
rebuilt = jax.tree.unflatten(treedef, [x * 10 for x in leaves])
print("unflatten:", rebuilt)
assert jax.tree.structure(rebuilt) == treedef

# (5) map_with_path:拿到每个叶子的键路径,例如只对 weights 做权重衰减
decayed = jax.tree.map_with_path(
    lambda path, x: x * 0.9 if 'weights' in jax.tree_util.keystr(path) else x, params)
print("map_with_path 路径:", jax.tree.map_with_path(lambda p, x: jax.tree_util.keystr(p), params))
assert jnp.allclose(decayed['layer1']['weights'], 0.9) and jnp.allclose(decayed['layer1']['bias'], 0.0)

# (6) reduce:把所有叶子归约成一个值,例如统计参数量、求梯度全局范数
n_params = jax.tree.reduce(operator.add, jax.tree.map(lambda x: x.size, params))
global_norm = jnp.sqrt(jax.tree.reduce(operator.add, jax.tree.map(lambda g: jnp.sum(g ** 2), grads)))
print("reduce 参数总数:", n_params, " 梯度全局范数:", global_norm)
assert n_params == 11

# (7) all:所有叶子都为真才为真
print("all:", jax.tree.all({'x': True, 'y': [True, True]}), jax.tree.all({'x': True, 'y': [True, False]}))
assert jax.tree.all({'x': True, 'y': [True, True]}) and not jax.tree.all({'x': True, 'y': [True, False]})

# (8) transpose:"list of dict" ↔ "dict of list",常用于把逐步记录的指标整理成按名字分组
records = [{'loss': 1.0, 'acc': 0.5}, {'loss': 0.8, 'acc': 0.6}, {'loss': 0.5, 'acc': 0.7}]
outer = jax.tree.structure([0] * len(records))
inner = jax.tree.structure({'loss': 0, 'acc': 0})
by_key = jax.tree.transpose(outer, inner, records)
print("transpose:", by_key)
assert by_key == {'acc': [0.5, 0.6, 0.7], 'loss': [1.0, 0.8, 0.5]}
map 单棵树: {'b': (4.0, 6.0), 'w': 2.0}
map 三棵树: {'b': 222.0, 'w': 111.0}
结构不一致 → ValueError: pytree structure error: different numbers of pytree children at key path
leaves: [1.0, 2.0, 3.0, Array([0., 1., 2.], dtype=float32)]
structure: PyTreeDef({'a': (*, None, [*, *]), 'b': *})
unflatten: {'a': (10.0, None, [20.0, 30.0]), 'b': Array([ 0., 10., 20.], dtype=float32)}
map_with_path 路径: {'layer1': {'bias': "['layer1']['bias']", 'weights': "['layer1']['weights']"}, 'layer2': {'bias': "['layer2']['bias']", 'weights': "['layer2']['weights']"}}
reduce 参数总数: 11  梯度全局范数: 0.37496668
all: True False
transpose: {'acc': [0.5, 0.6, 0.7], 'loss': [1.0, 0.8, 0.5]}

🔍 补充:自定义状态容器 —— NamedTuple、register_dataclass、register_pytree_node_class

⚪ 选读:想用带字段名的容器(TrainState 等)代替 dict 时读。

dict 写起来方便,但键名打错了只能等运行时才发现。实战中更常用"带字段名的容器"来表示状态:

  1. typing.NamedTuple:拿来就是 PyTree(JAX 自动识别)。它不可变,可以用 ._replace() 生成改过的副本。6.6 节就用了它。
  2. jax.tree_util.register_dataclass:把 @dataclass 注册成 PyTree。可以用 data_fields / meta_fields 显式指定;也可以直接装饰,让 JAX 根据字段推断。带 field(metadata=dict(static=True)) 的字段会作为静态元数据:不参与追踪,值一变就重新编译。建议配合 frozen=True,从语法上杜绝原地修改。
  3. jax.tree_util.register_pytree_node_class:最灵活,需要自己写 tree_flatten / tree_unflatten,适合结构比较特殊的类。
# 1) NamedTuple
class TrainState(NamedTuple):
    step: jax.Array
    params: dict

ts = TrainState(step=jnp.array(0), params={'w': jnp.ones(2)})
ts2 = ts._replace(step=ts.step + 1)        # 生成新对象,原对象不变
print("NamedTuple 结构:", jax.tree.structure(ts))
print("ts.step =", ts.step, " ts2.step =", ts2.step)

# 2) register_dataclass:显式指定数据字段和元数据字段
@partial(jax.tree_util.register_dataclass, data_fields=['w', 'b'], meta_fields=['name'])
@dataclass(frozen=True)
class Linear:
    w: jax.Array
    b: jax.Array
    name: str          # 静态元数据,不是叶子

lin = Linear(jnp.ones(2), jnp.zeros(2), 'fc1')
print("dataclass 叶子:", jax.tree.leaves(lin))
print("dataclass 结构:", jax.tree.structure(lin))

# 也可以直接装饰,由字段元数据推断哪些是静态的
@jax.tree_util.register_dataclass
@dataclass(frozen=True)
class AdamConfig:
    lr: jax.Array
    b1: float = field(default=0.9, metadata=dict(static=True))

print("推断结果:", jax.tree.structure(AdamConfig(jnp.array(1e-3))))

# frozen=True:尝试原地修改会立刻报错
try:
    lin.w = jnp.zeros(2)
except Exception as e:
    print(f"{type(e).__name__}: {e}")

# 这些容器都能直接穿过 jit / grad
@jax.jit
def forward(layer, x):
    return x @ layer.w + layer.b.sum()

print("jit 前向:", forward(lin, jnp.array([1.0, 2.0])))
print("对 dataclass 求梯度:", jax.grad(lambda l: forward(l, jnp.array([1.0, 2.0])))(lin))
NamedTuple 结构: PyTreeDef(CustomNode(namedtuple[TrainState], [*, {'w': *}]))
ts.step = 0  ts2.step = 1
dataclass 叶子: [Array([1., 1.], dtype=float32), Array([0., 0.], dtype=float32)]
dataclass 结构: PyTreeDef(CustomNode(Linear[('fc1',)], [*, *]))
推断结果: PyTreeDef(CustomNode(AdamConfig[(0.9,)], [*]))
FrozenInstanceError: cannot assign to field 'w'
jit 前向: 3.0
对 dataclass 求梯度: Linear(w=Array([1., 2.], dtype=float32), b=Array([1., 1.], dtype=float32), name='fc1')

6.5 示例:有状态计数器

先回顾标准 Python 的计数器类。它依赖可变状态(副作用),与 JAX 不兼容:直接对 increment 施加 jit 时,JAX 无法追踪 self.count 的原地变化。

class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1     # 原地修改:副作用
        return self.count

counter_obj = Counter()
print(counter_obj.increment(), counter_obj.increment())
1 2

函数式实现:状态就是一个普通的值。更新函数接收旧状态,返回新状态。一次调用的输出就是下一次调用的输入,状态就这样"流过"一串纯函数调用。

initial_state = 0

def update_counter(current_state):
    """将输入状态递增 1。"""
    print("Tracing update_counter...")   # 追踪时才会出现(演示用)
    return current_state + 1

state1 = update_counter(initial_state)
state2 = update_counter(state1)
state3 = update_counter(state2)
print("最终状态:", state3)
Tracing update_counter...
Tracing update_counter...
Tracing update_counter...
最终状态: 3

与 JIT 兼容:jax.jit(update_counter) 之后连续调用三次,"Tracing update_counter..." 只出现一次,后面都用缓存的编译版本。这说明 jit 能正确处理用函数方式管理的状态。只有输入的抽象签名(形状、dtype、弱类型标记)变了,才会再追踪一次。

⚠️ 注意:有一种说法是"输入类型变化时,比如从 Python int 变成 JAX tracer,可能出现多次追踪"。这不准确:tracer 是 jit 内部的追踪值,并不是调用方传进来的类型。从 Python int 0 开始调用时,返回的是弱类型的 int32 数组,签名和 Python int 一样,所以只追踪 1 次(下方 assert 验证)。真正会多追踪一次的情况是弱类型变成了强类型,见 6.8 的练习 2。

counter_traces = []
def update_counter_counted(current_state):
    counter_traces.append(1)          # 只在追踪时执行,用来数追踪次数
    return update_counter(current_state)

jitted_update_counter = jax.jit(update_counter_counted)

s = initial_state
for i in range(3):
    s = jitted_update_counter(s)
    print(f"第 {i + 1} 次调用后: {s}")
assert len(counter_traces) == 1 and int(s) == 3
Tracing update_counter...
第 1 次调用后: 1
第 2 次调用后: 2
第 3 次调用后: 3

用 lax.scan 迭代

在 Python 循环里反复调用 jit 函数,每次都有 Python 调度的开销。jax.lax.scan 把"反复把主体函数作用在累积状态上"这件事整个交给编译器:

  • 主体函数签名:body(carry, x) -> (new_carry, y)
    • carry:在各步之间传递的状态;
    • x:这一步的输入切片(来自 xs,可以是 None);
    • y:这一步的输出。各步的 y 会沿新的第 0 维堆起来返回。
  • 调用:final_carry, ys = lax.scan(body, init_carry, xs, length=...)

它的语义等价于下面这段 Python 代码:

def scan(f, init, xs, length=None):
    carry, ys = init, []
    for x in xs:                 # 若 xs 为 None,则循环 length 次,x 为 None
        carry, y = f(carry, x)
        ys.append(y)
    return carry, stack(ys)
def scan_body(carry_state, _):
    next_state = update_counter(carry_state)
    return next_state, None          # 每步没有额外输出

initial_scan_state = 0
num_steps = 10
final_state, accumulated_outputs = jax.lax.scan(
    scan_body, initial_scan_state, xs=None, length=num_steps)
print("scan 后的最终状态:", final_state)
print("每步输出:", accumulated_outputs)   # 每步输出 None,所以整体也是 None
Tracing update_counter...
scan 后的最终状态: 10
每步输出: None

把 lax.scan 放进 jax.jit 里,整个循环会编译成一个优化过的程序,比在 Python 循环里反复调用 jit 函数快得多。但要注意:scan 的 length 必须是编译期就知道的 Python 整数,因为它决定了输出 ys 的形状。所以循环次数要么作为静态参数传给 jit,要么换成允许动态次数的 lax.fori_loop。

⚠️ 注意:如果像 run_scan_jitted(init_state, steps) 这样,把 steps 当作 jit 的普通参数直接传给 length,会报 ConcretizationTypeError: The length argument to scan expects a concrete int value。下面先运行这种写法,再给出两种修法。

# ❌ 错误写法:steps 作为普通参数传入 jit,再传给 scan 的 length
@jax.jit
def run_scan_jitted(init_state, steps):
    final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
    return final

scan_error = None
try:
    print(run_scan_jitted(initial_scan_state, num_steps))
except Exception as e:
    scan_error = e
    print(f"实测结果 → {type(e).__name__}:\n{str(e)[:250]}")

assert isinstance(scan_error, jax.errors.ConcretizationTypeError)
实测结果 → ConcretizationTypeError:
Abstract tracer value encountered where concrete value is expected: traced array with shape int32[]
The `length` argument to `scan` expects a concrete `int` value. For scan-like iteration with a dynamic length, use `while_loop` or `fori_loop`.
The er
# ✅ 修正 1:用 static_argnames 把 steps 标为静态(每个不同的 steps 值会各编译一次)
@partial(jax.jit, static_argnames="steps")
def run_scan_jitted(init_state, steps):
    final, _ = jax.lax.scan(scan_body, init_state, xs=None, length=steps)
    return final

r_static = run_scan_jitted(initial_scan_state, steps=num_steps)
print("修正 1(static_argnames):", r_static)
assert int(r_static) == 10

# ✅ 修正 2:循环次数需要在运行时变化、又不需要逐步输出时,改用 fori_loop
@jax.jit
def run_fori(init_state, steps):
    return lax.fori_loop(0, steps, lambda i, c: update_counter(c), init_state)

r_fori_10, r_fori_25 = run_fori(initial_scan_state, 10), run_fori(initial_scan_state, 25)
print("修正 2(fori_loop):", r_fori_10, r_fori_25)
assert int(r_fori_10) == 10 and int(r_fori_25) == 25
修正 1(static_argnames): 10
Tracing update_counter...
修正 2(fori_loop): 10 25

原因:jit 的普通参数在追踪时是抽象的 tracer,只有形状和 dtype,没有具体值;scan 需要具体的步数来确定输出形状。static_argnames 让 steps 作为 Python 整数参与编译(代价是每个新值重新编译);fori_loop 的上界是动态值时底层会变成 while_loop,可以运行时改变次数,但不支持反向模式求导。

扩展到复杂状态:同样的模式适用于嵌套字典或 JAX 数组列表(模型参数、优化器统计量等)。只要更新函数接收整个 PyTree 并返回整个更新后的 PyTree,JAX 变换就能正确处理。

总结:把状态当作不可变的值,显式地传入、传出纯函数。这样既兼容 jit 等变换,也能使用 lax.scan 这类高效的执行方式。

🔍 补充:lax.scan 的 carry 就是状态 —— 用逐步输出记录轨迹

🟢 必读

上面的例子每步都输出 None,没体现出 scan 最有用的一面:carry 是状态,y 是"每一步想记下来的东西"。下面把一个带两个字段的 PyTree 状态(累计和、步数)放进 carry,同时把每一步的平均值作为 ys 收集起来。有几条规则:

  • carry 的 PyTree 结构、形状和 dtype 每一步都必须保持一致,否则报错(常见原因:初始值是 int32,主体函数却返回了 float32)。有一个例外:如果初始值是弱类型(Python 标量,或没指定 dtype 的 jnp.array(0)),scan 会先把它提升到输出的类型,不报错;
  • xs 可以是 PyTree,scan 会沿每个叶子的第 0 维切片;
  • scan 可以再被 grad、vmap 变换。
def running_mean_body(carry, x):
    new_carry = {'sum': carry['sum'] + x, 'n': carry['n'] + 1}
    y = new_carry['sum'] / new_carry['n']        # 每步输出:当前平均值
    return new_carry, y

xs = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
init = {'sum': jnp.array(0.0), 'n': jnp.array(0)}
final, means = lax.scan(running_mean_body, init, xs)
print("最终状态:", final)
print("每步平均:", means)
assert jnp.allclose(means, jnp.array([2.0, 3.0, 4.0, 5.0, 6.0]))

# carry 的 dtype 不一致会报错
def add_body(carry, x):
    return carry + x, None          # int 状态 + float 输入 → float

try:
    lax.scan(add_body, jnp.array(0, dtype=jnp.int32), xs)    # 显式 int32 初始状态
except TypeError as e:
    print("dtype 不一致 → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])

# 例外:弱类型(Python 标量、jnp.array(0) 这种未指定 dtype 的值)允许被提升
print("弱类型初始值 jnp.array(0):", lax.scan(add_body, jnp.array(0), xs)[0], "(未报错)")

# 形状不一致同样报错
try:
    lax.scan(lambda c, x: (jnp.stack([c, c]), None), jnp.array(0.0), xs)
except TypeError as e:
    print("形状不一致 → TypeError:", next(l for l in str(e).splitlines() if "input carry" in l)[:150])
最终状态: {'n': Array(5, dtype=int32, weak_type=True), 'sum': Array(30., dtype=float32)}
每步平均: [2. 3. 4. 5. 6.]
dtype 不一致 → TypeError: The input carry carry has type int32[] but the corresponding output carry component has type float32[], so the dtypes do not match.
弱类型初始值 jnp.array(0): 30.0 (未报错)
形状不一致 → TypeError: The input carry c has type float32[] but the corresponding output carry component has type float32[2], so the shapes do not match.

🔍 补充:随机数 key 本身就是一种需要显式传递的状态

🟢 必读

JAX 的 PRNG 是无状态的:随机函数只依赖传入的 key,同一个 key 永远得到同一个结果。所以 key 要像参数一样显式传入、传出,并且一个 key 只用一次:key, subkey = jax.random.split(key),用 subkey 采样,把 key 继续往下传;在 lax.scan 里就把 key 放进 carry。jax.random.key 与旧 PRNGKey 的区别见 第 1 章,split / fold_in / scan carry 等管理模式和常见陷阱的完整演示见 进阶 10「随机数与分片」§5–§6。


6.6 例子:简单的优化器状态

机器学习里的优化器(SGD、Adam 等)根据梯度一步步更新参数。很多优化器还要维护内部状态,比如动量向量,或者每个参数各自的自适应学习率。

最基本的梯度下降:

$$ \theta_{\text{new}} = \theta_{\text{old}} - \eta\, \nabla f(\theta_{\text{old}}) $$

其中 $\eta$ 是学习率。以最小化 $f(x) = (x-3)^2$ 为例,最小值在 $x=3$,梯度为 $f'(x) = 2(x-3)$。

def objective_function(x):
    return (x - 3.0) ** 2

grad_fn = jax.grad(objective_function)

def sgd_update(params, gradients, learning_rate):
    """纯函数:根据梯度返回新参数。"""
    return params - learning_rate * gradients

current_params = jnp.array(0.0)
learning_rate = 0.1
num_steps = 20

print(f"初始参数: {current_params:.4f}")
for step in range(num_steps):
    gradients = grad_fn(current_params)
    next_params = sgd_update(current_params, gradients, learning_rate)
    current_params = next_params          # 显式地把新状态接住
    if (step + 1) % 5 == 0:
        print(f"第 {step + 1:2d} 步: 参数 = {current_params:.4f}, 梯度 = {gradients:.4f}")
print(f"最终优化参数: {current_params:.4f}")
sgd_final = float(current_params)       # 记下来,稍后与动量法对比
初始参数: 0.0000
第  5 步: 参数 = 2.0170, 梯度 = -2.4576
第 10 步: 参数 = 2.6779, 梯度 = -0.8053
第 15 步: 参数 = 2.8944, 梯度 = -0.2639
第 20 步: 参数 = 2.9654, 梯度 = -0.0865
最终优化参数: 2.9654

sgd_update 是纯函数,"参数"这个状态被显式地传进去、再返回出来。

加入优化器状态:带动量的 SGD

动量法维护一个"速度"$v$。它把历史梯度累积起来,让更新更平滑,梯度方向一致时还能加速:

$$ v_{\text{new}} = \mu\, v_{\text{old}} + \eta\, \nabla f(\theta_{\text{old}}), \qquad \theta_{\text{new}} = \theta_{\text{old}} - v_{\text{new}} $$

其中 $\mu$ 是动量系数。现在状态有两部分(参数和速度),用 typing.NamedTuple 把它们打包(用字典 {'params': ..., 'velocity': ...} 也行)。NamedTuple 本身就是合法的 PyTree。

动量并不是在什么问题上都更快。它的优势在病态问题(各个方向曲率差别很大)上最明显。在 $f(x)=(x-3)^2$ 这种曲率为 2 的一维二次函数上,$\eta=0.1$ 时把动量取成 0.9,系统会欠阻尼,在最优点附近来回震荡。

⚠️ 注意:用 $\eta=0.1,\ \mu=0.9$ 演示动量法,效果反而不如普通 SGD:20 步后参数停在 4.0478,比普通 SGD 的 2.9654 离最优点 3 还远。把 $\mu$ 调到 0.5,20 步就能收敛到 2.9992。下面先运行 $\mu=0.9$ 的版本,再给出调参后的版本。

# ❌ 超参数不当:momentum=0.9 在这个问题上欠阻尼,20 步内不收敛
class OptimizerState(NamedTuple):
    params: jax.Array
    velocity: jax.Array

def momentum_update(state, gradients, learning_rate, momentum):
    """纯函数:接收旧的优化器状态,返回新的优化器状态。"""
    new_velocity = momentum * state.velocity + learning_rate * gradients
    new_params = state.params - new_velocity
    return OptimizerState(params=new_params, velocity=new_velocity)

initial_params = jnp.array(0.0)
initial_velocity = jnp.array(0.0)
current_state = OptimizerState(params=initial_params, velocity=initial_velocity)

learning_rate = 0.1
momentum_coeff = 0.9
num_steps = 20

print(f"初始状态: 参数 = {current_state.params:.4f}, 速度 = {current_state.velocity:.4f}")
for step in range(num_steps):
    gradients = grad_fn(current_state.params)
    current_state = momentum_update(current_state, gradients, learning_rate, momentum_coeff)
    if (step + 1) % 5 == 0:
        print(f"第 {step + 1:2d} 步: 参数 = {current_state.params:.4f}, "
              f"速度 = {current_state.velocity:.4f}, 梯度 = {gradients:.4f}")
print(f"最终优化参数: {current_state.params:.4f}")
momentum09_final = float(current_state.params)
print(f"实测:|动量 μ=0.9 − 3| = {abs(momentum09_final - 3):.4f}  >  |普通 SGD − 3| = {abs(sgd_final - 3):.4f}")
assert abs(momentum09_final - 3) > abs(sgd_final - 3)     # μ=0.9 的动量法反而不如普通 SGD
初始状态: 参数 = 0.0000, 速度 = 0.0000
第  5 步: 参数 = 4.7413, 速度 = -0.8155, 梯度 = 1.8516
第 10 步: 参数 = 2.9868, 速度 = 0.8358, 梯度 = 1.6452
第 15 步: 参数 = 1.9853, 速度 = -0.3751, 梯度 = -2.7795
第 20 步: 参数 = 4.0478, 速度 = -0.1091, 梯度 = 1.8774
最终优化参数: 4.0478
实测:|动量 μ=0.9 − 3| = 1.0478  >  |普通 SGD − 3| = 0.0346
# ✅ 修正:同样的 momentum_update,只把动量系数调到 0.5(学习率不变)
def run_momentum(mu, lr=0.1, steps=20):
    st = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
    for _ in range(steps):
        st = momentum_update(st, grad_fn(st.params), lr, mu)
    return float(st.params)

for mu_ in [0.0, 0.3, 0.5, 0.9]:
    print(f"momentum={mu_}: 20 步后参数 = {run_momentum(mu_):.4f}")

momentum05_final = run_momentum(0.5)
assert abs(momentum05_final - 3) < 1e-2                    # 调参后收敛
assert abs(momentum05_final - 3) < abs(sgd_final - 3)      # 并且优于普通 SGD
momentum=0.0: 20 步后参数 = 2.9654
momentum=0.3: 20 步后参数 = 2.9997
momentum=0.5: 20 步后参数 = 2.9992
momentum=0.9: 20 步后参数 = 4.0478

原因:在二次函数上,动量法相当于一个二阶线性系统。$\mu$ 太大时阻尼不够,参数会冲过最优点再折回来;$\mu$ 取个适中的值时收敛最快。实战中 0.9 是常用的默认动量,但往往要搭配更小的学习率。

和 JAX 变换的兼容性:momentum_update 是纯函数,输出只依赖输入,所以 jax.jit 能顺利地追踪和编译它。学习率、动量系数这类数值型超参数应该作为普通(动态)参数传入:它们只是标量,不改变程序结构,作为动态参数时任何取值都共用同一份编译结果。静态参数应该留给真正影响程序结构的东西(形状、循环次数、布尔开关、激活函数的选择等)。重新编译的触发条件总表见 第 2 章 2.3,怎么治理见 07 专题 1。

⚠️ 注意:有一种常见写法是 jax.jit(momentum_update, static_argnums=(2, 3)),把 learning_rate 和 momentum 设成静态参数,理由是"标成静态可以避免不必要的重新编译"。实际正好相反:静态参数的值是编译缓存键的一部分,每出现一个新值就重新编译一次。用 5 个值的学习率调度试一下:静态版本追踪了 5 次,动态版本只追踪 1 次。学习率不变时两者都只编译一次,代码本身也能运行;但这个习惯一碰上学习率调度,就会让每一步都重新编译。

# ❌ 错误写法:学习率和动量设为静态参数(配合学习率调度时每步都重新编译)
momentum_traces = []
def momentum_update_logged(state, gradients, learning_rate, momentum):
    momentum_traces.append(learning_rate)        # 只在追踪时执行,用来数追踪次数
    return momentum_update(state, gradients, learning_rate, momentum)

jitted_momentum_update = jax.jit(momentum_update_logged, static_argnums=(2, 3))

schedule = [0.1, 0.09, 0.08, 0.07, 0.06]   # 模拟学习率衰减
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- 正在使用 JIT 编译的更新运行(静态学习率)---")
for lr_t in schedule:
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"参数 = {current_state.params:.4f};追踪次数 = {len(momentum_traces)}")
assert len(momentum_traces) == 5
--- 正在使用 JIT 编译的更新运行(静态学习率)---
参数 = 2.7258;追踪次数 = 5
# ✅ 修正:学习率和动量作为普通(动态)参数传入
momentum_traces.clear()
jitted_momentum_update = jax.jit(momentum_update_logged)

current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
print("--- 正在使用 JIT 编译的更新运行(动态学习率)---")
for lr_t in schedule:
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), lr_t, 0.5)
print(f"参数 = {current_state.params:.4f};追踪次数 = {len(momentum_traces)}")
assert len(momentum_traces) == 1

# 恒定学习率、跑满 20 步:结果与未编译版本一致
current_state = OptimizerState(params=jnp.array(0.0), velocity=jnp.array(0.0))
for step in range(num_steps):
    current_state = jitted_momentum_update(current_state, grad_fn(current_state.params), 0.1, 0.5)
print(f"最终优化参数 (JIT 编译, μ=0.5): {current_state.params:.4f}")
assert jnp.allclose(current_state.params, momentum05_final)
--- 正在使用 JIT 编译的更新运行(动态学习率)---
参数 = 2.7258;追踪次数 = 1
最终优化参数 (JIT 编译, μ=0.5): 2.9992

小结:显式状态传递加上 PyTree 结构(这里是 NamedTuple),就能写出清楚、好管理、和 jit 与 grad 都兼容的训练循环。这是构建更复杂的模型和训练流程的基础。

🔍 补充:动量公式的两种写法

⚪ 选读:对照 PyTorch / Optax 或论文里的动量公式时读。

Sutskever 等人的写法(上面用的就是这种)把学习率放进速度里:$v \leftarrow \mu v + \eta g,\ \theta \leftarrow \theta - v$。PyTorch 和 Optax 的 sgd(momentum=...) 用的是 $v \leftarrow \mu v + g,\ \theta \leftarrow \theta - \eta v$。学习率恒定时两者完全等价($v_{\text{Sutskever}} = \eta\, v_{\text{PyTorch}}$);学习率随时间变化时,两者行为不同。迁移代码或对照论文时要注意。

# 验证两种动量公式在学习率恒定时等价
g_fn = jax.grad(lambda x: (x - 3.0) ** 2)
eta, mu = 0.1, 0.9
x1, v1 = 0.0, 0.0      # Sutskever 写法
x2, v2 = 0.0, 0.0      # PyTorch / Optax 写法
for _ in range(20):
    g1 = g_fn(x1); v1 = mu * v1 + eta * g1; x1 = x1 - v1
    g2 = g_fn(x2); v2 = mu * v2 + g2;       x2 = x2 - eta * v2
print(f"Sutskever 写法: {float(x1):.6f}   PyTorch/Optax 写法: {float(x2):.6f}")
assert jnp.allclose(x1, x2, atol=1e-5)
Sutskever 写法: 4.047758   PyTorch/Optax 写法: 4.047758

🔍 补充:通用的 PyTree 优化器(动量 SGD 与 Adam)+ 完整训练循环

⚪ 选读:想自己实现对任意 PyTree 适用的优化器、或看懂 Optax 的接口时读;补充练习 3、4 会用到这里的代码。

前面的优化器只处理一个标量参数。真实场景里参数是一棵 PyTree,优化器状态也应该是结构和它对应的 PyTree。下面仿照 Optax 的接口,写两个对任意 PyTree 都适用的优化器:

  • init(params) -> opt_state:根据参数结构创建初始状态;
  • update(grads, opt_state, params) -> (updates, new_opt_state):返回参数增量和新状态;
  • apply_updates(params, updates):把增量加到参数上。

动量 SGD(Optax/PyTorch 写法)的状态是速度 $v$(Optax 里叫 trace):

$$ v_t = \mu\, v_{t-1} + g_t, \qquad \Delta\theta_t = -\eta\, v_t, \qquad v_0 = 0 . $$

Adam 的状态包括步数 $t$、一阶矩 $m$ 和二阶矩 $v$:

$$ \begin{aligned} m_t &= \beta_1 m_{t-1} + (1-\beta_1)\, g_t, & v_t &= \beta_2 v_{t-1} + (1-\beta_2)\, g_t^2, & m_0 = v_0 &= 0,\\ \hat m_t &= \frac{m_t}{1-\beta_1^{\,t}}, & \hat v_t &= \frac{v_t}{1-\beta_2^{\,t}}, & t &= 1, 2, \dots\\ \Delta\theta_t &= -\eta\,\frac{\hat m_t}{\sqrt{\hat v_t + \epsilon_{\text{root}}}+\epsilon}. & & & & \end{aligned} $$

偏差修正是干什么的:$m_0=v_0=0$ 会让早期的矩估计偏向 0(第 1 步时 $m_1=(1-\beta_1)g_1$,只有真实梯度的 10%)。除以 $1-\beta^t$ 正好抵消这个偏差:第 1 步 $\hat m_1 = g_1$,$\hat v_1 = g_1^2$,所以第一步的更新幅度大约是 $\eta$,和梯度大小无关。

这两个优化器分别对应 Optax 的 trace + scale_by_learning_rate 和 scale_by_adam + scale_by_learning_rate。和 Optax 的逐项对照、可组合的 chain 写法,以及和 optax 的实际对拍(本环境装有 optax 0.2.9),见 07 专题 5「手写优化器」。下面先拿 NumPy 手算的参考实现,验证这里的实现是对的。

最后用一个小型 MLP 拟合 $y=\sin(x)$,把"参数 + 优化器状态"作为 lax.scan 的 carry,整个训练过程编译成一个程序。

# ---------- 通用优化器:接口仿照 Optax ----------
class Optimizer(NamedTuple):
    init: callable
    update: callable

def apply_updates(params, updates):
    return jax.tree.map(lambda p, u: p + u, params, updates)

class MomentumState(NamedTuple):
    velocity: dict

def sgd_momentum(lr, momentum=0.9):
    def init(params):
        return MomentumState(velocity=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        v = jax.tree.map(lambda v, g: momentum * v + g, state.velocity, grads)
        updates = jax.tree.map(lambda v: -lr * v, v)
        return updates, MomentumState(velocity=v)
    return Optimizer(init, update)

class AdamState(NamedTuple):
    count: jax.Array
    mu: dict
    nu: dict

def adam(lr, b1=0.9, b2=0.999, eps=1e-8):
    def init(params):
        # 注意:mu 和 nu 必须是两份独立的数组,不能共用同一个 zeros 对象(见 donate_argnums 补充)
        return AdamState(count=jnp.zeros([], jnp.int32),
                         mu=jax.tree.map(jnp.zeros_like, params),
                         nu=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        t = state.count + 1
        mu = jax.tree.map(lambda m, g: b1 * m + (1 - b1) * g, state.mu, grads)
        nu = jax.tree.map(lambda v, g: b2 * v + (1 - b2) * g ** 2, state.nu, grads)
        bc1 = 1 - b1 ** t                       # 偏差修正
        bc2 = 1 - b2 ** t
        updates = jax.tree.map(lambda m, v: -lr * (m / bc1) / (jnp.sqrt(v / bc2) + eps), mu, nu)
        return updates, AdamState(count=t, mu=mu, nu=nu)
    return Optimizer(init, update)

# ---------- 模型与数据 ----------
def init_mlp(key, sizes=(1, 32, 32, 1)):
    params = []
    keys = jax.random.split(key, len(sizes) - 1)
    for k, (n_in, n_out) in zip(keys, zip(sizes[:-1], sizes[1:])):
        params.append({'w': jax.random.normal(k, (n_in, n_out)) * jnp.sqrt(2.0 / n_in),
                       'b': jnp.zeros(n_out)})
    return params

def mlp(params, x):
    for layer in params[:-1]:
        x = jnp.tanh(x @ layer['w'] + layer['b'])
    return x @ params[-1]['w'] + params[-1]['b']

def loss_fn(params, x, y):
    return jnp.mean((mlp(params, x) - y) ** 2)

key = jax.random.key(0)
key, k_data, k_init = jax.random.split(key, 3)
X = jax.random.uniform(k_data, (256, 1), minval=-3.0, maxval=3.0)
Y = jnp.sin(X)
params0 = init_mlp(k_init)
print("参数结构:", jax.tree.map(jnp.shape, params0))
参数结构: [{'b': (32,), 'w': (1, 32)}, {'b': (32,), 'w': (32, 32)}, {'b': (1,), 'w': (32, 1)}]
import numpy as np

# 在一个二次函数上自证:手写的 Adam / 动量 SGD 与 NumPy 手算参考实现逐步一致
A = np.array([1.0, 4.0, 0.25], dtype=np.float32)        # 各维曲率不同
C = np.array([3.0, -1.0, 2.0], dtype=np.float32)        # 最优点

def quad_loss(p):                                        # f(x) = 1/2 Σ a_i (x_i - c_i)^2
    return 0.5 * jnp.sum(A * (p['x'] - C) ** 2)

def numpy_adam_reference(x, steps, lr=0.1, b1=0.9, b2=0.999, eps=1e-8):
    m = np.zeros_like(x); v = np.zeros_like(x); traj = []
    for t in range(1, steps + 1):
        g = A * (x - C)                                  # 手算梯度
        m = b1 * m + (1 - b1) * g
        v = b2 * v + (1 - b2) * g ** 2
        m_hat = m / (1 - b1 ** t); v_hat = v / (1 - b2 ** t)
        x = x - lr * m_hat / (np.sqrt(v_hat) + eps)
        traj.append(x.copy())
    return traj

def numpy_momentum_reference(x, steps, lr=0.1, mu=0.9):
    v = np.zeros_like(x); traj = []
    for _ in range(steps):
        g = A * (x - C)
        v = mu * v + g
        x = x - lr * v
        traj.append(x.copy())
    return traj

def run_jax_optimizer(opt, steps):
    p = {'x': jnp.zeros(3)}
    state = opt.init(p); traj = []
    for _ in range(steps):
        g = jax.grad(quad_loss)(p)
        updates, state = opt.update(g, state, p)
        p = apply_updates(p, updates)
        traj.append(np.asarray(p['x']))
    return traj, state

x0 = np.zeros(3, dtype=np.float32)
adam_traj, adam_state = run_jax_optimizer(adam(0.1), 3)
for t, (ours, ref) in enumerate(zip(adam_traj, numpy_adam_reference(x0, 3)), start=1):
    print(f"Adam 第 {t} 步  JAX: {ours}  NumPy: {ref}")
    assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6)   # 约 1e-6 的差异来自 float32 计算 1-β2^t 的舍入
assert int(adam_state.count) == 3
# 偏差修正的效果:第 1 步每个坐标都移动约 lr=0.1,与梯度大小无关
assert np.allclose(np.abs(adam_traj[0]), 0.1, atol=1e-6)

mom_traj, _ = run_jax_optimizer(sgd_momentum(0.1, momentum=0.9), 3)
for ours, ref in zip(mom_traj, numpy_momentum_reference(x0, 3)):
    assert np.allclose(ours, ref, rtol=1e-5, atol=1e-6)
print("Adam 前 3 步、动量 SGD 前 3 步均与 NumPy 参考实现 allclose ✓")
Adam 第 1 步  JAX: [ 0.09999934 -0.09999935  0.09999935]  NumPy: [ 0.1 -0.1  0.1]
Adam 第 2 步  JAX: [ 0.19989564 -0.19958612  0.19983187]  NumPy: [ 0.19989732 -0.19958776  0.19983351]
Adam 第 3 步  JAX: [ 0.29961547 -0.29841074  0.29937363]  NumPy: [ 0.2996185 -0.2984137  0.2993766]
Adam 前 3 步、动量 SGD 前 3 步均与 NumPy 参考实现 allclose ✓
def make_train(optimizer, num_steps):
    """返回 jit 编译的训练函数。参数、数据都作为函数参数传入(不靠闭包捕获大数组);
    (params, opt_state) 作为 scan 的 carry。"""
    @jax.jit
    def train(params, x, y):
        def train_step(carry, _):
            params, opt_state = carry
            loss, grads = jax.value_and_grad(loss_fn)(params, x, y)   # x, y 是 train 的参数(tracer)
            updates, opt_state = optimizer.update(grads, opt_state, params)
            params = apply_updates(params, updates)
            return (params, opt_state), loss          # carry=状态;y=每步损失

        opt_state = optimizer.init(params)
        (params, opt_state), losses = lax.scan(train_step, (params, opt_state), None, length=num_steps)
        return params, opt_state, losses
    return train

results = {}
for name, opt in [("SGD", sgd_momentum(0.01, momentum=0.0)),
                  ("SGD+动量", sgd_momentum(0.01, momentum=0.9)),
                  ("Adam", adam(0.01))]:
    p, s, losses = make_train(opt, 1000)(params0, X, Y)
    results[name] = losses
    print(f"{name:8s} 初始损失 {losses[0]:.4f} → 最终损失 {losses[-1]:.5f}")

print("Adam 状态中的步数:", s.count, ";一阶矩结构与参数一致:",
      jax.tree.structure(s.mu) == jax.tree.structure(p))
SGD      初始损失 0.2270 → 最终损失 0.00549
SGD+动量   初始损失 0.2270 → 最终损失 0.00087
Adam     初始损失 0.2270 → 最终损失 0.00003
Adam 状态中的步数: 1000 ;一阶矩结构与参数一致: True
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['PingFang SC', 'Heiti SC', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

fig, ax = plt.subplots(figsize=(6, 3.5))
for name, losses in results.items():
    ax.plot(losses, label=name)
ax.set_yscale('log'); ax.set_xlabel('步数'); ax.set_ylabel('MSE 损失(对数刻度)')
ax.set_title('同一模型、三种优化器状态'); ax.legend()
plt.tight_layout(); plt.show()
No description has been provided for this image

🔍 补充:donate_argnums —— 让旧状态的内存被新状态复用

⚪ 选读:显存吃紧(GPU/TPU 上训练大模型)时读,CPU 上收益很小。

原理。 显式状态传递有一个隐藏的成本:执行 new_state = step(state) 时,旧状态和新状态同时占着内存。JAX 数组不可变,XLA 默认得为输出另外分配缓冲区。jax.jit(f, donate_argnums=...)(或 donate_argnames=)是在告诉 XLA:"这些输入调用完我就不用了。"这样 XLA 就可以把输入的缓冲区直接拿来当作形状、dtype 相同的输出缓冲区(输入/输出别名,aliasing),相当于在编译后的程序里做原地更新。

什么时候有用。 主要是省 GPU/TPU 显存。训练大模型时,参数加上 Adam 的两份矩,大约是参数量的 3 倍。如果不捐赠,一次训练步的峰值显存还要再多出一整份"新状态",可能直接 OOM。捐赠之后,旧状态的缓冲区被新状态复用,峰值显存明显下降,也省掉了分配和拷贝。CPU 上内存一般比较宽裕,收益不大。

在 CPU 上实际运行的结果(JAX 0.12)。

  • 捐赠会生效:调用之后,被捐赠的数组 is_deleted() 为 True,再访问会抛出 RuntimeError: Array has been deleted;
  • 如果捐赠用不上(输出里没有形状、dtype 匹配的数组),不会报错,只给一个 UserWarning: Some donated buffers were not usable。

同一个 buffer 捐两次的坑。 如果被捐赠的 PyTree 里同一个数组对象出现了两次,捐赠时会报 INVALID_ARGUMENT: Attempt to donate the same buffer twice。下面的训练步里抛出的异常类型是 ValueError;在更简单的调用里也见过 JaxRuntimeError,所以捕获时别只认一种类型。最容易出这个问题的地方是优化器的 init:图省事写成 zeros = jax.tree.map(jnp.zeros_like, params); AdamState(mu=zeros, nu=zeros),不捐赠时一切正常,一捐赠就报错。修法是让 mu、nu 各自调用一次 jax.tree.map(jnp.zeros_like, params),前面的 adam 就是这么写的。写这份教程时就真的踩到过这个坑。

实战规则:训练步写成 state = step(state, batch),并捐赠 state(参数 + 优化器状态),调用完立刻用返回值覆盖旧的引用;每一步都要复用的数据不要捐赠;init 里每个状态缓冲区都要单独创建。

opt = adam(1e-2)

@partial(jax.jit, donate_argnums=(0, 1))     # 捐赠 params 和 opt_state;数据 x, y 不捐赠
def train_step_donated(params, opt_state, x, y):
    loss, grads = jax.value_and_grad(loss_fn)(params, x, y)
    updates, opt_state = opt.update(grads, opt_state, params)
    return apply_updates(params, updates), opt_state, loss

params = jax.tree.map(jnp.copy, params0)
opt_state = opt.init(params)
old_leaf = params[0]['w']

params, opt_state, loss = train_step_donated(params, opt_state, X, Y)   # 正确:覆盖旧引用
print("旧参数缓冲区已被删除:", old_leaf.is_deleted())
assert old_leaf.is_deleted()
deleted_error = None
try:
    print(old_leaf + 1)
except RuntimeError as e:
    deleted_error = e
    print("再访问旧数组 → RuntimeError:", e)
assert deleted_error is not None
assert not X.is_deleted()                    # 没有捐赠的数据仍然可用

for _ in range(200):
    params, opt_state, loss = train_step_donated(params, opt_state, X, Y)
print(f"200 步后损失:{loss:.5f}")

# 无法利用的捐赠:输出里没有同形状的数组 → 警告而非报错
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    jax.jit(lambda a: a.sum(), donate_argnums=0)(jnp.ones(10))
msgs = [str(m.message) for m in w]
print("警告:", [m[:60] for m in msgs])
assert any("donated buffers were not usable" in m for m in msgs)
旧参数缓冲区已被删除: True
再访问旧数组 → RuntimeError: Array has been deleted with shape=float32[1,32].
200 步后损失:0.00209
警告: ['Some donated buffers were not usable: float32[10].\nSee an ex']
# ❌ 错误写法(同一 buffer 捐两次):Adam 的 mu 和 nu 共用同一个 zeros 对象
def adam_init_shared(params):
    zeros = jax.tree.map(jnp.zeros_like, params)
    return AdamState(count=jnp.zeros([], jnp.int32), mu=zeros, nu=zeros)   # mu 与 nu 是同一批数组

params = jax.tree.map(jnp.copy, params0)
bad_opt_state = adam_init_shared(params)
double_donate_error = None
try:
    train_step_donated(params, bad_opt_state, X, Y)
except Exception as e:
    double_donate_error = e
    print(f"实测结果 → {type(e).__name__}: {str(e)[:120]}")
assert double_donate_error is not None and "donate the same buffer twice" in str(double_donate_error)
# (XLA 还会在 stderr 打印一行同样内容的 C++ 日志,可以忽略)
实测结果 → ValueError: INVALID_ARGUMENT: Attempt to donate the same buffer twice in Execute() (flattened argument 13, replica 0, partition 0, f
E1114 20:52:28.598210 6824452 common_pjrt_client.cc:3073] ExecutePrepareWithOomRetries failed: INVALID_ARGUMENT: Attempt to donate the same buffer twice in Execute() (flattened argument 13, replica 0, partition 0, first use: 7). Toy example for this bug: `f(donate(a), donate(a))`.
=== Source Location Trace: === 
external/xla+/xla/util.h:304
external/xla+/xla/pjrt/common_pjrt_client.cc:1997
external/xla+/xla/pjrt/common_pjrt_client.cc:2833

# ✅ 修正:mu 和 nu 各自创建(即前面 adam(...).init 的写法)
params = jax.tree.map(jnp.copy, params0)
good_opt_state = opt.init(params)
assert good_opt_state.mu[0]['w'] is not good_opt_state.nu[0]['w']
params, good_opt_state, loss = train_step_donated(params, good_opt_state, X, Y)
print(f"独立缓冲区 + 捐赠:正常运行,损失 = {loss:.5f}")
独立缓冲区 + 捐赠:正常运行,损失 = 0.22700

6.7 将状态管理与变换结合

JAX 用函数式的方式管理状态:把状态作为参数传进去,返回更新后的状态,这样函数就保持了纯粹。能不能和变换配合,关键就在这一点。本节逐个演示这种模式怎样和 jit、grad、vmap、pmap 一起工作。

状态管理与 JIT 编译

计数器的状态用字典 {'count': ...} 表示(这本身就是一个 PyTree)。分别手动调用两次、jit 之后调用两次,结果应该完全一样。

比较两个 PyTree 是否逐叶子相等,用 jax.tree.all(jax.tree.map(jnp.array_equal, a, b))。

⚠️ 注意:旧代码里常见 jax.tree_util.tree_all(jax.tree_map(...)) 这种比较写法。在 JAX 0.12 中,顶层别名 jax.tree_map 已经被移除,会报 AttributeError: module 'jax' has no attribute 'tree_map'。请改用 jax.tree.map(迁移表见 6.4)。

def stateful_counter(count_state, increment):
    """接收计数器状态和增量,返回新状态。"""
    new_count = count_state['count'] + increment
    return {'count': new_count}

initial_state = {'count': jnp.array(0)}

# 不使用 jit
state = stateful_counter(initial_state, 1)
state = stateful_counter(state, 2)
print("手动调用结果:", state)

# 使用 jit
jit_counter = jax.jit(stateful_counter)
jit_state = jit_counter(initial_state, 1)
jit_state = jit_counter(jit_state, 2)
print("JIT 调用结果:", jit_state)
手动调用结果: {'count': Array(3, dtype=int32, weak_type=True)}
JIT 调用结果: {'count': Array(3, dtype=int32, weak_type=True)}
# ❌ 旧写法(过时):顶层别名 jax.tree_map 在 JAX 0.12 中已移除
tree_map_error = None
try:
    assert jax.tree_util.tree_all(jax.tree_map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
except AttributeError as e:
    tree_map_error = e
    print("实测结果 → AttributeError:", e)
assert tree_map_error is not None
实测结果 → AttributeError: module 'jax' has no attribute 'tree_map'
# ✅ 推荐写法
assert jax.tree.all(jax.tree.map(lambda x, y: jnp.array_equal(x, y), state, jit_state))
print("jax.tree.map 断言通过:两种结果一致")
jax.tree.map 断言通过:两种结果一致

要点:jit 追踪的结果取决于状态的结构,以及参数的类型和形状。只要结构不变,就能复用编译缓存。

提示:如果两次调用之间状态的结构变了(比如给字典加了一个键),就会触发重新编译。

traces = []
@jax.jit
def bump(state):
    traces.append(jax.tree.structure(state))     # 追踪时记录
    return jax.tree.map(lambda x: x + 1, state)

bump({'count': jnp.array(0)})
bump({'count': jnp.array(5)})                     # 结构相同:命中缓存
bump({'count': jnp.array(0), 'steps': jnp.array(0)})   # 新增键:重新追踪
print("追踪次数:", len(traces))
for t in traces: print("  ", t)
assert len(traces) == 2
追踪次数: 2
   PyTreeDef({'count': *})
   PyTreeDef({'count': *, 'steps': *})

状态管理与自动微分

以线性模型 $\hat y = w x + b$ 为例,参数字典 params 就是状态。

  • predict_and_update(params, x):返回预测值,以及(未修改的)参数状态,演示"状态输入、状态输出"的函数签名;
  • loss_fn(params, x, y_target):均方误差 $L = \frac{1}{n}\sum_i (\hat y_i - y_i)^2$,只返回标量损失,这样才能被 grad 求导;
  • jax.grad(loss_fn, argnums=0):对第 0 个参数(整个 params PyTree)求梯度,结果与 params 结构相同;
  • jax.value_and_grad:一次同时得到损失值和梯度。
def predict_and_update(params, x):
    """前向预测;参数状态原样返回(此处不修改)。"""
    pred = params['w'] * x + params['b']
    return pred, params

def loss_fn_linear(params, x, y_target):
    pred, _ = predict_and_update(params, x)
    return jnp.mean((pred - y_target) ** 2)

# 数据:理想参数为 w=2.5, b=1.0
x_data = jnp.array([1.0, 2.0, 3.0])
y_data = jnp.array([3.5, 5.5, 7.5])
params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}

grad_fn_linear = jax.grad(loss_fn_linear, argnums=0)
grads = grad_fn_linear(params, x_data, y_data)
print("梯度(与 params 同结构):", grads)

loss_val, grads = jax.value_and_grad(loss_fn_linear)(params, x_data, y_data)
print("损失:", loss_val, " 梯度:", grads)
梯度(与 params 同结构): {'b': Array(-1., dtype=float32, weak_type=True), 'w': Array(-2., dtype=float32, weak_type=True)}
损失: 0.25  梯度: {'b': Array(-1., dtype=float32, weak_type=True), 'w': Array(-2., dtype=float32, weak_type=True)}

把"求梯度 + 更新参数"组合成一个训练步:输入旧参数,输出损失和新参数,这就是一次纯函数式的状态更新。逐叶子更新用 jax.tree.map。

⚠️ 注意:如果 training_step 里用 jax.tree_map 更新参数,在 JAX 0.12 中会报 AttributeError(和上面比较代码的问题一样),所以这里直接写成 jax.tree.map。

def training_step(params, x, y_target, learning_rate):
    loss, grads = jax.value_and_grad(loss_fn_linear)(params, x, y_target)
    updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)
    return loss, updated_params

learning_rate = 0.1
current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
    loss, current_params = training_step(current_params, x_data, y_data, learning_rate)
    print(f"第 {i + 1} 步: 损失 = {loss:.4f}, 参数 = {jax.tree.map(float, current_params)}")
# 手算核对:第 1 步梯度为 w:-2, b:-1,学习率 0.1 → w=2.2, b=1.1
assert jnp.allclose(current_params['w'], 2.1733334) and jnp.allclose(current_params['b'], 1.1)
第 1 步: 损失 = 0.2500, 参数 = {'b': 1.100000023841858, 'w': 2.200000047683716}
第 2 步: 损失 = 0.0267, 参数 = {'b': 1.100000023841858, 'w': 2.1733334064483643}

状态管理与 vmap

vmap 可以对"一批独立的状态"并行执行同一个有状态函数。下面同时维护 3 个计数器(初始都是 0,增量分别为 1、5、10),然后再做第二轮增量 [2, 3, 4]。

in_axes=({'count': 0}, 0) 的含义:状态字典中 'count' 叶子沿第 0 轴映射,增量也沿第 0 轴映射。in_axes 本身可以写成与输入同结构的 PyTree。

batched_counter = jax.vmap(stateful_counter, in_axes=({'count': 0}, 0))

batch_state = {'count': jnp.array([0, 0, 0])}
batch_state = batched_counter(batch_state, jnp.array([1, 5, 10]))
print("第一轮后:", batch_state)
batch_state = batched_counter(batch_state, jnp.array([2, 3, 4]))
print("第二轮后:", batch_state)
第一轮后: {'count': Array([ 1,  5, 10], dtype=int32)}
第二轮后: {'count': Array([ 3,  8, 14], dtype=int32)}

另一种常见情况是参数在批次之间共享,只有数据带批维度。in_axes=(None, 0) 表示第一个参数(params)不映射,x 沿第 0 轴映射。out_axes 默认是 0,所以返回的参数状态会被"复制"成带批维度的版本。

batched_predict = jax.vmap(predict_and_update, in_axes=(None, 0))
preds, out_params = batched_predict(current_params, x_data)
print("批量预测:", preds)
print("输出的参数状态(被沿第 0 轴复制):", out_params)
批量预测: [3.2733335 5.4466667 7.6200004]
输出的参数状态(被沿第 0 轴复制): {'b': Array([1.1, 1.1, 1.1], dtype=float32, weak_type=True), 'w': Array([2.1733334, 2.1733334, 2.1733334], dtype=float32, weak_type=True)}

🔍 补充:用 out_axes 避免共享状态被无谓复制

🟢 必读

看一下上面的输出:没有被批处理的 params,在默认的 out_axes=0 下被广播成了长度为 3 的数组,也就是每个样本一份拷贝。参数一多,这既浪费内存,也会让后面的代码误以为它是"每个样本各不相同的参数"。对于不随批次变化的输出,指定 out_axes=(0, None) 就能原样返回(前提是这个输出确实和批维度无关,否则会报错)。

另外,如果损失函数既要返回标量损失,又要返回更新后的状态(比如 BatchNorm 的滑动统计量),就用 jax.value_and_grad(f, has_aux=True):f 返回 (loss, aux),只对 loss 求导,aux 原样带出来。这是"在 grad 里携带状态"的标准做法。

batched_predict2 = jax.vmap(predict_and_update, in_axes=(None, 0), out_axes=(0, None))
preds2, out_params2 = batched_predict2(current_params, x_data)
print("out_axes=(0, None) 的参数输出:", out_params2)

# has_aux:损失之外再带出一份"状态"
def loss_with_stats(params, x, y, stats):
    pred = params['w'] * x + params['b']
    new_stats = {'pred_mean': 0.9 * stats['pred_mean'] + 0.1 * pred.mean(),   # 滑动平均统计量
                 'calls': stats['calls'] + 1}
    return jnp.mean((pred - y) ** 2), new_stats

stats = {'pred_mean': jnp.array(0.0), 'calls': jnp.array(0)}
(loss, stats), grads = jax.value_and_grad(loss_with_stats, has_aux=True)(current_params, x_data, y_data, stats)
print("损失:", loss, "\n新统计量:", stats, "\n梯度:", grads)
out_axes=(0, None) 的参数输出: {'b': Array(1.1, dtype=float32, weak_type=True), 'w': Array(2.1733334, dtype=float32, weak_type=True)}
损失: 0.022874068 
新统计量: {'calls': Array(1, dtype=int32, weak_type=True), 'pred_mean': Array(0.5446667, dtype=float32)} 
梯度: {'b': Array(-0.10666626, dtype=float32, weak_type=True), 'w': Array(0.0177787, dtype=float32, weak_type=True)}

状态管理与 pmap

多设备并行时,状态有两种常见的放法:

  • 复制状态:模型参数等在每个设备上各放一份完整副本(对应 in_axes=None,或者输入时就沿设备维度堆叠复制);
  • 分片状态:和数据相关的状态按设备切开(in_axes=0)。

并行算完梯度后,要用 lax.psum、lax.pmean 等集体操作在设备之间聚合,让各设备上的参数保持一致。细节见 pmap 那一章。

🔍 补充:pmap 在当前 JAX 中还能用,但官方推荐的新写法是 jax.jit 配合 jax.sharding(NamedSharding + PartitionSpec)做自动并行,或者用 jax.shard_map 按设备显式编程。对状态来说,"复制 / 分片"就对应 PartitionSpec()(全部复制)和 PartitionSpec('data')(按数据轴切分)。本机只有 1 个 CPU 设备,这里只讲概念;模拟 8 个设备的完整演示和 pmap → shard_map 迁移要点见 第 5 章。

组合多个变换

training_step 是纯函数,可以直接 jit。第二次调用用的是编译缓存,所以更快。之后还可以继续用 vmap 包一层(比如同时训练多组超参数),或者接入 pmap / 分片并行。

总结:显式状态传递 + PyTree,让有状态的计算能和 jit、grad、vmap、pmap 顺畅地配合。

import time
jit_training_step = jax.jit(training_step)

current_params = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
for i in range(2):
    t0 = time.perf_counter()
    loss, current_params = jit_training_step(current_params, x_data, y_data, learning_rate)
    loss.block_until_ready()                     # 计时前等待异步计算完成
    dt = (time.perf_counter() - t0) * 1e3
    print(f"第 {i + 1} 步: 损失 = {loss:.4f}, 参数 = {jax.tree.map(float, current_params)}, 耗时 {dt:.2f} ms")
print("(第 1 次包含追踪+编译,第 2 次直接使用缓存)")

# 进一步组合:vmap 同时用 4 个不同学习率各训练一步
lrs = jnp.array([0.01, 0.05, 0.1, 0.2])
init = {'w': jnp.array(2.0), 'b': jnp.array(1.0)}
losses, new_params = jax.jit(jax.vmap(training_step, in_axes=(None, None, None, 0)))(init, x_data, y_data, lrs)
print("4 个学习率各走一步后的 w:", new_params['w'])
第 1 步: 损失 = 0.2500, 参数 = {'b': 1.100000023841858, 'w': 2.200000047683716}, 耗时 25.95 ms
第 2 步: 损失 = 0.0267, 参数 = {'b': 1.100000023841858, 'w': 2.1733334064483643}, 耗时 0.23 ms
(第 1 次包含追踪+编译,第 2 次直接使用缓存)
4 个学习率各走一步后的 w: [2.02 2.1  2.2  2.4 ]

6.8 实践:实现有状态函数

下面三个练习都是同一个模式:纯函数接收当前状态,返回新状态(以及可选的辅助输出)。

练习 1:用 JIT 实现有状态计数器

定义 update_counter(count, increment),返回 (new_count, increment),也就是新状态加一个辅助输出。用 jax.jit 包装后循环 5 次,每次传入当前计数和循环索引 i 作为增量。

预期输出:初始计数 0;第 1~5 步的新计数分别是 1~5,返回的增量分别是 0~4。也就是说,这里的计数每次固定加 1,传进去的增量 i 只是原样作为辅助输出返回,用来演示"新状态 + 辅助输出"这种返回形式。

def update_counter(count, increment):
    """计数加 1,并把本次传入的增量作为辅助输出返回。"""
    new_count = count + 1
    return new_count, increment

jit_update_counter = jax.jit(update_counter)

count = 0
print("初始计数:", count)
for i in range(5):
    count, returned_inc = jit_update_counter(count, i)
    print(f"步骤 {i + 1}: 新计数 = {count}, 返回的增量 = {returned_inc}")
初始计数: 0
步骤 1: 新计数 = 1, 返回的增量 = 0
步骤 2: 新计数 = 2, 返回的增量 = 1
步骤 3: 新计数 = 3, 返回的增量 = 2
步骤 4: 新计数 = 4, 返回的增量 = 3
步骤 5: 新计数 = 5, 返回的增量 = 4

练习 2:计算简单移动平均

用元组 (current_sum, count) 作为状态。update_moving_average(state, new_value) 返回 (new_state, current_average)。

预期输出:对数据 [2, 4, 6, 8, 10],平均值依次是 2.00、3.00、4.00、5.00、6.00;状态依次是 (2.0, 1)、(6.0, 2)、(12.0, 3)、(20.0, 4)、(30.0, 5)。JIT 版本可做可不做。

初始状态要用确定 dtype 的数组,比如 (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32)),让它和更新后的状态类型完全一致。如果用 Python 标量 (0.0, 0),JAX 会把它当作弱类型(weak type)。第一次调用后,new_sum = 0.0 + float32 数组 变成了强类型的 float32,第二次调用时输入签名就变了,jit 会再追踪编译一次。在大模型里,这意味着几十秒甚至几分钟的额外编译时间,而且不报错,很难发现。

⚠️ 注意:如果用 (0.0, 0) 作初始状态,直接跑 JIT 版本,处理 5 个数据会追踪 2 次;改用确定 dtype 的数组后只追踪 1 次。两种写法的数值结果相同。

def update_moving_average(state, new_value):
    current_sum, count = state
    new_sum = current_sum + new_value
    new_count = count + 1
    current_average = new_sum / new_count
    return (new_sum, new_count), current_average

data = jnp.array([2.0, 4.0, 6.0, 8.0, 10.0])
state = (0.0, 0)                     # 不加 jit 时,Python 标量初始状态没有问题
averages = []
for v in data:
    state, avg = update_moving_average(state, v)
    averages.append(float(avg))
    print(f"新值 {float(v):5.1f} → 平均值 {float(avg):.2f}, 状态 = ({float(state[0])}, {int(state[1])})")
assert averages == [2.0, 3.0, 4.0, 5.0, 6.0]
新值   2.0 → 平均值 2.00, 状态 = (2.0, 1)
新值   4.0 → 平均值 3.00, 状态 = (6.0, 2)
新值   6.0 → 平均值 4.00, 状态 = (12.0, 3)
新值   8.0 → 平均值 5.00, 状态 = (20.0, 4)
新值  10.0 → 平均值 6.00, 状态 = (30.0, 5)
# ❌ 不推荐:JIT 版本用 Python 标量 (0.0, 0) 作初始状态,会多编译一次
ma_traces = []
def update_moving_average_counted(state, v):
    ma_traces.append(1)                  # 只在追踪时执行,用来数追踪次数
    return update_moving_average(state, v)

jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (0.0, 0)
for v in data:
    state, avg = jit_update_moving_average(state, v)
print("JIT 版本最终平均值:", avg, "| 追踪次数 =", len(ma_traces))
assert len(ma_traces) == 2 and float(avg) == 6.0
JIT 版本最终平均值: 6.0 | 追踪次数 = 2
# ✅ 修正:初始状态用与更新后一致的 dtype
ma_traces.clear()
jit_update_moving_average = jax.jit(update_moving_average_counted)
state = (jnp.array(0.0, jnp.float32), jnp.array(0, jnp.int32))
for v in data:
    state, avg = jit_update_moving_average(state, v)
print("JIT 版本最终平均值:", avg, "| 追踪次数 =", len(ma_traces))
assert len(ma_traces) == 1 and float(avg) == 6.0
JIT 版本最终平均值: 6.0 | 追踪次数 = 1

原因:jit 的缓存键包括每个输入的形状、dtype 和弱类型标记。Python 标量是弱类型,和强类型的 float32 数组算不同的签名。lax.scan 在这点上更严格:强类型的 carry 前后 dtype 不一致会直接报错(见 6.5 节补充:lax.scan 的 carry)。

练习 3:单步梯度下降

目标函数 $f(x) = x^2$,梯度 $f'(x) = 2x$。gradient_descent_step(params, learning_rate) 返回 params - learning_rate * gradient。初始参数 5.0,学习率 0.1,循环 5 步。

预期输出:每步乘以 $1 - 2\times0.1 = 0.8$,也就是 5.0 → 4.0000、3.2000、2.5600、2.0480、1.6384。JIT 版本里,学习率应该作为普通(动态)参数传入。

⚠️ 注意:常听到"学习率通常标成静态参数(static_argnums=(1,) / static_argnames)"的说法。学习率不变时这样做能正常运行,但学习率一变,每个新值都会触发重新编译(6.6 节演示过:5 个值重编译 5 次)。学习率只是一个标量,不影响程序结构,应该作为动态参数传入。

def loss_fn_sq(x):
    return x ** 2

grad_fn_sq = jax.grad(loss_fn_sq)

def gradient_descent_step(params, learning_rate):
    gradient = grad_fn_sq(params)
    return params - learning_rate * gradient

params = jnp.array(5.0)
lr = 0.1
print(f"初始参数: {params}")
for step in range(5):
    params = gradient_descent_step(params, lr)
    print(f"第 {step + 1} 步: {params:.4f}")
assert jnp.allclose(params, 1.6384)

# JIT 版本:学习率作为动态参数
jit_gd_step = jax.jit(gradient_descent_step)
p = jnp.array(5.0)
for _ in range(5):
    p = jit_gd_step(p, lr)
print("JIT 版本 5 步后:", p)
assert jnp.allclose(p, 1.6384)
初始参数: 5.0
第 1 步: 4.0000
第 2 步: 3.2000
第 3 步: 2.5600
第 4 步: 2.0480
第 5 步: 1.6384
JIT 版本 5 步后: 1.6383998

练习小结:纯函数 + "状态输入、状态输出"。借助 PyTree,这个模式可以从简单的计数器一路扩展到嵌套的模型参数和优化器状态。


🏋️ 补充练习(含参考答案)

下面几道练习难度递增,建议先自己动手,再看答案。

补充练习 1:参数的指数滑动平均(EMA)

训练中常常给参数维护一份 EMA 副本(评估时用它,效果更稳):$\bar\theta \leftarrow d\,\bar\theta + (1-d)\,\theta$。

要求:

  1. 定义 TrainState(NamedTuple),包含 step、params、ema_params 三个字段;
  2. 写一个 jit 编译的 ema_train_step(state, x, y, lr, decay):对 params 做一步 SGD,同时更新 ema_params,step 加 1;
  3. 用 6.7 节的线性回归数据训练 100 步,打印最终的 params 和 ema_params。

自检接口:TrainState(NamedTuple,字段 step、params、ema_params);ema_train_step(state, x, y, lr, decay):jit 编译,损失用 6.7 节的 loss_fn_linear,返回新的 TrainState。

# ✍️ 作答区:补充练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 1
try:
    TrainState, ema_train_step
except NameError as e:
    print(f"⏳ 补充练习 1 尚未作答:{e}")
else:
    def _selfcheck():
        import numpy as np
        assert {"step", "params", "ema_params"} <= set(getattr(TrainState, "_fields", ())), \
            "TrainState 应是包含 step、params、ema_params 三个字段的 NamedTuple"
        assert hasattr(ema_train_step, "lower"), "ema_train_step 应是用 jax.jit 编译过的函数"
        x = jnp.array([0.5, -1.0, 2.0, 3.0]); y = jnp.array([1.0, 0.0, 4.0, 7.5])
        w0, b0, ew, eb, lr, d = 1.5, -0.5, 0.2, 0.3, 0.1, 0.8
        st = TrainState(step=jnp.array(3), params={"w": jnp.array(w0), "b": jnp.array(b0)},
                        ema_params={"w": jnp.array(ew), "b": jnp.array(eb)})
        new = ema_train_step(st, x, y, lr, d)
        assert isinstance(new, TrainState), "ema_train_step 应返回一个新的 TrainState"
        assert int(new.step) == 4, f"step 应从 3 变成 4,实际 {new.step}"
        # 线性模型 MSE 的解析梯度
        xn, yn = np.asarray(x, np.float64), np.asarray(y, np.float64)
        r = w0 * xn + b0 - yn
        pw, pb = w0 - lr * 2 * np.mean(r * xn), b0 - lr * 2 * np.mean(r)
        got_p = [float(new.params["w"]), float(new.params["b"])]
        assert np.allclose(got_p, [pw, pb], atol=1e-5), f"params 不是一步 SGD 的结果:期望 {[pw, pb]},得到 {got_p}"
        got_e = [float(new.ema_params["w"]), float(new.ema_params["b"])]
        ema_new = [d * ew + (1 - d) * pw, d * eb + (1 - d) * pb]      # 用更新后的参数
        ema_old = [d * ew + (1 - d) * w0, d * eb + (1 - d) * b0]      # 用更新前的参数(两种约定都接受)
        assert np.allclose(got_e, ema_new, atol=1e-5) or np.allclose(got_e, ema_old, atol=1e-5), \
            f"ema_params 不符合 ē ← d·ē + (1−d)·θ:得到 {got_e}"
    _selfcheck(); del _selfcheck
    print("✅ 补充练习 1 自检通过")
⏳ 补充练习 1 尚未作答:name 'ema_train_step' is not defined

补充练习 2:用 lax.scan + vmap 实现 Welford 在线方差

Welford 算法用状态 $(n, \mu, M_2)$ 在线计算均值和方差,数值上比"先求平方和再相减"稳定:

$$ n \leftarrow n+1,\quad \delta = x-\mu,\quad \mu \leftarrow \mu + \delta/n,\quad M_2 \leftarrow M_2 + \delta\,(x-\mu),\quad \sigma^2 = M_2/n $$

要求:

  1. 写 welford_step(state, x) 作为 lax.scan 的主体函数,每步输出当前方差;
  2. 写 online_var(xs) 处理一条数据流,返回最终均值、方差和方差轨迹;
  3. 用 jax.vmap 同时处理 4 条长度为 1000 的随机数据流(用 jax.random.key 生成),并与 jnp.mean / jnp.var 对比。

自检接口:welford_step(state, x)(lax.scan 的主体函数);online_var(xs):输入一维数组,返回 (均值, 方差, 方差轨迹),方差轨迹的形状与 xs 相同。

# ✍️ 作答区:补充练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 2
try:
    welford_step, online_var
except NameError as e:
    print(f"⏳ 补充练习 2 尚未作答:{e}")
else:
    def _selfcheck():
        import numpy as np
        rng = np.random.default_rng(2)
        xs = (1000.0 + 0.5 * rng.standard_normal(300)).astype(np.float32)     # 大偏移、小方差:考验数值稳定性
        assert "scan" in str(jax.make_jaxpr(online_var)(jnp.asarray(xs))), "online_var 应该用 lax.scan 实现"
        m, v, tr = jax.jit(online_var)(jnp.asarray(xs))
        x64 = xs.astype(np.float64)
        assert np.shape(tr) == (300,), f"方差轨迹形状应为 (300,),实际 {np.shape(tr)}"
        assert np.isclose(float(m), x64.mean(), rtol=1e-6), f"均值不对:{float(m)} vs {x64.mean()}"
        assert np.isclose(float(v), x64.var(), rtol=2e-3), \
            f"方差 {float(v):.6f} 与 float64 参考值 {x64.var():.6f} 相差太大(是不是用了「平方和相减」?)"
        for k in (0, 1, 9, 99, 299):
            assert np.isclose(float(tr[k]), x64[:k + 1].var(), rtol=2e-3, atol=1e-6), \
                f"方差轨迹第 {k} 项应是前 {k + 1} 个数的方差"
        streams = (np.arange(1, 4)[:, None] * rng.standard_normal((3, 200)) + 50.0).astype(np.float32)
        mb, vb, tb = jax.vmap(online_var)(jnp.asarray(streams))
        assert np.shape(mb) == (3,) and np.shape(vb) == (3,) and np.shape(tb) == (3, 200), "vmap 后的输出形状不对"
        assert np.allclose(vb, streams.astype(np.float64).var(axis=1), rtol=2e-3), "vmap 后每条数据流的方差不对"
    _selfcheck(); del _selfcheck
    print("✅ 补充练习 2 自检通过")
⏳ 补充练习 2 尚未作答:name 'welford_step' is not defined

补充练习 3:实现 RMSProp,并接入前面的通用训练框架

RMSProp 的状态是梯度平方的滑动平均 $s$:

$$ s \leftarrow \rho\, s + (1-\rho)\, g^2,\qquad \Delta\theta = -\eta\, \frac{g}{\sqrt{s}+\epsilon} $$

要求:仿照前面的 sgd_momentum / adam,写出 rmsprop(lr, rho=0.9, eps=1e-8),返回带 init / update 的 Optimizer;然后用 make_train 在 $\sin(x)$ 拟合任务上训练 1000 步,与 Adam 的最终损失比较。

自检接口:rmsprop(lr, rho=0.9, eps=1e-8) 返回 Optimizer(init, update),其中 update(grads, state, params=None) 返回 (updates, new_state),updates 是要加到参数上的量;用 make_train 训练得到的逐步损失命名为 rms_losses。

# ✍️ 作答区:补充练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 3
try:
    rmsprop, rms_losses
except NameError as e:
    print(f"⏳ 补充练习 3 尚未作答:{e}")
else:
    def _selfcheck():
        import numpy as np
        lr, rho, eps = 0.05, 0.8, 1e-6
        params = {"a": jnp.array([1.0, -2.0, 3.0]), "b": [jnp.ones((2, 2))]}
        g1 = {"a": jnp.array([0.5, -1.0, 2.0]), "b": [jnp.array([[1.0, -3.0], [0.1, 0.0]])]}
        g2 = {"a": jnp.array([-0.5, 0.2, 1.0]), "b": [jnp.array([[2.0, 1.0], [-0.1, 4.0]])]}
        opt = rmsprop(lr, rho=rho, eps=eps)
        st = opt.init(params)
        u1, st = opt.update(g1, st, params)
        u2, st = jax.jit(opt.update)(g2, st, params)          # 也要能在 jit 里用
        assert jax.tree.structure(u1) == jax.tree.structure(params), "updates 的结构应与参数一致"
        s1 = jax.tree.map(lambda g: (1 - rho) * np.asarray(g, np.float64) ** 2, g1)
        s2 = jax.tree.map(lambda s, g: rho * s + (1 - rho) * np.asarray(g, np.float64) ** 2, s1, g2)
        e1 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g1, s1)
        e2 = jax.tree.map(lambda g, s: -lr * np.asarray(g, np.float64) / (np.sqrt(s) + eps), g2, s2)
        for name, got, exp in (("第 1 步", u1, e1), ("第 2 步", u2, e2)):
            ok = jax.tree.leaves(jax.tree.map(lambda a, b: bool(np.allclose(a, b, rtol=1e-5, atol=1e-6)), got, exp))
            assert all(ok), f"{name}的 updates 与 RMSProp 公式不符(注意 updates 应带负号、s 要跨步累积)"
        rl = np.asarray(rms_losses)
        assert rl.shape == (1000,) and np.all(np.isfinite(rl)), "rms_losses 应是 1000 步的有限损失"
        assert rl[-1] < 0.5 * rl[0], f"训练后损失没有明显下降:{rl[0]:.4f} → {rl[-1]:.4f}"
    _selfcheck(); del _selfcheck
    print("✅ 补充练习 3 自检通过")
⏳ 补充练习 3 尚未作答:name 'rmsprop' is not defined

补充练习 4:RNN 的隐藏状态 —— 对 lax.scan 求梯度

循环神经网络的隐藏状态 $h_t = \tanh(W_h h_{t-1} + W_x x_t + b)$ 是最典型的"循环中携带的状态"。

要求:

  1. 用 jax.random.key 初始化参数 {'Wh': (H,H), 'Wx': (D,H), 'b': (H,), 'Wo': (H,1)}(H=16, D=1);
  2. 写 rnn_forward(params, xs):用 lax.scan 把隐藏状态作为 carry,每步输出 $h_t W_o$;
  3. 任务:给定长度 20 的序列,预测"到当前为止的累计和"。用 Adam(前面实现的)训练 300 步,打印初始和最终损失;
  4. 观察:jax.grad 可以直接穿过 lax.scan(相当于自动实现了 BPTT,随时间反向传播)。

自检接口:rnn_forward(params, xs),params 含 Wh (16,16)、Wx (1,16)、b (16,)、Wo (16,1),xs 形状 (T, 1),返回形状 (T, 1);训练 300 步的逐步损失命名为 rnn_losses。

# ✍️ 作答区:补充练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 4
try:
    rnn_forward, rnn_losses
except NameError as e:
    print(f"⏳ 补充练习 4 尚未作答:{e}")
else:
    def _selfcheck():
        import numpy as np
        from jax.test_util import check_grads
        rng = np.random.default_rng(4)
        P = {"Wh": 0.3 * rng.standard_normal((16, 16)), "Wx": rng.standard_normal((1, 16)),
             "b": 0.1 * rng.standard_normal(16), "Wo": 0.3 * rng.standard_normal((16, 1))}
        xs = rng.uniform(-1, 1, (7, 1))
        params = jax.tree.map(lambda a: jnp.asarray(a, jnp.float32), P)
        xs_j = jnp.asarray(xs, jnp.float32)
        assert "scan" in str(jax.make_jaxpr(rnn_forward)(params, xs_j)), "rnn_forward 应该用 lax.scan 实现"
        out = np.asarray(jax.jit(rnn_forward)(params, xs_j))
        assert out.shape == (7, 1), f"输出形状应为 (T, 1) = (7, 1),实际 {out.shape}"
        def ref(row_vec):           # NumPy float64 逐步展开;两种矩阵乘法约定都接受
            h, ys = np.zeros(16), []
            for x in xs:
                pre = (h @ P["Wh"] if row_vec else P["Wh"] @ h) + x @ P["Wx"] + P["b"]
                h = np.tanh(pre)
                ys.append(h @ P["Wo"])
            return np.array(ys)
        assert np.allclose(out, ref(True), atol=1e-4) or np.allclose(out, ref(False), atol=1e-4), \
            "rnn_forward 的输出与 h_t = tanh(W_h h_{t-1} + W_x x_t + b)、y_t = h_t W_o 不符(h_0 应为 0)"
        batched = jax.vmap(rnn_forward, in_axes=(None, 0))(params, jnp.stack([xs_j, -xs_j, 2 * xs_j]))
        assert batched.shape == (3, 7, 1), "rnn_forward 应能被 vmap 到一批序列上"
        f = lambda p: jnp.sum(rnn_forward(p, xs_j) ** 2)
        g = jax.grad(f)(params)
        assert jax.tree.structure(g) == jax.tree.structure(params), "梯度结构应与 params 一致"
        check_grads(f, (params,), order=1, modes=["rev"], atol=5e-2, rtol=5e-2)   # 反向传播穿过 scan,与有限差分一致
        rl = np.asarray(rnn_losses)
        assert rl.shape == (300,) and np.all(np.isfinite(rl)), "rnn_losses 应是 300 步的有限损失"
        assert rl[-1] < 0.1 * rl[0], f"训练 300 步后损失应降到初始的 1/10 以下:{rl[0]:.4f} → {rl[-1]:.4f}"
    _selfcheck(); del _selfcheck
    print("✅ 补充练习 4 自检通过")
⏳ 补充练习 4 尚未作答:name 'rnn_forward' is not defined

🔍 补充:生态库如何管理状态 —— Optax 与 Flax

⚪ 选读:准备上手 Optax / Flax 时读。

本章手写的这套模式,正是 JAX 生态库的设计基础。不装这些库也能看懂下面的对照(下面是示意代码,没有在本环境运行,具体签名以各库当前的文档为准)。其实本环境装有 optax 0.2.9 和 flax 0.12.12,手写优化器和 optax 的实际对拍见 07 专题 5。

Optax:init / update 的纯函数优化器

Optax 的优化器就是一对纯函数,和本章的 Optimizer(init, update) 结构完全一样:

import optax

optimizer = optax.adam(learning_rate=1e-3)        # GradientTransformation(init, update)
opt_state = optimizer.init(params)                # 状态是一个 PyTree

@jax.jit
def train_step(params, opt_state, batch):
    loss, grads = jax.value_and_grad(loss_fn)(params, batch)
    updates, opt_state = optimizer.update(grads, opt_state, params)   # 显式传入、显式返回
    params = optax.apply_updates(params, updates)
    return params, opt_state, loss

Optax 的额外价值在于可以组合:optax.chain(optax.clip_by_global_norm(1.0), optax.adamw(schedule)) 把梯度裁剪、权重衰减、学习率调度串在一起,组合后的状态就是各部分状态组成的元组(又是一个 PyTree)。学习率调度在内部根据 count 状态来算,正好避开了 6.6 节提到的"静态学习率导致重编译"的问题。

Flax Linen:模块无状态,变量显式进出

Linen 的 Module 只描述计算,不持有数据。参数和其他可变集合(比如 BatchNorm 的 batch_stats)都放在一个 variables 字典里,显式地传给 apply:

variables = model.init(key, x)                    # {'params': ..., 'batch_stats': ...}
# 训练模式:声明 batch_stats 可变,apply 会把更新后的状态作为第二个返回值传出
y, updates = model.apply(variables, x, train=True, mutable=['batch_stats'])
variables = {**variables, **updates}
# 需要随机数(如 Dropout)时,key 也是显式传入的
y = model.apply(variables, x, rngs={'dropout': dropout_key})

这就是本章"状态输入、状态输出"模式的直接体现,mutable=[...] 对应 6.7 补充里的 has_aux。

Flax NNX:面向对象的写法,底层仍是纯函数

NNX 允许像 PyTorch 那样写可变对象(self.count.value += 1),用起来更顺手。它之所以能和 JAX 变换配合,是因为 nnx.jit、nnx.grad 这些包装器在进入变换前,会用 nnx.split(model) 把对象拆成静态结构 graphdef + 状态 PyTree state,在纯函数内部用 nnx.merge 重建,结束后再把新状态写回对象。也就是说,"可变"只是表面的语法,到了变换的边界上,依然是显式状态传递:

graphdef, state = nnx.split(model)     # 结构 + 状态 PyTree
# ... 在纯函数中:model = nnx.merge(graphdef, state); 计算; _, state = nnx.split(model)
nnx.update(model, state)               # 把新状态写回对象
方式状态放在哪里如何更新
本章手写你自己的 dict / NamedTuplenew_state = f(state, ...)
Optaxopt_state PyTreeupdates, opt_state = tx.update(...)
Flax Linenvariables 字典(按集合分类)apply(..., mutable=[...]) 返回更新
Flax NNX模块对象的 Variable 属性包装后的变换自动 split / merge

📝 第 6 章测验

说明:课程的官方测验见测验页(需要登录)。下面 8 道题侧重设计上的权衡和常见的故障模式。

1. 下列哪个函数在 jax.jit 下表现与不加 jit 时不同?

  • A. def f(x): return jnp.sin(x) * 2
  • B. def f(x): global n; n += 1; return x + n
  • C. def f(x, s): return x * s
  • D. def f(p, g): return jax.tree.map(lambda a, b: a - 0.1 * b, p, g)

2. 在 JAX 0.12 中,下列哪一个调用会抛出 AttributeError?

  • A. jax.tree.map(f, t) B. jax.tree_util.tree_map(f, t) C. jax.tree_map(f, t) D. jax.tree.leaves(t)

3. jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]}) 的结果是?

  • A. [None, 1.0, 2.0, 3.0] B. [2.0, 3.0, 1.0] C. [1.0, None, [2.0, 3.0]] D. [1.0, 2.0, 3.0]

4. 在 jit 函数中调用 lax.scan(body, init, None, length=steps),其中 steps 是 jit 的普通参数。会发生什么?

  • A. 正常运行 B. 抛出 ConcretizationTypeError C. 悄悄地只运行 1 步 D. 自动转成 while_loop

5. 训练中使用余弦衰减学习率,把学习率作为 static_argnums 传给 jit 编译的训练步,主要问题是?

  • A. 梯度会算错 B. 学习率不会生效 C. 每一步都会重新编译 D. 会抛出类型错误

6. 关于随机数 key,下列做法正确的是?

  • A. 在整个训练过程中反复使用同一个 key 以保证可复现
  • B. 用全局变量保存 key,在 jit 函数内部 split 并写回全局变量
  • C. 每次使用前 key, sub = jax.random.split(key),用 sub 采样,把 key 作为状态继续传递
  • D. 每步用 jax.random.key(step) 重新创建 key,这与 split 完全等价且没有任何缺点

7. step = jax.jit(update, donate_argnums=0),以下哪种用法会出错?

  • A. state = step(state, g)
  • B. new = step(state, g); print(state)
  • C. state, aux = step(state, g)(update 返回二元组)
  • D. 在循环中反复执行 state = step(state, g)

8. 一个函数需要同时返回标量损失和更新后的 BatchNorm 统计量,并只对损失求梯度。应该用?

  • A. jax.grad(f),并让 f 返回元组 B. jax.value_and_grad(f, has_aux=True) C. jax.vmap(jax.grad(f)) D. 把统计量存进全局变量
点击展开:答案与解析
  1. B。修改全局变量是副作用,只在追踪时执行一次,之后命中缓存时 n 不再增加;而且追踪时读到的 n 会被写死成常量。A、D 是纯函数;C 的 s 是参数,没有问题。
  2. C。顶层别名 jax.tree_map 已经移除;jax.tree.* 和 jax.tree_util.* 都能用(6.4 节验证过)。
  3. B。这道题考两个细节:① None 是没有子节点的空 PyTree 节点,不是叶子;② dict 按键排序展平,而不是按插入顺序。键排好序是 b, c, w:'b' 是 None,不贡献叶子;'c' 贡献 2.0, 3.0;'w' 贡献 1.0,所以结果是 [2.0, 3.0, 1.0]。选 D 的人忽略了键排序。(下方代码单元会验证)
  4. B。scan 的长度决定输出形状,必须在编译期就知道。修正方法是 static_argnames,或者改用 fori_loop(6.5 节演示过)。
  5. C。静态参数的值是编译缓存键的一部分,每个新值都会触发重新编译(6.6 节演示过)。应该把学习率作为动态数组参数传入,或者像 Optax 那样在函数内部根据步数来算。
  6. C。A 会让所有"随机数"都一样;B 在 jit 里写全局变量只在追踪时生效;D 用相邻整数当种子,统计上不推荐,正确的派生方式是 split 或 jax.random.fold_in(key, step)。
  7. B。state 被捐赠后,它的缓冲区已经删除,再访问会抛出 RuntimeError: Array has been deleted。
  8. B。has_aux=True 让 f 返回 (loss, aux),只对 loss 求导,aux 原样带出来(6.7 补充)。
# 验证测验第 2、3、4 题
print("Q2:", "jax.tree_map 存在" if hasattr(jax, "tree_map") else "jax.tree_map 不存在 → AttributeError")
print("Q3:", jax.tree.leaves({'w': 1.0, 'b': None, 'c': [2.0, 3.0]}))

@jax.jit
def q4(init, steps):
    return lax.scan(lambda c, _: (c + 1, None), init, None, length=steps)[0]
try:
    q4(0, 5)
except Exception as e:
    print("Q4:", type(e).__name__)
Q2: jax.tree_map 不存在 → AttributeError
Q3: [2.0, 3.0, 1.0]
Q4: ConcretizationTypeError

本章更新说明

下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过(环境:JAX 0.12.0.1,CPU)。

编号小节要点位置
16.2对原地修改属性的普通对象(如 StateHolder)做 jit,一定会报 TypeError(对象不是数组/PyTree);真正悄悄出错的是把它注册成 PyTree 之后6.2 函数式代码中的状态挑战
26.4逐叶子更新推荐写 jax.tree.map(...);jax.tree_util.tree_map 仍可用,但不再推荐6.4 使用 PyTree 管理分层状态
36.4"向量化"的英文是 vectorization;quantization 是"量化"6.4 使用 PyTree 管理分层状态
46.4PyTree API 完整的旧 → 新迁移表(标出 0.12 中已移除的顶层别名),并演示 map / leaves / structure / flatten / unflatten / map_with_path / reduce / all / transpose6.4 使用 PyTree 管理分层状态(补充:迁移表)
56.4除了 dict / list / tuple,状态容器还可以用 NamedTuple、register_dataclass、register_pytree_node_class6.4 使用 PyTree 管理分层状态(补充:自定义容器)
66.5tracer 不是调用方传入的类型;jit 计数器从 Python int 开始调用只追踪 1 次,弱类型变成强类型时才会多追踪6.5 示例:有状态计数器
76.5把 jit 的普通参数传给 scan(length=steps) 会报 ConcretizationTypeError;修法:static_argnames="steps",或改用 lax.fori_loop6.5 示例:有状态计数器
86.5随机数 key 是需要显式传递的状态:一个 key 只用一次,先 split;用 jax.random.key 代替 PRNGKey(演示与 assert 见第 1 章、进阶 10)6.5 示例:有状态计数器(补充:随机数 key)
96.6在 $f(x)=(x-3)^2$ 上,$\eta=0.1,\ \mu=0.9$ 的动量法 20 步后为 4.0478,不如普通 SGD 的 2.9654;$\mu=0.5$ 时为 2.99926.6 例子:简单的优化器状态
106.6把学习率、动量设成 static_argnums,每个新值都会触发重编译:5 个学习率值重编译 5 次;改为动态参数只编译 1 次6.6 例子:简单的优化器状态
116.6通用 PyTree 版动量 SGD 和 Adam(含偏差修正),与 NumPy 参考实现 allclose;附完整训练循环(与 Optax 的逐项对照和实际对拍见 07 专题 5)6.6 例子:简单的优化器状态(补充:通用优化器)
126.6用 donate_argnums 降低状态更新的内存开销:原理、CPU 上的实际行为、同一 buffer 捐两次的坑(Adam 的 mu、nu 不要共用 zeros)6.6 例子:简单的优化器状态(补充:donate_argnums)
136.7比较 jit 前后的状态用 jax.tree.all(jax.tree.map(...));顶层别名 jax.tree_map 已移除(AttributeError)6.7 将状态管理与变换结合
146.7training_step 中逐叶子更新参数用 jax.tree.map(jax.tree_map 已移除)6.7 将状态管理与变换结合
156.7vmap 共享参数时,默认 out_axes=0 会把参数沿批维度复制,用 out_axes=(0, None) 避免;需要带出状态时用 has_aux6.7 将状态管理与变换结合
166.7多设备下的状态管理,新写法是 jit + jax.sharding / jax.shard_map(pmap 仍可用)6.7 将状态管理与变换结合
176.8练习 2 的 JIT 版本如果用 Python 标量 (0.0, 0) 作初始状态,会因弱类型追踪 2 次;改用确定 dtype 的数组后只追踪 1 次6.8 实践:实现有状态函数
186.8练习 3 的学习率应作为动态参数传入,而不是静态参数(理由同第 10 条)6.8 实践:实现有状态函数
196.1jit 里写全局变量只在追踪时执行,读全局变量会被固化成常量;运行时打印用 jax.debug.print6.1 函数纯粹性与副作用
20测验本章 8 道测验题,侧重设计权衡与常见故障,附答案与解析第 6 章测验
216.1 / 6.2vmap 只追踪一次,函数里修改外部状态的副作用对整批只执行 1 次、拿到的是 BatchTracer;问题不是竞态,而是副作用不会按样本发生(assert)6.2 函数式代码中的状态挑战

📌 本章小结

  • 纯函数 = 确定性 + 无副作用。JAX 的 jit / grad / vmap / pmap 都建立在这个前提上。
  • jit 中的副作用(print、写全局变量)只在追踪时执行一次;读到的全局变量会被固化成常量。调试用 jax.debug.print。
  • 原地修改对象属性在 jit 下要么报错(对象不是 PyTree),要么悄悄出错(对象是 PyTree,修改的只是内部副本)。JAX 数组本身不可变,用 .at[].set()。
  • 显式状态传递:new_state, out = f(state, inputs),调用方负责接住新状态并传给下一次调用。
  • 用 PyTree(dict / list / tuple / NamedTuple / 注册的 dataclass)组织复杂状态,变换会自动保持结构;grad 的结果与参数同结构。
  • 用 jax.tree.*(推荐)或 jax.tree_util.*;jax.tree_map 等顶层别名已移除。
  • lax.scan 的 carry 就是状态,ys 用来收集每步输出;length 必须是静态值;carry 的结构、形状、dtype 每步必须一致。
  • 随机数 key 也是状态:一个 key 只用一次,split 后把一个传下去、一个拿去用。
  • 优化器状态(动量、Adam 的矩)是与参数同结构的 PyTree;init / update 接口是 Optax 的核心设计。
  • 学习率等会变化的标量不要设为静态参数,否则每个新值都重新编译;初始状态的 dtype 要与更新后一致,避免多编译一次。
  • donate_argnums 让旧状态的缓冲区被新状态复用,降低峰值内存;捐赠后不能再访问旧数组。
  • vmap 可批量处理独立状态(in_axes 可写成 PyTree);共享状态用 in_axes=None、out_axes=None。需要带出额外状态时用 has_aux=True。

🗂️ 速查表

API作用
jax.tree.map(f, t, *rest)逐叶子应用函数,返回同结构的新树
jax.tree.leaves(t)取出所有叶子(扁平列表)
jax.tree.structure(t)获取树结构 PyTreeDef
jax.tree.flatten(t) / jax.tree.unflatten(d, l)展平为(叶子, 结构)/ 由结构和叶子重建
jax.tree.all(t) / jax.tree.reduce(f, t)对叶子做逻辑与 / 归约
jax.tree.map_with_path(f, t)带键路径的 map,jax.tree_util.keystr 把路径转成字符串
jax.tree_util.register_dataclass把 dataclass 注册为 PyTree(区分数据字段与静态字段)
jax.tree_util.register_pytree_node_class自定义类的展平 / 重建规则
typing.NamedTuple开箱即用的带字段名 PyTree 状态容器,._replace() 生成新副本
x.at[idx].set(v)数组的函数式"修改",返回新数组
jax.lax.scan(body, init, xs, length)带状态的循环,body(carry, x) -> (carry, y)
jax.lax.fori_loop(lo, hi, body, init)次数可在运行时决定的循环(只返回最终状态)
jax.random.key(seed) / jax.random.split(key, n)创建 key / 派生新 key(key 是显式状态)
jax.random.fold_in(key, i)按整数编号派生 key
jax.value_and_grad(f, has_aux=True)同时得到损失、辅助状态和梯度
jax.jit(f, static_argnums/static_argnames)把影响程序结构的参数设为静态(每个新值重新编译)
jax.jit(f, donate_argnums=...)捐赠输入缓冲区给输出,节省状态更新的内存
jax.vmap(f, in_axes, out_axes)批量处理独立状态;None 表示共享
jax.debug.print(fmt, *args)编译后每次运行都会执行的打印

🧭 全课程总结与后续学习路线

学完本课程的 6 章,你已经掌握了 JAX 的核心心智模型:

  1. jax.Array 与 jax.numpy:不可变数组、类 NumPy 的 API、在 CPU / GPU / TPU 上统一运行;
  2. jit:追踪 → Jaxpr → XLA 编译,理解静态与动态参数、重新编译的触发条件;
  3. grad:函数式自动微分,value_and_grad、has_aux、高阶导数;
  4. vmap:自动向量化,in_axes / out_axes;
  5. pmap 与并行:多设备数据并行与集合通信;
  6. 状态管理:纯函数 + 显式状态传递 + PyTree,把以上所有变换组合成完整的训练流程。

推荐的后续学习路线

阶段内容说明
必读JAX - The Sharp Bits官方"坑点大全":纯函数、原地更新、越界索引、随机数、控制流、NaN 调试等,本章很多补充都来自这里
官方教程JAX 文档中的 Stateful computations、Pytrees、Control flow、Pseudorandom numbers本章内容的权威版本
优化器Optax可组合的梯度变换:Adam / AdamW / Lion、梯度裁剪、学习率调度
神经网络Flax(新项目推荐 NNX)模块化建模,理解 NNX 的 split / merge 与本章的关系
检查点Orbax保存 / 恢复参数与优化器状态 PyTree,支持异步与分片
并行官方 Distributed arrays and automatic parallelization、jax.sharding、shard_map新一代并行方式,逐步取代 pmap
性能jax.profiler、jax.make_jaxpr、donate_argnums、jax.checkpoint(重计算)定位编译与内存瓶颈
实战用 Flax + Optax + Orbax 写一个完整的 MNIST / CIFAR 训练脚本把参数、优化器状态、随机数 key、步数打包成一个 TrainState PyTree

附录:练习参考答案

先在正文的「✍️ 作答区」自己写,用「🧪 自检」检查;自检通过或者实在卡住了,再来这里对照。 下面的答案按顺序运行,会覆盖你在作答区定义的同名函数。

补充练习 1

题目见正文「补充练习 1:参数的指数滑动平均(EMA)」。

# 参考答案
class TrainState(NamedTuple):
    step: jax.Array
    params: dict
    ema_params: dict

@jax.jit
def ema_train_step(state, x, y, lr, decay):
    grads = jax.grad(loss_fn_linear)(state.params, x, y)
    new_params = jax.tree.map(lambda p, g: p - lr * g, state.params, grads)
    new_ema = jax.tree.map(lambda e, p: decay * e + (1 - decay) * p, state.ema_params, new_params)
    return TrainState(step=state.step + 1, params=new_params, ema_params=new_ema)

p0 = {'w': jnp.array(0.0), 'b': jnp.array(0.0)}
state = TrainState(step=jnp.array(0), params=p0, ema_params=p0)
for _ in range(100):
    state = ema_train_step(state, x_data, y_data, 0.05, 0.9)
print("step      :", state.step)
print("params    :", jax.tree.map(lambda a: round(float(a), 4), state.params))
print("ema_params:", jax.tree.map(lambda a: round(float(a), 4), state.ema_params))
step      : 100
params    : {'b': 1.3449, 'w': 2.0682}
ema_params: {'b': 1.3258, 'w': 2.0765}

补充练习 2

题目见正文「补充练习 2:用 lax.scan + vmap 实现 Welford 在线方差」。

# 参考答案
class WelfordState(NamedTuple):
    n: jax.Array
    mean: jax.Array
    m2: jax.Array

def welford_step(state, x):
    n = state.n + 1
    delta = x - state.mean
    mean = state.mean + delta / n
    m2 = state.m2 + delta * (x - mean)
    new_state = WelfordState(n, mean, m2)
    return new_state, m2 / n            # 每步输出当前方差

def online_var(xs):
    init = WelfordState(jnp.array(0.0), jnp.array(0.0), jnp.array(0.0))
    final, var_trace = lax.scan(welford_step, init, xs)
    return final.mean, final.m2 / final.n, var_trace

key = jax.random.key(7)
streams = jax.random.normal(key, (4, 1000)) * jnp.array([[1.0], [2.0], [0.5], [3.0]]) + 100.0
means, variances, traces = jax.jit(jax.vmap(online_var))(streams)
print("Welford 均值:", means)
print("jnp.mean    :", streams.mean(axis=1))
print("Welford 方差:", variances)
print("jnp.var     :", streams.var(axis=1))
print("方差轨迹形状:", traces.shape)
Welford 均值: [ 99.99555  100.016106  99.99033  100.0839  ]
jnp.mean    : [ 99.99555  100.01611   99.990326 100.0839  ]
Welford 方差: [1.0395197 4.149059  0.2674239 8.703659 ]
jnp.var     : [1.0395207  4.14906    0.26742393 8.703653  ]
方差轨迹形状: (4, 1000)

补充练习 3

题目见正文「补充练习 3:实现 RMSProp,并接入前面的通用训练框架」。

# 参考答案
class RMSPropState(NamedTuple):
    sq_avg: dict

def rmsprop(lr, rho=0.9, eps=1e-8):
    def init(params):
        return RMSPropState(sq_avg=jax.tree.map(jnp.zeros_like, params))
    def update(grads, state, params=None):
        s = jax.tree.map(lambda s, g: rho * s + (1 - rho) * g ** 2, state.sq_avg, grads)
        updates = jax.tree.map(lambda g, s: -lr * g / (jnp.sqrt(s) + eps), grads, s)
        return updates, RMSPropState(sq_avg=s)
    return Optimizer(init, update)

_, _, rms_losses = make_train(rmsprop(0.003), 1000)(params0, X, Y)
print(f"RMSProp 最终损失: {rms_losses[-1]:.5f}")
print(f"Adam    最终损失: {results['Adam'][-1]:.5f}")
RMSProp 最终损失: 0.00967
Adam    最终损失: 0.00003

补充练习 4

题目见正文「补充练习 4:RNN 的隐藏状态 —— 对 lax.scan 求梯度」。

# 参考答案
H, D, T = 16, 1, 20

def init_rnn(key):
    k1, k2, k3 = jax.random.split(key, 3)
    return {'Wh': jax.random.normal(k1, (H, H)) * 0.1,
            'Wx': jax.random.normal(k2, (D, H)) * 0.5,
            'b':  jnp.zeros(H),
            'Wo': jax.random.normal(k3, (H, 1)) * 0.1}

def rnn_forward(params, xs):            # xs: (T, D)
    def cell(h, x):
        h = jnp.tanh(h @ params['Wh'] + x @ params['Wx'] + params['b'])
        return h, h @ params['Wo']      # carry=隐藏状态,y=本步输出
    h0 = jnp.zeros(H)
    _, ys = lax.scan(cell, h0, xs)
    return ys                            # (T, 1)

def rnn_loss(params, xs_batch, ys_batch):
    preds = jax.vmap(rnn_forward, in_axes=(None, 0))(params, xs_batch)
    return jnp.mean((preds - ys_batch) ** 2)

key = jax.random.key(123)
key, k_data, k_init = jax.random.split(key, 3)
xs_batch = jax.random.uniform(k_data, (64, T, D), minval=-0.5, maxval=0.5)
ys_batch = jnp.cumsum(xs_batch, axis=1)       # 目标:累计和

rnn_params = init_rnn(k_init)
rnn_opt = adam(0.01)

@jax.jit
def train_rnn(params, xs, ys):
    """参数和数据都作为 jit 的参数传入,不靠闭包捕获。"""
    def rnn_step(carry, _):
        params, opt_state = carry
        loss, grads = jax.value_and_grad(rnn_loss)(params, xs, ys)
        updates, opt_state = rnn_opt.update(grads, opt_state, params)
        return (apply_updates(params, updates), opt_state), loss
    (params, _), losses = lax.scan(rnn_step, (params, rnn_opt.init(params)), None, length=300)
    return params, losses

rnn_params, rnn_losses = train_rnn(rnn_params, xs_batch, ys_batch)
print(f"初始损失 {rnn_losses[0]:.5f} → 最终损失 {rnn_losses[-1]:.5f}")
assert rnn_losses[-1] < 0.1 * rnn_losses[0]
print("梯度结构与参数一致:", jax.tree.map(jnp.shape, jax.grad(rnn_loss)(rnn_params, xs_batch, ys_batch)))
初始损失 0.78306 → 最终损失 0.00324
梯度结构与参数一致: {'Wh': (16, 16), 'Wo': (16, 1), 'Wx': (1, 16), 'b': (16,)}