第 3 章 用 grad 做自动微分

《JAX 入门》(ApX Machine Learning)第 3 章个人学习笔记 · 运行环境:JAX 0.12(CPU) 原课程:https://apxml.com/zh/courses/getting-started-with-jax/chapter-3-automatic-differentiation-grad

训练机器学习模型,几乎总要计算损失函数对参数的梯度。自动微分(Automatic Differentiation, AD)是计算梯度最高效、最精确的方法。本章介绍 JAX 的核心变换 jax.grad:它能从一段处理数值输入的普通 Python 函数,直接得到计算梯度的函数。

学习目标

  1. 用 jax.grad 计算标量函数的梯度;
  2. 理解 grad 背后的反向模式自动微分;
  3. 用 argnums 控制对哪些参数求导;
  4. 组合 grad 计算高阶导数;
  5. 用 jax.value_and_grad 同时得到函数值和梯度;
  6. 理解控制流与求导的关系,以及 grad 的局限。

小节目录

小节原课程链接
3.1 理解梯度understanding-gradients
3.2 介绍 jax.gradintroducing-jax-grad
3.3 自动微分的工作方式:反向模式how-autodiff-works
3.4 关于参数求导grad-respect-to-arguments
3.5 高阶导数(grad 的 grad)grad-higher-order-derivatives
3.6 值和梯度(jax.value_and_grad)jax-value-and-grad
3.7 求导与控制流grad-control-flow
3.8 局限性与注意事项grad-limitations
3.9 动手实践:计算梯度hands-on-computing-gradients
第 3 章测验quiz
本章更新说明见章末

阅读说明:代码示例按课程页面的内容整理,都在 JAX 0.12 下实际运行过,单元下方的输出就是运行结果。

  • 正文统一用 JAX 0.12 的推荐写法。容易踩坑的地方用 > ⚠️ **注意** 标出;涉及代码时,过时或错误的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法,结论用 assert 验证。
  • 标有「🔍 补充」的单元是扩展内容。
  • 这些注意事项在章末的 本章更新说明 里有汇总。

讲解文字按课程要点用中文重新组织;代码示例尽量沿用课程里的变量名和数值,并且都实际运行过。

import jax
import jax.numpy as jnp
import numpy as np

print("JAX 版本:", jax.__version__)
print("默认后端:", jax.default_backend())
JAX 版本: 0.12.0.1
默认后端: cpu

3.1 理解梯度

什么是梯度

先看单变量函数 $f(x)$。它在某一点的导数,就是曲线在这一点的切线斜率,表示 $x$ 稍微变一点时,$f$ 变得有多快。

例如 $f(x)=x^2$,$f'(x)=2x$。在 $x=2$ 处,切线斜率是 $4$,这就是这一点的导数(梯度)。

对多个输入、一个标量输出的函数 $f(x_1,\dots,x_n)$,把对每个输入的偏导数排成一个向量,就是梯度:

$$ \nabla f(x_1,\dots,x_n)=\left[\frac{\partial f}{\partial x_1},\ \frac{\partial f}{\partial x_2},\ \dots,\ \frac{\partial f}{\partial x_n}\right] $$

梯度是一个向量。它的方向是函数在这一点增长最快的方向,长度表示增长有多陡。

例子:$f(x,y)=x^2+\sin(y)$

$$ \frac{\partial f}{\partial x}=2x,\qquad \frac{\partial f}{\partial y}=\cos(y),\qquad \nabla f(x,y)=[2x,\ \cos y],\qquad \nabla f(1,0)=[2,\ 1] $$

梯度为什么对优化重要

既然梯度指向增长最快的方向,负梯度就指向下降最快的方向。训练模型就是要让损失函数尽量小,梯度下降用的正是这一点:

  1. 从一组初始参数出发;
  2. 计算损失在当前参数处的梯度;
  3. 沿负梯度方向走一小步;
  4. 重复第 2–3 步,直到收敛。

更新公式:

$$ \theta_{\text{新}} = \theta_{\text{旧}} - \eta\,\nabla f(\theta_{\text{旧}}) $$

其中 $\eta$ 是学习率,一个控制步长的小正数。

为什么需要自动微分

深度网络动辄几百万个参数,手推梯度不现实,也容易出错。让计算机求导,主要有三种办法:

方法做法缺点
数值微分用 $\dfrac{f(x+h)-f(x)}{h}$($h$ 很小)近似有截断误差和舍入误差;每个输入维度都要额外求值一次,$n$ 维要 $O(n)$ 次
符号微分像 SymPy 那样对表达式做符号推导表达式可能急剧膨胀,也很难处理带循环、分支的一般程序
自动微分在基本运算这一层系统地套用链式法则没有上面的问题:精确(到机器精度)又高效

JAX 的 grad 使用的是反向模式自动微分,也就是机器学习中所说的反向传播。它特别适合「输入很多、输出是一个标量(损失)」的函数。

# 用 JAX 验证上面的两个例子

# 1) f(x) = x^2 在 x=2 处的导数(切线斜率)应为 4
print("d/dx x^2 在 x=2:", jax.grad(lambda x: x**2)(2.0))

# 2) f(x, y) = x^2 + sin(y) 在 (1, 0) 处的梯度应为 [2, 1]
def f_xy(v):
    x, y = v
    return x**2 + jnp.sin(y)

print("∇f(1, 0) =", jax.grad(f_xy)(jnp.array([1.0, 0.0])))
d/dx x^2 在 x=2: 4.0
∇f(1, 0) = [2. 1.]

🔍 补充:数值微分到底差在哪

⚪ 选读:想知道为什么不用有限差分训练、或要自己写梯度检验时看。

数值微分的误差其实来自两方面。$h$ 太大,会有截断误差($O(h)$);$h$ 太小,浮点相减又会带来舍入误差(约 $\varepsilon/h$)。JAX 默认用 float32(机器精度 $\varepsilon\approx 1.2\times10^{-7}$),这个问题更明显。下面对 $\sin$ 在 $x=1$ 处求导,比较前向差分、中心差分和 jax.grad:

x0 = 1.0
exact = np.cos(x0)  # 真值(用 float64 计算)

print(f"{'h':>8} | {'前向差分误差':>14} | {'中心差分误差':>14}")
for h in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6]:
    x = jnp.float32(x0)
    fwd = (jnp.sin(x + h) - jnp.sin(x)) / h
    cen = (jnp.sin(x + h) - jnp.sin(x - h)) / (2 * h)
    print(f"{h:>8.0e} | {abs(float(fwd) - exact):>14.2e} | {abs(float(cen) - exact):>14.2e}")

print("\njax.grad 误差:", abs(float(jax.grad(jnp.sin)(jnp.float32(x0))) - exact))
       h |         前向差分误差 |         中心差分误差
   1e-01 |       4.29e-02 |       9.00e-04
   1e-02 |       4.21e-03 |       1.00e-05
   1e-03 |       3.44e-04 |       1.38e-05
   1e-04 |       3.12e-04 |       1.38e-05
   1e-05 |       2.10e-03 |       8.80e-04
   1e-06 |       3.86e-03 |       3.86e-03

jax.grad 误差: 2.9256811640010483e-08

可以看到,在 float32 下,差分误差先随着 $h$ 变小而下降,之后又因为舍入误差反弹回去,最好也只能到 $10^{-4}\sim10^{-5}$ 量级。自动微分的误差则在机器精度量级。所以数值微分只适合用来检验梯度(见 3.8 的 check_grads),不适合拿来训练。

3.2 介绍 jax.grad

jax.grad 本身是一个函数变换。你给它一个返回标量的 Python 数值函数,它还你一个新函数,这个新函数负责计算原函数的梯度。

以 $f(x)=x^3$ 为例,导数 $f'(x)=3x^2$,所以 $f'(2.0)=3\times 2.0^2=12.0$。先定义并测试原函数:

import jax
import jax.numpy as jnp

def f(x):
    """计算 x 的三次方。"""
    return x**3

# 测试原函数
print(f"f(2.0) = {f(2.0)}")
f(2.0) = 8.0

用 jax.grad 得到梯度函数。注意 grad_f 和 f 一样都是普通的 Python 可调用对象:

# 使用 jax.grad 获得梯度函数
grad_f = jax.grad(f)

print(f"Type of f: {type(f)}")
print(f"Type of grad_f: {type(grad_f)}")
Type of f: <class 'function'>
Type of grad_f: <class 'function'>
# 计算函数 f 在 x=2.0 处的梯度
gradient_at_2 = grad_f(2.0)

print(f"Gradient of f at x=2.0: {gradient_at_2}")  # 手算: 3 * 2.0**2 = 12.0
Gradient of f at x=2.0: 12.0

要点

  • 输入:一个返回单个标量的 Python 函数;
  • jax.grad 返回一个新函数;
  • 新函数接受与原函数相同的参数,返回输出对第一个位置参数的梯度。

多参数函数:$g(x,y)=x^2 y$。jax.grad(g) 默认只对第一个参数 $x$ 求导:$\dfrac{\partial g}{\partial x}=2xy$,在 $(2,3)$ 处是 $12$。对其他参数求导的方法见 3.4。

def g(x, y):
    """计算 x 的平方乘以 y。"""
    return (x**2) * y

# 获得函数 g 的梯度函数
grad_g = jax.grad(g)

# 计算在 (x=2.0, y=3.0) 处的梯度
# 默认情况下,这是相对于第一个参数 (x) 的梯度
gradient_g_wrt_x = grad_g(2.0, 3.0)

print(f"g(2.0, 3.0) = {g(2.0, 3.0)}")
print(f"Gradient of g w.r.t x at (2.0, 3.0): {gradient_g_wrt_x}")
g(2.0, 3.0) = 12.0
Gradient of g w.r.t x at (2.0, 3.0): 12.0

对函数的要求

  • 被求导的函数必须返回单个标量。返回数组、元组这类非标量会报 TypeError。多输出函数要用 jax.jacrev / jax.jacfwd(jax.jacobian 是 jax.jacrev 的别名),或者更底层的 jax.vjp / jax.jvp。它们和 grad 一样,都建在 jax.vjp(反向)或 jax.jvp(前向)之上,详见 3.3。
  • 求导的输入必须是浮点(或复数)类型。传入整数会直接报 TypeError,JAX 不会自动帮你转成浮点。确实需要整数参数(索引、步数等)时,把它放在不求导的位置上,也就是不写进 argnums。如果一定要把它列进 argnums,可以加 allow_int=True,这时它的梯度是 float0 类型的零。

⚠️ 注意:容易以为 jax.jacobian 这类多输出工具是建在 jax.grad 之上的,其实不是。jax.jacobian 的源码就是直接调用 jacrev(文档写明 Alias of jax.jacrev),而 jacrev 和 grad 都基于 jax.vjp 实现。

⚠️ 注意:用整数做输入不只是「不推荐」,而是会直接抛出 TypeError: grad requires real- or complex-valued inputs。

# 验证:整数输入报错;allow_int 的行为;jacobian 即 jacrev
try:
    jax.grad(f)(2)
    int_input_raised = False
except TypeError as e:
    int_input_raised = True
    print("整数输入报错:", str(e).splitlines()[0])
assert int_input_raised, "整数输入应当报 TypeError"
assert jax.grad(f)(2.0) == 12.0

def scale(x, n):
    return x * n

# allow_int=True:整数参数可以出现在 argnums 中,梯度为 float0 类型的零
gx, gn = jax.grad(scale, argnums=(0, 1), allow_int=True)(2.0, 3)
print("allow_int=True ->", gx, "| n 的梯度是 float0 类型:", gn.dtype == jax.dtypes.float0)
assert gn.dtype == jax.dtypes.float0

# jax.jacobian 就是 jax.jacrev
import inspect
print("jax.jacobian 的文档:", jax.jacobian.__doc__.strip())
assert "jacrev(fun" in inspect.getsource(jax.jacobian)
F_demo = lambda x: jnp.stack([x[0] * x[1], jnp.sin(x[1])])
x_demo = jnp.array([1.0, 2.0])
assert jnp.array_equal(jax.jacobian(F_demo)(x_demo), jax.jacrev(F_demo)(x_demo))
print("断言通过:jacobian 与 jacrev 结果完全相同")
整数输入报错: grad requires real- or complex-valued inputs (input dtype that is a sub-dtype of np.inexact), but got int32. If you want to use Boolean- or integer-valued inputs, use vjp or set allow_int to True.
allow_int=True -> 3.0 | n 的梯度是 float0 类型: True
jax.jacobian 的文档: Alias of :func:`jax.jacrev`.
断言通过:jacobian 与 jacrev 结果完全相同

再看一个例子:$h(x)=\sin x$,$h'(x)=\cos x$,在 $\pi/2$ 处梯度的数学值是 $\cos(\pi/2)=0$。

⚠️ 注意:grad_h(jnp.pi / 2.0) 打印出来并不是 0.0,而是约 -4.37e-08(默认 float32)。原因是 $\pi/2$ 转成 float32 时有舍入,cos 在舍入后的这个点上并不精确等于 0。比较浮点结果要用 jnp.allclose,不要用 ==。

# ❌ 错误预期:以为 cos(pi/2) 的梯度会精确打印为 0.0
def h(x):
    """计算 sin(x)。"""
    return jnp.sin(x)

grad_h = jax.grad(h)
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)

print(f"h(pi/2) = {h(jnp.pi / 2.0)}")
print(f"Gradient of h at x=pi/2: {gradient_h_at_pi_half}")   # 数学值是 0.0
print("与 0.0 精确相等?", bool(gradient_h_at_pi_half == 0.0))
h(pi/2) = 1.0
Gradient of h at x=pi/2: -4.371138828673793e-08
与 0.0 精确相等? False
# ✅ 推荐写法:浮点结果用 allclose 比较
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)
assert gradient_h_at_pi_half != 0.0                          # 确实不是精确的 0
assert jnp.allclose(gradient_h_at_pi_half, 0.0, atol=1e-6)   # 但在 float32 精度内等于 0
print("断言通过:grad_h(pi/2) ≈ 0(atol=1e-6)")
断言通过:grad_h(pi/2) ≈ 0(atol=1e-6)

3.3 自动微分的工作方式:反向模式

自动微分(AD)是 jax.grad 背后的技术。它把程序拆成一个个基本运算,逐个套用链式法则,得到精确的导数。AD 分前向模式和反向模式两种。jax.grad 用的是反向模式,在机器学习里通常叫反向传播。

计算图

把计算看成一串基本运算,可以画成由节点(运算)和有向边(数据流)组成的计算图。以 $f(x)=\sin(x^2)$ 为例,拆成两步:

$$ a = x^2,\qquad y=\sin(a) $$

  x ──[平方]──► a ──[sin]──► y

前向传播:计算值

取 $x=2.0$:

$$ a = 2.0^2 = 4.0,\qquad y=\sin(4.0)\approx -0.757 $$

在前向阶段,AD 系统会把中间值(这里是 $x$ 和 $a$)和图的结构存下来,留给反向阶段用。

反向传播:用链式法则计算梯度

