第 4 章 进阶自动微分

原课程:「JAX 高级编程:性能、优化与规模化」第 4 章 进阶自动微分方法 运行环境:JAX / jaxlib 0.12.0.1,仅 CPU,Python 3.14。本 notebook 里的结论都在这个环境下运行验证过。

学习目标

  1. 从「线性化 + 转置」的角度理解前向模式(JVP)和反向模式(VJP),并会用 jax.linearize、jax.linear_transpose 把它们拆开来看。
  2. 掌握高阶导数的几种组合方式;了解 Taylor 模式(jax.experimental.jet),知道什么是扰动混淆,以及 JAX 为什么不会出这个问题。
  3. 能按问题规模选 jacfwd 还是 jacrev,并会用 vmap(jvp) 加着色(coloring)的方法高效求出稀疏 Jacobian。
  4. 熟练使用 jax.custom_vjp(包括 residuals、nondiff_argnums、梯度裁剪、梯度反转、STE、隐函数定理)和 jax.custom_jvp(包括 defjvps、自动转置),并用 check_grads 验证。
  5. 弄清 cond、scan、fori_loop、while_loop 各自支持哪种求导模式;知道可以用 jax.checkpoint 加 scan 节省反向传播的内存(系统讲解在进阶第 6 章 §6.6)。
  6. 掌握 argmax、round、sort、top_k 等不可微操作的梯度行为、stop_gradient 的用法,以及常见的平滑近似。
  7. 综合实践:用 custom_vjp 实现隐式层(DEQ)和 ODE 伴随法求导,并和直接对展开版本求导的结果对拍。

小节目录

小节原课程页面
4.1 前向和反向模式自动微分回顾review-autodiff-modes
4.2 雅可比向量积 (JVPs) 与 jax.jvpjacobian-vector-products-jvp
4.3 向量-雅可比积 (VJPs) 与 jax.vjpvector-jacobian-products-vjp
4.4 高阶导数higher-order-derivatives
4.5 计算完整的雅可比矩阵和海森矩阵computing-full-jacobians-hessians
4.6 使用 jax.custom_vjp 的自定义微分规则custom-vjp-rules
4.7 jax.custom_jvp 自定义求导规则custom-jvp-rules
4.8 通过控制流原语求导differentiation-through-control-flow
4.9 处理不可微分函数handling-non-differentiable-functions
4.10 实践:实现自定义梯度practice-implementing-custom-gradient
动手练习 / 测验 / 本章更新说明 / 小结 / 速查表—

阅读说明:示例代码按课程各页的结构整理,变量名、数值和代码结构尽量和课程保持一致,讲解用中文重新组织。代码的输出都是在本机运行得到的。过时或会出错的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法。

与入门课的关系(不重复已讲内容)

⚠️ 环境注意:jaxlib 0.12.0.1 的 CPU 后端有一个已知 bug:jit 闭包捕获了较大的常量矩阵以后,先做 matmul(W @ X)再做 reduce,会悄悄算错(复现与规避见 入门 00 目录与导读 的「本环境已知问题」,根因分析见 进阶 02 §2.3)。所以本 notebook 里的权重和数据一律作为函数参数传进去,不在闭包里捕获大数组。

# 公共导入
import time
import inspect
from functools import partial

import numpy as np
import jax
import jax.numpy as jnp
from jax import lax
from jax.test_util import check_grads
from jax.ad_checkpoint import print_saved_residuals   # 查看反向传播保存了哪些残差(4.10 节使用)

print("JAX 版本:", jax.__version__)
print("设备:", jax.devices())
JAX 版本: 0.12.0.1
设备: [CpuDevice(id=0)]

4.1 前向和反向模式自动微分回顾

为什么要用自动微分:自动微分(AD)把函数拆成一串基本运算(加、乘、sin、exp 等),再对它们反复用链式法则。有限差分有截断误差和舍入误差,符号微分可能让表达式爆炸;AD 则能用可控的计算量拿到机器精度的导数。jax.grad 就建立在这些基本运算的规则之上,底层对应前向和反向两种模式。

前向模式(JVP):一边求函数值,一边把输入方向上的「敏感度」(切向量)往前传。对 $f:\mathbb{R}^n\to\mathbb{R}^m$:

$$\mathrm{JVP}(f, x, v) = J_f(x)\,v = \left.\frac{d}{d\alpha} f(x+\alpha v)\right|_{\alpha=0}$$

一次 JVP 的计算量和一次 $f(x)$ 求值差不多,和输出维度 $m$ 无关。要拿到完整的 Jacobian,需要做 $n$ 次 JVP,所以前向模式在 $n \ll m$ 时更划算。

反向模式(VJP):先做一次前向求值,把需要的中间值存下来,然后从输出往回传余切向量:

$$\mathrm{VJP}(f, x, u) = u^\top J_f(x)$$

一次 VJP 的计算量同样和 $f(x)$ 求值差不多,和输入维度 $n$ 无关。要拿到完整的 Jacobian,需要做 $m$ 次 VJP。对标量损失 $L:\mathbb{R}^n\to\mathbb{R}$,有 $\mathrm{VJP}(L,x,1)=\nabla L(x)^\top$,这正是 jax.grad 的基础。

怎么选:$n<m$ 时用前向模式;$m<n$ 时用反向模式。神经网络训练就是典型的后一种情况($m=1$),所以 jax.grad 默认走反向模式。后面几节会用 jax.jvp 和 jax.vjp 显式地控制这两种模式。

(jacfwd/jacrev 怎么选、以及计时验证,入门 03 自动微分grad 的 3.3 节已经讲过,这里不再重复。)

🔍 补充:反向模式 = 线性化 + 转置

🟢 必读

JAX 内部其实并没有给每个原语分别实现一套 JVP 规则和一套 VJP 规则。它是这么做的:

  1. 线性化(linearize):对 $f$ 做 JVP 并部分求值,把只依赖原始值(primal)的部分先算掉(这些就是 residuals),剩下一个关于切向量的线性函数 $v \mapsto J v$。
  2. 转置(transpose):线性函数 $v\mapsto Jv$ 的转置就是 $u \mapsto J^\top u$,也就是 VJP。

所以 $\texttt{vjp} = \texttt{transpose} \circ \texttt{linearize}$。每个原语只需要提供 JVP 规则;线性原语(mul 的一侧、add、dot、transpose、reduce_sum 等)再多提供一个转置规则就行。这也解释了 4.7 节会讲到的一点:custom_jvp 只要切向量部分是线性的,就能自动获得反向模式。

公开 API 里对应的两个工具:

  • y, f_lin = jax.linearize(f, x):f_lin(v) == jvp(f,(x,),(v,))[1]。residuals 只算一次,之后多次调用 f_lin 都很便宜。比如 Krylov 迭代里要在同一点反复求 $Jv$,就适合用它。
  • f_T = jax.linear_transpose(f_lin, x):要求传入的函数是线性的,返回它的转置。
def f(x):
    return jnp.sin(x) * x**2

x = jnp.arange(3.0)
v = jnp.ones(3)
u = jnp.array([1.0, 2.0, 3.0])

# 第 1 步:线性化,得到 y = f(x) 和线性函数 f_lin: v -> J v
y, f_lin = jax.linearize(f, x)
print("f_lin 的 jaxpr(a~d 是预先算好的 residuals,如 sin x、cos x、x²;e 是切向量,只剩对 e 的乘加):")
print(jax.make_jaxpr(f_lin)(v))

# f_lin(v) 与 jvp 一致
jvp_out = jax.jvp(f, (x,), (v,))[1]
assert jnp.allclose(f_lin(v), jvp_out)

# 第 2 步:转置线性函数,得到 u -> J^T u
f_T = jax.linear_transpose(f_lin, x)
(ct_from_transpose,) = f_T(u)

# 与 jax.vjp 完全一致:反向模式 = 线性化 + 转置
_, f_vjp = jax.vjp(f, x)
(ct_from_vjp,) = f_vjp(u)
print("linear_transpose(linearize):", ct_from_transpose)
print("vjp                        :", ct_from_vjp)
assert jnp.allclose(ct_from_transpose, ct_from_vjp)

# 与显式 Jacobian 对拍:J^T u
J = jax.jacfwd(f)(x)
assert jnp.allclose(J.T @ u, ct_from_vjp)
f_lin 的 jaxpr(a~d 是预先算好的 residuals,如 sin x、cos x、x²;e 是切向量,只剩对 e 的乘加):
{ lambda a:f32[3] b:f32[3] c:f32[3] d:f32[3]; e:f32[3]. let
    f:f32[3] = mul e a
    g:f32[3] = mul e b
    h:f32[3] = mul f c
    i:f32[3] = mul d g
    j:f32[3] = add_any h i
  in (j,) }
linear_transpose(linearize): [0.        4.4464884 5.9178066]
vjp                        : [0.        4.4464884 5.9178066]
# linear_transpose 只接受线性函数:对 sin 这种非线性函数做转置会报错
try:
    jax.linear_transpose(jnp.sin, x)(x)
    raised = False
except NotImplementedError as e:
    raised = True
    print("NotImplementedError:", str(e)[:90])
assert raised

# linearize 的好处:residuals 只算一次,同一点上的多个方向可以用 vmap 批量求 J v
V = jnp.eye(3)
cols = jax.vmap(f_lin)(V)          # 每行是 J e_i,即 J 的第 i 列
assert jnp.allclose(cols.T, J)
print("vmap(f_lin) 得到的 Jacobian:\n", cols.T)
NotImplementedError: Transpose rule (for reverse-mode differentiation) for 'sin' not implemented
vmap(f_lin) 得到的 Jacobian:
 [[0.        0.        0.       ]
 [0.        2.2232442 0.       ]
 [0.        0.        1.9726022]]

4.2 雅可比向量积 (JVPs) 与 jax.jvp

Jacobian:$f:\mathbb{R}^n\to\mathbb{R}^m$ 在 $x$ 处的 Jacobian $J(x)=\dfrac{\partial f}{\partial x}$ 是一个 $m\times n$ 矩阵,它是 $f$ 在 $x$ 附近的最佳线性近似。

JVP 的定义:

$$\mathrm{JVP}(x,v) = J(x)\,v = \left.\frac{d}{d\alpha} f(x+\alpha v)\right|_{\alpha=0}\in\mathbb{R}^m$$

它表示输入沿方向 $v$ 做无穷小扰动时,输出的变化率。关键在于,整个计算过程不需要显式构造 $J(x)$。

签名:jax.jvp(fun, primals, tangents)

  • primals:输入组成的元组(或列表);
  • tangents:和 primals 的树结构、形状、dtype 完全一致的切向量元组。只想扰动部分参数时,其余参数的切向量传零(传 None 不行,见下面的注意);
  • 返回 (primal_out, tangent_out),两者结构相同。

例 1:$f(x) = [x_0^2,\ x_0 x_1]$,在 $x=[2,3]$、$v=[1,0.5]$ 处求值。手算得 $J(x)=\begin{bmatrix}2x_0&0\\x_1&x_0\end{bmatrix}=\begin{bmatrix}4&0\\3&2\end{bmatrix}$,所以 $Jv=[4,4]$。

# 课程代码:简单例子
def f(x):
    return jnp.array([x[0]**2, x[0] * x[1]])

x_primal = jnp.array([2.0, 3.0])
v_tangent = jnp.array([1.0, 0.5])

primal_out, tangent_out = jax.jvp(f, (x_primal,), (v_tangent,))
print(f"Primal input (x): {x_primal}")
print(f"Tangent vector (v): {v_tangent}")
print(f"Primal output f(x): {primal_out}")
print(f"Tangent output (J(x)v): {tangent_out}")

# 手工推导:J = [[2*x0, 0], [x1, x0]] = [[4, 0], [3, 2]],J v = [4, 4]
J_manual = jnp.array([[4.0, 0.0], [3.0, 2.0]])
assert jnp.allclose(primal_out, jnp.array([4.0, 6.0]))
assert jnp.allclose(tangent_out, J_manual @ v_tangent)
Primal input (x): [2. 3.]
Tangent vector (v): [1.  0.5]
Primal output f(x): [4. 6.]
Tangent output (J(x)v): [4. 4.]

例 2(PyTree):线性模型 predict(params, inputs) = inputs @ w + b,其中 params 是字典 {'w', 'b'}。只扰动 w(切向量全为 1),b 和 inputs 都不扰动。切向量输出应该是 inputs @ tangent_w + tangent_b = [30, 30]。

⚠️ 注意:前向输出是 inputs @ w + b = [70+0.1, 100-0.1] = [70.1, 99.9]。第二项是 100 减 0.1,不是 100.9。

⚠️ 注意:别把 inputs 的切向量写成 jax.lax.stop_gradient(inputs)。stop_gradient(inputs) 的值就是 inputs 本身,也就是 [10, 20],并不是零切向量,所以切向量输出会变成 [100, 130],而不是 [30, 30]。传 None 也不行,会直接报 TypeError(树结构不一致)。要表示「不扰动」,应该传 jnp.zeros_like(inputs),或者用闭包把 inputs 固定住。

# ❌ 旧写法(过时/错误):用 stop_gradient(inputs) 当“零切向量”,或传 None
def predict(params, inputs):
    return jnp.dot(inputs, params['w']) + params['b']

params = {'w': jnp.array([[1.0, 2.0], [3.0, 4.0]]), 'b': jnp.array([0.1, -0.1])}
inputs = jnp.array([10.0, 20.0])
tangents = {'w': jnp.ones_like(params['w']), 'b': jnp.zeros_like(params['b'])}

_, t_wrong = jax.jvp(predict, (params, inputs), (tangents, jax.lax.stop_gradient(inputs)))
print("stop_gradient(inputs) 作切向量 ->", t_wrong, "(多出了 inputs 方向的贡献 inputs @ w)")
try:
    jax.jvp(predict, (params, inputs), (tangents, None))
except TypeError as e:
    print("传 None ->", type(e).__name__, ":", str(e)[:80], "...")
stop_gradient(inputs) 作切向量 -> [100. 130.] (多出了 inputs 方向的贡献 inputs @ w)
传 None -> TypeError : primal and tangent arguments to jax.jvp must have the same tree structure; prima ...
# ✅ 推荐写法:不扰动的参数传零切向量(或用闭包固定)
primal_out, tangent_out = jax.jvp(predict, (params, inputs), (tangents, jnp.zeros_like(inputs)))
print("Inputs:", inputs)
print("Params:", params)
print("Tangents (perturbation):", tangents)
print("Primal output (prediction):", primal_out)
print("Tangent output (change in prediction due to perturbation):", tangent_out)

# 另一种写法:用闭包只对 params 做 jvp
_, tangent_out2 = jax.jvp(lambda p: predict(p, inputs), (params,), (tangents,))

assert jnp.allclose(primal_out, jnp.array([70.1, 99.9]))      # 第二项是 99.9,不是 100.9
assert jnp.allclose(tangent_out, jnp.array([30.0, 30.0]))
assert jnp.allclose(tangent_out2, tangent_out)
assert jnp.allclose(t_wrong, jnp.array([100.0, 130.0]))       # stop_gradient 不等于零切向量
Inputs: [10. 20.]
Params: {'w': Array([[1., 2.],
       [3., 4.]], dtype=float32), 'b': Array([ 0.1, -0.1], dtype=float32)}
Tangents (perturbation): {'w': Array([[1., 1.],
       [1., 1.]], dtype=float32), 'b': Array([0., 0.], dtype=float32)}
Primal output (prediction): [70.1 99.9]
Tangent output (change in prediction due to perturbation): [30. 30.]

应用与优点,一共 4 点:

  1. 一次 JVP 的计算量通常是原函数求值的几倍(一般说法是 1.5–3 倍),和输出维度无关;
  2. 主要用来求方向导数,比如做敏感性分析;
  3. 取 $v=e_j$(独热向量)时,$J e_j$ 就是 Jacobian 的第 $j$ 列,重复 $n$ 次就能拼出完整的 Jacobian。实际中直接用 jax.jacfwd,它本质上就是 vmap 套在 jvp 上;
  4. JVP 是 JAX 自动微分的基础,Hessian 等量在内部都靠它(参见 4.1 节补充:VJP 本身就是把 JVP 线性化后再转置得到的)。

jax.jvp 可以和 jit、vmap 任意组合,和 jax.grad/jax.vjp 的反向模式互相补充。

🔍 补充:切向量的 dtype 规则与 float0

⚪ 选读:要对含整数参数的函数直接调用 jax.jvp 时

  • 切向量的 dtype 必须和 primal 完全一致。比如 float32 的 primal 配一个 Python int 切向量,会直接报 TypeError。
  • 整数或布尔类型的 primal 没有「无穷小扰动」,对应切向量的 dtype 必须是特殊的 jax.dtypes.float0。实际写代码时,更简单的办法是用闭包或 partial 把整数参数固定住,不让它出现在 primals 里。
# 切向量 dtype 与 primal 不一致 -> TypeError
try:
    jax.jvp(jnp.sin, (jnp.float32(1.0),), (1,))
    raised = False
except TypeError as e:
    raised = True
    print("TypeError:", str(e)[:110], "...")
assert raised

# 整数 primal 必须配 float0 切向量
def scale_by_int(x, n):
    return x * n

n = jnp.int32(3)
n_tangent = np.zeros((), dtype=jax.dtypes.float0)
_, t = jax.jvp(scale_by_int, (2.0, n), (1.0, n_tangent))
print("对 x 方向的导数:", t)
assert t == 3.0
TypeError: primal and tangent arguments to jax.jvp do not match; dtypes must be equal, or in case of int/bool primal dtyp ...
对 x 方向的导数: 3.0

4.3 向量-雅可比积 (VJPs) 与 jax.vjp

反向模式是深度学习里梯度下降的核心:只要一次反向计算,就能拿到标量损失对大量参数的梯度。jax.vjp 是 JAX 里计算 VJP 的底层函数,jax.grad 是它的易用封装。

数学:对 $f:\mathbb{R}^n\to\mathbb{R}^m$,反向模式计算的是余切向量和 Jacobian 的乘积:

$$u^\top J_f(x)\in\mathbb{R}^{1\times n}$$

机器学习中的情形:设 $L=\ell(y)$,$y=g(x)$,那么 $\nabla_x L = \nabla_y L\cdot J_g(x)$,这正是一个 VJP。余切从输出端开始往回传,初始余切为 $\partial L/\partial L = 1$。

签名:y, vjp_fun = jax.vjp(fun, *primals)

  • y = fun(*primals) 是前向输出;
  • vjp_fun(u) 计算 $u^\top J$。u 必须和 y 的结构、形状、dtype 一致。返回值是一个元组,每个 primal 对应一个元素,就算只有一个输入也是元组。

例 1(标量输出):$f(x,y)=x^2\sin y$,在 $x=3,\ y=\pi/2$ 处,$\partial f/\partial x = 2x\sin y = 6$,$\partial f/\partial y = x^2\cos y = 0$。

# 课程代码:标量输出
def f(x, y):
    return x**2 * jnp.sin(y)

x_primal = 3.0
y_primal = jnp.pi / 2.0

y_out, vjp_fun = jax.vjp(f, x_primal, y_primal)
print("前向输出 f(x, y):", y_out)

cotangent_v = 1.0
primal_grads = vjp_fun(cotangent_v)
print("对 x 的梯度:", primal_grads[0])   # 预期 2*x*sin(y) = 6.0
print("对 y 的梯度:", primal_grads[1])   # 预期 x^2*cos(y) = 0.0

grad_f = jax.grad(f, argnums=(0, 1))
grads_via_grad = grad_f(x_primal, y_primal)
print("jax.grad 结果:", grads_via_grad)

assert jnp.allclose(primal_grads[0], 6.0)
assert jnp.allclose(primal_grads[1], 0.0, atol=1e-5)
assert all(jnp.allclose(a, b) for a, b in zip(primal_grads, grads_via_grad))
前向输出 f(x, y): 9.0
对 x 的梯度: 6.0
对 y 的梯度: -3.934025e-07
jax.grad 结果: (Array(6., dtype=float32, weak_type=True), Array(-3.934025e-07, dtype=float32, weak_type=True))

可以看到,对标量函数来说,jax.grad 本质上就是「jax.vjp + 初始余切 1.0」的封装。

例 2(非标量输出):$g(x)=(x_0^2,\ x_1^3)$,在 $x=[2,3]$ 处,$y=[4,27]$,$J=\mathrm{diag}(2x_0, 3x_1^2)=\mathrm{diag}(4,27)$。取余切 $u=[10,20]$,得到 $u^\top J=[40,540]$。注意返回值是元组 ([40., 540.],)。

# 课程代码:非标量输出
def g(x):
    return jnp.array([x[0]**2, x[1]**3])

