第 11 章 科学计算与可微分仿真
运行环境:JAX / jaxlib 0.12.0.1,仅 CPU,Python 3.14,只用已安装的
jax、numpy、scipy、optax、matplotlib。本章全程使用 float64,所有结论都在这个环境里跑过,并用assert验证。
前面章节的例子大多是机器学习。本章换到计算力学 / 数值 PDE / 网格生成方向:同样是 grad、jit、vmap、scan,用在有限差分算子、Krylov 求解器、伴随灵敏度和网格优化上。
学习目标
- 理解 JAX 在科学计算里的定位:反问题、灵敏度分析、优化设计都依赖自动微分;参数扫描靠
jit+vmap;知道 float64 为什么必须在最开头开启。 - 会写**无矩阵(matrix-free)**的五点差分算子,用
jax.scipy.sparse.linalg.cg求解 Poisson 方程,并验证二阶收敛。 - 会用
jax.experimental.sparse.BCOO组装稀疏矩阵、做矩阵向量乘、用sparsify,并清楚 JAX 稀疏支持的边界。 - 掌握求解器的隐式微分:伴随方法的数学原理、
lax.custom_linear_solve与lax.custom_root的用法,以及它为什么比「对迭代展开求导」更省内存、更精确。 - 能用
grad+ Optax(L-BFGS / Adam)从观测数据反演空间变化的系数场 $k(x)$。 - 会用
lax.scan做显式 / Crank–Nicolson 时间推进,对物理参数和初始条件求灵敏度,并用jax.checkpoint控制长时间积分的内存。 - 会定义网格质量度量,用 Winslow 型能量 + 势垒函数做可微网格优化,并写出数值稳定(双 where)的势垒。
- 会用
vmap一次求解一批参数下的 PDE。
目录
| 节 | 内容 |
|---|---|
| 0 | 环境:开启 float64,并 assert |
| 1 | 为什么用 JAX 做科学计算;float64 的必要性(含 ❌/✅ 对照) |
| 2 | 有限差分 Poisson:无矩阵算子、cg 求解、二阶收敛 |
| 3 | 稀疏矩阵:BCOO 组装、matvec、sparsify、与 SciPy CSR 对拍、现状与限制 |
| 4 | 求解器的隐式微分:伴随方法、展开求导 vs custom_linear_solve、custom_root |
| 5 | 反问题:反演变系数扩散方程的 $k(x)$ |
| 6 | 时间相关问题:热方程、显式 / Crank–Nicolson、灵敏度、checkpoint |
| 7 | 可微网格优化:质量度量、Laplace vs Winslow、势垒与双 where |
| 8 | 参数扫描:vmap 批量求解 |
| 9 | 实战规则清单(含闭包常量 bug 在 float64 下的检验) |
| — | 练习(4 道)/ 测验(6 道)/ 本章小结 / 速查表 |
和前面章节的关系(已经讲过的不再重复)
- 隐函数定理、用
custom_vjp写不动点求导、DEQ 隐式层、ODE 连续伴随法,已在 进阶 04:进阶自动微分 讲过(4.6 节补充、4.10 节综合实践、补充练习 C)。本章不再手写custom_vjp,改用 JAX 专门为线性 / 非线性求解器准备的两个原语lax.custom_linear_solve和lax.custom_root,并把伴随方法用到 PDE 上。 lax.scan的用法、RK4 积分器、checkpoint+scan的基本原理,见 进阶 01:高级变换与控制流(1.2、1.5、1.8 节);while_loop不能反向求导,见进阶 04 的 4.8 节。本章的时间推进只关注 PDE 特有的问题:稳定性条件、隐式格式、长时间积分的 $O(\sqrt{T})$ 检查点。- 检查点策略(policy)和 $\sqrt{L}$ 分段的系统讲解见 进阶 06:大规模模型训练 6.6 节。
- 「双 where」技巧的原理见 入门 03:自动微分 grad 3.8 节的补充,本章在第 7 节把它用在网格势垒函数上。
- 本环境的 CPU 后端 bug 见 00 目录与导读。
⚠️ 环境注意:jaxlib 0.12.0.1 的 CPU 后端有个已知 bug:被
jit闭包捕获的常量矩阵做W @ x再求和(或求 max)会悄悄算错(详见 00 目录与导读「本环境已知问题」)。第 9 节会检查它在 float64 下的表现。不管结论如何,本章都遵守同一条规则:算子矩阵、系数场和观测数据一律作为函数参数传入,关键数值用 NumPy / SciPy float64 对拍。
0 环境:开启 float64¶
jax.config.update("jax_enable_x64", True) 必须放在第一个代码单元的最前面,在创建任何 JAX 数组之前执行。原因见 1.2 节的 ❌/✅ 对照。下面的单元开启之后,马上用 assert 确认默认 dtype 已经是 float64。
# 第一件事:开启 64 位精度(必须在创建任何 JAX 数组之前)
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
# 自证:默认浮点类型、随机数、Python 标量转换都是 float64
assert jnp.zeros(3).dtype == jnp.float64
assert jnp.asarray(1.0).dtype == jnp.float64
assert jax.random.normal(jax.random.key(0), (2,)).dtype == jnp.float64
assert jnp.arange(3).dtype == jnp.int64
print("默认浮点 dtype:", jnp.zeros(3).dtype)
# 公共导入
import os, sys, time, json, inspect, subprocess, textwrap
from functools import partial
import numpy as np
import scipy
import scipy.linalg
import scipy.sparse as sps
import scipy.sparse.linalg as spsl
import optax
import matplotlib
import matplotlib.pyplot as plt
from jax import lax
import jax.scipy.sparse.linalg as jsl
from jax.experimental import sparse
from jax.ad_checkpoint import print_saved_residuals
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Heiti SC", "Arial Unicode MS", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
print("JAX", jax.__version__, "| optax", optax.__version__, "| scipy", scipy.__version__,
"| numpy", np.__version__, "| matplotlib", matplotlib.__version__)
print("设备:", jax.devices())
def rel(a, b):
"""相对误差 ||a-b|| / ||b||(全部转成 NumPy float64 计算)"""
a, b = np.asarray(a, np.float64), np.asarray(b, np.float64)
return float(np.linalg.norm(a - b) / np.linalg.norm(b))
def timeit(fn, *args, n=20):
"""先预热(编译),再计时 n 次取平均,单位毫秒。只用于打印,不做 assert。"""
jax.block_until_ready(fn(*args))
t = time.perf_counter()
for _ in range(n):
jax.block_until_ready(fn(*args))
return (time.perf_counter() - t) / n * 1e3
1 为什么用 JAX 做科学计算¶
1.1 三个核心价值¶
⚪ 选读:想了解 JAX 在科学计算中的定位和代价时
(1) 自动微分让「求导」不再是额外的工程。 计算力学里有大量问题需要导数:
| 问题 | 要求的导数 | 传统做法 |
|---|---|---|
| 反问题 / 参数辨识:由观测反推材料系数、边界条件、源项 | 损失对整个系数场的梯度(成千上万个参数) | 手推并手写伴随方程,容易出错,模型一改就要重写 |
| 灵敏度分析:输出量对参数的变化率 | $\partial Q/\partial p$ | 有限差分,每个参数解一次 PDE,还要调步长 |
| 优化设计:形状优化、拓扑优化、网格优化 | 目标函数对设计变量的梯度 | 同上 |
反向模式 AD 计算一个标量目标对 $n$ 个参数的梯度,成本约为几次前向求解,与 $n$ 无关。再配合隐式微分(第 4 节),连迭代求解器也能以「多解一次伴随方程」的代价求导。
(2) jit + vmap 适合参数扫描与 UQ。 同一个求解器写一次,vmap 之后就能一次求解一批参数、一批载荷工况或一批随机样本(第 8 节),jit 把整个流程编译成一个 XLA 程序。
(3) 可组合。 grad(vmap(solve))、vmap(grad(loss))、jit(scan(step)) 可以任意嵌套,求解器、优化器和后处理都写成纯函数。
代价也要心里有数:JAX 没有成熟的稀疏直接求解器(第 3 节),不规则网格要自己用 gather/scatter 组装;对动态形状(自适应加密)不友好;部分 GPU(消费级卡)上 float64 吞吐量只有 float32 的 1/32~1/64;TPU 没有原生 float64。
1.2 float64 为什么关键¶
🟢 必读
科学计算对精度的要求比训练神经网络苛刻得多:
- 条件数:二维 Poisson 的五点差分矩阵条件数约为 $\kappa \approx \dfrac{4}{\pi^2 h^2}$,$N=255$ 时约 $2.6\times10^4$。float32 的机器精度 $\varepsilon\approx 1.2\times10^{-7}$,按扰动界,解的相对误差最坏可达 $\kappa\,\varepsilon\sim 3\times10^{-3}$。这是上界,实际误差通常小得多(下面用 float32 跑 $N=255$,约 $2\times10^{-5}$)。但细网格上的离散误差本身也只有 $10^{-5}$ 量级。舍入误差和离散误差差不多大,收敛阶就测不准了。
- 梯度检验:步长为 $\eta$ 的中心差分,误差约为截断项 $O(\eta^2)$ 加舍入项 $O(\varepsilon/\eta)$。最优步长 $\eta\sim\varepsilon^{1/3}$,这时相对误差约 $\varepsilon^{2/3}$。float32 下最好也只有 $10^{-5}$ 量级(4~5 位有效数字);要是沿用 float64 习惯的 $\eta=10^{-6}$,就只剩 2~3 位(下面都会验证)。float64 下能对拍到 $10^{-10}$ 量级。
- 求解器容差:float32 下 CG 的真实相对残差会停在 $10^{-5}$ 量级,
tol设得再小也降不下去(下面用 $N=63$、随机右端项验证),伴随梯度的精度也跟着受限。
为什么必须放在最开头:jax_enable_x64 决定的是「之后新建的数组和新的追踪」用什么默认类型。开启之前已经创建的数组仍然是 float32,而且 float32 数组乘 Python 浮点数(弱类型)结果还是 float32,于是程序里会悄悄混进单精度数据。下面在子进程里演示这两个错误(当前内核已经开启了 x64,没法在这里复现)。
# ❌ 错误写法:先创建数组、后开启 x64 —— 已有数组仍是 float32,且会经由弱类型运算传染
probe = textwrap.dedent("""
import jax, jax.numpy as jnp, json
a = jnp.ones(3) # 此时默认还是 float32
jax.config.update("jax_enable_x64", True) # 太晚了
b = jnp.ones(3)
print(json.dumps({"a": str(a.dtype), "b": str(b.dtype), "a*1.0": str((a * 1.0).dtype),
"a+b": str((a + b).dtype)}))
""")
try:
r = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, timeout=120,
env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
late = json.loads(r.stdout.strip().splitlines()[-1])
print("实测 dtype:", late)
except Exception as e:
late = None
print("子进程失败:", type(e).__name__, e)
assert late["a"] == "float32" and late["a*1.0"] == "float32" # 旧数组仍是单精度,且乘标量后还是单精度
assert late["b"] == "float64"
# ✅ 正确写法:先 update 再创建任何数组(同样在子进程里验证;本内核第 0 节就是这样做的)
probe_ok = textwrap.dedent("""
import jax
jax.config.update("jax_enable_x64", True) # 第一件事
import jax.numpy as jnp, json
a = jnp.ones(3)
print(json.dumps({"a": str(a.dtype), "a*1.0": str((a * 1.0).dtype)}))
""")
r = subprocess.run([sys.executable, "-c", probe_ok], capture_output=True, text=True, timeout=120,
env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
early = json.loads(r.stdout.strip().splitlines()[-1])
print("实测 dtype:", early)
assert early == {"a": "float64", "a*1.0": "float64"}
assert jnp.ones(3).dtype == jnp.float64 and (jnp.ones(3) * 1.0).dtype == jnp.float64 # 本内核
# ❌ 错误写法:float32 下做 Poisson 收敛性研究 —— 细网格上舍入误差淹没离散误差,二阶收敛「消失」
probe32 = textwrap.dedent("""
import jax, jax.numpy as jnp, json
import jax.scipy.sparse.linalg as jsl
assert jnp.zeros(1).dtype == jnp.float32
def neg_lap(u, h):
up = jnp.pad(u, 1)
return (4 * u - up[:-2, 1:-1] - up[2:, 1:-1] - up[1:-1, :-2] - up[1:-1, 2:]) / h**2
solve = jax.jit(lambda f, h: jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-10, maxiter=4 * f.size)[0])
errs = []
for N in [15, 31, 63, 127, 255]:
h = 1.0 / (N + 1); x = jnp.linspace(h, 1 - h, N); X, Y = jnp.meshgrid(x, x, indexing="ij")
ue = jnp.sin(jnp.pi * X) * jnp.sin(jnp.pi * Y)
errs.append(float(jnp.max(jnp.abs(solve(2 * jnp.pi**2 * ue, h) - ue))))
fr = jax.random.normal(jax.random.key(0), (63, 63)) # 随机右端项(不是特征向量)
res63 = float(jnp.linalg.norm(neg_lap(solve(fr, 1 / 64), 1 / 64) - fr) / jnp.linalg.norm(fr))
g = lambda a: jnp.sum(jnp.sin(a * jnp.arange(10.0)))
a, eps = jnp.float32(0.7), 1e-6
print(json.dumps({"errs": errs, "fd": float((g(a + eps) - g(a - eps)) / (2 * eps)), "ad": float(jax.grad(g)(a)),
"fd_best": float((g(a + 1e-3) - g(a - 1e-3)) / 2e-3), "res63": res63}))
""")
try:
r = subprocess.run([sys.executable, "-c", probe32], capture_output=True, text=True, timeout=300,
env={k: v for k, v in os.environ.items() if k != "JAX_ENABLE_X64"})
res32 = json.loads(r.stdout.strip().splitlines()[-1])
e32 = np.array(res32["errs"])
print("float32 最大误差:", np.array2string(e32, precision=3))
print("相邻网格误差比:", np.round(e32[:-1] / e32[1:], 3), " (二阶收敛应约等于 4)")
print(f"float32 有限差分梯度 {res32['fd']:.6f} vs AD 梯度 {res32['ad']:.6f}"
f" 相对误差 {abs(res32['fd'] - res32['ad']) / abs(res32['ad']):.1e}")
print(f"float32 有限差分(步长 1e-3,接近最优)相对误差 {abs(res32['fd_best'] - res32['ad']) / abs(res32['ad']):.1e}"
f";CG(tol=1e-10,N=63 随机右端项)的真实相对残差 {res32['res63']:.1e}")
except Exception as e:
res32 = None
print("子进程失败:", type(e).__name__, e)
assert e32[-2] / e32[-1] < 3.5 # 最细一级的误差比明显偏离 4
assert abs(res32["fd"] - res32["ad"]) / abs(res32["ad"]) > 1e-3 # 步长 1e-6 时 float32 有限差分只剩 2~3 位有效数字
assert 1e-6 < abs(res32["fd_best"] - res32["ad"]) / abs(res32["ad"]) < 1e-4 # 最优步长附近也只有 4~5 位
assert res32["res63"] > 1e-7 # CG 的真实残差降不到 1e-7
# ✅ 正确写法:float64 下做梯度检验(收敛性研究见第 2 节,二阶收敛一直保持到最细网格)
g = lambda a: jnp.sum(jnp.sin(a * jnp.arange(10.0)))
a0, eps = 0.7, 1e-6
fd64 = (g(a0 + eps) - g(a0 - eps)) / (2 * eps)
ad64 = jax.grad(g)(a0)
print(f"float64 有限差分 {float(fd64):.10f} vs AD {float(ad64):.10f} 相对误差 {abs(fd64 - ad64) / abs(ad64):.1e}")
assert abs(fd64 - ad64) / abs(ad64) < 1e-8
# float64 下的 Poisson 收敛阶见第 2 节
🔍 补充:x64 的另外两种开启方式和一个代价¶
⚪ 选读:要用环境变量或上下文管理器开启 x64、或需要局部单精度时
- 也可以在启动 Python 之前设置环境变量
JAX_ENABLE_X64=1,或者用上下文管理器jax.enable_x64()。后者在 0.12 中仍可用,但只影响上下文内新建的数组和新的追踪,混用时更容易出错。对科学计算 notebook,最稳妥的还是在第一行写jax.config.update。 - 开启 x64 之后,整数默认也是 int64(上面已经 assert 了
jnp.arange的类型),BCOO的索引数组也会是 int64,内存会多一倍。 - 需要「局部单精度」时(比如 GPU 上的大规模前处理),显式写
astype(jnp.float32);但注意第 9 节的结论:即使开启了 x64,显式 float32 的数组照样会触发闭包常量 bug。
2 有限差分 Poisson 方程¶
2.1 问题与离散¶
🟢 必读
在单位正方形 $\Omega=(0,1)^2$ 上求解
$$-\Delta u = f \quad \text{in } \Omega,\qquad u = 0 \quad \text{on } \partial\Omega .$$
取 $N\times N$ 个内部节点,步长 $h = 1/(N+1)$,五点差分格式为
$$(A_h u)_{ij} = \frac{4u_{ij} - u_{i-1,j} - u_{i+1,j} - u_{i,j-1} - u_{i,j+1}}{h^2} = f_{ij},$$
越过边界的邻点取 $u=0$(齐次 Dirichlet)。$A_h$ 对称正定,局部截断误差为 $O(h^2)$。
制造解:取 $u^\ast = e^{x + y/2}\sin(\pi x)\sin(\pi y)$(边界上为 0)。源项 $f = -\Delta u^\ast$ 不用手推,直接用 jax.hessian 求出来,这也算是 AD 在数值分析里的一个小用处。网格每加密一倍,最大误差应缩小到约 $1/4$。
为什么不用最常见的 $\sin(\pi x)\sin(\pi y)$?它恰好是五点差分算子的离散特征向量,CG 一步就精确收敛,测不出求解器的真实表现(2.3 节的补充里会碰到这一点)。
2.2 无矩阵(matrix-free)算子¶
🟢 必读
CG 只需要「给定 $v$,返回 $A_h v$」这一个函数,不需要矩阵本身。在二维网格上直接用数组切片写出五点格式:
- 不组装矩阵:内存只有一个 $N\times N$ 数组,
jit后算子会融合成一个循环; - 可微:系数、源项都只是普通数组,
grad可以直接穿过; - 易于
vmap:没有稀疏格式的索引结构。
边界条件用 jnp.pad(u, 1) 补一圈 0 实现。如果偷懒直接用 jnp.roll,得到的是周期边界条件,见下面的 ❌/✅ 对照。
def neg_lap(u, h):
"""无矩阵五点差分算子 -Δ_h u(齐次 Dirichlet 边界,用 pad 补 0 实现)"""
up = jnp.pad(u, 1)
return (4 * u - up[:-2, 1:-1] - up[2:, 1:-1] - up[1:-1, :-2] - up[1:-1, 2:]) / h**2
def scipy_neg_lap(N, h):
"""SciPy 参考:用 Kronecker 积组装同一个矩阵(CSR,float64)"""
T = sps.diags([-np.ones(N - 1), 2 * np.ones(N), -np.ones(N - 1)], [-1, 0, 1])
I = sps.identity(N)
return ((sps.kron(I, T) + sps.kron(T, I)) / h**2).tocsr()
N = 31; h = 1.0 / (N + 1)
v_np = np.random.default_rng(0).standard_normal((N, N))
A_ref = scipy_neg_lap(N, h)
ref = (A_ref @ v_np.ravel()).reshape(N, N) # 行优先展平,与 kron(I, T) + kron(T, I) 的排列一致
err_pad = rel(neg_lap(jnp.asarray(v_np), h), ref)
print(f"pad 切片算子 vs SciPy CSR:相对误差 {err_pad:.1e}")
assert err_pad < 1e-14
# ❌ 错误写法:直接用 jnp.roll 取邻点 —— roll 会把对边的值卷回来,等于周期边界条件
def neg_lap_roll_wrong(u, h):
return (4 * u - jnp.roll(u, 1, 0) - jnp.roll(u, -1, 0) - jnp.roll(u, 1, 1) - jnp.roll(u, -1, 1)) / h**2
try:
err_roll = rel(neg_lap_roll_wrong(jnp.asarray(v_np), h), ref)
out_roll = np.asarray(neg_lap_roll_wrong(jnp.asarray(v_np), h))
err_inner = rel(out_roll[1:-1, 1:-1], ref[1:-1, 1:-1])
err_edge = rel(out_roll[0], ref[0])
print(f"直接 roll 的算子 vs SciPy(Dirichlet):整体相对误差 {err_roll:.3f};"
f"内部节点 {err_inner:.1e},第一行 {err_edge:.3f} —— 不报错,但贴边的节点全错")
except Exception as e:
err_roll = None
print(type(e).__name__, e)
assert err_roll > 0.05 and err_edge > 0.1 and err_inner < 1e-14
# ✅ 正确写法:先 pad 一圈 0,再在扩充后的数组上 roll,最后裁掉外圈(与切片写法完全等价)
def neg_lap_roll(u, h):
up = jnp.pad(u, 1)
r = (4 * up - jnp.roll(up, 1, 0) - jnp.roll(up, -1, 0) - jnp.roll(up, 1, 1) - jnp.roll(up, -1, 1)) / h**2
return r[1:-1, 1:-1]
err_roll_ok = rel(neg_lap_roll(jnp.asarray(v_np), h), ref)
print(f"pad + roll 的算子 vs SciPy:相对误差 {err_roll_ok:.1e}")
assert err_roll_ok < 1e-14
assert jnp.allclose(neg_lap_roll(jnp.asarray(v_np), h), neg_lap(jnp.asarray(v_np), h), rtol=0, atol=1e-9)
2.3 用 jax.scipy.sparse.linalg.cg 求解¶
🟢 必读
jsl.cg(A, b, x0=None, *, tol=1e-05, atol=0.0, maxiter=None, M=None) 的 A 可以是矩阵,也可以是函数 $v\mapsto Av$。b 可以是任意形状的数组(甚至 pytree),所以二维网格上的解不需要展平。
注意 h 和 f 都是 solve_poisson 的参数。CG 里的 lambda v: neg_lap(v, h) 闭包捕获的是追踪值,不是常量,所以不受闭包常量 bug 影响。maxiter 必须是 Python 整数,这里从 f.size(静态形状)推出来。
@jax.jit
def solve_poisson(f, h):
"""-Δu = f,齐次 Dirichlet;算子以函数形式传给 cg"""
u, info = jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-12, maxiter=10 * f.size)
return u
def u_star(p):
"""制造解 u*(x, y),在单位正方形边界上为 0"""
x, y = p
return jnp.exp(x + 0.5 * y) * jnp.sin(jnp.pi * x) * jnp.sin(jnp.pi * y)
f_star = lambda p: -jnp.trace(jax.hessian(u_star)(p)) # f = -Δu*,由 AD 自动求出
# 抽查一个点:与手推的解析式对比
p0 = jnp.array([0.3, 0.7])
x0, y0 = p0
E, sx, cx, sy, cy = jnp.exp(x0 + 0.5 * y0), jnp.sin(jnp.pi * x0), jnp.cos(jnp.pi * x0), jnp.sin(jnp.pi * y0), jnp.cos(jnp.pi * y0)
lap_hand = E * ((1 - jnp.pi**2) * sx * sy + 2 * jnp.pi * cx * sy + (0.25 - jnp.pi**2) * sx * sy + jnp.pi * sx * cy)
assert abs(f_star(p0) + lap_hand) < 1e-12
def manufactured(N):
"""返回 h、网格坐标、制造解 u* 和源项 f = -Δu*(都在 N×N 内部节点上)"""
h = 1.0 / (N + 1)
x = jnp.linspace(h, 1 - h, N)
X, Y = jnp.meshgrid(x, x, indexing="ij")
P = jnp.stack([X, Y], -1).reshape(-1, 2)
ue = jax.vmap(u_star)(P).reshape(N, N)
f = jax.vmap(f_star)(P).reshape(N, N)
return h, X, Y, ue, f
Ns = [15, 31, 63, 127, 255]
errs = []
print(f"{'N':>5} {'h':>9} {'最大误差':>12} {'相对残差':>10} {'耗时(ms)':>9}")
for N in Ns:
h, X, Y, ue, f = manufactured(N)
u = solve_poisson(f, h)
e = float(jnp.max(jnp.abs(u - ue)))
res_rel = rel(neg_lap(u, h), f)
errs.append(e)
print(f"{N:>5} {h:>9.5f} {e:>12.4e} {res_rel:>10.1e} {timeit(solve_poisson, f, h, n=3):>9.1f}")
assert res_rel < 1e-10 # 代数误差远小于离散误差
errs = np.array(errs)
ratios = errs[:-1] / errs[1:]
orders = np.log2(ratios)
print("误差比:", np.round(ratios, 4))
print("收敛阶:", np.round(orders, 4))
assert np.all(np.abs(ratios - 4) < 0.1) # 网格加密一倍,误差缩小到约 1/4
assert np.all(np.abs(orders - 2) < 0.02) # 二阶收敛
POISSON_ORDERS = orders
# 与 SciPy 稀疏直接求解对拍(同一个离散系统,应在舍入误差内一致)
N = 63
h, X, Y, ue, f = manufactured(N)
u_jax = solve_poisson(f, h)
u_sp = spsl.spsolve(scipy_neg_lap(N, h).tocsc(), np.asarray(f).ravel()).reshape(N, N)
print(f"JAX cg vs SciPy spsolve:相对差 {rel(u_jax, u_sp):.1e}")
assert rel(u_jax, u_sp) < 1e-10
🔍 补充:jsl.cg 不告诉你是否收敛¶
⚪ 选读:用
jsl.cg做正式计算、需要判断是否收敛和选预条件器时
- 停止条件是 $\|r\|_2 \le \max(\texttt{tol}\cdot\|b\|_2,\ \texttt{atol})$,与 SciPy 新版(非 legacy)一致;
maxiter默认是10 * b.size。 - 返回值的第二项
info永远是None(源码里写死的)。到了maxiter还没收敛,也不会报错或警告。所以一定要自己算一次残差,像上面那样 assert。 - 右端项若恰好是算子的特征向量(例如 $\sin(\pi x)\sin(\pi y)$),CG 一步就收敛,测计时、测迭代次数时要避开。
- 算子必须对称正定(用了预条件器时
M也必须对称正定),jsl.cg不检查这一点;非对称问题用gmres/bicgstab。变系数问题建议加对角(Jacobi)预条件M=lambda r: r / diag,同样以函数形式传入。
# 验证:info 是 None;maxiter 太小时静默返回未收敛的解
h = 1.0 / 64
f = jnp.ones((63, 63))
u_bad, info = jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-12, maxiter=5)
print("info =", info, "| 只迭代 5 步时的相对残差:", f"{rel(neg_lap(u_bad, h), f):.2e}")
assert info is None
assert rel(neg_lap(u_bad, h), f) > 1e-2
3 稀疏矩阵:jax.experimental.sparse¶
无矩阵算子适合结构化网格。到了非结构网格(有限元、有限体积),通常还是要先组装一个稀疏矩阵。JAX 的稀疏支持在 jax.experimental.sparse 里,最主要的格式是 BCOO(batched COO):
BCOO((data, indices), shape=...):data形状为(nse,),indices形状为(nse, 2),nse是存储的元素个数;- 允许重复索引(矩阵向量乘时自动相加),正好对应有限元组装里「单元矩阵往全局矩阵上累加」的过程;
sum_duplicates()可以合并重复项; - 是一个 pytree,可以作为
jit/grad函数的参数传入;data可以求导,indices是整数,不参与求导。
3.1 用三元组组装五点差分矩阵,与 SciPy CSR 对拍¶
⚪ 选读:非结构网格需要组装稀疏矩阵时
def five_point_triplets(N, h):
"""五点差分矩阵的 COO 三元组 (row, col, val),NumPy 生成(前处理不需要可微)"""
idx = np.arange(N * N).reshape(N, N)
I, J = np.meshgrid(np.arange(N), np.arange(N), indexing="ij")
rows, cols, vals = [idx.ravel()], [idx.ravel()], [np.full(N * N, 4 / h**2)]
for di, dj in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
I2, J2 = I + di, J + dj
m = (I2 >= 0) & (I2 < N) & (J2 >= 0) & (J2 < N)
rows.append(idx[I[m], J[m]]); cols.append(idx[I2[m], J2[m]]); vals.append(np.full(m.sum(), -1 / h**2))
return np.concatenate(rows), np.concatenate(cols), np.concatenate(vals)
N = 63; h = 1.0 / (N + 1)
r_, c_, v_ = five_point_triplets(N, h)
A_csr = sps.csr_matrix((v_, (r_, c_)), shape=(N * N, N * N)) # SciPy 参考
A_bcoo = sparse.BCOO((jnp.asarray(v_), jnp.stack([jnp.asarray(r_), jnp.asarray(c_)], axis=1)),
shape=(N * N, N * N))
print(A_bcoo, "| data:", A_bcoo.data.dtype, "| indices:", A_bcoo.indices.dtype)
x_np = np.random.default_rng(1).standard_normal(N * N)
matvec = jax.jit(lambda A, x: A @ x) # A 作为参数传入,不在闭包里
y_bcoo = matvec(A_bcoo, jnp.asarray(x_np))
y_csr = A_csr @ x_np
print(f"BCOO matvec vs SciPy CSR:相对误差 {rel(y_bcoo, y_csr):.1e}")
assert rel(y_bcoo, y_csr) < 1e-14
assert rel(neg_lap(jnp.asarray(x_np).reshape(N, N), h).ravel(), y_csr) < 1e-14 # 与无矩阵算子一致
# 从 SciPy 直接转换,以及转回稠密做小规模检查
A_from_sp = sparse.BCOO.from_scipy_sparse(A_csr)
assert A_from_sp.nse == A_csr.nnz
assert np.allclose(sparse.BCOO.from_scipy_sparse(scipy_neg_lap(5, 1 / 6)).todense(),
scipy_neg_lap(5, 1 / 6).toarray())
# 有限元式组装:重复索引会累加(BCOO 保留重复项,matvec 时相加;sum_duplicates 合并)
A_dup = sparse.BCOO((jnp.array([1.0, 2.0, 3.0, 4.0]),
jnp.array([[0, 0], [0, 0], [1, 1], [0, 1]])), shape=(2, 2))
print("稠密形式:\n", A_dup.todense(), "\nnse:", A_dup.nse, "→ 合并后 nse:", A_dup.sum_duplicates().nse)
assert np.allclose(A_dup.todense(), [[3.0, 4.0], [0.0, 3.0]])
assert A_dup.sum_duplicates().nse == 3
assert np.allclose(A_dup @ jnp.array([1.0, 1.0]), [7.0, 3.0])
3.2 sparse.sparsify:让「稠密写法」的函数接受稀疏矩阵¶
⚪ 选读:想按稠密写法写稀疏代码、或要对矩阵元素求导时
sparse.sparsify(f) 把 f 里的 @、.T、逐元素乘等运算换成稀疏版本,函数体可以照着稠密矩阵写。另外,data 可以直接求导。想「对矩阵元素求灵敏度」时这很有用,比如看刚度矩阵某些项的扰动。sparse.grad 则会把梯度也包装成同样稀疏结构的 BCOO。
def energy_dense(M, x):
"""照着稠密矩阵写:||Mx||^2 + x^T M^T x"""
return jnp.sum((M @ x) ** 2) + (M.T @ x) @ x
energy_sparse = jax.jit(sparse.sparsify(energy_dense))
xj = jnp.asarray(x_np)
e_sp = energy_sparse(A_bcoo, xj)
e_ref = np.sum((A_csr @ x_np) ** 2) + (A_csr.T @ x_np) @ x_np
print(f"sparsify 结果 {float(e_sp):.6e} vs SciPy {e_ref:.6e}")
assert abs(float(e_sp) - e_ref) / abs(e_ref) < 1e-13
# 对矩阵非零元求导:d/dA_ij ||Ax||^2 = 2 (Ax)_i x_j,只在非零位置上取值
def loss_data(data, indices, x):
M = sparse.BCOO((data, indices), shape=(N * N, N * N))
return jnp.sum((M @ x) ** 2)
g_data = jax.jit(jax.grad(loss_data))(A_bcoo.data, A_bcoo.indices, xj)
g_ref = 2 * (A_csr @ x_np)[r_] * x_np[c_]
print("对 data 的梯度形状:", g_data.shape, "| 与解析式相对误差:", f"{rel(g_data, g_ref):.1e}")
assert rel(g_data, g_ref) < 1e-13
g_sp = sparse.grad(lambda M, x: jnp.sum((M @ x) ** 2))(A_bcoo, xj)
assert isinstance(g_sp, sparse.BCOO) and g_sp.nse == A_bcoo.nse
assert rel(g_sp.data, g_ref) < 1e-13
3.3 BCOO + CG,以及稀疏直接求解器的现状¶
⚪ 选读:需要稀疏直接法、或要批量求解多个右端项时
BCOO 可以直接用在 cg 的 matvec 里。直接法方面,jax.experimental.sparse.linalg.spsolve(data, indices, indptr, b) 接受 CSR 三件套。但它的文档写得很清楚:只有 CUDA 后端有原生实现,CPU 后端回退到 scipy.sparse.linalg.spsolve(Python 回调),而且 CPU 和 GPU 实现都不支持 vmap。下面验证一下。
from jax.experimental.sparse.linalg import spsolve
b = jnp.ones(N * N)
u_cg_bcoo = jax.jit(lambda A, b: jsl.cg(lambda v: A @ v, b, tol=1e-12, maxiter=10 * b.size)[0])(A_bcoo, b)
u_ref = spsl.spsolve(A_csr.tocsc(), np.ones(N * N))
print(f"BCOO + cg vs SciPy spsolve:相对差 {rel(u_cg_bcoo, u_ref):.1e}")
assert rel(u_cg_bcoo, u_ref) < 1e-10
A_bcsr = sparse.BCSR.from_bcoo(A_bcoo) # 转成 CSR 三件套
u_ss = jax.jit(spsolve)(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, b)
g_ss = jax.grad(lambda b: spsolve(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, b).sum())(b)
print(f"spsolve(CPU 回调 SciPy)vs SciPy:{rel(u_ss, u_ref):.1e};可以 jit、可以 grad")
assert rel(u_ss, u_ref) < 1e-12
assert rel(g_ss, spsl.spsolve(A_csr.T.tocsc(), np.ones(N * N))) < 1e-12 # 梯度 = A^{-T} 1
assert "scipy.sparse.linalg.spsolve" in inspect.getsource(sparse.linalg._spsolve_cpu_lowering)
# ❌ 错误写法:对 spsolve 做 vmap(一次解多个右端项)—— 没有 batching 规则
B = jnp.stack([b, 2 * b, 3 * b])
try:
jax.vmap(lambda bb: spsolve(A_bcsr.data, A_bcsr.indices, A_bcsr.indptr, bb))(B)
vmap_err = None
except NotImplementedError as e:
vmap_err = str(e)
print("NotImplementedError:", vmap_err)
assert vmap_err is not None and "spsolve" in vmap_err
# ✅ 正确写法:批量右端项用 vmap(cg)(matvec 是 BCOO,可批处理);或者在 SciPy 里一次分解、多次回代
U = jax.jit(jax.vmap(lambda A, bb: jsl.cg(lambda v: A @ v, bb, tol=1e-12, maxiter=10 * bb.size)[0],
in_axes=(None, 0)))(A_bcoo, B)
lu = spsl.splu(A_csr.tocsc()) # SciPy:一次 LU,多次回代
U_ref = np.stack([lu.solve(np.asarray(bb)) for bb in B])
print(f"vmap(cg) 批量求解 vs SciPy splu:{rel(U, U_ref):.1e}")
assert rel(U, U_ref) < 1e-10
# 计时(只打印):同一个 N=63 的五点算子,三种 matvec
u2 = xj.reshape(N, N)
t_bcoo = timeit(matvec, A_bcoo, xj, n=200)
t_mf = timeit(jax.jit(neg_lap), u2, h, n=200)
t0 = time.perf_counter()
for _ in range(200):
A_csr @ x_np
t_sp = (time.perf_counter() - t0) / 200 * 1e3
print(f"BCOO matvec : {t_bcoo * 1e3:8.1f} µs")
print(f"无矩阵 matvec : {t_mf * 1e3:8.1f} µs")
print(f"SciPy CSR : {t_sp * 1e3:8.1f} µs (NumPy 侧,无 JAX 调度开销)")
JAX 稀疏支持的现状(0.12,结合文档和上面的运行结果)
| 方面 | 现状 |
|---|---|
| 模块位置 | 仍在 jax.experimental.sparse,API 可能变动 |
| 格式 | BCOO(主力,支持最全)、BCSR;COO/CSR 类的文档注明「对 grad 等变换兼容性很差,建议优先用 BCOO」 |
| matvec / matmat | 可用,可 jit、grad(对 data)、vmap;CPU 上是通用的 gather + segment-sum,通常比无矩阵 stencil 和 SciPy 都慢;GPU 上部分运算走 cuSPARSE |
| 直接求解 | 只有 sparse.linalg.spsolve:GPU 用 cuSOLVER(QR),CPU 回调 SciPy;不支持 vmap;没有可复用的 LU/Cholesky 分解对象 |
| 迭代求解 | jax.scipy.sparse.linalg.cg / gmres / bicgstab 接受函数或 BCOO,可微(内部用 custom_linear_solve,见 4.5 节) |
| 特征值 | sparse.linalg.lobpcg_standard(实验性) |
| 稀疏结构 | nse 是静态的:稀疏模式变化(自适应加密)会触发重新编译 |
jnp 函数 | 大部分 jnp.* 不接受 BCOO(如 jnp.linalg.solve 直接报 TypeError),需要 sparsify 或手写 |
实践建议:结构化网格优先用无矩阵算子;非结构网格用 BCOO 组装 + 迭代法。确实需要直接法时,在 CPU 上直接用 SciPy:用 jax.pure_callback 包一层,再用 custom_linear_solve 提供导数,套路和第 4 节一样。
4 求解器的隐式微分¶
4.1 伴随方法的数学原理¶
🟢 必读
设离散 PDE 写成线性系统 $A(\theta)\,u = f(\theta)$,$\theta$ 是参数(例如每个节点上的扩散系数,几千个),目标是标量 $J(u(\theta),\theta)$。对约束求导:
$$A\,\frac{\partial u}{\partial \theta} = \frac{\partial f}{\partial \theta} - \frac{\partial (A u)}{\partial \theta}\bigg|_{u\ \text{固定}} \quad\Longrightarrow\quad \frac{\partial u}{\partial \theta} = A^{-1}\Big(\frac{\partial f}{\partial\theta} - \frac{\partial (Au)}{\partial\theta}\Big).$$
于是
$$\frac{dJ}{d\theta} = \frac{\partial J}{\partial\theta} + \frac{\partial J}{\partial u}\,A^{-1}\Big(\frac{\partial f}{\partial\theta} - \frac{\partial (Au)}{\partial\theta}\Big).$$
直接算 $\partial u/\partial\theta$ 要解 $\dim\theta$ 个线性系统。伴随方法换一个结合顺序:先解一个伴随方程
$$A^{\top}\lambda = \Big(\frac{\partial J}{\partial u}\Big)^{\top},$$
再得到
$$\boxed{\;\frac{dJ}{d\theta} = \frac{\partial J}{\partial\theta} + \lambda^{\top}\Big(\frac{\partial f}{\partial \theta} - \frac{\partial (A u)}{\partial \theta}\Big)\;}$$
整个梯度的代价是一次前向求解 + 一次伴随求解 + 一次 $\lambda^\top \partial(Au)/\partial\theta$ 的 VJP,与参数个数无关。这正是反向模式 AD 的 VJP:给定余切 $\bar u = \partial J/\partial u$,有 $\bar b = A^{-\top}\bar u$(就是上面的 $\lambda$),$\bar\theta = \bar b^{\top}\big(\partial f/\partial\theta - \partial(Au)/\partial\theta\big)$;$\theta$ 只出现在 $A$ 里时就是 $-\bar b^{\top}\,\partial(Au)/\partial\theta$。
两种求导方式的对比
| 对迭代展开求导(unrolled) | 隐式微分(implicit / adjoint) | |
|---|---|---|
| 做法 | 把 CG 写成固定步数的 fori_loop,让 AD 穿过每一步 | 只把「解」当成 $A u=f$ 的解,用上面的公式 |
| 内存 | 保存每一步的残差、方向、步长:$O(K\cdot n)$,$K$ 为迭代次数 | 只保存解 $u$:$O(n)$,与迭代次数无关 |
| 精度 | 得到的是「浮点迭代过程」的导数;迭代没收敛时梯度不准,收敛后继续迭代也可能被舍入误差放大(见 4.3 节) | 得到的是「真解」的导数,精度只取决于前向与伴随求解的容差 |
| 求解器要求 | 必须是可反向求导的固定步数循环 | 前向求解器可以是任意黑箱(while_loop、SciPy 回调、预条件器……) |
JAX 为这件事提供了专门的原语:
lax.custom_linear_solve(matvec, b, solve, transpose_solve=None, symmetric=False, has_aux=False):声明「solve(matvec, b)返回 $A^{-1}b$」。求导时 JAX 只用到matvec(对其中闭包捕获的参数求 JVP/VJP)以及solve/transpose_solve,从不穿过solve的内部。lax.custom_root(f, initial_guess, solve, tangent_solve):声明「solve返回 $f(x)=0$ 的根」,用隐函数定理求导,用于非线性求解器(4.6 节)。
和进阶 04 用 custom_vjp 手写不动点求导相比,这两个原语同时支持前向和反向模式,可以 vmap,而且会对 matvec 闭包里捕获的追踪值自动求导,不用自己整理参数列表。
4.2 变系数扩散算子¶
⚪ 选读:需要变系数扩散算子的离散细节时
把第 2 节推广到变系数 $-\nabla\cdot(k\nabla u) = f$:$k$ 定义在节点上,面上的系数取相邻两点的算术平均,通量差分格式为
$$(A(k)u)_{ij} = -\frac{1}{h^2}\Big[k_{i+\frac12,j}(u_{i+1,j}-u_{ij}) - k_{i-\frac12,j}(u_{ij}-u_{i-1,j}) + k_{i,j+\frac12}(u_{i,j+1}-u_{ij}) - k_{i,j-\frac12}(u_{ij}-u_{i,j-1})\Big].$$
$k>0$ 时 $A(k)$ 对称正定。下面先验证三件事:$k\equiv1$ 时退化为 neg_lap;对称性 $\langle Au, v\rangle = \langle u, Av\rangle$;以及用 jax.linear_transpose 得到的转置算子与原算子一致。
def apply_A(k, u, h):
"""无矩阵变系数扩散算子 -∇·(k∇u),k 和 u 都是 N×N 节点数组,边界外的 k 取最近值"""
kp = jnp.pad(k, 1, mode="edge")
up = jnp.pad(u, 1) # Dirichlet 0
kx = 0.5 * (kp[1:, 1:-1] + kp[:-1, 1:-1]) # x 方向面系数 (N+1, N)
ky = 0.5 * (kp[1:-1, 1:] + kp[1:-1, :-1]) # y 方向面系数 (N, N+1)
fx = kx * (up[1:, 1:-1] - up[:-1, 1:-1]) # 面通量
fy = ky * (up[1:-1, 1:] - up[1:-1, :-1])
return -((fx[1:] - fx[:-1]) + (fy[:, 1:] - fy[:, :-1])) / h**2
N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
k0 = 1 + 0.5 * jnp.exp(-((XX - 0.6) ** 2 + (YY - 0.4) ** 2) / 0.02)
ka, kb = jax.random.split(jax.random.key(10))
uu, vv = jax.random.normal(ka, (N, N)), jax.random.normal(kb, (N, N))
assert jnp.allclose(apply_A(jnp.ones((N, N)), uu, h), neg_lap(uu, h), rtol=1e-13, atol=1e-9)
lhs, rhs_ = jnp.vdot(apply_A(k0, uu, h), vv), jnp.vdot(uu, apply_A(k0, vv, h))
print(f"<Au,v> = {float(lhs):.10e},<u,Av> = {float(rhs_):.10e}")
assert abs(lhs - rhs_) / abs(lhs) < 1e-12
A_T = jax.linear_transpose(lambda v: apply_A(k0, v, h), uu)
assert jnp.allclose(A_T(vv)[0], apply_A(k0, vv, h), rtol=1e-12, atol=1e-8)
assert jnp.vdot(uu, apply_A(k0, uu, h)) > 0 # 正定性抽查
print("变系数算子:退化检查、对称性、linear_transpose 一致性全部通过")
4.3 两种求导方式:实现与对拍¶
🟢 必读
cg_unrolled:标准 CG,用静态步数的fori_loop写(内部降为scan,可以反向求导);cg_while:同样的 CG,用while_loop按残差停止。它不能直接反向求导(进阶 04 的 4.8 节)。但拿来当custom_linear_solve的solve完全没问题,因为 AD 不会穿过它;u_implicit:lax.custom_linear_solve(matvec, f, solve, symmetric=True)。symmetric=True表示 $A^\top=A$,于是transpose_solve直接复用solve。
目标函数取数据失配 $J(k) = \tfrac12 h^2\sum (u(k) - u_{\text{obs}})^2$。
def cg_unrolled(matvec, b, n_iter):
"""固定 n_iter 步的 CG(静态步数 fori_loop → 可反向求导,但要存每一步)"""
rs0 = jnp.vdot(b, b)
def body(i, c):
x, r, p, rs = c
Ap = matvec(p)
alpha = rs / jnp.vdot(p, Ap)
x, r = x + alpha * p, r - alpha * Ap
rs_new = jnp.vdot(r, r)
return x, r, r + (rs_new / rs) * p, rs_new
return lax.fori_loop(0, n_iter, body, (jnp.zeros_like(b), b, b, rs0))[0]
def cg_while(matvec, b, tol=1e-12, maxiter=10_000):
"""按相对残差停止的 CG(while_loop,不能直接反向求导)"""
bb = jnp.vdot(b, b)
def cond(c):
x, r, p, rs, i = c
return (rs > tol**2 * bb) & (i < maxiter)
def body(c):
x, r, p, rs, i = c
Ap = matvec(p)
alpha = rs / jnp.vdot(p, Ap)
x, r = x + alpha * p, r - alpha * Ap
rs_new = jnp.vdot(r, r)
return x, r, r + (rs_new / rs) * p, rs_new, i + 1
return lax.while_loop(cond, body, (jnp.zeros_like(b), b, b, bb, 0))[0]
def u_unrolled(k, f, h, n_iter):
return cg_unrolled(lambda v: apply_A(k, v, h), f, n_iter)
def u_implicit(k, f, h, tol=1e-12, maxiter=10_000):
"""隐式微分:AD 只用到 matvec(对闭包里的 k 求导)和 solve 本身"""
solve = lambda mv, rhs: cg_while(mv, rhs, tol, maxiter)
return lax.custom_linear_solve(lambda v: apply_A(k, v, h), f, solve, symmetric=True)
def J_unrolled(k, f, u_obs, h, n_iter):
return 0.5 * h**2 * jnp.sum((u_unrolled(k, f, h, n_iter) - u_obs) ** 2)
def J_implicit(k, f, u_obs, h, tol=1e-12, maxiter=10_000):
return 0.5 * h**2 * jnp.sum((u_implicit(k, f, h, tol, maxiter) - u_obs) ** 2)
f_src = jnp.ones((N, N))
u_obs = 0.9 * u_implicit(jnp.ones((N, N)), f_src, h) # 一个人造的「观测」
grad_unr = jax.jit(jax.grad(J_unrolled), static_argnums=4)
grad_imp = jax.jit(jax.grad(J_implicit))
K_UNROLL = 300
g_unr = grad_unr(k0, f_src, u_obs, h, K_UNROLL)
g_imp = grad_imp(k0, f_src, u_obs, h)
# 有限差分(中心差分,沿 3 个随机方向),前向用紧容差的 cg_while
J_fd = jax.jit(J_implicit)
eps = 1e-5
print(f"{'方向':>4} {'有限差分':>16} {'隐式微分':>16} {'展开求导':>16} {'隐式相对误差':>12} {'展开相对误差':>12}")
for i, key in enumerate(jax.random.split(jax.random.key(3), 3)):
d = jax.random.normal(key, (N, N))
fd = (J_fd(k0 + eps * d, f_src, u_obs, h) - J_fd(k0 - eps * d, f_src, u_obs, h)) / (2 * eps)
gi, gu = jnp.vdot(g_imp, d), jnp.vdot(g_unr, d)
ei, eu = abs(gi - fd) / abs(fd), abs(gu - fd) / abs(fd)
print(f"{i:>4} {float(fd):>16.10e} {float(gi):>16.10e} {float(gu):>16.10e} {float(ei):>12.1e} {float(eu):>12.1e}")
assert ei < 1e-7 # 隐式梯度与有限差分一致
assert eu < 1e-3 # 展开 300 步的梯度也与有限差分一致(但差了好几个数量级)
GRAD_REL_UNR_IMP = rel(g_unr, g_imp)
print(f"\n展开 {K_UNROLL} 步 vs 隐式:梯度相对差 {GRAD_REL_UNR_IMP:.2e}")
assert GRAD_REL_UNR_IMP < 1e-4
# 内存:读取 XLA 编译后反向传播所需的临时缓冲区
def temp_mib(fn, *args, static_argnums=()):
c = jax.jit(jax.grad(fn), static_argnums=static_argnums).lower(*args).compile()
return c.memory_analysis().temp_size_in_bytes / 2**20
MEM = {}
for K in [100, 300]:
MEM[f"展开 {K} 步"] = temp_mib(J_unrolled, k0, f_src, u_obs, h, K, static_argnums=4)
MEM["隐式 (custom_linear_solve)"] = temp_mib(J_implicit, k0, f_src, u_obs, h)
for name, m in MEM.items():
print(f"{name:<28}: {m:8.3f} MiB")
assert MEM["展开 300 步"] > 2.5 * MEM["展开 100 步"] # 随迭代次数线性增长
assert MEM["隐式 (custom_linear_solve)"] < MEM["展开 300 步"] / 50
print("\n计时(只打印):")
print(f" 展开 300 步 grad: {timeit(grad_unr, k0, f_src, u_obs, h, K_UNROLL):.2f} ms")
print(f" 隐式 grad : {timeit(grad_imp, k0, f_src, u_obs, h):.2f} ms")
print("\n隐式版本保存的 residuals(只有输入 k 和由解 u 算出的几个同规模数组,没有任何按迭代步堆叠的量):")
print_saved_residuals(lambda k: J_implicit(k, f_src, u_obs, h), k0)
为什么隐式梯度更精确:下面固定迭代次数 $K$,比较「展开求导」和「前向、伴随各最多迭代 $K$ 步的隐式微分」相对于真梯度(紧容差隐式梯度,已与有限差分对拍)的误差。
print(f"{'K':>5} {'前向解误差':>12} {'展开梯度误差':>14} {'隐式梯度误差':>14}")
scan_rows = []
for K in [20, 40, 60, 70, 80, 90, 100, 150, 300]:
gu = grad_unr(k0, f_src, u_obs, h, K)
gi = grad_imp(k0, f_src, u_obs, h, 0.0, K) # tol=0:恰好迭代 K 步
ef = rel(u_unrolled(k0, f_src, h, K), u_implicit(k0, f_src, h))
eu, ei = rel(gu, g_imp), rel(gi, g_imp)
scan_rows.append((K, ef, eu, ei))
print(f"{K:>5} {ef:>12.2e} {eu:>14.2e} {ei:>14.2e}")
scan_rows = np.array(scan_rows)
# 同一个 K=80:展开函数自身的有限差分 = 真梯度;但 AD 的 jvp(前向)和 vjp(反向)都远离真值,彼此也不一致
d = jax.random.normal(jax.random.key(3), (N, N))
_, jvp80 = jax.jvp(lambda k: J_unrolled(k, f_src, u_obs, h, 80), (k0,), (d,))
vjp80 = jnp.vdot(grad_unr(k0, f_src, u_obs, h, 80), d)
fd80 = (J_unrolled(k0 + 1e-6 * d, f_src, u_obs, h, 80) - J_unrolled(k0 - 1e-6 * d, f_src, u_obs, h, 80)) / 2e-6
true80 = jnp.vdot(g_imp, d)
print(f"\nK=80:真梯度 {float(true80):.6e}|展开函数自身的有限差分 {float(fd80):.6e}|"
f"展开 jvp {float(jvp80):.6e}|展开 vjp {float(vjp80):.6e}")
assert abs(fd80 - true80) < 1e-6 * abs(fd80) # 函数值本身已收敛,其有限差分就是真梯度
assert abs(jvp80 - true80) > 100 * abs(true80) # 前向模式的展开导数同样失真 → 不是反向模式的 bug
assert abs(vjp80 - true80) > 100 * abs(true80)
assert scan_rows[:, 2].max() > 1e-2 # 展开梯度在收敛阶段会严重失真
assert np.all(scan_rows[scan_rows[:, 0] >= 80][:, 3] < 1e-5) # 隐式梯度随前向一起稳定收敛
从结果可以看出:
- 没收敛时($K\le 40$),两种梯度都不准,但隐式梯度的误差一直和前向解误差同量级;
- 刚好收敛前后($K\approx 70\sim 100$),前向解误差已降到 $10^{-6}\sim10^{-10}$,展开梯度却可能错上好几个数量级。展开函数自身的有限差分等于真梯度,说明函数本身并不「陡」。而前向模式(jvp)和反向模式(vjp)的展开导数都远离真值,彼此也不一致,说明这不是反向模式实现的 bug,而是被放大的舍入噪声。CG 的 $\alpha=\frac{r^\top r}{p^\top Ap}$、$\beta=\frac{r_{k+1}^\top r_{k+1}}{r_k^\top r_k}$ 求导后会出现 $1/\lVert r\rVert^2$ 量级的因子(例如 $\dot\beta/\beta = 2r_{k+1}^\top\dot r_{k+1}/\lVert r_{k+1}\rVert^2 - 2r_k^\top\dot r_k/\lVert r_k\rVert^2$)。残差越小,这些项越大。精确算术下它们会相互抵消,浮点下抵消不完全,舍入误差就被成倍放大。前向迭代能「自我纠错」(残差小了就不再改变解),导数的递推却不能。具体数值对编译方式、方向都敏感,每次运行不完全一样,但量级是稳定的;
- 迭代远多于需要时($K=300$),展开梯度才回落,但精度仍比隐式差几个数量级,内存却是隐式的上百倍。
所以,只要求解器是在「解一个方程」,就对方程求导,而不要对求解过程求导。
4.4 symmetric 与 transpose_solve:非对称算子的坑¶
🟢 必读
反向模式需要解转置系统 $A^\top\lambda = \bar u$。custom_linear_solve 用 transpose_solve(vecmat, b) 来解它,其中 vecmat 是 JAX 用 linear_transpose 自动得到的 $v\mapsto A^\top v$。symmetric=True 是一个承诺:JAX 会直接拿 solve 和 matvec 去解转置系统,不做任何检查。
例子:一维对流扩散 $-u'' + \beta u' = f$(中心差分),$\beta\neq 0$ 时矩阵非对称。用 GMRES 求解,比较 $J = w^\top u$ 对 $f$ 的梯度(解析值为 $A^{-\top}w$)。
n1 = 50; h1 = 1.0 / (n1 + 1)
def conv_diff(beta, u):
"""一维对流扩散 -u'' + beta u'(中心差分,Dirichlet 0),beta≠0 时非对称"""
up = jnp.pad(u, 1)
return (2 * u - up[:-2] - up[2:]) / h1**2 + beta * (up[2:] - up[:-2]) / (2 * h1)
gmres_solve = lambda mv, rhs: jsl.gmres(mv, rhs, tol=1e-13, atol=0.0, restart=n1, maxiter=20)[0]
f1 = jnp.sin(jnp.linspace(0, 3, n1))
w1 = jnp.linspace(1, 2, n1)
beta0 = 20.0
A1 = np.asarray(jax.jacfwd(lambda u: conv_diff(beta0, u))(jnp.zeros(n1))) # 小规模:显式矩阵做参考
lam_ref = np.linalg.solve(A1.T, np.asarray(w1)) # dJ/df = A^{-T} w
assert not np.allclose(A1, A1.T)
# ❌ 错误写法:非对称算子却声明 symmetric=True —— 反向梯度静默算错;不给 transpose_solve 则直接报错
def J_sym_wrong(f):
u = lax.custom_linear_solve(lambda v: conv_diff(beta0, v), f, gmres_solve, symmetric=True)
return jnp.vdot(w1, u)
def J_no_transpose(f):
u = lax.custom_linear_solve(lambda v: conv_diff(beta0, v), f, gmres_solve)
return jnp.vdot(w1, u)
try:
err_sym = rel(jax.grad(J_sym_wrong)(f1), lam_ref)
print(f"symmetric=True:梯度相对误差 {err_sym:.3f}(无任何警告)")
except Exception as e:
err_sym = None
print(type(e).__name__, e)
try:
jax.grad(J_no_transpose)(f1)
msg_tr = None
except TypeError as e:
msg_tr = str(e)
print("不给 transpose_solve:TypeError:", msg_tr)
assert err_sym > 0.1
assert msg_tr is not None and "transpose_solve" in msg_tr
# ✅ 正确写法:symmetric=False 并提供 transpose_solve(这里同样用 GMRES 解 A^T λ = b)
def u_cd(beta, f):
return lax.custom_linear_solve(lambda v: conv_diff(beta, v), f, gmres_solve,
transpose_solve=gmres_solve, symmetric=False)
J_cd = lambda beta, f: jnp.vdot(w1, u_cd(beta, f))
g_f = jax.grad(J_cd, argnums=1)(beta0, f1)
g_beta = jax.grad(J_cd, argnums=0)(beta0, f1)
fd_beta = (J_cd(beta0 + 1e-5, f1) - J_cd(beta0 - 1e-5, f1)) / 2e-5
print(f"dJ/df 相对误差 {rel(g_f, lam_ref):.1e};dJ/dβ: AD {float(g_beta):.10f} 有限差分 {float(fd_beta):.10f}")
assert rel(g_f, lam_ref) < 1e-10
assert abs(g_beta - fd_beta) < 1e-8 * abs(g_beta)
# 前向模式只用 solve,不受 symmetric 声明影响
assert abs(jax.jvp(lambda b: J_cd(b, f1), (beta0,), (1.0,))[1] - g_beta) < 1e-10
4.5 jax.scipy.sparse.linalg.cg 本身就是隐式微分¶
🟢 必读
看一眼源码就知道,jsl.cg / gmres / bicgstab 内部都调用了 lax.custom_linear_solve(A, b, solve=..., transpose_solve=...),迭代本身用 while_loop。所以直接对 jsl.cg 求 grad 得到的就是伴随梯度,内存与迭代次数无关。只有自己手写迭代求解器时才需要显式包一层 custom_linear_solve。
src = inspect.getsource(sys.modules[jsl.cg.__module__]._isolve)
assert "custom_linear_solve" in src and "transpose_solve" in src
print("jsl.cg 的内部实现调用了 custom_linear_solve ✓")
def J_jsl(k, f, u_obs, h):
u = jsl.cg(lambda v: apply_A(k, v, h), f, tol=1e-12, maxiter=10 * f.size)[0]
return 0.5 * h**2 * jnp.sum((u - u_obs) ** 2)
g_jsl = jax.jit(jax.grad(J_jsl))(k0, f_src, u_obs, h)
print(f"jsl.cg 的梯度 vs 手写 custom_linear_solve:相对差 {rel(g_jsl, g_imp):.1e};"
f"临时内存 {temp_mib(J_jsl, k0, f_src, u_obs, h):.3f} MiB")
assert rel(g_jsl, g_imp) < 1e-9
assert temp_mib(J_jsl, k0, f_src, u_obs, h) < MEM["展开 100 步"] / 10
4.6 非线性问题:lax.custom_root¶
⚪ 选读:求解非线性方程并需要对解求导时
非线性反应扩散 $F(u;k,c) = -\nabla\cdot(k\nabla u) + c\,u^3 - f = 0$,用 Newton–Krylov 求解:每步解 $J_F\,\delta u = -F$,$J_F\,v$ 用 jax.jvp 得到(无矩阵),内层用 cg_while。
lax.custom_root(f, initial_guess, solve, tangent_solve) 的约定:
f:残差函数,只接受未知量 $u$;参数 $k, c$ 通过闭包传入(它们是追踪值,JAX 会对它们求导);solve(f, x0):任意求根器(这里是 Newton,fori_loop或while_loop都行,AD 不穿过它);tangent_solve(g, y):解线性化系统 $g(x)=y$,$g$ 是 $f$ 在根处的线性化。
由隐函数定理,$\dfrac{du^\ast}{dp} = -J_F^{-1}\dfrac{\partial F}{\partial p}$。反向模式下 JAX 会对 tangent_solve 做转置,所以 tangent_solve 必须是关于 y 可转置的线性函数。这里很容易踩坑,下面试一下。
def F_nl(u, k, c, f, h):
return apply_A(k, u, h) + c * u**3 - f
def newton_krylov(F_u, u0, n_newton=12):
"""Newton 法,Jacobian-向量积用 jvp,内层 CG(Jacobian 对称正定)"""
def body(i, u):
r = F_u(u)
Jv = lambda v: jax.jvp(F_u, (u,), (v,))[1]
return u + cg_while(Jv, -r, tol=1e-13)
return lax.fori_loop(0, n_newton, body, u0)
f_nl = 20.0 * jnp.ones((N, N))
def L_root(k, c, tangent_solve):
u = lax.custom_root(lambda u: F_nl(u, k, c, f_nl, h), jnp.zeros((N, N)), newton_krylov, tangent_solve)
return h**2 * jnp.sum(u**2)
u_nl = lax.custom_root(lambda u: F_nl(u, k0, 5.0, f_nl, h), jnp.zeros((N, N)), newton_krylov,
lambda g, y: lax.custom_linear_solve(g, y, cg_while, symmetric=True))
res_nl = float(jnp.linalg.norm(F_nl(u_nl, k0, 5.0, f_nl, h)) / jnp.linalg.norm(f_nl))
print(f"Newton–Krylov 收敛:相对残差 {res_nl:.1e}")
assert res_nl < 1e-11
# ❌ 错误写法:tangent_solve 里直接调用 while_loop 求解器(不可转置),或直接调用 jsl.cg(0.12.0.1 上会报错)
bad_tangent = {
"直接用 cg_while": lambda g, y: cg_while(g, y),
"直接用 jsl.cg": lambda g, y: jsl.cg(g, y, tol=1e-13)[0],
}
bad_results = {}
for name, ts in bad_tangent.items():
try:
jax.grad(L_root, argnums=1)(k0, 5.0, ts)
bad_results[name] = "无报错"
except Exception as e:
bad_results[name] = type(e).__name__
print(f"{name:<14} → {type(e).__name__}: {str(e).splitlines()[0][:90]}")
# 前向模式不需要转置 tangent_solve,两者都能算
_, t = jax.jvp(lambda c: L_root(k0, c, ts), (5.0,), (1.0,))
print(f"{'':<14} 但 jvp 正常:dL/dc = {float(t):.8f}")
assert bad_results["直接用 cg_while"] == "ValueError"
assert bad_results["直接用 jsl.cg"] == "NotImplementedError"
# ✅ 正确写法:tangent_solve 里把迭代求解器包一层 custom_linear_solve(线性化算子对称,symmetric=True 合法)
good_ts = lambda g, y: lax.custom_linear_solve(g, y, cg_while, symmetric=True)
gc = jax.jit(jax.grad(L_root, argnums=1), static_argnums=2)(k0, 5.0, good_ts)
L_j = jax.jit(L_root, static_argnums=2)
fd_c = (L_j(k0, 5.0 + 1e-5, good_ts) - L_j(k0, 5.0 - 1e-5, good_ts)) / 2e-5
gk = jax.grad(L_root, argnums=0)(k0, 5.0, good_ts)
d = jax.random.normal(jax.random.key(7), (N, N))
fd_k = (L_j(k0 + 1e-5 * d, 5.0, good_ts) - L_j(k0 - 1e-5 * d, 5.0, good_ts)) / 2e-5
print(f"dL/dc : AD {float(gc):.10f} 有限差分 {float(fd_c):.10f}")
print(f"dL/dk·d: AD {float(jnp.vdot(gk, d)):.10e} 有限差分 {float(fd_k):.10e}")
assert abs(gc - fd_c) < 1e-7 * abs(gc)
assert abs(jnp.vdot(gk, d) - fd_k) < 1e-7 * abs(fd_k)
🔍 补充:隐式微分的三个前提¶
⚪ 选读:正式使用隐式微分前核对前提条件时
- 前向解要足够准。隐式公式假设 $A u = f$(或 $F(u)=0$)精确成立。前向只解到 $10^{-6}$,梯度一般也只有约 $10^{-6}$ 的精度。伴随求解的容差也是一样。反问题里常用的折中是:前向、伴随都用 $10^{-10}$ 左右的相对容差(float64 下代价不大)。
- 伴随算子要对。对称问题用
symmetric=True;非对称(对流、非保守格式、某些边界处理)必须给transpose_solve。不确定时用jax.linear_transpose生成 $A^\top$,再像 4.2 节那样数值验证对称性。 - 雅可比可逆。$A$ 或 $J_F$ 奇异(纯 Neumann 问题、分岔点)时隐式梯度不存在。纯 Neumann 问题要先固定常数模态(例如加约束 $\sum u = 0$)。
5 反问题:反演扩散系数 $k(x)$¶
5.1 问题设定¶
🟢 必读
在第 4 节的变系数问题 $-\nabla\cdot(k\nabla u)=f$ 上:已知若干组源项 $f_s$ 和对应的(带噪声)观测场 $u^{\text{obs}}_s$,反推空间变化的系数场 $k(x)$。
- 参数化:$k = e^{m}$,优化变量是 $m$($N^2=961$ 个),自动保证 $k>0$;
- 多工况:只有一个源项时,$\nabla u = 0$ 的地方 $k$ 不可辨识。所以用 5 个不同的源项(4 个局部高斯源 + 1 个均匀源),前向求解用
vmap一次完成; - 正则化:反问题是不适定的,加 Tikhonov 平滑项 $\tfrac{\alpha}{2}\|\nabla_h m\|^2$;
- 目标函数
$$\mathcal{L}(m) = \frac{1}{2}\,\frac{\sum_s \|u_s(e^{m}) - u_s^{\text{obs}}\|^2}{\sum_s\|u^{\text{obs}}_s\|^2} + \frac{\alpha}{2}\|\nabla_h m\|^2 .$$
每次求梯度 = 5 次前向求解 + 5 次伴随求解(由 custom_linear_solve 自动完成),与参数个数无关。
数据和源项都作为参数传给 jit 函数,不在闭包里捕获。
N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
def gauss(cx, cy, s2):
return jnp.exp(-((XX - cx) ** 2 + (YY - cy) ** 2) / s2)
k_true = 1 + 1.0 * gauss(0.3, 0.65, 0.015) + 0.6 * gauss(0.7, 0.3, 0.03)
F_src = jnp.stack([10 * gauss(a, b, 0.02) for a, b in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]]
+ [jnp.ones((N, N))])
solve_many = jax.jit(jax.vmap(u_implicit, in_axes=(None, 0, None))) # 一次解 5 个工况
U_clean = solve_many(k_true, F_src, h)
noise_level = 0.002 * float(jnp.max(jnp.abs(U_clean)))
U_obs = U_clean + noise_level * jax.random.normal(jax.random.key(2024), U_clean.shape)
# 前向求解与 SciPy 对拍(变系数矩阵用 jacfwd 显式求出,961×961 可行)
A_k = np.asarray(jax.jacfwd(lambda u: apply_A(k_true, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
u_ref0 = np.linalg.solve(A_k, np.asarray(F_src[0]).ravel()).reshape(N, N)
print(f"工况数 {F_src.shape[0]},噪声标准差 {noise_level:.2e};前向解 vs NumPy 稠密求解 {rel(U_clean[0], u_ref0):.1e}")
assert rel(U_clean[0], u_ref0) < 1e-10
def inv_loss(m, F, U_obs, alpha, h):
U = jax.vmap(u_implicit, in_axes=(None, 0, None))(jnp.exp(m), F, h)
misfit = 0.5 * jnp.sum((U - U_obs) ** 2) / jnp.sum(U_obs**2)
mp = jnp.pad(m, 1, mode="edge")
reg = 0.5 * alpha * (jnp.sum((mp[1:, 1:-1] - mp[:-1, 1:-1]) ** 2) + jnp.sum((mp[1:-1, 1:] - mp[1:-1, :-1]) ** 2))
return misfit + reg
k_err = lambda m: rel(jnp.exp(m), k_true)
ALPHA = 1e-5
# 梯度先和有限差分对拍
m_test = 0.1 * jax.random.normal(jax.random.key(5), (N, N))
d = jax.random.normal(jax.random.key(6), (N, N))
g_inv = jax.grad(inv_loss)(m_test, F_src, U_obs, ALPHA, h)
L_inv = jax.jit(inv_loss)
fd_inv = (L_inv(m_test + 1e-6 * d, F_src, U_obs, ALPHA, h) - L_inv(m_test - 1e-6 * d, F_src, U_obs, ALPHA, h)) / 2e-6
print(f"反问题梯度:AD {float(jnp.vdot(g_inv, d)):.10e} 有限差分 {float(fd_inv):.10e}")
assert abs(jnp.vdot(g_inv, d) - fd_inv) < 1e-6 * abs(fd_inv)
5.2 用 Optax 的 L-BFGS 优化¶
⚪ 选读:要用 Optax 的 L-BFGS 做确定性优化时
先确认 Optax 0.2.9 里有 optax.lbfgs(它自带 zoom 线搜索)。L-BFGS 的 update 比一阶优化器多几个关键字参数:value、grad、value_fn(线搜索要重新求函数值)。optax.value_and_grad_from_state 能复用线搜索里已经算过的函数值和梯度,省掉一次前向 + 伴随。
assert hasattr(optax, "lbfgs") and hasattr(optax, "value_and_grad_from_state")
print("optax.lbfgs 签名:", inspect.signature(optax.lbfgs))
lbfgs = optax.lbfgs()
@jax.jit
def lbfgs_step(m, state, F, U_obs, alpha, h):
fun = lambda m: inv_loss(m, F, U_obs, alpha, h) # 闭包里是追踪值,不是常量
value, grad = optax.value_and_grad_from_state(fun)(m, state=state)
updates, state = lbfgs.update(grad, state, m, value=value, grad=grad, value_fn=fun)
return optax.apply_updates(m, updates), state, value, grad
m = jnp.zeros((N, N)); state = lbfgs.init(m)
err0 = k_err(m)
hist = []
t0 = time.perf_counter()
for it in range(60):
m, state, val, g = lbfgs_step(m, state, F_src, U_obs, ALPHA, h)
hist.append((it, float(val), k_err(m), float(jnp.linalg.norm(g))))
t_lbfgs = time.perf_counter() - t0
for it, v, e, gn in hist[::10] + [hist[-1]]:
print(f"迭代 {it:3d}: 目标 {v:.6e} |k 相对误差 {e:.4f} |梯度范数 {gn:.2e}")
m_lbfgs = m
INV_ERR = k_err(m_lbfgs)
print(f"初始 k 误差 {err0:.4f} → L-BFGS 60 步后 {INV_ERR:.4f}(耗时 {t_lbfgs:.2f} s,仅打印)")
assert INV_ERR < 0.03
assert INV_ERR < err0 / 10
# 对照:Adam(一阶方法,步数要多得多)
adam = optax.adam(0.05)
@jax.jit
def adam_step(m, s, F, U_obs, alpha, h):
val, g = jax.value_and_grad(inv_loss)(m, F, U_obs, alpha, h)
upd, s = adam.update(g, s, m)
return optax.apply_updates(m, upd), s, val
m_a = jnp.zeros((N, N)); s_a = adam.init(m_a)
t0 = time.perf_counter()
for it in range(300):
m_a, s_a, val_a = adam_step(m_a, s_a, F_src, U_obs, ALPHA, h)
print(f"Adam 300 步:目标 {float(val_a):.6e},k 相对误差 {k_err(m_a):.4f}(耗时 {time.perf_counter() - t0:.2f} s,仅打印)")
assert k_err(m_a) < 0.03
fig, axes = plt.subplots(1, 3, figsize=(12, 3.6), constrained_layout=True)
vmin, vmax = float(k_true.min()), float(k_true.max())
ext = [0, 1, 0, 1]
for ax, field, title in [(axes[0], k_true, "真值 k(x)"), (axes[1], jnp.exp(m_lbfgs), "L-BFGS 反演结果")]:
im = ax.imshow(np.asarray(field).T, origin="lower", extent=ext, cmap="Blues", vmin=vmin, vmax=vmax)
ax.set_title(title, fontsize=11)
fig.colorbar(im, ax=axes[:2], shrink=0.85, label="k")
errf = np.asarray(jnp.exp(m_lbfgs) - k_true).T
lim = float(np.abs(errf).max())
im2 = axes[2].imshow(errf, origin="lower", extent=ext, cmap="RdBu_r", vmin=-lim, vmax=lim)
axes[2].set_title(f"误差(相对 L2 = {INV_ERR:.3f})", fontsize=11)
fig.colorbar(im2, ax=axes[2], shrink=0.85)
for ax in axes:
ax.set_xlabel("x"); ax.set_ylabel("y")
plt.show()
🔍 补充:反问题里比优化器更重要的东西¶
⚪ 选读:实际做反问题、要处理正则化和可辨识性时
- 误差在哪里:误差图里最显眼的是两个高斯峰的峰顶被低估(蓝色),这是平滑正则化把尖峰抹平的典型表现。其余区域是噪声引起的小幅随机误差。L-BFGS 和 Adam 收敛到同一个目标值、同一个误差,说明 1.2% 的误差来自「噪声 + 正则化」,而不是优化没收敛。
- 可辨识性:边界上 $u=0$、$\nabla u$ 小,数据对 $k$ 不敏感;单个源项时 $\nabla u=0$ 的点上 $k$ 完全不可辨识。多工况(不同源项)是最有效的改进手段,比换优化器有效得多。
- 正则化参数:$\alpha$ 太大,峰值被抹平;太小,噪声被放大成高频振荡。常用 Morozov 偏差原理:选 $\alpha$ 使失配项约等于噪声水平。
- 参数化:$k=e^m$ 既保证正性,又让「乘性」变化变成加性,条件数更好。离散上也可以把 $k$ 放到单元上,而不是节点上。
- 二阶信息:Gauss–Newton / Newton-CG 需要 Hessian-向量积,可以用
jax.jvp(jax.grad(loss), ...)(forward-over-reverse,见入门 07 的 HVP 一节),对custom_linear_solve同样成立。
6 时间相关问题:热方程¶
6.1 显式与 Crank–Nicolson 格式¶
🟢 必读
一维热方程 $u_t = \alpha u_{xx}$,$x\in(0,1)$,$u(0,t)=u(1,t)=0$。空间用中心差分 $L_h$,记 $r = \alpha\Delta t/h^2$:
- 显式 Euler:$u^{n+1} = u^n + \alpha\Delta t\,L_h u^n$。时间一阶,条件稳定:要求 $r\le \tfrac12$;
- Crank–Nicolson:$\big(I - \tfrac{\alpha\Delta t}{2}L_h\big)u^{n+1} = \big(I + \tfrac{\alpha\Delta t}{2}L_h\big)u^n$。时间二阶,无条件稳定。每步解一个三对角系统,用
lax.linalg.tridiagonal_solve(dl, d, du, b)($O(n)$ 的 Thomas 算法,可微)。
时间积分用 lax.scan,物理参数 $\alpha$、步长都作为参数传入。
参考解:取 $u_0 = \sin(\pi x)$,它是 $L_h$ 的特征向量,特征值 $-\lambda_h = -\tfrac{4}{h^2}\sin^2\tfrac{\pi h}{2}$,半离散精确解为 $u(t) = e^{-\alpha\lambda_h t}u_0$。用它做参照可以把时间误差单独分离出来。
def lap1d(u, h):
up = jnp.pad(u, 1)
return (up[:-2] - 2 * u + up[2:]) / h**2
def explicit_step(u, alpha, dt, h):
return u + dt * alpha * lap1d(u, h)
def cn_step(u, alpha, dt, h):
n = u.shape[0]
r = alpha * dt / h**2
rhs = u + 0.5 * dt * alpha * lap1d(u, h)
off = -0.5 * r * jnp.ones(n - 1)
dl = jnp.concatenate([jnp.zeros(1), off]) # 约定:dl[0] = 0, du[-1] = 0
du = jnp.concatenate([off, jnp.zeros(1)])
d = (1 + r) * jnp.ones(n)
return lax.linalg.tridiagonal_solve(dl, d, du, rhs[:, None])[:, 0]
@partial(jax.jit, static_argnames=("step", "n_steps"))
def integrate(u0, alpha, dt, h, step, n_steps):
def body(u, _):
return step(u, alpha, dt, h), None
return lax.scan(body, u0, None, length=n_steps)[0]
Nx = 99; hx = 1.0 / (Nx + 1)
x1 = jnp.linspace(hx, 1 - hx, Nx)
u0 = jnp.sin(jnp.pi * x1)
alpha0, T_end = 0.5, 0.2
lam_h = 4 / hx**2 * jnp.sin(jnp.pi * hx / 2) ** 2
u_semi = jnp.exp(-alpha0 * lam_h * T_end) * u0 # 半离散精确解
# 三对角求解与 SciPy 对拍
dt_ = T_end / 50
r_ = alpha0 * dt_ / hx**2
ab = np.zeros((3, Nx)); ab[0, 1:] = -0.5 * r_; ab[1] = 1 + r_; ab[2, :-1] = -0.5 * r_
rhs_ = np.asarray(u0 + 0.5 * dt_ * alpha0 * lap1d(u0, hx))
assert rel(cn_step(u0, alpha0, dt_, hx), scipy.linalg.solve_banded((1, 1), ab, rhs_)) < 1e-13
for name, step, steps_list in [("显式 Euler", explicit_step, [4000, 8000, 16000]),
("Crank–Nicolson", cn_step, [50, 100, 200, 400])]:
e = np.array([float(jnp.max(jnp.abs(integrate(u0, alpha0, T_end / ns, hx, step, ns) - u_semi)))
for ns in steps_list])
ratio = e[:-1] / e[1:]
print(f"{name:<15} r = {[round(alpha0 * T_end / ns / hx**2, 3) for ns in steps_list]}")
print(f"{'':<15} 时间误差 {np.array2string(e, precision=3)} 误差比 {np.round(ratio, 3)}")
if step is explicit_step:
assert np.all(np.abs(ratio - 2) < 0.05) # 一阶
else:
assert np.all(np.abs(ratio - 4) < 0.05) # 二阶
# ❌ 错误写法:显式格式取 r > 1/2 —— 不报错,舍入误差里的高频模态按 |1-4r| 的倍率指数放大
n_big = 100; dt_big = T_end / n_big
r_big = alpha0 * dt_big / hx**2
u0_pert = u0 + 1e-12 * jnp.cos(37 * x1) # 模拟舍入级别的扰动
try:
u_exp_bad = integrate(u0_pert, alpha0, dt_big, hx, explicit_step, n_big)
blow = float(jnp.max(jnp.abs(u_exp_bad)))
print(f"r = {r_big:.1f}:显式格式 max|u(T)| = {blow:.3e}(真解约 {float(u_semi.max()):.3f})")
except Exception as e:
blow = None
print(type(e).__name__, e)
assert blow > 1e10
# ✅ 正确写法:显式格式满足 r ≤ 1/2;或者改用无条件稳定的 Crank–Nicolson(同样的大步长)
u_cn_big = integrate(u0_pert, alpha0, dt_big, hx, cn_step, n_big)
err_cn_big = float(jnp.max(jnp.abs(u_cn_big - u_semi)))
print(f"r = {r_big:.1f}:Crank–Nicolson 误差 {err_cn_big:.2e}")
assert err_cn_big < 1e-4
n_ok = int(np.ceil(alpha0 * T_end / (0.5 * hx**2))) # 满足 r ≤ 1/2 的最少步数
u_exp_ok = integrate(u0_pert, alpha0, T_end / n_ok, hx, explicit_step, n_ok)
print(f"显式格式需要 ≥ {n_ok} 步才稳定,此时误差 {float(jnp.max(jnp.abs(u_exp_ok - u_semi))):.2e}")
assert float(jnp.max(jnp.abs(u_exp_ok - u_semi))) < 1e-3
6.2 灵敏度:对物理参数和初始条件求梯度¶
🟢 必读
取观测泛函 $Q = h\sum_i w_i\,u_i(T)$,$w = x(1-x)$。grad 穿过 scan,一次反向传播同时得到 $\partial Q/\partial\alpha$ 和 $\partial Q/\partial u_0$(一个 99 维的向量)。后者也可以解析验证:CN 的传播算子 $P=(I-\tfrac{\alpha\Delta t}{2}L_h)^{-1}(I+\tfrac{\alpha\Delta t}{2}L_h)$ 对称,所以 $\partial Q/\partial u_0 = h\,(P^{\top})^{n}w = h\,P^n w$,等于把 $w$ 作为初值再积分一遍。
w_obs = x1 * (1 - x1)
N_STEPS = 400
def Q(u0, alpha):
return hx * jnp.sum(w_obs * integrate(u0, alpha, T_end / N_STEPS, hx, cn_step, N_STEPS))
dQ_du0, dQ_dalpha = jax.jit(jax.grad(Q, argnums=(0, 1)))(u0, alpha0)
eps = 1e-6
fd_alpha = (Q(u0, alpha0 + eps) - Q(u0, alpha0 - eps)) / (2 * eps)
du_dir = jax.random.normal(jax.random.key(11), (Nx,))
fd_u0 = (Q(u0 + eps * du_dir, alpha0) - Q(u0 - eps * du_dir, alpha0)) / (2 * eps)
print(f"dQ/dα : AD {float(dQ_dalpha):.10f} 有限差分 {float(fd_alpha):.10f}")
print(f"dQ/du0·d: AD {float(jnp.vdot(dQ_du0, du_dir)):.10f} 有限差分 {float(fd_u0):.10f}")
assert abs(dQ_dalpha - fd_alpha) < 1e-7 * abs(dQ_dalpha)
assert abs(jnp.vdot(dQ_du0, du_dir) - fd_u0) < 1e-7 * abs(fd_u0)
adj = hx * integrate(w_obs, alpha0, T_end / N_STEPS, hx, cn_step, N_STEPS) # 伴随 = 把 w 正向再积一遍
print(f"dQ/du0 vs 伴随解析式:相对误差 {rel(dQ_du0, adj):.1e}")
assert rel(dQ_du0, adj) < 1e-12
6.3 长时间积分的内存:checkpoint 与 $O(\sqrt{T})$ 分段¶
⚪ 选读:长时间积分的反向传播内存不够时(原理见进阶/06 §6.6)
对 scan 反向求导时,每一步都要保存残差。CN 步内部还有三对角求解的中间量,所以默认内存约为「步数 × 若干个状态向量」。三种写法:
- 不加检查点:每步保存所有残差;
- 每步检查点
scan(jax.checkpoint(step)):每步只保存 carry(状态 $u^n$),反向时重算步内中间量; - $\sqrt{T}$ 分段:外层
scan跑 $T/B$ 个块,每块是一个被checkpoint包起来的内层scan($B$ 步)。前向只存每块的起点($T/B$ 个状态),反向时逐块重算,块内再存 $B$ 步。取 $B\approx\sqrt T$ 时内存为 $O(\sqrt T)$,代价约为多一次前向。
原理和策略(policy)详见进阶 06 的 6.6 节和进阶 04 的 4.8 节补充,这里只看它在 PDE 时间推进上的效果。下面 $N_x=999$,$T=4096$ 步。
def loss_plain(u0, alpha, dt, h, n):
body = lambda u, _: (cn_step(u, alpha, dt, h), None)
return jnp.sum(lax.scan(body, u0, None, length=n)[0] ** 2)
def loss_ckpt(u0, alpha, dt, h, n):
body = lambda u, _: (cn_step(u, alpha, dt, h), None)
return jnp.sum(lax.scan(jax.checkpoint(body), u0, None, length=n)[0] ** 2)
def loss_sqrt(u0, alpha, dt, h, n, block=64):
body = lambda u, _: (cn_step(u, alpha, dt, h), None)
@jax.checkpoint
def outer(u, _):
return lax.scan(body, u, None, length=block)[0], None
return jnp.sum(lax.scan(outer, u0, None, length=n // block)[0] ** 2)
Nx2 = 999; hx2 = 1.0 / (Nx2 + 1)
u02 = jnp.sin(jnp.pi * jnp.linspace(hx2, 1 - hx2, Nx2))
NT = 4096; dt2 = T_end / NT
CK = {}
for name, fn in [("不加检查点", loss_plain), ("每步检查点", loss_ckpt), ("√T 分段 (B=64)", loss_sqrt)]:
comp = jax.jit(jax.grad(fn, argnums=(0, 1)), static_argnums=4).lower(u02, alpha0, dt2, hx2, NT).compile()
mem = comp.memory_analysis().temp_size_in_bytes / 2**20
t_ms = timeit(comp, u02, alpha0, dt2, hx2, n=3)
CK[name] = (mem, comp(u02, alpha0, dt2, hx2))
print(f"{name:<16}: 临时内存 {mem:8.2f} MiB 耗时 {t_ms:7.1f} ms(仅打印)")
g_ref_ck = CK["不加检查点"][1]
for name, (mem, g) in CK.items():
assert rel(g[0], g_ref_ck[0]) < 1e-12 and abs(g[1] - g_ref_ck[1]) <= 1e-12 * abs(g_ref_ck[1]) # 梯度一致
assert CK["每步检查点"][0] < CK["不加检查点"][0] / 2
assert CK["√T 分段 (B=64)"][0] < CK["不加检查点"][0] / 20
print("\n不加检查点时每步保存的残差(10 步的小例子):")
print_saved_residuals(lambda u, a: loss_plain(u, a, dt2, hx, 10), u0, alpha0)
print("\n每步检查点:")
print_saved_residuals(lambda u, a: loss_ckpt(u, a, dt2, hx, 10), u0, alpha0)
可以看到:不加检查点时,每步保存了状态、右端项以及三对角求解的若干中间数组(形状 [10, 99]、[10, 98, 1] 等);每步检查点只剩 [10, 99] 的状态轨迹;$\sqrt{T}$ 分段把这一项再压缩到 $T/B + B$ 个状态。
🔍 补充:「先离散再求导」与「先求导再离散」¶
⚪ 选读:要在离散伴随与连续伴随之间做选择时
对 scan 求导得到的是离散伴随(discretize-then-optimize)。它是离散目标函数的精确梯度,和有限差分的相对差在 $10^{-8}$ 量级(见 6.2 节,主要受限于有限差分本身的误差)。进阶 04 补充练习 C 的连续伴随(optimize-then-discretize)把伴随 ODE 单独离散,只保存终点,内存 $O(1)$。但它的梯度和离散目标函数只在 $O(\Delta t^p)$ 意义下一致,用在优化里可能导致线搜索失败。另外,「只保存终点、反向时把状态倒着积回去」对热方程这类耗散问题行不通:反向时间的热方程是不适定的,高频误差会被指数放大。所以 PDE 里的连续伴随通常也要存储前向轨迹,或者给它加检查点。PDE 约束优化里,除非内存实在不够,一般优先用离散伴随 + 检查点。
7 可微网格优化¶
7.1 问题:把结构化网格映射到非凸区域¶
⚪ 选读:做网格生成或网格优化时
计算区域取 L 形区域 $\Omega = [0,1]^2 \setminus [0.5,1]\times[0.5,1]$,它在 $(0.5, 0.5)$ 处有一个 $270^\circ$ 的凹角。用 $n\times n$ 的结构化四边形网格,参数正方形 $(s,t)\in[0,1]^2$ 的四条边映射到 $\Omega$ 的边界:
- 下边 $t=0$ → $(s, 0)$;左边 $s=0$ → $(0, t)$;右边 $s=1$ → $(1, t/2)$;
- 上边 $t=1$ → 按弧长走折线 $(0,1)\to(0.5,1)\to(0.5,0.5)\to(1,0.5)$,凹角正好落在一个节点上($n$ 取 3 的倍数)。
初始网格用超限插值(TFI,transfinite interpolation)生成:
$$\mathbf x(s,t) = (1-t)\mathbf B(s) + t\,\mathbf T(s) + (1-s)\mathbf L(t) + s\,\mathbf R(t) - \big[(1-s)(1-t)\mathbf P_{00} + s(1-t)\mathbf P_{10} + (1-s)t\,\mathbf P_{01} + st\,\mathbf P_{11}\big].$$
TFI 在凸区域上表现很好,但在凹角附近会把节点插值到区域外面,产生翻转(inverted)单元。
7.2 网格质量度量¶
⚪ 选读:需要评价网格质量时
对四边形单元的每个角点,取从该角点出发、按逆时针排列的两条边向量 $\mathbf e_1, \mathbf e_2$(乘以 $n$ 归一化到参数空间),组成角点 Jacobian $J = [\mathbf e_1\ \mathbf e_2]$:
| 度量 | 定义 | 理想值 | 含义 |
|---|---|---|---|
| Jacobian 行列式 | $\det J$ | $>0$ | $\le 0$ 表示单元翻转或退化 |
| 缩放 Jacobian(scaled Jacobian) | $\dfrac{\det J}{\lvert\mathbf e_1\rvert\,\lvert\mathbf e_2\rvert} = \sin\theta$ | 1 | 角度质量,$[-1,1]$ |
| 正交性 | $\lvert\cos\theta\rvert = \dfrac{\lvert\mathbf e_1\cdot\mathbf e_2\rvert}{\lvert\mathbf e_1\rvert\,\lvert\mathbf e_2\rvert}$ | 0 | 网格线夹角偏离 $90^\circ$ 的程度 |
| 条件数 | $\kappa = \dfrac{\lVert J\rVert_F^2}{2\,\lvert\det J\rvert} = \dfrac{\sigma_1^2+\sigma_2^2}{2\sigma_1\sigma_2}$ | 1 | 同时度量角度和长宽比;$\kappa\ge1$,等号当且仅当 $J$ 是相似变换 |
7.3 两种平滑能量¶
⚪ 选读:想比较 Laplace 与 Winslow 两种平滑能量时
- Laplace 型:$E_L = \sum_{\text{边}} \lvert\mathbf x_a - \mathbf x_b\rvert^2$。它是二次函数,极小点满足「每个内部节点等于四个邻点的平均」,即离散调和映射。Radó–Kneser–Choquet 定理只保证调和映射在凸目标区域上是单射,所以在凹区域上 Laplace 平滑可能折叠。
- Winslow 型:$E_W = \dfrac{1}{4n^2}\sum_{\text{角点}} \dfrac{\lVert J\rVert_F^2}{2\det J}$(角点条件数的平均)。连续情形下它等价于 Winslow 的「逆调和映射」,$\det J\to0^+$ 时能量趋于 $+\infty$,自带势垒,能阻止单元翻转。
两者都只优化内部节点,边界节点固定。
def top_path(s):
"""上边:按弧长走折线 (0,1)→(0.5,1)→(0.5,0.5)→(1,0.5),总长 1.5"""
L = 1.5 * s
x = np.where(L <= 0.5, L, np.where(L <= 1.0, 0.5, L - 0.5))
y = np.where(L <= 0.5, 1.0, np.where(L <= 1.0, 1.5 - L, 0.5))
return np.stack([x, y], -1)
def l_shape_tfi(n):
"""L 形区域上的 (n+1)×(n+1) 结构化网格,超限插值(TFI)生成,NumPy float64"""
s = np.linspace(0, 1, n + 1)
B, T = np.stack([s, 0 * s], -1), top_path(s)
Lf, R = np.stack([0 * s, s], -1), np.stack([np.ones_like(s), 0.5 * s], -1)
S, Tt = np.meshgrid(s, s, indexing="ij")
S3, T3 = S[..., None], Tt[..., None]
X = ((1 - T3) * B[:, None] + T3 * T[:, None] + (1 - S3) * Lf[None] + S3 * R[None]
- (1 - S3) * (1 - T3) * B[0] - S3 * (1 - T3) * B[-1] - (1 - S3) * T3 * T[0] - S3 * T3 * T[-1])
return X
def corner_jacobians(X):
"""每个单元 4 个角点的 Jacobian,形状 (4, n, n, 2, 2);列向量为逆时针相邻的两条边(乘 n 归一化)"""
n = X.shape[0] - 1
p00, p10, p11, p01 = X[:-1, :-1], X[1:, :-1], X[1:, 1:], X[:-1, 1:]
Js = [jnp.stack([(nxt - c) * n, (prv - c) * n], -1)
for c, nxt, prv in [(p00, p10, p01), (p10, p11, p00), (p11, p01, p10), (p01, p00, p11)]]
return jnp.stack(Js, 0)
def det2(J):
return J[..., 0, 0] * J[..., 1, 1] - J[..., 0, 1] * J[..., 1, 0]
def mesh_quality(X):
J = corner_jacobians(X)
det = det2(J)
e1, e2 = J[..., :, 0], J[..., :, 1]
l1, l2 = jnp.linalg.norm(e1, axis=-1), jnp.linalg.norm(e2, axis=-1)
sj = det / (l1 * l2)
cos = jnp.abs(jnp.sum(e1 * e2, -1)) / (l1 * l2)
kappa = jnp.sum(J**2, axis=(-1, -2)) / (2 * jnp.abs(det))
n_inv = int(jnp.sum(jnp.min(det, axis=0) <= 0))
return dict(翻转单元=n_inv, 最小det=float(det.min()), 最小缩放J=float(sj.min()),
平均缩放J=float(sj.mean()), 最大cos=float(cos.max()), 平均cos=float(cos.mean()),
最大条件数=float(kappa.max()) if n_inv == 0 else float("inf"))
n_mesh = 24
X_tfi = jnp.asarray(l_shape_tfi(n_mesh))
# 边界检查:四个角和凹角都在正确位置
assert np.allclose(X_tfi[0, 0], [0, 0]) and np.allclose(X_tfi[-1, 0], [1, 0])
assert np.allclose(X_tfi[-1, -1], [1, 0.5]) and np.allclose(X_tfi[0, -1], [0, 1])
assert np.allclose(X_tfi[2 * n_mesh // 3, -1], [0.5, 0.5]) # 凹角节点
# 单个正方形单元:det=1、缩放 J=1、cos=0、条件数=1
q_sq = mesh_quality(jnp.stack(jnp.meshgrid(jnp.array([0.0, 1.0]), jnp.array([0.0, 1.0]), indexing="ij"), -1))
assert np.isclose(q_sq["最小缩放J"], 1) and np.isclose(q_sq["最大cos"], 0) and np.isclose(q_sq["最大条件数"], 1)
Q_TFI = mesh_quality(X_tfi)
print("TFI 初始网格:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_TFI.items()})
assert Q_TFI["翻转单元"] > 0
def assemble(Xb, Y):
"""把内部节点 Y 写回完整网格;Xb 提供(固定的)边界节点,作为参数传入"""
return Xb.at[1:-1, 1:-1].set(Y)
def laplace_energy(Y, Xb):
Z = assemble(Xb, Y)
return jnp.sum((Z[1:] - Z[:-1]) ** 2) + jnp.sum((Z[:, 1:] - Z[:, :-1]) ** 2)
def run_lbfgs(energy, Y0, Xb, n_iter, *extra):
opt = optax.lbfgs()
@jax.jit
def step(Y, st, Xb, *extra):
fun = lambda Y: energy(Y, Xb, *extra)
v, g = optax.value_and_grad_from_state(fun)(Y, state=st)
upd, st = opt.update(g, st, Y, value=v, grad=g, value_fn=fun)
return optax.apply_updates(Y, upd), st, v, g
Y, st = Y0, opt.init(Y0)
for _ in range(n_iter):
Y, st, v, g = step(Y, st, Xb, *extra)
return Y, float(v), float(jnp.linalg.norm(g))
Y_lap, E_lap, gn_lap = run_lbfgs(laplace_energy, X_tfi[1:-1, 1:-1], X_tfi, 200)
X_lap = assemble(X_tfi, Y_lap)
avg4 = 0.25 * (X_lap[2:, 1:-1] + X_lap[:-2, 1:-1] + X_lap[1:-1, 2:] + X_lap[1:-1, :-2])
print(f"Laplace 能量极小化:梯度范数 {gn_lap:.1e},与「四邻点平均」的最大偏差 {float(jnp.abs(avg4 - Y_lap).max()):.1e}")
assert float(jnp.abs(avg4 - Y_lap).max()) < 1e-10 # 极小点 = 离散调和映射
Q_LAP = mesh_quality(X_lap)
print("Laplace 平滑后:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_LAP.items()})
assert Q_LAP["翻转单元"] > 0 # 凹区域上调和映射折叠了
7.4 势垒函数的数值稳定写法:双 where¶
⚪ 选读:写势垒、
1/x这类在边界处发散的函数时(双 where 原理见入门 03 §3.8)
$\lVert J\rVert_F^2/\det J$ 只在 $\det J>0$ 时有意义。可初始网格里本来就有翻转单元,优化器也可能试探到 $\det J\le 0$ 的点,所以要把势垒延拓到整条实轴上。做法是取一个阈值 $\delta>0$:
$$\phi(d) = \begin{cases} 1/d, & d > \delta \\ \dfrac1\delta - \dfrac{d-\delta}{\delta^2} + \dfrac{(d-\delta)^2}{\delta^3}, & d \le \delta \end{cases}$$
第二段是 $1/d$ 在 $\delta$ 处的二阶 Taylor 展开,所以 $\phi$ 连续可导,$d\to-\infty$ 时仍然增长,能把翻转单元「推回来」(untangling)。
直接写 jnp.where(det > δ, 1/det, 延拓),前向没问题。但只要有一个角点恰好 $\det J = 0$(退化单元,比如三个节点共线),没被选中的分支 $1/d$ 的导数 $-1/d^2 = -\infty$,乘以 where 分给它的 0,就得到 $0\times\infty=\text{NaN}$,整个梯度都被污染。原理见 入门 03 的「双 where」补充。
DELTA = 0.05
def barrier_ext(d, delta):
return 1 / delta - (d - delta) / delta**2 + (d - delta) ** 2 / delta**3
# 一个 2×2 单元的小网格:把中心节点挪到下边界上 (0.25, 0),与 (0,0)、(0.5,0) 共线 → 有角点 det 恰好为 0
s3 = jnp.array([0.0, 0.5, 1.0])
X_deg = jnp.stack(jnp.meshgrid(s3, s3, indexing="ij"), -1).at[1, 1].set(jnp.array([0.25, 0.0]))
det_deg = det2(corner_jacobians(X_deg))
print("退化网格的角点 det:恰好为 0 的个数 =", int(jnp.sum(det_deg == 0)), ",为负的个数 =", int(jnp.sum(det_deg < 0)))
assert int(jnp.sum(det_deg == 0)) >= 1
# ❌ 错误写法:单个 where —— 前向值有限,但 det=0 的角点让 1/det 分支的导数变成 0×inf = NaN
def winslow_single_where(Y, Xb, delta):
J = corner_jacobians(assemble(Xb, Y))
det = det2(J)
phi = jnp.where(det > delta, 1 / det, barrier_ext(det, delta))
return jnp.mean(0.5 * jnp.sum(J**2, axis=(-1, -2)) * phi)
try:
v_bad, g_bad = jax.value_and_grad(winslow_single_where)(X_deg[1:-1, 1:-1], X_deg, DELTA)
print(f"能量 = {float(v_bad):.4f}(有限),梯度 = {np.asarray(g_bad).ravel()}")
except Exception as e:
g_bad = None
print(type(e).__name__, e)
assert bool(jnp.isfinite(v_bad)) and bool(jnp.any(jnp.isnan(g_bad)))
# ✅ 正确写法:双 where —— 先把危险输入换成安全值 δ,再选择输出;1/d 分支永远看不到 d ≤ δ
def winslow_energy(Y, Xb, delta):
J = corner_jacobians(assemble(Xb, Y))
det = det2(J)
ok = det > delta
det_safe = jnp.where(ok, det, delta) # 第 1 个 where:屏蔽危险输入
phi = jnp.where(ok, 1 / det_safe, barrier_ext(det, delta)) # 第 2 个 where:选择输出
return jnp.mean(0.5 * jnp.sum(J**2, axis=(-1, -2)) * phi)
v_ok, g_ok = jax.value_and_grad(winslow_energy)(X_deg[1:-1, 1:-1], X_deg, DELTA)
print(f"能量 = {float(v_ok):.4f},梯度 = {np.asarray(g_ok).ravel()}")
assert bool(jnp.all(jnp.isfinite(g_ok)))
assert jnp.allclose(v_ok, winslow_single_where(X_deg[1:-1, 1:-1], X_deg, DELTA)) # 前向值完全相同
# 梯度与有限差分一致(在大网格的 TFI 初值上检查,那里同时有正常、延拓两种角点)
Yt = X_tfi[1:-1, 1:-1]
dY = jax.random.normal(jax.random.key(21), Yt.shape)
gW = jax.grad(winslow_energy)(Yt, X_tfi, DELTA)
fdW = (winslow_energy(Yt + 1e-7 * dY, X_tfi, DELTA) - winslow_energy(Yt - 1e-7 * dY, X_tfi, DELTA)) / 2e-7
print(f"TFI 网格上 Winslow 梯度:AD {float(jnp.vdot(gW, dY)):.8e} 有限差分 {float(fdW):.8e}")
assert abs(jnp.vdot(gW, dY) - fdW) < 1e-5 * abs(fdW)
7.5 Winslow 优化:从翻转的 TFI 网格出发¶
⚪ 选读:想看网格解缠与光顺的完整效果时
t0 = time.perf_counter()
Y_w, E_w, gn_w = run_lbfgs(winslow_energy, X_tfi[1:-1, 1:-1], X_tfi, 300, DELTA)
t_mesh = time.perf_counter() - t0
X_w = assemble(X_tfi, Y_w)
Q_W = mesh_quality(X_w)
print(f"Winslow 优化 300 步 L-BFGS:能量 {E_w:.6f},梯度范数 {gn_w:.1e},耗时 {t_mesh:.2f} s(仅打印)\n")
rows = [("TFI", Q_TFI), ("Laplace", Q_LAP), ("Winslow", Q_W)]
keys = list(Q_W.keys())
print(f"{'网格':<9}" + "".join(f"{k:>11}" for k in keys))
for name, q in rows:
print(f"{name:<9}" + "".join(f"{q[k]:>11d}" if isinstance(q[k], int) else f"{q[k]:>11.4f}" for k in keys))
assert np.allclose(X_w[0], X_tfi[0]) and np.allclose(X_w[-1], X_tfi[-1]) # 边界节点没动
assert np.allclose(X_w[:, 0], X_tfi[:, 0]) and np.allclose(X_w[:, -1], X_tfi[:, -1])
assert Q_W["翻转单元"] == 0 and Q_W["最小det"] > 0 # 没有翻转单元
assert Q_W["最小det"] > DELTA # 最优点在真势垒区,延拓段未激活
assert Q_W["最小缩放J"] > Q_TFI["最小缩放J"] + 0.5 # 最差角度质量大幅改善
assert Q_W["平均缩放J"] > Q_TFI["平均缩放J"] and Q_W["最大cos"] < Q_TFI["最大cos"] # 整体角度质量也提升
assert Q_W["最大条件数"] < 10
MESH_Q = rows
def plot_mesh(ax, X, title):
X = np.asarray(X)
det = np.asarray(jnp.min(det2(corner_jacobians(jnp.asarray(X))), axis=0))
bad = np.argwhere(det <= 0)
for i, j in bad: # 翻转单元填充
quad = np.array([X[i, j], X[i + 1, j], X[i + 1, j + 1], X[i, j + 1]])
ax.fill(quad[:, 0], quad[:, 1], color="#d1495b", alpha=0.55, lw=0)
for i in range(X.shape[0]):
ax.plot(X[i, :, 0], X[i, :, 1], color="#4a4a4a", lw=0.5)
ax.plot(X[:, i, 0], X[:, i, 1], color="#4a4a4a", lw=0.5)
outline = np.array([[0, 0], [1, 0], [1, 0.5], [0.5, 0.5], [0.5, 1], [0, 1], [0, 0]])
ax.plot(outline[:, 0], outline[:, 1], color="black", lw=1.4)
ax.set_aspect("equal"); ax.set_xlim(-0.05, 1.05); ax.set_ylim(-0.05, 1.05)
ax.set_title(f"{title}\n翻转单元 {len(bad)} 个(红色)", fontsize=11)
ax.set_xticks([]); ax.set_yticks([])
fig, axes = plt.subplots(1, 3, figsize=(13, 4.6), constrained_layout=True)
for ax, (name, X) in zip(axes, [("TFI 初始网格", X_tfi), ("Laplace 平滑", X_lap), ("Winslow + 势垒优化", X_w)]):
plot_mesh(ax, X, name)
plt.show()
解读:TFI 和 Laplace 平滑都在凹角附近把网格线「拉出」区域,产生翻转单元。Laplace 平滑甚至让最小缩放 Jacobian 接近 $-1$(单元完全翻过来)。Winslow 能量从同一个翻转初值出发,先靠延拓段把单元「解缠」;进入 $\det J>\delta$ 的区域后,势垒阻止它们再翻转,最终所有角点 $\det J>0$。凹角两侧的两个单元必须分摊 $270^\circ$ 的内角,所以最小缩放 Jacobian 不可能接近 1,这是拓扑决定的。
还要注意,Winslow 能量优化的是条件数(角度和长宽比的综合),并不直接优化正交性。表中平均 $\lvert\cos\theta\rvert$ 反而比 TFI 略差一点,因为凹角附近的网格线必须弯折,才能保证单元不翻转。如果正交性更重要(比如边界层网格),可以在能量里加一项 $\sum\cos^2\theta$ 作为加权惩罚。
🔍 补充:势垒的其他写法与实战要点¶
⚪ 选读:实际做网格优化、要选势垒形式和尺度时
- Escobar 正则化:把 $\det J$ 换成 $h(d) = \tfrac12\big(d + \sqrt{d^2 + 4\delta^2}\big)$,它处处为正、光滑,$d\gg\delta$ 时 $h\approx d$,同时完成解缠和光顺(Escobar 等,2003)。实现时 $\sqrt{\cdot}$ 的参数恒正,不需要双 where;然后逐步减小 $\delta$。
- 对数势垒 $-\log\det J$ 在 $\det J\to0^+$ 时增长慢,常和「步长控制(保证每步后 $\det J>0$)」一起用;配合线搜索时,函数值在不可行区返回
inf会让 zoom 线搜索自动缩短步长,但梯度仍要用双 where 保护。 - 势垒的尺度:$\delta$ 要相对于典型的 $\det J$ 来取。这里用了 $n$ 归一化的 Jacobian(规则网格的 $\det J\approx$ 区域面积),所以 $\delta=0.05$ 是无量纲的。
- 网格优化的变量个数是 $2(n-1)^2$,只是一个中等规模的光滑优化问题,L-BFGS 很合适;更大的网格可以按节点做 Gauss–Seidel 式的局部优化,用
vmap对独立节点集并行。
8 参数扫描:vmap 一次求解一批 PDE¶
把一个求解器写成「单个参数 → 解」的纯函数,vmap 之后就能一次处理一批参数。下面扫描变系数扩散问题里高斯包的幅值 $a$:$k = 1 + a\,g(x)$,$a$ 取 32 个值。bump 形状和源项都作为参数传入。
注意 jsl.cg 内部是 while_loop,vmap 之后整批会一直迭代到最慢的那个收敛为止(见进阶 01 的 1.4 节补充)。所以参数差异很大时,批量求解不一定比逐个求解省时间。
N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
bump = jnp.exp(-((XX - 0.5) ** 2 + (YY - 0.5) ** 2) / 0.02)
f_sw = jnp.ones((N, N))
def solve_amp(a, bump, f, h):
k = 1 + a * bump
return jsl.cg(lambda v: apply_A(k, v, h), f, tol=1e-12, maxiter=10 * f.size)[0]
a_batch = jnp.linspace(0.0, 3.0, 32)
solve_batch = jax.jit(jax.vmap(solve_amp, in_axes=(0, None, None, None)))
solve_one = jax.jit(solve_amp)
U_b = solve_batch(a_batch, bump, f_sw, h)
U_l = jnp.stack([solve_one(a, bump, f_sw, h) for a in a_batch])
print("批量解形状:", U_b.shape, "| vmap vs 逐个求解 最大相对差:",
f"{max(rel(U_b[i], U_l[i]) for i in range(len(a_batch))):.1e}")
assert all(rel(U_b[i], U_l[i]) < 1e-10 for i in range(len(a_batch)))
# 与 SciPy 对拍其中一个参数
A_mid = np.asarray(jax.jacfwd(lambda u: apply_A(1 + a_batch[16] * bump, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
assert rel(U_b[16].ravel(), np.linalg.solve(A_mid, np.ones(N * N))) < 1e-10
t_b = timeit(solve_batch, a_batch, bump, f_sw, h, n=5)
t_l = timeit(lambda a_s, b_, f_, h_: [solve_one(a, b_, f_, h_) for a in a_s], a_batch, bump, f_sw, h, n=5)
print(f"计时(仅打印):vmap 批量 {t_b:.1f} ms | Python 循环逐个 {t_l:.1f} ms")
# 灵敏度扫描:vmap(grad) 一次得到每个参数下的 dQ/da,Q = 平均温度(隐式梯度,内存与迭代次数无关)
Q_amp = lambda a, bump, f, h: jnp.mean(solve_amp(a, bump, f, h))
dQ_batch = jax.jit(jax.vmap(jax.grad(Q_amp), in_axes=(0, None, None, None)))(a_batch, bump, f_sw, h)
Q_j = jax.jit(Q_amp)
fd_batch = jnp.array([(Q_j(a + 1e-6, bump, f_sw, h) - Q_j(a - 1e-6, bump, f_sw, h)) / 2e-6 for a in a_batch[::8]])
print("dQ/da(每隔 8 个): AD", np.round(np.asarray(dQ_batch[::8]), 8), "\n FD", np.round(np.asarray(fd_batch), 8))
assert jnp.allclose(dQ_batch[::8], fd_batch, rtol=1e-6)
assert bool(jnp.all(dQ_batch < 0)) # 系数越大,扩散越快,平均温度越低
9 实战规则清单¶
9.1 先实测:闭包常量 bug 在 float64 下是否存在¶
⚪ 选读:想知道闭包常量 bug 在 float64 下是否存在时
闭包常量 bug 的现象、触发条件和规避方法见 00 目录与导读「本环境已知问题」,根因分析见 进阶/02 §2.3,那里的结论都是在 float32 下得到的。本节只回答一个问题:开启 x64 以后,float64 数组还会不会触发它?
下面在当前内核(已开启 x64)里,分别用 float64 和显式 float32 的矩阵做探针,参考值用 NumPy float64 计算。
def closure_probe(dtype, n):
W = (jax.random.normal(jax.random.key(1), (n, n)) / jnp.sqrt(n)).astype(dtype)
X = jax.random.normal(jax.random.key(2), (n, 50)).astype(dtype)
Wn, Xn = np.asarray(W, np.float64), np.asarray(X, np.float64)
ref_sum, ref_max = (Wn @ Xn).sum(0), (Wn @ Xn).max(0)
out = {
"vmap 闭包 sum": jax.jit(jax.vmap(lambda x: jnp.sum(W @ x), in_axes=1))(X) - ref_sum,
"矩阵 闭包 sum": jax.jit(lambda X: jnp.sum(W @ X, axis=0))(X) - ref_sum,
"矩阵 闭包 max": jax.jit(lambda X: jnp.max(W @ X, axis=0))(X) - ref_max,
"矩阵 参数 sum": jax.jit(lambda W, X: jnp.sum(W @ X, axis=0))(W, X) - ref_sum,
"单向量 闭包": jax.jit(lambda x: jnp.sum(W @ x))(X[:, 0]) - ref_sum[0],
}
return {k: float(np.abs(np.asarray(v, np.float64)).max()) for k, v in out.items()}
probe_res = {(dt.__name__, n): closure_probe(dt, n) for dt in (jnp.float32, jnp.float64) for n in (128, 256)}
cols = list(next(iter(probe_res.values())).keys())
print(f"{'dtype':<8}{'n':>5}" + "".join(f"{c:>14}" for c in cols))
for (dt, n), r in probe_res.items():
print(f"{dt:<8}{n:>5}" + "".join(f"{r[c]:>14.2e}" for c in cols))
BUG_F32 = any(r["vmap 闭包 sum"] > 1 for (dt, _), r in probe_res.items() if dt == "float32")
import platform, sys
if sys.platform == "darwin" and platform.machine() == "arm64":
assert BUG_F32, "macOS arm64 上没有复现:可能已在新版 jaxlib 修复,请更新 00 目录与本节结论"
if not BUG_F32:
print("本机 float32 下也没有触发这个 bug(它在 macOS / Apple Silicon 上能复现,在 Linux x86 上测试时没有出现),下面的 float64 结论照样成立。")
for (dt, n), r in probe_res.items():
if dt == "float64":
assert all(v < 1e-10 for v in r.values()) # float64:所有写法都正确
else:
assert r["矩阵 参数 sum"] < 1e-3 and r["单向量 闭包"] < 1e-3 # 作为参数传入 / 单向量:正确
if BUG_F32:
# 若升级 jaxlib 后以下断言失败,说明 bug 已修复,请同步更新 00 目录与本节结论
assert r["vmap 闭包 sum"] > 1 and r["矩阵 闭包 sum"] > 1 and r["矩阵 闭包 max"] > 0.1
CLOSURE_BUG_F64 = any(v > 1e-10 for (dt, _), r in probe_res.items() if dt == "float64" for v in r.values())
print("\nfloat64 下是否复现闭包常量 bug:", CLOSURE_BUG_F64)
# float64 版本同样被编译进了 __ynn_fusion,只是结果正确:问题不在「是否融合」,而在融合内部对 float32 的处理
W64 = jax.random.normal(jax.random.key(1), (128, 128))
hlo64 = jax.jit(lambda X: jnp.sum(W64 @ X, axis=0)).lower(jnp.ones((128, 50))).compile().as_text()
print("float64 闭包版的 HLO 中含 __ynn_fusion:", "__ynn_fusion" in hlo64)
assert W64.dtype == jnp.float64
if BUG_F32:
assert "__ynn_fusion" in hlo64
结论:在本机 jaxlib 0.12.0.1 上,float64 矩阵在所有写法下都正确,float64 下没有复现这个 bug。float64 版本同样被编译进了 __ynn_fusion(上面已检查 HLO),只是结果正确。问题出在融合内部对 float32 的处理,具体原因这里没有深究。但开启 x64 并不能消除风险:只要某个数组显式是 float32(比如为了省内存而降精度),闭包捕获它时照样算错,误差可达 $10^2$ 量级。所以规则不变:矩阵、系数、数据一律作为参数传入。
9.2 规则清单¶
🟢 必读
| # | 规则 | 理由 / 出处 |
|---|---|---|
| 1 | 第一个单元第一行开启 jax_enable_x64,并 assert 默认 dtype | 晚开启时旧数组仍是 float32,并经弱类型运算传染(1.2 节) |
| 2 | 收敛性研究、梯度检验、反问题都用 float64 | float32 下二阶收敛在细网格「消失」,有限差分最好也只有 4~5 位有效数字(常用步长下只剩 2~3 位) |
| 3 | 结构化网格优先用无矩阵算子(pad + 切片) | 内存小、可融合、可微、易 vmap;roll 默认是周期边界(2.2 节) |
| 4 | jsl.cg 的 info 永远是 None,自己检查残差 | 不收敛也不会报错(2.3 节) |
| 5 | 非结构网格用 BCOO 组装;CPU 上直接法交给 SciPy | spsolve 在 CPU 上是回调,且不支持 vmap(第 3 节) |
| 6 | 隐式微分优先:custom_linear_solve / custom_root,或直接用 jsl.cg | 内存 $O(n)$、梯度精确;展开求导内存 $O(Kn)$,收敛阶段梯度可能严重失真(4.3 节) |
| 7 | 非对称算子不要写 symmetric=True,要给 transpose_solve | 否则反向梯度悄悄出错(4.4 节) |
| 8 | custom_root 的 tangent_solve 用 custom_linear_solve 包住迭代求解器 | 反向模式要转置 tangent_solve;while_loop、jsl.cg 直接用都会报错(4.6 节) |
| 9 | 显式格式先检查 CFL/扩散数,刚性问题用 CN 等隐式格式 | 不稳定不会报错,只会得到 $10^{100}$ 级别的垃圾(6.1 节) |
| 10 | 长时间积分用 checkpoint / $\sqrt T$ 分段 | 梯度完全一致;每步检查点内存降到约 1/4,$\sqrt T$ 分段降到约 1/50(6.3 节) |
| 11 | 势垒、1/x、log、sqrt 一律用双 where | 退化单元 $\det J=0$ 会让梯度变 NaN(7.4 节) |
| 12 | 算子矩阵、系数场、观测数据作为函数参数传入,不在 jit 闭包里捕获 | 闭包常量 bug(float32 下复现)、常量嵌入 HLO 拖慢编译、无法对其求导(9.1 节,进阶 01 的 1.7 节) |
| 13 | 每个求解器都和 NumPy/SciPy float64 对拍,每个梯度都和有限差分对拍 | 本章的所有结论都是这样验证的 |
练习¶
每道题的参考答案在本章末尾的「附录:练习参考答案」里(代码单元第一行是
# 参考答案:练习 N)。建议先自己写,再对照。
练习 1:带反应项的 Poisson 方程¶
⚪ 选读:巩固无矩阵算子与收敛阶验证
写无矩阵算子 apply_H(u, c, h) 表示 $-\Delta_h u + c\,u$($c>0$ 为常数,齐次 Dirichlet 边界)。
- 用
jax.linear_transpose验证它是对称算子; - 用 2.3 节的制造解 $u^\ast$(
manufactured返回的f是 $-\Delta u^\ast$),构造本问题的源项 $f + c\,u^\ast$,用jsl.cg求解 $N=31$ 和 $N=63$ 两套网格,assert 误差比约等于 4; - 在 $N=31$ 上与 SciPy
spsolve(矩阵 $=$scipy_neg_lap$+\,c I$)对拍。
自检接口:apply_H(u, c, h),u 是 N×N 数组,返回同形状数组。
# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
apply_H
except NameError as e:
print(f"⏳ 练习 1 尚未作答:{e}")
else:
_N, _c = 15, 3.7
_h = 1.0 / (_N + 1)
_u = np.random.default_rng(5).standard_normal((_N, _N))
_A = scipy_neg_lap(_N, _h) + _c * sps.identity(_N * _N) # SciPy 组装的参考矩阵
_out = apply_H(jnp.asarray(_u), _c, _h)
assert _out.shape == (_N, _N), f"apply_H 应返回 {(_N, _N)} 数组,实际 {_out.shape}"
assert rel(_out.ravel(), _A @ _u.ravel()) < 1e-12, "apply_H 与 -Δ_h + cI 的矩阵乘结果不一致"
assert rel(jax.jit(apply_H)(jnp.asarray(_u), _c, _h), _out) < 1e-14, "apply_H 在 jit 下(c、h 为动态参数)结果不同"
print("✅ 练习 1 自检通过")
练习 2:对源项的灵敏度就是伴随解¶
⚪ 选读:巩固伴随方法:对源项的灵敏度
在第 4 节的变系数问题上($N=31$,系数 k0),取 $J(f) = h^2\sum_{ij} w_{ij}\,u_{ij}(f)$,$w = x\,y$。
- 用
u_implicit和jax.grad求 $\partial J/\partial f$; - 由伴随方法,$\partial J/\partial f = A^{-\top}(h^2 w) = A^{-1}(h^2 w)$。用
jax.jacfwd得到显式矩阵,再用 NumPy 稠密求解对拍; - 沿一个随机方向做有限差分检验。
自检接口:J_src(f, k, w, h),返回标量 $J$;f、k、w 都是 N×N 数组。自检会换一组 f 和 w 检查函数值和梯度。
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
J_src
except NameError as e:
print(f"⏳ 练习 2 尚未作答:{e}")
else:
_N = k0.shape[0]
_h = 1.0 / (_N + 1)
_rng = np.random.default_rng(8)
_f, _w, _fb = (jnp.asarray(_rng.standard_normal((_N, _N))) for _ in range(3))
_AT = np.asarray(jax.vmap(lambda e: apply_A(k0, e.reshape(_N, _N), _h).ravel())(jnp.eye(_N * _N)))
_u = np.linalg.solve(_AT.T, np.asarray(_f).ravel()) # 稠密求解 A u = f
_J_ref = _h**2 * float(np.sum(np.asarray(_w).ravel() * _u))
_J = float(J_src(_f, k0, _w, _h))
assert abs(_J - _J_ref) < 1e-9 * abs(_J_ref), f"J_src 的值不对:{_J:.6e},参考 {_J_ref:.6e}"
_g = jax.grad(J_src)(_f, k0, _w, _h)
_Jb = float(J_src(_fb, k0, _w, _h))
assert abs(float(jnp.vdot(_g, _fb)) - _Jb) < 1e-8 * abs(_Jb), "J 对 f 是线性的,应有 J(f_b) = <∂J/∂f, f_b>;梯度不对"
print("✅ 练习 2 自检通过")
练习 3:从终态反演热扩散率¶
⚪ 选读:练习
jacfwd与 Gauss–Newton 反演
用第 6 节的 Crank–Nicolson 积分器($N_x=99$,$T=0.2$,400 步)。以 $\alpha^\ast=0.5$ 生成终态观测 $u^{\text{obs}} = u(T;\alpha^\ast)$(无噪声),初值 $u_0 = \sin(\pi x) + 0.5\sin(3\pi x)$。从 $\alpha=0.2$ 出发,用 Gauss–Newton 迭代
$$\alpha \leftarrow \alpha - \frac{J^\top r}{J^\top J},\qquad r(\alpha) = u(T;\alpha) - u^{\text{obs}},\quad J = \frac{\partial r}{\partial\alpha}$$
反演 $\alpha$($J$ 用 jax.jacfwd 求,标量参数时前向模式最划算),assert 结果与真值相差小于 $10^{-10}$。
自检接口:forward_T(alpha, u0) 返回 $u(T;\alpha)$;gn_step(alpha, u0, u_obs) 做一步 Gauss–Newton,返回 (新的 alpha, 残差范数);alpha_hat 是最终反演结果。自检会换一个真值和初值重新反演。
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
forward_T, gn_step, alpha_hat
except NameError as e:
print(f"⏳ 练习 3 尚未作答:{e}")
else:
assert abs(float(alpha_hat) - 0.5) < 1e-10, f"alpha_hat 应在 1e-10 内等于 0.5,实际 {float(alpha_hat):.14f}"
# CN 格式对离散特征向量 sin(kπx) 的放大因子有闭式解,用它独立核对 forward_T
_a, _dt = 0.37, T_end / 400
_lam = -4.0 / hx**2 * np.sin(2 * np.pi * hx / 2) ** 2
_g = (1 + 0.5 * _dt * _a * _lam) / (1 - 0.5 * _dt * _a * _lam)
_u0 = jnp.sin(2 * jnp.pi * x1)
assert rel(forward_T(_a, _u0), _g**400 * _u0) < 1e-10, "forward_T 与 Crank–Nicolson(400 步,T = 0.2)的闭式结果不一致"
_u0b = jnp.sin(jnp.pi * x1) + 0.3 * jnp.sin(2 * jnp.pi * x1)
_obs = forward_T(0.3, _u0b)
_al = 0.15
for _ in range(12):
_al, _rn = gn_step(_al, _u0b, _obs)
assert abs(float(_al) - 0.3) < 1e-9, f"从 0.15 出发反演真值 0.3:12 步后得到 {float(_al):.12f}"
assert float(gn_step(_al, _u0b, _obs)[1]) < 1e-10, "收敛后残差范数应接近 0"
print("✅ 练习 3 自检通过")
练习 4:用 jacfwd 验证角点 Jacobian¶
⚪ 选读:练习用
jacfwd核对网格 Jacobian
四边形单元的双线性映射为
$$\mathbf x(\xi,\eta) = (1-\xi)(1-\eta)\mathbf p_{00} + \xi(1-\eta)\mathbf p_{10} + \xi\eta\,\mathbf p_{11} + (1-\xi)\eta\,\mathbf p_{01},\qquad (\xi,\eta)\in[0,1]^2 .$$
对 7.5 节优化后的网格 X_w,用 jax.jacfwd 求每个单元在四个角点 $(0,0),(1,0),(1,1),(0,1)$ 处的 $\partial\mathbf x/\partial(\xi,\eta)$(用 vmap 遍历单元),验证它的行列式乘以 $n^2$ 等于 corner_jacobians 给出的 $\det J$。思考:为什么比较的是行列式而不是矩阵本身?
自检接口:bilinear(P, q),P 形状 (4, 2) 依次为 $p_{00},p_{10},p_{11},p_{01}$,q = (ξ, η);det_bil 形状 (n_mesh**2, 4):单元按 (i, j) 行优先排列(单元 (i, j) 的 $p_{00}$ 是 X_w[i, j],$p_{10}$ 是 X_w[i+1, j]),四列依次是角点 $(0,0),(1,0),(1,1),(0,1)$,已乘 $n^2$。
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
bilinear, det_bil
except NameError as e:
print(f"⏳ 练习 4 尚未作答:{e}")
else:
_rng = np.random.default_rng(3)
_P = _rng.standard_normal((4, 2))
for _q, _pt in [((0.0, 0.0), _P[0]), ((1.0, 0.0), _P[1]), ((1.0, 1.0), _P[2]), ((0.0, 1.0), _P[3])]:
assert np.allclose(bilinear(jnp.asarray(_P), jnp.array(_q)), _pt, atol=1e-12), f"bilinear 在角点 {_q} 处应等于对应顶点"
_xi, _eta = 0.3, 0.8
_ref = ((1 - _xi) * (1 - _eta) * _P[0] + _xi * (1 - _eta) * _P[1] + _xi * _eta * _P[2] + (1 - _xi) * _eta * _P[3])
assert np.allclose(bilinear(jnp.asarray(_P), jnp.array([_xi, _eta])), _ref, atol=1e-12), "bilinear 在单元内部的取值不对"
# 用角点处的两条边独立计算行列式(NumPy)
_X = np.asarray(X_w)
_p00, _p10, _p11, _p01 = _X[:-1, :-1], _X[1:, :-1], _X[1:, 1:], _X[:-1, 1:]
_cr = lambda a, b: a[..., 0] * b[..., 1] - a[..., 1] * b[..., 0]
_dets = np.stack([_cr(_p10 - _p00, _p01 - _p00), _cr(_p10 - _p00, _p11 - _p10),
_cr(_p11 - _p01, _p11 - _p10), _cr(_p11 - _p01, _p01 - _p00)], -1).reshape(-1, 4) * n_mesh**2
assert np.shape(det_bil) == _dets.shape, f"det_bil 形状应为 {_dets.shape},实际 {np.shape(det_bil)}"
assert np.allclose(det_bil, _dets, rtol=1e-12, atol=1e-12), "det_bil 与角点边向量算出的行列式不一致"
print("✅ 练习 4 自检通过")
测验¶
1. 为什么 jax.config.update("jax_enable_x64", True) 必须放在第一个代码单元的最前面?晚一点开启会出现什么现象?
答案
开关只影响之后新建的数组和新的追踪。开启前创建的数组仍是 float32,而且 float32 数组与 Python 浮点数(弱类型)运算后结果还是 float32(1.2 节验证过,a * 1.0 仍为 float32),于是单精度会悄悄混进计算里:收敛阶测不准、梯度检验失败,也不报错。
2. 用 jnp.roll 写五点差分算子,在齐次 Dirichlet 问题上会出什么问题?怎么改?
答案
roll 把对边的值卷回来,相当于周期边界条件,贴边节点的结果是错的(2.2 节的结果:内部节点完全一致,第一行相对误差约 0.35),而且不报错。改法:先 jnp.pad(u, 1) 补一圈 0 再取邻点(切片或 roll 都行),最后裁掉外圈。
3. 对一个线性求解器求梯度,「对 CG 迭代展开求导」和「custom_linear_solve 隐式微分」在内存和精度上各有什么特点?
答案
展开求导要保存每一步的中间量,内存 $O(K n)$,随迭代次数线性增长;它求的是浮点迭代过程的导数,收敛阶段可能严重失真(4.3 节里 $K=80$ 附近相对误差可达 $10^5$ 量级),迭代很多步后才回落。隐式微分只存解,内存 $O(n)$;梯度 = 一次伴随求解,精度只取决于前向和伴随的容差,和有限差分一致到 $10^{-10}$ 量级。
4. 对非对称算子调用 lax.custom_linear_solve(..., symmetric=True) 会怎样?前向模式和反向模式分别受不受影响?
答案
symmetric=True 是一个不做检查的承诺。反向模式会用 $A$ 代替 $A^\top$ 去解伴随系统,梯度悄悄算错(4.4 节里相对误差约 0.84);前向模式(jvp)只用到 solve,结果正确。正确做法是 symmetric=False 并提供 transpose_solve。
5. lax.custom_root 的 tangent_solve 有什么要求?为什么直接在里面调用 while_loop 写的 CG 会在 grad 时报错?
答案
tangent_solve(g, y) 要返回 $g(x)=y$ 的解。反向模式下 JAX 要对 tangent_solve 关于 y 做转置,所以它必须是可转置的线性函数。while_loop 不可转置(报 ValueError);本机 0.12.0.1 上直接用 jsl.cg 也会报 NotImplementedError。用 lax.custom_linear_solve(g, y, cg_while, symmetric=True) 包一层即可(线性化算子对称时)。
6. 在 L 形区域上,为什么 Laplace 平滑会产生翻转单元,而 Winslow 能量不会?写 Winslow 势垒时为什么要用双 where?
答案
Laplace 能量的极小点是离散调和映射,只有目标区域为凸时才保证是单射(Radó–Kneser–Choquet);L 形区域有凹角,节点被平均到区域外,单元就翻转了。Winslow 能量 $\lVert J\rVert_F^2/(2\det J)$ 在 $\det J\to0^+$ 时趋于无穷,自带势垒。为了处理初始的翻转单元,$\det J\le\delta$ 时要换成多项式延拓;如果只用一个 where,退化单元($\det J=0$)上未选中分支 $1/d$ 的导数是无穷大,$0\times\infty$ 得到 NaN 梯度。先用第一个 where 把 $d$ 换成安全值,就能避免。
本章小结¶
- float64 是科学计算的默认选项:第一行开启,再用 assert 确认。float32 会让二阶收敛在细网格上「消失」,有限差分检验也失效。
- 无矩阵算子加上
jsl.cg(算子以函数形式传入),是结构化网格上最简单、最快的组合。五点格式跑出来的收敛阶为 1.99~2.00(误差比 3.97~4.00)。 - BCOO 可以组装、做 matvec、
sparsify、对data求导,并与 SciPy CSR 一致到舍入误差。但 JAX 没有原生的 CPU 稀疏直接求解器,spsolve在 CPU 上回调 SciPy,而且不支持vmap。 - 隐式微分的数学基础是伴随方法,一次伴随求解就能得到完整梯度。
lax.custom_linear_solve和lax.custom_root把它变成了一行代码(jsl.cg内部已经这样做了)。和展开求导相比,临时内存小两个数量级左右(4.3 节中展开 100 / 300 步时分别约是隐式的 100 / 290 倍),梯度精确到求解容差;展开求导在收敛阶段的梯度可能严重失真。 - 反问题 = 隐式微分 +
vmap多工况 + 正则化 + L-BFGS(optax.lbfgs自带 zoom 线搜索)。 - 时间推进用
scan:显式格式要满足 $r\le\tfrac12$,CN 无条件稳定、时间二阶。对scan求导得到离散伴随;长时间积分用 $\sqrt T$ 分段检查点,内存降到原来的约 1/50,梯度不变。 - 网格优化:Laplace 平滑在凹区域上会折叠,Winslow 能量 + 延拓势垒能从翻转的初始网格出发完成解缠和光顺;势垒必须用双 where 写。
- 工程规则:数据和算子作为参数传入(闭包常量 bug 在 float64 下未复现,但 float32 仍存在),每个结论都和 SciPy / 有限差分对拍。
速查表¶
| API | 作用 |
|---|---|
jax.config.update("jax_enable_x64", True) | 开启 float64 / int64(必须最先执行) |
jnp.pad(u, 1) + 切片 | 无矩阵 stencil 的 Dirichlet 边界 |
jax.scipy.sparse.linalg.cg(A, b, tol=, atol=, maxiter=, M=) | CG;A、M 可以是函数;info 恒为 None;内部已是隐式微分 |
jax.scipy.sparse.linalg.gmres(..., restart=, solve_method=) | 非对称系统的 GMRES |
sparse.BCOO((data, indices), shape=) | 由 COO 三元组组装稀疏矩阵(允许重复索引) |
sparse.BCOO.from_scipy_sparse(A) / .todense() / .sum_duplicates() | 与 SciPy / 稠密互转、合并重复项 |
sparse.BCSR.from_bcoo(A) | 转成 CSR(data, indices, indptr) |
sparse.sparsify(f) | 让按稠密写法写的函数接受稀疏矩阵 |
sparse.grad(f) | 对 BCOO 参数求梯度,结果保持稀疏结构 |
sparse.linalg.spsolve(data, indices, indptr, b) | 稀疏直接法(GPU:cuSOLVER;CPU:回调 SciPy;不支持 vmap) |
lax.custom_linear_solve(matvec, b, solve, transpose_solve=, symmetric=) | 声明线性求解,求导时用伴随方法,不穿过 solve |
lax.custom_root(f, x0, solve, tangent_solve) | 声明非线性求根,用隐函数定理求导;tangent_solve 必须可转置 |
jax.linear_transpose(f, x) | 得到线性算子的转置(验证对称性、构造伴随) |
lax.linalg.tridiagonal_solve(dl, d, du, b) | 可微的三对角求解(CN、一维隐式格式) |
lax.scan(step, u0, None, length=T) | 时间推进 |
jax.checkpoint(step) / 嵌套 scan | 每步检查点 / $\sqrt T$ 分段检查点 |
jit(grad(f)).lower(...).compile().memory_analysis() | 读取反向传播的临时内存 |
jax.ad_checkpoint.print_saved_residuals(f, *args) | 查看反向传播保存了什么 |
optax.lbfgs() + optax.value_and_grad_from_state | L-BFGS(zoom 线搜索);update 需要 value=, grad=, value_fn= |
jax.vmap(solve, in_axes=(0, None, ...)) | 参数扫描 / 多工况批量求解 |
jnp.where(ok, x, safe) → jnp.where(ok, f(x_safe), g(x)) | 双 where:势垒、1/x、log、sqrt 的安全写法 |
练习 1¶
题目见正文「练习 1:带反应项的 Poisson 方程」。
# 参考答案:练习 1
def apply_H(u, c, h):
return neg_lap(u, h) + c * u
c_r = 10.0
uu1 = jax.random.normal(jax.random.key(31), (31, 31))
vv1 = jax.random.normal(jax.random.key(32), (31, 31))
HT = jax.linear_transpose(lambda v: apply_H(v, c_r, 1 / 32), uu1)
assert jnp.allclose(HT(vv1)[0], apply_H(vv1, c_r, 1 / 32), rtol=1e-12, atol=1e-8)
@jax.jit
def solve_H(f, c, h):
return jsl.cg(lambda v: apply_H(v, c, h), f, tol=1e-12, maxiter=10 * f.size)[0]
errs_h = []
for Nh in [31, 63]:
hh, Xh, Yh, ueh, f_lap = manufactured(Nh)
fh = f_lap + c_r * ueh # -Δu* + c u*
uh = solve_H(fh, c_r, hh)
errs_h.append(float(jnp.max(jnp.abs(uh - ueh))))
if Nh == 31:
Asp = scipy_neg_lap(Nh, hh) + c_r * sps.identity(Nh * Nh)
assert rel(uh.ravel(), spsl.spsolve(Asp.tocsc(), np.asarray(fh).ravel())) < 1e-10
print("误差:", errs_h, "误差比:", errs_h[0] / errs_h[1])
assert abs(errs_h[0] / errs_h[1] - 4) < 0.1
练习 2¶
题目见正文「练习 2:对源项的灵敏度就是伴随解」。
# 参考答案:练习 2
N = 31; h = 1.0 / (N + 1)
xg = jnp.linspace(h, 1 - h, N)
XX, YY = jnp.meshgrid(xg, xg, indexing="ij")
w2 = XX * YY
def J_src(f, k, w, h):
return h**2 * jnp.sum(w * u_implicit(k, f, h))
f2 = jnp.ones((N, N))
g_src = jax.jit(jax.grad(J_src))(f2, k0, w2, h)
A_k0 = np.asarray(jax.jacfwd(lambda u: apply_A(k0, u.reshape(N, N), h).ravel())(jnp.zeros(N * N)))
lam2 = np.linalg.solve(A_k0.T, h**2 * np.asarray(w2).ravel()).reshape(N, N)
print(f"dJ/df vs 伴随解:相对误差 {rel(g_src, lam2):.1e}")
assert rel(g_src, lam2) < 1e-10
d2 = jax.random.normal(jax.random.key(41), (N, N))
fd2 = (J_src(f2 + 1e-4 * d2, k0, w2, h) - J_src(f2 - 1e-4 * d2, k0, w2, h)) / 2e-4
assert abs(jnp.vdot(g_src, d2) - fd2) < 1e-8 * abs(fd2)
练习 3¶
题目见正文「练习 3:从终态反演热扩散率」。
# 参考答案:练习 3
u0_3 = jnp.sin(jnp.pi * x1) + 0.5 * jnp.sin(3 * jnp.pi * x1)
def forward_T(alpha, u0):
return integrate(u0, alpha, T_end / 400, hx, cn_step, 400)
u_obs3 = forward_T(0.5, u0_3)
@jax.jit
def gn_step(alpha, u0, u_obs):
r = forward_T(alpha, u0) - u_obs
Jr = jax.jacfwd(forward_T)(alpha, u0)
return alpha - jnp.vdot(Jr, r) / jnp.vdot(Jr, Jr), jnp.linalg.norm(r)
alpha_hat = 0.2
for it in range(8):
alpha_hat, rn = gn_step(alpha_hat, u0_3, u_obs3)
print(f"迭代 {it}: α = {float(alpha_hat):.14f},残差范数 {float(rn):.2e}")
assert abs(alpha_hat - 0.5) < 1e-10
练习 4¶
题目见正文「练习 4:用 jacfwd 验证角点 Jacobian」。
# 参考答案:练习 4
def bilinear(P, q):
"""P: (4, 2) 依次为 p00, p10, p11, p01;q = (ξ, η)"""
xi, eta = q
Nshape = jnp.array([(1 - xi) * (1 - eta), xi * (1 - eta), xi * eta, (1 - xi) * eta])
return Nshape @ P
Xw = jnp.asarray(X_w)
P_cells = jnp.stack([Xw[:-1, :-1], Xw[1:, :-1], Xw[1:, 1:], Xw[:-1, 1:]], axis=2).reshape(-1, 4, 2)
corners = jnp.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])
jac = jax.vmap(lambda P: jax.vmap(lambda q: jax.jacfwd(bilinear, argnums=1)(P, q))(corners))(P_cells) # (cells, 4, 2, 2)
det_bil = det2(jac) * n_mesh**2 # (cells, 4)
det_ref = jnp.moveaxis(det2(corner_jacobians(Xw)), 0, -1).reshape(-1, 4)
print(f"最大差异 {float(jnp.abs(det_bil - det_ref).max()):.1e}")
assert jnp.allclose(det_bil, det_ref, rtol=1e-12, atol=1e-12)
# 矩阵本身只相差一个列的排列/变号(例如角点 p10 处是 [∂η, -∂ξ]),行列式不变