第 4 章 进阶自动微分
原课程:「JAX 高级编程:性能、优化与规模化」第 4 章 进阶自动微分方法 运行环境:JAX / jaxlib 0.12.0.1,仅 CPU,Python 3.14。本 notebook 里的结论都在这个环境下运行验证过。
学习目标
- 从「线性化 + 转置」的角度理解前向模式(JVP)和反向模式(VJP),并会用
jax.linearize、jax.linear_transpose把它们拆开来看。 - 掌握高阶导数的几种组合方式;了解 Taylor 模式(
jax.experimental.jet),知道什么是扰动混淆,以及 JAX 为什么不会出这个问题。 - 能按问题规模选
jacfwd还是jacrev,并会用vmap(jvp)加着色(coloring)的方法高效求出稀疏 Jacobian。 - 熟练使用
jax.custom_vjp(包括 residuals、nondiff_argnums、梯度裁剪、梯度反转、STE、隐函数定理)和jax.custom_jvp(包括defjvps、自动转置),并用check_grads验证。 - 弄清
cond、scan、fori_loop、while_loop各自支持哪种求导模式;知道可以用jax.checkpoint加scan节省反向传播的内存(系统讲解在进阶第 6 章 §6.6)。 - 掌握 argmax、round、sort、top_k 等不可微操作的梯度行为、
stop_gradient的用法,以及常见的平滑近似。 - 综合实践:用
custom_vjp实现隐式层(DEQ)和 ODE 伴随法求导,并和直接对展开版本求导的结果对拍。
小节目录
| 小节 | 原课程页面 |
|---|---|
| 4.1 前向和反向模式自动微分回顾 | review-autodiff-modes |
4.2 雅可比向量积 (JVPs) 与 jax.jvp | jacobian-vector-products-jvp |
4.3 向量-雅可比积 (VJPs) 与 jax.vjp | vector-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 |
| 动手练习 / 测验 / 本章更新说明 / 小结 / 速查表 | — |
阅读说明:示例代码按课程各页的结构整理,变量名、数值和代码结构尽量和课程保持一致,讲解用中文重新组织。代码的输出都是在本机运行得到的。过时或会出错的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法。
与入门课的关系(不重复已讲内容)
- 入门 03 自动微分grad 已经讲过
jvp/vjp的基本用法、jacfwd/jacrev怎么选和计时、NaN 梯度与「双 where」、custom_jvp入门、stop_gradient和有限差分检验。本章在这些内容上往深处走,相关的地方直接引用,不再展开。 - 入门 07 实战专题 已经讲过 HVP 的几种写法和性能对比,4.4 节只放课程里的示例代码,不再重复讨论。
vmap见 入门 04 自动向量化vmap,jit见 入门 02 JIT编译。jax.checkpoint(重计算)的系统讲解见进阶第 6 章:./06_大规模模型训练.ipynb。
⚠️ 环境注意: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())
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 规则。它是这么做的:
- 线性化(linearize):对 $f$ 做 JVP 并部分求值,把只依赖原始值(primal)的部分先算掉(这些就是 residuals),剩下一个关于切向量的线性函数 $v \mapsto J v$。
- 转置(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)
# 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)
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)
例 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], "...")
# ✅ 推荐写法:不扰动的参数传零切向量(或用闭包固定)
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 不等于零切向量
应用与优点,一共 4 点:
- 一次 JVP 的计算量通常是原函数求值的几倍(一般说法是 1.5–3 倍),和输出维度无关;
- 主要用来求方向导数,比如做敏感性分析;
- 取 $v=e_j$(独热向量)时,$J e_j$ 就是 Jacobian 的第 $j$ 列,重复 $n$ 次就能拼出完整的 Jacobian。实际中直接用
jax.jacfwd,它本质上就是vmap套在jvp上; - 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
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))
可以看到,对标量函数来说,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]))
为什么有时要直接用 jax.vjp,有 4 个理由:
- 有多个输出、或者要处理中间梯度时,直接用它更灵活,也更高效;
- 它是
jax.custom_vjp的基础(4.6 节); - 有些优化算法和 AD 研究需要直接拿到 VJP;
- 有助于理解反向模式和
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
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
Hessian-向量积(HVP):对 $f:\mathbb{R}^n\to\mathbb{R}$,grad(grad(f)) 没法直接用(grad(f) 的输出是向量,不能再 grad),而且通常也不需要完整的 Hessian,只需要 $Hv$。有两种写法:
- 前向套反向(forward-over-reverse):$\mathrm{jvp}(\nabla f, x, v) = J_{\nabla f}(x)\,v = H(x)\,v$;
- 反向套反向(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,)))
完整 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
🔍 补充:扰动混淆(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
🔍 补充: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}")
# 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)
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)
# 课程代码 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 一致")
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]]))
课程代码 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)
# ✅ 推荐写法
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)
🔍 补充:稀疏 / 结构化 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)
# 计时对比(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]
注意:如果把压缩结果再解压成 $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, "(旧写法,且本例根本不需要随机数)")
# ✅ 推荐写法:需要随机数时用 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))
例 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)
# ✅ 推荐写法:前向用 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
几点注意事项:
- 正确性:一定要和数值微分或 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
🔍 补充:签名细节、residuals 与 nondiff_argnums¶
🟢 必读
| 情形 | fwd 签名 | bwd 签名 | bwd 返回 |
|---|---|---|---|
| 普通 | fwd(*args) | bwd(res, g) | 长度为 len(args) 的元组 |
nondiff_argnums=(0,) | fwd(*args)(仍是完整参数) | bwd(nondiff0, res, g)(不可微参数排在最前面) | 只包含可微参数的元组 |
几个要点:
fwd的输出必须和f的输出结构相同;residuals可以是任意 pytree(元组、字典、None)。nondiff_argnums是给非数组参数用的,比如 Python 函数、整数超参数或 dtype。这些值不能是 tracer:如果在jit里把一个被追踪的数组传到nondiff_argnums的位置,会抛UnexpectedTracerError。数组形式的「不求导」参数,应该当普通参数传进去,在bwd里返回None或零。- 默认(
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) # 前向恒等
# 梯度反转层(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
Straight-through estimator(STE)是另一种典型的「人为指定梯度」。它放在 4.9 节讲,那里会同时给出 custom_vjp 和 stop_gradient 两种写法。
🔍 补充:隐函数定理 —— 对不动点迭代求导¶
⚪ 选读:要对不动点迭代、优化层或隐式层(DEQ)求导时
很多量是作为迭代求解的结果来定义的,比如不动点 $x^\star = T(a, x^\star)$、优化问题的最优解、方程 $F(a,x)=0$ 的根。直接对迭代过程求导有三个问题:
- 要保存每一步的中间值,内存是 $O(\text{迭代次数})$;
- 梯度准不准,取决于迭代有没有充分收敛;
- 用
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)
# 例 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
🔍 补充:用 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
4.7 jax.custom_jvp 自定义求导规则¶
jax.grad/jax.vjp 是反向模式。有时我们想直接控制前向模式的规则,也就是给定切向量,它怎么传到输出。自定义 JVP 的四种典型理由:
- 数值稳定性:
sqrt、log在 0 附近; - 性能;
- 不可微点上的约定:比如
jnp.clip的边界; - 外部代码:比如
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,)))
# ✅ 推荐写法:前向用真 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
与 custom_vjp 的关系:
⚠️ 注意:容易以为「只用
jvp/jacfwd时定义custom_jvp可能就够了,需要jax.grad时通常要定义custom_vjp;JAX 有时能从 JVP 自动转置出 VJP,但不一定可靠,两者都定义最稳妥」。实际情况是:
- 只要 JVP 规则的输出关于切向量是线性的(正确的 JVP 规则一定满足这一点),而且切向量这一侧只用 JAX 自带的可转置原语来算(不把切向量交给
pure_callback之类的黑盒),custom_jvp函数就能被grad/vjp/jacrev使用。JAX 通过 4.1 节的「线性化 + 转置」自动得到反向规则,不是「有时能」(反例见 4.7 节补充「JVP 规则必须关于切向量线性」)。- 两者不能同时定义在同一个函数上:
custom_jvp对象根本没有defvjp方法。一个函数只能二选一。- 反过来,
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
🔍 补充:数值稳定的 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 检查通过")
🔍 补充: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
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
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")
# ✅ 推荐写法
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
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)
🔍 补充:各控制流原语的可微性一览(实测)¶
🟢 必读
| 原语 | 前向模式 (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 规则。有些运算在数学上不可微,有些作用在整数上;有时我们还会出于建模或性能的考虑,主动把梯度切断。
求导可能失败的原因,可以分成三类:
- 数学上不连续:取整(
round、floor、ceil);sign在 0 点;比较运算产生的布尔值被用在cond或算术里;argmax/argmin返回整数索引,输入稍微一变,索引就可能跳。 - 整数运算:类型转换、按整数值索引,这些没有通常意义上的导数。
- 原语没有实现求导规则:对它求导会报错。
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"}
🔍 补充:常见「不可微」操作的实测梯度行为¶
🟢 必读
| 操作 | 梯度行为(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
用 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 种情况:
- 数学上必须这么做(比如 round);
- 建模假设(比如强化学习里的目标网络);
- 截断展开循环里的梯度回溯(截断 BPTT);
- 性能:被
stop_gradient截断的支路在求导时被当成常量,JAX 根本不会为它生成反向计算(而不是算出来再乘 0); - 数值稳定性:把会产生 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
不可微运算的替代办法:
- 平滑近似:比如用 $\sigma(kx)$ 近似阶跃函数,$k$ 越大越接近;
- 自定义梯度,尤其是直通估计器(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 的 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)
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 里。三个组成部分:
- 用
@jax.custom_vjp装饰原函数; fwd:参数和原函数相同,返回(output, residuals);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))
# ❌ 旧写法(过时/错误):前向仍为朴素实现;用 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)
# ✅ 推荐写法:稳定前向(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"])
验证与分析:在正常范围内,自定义梯度和自动梯度一致(在浮点精度内);反向显式用了 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))
🔍 综合实践:用 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)
# 用 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)
# ✍️ 作答区:课程练习 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 自检通过")
课程练习 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 自检通过")
课程练习 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 自检通过")
补充练习 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 自检通过")
补充练习 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 自检通过")
补充练习 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 自检通过")
补充练习 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。要求:
grad在 0 处为全零,在非零点和jnp.linalg.norm一致;- 前向模式(
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 自检通过")
本章测验¶
以下 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 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 4.2 | PyTree 例子的前向输出 inputs @ w + b 是 [70.1, 99.9](不是 100.9) | 4.2 例 2 ✅ 单元 |
| 2 | 4.2 | stop_gradient(inputs) 的值是 inputs 本身,不能当零切向量(会让切向量输出变成 [100,130]);传 None 报 TypeError;不扰动应传 jnp.zeros_like(inputs) 或用闭包 | 4.2 ❌/✅ 单元 |
| 3 | 4.4 | grad 套 grad 的 HVP 写法是反向套反向,不是「反向-正向叠加」 | 4.4 HVP 讲解 |
| 4 | 4.4/4.5 | jax.hessian(f) 在源码里固定是 jacfwd(jacrev(f)) | 4.5 Hessian 讲解 |
| 5 | 4.5 | jax.jvp(grad_f, (x_in,), (v_)) 中 v_ 未定义会报 NameError,切向量要写成元组 (v,) | 4.5 ❌/✅ 单元 |
| 6 | 4.6 | 前向用 log1p(exp(x)) 的 stable_log1pexp 只有梯度稳定,$x=100$ 时前向为 inf;稳定前向用 logaddexp(x,0) | 4.6 例 2 |
| 7 | 4.6 | 不稳定梯度默认不抛异常,悄悄返回 nan,try/except FloatingPointError 捕获不到;只有在 jax.debug_nans(True) 下才会抛出 | 4.6 例 2 |
| 8 | 4.6 | 用不到随机数时不必创建 key;需要时用 jax.random.key,而不是旧式的 jax.random.PRNGKey | 4.6 例 1 ❌/✅ 单元 |
| 9 | 4.6 | 不可微的数组参数可以在 bwd 中返回 None(视为 0);非数组参数要用 nondiff_argnums(bwd 签名变为 bwd(*nondiff, res, g),且这些参数不能是 tracer) | 4.6 补充 |
| 10 | 4.7 | custom_jvp 的 safe_x 技巧只能用在导数公式里;前向也用 safe_x 会让 stable_sqrt(0) 返回 1.0,前向应直接用 jnp.sqrt(x) | 4.7 ❌/✅ 单元 |
| 11 | 4.7 | 对切向量线性、且切向量一侧只用 JAX 原语的 JVP 规则都能自动转置得到 VJP;custom_jvp 和 custom_vjp 不能同时定义(custom_jvp 没有 defvjp);custom_vjp 不支持前向模式 | 4.7 custom_vjp 关系讲解 |
| 12 | 4.8 | while_loop 只支持前向模式,grad 会报 ValueError,加 checkpoint 也不行;改用静态 fori_loop/scan 或隐函数定理 | 4.8 ❌/✅ 单元 |
| 13 | 4.8 | scan RNN 例子中对 $h_0$ 求导得到的是初始状态的梯度,不是参数梯度,函数命名为 grad_rnn_h0 | 4.8 scan 例子 |
| 14 | 4.9 | 没有求导规则时报错类型视情况而定:TypeError(整数输入/输出)、ValueError(pure_callback、while_loop)、NotImplementedError(缺少转置规则) | 4.9 求导失败原因讲解 |
| 15 | 4.10 | 朴素 softplus 在 $x=100$ 时前向 inf、梯度 nan;$x=88$ 时梯度因非规格化数被刷零而悄悄变为 0.0,XLA 不会自动改写成稳定形式 | 4.10 开头讲解 |
| 16 | 4.10 | 朴素 softplus 溢出时不会抛 OverflowError(except 分支是死代码),实际结果是 nan | 4.10 ❌/✅ 单元 |
| 17 | 4.10 | 不在梯度路径上的输出,bwd 收到的 g 默认是全零数组而不是 None;只有 symbolic_zeros=True 时才是符号零 | 4.10 考量讲解 |
| 18 | 4.10 | softplus_custom 前向仍是朴素实现;完整实现要用稳定前向(logaddexp)加 custom_vjp,并用 check_grads 验证 | 4.10 ✅ 单元 |
本章小结¶
- 两种模式的本质:JVP 计算 $Jv$,开销与输出维度无关;VJP 计算 $u^\top J$,开销与输入维度无关。在 JAX 中,VJP = 线性化 + 转置(
linearize+linear_transpose),每个原语只需要提供 JVP 规则,线性原语再提供转置规则。 - 高阶导数:变换可以任意嵌套(HVP 优先用前向套反向);JAX 用带层级的 trace 避免扰动混淆;
jax.experimental.jet的 Taylor 模式在 0.12 中仍可用,一次传播就能得到多阶导数,但不支持控制流和custom_jvp。 - 完整矩阵:
jacfwd/jacrev本质上都是vmap套在 JVP/VJP 上;hessian = jacfwd(jacrev);稀疏 Jacobian 用着色法,把 JVP 次数从 $n$ 降到颜色数。 custom_vjp:fwd返回(out, res),bwd(res, g)返回与参数等长的元组;非数组参数用nondiff_argnums(不能是 tracer)。典型用途有梯度裁剪、梯度反转、STE、隐函数定理(不动点、DEQ、ODE 伴随)。不支持前向模式。custom_jvp:用defjvp或defjvps;只要规则对切向量是线性的(且切向量一侧只用 JAX 原语),就会自动获得反向模式;不能与defvjp同时用在同一个函数上。能用它就优先用它。- 控制流:
cond、scan、静态fori_loop都可以双向求导;while_loop只支持前向模式;checkpoint+scan可以把反向传播的内存从「每步的中间量」降到「每步的 carry」(定量对比见进阶第 6 章 §6.6)。 - 不可微操作:
round/floor/sign/argmax的梯度为 0(对整数输出求grad会报错);sort/top_k/max按置换或选择回传梯度;stop_gradient是「前向恒等、导数为零」;可以用平滑近似(sigmoid、soft-argmax、Gumbel-softmax)或 STE 替代。 - 验证:所有自定义规则都要用
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)
课程练习 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)
课程练习 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))
补充练习 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)))
补充练习 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 色时解压结果错误(列之间发生冲突)")
补充练习 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)
补充练习 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 与解析解一致")