记 $\bar v = \dfrac{\partial y}{\partial v}$(输出 $y$ 对变量 $v$ 的「敏感度」,也叫伴随量 adjoint)。从输出往回走:

  1. 初始化:输出对自身的敏感度 $\bar y=\dfrac{dy}{dy}=1$。
  2. 经过 sin 节点:$\bar a=\bar y\cdot\dfrac{dy}{da}=\bar y\cos(a)=\cos(4.0)\approx-0.654$。
  3. 经过平方节点:$\bar x=\bar a\cdot\dfrac{da}{dx}=\bar a\cdot 2x\approx-0.654\times4.0\approx-2.615$。

$\bar x$ 就是我们要的 $\dfrac{dy}{dx}$。说白了,反向传播就是从输出出发,一步步套用链式法则,依次求出输出对每个中间变量、最后对每个输入的敏感度。

前向(算值):   x=2.0 ──► a=4.0 ──► y≈-0.757      (保存 x、a)
反向(算梯度): x̄≈-2.615 ◄── ā≈-0.654 ◄── ȳ=1

为什么机器学习用反向模式

损失函数 $L=f(W_1,\dots,W_n,x,y)$ 通常有数百万个输入(参数),却只有一个标量输出。

  • 反向模式:一次反向传播就能拿到对所有输入的梯度,成本只是原函数计算的一个小常数倍(通常说是 2–4 倍),和输入个数基本无关。
  • 前向模式:每次只能沿一个输入方向求导。要拿到完整梯度,次数和输入个数成正比,对大模型来说太贵了。

JAX 在幕后做了什么

  1. 追踪(tracing):用「追踪器」(tracer)对象代替输入,把函数执行一遍,记下运算序列(计算图)。单独用 grad 时,追踪器带着具体数值,前向值当场就算出来,同时记下求导要用的运算。放进 jit 之后,追踪器才是只带形状和 dtype 的抽象值,记下来的是中间表示 jaxpr(两者的区别见 3.7)。
  2. 前向传播:执行这些运算,算出求梯度要用的中间值(用 value_and_grad 时还会顺便拿到函数值)。
  3. 反向传播:根据记下的图,自动生成并执行反向计算,得到对指定输入的梯度。

你只需要用类似 NumPy 的语法写前向计算,梯度怎么推、怎么算,都交给 JAX。

⚠️ 注意:单独用 grad 时,追踪器并不是抽象的。它打印出来是 GradTracer(primal=2.0, ...),带着具体数值。只带形状和 dtype 的抽象追踪器是 jit 的行为(3.7 的单元用 assert 验证了这一点)。

# 用 JAX 验证上面手算的结果
def f_sin_sq(x):
    a = x**2         # 平方节点
    y = jnp.sin(a)   # sin 节点
    return y

x = 2.0
print("y      =", f_sin_sq(x))
print("ā = cos(4.0)      ≈", jnp.cos(4.0))
print("x̄ = ā * 2x        ≈", jnp.cos(4.0) * 2 * x)
print("jax.grad(f)(2.0)  =", jax.grad(f_sin_sq)(x))
assert jnp.allclose(jax.grad(f_sin_sq)(x), jnp.cos(4.0) * 2 * x)
y      = -0.7568025
ā = cos(4.0)      ≈ -0.6536436
x̄ = ā * 2x        ≈ -2.6145744
jax.grad(f)(2.0)  = -2.6145744

🔍 补充:亲眼看看 jaxpr,以及 jvp/vjp 与两种模式的关系

🟢 必读

(1) 看看 grad 生成了什么。 jax.make_jaxpr 可以打印追踪得到的 jaxpr。对比原函数和梯度函数的 jaxpr,能看到反向计算里出现了 cos(sin 的导数)和乘以 2x 的运算:

print("原函数的 jaxpr:")
print(jax.make_jaxpr(f_sin_sq)(2.0))
print("梯度函数的 jaxpr:")
print(jax.make_jaxpr(jax.grad(f_sin_sq))(2.0))
原函数的 jaxpr:
{ lambda ; a:f32[]. let b:f32[] = integer_pow[y=2] a; c:f32[] = sin b in (c,) }
梯度函数的 jaxpr:
{ lambda ; a:f32[]. let
    b:f32[] = integer_pow[y=2] a
    c:f32[] = mul 2.0:f32[] a
    _:f32[] = sin b
    d:f32[] = cos b
    e:f32[] = mul 1.0:f32[] d
    f:f32[] = mul e c
  in (f,) }

(2) 前向模式 = jax.jvp,反向模式 = jax.vjp。 上面讲的两种模式,在 JAX 里各对应一个底层 API。设 $f:\mathbb R^n\to\mathbb R^m$,雅可比矩阵 $J\in\mathbb R^{m\times n}$:

API模式计算一次调用得到
jax.jvp(f, (x,), (v,))前向$J v$(Jacobian-vector product),$v\in\mathbb R^n$雅可比的一列(输入方向 $v$ 上的方向导数)
jax.vjp(f, x) → vjp_fn(u)反向$u^\top J$(vector-Jacobian product),$u\in\mathbb R^m$雅可比的一行
  • jax.grad(f)(x) 本质上是 vjp_fn(1.0)($m=1$ 时 $J$ 只有一行,一次 VJP 就得到整个梯度)。
  • jax.jvp 同时返回函数值和方向导数。前向模式不用保存中间值,内存开销小。
  • jax.vjp 返回「函数值 + 一个可以多次调用的 vjp_fn」,前向阶段存下的中间值就放在 vjp_fn 里。
def F(x):
    # F: R^3 -> R^2
    return jnp.array([x[0] * x[1], jnp.sin(x[2]) + x[0]])

x = jnp.array([1.0, 2.0, 3.0])

# 前向模式:J @ v,v 取第 1 个基向量 -> 得到雅可比第 1 列
y, Jv = jax.jvp(F, (x,), (jnp.array([1.0, 0.0, 0.0]),))
print("F(x) =", y, "| J 的第 1 列 =", Jv)

# 反向模式:u^T @ J,u 取第 1 个基向量 -> 得到雅可比第 1 行
y, vjp_fn = jax.vjp(F, x)
(uJ,) = vjp_fn(jnp.array([1.0, 0.0]))
print("J 的第 1 行 =", uJ)

print("完整雅可比 J =\n", jax.jacrev(F)(x))

# grad 就是输出维度为 1 时的 vjp
def loss(x):
    return jnp.sum(x**2)

_, vjp_loss = jax.vjp(loss, x)
print("\nvjp_fn(1.0) =", vjp_loss(1.0)[0], "| grad =", jax.grad(loss)(x))
J_full = jax.jacrev(F)(x)
assert jnp.allclose(Jv, J_full[:, 0]) and jnp.allclose(uJ, J_full[0, :])   # jvp 得列,vjp 得行
assert jnp.allclose(vjp_loss(1.0)[0], jax.grad(loss)(x))                    # grad 就是 vjp(1.0)
F(x) = 
[2.      1.14112] | J 的第 1 列 = [2. 1.]
J 的第 1 行 = [2. 1. 0.]
完整雅可比 J =
 [[ 2.         1.         0.       ]
 [ 1.         0.        -0.9899925]]
vjp_fn(1.0) = [2. 4. 6.] | grad = [2. 4. 6.]

🔍 补充:jacfwd 与 jacrev 怎么选(决策规则 + 计时验证)

⚪ 选读:需要完整雅可比或 Hessian、关心计算效率时看。

计算 $f:\mathbb R^n\to\mathbb R^m$ 的完整雅可比 $J\in\mathbb R^{m\times n}$ 时:

  • jacfwd 用前向模式逐列计算,需要 $n$ 次 JVP(JAX 用 vmap 一次批量完成);
  • jacrev 用反向模式逐行计算,需要 $m$ 次 VJP,并且要保存前向中间值,内存开销更大。

两者算出来的结果一样,差别只在效率。由此可以得到下面的选择规则:

场景选择理由
输入维度 $n$ > 输出维度 $m$(「矮胖」雅可比)jax.jacrev只需 $m$ 次 VJP
输入维度 $n$ < 输出维度 $m$(「高瘦」雅可比)jax.jacfwd只需 $n$ 次 JVP
标量输出($m=1$,如损失函数)jax.gradjacrev 的特例,一次 VJP
$n\approx m$两个都行,偏向 jacfwd前向模式不保存中间值,更省内存
Hessian(标量函数的二阶导)jax.hessian = jacfwd(jacrev(f))内层梯度是 $n\to1$ 用反向;外层是 $n\to n$ 的雅可比,用前向更省内存
只需要 $Jv$ 或 $u^\top J$,不需要整个矩阵jax.jvp / jax.vjp不构造 $J$,开销与一次前向/反向相当

下面在 CPU 上计时验证一下(具体耗时因机器而异,看相对大小就行)。

注意:这里把权重矩阵作为参数传给被 jit 的函数,而不是靠闭包捕获。这是 JAX 推荐的做法(闭包捕获的数组会作为常量嵌进编译结果)。这样也能避开本环境 CPU 后端的一个已知问题:「闭包捕获大数组 + matmul + 归约」会悄悄算错。详见 00 · 本环境已知问题(根因见 进阶 02 §2.3)。

import time

def bench(fn, *args, n=20):
    """返回 fn(*args) 的平均耗时(毫秒);先运行一次触发编译。"""
    jax.block_until_ready(fn(*args))
    t0 = time.perf_counter()
    for _ in range(n):
        jax.block_until_ready(fn(*args))
    return (time.perf_counter() - t0) / n * 1e3

def layer(W, x):
    # 权重 W 作为参数传入;只对 x(argnums=1)求雅可比
    return jnp.tanh(W @ x)

W_wide = jax.random.normal(jax.random.key(0), (4, 2000))   # R^2000 -> R^4
W_tall = jax.random.normal(jax.random.key(1), (2000, 4))   # R^4 -> R^2000

jac_fwd = jax.jit(jax.jacfwd(layer, argnums=1))
jac_rev = jax.jit(jax.jacrev(layer, argnums=1))

results = {}
for name, W, x in [("R^2000 -> R^4 (n > m)", W_wide, jnp.ones(2000)),
                   ("R^4 -> R^2000 (n < m)", W_tall, jnp.ones(4))]:
    J_f, J_r = jac_fwd(W, x), jac_rev(W, x)
    # 正确性:jit 版本、非 jit 版本、两种模式之间都要一致
    assert jnp.allclose(J_f, J_r, atol=1e-5)
    assert jnp.allclose(J_r, jax.jacrev(layer, argnums=1)(W, x), atol=1e-5)
    t_f, t_r = bench(jac_fwd, W, x), bench(jac_rev, W, x)
    results[name] = (t_f, t_r)
    print(f"{name}: jacfwd {t_f:7.3f} ms | jacrev {t_r:7.3f} ms | 更快的是 {'jacfwd' if t_f < t_r else 'jacrev'}")

t_f, t_r = results["R^2000 -> R^4 (n > m)"]
print("\n规则「n > m 用 jacrev」是否得到印证:", t_r < t_f)
t_f, t_r = results["R^4 -> R^2000 (n < m)"]
print("规则「n < m 用 jacfwd」是否得到印证:", t_f < t_r)
R^2000 -> R^4 (n > m): jacfwd   2.887 ms | jacrev   0.031 ms | 更快的是 jacrev
R^4 -> R^2000 (n < m): jacfwd   0.251 ms | jacrev   1.885 ms | 更快的是 jacfwd

规则「n > m 用 jacrev」是否得到印证: True
规则「n < m 用 jacfwd」是否得到印证: True
# Hessian 的四种组合:结果相同,效率不同
def scalar_fn(W, x):
    return jnp.sum(jnp.tanh(W @ x) ** 2)

n = 300
W_sq = jax.random.normal(jax.random.key(2), (n, n)) / jnp.sqrt(n)
x0 = jax.random.normal(jax.random.key(3), (n,))

