Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

3D-Var — derivation, preconditioning, and adjoint methods with lineax / optimistix

3D-Var for methane retrieval

This note derives the cost function used by plumax.assimilation, walks through the three control-variable formulations we implement, and then maps each piece of the math onto a concrete jax / lineax / optimistix / gaussx call. The aim is that you can read this side-by-side with solve.py and see exactly which line implements which equation.

The derivation borrows notation from Bouttier & Courtier’s Data assimilation concepts and methods [1] and Rodgers’ Inverse methods for atmospheric sounding [2]; the JAX-side implementation tricks are from the lineax docs [3] and the optimistix paper [4].

1. From Bayes to the 3D-Var cost

We have a state vector x∈RNx \in \mathbb{R}^N — for our methane retrieval, the per-pixel VMR field flattened to length N=ny⋅nxN = n_y \cdot n_x — and an observation vector y∈RMy \in \mathbb{R}^M, the band-integrated radiance cube. Both come with Gaussian uncertainty:

By Bayes’ rule the posterior p(x∣y)∝p(y∣x) p(x)p(x \mid y) \propto p(y \mid x)\, p(x) is proportional to exp⁡(−J(x))\exp(-J(x)) with

J(x)  =  12 (x−xb)⊤B−1(x−xb)  +  12 (y−H(x))⊤R−1(y−H(x)).J(x) \;=\; \tfrac{1}{2}\,(x - x_b)^\top B^{-1} (x - x_b) \;+\; \tfrac{1}{2}\,\bigl(y - H(x)\bigr)^\top R^{-1} \bigl(y - H(x)\bigr).

The MAP estimate x^=arg⁡min⁡xJ(x)\hat x = \arg\min_x J(x) is the 3D-Var analysis. The “3D” refers to the three spatial dimensions of the state — there is no time axis, distinguishing 3D-Var from 4D-Var.

1.1 Incremental form

In practice we minimise over the increment δx=x−xb\delta x = x - x_b, because (i) the background term then has a clean quadratic form, and (ii) the observation operator is often linearised around xbx_b (the inner loop of incremental 3D-Var). Substituting x=xb+δxx = x_b + \delta x and writing d:=y−H(xb)d := y - H(x_b) for the innovation:

J(δx)  =  12 δx⊤B−1δx  +  12 (d−H′ δx)⊤R−1(d−H′ δx),J(\delta x) \;=\; \tfrac{1}{2}\,\delta x^\top B^{-1} \delta x \;+\; \tfrac{1}{2}\,\bigl(d - H'\, \delta x\bigr)^\top R^{-1} \bigl(d - H'\, \delta x\bigr),

where H′:=∂H/∂x ∣xbH' := \partial H / \partial x \, \big|_{x_b} is the tangent-linear operator. The optimum satisfies the normal equations

(B−1+H′⊤R−1H′) δx^  =  H′⊤R−1 d,\bigl(B^{-1} + H'^\top R^{-1} H'\bigr)\, \delta\hat x \;=\; H'^\top R^{-1}\, d,

with Hessian A:=B−1+H′⊤R−1H′A := B^{-1} + H'^\top R^{-1} H' — the information matrix of the posterior. Its inverse A−1A^{-1} is the textbook posterior covariance for the linear-Gaussian case.

In code. Equation eq-J is built by build_cost_x; the prior term uses gaussx.solve(B, δx_flat) so any structured B from background.py — diagonal, Kronecker, low-rank — is plugged in without materialising. The obs term differentiates through the JAX forward_fn via reverse-mode AD, so we never write H'^\top by hand.

2. Why preconditioning matters

The catch in eq-normal is that the conditioning of AA tracks the spread of eigenvalues of B−1+H′⊤R−1H′B^{-1} + H'^\top R^{-1} H'. In a typical retrieval B−1B^{-1} has a large dynamic range (some pixels are tightly constrained by the prior, others very loose), so a vanilla minimiser like LBFGS can take hundreds of iterations to make progress along the floppy directions.

The control variable transform (CVT) eliminates this by changing variables to ξ:=U−1δx\xi := U^{-1} \delta x, where UU is any square root of BB:

B  =  UU⊤.B \;=\; U U^\top.

We pick U=chol(B)U = \mathrm{chol}(B) but other choices (eigendecomposition, balanced operators) work the same way. Substituting δx=Uξ\delta x = U \xi into eq-J-incremental gives

J(ξ)  =  12 ξ⊤ξ  +  12 (d−(H′U) ξ)⊤R−1(d−(H′U) ξ).J(\xi) \;=\; \tfrac{1}{2}\,\xi^\top \xi \;+\; \tfrac{1}{2}\,\bigl(d - (H' U)\, \xi\bigr)^\top R^{-1} \bigl(d - (H' U)\, \xi\bigr).

The prior term collapses to 12∥ξ∥2\tfrac{1}{2}\|\xi\|^2 — a sphere — and the Hessian becomes

Aξ  =  I+(H′U)⊤R−1(H′U),A_\xi \;=\; I + (H'U)^\top R^{-1} (H' U),