x_primal = jnp.array([2.0, 3.0])
y_out, vjp_fun = jax.vjp(g, x_primal)
print("y_out:", y_out)                       # [4., 27.]

cotangent_v = jnp.array([10.0, 20.0])
primal_grad = vjp_fun(cotangent_v)
print("u^T J:", primal_grad)                 # ([40., 540.],)

assert isinstance(primal_grad, tuple) and len(primal_grad) == 1
assert jnp.allclose(primal_grad[0], jnp.array([40.0, 540.0]))
y_out: [ 4. 27.]
u^T J: (Array([ 40., 540.], dtype=float32),)

为什么有时要直接用 jax.vjp,有 4 个理由:

  1. 有多个输出、或者要处理中间梯度时,直接用它更灵活,也更高效;
  2. 它是 jax.custom_vjp 的基础(4.6 节);
  3. 有些优化算法和 AD 研究需要直接拿到 VJP;
  4. 有助于理解反向模式和 jax.grad 是怎么搭起来的。

🔍 补充:vjp_fun 可以复用,余切形状必须严格匹配

⚪ 选读:直接使用 jax.vjp、或要批量求 Jacobian 的多行时

  • jax.vjp 做完前向以后,residuals 都存在 vjp_fun 的闭包里。同一个 vjp_fun 可以用不同的余切反复调用,还可以直接 vmap,一次拿到 Jacobian 的多行(jacrev 就是这么实现的)。
  • 余切的形状和 dtype 必须和输出完全一致,JAX 不会帮你广播:输出是 (3,) 时传标量 1.0 会报 ValueError。这和 grad 要求输出必须是标量是一个道理。
  • jax.vjp(f, x, has_aux=True) 返回 (y, vjp_fun, aux),用法和 grad 的 has_aux 一样。
y, vjp_sq = jax.vjp(lambda x: x**2, jnp.arange(1.0, 4.0))

# 同一个 vjp_fun,vmap 到 3 个基向量上 -> Jacobian 的 3 行
rows = jax.vmap(vjp_sq)(jnp.eye(3))[0]
assert jnp.allclose(rows, jnp.diag(2 * jnp.arange(1.0, 4.0)))
print("vmap(vjp_fun)(I) =\n", rows)

# 余切形状不匹配(不会自动广播)
for bad_ct in [1.0, jnp.ones(2)]:
    try:
        vjp_sq(bad_ct)
        raised = False
    except ValueError as e:
        raised = True
        print("ValueError:", str(e)[:100], "...")
    assert raised
vmap(vjp_fun)(I) =
 [[2. 0. 0.]
 [0. 4. 0.]
 [0. 0. 6.]]
ValueError: unexpected JAX type (e.g. shape/dtype) for argument to VJP function: got float32[], but expected flo ...
ValueError: unexpected JAX type (e.g. shape/dtype) for argument to VJP function: got float32[2], but expected fl ...

4.4 高阶导数

JAX 的 grad、jvp、vjp 都是可组合的函数变换:对变换后的函数再做一次变换,就得到高阶导数。

标量例子:$f(x)=x^3+2x^2-3x+1$,$f'(x)=3x^2+4x-3$,$f''(x)=6x+4$,$f^{(3)}(x)=6$。在 $x=2$ 处,$f'(2)=17$,$f''(2)=16$。

# 课程代码 A:连续两次 grad
def f(x):
    return x**3 + 2 * x**2 - 3 * x + 1

grad_f = jax.grad(f)
grad_grad_f = jax.grad(grad_f)

x_val = 2.0
first_deriv = grad_f(x_val)
second_deriv = grad_grad_f(x_val)
print("f(x) = x^3 + 2x^2 - 3x + 1")
print(f"f'(x) = 3x^2 + 4x - 3,  f'({x_val}) = {first_deriv}")
print(f"f''(x) = 6x + 4,        f''({x_val}) = {second_deriv}")

assert first_deriv == 17.0 and second_deriv == 16.0
f(x) = x^3 + 2x^2 - 3x + 1
f'(x) = 3x^2 + 4x - 3,  f'(2.0) = 17.0
f''(x) = 6x + 4,        f''(2.0) = 16.0

Hessian-向量积(HVP):对 $f:\mathbb{R}^n\to\mathbb{R}$,grad(grad(f)) 没法直接用(grad(f) 的输出是向量,不能再 grad),而且通常也不需要完整的 Hessian,只需要 $Hv$。有两种写法:

  1. 前向套反向(forward-over-reverse):$\mathrm{jvp}(\nabla f, x, v) = J_{\nabla f}(x)\,v = H(x)\,v$;
  2. 反向套反向(reverse-over-reverse):$\nabla_x\big(\nabla f(x)\cdot v\big) = H v$。

推荐优先用第 1 种,因为它只需要一次反向传播,再叠一层前向模式。

⚠️ 注意:第 2 种写法的代码是 grad 套 grad,所以它是反向套反向,别把它叫成「反向-正向叠加」。

HVP 更完整的写法对比(包括 jvp(grad)、grad(dot(grad, v))、vjp(grad) 等)和计时,见 入门 07 实战专题,这里只放课程的示例代码。

# 课程代码 B / C:两种 HVP
def hvp_forward_over_reverse(f, primals, tangents):
    # jvp 返回 (grad_f(x), H v),取第二个输出
    return jax.jvp(jax.grad(f), primals, tangents)[1]

def hvp_reverse_over_reverse(f, primals, tangents):
    x, = primals
    v, = tangents
    # 用 lambda 捕获 v,对 grad(f)(x)·v 再求一次梯度
    return jax.grad(lambda x_lambda: jnp.dot(jax.grad(f)(x_lambda), v))(x)

# 课程代码 D:f(x, y) = x^2 y + y^3
def func(x):
    return x[0]**2 * x[1] + x[1]**3

x_primal = jnp.array([1.0, 2.0])
v_tangent = jnp.array([1.0, 0.0])
hvp_val = jax.jvp(jax.grad(func), (x_primal,), (v_tangent,))[1]
# 梯度 [2xy, x^2+3y^2];Hessian [[2y, 2x], [2x, 6y]],在 (1,2) 处 = [[4,2],[2,12]],Hv = [4, 2]
print("HVP (fwd-over-rev):", hvp_val)
print("HVP (rev-over-rev):", hvp_reverse_over_reverse(func, (x_primal,), (v_tangent,)))

assert jnp.allclose(hvp_val, jnp.array([4.0, 2.0]))
assert jnp.allclose(hvp_forward_over_reverse(func, (x_primal,), (v_tangent,)),
                    hvp_reverse_over_reverse(func, (x_primal,), (v_tangent,)))
HVP (fwd-over-rev): [4. 2.]
HVP (rev-over-rev): [4. 2.]

完整 Hessian:可以用 jax.hessian(f),也可以用 jacfwd(grad(f))(逐列求,基于 JVP)或 jacrev(grad(f))(逐行求,基于 VJP)。

# 课程代码 E:三种方式求完整 Hessian
hessian_matrix = jax.hessian(func)(x_primal)
hessian_jacfwd = jax.jacfwd(jax.grad(func))(x_primal)
hessian_jacrev = jax.jacrev(jax.grad(func))(x_primal)
print("jax.hessian:\n", hessian_matrix)

H_expected = jnp.array([[4.0, 2.0], [2.0, 12.0]])
for H in (hessian_matrix, hessian_jacfwd, hessian_jacrev):
    assert jnp.allclose(H, H_expected)

# 课程代码 F:三阶导数
grad_grad_grad_f = jax.grad(grad_grad_f)
third_deriv = grad_grad_grad_f(x_val)
print("f^(3)(2) =", third_deriv)
assert third_deriv == 6.0
jax.hessian:
 [[ 4.  2.]
 [ 2. 12.]]
f^(3)(2) = 6.0

🔍 补充:扰动混淆(perturbation confusion)以及 JAX 如何避免

⚪ 选读:想了解嵌套前向求导为什么不会算错(AD 的实现原理)时

嵌套使用前向模式时,有一个经典陷阱。看这个式子:

$$\frac{d}{dx}\left[x\cdot \left.\frac{d}{dy}(x+y)\right|_{y=1}\right]_{x=2}$$

内层导数 $\frac{d}{dy}(x+y)=1$,所以整个式子等于 $\frac{d}{dx}[x]=1$。

如果用最朴素的对偶数(dual number)来实现前向 AD,所有层级的切向量都挤在同一个「$\varepsilon$」槽位里。内层求导时,外层给 $x$ 的扰动 $\varepsilon$ 会被误当成内层的扰动,于是内层导数变成 2,最终结果也错成 2。这就是扰动混淆。

JAX 是这么避免的:每调用一次 jvp/grad,就新建一个 trace,它有唯一的身份(嵌套层级)。每个 tracer 都记着自己属于哪个 trace。内层 trace 碰到外层的 tracer 时,会把它当成常量(那不是自己的扰动),外层的切向量也不会被内层读到。所以不同层级的扰动永远不会混在一起。下面用 30 行代码写一个朴素的对偶数实现,复现这个错误,再和 JAX 的结果对比。

class Dual:
    """朴素对偶数:所有嵌套层共用同一个无穷小 ε(没有层级标签)"""
    def __init__(self, p, t):
        self.p, self.t = p, t

    @staticmethod
    def lift(o):
        return o if isinstance(o, Dual) else Dual(o, 0.0)

    def __add__(self, o):
        o = Dual.lift(o)
        return Dual(self.p + o.p, self.t + o.t)
    __radd__ = __add__

    def __mul__(self, o):
        o = Dual.lift(o)
        return Dual(self.p * o.p, self.p * o.t + self.t * o.p)
    __rmul__ = __mul__

def naive_D(f):
    def df(x):
        out = f(Dual(x, 1.0))
        return out.t if isinstance(out, Dual) else 0.0
    return df

# d/dx [ x * d/dy (x + y)|_{y=1} ] 在 x=2 处,正确答案是 1
naive = naive_D(lambda x: x * naive_D(lambda y: x + y)(1.0))(2.0)

def jax_D(f):
    return lambda x: jax.jvp(f, (x,), (1.0,))[1]

jax_result = jax_D(lambda x: x * jax_D(lambda y: x + y)(1.0))(2.0)
print("朴素对偶数:", naive, "(扰动混淆,错误)")
print("JAX jvp   :", jax_result, "(正确)")
assert naive == 2.0
assert jax_result == 1.0

# 用 grad 嵌套同样正确:d/dx [ x * d/dy (x*y) ] = d/dx [x^2] = 2x = 4
assert jax.grad(lambda x: x * jax.grad(lambda y: x * y)(3.0))(2.0) == 4.0
朴素对偶数: 2.0 (扰动混淆,错误)
JAX jvp   : 1.0 (正确)

🔍 补充:Taylor 模式 —— jax.experimental.jet

⚪ 选读:需要一次求出 3 阶以上的高阶导数时

把 grad 嵌套 $k$ 次来求 $k$ 阶导数,计算图会随 $k$ 迅速膨胀:每多一层求导,就要对上一层已经变大的导数图再求一次导。Taylor 模式的做法是沿输入曲线 $x(t)=x_0 + x_1 t + \tfrac{1}{2}x_2 t^2+\dots$,把截断的 Taylor 级数一次性往前传。每个原语按 Faà di Bruno 公式直接传播高阶系数,代价大约随阶数多项式增长。

JAX 0.12 里 jax.experimental.jet 还在,可以正常导入。签名是 jet(fun, primals, series),返回 (primal_out, series_out)。它的约定是:series 里第 $k$ 项是 $x$ 沿曲线的 $k$ 阶导数(不是除以 $k!$ 之后的系数)。取 series=((1, 0, 0, ...),) 时,输出就是 $f', f'', f^{(3)}, \dots$。

注意:它在 experimental 里,API 可能会变。下面的代码还会验证:它不支持 cond、while_loop、sort,也不支持 custom_jvp 函数(比如 jax.nn.relu、jnp.logaddexp)。

from jax.experimental import jet

# 约定验证:x^3 在 x=2 处的 1/2/3 阶导数是 12, 12, 6
y0, (d1, d2, d3) = jet.jet(lambda x: x**3, (2.0,), ((1.0, 0.0, 0.0),))
print("x^3 的 jet:", y0, d1, d2, d3)
assert (y0, d1, d2, d3) == (8.0, 12.0, 12.0, 6.0)

# 与嵌套 grad 对拍:f(x) = exp(sin x) * cos(x^2),求 1~6 阶导数
def g(x):
    return jnp.exp(jnp.sin(x)) * jnp.cos(x**2)

K = 6
def jet_derivs(x):
    _, ys = jet.jet(g, (x,), ((1.0,) + (0.0,) * (K - 1),))
    return jnp.stack(ys)

def nested_grads(x):
    out, h = [], g
    for _ in range(K):
        h = jax.grad(h)
        out.append(h(x))
    return jnp.stack(out)

dj, dn = jet_derivs(0.3), nested_grads(0.3)
print("jet       :", dj)
print("嵌套 grad :", dn)
assert jnp.allclose(dj, dn, rtol=1e-4)

# 计算图大小对比:第 K 阶导数的 jaxpr 长度(字符数)
def kth(h, k):
    for _ in range(k):
        h = jax.grad(h)
    return h
len_nested = len(str(jax.make_jaxpr(kth(g, K))(0.3)))
len_jet = len(str(jax.make_jaxpr(jet_derivs)(0.3)))
print(f"{K} 阶导数 jaxpr 字符数:嵌套 grad = {len_nested},jet(同时给出 1~{K} 阶)= {len_jet}")
x^3 的 jet: 8.0 12.0 12.0 6.0
jet       : [ 1.2061408e+00 -3.5863206e-02 -8.2447195e+00 -4.1277473e+01
 -9.0145424e+01  1.7200932e+02]
嵌套 grad : [ 1.2061408e+00 -3.5863161e-02 -8.2447205e+00 -4.1277473e+01
 -9.0145416e+01  1.7200931e+02]
6 阶导数 jaxpr 字符数:嵌套 grad = 84571,jet(同时给出 1~6 阶)= 23462
# jet 不支持的原语(实测)
unsupported = {
    "lax.cond": lambda x: lax.cond(x > 0, jnp.sin, jnp.cos, x),
    "jnp.sort": lambda x: jnp.sort(jnp.stack([x, 2 * x])).sum(),
    "lax.while_loop": lambda x: lax.while_loop(lambda c: c[0] < 3, lambda c: (c[0] + 1, c[1] * x), (0, 1.0))[1],
    "jax.nn.relu(custom_jvp)": jax.nn.relu,
}
n_fail = 0
for name, fn in unsupported.items():
    try:
        jet.jet(fn, (0.5,), ((1.0, 0.0),))
        print(f"{name:26s} 支持")
    except Exception as e:
        n_fail += 1
        print(f"{name:26s} 不支持 -> {type(e).__name__}")
assert n_fail == len(unsupported)
lax.cond                   不支持 -> KeyError
jnp.sort                   不支持 -> KeyError
lax.while_loop             不支持 -> KeyError
jax.nn.relu(custom_jvp)    不支持 -> UnexpectedTracerError

4.5 计算完整的雅可比矩阵和海森矩阵

完整的 $J$ 和 $H$ 算起来、存起来都很贵。机器学习里多数时候只需要 JVP、VJP 或 HVP,但有时确实要完整矩阵。

Jacobian:$f:\mathbb{R}^n\to\mathbb{R}^m$,$J_{ij}=\dfrac{\partial f_i}{\partial x_j}$,形状为 $m\times n$。

  • jax.jacfwd:对每个输入基向量 $e_j$ 算 $Je_j$,得到第 $j$ 列,一共 $n$ 次 JVP,适合 $n<m$;
  • jax.jacrev:对每个输出基向量 $e_i$ 算 $e_i^\top J$,得到第 $i$ 行,一共 $m$ 次 VJP,适合 $m<n$。jax.grad 就是 jacrev 在标量输出($m=1$)时的特例;
  • 两者内部都用 vmap 把这些 JVP/VJP 批量化,而不是写 Python 循环;
  • 怎么选:$m\ll n$ 用 jacrev,$n\ll m$ 用 jacfwd,两者差不多时,实际测一下再定。

示例函数:$f(x)=[x_0^2x_1,\ \sin x_2]$,也就是 $\mathbb{R}^3\to\mathbb{R}^2$。在 $x=[1,2,\pi/2]$ 处,$J=\begin{bmatrix}2x_0x_1 & x_0^2 & 0\\ 0&0&\cos x_2\end{bmatrix}=\begin{bmatrix}4&1&0\\0&0&\approx 0\end{bmatrix}$。

# 课程代码 A / B:jacfwd 与 jacrev
def func(x):
    return jnp.array([x[0]**2 * x[1], jnp.sin(x[2])])

x_in = jnp.array([1.0, 2.0, jnp.pi / 2])
jacobian_fwd = jax.jacfwd(func)(x_in)
jacobian_rev = jax.jacrev(func)(x_in)
print("输入:", x_in, " 输出:", func(x_in))
print("jacfwd (形状 %s):\n" % (jacobian_fwd.shape,), jacobian_fwd)

J_expected = jnp.array([[4.0, 1.0, 0.0], [0.0, 0.0, 0.0]])
assert jacobian_fwd.shape == (2, 3)
assert jnp.allclose(jacobian_fwd, J_expected, atol=1e-6)
assert jnp.allclose(jacobian_rev, jacobian_fwd)
输入: [1.        2.        1.5707964]  输出: [2. 1.]
jacfwd (形状 (2, 3)):
 [[ 4.000000e+00  1.000000e+00  0.000000e+00]
 [-0.000000e+00 -0.000000e+00 -4.371139e-08]]
# 课程代码 C:vmap + jvp 手动构造(每个输入基向量 -> 一列)
n = x_in.shape[0]
basis_vectors_in = jnp.eye(n)
primals_out, jac_cols = jax.vmap(lambda tangent: jax.jvp(func, (x_in,), (tangent,)),
                                 in_axes=0)(basis_vectors_in)
jacobian_vmap_jvp = jac_cols.T      # vmap 把列堆在第 0 维,需转置

# 课程代码 D:vmap + vjp 手动构造(每个输出基向量 -> 一行)
primals_out, vjp_fun = jax.vjp(func, x_in)
m = primals_out.shape[0]
basis_vectors_out = jnp.eye(m)
jac_rows = jax.vmap(vjp_fun, in_axes=0)(basis_vectors_out)
jacobian_vmap_vjp = jac_rows[0]     # vjp_fun 返回一个元组,取第一个元素

assert jnp.allclose(jacobian_vmap_jvp, jacobian_fwd)
assert jnp.allclose(jacobian_vmap_vjp, jacobian_rev)
print("vmap(jvp) 与 vmap(vjp) 构造的 Jacobian 与 jacfwd/jacrev 一致")
vmap(jvp) 与 vmap(vjp) 构造的 Jacobian 与 jacfwd/jacrev 一致

Hessian:$H_{ij}=\dfrac{\partial^2 f}{\partial x_i\,\partial x_j}$,形状 $n\times n$。二阶导数连续时 $H$ 是对称的。外层用 jacfwd 通常更好,因为 grad(f) 是 $\mathbb{R}^n\to\mathbb{R}^n$ 的「方阵」函数,前向模式的常数开销更小。

⚠️ 注意:jax.hessian(f) 内部到底怎么实现?常见的说法有「jax.jacfwd(jax.grad(f))」,也有「jacfwd(jacrev(f)) 或 jacrev(jacfwd(f))」。看 JAX 0.12 的源码,它固定是 jacfwd(jacrev(f)),也就是前向套反向。(对标量 $f$ 来说,jacrev(f) 和 grad(f) 算的是同一个量。)

性能:Hessian 有 $O(n^2)$ 个元素,存储也是 $O(n^2)$。优化算法里通常只用 HVP,一次 HVP 的代价大约是两次梯度计算。真正需要完整矩阵的情形有:要显式解线性系统的牛顿法、维度很低的问题,以及调试和分析。

# 查看 jax.hessian 的源码
src = inspect.getsource(jax.hessian)
print(src[src.index("return jacfwd"):].strip())
assert "jacfwd(jacrev(" in src

# 课程代码 E:jax.hessian
def scalar_func(x):
    return x[0]**2 * x[1] + x[1]**3

x_in = jnp.array([1.0, 2.0])
hessian_matrix = jax.hessian(scalar_func)(x_in)
print("Hessian:\n", hessian_matrix)
assert hessian_matrix.shape == (2, 2)
assert jnp.allclose(hessian_matrix, jnp.array([[4.0, 2.0], [2.0, 12.0]]))
return jacfwd(jacrev(fun, argnums, has_aux=has_aux, holomorphic=holomorphic),
                argnums, has_aux=has_aux, holomorphic=holomorphic)