combos = {
    "jacfwd(jacrev)  [= jax.hessian]": jax.jacfwd(jax.jacrev(scalar_fn, argnums=1), argnums=1),
    "jacrev(jacrev)": jax.jacrev(jax.jacrev(scalar_fn, argnums=1), argnums=1),
    "jacrev(jacfwd)": jax.jacrev(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
    "jacfwd(jacfwd)": jax.jacfwd(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
}
H_ref = jax.hessian(scalar_fn, argnums=1)(W_sq, x0)
for name, fn in combos.items():
    fn_jit = jax.jit(fn)
    assert jnp.allclose(fn_jit(W_sq, x0), H_ref, atol=1e-4), name
    print(f"{name:34s}: {bench(fn_jit, W_sq, x0, n=10):7.2f} ms")
print("断言通过:四种组合得到的 Hessian 一致")
jacfwd(jacrev)  [= jax.hessian]   :    0.66 ms
jacrev(jacrev)                    :    1.86 ms
jacrev(jacfwd)                    :    2.87 ms
jacfwd(jacfwd)                    :    2.26 ms
断言通过:四种组合得到的 Hessian 一致

从计时可以看到,内层用反向模式的两种组合(jacfwd(jacrev)、jacrev(jacrev))明显比内层用前向模式的快,因为内层算的是标量函数的梯度($n\to1$)。这两种组合之间差得不多(谁更快因机器而异)。jax.hessian 选 jacfwd(jacrev),是因为外层用前向模式不用保存反向需要的中间值,更省内存。

3.4 关于参数求导

jax.grad 默认对第一个参数求导。但机器学习里的损失函数通常同时接收模型参数和数据,而我们只需要对参数求梯度。argnums 参数就是用来指定对哪个(或哪几个)位置参数求导的。

指定单个参数

以 $f(x,y)=x^2 y$ 为例,$\dfrac{\partial f}{\partial x}=2xy$,$\dfrac{\partial f}{\partial y}=x^2$。取 $x=3,\ y=4$:

import jax
import jax.numpy as jnp

def power_product(x, y):
    """计算 x^2 * y"""
    return (x**2) * y

# 定义一些输入值
x_val = 3.0
y_val = 4.0

print(f"函数输出: {power_product(x_val, y_val)}")
函数输出: 36.0
# 对第一个参数 (x) 求导 - 默认行为
grad_f_wrt_x = jax.grad(power_product)
gradient_x = grad_f_wrt_x(x_val, y_val)

print(f"关于 x 的梯度 (默认): {gradient_x}")  # 预期: 2 * 3.0 * 4.0 = 24.0
关于 x 的梯度 (默认): 24.0

argnums 的索引从 0 开始,argnums=1 表示对第二个参数 $y$ 求导:

# 使用 argnums=1 对第二个参数 (y) 求梯度
grad_f_wrt_y = jax.grad(power_product, argnums=1)
gradient_y = grad_f_wrt_y(x_val, y_val)

print(f"关于 y 的梯度 (argnums=1): {gradient_y}")  # 预期: 3.0**2 = 9.0
关于 y 的梯度 (argnums=1): 9.0

指定多个参数

给 argnums 传一个整数元组,梯度函数就返回一个元组,元素顺序和 argnums 里的一致。没被选中的参数,求导时当作常数。

以仿射函数 $g(w,b,x)=wx+b$ 为例:$\dfrac{\partial g}{\partial w}=x$,$\dfrac{\partial g}{\partial b}=1$。

def affine(w, b, x):
    """计算 w*x + b"""
    return w * x + b

# 定义输入值
w_val = 2.0
b_val = 1.0
x_data = 5.0

print(f"仿射函数输出: {affine(w_val, b_val, x_data)}")

# 对第一个 (w) 和第二个 (b) 参数求梯度
grad_g_wrt_wb = jax.grad(affine, argnums=(0, 1))

# 注意: 在求导过程中,输入 x_data 被视为常数
gradients_wb = grad_g_wrt_wb(w_val, b_val, x_data)

print(f"关于 (w, b) 的梯度 (使用 argnums=(0, 1)): {gradients_wb}")
# 预期: (关于 w 的梯度, 关于 b 的梯度) = (x, 1) = (5.0, 1.0)
仿射函数输出: 11.0
关于 (w, b) 的梯度 (使用 argnums=(0, 1)): (Array(5., dtype=float32, weak_type=True), Array(1., dtype=float32, weak_type=True))

与机器学习的联系

训练时要的是损失对参数的梯度 $\nabla_{\text{params}}\,\text{loss}(\text{params},\ \text{data\_batch})$,数据批次只是固定的输入。所以通常把参数放在第一个位置,用默认的 argnums=0 就行。

真实模型的参数通常是嵌套的 dict / list / tuple,JAX 管这叫 pytree。jax.grad 直接支持 pytree,返回的梯度和参数的结构一模一样。所以更新参数时,要用 jax.tree.map 对每个叶子(数组)分别做 p - lr * g。

⚠️ 注意:updated_params = current_params - learning_rate * gradients 这种写法,只在参数是单个数组时成立。参数是 dict 时会报 TypeError: unsupported operand type(s),要改用 jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)。

下面把这个例子补全成可以运行的代码:

# 参数是一个 dict(pytree)
params = {
    "w": jnp.array([1.0, -2.0]),
    "b": jnp.array(0.5),
}
batch = {
    "inputs": jnp.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
    "targets": jnp.array([1.0, 0.0, -1.0]),
}

def model_apply(params, x):
    return x @ params["w"] + params["b"]

def loss_function(params, data_batch):
    predictions = model_apply(params, data_batch["inputs"])
    error = predictions - data_batch["targets"]
    return jnp.mean(error**2)  # 均方误差

grad_loss_wrt_params = jax.grad(loss_function, argnums=0)
gradients = grad_loss_wrt_params(params, batch)
print("gradients =", gradients)
assert jax.tree.structure(gradients) == jax.tree.structure(params)
print("断言通过:梯度的 pytree 结构与参数相同")
gradients = {'b': Array(-9., dtype=float32, weak_type=True), 'w': Array([-29.666668, -38.666668], dtype=float32)}
断言通过:梯度的 pytree 结构与参数相同
# ❌ 错误写法:参数是 dict 时不能直接做算术
learning_rate = 0.01
try:
    updated_params = params - learning_rate * gradients
    dict_arith_raised = False
except TypeError as e:
    dict_arith_raised = True
    print("报错 TypeError:", e)
assert dict_arith_raised
报错 TypeError: unsupported operand type(s) for *: 'float' and 'dict'
# ✅ 推荐写法:用 jax.tree.map 对每个叶子更新
updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, gradients)

loss_before = loss_function(params, batch)
loss_after = loss_function(updated_params, batch)
print("更新前 loss:", loss_before)
print("更新后 loss:", loss_after)
assert loss_after < loss_before
assert jnp.allclose(updated_params["w"], params["w"] - learning_rate * gradients["w"])
print("断言通过:tree.map 正确更新了每个叶子,loss 下降")
更新前 loss: 20.916668
更新后 loss: 3.983675
断言通过:tree.map 正确更新了每个叶子,loss 下降

🔍 补充:has_aux=True——函数还要返回其他东西时

🟢 必读

grad 要求函数返回标量。可损失函数往往还想顺便带回预测值、准确率这类辅助信息,直接返回元组又会报错。正确做法是返回 (loss, aux),再加上 has_aux=True。这时梯度函数返回 (grads, aux),aux 不参与求导。

def loss_with_metrics(params, data_batch):
    predictions = model_apply(params, data_batch["inputs"])
    loss = jnp.mean((predictions - data_batch["targets"]) ** 2)
    aux = {"predictions": predictions, "max_abs_err": jnp.max(jnp.abs(predictions - data_batch["targets"]))}
    return loss, aux

# 不加 has_aux:返回元组会报错
try:
    jax.grad(loss_with_metrics)(params, batch)
except TypeError as e:
    print("未加 has_aux 报错:", str(e).splitlines()[0])

# 加上 has_aux=True
grads, aux = jax.grad(loss_with_metrics, has_aux=True)(params, batch)
print("\ngrads:", grads)
print("aux  :", aux)
未加 has_aux 报错: Gradient only defined for scalar-output functions. Output was (Array(20.916668, dtype=float32), {'max_abs_err': Array(5.5, dtype=float32), 'predictions': Array([-2.5, -4.5, -6.5], dtype=float32)}).

grads: {'b': Array(-9., dtype=float32, weak_type=True), 'w': Array([-29.666668, -38.666668], dtype=float32)}
aux  : {'max_abs_err': Array(5.5, dtype=float32), 'predictions': Array([-2.5, -4.5, -6.5], dtype=float32)}

3.5 高阶导数(grad 的 grad)

jax.grad 接收一个输出标量的函数 $f$,返回一个计算梯度的新 Python 函数。JAX 的变换输入是 Python 函数,输出也是 Python 函数,所以变换的结果可以再拿去变换。对一元标量函数,grad 得到的仍然是标量函数,于是可以再 grad 一次,得到二阶导数。

以 $f(x)=x^3$ 为例:$f'(x)=3x^2$,$f''(x)=6x$。在 $x=2$ 处:$f=8,\ f'=12,\ f''=12$。

import jax
import jax.numpy as jnp

def f(x):
    return x**3

# 一阶导数
grad_f = jax.grad(f)
# 二阶导数:对一阶导数函数再求导
grad_grad_f = jax.grad(grad_f)

x = 2.0
print(f"f({x})   = {f(x)}")            # 8.0
print(f"f'({x})  = {grad_f(x)}")       # 3 * 2^2 = 12.0
print(f"f''({x}) = {grad_grad_f(x)}")  # 6 * 2 = 12.0
f(2.0)   = 8.0
f'(2.0)  = 12.0
f''(2.0) = 12.0

也可以直接嵌套着写。同样的办法能推广到三阶甚至更高阶,代价是计算量变大,数值稳定性也可能变差:

# 嵌套写法
second_derivative = jax.grad(jax.grad(f))
print("f''(2.0)  =", second_derivative(2.0))   # 12.0

# 三阶导数 f⁽³⁾(x) = 6
third_derivative = jax.grad(jax.grad(jax.grad(f)))
print("f⁽³⁾(2.0) =", third_derivative(2.0))    # 6.0
f''(2.0)  = 12.0
f⁽³⁾(2.0) = 6.0

多元函数与 Hessian

对多元函数,所有二阶偏导组成 Hessian 矩阵:

$$ H_{ij}=\frac{\partial^2 f}{\partial x_i\,\partial x_j} $$

例:$f(x,y)=x^2y+y^3$。

$$ \frac{\partial f}{\partial x}=2xy,\quad \frac{\partial^2 f}{\partial x^2}=2y,\quad \frac{\partial^2 f}{\partial y\,\partial x}=2x $$

两次都对 $x$ 求导(argnums=0),得到 $\partial^2 f/\partial x^2$。第一次对 $x$、第二次改用 argnums=1 对 $y$ 求导,得到混合偏导。在 $(2,3)$ 处两者分别是 $6$ 和 $4$:

def f_xy(x, y):
    return x**2 * y + y**3

x, y = 2.0, 3.0

# ∂f/∂x
df_dx = jax.grad(f_xy, argnums=0)

# ∂²f/∂x²:再对 x 求导
d2f_dx2 = jax.grad(df_dx, argnums=0)
print("∂²f/∂x²  在 (2,3) =", d2f_dx2(x, y))   # 2y = 6.0

# ∂²f/∂y∂x:对 ∂f/∂x 再对 y 求导
d2f_dydx = jax.grad(df_dx, argnums=1)
print("∂²f/∂y∂x 在 (2,3) =", d2f_dydx(x, y))  # 2x = 4.0
∂²f/∂x²  在 (2,3) = 6.0
∂²f/∂y∂x 在 (2,3) = 4.0

要算完整的 Hessian,可以直接用 jax.hessian,通常更高效。

🔍 补充:jax.hessian 的用法与原理

⚪ 选读:需要二阶导、Hessian 或 Hessian-向量积时看。

  • jax.hessian(f) 的实现就是 jax.jacfwd(jax.jacrev(f)),也就是「先反向、再前向」(forward-over-reverse)。内层 jacrev 求梯度(输入多、输出 1 个,适合反向),外层 jacfwd 对梯度再求雅可比(输入 $n$、输出 $n$,前向模式的内存开销更小)。
  • jax.hessian 默认对第一个参数求导,所以多元函数通常把变量打包成一个向量传进去。也可以用 argnums=(0, 1),这时返回的是嵌套元组 ((H_xx, H_xy), (H_yx, H_yy))。
  • 完整 Hessian 有 $n^2$ 个元素,$n$ 很大时根本存不下。很多算法(共轭梯度、牛顿-CG)只需要 Hessian-向量积 $Hv$,可以用 jax.jvp(jax.grad(f), (x,), (v,)) 计算,不必构造整个矩阵(见练习 C)。
# 把 (x, y) 打包成向量
def f_vec(v):
    x, y = v
    return x**2 * y + y**3

v = jnp.array([2.0, 3.0])
H = jax.hessian(f_vec)(v)
print("Hessian =\n", H)
# 解析解: [[2y, 2x], [2x, 6y]] = [[6, 4], [4, 18]]

assert jnp.allclose(H, jnp.array([[6.0, 4.0], [4.0, 18.0]]))
assert jnp.allclose(H, jax.jacfwd(jax.jacrev(f_vec))(v))
print("断言通过:与解析解、jacfwd(jacrev(f)) 一致")

# 多参数形式:argnums=(0, 1) 返回嵌套元组
H_tuple = jax.hessian(f_xy, argnums=(0, 1))(2.0, 3.0)
print("argnums=(0,1) 的结果:", H_tuple)
Hessian =
 [[ 6.  4.]
 [ 4. 18.]]
断言通过:与解析解、jacfwd(jacrev(f)) 一致
argnums=(0,1) 的结果: ((Array(6., dtype=float32, weak_type=True), Array(4., dtype=float32, weak_type=True)), (Array(4., dtype=float32, weak_type=True), Array(18., dtype=float32, weak_type=True)))

3.6 值和梯度(jax.value_and_grad)

训练模型时,通常既要梯度(拿来更新参数),又要损失值(拿来看训练进度)。如果分别调用 f(x) 和 jax.grad(f)(x),前向计算就做了两遍。其实反向模式本来就要先做一次前向传播,函数值在这一步已经算出来了。

jax.value_and_grad(f) 返回一个新函数,调用后返回元组 (value, gradient),只做一次前向计算。

例:$p(x)=x^3+2x^2-3x+1$,$p'(x)=3x^2+4x-3$。在 $x=2$ 处:$p(2)=8+8-6+1=11$,$p'(2)=12+8-3=17$。

import jax
import jax.numpy as jnp

def polynomial(x):
    return x**3 + 2 * x**2 - 3 * x + 1

# 分开计算:前向计算会做两遍
value = polynomial(2.0)
gradient = jax.grad(polynomial)(2.0)
print(f"分开计算: value = {value}, gradient = {gradient}")

# 用 value_and_grad 一次得到两者
value_and_grad_fn = jax.value_and_grad(polynomial)
value, gradient = value_and_grad_fn(2.0)
print(f"value_and_grad: value = {value}, gradient = {gradient}")  # 11.0, 17.0
分开计算: value = 11.0, gradient = 17.0
value_and_grad: value = 11.0, gradient = 17.0

value_and_grad 同样支持 argnums:

  • argnums 是整数时,返回的梯度就对应这一个参数;
  • argnums 是整数元组时,返回的梯度也是元组,每个元素对应一个指定的参数。

例:$m(a,b)=a^2\sin b$,$\dfrac{\partial m}{\partial a}=2a\sin b$,$\dfrac{\partial m}{\partial b}=a^2\cos b$。取 $a=3,\ b=\pi/2$:函数值是 $9$,对 $a$ 的梯度是 $6$,对 $b$ 的梯度的数学值是 $0$。

⚠️ 注意:对 $b$ 的梯度打印出来不是 0,而是约 -3.93e-07(float32)。原因和 3.2 一样,是 $\pi/2$ 在 float32 下有舍入,所以要用 jnp.allclose 判断。

def multi_arg_func(a, b):
    return a**2 * jnp.sin(b)

a, b = 3.0, jnp.pi / 2

# 只对 a 求导
value, grad_a = jax.value_and_grad(multi_arg_func, argnums=0)(a, b)
print(f"argnums=0     : value = {value}, ∂/∂a = {grad_a}")  # 9.0, 6.0

# 对 a 和 b 都求导
value, (grad_a, grad_b) = jax.value_and_grad(multi_arg_func, argnums=(0, 1))(a, b)
print(f"argnums=(0,1) : value = {value}, ∂/∂a = {grad_a}, ∂/∂b = {grad_b}")

assert jnp.allclose(value, 9.0) and jnp.allclose(grad_a, 6.0)
assert grad_b != 0.0 and jnp.allclose(grad_b, 0.0, atol=1e-5)   # 不是精确 0,但在精度内为 0
print("断言通过:value=9, ∂/∂a=6, ∂/∂b≈0")
argnums=0     : value = 9.0, ∂/∂a = 6.0
argnums=(0,1) : value = 9.0, ∂/∂a = 6.0, ∂/∂b = -3.934025016860687e-07
断言通过:value=9, ∂/∂a=6, ∂/∂b≈0

梯度下降这类优化算法每一步都要「损失 + 梯度」,所以 value_and_grad 是训练循环的标准写法,也可以和 jax.jit 组合使用(见 3.9 的线性回归例子)。

🔍 补充:与 has_aux 组合时的返回结构

🟢 必读

实际写代码时,最常见的组合是 jax.value_and_grad(loss_fn, has_aux=True),它返回的结构是 ((loss, aux), grads)。值的部分就是函数原样返回的 (loss, aux) 元组。解包时括号很容易写错:

def loss_with_acc(w, x, y):
    logits = x * w
    loss = jnp.mean((logits - y) ** 2)
    acc = jnp.mean(jnp.sign(logits) == jnp.sign(y))   # 辅助信息:符号预测准确率
    return loss, acc

x = jnp.array([1.0, -2.0, 3.0])
y = jnp.array([2.0, -4.0, -1.0])

