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 — for our methane retrieval, the per-pixel VMR field flattened to length — and an observation vector , the band-integrated radiance cube. Both come with Gaussian uncertainty:
Prior: . The background mean is whatever we knew before the observation arrived (typically a uniform climatological VMR), and encodes per-pixel variance and any spatial correlations.
Likelihood: with the (possibly nonlinear) observation operator and the observation-error covariance . For our case, .
By Bayes’ rule the posterior is proportional to with
The MAP estimate 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 , because (i) the background term then has a clean quadratic form, and (ii) the observation operator is often linearised around (the inner loop of incremental 3D-Var). Substituting and writing for the innovation:
where is the tangent-linear operator. The optimum satisfies the normal equations
with Hessian — the information matrix of the posterior. Its inverse is the textbook posterior covariance for the linear-Gaussian case.
In code. Equation eq-J is built by
build_cost_x; the prior term usesgaussx.solve(B, δx_flat)so any structuredBfrom background.py — diagonal, Kronecker, low-rank — is plugged in without materialising. The obs term differentiates through the JAXforward_fnvia reverse-mode AD, so we never writeH'^\topby hand.
2. Why preconditioning matters¶
The catch in eq-normal is that the conditioning of tracks the spread of eigenvalues of . In a typical retrieval 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 , where is any square root of :
We pick but other choices (eigendecomposition, balanced operators) work the same way. Substituting into eq-J-incremental gives
The prior term collapses to — a sphere — and the Hessian becomes
a perturbation of the identity by a positive-semidefinite term whose rank is at most . Even vanilla LBFGS converges in a handful of iterations regardless of how badly conditioned 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 is constructed byWhiteningTransform.from_background, which callsgaussx.cholesky(B). Crucially, gaussx returns a structured operator — a Cholesky of aKroneckeris 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 system. When the observation count is small ( — 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:
The middle factor is a system. Define — the dual variable — so the recipe is:
Solve for (CG-friendly: the matrix is symmetric PD).
Set .
This is the Physical-Space Statistical Analysis System (PSAS) [5]. For a linear , the dual and primal are mathematically identical; for a nonlinear , the dual is what the inner loop of incremental 4D-Var solves.
In code.
run_dual_psaswraps the dual matrix as alineax.FunctionLinearOperator, tags it symmetric+PSD, and feeds it tolineax.linear_solve(..., solver=lineax.CG(...)). The twoH'and applications inside the matvec are produced by differencingforward_fnatx_band by a single hoistedjax.vjprespectively — no explicit adjoint of the obs operator anywhere in the code.
3.1 The matched filter as a single PSAS step¶
If we set (uninformative prior), (per-pixel covariance), and restrict to a 1-D subspace spanned by a target signature , the dual solution eq-dual-solution collapses to the pixel-wise matched filter
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 requires the adjoint (transpose) of the tangent-linear . 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.
| Method | When it applies | Cost | Memory | JAX call |
|---|---|---|---|---|
| Discrete adjoint (reverse-mode AD) | is a JAX program; one-shot forward pass | jax.grad(loss) | ||
| Tangent-linear (forward-mode AD) | Sensitivities, ensemble propagation | jax.jvp(H, (x,), (v,)) | ||
| Implicit-function adjoint | Differentiating through a converged inner solve | optimistix.ImplicitAdjoint(linear_solver=...) | ||
| Checkpointed adjoint | Memory-bounded long unrolls (ODE / iterative) | 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 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 in one forward pass without ever forming the Jacobian. Useful for:
The dual matvec inside eq-dual-solution: one
jvpper CG iteration (we use this implicitly viaforward_fndifferenced atx_binrun_dual_psas).Hessian-vector products via forward-over-reverse:
jax.jvp(jax.grad(J), (x,), (v,))returns at cost. This is whatCost.hvpdoes, and it’s what feeds the diagnostics in diagnostics.py.
4.3 Implicit-function adjoint¶
Suppose we want — the derivative of the converged 3D-Var solution with respect to a hyperparameter (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 . Differentiating implicitly,
so we just need to solve one linear system in the Hessian . 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 customlinear_solver(e.g.,lineax.CG(rtol=...)for matrix-free, orlineax.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 -style binomial checkpointing: only 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 object | Library call |
|---|---|
| (Kronecker / low-rank / diagonal) | gaussx.Kronecker, gaussx.LowRankUpdate, lineax.DiagonalLinearOperator |
gaussx.solve(B, δx) (structured dispatch) | |
| such that | gaussx.cholesky(B) |
| (forward op, JAX) | RadianceObservationModel.forward(...) |
| (tangent-linear) | jax.jvp(forward_fn, (x,), (v,)) |
| (adjoint) | jax.vjp(forward_fn, x)[1](u) — no manual adjoint |
| (discrete adjoint) | jax.grad(J) |
| (Hessian-vector) | jax.jvp(jax.grad(J), (x,), (v,)) |
| Outer minimiser (whitened LBFGS) | optimistix.LBFGS(...) via optimistix.minimise(...) |
| Gauss-Newton with custom inner solver | optimistix.GaussNewton(...) + lineax.CG(...) for |
| Dual / PSAS solve | lineax.linear_solve(dual_op, d, solver=lineax.CG(...)) |
| Adjoint through the converged solve | optimistix.ImplicitAdjoint(linear_solver=lineax.CG(...)) |
| , DFS, posterior cov | reduced_chi_squared, degrees_of_freedom_for_signal, posterior_covariance_proxy |
6. Worked example¶
The end-to-end demo lives in 06
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 Rodgers, C. D. (2000). Inverse methods for atmospheric sounding: theory and practice. World Scientific.
Rader, J., Lyons, T., & Kidger, P. (2024). Lineax: a library for linear solvers in JAX. https://
docs .kidger .site /lineax/ Rader, J., Lyons, T., & Kidger, P. (2024). Optimistix: modular optimisation in JAX and Equinox. arXiv:2402.09983.
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.