Hessian:
 [[ 4.  2.]
 [ 2. 12.]]

课程代码 F 对比「先求完整 Hessian 再乘 $v$」和「直接用 JVP 求 HVP」。$H=\begin{bmatrix}4&2\\2&12\end{bmatrix}$,$v=[0.5,-0.5]$,所以 $Hv=[1,-5]$。

⚠️ 注意:方法 2 如果写成 jax.jvp(grad_f, (x_in,), (v_)),会出两个问题:v_ 是没定义的变量(定义的是 v),运行会报 NameError;而且 (v) 不是元组。正确写法是 (v,)。

# ❌ 旧写法(过时/错误):切向量写成 (v_),变量未定义且不是元组
v = jnp.array([0.5, -0.5])
grad_f = jax.grad(scalar_func)
try:
    _, hvp_efficient = jax.jvp(grad_f, (x_in,), (v_))
except NameError as e:
    print("NameError:", e)
NameError: name 'v_' is not defined
# ✅ 推荐写法
v = jnp.array([0.5, -0.5])
# 方法 1:显式 Hessian
hessian_matrix = jax.hessian(scalar_func)(x_in)
hvp_explicit = hessian_matrix @ v
# 方法 2:jvp(grad) 直接得到 H v,不构造 H
grad_f = jax.grad(scalar_func)
_, hvp_efficient = jax.jvp(grad_f, (x_in,), (v,))
print("显式 H @ v :", hvp_explicit)
print("jvp(grad)  :", hvp_efficient)
assert jnp.allclose(hvp_explicit, jnp.array([1.0, -5.0]))
assert jnp.allclose(hvp_efficient, hvp_explicit)
显式 H @ v : [ 1. -5.]
jvp(grad)  : [ 1. -5.]

🔍 补充:稀疏 / 结构化 Jacobian 与着色(coloring)

⚪ 选读:要求大规模稀疏(如带状)Jacobian 时

jacfwd 对 $n$ 个基向量各做一次 JVP。如果 $J$ 是稀疏的,比如三对角矩阵,这就很浪费:第 $j$ 列只在第 $j-1, j, j+1$ 行有非零元。

着色思想(Curtis–Powell–Reid):如果两列的非零行互不重叠,它们就可以「共用」一次 JVP,把两个基向量加起来当切向量就行,因为结果里两列的非零元不会撞在一起。对三对角矩阵,按 $j \bmod 3$ 分成 3 组(3 种颜色),每组的种子向量是 $s_c = \sum_{j\equiv c\ (\mathrm{mod}\ 3)} e_j$,于是

$$J s_c = \sum_{j\equiv c} J_{:,j}$$

第 $i$ 行只会有一个 $j\equiv c$ 满足 $|i-j|\le 1$,所以可以没有歧义地解压出来:$J_{ij} = (J s_{j \bmod 3})_i$(当 $|i-j|\le1$ 时)。

这样,不管 $n$ 多大,都只要 3 次 JVP(再用 vmap 批量化),而 jacfwd 要 $n$ 次。反向模式也能这么做:对行着色,再用 vmap(vjp)。

def f_band(x):
    """f_i = x_i^2 - x_{i-1} x_{i+1} + sin(x_i),Jacobian 为三对角"""
    xp = jnp.concatenate([x[1:], jnp.zeros(1, x.dtype)])
    xm = jnp.concatenate([jnp.zeros(1, x.dtype), x[:-1]])
    return x**2 - xm * xp + jnp.sin(x)

def colored_jacobian_tridiag(f, x, n_colors=3):
    n = x.shape[0]
    j = jnp.arange(n)
    seeds = (j[None, :] % n_colors == jnp.arange(n_colors)[:, None]).astype(x.dtype)  # (3, n)
    compressed = jax.vmap(lambda s: jax.jvp(f, (x,), (s,))[1])(seeds)                 # (3, n):只做 3 次 JVP
    ii, jj = j[:, None], j[None, :]
    return jnp.where(jnp.abs(ii - jj) <= 1, compressed[jj % n_colors, ii], 0.0)

x9 = jnp.linspace(0.1, 1.0, 9)
J_dense = jax.jacfwd(f_band)(x9)
J_color = colored_jacobian_tridiag(f_band, x9)
print("三对角 Jacobian(着色法,3 次 JVP):\n", np.round(np.asarray(J_color), 3))
assert jnp.allclose(J_color, J_dense)
三对角 Jacobian(着色法,3 次 JVP):
 [[ 1.195  0.     0.     0.     0.     0.     0.     0.     0.   ]
 [-0.325  1.403 -0.1    0.     0.     0.     0.     0.     0.   ]
 [ 0.    -0.438  1.598 -0.212  0.     0.     0.     0.     0.   ]
 [ 0.     0.    -0.55   1.781 -0.325  0.     0.     0.     0.   ]
 [ 0.     0.     0.    -0.662  1.953 -0.438  0.     0.     0.   ]
 [ 0.     0.     0.     0.    -0.775  2.113 -0.55   0.     0.   ]
 [ 0.     0.     0.     0.     0.    -0.888  2.264 -0.662  0.   ]
 [ 0.     0.     0.     0.     0.     0.    -1.     2.406 -0.775]
 [ 0.     0.     0.     0.     0.     0.     0.     0.     2.54 ]]
# 计时对比(n = 3000):jacfwd 需要 3000 次 JVP 并输出 n×n 稠密矩阵;
# 着色法只做 3 次 JVP,输出 3×n 的压缩形式(已包含全部非零元,三对角结构下无需解压成稠密)
def compressed_tridiag(f, x, n_colors=3):
    n = x.shape[0]
    j = jnp.arange(n)
    seeds = (j[None, :] % n_colors == jnp.arange(n_colors)[:, None]).astype(x.dtype)
    return jax.vmap(lambda s: jax.jvp(f, (x,), (s,))[1])(seeds)

n_big = 3000
x_big = jnp.linspace(0.1, 1.0, n_big)
jac_dense_fn = jax.jit(jax.jacfwd(f_band))
jac_comp_fn = jax.jit(lambda x: compressed_tridiag(f_band, x))

# 预热(编译)
jac_dense_fn(x_big).block_until_ready()
jac_comp_fn(x_big).block_until_ready()

def bench(fn, x, reps=10):
    t0 = time.perf_counter()
    for _ in range(reps):
        fn(x).block_until_ready()
    return (time.perf_counter() - t0) / reps * 1e3

t_dense, t_comp = bench(jac_dense_fn, x_big), bench(jac_comp_fn, x_big)
print(f"jacfwd(稠密 {n_big}×{n_big}): {t_dense:.2f} ms")
print(f"着色法(压缩 3×{n_big})    : {t_comp:.2f} ms   (本机比值约 {t_dense / t_comp:.1f}×)")

# 正确性:压缩形式中的对角线与稠密 Jacobian 一致
Jd, C = jac_dense_fn(x_big), jac_comp_fn(x_big)
idx = jnp.arange(n_big)
assert jnp.allclose(C[idx % 3, idx], jnp.diag(Jd))                      # 主对角 J[i,i]
assert jnp.allclose(C[(idx[1:]) % 3, idx[:-1]], jnp.diag(Jd, k=1))      # 上对角 J[i,i+1]
assert jnp.allclose(C[(idx[:-1]) % 3, idx[1:]], jnp.diag(Jd, k=-1))     # 下对角 J[i+1,i]
jacfwd(稠密 3000×3000): 1.64 ms
着色法(压缩 3×3000)    : 0.11 ms   (本机比值约 15.0×)

注意:如果把压缩结果再解压成 $n\times n$ 的稠密矩阵(比如上面的 colored_jacobian_tridiag),光是写稠密矩阵就有 $O(n^2)$ 的开销。在本机测下来,这时着色法并不比 jacfwd 快。着色法的好处来自只算、只存非零元。

实战提示:着色法的关键是事先知道稀疏模式。如果不知道,可以先在几个随机点上求一次 jacfwd 拿到非零结构,或者用 jaxpr 分析依赖关系,再用贪心图着色求出颜色数。真正大规模的场景,解压后应该存成稀疏格式(比如 jax.experimental.sparse.BCOO),而不是像上面那样写出稠密矩阵。

4.6 使用 jax.custom_vjp 的自定义微分规则

常见的需要自定义 VJP 的情形有:数值稳定性、性能(已经知道更便宜的梯度公式)、数学简化、隐函数(比如不动点、优化问题的解),以及近似梯度(STE、梯度裁剪等)。

结构:

@jax.custom_vjp
def f(x, y): ...                 # 原函数:不求导时直接调用
def f_fwd(x, y):                 # 前向:与 f 同签名
    return out, residuals        # 返回 (输出, 反向要用的中间量)
def f_bwd(residuals, g):         # 反向:g 是输出的余切,结构与 out 相同
    return (gx, gy)              # 元组,长度 = f 的位置参数个数
f.defvjp(f_fwd, f_bwd)

在反向模式下,jax.grad 不会去微分 f 的 Python 代码,而是先执行 f_fwd 存下 residuals,反向时再调用 f_bwd。

例 1:$f(x,y)=x\sin y$,$\partial f/\partial x=\sin y$,$\partial f/\partial y=x\cos y$。反向规则是 $\bar x = g\sin y$,$\bar y = g\,x\cos y$。用 $L = f^2$ 来测试:在 $x=2,\ y=\pi/2$ 处,$f=2$,$L=4$,$\partial L/\partial x = 2f\sin y = 4$,$\partial L/\partial y = 2f\,x\cos y\approx 0$。

⚠️ 注意:这个例子用不到随机数,所以不用创建 key = jax.random.PRNGKey(0)。顺带一提,PRNGKey 返回的是旧式的 uint32[2] 原始 key;需要随机数时,推荐用 jax.random.key(返回类型化 key)。(key 的入门见 入门 01 JAX简介,更深入的随机数管理见 进阶 10 随机数与分片。)

# ❌ 旧写法(过时/错误):创建了从未使用的 jax.random.PRNGKey(0)(旧式 uint32[2] 原始 key)
key_old = jax.random.PRNGKey(0)
print("PRNGKey:", key_old.dtype, key_old.shape, "(旧写法,且本例根本不需要随机数)")
PRNGKey: uint32 (2,) (旧写法,且本例根本不需要随机数)
# ✅ 推荐写法:需要随机数时用 jax.random.key(类型化 key);本例不需要,直接删掉
key_new = jax.random.key(0)
print("jax.random.key:", key_new.dtype, key_new.shape)
assert jnp.issubdtype(key_new.dtype, jax.dtypes.prng_key)

# 课程代码 B:f(x, y) = x * sin(y) 的自定义 VJP
@jax.custom_vjp
def f(x, y):
    return x * jnp.sin(y)

def f_fwd(x, y):
    primals_out = f(x, y)                         # 在 fwd 里调用 f 本身是允许的(此时不会再走自定义规则)
    residuals = (x, y, jnp.sin(y), jnp.cos(y))   # 这里保存了 4 个量;其实 (x, sin y, cos y) 就够了
    return primals_out, residuals

def f_bwd(residuals, cotangents_in):
    x, y, sin_y, cos_y = residuals
    g = cotangents_in
    grad_x = g * sin_y
    grad_y = g * x * cos_y
    return (grad_x, grad_y)

f.defvjp(f_fwd, f_bwd)

x_val = jnp.float32(2.0)
y_val = jnp.pi / 2.0
value, grads = jax.value_and_grad(lambda x, y: f(x, y)**2, argnums=(0, 1))(x_val, y_val)
value_auto, grads_auto = jax.value_and_grad(lambda x, y: (x * jnp.sin(y))**2, argnums=(0, 1))(x_val, y_val)
print("自定义:", value, grads)
print("自动  :", value_auto, grads_auto)
assert jnp.allclose(value, 4.0) and jnp.allclose(grads[0], 4.0) and jnp.allclose(grads[1], 0.0, atol=1e-5)
assert all(jnp.allclose(a, b, atol=1e-6) for a, b in zip(grads, grads_auto))
jax.random.key: key<fry> ()
自定义: 4.0 (Array(4., dtype=float32), Array(-3.496911e-07, dtype=float32))
自动  : 4.0 (Array(4., dtype=float32), Array(-3.496911e-07, dtype=float32, weak_type=True))

例 2($\log(1+e^x)$ 的稳定梯度):$f(x)=\log(1+e^x)$,$f'(x)=\dfrac{e^x}{1+e^x}=\dfrac{1}{1+e^{-x}}=\sigma(x)$。思路是:前向还用 log1p(exp(x)),反向改用 $\sigma(x)$,避免 $x$ 很大时梯度出问题。

⚠️ 注意:这样写只有梯度是稳定的,前向并不稳定。前向仍然是 log1p(exp(x)),在 float32 下 $x=100$ 时 exp 会溢出,前向输出是 inf。真正稳定的前向要写成 jnp.logaddexp(x, 0.0)(jax.nn.softplus 就是这么实现的)。

⚠️ 注意:别指望用 try/except FloatingPointError 捕获「不稳定梯度在 $x=100$ 处溢出」。JAX 默认不会抛出任何异常,不稳定的梯度会悄悄地返回 nan(反向中出现 $\infty/\infty$),所以 except 分支永远不会执行。只有打开 jax.debug_nans(True) 时,才会抛出 FloatingPointError。另外注意本环境的已知问题:进程里追踪过 vmap 之后,jax_debug_nans 可能漏检,详见 进阶 08 调试工具链 §3.1。

# ❌ 旧写法(过时/错误):前向仍会溢出;try/except FloatingPointError 捕获不到任何东西
@jax.custom_vjp
def stable_log1pexp(x):
    return jnp.log1p(jnp.exp(x))

def stable_log1pexp_fwd(x):
    exp_x = jnp.exp(x)
    output = jnp.log1p(exp_x)
    return output, (x,)

def stable_log1pexp_bwd(residuals, cotangent_in):
    (x,) = residuals
    return (cotangent_in * (1.0 / (1.0 + jnp.exp(-x))),)

stable_log1pexp.defvjp(stable_log1pexp_fwd, stable_log1pexp_bwd)

def unstable_log1pexp(x):
    return jnp.log(1.0 + jnp.exp(x))

grad_stable = jax.grad(stable_log1pexp)
grad_unstable = jax.grad(unstable_log1pexp)
x_small, x_problematic, x_large = jnp.float32(1.0), jnp.float32(35.0), jnp.float32(100.0)
print(f"x=1   稳定梯度 {grad_stable(x_small):.6f}  不稳定梯度 {grad_unstable(x_small):.6f}")
print(f"x=35  稳定梯度 {grad_stable(x_problematic):.6f}  不稳定梯度 {grad_unstable(x_problematic):.6f}")
print(f"x=100 稳定梯度 {grad_stable(x_large)}  但前向 stable_log1pexp(100) = {stable_log1pexp(x_large)}")

caught = False
try:
    g_bad = grad_unstable(x_large)
    print("没有抛异常,不稳定梯度 =", g_bad)
except FloatingPointError:
    caught = True
print("except 分支是否执行:", caught)
x=1   稳定梯度 0.731059  不稳定梯度 0.731059
x=35  稳定梯度 1.000000  不稳定梯度 1.000000
x=100 稳定梯度 1.0  但前向 stable_log1pexp(100) = inf
没有抛异常,不稳定梯度 = nan
except 分支是否执行: False
# ✅ 推荐写法:前向用 logaddexp(稳定),反向用 sigmoid
@jax.custom_vjp
def log1pexp(x):
    return jnp.logaddexp(x, 0.0)

def log1pexp_fwd(x):
    return log1pexp(x), x

def log1pexp_bwd(x, g):
    return (g * jax.nn.sigmoid(x),)

log1pexp.defvjp(log1pexp_fwd, log1pexp_bwd)

print("log1pexp(100) =", log1pexp(100.0), " grad =", jax.grad(log1pexp)(100.0))
print("内置 jax.nn.softplus 的梯度 =", jax.grad(jax.nn.softplus)(100.0))

assert jnp.isinf(stable_log1pexp(x_large))           # ❌ 版本的前向溢出
assert log1pexp(100.0) == 100.0                      # 修正版前向正确
assert not caught and jnp.isnan(grad_unstable(x_large))   # 没有异常,静默返回 NaN
assert jax.grad(log1pexp)(100.0) == 1.0

# 想让 NaN 立刻报错,用 debug_nans(调试用,有性能代价)
with jax.debug_nans(True):
    try:
        grad_unstable(x_large)
        raised = False
    except FloatingPointError:
        raised = True
print("debug_nans 下是否抛 FloatingPointError:", raised)
assert raised
log1pexp(100) = 100.0  grad = 1.0
内置 jax.nn.softplus 的梯度 = 1.0
Invalid nan value encountered in the backward pass of a jax.jit function. Calling the de-optimized backward pass.
debug_nans 下是否抛 FloatingPointError: True

几点注意事项:

  • 正确性:一定要和数值微分或 JAX 自动求导对拍(见下面的 check_grads);
  • residuals 只存必要的数据:它们会一直占着内存,直到反向传播结束;
  • 带自定义 VJP 的函数可以和 jit、vmap 以及反向模式的嵌套微分组合(但不能做前向模式,见 4.7 节);
  • 如果 f 有不需要求导的参数,f_bwd 在对应位置返回 None。

⚠️ 注意:关于最后一点:在 bwd 里对某个输入返回 None 确实可行,JAX 会把它当成零梯度。但如果参数不是数组(Python 函数、字符串、配置对象),就必须用 nondiff_argnums 声明,见下面的补充。

# bwd 对某个输入返回 None,JAX 视为零梯度
@jax.custom_vjp
def scaled(x, s):
    return x * s

scaled.defvjp(lambda x, s: (x * s, s), lambda s, g: (g * s, None))
gx, gs = jax.grad(scaled, argnums=(0, 1))(2.0, 3.0)
print("对 x 的梯度:", gx, " 对 s 的梯度(bwd 返回 None):", gs)
assert gx == 3.0 and gs == 0.0
对 x 的梯度: 3.0  对 s 的梯度(bwd 返回 None): 0.0

🔍 补充:签名细节、residuals 与 nondiff_argnums

🟢 必读

情形fwd 签名bwd 签名bwd 返回
普通fwd(*args)bwd(res, g)长度为 len(args) 的元组
nondiff_argnums=(0,)fwd(*args)(仍是完整参数)bwd(nondiff0, res, g)(不可微参数排在最前面)只包含可微参数的元组

几个要点:

  1. fwd 的输出必须和 f 的输出结构相同;residuals 可以是任意 pytree(元组、字典、None)。
  2. nondiff_argnums 是给非数组参数用的,比如 Python 函数、整数超参数或 dtype。这些值不能是 tracer:如果在 jit 里把一个被追踪的数组传到 nondiff_argnums 的位置,会抛 UnexpectedTracerError。数组形式的「不求导」参数,应该当普通参数传进去,在 bwd 里返回 None 或零。
  3. 默认(symbolic_zeros=False)情况下,bwd 收到的 g 总是实际的数组:某个输出没参与损失时,g 对应位置是零数组,不是 None(4.10 节会验证)。

下面用 nondiff_argnums 实现梯度裁剪和梯度反转层,并验证第 2 条里的陷阱。

# 梯度裁剪:前向恒等,反向把余切裁剪到 [lo, hi]
@partial(jax.custom_vjp, nondiff_argnums=(0, 1))
def clip_gradient(lo, hi, x):
    return x

def clip_gradient_fwd(lo, hi, x):
    return x, None                        # 无需 residuals

def clip_gradient_bwd(lo, hi, res, g):    # 不可微参数排在最前面
    return (jnp.clip(g, lo, hi),)         # 只为可微参数 x 返回梯度

clip_gradient.defvjp(clip_gradient_fwd, clip_gradient_bwd)

loss = lambda x: jnp.sum(10.0 * clip_gradient(-1.0, 1.0, x) ** 2)
xs = jnp.array([-2.0, 0.01, 0.03, 3.0])
print("原始梯度  :", jax.grad(lambda x: jnp.sum(10.0 * x**2))(xs))
print("裁剪后梯度:", jax.grad(loss)(xs))
assert jnp.allclose(jax.grad(loss)(xs), jnp.clip(20.0 * xs, -1.0, 1.0))
assert jnp.allclose(clip_gradient(-1.0, 1.0, xs), xs)   # 前向恒等
原始梯度  : [-40.           0.19999999   0.59999996  60.        ]
裁剪后梯度: [-1.          0.19999999  0.59999996  1.        ]
# 梯度反转层(Gradient Reversal Layer, DANN):前向恒等,反向乘以 -λ
@jax.custom_vjp
def grad_reverse(x, lam):
    return x

def grad_reverse_fwd(x, lam):
    return x, lam