(loss, acc), g = jax.value_and_grad(loss_with_acc, has_aux=True)(1.0, x, y)
print(f"loss = {loss:.4f}, acc = {acc:.3f}, grad = {g:.4f}")
# 解析梯度:mean(2 (w x - y) x)
assert jnp.allclose(g, jnp.mean(2 * (1.0 * x - y) * x))
loss = 7.0000, acc = 0.667, grad = 4.6667

3.7 求导与控制流

grad 求导的对象,是函数在当前输入下实际走过的那条路径。要理解这一点,关键是分清两种追踪方式:

单独使用 jax.gradjax.jit(或 jit(grad(f)))
追踪器带具体数值(打印为 GradTracer(primal=...))抽象值,只有形状和 dtype
何时追踪每次调用都重新追踪对每种输入形状/dtype 只追踪、编译一次
Python if x > 0可以,按当前 x 的真实值选分支报 TracerBoolConversionError
Python for/while(次数依赖数值)可以报错

实际训练时 grad 几乎总是放在 jit 里,所以依赖数值的控制流要改用 jax.lax.cond、jnp.where、jax.lax.fori_loop、jax.lax.scan 这些结构化控制流原语。

⚠️ 注意:容易以为「JAX 对给定的输入形状和类型只追踪一次」,所以 grad 只能对追踪时走过的那一个分支求导,Python if 在 x=-2.0 时还会报「抽象追踪器值的真值不明确」。其实「只追踪一次」和这个报错都是 jit 的行为。单独用 grad 时,Python if 完全正常(x=-2.0 返回 -1.0);jit(grad(f))(-2.0) 才会报 TracerBoolConversionError。

条件语句

看课程里的例子:$x>0$ 时返回 $x^2$,否则返回 $-x$。

import jax
import jax.numpy as jnp

# ❌ 常见误解:以为下面对 x=-2.0 的调用会因「追踪器真值不明确」而报错
def conditional_func(x):
    if x > 0:
        return x * x
    else:
        return -x

grad_conditional = jax.grad(conditional_func)

try:
    g_pos, g_neg = grad_conditional(2.0), grad_conditional(-2.0)
    print("实测:没有报错。grad 在 x=2.0 :", g_pos, "| x=-2.0 :", g_neg)
except Exception as e:
    g_pos = g_neg = None
    print("报错:", type(e).__name__, e)

# 断言:单独用 grad 时 Python if 正常工作,两个分支的梯度都正确
assert g_pos == 4.0 and g_neg == -1.0
实测:没有报错。grad 在 x=2.0 : 4.0 | x=-2.0 : -1.0
# 原因:单独 grad 时追踪器带有具体数值
seen = []
def peek(x):
    print("  grad 内部看到的 x:", x)
    seen.append(repr(x))
    return x * x

jax.grad(peek)(2.0)
assert "primal=2.0" in seen[0]   # 单独 grad:追踪器带着具体数值 2.0

# 真正会报错的是 jit:jit 的追踪器是抽象值
try:
    jax.jit(grad_conditional)(-2.0)
    jit_if_raised = False
except jax.errors.TracerBoolConversionError as e:
    jit_if_raised = True
    print("\njit(grad) 报错: TracerBoolConversionError")
    print(" ", str(e).splitlines()[0])
assert jit_if_raised, "jit 下 Python if 作用于追踪值应当报错"
print("断言通过:grad 单独使用不报错,套上 jit 才报错")
  grad 内部看到的 x: GradTracer(primal=2.0, typeof(tangent)=f32[])

jit(grad) 报错: TracerBoolConversionError
  Attempted boolean conversion of traced array with shape bool[].
断言通过:grad 单独使用不报错,套上 jit 才报错

用 jax.lax.cond 改写(在 jit 下也能用)

jax.lax.cond(pred, true_fun, false_fun, *operands) 根据 pred 决定执行 true_fun(*operands) 还是 false_fun(*operands)。两个分支都会被追踪,到运行时才选,所以既能 grad 也能 jit。两个分支返回的结果,形状和 dtype 必须相同。操作数可以写成多个位置参数(*operands)。

如果是逐元素二选一,也可以用 jnp.where(pred, a, b),不过要小心它的 NaN 梯度陷阱(见 3.8 的专题)。

# ✅ 推荐写法:lax.cond,grad 与 jit(grad) 都正确
def conditional_func_lax(x):
    return jax.lax.cond(
        x > 0,              # 条件
        lambda x: x * x,    # 条件为真时执行
        lambda x: -x,       # 条件为假时执行
        x,                  # 操作数
    )

grad_cond_lax = jax.grad(conditional_func_lax)
grad_cond_lax_jit = jax.jit(grad_cond_lax)
for x in [2.0, -2.0]:
    print(f"x={x:5}: grad = {grad_cond_lax(x)}, jit(grad) = {grad_cond_lax_jit(x)}")

assert grad_cond_lax(2.0) == 4.0 and grad_cond_lax(-2.0) == -1.0
assert grad_cond_lax_jit(2.0) == 4.0 and grad_cond_lax_jit(-2.0) == -1.0
print("断言通过")
x=  2.0: grad = 4.0, jit(grad) = 4.0
x= -2.0: grad = -1.0, jit(grad) = -1.0
断言通过

循环

静态循环:如果 for 循环的次数在追踪时就已知(比如是 Python 常量),它会被展开成一串普通运算,求导没问题。比如循环 3 次、每次乘 2,$y=8x$,梯度是 $8$。缺点是次数很大时,展开后的计算图会非常大,编译变慢。

def fixed_loop(x, n=3):
    y = x
    for _ in range(n):   # n 在追踪时是已知的 Python 整数,循环会被展开
        y = y * 2
    return y

print("fixed_loop 梯度:", jax.grad(fixed_loop)(1.0))   # 8.0
assert jax.jit(jax.grad(fixed_loop))(1.0) == 8.0
fixed_loop 梯度: 8.0

动态循环:如果循环次数或 while 条件依赖被追踪的值,普通 Python 循环在 jit 下就跑不了,要改用结构化控制流原语。但这些原语对反向求导(grad/vjp)的支持不一样:

原语前向模式(jvp/jacfwd)反向模式(grad/vjp)
lax.scan(f, init, xs, length)✅✅
lax.fori_loop(lo, hi, body, init),lo/hi 为具体整数✅✅(内部转成 scan)
lax.fori_loop,lo/hi 为追踪值(如 jit 中的普通参数)✅❌(退化为 while_loop)
lax.while_loop(cond, body, init)✅❌

反向模式要保存每一步的中间值,而 while_loop 的步数在编译时不知道,没法提前分配空间,所以不支持。需要反向求导的循环,就用 lax.scan(固定次数),或者把次数声明成静态参数(jax.jit(..., static_argnums=...))。

⚠️ 注意:jax.lax.while_loop 和上下界是追踪值的 fori_loop,都不能拿来做需要反向求导的循环。grad 穿过它们会报 ValueError: Reverse-mode differentiation does not work for lax.while_loop ...,它们只支持前向模式。下面的 fori_loop 示例之所以能跑通,是因为单独 grad 时 n=3 是具体整数。

body_fun(i, val) 返回新的 val:

def dynamic_loop_lax(x, n):
    # body_fun(i, val) -> 新的 val
    return jax.lax.fori_loop(0, n, lambda i, val: val * 2, x)

# 单独 grad:n=3 是具体整数,fori_loop 被转成 scan,可以反向求导
print("fori_loop 梯度 (n=3):", jax.grad(dynamic_loop_lax)(1.0, 3))   # 8.0
assert jax.grad(dynamic_loop_lax)(1.0, 3) == 8.0
fori_loop 梯度 (n=3): 8.0
# ❌ 常见误解:以为 while_loop / 动态 fori_loop 可以用来做可反向求导的动态循环
def double_until(x):
    # 状态 (值, 计数器),循环 3 次
    return jax.lax.while_loop(lambda c: c[1] < 3, lambda c: (c[0] * 2, c[1] + 1), (x, 0))[0]

def raises_reverse_mode_error(fn, *args):
    try:
        fn(*args)
        return False
    except ValueError as e:
        print("  报错:", str(e).splitlines()[0])
        return True

print("grad 穿过 while_loop:")
assert raises_reverse_mode_error(jax.grad(double_until), 1.0)
print("jit 中 n 为追踪值的 fori_loop:")
assert raises_reverse_mode_error(jax.jit(jax.grad(dynamic_loop_lax)), 1.0, 3)

# while_loop 支持前向模式
primal, tangent = jax.jvp(double_until, (1.0,), (1.0,))
print("while_loop + jvp:", primal, tangent)
assert tangent == 8.0
grad 穿过 while_loop:
  报错: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.
jit 中 n 为追踪值的 fori_loop:
  报错: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values. Try using lax.scan, or using fori_loop with static start/stop.
while_loop + jvp: 8.0 8.0
# ✅ 推荐写法 1:把循环次数声明为静态参数
grad_static = jax.jit(jax.grad(dynamic_loop_lax), static_argnums=1)
print("static_argnums:", grad_static(1.0, 3))
assert grad_static(1.0, 3) == 8.0

# ✅ 推荐写法 2:用 lax.scan 写固定次数的循环
def double_scan(x, n=3):
    y, _ = jax.lax.scan(lambda c, _: (c * 2, None), x, None, length=n)
    return y

print("scan + jit(grad):", jax.jit(jax.grad(double_scan))(1.0))
assert jax.jit(jax.grad(double_scan))(1.0) == 8.0
print("断言通过")
static_argnums: 8.0
scan + jit(grad): 8.0
断言通过

最后提醒两点。第一,在 jit 里不用这些原语,追踪时就会报错。第二,就算不用 jit,grad 也只对当前输入走过的路径求导,在分支边界附近得到的梯度可能会误导人。

3.8 局限性与注意事项

1. 不可微的操作

自动微分靠的是链式法则,要求每个基本运算都可导。下面这些操作在数学上不可导,或者导数几乎处处为零:

  • 不连续函数:jnp.sign、jnp.round、jnp.floor、jnp.ceil 等,JAX 把它们的导数定义为 0;
  • 转换为整数:x.astype(jnp.int32) 是阶梯函数,梯度到这里就被截断成 0(如果函数输出本身是整数,grad 会直接报 TypeError);
  • 比较运算:x > 0 这类运算的结果是布尔值,在输入附近是常数,梯度为零。

在尖点(导数不存在的点)上,JAX 给每个原语都规定了确定的取值,不会随平台变化:

表达式在 0 处的梯度(实测)说明
jnp.abs1.0取右导数
jax.nn.relu0.0取左导数
jnp.sign0.0导数处处定义为 0
jnp.sqrtinf$\frac{1}{2\sqrt x}$ 在 0 处发散
jnp.linalg.norm(零向量)nan内部是 $\frac{x}{\lVert x\rVert}=\frac{0}{0}$

真正的 NaN 主要出在后两行这种「导数是无穷大或 0/0」的点上(inf 再乘上 0 也会变成 NaN),下面的专题会专门讲。如果想在不连续的地方得到有用的梯度,有几个办法:用平滑函数代替(例如用 jax.nn.sigmoid(k*x) 近似阶跃函数);用 straight-through estimator(见下文 stop_gradient);或者换用梯度下降以外的优化方法。

⚠️ 注意:jax.grad(jnp.abs)(0.0) 是 1.0,不是 0.0。

⚠️ 注意:jax.grad(jnp.sign)(0.0) 是确定的 0.0(sign 的导数被定义为处处为 0),不会是 NaN,也不随平台变化。

import jax
import jax.numpy as jnp
import numpy as np

# 课程示例:先转整数再平方
def cast_and_square(x):
    x_int = x.astype(jnp.int32)          # 阶梯函数:信息被截断
    return x_int.astype(jnp.float32) ** 2

g_cast = jax.grad(cast_and_square)(2.7)
print("cast_and_square(2.7) 的梯度:", g_cast)
assert g_cast == 0.0

# 其他不连续函数和比较运算:梯度为 0
for name, fn in [("sign", jnp.sign), ("round", jnp.round), ("floor", jnp.floor), ("ceil", jnp.ceil),
                 ("(x>1).astype(float)", lambda x: (x > 1).astype(jnp.float32))]:
    g = jax.grad(fn)(2.7)
    print(f"grad({name})(2.7) = {g}")
    assert g == 0.0

# 输出本身是整数时,grad 直接报错
try:
    jax.grad(lambda x: x.astype(jnp.int32))(2.7)
except TypeError as e:
    print("\n整数输出报错:", str(e).splitlines()[0])
cast_and_square(2.7) 的梯度: 0.0
grad(sign)(2.7) = 0.0
grad(round)(2.7) = 0.0
grad(floor)(2.7) = 0.0
grad(ceil)(2.7) = 0.0
grad((x>1).astype(float))(2.7) = 0.0

整数输出报错: grad requires real-valued outputs (output dtype that is a sub-dtype of np.floating), but got int32. For differentiation of functions with integer outputs, use jax.vjp directly.
# ❌ 常见误解:以为 grad(abs)(0.0) 通常为 0.0,grad(sign)(0.0) 常为 NaN 或与平台相关
course_claim = {"grad(abs)(0.0)": 0.0, "grad(sign)(0.0)": "NaN 或与平台相关"}
measured = {"grad(abs)(0.0)": jax.grad(jnp.abs)(0.0), "grad(sign)(0.0)": jax.grad(jnp.sign)(0.0)}
for k in course_claim:
    print(f"{k:16s} 常见说法: {course_claim[k]!s:18s} 实际: {measured[k]}")
grad(abs)(0.0)   常见说法: 0.0                实际: 1.0
grad(sign)(0.0)  常见说法: NaN 或与平台相关         实际: 0.0
# ✅ 正确结论:尖点处的取值是确定的
assert jax.grad(jnp.abs)(0.0) == 1.0
assert jax.grad(jnp.sign)(0.0) == 0.0
assert jax.grad(jax.nn.relu)(0.0) == 0.0
assert jnp.isinf(jax.grad(jnp.sqrt)(0.0))
assert jnp.all(jnp.isnan(jax.grad(jnp.linalg.norm)(jnp.zeros(3))))
print("断言通过:abs→1.0,sign→0.0,relu→0.0,sqrt→inf,norm(0)→nan")

# 平滑近似:用 sigmoid(k x) 近似阶跃函数,梯度不再为零
k = 5.0
g_smooth = jax.grad(lambda x: jax.nn.sigmoid(k * x))(0.1)
print("grad(sigmoid(k x))(0.1) =", g_smooth)
assert g_smooth > 0
断言通过:abs→1.0,sign→0.0,relu→0.0,sqrt→inf,norm(0)→nan
grad(sigmoid(k x))(0.1) = 1.1750185

🔍 补充(专题):NaN 梯度与「双 where」技巧

🟢 必读

现象

为了避开 sqrt(0)、log(0) 这类危险点,很多人会写 jnp.where(x > 0, jnp.log(x), 0.0),以为 $x\le0$ 时就会选 0.0。前向结果确实没错,但梯度还是 NaN。

原理:where 挡不住反向传播里的 $0\times\infty$