a perturbation of the identity by a positive-semidefinite term whose rank is at most M=dim⁡(y)M = \dim(y). Even vanilla LBFGS converges in a handful of iterations regardless of how badly conditioned BB was — this is, in practice, the single biggest lever you have in variational DA.

In code. Equation eq-J-whitened is built by build_cost_xi; the operator UU is constructed by WhiteningTransform.from_background, which calls gaussx.cholesky(B). Crucially, gaussx returns a structured operator — a Cholesky of a Kronecker is itself a Kronecker of Cholesky factors — so all subsequent matvecs and inverse-solves stay in the cheap regime.

3. Primal vs dual: PSAS

The normal equations eq-normal are an N×NN \times N system. When the observation count is small (M≪NM \ll N — typical for sparse plumes seen by an instrument with a few bands), the dual form is dramatically cheaper. Apply the matrix-inversion lemma to eq-normal:

δx^  =  BH′⊤(H′BH′⊤+R)−1d.\delta\hat x \;=\; B H'^\top \bigl(H' B H'^\top + R\bigr)^{-1} d.

The middle factor is a M×MM \times M system. Define λ:=(H′BH′⊤+R)−1d∈RM\lambda := (H' B H'^\top + R)^{-1} d \in \mathbb{R}^M — the dual variable — so the recipe is:

  1. Solve (H′BH′⊤+R) λ=d(H' B H'^\top + R)\, \lambda = d for λ\lambda (CG-friendly: the matrix is symmetric PD).

  2. Set δx^=BH′⊤λ\delta\hat x = B H'^\top \lambda.

This is the Physical-Space Statistical Analysis System (PSAS) [5]. For a linear HH, the dual and primal are mathematically identical; for a nonlinear HH, the dual is what the inner loop of incremental 4D-Var solves.

In code. run_dual_psas wraps the dual matrix as a lineax.FunctionLinearOperator, tags it symmetric+PSD, and feeds it to lineax.linear_solve(..., solver=lineax.CG(...)). The two H' and H′⊤H'^\top applications inside the matvec are produced by differencing forward_fn at x_b and by a single hoisted jax.vjp respectively — no explicit adjoint of the obs operator anywhere in the code.

3.1 The matched filter as a single PSAS step

If we set B=∞⋅IB = \infty \cdot I (uninformative prior), R=ΣpixelR = \Sigma_\text{pixel} (per-pixel covariance), and restrict δx\delta x to a 1-D subspace spanned by a target signature t=H′⋅1t = H' \cdot \mathbf{1}, the dual solution eq-dual-solution collapses to the pixel-wise matched filter

ε^  =  (x−μ)⊤Σ−1tt⊤Σ−1t.\hat\varepsilon \;=\; \frac{(x - \mu)^\top \Sigma^{-1} t}{t^\top \Sigma^{-1} t}.

So matched filter, narrow-band retrieval, and full 3D-Var are the same equation with progressively richer prior assumptions. The matched-filter implementation in matched_filter.py and the gaussx-backed variant in gaussx_solve.py drop out as the simplest case of the framework here.

4. Adjoint methods — four flavours

Computing the gradient ∇J(δx)\nabla J(\delta x) requires the adjoint (transpose) of the tangent-linear H′H'. Historically this was the part of variational DA you wrote and maintained by hand; with JAX we get four options out of the box, each with a different cost / memory profile.

MethodWhen it appliesCostMemoryJAX call
Discrete adjoint (reverse-mode AD)HH is a JAX program; one-shot forward passO(forward)\mathcal{O}(\text{forward})O(forward tape)\mathcal{O}(\text{forward tape})jax.grad(loss)
Tangent-linear (forward-mode AD)Sensitivities, ensemble propagationO(forward)×dim⁡(input)\mathcal{O}(\text{forward}) \times \dim(\text{input})O(forward)\mathcal{O}(\text{forward})jax.jvp(H, (x,), (v,))
Implicit-function adjointDifferentiating through a converged inner solveO(1 linear solve)\mathcal{O}(1\,\text{linear solve})O(linear solve)\mathcal{O}(\text{linear solve})optimistix.ImplicitAdjoint(linear_solver=...)
Checkpointed adjointMemory-bounded long unrolls (ODE / iterative)O(forwardlog⁡n)\mathcal{O}(\text{forward} \log n)O(n)\mathcal{O}(\sqrt{n})optimistix.RecursiveCheckpointAdjoint(...)

4.1 Discrete adjoint (jax.grad)

This is what we use for the obs-term gradient in eq-J. Concretely, build_cost_xi wraps the cost in jax.value_and_grad(value); reverse-mode AD walks back through forward_fn, applying each elementary VJP — including the kernel-flipped convolution adjoint inside PointSpreadFunction and the spread-with-1/f² adjoint inside GroundSamplingDistance. The unit tests in test_instrument.py verify the inner-product identity ⟨Ax,u⟩=⟨x,A⊤u⟩\langle A x, u \rangle = \langle x, A^\top u \rangle for both operators, so the autodiff gradient is provably equal to what a hand-rolled adjoint would give.

