第 3 章 用 grad 做自动微分
《JAX 入门》(ApX Machine Learning)第 3 章个人学习笔记 · 运行环境:JAX 0.12(CPU) 原课程:https://apxml.com/zh/courses/getting-started-with-jax/chapter-3-automatic-differentiation-grad
训练机器学习模型,几乎总要计算损失函数对参数的梯度。自动微分(Automatic Differentiation, AD)是计算梯度最高效、最精确的方法。本章介绍 JAX 的核心变换 jax.grad:它能从一段处理数值输入的普通 Python 函数,直接得到计算梯度的函数。
学习目标
- 用
jax.grad计算标量函数的梯度; - 理解
grad背后的反向模式自动微分; - 用
argnums控制对哪些参数求导; - 组合
grad计算高阶导数; - 用
jax.value_and_grad同时得到函数值和梯度; - 理解控制流与求导的关系,以及
grad的局限。
小节目录
| 小节 | 原课程链接 |
|---|---|
| 3.1 理解梯度 | understanding-gradients |
3.2 介绍 jax.grad | introducing-jax-grad |
| 3.3 自动微分的工作方式:反向模式 | how-autodiff-works |
| 3.4 关于参数求导 | grad-respect-to-arguments |
3.5 高阶导数(grad 的 grad) | grad-higher-order-derivatives |
3.6 值和梯度(jax.value_and_grad) | jax-value-and-grad |
| 3.7 求导与控制流 | grad-control-flow |
| 3.8 局限性与注意事项 | grad-limitations |
| 3.9 动手实践:计算梯度 | hands-on-computing-gradients |
| 第 3 章测验 | quiz |
| 本章更新说明 | 见章末 |
阅读说明:代码示例按课程页面的内容整理,都在 JAX 0.12 下实际运行过,单元下方的输出就是运行结果。
- 正文统一用 JAX 0.12 的推荐写法。容易踩坑的地方用
> ⚠️ **注意**标出;涉及代码时,过时或错误的写法放在 ❌ 单元里,后面紧跟 ✅ 推荐写法,结论用assert验证。- 标有「🔍 补充」的单元是扩展内容。
- 这些注意事项在章末的 本章更新说明 里有汇总。
讲解文字按课程要点用中文重新组织;代码示例尽量沿用课程里的变量名和数值,并且都实际运行过。
import jax
import jax.numpy as jnp
import numpy as np
print("JAX 版本:", jax.__version__)
print("默认后端:", jax.default_backend())
3.1 理解梯度¶
什么是梯度¶
先看单变量函数 $f(x)$。它在某一点的导数,就是曲线在这一点的切线斜率,表示 $x$ 稍微变一点时,$f$ 变得有多快。
例如 $f(x)=x^2$,$f'(x)=2x$。在 $x=2$ 处,切线斜率是 $4$,这就是这一点的导数(梯度)。
对多个输入、一个标量输出的函数 $f(x_1,\dots,x_n)$,把对每个输入的偏导数排成一个向量,就是梯度:
$$ \nabla f(x_1,\dots,x_n)=\left[\frac{\partial f}{\partial x_1},\ \frac{\partial f}{\partial x_2},\ \dots,\ \frac{\partial f}{\partial x_n}\right] $$
梯度是一个向量。它的方向是函数在这一点增长最快的方向,长度表示增长有多陡。
例子:$f(x,y)=x^2+\sin(y)$
$$ \frac{\partial f}{\partial x}=2x,\qquad \frac{\partial f}{\partial y}=\cos(y),\qquad \nabla f(x,y)=[2x,\ \cos y],\qquad \nabla f(1,0)=[2,\ 1] $$
梯度为什么对优化重要¶
既然梯度指向增长最快的方向,负梯度就指向下降最快的方向。训练模型就是要让损失函数尽量小,梯度下降用的正是这一点:
- 从一组初始参数出发;
- 计算损失在当前参数处的梯度;
- 沿负梯度方向走一小步;
- 重复第 2–3 步,直到收敛。
更新公式:
$$ \theta_{\text{新}} = \theta_{\text{旧}} - \eta\,\nabla f(\theta_{\text{旧}}) $$
其中 $\eta$ 是学习率,一个控制步长的小正数。
为什么需要自动微分¶
深度网络动辄几百万个参数,手推梯度不现实,也容易出错。让计算机求导,主要有三种办法:
| 方法 | 做法 | 缺点 |
|---|---|---|
| 数值微分 | 用 $\dfrac{f(x+h)-f(x)}{h}$($h$ 很小)近似 | 有截断误差和舍入误差;每个输入维度都要额外求值一次,$n$ 维要 $O(n)$ 次 |
| 符号微分 | 像 SymPy 那样对表达式做符号推导 | 表达式可能急剧膨胀,也很难处理带循环、分支的一般程序 |
| 自动微分 | 在基本运算这一层系统地套用链式法则 | 没有上面的问题:精确(到机器精度)又高效 |
JAX 的 grad 使用的是反向模式自动微分,也就是机器学习中所说的反向传播。它特别适合「输入很多、输出是一个标量(损失)」的函数。
# 用 JAX 验证上面的两个例子
# 1) f(x) = x^2 在 x=2 处的导数(切线斜率)应为 4
print("d/dx x^2 在 x=2:", jax.grad(lambda x: x**2)(2.0))
# 2) f(x, y) = x^2 + sin(y) 在 (1, 0) 处的梯度应为 [2, 1]
def f_xy(v):
x, y = v
return x**2 + jnp.sin(y)
print("∇f(1, 0) =", jax.grad(f_xy)(jnp.array([1.0, 0.0])))
🔍 补充:数值微分到底差在哪¶
⚪ 选读:想知道为什么不用有限差分训练、或要自己写梯度检验时看。
数值微分的误差其实来自两方面。$h$ 太大,会有截断误差($O(h)$);$h$ 太小,浮点相减又会带来舍入误差(约 $\varepsilon/h$)。JAX 默认用 float32(机器精度 $\varepsilon\approx 1.2\times10^{-7}$),这个问题更明显。下面对 $\sin$ 在 $x=1$ 处求导,比较前向差分、中心差分和 jax.grad:
x0 = 1.0
exact = np.cos(x0) # 真值(用 float64 计算)
print(f"{'h':>8} | {'前向差分误差':>14} | {'中心差分误差':>14}")
for h in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6]:
x = jnp.float32(x0)
fwd = (jnp.sin(x + h) - jnp.sin(x)) / h
cen = (jnp.sin(x + h) - jnp.sin(x - h)) / (2 * h)
print(f"{h:>8.0e} | {abs(float(fwd) - exact):>14.2e} | {abs(float(cen) - exact):>14.2e}")
print("\njax.grad 误差:", abs(float(jax.grad(jnp.sin)(jnp.float32(x0))) - exact))
可以看到,在 float32 下,差分误差先随着 $h$ 变小而下降,之后又因为舍入误差反弹回去,最好也只能到 $10^{-4}\sim10^{-5}$ 量级。自动微分的误差则在机器精度量级。所以数值微分只适合用来检验梯度(见 3.8 的 check_grads),不适合拿来训练。
3.2 介绍 jax.grad¶
jax.grad 本身是一个函数变换。你给它一个返回标量的 Python 数值函数,它还你一个新函数,这个新函数负责计算原函数的梯度。
以 $f(x)=x^3$ 为例,导数 $f'(x)=3x^2$,所以 $f'(2.0)=3\times 2.0^2=12.0$。先定义并测试原函数:
import jax
import jax.numpy as jnp
def f(x):
"""计算 x 的三次方。"""
return x**3
# 测试原函数
print(f"f(2.0) = {f(2.0)}")
用 jax.grad 得到梯度函数。注意 grad_f 和 f 一样都是普通的 Python 可调用对象:
# 使用 jax.grad 获得梯度函数
grad_f = jax.grad(f)
print(f"Type of f: {type(f)}")
print(f"Type of grad_f: {type(grad_f)}")
# 计算函数 f 在 x=2.0 处的梯度
gradient_at_2 = grad_f(2.0)
print(f"Gradient of f at x=2.0: {gradient_at_2}") # 手算: 3 * 2.0**2 = 12.0
要点
- 输入:一个返回单个标量的 Python 函数;
jax.grad返回一个新函数;- 新函数接受与原函数相同的参数,返回输出对第一个位置参数的梯度。
多参数函数:$g(x,y)=x^2 y$。jax.grad(g) 默认只对第一个参数 $x$ 求导:$\dfrac{\partial g}{\partial x}=2xy$,在 $(2,3)$ 处是 $12$。对其他参数求导的方法见 3.4。
def g(x, y):
"""计算 x 的平方乘以 y。"""
return (x**2) * y
# 获得函数 g 的梯度函数
grad_g = jax.grad(g)
# 计算在 (x=2.0, y=3.0) 处的梯度
# 默认情况下,这是相对于第一个参数 (x) 的梯度
gradient_g_wrt_x = grad_g(2.0, 3.0)
print(f"g(2.0, 3.0) = {g(2.0, 3.0)}")
print(f"Gradient of g w.r.t x at (2.0, 3.0): {gradient_g_wrt_x}")
对函数的要求
- 被求导的函数必须返回单个标量。返回数组、元组这类非标量会报
TypeError。多输出函数要用jax.jacrev/jax.jacfwd(jax.jacobian是jax.jacrev的别名),或者更底层的jax.vjp/jax.jvp。它们和grad一样,都建在jax.vjp(反向)或jax.jvp(前向)之上,详见 3.3。 - 求导的输入必须是浮点(或复数)类型。传入整数会直接报
TypeError,JAX 不会自动帮你转成浮点。确实需要整数参数(索引、步数等)时,把它放在不求导的位置上,也就是不写进argnums。如果一定要把它列进argnums,可以加allow_int=True,这时它的梯度是float0类型的零。
⚠️ 注意:容易以为
jax.jacobian这类多输出工具是建在jax.grad之上的,其实不是。jax.jacobian的源码就是直接调用jacrev(文档写明 Alias of jax.jacrev),而jacrev和grad都基于jax.vjp实现。⚠️ 注意:用整数做输入不只是「不推荐」,而是会直接抛出
TypeError: grad requires real- or complex-valued inputs。
# 验证:整数输入报错;allow_int 的行为;jacobian 即 jacrev
try:
jax.grad(f)(2)
int_input_raised = False
except TypeError as e:
int_input_raised = True
print("整数输入报错:", str(e).splitlines()[0])
assert int_input_raised, "整数输入应当报 TypeError"
assert jax.grad(f)(2.0) == 12.0
def scale(x, n):
return x * n
# allow_int=True:整数参数可以出现在 argnums 中,梯度为 float0 类型的零
gx, gn = jax.grad(scale, argnums=(0, 1), allow_int=True)(2.0, 3)
print("allow_int=True ->", gx, "| n 的梯度是 float0 类型:", gn.dtype == jax.dtypes.float0)
assert gn.dtype == jax.dtypes.float0
# jax.jacobian 就是 jax.jacrev
import inspect
print("jax.jacobian 的文档:", jax.jacobian.__doc__.strip())
assert "jacrev(fun" in inspect.getsource(jax.jacobian)
F_demo = lambda x: jnp.stack([x[0] * x[1], jnp.sin(x[1])])
x_demo = jnp.array([1.0, 2.0])
assert jnp.array_equal(jax.jacobian(F_demo)(x_demo), jax.jacrev(F_demo)(x_demo))
print("断言通过:jacobian 与 jacrev 结果完全相同")
再看一个例子:$h(x)=\sin x$,$h'(x)=\cos x$,在 $\pi/2$ 处梯度的数学值是 $\cos(\pi/2)=0$。
⚠️ 注意:
grad_h(jnp.pi / 2.0)打印出来并不是0.0,而是约-4.37e-08(默认 float32)。原因是 $\pi/2$ 转成 float32 时有舍入,cos在舍入后的这个点上并不精确等于 0。比较浮点结果要用jnp.allclose,不要用==。
# ❌ 错误预期:以为 cos(pi/2) 的梯度会精确打印为 0.0
def h(x):
"""计算 sin(x)。"""
return jnp.sin(x)
grad_h = jax.grad(h)
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)
print(f"h(pi/2) = {h(jnp.pi / 2.0)}")
print(f"Gradient of h at x=pi/2: {gradient_h_at_pi_half}") # 数学值是 0.0
print("与 0.0 精确相等?", bool(gradient_h_at_pi_half == 0.0))
# ✅ 推荐写法:浮点结果用 allclose 比较
gradient_h_at_pi_half = grad_h(jnp.pi / 2.0)
assert gradient_h_at_pi_half != 0.0 # 确实不是精确的 0
assert jnp.allclose(gradient_h_at_pi_half, 0.0, atol=1e-6) # 但在 float32 精度内等于 0
print("断言通过:grad_h(pi/2) ≈ 0(atol=1e-6)")
3.3 自动微分的工作方式:反向模式¶
自动微分(AD)是 jax.grad 背后的技术。它把程序拆成一个个基本运算,逐个套用链式法则,得到精确的导数。AD 分前向模式和反向模式两种。jax.grad 用的是反向模式,在机器学习里通常叫反向传播。
计算图¶
把计算看成一串基本运算,可以画成由节点(运算)和有向边(数据流)组成的计算图。以 $f(x)=\sin(x^2)$ 为例,拆成两步:
$$ a = x^2,\qquad y=\sin(a) $$
x ──[平方]──► a ──[sin]──► y
前向传播:计算值¶
取 $x=2.0$:
$$ a = 2.0^2 = 4.0,\qquad y=\sin(4.0)\approx -0.757 $$
在前向阶段,AD 系统会把中间值(这里是 $x$ 和 $a$)和图的结构存下来,留给反向阶段用。
反向传播:用链式法则计算梯度¶
记 $\bar v = \dfrac{\partial y}{\partial v}$(输出 $y$ 对变量 $v$ 的「敏感度」,也叫伴随量 adjoint)。从输出往回走:
- 初始化:输出对自身的敏感度 $\bar y=\dfrac{dy}{dy}=1$。
- 经过 sin 节点:$\bar a=\bar y\cdot\dfrac{dy}{da}=\bar y\cos(a)=\cos(4.0)\approx-0.654$。
- 经过平方节点:$\bar x=\bar a\cdot\dfrac{da}{dx}=\bar a\cdot 2x\approx-0.654\times4.0\approx-2.615$。
$\bar x$ 就是我们要的 $\dfrac{dy}{dx}$。说白了,反向传播就是从输出出发,一步步套用链式法则,依次求出输出对每个中间变量、最后对每个输入的敏感度。
前向(算值): x=2.0 ──► a=4.0 ──► y≈-0.757 (保存 x、a)
反向(算梯度): x̄≈-2.615 ◄── ā≈-0.654 ◄── ȳ=1
为什么机器学习用反向模式¶
损失函数 $L=f(W_1,\dots,W_n,x,y)$ 通常有数百万个输入(参数),却只有一个标量输出。
- 反向模式:一次反向传播就能拿到对所有输入的梯度,成本只是原函数计算的一个小常数倍(通常说是 2–4 倍),和输入个数基本无关。
- 前向模式:每次只能沿一个输入方向求导。要拿到完整梯度,次数和输入个数成正比,对大模型来说太贵了。
JAX 在幕后做了什么¶
- 追踪(tracing):用「追踪器」(tracer)对象代替输入,把函数执行一遍,记下运算序列(计算图)。单独用
grad时,追踪器带着具体数值,前向值当场就算出来,同时记下求导要用的运算。放进jit之后,追踪器才是只带形状和 dtype 的抽象值,记下来的是中间表示 jaxpr(两者的区别见 3.7)。 - 前向传播:执行这些运算,算出求梯度要用的中间值(用
value_and_grad时还会顺便拿到函数值)。 - 反向传播:根据记下的图,自动生成并执行反向计算,得到对指定输入的梯度。
你只需要用类似 NumPy 的语法写前向计算,梯度怎么推、怎么算,都交给 JAX。
⚠️ 注意:单独用
grad时,追踪器并不是抽象的。它打印出来是GradTracer(primal=2.0, ...),带着具体数值。只带形状和 dtype 的抽象追踪器是jit的行为(3.7 的单元用assert验证了这一点)。
# 用 JAX 验证上面手算的结果
def f_sin_sq(x):
a = x**2 # 平方节点
y = jnp.sin(a) # sin 节点
return y
x = 2.0
print("y =", f_sin_sq(x))
print("ā = cos(4.0) ≈", jnp.cos(4.0))
print("x̄ = ā * 2x ≈", jnp.cos(4.0) * 2 * x)
print("jax.grad(f)(2.0) =", jax.grad(f_sin_sq)(x))
assert jnp.allclose(jax.grad(f_sin_sq)(x), jnp.cos(4.0) * 2 * x)
🔍 补充:亲眼看看 jaxpr,以及 jvp/vjp 与两种模式的关系¶
🟢 必读
(1) 看看 grad 生成了什么。 jax.make_jaxpr 可以打印追踪得到的 jaxpr。对比原函数和梯度函数的 jaxpr,能看到反向计算里出现了 cos(sin 的导数)和乘以 2x 的运算:
print("原函数的 jaxpr:")
print(jax.make_jaxpr(f_sin_sq)(2.0))
print("梯度函数的 jaxpr:")
print(jax.make_jaxpr(jax.grad(f_sin_sq))(2.0))
(2) 前向模式 = jax.jvp,反向模式 = jax.vjp。 上面讲的两种模式,在 JAX 里各对应一个底层 API。设 $f:\mathbb R^n\to\mathbb R^m$,雅可比矩阵 $J\in\mathbb R^{m\times n}$:
| API | 模式 | 计算 | 一次调用得到 |
|---|---|---|---|
jax.jvp(f, (x,), (v,)) | 前向 | $J v$(Jacobian-vector product),$v\in\mathbb R^n$ | 雅可比的一列(输入方向 $v$ 上的方向导数) |
jax.vjp(f, x) → vjp_fn(u) | 反向 | $u^\top J$(vector-Jacobian product),$u\in\mathbb R^m$ | 雅可比的一行 |
jax.grad(f)(x)本质上是vjp_fn(1.0)($m=1$ 时 $J$ 只有一行,一次 VJP 就得到整个梯度)。jax.jvp同时返回函数值和方向导数。前向模式不用保存中间值,内存开销小。jax.vjp返回「函数值 + 一个可以多次调用的vjp_fn」,前向阶段存下的中间值就放在vjp_fn里。
def F(x):
# F: R^3 -> R^2
return jnp.array([x[0] * x[1], jnp.sin(x[2]) + x[0]])
x = jnp.array([1.0, 2.0, 3.0])
# 前向模式:J @ v,v 取第 1 个基向量 -> 得到雅可比第 1 列
y, Jv = jax.jvp(F, (x,), (jnp.array([1.0, 0.0, 0.0]),))
print("F(x) =", y, "| J 的第 1 列 =", Jv)
# 反向模式:u^T @ J,u 取第 1 个基向量 -> 得到雅可比第 1 行
y, vjp_fn = jax.vjp(F, x)
(uJ,) = vjp_fn(jnp.array([1.0, 0.0]))
print("J 的第 1 行 =", uJ)
print("完整雅可比 J =\n", jax.jacrev(F)(x))
# grad 就是输出维度为 1 时的 vjp
def loss(x):
return jnp.sum(x**2)
_, vjp_loss = jax.vjp(loss, x)
print("\nvjp_fn(1.0) =", vjp_loss(1.0)[0], "| grad =", jax.grad(loss)(x))
J_full = jax.jacrev(F)(x)
assert jnp.allclose(Jv, J_full[:, 0]) and jnp.allclose(uJ, J_full[0, :]) # jvp 得列,vjp 得行
assert jnp.allclose(vjp_loss(1.0)[0], jax.grad(loss)(x)) # grad 就是 vjp(1.0)
🔍 补充:jacfwd 与 jacrev 怎么选(决策规则 + 计时验证)¶
⚪ 选读:需要完整雅可比或 Hessian、关心计算效率时看。
计算 $f:\mathbb R^n\to\mathbb R^m$ 的完整雅可比 $J\in\mathbb R^{m\times n}$ 时:
jacfwd用前向模式逐列计算,需要 $n$ 次 JVP(JAX 用vmap一次批量完成);jacrev用反向模式逐行计算,需要 $m$ 次 VJP,并且要保存前向中间值,内存开销更大。
两者算出来的结果一样,差别只在效率。由此可以得到下面的选择规则:
| 场景 | 选择 | 理由 |
|---|---|---|
| 输入维度 $n$ > 输出维度 $m$(「矮胖」雅可比) | jax.jacrev | 只需 $m$ 次 VJP |
| 输入维度 $n$ < 输出维度 $m$(「高瘦」雅可比) | jax.jacfwd | 只需 $n$ 次 JVP |
| 标量输出($m=1$,如损失函数) | jax.grad | jacrev 的特例,一次 VJP |
| $n\approx m$ | 两个都行,偏向 jacfwd | 前向模式不保存中间值,更省内存 |
| Hessian(标量函数的二阶导) | jax.hessian = jacfwd(jacrev(f)) | 内层梯度是 $n\to1$ 用反向;外层是 $n\to n$ 的雅可比,用前向更省内存 |
| 只需要 $Jv$ 或 $u^\top J$,不需要整个矩阵 | jax.jvp / jax.vjp | 不构造 $J$,开销与一次前向/反向相当 |
下面在 CPU 上计时验证一下(具体耗时因机器而异,看相对大小就行)。
注意:这里把权重矩阵作为参数传给被
jit的函数,而不是靠闭包捕获。这是 JAX 推荐的做法(闭包捕获的数组会作为常量嵌进编译结果)。这样也能避开本环境 CPU 后端的一个已知问题:「闭包捕获大数组 + matmul + 归约」会悄悄算错。详见 00 · 本环境已知问题(根因见 进阶 02 §2.3)。
import time
def bench(fn, *args, n=20):
"""返回 fn(*args) 的平均耗时(毫秒);先运行一次触发编译。"""
jax.block_until_ready(fn(*args))
t0 = time.perf_counter()
for _ in range(n):
jax.block_until_ready(fn(*args))
return (time.perf_counter() - t0) / n * 1e3
def layer(W, x):
# 权重 W 作为参数传入;只对 x(argnums=1)求雅可比
return jnp.tanh(W @ x)
W_wide = jax.random.normal(jax.random.key(0), (4, 2000)) # R^2000 -> R^4
W_tall = jax.random.normal(jax.random.key(1), (2000, 4)) # R^4 -> R^2000
jac_fwd = jax.jit(jax.jacfwd(layer, argnums=1))
jac_rev = jax.jit(jax.jacrev(layer, argnums=1))
results = {}
for name, W, x in [("R^2000 -> R^4 (n > m)", W_wide, jnp.ones(2000)),
("R^4 -> R^2000 (n < m)", W_tall, jnp.ones(4))]:
J_f, J_r = jac_fwd(W, x), jac_rev(W, x)
# 正确性:jit 版本、非 jit 版本、两种模式之间都要一致
assert jnp.allclose(J_f, J_r, atol=1e-5)
assert jnp.allclose(J_r, jax.jacrev(layer, argnums=1)(W, x), atol=1e-5)
t_f, t_r = bench(jac_fwd, W, x), bench(jac_rev, W, x)
results[name] = (t_f, t_r)
print(f"{name}: jacfwd {t_f:7.3f} ms | jacrev {t_r:7.3f} ms | 更快的是 {'jacfwd' if t_f < t_r else 'jacrev'}")
t_f, t_r = results["R^2000 -> R^4 (n > m)"]
print("\n规则「n > m 用 jacrev」是否得到印证:", t_r < t_f)
t_f, t_r = results["R^4 -> R^2000 (n < m)"]
print("规则「n < m 用 jacfwd」是否得到印证:", t_f < t_r)
# Hessian 的四种组合:结果相同,效率不同
def scalar_fn(W, x):
return jnp.sum(jnp.tanh(W @ x) ** 2)
n = 300
W_sq = jax.random.normal(jax.random.key(2), (n, n)) / jnp.sqrt(n)
x0 = jax.random.normal(jax.random.key(3), (n,))
combos = {
"jacfwd(jacrev) [= jax.hessian]": jax.jacfwd(jax.jacrev(scalar_fn, argnums=1), argnums=1),
"jacrev(jacrev)": jax.jacrev(jax.jacrev(scalar_fn, argnums=1), argnums=1),
"jacrev(jacfwd)": jax.jacrev(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
"jacfwd(jacfwd)": jax.jacfwd(jax.jacfwd(scalar_fn, argnums=1), argnums=1),
}
H_ref = jax.hessian(scalar_fn, argnums=1)(W_sq, x0)
for name, fn in combos.items():
fn_jit = jax.jit(fn)
assert jnp.allclose(fn_jit(W_sq, x0), H_ref, atol=1e-4), name
print(f"{name:34s}: {bench(fn_jit, W_sq, x0, n=10):7.2f} ms")
print("断言通过:四种组合得到的 Hessian 一致")
从计时可以看到,内层用反向模式的两种组合(jacfwd(jacrev)、jacrev(jacrev))明显比内层用前向模式的快,因为内层算的是标量函数的梯度($n\to1$)。这两种组合之间差得不多(谁更快因机器而异)。jax.hessian 选 jacfwd(jacrev),是因为外层用前向模式不用保存反向需要的中间值,更省内存。
import jax
import jax.numpy as jnp
def power_product(x, y):
"""计算 x^2 * y"""
return (x**2) * y
# 定义一些输入值
x_val = 3.0
y_val = 4.0
print(f"函数输出: {power_product(x_val, y_val)}")
# 对第一个参数 (x) 求导 - 默认行为
grad_f_wrt_x = jax.grad(power_product)
gradient_x = grad_f_wrt_x(x_val, y_val)
print(f"关于 x 的梯度 (默认): {gradient_x}") # 预期: 2 * 3.0 * 4.0 = 24.0
argnums 的索引从 0 开始,argnums=1 表示对第二个参数 $y$ 求导:
# 使用 argnums=1 对第二个参数 (y) 求梯度
grad_f_wrt_y = jax.grad(power_product, argnums=1)
gradient_y = grad_f_wrt_y(x_val, y_val)
print(f"关于 y 的梯度 (argnums=1): {gradient_y}") # 预期: 3.0**2 = 9.0
指定多个参数¶
给 argnums 传一个整数元组,梯度函数就返回一个元组,元素顺序和 argnums 里的一致。没被选中的参数,求导时当作常数。
以仿射函数 $g(w,b,x)=wx+b$ 为例:$\dfrac{\partial g}{\partial w}=x$,$\dfrac{\partial g}{\partial b}=1$。
def affine(w, b, x):
"""计算 w*x + b"""
return w * x + b
# 定义输入值
w_val = 2.0
b_val = 1.0
x_data = 5.0
print(f"仿射函数输出: {affine(w_val, b_val, x_data)}")
# 对第一个 (w) 和第二个 (b) 参数求梯度
grad_g_wrt_wb = jax.grad(affine, argnums=(0, 1))
# 注意: 在求导过程中,输入 x_data 被视为常数
gradients_wb = grad_g_wrt_wb(w_val, b_val, x_data)
print(f"关于 (w, b) 的梯度 (使用 argnums=(0, 1)): {gradients_wb}")
# 预期: (关于 w 的梯度, 关于 b 的梯度) = (x, 1) = (5.0, 1.0)
与机器学习的联系¶
训练时要的是损失对参数的梯度 $\nabla_{\text{params}}\,\text{loss}(\text{params},\ \text{data\_batch})$,数据批次只是固定的输入。所以通常把参数放在第一个位置,用默认的 argnums=0 就行。
真实模型的参数通常是嵌套的 dict / list / tuple,JAX 管这叫 pytree。jax.grad 直接支持 pytree,返回的梯度和参数的结构一模一样。所以更新参数时,要用 jax.tree.map 对每个叶子(数组)分别做 p - lr * g。
⚠️ 注意:
updated_params = current_params - learning_rate * gradients这种写法,只在参数是单个数组时成立。参数是 dict 时会报TypeError: unsupported operand type(s),要改用jax.tree.map(lambda p, g: p - learning_rate * g, params, grads)。
下面把这个例子补全成可以运行的代码:
# 参数是一个 dict(pytree)
params = {
"w": jnp.array([1.0, -2.0]),
"b": jnp.array(0.5),
}
batch = {
"inputs": jnp.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]),
"targets": jnp.array([1.0, 0.0, -1.0]),
}
def model_apply(params, x):
return x @ params["w"] + params["b"]
def loss_function(params, data_batch):
predictions = model_apply(params, data_batch["inputs"])
error = predictions - data_batch["targets"]
return jnp.mean(error**2) # 均方误差
grad_loss_wrt_params = jax.grad(loss_function, argnums=0)
gradients = grad_loss_wrt_params(params, batch)
print("gradients =", gradients)
assert jax.tree.structure(gradients) == jax.tree.structure(params)
print("断言通过:梯度的 pytree 结构与参数相同")
# ❌ 错误写法:参数是 dict 时不能直接做算术
learning_rate = 0.01
try:
updated_params = params - learning_rate * gradients
dict_arith_raised = False
except TypeError as e:
dict_arith_raised = True
print("报错 TypeError:", e)
assert dict_arith_raised
# ✅ 推荐写法:用 jax.tree.map 对每个叶子更新
updated_params = jax.tree.map(lambda p, g: p - learning_rate * g, params, gradients)
loss_before = loss_function(params, batch)
loss_after = loss_function(updated_params, batch)
print("更新前 loss:", loss_before)
print("更新后 loss:", loss_after)
assert loss_after < loss_before
assert jnp.allclose(updated_params["w"], params["w"] - learning_rate * gradients["w"])
print("断言通过:tree.map 正确更新了每个叶子,loss 下降")
🔍 补充:has_aux=True——函数还要返回其他东西时¶
🟢 必读
grad 要求函数返回标量。可损失函数往往还想顺便带回预测值、准确率这类辅助信息,直接返回元组又会报错。正确做法是返回 (loss, aux),再加上 has_aux=True。这时梯度函数返回 (grads, aux),aux 不参与求导。
def loss_with_metrics(params, data_batch):
predictions = model_apply(params, data_batch["inputs"])
loss = jnp.mean((predictions - data_batch["targets"]) ** 2)
aux = {"predictions": predictions, "max_abs_err": jnp.max(jnp.abs(predictions - data_batch["targets"]))}
return loss, aux
# 不加 has_aux:返回元组会报错
try:
jax.grad(loss_with_metrics)(params, batch)
except TypeError as e:
print("未加 has_aux 报错:", str(e).splitlines()[0])
# 加上 has_aux=True
grads, aux = jax.grad(loss_with_metrics, has_aux=True)(params, batch)
print("\ngrads:", grads)
print("aux :", aux)
3.5 高阶导数(grad 的 grad)¶
jax.grad 接收一个输出标量的函数 $f$,返回一个计算梯度的新 Python 函数。JAX 的变换输入是 Python 函数,输出也是 Python 函数,所以变换的结果可以再拿去变换。对一元标量函数,grad 得到的仍然是标量函数,于是可以再 grad 一次,得到二阶导数。
以 $f(x)=x^3$ 为例:$f'(x)=3x^2$,$f''(x)=6x$。在 $x=2$ 处:$f=8,\ f'=12,\ f''=12$。
import jax
import jax.numpy as jnp
def f(x):
return x**3
# 一阶导数
grad_f = jax.grad(f)
# 二阶导数:对一阶导数函数再求导
grad_grad_f = jax.grad(grad_f)
x = 2.0
print(f"f({x}) = {f(x)}") # 8.0
print(f"f'({x}) = {grad_f(x)}") # 3 * 2^2 = 12.0
print(f"f''({x}) = {grad_grad_f(x)}") # 6 * 2 = 12.0
也可以直接嵌套着写。同样的办法能推广到三阶甚至更高阶,代价是计算量变大,数值稳定性也可能变差:
# 嵌套写法
second_derivative = jax.grad(jax.grad(f))
print("f''(2.0) =", second_derivative(2.0)) # 12.0
# 三阶导数 f⁽³⁾(x) = 6
third_derivative = jax.grad(jax.grad(jax.grad(f)))
print("f⁽³⁾(2.0) =", third_derivative(2.0)) # 6.0
多元函数与 Hessian¶
对多元函数,所有二阶偏导组成 Hessian 矩阵:
$$ H_{ij}=\frac{\partial^2 f}{\partial x_i\,\partial x_j} $$
例:$f(x,y)=x^2y+y^3$。
$$ \frac{\partial f}{\partial x}=2xy,\quad \frac{\partial^2 f}{\partial x^2}=2y,\quad \frac{\partial^2 f}{\partial y\,\partial x}=2x $$
两次都对 $x$ 求导(argnums=0),得到 $\partial^2 f/\partial x^2$。第一次对 $x$、第二次改用 argnums=1 对 $y$ 求导,得到混合偏导。在 $(2,3)$ 处两者分别是 $6$ 和 $4$:
def f_xy(x, y):
return x**2 * y + y**3
x, y = 2.0, 3.0
# ∂f/∂x
df_dx = jax.grad(f_xy, argnums=0)
# ∂²f/∂x²:再对 x 求导
d2f_dx2 = jax.grad(df_dx, argnums=0)
print("∂²f/∂x² 在 (2,3) =", d2f_dx2(x, y)) # 2y = 6.0
# ∂²f/∂y∂x:对 ∂f/∂x 再对 y 求导
d2f_dydx = jax.grad(df_dx, argnums=1)
print("∂²f/∂y∂x 在 (2,3) =", d2f_dydx(x, y)) # 2x = 4.0
要算完整的 Hessian,可以直接用 jax.hessian,通常更高效。
🔍 补充:jax.hessian 的用法与原理¶
⚪ 选读:需要二阶导、Hessian 或 Hessian-向量积时看。
jax.hessian(f)的实现就是jax.jacfwd(jax.jacrev(f)),也就是「先反向、再前向」(forward-over-reverse)。内层jacrev求梯度(输入多、输出 1 个,适合反向),外层jacfwd对梯度再求雅可比(输入 $n$、输出 $n$,前向模式的内存开销更小)。jax.hessian默认对第一个参数求导,所以多元函数通常把变量打包成一个向量传进去。也可以用argnums=(0, 1),这时返回的是嵌套元组((H_xx, H_xy), (H_yx, H_yy))。- 完整 Hessian 有 $n^2$ 个元素,$n$ 很大时根本存不下。很多算法(共轭梯度、牛顿-CG)只需要 Hessian-向量积 $Hv$,可以用
jax.jvp(jax.grad(f), (x,), (v,))计算,不必构造整个矩阵(见练习 C)。
# 把 (x, y) 打包成向量
def f_vec(v):
x, y = v
return x**2 * y + y**3
v = jnp.array([2.0, 3.0])
H = jax.hessian(f_vec)(v)
print("Hessian =\n", H)
# 解析解: [[2y, 2x], [2x, 6y]] = [[6, 4], [4, 18]]
assert jnp.allclose(H, jnp.array([[6.0, 4.0], [4.0, 18.0]]))
assert jnp.allclose(H, jax.jacfwd(jax.jacrev(f_vec))(v))
print("断言通过:与解析解、jacfwd(jacrev(f)) 一致")
# 多参数形式:argnums=(0, 1) 返回嵌套元组
H_tuple = jax.hessian(f_xy, argnums=(0, 1))(2.0, 3.0)
print("argnums=(0,1) 的结果:", H_tuple)
3.6 值和梯度(jax.value_and_grad)¶
训练模型时,通常既要梯度(拿来更新参数),又要损失值(拿来看训练进度)。如果分别调用 f(x) 和 jax.grad(f)(x),前向计算就做了两遍。其实反向模式本来就要先做一次前向传播,函数值在这一步已经算出来了。
jax.value_and_grad(f) 返回一个新函数,调用后返回元组 (value, gradient),只做一次前向计算。
例:$p(x)=x^3+2x^2-3x+1$,$p'(x)=3x^2+4x-3$。在 $x=2$ 处:$p(2)=8+8-6+1=11$,$p'(2)=12+8-3=17$。
import jax
import jax.numpy as jnp
def polynomial(x):
return x**3 + 2 * x**2 - 3 * x + 1
# 分开计算:前向计算会做两遍
value = polynomial(2.0)
gradient = jax.grad(polynomial)(2.0)
print(f"分开计算: value = {value}, gradient = {gradient}")
# 用 value_and_grad 一次得到两者
value_and_grad_fn = jax.value_and_grad(polynomial)
value, gradient = value_and_grad_fn(2.0)
print(f"value_and_grad: value = {value}, gradient = {gradient}") # 11.0, 17.0
value_and_grad 同样支持 argnums:
argnums是整数时,返回的梯度就对应这一个参数;argnums是整数元组时,返回的梯度也是元组,每个元素对应一个指定的参数。
例:$m(a,b)=a^2\sin b$,$\dfrac{\partial m}{\partial a}=2a\sin b$,$\dfrac{\partial m}{\partial b}=a^2\cos b$。取 $a=3,\ b=\pi/2$:函数值是 $9$,对 $a$ 的梯度是 $6$,对 $b$ 的梯度的数学值是 $0$。
⚠️ 注意:对 $b$ 的梯度打印出来不是
0,而是约-3.93e-07(float32)。原因和 3.2 一样,是 $\pi/2$ 在 float32 下有舍入,所以要用jnp.allclose判断。
def multi_arg_func(a, b):
return a**2 * jnp.sin(b)
a, b = 3.0, jnp.pi / 2
# 只对 a 求导
value, grad_a = jax.value_and_grad(multi_arg_func, argnums=0)(a, b)
print(f"argnums=0 : value = {value}, ∂/∂a = {grad_a}") # 9.0, 6.0
# 对 a 和 b 都求导
value, (grad_a, grad_b) = jax.value_and_grad(multi_arg_func, argnums=(0, 1))(a, b)
print(f"argnums=(0,1) : value = {value}, ∂/∂a = {grad_a}, ∂/∂b = {grad_b}")
assert jnp.allclose(value, 9.0) and jnp.allclose(grad_a, 6.0)
assert grad_b != 0.0 and jnp.allclose(grad_b, 0.0, atol=1e-5) # 不是精确 0,但在精度内为 0
print("断言通过:value=9, ∂/∂a=6, ∂/∂b≈0")
梯度下降这类优化算法每一步都要「损失 + 梯度」,所以 value_and_grad 是训练循环的标准写法,也可以和 jax.jit 组合使用(见 3.9 的线性回归例子)。
🔍 补充:与 has_aux 组合时的返回结构¶
🟢 必读
实际写代码时,最常见的组合是 jax.value_and_grad(loss_fn, has_aux=True),它返回的结构是 ((loss, aux), grads)。值的部分就是函数原样返回的 (loss, aux) 元组。解包时括号很容易写错:
def loss_with_acc(w, x, y):
logits = x * w
loss = jnp.mean((logits - y) ** 2)
acc = jnp.mean(jnp.sign(logits) == jnp.sign(y)) # 辅助信息:符号预测准确率
return loss, acc
x = jnp.array([1.0, -2.0, 3.0])
y = jnp.array([2.0, -4.0, -1.0])
(loss, acc), g = jax.value_and_grad(loss_with_acc, has_aux=True)(1.0, x, y)
print(f"loss = {loss:.4f}, acc = {acc:.3f}, grad = {g:.4f}")
# 解析梯度:mean(2 (w x - y) x)
assert jnp.allclose(g, jnp.mean(2 * (1.0 * x - y) * x))
3.7 求导与控制流¶
grad 求导的对象,是函数在当前输入下实际走过的那条路径。要理解这一点,关键是分清两种追踪方式:
单独使用 jax.grad | jax.jit(或 jit(grad(f))) | |
|---|---|---|
| 追踪器 | 带具体数值(打印为 GradTracer(primal=...)) | 抽象值,只有形状和 dtype |
| 何时追踪 | 每次调用都重新追踪 | 对每种输入形状/dtype 只追踪、编译一次 |
Python if x > 0 | 可以,按当前 x 的真实值选分支 | 报 TracerBoolConversionError |
Python for/while(次数依赖数值) | 可以 | 报错 |
实际训练时 grad 几乎总是放在 jit 里,所以依赖数值的控制流要改用 jax.lax.cond、jnp.where、jax.lax.fori_loop、jax.lax.scan 这些结构化控制流原语。
⚠️ 注意:容易以为「JAX 对给定的输入形状和类型只追踪一次」,所以
grad只能对追踪时走过的那一个分支求导,Pythonif在x=-2.0时还会报「抽象追踪器值的真值不明确」。其实「只追踪一次」和这个报错都是jit的行为。单独用grad时,Pythonif完全正常(x=-2.0返回-1.0);jit(grad(f))(-2.0)才会报TracerBoolConversionError。
条件语句¶
看课程里的例子:$x>0$ 时返回 $x^2$,否则返回 $-x$。
import jax
import jax.numpy as jnp
# ❌ 常见误解:以为下面对 x=-2.0 的调用会因「追踪器真值不明确」而报错
def conditional_func(x):
if x > 0:
return x * x
else:
return -x
grad_conditional = jax.grad(conditional_func)
try:
g_pos, g_neg = grad_conditional(2.0), grad_conditional(-2.0)
print("实测:没有报错。grad 在 x=2.0 :", g_pos, "| x=-2.0 :", g_neg)
except Exception as e:
g_pos = g_neg = None
print("报错:", type(e).__name__, e)
# 断言:单独用 grad 时 Python if 正常工作,两个分支的梯度都正确
assert g_pos == 4.0 and g_neg == -1.0
# 原因:单独 grad 时追踪器带有具体数值
seen = []
def peek(x):
print(" grad 内部看到的 x:", x)
seen.append(repr(x))
return x * x
jax.grad(peek)(2.0)
assert "primal=2.0" in seen[0] # 单独 grad:追踪器带着具体数值 2.0
# 真正会报错的是 jit:jit 的追踪器是抽象值
try:
jax.jit(grad_conditional)(-2.0)
jit_if_raised = False
except jax.errors.TracerBoolConversionError as e:
jit_if_raised = True
print("\njit(grad) 报错: TracerBoolConversionError")
print(" ", str(e).splitlines()[0])
assert jit_if_raised, "jit 下 Python if 作用于追踪值应当报错"
print("断言通过:grad 单独使用不报错,套上 jit 才报错")
用 jax.lax.cond 改写(在 jit 下也能用)¶
jax.lax.cond(pred, true_fun, false_fun, *operands) 根据 pred 决定执行 true_fun(*operands) 还是 false_fun(*operands)。两个分支都会被追踪,到运行时才选,所以既能 grad 也能 jit。两个分支返回的结果,形状和 dtype 必须相同。操作数可以写成多个位置参数(*operands)。
如果是逐元素二选一,也可以用 jnp.where(pred, a, b),不过要小心它的 NaN 梯度陷阱(见 3.8 的专题)。
# ✅ 推荐写法:lax.cond,grad 与 jit(grad) 都正确
def conditional_func_lax(x):
return jax.lax.cond(
x > 0, # 条件
lambda x: x * x, # 条件为真时执行
lambda x: -x, # 条件为假时执行
x, # 操作数
)
grad_cond_lax = jax.grad(conditional_func_lax)
grad_cond_lax_jit = jax.jit(grad_cond_lax)
for x in [2.0, -2.0]:
print(f"x={x:5}: grad = {grad_cond_lax(x)}, jit(grad) = {grad_cond_lax_jit(x)}")
assert grad_cond_lax(2.0) == 4.0 and grad_cond_lax(-2.0) == -1.0
assert grad_cond_lax_jit(2.0) == 4.0 and grad_cond_lax_jit(-2.0) == -1.0
print("断言通过")
循环¶
静态循环:如果 for 循环的次数在追踪时就已知(比如是 Python 常量),它会被展开成一串普通运算,求导没问题。比如循环 3 次、每次乘 2,$y=8x$,梯度是 $8$。缺点是次数很大时,展开后的计算图会非常大,编译变慢。
def fixed_loop(x, n=3):
y = x
for _ in range(n): # n 在追踪时是已知的 Python 整数,循环会被展开
y = y * 2
return y
print("fixed_loop 梯度:", jax.grad(fixed_loop)(1.0)) # 8.0
assert jax.jit(jax.grad(fixed_loop))(1.0) == 8.0
动态循环:如果循环次数或 while 条件依赖被追踪的值,普通 Python 循环在 jit 下就跑不了,要改用结构化控制流原语。但这些原语对反向求导(grad/vjp)的支持不一样:
| 原语 | 前向模式(jvp/jacfwd) | 反向模式(grad/vjp) |
|---|---|---|
lax.scan(f, init, xs, length) | ✅ | ✅ |
lax.fori_loop(lo, hi, body, init),lo/hi 为具体整数 | ✅ | ✅(内部转成 scan) |
lax.fori_loop,lo/hi 为追踪值(如 jit 中的普通参数) | ✅ | ❌(退化为 while_loop) |
lax.while_loop(cond, body, init) | ✅ | ❌ |
反向模式要保存每一步的中间值,而 while_loop 的步数在编译时不知道,没法提前分配空间,所以不支持。需要反向求导的循环,就用 lax.scan(固定次数),或者把次数声明成静态参数(jax.jit(..., static_argnums=...))。
⚠️ 注意:
jax.lax.while_loop和上下界是追踪值的fori_loop,都不能拿来做需要反向求导的循环。grad穿过它们会报ValueError: Reverse-mode differentiation does not work for lax.while_loop ...,它们只支持前向模式。下面的fori_loop示例之所以能跑通,是因为单独grad时n=3是具体整数。
body_fun(i, val) 返回新的 val:
def dynamic_loop_lax(x, n):
# body_fun(i, val) -> 新的 val
return jax.lax.fori_loop(0, n, lambda i, val: val * 2, x)
# 单独 grad:n=3 是具体整数,fori_loop 被转成 scan,可以反向求导
print("fori_loop 梯度 (n=3):", jax.grad(dynamic_loop_lax)(1.0, 3)) # 8.0
assert jax.grad(dynamic_loop_lax)(1.0, 3) == 8.0
# ❌ 常见误解:以为 while_loop / 动态 fori_loop 可以用来做可反向求导的动态循环
def double_until(x):
# 状态 (值, 计数器),循环 3 次
return jax.lax.while_loop(lambda c: c[1] < 3, lambda c: (c[0] * 2, c[1] + 1), (x, 0))[0]
def raises_reverse_mode_error(fn, *args):
try:
fn(*args)
return False
except ValueError as e:
print(" 报错:", str(e).splitlines()[0])
return True
print("grad 穿过 while_loop:")
assert raises_reverse_mode_error(jax.grad(double_until), 1.0)
print("jit 中 n 为追踪值的 fori_loop:")
assert raises_reverse_mode_error(jax.jit(jax.grad(dynamic_loop_lax)), 1.0, 3)
# while_loop 支持前向模式
primal, tangent = jax.jvp(double_until, (1.0,), (1.0,))
print("while_loop + jvp:", primal, tangent)
assert tangent == 8.0
# ✅ 推荐写法 1:把循环次数声明为静态参数
grad_static = jax.jit(jax.grad(dynamic_loop_lax), static_argnums=1)
print("static_argnums:", grad_static(1.0, 3))
assert grad_static(1.0, 3) == 8.0
# ✅ 推荐写法 2:用 lax.scan 写固定次数的循环
def double_scan(x, n=3):
y, _ = jax.lax.scan(lambda c, _: (c * 2, None), x, None, length=n)
return y
print("scan + jit(grad):", jax.jit(jax.grad(double_scan))(1.0))
assert jax.jit(jax.grad(double_scan))(1.0) == 8.0
print("断言通过")
最后提醒两点。第一,在 jit 里不用这些原语,追踪时就会报错。第二,就算不用 jit,grad 也只对当前输入走过的路径求导,在分支边界附近得到的梯度可能会误导人。
3.8 局限性与注意事项¶
1. 不可微的操作¶
自动微分靠的是链式法则,要求每个基本运算都可导。下面这些操作在数学上不可导,或者导数几乎处处为零:
- 不连续函数:
jnp.sign、jnp.round、jnp.floor、jnp.ceil等,JAX 把它们的导数定义为 0; - 转换为整数:
x.astype(jnp.int32)是阶梯函数,梯度到这里就被截断成 0(如果函数输出本身是整数,grad会直接报TypeError); - 比较运算:
x > 0这类运算的结果是布尔值,在输入附近是常数,梯度为零。
在尖点(导数不存在的点)上,JAX 给每个原语都规定了确定的取值,不会随平台变化:
| 表达式 | 在 0 处的梯度(实测) | 说明 |
|---|---|---|
jnp.abs | 1.0 | 取右导数 |
jax.nn.relu | 0.0 | 取左导数 |
jnp.sign | 0.0 | 导数处处定义为 0 |
jnp.sqrt | inf | $\frac{1}{2\sqrt x}$ 在 0 处发散 |
jnp.linalg.norm(零向量) | nan | 内部是 $\frac{x}{\lVert x\rVert}=\frac{0}{0}$ |
真正的 NaN 主要出在后两行这种「导数是无穷大或 0/0」的点上(inf 再乘上 0 也会变成 NaN),下面的专题会专门讲。如果想在不连续的地方得到有用的梯度,有几个办法:用平滑函数代替(例如用 jax.nn.sigmoid(k*x) 近似阶跃函数);用 straight-through estimator(见下文 stop_gradient);或者换用梯度下降以外的优化方法。
⚠️ 注意:
jax.grad(jnp.abs)(0.0)是1.0,不是 0.0。⚠️ 注意:
jax.grad(jnp.sign)(0.0)是确定的0.0(sign的导数被定义为处处为 0),不会是 NaN,也不随平台变化。
import jax
import jax.numpy as jnp
import numpy as np
# 课程示例:先转整数再平方
def cast_and_square(x):
x_int = x.astype(jnp.int32) # 阶梯函数:信息被截断
return x_int.astype(jnp.float32) ** 2
g_cast = jax.grad(cast_and_square)(2.7)
print("cast_and_square(2.7) 的梯度:", g_cast)
assert g_cast == 0.0
# 其他不连续函数和比较运算:梯度为 0
for name, fn in [("sign", jnp.sign), ("round", jnp.round), ("floor", jnp.floor), ("ceil", jnp.ceil),
("(x>1).astype(float)", lambda x: (x > 1).astype(jnp.float32))]:
g = jax.grad(fn)(2.7)
print(f"grad({name})(2.7) = {g}")
assert g == 0.0
# 输出本身是整数时,grad 直接报错
try:
jax.grad(lambda x: x.astype(jnp.int32))(2.7)
except TypeError as e:
print("\n整数输出报错:", str(e).splitlines()[0])
# ❌ 常见误解:以为 grad(abs)(0.0) 通常为 0.0,grad(sign)(0.0) 常为 NaN 或与平台相关
course_claim = {"grad(abs)(0.0)": 0.0, "grad(sign)(0.0)": "NaN 或与平台相关"}
measured = {"grad(abs)(0.0)": jax.grad(jnp.abs)(0.0), "grad(sign)(0.0)": jax.grad(jnp.sign)(0.0)}
for k in course_claim:
print(f"{k:16s} 常见说法: {course_claim[k]!s:18s} 实际: {measured[k]}")
# ✅ 正确结论:尖点处的取值是确定的
assert jax.grad(jnp.abs)(0.0) == 1.0
assert jax.grad(jnp.sign)(0.0) == 0.0
assert jax.grad(jax.nn.relu)(0.0) == 0.0
assert jnp.isinf(jax.grad(jnp.sqrt)(0.0))
assert jnp.all(jnp.isnan(jax.grad(jnp.linalg.norm)(jnp.zeros(3))))
print("断言通过:abs→1.0,sign→0.0,relu→0.0,sqrt→inf,norm(0)→nan")
# 平滑近似:用 sigmoid(k x) 近似阶跃函数,梯度不再为零
k = 5.0
g_smooth = jax.grad(lambda x: jax.nn.sigmoid(k * x))(0.1)
print("grad(sigmoid(k x))(0.1) =", g_smooth)
assert g_smooth > 0
🔍 补充(专题):NaN 梯度与「双 where」技巧¶
🟢 必读
现象¶
为了避开 sqrt(0)、log(0) 这类危险点,很多人会写 jnp.where(x > 0, jnp.log(x), 0.0),以为 $x\le0$ 时就会选 0.0。前向结果确实没错,但梯度还是 NaN。
原理:where 挡不住反向传播里的 $0\times\infty$¶
设 $y=\text{where}(c,\ a(x),\ b(x))$。jnp.where 会先把两个分支都算出来,再逐元素挑选。反向传播时,上游梯度 $\bar y$ 按条件分给两个分支:
$$ \bar a = \text{where}(c,\ \bar y,\ 0),\qquad \bar b = \text{where}(c,\ 0,\ \bar y) $$
接着各分支继续按链式法则往回传:$\bar x = \bar a\cdot a'(x) + \bar b\cdot b'(x)$。
当 $c$ 为假时,$\bar a=0$。这时如果 $a'(x)$ 是有限值,$0\times a'(x)=0$,一切正常。但如果危险分支在这个点的导数是 $\pm\infty$(比如 $\log'(0)=\frac10$)或 NaN,就会得到
$$ 0\times\infty=\text{NaN}\quad(\text{IEEE 754 规定}) $$
NaN 加上任何数都还是 NaN,于是 $\bar x$ 也成了 NaN。换句话说,where 只挑了前向的值,却挡不住反向时没选中的分支带来的「$0\times\infty$」。
「双 where」技巧¶
问题的根源在于,危险分支拿危险输入算了一遍。解决办法是:先用第一个 where 把危险输入换成一个安全值(只要能让危险分支的导数有限就行),再用第二个 where 选输出:
x_safe = jnp.where(cond, x, SAFE) # 第 1 个 where:危险分支永远不会看到危险输入
y = jnp.where(cond, danger_fn(x_safe), fallback) # 第 2 个 where:选择输出
这样一来,没选中的分支的导数 $a'(\text{SAFE})$ 是有限值,$0\times\text{有限值}=0$。而且 $c$ 为假时,第一个 where 把梯度引到常数 SAFE 上,不会传回 $x$。
常见场景¶
| 场景 | 危险点 | ❌ 会得到 NaN 梯度的写法 | ✅ 双 where 写法 |
|---|---|---|---|
sqrt | $x=0$ | where(x>0, sqrt(x), 0.) | xs=where(x>0, x, 1.);where(x>0, sqrt(xs), 0.) |
log | $x=0$ | where(x>0, log(x), 0.) | xs=where(x>0, x, 1.);where(x>0, log(xs), 0.) |
| 向量范数 | $\mathbf v=\mathbf 0$ | where(sum(v**2)==0, 0., norm(v)) | s=sum(v**2);ss=where(s==0, 1., s);where(s==0, 0., sqrt(ss)) |
| 除法 | 分母 $y=0$ | where(y!=0, a/y, 0.) | ys=where(y!=0, y, 1.);where(y!=0, a/ys, 0.) |
arcsin | $\lvert x\rvert=1$ | where(abs(x)<1, arcsin(x), sign(x)*π/2) | xs=where(abs(x)<1, x, 0.);where(abs(x)<1, arcsin(xs), sign(x)*π/2) |
先用一个最小的例子把反向传播拆开,亲眼看看 $0\times\infty$:
# 手动拆开 where(x>0, log(x), 0.) 在 x=0 处的反向传播
x = jnp.float32(0.0)
y_bar = 1.0 # 上游梯度
a_bar = jnp.where(x > 0, y_bar, 0.0) # 分给 log 分支的梯度:0
dlog_dx = 1.0 / x # log'(0) = inf
print("ā =", a_bar, "| log'(0) =", dlog_dx, "| ā × log'(0) =", a_bar * dlog_dx)
assert jnp.isnan(a_bar * dlog_dx) # 0 × inf = nan
# ❌ 朴素写法(单个 where):前向值正确,但梯度是 NaN
naive = {
"sqrt": (lambda x: jnp.where(x > 0, jnp.sqrt(x), 0.0), 0.0),
"log": (lambda x: jnp.where(x > 0, jnp.log(x), 0.0), 0.0),
"norm": (lambda v: jnp.where(jnp.sum(v**2) == 0, 0.0, jnp.linalg.norm(v)), jnp.zeros(3)),
"除法": (lambda y: jnp.where(y != 0, 2.0 / y, 0.0), 0.0),
"arcsin": (lambda x: jnp.where(jnp.abs(x) < 1, jnp.arcsin(x), jnp.sign(x) * jnp.pi / 2), 1.0),
}
for name, (fn, x_bad) in naive.items():
val, g = jax.value_and_grad(fn)(x_bad)
print(f"{name:6s} 危险点: 值 = {val:.4f}, 梯度 = {g}")
assert jnp.all(jnp.isfinite(val)) and jnp.any(jnp.isnan(g)) # 值有限、梯度 NaN
# ✅ 推荐写法(双 where):危险分支永远看不到危险输入
def safe_sqrt(x):
xs = jnp.where(x > 0, x, 1.0)
return jnp.where(x > 0, jnp.sqrt(xs), 0.0)
def safe_log(x):
xs = jnp.where(x > 0, x, 1.0)
return jnp.where(x > 0, jnp.log(xs), 0.0)
def safe_norm(v):
s = jnp.sum(v**2)
ss = jnp.where(s == 0, 1.0, s)
return jnp.where(s == 0, 0.0, jnp.sqrt(ss))
def safe_div(y, a=2.0):
ys = jnp.where(y != 0, y, 1.0)
return jnp.where(y != 0, a / ys, 0.0)
def safe_arcsin(x):
inside = jnp.abs(x) < 1
xs = jnp.where(inside, x, 0.0)
return jnp.where(inside, jnp.arcsin(xs), jnp.sign(x) * jnp.pi / 2)
safe = {
# 名称: (函数, 危险点, 正常点, 正常点处的解析梯度)
"sqrt": (safe_sqrt, 0.0, 4.0, 0.25),
"log": (safe_log, 0.0, 2.0, 0.5),
"norm": (safe_norm, jnp.zeros(3), jnp.array([3., 4., 0.]), jnp.array([0.6, 0.8, 0.0])),
"除法": (safe_div, 0.0, 4.0, -2.0 / 16),
"arcsin": (safe_arcsin, 1.0, 0.5, 1 / jnp.sqrt(1 - 0.25)),
}
for name, (fn, x_bad, x_ok, g_expected) in safe.items():
g_bad = jax.grad(fn)(x_bad)
g_ok = jax.grad(fn)(x_ok)
print(f"{name:6s} 危险点梯度 = {g_bad} | 正常点梯度 = {g_ok}")
assert jnp.all(jnp.isfinite(g_bad)), name # 危险点梯度有限
assert jnp.allclose(g_ok, g_expected, atol=1e-6), name # 正常点梯度不受影响
assert jnp.allclose(fn(x_ok), naive[name][0](x_ok)) # 前向值与朴素写法一致
print("断言通过:5 个场景的危险点梯度都是有限值,正常点梯度正确")
实战经验
- 安全值要让危险分支的导数有限,光是值有限还不够。
sqrt/log/除法用1.0,arcsin用0.0。 - 只要被屏蔽的分支在危险点上的导数有限,单个
where就够了,例如where(x>0, x**2, 0.)在 0 处梯度为 0,不会出 NaN。 - 训练中出现 NaN 时,可以打开
jax.config.update("jax_debug_nans", True),让 JAX 在第一个产生 NaN 的原语处报错,很快就能定位。不过它在本环境中会漏检(比如用过vmap之后),用法和已知限制见 进阶 08 §3.1。 - 对于固定的数学函数,也可以用
jax.custom_jvp直接给出稳定的导数(见下文)。
2. 数值稳定性¶
- 梯度消失 / 梯度爆炸:深层网络里链式法则要连乘很多项,梯度可能趋近于 0,也可能急剧变大。
- 例如 $\frac{d}{dx}\log x=\frac1x$ 在 $x=10^{-20}$ 处是 $10^{20}$,非常大。
缓解办法:
- 梯度裁剪(gradient clipping);
- 使用数值稳定的实现,例如用
jax.scipy.special.logsumexp(x)代替jnp.log(jnp.sum(jnp.exp(x))); - 必要时启用 64 位精度:
jax.config.update("jax_enable_x64", True)(在程序开头、创建数组之前设置)。
from jax.scipy.special import logsumexp
g_log = jax.grad(jnp.log)(1e-20)
print("grad(log)(1e-20) =", g_log)
assert jnp.allclose(g_log, 1e20, rtol=1e-5)
# 朴素的 log-sum-exp 会溢出,梯度也随之变成 nan
def naive_lse(x):
return jnp.log(jnp.sum(jnp.exp(x)))
x_big = jnp.array([1000.0, 1000.0])
print("\n朴素实现 值:", naive_lse(x_big), "| 梯度:", jax.grad(naive_lse)(x_big))
print("logsumexp 值:", logsumexp(x_big), "| 梯度:", jax.grad(logsumexp)(x_big))
assert jnp.all(jnp.isnan(jax.grad(naive_lse)(x_big)))
assert jnp.allclose(jax.grad(logsumexp)(x_big), 0.5)
🔍 补充:用 jax.custom_jvp 自定义导数¶
⚪ 选读:某个函数的自动求导数值不稳定、想手写导数规则时看。
有些函数,用基本运算组合出来的导数在数值上不稳定,但导数本身有简单又稳定的解析式。这时可以用 jax.custom_jvp 直接告诉 JAX 导数怎么算。官方文档里的经典例子是 $\log(1+e^x)$(softplus)。它的导数是 $\sigma(x)=\frac{e^x}{1+e^x}$,可直接自动求导的话,$x$ 很大时会算出 $\frac{\infty}{\infty}=\text{NaN}$。
只要定义 JVP 规则(前向模式),JAX 会自动推出反向模式,所以 grad、jacrev、hessian 都能用。(另外还有 jax.custom_vjp,可以直接自定义反向规则,用在梯度裁剪、节省内存等场景。)
def log1pexp_naive(x):
return jnp.log1p(jnp.exp(x))
print("朴素实现 grad(100.0) =", jax.grad(log1pexp_naive)(100.0)) # nan
assert jnp.isnan(jax.grad(log1pexp_naive)(100.0))
@jax.custom_jvp
def log1pexp(x):
return jnp.log1p(jnp.exp(x))
@log1pexp.defjvp
def log1pexp_jvp(primals, tangents):
(x,), (x_dot,) = primals, tangents
y = log1pexp(x)
# 导数的稳定形式:sigmoid(x)
return y, x_dot * jax.nn.sigmoid(x)
print("custom_jvp grad(100.0) =", jax.grad(log1pexp)(100.0)) # 1.0
print("custom_jvp grad(0.0) =", jax.grad(log1pexp)(0.0)) # 0.5
print("二阶导也能用 (0.0) =", jax.grad(jax.grad(log1pexp))(0.0)) # σ(0)(1-σ(0)) = 0.25
assert jax.grad(log1pexp)(100.0) == 1.0
assert jnp.allclose(jax.grad(jax.grad(log1pexp))(0.0), 0.25)
🔍 补充:jax.lax.stop_gradient——让某一部分不参与求导¶
⚪ 选读:需要截断部分梯度(target 网络、straight-through estimator 等)时看。
stop_gradient(x) 前向计算时原样返回 x,求导时却把它当成常数(梯度为 0)。典型用途有:强化学习中的 target 网络、对比学习的 stop-grad 分支、straight-through estimator 等。
from jax.lax import stop_gradient
def f_normal(x):
return x * x # 导数 2x
def f_stopped(x):
return x * stop_gradient(x) # 第二个 x 被视为常数,导数 = x
print("grad(x*x)(3.0) =", jax.grad(f_normal)(3.0)) # 6.0
print("grad(x*stop_gradient(x))(3.0) =", jax.grad(f_stopped)(3.0)) # 3.0
assert jax.grad(f_normal)(3.0) == 6.0 and jax.grad(f_stopped)(3.0) == 3.0
# 实用技巧:straight-through estimator
# 前向用 round(不可导),反向假装是恒等函数(梯度为 1)
def ste_round(x):
return x + stop_gradient(jnp.round(x) - x)
print("\nste_round(2.7) =", ste_round(2.7), "| 梯度 =", jax.grad(ste_round)(2.7))
assert ste_round(2.7) == 3.0 and jax.grad(ste_round)(2.7) == 1.0
3. 与 Python 控制流的交互¶
grad 只对当前输入实际走过的路径求导。如果函数在不同输入下会切换分支,你拿到的只是当前分支的梯度,在分支边界附近可能会误导人。单独用 grad 时 Python if 能正常工作,但在 jit 下要改用 lax.cond / jnp.where(详见 3.7)。
4. 外部代码与副作用¶
被求导的函数里应该只用 jnp、jax.lax、jax.scipy、jax.nn 这些 JAX 函数。外部库在 grad 下的实际表现分三种情况:
- 不依赖输入的 NumPy 计算(如用
np.random生成的常量数组):确实是常量,梯度不经过它。 - 把追踪器传给 NumPy 函数(如
np.sin(x)):直接报TracerArrayConversionError。SciPy(非jax.scipy)、Pandas、scikit-learn 等库内部会把输入转成 NumPy 数组,结果通常也是报错。 - 少数 NumPy 函数(如
np.sum)会通过鸭子类型转而调用x.sum(),于是碰巧能求导。这靠的是实现细节,不可靠,还是应该一律改用jnp。
打印、读写文件、修改全局变量这类副作用,会破坏 JAX 的函数式假设。在 grad 里,print 打印出来的是追踪器;在 jit 里,它只在追踪时执行一次。想调试数值,请用 jax.debug.print。
⚠️ 注意:被求导函数里的 NumPy 调用(如
np.sum),并不会被悄悄当成常量、让梯度变成 0。np.sin(tracer)会直接报TracerArrayConversionError;np.sum则碰巧能正确求导。只有和输入无关的 NumPy 常量,才是真正的常量。
# ❌ 常见误解:在被求导函数里调用 NumPy,以为它会被当作常量、梯度为 0
x = jnp.array([1.0, 2.0, 3.0])
try:
g_np = jax.grad(lambda x: np.sin(x).sum())(x)
np_raised = False
print("np.sin 版本梯度:", g_np)
except jax.errors.TracerArrayConversionError as e:
np_raised = True
print("np.sin 作用于追踪器 -> TracerArrayConversionError")
assert np_raised, "NumPy 函数作用于 tracer 时应当报错"
# np.sum 碰巧转调 x.sum(),梯度并不是 0
g_npsum = jax.grad(lambda x: np.sum(x**2))(x)
print("np.sum 版本梯度:", g_npsum)
assert jnp.allclose(g_npsum, 2 * x) # 不是 0,而是正确的 2x
# ✅ 推荐写法:被求导函数中一律使用 jnp;NumPy 只用来生成与输入无关的常量
noise = np.random.default_rng(0).random(3) # NumPy 常量
g_jnp = jax.grad(lambda x: jnp.sum(jnp.sin(x) * noise))(x)
print("jnp 版本梯度:", g_jnp)
assert jnp.allclose(g_jnp, jnp.cos(x) * noise)
# 调试数值:jax.debug.print 在 jit 中也会每次执行
@jax.jit
def debug_loss(x):
jax.debug.print("debug_loss 收到的 x = {}", x)
return jnp.sum(x**2)
_ = jax.grad(debug_loss)(x)
5. 非标量输出¶
jax.grad 只适用于 $f:\mathbb R^n\to\mathbb R$(标量输出),函数返回数组时会抛出 TypeError。向量值函数要用 jax.jacfwd、jax.jacrev(求完整雅可比),或者 jax.jvp、jax.vjp(求雅可比和向量的乘积),见 3.3。
def vector_output(x):
return x**2 # 输出是向量,不是标量
x = jnp.array([1.0, 2.0, 3.0])
try:
jax.grad(vector_output)(x)
vec_raised = False
except TypeError as e:
vec_raised = True
print("TypeError:", str(e).splitlines()[0])
assert vec_raised
# 替代方案
J = jax.jacrev(vector_output)(x)
print("\njacrev 得到完整雅可比(对角阵 2x):\n", J)
assert jnp.allclose(J, jnp.diag(2 * x))
# 如果只需要逐元素函数「对每个元素的导数」,对输出求和再 grad 就行
print("grad(sum(f)) =", jax.grad(lambda x: jnp.sum(vector_output(x)))(x))
🔍 补充:用有限差分检验梯度¶
⚪ 选读:写了自定义导数或复杂模型、需要验证梯度是否正确时看。
写了 custom_jvp、手写了反向规则,或者模型比较复杂时,最好用数值微分检验一下梯度对不对。
- 官方工具:
jax.test_util.check_grads(f, args, order, modes),用中心差分同时检查前向(fwd)和反向(rev)模式,order=2还会检查二阶导数。注意要显式import jax.test_util。只import jax就直接访问jax.test_util,会报AttributeError。 - 手写:中心差分 $\dfrac{f(x+h e_i)-f(x-h e_i)}{2h}$。float32 下误差比较大,$h$ 取 $10^{-3}$ 左右,容差也要适当放宽。
import jax.test_util # 必须显式导入
from jax.test_util import check_grads
def g(x):
return jnp.sum(jnp.sin(x) * x**2)
x = jnp.array([0.5, 1.0, 2.0])
# 官方工具:通过时不输出,失败时抛 AssertionError
check_grads(g, (x,), order=2, modes=["fwd", "rev"])
print("check_grads: g 的一、二阶梯度检验通过")
# 故意写错导数(多乘了 2),check_grads 能发现
@jax.custom_jvp
def wrong_sin(x):
return jnp.sin(x)
@wrong_sin.defjvp
def wrong_sin_jvp(primals, tangents):
(x,), (t,) = primals, tangents
return jnp.sin(x), 2 * jnp.cos(x) * t # 错误:正确应为 cos(x) * t
try:
check_grads(wrong_sin, (x,), order=1, modes=["rev"])
caught = False
except AssertionError as e:
caught = True
print("check_grads 发现错误梯度:", str(e).strip().splitlines()[0])
assert caught
# 手写中心差分
def numerical_grad(f, x, h=1e-3):
grads = []
for i in range(x.size):
e = jnp.zeros_like(x).at[i].set(h)
grads.append((f(x + e) - f(x - e)) / (2 * h))
return jnp.array(grads)
print("\n手写中心差分:", numerical_grad(g, x))
print("jax.grad :", jax.grad(g)(x))
assert jnp.allclose(numerical_grad(g, x), jax.grad(g)(x), rtol=1e-3)
3.9 动手实践:计算梯度¶
这一节用几个练习巩固 jax.grad 的用法:从简单函数开始,再到对指定参数求导、高阶导数、value_and_grad,以及 jax.numpy 函数的梯度。
准备工作:导入 jax、jax.numpy、numpy,按需启用 64 位精度,再打印 JAX 版本和默认后端。
准备工作:64 位精度的开启方式¶
⚠️ 注意:旧资料里常见先
from jax.config import config、再config.update("jax_enable_x64", True)的写法。JAX 0.12 已经移除了jax.config子模块,这样写会报ModuleNotFoundError: No module named 'jax.config'。请改用jax.config.update("jax_enable_x64", True),并放在程序开头、创建任何数组之前(也可以用环境变量JAX_ENABLE_X64=1)。
为了让本章其余部分保持 JAX 默认的 float32 行为,下面只演示写法,不真的开启 64 位。
# ❌ 旧写法(过时):jax.config 子模块在 JAX 0.12 中已移除
try:
from jax.config import config
config.update("jax_enable_x64", True)
old_import_ok = True
except ModuleNotFoundError as e:
old_import_ok = False
print("实测报错 ModuleNotFoundError:", e)
assert not old_import_ok
# ✅ 推荐写法
import jax
import jax.numpy as jnp
import numpy as np
# jax.config.update("jax_enable_x64", True) # 需要 64 位时取消注释,并放在程序最开头
print("当前 jax_enable_x64 =", jax.config.jax_enable_x64)
assert hasattr(jax.config, "update")
print(f"JAX 版本: {jax.__version__}")
print(f"默认后端: {jax.default_backend()}")
练习 1:简单多项式的梯度¶
$f(x)=3x^2+2x+5$,解析导数 $f'(x)=6x+2$。在 $x=4$ 处:$f'(4)=6\times4+2=26$。
def poly_func(x):
return 3 * x**2 + 2 * x + 5
# 得到梯度函数
grad_poly = jax.grad(poly_func)
x_val = 4.0
gradient_val = grad_poly(x_val)
analytical = 6 * x_val + 2
print(f"f({x_val}) = {poly_func(x_val)}")
print(f"JAX 梯度: {gradient_val}")
print(f"解析梯度: {analytical}")
assert gradient_val == analytical == 26.0
练习 2:对特定参数求导¶
$g(x,y)=x^3y+2x^2$,偏导数:
$$ \frac{\partial g}{\partial x}=3x^2y+4x,\qquad \frac{\partial g}{\partial y}=x^3 $$
取 $x=2,\ y=3$:$\partial g/\partial x=3\times4\times3+8=44$,$\partial g/\partial y=8$。
def multi_var_func(x, y):
return x**3 * y + 2 * x**2
x_val, y_val = 2.0, 3.0
# 默认对第一个参数 x 求导(argnums=0)
grad_x = jax.grad(multi_var_func)(x_val, y_val)
print(f"∂g/∂x: {grad_x} (解析: {3 * x_val**2 * y_val + 4 * x_val})")
# argnums=1 对 y 求导
grad_y = jax.grad(multi_var_func, argnums=1)(x_val, y_val)
print(f"∂g/∂y: {grad_y} (解析: {x_val**3})")
# argnums=(0, 1) 返回两个梯度组成的元组
grad_xy = jax.grad(multi_var_func, argnums=(0, 1))(x_val, y_val)
print(f"(∂g/∂x, ∂g/∂y): {grad_xy}")
assert grad_xy == (44.0, 8.0)
练习 3:高阶导数¶
对练习 1 的 $f(x)=3x^2+2x+5$,二阶导数 $f''(x)=6$,和 $x$ 无关。套两次 jax.grad 就行。
second_grad_poly = jax.grad(jax.grad(poly_func))
for x_val in [4.0, -1.0, 100.0]:
print(f"f''({x_val}) = {second_grad_poly(x_val)}") # 恒为 6.0
assert second_grad_poly(x_val) == 6.0
练习 4:jax.value_and_grad¶
$h(w)=(w-5)^2$,$h'(w)=2(w-5)$。在 $w=2$ 处:$h(2)=9$,$h'(2)=-6$。
def simple_loss(w):
return (w - 5.0) ** 2
value, grad = jax.value_and_grad(simple_loss)(2.0)
print(f"函数值: {value}") # 9.0
print(f"梯度 : {grad}") # -6.0
assert (value, grad) == (9.0, -6.0)
练习 5:jax.numpy 函数的梯度¶
$k(\mathbf v)=\sum_i\sin(v_i)$,梯度的第 $j$ 个分量为 $\dfrac{\partial k}{\partial v_j}=\cos(v_j)$。取 $\mathbf v=[0,\ \pi/2,\ \pi]$,梯度的数学值为 $[1,\ 0,\ -1]$。
⚠️ 注意:打印结果并不是精确的
[1. 0. -1.],中间一项约为-4.37e-08(float32)。原因和 3.2 一样,是 $\pi/2$ 在 float32 下有舍入,所以要用jnp.allclose判断。
def sum_sin(v):
return jnp.sum(jnp.sin(v))
v = jnp.array([0.0, jnp.pi / 2, jnp.pi])
grad_v = jax.grad(sum_sin)(v)
print("JAX 梯度 :", grad_v)
print("jnp.cos(v) :", jnp.cos(v))
print("与 cos(v) 一致:", jnp.allclose(grad_v, jnp.cos(v)))
assert jnp.allclose(grad_v, jnp.array([1.0, 0.0, -1.0]), atol=1e-6)
小结一下:这些练习覆盖了简单多项式、多参数求导、高阶导数、同时算值和梯度,以及 jax.numpy 函数的梯度。
🔍 补充:完整的线性回归梯度下降¶
⚪ 选读:想看本章 API 串成一个完整训练循环时看。
把本章内容串起来:用 jax.random.key 生成数据,用 dict 存放参数,用 jax.jit(jax.value_and_grad(...)) 写训练步,用 jax.tree.map 更新参数。最后和最小二乘的闭式解对比。
# 1) 生成数据:y = 3x + 2 + 噪声
key = jax.random.key(42)
key_x, key_noise = jax.random.split(key)
n_samples = 200
X = jax.random.uniform(key_x, (n_samples,), minval=-1.0, maxval=1.0)
y = 3.0 * X + 2.0 + 0.1 * jax.random.normal(key_noise, (n_samples,))
# 2) 模型与损失
def predict(params, x):
return params["w"] * x + params["b"]
def mse_loss(params, x, y):
return jnp.mean((predict(params, x) - y) ** 2)
# 3) 训练步:value_and_grad + jit
@jax.jit
def train_step(params, x, y, lr):
loss, grads = jax.value_and_grad(mse_loss)(params, x, y)
new_params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
return new_params, loss
# 4) 训练循环
params = {"w": jnp.array(0.0), "b": jnp.array(0.0)}
lr = 0.1
for step in range(201):
params, loss = train_step(params, X, y, lr)
if step % 40 == 0:
print(f"step {step:3d} | loss = {loss:.5f} | w = {params['w']:.4f} | b = {params['b']:.4f}")
# 5) 与闭式解(最小二乘)对比
A = jnp.stack([X, jnp.ones_like(X)], axis=1)
w_ls, b_ls = jnp.linalg.lstsq(A, y)[0]
print(f"\n梯度下降: w = {params['w']:.4f}, b = {params['b']:.4f}")
print(f"最小二乘: w = {w_ls:.4f}, b = {b_ls:.4f}")
assert jnp.allclose(params["w"], w_ls, atol=1e-3) and jnp.allclose(params["b"], b_ls, atol=1e-3)
补充练习¶
下面几道练习更贴近实战,建议先自己写,再看参考答案。
练习 A:逻辑回归的梯度,并与解析式对照¶
二分类逻辑回归:$p_i=\sigma(\mathbf x_i^\top\mathbf w+b)$,平均交叉熵损失
$$ L=-\frac1N\sum_i\big[y_i\log p_i+(1-y_i)\log(1-p_i)\big] $$
它的解析梯度为 $\nabla_{\mathbf w}L=\frac1N X^\top(\mathbf p-\mathbf y)$,$\nabla_b L=\frac1N\sum_i(p_i-y_i)$。
要求:
- 参数用 dict
{"w": ..., "b": ...}存放; - 用数值稳定的方式实现损失(提示:$\log\sigma(z)=-\mathrm{softplus}(-z)$,可用
jax.nn.log_sigmoid); - 用
jax.grad求梯度,与解析梯度对比; - 用
has_aux=True顺便返回准确率。
自检接口:定义 logistic_loss(params, X, y) -> (loss, acc),其中 params = {"w": ..., "b": ...}。
# ✍️ 作答区:练习 A
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 A
try:
logistic_loss
except NameError as e:
print(f"⏳ 练习 A 尚未作答:{e}")
else:
_k1, _k2, _k3 = jax.random.split(jax.random.key(99), 3)
_X = jax.random.normal(_k1, (64, 4))
_y = (jax.random.uniform(_k2, (64,)) > 0.5).astype(jnp.float32)
_p = {"w": jax.random.normal(_k3, (4,)), "b": jnp.array(0.3)}
_out = logistic_loss(_p, _X, _y)
assert isinstance(_out, tuple) and len(_out) == 2, "logistic_loss 应返回 (loss, acc) 两个值"
_loss, _acc = _out
_Xn, _yn = np.asarray(_X, np.float64), np.asarray(_y, np.float64)
def _ref(w, b): # float64 下的参考值:-log σ(z) = log(1 + e^{-z})
z = _Xn @ np.asarray(w, np.float64) + float(b)
return np.mean(_yn * np.logaddexp(0, -z) + (1 - _yn) * np.logaddexp(0, z)), z
_lref, _z = _ref(_p["w"], _p["b"])
assert np.isclose(float(_loss), _lref, rtol=1e-4), f"损失 {float(_loss):.6f} 不对,应为 {_lref:.6f}"
assert np.isclose(float(_acc), np.mean((_z > 0) == (_yn > 0.5)), atol=1e-6), "准确率不对"
_loss_only = lambda p: logistic_loss(p, _X, _y)[0]
_g = jax.grad(_loss_only)(_p)
_pr = 1 / (1 + np.exp(-_z))
assert np.allclose(_g["w"], _Xn.T @ (_pr - _yn) / 64, atol=1e-5), "对 w 的梯度与解析式不一致"
assert np.isclose(float(_g["b"]), np.mean(_pr - _yn), atol=1e-5), "对 b 的梯度与解析式不一致"
_big = {"w": _p["w"] * 1000.0, "b": _p["b"]} # logit 达到几千:朴素写法会得到 inf/NaN
_lb = float(_loss_only(_big))
_gb = jax.grad(_loss_only)(_big)
assert np.isfinite(_lb) and all(np.all(np.isfinite(np.asarray(v))) for v in _gb.values()), \
"logit 很大时损失或梯度出现了 inf/NaN:损失需要用数值稳定的写法"
assert np.isclose(_lb, _ref(_big["w"], _big["b"])[0], rtol=1e-3), "logit 很大时损失的数值不对"
print("✅ 练习 A 自检通过")
练习 B:写一个在 0 处梯度安全的 safe_sqrt¶
实现函数 safe_sqrt(x):$x>0$ 时等于 $\sqrt x$,$x\le 0$ 时等于 $0$,并满足:
jax.grad(safe_sqrt)(0.0)和jax.grad(safe_sqrt)(-1.0)是有限值(取 0);jax.grad(safe_sqrt)(4.0) == 0.25;- 能处理向量输入(配合
jnp.sum求梯度)。
分别用「双 where」和 jax.custom_jvp 两种方法实现,并用 check_grads 在 $x>0$ 的点上检验。
自检接口:定义 safe_sqrt_where(x)(双 where)和 safe_sqrt_custom(x)(jax.custom_jvp)。
# ✍️ 作答区:练习 B
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 B
try:
safe_sqrt_where, safe_sqrt_custom
except NameError as e:
print(f"⏳ 练习 B 尚未作答:{e}")
else:
assert isinstance(safe_sqrt_custom, jax.custom_jvp), "safe_sqrt_custom 应该用 @jax.custom_jvp 定义"
_xs = jnp.array([-3.0, -1.0, 0.0, 1e-6, 0.25, 4.0, 9.0])
_xn = np.asarray(_xs, np.float64)
_vref = np.sqrt(np.maximum(_xn, 0.0))
_gref = np.where(_xn > 0, 0.5 / np.sqrt(np.maximum(_xn, 1e-30)), 0.0)
for _name, _fn in [("safe_sqrt_where", safe_sqrt_where), ("safe_sqrt_custom", safe_sqrt_custom)]:
_v = np.asarray(_fn(_xs))
assert np.allclose(_v, _vref, rtol=1e-5, atol=1e-7), f"{_name} 的函数值不对:期望 {_vref},实际 {_v}"
_gsum = jax.grad(lambda x: jnp.sum(_fn(x)))
for _tag, _gfn in [("", _gsum), ("(jit 下)", jax.jit(_gsum))]:
_g = np.asarray(_gfn(_xs))
assert np.all(np.isfinite(_g)), f"{_name}{_tag} 的梯度出现了 inf/NaN:{_g}"
assert np.allclose(_g, _gref, rtol=1e-4), f"{_name}{_tag} 的梯度不对:期望 {_gref},实际 {_g}"
assert float(jax.grad(_fn)(4.0)) == 0.25, f"{_name}:grad 在 4.0 处应为 0.25"
_, _t = jax.jvp(_fn, (_xs,), (jnp.ones_like(_xs),))
assert np.all(np.isfinite(np.asarray(_t))), f"{_name}:前向模式(jvp)在非正数处给出了 inf/NaN"
print("✅ 练习 B 自检通过")
练习 C:不构造 Hessian,计算 Hessian-向量积¶
对 $f(\mathbf x)=\sum_i x_i^4 + \big(\sum_i x_i\big)^2$,给定向量 $\mathbf v$,计算 $H\mathbf v$。
要求:
- 用
jax.jvp(jax.grad(f), (x,), (v,))实现hvp(f, x, v)(前向套反向,不用构造 $n\times n$ 的 Hessian,内存开销和一次梯度计算差不多); - 在 $n=5$ 时和
jax.hessian(f)(x) @ v对比; - 想一想:为什么不直接对
jax.grad(f)(x) @ v再求grad?(提示:那也是一种可行的写法,叫 reverse-over-reverse,只是内存开销更大。)
自检接口:定义 hvp(f, x, v),对任意标量函数 f 返回 $H\mathbf v$。
# ✍️ 作答区:练习 C
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 C
try:
hvp
except NameError as e:
print(f"⏳ 练习 C 尚未作答:{e}")
else:
_n = 13
_a = jnp.linspace(-1.0, 1.0, _n)
def _g(x): # 换一个新函数,检验 hvp 对任意 f 都成立
return jnp.sum(jnp.sin(x) * x**2) + jnp.sum(_a * x) ** 3
_kx, _kv = jax.random.split(jax.random.key(3))
_x, _v = jax.random.normal(_kx, (_n,)), jax.random.normal(_kv, (_n,))
_xn, _vn, _an = (np.asarray(t, np.float64) for t in (_x, _v, _a))
_diag = (2 - _xn**2) * np.sin(_xn) + 4 * _xn * np.cos(_xn) # (x² sin x)'' 的解析式
_Hv_ref = _diag * _vn + 6 * (_an @ _xn) * _an * (_an @ _vn)
_Hv = np.asarray(hvp(_g, _x, _v))
assert _Hv.shape == (_n,), f"hvp 应返回长度 {_n} 的向量,实际形状 {_Hv.shape}"
assert np.allclose(_Hv, _Hv_ref, rtol=1e-4, atol=1e-4), f"hvp 结果不对:\n 期望 {_Hv_ref}\n 实际 {_Hv}"
_jx = str(jax.make_jaxpr(lambda x, v: hvp(_g, x, v))(_x, _v))
assert f"[{_n},{_n}]" not in _jx, f"hvp 的计算过程中出现了 {_n}×{_n} 的矩阵:不要构造完整的 Hessian"
print("✅ 练习 C 自检通过")
练习 D:用 grad 的 grad 实现牛顿法¶
牛顿法求一元函数极小值:$x_{k+1}=x_k-\dfrac{f'(x_k)}{f''(x_k)}$。
对 $f(x)=x^4-3x^2+x$,从 $x_0=2$ 出发迭代 8 步,打印每步的 $x$、$f'(x)$,并与梯度下降(学习率 0.01,同样 8 步)比较收敛速度。要求整个迭代函数可以被 jax.jit(提示:用 lax.fori_loop,上下界用 Python 常量)。
自检接口:定义一阶、二阶导数函数 df、d2f,单步牛顿更新 newton_step(x),以及梯度下降 gd(x0)(8 步、学习率 0.01,可以被 jax.jit)。
# ✍️ 作答区:练习 D
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 D
try:
df, d2f, newton_step, gd
except NameError as e:
print(f"⏳ 练习 D 尚未作答:{e}")
else:
_fp = lambda x: 4 * x**3 - 6 * x + 1 # f(x) = x⁴ - 3x² + x 的导数(解析式)
_fpp = lambda x: 12 * x**2 - 6
for _x0 in [-1.7, 0.4, 1.3, 2.5]:
assert np.isclose(float(df(_x0)), _fp(_x0), rtol=1e-5, atol=1e-5), f"df({_x0}) = {float(df(_x0))},应为 {_fp(_x0):.6f}"
assert np.isclose(float(d2f(_x0)), _fpp(_x0), rtol=1e-5, atol=1e-5), f"d2f({_x0}) = {float(d2f(_x0))},应为 {_fpp(_x0):.6f}"
_want = _x0 - _fp(_x0) / _fpp(_x0)
assert np.isclose(float(newton_step(_x0)), _want, rtol=1e-5, atol=1e-5), f"newton_step({_x0}) 应为 {_want:.6f}"
_xk = 2.0
for _ in range(8):
_xk = newton_step(_xk)
assert abs(_fp(float(_xk))) < 1e-4, f"从 2 出发迭代 8 步后 f'(x) 应接近 0,实际 {_fp(float(_xk)):.2e}"
for _x0 in [2.0, -0.5]:
_xr = _x0
for _ in range(8):
_xr = _xr - 0.01 * _fp(_xr)
assert np.isclose(float(gd(_x0)), _xr, rtol=1e-5), f"gd({_x0}) = {float(gd(_x0)):.6f},8 步学习率 0.01 的梯度下降应得到 {_xr:.6f}"
jax.jit(gd)(jnp.float32(2.0)) # 整个迭代函数可以被 jit
print("✅ 练习 D 自检通过")
第 3 章测验¶
下面 8 道题围绕本章内容,帮你检查掌握得怎么样,有几道专门考容易踩的坑。点击「答案与解析」展开。
1. 下面哪一项对 jax.grad(f) 的描述是正确的?
- A. 它立即计算
f的梯度并返回一个数组 - B. 它返回一个新函数,该函数默认计算
f的输出对第一个位置参数的梯度 - C. 它返回一个新函数,该函数计算
f对所有参数的梯度 - D. 它只能用于单变量函数
答案与解析
B。grad 是函数变换,返回新函数,默认 argnums=0。要对多个参数求导,得显式传 argnums=(0, 1, ...)。它也能处理数组、pytree 形式的多变量输入。
2. 对 def f(w, b, x): return w * x + b,jax.grad(f, argnums=(0, 1))(2.0, 1.0, 5.0) 的结果是?
- A.
5.0 - B.
(5.0, 1.0) - C.
(5.0, 1.0, 2.0) - D.
[5.0, 1.0](一个数组)
答案与解析
B。argnums 是元组时返回元组,顺序和 argnums 一致。$\partial/\partial w=x=5$,$\partial/\partial b=1$,x 当作常数。
3. 为什么 jax.grad 采用反向模式自动微分?
- A. 反向模式比前向模式更精确
- B. 反向模式不需要保存中间值,内存更省
- C. 对「输入很多、输出是一个标量」的函数,反向模式一次传播就能得到对所有输入的梯度
- D. 前向模式无法处理
jnp.sin等超越函数
答案与解析
C。两种模式精度相同(A 错);反向模式恰恰需要保存前向中间值,内存开销更大(B 错);前向模式同样支持所有可导原语(D 错)。$f:\mathbb R^n\to\mathbb R^m$ 时,前向模式需要 $n$ 次、反向模式需要 $m$ 次传播。
4. 要计算 $f:\mathbb R^{3}\to\mathbb R^{10000}$ 的完整雅可比矩阵,哪个更高效?
- A.
jax.jacfwd - B.
jax.jacrev - C. 两者一样
- D.
jax.grad
答案与解析
A。输入维度(3)远小于输出维度(10000),前向模式只需 3 次 JVP,反向模式需要 10000 次 VJP。grad 只能用于标量输出。
5. 已知 def f(x): return x*x if x > 0 else -x,在 JAX 0.12 中执行以下两行,结果分别是?
jax.grad(f)(-2.0)
jax.jit(jax.grad(f))(-2.0)
- A. 都返回
-1.0 - B. 都报错
- C. 第一行返回
-1.0,第二行报TracerBoolConversionError - D. 第一行报错,第二行返回
-1.0
答案与解析
C。单独 grad 时追踪器带着具体值,Python if 能判断,而且每次调用都会重新追踪。jit 用的是抽象追踪器,没法转成布尔值。容易以为第一行就会报错,其实不会(见 3.7)。想让两行都能用,就改用 jax.lax.cond 或 jnp.where。
6. jax.grad(lambda x: jnp.where(x > 0, jnp.log(x), 0.0))(0.0) 的结果是?
- A.
0.0 - B.
nan - C.
inf - D. 报错
答案与解析
B。where 两个分支都会算。没选中的分支上游梯度是 0,但 $0\times\frac{1}{0}=0\times\infty=\text{NaN}$。解决办法是「双 where」:先把输入换成安全值,再取 log。
7. 关于 jax.value_and_grad(loss_fn, has_aux=True)(params, batch) 的返回值,正确的解包方式是?
- A.
loss, aux, grads = ... - B.
(loss, grads), aux = ... - C.
(loss, aux), grads = ... - D.
loss, (aux, grads) = ...
答案与解析
C。值的部分是 loss_fn 原样返回的 (loss, aux),梯度部分只对 loss 求导。
8. 下面哪个循环写法可以在 jax.jit 下用 jax.grad 反向求导?
- A.
lax.while_loop(cond, body, x) - B.
lax.fori_loop(0, n, body, x),其中n是jit函数的普通(非静态)参数 - C.
lax.scan(body, x, None, length=10) - D. 以上都可以
答案与解析
C。while_loop 只支持前向模式。fori_loop 的上下界是追踪值时会退化成 while_loop,同样不能反向求导。scan 的次数固定,可以反向求导。如果一定要用 fori_loop,可以把 n 设为 static_argnums。
本章小结¶
- 梯度是标量函数对各输入的偏导组成的向量,指向增长最快的方向;梯度下降沿负梯度更新参数:$\theta\leftarrow\theta-\eta\nabla f(\theta)$。
- 自动微分在基本运算层面应用链式法则,结果精确到机器精度;数值微分只适合用来检验梯度。
jax.grad是函数变换:输入标量输出函数,返回梯度函数;默认对第一个参数求导,用argnums指定其他参数。grad使用反向模式(反向传播):先前向计算并保存中间值,再从输出反向传播敏感度。适合「输入多、输出为标量」的情况。- 前向模式 =
jax.jvp($Jv$),反向模式 =jax.vjp($u^\top J$);完整雅可比:输入少用jacfwd,输出少用jacrev;jax.hessian = jacfwd(jacrev(f))。 - 变换可以组合:
grad(grad(f))求高阶导,jax.jvp(jax.grad(f), ...)求 Hessian-向量积,jit(value_and_grad(f))写训练步。 - 参数可以是 pytree(dict/list/tuple),梯度结构与参数相同,用
jax.tree.map更新。 - 函数需要返回额外信息时用
has_aux=True;value_and_grad+has_aux返回((loss, aux), grads)。 - 控制流:单独用
grad时 Pythonif可以工作,jit下需要lax.cond/jnp.where;while_loop及动态上下界的fori_loop不支持反向求导,用scan或静态次数。 - 常见坑:整数输入报错;不连续函数梯度为 0;
abs在 0 处梯度为 1;sqrt/norm在 0 处产生 inf/NaN;jnp.where不能挡住 NaN 梯度,需用双 where;被求导代码里不要用np;输出必须是标量。 - 工具:
stop_gradient截断梯度;custom_jvp/custom_vjp自定义导数;jax.test_util.check_grads检验梯度。 - 64 位精度:
jax.config.update("jax_enable_x64", True)(from jax.config import config已失效)。
速查表¶
| API | 作用 |
|---|---|
jax.grad(f, argnums=0, has_aux=False, allow_int=False) | 返回计算标量函数梯度的函数 |
jax.value_and_grad(f, argnums=0, has_aux=False) | 一次前向计算同时返回 (值, 梯度) |
argnums=1 / argnums=(0, 1) | 对第 2 个参数 / 对多个参数求导(返回元组) |
has_aux=True | 函数返回 (标量, 辅助信息),辅助信息不参与求导 |
jax.jvp(f, primals, tangents) | 前向模式:返回 (f(x), J·v) |
jax.vjp(f, *primals) | 反向模式:返回 (f(x), vjp_fn),vjp_fn(u) = uᵀJ |
jax.jacfwd(f) | 前向模式求完整雅可比,适合输入维度小 |
jax.jacrev(f) / jax.jacobian(f) | 反向模式求完整雅可比,适合输出维度小(jacobian 是 jacrev 的别名) |
jax.hessian(f) | Hessian 矩阵,等价于 jacfwd(jacrev(f)) |
jax.lax.stop_gradient(x) | 前向原样返回,反向视为常数 |
jax.custom_jvp / jax.custom_vjp | 自定义前向 / 反向求导规则 |
jax.lax.cond(pred, t_fn, f_fn, *ops) | 可 jit、可求导的条件分支 |
jax.lax.fori_loop(lo, hi, body, init) | 计数循环;上下界为常量时可反向求导 |
jax.lax.while_loop(cond, body, init) | while 循环;只支持前向模式 |
jax.lax.scan(f, init, xs, length) | 固定次数循环,可反向求导 |
jnp.where(c, a, b) | 逐元素选择;处理不可导点时用「双 where」 |
jax.tree.map(fn, params, grads) | 对 pytree 参数逐叶子更新 |
jax.test_util.check_grads(f, args, order, modes) | 用有限差分检验梯度(需显式 import jax.test_util) |
jax.make_jaxpr(f)(x) | 查看追踪得到的 jaxpr(也可查看梯度函数的) |
jax.config.update("jax_enable_x64", True) | 启用 64 位精度 |
本章更新说明¶
下面汇总本章在 JAX 0.12 下需要注意的写法和结论,每条都在正文对应位置用 assert 验证过。
| 编号 | 小节 | 要点 | 位置 |
|---|---|---|---|
| 1 | 3.2 | jax.jacobian 就是 jax.jacrev 的别名,并不建在 jax.grad 之上;jacrev、grad 都基于 jax.vjp,jacfwd 基于 jax.jvp | 3.2 介绍 jax.grad |
| 2 | 3.2 | 求导的输入用整数会直接报 TypeError;整数参数放在 argnums 之外,或用 allow_int=True(梯度为 float0) | 3.2 介绍 jax.grad |
| 3 | 3.2 | grad(sin)(π/2) 在 float32 下约为 -4.37e-08,不是精确的 0.0,要用 jnp.allclose 比较 | 3.2 介绍 jax.grad |
| 4 | 3.4 | 参数是 dict 时,params - learning_rate * gradients 会报 TypeError;要用 jax.tree.map(lambda p, g: p - lr * g, params, grads) | 3.4 关于参数求导 |
| 5 | 3.6 | a²·sin(b) 对 b 的梯度在 b=π/2 处,float32 下约为 -3.93e-07,不是精确的 0 | 3.6 值和梯度(jax.value_and_grad) |
| 6 | 3.7 | 单独用 grad 时每次调用都重新追踪,追踪器带具体值,含 Python if 的函数在 x=-2.0 处正常返回 -1.0;只有 jit(grad(f)) 才报 TracerBoolConversionError | 3.7 求导与控制流 |
| 7 | 3.7 | lax.while_loop 和动态次数的 lax.fori_loop 只支持前向模式,grad 穿过它们会报 ValueError;需要反向求导时用 lax.scan 或 static_argnums | 3.7 求导与控制流 |
| 8 | 3.8 | jax.grad(jnp.abs)(0.0) 为 1.0(取右导数) | 3.8 局限性与注意事项 |
| 9 | 3.8 | jax.grad(jnp.sign)(0.0) 为确定的 0.0,不是 NaN,也不随平台变化 | 3.8 局限性与注意事项 |
| 10 | 3.8 | 被求导函数里的 NumPy 调用不会被当作常量:np.sin(tracer) 报 TracerArrayConversionError,np.sum 碰巧能正确求导;只有与输入无关的 NumPy 常量才是常量 | 3.8 局限性与注意事项 |
| 11 | 3.8 | jnp.where 挡不住反向传播里的 0×inf,会得到 NaN 梯度;用「双 where」处理 sqrt/log/norm/除法/arcsin | 3.8 局限性与注意事项 |
| 12 | 3.9 | from jax.config import config 会报 ModuleNotFoundError;开启 64 位精度要用 jax.config.update("jax_enable_x64", True) | 3.9 动手实践:计算梯度 |
| 13 | 3.9 | 练习 5 中 sum(sin(v)) 在 v=[0, π/2, π] 处的梯度,中间项在 float32 下约为 -4.37e-08,不是精确的 [1. 0. -1.] | 3.9 动手实践:计算梯度 |
| 14 | 3.3 | 前向模式 = jax.jvp(得到雅可比的列),反向模式 = jax.vjp(得到行);jacfwd/jacrev 按输入/输出维度选择 | 3.3 自动微分的工作方式:反向模式 |
| 15 | 3.4 | grad 支持 dict 等 pytree 参数;需要返回额外信息时用 has_aux=True | 3.4 关于参数求导 |
| 16 | 3.5 | jax.hessian = jacfwd(jacrev(f));规模大时用 jvp(grad(f)) 求 Hessian-向量积 | 3.5 高阶导数(grad 的 grad) |
| 17 | 3.8 | 自定义导数与梯度检验:jax.custom_jvp/custom_vjp、jax.lax.stop_gradient、jax.test_util.check_grads(需显式导入) | 3.8 局限性与注意事项 |
| 18 | 3.3 | 单独用 grad 时追踪器带具体数值(GradTracer(primal=...)),只带形状和 dtype 的抽象追踪器是 jit 的行为 | 3.3 自动微分的工作方式:反向模式 |
练习 A¶
题目见正文「练习 A:逻辑回归的梯度,并与解析式对照」。
# 参考答案:练习 A
key = jax.random.key(0)
k1, k2, k3 = jax.random.split(key, 3)
N, D = 100, 3
X = jax.random.normal(k1, (N, D))
true_w = jnp.array([1.5, -2.0, 0.5])
y = (X @ true_w + 0.3 * jax.random.normal(k2, (N,)) > 0).astype(jnp.float32)
params = {"w": 0.1 * jax.random.normal(k3, (D,)), "b": jnp.array(0.0)}
def logistic_loss(params, X, y):
z = X @ params["w"] + params["b"]
# 稳定写法:log σ(z) 与 log(1-σ(z)) = log σ(-z)
loss = -jnp.mean(y * jax.nn.log_sigmoid(z) + (1 - y) * jax.nn.log_sigmoid(-z))
acc = jnp.mean((z > 0) == (y > 0.5))
return loss, acc
grads, acc = jax.grad(logistic_loss, has_aux=True)(params, X, y)
# 解析梯度
p = jax.nn.sigmoid(X @ params["w"] + params["b"])
grad_w_analytic = X.T @ (p - y) / N
grad_b_analytic = jnp.mean(p - y)
assert jnp.allclose(grads["w"], grad_w_analytic, atol=1e-6)
assert jnp.allclose(grads["b"], grad_b_analytic, atol=1e-6)
print("断言通过:jax.grad 与解析梯度一致")
print("初始准确率:", acc)
练习 B¶
题目见正文「练习 B:写一个在 0 处梯度安全的 safe_sqrt」。
# 参考答案:练习 B
from jax.test_util import check_grads
# 方法 1:双 where
def safe_sqrt_where(x):
pos = x > 0
x_safe = jnp.where(pos, x, 1.0)
return jnp.where(pos, jnp.sqrt(x_safe), 0.0)
# 方法 2:custom_jvp
@jax.custom_jvp
def safe_sqrt_custom(x):
return jnp.sqrt(jnp.maximum(x, 0.0))
@safe_sqrt_custom.defjvp
def _safe_sqrt_jvp(primals, tangents):
(x,), (t,) = primals, tangents
y = safe_sqrt_custom(x)
pos = x > 0
# 导数 1/(2 sqrt(x)),非正处定义为 0;同样要避免 1/0
dy = jnp.where(pos, 0.5 / jnp.where(pos, y, 1.0), 0.0)
return y, dy * t
xs = jnp.array([-1.0, 0.0, 4.0])
for name, fn in [("双 where", safe_sqrt_where), ("custom_jvp", safe_sqrt_custom)]:
g = jax.grad(lambda x: jnp.sum(fn(x)))(xs)
print(f"{name:10s} 值 = {fn(xs)} | 梯度 = {g}")
# 只在可导区域(x>0)做有限差分检验
x_pos = jnp.array([0.5, 1.0, 4.0])
check_grads(safe_sqrt_where, (x_pos,), order=1, modes=["rev"])
check_grads(safe_sqrt_custom, (x_pos,), order=1, modes=["fwd", "rev"])
print("check_grads 通过")
for fn in (safe_sqrt_where, safe_sqrt_custom):
assert jnp.all(jnp.isfinite(jax.grad(lambda x: jnp.sum(fn(x)))(xs)))
assert jax.grad(fn)(4.0) == 0.25
练习 C¶
题目见正文「练习 C:不构造 Hessian,计算 Hessian-向量积」。
# 参考答案:练习 C
def f_quartic(x):
return jnp.sum(x**4) + jnp.sum(x) ** 2
def hvp(f, x, v):
# 前向模式对 grad(f) 求方向导数:d/dε ∇f(x + ε v) = H v
return jax.jvp(jax.grad(f), (x,), (v,))[1]
def hvp_rev_rev(f, x, v):
# 另一种写法:∇_x (∇f(x) · v) = H v
return jax.grad(lambda x: jnp.vdot(jax.grad(f)(x), v))(x)
key = jax.random.key(1)
kx, kv = jax.random.split(key)
x = jax.random.normal(kx, (5,))
v = jax.random.normal(kv, (5,))
hv1 = hvp(f_quartic, x, v)
hv2 = hvp_rev_rev(f_quartic, x, v)
hv_full = jax.hessian(f_quartic)(x) @ v
print("jvp(grad) :", hv1)
print("grad(grad·v):", hv2)
print("hessian @ v:", hv_full)
assert jnp.allclose(hv1, hv_full, atol=1e-4) and jnp.allclose(hv2, hv_full, atol=1e-4)
print("断言通过:三种方法结果一致")
练习 D¶
题目见正文「练习 D:用 grad 的 grad 实现牛顿法」。
# 参考答案:练习 D
def f_newton(x):
return x**4 - 3 * x**2 + x
df = jax.grad(f_newton)
d2f = jax.grad(df)
@jax.jit
def newton_step(x):
return x - df(x) / d2f(x)
x = 2.0
print("牛顿法:")
for k in range(8):
x = newton_step(x)
print(f" 第 {k + 1} 步: x = {x:.8f}, f'(x) = {df(x):.2e}")
# 梯度下降对比:整个循环放进 jit,fori_loop 上下界是常量,可反向求导,也可以 jit
@jax.jit
def gd(x0):
return jax.lax.fori_loop(0, 8, lambda i, x: x - 0.01 * df(x), x0)
x_gd = gd(2.0)
print(f"\n梯度下降 8 步: x = {x_gd:.6f}, f'(x) = {df(x_gd):.2e}")
assert abs(df(x)) < 1e-5 and abs(df(x_gd)) > abs(df(x)) # 牛顿法收敛远快于梯度下降