def grad_reverse_bwd(lam, g):
    return (-lam * g, None)        # λ 是数组参数(可被 jit 追踪),不求导,返回 None

grad_reverse.defvjp(grad_reverse_fwd, grad_reverse_bwd)

# 特征提取器 h = w*x 接一个“域分类器” loss = h^2;GRL 让 w 的梯度反号
def domain_loss(w, x, lam):
    h = w * x
    return grad_reverse(h, lam) ** 2

w0, x0, lam0 = 1.5, 2.0, 0.5
g_plain = jax.grad(lambda w: (w * x0) ** 2)(w0)
g_grl = jax.jit(jax.grad(domain_loss))(w0, x0, lam0)
print("普通梯度:", g_plain, "  GRL 梯度:", g_grl)
assert jnp.allclose(g_grl, -lam0 * g_plain)

# 陷阱:把 jit 追踪的数组放进 nondiff_argnums -> UnexpectedTracerError
@partial(jax.custom_vjp, nondiff_argnums=(1,))
def grl_bad(x, lam):
    return x
grl_bad.defvjp(lambda x, lam: (x, None), lambda lam, r, g: (-lam * g,))
print("lam 是 Python 常量时可用:", jax.grad(lambda x: grl_bad(x, 0.5))(1.0))
try:
    jax.jit(jax.grad(lambda x, lam: grl_bad(x, lam)))(1.0, 0.5)
    raised = False
except jax.errors.UnexpectedTracerError as e:
    raised = True
    print("UnexpectedTracerError:", str(e)[:100], "...")
assert raised
普通梯度: 12.0   GRL 梯度: -6.0
lam 是 Python 常量时可用: -0.5
UnexpectedTracerError: Found a JAX Tracer object passed as an argument to a custom_vjp function in a position indicated by  ...

Straight-through estimator(STE)是另一种典型的「人为指定梯度」。它放在 4.9 节讲,那里会同时给出 custom_vjp 和 stop_gradient 两种写法。

🔍 补充:隐函数定理 —— 对不动点迭代求导

⚪ 选读:要对不动点迭代、优化层或隐式层(DEQ)求导时

很多量是作为迭代求解的结果来定义的,比如不动点 $x^\star = T(a, x^\star)$、优化问题的最优解、方程 $F(a,x)=0$ 的根。直接对迭代过程求导有三个问题:

  1. 要保存每一步的中间值,内存是 $O(\text{迭代次数})$;
  2. 梯度准不准,取决于迭代有没有充分收敛;
  3. 用 while_loop(迭代次数取决于收敛情况)实现时,根本没法做反向模式求导(4.8 节)。

隐函数定理:对 $x^\star = T(a,x^\star)$ 两边求导,得到 $\dfrac{\partial x^\star}{\partial a} = \Big(I-\dfrac{\partial T}{\partial x}\Big)^{-1}\dfrac{\partial T}{\partial a}$。反向模式下,给定余切 $g$:

$$\bar a = u^\top\frac{\partial T}{\partial a},\qquad u = g + \Big(\frac{\partial T}{\partial x}\Big)^\top u$$

注意 $u$ 本身也是一个不动点问题,同样可以用 while_loop 迭代求解,只要 $T$ 关于 $x$ 是压缩映射就能收敛。整个反向过程只需要最终解 $x^\star$ 作为 residual,内存是 $O(1)$。

@partial(jax.custom_vjp, nondiff_argnums=(0,))
def fixed_point(T, a, x0):
    """用 while_loop 求 x = T(a, x) 的不动点(迭代次数由收敛条件决定)"""
    def cond(c):
        x, x_prev, i = c
        return (jnp.max(jnp.abs(x - x_prev)) > 1e-6) & (i < 500)
    def body(c):
        x, _, i = c
        return T(a, x), x, i + 1
    return lax.while_loop(cond, body, (T(a, x0), x0, 0))[0]

def fixed_point_fwd(T, a, x0):
    x_star = fixed_point(T, a, x0)
    return x_star, (a, x_star)                 # 只保存解本身

def fixed_point_bwd(T, res, g):
    a, x_star = res
    _, vjp_a = jax.vjp(lambda a: T(a, x_star), a)
    _, vjp_x = jax.vjp(lambda x: T(a, x), x_star)
    # 伴随不动点 u = g + (∂T/∂x)^T u,也用 while_loop 迭代
    def cond(c):
        u, u_prev, i = c
        return (jnp.max(jnp.abs(u - u_prev)) > 1e-7) & (i < 500)
    def body(c):
        u, _, i = c
        return g + vjp_x(u)[0], u, i + 1
    u = lax.while_loop(cond, body, (g + vjp_x(g)[0], g, 0))[0]
    (a_bar,) = vjp_a(u)
    return a_bar, jnp.zeros_like(x_star)       # 解与初值无关,x0 的梯度为 0

fixed_point.defvjp(fixed_point_fwd, fixed_point_bwd)

# 例 1:Newton 迭代求 sqrt(a):x = (x + a/x)/2,解析导数 d sqrt(a)/da = 1/(2 sqrt(a))
newton_T = lambda a, x: 0.5 * (x + a / x)
a = 2.0
x_star = fixed_point(newton_T, a, 1.0)
g_implicit = jax.grad(lambda a: fixed_point(newton_T, a, 1.0))(a)

def unrolled_newton(a, n_iter=30):               # 展开版本:固定步数,普通反向传播
    return lax.fori_loop(0, n_iter, lambda i, x: newton_T(a, x), 1.0)
g_unrolled = jax.grad(unrolled_newton)(a)

print(f"x* = {x_star:.7f}  (sqrt(2) = {np.sqrt(2):.7f})")
print(f"隐函数梯度 {g_implicit:.7f} | 展开梯度 {g_unrolled:.7f} | 解析 {0.5 / np.sqrt(2):.7f}")
assert jnp.allclose(x_star, np.sqrt(2.0))
assert jnp.allclose(g_implicit, 0.5 / np.sqrt(2.0))
assert jnp.allclose(g_implicit, g_unrolled)
x* = 1.4142135  (sqrt(2) = 1.4142136)
隐函数梯度 0.3535534 | 展开梯度 0.3535534 | 解析 0.3535534
# 例 2:x = cos(a x),解析:dx/da = -x sin(ax) / (1 + a sin(ax))
cos_T = lambda a, x: jnp.cos(a * x)
a = 1.0
x_star = fixed_point(cos_T, a, 0.5)
g_implicit = jax.jit(jax.grad(lambda a: fixed_point(cos_T, a, 0.5)))(a)   # 可以 jit
g_analytic = -x_star * jnp.sin(a * x_star) / (1 + a * jnp.sin(a * x_star))
print(f"x* = {x_star:.6f}, 隐函数梯度 = {g_implicit:.6f}, 解析 = {g_analytic:.6f}")
assert jnp.allclose(g_implicit, g_analytic, atol=1e-5)

# 对照:直接对 while_loop 版本做反向求导会失败(4.8 节详解)
def fixed_point_plain(a):
    return lax.while_loop(lambda c: jnp.abs(c[0] - c[1]) > 1e-6,
                          lambda c: (cos_T(a, c[0]), c[0]), (cos_T(a, 0.5), 0.5))[0]
try:
    jax.grad(fixed_point_plain)(a)
    raised = False
except ValueError as e:
    raised = True
    print("直接 grad(while_loop):", type(e).__name__, "-", str(e)[:75], "...")
assert raised
x* = 0.739085, 隐函数梯度 = -0.297474, 解析 = -0.297474
直接 grad(while_loop): ValueError - Reverse-mode differentiation does not work for lax.while_loop or lax.fori_l ...

🔍 补充:用 jax.test_util.check_grads 验证自定义规则

🟢 必读

check_grads(f, args, order, modes=('fwd','rev'), atol, rtol, eps) 会把 JVP/VJP 和有限差分对拍。fwd 模式直接比较 JVP 和数值 JVP;rev 模式取随机方向 $t$、$c$,检查 $\langle t,\ \mathrm{VJP}(c)\rangle \approx \langle \text{数值 JVP}(t),\ c\rangle$,也就是检查 VJP 是不是数值导数的转置。两种模式都只在随机方向上检查,并不会直接拿 JAX 自己的 JVP 和 VJP 互相比较。order=2 时还会检查二阶导数。它在 JAX 0.12 里仍然在 jax.test_util 下。几点经验:

  • custom_vjp 函数只能用 modes=['rev'],因为它没有前向模式;
  • 默认在 float32 下容差比较紧,迭代求解类的函数要适当放宽 atol/rtol;
  • 它能抓住「写错了的 bwd」。而梯度裁剪、GRL、STE 这类故意和真实导数不同的规则,当然通不过检查。这是预期的,正好可以用来确认规则真的生效了。
# 1) 正确的自定义规则:通过
check_grads(f, (jnp.float32(2.0), jnp.float32(0.7)), order=1, modes=["rev"])
check_grads(log1pexp, (jnp.float32(3.0),), order=1, modes=["rev"])
check_grads(lambda a: fixed_point(cos_T, a, 0.5), (jnp.float32(1.0),), order=1, modes=["rev"], atol=1e-2, rtol=1e-2)
print("f / log1pexp / fixed_point 通过 check_grads")

# 2) 故意写错的 bwd:被抓住
@jax.custom_vjp
def buggy_sin(x):
    return jnp.sin(x)
buggy_sin.defvjp(lambda x: (jnp.sin(x), x), lambda x, g: (g * jnp.sin(x),))   # 应为 cos
try:
    check_grads(buggy_sin, (jnp.float32(1.0),), order=1, modes=["rev"])
    caught_bug = False
except AssertionError:
    caught_bug = True
print("check_grads 发现错误的 bwd:", caught_bug)
assert caught_bug

# 3) 故意修改梯度的规则(GRL)同样“通不过”,说明规则在起作用
try:
    # 注意:参数要传 jnp 标量。本例若传 Python float 1.0,check_grads 内部调用 .reshape 时会报 AttributeError(实测)
    check_grads(lambda x: grad_reverse(x, 0.5), (jnp.float32(1.0),), order=1, modes=["rev"])
    grl_passes = True
except AssertionError:
    grl_passes = False
assert not grl_passes
f / log1pexp / fixed_point 通过 check_grads
check_grads 发现错误的 bwd: True

4.7 jax.custom_jvp 自定义求导规则

jax.grad/jax.vjp 是反向模式。有时我们想直接控制前向模式的规则,也就是给定切向量,它怎么传到输出。自定义 JVP 的四种典型理由:

  1. 数值稳定性:sqrt、log 在 0 附近;
  2. 性能;
  3. 不可微点上的约定:比如 jnp.clip 的边界;
  4. 外部代码:比如 jax.pure_callback 本身没有求导规则。

写法:用 @jax.custom_jvp 装饰原函数,再用 @原函数.defjvp 注册规则。规则接收 primals(元组,如 (x,))和 tangents(元组,如 (t,)),返回 (primal_out, tangent_out),数学上算的是 $J(x)\,t$。

例子是一个「稳定的 sqrt」,目标是让 $x=0$ 处的导数是有限值(约定取 0;真实导数 $\frac{1}{2\sqrt{x}}\to\infty$)。

⚠️ 注意:safe_x = where(x==0, 1, x) 这个技巧只能用在导数公式里,不能用在前向。如果前向也用 safe_x,stable_sqrt(0.0) 会返回 1.0 而不是 0,前向结果就被改错了(下面的 ❌ 单元就是这种情况)。前向直接用 jnp.sqrt(x) 就行,$\sqrt 0 = 0$ 没有任何问题。

# ❌ 旧写法(过时/错误):前向也用 safe_x,导致 stable_sqrt(0) = 1
@jax.custom_jvp
def stable_sqrt_course(x):
    is_zero = (x == 0.0)
    safe_x = jnp.where(is_zero, 1.0, x)
    return jnp.sqrt(safe_x)

@stable_sqrt_course.defjvp
def stable_sqrt_course_jvp(primals, tangents):
    x, = primals
    t, = tangents
    primal_out = stable_sqrt_course(x)
    is_zero = (x == 0.0)
    safe_x = jnp.where(is_zero, 1.0, x)
    deriv = 0.5 * jax.lax.rsqrt(safe_x)
    tangent_out = jnp.where(is_zero, 0.0, deriv * t)
    return primal_out, tangent_out

print("原始版 jvp at x=0:", jax.jvp(stable_sqrt_course, (0.0,), (1.0,)))
原始版 jvp at x=0: (Array(1., dtype=float32, weak_type=True), Array(0., dtype=float32, weak_type=True))
# ✅ 推荐写法:前向用真 sqrt,safe_x 只用于导数公式
@jax.custom_jvp
def stable_sqrt(x):
    return jnp.sqrt(x)

@stable_sqrt.defjvp
def stable_sqrt_jvp(primals, tangents):
    x, = primals
    t, = tangents
    is_zero = (x == 0.0)
    safe_x = jnp.where(is_zero, 1.0, x)
    tangent_out = jnp.where(is_zero, 0.0, 0.5 * lax.rsqrt(safe_x) * t)   # 约定 x=0 处导数为 0
    return jnp.sqrt(x), tangent_out

p0, t0 = jax.jvp(stable_sqrt, (0.0,), (1.0,))
p4, t4 = jax.jvp(stable_sqrt, (4.0,), (1.0,))
print("x=0:", p0, t0, "   x=4:", p4, t4)       # x=4: 0.5 * 4**-0.5 = 0.25
assert stable_sqrt_course(0.0) == 1.0           # ❌ 版本的前向错误
assert p0 == 0.0 and t0 == 0.0
assert p4 == 2.0 and t4 == 0.25
# 对比:原生 jnp.sqrt 在 0 处梯度为 inf
assert jnp.isinf(jax.grad(jnp.sqrt)(0.0)) and jax.grad(stable_sqrt)(0.0) == 0.0
x=0: 0.0 0.0    x=4: 2.0 0.25

与 custom_vjp 的关系:

⚠️ 注意:容易以为「只用 jvp/jacfwd 时定义 custom_jvp 可能就够了,需要 jax.grad 时通常要定义 custom_vjp;JAX 有时能从 JVP 自动转置出 VJP,但不一定可靠,两者都定义最稳妥」。实际情况是:

  1. 只要 JVP 规则的输出关于切向量是线性的(正确的 JVP 规则一定满足这一点),而且切向量这一侧只用 JAX 自带的可转置原语来算(不把切向量交给 pure_callback 之类的黑盒),custom_jvp 函数就能被 grad/vjp/jacrev 使用。JAX 通过 4.1 节的「线性化 + 转置」自动得到反向规则,不是「有时能」(反例见 4.7 节补充「JVP 规则必须关于切向量线性」)。
  2. 两者不能同时定义在同一个函数上:custom_jvp 对象根本没有 defvjp 方法。一个函数只能二选一。
  3. 反过来,custom_vjp 函数不支持前向模式,对它调用 jvp/jacfwd/hessian 会报 TypeError。

所以选择规则是:能用 custom_jvp 就用 custom_jvp,前向、反向、高阶导数全都能用。只有需要控制反向计算本身时(隐函数、梯度裁剪、自定义 residuals 来省内存、反向要调用外部代码),才用 custom_vjp。

带自定义 JVP 的函数可以和 jit、vmap 等正确组合。它的典型用例也是这几类:数值稳定、性能、处理不连续,以及和外部代码对接。

# 1) custom_jvp 自动获得反向模式(grad 通过转置得到)
assert jax.grad(stable_sqrt)(4.0) == 0.25
assert jnp.allclose(jax.jacrev(stable_sqrt)(4.0), jax.jacfwd(stable_sqrt)(4.0))

# 2) custom_jvp 对象没有 defvjp:两者不能同时定义
assert not hasattr(stable_sqrt, "defvjp")
print("custom_jvp 对象的方法:", [m for m in dir(stable_sqrt) if m.startswith("def")])

# 3) custom_vjp 不支持前向模式:jvp / jacfwd / hessian 都报 TypeError
n_err = 0
for name, call in [("jvp", lambda: jax.jvp(log1pexp, (1.0,), (1.0,))),
                   ("jacfwd", lambda: jax.jacfwd(log1pexp)(1.0)),
                   ("hessian", lambda: jax.hessian(log1pexp)(1.0))]:
    try:
        call()
    except TypeError as e:
        n_err += 1
        print(f"{name:8s} -> TypeError: {str(e)[:70]}")
assert n_err == 3
custom_jvp 对象的方法: ['defjvp', 'defjvps']
jvp      -> TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
jacfwd   -> TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.
hessian  -> TypeError: can't apply forward-mode autodiff (jvp) to a custom_vjp function.

🔍 补充:数值稳定的 softplus 与 defjvps

⚪ 选读:给逐元素函数写 custom_jvp 时

