Chapter 11: Scientific Computing and Differentiable Simulation
Environment: JAX / jaxlib 0.12.0.1, CPU only, Python 3.14, using only the installed
jax,numpy,scipy,optaxandmatplotlib. This chapter uses float64 throughout, and every conclusion was run in this environment and verified withassert.
Most examples in earlier chapters were machine learning. This chapter moves to computational mechanics / numerical PDEs / mesh generation: the same grad, jit, vmap and scan, applied to finite-difference operators, Krylov solvers, adjoint sensitivities and mesh optimization.
Learning goals
- Understand where JAX fits in scientific computing: inverse problems, sensitivity analysis and design optimization all rely on automatic differentiation; parameter sweeps rely on
jit+vmap; and know why float64 must be turned on at the very start. - Write a matrix-free five-point finite-difference operator, solve the Poisson equation with
jax.scipy.sparse.linalg.cg, and verify second-order convergence. - Assemble sparse matrices with
jax.experimental.sparse.BCOO, do matrix-vector products, usesparsify, and know the limits of JAX's sparse support. - Master implicit differentiation of solvers: the math behind the adjoint method, how to use
lax.custom_linear_solveandlax.custom_root, and why it uses less memory and is more accurate than "differentiating through the unrolled iterations". - Use
grad+ Optax (L-BFGS / Adam) to recover a spatially varying coefficient field $k(x)$ from observed data. - Use
lax.scanfor explicit / Crank–Nicolson time stepping, compute sensitivities with respect to physical parameters and initial conditions, and control memory for long time integration withjax.checkpoint. - Define mesh quality metrics, do differentiable mesh optimization with a Winslow-type energy + a barrier function, and write a numerically stable (double-where) barrier.
- Use
vmapto solve a PDE for a whole batch of parameters at once.
Contents
| Section | Content |
|---|---|
| 0 | Environment: turn on float64 and assert it |
| 1 | Why use JAX for scientific computing; why float64 is necessary (with ❌/✅ comparisons) |
| 2 | Finite-difference Poisson: matrix-free operator, solving with cg, second-order convergence |
| 3 | Sparse matrices: BCOO assembly, matvec, sparsify, cross-check against SciPy CSR, current status and limits |
| 4 | Implicit differentiation of solvers: the adjoint method, unrolled differentiation vs custom_linear_solve, custom_root |
| 5 | Inverse problem: recovering $k(x)$ in a variable-coefficient diffusion equation |
| 6 | Time-dependent problems: heat equation, explicit / Crank–Nicolson, sensitivities, checkpoint |
| 7 | Differentiable mesh optimization: quality metrics, Laplace vs Winslow, barriers and double where |
| 8 | Parameter sweeps: batched solves with vmap |
| 9 | A checklist of practical rules (including a check of the closure-constant bug under float64) |
| — | Exercises (4) / Quiz (6) / Chapter summary / Cheat sheet |
How this relates to earlier chapters (what was already covered isn't repeated)
- The implicit function theorem, writing fixed-point derivatives with
custom_vjp, DEQ implicit layers and the continuous ODE adjoint method were covered in Advanced 04: Advanced automatic differentiation (the 4.6 extra, the 4.10 hands-on, Extra exercise C). This chapter doesn't hand-writecustom_vjpagain; instead it uses the two primitives JAX provides specifically for linear / nonlinear solvers,lax.custom_linear_solveandlax.custom_root, and applies the adjoint method to PDEs. - For how to use
lax.scan, the RK4 integrator, and the basics ofcheckpoint+scan, see Advanced 01: Advanced transformations and control flow (sections 1.2, 1.5, 1.8); for whywhile_loopcan't be differentiated in reverse mode, see section 4.8 of Advanced 04. The time stepping in this chapter focuses only on PDE-specific issues: stability conditions, implicit schemes, and $O(\sqrt{T})$ checkpointing for long time integration. - Checkpoint policies and $\sqrt{L}$ segmentation are covered systematically in section 6.6 of Advanced 06: Large-scale model training.
- How the "double where" trick works is in the extra in section 3.8 of Basics 03: Automatic differentiation with grad; section 7 of this chapter uses it in a mesh barrier function.
- For this environment's CPU backend bug, see 00 Overview.
⚠️ Environment note: the CPU backend of jaxlib 0.12.0.1 has a known bug: a constant matrix captured by a
jitclosure, used inW @ xand then summed (or maxed), silently returns wrong results (see 00 Overview, "Known issues in this environment"). Section 9 checks how it behaves under float64. Whatever the result, this chapter follows one rule: operator matrices, coefficient fields and observed data are always passed in as function arguments, and key numbers are cross-checked against NumPy / SciPy float64.
0 Environment: turn on float64¶
jax.config.update("jax_enable_x64", True) must go at the very top of the first code cell, before any JAX array is created. Section 1.2 shows why with a ❌/✅ comparison. Right after the cell below turns it on, we assert that the default dtype is now float64.
# First thing: turn on 64-bit precision (must happen before any JAX array is created)
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
# Self-check: the default float type, random numbers and Python scalar conversion are all 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("Default float dtype:", jnp.zeros(3).dtype)
# Common imports
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("Devices:", jax.devices())
def rel(a, b):
"""Relative error ||a-b|| / ||b|| (computed after converting everything to 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):
"""Warm up (compile) first, then time n runs and return the mean in milliseconds. Only for printing, never asserted."""
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 Why use JAX for scientific computing¶
1.1 Three core benefits¶
⚪ Optional: if you want to know where JAX fits in scientific computing and what it costs
(1) Automatic differentiation makes "taking derivatives" no longer a separate engineering job. Lots of problems in computational mechanics need derivatives:
| Problem | Derivative needed | Traditional approach |
|---|---|---|
| Inverse problems / parameter identification: inferring material coefficients, boundary conditions or source terms from observations | Gradient of the loss with respect to the whole coefficient field (thousands of parameters) | Derive and hand-code the adjoint equations; error-prone, and has to be rewritten whenever the model changes |
| Sensitivity analysis: how fast an output changes with a parameter | $\partial Q/\partial p$ | Finite differences: one PDE solve per parameter, plus step-size tuning |
| Design optimization: shape optimization, topology optimization, mesh optimization | Gradient of the objective with respect to the design variables | Same as above |
Reverse-mode AD computes the gradient of a scalar objective with respect to $n$ parameters for roughly the cost of a few forward solves, independent of $n$. Combined with implicit differentiation (section 4), even iterative solvers can be differentiated for the cost of "one extra adjoint solve".
(2) jit + vmap suit parameter sweeps and UQ. Write a solver once, and after vmap it solves a batch of parameters, load cases or random samples in one go (section 8); jit compiles the whole pipeline into one XLA program.
(3) Composability. grad(vmap(solve)), vmap(grad(loss)) and jit(scan(step)) nest freely, and solvers, optimizers and post-processing are all written as pure functions.
Be clear about the costs too: JAX has no mature sparse direct solver (section 3), and for irregular meshes you assemble things yourself with gather/scatter; it's not friendly to dynamic shapes (adaptive refinement); on some GPUs (consumer cards) float64 throughput is only 1/32 to 1/64 of float32; TPUs have no native float64.
1.2 Why float64 matters¶
🟢 Must-read
Scientific computing is much more demanding about precision than training neural networks:
- Condition number: the condition number of the 2D Poisson five-point matrix is about $\kappa \approx \dfrac{4}{\pi^2 h^2}$, which is about $2.6\times10^4$ for $N=255$. float32 machine epsilon is $\varepsilon\approx 1.2\times10^{-7}$, so by the perturbation bound the relative error of the solution can be as bad as $\kappa\,\varepsilon\sim 3\times10^{-3}$. That's an upper bound and the actual error is usually much smaller (running $N=255$ in float32 below gives about $2\times10^{-5}$). But on fine meshes the discretization error itself is only around $10^{-5}$. When rounding error is about as big as discretization error, you can't measure the convergence order reliably.
- Gradient checks: for a central difference with step $\eta$, the error is roughly a truncation term $O(\eta^2)$ plus a rounding term $O(\varepsilon/\eta)$. The best step is $\eta\sim\varepsilon^{1/3}$, which gives a relative error around $\varepsilon^{2/3}$. In float32 the best you get is around $10^{-5}$ (4 to 5 significant digits); if you keep the float64 habit of $\eta=10^{-6}$, only 2 to 3 digits are left (both are verified below). In float64 you can cross-check down to around $10^{-10}$.
- Solver tolerance: in float32 the true relative residual of CG stalls around $10^{-5}$ no matter how small you set
tol(verified below with $N=63$ and a random right-hand side), and the accuracy of adjoint gradients is limited along with it.
Why it must come first: jax_enable_x64 decides the default type for "arrays created and traces started from now on". Arrays created before it's turned on are still float32, and a float32 array times a Python float (weakly typed) is still float32, so single-precision data quietly sneaks into the program. Below we demonstrate both mistakes in a subprocess (the current kernel already has x64 on, so we can't reproduce them here).
# ❌ Wrong: create arrays first, turn on x64 later; the existing arrays stay float32 and spread through weakly typed operations
probe = textwrap.dedent("""
import jax, jax.numpy as jnp, json
a = jnp.ones(3) # the default is still float32 at this point
jax.config.update("jax_enable_x64", True) # too late
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("Measured dtypes:", late)
except Exception as e:
late = None
print("Subprocess failed:", type(e).__name__, e)
assert late["a"] == "float32" and late["a*1.0"] == "float32" # the old array is still single precision, and so is the result of multiplying by a scalar
assert late["b"] == "float64"
# ✅ Correct: update first, then create any arrays (also checked in a subprocess; section 0 of this kernel does exactly this)
probe_ok = textwrap.dedent("""
import jax
jax.config.update("jax_enable_x64", True) # first thing
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("Measured dtypes:", 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 # this kernel
# ❌ Wrong: doing a Poisson convergence study in float32; on fine meshes rounding error swamps discretization error and second-order convergence "disappears"
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)) # random right-hand side (not an eigenvector)
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 max error:", np.array2string(e32, precision=3))
print("Error ratio between consecutive meshes:", np.round(e32[:-1] / e32[1:], 3), " (should be about 4 for second-order convergence)")
print(f"float32 finite-difference gradient {res32['fd']:.6f} vs AD gradient {res32['ad']:.6f}"
f" relative error {abs(res32['fd'] - res32['ad']) / abs(res32['ad']):.1e}")
print(f"float32 finite difference (step 1e-3, near optimal) relative error {abs(res32['fd_best'] - res32['ad']) / abs(res32['ad']):.1e}"
f"; true relative residual of CG (tol=1e-10, N=63, random right-hand side) {res32['res63']:.1e}")
except Exception as e:
res32 = None
print("Subprocess failed:", type(e).__name__, e)
assert e32[-2] / e32[-1] < 3.5 # the error ratio at the finest level is clearly off from 4
assert abs(res32["fd"] - res32["ad"]) / abs(res32["ad"]) > 1e-3 # with step 1e-6, float32 finite differences keep only 2 to 3 significant digits
assert 1e-6 < abs(res32["fd_best"] - res32["ad"]) / abs(res32["ad"]) < 1e-4 # even near the optimal step, only 4 to 5 digits
assert res32["res63"] > 1e-7 # CG's true residual can't get below 1e-7
# ✅ Correct: gradient check in float64 (for the convergence study see section 2; second-order convergence holds all the way to the finest mesh)
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 finite difference {float(fd64):.10f} vs AD {float(ad64):.10f} relative error {abs(fd64 - ad64) / abs(ad64):.1e}")
assert abs(fd64 - ad64) / abs(ad64) < 1e-8
# For the Poisson convergence order in float64, see section 2
🔍 Extra: two other ways to turn on x64, and one cost¶
⚪ Optional: if you want to turn on x64 with an environment variable or a context manager, or need local single precision
- You can also set the environment variable
JAX_ENABLE_X64=1before starting Python, or use the context managerjax.enable_x64(). The latter still works in 0.12, but it only affects arrays created and traces started inside the context, which makes mixing easier to get wrong. For a scientific computing notebook, the safest choice is stilljax.config.updateon the first line. - Once x64 is on, integers default to int64 too (we asserted the type of
jnp.arangeabove), andBCOOindex arrays become int64 as well, doubling their memory. - When you need "local single precision" (for example large-scale preprocessing on a GPU), write
astype(jnp.float32)explicitly; but note the conclusion of section 9: even with x64 on, explicitly float32 arrays still trigger the closure-constant bug.
2 The finite-difference Poisson equation¶
2.1 Problem and discretization¶
🟢 Must-read
On the unit square $\Omega=(0,1)^2$, solve
$$-\Delta u = f \quad \text{in } \Omega,\qquad u = 0 \quad \text{on } \partial\Omega .$$
With $N\times N$ interior nodes and step $h = 1/(N+1)$, the five-point finite-difference scheme is
$$(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},$$
where neighbors beyond the boundary take $u=0$ (homogeneous Dirichlet). $A_h$ is symmetric positive definite, and the local truncation error is $O(h^2)$.
Manufactured solution: take $u^\ast = e^{x + y/2}\sin(\pi x)\sin(\pi y)$ (zero on the boundary). We don't derive the source term $f = -\Delta u^\ast$ by hand; we compute it directly with jax.hessian, a small use of AD in numerical analysis. Each time the mesh is refined by a factor of 2, the max error should shrink to about $1/4$.
Why not use the more common $\sin(\pi x)\sin(\pi y)$? It happens to be a discrete eigenvector of the five-point operator, so CG converges exactly in one step, which tells you nothing about how the solver really performs (the extra in section 2.3 runs into this).
2.2 The matrix-free operator¶
🟢 Must-read
CG only needs one function, "given $v$, return $A_h v$"; it doesn't need the matrix itself. On a 2D grid we can write the five-point stencil directly with array slices:
- No matrix assembly: memory is just one $N\times N$ array, and after
jitthe operator fuses into a single loop; - Differentiable: coefficients and source terms are just ordinary arrays, so
gradpasses straight through; - Easy to
vmap: there's no sparse-format index structure.
The boundary condition is implemented by padding a ring of zeros with jnp.pad(u, 1). If you cut corners and use jnp.roll directly, you get periodic boundary conditions; see the ❌/✅ comparison below.
def neg_lap(u, h):
"""Matrix-free five-point operator -Δ_h u (homogeneous Dirichlet boundary, implemented by zero padding)"""
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 reference: assemble the same matrix with Kronecker products (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) # row-major flattening, matching the ordering of kron(I, T) + kron(T, I)
err_pad = rel(neg_lap(jnp.asarray(v_np), h), ref)
print(f"pad-and-slice operator vs SciPy CSR: relative error {err_pad:.1e}")
assert err_pad < 1e-14
# ❌ Wrong: taking neighbors directly with jnp.roll; roll wraps values from the opposite edge around, which amounts to periodic boundary conditions
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"plain-roll operator vs SciPy (Dirichlet): overall relative error {err_roll:.3f}; "
f"interior nodes {err_inner:.1e}, first row {err_edge:.3f}; no error raised, but every node next to the boundary is wrong")
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
# ✅ Correct: pad a ring of zeros first, roll on the padded array, then crop the outer ring (exactly equivalent to the slicing version)
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 operator vs SciPy: relative error {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 Solving with jax.scipy.sparse.linalg.cg¶
🟢 Must-read
In jsl.cg(A, b, x0=None, *, tol=1e-05, atol=0.0, maxiter=None, M=None), A can be a matrix or a function $v\mapsto Av$. b can be an array of any shape (even a pytree), so the solution on a 2D grid doesn't need to be flattened.
Note that h and f are both arguments of solve_poisson. The lambda v: neg_lap(v, h) closure inside CG captures a traced value, not a constant, so it isn't affected by the closure-constant bug. maxiter must be a Python integer; here it's derived from f.size (a static shape).
@jax.jit
def solve_poisson(f, h):
"""-Δu = f, homogeneous Dirichlet; the operator is passed to cg as a function"""
u, info = jsl.cg(lambda v: neg_lap(v, h), f, tol=1e-12, maxiter=10 * f.size)
return u
def u_star(p):
"""Manufactured solution u*(x, y), zero on the boundary of the unit square"""
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*, computed automatically by AD
# Spot-check one point against the hand-derived analytic expression
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):
"""Return h, the grid coordinates, the manufactured solution u* and the source term f = -Δu* (all on the N×N interior nodes)"""
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} {'max error':>12} {'rel resid':>10} {'time (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 # algebraic error is far below discretization error
errs = np.array(errs)
ratios = errs[:-1] / errs[1:]
orders = np.log2(ratios)
print("Error ratios:", np.round(ratios, 4))
print("Convergence orders:", np.round(orders, 4))
assert np.all(np.abs(ratios - 4) < 0.1) # refining the mesh by 2 shrinks the error to about 1/4
assert np.all(np.abs(orders - 2) < 0.02) # second-order convergence
POISSON_ORDERS = orders
# Cross-check against SciPy's sparse direct solver (same discrete system; should agree within rounding error)
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: relative difference {rel(u_jax, u_sp):.1e}")
assert rel(u_jax, u_sp) < 1e-10
🔍 Extra: jsl.cg doesn't tell you whether it converged¶
⚪ Optional: when you use
jsl.cgfor real computations and need to judge convergence and pick a preconditioner
- The stopping criterion is $\|r\|_2 \le \max(\texttt{tol}\cdot\|b\|_2,\ \texttt{atol})$, matching newer SciPy (non-legacy);
maxiterdefaults to10 * b.size. - The second return value
infois alwaysNone(hard-coded in the source). If it hasn't converged bymaxiter, there's no error or warning either. So always compute the residual yourself and assert on it, as above. - If the right-hand side happens to be an eigenvector of the operator (such as $\sin(\pi x)\sin(\pi y)$), CG converges in one step; avoid that when timing or counting iterations.
- The operator must be symmetric positive definite (and so must
Mif you use a preconditioner);jsl.cgdoesn't check this. For nonsymmetric problems usegmres/bicgstab. For variable-coefficient problems, a diagonal (Jacobi) preconditionerM=lambda r: r / diagis recommended, also passed as a function.
# Check: info is None; with too small a maxiter it silently returns an unconverged solution
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, "| relative residual after only 5 iterations:", f"{rel(neg_lap(u_bad, h), f):.2e}")
assert info is None
assert rel(neg_lap(u_bad, h), f) > 1e-2
3 Sparse matrices: jax.experimental.sparse¶
Matrix-free operators suit structured grids. On unstructured meshes (finite elements, finite volumes), you usually still have to assemble a sparse matrix first. JAX's sparse support lives in jax.experimental.sparse, and the main format is BCOO (batched COO):
BCOO((data, indices), shape=...):datahas shape(nse,),indiceshas shape(nse, 2), andnseis the number of stored elements;- Duplicate indices are allowed (they're summed during matrix-vector products), which matches exactly how finite-element assembly "accumulates element matrices into the global matrix";
sum_duplicates()merges duplicates; - It's a pytree, so it can be passed as an argument to
jit/gradfunctions;datais differentiable, whileindicesare integers and don't take part in differentiation.
3.1 Assembling the five-point matrix from triplets and cross-checking against SciPy CSR¶
⚪ Optional: when you need to assemble a sparse matrix for an unstructured mesh
def five_point_triplets(N, h):
"""COO triplets (row, col, val) of the five-point matrix, generated with NumPy (preprocessing doesn't need to be differentiable)"""
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 reference
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 is passed in as an argument, not captured in a closure
y_bcoo = matvec(A_bcoo, jnp.asarray(x_np))
y_csr = A_csr @ x_np
print(f"BCOO matvec vs SciPy CSR: relative error {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 # matches the matrix-free operator
# Convert directly from SciPy, and convert back to dense for a small-scale check
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())
# Finite-element-style assembly: duplicate indices accumulate (BCOO keeps duplicates, sums them in matvec; sum_duplicates merges them)
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("Dense form:\n", A_dup.todense(), "\nnse:", A_dup.nse, "→ nse after merging:", 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: letting "dense-style" functions accept sparse matrices¶
⚪ Optional: if you want to write sparse code in dense style, or need derivatives with respect to matrix entries
sparse.sparsify(f) replaces operations in f such as @, .T and elementwise multiplication with sparse versions, so you can write the function body as if the matrix were dense. Also, data can be differentiated directly. That's useful when you want "sensitivities with respect to matrix entries", for example to study perturbations of certain entries of a stiffness matrix. sparse.grad additionally wraps the gradient as a BCOO with the same sparsity structure.
def energy_dense(M, x):
"""Written as for a dense matrix: ||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 result {float(e_sp):.6e} vs SciPy {e_ref:.6e}")
assert abs(float(e_sp) - e_ref) / abs(e_ref) < 1e-13
# Derivative with respect to the matrix nonzeros: d/dA_ij ||Ax||^2 = 2 (Ax)_i x_j, evaluated only at the nonzero positions
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("Shape of the gradient w.r.t. data:", g_data.shape, "| relative error vs the analytic formula:", 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, and the state of sparse direct solvers¶
⚪ Optional: when you need a sparse direct method, or need to solve for many right-hand sides in a batch
A BCOO can be used directly in the matvec of cg. For direct methods, jax.experimental.sparse.linalg.spsolve(data, indices, indptr, b) takes the CSR triple. But its docs are clear: only the CUDA backend has a native implementation; the CPU backend falls back to scipy.sparse.linalg.spsolve (a Python callback), and neither the CPU nor the GPU implementation supports vmap. Let's verify that below.
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: relative difference {rel(u_cg_bcoo, u_ref):.1e}")
assert rel(u_cg_bcoo, u_ref) < 1e-10
A_bcsr = sparse.BCSR.from_bcoo(A_bcoo) # convert to the CSR triple
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 calls back into SciPy) vs SciPy: {rel(u_ss, u_ref):.1e}; works with jit and 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 # gradient = A^{-T} 1
assert "scipy.sparse.linalg.spsolve" in inspect.getsource(sparse.linalg._spsolve_cpu_lowering)
# ❌ Wrong: vmap over spsolve (solving for several right-hand sides at once); there's no batching rule
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
# ✅ Correct: for batched right-hand sides use vmap(cg) (the matvec is a BCOO, which batches); or factor once in SciPy and back-substitute many times
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: one LU, many back-substitutions
U_ref = np.stack([lu.solve(np.asarray(bb)) for bb in B])
print(f"batched solve with vmap(cg) vs SciPy splu: {rel(U, U_ref):.1e}")
assert rel(U, U_ref) < 1e-10
# Timing (printed only): the same N=63 five-point operator, three kinds of 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"matrix-free matvec : {t_mf * 1e3:8.1f} µs")
print(f"SciPy CSR : {t_sp * 1e3:8.1f} µs (NumPy side, no JAX dispatch overhead)")
The state of sparse support in JAX (0.12, based on the docs and the results above)
| Aspect | Status |
|---|---|
| Module location | Still in jax.experimental.sparse; the API may change |
| Formats | BCOO (the main one, best supported), BCSR; the docs for the COO/CSR classes say they "have poor compatibility with transformations like grad; prefer BCOO" |
| matvec / matmat | Available, and works with jit, grad (w.r.t. data) and vmap; on CPU it's a generic gather + segment-sum, usually slower than both a matrix-free stencil and SciPy; on GPU some operations go through cuSPARSE |
| Direct solves | Only sparse.linalg.spsolve: cuSOLVER (QR) on GPU, calls back into SciPy on CPU; no vmap; no reusable LU/Cholesky factorization object |
| Iterative solves | jax.scipy.sparse.linalg.cg / gmres / bicgstab accept a function or a BCOO and are differentiable (they use custom_linear_solve internally; see section 4.5) |
| Eigenvalues | sparse.linalg.lobpcg_standard (experimental) |
| Sparsity structure | nse is static: a changing sparsity pattern (adaptive refinement) triggers recompilation |
jnp functions | Most jnp.* functions don't accept BCOO (for example jnp.linalg.solve raises TypeError right away); you need sparsify or hand-written code |
Practical advice: prefer matrix-free operators for structured grids; for unstructured meshes, assemble with BCOO and use iterative methods. When you really need a direct method on CPU, just use SciPy: wrap it with jax.pure_callback and supply the derivative with custom_linear_solve, the same pattern as in section 4.
4 Implicit differentiation of solvers¶
4.1 The math behind the adjoint method¶
🟢 Must-read
Write the discrete PDE as a linear system $A(\theta)\,u = f(\theta)$, where $\theta$ are the parameters (for example the diffusion coefficient at each node, thousands of them), and the objective is a scalar $J(u(\theta),\theta)$. Differentiating the constraint:
$$A\,\frac{\partial u}{\partial \theta} = \frac{\partial f}{\partial \theta} - \frac{\partial (A u)}{\partial \theta}\bigg|_{u\ \text{fixed}} \quad\Longrightarrow\quad \frac{\partial u}{\partial \theta} = A^{-1}\Big(\frac{\partial f}{\partial\theta} - \frac{\partial (Au)}{\partial\theta}\Big).$$
So
$$\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).$$
Computing $\partial u/\partial\theta$ directly takes $\dim\theta$ linear solves. The adjoint method changes the order of association: first solve one adjoint equation
$$A^{\top}\lambda = \Big(\frac{\partial J}{\partial u}\Big)^{\top},$$
and then get
$$\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)\;}$$
The whole gradient costs one forward solve + one adjoint solve + one VJP of $\lambda^\top \partial(Au)/\partial\theta$, independent of the number of parameters. This is exactly the VJP of reverse-mode AD: given the cotangent $\bar u = \partial J/\partial u$, we have $\bar b = A^{-\top}\bar u$ (the $\lambda$ above) and $\bar\theta = \bar b^{\top}\big(\partial f/\partial\theta - \partial(Au)/\partial\theta\big)$; when $\theta$ appears only in $A$, this is $-\bar b^{\top}\,\partial(Au)/\partial\theta$.
Comparing the two ways to differentiate
| Differentiating through the unrolled iterations | Implicit differentiation (implicit / adjoint) | |
|---|---|---|
| How | Write CG as a fixed-step fori_loop and let AD pass through every step | Treat the "solution" only as the solution of $A u=f$ and use the formula above |
| Memory | Stores the residual, direction and step size of every step: $O(K\cdot n)$, with $K$ the number of iterations | Stores only the solution $u$: $O(n)$, independent of the number of iterations |
| Accuracy | You get the derivative of "the floating-point iteration"; if it hasn't converged the gradient is inaccurate, and iterating past convergence can also amplify rounding error (see section 4.3) | You get the derivative of "the true solution"; accuracy depends only on the tolerances of the forward and adjoint solves |
| Solver requirements | Must be a fixed-step loop that can be differentiated in reverse mode | The forward solver can be any black box (while_loop, a SciPy callback, a preconditioner…) |
JAX provides dedicated primitives for this:
lax.custom_linear_solve(matvec, b, solve, transpose_solve=None, symmetric=False, has_aux=False): declares that "solve(matvec, b)returns $A^{-1}b$". When differentiating, JAX only usesmatvec(taking JVPs/VJPs with respect to the parameters it captures in its closure) andsolve/transpose_solve, and never goes insidesolve.lax.custom_root(f, initial_guess, solve, tangent_solve): declares that "solvereturns a root of $f(x)=0$" and differentiates with the implicit function theorem; it's for nonlinear solvers (section 4.6).
Compared with hand-writing a fixed-point derivative with custom_vjp as in Advanced 04, these two primitives support both forward and reverse mode, work with vmap, and automatically differentiate with respect to traced values captured in the matvec closure, so you don't have to sort out the argument list yourself.
4.2 A variable-coefficient diffusion operator¶
⚪ Optional: when you need the discretization details of a variable-coefficient diffusion operator
Generalize section 2 to the variable-coefficient equation $-\nabla\cdot(k\nabla u) = f$: $k$ is defined at the nodes, the coefficient on each face is the arithmetic mean of the two neighboring nodes, and the flux-difference scheme is
$$(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].$$
For $k>0$, $A(k)$ is symmetric positive definite. Below we first check three things: with $k\equiv1$ it reduces to neg_lap; symmetry $\langle Au, v\rangle = \langle u, Av\rangle$; and the transposed operator obtained with jax.linear_transpose matches the original.
def apply_A(k, u, h):
"""Matrix-free variable-coefficient diffusion operator -∇·(k∇u); k and u are both N×N node arrays, and k outside the boundary takes the nearest value"""
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]) # face coefficients in x (N+1, N)
ky = 0.5 * (kp[1:-1, 1:] + kp[1:-1, :-1]) # face coefficients in y (N, N+1)
fx = kx * (up[1:, 1:-1] - up[:-1, 1:-1]) # face fluxes
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 # spot-check positive definiteness
print("Variable-coefficient operator: reduction check, symmetry and linear_transpose consistency all pass")
4.3 The two ways to differentiate: implementation and cross-check¶
🟢 Must-read
cg_unrolled: standard CG written as afori_loopwith a static number of steps (it lowers toscaninternally and can be differentiated in reverse mode);cg_while: the same CG, written withwhile_loopand stopping on the residual. It can't be differentiated in reverse mode directly (section 4.8 of Advanced 04). But it's perfectly fine as thesolveofcustom_linear_solve, because AD never goes through it;u_implicit:lax.custom_linear_solve(matvec, f, solve, symmetric=True).symmetric=Truemeans $A^\top=A$, sotranspose_solvejust reusessolve.
The objective is the data misfit $J(k) = \tfrac12 h^2\sum (u(k) - u_{\text{obs}})^2$.
def cg_unrolled(matvec, b, n_iter):
"""CG with a fixed n_iter steps (static-length fori_loop → reverse-mode differentiable, but stores every step)"""
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 that stops on the relative residual (while_loop; can't be differentiated in reverse mode directly)"""
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):
"""Implicit differentiation: AD only uses matvec (differentiating w.r.t. the k in its closure) and solve itself"""
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) # a synthetic "observation"
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)
# Finite differences (central, along 3 random directions); the forward pass uses cg_while with a tight tolerance
J_fd = jax.jit(J_implicit)
eps = 1e-5
print(f"{'dir':>4} {'finite diff':>16} {'implicit':>16} {'unrolled':>16} {'impl rel err':>12} {'unr rel err':>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 # the implicit gradient matches finite differences
assert eu < 1e-3 # the gradient unrolled for 300 steps also matches finite differences (but several orders of magnitude worse)
GRAD_REL_UNR_IMP = rel(g_unr, g_imp)
print(f"\nUnrolled {K_UNROLL} steps vs implicit: relative gradient difference {GRAD_REL_UNR_IMP:.2e}")
assert GRAD_REL_UNR_IMP < 1e-4
# Memory: read the temporary buffers the compiled XLA program needs for backprop
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"unrolled {K} steps"] = temp_mib(J_unrolled, k0, f_src, u_obs, h, K, static_argnums=4)
MEM["implicit (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["unrolled 300 steps"] > 2.5 * MEM["unrolled 100 steps"] # grows linearly with the number of iterations
assert MEM["implicit (custom_linear_solve)"] < MEM["unrolled 300 steps"] / 50
print("\nTiming (printed only):")
print(f" unrolled 300 steps grad: {timeit(grad_unr, k0, f_src, u_obs, h, K_UNROLL):.2f} ms")
print(f" implicit grad : {timeit(grad_imp, k0, f_src, u_obs, h):.2f} ms")
print("\nResiduals saved by the implicit version (only the input k and a few same-sized arrays computed from the solution u; nothing stacked per iteration):")
print_saved_residuals(lambda k: J_implicit(k, f_src, u_obs, h), k0)
Why the implicit gradient is more accurate: below we fix the number of iterations $K$ and compare the errors of "unrolled differentiation" and "implicit differentiation with at most $K$ iterations each for the forward and adjoint solves", relative to the true gradient (the tight-tolerance implicit gradient, already cross-checked against finite differences).
print(f"{'K':>5} {'fwd sol err':>12} {'unrolled grad err':>14} {'implicit grad err':>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: exactly K iterations
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)
# Same K=80: finite differences of the unrolled function itself give the true gradient; but both the AD jvp (forward) and vjp (reverse) are far from it, and disagree with each other
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: true gradient {float(true80):.6e} | finite difference of the unrolled function itself {float(fd80):.6e} | "
f"unrolled jvp {float(jvp80):.6e} | unrolled vjp {float(vjp80):.6e}")
assert abs(fd80 - true80) < 1e-6 * abs(fd80) # the function value itself has converged, so its finite difference is the true gradient
assert abs(jvp80 - true80) > 100 * abs(true80) # the forward-mode unrolled derivative is just as wrong → not a reverse-mode bug
assert abs(vjp80 - true80) > 100 * abs(true80)
assert scan_rows[:, 2].max() > 1e-2 # the unrolled gradient gets badly distorted around convergence
assert np.all(scan_rows[scan_rows[:, 0] >= 80][:, 3] < 1e-5) # the implicit gradient converges steadily along with the forward solve
What the results show:
- Before convergence ($K\le 40$), both gradients are inaccurate, but the implicit gradient's error always stays on the same order as the forward solution's error;
- Right around convergence ($K\approx 70\sim 100$), the forward solution error has already dropped to $10^{-6}\sim10^{-10}$, yet the unrolled gradient can be off by several orders of magnitude. Finite differences of the unrolled function itself equal the true gradient, so the function itself isn't "steep". Both the forward-mode (jvp) and reverse-mode (vjp) unrolled derivatives are far from the true value and disagree with each other, so this isn't a bug in the reverse-mode implementation; it's amplified rounding noise. Differentiating CG's $\alpha=\frac{r^\top r}{p^\top Ap}$ and $\beta=\frac{r_{k+1}^\top r_{k+1}}{r_k^\top r_k}$ produces factors on the order of $1/\lVert r\rVert^2$ (for example $\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$). The smaller the residual, the bigger these terms get. In exact arithmetic they cancel out; in floating point the cancellation is incomplete, so rounding error gets multiplied. The forward iteration "corrects itself" (once the residual is small it stops changing the solution), but the derivative recurrence can't. The exact numbers are sensitive to how things are compiled and to the direction, and differ a bit from run to run, but the order of magnitude is stable;
- Only when iterating far more than needed ($K=300$) does the unrolled gradient come back down, and even then it's several orders of magnitude less accurate than the implicit one, while using hundreds of times more memory.
So, whenever a solver is "solving an equation", differentiate the equation, not the solution process.
4.4 symmetric and transpose_solve: the pitfall with nonsymmetric operators¶
🟢 Must-read
Reverse mode needs to solve the transposed system $A^\top\lambda = \bar u$. custom_linear_solve solves it with transpose_solve(vecmat, b), where vecmat is $v\mapsto A^\top v$, obtained automatically by JAX via linear_transpose. symmetric=True is a promise: JAX just uses solve and matvec to solve the transposed system, with no checks at all.
Example: 1D convection-diffusion $-u'' + \beta u' = f$ (central differences); for $\beta\neq 0$ the matrix is nonsymmetric. We solve with GMRES and compare the gradient of $J = w^\top u$ with respect to $f$ (the analytic value is $A^{-\top}w$).
n1 = 50; h1 = 1.0 / (n1 + 1)
def conv_diff(beta, u):
"""1D convection-diffusion -u'' + beta u' (central differences, Dirichlet 0); nonsymmetric for 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))) # small problem: an explicit matrix as the reference
lam_ref = np.linalg.solve(A1.T, np.asarray(w1)) # dJ/df = A^{-T} w
assert not np.allclose(A1, A1.T)
# ❌ Wrong: declaring symmetric=True for a nonsymmetric operator; the reverse-mode gradient is silently wrong. Leaving out transpose_solve raises an error outright
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: relative gradient error {err_sym:.3f} (no warning at all)")
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("Without transpose_solve: TypeError:", msg_tr)
assert err_sym > 0.1
assert msg_tr is not None and "transpose_solve" in msg_tr
# ✅ Correct: symmetric=False and provide transpose_solve (here also using GMRES to solve 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 relative error {rel(g_f, lam_ref):.1e}; dJ/dβ: AD {float(g_beta):.10f} finite difference {float(fd_beta):.10f}")
assert rel(g_f, lam_ref) < 1e-10
assert abs(g_beta - fd_beta) < 1e-8 * abs(g_beta)
# Forward mode only uses solve, so it isn't affected by the symmetric declaration
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 is itself implicit differentiation¶
🟢 Must-read
One look at the source shows that jsl.cg / gmres / bicgstab all call lax.custom_linear_solve(A, b, solve=..., transpose_solve=...) internally, and the iteration itself uses while_loop. So taking grad of jsl.cg directly gives you the adjoint gradient, with memory independent of the number of iterations. You only need to wrap things in custom_linear_solve explicitly when you hand-write your own iterative solver.
src = inspect.getsource(sys.modules[jsl.cg.__module__]._isolve)
assert "custom_linear_solve" in src and "transpose_solve" in src
print("The internal implementation of jsl.cg calls 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 gradient vs hand-written custom_linear_solve: relative difference {rel(g_jsl, g_imp):.1e}; "
f"temporary memory {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["unrolled 100 steps"] / 10
4.6 Nonlinear problems: lax.custom_root¶
⚪ Optional: when you solve a nonlinear equation and need derivatives of the solution
Nonlinear reaction-diffusion $F(u;k,c) = -\nabla\cdot(k\nabla u) + c\,u^3 - f = 0$, solved with Newton–Krylov: each step solves $J_F\,\delta u = -F$, with $J_F\,v$ obtained from jax.jvp (matrix-free) and cg_while as the inner solver.
The contract of lax.custom_root(f, initial_guess, solve, tangent_solve):
f: the residual function, which takes only the unknown $u$; the parameters $k, c$ come in through a closure (they're traced values, and JAX differentiates with respect to them);solve(f, x0): any root finder (here Newton;fori_looporwhile_loopboth work, since AD doesn't go through it);tangent_solve(g, y): solves the linearized system $g(x)=y$, where $g$ is the linearization of $f$ at the root.
By the implicit function theorem, $\dfrac{du^\ast}{dp} = -J_F^{-1}\dfrac{\partial F}{\partial p}$. In reverse mode JAX transposes tangent_solve, so tangent_solve must be a linear function of y that can be transposed. This is an easy place to trip up; let's try it below.
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's method, with Jacobian-vector products from jvp and inner CG (the Jacobian is symmetric positive definite)"""
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 converged: relative residual {res_nl:.1e}")
assert res_nl < 1e-11
# ❌ Wrong: calling a while_loop solver directly inside tangent_solve (not transposable), or calling jsl.cg directly (errors on 0.12.0.1)
bad_tangent = {
"plain cg_while": lambda g, y: cg_while(g, y),
"plain 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] = "no error"
except Exception as e:
bad_results[name] = type(e).__name__
print(f"{name:<14} → {type(e).__name__}: {str(e).splitlines()[0][:90]}")
# Forward mode doesn't need to transpose tangent_solve, so both work there
_, t = jax.jvp(lambda c: L_root(k0, c, ts), (5.0,), (1.0,))
print(f"{'':<14} but jvp works: dL/dc = {float(t):.8f}")
assert bad_results["plain cg_while"] == "ValueError"
assert bad_results["plain jsl.cg"] == "NotImplementedError"
# ✅ Correct: inside tangent_solve, wrap the iterative solver in custom_linear_solve (the linearized operator is symmetric, so symmetric=True is valid)
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} finite difference {float(fd_c):.10f}")
print(f"dL/dk·d: AD {float(jnp.vdot(gk, d)):.10e} finite difference {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)
🔍 Extra: three preconditions for implicit differentiation¶
⚪ Optional: to check the preconditions before using implicit differentiation for real
- The forward solution has to be accurate enough. The implicit formula assumes $A u = f$ (or $F(u)=0$) holds exactly. If the forward solve only gets to $10^{-6}$, the gradient is generally only accurate to about $10^{-6}$ too. The same goes for the tolerance of the adjoint solve. A common compromise in inverse problems: use a relative tolerance around $10^{-10}$ for both the forward and adjoint solves (cheap in float64).
- The adjoint operator has to be right. For symmetric problems use
symmetric=True; for nonsymmetric ones (convection, non-conservative schemes, some boundary treatments) you must providetranspose_solve. If you're not sure, generate $A^\top$ withjax.linear_transposeand check symmetry numerically as in section 4.2. - The Jacobian has to be invertible. When $A$ or $J_F$ is singular (pure Neumann problems, bifurcation points), the implicit gradient doesn't exist. For pure Neumann problems, pin down the constant mode first (for example add the constraint $\sum u = 0$).
5 Inverse problem: recovering the diffusion coefficient $k(x)$¶
5.1 Setup¶
🟢 Must-read
Using the variable-coefficient problem $-\nabla\cdot(k\nabla u)=f$ from section 4: given several source terms $f_s$ and the corresponding (noisy) observed fields $u^{\text{obs}}_s$, recover the spatially varying coefficient field $k(x)$.
- Parameterization: $k = e^{m}$, with $m$ as the optimization variable ($N^2=961$ of them), which guarantees $k>0$ automatically;
- Multiple load cases: with a single source term, $k$ can't be identified where $\nabla u = 0$. So we use 5 different source terms (4 localized Gaussian sources + 1 uniform source), and the forward solves are done in one go with
vmap; - Regularization: the inverse problem is ill-posed, so we add a Tikhonov smoothing term $\tfrac{\alpha}{2}\|\nabla_h m\|^2$;
- Objective
$$\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 .$$
Each gradient = 5 forward solves + 5 adjoint solves (handled automatically by custom_linear_solve), independent of the number of parameters.
Data and source terms are passed to the jit functions as arguments, not captured in closures.
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))) # solve all 5 load cases at once
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)
# Cross-check the forward solve against SciPy (the variable-coefficient matrix is computed explicitly with jacfwd; 961×961 is feasible)
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"load cases: {F_src.shape[0]}, noise std {noise_level:.2e}; forward solution vs NumPy dense solve {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
# Cross-check the gradient against finite differences first
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"Inverse-problem gradient: AD {float(jnp.vdot(g_inv, d)):.10e} finite difference {float(fd_inv):.10e}")
assert abs(jnp.vdot(g_inv, d) - fd_inv) < 1e-6 * abs(fd_inv)
5.2 Optimizing with Optax's L-BFGS¶
⚪ Optional: when you want deterministic optimization with Optax's L-BFGS
First confirm that Optax 0.2.9 has optax.lbfgs (it comes with a zoom line search). L-BFGS's update takes a few more keyword arguments than first-order optimizers: value, grad and value_fn (the line search needs to re-evaluate the function). optax.value_and_grad_from_state reuses the function value and gradient already computed during the line search, saving one forward + adjoint pass.
assert hasattr(optax, "lbfgs") and hasattr(optax, "value_and_grad_from_state")
print("optax.lbfgs signature:", 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) # the closure holds traced values, not constants
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"iter {it:3d}: objective {v:.6e} |k relative error {e:.4f} |gradient norm {gn:.2e}")
m_lbfgs = m
INV_ERR = k_err(m_lbfgs)
print(f"Initial k error {err0:.4f} → {INV_ERR:.4f} after 60 L-BFGS steps (took {t_lbfgs:.2f} s, printed only)")
assert INV_ERR < 0.03
assert INV_ERR < err0 / 10
# For comparison: Adam (a first-order method, needs many more steps)
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 steps: objective {float(val_a):.6e}, k relative error {k_err(m_a):.4f} (took {time.perf_counter() - t0:.2f} s, printed only)")
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, "true k(x)"), (axes[1], jnp.exp(m_lbfgs), "L-BFGS reconstruction")]:
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"error (relative 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()
🔍 Extra: what matters more than the optimizer in inverse problems¶
⚪ Optional: when you do inverse problems for real and need to deal with regularization and identifiability
- Where the error is: the most visible thing in the error map is that the tops of the two Gaussian peaks are underestimated (blue), the classic sign of smoothing regularization flattening sharp peaks. The rest is small random error caused by the noise. L-BFGS and Adam converge to the same objective value and the same error, which shows the 1.2% error comes from "noise + regularization", not from the optimizer failing to converge.
- Identifiability: near the boundary $u=0$ and $\nabla u$ is small, so the data is insensitive to $k$; with a single source term, $k$ is completely unidentifiable at points where $\nabla u=0$. Multiple load cases (different source terms) are the most effective improvement, far more than switching optimizers.
- Regularization parameter: if $\alpha$ is too big, the peaks get flattened; too small, and noise gets amplified into high-frequency oscillations. A common choice is Morozov's discrepancy principle: pick $\alpha$ so the misfit is about the noise level.
- Parameterization: $k=e^m$ both guarantees positivity and turns "multiplicative" changes into additive ones, which improves conditioning. In the discretization you can also put $k$ on the elements instead of the nodes.
- Second-order information: Gauss–Newton / Newton-CG need Hessian-vector products, which you can get with
jax.jvp(jax.grad(loss), ...)(forward-over-reverse; see the HVP section of Basics 07), and this works throughcustom_linear_solvetoo.
6 Time-dependent problems: the heat equation¶
6.1 Explicit and Crank–Nicolson schemes¶
🟢 Must-read
The 1D heat equation $u_t = \alpha u_{xx}$, $x\in(0,1)$, $u(0,t)=u(1,t)=0$. In space we use central differences $L_h$, and write $r = \alpha\Delta t/h^2$:
- Explicit Euler: $u^{n+1} = u^n + \alpha\Delta t\,L_h u^n$. First order in time, conditionally stable: it requires $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$. Second order in time, unconditionally stable. Each step solves a tridiagonal system with
lax.linalg.tridiagonal_solve(dl, d, du, b)(the $O(n)$ Thomas algorithm, differentiable).
Time integration uses lax.scan, and the physical parameter $\alpha$ and the step size are passed in as arguments.
Reference solution: take $u_0 = \sin(\pi x)$, an eigenvector of $L_h$ with eigenvalue $-\lambda_h = -\tfrac{4}{h^2}\sin^2\tfrac{\pi h}{2}$; the exact semi-discrete solution is $u(t) = e^{-\alpha\lambda_h t}u_0$. Using it as the reference isolates the time error.
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]) # convention: 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 # exact semi-discrete solution
# Cross-check the tridiagonal solve against 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 [("Explicit 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} time error {np.array2string(e, precision=3)} error ratio {np.round(ratio, 3)}")
if step is explicit_step:
assert np.all(np.abs(ratio - 2) < 0.05) # first order
else:
assert np.all(np.abs(ratio - 4) < 0.05) # second order
# ❌ Wrong: using r > 1/2 with the explicit scheme; no error is raised, and high-frequency modes in the rounding error grow exponentially by a factor of |1-4r| per step
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) # simulate a perturbation at rounding level
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}: explicit scheme max|u(T)| = {blow:.3e} (the true solution is about {float(u_semi.max()):.3f})")
except Exception as e:
blow = None
print(type(e).__name__, e)
assert blow > 1e10
# ✅ Correct: keep r ≤ 1/2 for the explicit scheme; or switch to the unconditionally stable Crank–Nicolson (same large step)
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 error {err_cn_big:.2e}")
assert err_cn_big < 1e-4
n_ok = int(np.ceil(alpha0 * T_end / (0.5 * hx**2))) # the minimum number of steps that satisfies r ≤ 1/2
u_exp_ok = integrate(u0_pert, alpha0, T_end / n_ok, hx, explicit_step, n_ok)
print(f"The explicit scheme needs ≥ {n_ok} steps to be stable; the error is then {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 Sensitivities: gradients with respect to physical parameters and initial conditions¶
🟢 Must-read
Take the observation functional $Q = h\sum_i w_i\,u_i(T)$ with $w = x(1-x)$. grad goes through scan, and a single backward pass gives both $\partial Q/\partial\alpha$ and $\partial Q/\partial u_0$ (a 99-dimensional vector). The latter can also be checked analytically: the CN propagator $P=(I-\tfrac{\alpha\Delta t}{2}L_h)^{-1}(I+\tfrac{\alpha\Delta t}{2}L_h)$ is symmetric, so $\partial Q/\partial u_0 = h\,(P^{\top})^{n}w = h\,P^n w$, which is the same as integrating once more with $w$ as the initial value.
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} finite difference {float(fd_alpha):.10f}")
print(f"dQ/du0·d: AD {float(jnp.vdot(dQ_du0, du_dir)):.10f} finite difference {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) # adjoint = integrate w forward once more
print(f"dQ/du0 vs analytic adjoint: relative error {rel(dQ_du0, adj):.1e}")
assert rel(dQ_du0, adj) < 1e-12
6.3 Memory for long time integration: checkpoint and $O(\sqrt{T})$ segmentation¶
⚪ Optional: when backprop through a long time integration runs out of memory (theory in Advanced 06 §6.6)
When you differentiate scan in reverse mode, residuals have to be saved for every step. A CN step also has intermediates from the tridiagonal solve, so by default memory is about "number of steps × several state vectors". Three ways to write it:
- No checkpoint: save all residuals at every step;
- Per-step checkpoint
scan(jax.checkpoint(step)): save only the carry (the state $u^n$) at each step, and recompute the within-step intermediates in the backward pass; - $\sqrt{T}$ segmentation: an outer
scanruns $T/B$ blocks, each an innerscan($B$ steps) wrapped incheckpoint. The forward pass stores only the start of each block ($T/B$ states), and the backward pass recomputes block by block, storing $B$ steps inside the block. With $B\approx\sqrt T$, memory is $O(\sqrt T)$, at the cost of about one extra forward pass.
For the theory and policies, see section 6.6 of Advanced 06 and the 4.8 extra in Advanced 04; here we only look at the effect on PDE time stepping. Below, $N_x=999$ and $T=4096$ steps.
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 [("no checkpoint", loss_plain), ("per-step checkpoint", loss_ckpt), ("√T segments (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}: temporary memory {mem:8.2f} MiB time {t_ms:7.1f} ms (printed only)")
g_ref_ck = CK["no checkpoint"][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]) # gradients agree
assert CK["per-step checkpoint"][0] < CK["no checkpoint"][0] / 2
assert CK["√T segments (B=64)"][0] < CK["no checkpoint"][0] / 20
print("\nResiduals saved per step without checkpointing (a small 10-step example):")
print_saved_residuals(lambda u, a: loss_plain(u, a, dt2, hx, 10), u0, alpha0)
print("\nPer-step checkpoint:")
print_saved_residuals(lambda u, a: loss_ckpt(u, a, dt2, hx, 10), u0, alpha0)
As you can see: without checkpointing, each step saves the state, the right-hand side and several intermediate arrays from the tridiagonal solve (shapes [10, 99], [10, 98, 1] and so on); with per-step checkpointing only the [10, 99] state trajectory is left; $\sqrt{T}$ segmentation compresses that further to $T/B + B$ states.
🔍 Extra: "discretize then differentiate" vs "differentiate then discretize"¶
⚪ Optional: when choosing between the discrete adjoint and the continuous adjoint
Differentiating scan gives the discrete adjoint (discretize-then-optimize). It's the exact gradient of the discrete objective, and its relative difference from finite differences is around $10^{-8}$ (see section 6.2; mostly limited by the error of the finite differences themselves). The continuous adjoint (optimize-then-discretize) from Extra exercise C in Advanced 04 discretizes the adjoint ODE separately and stores only the endpoint, with $O(1)$ memory. But its gradient agrees with the discrete objective only up to $O(\Delta t^p)$, which can make line searches fail when used in optimization. Also, "store only the endpoint and integrate the state backward in the reverse pass" doesn't work for dissipative problems like the heat equation: the heat equation backward in time is ill-posed, and high-frequency errors get amplified exponentially. So continuous adjoints for PDEs usually have to store the forward trajectory too, or add checkpointing. In PDE-constrained optimization, unless memory is really too tight, prefer the discrete adjoint + checkpointing.
7 Differentiable mesh optimization¶
7.1 Problem: mapping a structured mesh onto a non-convex domain¶
⚪ Optional: if you work on mesh generation or mesh optimization
The computational domain is the L-shaped domain $\Omega = [0,1]^2 \setminus [0.5,1]\times[0.5,1]$, which has a $270^\circ$ reentrant corner at $(0.5, 0.5)$. We use an $n\times n$ structured quadrilateral mesh, and map the four sides of the parameter square $(s,t)\in[0,1]^2$ onto the boundary of $\Omega$:
- bottom $t=0$ → $(s, 0)$; left $s=0$ → $(0, t)$; right $s=1$ → $(1, t/2)$;
- top $t=1$ → follows the polyline $(0,1)\to(0.5,1)\to(0.5,0.5)\to(1,0.5)$ by arc length, so the reentrant corner lands exactly on a node ($n$ a multiple of 3).
The initial mesh is generated with transfinite interpolation (TFI):
$$\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 works well on convex domains, but near a reentrant corner it interpolates nodes outside the domain, producing inverted elements.
7.2 Mesh quality metrics¶
⚪ Optional: when you need to assess mesh quality
At each corner of a quadrilateral element, take the two edge vectors $\mathbf e_1, \mathbf e_2$ leaving that corner in counterclockwise order (multiplied by $n$ to normalize to parameter space), and form the corner Jacobian $J = [\mathbf e_1\ \mathbf e_2]$:
| Metric | Definition | Ideal value | Meaning |
|---|---|---|---|
| Jacobian determinant | $\det J$ | $>0$ | $\le 0$ means the element is inverted or degenerate |
| Scaled Jacobian | $\dfrac{\det J}{\lvert\mathbf e_1\rvert\,\lvert\mathbf e_2\rvert} = \sin\theta$ | 1 | Angle quality, in $[-1,1]$ |
| Orthogonality | $\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 | How far the angle between mesh lines deviates from $90^\circ$ |
| Condition number | $\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 | Measures angles and aspect ratio together; $\kappa\ge1$, with equality iff $J$ is a similarity transform |
7.3 Two smoothing energies¶
⚪ Optional: if you want to compare the Laplace and Winslow smoothing energies
- Laplace type: $E_L = \sum_{\text{edges}} \lvert\mathbf x_a - \mathbf x_b\rvert^2$. It's quadratic, and its minimizer satisfies "each interior node equals the average of its four neighbors", i.e. a discrete harmonic map. The Radó–Kneser–Choquet theorem only guarantees that a harmonic map is injective onto a convex target domain, so on a non-convex domain Laplace smoothing can fold.
- Winslow type: $E_W = \dfrac{1}{4n^2}\sum_{\text{corners}} \dfrac{\lVert J\rVert_F^2}{2\det J}$ (the mean corner condition number). In the continuous setting it's equivalent to Winslow's "inverse harmonic map"; as $\det J\to0^+$ the energy goes to $+\infty$, so it has a built-in barrier that keeps elements from inverting.
Both optimize only the interior nodes, with boundary nodes fixed.
def top_path(s):
"""Top side: follows the polyline (0,1)→(0.5,1)→(0.5,0.5)→(1,0.5) by arc length, total length 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):
"""(n+1)×(n+1) structured mesh on the L-shaped domain, generated by transfinite interpolation (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):
"""Jacobians at the 4 corners of each element, shape (4, n, n, 2, 2); the columns are the two counterclockwise-adjacent edges (multiplied by n to normalize)"""
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(inverted=n_inv, min_det=float(det.min()), min_sJ=float(sj.min()),
mean_sJ=float(sj.mean()), max_cos=float(cos.max()), mean_cos=float(cos.mean()),
max_cond=float(kappa.max()) if n_inv == 0 else float("inf"))
n_mesh = 24
X_tfi = jnp.asarray(l_shape_tfi(n_mesh))
# Boundary check: the four corners and the reentrant corner are all in the right place
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]) # the reentrant-corner node
# A single square element: det=1, scaled J=1, cos=0, condition number=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["min_sJ"], 1) and np.isclose(q_sq["max_cos"], 0) and np.isclose(q_sq["max_cond"], 1)
Q_TFI = mesh_quality(X_tfi)
print("Initial TFI mesh:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_TFI.items()})
assert Q_TFI["inverted"] > 0
def assemble(Xb, Y):
"""Write the interior nodes Y back into the full mesh; Xb supplies the (fixed) boundary nodes and is passed in as an argument"""
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 energy minimization: gradient norm {gn_lap:.1e}, max deviation from the four-neighbor average {float(jnp.abs(avg4 - Y_lap).max()):.1e}")
assert float(jnp.abs(avg4 - Y_lap).max()) < 1e-10 # the minimizer = a discrete harmonic map
Q_LAP = mesh_quality(X_lap)
print("After Laplace smoothing:", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in Q_LAP.items()})
assert Q_LAP["inverted"] > 0 # the harmonic map folds on the non-convex domain
7.4 A numerically stable barrier: double where¶
⚪ Optional: when writing barriers,
1/xand other functions that blow up at a boundary (how double where works: Basics 03 §3.8)
$\lVert J\rVert_F^2/\det J$ only makes sense for $\det J>0$. But the initial mesh already has inverted elements, and the optimizer may also probe points with $\det J\le 0$, so the barrier has to be extended to the whole real line. We pick a threshold $\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}$$
The second piece is the second-order Taylor expansion of $1/d$ at $\delta$, so $\phi$ is continuously differentiable and still grows as $d\to-\infty$, which can "push inverted elements back" (untangling).
Writing jnp.where(det > δ, 1/det, extension) directly is fine for the forward pass. But as soon as one corner has exactly $\det J = 0$ (a degenerate element, for example three collinear nodes), the derivative of the unselected branch $1/d$ is $-1/d^2 = -\infty$, multiplied by the 0 that where assigns to it, giving $0\times\infty=\text{NaN}$, which pollutes the whole gradient. For how this works, see the "double where" extra in Basics 03.
DELTA = 0.05
def barrier_ext(d, delta):
return 1 / delta - (d - delta) / delta**2 + (d - delta) ** 2 / delta**3
# A small mesh of 2×2 elements: move the center node onto the bottom boundary at (0.25, 0), collinear with (0,0) and (0.5,0) → some corner has det exactly 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("Corner dets of the degenerate mesh: number exactly 0 =", int(jnp.sum(det_deg == 0)), ", number negative =", int(jnp.sum(det_deg < 0)))
assert int(jnp.sum(det_deg == 0)) >= 1
# ❌ Wrong: a single where; the forward value is finite, but a corner with det=0 makes the derivative of the 1/det branch 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"energy = {float(v_bad):.4f} (finite), gradient = {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)))
# ✅ Correct: double where; first replace dangerous inputs with the safe value δ, then select the output; the 1/d branch never sees 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) # 1st where: mask the dangerous inputs
phi = jnp.where(ok, 1 / det_safe, barrier_ext(det, delta)) # 2nd where: select the output
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"energy = {float(v_ok):.4f}, gradient = {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)) # identical forward value
# The gradient matches finite differences (checked on the TFI initial mesh of the large grid, which has both regular and extended corners)
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"Winslow gradient on the TFI mesh: AD {float(jnp.vdot(gW, dY)):.8e} finite difference {float(fdW):.8e}")
assert abs(jnp.vdot(gW, dY) - fdW) < 1e-5 * abs(fdW)
7.5 Winslow optimization: starting from the inverted TFI mesh¶
⚪ Optional: if you want to see the full effect of mesh untangling and smoothing
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 optimization, 300 L-BFGS steps: energy {E_w:.6f}, gradient norm {gn_w:.1e}, took {t_mesh:.2f} s (printed only)\n")
rows = [("TFI", Q_TFI), ("Laplace", Q_LAP), ("Winslow", Q_W)]
keys = list(Q_W.keys())
print(f"{'mesh':<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]) # boundary nodes didn't move
assert np.allclose(X_w[:, 0], X_tfi[:, 0]) and np.allclose(X_w[:, -1], X_tfi[:, -1])
assert Q_W["inverted"] == 0 and Q_W["min_det"] > 0 # no inverted elements
assert Q_W["min_det"] > DELTA # the optimum is in the true barrier region; the extension isn't active
assert Q_W["min_sJ"] > Q_TFI["min_sJ"] + 0.5 # the worst angle quality improves a lot
assert Q_W["mean_sJ"] > Q_TFI["mean_sJ"] and Q_W["max_cos"] < Q_TFI["max_cos"] # overall angle quality improves too
assert Q_W["max_cond"] < 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: # fill the inverted elements
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}\ninverted elements: {len(bad)} (red)", 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, [("initial TFI mesh", X_tfi), ("Laplace smoothing", X_lap), ("Winslow + barrier optimization", X_w)]):
plot_mesh(ax, X, name)
plt.show()
Interpretation: both TFI and Laplace smoothing "pull" mesh lines out of the domain near the reentrant corner, producing inverted elements. Laplace smoothing even drives the minimum scaled Jacobian close to $-1$ (an element flipped completely over). Starting from the same inverted initial mesh, the Winslow energy first "untangles" the elements using the extension piece; once they're in the $\det J>\delta$ region, the barrier keeps them from inverting again, and in the end every corner has $\det J>0$. The two elements on either side of the reentrant corner have to share the $270^\circ$ interior angle, so the minimum scaled Jacobian can't get close to 1; that's dictated by the topology.
Also note that the Winslow energy optimizes the condition number (a combination of angles and aspect ratio), not orthogonality directly. In the table, the mean $\lvert\cos\theta\rvert$ is actually a little worse than TFI, because mesh lines near the reentrant corner have to bend to keep elements from inverting. If orthogonality matters more (for example in boundary-layer meshes), you can add a weighted penalty term $\sum\cos^2\theta$ to the energy.
🔍 Extra: other barrier forms and practical tips¶
⚪ Optional: when you do mesh optimization for real and need to choose the barrier form and scale
- Escobar regularization: replace $\det J$ with $h(d) = \tfrac12\big(d + \sqrt{d^2 + 4\delta^2}\big)$, which is positive and smooth everywhere and $h\approx d$ for $d\gg\delta$; it untangles and smooths at the same time (Escobar et al., 2003). The argument of $\sqrt{\cdot}$ is always positive, so you don't need double where; then decrease $\delta$ step by step.
- The log barrier $-\log\det J$ grows slowly as $\det J\to0^+$ and is often used together with "step-size control (guaranteeing $\det J>0$ after each step)"; with a line search, returning
inffor the function value in the infeasible region makes the zoom line search shorten the step automatically, but the gradient still needs double-where protection. - The scale of the barrier: $\delta$ should be chosen relative to a typical $\det J$. Here we use the $n$-normalized Jacobian ($\det J\approx$ domain area on a regular mesh), so $\delta=0.05$ is dimensionless.
- Mesh optimization has $2(n-1)^2$ variables, just a medium-sized smooth optimization problem, which suits L-BFGS well; for larger meshes you can do Gauss–Seidel-style local optimization node by node, running independent node sets in parallel with
vmap.
8 Parameter sweeps: solving a batch of PDEs at once with vmap¶
Write a solver as a pure function "single parameter → solution", and after vmap it handles a whole batch of parameters at once. Below we sweep the amplitude $a$ of the Gaussian bump in the variable-coefficient diffusion problem: $k = 1 + a\,g(x)$, with 32 values of $a$. The bump shape and the source term are passed in as arguments.
Note that jsl.cg uses while_loop internally, so after vmap the whole batch keeps iterating until the slowest one converges (see the 1.4 extra in Advanced 01). So when the parameters differ a lot, a batched solve doesn't necessarily save time over solving one by one.
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("Batched solution shape:", U_b.shape, "| max relative difference, vmap vs one by one:",
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)))
# Cross-check one of the parameters against 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"Timing (printed only): vmap batch {t_b:.1f} ms | Python loop one by one {t_l:.1f} ms")
# Sensitivity sweep: vmap(grad) gives dQ/da for every parameter at once, with Q = mean temperature (implicit gradient, memory independent of the number of iterations)
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 (every 8th): 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)) # bigger coefficient, faster diffusion, lower mean temperature
9 A checklist of practical rules¶
9.1 Measure first: does the closure-constant bug exist under float64?¶
⚪ Optional: if you want to know whether the closure-constant bug shows up under float64
For the symptoms, trigger conditions and workarounds of the closure-constant bug, see 00 Overview, "Known issues in this environment"; for the root-cause analysis, see Advanced 02 §2.3. The conclusions there were all obtained under float32. This section answers just one question: once x64 is on, do float64 arrays still trigger it?
Below, in the current kernel (x64 on), we probe with a float64 matrix and an explicitly float32 matrix, using NumPy float64 for the reference values.
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 closure sum": jax.jit(jax.vmap(lambda x: jnp.sum(W @ x), in_axes=1))(X) - ref_sum,
"mat closure sum": jax.jit(lambda X: jnp.sum(W @ X, axis=0))(X) - ref_sum,
"mat closure max": jax.jit(lambda X: jnp.max(W @ X, axis=0))(X) - ref_max,
"mat arg sum": jax.jit(lambda W, X: jnp.sum(W @ X, axis=0))(W, X) - ref_sum,
"vec closure": 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 closure 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, "Not reproduced on macOS arm64: it may be fixed in a newer jaxlib, so update 00 Overview and this section"
if not BUG_F32:
print("This machine doesn't trigger the bug even in float32 (it reproduces on macOS / Apple Silicon and did not show up in our Linux x86 test); the float64 conclusion below still holds.")
for (dt, n), r in probe_res.items():
if dt == "float64":
assert all(v < 1e-10 for v in r.values()) # float64: every variant is correct
else:
assert r["mat arg sum"] < 1e-3 and r["vec closure"] < 1e-3 # passed as an argument / single vector: correct
if BUG_F32:
# If these assertions fail after upgrading jaxlib, the bug has been fixed; update 00 Overview and this section's conclusion accordingly
assert r["vmap closure sum"] > 1 and r["mat closure sum"] > 1 and r["mat closure 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("\nDoes the closure-constant bug reproduce under float64:", CLOSURE_BUG_F64)
# The float64 version is also compiled into __ynn_fusion, it just gives correct results: the problem isn't "whether it's fused", but how float32 is handled inside the fusion
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("HLO of the float64 closure version contains __ynn_fusion:", "__ynn_fusion" in hlo64)
assert W64.dtype == jnp.float64
if BUG_F32:
assert "__ynn_fusion" in hlo64
Conclusion: on this machine's jaxlib 0.12.0.1, float64 matrices are correct in every variant; the bug does not reproduce under float64. The float64 version is also compiled into __ynn_fusion (we checked the HLO above), it just gives correct results. The problem lies in how float32 is handled inside the fusion; we haven't dug into the exact cause here. But turning on x64 does not remove the risk: as long as some array is explicitly float32 (for example lowered in precision to save memory), capturing it in a closure still gives wrong results, with errors up to around $10^2$. So the rule stands: matrices, coefficients and data are always passed in as arguments.
9.2 The checklist¶
🟢 Must-read
| # | Rule | Reason / where |
|---|---|---|
| 1 | Turn on jax_enable_x64 on the first line of the first cell, and assert the default dtype | If turned on late, old arrays stay float32 and spread through weakly typed operations (section 1.2) |
| 2 | Use float64 for convergence studies, gradient checks and inverse problems | Under float32, second-order convergence "disappears" on fine meshes, and finite differences give at best 4 to 5 significant digits (only 2 to 3 with the usual step size) |
| 3 | Prefer matrix-free operators (pad + slicing) on structured grids | Little memory, fusable, differentiable, easy to vmap; roll gives periodic boundaries by default (section 2.2) |
| 4 | The info of jsl.cg is always None; check the residual yourself | It doesn't raise an error even when it hasn't converged (section 2.3) |
| 5 | Assemble with BCOO on unstructured meshes; leave direct solves on CPU to SciPy | spsolve is a callback on CPU and doesn't support vmap (section 3) |
| 6 | Prefer implicit differentiation: custom_linear_solve / custom_root, or just use jsl.cg | $O(n)$ memory and exact gradients; unrolled differentiation uses $O(Kn)$ memory, and its gradient can be badly distorted around convergence (section 4.3) |
| 7 | Don't write symmetric=True for nonsymmetric operators; provide transpose_solve | Otherwise the reverse-mode gradient silently comes out wrong (section 4.4) |
| 8 | In custom_root, wrap the iterative solver in tangent_solve with custom_linear_solve | Reverse mode transposes tangent_solve; using while_loop or jsl.cg directly both raise errors (section 4.6) |
| 9 | Check the CFL / diffusion number first for explicit schemes; use implicit schemes like CN for stiff problems | Instability doesn't raise an error; you just get garbage around $10^{100}$ (section 6.1) |
| 10 | Use checkpoint / $\sqrt T$ segmentation for long time integration | Gradients are identical; per-step checkpointing cuts memory to about 1/4, $\sqrt T$ segmentation to about 1/50 (section 6.3) |
| 11 | Always use double where for barriers, 1/x, log and sqrt | A degenerate element with $\det J=0$ turns the gradient into NaN (section 7.4) |
| 12 | Pass operator matrices, coefficient fields and observed data as function arguments; don't capture them in jit closures | The closure-constant bug (reproduces under float32), constants embedded in the HLO slow down compilation, and you can't differentiate with respect to them (section 9.1, section 1.7 of Advanced 01) |
| 13 | Cross-check every solver against NumPy/SciPy float64, and every gradient against finite differences | That's how every conclusion in this chapter was verified |
Exercises¶
The reference solution for each exercise is in the "Appendix: reference solutions" at the end of this chapter (the first line of the code cell is
# Reference solution: Exercise N). Try writing your own first, then compare.
Exercise 1: the Poisson equation with a reaction term¶
⚪ Optional: practice with matrix-free operators and verifying the convergence order
Write a matrix-free operator apply_H(u, c, h) for $-\Delta_h u + c\,u$ ($c>0$ a constant, homogeneous Dirichlet boundary).
- Use
jax.linear_transposeto verify that it's a symmetric operator; - Using the manufactured solution $u^\ast$ from section 2.3 (the
freturned bymanufacturedis $-\Delta u^\ast$), build this problem's source term $f + c\,u^\ast$, solve on two meshes $N=31$ and $N=63$ withjsl.cg, and assert that the error ratio is about 4; - Cross-check on $N=31$ against SciPy
spsolve(matrix $=$scipy_neg_lap$+\,c I$).
Self-check interface: apply_H(u, c, h), where u is an N×N array; returns an array of the same shape.
# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
apply_H
except NameError as e:
print(f"⏳ Exercise 1: not answered yet: {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) # reference matrix assembled with SciPy
_out = apply_H(jnp.asarray(_u), _c, _h)
assert _out.shape == (_N, _N), f"apply_H should return a {(_N, _N)} array, got {_out.shape}"
assert rel(_out.ravel(), _A @ _u.ravel()) < 1e-12, "apply_H doesn't match the matrix product with -Δ_h + cI"
assert rel(jax.jit(apply_H)(jnp.asarray(_u), _c, _h), _out) < 1e-14, "apply_H gives a different result under jit (with c and h as dynamic arguments)"
print("✅ Exercise 1: self-check passed")
Exercise 2: the sensitivity to the source term is the adjoint solution¶
⚪ Optional: practice with the adjoint method: sensitivity to the source term
On the variable-coefficient problem from section 4 ($N=31$, coefficient k0), take $J(f) = h^2\sum_{ij} w_{ij}\,u_{ij}(f)$ with $w = x\,y$.
- Compute $\partial J/\partial f$ with
u_implicitandjax.grad; - By the adjoint method, $\partial J/\partial f = A^{-\top}(h^2 w) = A^{-1}(h^2 w)$. Get the explicit matrix with
jax.jacfwdand cross-check with a NumPy dense solve; - Run a finite-difference check along a random direction.
Self-check interface: J_src(f, k, w, h) returns the scalar $J$; f, k and w are all N×N arrays. The self-check uses a new f and w to check the function value and the gradient.
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
J_src
except NameError as e:
print(f"⏳ Exercise 2: not answered yet: {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()) # dense solve of 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"Wrong value of J_src: {_J:.6e}, reference {_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 is linear in f, so J(f_b) = <∂J/∂f, f_b> should hold; the gradient is wrong"
print("✅ Exercise 2: self-check passed")
Exercise 3: recovering the thermal diffusivity from the final state¶
⚪ Optional: practice with
jacfwdand Gauss–Newton inversion
Use the Crank–Nicolson integrator from section 6 ($N_x=99$, $T=0.2$, 400 steps). Generate the final-state observation $u^{\text{obs}} = u(T;\alpha^\ast)$ with $\alpha^\ast=0.5$ (no noise), with initial value $u_0 = \sin(\pi x) + 0.5\sin(3\pi x)$. Starting from $\alpha=0.2$, recover $\alpha$ with the Gauss–Newton iteration
$$\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}$$
(compute $J$ with jax.jacfwd; forward mode is the cheapest for a scalar parameter), and assert that the result is within $10^{-10}$ of the true value.
Self-check interface: forward_T(alpha, u0) returns $u(T;\alpha)$; gn_step(alpha, u0, u_obs) does one Gauss–Newton step and returns (new alpha, residual norm); alpha_hat is the final recovered value. The self-check reruns the inversion with a different true value and initial guess.
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
forward_T, gn_step, alpha_hat
except NameError as e:
print(f"⏳ Exercise 3: not answered yet: {e}")
else:
assert abs(float(alpha_hat) - 0.5) < 1e-10, f"alpha_hat should equal 0.5 within 1e-10, got {float(alpha_hat):.14f}"
# The CN amplification factor for the discrete eigenvector sin(kπx) has a closed form; use it to check forward_T independently
_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 doesn't match the closed-form Crank–Nicolson result (400 steps, 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"Recovering the true value 0.3 starting from 0.15, after 12 steps got {float(_al):.12f}"
assert float(gn_step(_al, _u0b, _obs)[1]) < 1e-10, "After convergence the residual norm should be close to 0"
print("✅ Exercise 3: self-check passed")
Exercise 4: verifying the corner Jacobians with jacfwd¶
⚪ Optional: practice checking mesh Jacobians with
jacfwd
The bilinear map of a quadrilateral element is
$$\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 .$$
For the optimized mesh X_w from section 7.5, use jax.jacfwd to compute $\partial\mathbf x/\partial(\xi,\eta)$ for each element at the four corners $(0,0),(1,0),(1,1),(0,1)$ (loop over elements with vmap), and verify that its determinant times $n^2$ equals the $\det J$ given by corner_jacobians. Think about it: why do we compare determinants rather than the matrices themselves?
Self-check interface: bilinear(P, q), where P has shape (4, 2) holding $p_{00},p_{10},p_{11},p_{01}$ in order and q = (ξ, η); det_bil has shape (n_mesh**2, 4): elements in row-major (i, j) order (element (i, j) has $p_{00}$ = X_w[i, j] and $p_{10}$ = X_w[i+1, j]), with the four columns being corners $(0,0),(1,0),(1,1),(0,1)$ in order, already multiplied by $n^2$.
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
bilinear, det_bil
except NameError as e:
print(f"⏳ Exercise 4: not answered yet: {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 at corner {_q} should equal the corresponding vertex"
_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 gives the wrong value inside the element"
# Compute the determinants independently from the two edges at each corner (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 should have shape {_dets.shape}, got {np.shape(det_bil)}"
assert np.allclose(det_bil, _dets, rtol=1e-12, atol=1e-12), "det_bil doesn't match the determinants computed from the corner edge vectors"
print("✅ Exercise 4: self-check passed")
Quiz¶
1. Why must jax.config.update("jax_enable_x64", True) go at the very top of the first code cell? What happens if you turn it on later?
Answer
The switch only affects arrays created and traces started afterwards. Arrays created before it's turned on are still float32, and a float32 array combined with a Python float (weakly typed) is still float32 (verified in section 1.2: a * 1.0 is still float32), so single precision quietly sneaks into the computation: convergence orders can't be measured reliably and gradient checks fail, with no error raised.
2. What goes wrong if you write the five-point operator with jnp.roll for a homogeneous Dirichlet problem? How do you fix it?
Answer
roll wraps values from the opposite edge around, which amounts to periodic boundary conditions, so the results at nodes next to the boundary are wrong (result from section 2.2: interior nodes match exactly, the first row has a relative error of about 0.35), and no error is raised. The fix: pad a ring of zeros with jnp.pad(u, 1) first, then take neighbors (slicing or roll both work), and finally crop the outer ring.
3. When taking the gradient of a linear solver, what are the memory and accuracy characteristics of "differentiating through the unrolled CG iterations" versus "custom_linear_solve implicit differentiation"?
Answer
Unrolled differentiation has to save the intermediates of every step, so memory is $O(K n)$ and grows linearly with the number of iterations; it computes the derivative of the floating-point iteration, which can be badly distorted around convergence (in section 4.3 the relative error near $K=80$ reaches around $10^5$), and only comes back down after many more iterations. Implicit differentiation stores only the solution, with $O(n)$ memory; the gradient = one adjoint solve, its accuracy depends only on the forward and adjoint tolerances, and it agrees with finite differences to around $10^{-10}$.
4. What happens if you call lax.custom_linear_solve(..., symmetric=True) on a nonsymmetric operator? Are forward mode and reverse mode affected?
Answer
symmetric=True is an unchecked promise. Reverse mode uses $A$ instead of $A^\top$ to solve the adjoint system, so the gradient silently comes out wrong (in section 4.4 the relative error is about 0.84); forward mode (jvp) only uses solve, so its result is correct. The right approach is symmetric=False plus a transpose_solve.
5. What does lax.custom_root require of tangent_solve? Why does calling a CG written with while_loop directly inside it raise an error under grad?
Answer
tangent_solve(g, y) must return the solution of $g(x)=y$. In reverse mode JAX has to transpose tangent_solve with respect to y, so it must be a transposable linear function. while_loop can't be transposed (it raises ValueError); on this machine's 0.12.0.1, using jsl.cg directly also raises NotImplementedError. Wrapping it with lax.custom_linear_solve(g, y, cg_while, symmetric=True) fixes it (when the linearized operator is symmetric).
6. On the L-shaped domain, why does Laplace smoothing produce inverted elements while the Winslow energy doesn't? Why do you need double where when writing the Winslow barrier?
Answer
The minimizer of the Laplace energy is a discrete harmonic map, which is only guaranteed to be injective when the target domain is convex (Radó–Kneser–Choquet); the L-shaped domain has a reentrant corner, nodes get averaged outside the domain, and elements invert. The Winslow energy $\lVert J\rVert_F^2/(2\det J)$ goes to infinity as $\det J\to0^+$, so it has a built-in barrier. To handle the initially inverted elements, it switches to a polynomial extension for $\det J\le\delta$; with only one where, on a degenerate element ($\det J=0$) the derivative of the unselected branch $1/d$ is infinite, and $0\times\infty$ gives a NaN gradient. Using a first where to replace $d$ with a safe value avoids this.
Chapter summary¶
- float64 is the default for scientific computing: turn it on in the first line, then confirm with an assert. float32 makes second-order convergence "disappear" on fine meshes and breaks finite-difference checks.
- A matrix-free operator plus
jsl.cg(with the operator passed as a function) is the simplest and fastest combination on structured grids. The five-point scheme gives convergence orders of 1.99 to 2.00 (error ratios 3.97 to 4.00). - BCOO can assemble, do matvecs,
sparsify, differentiate with respect todata, and agrees with SciPy CSR to rounding error. But JAX has no native sparse direct solver on CPU:spsolvecalls back into SciPy on CPU and doesn't supportvmap. - The math behind implicit differentiation is the adjoint method: one adjoint solve gives the full gradient.
lax.custom_linear_solveandlax.custom_rootturn it into one line of code (jsl.cgalready does this internally). Compared with unrolled differentiation, temporary memory is about two orders of magnitude smaller (in section 4.3, unrolling 100 / 300 steps uses about 100 / 290 times the memory of the implicit version), and the gradient is exact up to the solver tolerance; the unrolled gradient can be badly distorted around convergence. - Inverse problems = implicit differentiation +
vmapover load cases + regularization + L-BFGS (optax.lbfgscomes with a zoom line search). - Time stepping uses
scan: explicit schemes must satisfy $r\le\tfrac12$, while CN is unconditionally stable and second order in time. Differentiatingscangives the discrete adjoint; for long time integration, $\sqrt T$ segmented checkpointing cuts memory to about 1/50 with the same gradient. - Mesh optimization: Laplace smoothing folds on non-convex domains, while the Winslow energy + an extended barrier can untangle and smooth starting from an inverted initial mesh; the barrier must be written with double where.
- Engineering rules: pass data and operators in as arguments (the closure-constant bug doesn't reproduce under float64, but still exists for float32), and cross-check every conclusion against SciPy / finite differences.
Cheat sheet¶
| API | What it does |
|---|---|
jax.config.update("jax_enable_x64", True) | Turn on float64 / int64 (must run first) |
jnp.pad(u, 1) + slicing | Dirichlet boundary for a matrix-free stencil |
jax.scipy.sparse.linalg.cg(A, b, tol=, atol=, maxiter=, M=) | CG; A and M can be functions; info is always None; already implicit differentiation internally |
jax.scipy.sparse.linalg.gmres(..., restart=, solve_method=) | GMRES for nonsymmetric systems |
sparse.BCOO((data, indices), shape=) | Assemble a sparse matrix from COO triplets (duplicate indices allowed) |
sparse.BCOO.from_scipy_sparse(A) / .todense() / .sum_duplicates() | Convert to/from SciPy / dense, merge duplicates |
sparse.BCSR.from_bcoo(A) | Convert to CSR (data, indices, indptr) |
sparse.sparsify(f) | Let a function written for dense matrices accept sparse ones |
sparse.grad(f) | Gradient with respect to a BCOO argument, keeping the sparsity structure |
sparse.linalg.spsolve(data, indices, indptr, b) | Sparse direct solve (GPU: cuSOLVER; CPU: calls back into SciPy; no vmap) |
lax.custom_linear_solve(matvec, b, solve, transpose_solve=, symmetric=) | Declare a linear solve; differentiated with the adjoint method, never going through solve |
lax.custom_root(f, x0, solve, tangent_solve) | Declare a nonlinear root find, differentiated with the implicit function theorem; tangent_solve must be transposable |
jax.linear_transpose(f, x) | Get the transpose of a linear operator (check symmetry, build adjoints) |
lax.linalg.tridiagonal_solve(dl, d, du, b) | Differentiable tridiagonal solve (CN, 1D implicit schemes) |
lax.scan(step, u0, None, length=T) | Time stepping |
jax.checkpoint(step) / nested scan | Per-step checkpointing / $\sqrt T$ segmented checkpointing |
jit(grad(f)).lower(...).compile().memory_analysis() | Read the temporary memory of backprop |
jax.ad_checkpoint.print_saved_residuals(f, *args) | See what backprop saves |
optax.lbfgs() + optax.value_and_grad_from_state | L-BFGS (zoom line search); update needs value=, grad=, value_fn= |
jax.vmap(solve, in_axes=(0, None, ...)) | Parameter sweeps / batched solves over load cases |
jnp.where(ok, x, safe) → jnp.where(ok, f(x_safe), g(x)) | Double where: the safe way to write barriers, 1/x, log, sqrt |
Appendix: reference solutions¶
Write your own answer in the "✍️ Your answer" cell in the main text first and check it with the "🧪 Self-check". Come here to compare once the self-check passes, or if you're really stuck. Running the solutions below in order overwrites any functions with the same names that you defined in the answer cells.
Exercise 1¶
See "Exercise 1: the Poisson equation with a reaction term" in the main text for the task.
# Reference solution: Exercise 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("Errors:", errs_h, "error ratio:", errs_h[0] / errs_h[1])
assert abs(errs_h[0] / errs_h[1] - 4) < 0.1
Exercise 2¶
See "Exercise 2: the sensitivity to the source term is the adjoint solution" in the main text for the task.
# Reference solution: Exercise 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 the adjoint solution: relative error {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)
Exercise 3¶
See "Exercise 3: recovering the thermal diffusivity from the final state" in the main text for the task.
# Reference solution: Exercise 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"iter {it}: α = {float(alpha_hat):.14f}, residual norm {float(rn):.2e}")
assert abs(alpha_hat - 0.5) < 1e-10
Exercise 4¶
See "Exercise 4: verifying the corner Jacobians with jacfwd" in the main text for the task.
# Reference solution: Exercise 4
def bilinear(P, q):
"""P: (4, 2) holding p00, p10, p11, p01 in order; 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"Max difference {float(jnp.abs(det_bil - det_ref).max()):.1e}")
assert jnp.allclose(det_bil, det_ref, rtol=1e-12, atol=1e-12)
# The matrices themselves differ only by a column permutation / sign flip (for example at corner p10 it's [∂η, -∂ξ]), which leaves the determinant unchanged