4.2 Tangent-linear (jax.jvp)

jax.jvp(H, (x,), (v,)) computes H′(x)⋅vH'(x) \cdot v in one forward pass without ever forming the Jacobian. Useful for:

4.3 Implicit-function adjoint

Suppose we want ∂x^/∂θ\partial \hat x / \partial \theta — the derivative of the converged 3D-Var solution with respect to a hyperparameter θ\theta (e.g., a regularisation weight, an obs-error variance, a prior length-scale). Naively differentiating through the LBFGS unroll would be both expensive and numerically suspect.

The implicit-function theorem gives a closed-form. The optimum satisfies F(x^,θ)=∇xJ(x^,θ)=0F(\hat x, \theta) = \nabla_x J(\hat x, \theta) = 0. Differentiating implicitly,

∂x^∂θ  =  −(∇xF)−1 ∇θF,\frac{\partial \hat x}{\partial \theta} \;=\; -\bigl(\nabla_x F\bigr)^{-1}\, \nabla_\theta F,

so we just need to solve one linear system in the Hessian ∇xF\nabla_x F. This is what optimistix.ImplicitAdjoint(linear_solver=lineax.CG(...)) does internally; you write a normal optimistix.minimise(...) call, and gradients with respect to anything captured by the cost flow back through this single adjoint solve. No tape, no checkpoints.

In code. All of our solvers in solve.py accept the optimistix default adjoint=ImplicitAdjoint(); you can override it with a custom linear_solver (e.g., lineax.CG(rtol=...) for matrix-free, or lineax.Cholesky() for small dense Hessians) when you start computing sensitivities of the analysis with respect to its inputs.

4.4 Checkpointed adjoint

When the forward map is itself an iterative procedure (e.g., a chemistry solver inside the obs operator, or a Kalman-smoother prior), the reverse-mode tape can blow up memory. optimistix.RecursiveCheckpointAdjoint(checkpoints=k) does n\sqrt{n}-style binomial checkpointing: only kk forward states are stored, and the rest are recomputed on the backward pass. We don’t currently need this — the methane forward map is a single fused convolution chain — but it’s the right tool the moment you couple the retrieval to a dynamical model.

5. Mapping math → libraries

Math objectLibrary call
BB (Kronecker / low-rank / diagonal)gaussx.Kronecker, gaussx.LowRankUpdate, lineax.DiagonalLinearOperator
B−1δxB^{-1} \delta xgaussx.solve(B, δx) (structured dispatch)
UU such that B=UU⊤B = U U^\topgaussx.cholesky(B)
HH (forward op, JAX)RadianceObservationModel.forward(...)
H′⋅vH' \cdot v (tangent-linear)jax.jvp(forward_fn, (x,), (v,))
H′⊤⋅uH'^\top \cdot u (adjoint)jax.vjp(forward_fn, x)[1](u) — no manual adjoint
∇J\nabla J (discrete adjoint)jax.grad(J)
∇2J⋅v\nabla^2 J \cdot v (Hessian-vector)jax.jvp(jax.grad(J), (x,), (v,))
Outer minimiser (whitened LBFGS)optimistix.LBFGS(...) via optimistix.minimise(...)
Gauss-Newton with custom inner solveroptimistix.GaussNewton(...) + lineax.CG(...) for J⊤JJ^\top J
Dual / PSAS solvelineax.linear_solve(dual_op, d, solver=lineax.CG(...))
Adjoint through the converged solveoptimistix.ImplicitAdjoint(linear_solver=lineax.CG(...))
χred2\chi^2_\text{red}, DFS, posterior covreduced_chi_squared, degrees_of_freedom_for_signal, posterior_covariance_proxy

6. Worked example

The end-to-end demo lives in 06_variational_methane_retrieval.ipynb. It builds a synthetic plume from the HAPI LUT, injects it via the JAX forward operator, and runs the retrieval three ways — naive primal, whitened LBFGS, and dual PSAS — comparing iteration counts, run times, and posterior diagnostics. The same notebook also shows the matched filter from PR #24 falling out of the dual recipe with a single CG iteration.

Footnotes
  1. Bouttier, F. & Courtier, P. (1999). Data assimilation concepts and methods. Meteorological Training Course Lecture Series, ECMWF. https://www.ecmwf.int/sites/default/files/elibrary/2003/16928-data-assimilation-concepts-and-methods.pdf

  2. Rodgers, C. D. (2000). Inverse methods for atmospheric sounding: theory and practice. World Scientific.

  3. Rader, J., Lyons, T., & Kidger, P. (2024). Lineax: a library for linear solvers in JAX. https://docs.kidger.site/lineax/

  4. Rader, J., Lyons, T., & Kidger, P. (2024). Optimistix: modular optimisation in JAX and Equinox. arXiv:2402.09983.

  5. Cohn, S. E., et al. (1998). Assessing the effects of data selection with the DAO Physical-Space Statistical Analysis System. Mon. Wea. Rev. 126, 2913-2926.