逐元素函数最常见的写法是 defjvps:给每个位置参数提供一个函数 jvp_i(t_i, ans, *primals),返回这个参数的切向量对输出切向量的贡献。JAX 会把各参数的贡献加起来。

  • ans 是前向输出,可以在规则里复用,避免重复计算(比如 $\sigma'(x)=\sigma(1-\sigma)$ 直接用 ans);
  • 某个参数不需要求导时,对应位置传 None(这个参数的梯度为 0);
  • 前向的 primal_out 由原函数自动算,规则里不用返回它。

$$\mathrm{softplus}(x)=\log(1+e^x)=\max(x,0)+\log\big(1+e^{-|x|}\big),\qquad \mathrm{softplus}'(x)=\sigma(x)$$

@jax.custom_jvp
def softplus(x):
    # 数值稳定的前向:max(x,0) + log1p(exp(-|x|)),不会溢出
    return jnp.maximum(x, 0.0) + jnp.log1p(jnp.exp(-jnp.abs(x)))

softplus.defjvps(lambda t, ans, x: t * jax.nn.sigmoid(x))

xs = jnp.array([-100.0, -10.0, 0.0, 10.0, 100.0])
print("softplus:", softplus(xs))
print("grad    :", jax.vmap(jax.grad(softplus))(xs))
assert jnp.allclose(softplus(xs), jax.nn.softplus(xs))
assert jnp.allclose(jax.vmap(jax.grad(softplus))(xs), jax.nn.sigmoid(xs))
assert jnp.all(jnp.isfinite(jax.vmap(jax.grad(jax.grad(softplus)))(xs)))   # 二阶导也能用

# 前向和反向都能用,并通过 check_grads(同时检查 fwd 和 rev、二阶)
check_grads(softplus, (jnp.float32(1.3),), order=2, modes=["fwd", "rev"])

# 用 ans 复用前向结果:sigmoid' = ans * (1 - ans);多参数时可对某参数传 None
@jax.custom_jvp
def my_sigmoid(x):
    return 1.0 / (1.0 + jnp.exp(-x))
my_sigmoid.defjvps(lambda t, ans, x: t * ans * (1.0 - ans))

@jax.custom_jvp
def mul_no_grad_y(x, y):
    return x * y
mul_no_grad_y.defjvps(lambda tx, ans, x, y: tx * y, None)   # y 不求导

assert jnp.allclose(jax.grad(my_sigmoid)(0.3), jax.grad(jax.nn.sigmoid)(0.3))
assert jax.grad(mul_no_grad_y, argnums=(0, 1))(2.0, 3.0) == (3.0, 0.0)
print("defjvps 检查通过")
softplus: [0.0000000e+00 4.5398901e-05 6.9314718e-01 1.0000046e+01 1.0000000e+02]
grad    : [0.0000000e+00 4.5397872e-05 5.0000000e-01 9.9995458e-01 1.0000000e+00]
defjvps 检查通过

🔍 补充:JVP 规则必须关于切向量线性

🟢 必读

自动转置要求 JVP 规则的 tangent_out 是切向量的线性函数。规则里面可以对 primal 做任意计算,包括 while_loop 和外部回调,只要这些计算不依赖切向量就行。如果写出了对切向量非线性的规则(比如 t**2),jvp 照样能跑(只是结果在数学上不是导数),但 grad 会在转置时报 NotImplementedError。同样,切向量这一侧也只能用 JAX 原语来表达:如果把切向量交给 jax.pure_callback,jvp 照样能跑,grad 却会在转置时报 ValueError: Pure callbacks do not support transpose(下面会验证)。

@jax.custom_jvp
def nonlinear_rule(x):
    return jnp.sin(x)

@nonlinear_rule.defjvp
def _nonlinear_rule_jvp(primals, tangents):
    x, = primals
    t, = tangents
    return jnp.sin(x), jnp.cos(x) * t**2        # ❗ 对切向量非线性

print("jvp 能跑:", jax.jvp(nonlinear_rule, (1.0,), (2.0,)))
try:
    jax.grad(nonlinear_rule)(1.0)
    raised = False
except NotImplementedError as e:
    raised = True
    print("grad -> NotImplementedError:", str(e)[:70])
assert raised

# 规则里对 primal 用 while_loop 完全没问题:切向量部分仍是线性的
@jax.custom_jvp
def sin_with_loop(x):
    return jnp.sin(x)

@sin_with_loop.defjvp
def _sin_with_loop_jvp(primals, tangents):
    x, = primals
    t, = tangents
    d = lax.while_loop(lambda c: c[0] < 3, lambda c: (c[0] + 1, c[1]), (0, jnp.cos(x)))[1]
    return jnp.sin(x), d * t

assert jnp.allclose(jax.grad(sin_with_loop)(1.0), jnp.cos(1.0))

# 切向量交给 pure_callback:数学上仍是线性的,jvp 能跑,但 grad 在转置时失败
@jax.custom_jvp
def tangent_via_callback(x):
    return jnp.sin(x)

@tangent_via_callback.defjvp
def _tangent_via_callback_jvp(primals, tangents):
    x, = primals
    t, = tangents
    t_cb = jax.pure_callback(lambda a: np.asarray(a), jax.ShapeDtypeStruct(t.shape, t.dtype), t)
    return jnp.sin(x), jnp.cos(x) * t_cb

x1 = jnp.float32(1.0)
assert jnp.allclose(jax.jvp(tangent_via_callback, (x1,), (x1,))[1], jnp.cos(1.0))
try:
    jax.grad(tangent_via_callback)(x1)
    raised = False
except ValueError as e:
    raised = True
    print("切向量经 pure_callback → grad:", str(e)[:60])
assert raised
jvp 能跑: (Array(0.84147096, dtype=float32, weak_type=True), Array(2.161209, dtype=float32, weak_type=True))
grad -> NotImplementedError: Transpose rule (for reverse-mode differentiation) for 'integer_pow' no
切向量经 pure_callback → grad: Pure callbacks do not support transpose. Please use `jax.cus

4.8 通过控制流原语求导

JAX 的自动微分和 lax.cond、lax.scan、lax.fori_loop、lax.while_loop 结合得很紧。这些原语在追踪时会把整个控制结构记进计算图,所以在 jit 下也能求导。(Python 原生的 if/for 和 grad 的关系,见 入门 03 自动微分grad 3.7 节。)

lax.cond:JAX 会追踪 true_fun 和 false_fun 两个分支,导函数里同时包含两个分支的梯度逻辑,运行时按谓词选其中一个。

  • 两个分支的输出必须结构相同(形状、dtype 一致);
  • 对谓词求导通常没有意义:谓词是布尔值,函数关于决定谓词的那个变量是分段常数,梯度为 0。

例子:$y>0$ 时 $f=\sin(2x)$,导数 $2\cos(2x)$,在 $x=\pi/4$ 处为 0;$y\le 0$ 时 $f=\cos(x/2)$,导数 $-\tfrac12\sin(x/2)$,在 $x=\pi/4$ 处约为 $-0.191$。

# 课程代码:对 lax.cond 求导
def conditional_computation(x, y):
    return lax.cond(y > 0,
                    lambda op: jnp.sin(op * 2.0),     # 真分支
                    lambda op: jnp.cos(op / 2.0),     # 假分支
                    x)

grad_x = jax.grad(conditional_computation, argnums=0)
grad_y = jax.grad(conditional_computation, argnums=1)   # 关于决定谓词的 y:梯度为 0

x_val, y_val_pos, y_val_neg = jnp.pi / 4.0, 1.0, -1.0
print(f"f(x, y>0) = {conditional_computation(x_val, y_val_pos):.4f}, df/dx = {grad_x(x_val, y_val_pos):.4f}")
print(f"f(x, y<0) = {conditional_computation(x_val, y_val_neg):.4f}, df/dx = {grad_x(x_val, y_val_neg):.4f}")
print(f"df/dy (y>0) = {grad_y(x_val, y_val_pos)}, df/dy (y<0) = {grad_y(x_val, y_val_neg)}")

assert jnp.allclose(grad_x(x_val, y_val_pos), 0.0, atol=1e-6)
assert jnp.allclose(grad_x(x_val, y_val_neg), -0.5 * jnp.sin(jnp.pi / 8))
assert grad_y(x_val, y_val_pos) == 0.0 and grad_y(x_val, y_val_neg) == 0.0
f(x, y>0) = 1.0000, df/dx = -0.0000
f(x, y<0) = 0.9239, df/dx = -0.1913
df/dy (y>0) = 0.0, df/dy (y<0) = 0.0

lax.while_loop:容易把它类比成 RNN 的 BPTT,以为 while_loop 的反向传播会「保存每次迭代的中间值」,于是直接对 while_loop 调用 jax.grad。比如对 $\sum_{i=0}^{4} \text{scale}\cdot i$ 求导,期望梯度为 $0+1+2+3+4=10$,内存不够时再套上 jax.checkpoint。

⚠️ 注意:lax.while_loop 不支持反向模式求导。jax.grad 会直接报 ValueError: Reverse-mode differentiation does not work for lax.while_loop or lax.fori_loop with dynamic start/stop values。原因是迭代次数要到运行时才知道,JAX 没法事先分配保存中间值的缓冲区。while_loop 只支持前向模式(jvp/jacfwd)。给循环体套上 jax.checkpoint 也没用,还是报同样的错。这个例子的正确写法是:

  • 迭代次数在追踪时已知:用 lax.fori_loop,并把上下界写成静态的 Python 整数(内部会降为 scan),或者直接用 lax.scan;
  • 迭代次数取决于收敛情况:用 custom_vjp 配合隐函数定理(4.6 节),或者只用前向模式。
# ❌ 旧写法(过时/错误):对 lax.while_loop 做反向模式求导
def loop_sum_param(scale, n_iters):
    init_state = (0, 0.0)
    def cond_fun(state):
        i, _ = state
        return i < n_iters
    def body_fun(state):
        i, current_sum = state
        return i + 1, current_sum + scale * i
    _, final_sum = lax.while_loop(cond_fun, body_fun, init_state)
    return final_sum

grad_loop_sum = jax.grad(loop_sum_param, argnums=0)
try:
    print(grad_loop_sum(2.0, 5))
except ValueError as e:
    print("ValueError:", e)

# 套 jax.checkpoint 也不行
try:
    jax.grad(lambda s: lax.while_loop(lambda c: c[0] < 5,
                                      jax.checkpoint(lambda c: (c[0] + 1, c[1] + s * c[0])),
                                      (0, 0.0))[1])(2.0)
except ValueError as e:
    print("加 checkpoint 后仍是 ValueError")
ValueError: 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.
加 checkpoint 后仍是 ValueError
# ✅ 推荐写法
scale_val, iters = 2.0, 5
print("前向值:", loop_sum_param(scale_val, iters))              # 2*(0+1+2+3+4) = 20

# (a) while_loop 只支持前向模式:jvp / jacfwd 可用
_, tangent = jax.jvp(lambda s: loop_sum_param(s, iters), (scale_val,), (1.0,))
g_fwd = jax.jacfwd(loop_sum_param)(scale_val, iters)

# (b) 静态上下界的 fori_loop(内部降为 scan)支持反向模式
def loop_sum_fori(scale, n_iters):
    return lax.fori_loop(0, n_iters, lambda i, acc: acc + scale * i, 0.0)
g_rev = jax.grad(loop_sum_fori)(scale_val, iters)               # n_iters 是 Python int(静态)

# (c) 等价的 scan 写法
def loop_sum_scan(scale, n_iters):
    acc, _ = lax.scan(lambda acc, i: (acc + scale * i, None), 0.0, jnp.arange(n_iters))
    return acc
g_scan = jax.grad(loop_sum_scan, argnums=0)(scale_val, iters)

print("jvp:", tangent, " jacfwd:", g_fwd, " grad(fori 静态):", g_rev, " grad(scan):", g_scan)
assert loop_sum_param(scale_val, iters) == 20.0
assert tangent == 10.0 and g_fwd == 10.0 and g_rev == 10.0 and g_scan == 10.0

# 陷阱:在 jit 中 n_iters 成了 tracer -> fori_loop 退化为 while_loop -> 反向模式又报错
try:
    jax.jit(jax.grad(loop_sum_fori))(scale_val, iters)
    raised = False
except ValueError:
    raised = True
print("jit 下 n_iters 为动态值时 grad(fori_loop) 报错:", raised)
assert raised
# 修正:把 n_iters 声明为静态参数
g_jit = jax.jit(jax.grad(loop_sum_fori), static_argnums=1)(scale_val, iters)
assert g_jit == 10.0
前向值: 20.0
jvp: 10.0  jacfwd: 10.0  grad(fori 静态): 10.0  grad(scan): 10.0
jit 下 n_iters 为动态值时 grad(fori_loop) 报错: True

lax.scan:scan 的求导定义明确,而且高效,JAX 会自动处理 carry 和每步输出 $y_t$ 的梯度传播。固定长度的序列用 scan 通常比 while_loop 更合适(何况后者不能反向求导);长序列可以对步函数用 jax.checkpoint 省内存(下面的补充只给要点,完整讲解见进阶第 6 章 §6.6)。

例子是一个极简 RNN:$h_t=\tanh(0.5\,h_{t-1}+1.5\,x_t)$,$y_t = 2h_t$,损失为 $\sum_t y_t$,分别对初始状态 $h_0$ 和输入序列 $x_{1:T}$ 求导。

⚠️ 注意:对 $h_0$ 求导的函数,这里命名为 grad_rnn_h0,而不是 grad_rnn_params。它求的是对初始状态的梯度,不是参数梯度:步函数里的权重是固定常数,根本没有可训练的参数。

# 课程代码:对 lax.scan 求导
def simple_rnn_step(carry, x_t):
    prev_h = carry
    weight_hh, weight_xh = 0.5, 1.5           # 固定参数(非可训练)
    new_h = jnp.tanh(prev_h * weight_hh + x_t * weight_xh)
    y_t = new_h * 2.0
    return new_h, y_t

def run_rnn(initial_state, inputs):
    _, outputs_y = lax.scan(simple_rnn_step, initial_state, inputs)
    return jnp.sum(outputs_y)

grad_rnn_h0 = jax.grad(run_rnn, argnums=0)       # 对初始状态
grad_rnn_inputs = jax.grad(run_rnn, argnums=1)   # 对输入序列

h0 = jnp.zeros(())
xts = jnp.array([0.1, 0.2, -0.1, 0.3])
total_output, dh0, dxts = run_rnn(h0, xts), grad_rnn_h0(h0, xts), grad_rnn_inputs(h0, xts)
print("total_output:", total_output, " dL/dh0:", dh0, " dL/dx:", dxts)

# 用 NumPy float64 手写 BPTT 对拍
def bptt_np(h0, xs):
    hs = [float(h0)]
    for x in xs:
        hs.append(np.tanh(0.5 * hs[-1] + 1.5 * x))
    dh, dxs = 0.0, np.zeros(len(xs))
    for t in reversed(range(len(xs))):
        dh = dh + 2.0                      # 来自 y_t = 2 h_t
        dpre = dh * (1 - hs[t + 1] ** 2)
        dxs[t] = dpre * 1.5
        dh = dpre * 0.5                    # 传给 h_{t-1}
    return 2 * sum(hs[1:]), dh, dxs

ref_out, ref_dh0, ref_dx = bptt_np(h0, np.asarray(xts, dtype=np.float64))
assert np.allclose(total_output, ref_out, rtol=1e-5)
assert np.allclose(dh0, ref_dh0, rtol=1e-5)
assert np.allclose(dxts, ref_dx, rtol=1e-5)
total_output: 1.9387988  dL/dh0: 1.70356  dL/dx: [5.11068  4.45307  4.214307 2.435673]

🔍 补充:各控制流原语的可微性一览(实测)

🟢 必读

原语前向模式 (jvp)反向模式 (grad)说明
lax.cond / lax.switch✅✅谓词 / 索引本身是布尔或整数,不能对它求 grad(TypeError);对决定谓词的连续变量求导,梯度为 0
lax.scan✅✅默认保存每步的 residuals,内存 $O(T)$
lax.fori_loop(上下界为静态 int)✅✅内部降为 scan
lax.fori_loop(上下界为 tracer)✅❌ ValueError内部降为 while_loop
lax.while_loop✅❌ ValueError迭代次数未知;用 scan 加上限,或 custom_vjp + 隐函数定理

🔍 补充:jax.checkpoint + scan 节省反向传播的内存

⚪ 选读:长序列或深层 scan 的反向传播内存不够时

scan 的反向传播默认把每一步的 residuals 都存下来,内存是 $O(T\cdot\text{每步中间量})$。给步函数套上 jax.checkpoint(别名 jax.remat)以后,前向只保存每步的 carry,反向时再重算步内的中间量,用大约一次额外的前向计算换来大量内存。用 memory_analysis() 和 print_saved_residuals 做的定量对比、重计算策略(policy),以及「scan 堆叠层 + 检查点」的标准写法,都统一放在 进阶第 6 章 §6.6。

4.9 处理不可微分函数

自动微分是靠对每个原语递归地用链式法则来工作的,前提是每个原语都有 JVP/VJP 规则。有些运算在数学上不可微,有些作用在整数上;有时我们还会出于建模或性能的考虑,主动把梯度切断。

求导可能失败的原因,可以分成三类:

  1. 数学上不连续:取整(round、floor、ceil);sign 在 0 点;比较运算产生的布尔值被用在 cond 或算术里;argmax/argmin 返回整数索引,输入稍微一变,索引就可能跳。
  2. 整数运算:类型转换、按整数值索引,这些没有通常意义上的导数。
  3. 原语没有实现求导规则:对它求导会报错。

JAX 的默认行为:对 round 和整数转换这类「几乎处处导数为 0」的运算,JAX 规定它们的梯度为 0,所以 jax.grad(lambda x: jnp.round(x))(x) 得到 0.0。

⚠️ 注意:遇到没有求导规则的运算时,报的不一定是 TypeError,要看具体情况:

  • 对整数输入求 grad:TypeError(提示改用 allow_int=True 或 vjp);
  • 对整数输出(比如 argmax 直接返回的索引)求 grad:TypeError;
  • jax.pure_callback 没有 JVP:ValueError(提示用 custom_jvp);
  • 缺少转置规则(非线性的 JVP 规则、对非线性函数调用 linear_transpose):NotImplementedError;
  • while_loop 的反向模式:ValueError。
errors = {}
def record(name, fn):
    try:
        fn()
        errors[name] = "无报错"
    except Exception as e:
        errors[name] = type(e).__name__

record("grad 整数输入", lambda: jax.grad(lambda x: x * 2)(3))
record("grad 整数输出(argmax)", lambda: jax.grad(lambda x: jnp.argmax(x))(jnp.array([0.3, 2.0, 1.0])))
record("grad pure_callback", lambda: jax.grad(
    lambda x: jax.pure_callback(lambda v: np.sin(v).astype(np.float32),
                                jax.ShapeDtypeStruct((), jnp.float32), x))(1.0))
record("grad 非线性 JVP 规则", lambda: jax.grad(nonlinear_rule)(1.0))
record("grad while_loop", lambda: jax.grad(loop_sum_param)(2.0, 5))
for k, v in errors.items():
    print(f"{k:24s} -> {v}")
assert errors == {"grad 整数输入": "TypeError", "grad 整数输出(argmax)": "TypeError",
                  "grad pure_callback": "ValueError", "grad 非线性 JVP 规则": "NotImplementedError",
                  "grad while_loop": "ValueError"}
grad 整数输入                -> TypeError
grad 整数输出(argmax)        -> TypeError
grad pure_callback       -> ValueError
grad 非线性 JVP 规则          -> NotImplementedError
grad while_loop          -> ValueError

🔍 补充:常见「不可微」操作的实测梯度行为

🟢 必读

操作梯度行为(JAX 0.12 实测)
round / floor / ceil / sign处处为 0(包括跳变点)
argmax(转成 float 后)0;直接对整数输出求 grad 会报 TypeError
sort非零:梯度按排序的置换回传到原位置(sort 关于输入是分段线性的)
lax.top_k值部分同 sort,梯度散射回被选中的位置,未选中的位置为 0;索引部分是整数,无梯度
max(有并列最大值时)在并列位置上均分:[1,2,2] → [0, 0.5, 0.5]
abs 在 0 处1.0(取次梯度中的 +1)
relu 在 0 处0.0
xx = jnp.array([0.3, 2.0, 1.0])
w = jnp.array([1.0, 2.0, 3.0])
results = {
    "round": jax.grad(lambda x: jnp.round(x).sum())(xx),
    "floor": jax.grad(lambda x: jnp.floor(x).sum())(xx),
    "sign": jax.grad(lambda x: jnp.sign(x).sum())(xx),
    "argmax(float)": jax.grad(lambda x: jnp.argmax(x).astype(jnp.float32))(xx),
    "sort·w": jax.grad(lambda x: (jnp.sort(x) * w).sum())(xx),
    "top_k(2)·[10,1]": jax.grad(lambda x: (lax.top_k(x, 2)[0] * jnp.array([10.0, 1.0])).sum())(xx),
    "max 并列": jax.grad(jnp.max)(jnp.array([1.0, 2.0, 2.0])),
}
for k, v in results.items():
    print(f"{k:16s} {v}")
print("abs'(0) =", jax.grad(jnp.abs)(0.0), "  relu'(0) =", jax.grad(jax.nn.relu)(0.0))

for k in ["round", "floor", "sign", "argmax(float)"]:
    assert jnp.all(results[k] == 0.0)
# sort 后的顺序为 [0.3, 1.0, 2.0],即原位置 [0, 2, 1],故原位置的梯度为 [w0, w2, w1] = [1, 3, 2]
assert jnp.allclose(results["sort·w"], jnp.array([1.0, 3.0, 2.0]))
# top_2 选中的是 2.0(位置 1,权重 10)与 1.0(位置 2,权重 1)
assert jnp.allclose(results["top_k(2)·[10,1]"], jnp.array([0.0, 10.0, 1.0]))
assert jnp.allclose(results["max 并列"], jnp.array([0.0, 0.5, 0.5]))
assert jax.grad(jnp.abs)(0.0) == 1.0 and jax.grad(jax.nn.relu)(0.0) == 0.0
round            [0. 0. 0.]
floor            [0. 0. 0.]
sign             [0. 0. 0.]
argmax(float)    [0. 0. 0.]
sort·w           [1. 3. 2.]
top_k(2)·[10,1]  [ 0. 10.  1.]
max 并列           [0.  0.5 0.5]
abs'(0) = 1.0   relu'(0) = 0.0

用 jax.lax.stop_gradient 显式停止梯度:前向是恒等函数,反向(以及 JVP)的梯度为 0。也就是说,对求导来说,被它包住的值就是一个常量。(入门部分见 入门 03 自动微分grad 3.8 节。)

例子:$f(x)=\sin x\cos x$,正常的梯度是 $\cos^2x-\sin^2x=\cos 2x$,在 $x=\pi/4$ 处为 0。把 $\cos x$ 用 stop_gradient 包住以后,梯度只剩 $\cos x\cdot\cos x = 0.5$。

什么时候停止梯度,常见的有 5 种情况:

  1. 数学上必须这么做(比如 round);
  2. 建模假设(比如强化学习里的目标网络);
  3. 截断展开循环里的梯度回溯(截断 BPTT);
  4. 性能:被 stop_gradient 截断的支路在求导时被当成常量,JAX 根本不会为它生成反向计算(而不是算出来再乘 0);
  5. 数值稳定性:把会产生 NaN/Inf 的部分隔离开。
# 课程代码:stop_gradient
def f_normal(x):
    y = jnp.sin(x)
    z = jnp.cos(x)
    return y * z                     # 梯度:cos^2 - sin^2 = cos(2x)

def f_stopped(x):
    y = jnp.sin(x)
    z = jax.lax.stop_gradient(jnp.cos(x))
    return y * z                     # 梯度:cos(x) * z

x_val = jnp.pi / 4.0
print(f"x = {x_val:.3f}, f_normal = {f_normal(x_val):.4f}, f_stopped = {f_stopped(x_val):.4f}(前向相同)")
print(f"grad normal = {jax.grad(f_normal)(x_val):.4f}, grad stopped = {jax.grad(f_stopped)(x_val):.4f}")
assert jnp.allclose(f_normal(x_val), f_stopped(x_val))
assert jnp.allclose(jax.grad(f_normal)(x_val), 0.0, atol=1e-6)
assert jnp.allclose(jax.grad(f_stopped)(x_val), 0.5)
# JVP 中同样为 0
assert jax.jvp(lax.stop_gradient, (1.0,), (1.0,))[1] == 0.0
x = 0.785, f_normal = 0.5000, f_stopped = 0.5000(前向相同)
grad normal = 0.0000, grad stopped = 0.5000

不可微运算的替代办法:

  1. 平滑近似:比如用 $\sigma(kx)$ 近似阶跃函数,$k$ 越大越接近;
  2. 自定义梯度,尤其是直通估计器(Straight-Through Estimator, STE):前向用不可微运算(比如取整),反向把上游梯度 $g$ 原样传回去,就好像这个运算是恒等函数一样。

下面用 custom_vjp 实现 STE:ste_example(x) = round(x) * x。在 $x=2.7$ 处,前向为 $3\times2.7=8.1$,STE 梯度为 $\mathrm{round}(x)+x=3+2.7=5.7$(真实梯度是 $\mathrm{round}(x) = 3$)。fwd 里直接调用了 round_straight_through(x) 本身。这样写是可以的,因为在 fwd 内部调用时不会再次触发自定义规则。

# 课程代码:用 custom_vjp 实现 STE
@jax.custom_vjp
def round_straight_through(x):
    """前向为 round,反向为恒等"""
    return jnp.round(x)

def round_straight_through_fwd(x):
    return round_straight_through(x), None      # 无需 residuals

def round_straight_through_bwd(residuals, g):
    return (g,)                                 # 梯度直通

round_straight_through.defvjp(round_straight_through_fwd, round_straight_through_bwd)

def ste_example(x):
    rounded_x = round_straight_through(x)
    return rounded_x * x

x_val = 2.7
print("ste_example(2.7) =", ste_example(x_val), " grad =", jax.grad(ste_example)(x_val))
assert jnp.allclose(ste_example(x_val), 8.1)
assert jnp.allclose(jax.grad(ste_example)(x_val), 5.7)
assert jnp.allclose(jax.grad(lambda x: jnp.round(x) * x)(x_val), 3.0)   # 不用 STE 时 round 的梯度为 0
ste_example(2.7) = 8.1  grad = 5.7

🔍 补充:STE 的 stop_gradient 写法,以及 soft-argmax、Gumbel-softmax

⚪ 选读:做量化感知训练、离散采样等需要「人为指定梯度」的场景时

STE 的一行写法:x + stop_gradient(round(x) - x)。前向时 $x + (\mathrm{round}(x)-x) = \mathrm{round}(x)$;反向时 stop_gradient 那部分是常量,只剩下 $x$ 的恒等梯度。它不需要 custom_vjp,而且前向模式和反向模式都支持,量化感知训练(QAT)、VQ-VAE 里常这么写。

soft-argmax:$\mathrm{softargmax}_\tau(x)=\sum_i i\cdot\mathrm{softmax}(x/\tau)_i$。当 $\tau\to0$ 时它趋近于 argmax,而且处处可微。

Gumbel-softmax(Concrete 分布):要从类别分布 $\pi$ 里可微地采样,先加 Gumbel 噪声 $g_i\sim\mathrm{Gumbel}(0,1)$,再做带温度的 softmax:$y=\mathrm{softmax}\big((\log\pi+g)/\tau\big)$。hard=True 时,前向输出 one-hot,反向用 soft 版本的梯度(这也是一种 STE)。Gumbel-max 技巧保证 $\arg\max_i(\log\pi_i+g_i)$ 恰好服从 $\pi$。

# STE 的 stop_gradient 写法:与 custom_vjp 版本完全一致,且支持前向模式
def round_ste(x):
    return x + lax.stop_gradient(jnp.round(x) - x)

assert round_ste(2.7) == 3.0
assert jnp.allclose(jax.grad(lambda x: round_ste(x) * x)(2.7), 5.7)
assert jax.jvp(round_ste, (2.7,), (1.0,))[1] == 1.0          # 前向模式也能用

# 阶跃函数的平滑近似 sigmoid(kx)
step_smooth = lambda x, k: jax.nn.sigmoid(k * x)
print("sigmoid(kx) 在 x=0.1 处:", [float(step_smooth(0.1, k)) for k in (1, 10, 100)])

# soft-argmax
def soft_argmax(x, tau):
    p = jax.nn.softmax(x / tau)
    return jnp.sum(p * jnp.arange(x.shape[0]))

x3 = jnp.array([0.3, 2.0, 1.0])
print("soft_argmax(τ=0.05) =", soft_argmax(x3, 0.05), "  argmax =", jnp.argmax(x3))
print("grad soft_argmax(τ=0.5) =", jax.grad(soft_argmax)(x3, 0.5))
assert jnp.allclose(soft_argmax(x3, 0.05), 1.0, atol=1e-4)
assert jnp.any(jax.grad(soft_argmax)(x3, 0.5) != 0.0)

# Gumbel-softmax(hard 版本 = one-hot 前向 + soft 梯度)
def gumbel_softmax(key, logits, tau, hard=False):
    g = jax.random.gumbel(key, logits.shape)
    y = jax.nn.softmax((logits + g) / tau)
    if hard:
        y_hard = jax.nn.one_hot(jnp.argmax(y), logits.shape[-1])
        y = y + lax.stop_gradient(y_hard - y)
    return y

probs = jnp.array([0.2, 0.5, 0.3])
keys = jax.random.split(jax.random.key(0), 20000)
samples = jax.vmap(lambda k: gumbel_softmax(k, jnp.log(probs), 0.5, hard=True))(keys)
freq = samples.mean(axis=0)
print("hard Gumbel-softmax 采样频率:", freq, " 目标:", probs)
assert jnp.allclose(samples.sum(axis=1), 1.0) and jnp.all((samples == 0) | (samples == 1))   # 前向是 one-hot
assert jnp.allclose(freq, probs, atol=0.015)                                                 # 分布正确
g_logits = jax.grad(lambda l: gumbel_softmax(jax.random.key(0), l, 0.5, hard=True)[1])(jnp.zeros(3))
print("对 logits 的梯度(来自 soft 版本):", g_logits)
assert jnp.any(g_logits != 0.0)
sigmoid(kx) 在 x=0.1 处: [0.5249791741371155, 0.7310585975646973, 0.9999545812606812]
soft_argmax(τ=0.05) = 1.0   argmax = 1
grad soft_argmax(τ=0.5) = [-0.06209395 -0.14929879  0.2113926 ]
hard Gumbel-softmax 采样频率: [0.20005    0.49754998 0.3024    ]  目标: [0.2 0.5 0.3]
对 logits 的梯度(来自 soft 版本): [-0.24008586  0.24065733 -0.00057147]

4.10 实践:实现自定义梯度

这一节把 custom_vjp 用在 softplus 上。custom_vjp 有四类用途:数值稳定性(自动求导的梯度可能上溢或下溢)、性能(已经知道更高效的梯度算法)、非 JAX 代码(必须手动提供梯度),以及停止梯度或近似梯度。

$$\mathrm{softplus}(x)=\log(1+e^x),\qquad \frac{d}{dx}\mathrm{softplus}(x)=\frac{e^x}{1+e^x}=\frac{1}{1+e^{-x}}=\sigma(x)$$

$\sigma(x)$ 本身是数值稳定的,可以直接用在自定义 VJP 里。三个组成部分:

  1. 用 @jax.custom_vjp 装饰原函数;
  2. fwd:参数和原函数相同,返回 (output, residuals);
  3. bwd:接收 residuals 和上游梯度 $g = \partial\mathcal L/\partial y$,返回对每个位置参数的梯度元组,也就是 $\partial\mathcal L/\partial x = g\cdot \partial y/\partial x$。

⚠️ 注意:别指望 JAX 和 XLA 会「智能地」处理 $x$ 很大时 $e^x$ 的溢出,让 $\log(1+e^x)$ 的默认自动微分给出稳定的梯度。在 float32、CPU 上:朴素实现 jnp.log(1 + jnp.exp(x)) 在 $x=100$ 时前向是 inf,梯度是 nan。更隐蔽的是 $x=88$:exp(88) 本身还没溢出,但梯度 $e^x\cdot\frac{1}{1+e^x}$ 里的 $\frac{1}{1+e^x}\approx 6\times10^{-39}$ 是一个非规格化数,被 CPU 刷成了 0,于是梯度悄悄地变成 0.0(真值约为 1)。XLA 不会把它改写成稳定的形式。

⚠️ 注意:用 except OverflowError: naive_grad = float('inf') 捕获溢出、期望得到 inf,也是行不通的。JAX 不会抛出任何异常,这个分支是死代码,实际得到的是 nan。

# 课程代码 1:朴素实现
def softplus_naive(x):
    return jnp.log(1 + jnp.exp(x))       # 大 x 不稳定

x_val = 10.0
print(f"softplus_naive({x_val}) = {softplus_naive(x_val)}")
print(f"在 {x_val} 处的梯度 (JAX 自动): {jax.grad(softplus_naive)(x_val)}")
assert jnp.allclose(jax.grad(softplus_naive)(x_val), jax.nn.sigmoid(10.0))
softplus_naive(10.0) = 10.000045776367188
在 10.0 处的梯度 (JAX 自动): 0.9999546408653259
# ❌ 旧写法(过时/错误):前向仍为朴素实现;用 except OverflowError 捕获溢出(不会触发)
@jax.custom_vjp
def softplus_custom(x):
    """用于自定义 VJP 的 Softplus 实现"""
    return jnp.log(1 + jnp.exp(x))       # 前向仍是朴素版本(仅为演示)

def softplus_fwd(x):
    y = softplus_custom(x)
    residuals = x                        # 反向需要 x 来计算 sigmoid(x)
    return y, residuals

def softplus_bwd(residuals, g):
    x = residuals
    grad_x = g * jax.nn.sigmoid(x)       # 稳定
    return (grad_x,)

softplus_custom.defvjp(softplus_fwd, softplus_bwd)

grad_softplus_custom = jax.grad(softplus_custom)
test_values = jnp.array([-10.0, 0.0, 10.0, 80.0, 88.0, 100.0])   # 额外加了 88,看梯度静默出错的情况
print("输入  | 朴素梯度 (自动微分)    | 自定义梯度 (VJP)   | 前向 softplus_custom")
print("-" * 72)
overflow_caught = False
naive_grads = []
for x_val in test_values:
    try:
        naive_grad = jax.grad(softplus_naive)(x_val)
    except OverflowError:
        overflow_caught = True
        naive_grad = float("inf")
    naive_grads.append(naive_grad)
    custom_grad = grad_softplus_custom(x_val)
    print(f"{x_val:<5.1f} | {naive_grad:<21.8f} | {custom_grad:<18.8f} | {softplus_custom(x_val)}")

jit_grad_softplus_custom = jax.jit(jax.grad(softplus_custom))
print(f"\nJIT 编译的自定义梯度在 10.0 处: {jit_grad_softplus_custom(10.0):.8f}")
print("except OverflowError 是否触发:", overflow_caught)
输入  | 朴素梯度 (自动微分)    | 自定义梯度 (VJP)   | 前向 softplus_custom
------------------------------------------------------------------------
-10.0 | 0.00004540            | 0.00004540         | 4.541770613286644e-05
0.0   | 0.50000000            | 0.50000000         | 0.6931471824645996
10.0  | 0.99995464            | 0.99995458         | 10.000045776367188
80.0  | 1.00000000            | 1.00000000         | 80.0
88.0  | 0.00000000            | 1.00000000         | 88.0
100.0 | nan                   | 1.00000000         | inf
JIT 编译的自定义梯度在 10.0 处: 0.99995458
except OverflowError 是否触发: False
# ✅ 推荐写法:稳定前向(logaddexp)+ 自定义 VJP(sigmoid)
@jax.custom_vjp
def softplus_stable(x):
    return jnp.logaddexp(x, 0.0)

def softplus_stable_fwd(x):
    return softplus_stable(x), x

def softplus_stable_bwd(x, g):
    return (g * jax.nn.sigmoid(x),)

softplus_stable.defvjp(softplus_stable_fwd, softplus_stable_bwd)

g_stable = jax.vmap(jax.grad(softplus_stable))(test_values)
print("稳定版前向:", softplus_stable(test_values))
print("稳定版梯度:", g_stable)

naive_grads = jnp.array(naive_grads)
assert not overflow_caught                                  # except 分支从未执行
assert jnp.isnan(naive_grads[-1])                           # x=100:nan,不是 inf
assert abs(float(naive_grads[-2]) - 1.0) > 0.5              # x=88 时梯度静默出错(本机为 0.0)
assert jnp.isinf(softplus_custom(100.0))                    # 这个版本的前向在 x=100 溢出
assert jnp.allclose(g_stable, jax.nn.sigmoid(test_values))  # 稳定版梯度处处正确
assert jnp.allclose(softplus_stable(test_values), jax.nn.softplus(test_values))
assert jnp.allclose(jax.jit(jax.grad(softplus_stable))(10.0), 0.9999546, atol=1e-6)
check_grads(softplus_stable, (jnp.float32(2.0),), order=1, modes=["rev"])
稳定版前向: [4.5398901e-05 6.9314718e-01 1.0000046e+01 8.0000000e+01 8.8000000e+01
 1.0000000e+02]
稳定版梯度: [4.5397872e-05 5.0000000e-01 9.9995458e-01 1.0000000e+00 1.0000000e+00
 1.0000000e+00]

验证与分析:在正常范围内,自定义梯度和自动梯度一致(在浮点精度内);反向显式用了 jax.nn.sigmoid,保证了稳定性。

⚠️ 注意:softplus_custom 只改了梯度,前向仍然是朴素实现,$x=100$ 时前向是 inf。生产环境需要稳定的前向:上面的 ✅ 版本给出了完整实现,稳定前向(logaddexp)加自定义 VJP,并通过了 check_grads。也可以直接用 jax.nn.softplus。

整个机制是:softplus_fwd 缓存 x,softplus_bwd 计算 g * sigmoid(x)。

还要考虑的几点:

  • residuals 只保存反向必需的内容:存大的中间数组会多占内存,有时在 bwd 里重新算一遍反而更划算。(比如 softplus 只存 x,不存 exp(x)。)
  • 多输入 / 多输出:fwd 接收全部输入;bwd 返回的元组长度必须等于位置参数的个数;g 的结构和原函数的输出一致。
  • jax.custom_jvp:给定 (x, x_dot) 计算 (y, y_dot),见 4.7 节。
  • 一定要对自定义规则做充分测试,和 JAX 自动微分或数值梯度对比。

⚠️ 注意:对于不在梯度路径上的输出,bwd 收到的 g 对应位置不是 None。默认(symbolic_zeros=False)情况下,它是一个全零数组。只有在 defvjp(fwd, bwd, symbolic_zeros=True) 时才会传入符号零,这时可以利用它跳过计算。

# 未参与损失的输出,bwd 收到的是零数组而不是 None
seen = {}

@jax.custom_vjp
def two_outputs(x):
    return jnp.sin(x), jnp.cos(x)

def two_outputs_fwd(x):
    return two_outputs(x), x

def two_outputs_bwd(x, g):
    g_sin, g_cos = g
    seen["types"] = (type(g_sin).__name__, type(g_cos).__name__)
    seen["g_cos"] = g_cos
    return (g_sin * jnp.cos(x) - g_cos * jnp.sin(x),)

two_outputs.defvjp(two_outputs_fwd, two_outputs_bwd)

gx = jax.grad(lambda x: two_outputs(x)[0])(1.0)     # 只用第一个输出
print("bwd 收到的 g 类型:", seen["types"], " g_cos =", seen["g_cos"])
assert seen["g_cos"] is not None and seen["g_cos"] == 0.0
assert jnp.allclose(gx, jnp.cos(1.0))
bwd 收到的 g 类型: ('ArrayImpl', 'ArrayImpl')  g_cos = 0.0

🔍 综合实践:用 custom_vjp 实现隐式层(Deep Equilibrium, DEQ)

隐式层的输出定义成一个不动点,而不是有限步的计算:

$$z^\star = \tanh(Wz^\star + Ux),\qquad \text{loss} = \|z^\star\|^2$$

  • 前向:用 while_loop 迭代到收敛(迭代次数由数据决定);
  • 反向(隐函数定理,和 4.6 节一样):设 $T(\theta, x, z)=\tanh(Wz+Ux)$,先求伴随不动点 $u = g + (\partial T/\partial z)^\top u$,再求 $\bar\theta = u^\top\partial T/\partial\theta$ 和 $\bar x = u^\top \partial T/\partial x$;
  • 内存:只保存 $z^\star$,和迭代次数无关;展开版本则要保存每一步。

$\|W\|$ 足够小时,$T$ 关于 $z$ 是压缩映射,前向和伴随迭代都能收敛。参数作为函数参数传进去(不在闭包里捕获),然后和「展开 100 步再直接反向传播」的结果对拍。

def deq_cell(params, x, z):
    W, U = params
    return jnp.tanh(W @ z + U @ x)

@partial(jax.custom_vjp, nondiff_argnums=(0,))
def deq_solve(T, params, x):
    W, _ = params
    def cond(c):
        z, z_prev, i = c
        return (jnp.max(jnp.abs(z - z_prev)) > 1e-6) & (i < 500)
    def body(c):
        z, _, i = c
        return T(params, x, z), z, i + 1
    z0 = jnp.zeros(W.shape[0], W.dtype)
    z_star, _, n_iter = lax.while_loop(cond, body, (T(params, x, z0), z0, 0))
    return z_star

def deq_fwd(T, params, x):
    z_star = deq_solve(T, params, x)
    return z_star, (params, x, z_star)

def deq_bwd(T, res, g):
    params, x, z_star = res
    _, vjp_z = jax.vjp(lambda z: T(params, x, z), z_star)
    def cond(c):
        u, u_prev, i = c
        return (jnp.max(jnp.abs(u - u_prev)) > 1e-7) & (i < 500)
    def body(c):
        u, _, i = c
        return g + vjp_z(u)[0], u, i + 1
    u = lax.while_loop(cond, body, (g + vjp_z(g)[0], g, 0))[0]
    _, vjp_px = jax.vjp(lambda p, xx: T(p, xx, z_star), params, x)
    return vjp_px(u)                      # (params 的梯度, x 的梯度)

deq_solve.defvjp(deq_fwd, deq_bwd)

def loss_implicit(params, x):
    return jnp.sum(deq_solve(deq_cell, params, x) ** 2)

def loss_unrolled(params, x, n_steps=100):
    z = lax.fori_loop(0, n_steps, lambda i, z: deq_cell(params, x, z), jnp.zeros(params[0].shape[0]))
    return jnp.sum(z ** 2)

k1, k2, k3 = jax.random.split(jax.random.key(42), 3)
d, m = 32, 8
W = jax.random.normal(k1, (d, d)) * 0.4 / jnp.sqrt(d)     # 谱范数较小,保证压缩
U = jax.random.normal(k2, (d, m)) / jnp.sqrt(m)
x_in = jax.random.normal(k3, (m,))
params = (W, U)

(gW_i, gU_i), gx_i = jax.jit(jax.grad(loss_implicit, argnums=(0, 1)))(params, x_in)
(gW_u, gU_u), gx_u = jax.jit(jax.grad(loss_unrolled, argnums=(0, 1)))(params, x_in)
for name, a, b in [("dW", gW_i, gW_u), ("dU", gU_i, gU_u), ("dx", gx_i, gx_u)]:
    print(f"{name}: 最大绝对差 {float(jnp.max(jnp.abs(a - b))):.2e},量级 {float(jnp.max(jnp.abs(b))):.3f}")
    assert jnp.allclose(a, b, atol=1e-5, rtol=1e-4)
assert jnp.allclose(loss_implicit(params, x_in), loss_unrolled(params, x_in), atol=1e-6)
dW: 最大绝对差 3.58e-07,量级 0.891
dU: 最大绝对差 9.54e-07,量级 1.697
dx: 最大绝对差 9.54e-07,量级 5.773
# 用 NumPy float64 独立对拍:直接用隐函数定理的解析公式(显式求逆,小维度可行)
Wn, Un, xn = (np.asarray(a, dtype=np.float64) for a in (W, U, x_in))
z = np.zeros(d)
for _ in range(500):
    z = np.tanh(Wn @ z + Un @ xn)
D = np.diag(1 - z**2)                                 # tanh' 在 z* 处
g = 2 * z                                             # dL/dz*
u = np.linalg.solve((np.eye(d) - D @ Wn).T, g)        # u = (I - ∂T/∂z)^{-T} g
s = D @ u                                             # 回传到 tanh 的输入
gW_ref, gU_ref, gx_ref = np.outer(s, z), np.outer(s, xn), Un.T @ s
assert np.allclose(gW_i, gW_ref, atol=1e-5)
assert np.allclose(gU_i, gU_ref, atol=1e-5)
assert np.allclose(gx_i, gx_ref, atol=1e-5)
print("隐式层梯度与 NumPy float64 解析解一致")

# 保存的 residuals:隐式版本只存 z*,展开版本存每一步
print("\n隐式版本保存的 residuals:")
print_saved_residuals(loss_implicit, params, x_in)
隐式层梯度与 NumPy float64 解析解一致

隐式版本保存的 residuals:
f32[32,32] from the argument params[0]
f32[32,8] from the argument params[1]
f32[8] from the argument x
f32[32] output of custom_vjp_call from /tmp/ipykernel_89873/2388325344.py:19:13 (deq_fwd)
f32[32] output of mul from /tmp/ipykernel_89873/2388325344.py:38:19 (loss_implicit)

动手练习

4.10 节里隐含了 3 个任务,就是下面的课程练习 1–3;之后再加 4 道补充练习。每道题都附有参考答案,并用 assert 自检。

课程练习 1:比较朴素梯度与自定义梯度

在正常范围内($|x|\le 20$),验证 4.10 节 softplus_custom 的梯度和朴素自动梯度一致。

自检接口:g_naive、g_custom:朴素版 softplus_naive 和自定义版 softplus_custom 在 $[-20, 20]$ 上 41 个等距点处的梯度,形状都是 (41,)。

# ✍️ 作答区:课程练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:课程练习 1
try:
    g_naive, g_custom
except NameError as e:
    print(f"⏳ 课程练习 1 尚未作答:{e}")
else:
    _xs = np.linspace(-20.0, 20.0, 41)
    _sig = 1.0 / (1.0 + np.exp(-_xs))                       # softplus 的解析导数 σ(x),float64
    assert np.shape(g_naive) == (41,) and np.shape(g_custom) == (41,), "两个梯度数组的形状都应是 (41,),对应 linspace(-20, 20, 41)"
    assert np.allclose(g_naive, _sig, atol=1e-6), "g_naive 与解析导数 σ(x) 不符:确认是对 softplus_naive 逐点求 grad"
    assert np.allclose(g_custom, _sig, atol=1e-6), "g_custom 与解析导数 σ(x) 不符:确认是对 softplus_custom 逐点求 grad"
    print("✅ 课程练习 1 自检通过")
⏳ 课程练习 1 尚未作答:name 'g_naive' is not defined

课程练习 2:residuals 的取舍 —— 保存还是重算

对 $f(x)=\sum_i \sin(x_i)^2$ 写两个 custom_vjp 版本:A 保存 $\cos x$ 和 $\sin x$ 作为 residuals;B 只保存 $x$,在 bwd 里重新算。用 print_saved_residuals 比较两者保存的数据量,并验证梯度相同。

自检接口:sin2_save(x)、sin2_recompute(x):两个 jax.custom_vjp 函数,都计算 $\sum_i\sin(x_i)^2$(x 为一维数组)。

# ✍️ 作答区:课程练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:课程练习 2
try:
    sin2_save, sin2_recompute
except NameError as e:
    print(f"⏳ 课程练习 2 尚未作答:{e}")
else:
    import io, re, contextlib
    def _resid_elems(f, *args):
        """print_saved_residuals 的输出 → 保存的残差元素总数"""
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            print_saved_residuals(f, *args)
        tot = 0
        for line in buf.getvalue().splitlines():
            m = re.match(r"\s*\w+\[([\d,]*)\]", line)
            if m:
                tot += int(np.prod([int(d) for d in m.group(1).split(",") if d] or [1]))
        return tot
    _x = jax.random.uniform(jax.random.key(42), (257,), minval=-3.0, maxval=3.0)
    _x64 = np.asarray(_x, np.float64)
    for _name, _f in [("sin2_save", sin2_save), ("sin2_recompute", sin2_recompute)]:
        assert isinstance(_f, jax.custom_vjp), f"{_name} 应该用 jax.custom_vjp 定义"
        assert np.isclose(float(_f(_x)), np.sum(np.sin(_x64) ** 2), rtol=1e-5), f"{_name} 的前向值不对"
        assert np.allclose(jax.jit(jax.grad(_f))(_x), np.sin(2 * _x64), atol=1e-5), f"{_name} 的梯度与解析式 2 sin x cos x 不符"
    _n_save, _n_re = _resid_elems(sin2_save, _x), _resid_elems(sin2_recompute, _x)
    assert _n_save >= 2 * 257, f"sin2_save 应保存 sin x 和 cos x 两个数组(实际只保存了 {_n_save} 个元素)"
    assert _n_re <= 257, f"sin2_recompute 应只保存 x,在 bwd 里重算(实际保存了 {_n_re} 个元素)"
    print(f"残差元素数:A = {_n_save},B = {_n_re}")
    print("✅ 课程练习 2 自检通过")
⏳ 课程练习 2 尚未作答:name 'sin2_save' is not defined

课程练习 3:生产环境中的 softplus

实现一个 softplus:前向在 $x\in[-1000,1000]$ 上不溢出,梯度用 custom_vjp 给出,并且能配合 jit + vmap 使用。(这就是 4.10 节的 softplus_stable,这里在极端区间上验证一下。)

自检接口:softplus_prod(x):你的实现(标量输入、标量输出,梯度由 jax.custom_vjp 给出)。

# ✍️ 作答区:课程练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:课程练习 3
try:
    softplus_prod
except NameError as e:
    print(f"⏳ 课程练习 3 尚未作答:{e}")
else:
    assert isinstance(softplus_prod, jax.custom_vjp), "题目要求梯度由 jax.custom_vjp 给出"
    _x = jnp.array([-1000.0, -300.0, -30.0, -1.0, 0.0, 0.5, 30.0, 300.0, 1000.0])
    _x64 = np.asarray(_x, np.float64)
    _y = jax.jit(jax.vmap(softplus_prod))(_x)
    _g = jax.jit(jax.vmap(jax.grad(softplus_prod)))(_x)
    assert np.all(np.isfinite(_y)), f"前向出现 inf/nan:{_y}"
    assert np.all(np.isfinite(_g)), f"梯度出现 inf/nan:{_g}"
    assert np.allclose(_y, np.logaddexp(_x64, 0.0), rtol=1e-6, atol=1e-6), "前向值与 log(1+eˣ) 的 float64 参考值不符"
    assert np.allclose(_g, 0.5 * (1 + np.tanh(_x64 / 2)), atol=1e-6), "梯度与 σ(x) 不符"
    print("✅ 课程练习 3 自检通过")
⏳ 课程练习 3 尚未作答:name 'softplus_prod' is not defined

补充练习 A:pytree 的全局范数梯度裁剪

用 custom_vjp 实现 clip_grad_global_norm(params, max_norm):前向恒等;反向时,如果整个 pytree 余切的全局 L2 范数超过 max_norm,就按比例缩放到 max_norm。这是一种「逐层插入」的裁剪方式,效果和 optax 在优化器里做裁剪一样。用一个字典参数验证:裁剪后梯度的范数等于 max_norm,方向不变。

自检接口:clip_grad_global_norm(params, max_norm):前向恒等返回 params(任意 pytree),反向按全局范数裁剪余切;max_norm 是 Python float。

# ✍️ 作答区:补充练习 A
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 A
try:
    clip_grad_global_norm
except NameError as e:
    print(f"⏳ 补充练习 A 尚未作答:{e}")
else:
    _k = jax.random.split(jax.random.key(7), 6)
    _p = {"a": jax.random.normal(_k[0], (4, 3)), "b": [jax.random.normal(_k[1], (5,)), jnp.array(2.0)]}
    _c = {"a": jax.random.normal(_k[2], (4, 3)), "b": [jax.random.normal(_k[3], (5,)), jnp.array(-1.5)]}
    _lin = lambda q: sum(jnp.vdot(u, v) for u, v in zip(jax.tree.leaves(_c), jax.tree.leaves(q)))   # 梯度恰好是 _c
    _nc = float(np.sqrt(sum(np.sum(np.asarray(l) ** 2) for l in jax.tree.leaves(_c))))
    _out = clip_grad_global_norm(_p, 1.0)
    assert jax.tree.structure(_out) == jax.tree.structure(_p) and all(
        np.array_equal(u, v) for u, v in zip(jax.tree.leaves(_out), jax.tree.leaves(_p))), "前向应原样返回 params"
    for _mn in [_nc / 4, _nc * 3]:
        _g = jax.jit(jax.grad(lambda q: _lin(clip_grad_global_norm(q, _mn))))(_p)
        _scale = min(1.0, _mn / _nc)
        for u, v in zip(jax.tree.leaves(_g), jax.tree.leaves(_c)):
            assert np.allclose(u, _scale * np.asarray(v), rtol=1e-5, atol=1e-6), (
                f"max_norm={_mn:.3f}(余切范数 {_nc:.3f})时梯度应为余切 × {_scale:.3f}:"
                + ("超过阈值要按比例缩放" if _scale < 1 else "未超过阈值时不应改变梯度"))
    print("✅ 补充练习 A 自检通过")
⏳ 补充练习 A 尚未作答:name 'clip_grad_global_norm' is not defined

补充练习 B:五对角 Jacobian 的着色

函数 $f_i = x_i^3 + x_{i-2}x_{i+2} - x_{i-1}$(越界项按 0 处理)的 Jacobian 是五对角的(带宽为 2)。用 5 种颜色、vmap(jvp) 求出 $n=50$ 时的 Jacobian,并和 jacfwd 对拍。想一想:带宽为 $b$ 时最少要几种颜色?(答案:$2b+1$。)

自检接口:f_penta(x):题中的五对角函数;colored_jacobian_banded(f, x, bandwidth):对带宽为 bandwidth 的 f,用 $2b+1$ 种颜色、vmap(jvp) 求出完整的 $n\times n$ Jacobian。

# ✍️ 作答区:补充练习 B
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 B
try:
    f_penta, colored_jacobian_banded
except NameError as e:
    print(f"⏳ 补充练习 B 尚未作答:{e}")
else:
    _x = jax.random.normal(jax.random.key(3), (37,))
    _xn = np.pad(np.asarray(_x, np.float64), 2)
    _ref = _xn[2:-2] ** 3 + _xn[:-4] * _xn[4:] - _xn[1:-3]            # f_i = x_i³ + x_{i-2}x_{i+2} − x_{i-1}
    assert np.allclose(f_penta(_x), _ref, atol=1e-5), "f_penta 的值与题目定义不符(越界项应按 0 处理)"
    _J = jax.jacfwd(f_penta)(_x)
    assert np.allclose(colored_jacobian_banded(f_penta, _x, 2), _J, atol=1e-5), "五对角情形(bandwidth=2)的 Jacobian 与 jacfwd 不一致"
    f_tri = lambda v: jnp.sin(v) + jnp.pad(v, 1)[:-2] * jnp.pad(v, 1)[2:]   # 三对角
    assert np.allclose(colored_jacobian_banded(f_tri, _x, 1), jax.jacfwd(f_tri)(_x), atol=1e-5), "三对角函数(bandwidth=1)的 Jacobian 不对"
    # 真正按颜色压缩时,颜色数不够会让相邻列互相叠加 → 带内的值也出错;
    # 如果只是 jacfwd 再截取带状部分,这里反而会「正确」。
    _band1 = np.where(np.abs(np.subtract.outer(np.arange(37), np.arange(37))) <= 1, np.asarray(_J), 0.0)
    assert not np.allclose(colored_jacobian_banded(f_penta, _x, 1), _band1, atol=1e-5), (
        "颜色数不足(对五对角函数只用 3 色)时结果却等于真 Jacobian 的带状部分:说明没有真正做着色压缩")
    print("✅ 补充练习 B 自检通过")
⏳ 补充练习 B 尚未作答:name 'f_penta' is not defined

补充练习 C:ODE 伴随法(adjoint)求导

对 $\dot z = \tanh(Wz)$,$z(0)=z_0$,用固定步长 RK4 积分到 $T=1$,损失为 $L=\|z(T)\|^2$。用 custom_vjp 实现连续伴随法:

$$a(T)=\frac{\partial L}{\partial z(T)},\quad \dot a = -a^\top\frac{\partial f}{\partial z},\quad \frac{dL}{dW} = \int_0^T a^\top\frac{\partial f}{\partial W}\,dt$$

把增广状态 $(z, a, \bar W)$ 从 $T$ 反向积分回 0($z$ 也一起反向积分重建,不用保存轨迹),得到 $\partial L/\partial z_0 = a(0)$ 和 $\partial L/\partial W$。然后和「直接对 RK4 展开版本求导」对拍。连续伴随和离散反传只差 $O(h^4)$ 的离散误差,所以取 $N=100$ 步时,两者应该在 $10^{-4}$ 以内一致。

自检接口:odeint_unrolled(z0, W):RK4 展开版(全局 N_STEPS=100、T_END=1.0);odeint_adjoint(z0, W):同样的前向,用 jax.custom_vjp 实现连续伴随反向。两者都返回 $z(T)$,z0 形状 (d,),W 形状 (d, d)。

# ✍️ 作答区:补充练习 C
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 C
try:
    odeint_unrolled, odeint_adjoint
except NameError as e:
    print(f"⏳ 补充练习 C 尚未作答:{e}")
else:
    from jax.experimental.ode import odeint as _dopri
    import io, re, contextlib
    def _resid_elems(f, *args):
        """print_saved_residuals 的输出 → 保存的残差元素总数"""
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            print_saved_residuals(f, *args)
        tot = 0
        for line in buf.getvalue().splitlines():
            m = re.match(r"\s*\w+\[([\d,]*)\]", line)
            if m:
                tot += int(np.prod([int(d) for d in m.group(1).split(",") if d] or [1]))
        return tot
    assert isinstance(odeint_adjoint, jax.custom_vjp), "odeint_adjoint 应该用 jax.custom_vjp 定义"
    _k1, _k2 = jax.random.split(jax.random.key(123))
    _d = 5
    _W = 0.8 * jax.random.normal(_k1, (_d, _d))
    _z0 = jax.random.normal(_k2, (_d,))
    _zT_ref = _dopri(lambda z, t, W: jnp.tanh(W @ z), _z0, jnp.array([0.0, 1.0]), _W, rtol=1e-7, atol=1e-7)[-1]
    assert np.allclose(odeint_unrolled(_z0, _W), _zT_ref, atol=1e-5), "odeint_unrolled 的 z(T) 与高精度自适应积分器不符"
    assert np.allclose(odeint_adjoint(_z0, _W), _zT_ref, atol=1e-5), "odeint_adjoint 的前向 z(T) 不对"
    _L_u = lambda z0, W: jnp.sum(odeint_unrolled(z0, W) ** 2)
    _L_a = lambda z0, W: jnp.sum(odeint_adjoint(z0, W) ** 2)
    _gu = jax.grad(_L_u, argnums=(0, 1))(_z0, _W)
    _ga = jax.jit(jax.grad(_L_a, argnums=(0, 1)))(_z0, _W)
    assert np.allclose(_ga[0], _gu[0], atol=1e-4), "伴随法的 ∂L/∂z0 与展开版直接求导不一致(检查 a 的反向积分)"
    assert np.allclose(_ga[1], _gu[1], atol=1e-4), "伴随法的 ∂L/∂W 与展开版直接求导不一致(检查 W̄ 的积分)"
    _n_adj = _resid_elems(_L_a, _z0, _W)
    assert _n_adj < 100 * _d, f"伴随法不应保存轨迹:反向保存了 {_n_adj} 个元素(100 步 × {_d} 维的轨迹就是 {100 * _d} 个)"
    print(f"伴随法保存的残差元素数:{_n_adj}(展开版:{_resid_elems(_L_u, _z0, _W)})")
    print("✅ 补充练习 C 自检通过")
⏳ 补充练习 C 尚未作答:name 'odeint_unrolled' is not defined

补充练习 D:在 0 处安全的向量范数(custom_jvp)

$\|x\|_2$ 在 $x=0$ 处不可微,jax.grad(jnp.linalg.norm) 在 0 处给出 nan。用 custom_jvp 实现 safe_norm:前向为 $\|x\|$,JVP 为 $\dfrac{x^\top t}{\|x\|}$,在 $x=0$ 处约定为 0。要求:

  1. grad 在 0 处为全零,在非零点和 jnp.linalg.norm 一致;
  2. 前向模式(jvp)和反向模式都能用,并通过 check_grads。

(可以和入门课 入门 03 自动微分grad 里用「双 where」写的 safe_sqrt 对照:这里用 custom_jvp 达到同样的目的,而且规则更直观。)

自检接口:safe_norm(x):用 jax.custom_jvp 定义,x 为一维数组,返回标量 $\|x\|_2$。

# ✍️ 作答区:补充练习 D
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:补充练习 D
try:
    safe_norm
except NameError as e:
    print(f"⏳ 补充练习 D 尚未作答:{e}")
else:
    assert isinstance(safe_norm, jax.custom_jvp), "safe_norm 应该用 jax.custom_jvp 定义"
    _z = jnp.zeros(5)
    _g0 = jax.jit(jax.grad(safe_norm))(_z)
    assert np.all(np.isfinite(_g0)) and np.all(_g0 == 0.0), f"x = 0 处的梯度应为全零,实际为 {_g0}"
    _x = jax.random.normal(jax.random.key(11), (5,))
    _x64 = np.asarray(_x, np.float64)
    assert np.isclose(float(safe_norm(_x)), np.linalg.norm(_x64), rtol=1e-6), "前向值不是 L2 范数"
    assert np.allclose(jax.grad(safe_norm)(_x), _x64 / np.linalg.norm(_x64), atol=1e-6), "非零点处的梯度应为 x / ‖x‖"
    _t = jax.random.normal(jax.random.key(12), (5,))
    assert np.isclose(float(jax.jvp(safe_norm, (_x,), (_t,))[1]), _x64 @ np.asarray(_t, np.float64) / np.linalg.norm(_x64), rtol=1e-5), "JVP 应为 xᵀt / ‖x‖"
    assert np.allclose(jax.vmap(jax.grad(safe_norm))(jnp.stack([_z, _x])), np.stack([np.zeros(5), _x64 / np.linalg.norm(_x64)]), atol=1e-6), "vmap 下的梯度不对"
    check_grads(safe_norm, (_x,), order=1, modes=["fwd", "rev"])
    print("✅ 补充练习 D 自检通过")
⏳ 补充练习 D 尚未作答:name 'safe_norm' is not defined

本章测验

以下 8 题的答案都已在本 notebook 里运行验证过。

1. 对 $f:\mathbb{R}^{1000}\to\mathbb{R}^{3}$ 求完整 Jacobian,该用 jacfwd 还是 jacrev?各要多少次 JVP 或 VJP?

答案与解析

用 jacrev:只要 3 次 VJP(每个输出一次),而 jacfwd 要 1000 次 JVP。一般规则是:输出少用反向,输入少用前向。

2. jax.linear_transpose(jnp.sin, x) 会怎样?jax.vjp 和 linearize、linear_transpose 是什么关系?

答案与解析

会报 NotImplementedError,因为 sin 不是线性函数,没有转置规则。vjp = linear_transpose ∘ linearize:先线性化,得到关于切向量的线性函数 $v\mapsto Jv$,再转置得到 $u\mapsto J^\top u$(见 4.1 节)。

3. 下面的代码输出什么?jax.jvp(predict, (params, inputs), (tangents, jax.lax.stop_gradient(inputs)))

答案与解析

stop_gradient(inputs) 的值就是 inputs 本身,所以它被当成一个非零切向量,输出里会多出 inputs @ w 这一项(4.2 节的结果是 [100, 130],而不是 [30, 30])。「不扰动」应该传 jnp.zeros_like(inputs)。stop_gradient 影响的是对它的输出求导,并不会让它的值变成零。

4. 一个函数用 jax.custom_vjp 定义了梯度,能对它用 jax.hessian 吗?改用 jax.custom_jvp 呢?

答案与解析

custom_vjp 不行:hessian = jacfwd(jacrev(f)) 的外层是前向模式,而 custom_vjp 不支持 JVP,会报 TypeError。custom_jvp 可以:前向、反向(自动转置)、高阶导数都支持。前提是规则对切向量是线性的(切向量一侧只用 JAX 原语),而且规则本身也要可微。

5. jax.grad 作用在 lax.while_loop 上会怎样?给循环体加上 jax.checkpoint 能解决吗?有哪些正确的替代方案?

答案与解析

会报 ValueError: Reverse-mode differentiation does not work for lax.while_loop ...。加 checkpoint 解决不了。替代方案:迭代次数已知时,用静态上下界的 fori_loop 或 scan;迭代到收敛的情形,用 custom_vjp + 隐函数定理;或者只用前向模式(jvp/jacfwd)。另外要注意,在 jit 里 fori_loop 的上界如果是 tracer,也会退化成 while_loop。

6. jax.grad(lambda x: (jnp.sort(x) * w).sum()) 的梯度是零吗?argmax 呢?

答案与解析

sort 的梯度不是零:sort 是一个随输入而定的置换,梯度按这个置换传回去,比如 4.9 节里得到 [1, 3, 2]。argmax 的输出是整数,直接 grad 会报 TypeError;转成 float 以后梯度为 0。需要可微的替代时,可以用 soft-argmax 或 Gumbel-softmax(配合 STE)。

7. 什么是扰动混淆?JAX 为什么不会出现?

答案与解析

嵌套前向 AD 时,如果不同层级的无穷小 $\varepsilon$ 不加区分,内层求导会把外层的扰动当成自己的,结果就错了(4.4 节的例子里,朴素实现得到 2,正确答案是 1)。JAX 每次 jvp/grad 都会新建一个有唯一层级的 trace,每个 tracer 都记着自己属于哪个 trace,内层碰到外层 tracer 时把它当常量,所以不会混淆。

8. 用 custom_vjp 对不动点 $x^\star=T(a,x^\star)$ 求导时,反向要保存什么?伴随方程是什么?和「展开迭代再反传」比有什么好处?

答案与解析

只要保存 $x^\star$(以及 $a$)。伴随方程是 $u = g + (\partial T/\partial x)^\top u$(同样是一个不动点问题),梯度为 $\bar a = u^\top \partial T/\partial a$。好处有三点:内存和迭代次数无关;前向可以用 while_loop 迭代到收敛(展开版本做不到反向);梯度精度只取决于解的精度,和迭代路径无关。

本章更新说明

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

编号小节要点位置
14.2PyTree 例子的前向输出 inputs @ w + b 是 [70.1, 99.9](不是 100.9)4.2 例 2 ✅ 单元
24.2stop_gradient(inputs) 的值是 inputs 本身,不能当零切向量(会让切向量输出变成 [100,130]);传 None 报 TypeError;不扰动应传 jnp.zeros_like(inputs) 或用闭包4.2 ❌/✅ 单元
34.4grad 套 grad 的 HVP 写法是反向套反向,不是「反向-正向叠加」4.4 HVP 讲解
44.4/4.5jax.hessian(f) 在源码里固定是 jacfwd(jacrev(f))4.5 Hessian 讲解
54.5jax.jvp(grad_f, (x_in,), (v_)) 中 v_ 未定义会报 NameError,切向量要写成元组 (v,)4.5 ❌/✅ 单元
64.6前向用 log1p(exp(x)) 的 stable_log1pexp 只有梯度稳定,$x=100$ 时前向为 inf;稳定前向用 logaddexp(x,0)4.6 例 2
74.6不稳定梯度默认不抛异常,悄悄返回 nan,try/except FloatingPointError 捕获不到;只有在 jax.debug_nans(True) 下才会抛出4.6 例 2
84.6用不到随机数时不必创建 key;需要时用 jax.random.key,而不是旧式的 jax.random.PRNGKey4.6 例 1 ❌/✅ 单元
94.6不可微的数组参数可以在 bwd 中返回 None(视为 0);非数组参数要用 nondiff_argnums(bwd 签名变为 bwd(*nondiff, res, g),且这些参数不能是 tracer)4.6 补充
104.7custom_jvp 的 safe_x 技巧只能用在导数公式里;前向也用 safe_x 会让 stable_sqrt(0) 返回 1.0,前向应直接用 jnp.sqrt(x)4.7 ❌/✅ 单元
114.7对切向量线性、且切向量一侧只用 JAX 原语的 JVP 规则都能自动转置得到 VJP;custom_jvp 和 custom_vjp 不能同时定义(custom_jvp 没有 defvjp);custom_vjp 不支持前向模式4.7 custom_vjp 关系讲解
124.8while_loop 只支持前向模式,grad 会报 ValueError,加 checkpoint 也不行;改用静态 fori_loop/scan 或隐函数定理4.8 ❌/✅ 单元
134.8scan RNN 例子中对 $h_0$ 求导得到的是初始状态的梯度,不是参数梯度,函数命名为 grad_rnn_h04.8 scan 例子
144.9没有求导规则时报错类型视情况而定:TypeError(整数输入/输出)、ValueError(pure_callback、while_loop)、NotImplementedError(缺少转置规则)4.9 求导失败原因讲解
154.10朴素 softplus 在 $x=100$ 时前向 inf、梯度 nan;$x=88$ 时梯度因非规格化数被刷零而悄悄变为 0.0,XLA 不会自动改写成稳定形式4.10 开头讲解
164.10朴素 softplus 溢出时不会抛 OverflowError(except 分支是死代码),实际结果是 nan4.10 ❌/✅ 单元
174.10不在梯度路径上的输出,bwd 收到的 g 默认是全零数组而不是 None;只有 symbolic_zeros=True 时才是符号零4.10 考量讲解
184.10softplus_custom 前向仍是朴素实现;完整实现要用稳定前向(logaddexp)加 custom_vjp,并用 check_grads 验证4.10 ✅ 单元

本章小结

  1. 两种模式的本质:JVP 计算 $Jv$,开销与输出维度无关;VJP 计算 $u^\top J$,开销与输入维度无关。在 JAX 中,VJP = 线性化 + 转置(linearize + linear_transpose),每个原语只需要提供 JVP 规则,线性原语再提供转置规则。
  2. 高阶导数:变换可以任意嵌套(HVP 优先用前向套反向);JAX 用带层级的 trace 避免扰动混淆;jax.experimental.jet 的 Taylor 模式在 0.12 中仍可用,一次传播就能得到多阶导数,但不支持控制流和 custom_jvp。
  3. 完整矩阵:jacfwd/jacrev 本质上都是 vmap 套在 JVP/VJP 上;hessian = jacfwd(jacrev);稀疏 Jacobian 用着色法,把 JVP 次数从 $n$ 降到颜色数。
  4. custom_vjp:fwd 返回 (out, res),bwd(res, g) 返回与参数等长的元组;非数组参数用 nondiff_argnums(不能是 tracer)。典型用途有梯度裁剪、梯度反转、STE、隐函数定理(不动点、DEQ、ODE 伴随)。不支持前向模式。
  5. custom_jvp:用 defjvp 或 defjvps;只要规则对切向量是线性的(且切向量一侧只用 JAX 原语),就会自动获得反向模式;不能与 defvjp 同时用在同一个函数上。能用它就优先用它。
  6. 控制流:cond、scan、静态 fori_loop 都可以双向求导;while_loop 只支持前向模式;checkpoint + scan 可以把反向传播的内存从「每步的中间量」降到「每步的 carry」(定量对比见进阶第 6 章 §6.6)。
  7. 不可微操作:round/floor/sign/argmax 的梯度为 0(对整数输出求 grad 会报错);sort/top_k/max 按置换或选择回传梯度;stop_gradient 是「前向恒等、导数为零」;可以用平滑近似(sigmoid、soft-argmax、Gumbel-softmax)或 STE 替代。
  8. 验证:所有自定义规则都要用 check_grads 或解析解对拍;数值问题(inf/nan/非规格化数被刷零)不会抛异常,需要时开启 jax.debug_nans。

速查表

API作用
jax.jvp(f, primals, tangents)前向模式:返回 (f(x), J v);切向量的 dtype 和结构必须与 primal 一致
jax.vjp(f, *primals)反向模式:返回 (f(x), vjp_fun),vjp_fun(u) 返回 $u^\top J$(元组)
jax.linearize(f, x)返回 (f(x), f_lin),f_lin(v) = J v,residuals 只算一次
jax.linear_transpose(f_lin, x)线性函数的转置,vjp = linear_transpose ∘ linearize
jax.jacfwd / jax.jacrev完整 Jacobian:vmap(jvp) / vmap(vjp)
jax.hessian(f)jacfwd(jacrev(f))
jax.jvp(jax.grad(f), (x,), (v,))[1]HVP(前向套反向)
jax.experimental.jet.jet(f, (x,), ((1., 0., ...),))Taylor 模式,一次得到 1~k 阶导数(不支持控制流和 custom_jvp)
vmap(lambda s: jvp(f,(x,),(s,))[1])(seeds)着色法求稀疏 Jacobian
@jax.custom_vjp + f.defvjp(fwd, bwd)自定义反向:fwd → (out, res),bwd(res, g) → tuple
partial(jax.custom_vjp, nondiff_argnums=(i,))非数组参数;bwd(*nondiff, res, g)
defvjp(..., symbolic_zeros=True)bwd 收到符号零,可以跳过计算
@jax.custom_jvp + @f.defjvp自定义前向 rule(primals, tangents) → (out, t_out),自动获得反向
f.defjvps(jvp_x, None, ...)逐参数的 JVP 规则 jvp_i(t_i, ans, *primals)
jax.test_util.check_grads(f, args, order, modes)与有限差分对拍;custom_vjp 只能用 modes=['rev']
lax.cond / lax.scan / lax.fori_loop(静态上下界)前向、反向都支持
lax.while_loop只支持前向模式
jax.checkpoint(step) 放进 scan反向时重算步内中间量,节省内存
jax.ad_checkpoint.print_saved_residuals(f, *args)查看反向传播保存了哪些张量
jit(f).lower(*a).compile().memory_analysis()查看编译后的临时内存(用法见进阶第 6 章 §6.6)
lax.stop_gradient(x)前向恒等,导数为 0
x + stop_gradient(round(x) - x)STE 的一行写法
jax.random.gumbel + softmax((logits+g)/τ)Gumbel-softmax
jax.debug_nans(True)出现 NaN 时立即抛出 FloatingPointError

附录:练习参考答案

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

课程练习 1

题目见正文「课程练习 1:比较朴素梯度与自定义梯度」。

# 参考答案:课程练习 1
xs = jnp.linspace(-20.0, 20.0, 41)
g_naive = jax.vmap(jax.grad(softplus_naive))(xs)
g_custom = jax.vmap(jax.grad(softplus_custom))(xs)
print("最大差:", float(jnp.max(jnp.abs(g_naive - g_custom))))
assert jnp.allclose(g_naive, g_custom, atol=1e-6)
最大差: 5.960464477539063e-08

课程练习 2

题目见正文「课程练习 2:residuals 的取舍 —— 保存还是重算」。

# 参考答案:课程练习 2
@jax.custom_vjp
def sin2_save(x):
    return jnp.sum(jnp.sin(x) ** 2)
sin2_save.defvjp(lambda x: (jnp.sum(jnp.sin(x) ** 2), (jnp.sin(x), jnp.cos(x))),
                 lambda r, g: (g * 2 * r[0] * r[1],))

@jax.custom_vjp
def sin2_recompute(x):
    return jnp.sum(jnp.sin(x) ** 2)
sin2_recompute.defvjp(lambda x: (jnp.sum(jnp.sin(x) ** 2), x),
                      lambda x, g: (g * jnp.sin(2 * x),))       # 2 sin cos = sin 2x,在 bwd 中重算

xv = jnp.linspace(0.0, 3.0, 1000)
print("A(保存 sin、cos):"); print_saved_residuals(sin2_save, xv)
print("B(只保存 x)    :"); print_saved_residuals(sin2_recompute, xv)
assert jnp.allclose(jax.grad(sin2_save)(xv), jax.grad(sin2_recompute)(xv), atol=1e-6)
assert jnp.allclose(jax.grad(sin2_recompute)(xv), jnp.sin(2 * xv), atol=1e-6)
A(保存 sin、cos):
f32[1000] output of sin from /tmp/ipykernel_89873/2868799737.py:5:55 (<lambda>)
f32[1000] output of cos from /tmp/ipykernel_89873/2868799737.py:5:67 (<lambda>)
B(只保存 x)    :
f32[1000] from the argument x

课程练习 3

题目见正文「课程练习 3:生产环境中的 softplus」。

# 参考答案:课程练习 3
softplus_prod = softplus_stable      # 4.10 节的稳定实现已满足要求,下面在极端区间上验证
xs = jnp.array([-1000.0, -50.0, 0.0, 50.0, 1000.0])
y = jax.jit(jax.vmap(softplus_stable))(xs)
gy = jax.jit(jax.vmap(jax.grad(softplus_stable)))(xs)
print(y, gy)
assert jnp.all(jnp.isfinite(y)) and jnp.all(jnp.isfinite(gy))
assert jnp.allclose(y, np.logaddexp(np.asarray(xs, np.float64), 0.0))
[0.0000000e+00 1.9287499e-22 6.9314718e-01 5.0000000e+01 1.0000000e+03] [0.0000000e+00 1.9287499e-22 5.0000000e-01 1.0000000e+00 1.0000000e+00]

补充练习 A

题目见正文「补充练习 A:pytree 的全局范数梯度裁剪」。

# 参考答案:补充练习 A
@partial(jax.custom_vjp, nondiff_argnums=(1,))
def clip_grad_global_norm(params, max_norm):
    return params

def _cgn_fwd(params, max_norm):
    return params, None

def _cgn_bwd(max_norm, _, g):
    norm = jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(g)))
    scale = jnp.minimum(1.0, max_norm / (norm + 1e-12))
    return (jax.tree.map(lambda l: l * scale, g),)

clip_grad_global_norm.defvjp(_cgn_fwd, _cgn_bwd)

def loss_p(p, x):
    return jnp.sum((x @ p["w"] + p["b"]) ** 2)

p = {"w": jnp.ones((3, 2)), "b": jnp.array([0.5, -0.5])}
xb = jnp.arange(6.0).reshape(2, 3)
g_raw = jax.grad(loss_p)(p, xb)
g_clip = jax.grad(lambda p, x: loss_p(clip_grad_global_norm(p, 1.0), x))(p, xb)

gnorm = lambda t: float(jnp.sqrt(sum(jnp.sum(l ** 2) for l in jax.tree.leaves(t))))
print("原始范数:", gnorm(g_raw), " 裁剪后:", gnorm(g_clip))
assert abs(gnorm(g_clip) - 1.0) < 1e-5
assert all(jnp.allclose(a / gnorm(g_raw), b) for a, b in zip(jax.tree.leaves(g_raw), jax.tree.leaves(g_clip)))

# 与 optax.clip_by_global_norm 对拍
import optax
tx = optax.clip_by_global_norm(1.0)
g_optax, _ = tx.update(g_raw, tx.init(p))
assert all(jnp.allclose(a, b, atol=1e-6) for a, b in zip(jax.tree.leaves(g_optax), jax.tree.leaves(g_clip)))
原始范数: 260.7642517089844  裁剪后: 1.0000001192092896

补充练习 B

题目见正文「补充练习 B:五对角 Jacobian 的着色」。

# 参考答案:补充练习 B
def f_penta(x):
    pad = jnp.pad(x, 2)
    xm2, xm1, xp2 = pad[:-4], pad[1:-3], pad[4:]
    return x**3 + xm2 * xp2 - xm1

def colored_jacobian_banded(f, x, bandwidth):
    n, c = x.shape[0], 2 * bandwidth + 1
    j = jnp.arange(n)
    seeds = (j[None, :] % c == jnp.arange(c)[:, None]).astype(x.dtype)
    comp = jax.vmap(lambda s: jax.jvp(f, (x,), (s,))[1])(seeds)     # 只做 c 次 JVP
    ii, jj = j[:, None], j[None, :]
    return jnp.where(jnp.abs(ii - jj) <= bandwidth, comp[jj % c, ii], 0.0)

x50 = jnp.linspace(-1.0, 1.0, 50)
J5 = colored_jacobian_banded(f_penta, x50, 2)
assert jnp.allclose(J5, jax.jacfwd(f_penta)(x50))
# 同一函数对三对角着色(colors 不够)会出错:说明颜色数必须 >= 2b+1
J3_wrong = colored_jacobian_banded(f_penta, x50, 1)
assert not jnp.allclose(J3_wrong, jax.jacfwd(f_penta)(x50))
print("5 色着色正确;只用 3 色时解压结果错误(列之间发生冲突)")
5 色着色正确;只用 3 色时解压结果错误(列之间发生冲突)

补充练习 C

题目见正文「补充练习 C:ODE 伴随法(adjoint)求导」。

# 参考答案:补充练习 C
def ode_f(z, W):
    return jnp.tanh(W @ z)

def rk4_step(f, s, h, W):
    add = lambda s, k, c: jax.tree.map(lambda a, b: a + c * b, s, k)
    k1 = f(s, W)
    k2 = f(add(s, k1, h / 2), W)
    k3 = f(add(s, k2, h / 2), W)
    k4 = f(add(s, k3, h), W)
    return jax.tree.map(lambda s_, a, b, c, d: s_ + h / 6 * (a + 2 * b + 2 * c + d), s, k1, k2, k3, k4)

N_STEPS, T_END = 100, 1.0

def odeint_unrolled(z0, W):
    h = T_END / N_STEPS
    return lax.fori_loop(0, N_STEPS, lambda i, z: rk4_step(ode_f, z, h, W), z0)

@jax.custom_vjp
def odeint_adjoint(z0, W):
    return odeint_unrolled(z0, W)

def _ode_fwd(z0, W):
    zT = odeint_unrolled(z0, W)
    return zT, (zT, W)                         # 只保存终点,不保存轨迹

def _ode_bwd(res, g):
    zT, W = res
    def aug_dynamics(s, W):
        z, a, _ = s
        _, vjp = jax.vjp(ode_f, z, W)
        a_z, a_W = vjp(a)
        return (ode_f(z, W), -a_z, -a_W)        # d/dt (z, a, W̄)
    h = -T_END / N_STEPS                        # 反向积分
    s = (zT, g, jnp.zeros_like(W))
    _, a0, gW = lax.fori_loop(0, N_STEPS, lambda i, s: rk4_step(aug_dynamics, s, h, W), s)
    return a0, gW

odeint_adjoint.defvjp(_ode_fwd, _ode_bwd)

k1, k2 = jax.random.split(jax.random.key(1))
W_ode = jax.random.normal(k1, (4, 4))
z0_ode = jax.random.normal(k2, (4,))
L_unr = lambda z0, W: jnp.sum(odeint_unrolled(z0, W) ** 2)
L_adj = lambda z0, W: jnp.sum(odeint_adjoint(z0, W) ** 2)
gz_u, gW_u = jax.grad(L_unr, argnums=(0, 1))(z0_ode, W_ode)
gz_a, gW_a = jax.jit(jax.grad(L_adj, argnums=(0, 1)))(z0_ode, W_ode)
print("dL/dz0 最大差:", float(jnp.max(jnp.abs(gz_u - gz_a))), " dL/dW 最大差:", float(jnp.max(jnp.abs(gW_u - gW_a))))
assert jnp.allclose(gz_u, gz_a, atol=1e-4)
assert jnp.allclose(gW_u, gW_a, atol=1e-4)
dL/dz0 最大差: 2.86102294921875e-06  dL/dW 最大差: 7.152557373046875e-07

补充练习 D

题目见正文「补充练习 D:在 0 处安全的向量范数(custom_jvp)」。

# 参考答案:补充练习 D
@jax.custom_jvp
def safe_norm(x):
    return jnp.sqrt(jnp.sum(x ** 2))

@safe_norm.defjvp
def _safe_norm_jvp(primals, tangents):
    x, = primals
    t, = tangents
    n = safe_norm(x)
    safe_n = jnp.where(n == 0.0, 1.0, n)             # 避免 0/0
    return n, jnp.where(n == 0.0, 0.0, jnp.dot(x, t) / safe_n)

z3 = jnp.zeros(3)
print("jnp.linalg.norm 在 0 处的梯度:", jax.grad(jnp.linalg.norm)(z3))
print("safe_norm 在 0 处的梯度      :", jax.grad(safe_norm)(z3))
assert jnp.all(jnp.isnan(jax.grad(jnp.linalg.norm)(z3)))
assert jnp.all(jax.grad(safe_norm)(z3) == 0.0)
xv = jnp.array([3.0, -4.0, 12.0])
assert jnp.allclose(jax.grad(safe_norm)(xv), jax.grad(jnp.linalg.norm)(xv))
assert jnp.allclose(jax.jvp(safe_norm, (xv,), (jnp.ones(3),))[1], jnp.sum(xv) / 13.0)
check_grads(safe_norm, (xv,), order=1, modes=["fwd", "rev"])
# 二阶:float32 下二阶有限差分噪声较大(实测相对误差约 1%),改为与解析 Hessian 对拍
# ∇²||x|| = (I - x x^T / ||x||^2) / ||x||
H_ref = (np.eye(3) - np.outer(xv, xv) / 169.0) / 13.0
assert np.allclose(jax.hessian(safe_norm)(xv), H_ref, atol=1e-6)
print("safe_norm 通过 check_grads(fwd + rev),Hessian 与解析解一致")
jnp.linalg.norm 在 0 处的梯度: [nan nan nan]
safe_norm 在 0 处的梯度      : [0. 0. 0.]
safe_norm 通过 check_grads(fwd + rev),Hessian 与解析解一致