设 $y=\text{where}(c,\ a(x),\ b(x))$。jnp.where 会先把两个分支都算出来,再逐元素挑选。反向传播时,上游梯度 $\bar y$ 按条件分给两个分支:

$$ \bar a = \text{where}(c,\ \bar y,\ 0),\qquad \bar b = \text{where}(c,\ 0,\ \bar y) $$

接着各分支继续按链式法则往回传:$\bar x = \bar a\cdot a'(x) + \bar b\cdot b'(x)$。

当 $c$ 为假时,$\bar a=0$。这时如果 $a'(x)$ 是有限值,$0\times a'(x)=0$,一切正常。但如果危险分支在这个点的导数是 $\pm\infty$(比如 $\log'(0)=\frac10$)或 NaN,就会得到

$$ 0\times\infty=\text{NaN}\quad(\text{IEEE 754 规定}) $$

NaN 加上任何数都还是 NaN,于是 $\bar x$ 也成了 NaN。换句话说,where 只挑了前向的值,却挡不住反向时没选中的分支带来的「$0\times\infty$」。

「双 where」技巧

问题的根源在于,危险分支拿危险输入算了一遍。解决办法是:先用第一个 where 把危险输入换成一个安全值(只要能让危险分支的导数有限就行),再用第二个 where 选输出:

x_safe = jnp.where(cond, x, SAFE)               # 第 1 个 where:危险分支永远不会看到危险输入
y = jnp.where(cond, danger_fn(x_safe), fallback)  # 第 2 个 where:选择输出

这样一来,没选中的分支的导数 $a'(\text{SAFE})$ 是有限值,$0\times\text{有限值}=0$。而且 $c$ 为假时,第一个 where 把梯度引到常数 SAFE 上,不会传回 $x$。

常见场景
场景危险点❌ 会得到 NaN 梯度的写法✅ 双 where 写法
sqrt$x=0$where(x>0, sqrt(x), 0.)xs=where(x>0, x, 1.);where(x>0, sqrt(xs), 0.)
log$x=0$where(x>0, log(x), 0.)xs=where(x>0, x, 1.);where(x>0, log(xs), 0.)
向量范数$\mathbf v=\mathbf 0$where(sum(v**2)==0, 0., norm(v))s=sum(v**2);ss=where(s==0, 1., s);where(s==0, 0., sqrt(ss))
除法分母 $y=0$where(y!=0, a/y, 0.)ys=where(y!=0, y, 1.);where(y!=0, a/ys, 0.)
arcsin$\lvert x\rvert=1$where(abs(x)<1, arcsin(x), sign(x)*π/2)xs=where(abs(x)<1, x, 0.);where(abs(x)<1, arcsin(xs), sign(x)*π/2)

先用一个最小的例子把反向传播拆开,亲眼看看 $0\times\infty$:

# 手动拆开 where(x>0, log(x), 0.) 在 x=0 处的反向传播
x = jnp.float32(0.0)
y_bar = 1.0                                  # 上游梯度
a_bar = jnp.where(x > 0, y_bar, 0.0)         # 分给 log 分支的梯度:0
dlog_dx = 1.0 / x                            # log'(0) = inf
print("ā =", a_bar, "| log'(0) =", dlog_dx, "| ā × log'(0) =", a_bar * dlog_dx)
assert jnp.isnan(a_bar * dlog_dx)            # 0 × inf = nan
ā = 0.0 | log'(0) = inf | ā × log'(0) = nan
# ❌ 朴素写法(单个 where):前向值正确,但梯度是 NaN
naive = {
    "sqrt":   (lambda x: jnp.where(x > 0, jnp.sqrt(x), 0.0),                       0.0),
    "log":    (lambda x: jnp.where(x > 0, jnp.log(x), 0.0),                        0.0),
    "norm":   (lambda v: jnp.where(jnp.sum(v**2) == 0, 0.0, jnp.linalg.norm(v)),   jnp.zeros(3)),
    "除法":   (lambda y: jnp.where(y != 0, 2.0 / y, 0.0),                          0.0),
    "arcsin": (lambda x: jnp.where(jnp.abs(x) < 1, jnp.arcsin(x), jnp.sign(x) * jnp.pi / 2), 1.0),
}
for name, (fn, x_bad) in naive.items():
    val, g = jax.value_and_grad(fn)(x_bad)
    print(f"{name:6s} 危险点: 值 = {val:.4f}, 梯度 = {g}")
    assert jnp.all(jnp.isfinite(val)) and jnp.any(jnp.isnan(g))   # 值有限、梯度 NaN
sqrt   危险点: 值 = 0.0000, 梯度 = nan
log    危险点: 值 = 0.0000, 梯度 = nan
norm   危险点: 值 = 0.0000, 梯度 = [nan nan nan]
除法     危险点: 值 = 0.0000, 梯度 = nan
arcsin 危险点: 值 = 1.5708, 梯度 = nan
# ✅ 推荐写法(双 where):危险分支永远看不到危险输入
def safe_sqrt(x):
    xs = jnp.where(x > 0, x, 1.0)
    return jnp.where(x > 0, jnp.sqrt(xs), 0.0)

def safe_log(x):
    xs = jnp.where(x > 0, x, 1.0)
    return jnp.where(x > 0, jnp.log(xs), 0.0)

def safe_norm(v):
    s = jnp.sum(v**2)
    ss = jnp.where(s == 0, 1.0, s)
    return jnp.where(s == 0, 0.0, jnp.sqrt(ss))

def safe_div(y, a=2.0):
    ys = jnp.where(y != 0, y, 1.0)
    return jnp.where(y != 0, a / ys, 0.0)

def safe_arcsin(x):
    inside = jnp.abs(x) < 1
    xs = jnp.where(inside, x, 0.0)
    return jnp.where(inside, jnp.arcsin(xs), jnp.sign(x) * jnp.pi / 2)

safe = {
    # 名称: (函数, 危险点, 正常点, 正常点处的解析梯度)
    "sqrt":   (safe_sqrt,   0.0,          4.0,                  0.25),
    "log":    (safe_log,    0.0,          2.0,                  0.5),
    "norm":   (safe_norm,   jnp.zeros(3), jnp.array([3., 4., 0.]), jnp.array([0.6, 0.8, 0.0])),
    "除法":   (safe_div,    0.0,          4.0,                  -2.0 / 16),
    "arcsin": (safe_arcsin, 1.0,          0.5,                  1 / jnp.sqrt(1 - 0.25)),
}
for name, (fn, x_bad, x_ok, g_expected) in safe.items():
    g_bad = jax.grad(fn)(x_bad)
    g_ok = jax.grad(fn)(x_ok)
    print(f"{name:6s} 危险点梯度 = {g_bad} | 正常点梯度 = {g_ok}")
    assert jnp.all(jnp.isfinite(g_bad)), name                     # 危险点梯度有限
    assert jnp.allclose(g_ok, g_expected, atol=1e-6), name        # 正常点梯度不受影响
    assert jnp.allclose(fn(x_ok), naive[name][0](x_ok))           # 前向值与朴素写法一致
print("断言通过:5 个场景的危险点梯度都是有限值,正常点梯度正确")
sqrt   危险点梯度 = 0.0 | 正常点梯度 = 0.25
log    危险点梯度 = 0.0 | 正常点梯度 = 0.5
norm   危险点梯度 = [0. 0. 0.] | 正常点梯度 = [0.6 0.8 0. ]
除法     危险点梯度 = 0.0 | 正常点梯度 = -0.125
arcsin 危险点梯度 = 0.0 | 正常点梯度 = 1.154700517654419
断言通过:5 个场景的危险点梯度都是有限值,正常点梯度正确

实战经验

  • 安全值要让危险分支的导数有限,光是值有限还不够。sqrt/log/除法用 1.0,arcsin 用 0.0。
  • 只要被屏蔽的分支在危险点上的导数有限,单个 where 就够了,例如 where(x>0, x**2, 0.) 在 0 处梯度为 0,不会出 NaN。
  • 训练中出现 NaN 时,可以打开 jax.config.update("jax_debug_nans", True),让 JAX 在第一个产生 NaN 的原语处报错,很快就能定位。不过它在本环境中会漏检(比如用过 vmap 之后),用法和已知限制见 进阶 08 §3.1。
  • 对于固定的数学函数,也可以用 jax.custom_jvp 直接给出稳定的导数(见下文)。

2. 数值稳定性

  • 梯度消失 / 梯度爆炸:深层网络里链式法则要连乘很多项,梯度可能趋近于 0,也可能急剧变大。
  • 例如 $\frac{d}{dx}\log x=\frac1x$ 在 $x=10^{-20}$ 处是 $10^{20}$,非常大。

缓解办法:

  • 梯度裁剪(gradient clipping);
  • 使用数值稳定的实现,例如用 jax.scipy.special.logsumexp(x) 代替 jnp.log(jnp.sum(jnp.exp(x)));
  • 必要时启用 64 位精度:jax.config.update("jax_enable_x64", True)(在程序开头、创建数组之前设置)。
from jax.scipy.special import logsumexp

g_log = jax.grad(jnp.log)(1e-20)
print("grad(log)(1e-20) =", g_log)
assert jnp.allclose(g_log, 1e20, rtol=1e-5)

# 朴素的 log-sum-exp 会溢出,梯度也随之变成 nan
def naive_lse(x):
    return jnp.log(jnp.sum(jnp.exp(x)))

x_big = jnp.array([1000.0, 1000.0])
print("\n朴素实现  值:", naive_lse(x_big), "| 梯度:", jax.grad(naive_lse)(x_big))
print("logsumexp 值:", logsumexp(x_big), "| 梯度:", jax.grad(logsumexp)(x_big))
assert jnp.all(jnp.isnan(jax.grad(naive_lse)(x_big)))
assert jnp.allclose(jax.grad(logsumexp)(x_big), 0.5)
grad(log)(1e-20) = 1e+20
朴素实现  值: inf | 梯度: [nan nan]
logsumexp 值: 1000.6932 | 梯度: [0.5 0.5]

🔍 补充:用 jax.custom_jvp 自定义导数

⚪ 选读:某个函数的自动求导数值不稳定、想手写导数规则时看。

有些函数,用基本运算组合出来的导数在数值上不稳定,但导数本身有简单又稳定的解析式。这时可以用 jax.custom_jvp 直接告诉 JAX 导数怎么算。官方文档里的经典例子是 $\log(1+e^x)$(softplus)。它的导数是 $\sigma(x)=\frac{e^x}{1+e^x}$,可直接自动求导的话,$x$ 很大时会算出 $\frac{\infty}{\infty}=\text{NaN}$。

只要定义 JVP 规则(前向模式),JAX 会自动推出反向模式,所以 grad、jacrev、hessian 都能用。(另外还有 jax.custom_vjp,可以直接自定义反向规则,用在梯度裁剪、节省内存等场景。)

def log1pexp_naive(x):
    return jnp.log1p(jnp.exp(x))

print("朴素实现 grad(100.0) =", jax.grad(log1pexp_naive)(100.0))   # nan
assert jnp.isnan(jax.grad(log1pexp_naive)(100.0))

@jax.custom_jvp
def log1pexp(x):
    return jnp.log1p(jnp.exp(x))

@log1pexp.defjvp
def log1pexp_jvp(primals, tangents):
    (x,), (x_dot,) = primals, tangents
    y = log1pexp(x)
    # 导数的稳定形式:sigmoid(x)
    return y, x_dot * jax.nn.sigmoid(x)

print("custom_jvp grad(100.0) =", jax.grad(log1pexp)(100.0))            # 1.0
print("custom_jvp grad(0.0)   =", jax.grad(log1pexp)(0.0))              # 0.5
print("二阶导也能用 (0.0)     =", jax.grad(jax.grad(log1pexp))(0.0))    # σ(0)(1-σ(0)) = 0.25
assert jax.grad(log1pexp)(100.0) == 1.0
assert jnp.allclose(jax.grad(jax.grad(log1pexp))(0.0), 0.25)
朴素实现 grad(100.0) = nan
custom_jvp grad(100.0) = 1.0
custom_jvp grad(0.0)   = 0.5
二阶导也能用 (0.0)     = 0.25

🔍 补充:jax.lax.stop_gradient——让某一部分不参与求导

⚪ 选读:需要截断部分梯度(target 网络、straight-through estimator 等)时看。

stop_gradient(x) 前向计算时原样返回 x,求导时却把它当成常数(梯度为 0)。典型用途有:强化学习中的 target 网络、对比学习的 stop-grad 分支、straight-through estimator 等。

from jax.lax import stop_gradient

def f_normal(x):
    return x * x                    # 导数 2x

def f_stopped(x):
    return x * stop_gradient(x)     # 第二个 x 被视为常数,导数 = x

print("grad(x*x)(3.0)                 =", jax.grad(f_normal)(3.0))    # 6.0
print("grad(x*stop_gradient(x))(3.0)  =", jax.grad(f_stopped)(3.0))   # 3.0
assert jax.grad(f_normal)(3.0) == 6.0 and jax.grad(f_stopped)(3.0) == 3.0

# 实用技巧:straight-through estimator
# 前向用 round(不可导),反向假装是恒等函数(梯度为 1)
def ste_round(x):
    return x + stop_gradient(jnp.round(x) - x)

print("\nste_round(2.7) =", ste_round(2.7), "| 梯度 =", jax.grad(ste_round)(2.7))
assert ste_round(2.7) == 3.0 and jax.grad(ste_round)(2.7) == 1.0
grad(x*x)(3.0)                 = 6.0
grad(x*stop_gradient(x))(3.0)  = 3.0
ste_round(2.7) = 3.0 | 梯度 = 1.0

3. 与 Python 控制流的交互

grad 只对当前输入实际走过的路径求导。如果函数在不同输入下会切换分支,你拿到的只是当前分支的梯度,在分支边界附近可能会误导人。单独用 grad 时 Python if 能正常工作,但在 jit 下要改用 lax.cond / jnp.where(详见 3.7)。

4. 外部代码与副作用

被求导的函数里应该只用 jnp、jax.lax、jax.scipy、jax.nn 这些 JAX 函数。外部库在 grad 下的实际表现分三种情况:

  1. 不依赖输入的 NumPy 计算(如用 np.random 生成的常量数组):确实是常量,梯度不经过它。
  2. 把追踪器传给 NumPy 函数(如 np.sin(x)):直接报 TracerArrayConversionError。SciPy(非 jax.scipy)、Pandas、scikit-learn 等库内部会把输入转成 NumPy 数组,结果通常也是报错。
  3. 少数 NumPy 函数(如 np.sum)会通过鸭子类型转而调用 x.sum(),于是碰巧能求导。这靠的是实现细节,不可靠,还是应该一律改用 jnp。

打印、读写文件、修改全局变量这类副作用,会破坏 JAX 的函数式假设。在 grad 里,print 打印出来的是追踪器;在 jit 里,它只在追踪时执行一次。想调试数值,请用 jax.debug.print。

⚠️ 注意:被求导函数里的 NumPy 调用(如 np.sum),并不会被悄悄当成常量、让梯度变成 0。np.sin(tracer) 会直接报 TracerArrayConversionError;np.sum 则碰巧能正确求导。只有和输入无关的 NumPy 常量,才是真正的常量。

# ❌ 常见误解:在被求导函数里调用 NumPy,以为它会被当作常量、梯度为 0
x = jnp.array([1.0, 2.0, 3.0])

try:
    g_np = jax.grad(lambda x: np.sin(x).sum())(x)
    np_raised = False
    print("np.sin 版本梯度:", g_np)
except jax.errors.TracerArrayConversionError as e:
    np_raised = True
    print("np.sin 作用于追踪器 -> TracerArrayConversionError")
assert np_raised, "NumPy 函数作用于 tracer 时应当报错"

# np.sum 碰巧转调 x.sum(),梯度并不是 0
g_npsum = jax.grad(lambda x: np.sum(x**2))(x)
print("np.sum 版本梯度:", g_npsum)
assert jnp.allclose(g_npsum, 2 * x)   # 不是 0,而是正确的 2x
np.sin 作用于追踪器 -> TracerArrayConversionError
np.sum 版本梯度: [2. 4. 6.]
# ✅ 推荐写法:被求导函数中一律使用 jnp;NumPy 只用来生成与输入无关的常量
noise = np.random.default_rng(0).random(3)            # NumPy 常量
g_jnp = jax.grad(lambda x: jnp.sum(jnp.sin(x) * noise))(x)
print("jnp 版本梯度:", g_jnp)
assert jnp.allclose(g_jnp, jnp.cos(x) * noise)

# 调试数值:jax.debug.print 在 jit 中也会每次执行
@jax.jit
def debug_loss(x):
    jax.debug.print("debug_loss 收到的 x = {}", x)
    return jnp.sum(x**2)

_ = jax.grad(debug_loss)(x)
jnp 版本梯度: [ 0.34415185 -0.11227089 -0.04056348]
debug_loss 收到的 x = [1. 2. 3.]

5. 非标量输出

jax.grad 只适用于 $f:\mathbb R^n\to\mathbb R$(标量输出),函数返回数组时会抛出 TypeError。向量值函数要用 jax.jacfwd、jax.jacrev(求完整雅可比),或者 jax.jvp、jax.vjp(求雅可比和向量的乘积),见 3.3。

def vector_output(x):
    return x**2   # 输出是向量,不是标量

x = jnp.array([1.0, 2.0, 3.0])
try:
    jax.grad(vector_output)(x)
    vec_raised = False
except TypeError as e:
    vec_raised = True
    print("TypeError:", str(e).splitlines()[0])
assert vec_raised

# 替代方案
J = jax.jacrev(vector_output)(x)
print("\njacrev 得到完整雅可比(对角阵 2x):\n", J)
assert jnp.allclose(J, jnp.diag(2 * x))
# 如果只需要逐元素函数「对每个元素的导数」,对输出求和再 grad 就行
print("grad(sum(f)) =", jax.grad(lambda x: jnp.sum(vector_output(x)))(x))
TypeError: Gradient only defined for scalar-output functions. Output had shape: (3,). To get the gradient, reduce the output to a scalar with output.sum(), or use jax.jacobian to differentiate a function with a non-scalar output.
jacrev 得到完整雅可比(对角阵 2x):
 [[2. 0. 0.]
 [0. 4. 0.]
 [0. 0. 6.]]
grad(sum(f)) = [2. 4. 6.]

🔍 补充:用有限差分检验梯度

⚪ 选读:写了自定义导数或复杂模型、需要验证梯度是否正确时看。

写了 custom_jvp、手写了反向规则,或者模型比较复杂时,最好用数值微分检验一下梯度对不对。

  • 官方工具:jax.test_util.check_grads(f, args, order, modes),用中心差分同时检查前向(fwd)和反向(rev)模式,order=2 还会检查二阶导数。注意要显式 import jax.test_util。只 import jax 就直接访问 jax.test_util,会报 AttributeError。
  • 手写:中心差分 $\dfrac{f(x+h e_i)-f(x-h e_i)}{2h}$。float32 下误差比较大,$h$ 取 $10^{-3}$ 左右,容差也要适当放宽。
import jax.test_util   # 必须显式导入
from jax.test_util import check_grads

def g(x):
    return jnp.sum(jnp.sin(x) * x**2)

x = jnp.array([0.5, 1.0, 2.0])

# 官方工具:通过时不输出,失败时抛 AssertionError
check_grads(g, (x,), order=2, modes=["fwd", "rev"])
print("check_grads: g 的一、二阶梯度检验通过")

# 故意写错导数(多乘了 2),check_grads 能发现
@jax.custom_jvp
def wrong_sin(x):
    return jnp.sin(x)

@wrong_sin.defjvp
def wrong_sin_jvp(primals, tangents):
    (x,), (t,) = primals, tangents
    return jnp.sin(x), 2 * jnp.cos(x) * t   # 错误:正确应为 cos(x) * t

try:
    check_grads(wrong_sin, (x,), order=1, modes=["rev"])
    caught = False
except AssertionError as e:
    caught = True
    print("check_grads 发现错误梯度:", str(e).strip().splitlines()[0])
assert caught

# 手写中心差分
def numerical_grad(f, x, h=1e-3):
    grads = []
    for i in range(x.size):
        e = jnp.zeros_like(x).at[i].set(h)
        grads.append((f(x + e) - f(x - e)) / (2 * h))
    return jnp.array(grads)

print("\n手写中心差分:", numerical_grad(g, x))
print("jax.grad    :", jax.grad(g)(x))
assert jnp.allclose(numerical_grad(g, x), jax.grad(g)(x), rtol=1e-3)
check_grads: g 的一、二阶梯度检验通过
check_grads 发现错误梯度: Not equal to tolerance rtol=0.002, atol=0.002

手写中心差分: [0.6988048 2.2232533 1.9726752]
jax.grad    : [0.6988212 2.2232442 1.9726022]

3.9 动手实践:计算梯度

这一节用几个练习巩固 jax.grad 的用法:从简单函数开始,再到对指定参数求导、高阶导数、value_and_grad,以及 jax.numpy 函数的梯度。

准备工作:导入 jax、jax.numpy、numpy,按需启用 64 位精度,再打印 JAX 版本和默认后端。

准备工作:64 位精度的开启方式

⚠️ 注意:旧资料里常见先 from jax.config import config、再 config.update("jax_enable_x64", True) 的写法。JAX 0.12 已经移除了 jax.config 子模块,这样写会报 ModuleNotFoundError: No module named 'jax.config'。请改用 jax.config.update("jax_enable_x64", True),并放在程序开头、创建任何数组之前(也可以用环境变量 JAX_ENABLE_X64=1)。

为了让本章其余部分保持 JAX 默认的 float32 行为,下面只演示写法,不真的开启 64 位。

# ❌ 旧写法(过时):jax.config 子模块在 JAX 0.12 中已移除
try:
    from jax.config import config
    config.update("jax_enable_x64", True)
    old_import_ok = True
except ModuleNotFoundError as e:
    old_import_ok = False
    print("实测报错 ModuleNotFoundError:", e)
assert not old_import_ok
实测报错 ModuleNotFoundError: No module named 'jax.config'
# ✅ 推荐写法
import jax
import jax.numpy as jnp
import numpy as np

# jax.config.update("jax_enable_x64", True)   # 需要 64 位时取消注释,并放在程序最开头
print("当前 jax_enable_x64 =", jax.config.jax_enable_x64)
assert hasattr(jax.config, "update")

print(f"JAX 版本: {jax.__version__}")
print(f"默认后端: {jax.default_backend()}")
当前 jax_enable_x64 = False
JAX 版本: 0.12.0.1
默认后端: cpu

练习 1:简单多项式的梯度

$f(x)=3x^2+2x+5$,解析导数 $f'(x)=6x+2$。在 $x=4$ 处:$f'(4)=6\times4+2=26$。

def poly_func(x):
    return 3 * x**2 + 2 * x + 5

# 得到梯度函数
grad_poly = jax.grad(poly_func)

x_val = 4.0
gradient_val = grad_poly(x_val)
analytical = 6 * x_val + 2

print(f"f({x_val}) = {poly_func(x_val)}")
print(f"JAX 梯度: {gradient_val}")
print(f"解析梯度: {analytical}")
assert gradient_val == analytical == 26.0
f(4.0) = 61.0
JAX 梯度: 26.0
解析梯度: 26.0

练习 2:对特定参数求导

$g(x,y)=x^3y+2x^2$,偏导数:

$$ \frac{\partial g}{\partial x}=3x^2y+4x,\qquad \frac{\partial g}{\partial y}=x^3 $$

取 $x=2,\ y=3$:$\partial g/\partial x=3\times4\times3+8=44$,$\partial g/\partial y=8$。

def multi_var_func(x, y):
    return x**3 * y + 2 * x**2

x_val, y_val = 2.0, 3.0

# 默认对第一个参数 x 求导(argnums=0)
grad_x = jax.grad(multi_var_func)(x_val, y_val)
print(f"∂g/∂x: {grad_x}  (解析: {3 * x_val**2 * y_val + 4 * x_val})")

# argnums=1 对 y 求导
grad_y = jax.grad(multi_var_func, argnums=1)(x_val, y_val)
print(f"∂g/∂y: {grad_y}  (解析: {x_val**3})")

# argnums=(0, 1) 返回两个梯度组成的元组
grad_xy = jax.grad(multi_var_func, argnums=(0, 1))(x_val, y_val)
print(f"(∂g/∂x, ∂g/∂y): {grad_xy}")
assert grad_xy == (44.0, 8.0)
∂g/∂x: 44.0  (解析: 44.0)
∂g/∂y: 8.0  (解析: 8.0)
(∂g/∂x, ∂g/∂y): (Array(44., dtype=float32, weak_type=True), Array(8., dtype=float32, weak_type=True))

练习 3:高阶导数

对练习 1 的 $f(x)=3x^2+2x+5$,二阶导数 $f''(x)=6$,和 $x$ 无关。套两次 jax.grad 就行。

second_grad_poly = jax.grad(jax.grad(poly_func))

for x_val in [4.0, -1.0, 100.0]:
    print(f"f''({x_val}) = {second_grad_poly(x_val)}")   # 恒为 6.0
    assert second_grad_poly(x_val) == 6.0
f''(4.0) = 6.0
f''(-1.0) = 6.0
f''(100.0) = 6.0

练习 4:jax.value_and_grad

$h(w)=(w-5)^2$,$h'(w)=2(w-5)$。在 $w=2$ 处:$h(2)=9$,$h'(2)=-6$。

def simple_loss(w):
    return (w - 5.0) ** 2

value, grad = jax.value_and_grad(simple_loss)(2.0)
print(f"函数值: {value}")   # 9.0
print(f"梯度  : {grad}")    # -6.0
assert (value, grad) == (9.0, -6.0)
函数值: 9.0
梯度  : -6.0

练习 5:jax.numpy 函数的梯度

$k(\mathbf v)=\sum_i\sin(v_i)$,梯度的第 $j$ 个分量为 $\dfrac{\partial k}{\partial v_j}=\cos(v_j)$。取 $\mathbf v=[0,\ \pi/2,\ \pi]$,梯度的数学值为 $[1,\ 0,\ -1]$。

⚠️ 注意:打印结果并不是精确的 [1. 0. -1.],中间一项约为 -4.37e-08(float32)。原因和 3.2 一样,是 $\pi/2$ 在 float32 下有舍入,所以要用 jnp.allclose 判断。

def sum_sin(v):
    return jnp.sum(jnp.sin(v))

v = jnp.array([0.0, jnp.pi / 2, jnp.pi])
grad_v = jax.grad(sum_sin)(v)

print("JAX 梯度   :", grad_v)
print("jnp.cos(v) :", jnp.cos(v))
print("与 cos(v) 一致:", jnp.allclose(grad_v, jnp.cos(v)))
assert jnp.allclose(grad_v, jnp.array([1.0, 0.0, -1.0]), atol=1e-6)
JAX 梯度   : [ 1.000000e+00 -4.371139e-08 -1.000000e+00]
jnp.cos(v) : [ 1.000000e+00 -4.371139e-08 -1.000000e+00]
与 cos(v) 一致: True

小结一下:这些练习覆盖了简单多项式、多参数求导、高阶导数、同时算值和梯度,以及 jax.numpy 函数的梯度。

🔍 补充:完整的线性回归梯度下降

⚪ 选读:想看本章 API 串成一个完整训练循环时看。

把本章内容串起来:用 jax.random.key 生成数据,用 dict 存放参数,用 jax.jit(jax.value_and_grad(...)) 写训练步,用 jax.tree.map 更新参数。最后和最小二乘的闭式解对比。

# 1) 生成数据:y = 3x + 2 + 噪声
key = jax.random.key(42)
key_x, key_noise = jax.random.split(key)
n_samples = 200
X = jax.random.uniform(key_x, (n_samples,), minval=-1.0, maxval=1.0)
y = 3.0 * X + 2.0 + 0.1 * jax.random.normal(key_noise, (n_samples,))

# 2) 模型与损失
def predict(params, x):
    return params["w"] * x + params["b"]

def mse_loss(params, x, y):
    return jnp.mean((predict(params, x) - y) ** 2)

# 3) 训练步:value_and_grad + jit
@jax.jit
def train_step(params, x, y, lr):
    loss, grads = jax.value_and_grad(mse_loss)(params, x, y)
    new_params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
    return new_params, loss

# 4) 训练循环
params = {"w": jnp.array(0.0), "b": jnp.array(0.0)}
lr = 0.1
for step in range(201):
    params, loss = train_step(params, X, y, lr)
    if step % 40 == 0:
        print(f"step {step:3d} | loss = {loss:.5f} | w = {params['w']:.4f} | b = {params['b']:.4f}")

# 5) 与闭式解(最小二乘)对比
A = jnp.stack([X, jnp.ones_like(X)], axis=1)
w_ls, b_ls = jnp.linalg.lstsq(A, y)[0]
print(f"\n梯度下降: w = {params['w']:.4f}, b = {params['b']:.4f}")
print(f"最小二乘: w = {w_ls:.4f}, b = {b_ls:.4f}")
assert jnp.allclose(params["w"], w_ls, atol=1e-3) and jnp.allclose(params["b"], b_ls, atol=1e-3)
step   0 | loss = 7.08759 | w = 0.1965 | b = 0.4160
step  40 | loss = 0.02566 | w = 2.7787 | b = 1.9971
step  80 | loss = 0.00927 | w = 2.9791 | b = 1.9884
step 120 | loss = 0.00916 | w = 2.9951 | b = 1.9877
step 160 | loss = 0.00916 | w = 2.9964 | b = 1.9876
step 200 | loss = 0.00916 | w = 2.9965 | b = 1.9876
梯度下降: w = 2.9965, b = 1.9876
最小二乘: w = 2.9965, b = 1.9876

补充练习

下面几道练习更贴近实战,建议先自己写,再看参考答案。

练习 A:逻辑回归的梯度,并与解析式对照

二分类逻辑回归:$p_i=\sigma(\mathbf x_i^\top\mathbf w+b)$,平均交叉熵损失

$$ L=-\frac1N\sum_i\big[y_i\log p_i+(1-y_i)\log(1-p_i)\big] $$

它的解析梯度为 $\nabla_{\mathbf w}L=\frac1N X^\top(\mathbf p-\mathbf y)$,$\nabla_b L=\frac1N\sum_i(p_i-y_i)$。

要求:

  1. 参数用 dict {"w": ..., "b": ...} 存放;
  2. 用数值稳定的方式实现损失(提示:$\log\sigma(z)=-\mathrm{softplus}(-z)$,可用 jax.nn.log_sigmoid);
  3. 用 jax.grad 求梯度,与解析梯度对比;
  4. 用 has_aux=True 顺便返回准确率。

自检接口:定义 logistic_loss(params, X, y) -> (loss, acc),其中 params = {"w": ..., "b": ...}。

# ✍️ 作答区:练习 A
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 A
try:
    logistic_loss
except NameError as e:
    print(f"⏳ 练习 A 尚未作答:{e}")
else:
    _k1, _k2, _k3 = jax.random.split(jax.random.key(99), 3)
    _X = jax.random.normal(_k1, (64, 4))
    _y = (jax.random.uniform(_k2, (64,)) > 0.5).astype(jnp.float32)
    _p = {"w": jax.random.normal(_k3, (4,)), "b": jnp.array(0.3)}
    _out = logistic_loss(_p, _X, _y)
    assert isinstance(_out, tuple) and len(_out) == 2, "logistic_loss 应返回 (loss, acc) 两个值"
    _loss, _acc = _out

    _Xn, _yn = np.asarray(_X, np.float64), np.asarray(_y, np.float64)
    def _ref(w, b):                    # float64 下的参考值:-log σ(z) = log(1 + e^{-z})
        z = _Xn @ np.asarray(w, np.float64) + float(b)
        return np.mean(_yn * np.logaddexp(0, -z) + (1 - _yn) * np.logaddexp(0, z)), z
    _lref, _z = _ref(_p["w"], _p["b"])
    assert np.isclose(float(_loss), _lref, rtol=1e-4), f"损失 {float(_loss):.6f} 不对,应为 {_lref:.6f}"
    assert np.isclose(float(_acc), np.mean((_z > 0) == (_yn > 0.5)), atol=1e-6), "准确率不对"

    _loss_only = lambda p: logistic_loss(p, _X, _y)[0]
    _g = jax.grad(_loss_only)(_p)
    _pr = 1 / (1 + np.exp(-_z))
    assert np.allclose(_g["w"], _Xn.T @ (_pr - _yn) / 64, atol=1e-5), "对 w 的梯度与解析式不一致"
    assert np.isclose(float(_g["b"]), np.mean(_pr - _yn), atol=1e-5), "对 b 的梯度与解析式不一致"

    _big = {"w": _p["w"] * 1000.0, "b": _p["b"]}           # logit 达到几千:朴素写法会得到 inf/NaN
    _lb = float(_loss_only(_big))
    _gb = jax.grad(_loss_only)(_big)
    assert np.isfinite(_lb) and all(np.all(np.isfinite(np.asarray(v))) for v in _gb.values()), \
        "logit 很大时损失或梯度出现了 inf/NaN:损失需要用数值稳定的写法"
    assert np.isclose(_lb, _ref(_big["w"], _big["b"])[0], rtol=1e-3), "logit 很大时损失的数值不对"
    print("✅ 练习 A 自检通过")
⏳ 练习 A 尚未作答:name 'logistic_loss' is not defined

练习 B:写一个在 0 处梯度安全的 safe_sqrt

实现函数 safe_sqrt(x):$x>0$ 时等于 $\sqrt x$,$x\le 0$ 时等于 $0$,并满足:

  • jax.grad(safe_sqrt)(0.0) 和 jax.grad(safe_sqrt)(-1.0) 是有限值(取 0);
  • jax.grad(safe_sqrt)(4.0) == 0.25;
  • 能处理向量输入(配合 jnp.sum 求梯度)。

分别用「双 where」和 jax.custom_jvp 两种方法实现,并用 check_grads 在 $x>0$ 的点上检验。

自检接口:定义 safe_sqrt_where(x)(双 where)和 safe_sqrt_custom(x)(jax.custom_jvp)。

# ✍️ 作答区:练习 B
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 B
try:
    safe_sqrt_where, safe_sqrt_custom
except NameError as e:
    print(f"⏳ 练习 B 尚未作答:{e}")
else:
    assert isinstance(safe_sqrt_custom, jax.custom_jvp), "safe_sqrt_custom 应该用 @jax.custom_jvp 定义"
    _xs = jnp.array([-3.0, -1.0, 0.0, 1e-6, 0.25, 4.0, 9.0])
    _xn = np.asarray(_xs, np.float64)
    _vref = np.sqrt(np.maximum(_xn, 0.0))
    _gref = np.where(_xn > 0, 0.5 / np.sqrt(np.maximum(_xn, 1e-30)), 0.0)
    for _name, _fn in [("safe_sqrt_where", safe_sqrt_where), ("safe_sqrt_custom", safe_sqrt_custom)]:
        _v = np.asarray(_fn(_xs))
        assert np.allclose(_v, _vref, rtol=1e-5, atol=1e-7), f"{_name} 的函数值不对:期望 {_vref},实际 {_v}"
        _gsum = jax.grad(lambda x: jnp.sum(_fn(x)))
        for _tag, _gfn in [("", _gsum), ("(jit 下)", jax.jit(_gsum))]:
            _g = np.asarray(_gfn(_xs))
            assert np.all(np.isfinite(_g)), f"{_name}{_tag} 的梯度出现了 inf/NaN:{_g}"
            assert np.allclose(_g, _gref, rtol=1e-4), f"{_name}{_tag} 的梯度不对:期望 {_gref},实际 {_g}"
        assert float(jax.grad(_fn)(4.0)) == 0.25, f"{_name}:grad 在 4.0 处应为 0.25"
        _, _t = jax.jvp(_fn, (_xs,), (jnp.ones_like(_xs),))
        assert np.all(np.isfinite(np.asarray(_t))), f"{_name}:前向模式(jvp)在非正数处给出了 inf/NaN"
    print("✅ 练习 B 自检通过")
⏳ 练习 B 尚未作答:name 'safe_sqrt_where' is not defined

练习 C:不构造 Hessian,计算 Hessian-向量积

对 $f(\mathbf x)=\sum_i x_i^4 + \big(\sum_i x_i\big)^2$,给定向量 $\mathbf v$,计算 $H\mathbf v$。

要求:

  1. 用 jax.jvp(jax.grad(f), (x,), (v,)) 实现 hvp(f, x, v)(前向套反向,不用构造 $n\times n$ 的 Hessian,内存开销和一次梯度计算差不多);
  2. 在 $n=5$ 时和 jax.hessian(f)(x) @ v 对比;
  3. 想一想:为什么不直接对 jax.grad(f)(x) @ v 再求 grad?(提示:那也是一种可行的写法,叫 reverse-over-reverse,只是内存开销更大。)

自检接口:定义 hvp(f, x, v),对任意标量函数 f 返回 $H\mathbf v$。

# ✍️ 作答区:练习 C
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 C
try:
    hvp
except NameError as e:
    print(f"⏳ 练习 C 尚未作答:{e}")
else:
    _n = 13
    _a = jnp.linspace(-1.0, 1.0, _n)
    def _g(x):                         # 换一个新函数,检验 hvp 对任意 f 都成立
        return jnp.sum(jnp.sin(x) * x**2) + jnp.sum(_a * x) ** 3
    _kx, _kv = jax.random.split(jax.random.key(3))
    _x, _v = jax.random.normal(_kx, (_n,)), jax.random.normal(_kv, (_n,))

    _xn, _vn, _an = (np.asarray(t, np.float64) for t in (_x, _v, _a))
    _diag = (2 - _xn**2) * np.sin(_xn) + 4 * _xn * np.cos(_xn)   # (x² sin x)'' 的解析式
    _Hv_ref = _diag * _vn + 6 * (_an @ _xn) * _an * (_an @ _vn)
    _Hv = np.asarray(hvp(_g, _x, _v))
    assert _Hv.shape == (_n,), f"hvp 应返回长度 {_n} 的向量,实际形状 {_Hv.shape}"
    assert np.allclose(_Hv, _Hv_ref, rtol=1e-4, atol=1e-4), f"hvp 结果不对:\n  期望 {_Hv_ref}\n  实际 {_Hv}"
    _jx = str(jax.make_jaxpr(lambda x, v: hvp(_g, x, v))(_x, _v))
    assert f"[{_n},{_n}]" not in _jx, f"hvp 的计算过程中出现了 {_n}×{_n} 的矩阵:不要构造完整的 Hessian"
    print("✅ 练习 C 自检通过")
⏳ 练习 C 尚未作答:name 'hvp' is not defined

练习 D:用 grad 的 grad 实现牛顿法

牛顿法求一元函数极小值:$x_{k+1}=x_k-\dfrac{f'(x_k)}{f''(x_k)}$。

对 $f(x)=x^4-3x^2+x$,从 $x_0=2$ 出发迭代 8 步,打印每步的 $x$、$f'(x)$,并与梯度下降(学习率 0.01,同样 8 步)比较收敛速度。要求整个迭代函数可以被 jax.jit(提示:用 lax.fori_loop,上下界用 Python 常量)。

自检接口:定义一阶、二阶导数函数 df、d2f,单步牛顿更新 newton_step(x),以及梯度下降 gd(x0)(8 步、学习率 0.01,可以被 jax.jit)。

# ✍️ 作答区:练习 D
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 D
try:
    df, d2f, newton_step, gd
except NameError as e:
    print(f"⏳ 练习 D 尚未作答:{e}")
else:
    _fp = lambda x: 4 * x**3 - 6 * x + 1      # f(x) = x⁴ - 3x² + x 的导数(解析式)
    _fpp = lambda x: 12 * x**2 - 6
    for _x0 in [-1.7, 0.4, 1.3, 2.5]:
        assert np.isclose(float(df(_x0)), _fp(_x0), rtol=1e-5, atol=1e-5), f"df({_x0}) = {float(df(_x0))},应为 {_fp(_x0):.6f}"
        assert np.isclose(float(d2f(_x0)), _fpp(_x0), rtol=1e-5, atol=1e-5), f"d2f({_x0}) = {float(d2f(_x0))},应为 {_fpp(_x0):.6f}"
        _want = _x0 - _fp(_x0) / _fpp(_x0)
        assert np.isclose(float(newton_step(_x0)), _want, rtol=1e-5, atol=1e-5), f"newton_step({_x0}) 应为 {_want:.6f}"
    _xk = 2.0
    for _ in range(8):
        _xk = newton_step(_xk)
    assert abs(_fp(float(_xk))) < 1e-4, f"从 2 出发迭代 8 步后 f'(x) 应接近 0,实际 {_fp(float(_xk)):.2e}"
    for _x0 in [2.0, -0.5]:
        _xr = _x0
        for _ in range(8):
            _xr = _xr - 0.01 * _fp(_xr)
        assert np.isclose(float(gd(_x0)), _xr, rtol=1e-5), f"gd({_x0}) = {float(gd(_x0)):.6f},8 步学习率 0.01 的梯度下降应得到 {_xr:.6f}"
    jax.jit(gd)(jnp.float32(2.0))             # 整个迭代函数可以被 jit
    print("✅ 练习 D 自检通过")
⏳ 练习 D 尚未作答:name 'df' is not defined

第 3 章测验

下面 8 道题围绕本章内容,帮你检查掌握得怎么样,有几道专门考容易踩的坑。点击「答案与解析」展开。

1. 下面哪一项对 jax.grad(f) 的描述是正确的?

  • A. 它立即计算 f 的梯度并返回一个数组
  • B. 它返回一个新函数,该函数默认计算 f 的输出对第一个位置参数的梯度
  • C. 它返回一个新函数,该函数计算 f 对所有参数的梯度
  • D. 它只能用于单变量函数
答案与解析

B。grad 是函数变换,返回新函数,默认 argnums=0。要对多个参数求导,得显式传 argnums=(0, 1, ...)。它也能处理数组、pytree 形式的多变量输入。

2. 对 def f(w, b, x): return w * x + b,jax.grad(f, argnums=(0, 1))(2.0, 1.0, 5.0) 的结果是?

  • A. 5.0
  • B. (5.0, 1.0)
  • C. (5.0, 1.0, 2.0)
  • D. [5.0, 1.0](一个数组)
答案与解析

B。argnums 是元组时返回元组,顺序和 argnums 一致。$\partial/\partial w=x=5$,$\partial/\partial b=1$,x 当作常数。

3. 为什么 jax.grad 采用反向模式自动微分?

  • A. 反向模式比前向模式更精确
  • B. 反向模式不需要保存中间值,内存更省
  • C. 对「输入很多、输出是一个标量」的函数,反向模式一次传播就能得到对所有输入的梯度
  • D. 前向模式无法处理 jnp.sin 等超越函数
答案与解析

C。两种模式精度相同(A 错);反向模式恰恰需要保存前向中间值,内存开销更大(B 错);前向模式同样支持所有可导原语(D 错)。$f:\mathbb R^n\to\mathbb R^m$ 时,前向模式需要 $n$ 次、反向模式需要 $m$ 次传播。

4. 要计算 $f:\mathbb R^{3}\to\mathbb R^{10000}$ 的完整雅可比矩阵,哪个更高效?

  • A. jax.jacfwd
  • B. jax.jacrev
  • C. 两者一样
  • D. jax.grad
答案与解析

A。输入维度(3)远小于输出维度(10000),前向模式只需 3 次 JVP,反向模式需要 10000 次 VJP。grad 只能用于标量输出。

5. 已知 def f(x): return x*x if x > 0 else -x,在 JAX 0.12 中执行以下两行,结果分别是?

jax.grad(f)(-2.0)
jax.jit(jax.grad(f))(-2.0)
  • A. 都返回 -1.0
  • B. 都报错
  • C. 第一行返回 -1.0,第二行报 TracerBoolConversionError
  • D. 第一行报错,第二行返回 -1.0
答案与解析

C。单独 grad 时追踪器带着具体值,Python if 能判断,而且每次调用都会重新追踪。jit 用的是抽象追踪器,没法转成布尔值。容易以为第一行就会报错,其实不会(见 3.7)。想让两行都能用,就改用 jax.lax.cond 或 jnp.where。

6. jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0) 的结果是?

  • A. 0.0
  • B. nan
  • C. inf
  • D. 报错
答案与解析

B。where 两个分支都会算。没选中的分支上游梯度是 0,但 $0\times\frac{1}{0}=0\times\infty=\text{NaN}$。解决办法是「双 where」:先把输入换成安全值,再取 log。

7. 关于 jax.value_and_grad(loss_fn, has_aux=True)(params, batch) 的返回值,正确的解包方式是?

  • A. loss, aux, grads = ...
  • B. (loss, grads), aux = ...
  • C. (loss, aux), grads = ...
  • D. loss, (aux, grads) = ...
答案与解析

C。值的部分是 loss_fn 原样返回的 (loss, aux),梯度部分只对 loss 求导。

8. 下面哪个循环写法可以在 jax.jit 下用 jax.grad 反向求导?

  • A. lax.while_loop(cond, body, x)
  • B. lax.fori_loop(0, n, body, x),其中 n 是 jit 函数的普通(非静态)参数
  • C. lax.scan(body, x, None, length=10)
  • D. 以上都可以
答案与解析

C。while_loop 只支持前向模式。fori_loop 的上下界是追踪值时会退化成 while_loop,同样不能反向求导。scan 的次数固定,可以反向求导。如果一定要用 fori_loop,可以把 n 设为 static_argnums。

本章小结

  • 梯度是标量函数对各输入的偏导组成的向量,指向增长最快的方向;梯度下降沿负梯度更新参数:$\theta\leftarrow\theta-\eta\nabla f(\theta)$。
  • 自动微分在基本运算层面应用链式法则,结果精确到机器精度;数值微分只适合用来检验梯度。
  • jax.grad 是函数变换:输入标量输出函数,返回梯度函数;默认对第一个参数求导,用 argnums 指定其他参数。
  • grad 使用反向模式(反向传播):先前向计算并保存中间值,再从输出反向传播敏感度。适合「输入多、输出为标量」的情况。
  • 前向模式 = jax.jvp($Jv$),反向模式 = jax.vjp($u^\top J$);完整雅可比:输入少用 jacfwd,输出少用 jacrev;jax.hessian = jacfwd(jacrev(f))。
  • 变换可以组合:grad(grad(f)) 求高阶导,jax.jvp(jax.grad(f), ...) 求 Hessian-向量积,jit(value_and_grad(f)) 写训练步。
  • 参数可以是 pytree(dict/list/tuple),梯度结构与参数相同,用 jax.tree.map 更新。
  • 函数需要返回额外信息时用 has_aux=True;value_and_grad + has_aux 返回 ((loss, aux), grads)。
  • 控制流:单独用 grad 时 Python if 可以工作,jit 下需要 lax.cond/jnp.where;while_loop 及动态上下界的 fori_loop 不支持反向求导,用 scan 或静态次数。
  • 常见坑:整数输入报错;不连续函数梯度为 0;abs 在 0 处梯度为 1;sqrt/norm 在 0 处产生 inf/NaN;jnp.where 不能挡住 NaN 梯度,需用双 where;被求导代码里不要用 np;输出必须是标量。
  • 工具:stop_gradient 截断梯度;custom_jvp/custom_vjp 自定义导数;jax.test_util.check_grads 检验梯度。
  • 64 位精度:jax.config.update("jax_enable_x64", True)(from jax.config import config 已失效)。

速查表

API作用
jax.grad(f, argnums=0, has_aux=False, allow_int=False)返回计算标量函数梯度的函数
jax.value_and_grad(f, argnums=0, has_aux=False)一次前向计算同时返回 (值, 梯度)
argnums=1 / argnums=(0, 1)对第 2 个参数 / 对多个参数求导(返回元组)
has_aux=True函数返回 (标量, 辅助信息),辅助信息不参与求导
jax.jvp(f, primals, tangents)前向模式:返回 (f(x), J·v)
jax.vjp(f, *primals)反向模式:返回 (f(x), vjp_fn),vjp_fn(u) = uᵀJ
jax.jacfwd(f)前向模式求完整雅可比,适合输入维度小
jax.jacrev(f) / jax.jacobian(f)反向模式求完整雅可比,适合输出维度小(jacobian 是 jacrev 的别名)
jax.hessian(f)Hessian 矩阵,等价于 jacfwd(jacrev(f))
jax.lax.stop_gradient(x)前向原样返回,反向视为常数
jax.custom_jvp / jax.custom_vjp自定义前向 / 反向求导规则
jax.lax.cond(pred, t_fn, f_fn, *ops)可 jit、可求导的条件分支
jax.lax.fori_loop(lo, hi, body, init)计数循环;上下界为常量时可反向求导
jax.lax.while_loop(cond, body, init)while 循环;只支持前向模式
jax.lax.scan(f, init, xs, length)固定次数循环,可反向求导
jnp.where(c, a, b)逐元素选择;处理不可导点时用「双 where」
jax.tree.map(fn, params, grads)对 pytree 参数逐叶子更新
jax.test_util.check_grads(f, args, order, modes)用有限差分检验梯度(需显式 import jax.test_util)
jax.make_jaxpr(f)(x)查看追踪得到的 jaxpr(也可查看梯度函数的)
jax.config.update("jax_enable_x64", True)启用 64 位精度

本章更新说明

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

编号小节要点位置
13.2jax.jacobian 就是 jax.jacrev 的别名,并不建在 jax.grad 之上;jacrev、grad 都基于 jax.vjp,jacfwd 基于 jax.jvp3.2 介绍 jax.grad
23.2求导的输入用整数会直接报 TypeError;整数参数放在 argnums 之外,或用 allow_int=True(梯度为 float0)3.2 介绍 jax.grad
33.2grad(sin)(π/2) 在 float32 下约为 -4.37e-08,不是精确的 0.0,要用 jnp.allclose 比较3.2 介绍 jax.grad
43.4参数是 dict 时,params - learning_rate * gradients 会报 TypeError;要用 jax.tree.map(lambda p, g: p - lr * g, params, grads)3.4 关于参数求导
53.6a²·sin(b) 对 b 的梯度在 b=π/2 处,float32 下约为 -3.93e-07,不是精确的 03.6 值和梯度(jax.value_and_grad)
63.7单独用 grad 时每次调用都重新追踪,追踪器带具体值,含 Python if 的函数在 x=-2.0 处正常返回 -1.0;只有 jit(grad(f)) 才报 TracerBoolConversionError3.7 求导与控制流
73.7lax.while_loop 和动态次数的 lax.fori_loop 只支持前向模式,grad 穿过它们会报 ValueError;需要反向求导时用 lax.scan 或 static_argnums3.7 求导与控制流
83.8jax.grad(jnp.abs)(0.0) 为 1.0(取右导数)3.8 局限性与注意事项
93.8jax.grad(jnp.sign)(0.0) 为确定的 0.0,不是 NaN,也不随平台变化3.8 局限性与注意事项
103.8被求导函数里的 NumPy 调用不会被当作常量:np.sin(tracer) 报 TracerArrayConversionError,np.sum 碰巧能正确求导;只有与输入无关的 NumPy 常量才是常量3.8 局限性与注意事项
113.8jnp.where 挡不住反向传播里的 0×inf,会得到 NaN 梯度;用「双 where」处理 sqrt/log/norm/除法/arcsin3.8 局限性与注意事项
123.9from jax.config import config 会报 ModuleNotFoundError;开启 64 位精度要用 jax.config.update("jax_enable_x64", True)3.9 动手实践:计算梯度
133.9练习 5 中 sum(sin(v)) 在 v=[0, π/2, π] 处的梯度,中间项在 float32 下约为 -4.37e-08,不是精确的 [1. 0. -1.]3.9 动手实践:计算梯度
143.3前向模式 = jax.jvp(得到雅可比的列),反向模式 = jax.vjp(得到行);jacfwd/jacrev 按输入/输出维度选择3.3 自动微分的工作方式:反向模式
153.4grad 支持 dict 等 pytree 参数;需要返回额外信息时用 has_aux=True3.4 关于参数求导
163.5jax.hessian = jacfwd(jacrev(f));规模大时用 jvp(grad(f)) 求 Hessian-向量积3.5 高阶导数(grad 的 grad)
173.8自定义导数与梯度检验:jax.custom_jvp/custom_vjp、jax.lax.stop_gradient、jax.test_util.check_grads(需显式导入)3.8 局限性与注意事项
183.3单独用 grad 时追踪器带具体数值(GradTracer(primal=...)),只带形状和 dtype 的抽象追踪器是 jit 的行为3.3 自动微分的工作方式:反向模式

附录:练习参考答案

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

练习 A

题目见正文「练习 A:逻辑回归的梯度,并与解析式对照」。

# 参考答案:练习 A
key = jax.random.key(0)
k1, k2, k3 = jax.random.split(key, 3)
N, D = 100, 3
X = jax.random.normal(k1, (N, D))
true_w = jnp.array([1.5, -2.0, 0.5])
y = (X @ true_w + 0.3 * jax.random.normal(k2, (N,)) > 0).astype(jnp.float32)
params = {"w": 0.1 * jax.random.normal(k3, (D,)), "b": jnp.array(0.0)}

def logistic_loss(params, X, y):
    z = X @ params["w"] + params["b"]
    # 稳定写法:log σ(z) 与 log(1-σ(z)) = log σ(-z)
    loss = -jnp.mean(y * jax.nn.log_sigmoid(z) + (1 - y) * jax.nn.log_sigmoid(-z))
    acc = jnp.mean((z > 0) == (y > 0.5))
    return loss, acc

grads, acc = jax.grad(logistic_loss, has_aux=True)(params, X, y)

# 解析梯度
p = jax.nn.sigmoid(X @ params["w"] + params["b"])
grad_w_analytic = X.T @ (p - y) / N
grad_b_analytic = jnp.mean(p - y)

assert jnp.allclose(grads["w"], grad_w_analytic, atol=1e-6)
assert jnp.allclose(grads["b"], grad_b_analytic, atol=1e-6)
print("断言通过:jax.grad 与解析梯度一致")
print("初始准确率:", acc)
断言通过:jax.grad 与解析梯度一致
初始准确率: 0.44

练习 B

题目见正文「练习 B:写一个在 0 处梯度安全的 safe_sqrt」。

# 参考答案:练习 B
from jax.test_util import check_grads

# 方法 1:双 where
def safe_sqrt_where(x):
    pos = x > 0
    x_safe = jnp.where(pos, x, 1.0)
    return jnp.where(pos, jnp.sqrt(x_safe), 0.0)

# 方法 2:custom_jvp
@jax.custom_jvp
def safe_sqrt_custom(x):
    return jnp.sqrt(jnp.maximum(x, 0.0))

@safe_sqrt_custom.defjvp
def _safe_sqrt_jvp(primals, tangents):
    (x,), (t,) = primals, tangents
    y = safe_sqrt_custom(x)
    pos = x > 0
    # 导数 1/(2 sqrt(x)),非正处定义为 0;同样要避免 1/0
    dy = jnp.where(pos, 0.5 / jnp.where(pos, y, 1.0), 0.0)
    return y, dy * t

xs = jnp.array([-1.0, 0.0, 4.0])
for name, fn in [("双 where", safe_sqrt_where), ("custom_jvp", safe_sqrt_custom)]:
    g = jax.grad(lambda x: jnp.sum(fn(x)))(xs)
    print(f"{name:10s} 值 = {fn(xs)} | 梯度 = {g}")

# 只在可导区域(x>0)做有限差分检验
x_pos = jnp.array([0.5, 1.0, 4.0])
check_grads(safe_sqrt_where, (x_pos,), order=1, modes=["rev"])
check_grads(safe_sqrt_custom, (x_pos,), order=1, modes=["fwd", "rev"])
print("check_grads 通过")
for fn in (safe_sqrt_where, safe_sqrt_custom):
    assert jnp.all(jnp.isfinite(jax.grad(lambda x: jnp.sum(fn(x)))(xs)))
    assert jax.grad(fn)(4.0) == 0.25
双 where    值 = [0. 0. 2.] | 梯度 = [0.   0.   0.25]
custom_jvp 值 = [0. 0. 2.] | 梯度 = [0.   0.   0.25]
check_grads 通过

练习 C

题目见正文「练习 C:不构造 Hessian,计算 Hessian-向量积」。

# 参考答案:练习 C
def f_quartic(x):
    return jnp.sum(x**4) + jnp.sum(x) ** 2

def hvp(f, x, v):
    # 前向模式对 grad(f) 求方向导数:d/dε ∇f(x + ε v) = H v
    return jax.jvp(jax.grad(f), (x,), (v,))[1]

def hvp_rev_rev(f, x, v):
    # 另一种写法:∇_x (∇f(x) · v) = H v
    return jax.grad(lambda x: jnp.vdot(jax.grad(f)(x), v))(x)

key = jax.random.key(1)
kx, kv = jax.random.split(key)
x = jax.random.normal(kx, (5,))
v = jax.random.normal(kv, (5,))

hv1 = hvp(f_quartic, x, v)
hv2 = hvp_rev_rev(f_quartic, x, v)
hv_full = jax.hessian(f_quartic)(x) @ v

print("jvp(grad) :", hv1)
print("grad(grad·v):", hv2)
print("hessian @ v:", hv_full)
assert jnp.allclose(hv1, hv_full, atol=1e-4) and jnp.allclose(hv2, hv_full, atol=1e-4)
print("断言通过:三种方法结果一致")
jvp(grad) : [ -2.0931249  -0.4941277  -8.804403  -11.358394   52.02126  ]
grad(grad·v): [ -2.0931249  -0.4941277  -8.804403  -11.358394   52.02126  ]
hessian @ v: [ -2.0931249  -0.4941275  -8.804403  -11.358395   52.02126  ]
断言通过:三种方法结果一致

练习 D

题目见正文「练习 D:用 grad 的 grad 实现牛顿法」。

# 参考答案:练习 D
def f_newton(x):
    return x**4 - 3 * x**2 + x

df = jax.grad(f_newton)
d2f = jax.grad(df)

@jax.jit
def newton_step(x):
    return x - df(x) / d2f(x)

x = 2.0
print("牛顿法:")
for k in range(8):
    x = newton_step(x)
    print(f"  第 {k + 1} 步: x = {x:.8f}, f'(x) = {df(x):.2e}")

# 梯度下降对比:整个循环放进 jit,fori_loop 上下界是常量,可反向求导,也可以 jit
@jax.jit
def gd(x0):
    return jax.lax.fori_loop(0, 8, lambda i, x: x - 0.01 * df(x), x0)

x_gd = gd(2.0)
print(f"\n梯度下降 8 步: x = {x_gd:.6f}, f'(x) = {df(x_gd):.2e}")
assert abs(df(x)) < 1e-5 and abs(df(x_gd)) > abs(df(x))   # 牛顿法收敛远快于梯度下降
牛顿法:
  第 1 步: x = 1.50000000, f'(x) = 5.50e+00
  第 2 步: x = 1.23809528, f'(x) = 1.16e+00
  第 3 步: x = 1.14427722, f'(x) = 1.27e-01
  第 4 步: x = 1.13115311, f'(x) = 2.36e-03
  第 5 步: x = 1.13090122, f'(x) = 9.54e-07
  第 6 步: x = 1.13090110, f'(x) = 0.00e+00
  第 7 步: x = 1.13090110, f'(x) = 0.00e+00
  第 8 步: x = 1.13090110, f'(x) = 0.00e+00

梯度下降 8 步: x = 1.333329, f'(x) = 2.48e+00