GeoStack · vardax · primer

4D-Var: fitting a model trajectory to a window of observations

Four-dimensional variational assimilation (4D-Var) asks one question: which initial state, run forward through the model, best explains every observation in a time window, without straying far from what we believed before? This page is the background for the learned data assimilation page: the intuition, the mathematics, how the gradient is computed, three classic algorithms, and how it maps onto an ocean problem.

TL;DR

One cost, one gradient, three ways to minimise

Intuition

A 3D-Var or optimal-interpolation analysis corrects one snapshot with the observations at that time. 4D-Var corrects a trajectory: it chooses the starting point, lets the model run, and scores the whole run against everything observed in the window. Two consequences follow.

A mechanical picture: each observation is a spring pulling the trajectory toward it, with stiffness \(1/\sigma_\varepsilon^2\); the background is a spring on the initial state with stiffness \(\gmat{\Sigma}_x^{-1}\); the model is a rigid rod connecting all times. 4D-Var finds where the rod comes to rest.

The running example is Lorenz-96: 40 values around a latitude circle, chaotic, a standard data-assimilation test bed (the one vardax and somax use). The window is 10 observation times of 10 model steps each; every second site is observed with error \(\sigma_\varepsilon = 1\); the background error is \(\sigma_x = 1\) with a correlation length of 2 sites.

site 1 · observedwindowforecast012site 2 · never observedwindowforecast012truthbackground4D-Vardots: observations
One observed and one never-observed site. The background trajectory drifts away from the truth because Lorenz-96 is chaotic; the 4D-Var trajectory follows it through the window and stays close into the forecast. The unobserved site is corrected entirely through the model.
RMSE (over all 40 sites)at \(t = 0\)end of windowone window later (forecast)
background1.003.204.71
4D-Var analysis0.200.301.73

Where it comes from

In the terms of the six-step page, 4D-Var is step 2 for one particular set of densities. The prior on the initial state, the transition, and the likelihood are

\[ p(\vect{x}_0) = \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x), \] \[ p(\vect{x}_k \mid \vect{x}_{k-1}) = \delta(\vect{x}_k - f_k(\vect{x}_{k-1})), \] \[ p(\vect{y}_k \mid \vect{x}_k) = \mathcal{N}(h_k(\vect{x}_k), \gmat{\Sigma}_{\varepsilon,k}). \]

The point-mass transition makes every state a function of \(\vect{x}_0\), so the posterior is over \(\vect{x}_0\) alone, and its negative log is, up to a constant, the 4D-Var cost. 4D-Var returns that posterior's mode (the MAP estimate). It is the right summary when the posterior has one dominant, near-Gaussian peak, and the Laplace approximation around the mode then gives its spread. Replacing the point mass by \(\mathcal{N}(f_k(\vect{x}_{k-1}), \gmat{\Sigma}_\eta)\) gives weak-constraint 4D-Var, in which the model errors become extra controls.

The mathematics

Notation

SymbolMeaningShapeUnits (ocean)
\(\vect{x}_k = \vect{x}(t_k)\)state at observation time \(t_k\), \(k = 0, \ldots, T\)\(\mathbb{R}^{D_x}\) (state)m (SSH) or s⁻¹ (PV)
\(\gvect{\mu}_x\), \(\gmat{\Sigma}_x\)background mean and covariance of \(\vect{x}_0\)\(\mathbb{R}^{D_x}\), \(\mathbb{R}^{D_x \times D_x}\)m, m²
\(f_k\), \(f_{k \leftarrow 0} = f_k \circ \ldots \circ f_1\)model over one interval; over \(k\) intervals\(\mathbb{R}^{D_x} \to \mathbb{R}^{D_x}\)—
\(\mat{F}_k = \mat{J}_{f_k}(\vect{x}_{k-1})\), \(\mat{F}_{k \leftarrow 0} = \mat{F}_k \ldots \mat{F}_1\)tangent linear of \(f_k\) at the current trajectory\(\mathbb{R}^{D_x \times D_x}\), never formed—
\(\vect{y}_k\), \(h_k\), \(\mat{H}_k = \mat{J}_{h_k}(\vect{x}_k)\), \(\gmat{\Sigma}_{\varepsilon,k}\)observations, observation operator, its tangent linear, observation-error covariance\(\mathbb{R}^{D_y}\) (obs), \(\mathbb{R}^{D_x} \to \mathbb{R}^{D_y}\), \(\mathbb{R}^{D_y \times D_x}\), \(\mathbb{R}^{D_y \times D_y}\)m, —, —, m²
\(\gvect{\xi}\)whitened control: \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\)\(\mathbb{R}^{D_x}\)dimensionless
\(\gmat{\Phi}\), \(\vect{z}_\Phi\), \(\gmat{\Sigma}_{z_\Phi}\)basis, coefficients, coefficient prior covariance (diagonal)\(\mathbb{R}^{D_x \times N_\Phi}\), \(\mathbb{R}^{N_\Phi}\) (basis), \(\mathbb{R}^{N_\Phi \times N_\Phi}\)m per unit, —, —
\(\vect{x}^\dagger_k\)adjoint variable: sensitivity of \(\mathcal{J}\) to \(\vect{x}_k\)\(\mathbb{R}^{D_x}\)1 / state units

vardax also writes the state as \(\vect{x}\), so the letters here match its code; \(D_x\) and \(D_y\) are the state and per-time observation sizes.

The cost

\[ \mathcal{J}(\vect{x}_0) = \underbrace{\frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}}}_{\text{background}} + \underbrace{\frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(f_{k \leftarrow 0}(\vect{x}_0)) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}}_{\text{observations}}, \] \[ \Vert \vect{r} \Vert^2_{\mat{W}} = \vect{r}^\top \mat{W} \vect{r}. \]

With the change of variable \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\) the background term becomes \(\frac{1}{2}\Vert \gvect{\xi}\Vert^2\): all directions of \(\gvect{\xi}\) are equally likely a priori, the problem is far better conditioned, and \(\gmat{\Sigma}_x^{-1}\) is never needed. This "control-variable transform" is how operational 4D-Var is run.

Use4D-Var analysisone window, strong constraint
In
\(\vect{y}_{1:T}\) observationsunits of the observed field (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) masks / indiceswhich state values each observation sees
(time, obs)
\(\gvect{\mu}_x\) backgroundbest prior guess of \(\vect{x}_0\); state units
(state,)
\(\gmat{\Sigma}_x\) background covariancestate units²; applied as an operator
(state, state)
\(\gmat{\Sigma}_\varepsilon\) observation covarianceobservation units²
(obs, obs) per time
\(f_k\) modelone observation interval of the dynamics
(state,) → (state,)
↓
\(\vect{x}_0^\star = \mathrm{argmin}_{\vect{x}_0} \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \sum_k \Vert \vect{y}_k - h_k(f_{k \leftarrow 0}(\vect{x}_0)) \Vert^2_{\gmat{\Sigma}_\varepsilon^{-1}}\)
Out
\(\vect{x}_0^\star\) analysisthe initial state that best explains the window; state units
(state,)
\(\vect{x}_{1:T}^\star\) analysed trajectoryrun the model from \(\vect{x}_0^\star\)
(time, state)
\(\mathcal{J}^\star\)dimensionless; expect about half the number of observations
()
posterior spread (optional)leading eigenpairs of the Gauss–Newton Hessian
(rank, state), (rank,)
No training: each window is a fresh optimisation. The model, \(\gmat{\Sigma}_x\) and \(\gmat{\Sigma}_\varepsilon\) are inputs, not things 4D-Var learns.

The gradient: the adjoint recursion

Write \(\nabla_{\vect{x}_k}\mathcal{J}_{\text{o},k} = \mat{H}_k^\top \gmat{\Sigma}_{\varepsilon,k}^{-1}(h_k(\vect{x}_k) - \vect{y}_k)\) for the observation misfit at time \(k\), pulled back to the state. Starting from \(\vect{x}^\dagger_{T+1} = \vect{0}\), sweep backwards:

\[ \vect{x}^\dagger_k = \nabla_{\vect{x}_k}\mathcal{J}_{\text{o},k} + \mat{F}_{k+1}^\top \vect{x}^\dagger_{k+1}, \text{ for } k = T, \ldots, 1, \] \[ \nabla_{\vect{x}_0}\mathcal{J} = \gmat{\Sigma}_x^{-1}(\vect{x}_0 - \gvect{\mu}_x) + \mat{F}_1^\top \vect{x}^\dagger_1, \] \[ \nabla_{\gvect{\xi}}\mathcal{J} = \gvect{\xi} + (\gmat{\Sigma}_x^{1/2})^\top \mat{F}_1^\top \vect{x}^\dagger_1. \]

The adjoint carries each misfit backwards along the trajectory, through the transposed tangent-linear model, accumulating as it goes. Its cost is about one forward run, independent of the number of controls: that is why 4D-Var is feasible for millions of state values.

Usecost and gradientcalled by every optimiser iteration
In
\(\gvect{\xi}\) controlwhitened increment, \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\); dimensionless
(state,)
\(\vect{y}_{1:T}\) observationsunits of the observed field (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) masks / indiceswhich state values each observation sees
(time, obs)
\(\gvect{\mu}_x\) backgroundbest prior guess of \(\vect{x}_0\); state units
(state,)
↓
forward run \(\vect{x}_{1:T}\), misfits \(\vect{r}_k\); then the adjoint sweep backwards for \(\nabla_{\gvect{\xi}}\mathcal{J}\)
Out
\(\mathcal{J}(\gvect{\xi})\)dimensionless
()
\(\nabla_{\gvect{\xi}}\mathcal{J}\)dimensionless
(state,)
One forward run plus one adjoint run per call. Everything else in 4D-Var is a way of calling this fewer times or more cheaply.
import einx
import jax
import jax.numpy as jnp
import lineax as lx
import optimistix as optx
import vardax

# Shapes: N = 40 state values (state); T = 10 observation times (time);
#         M = 20 observed values per time (obs)


def rollout(forward, initial, num_times):
    def interval(state, _):
        # user (forward.step: one observation interval, vardax's ForwardModel)
        # x_k = f_k(x_{k−1})
        state = forward.step(state, forward.dt)       # (N,) → (N,)
        return state, state

    # x_{1:T}: the model trajectory from x₀
    _, states = jax.lax.scan(interval, initial, None, length=num_times)  # → (T, N)
    return states


def cost(whitened, args):
    forward, background, prior_sqrt, observations, obs_index, obs_std = args
    # x₀ − μ_x = Σ_x^{1/2} ξ
    increment = prior_sqrt.mv(whitened)               # (N,) → (N,)
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial = background + increment                  # (N,)
    states = rollout(forward, initial, observations.shape[0])  # → (T, N)
    # h_k(x_k): the observed entries of each state
    predicted = states[:, obs_index]                  # (T, N) → (T, M)
    # y_k − h_k(x_k)
    misfit = observations - predicted                 # (T, M)
    # r_k = Σ_ε^{−1/2} (y_k − h_k(x_k)), diagonal Σ_ε = σ_ε² I
    residual = misfit / obs_std                       # (T, M)
    # ‖r_k‖² summed over the window
    misfit_sq = einx.sum("time obs ->", residual**2)  # (T, M) → ()
    # 𝒥_o = ½ ∑_k ‖y_k − h_k(x_k)‖²_{Σ_ε⁻¹}
    obs_cost = 0.5 * misfit_sq                        # → ()
    # 𝒥_b = ½ ‖x₀ − μ_x‖²_{Σ_x⁻¹} = ½ ‖ξ‖²
    background_cost = 0.5 * einx.dot("state, state ->", whitened, whitened)  # → ()
    # 𝒥(ξ) = 𝒥_b + 𝒥_o
    return background_cost + obs_cost

Curvature, convergence, and spread

The Gauss–Newton Hessian, in whitened variables, and the matching Laplace covariance of the analysis are

\[ \gmat{\Lambda}_{\xi \mid y} = \mat{I} + \sum_k (\mat{H}_k \mat{F}_{k \leftarrow 0} \gmat{\Sigma}_x^{1/2})^\top \gmat{\Sigma}_{\varepsilon,k}^{-1} (\mat{H}_k \mat{F}_{k \leftarrow 0} \gmat{\Sigma}_x^{1/2}), \] \[ \gmat{\Sigma}_{x_0 \mid y} \approx \gmat{\Sigma}_x^{1/2} \gmat{\Lambda}_{\xi \mid y}^{-1} (\gmat{\Sigma}_x^{1/2})^\top. \]

Its eigenvalues are 1 plus "how strongly the window's observations constrain each direction"; CG converges in about as many iterations as there are eigenvalues well above 1, which is why the whitened form converges fast. The same Hessian, through a few Lanczos vectors, gives the posterior spread. A last check needs no truth: at the minimum, \(2\mathcal{J}^\star / N_\text{obs} \approx 1\) for \(N_\text{obs}\) observations if \(\gmat{\Sigma}_x\) and \(\gmat{\Sigma}_\varepsilon\) are right. Here \(N_\text{obs} = 200\) and \(2\mathcal{J}^\star / N_\text{obs} = \) 1.01.

Computing the gradient

Every 4D-Var algorithm needs \(\nabla_{\vect{x}_0}\mathcal{J}\), or products with the tangent-linear model and its adjoint. There are several ways to get them, and they differ in exactness, memory and run time.

MethodWhat it computesMemoryExact for the discrete model?Use when
Hand-written TL and adjoint codes\(\mat{F}_k \vect{v}\), \(\mat{F}_k^\top \vect{w}\) line by linestored or recomputed trajectoryyes, if maintainedlegacy Fortran models; JAX makes it unnecessary
jax.grad through the scanthe discrete adjoint, automaticallyevery model step storedyessmall and medium problems; the default
jax.vjp / jax.jvp per intervalexplicit \(\mat{F}_k^\top \vect{w}\) and \(\mat{F}_k \vect{v}\)as aboveyesincremental 4D-Var, adjoint tests, Hessian products
jax.checkpoint on each intervalthe same gradient, recomputing inside intervalsinterval boundaries onlyyeslong windows; large states
diffrax RecursiveCheckpointAdjointdiscretise-then-optimise with binomial checkpointsabout the number of checkpointsyesmodels written as diffrax solves; the recommended default
diffrax BacksolveAdjointthe continuous adjoint ODE, solved backwardsO(1)no: approximate, can diverge for chaotic or dissipative flowsalmost never for 4D-Var
diffrax ForwardMode, jax.jacfwdone tangent run per control directionsmallyesfew controls: reduced basis, parameters only
Implicit differentiation (ImplicitAdjoint)derivatives of a solution via the implicit function theoremsmallyes, at a converged solutiondifferentiating through the minimiser (vardax's minimiser_adjoint), steady states; not the window's trajectory

Two adjoints appear in a differentiable 4D-Var and should not be confused. The forward adjoint differentiates the model trajectory, to get \(\nabla_{\gvect{\xi}}\mathcal{J}\) for the optimiser. The minimiser adjoint differentiates the analysis \(\vect{x}_0^\star(\vect{y}, \gvect{\theta})\) with respect to its inputs, which is needed only when 4D-Var sits inside a larger trained system (learned priors, hyperparameter tuning). There, unrolling the optimiser is the expensive route; the implicit function theorem gives \(\partial \vect{x}_0^\star / \partial \gvect{\theta} = -(\nabla^2_{\vect{x}_0}\mathcal{J})^{-1} \nabla^2_{\vect{x}_0 \gvect{\theta}}\mathcal{J}\) at the solution, which is what optimistix.ImplicitAdjoint does.

Measured on Lorenz-96

Gradient of the cost over a 50-interval window (500 model steps), CPU, after compilation. Memory is the compiled temporary buffer size reported by XLA.

Gradient byWorking memoryTime per gradientMax difference from reverse mode
reverse, store every step1290 KB6.0 ms0e+00
reverse, checkpoint each interval55 KB3.8 ms0e+00
forward mode (N jvps)170 KB22.7 ms1e-10
cost alone (one forward run)—0.5 ms—

Checkpointing each interval cuts working memory by about 23× and returns the identical gradient; on this small CPU problem it is even faster, because the smaller working set stays in cache (on a GPU with a large state, expect roughly one extra forward run of recomputation instead). Forward mode needs one tangent run per control, 40 here, which is why it only pays when the control is small.

# Shapes: N state values (state); T observation times (time); M observed values (obs)

# --- (a) reverse mode through the scan: the discrete adjoint, exact ---
# ∇_ξ 𝒥: one forward run, one backward sweep, every state x_k stored
value, gradient = jax.value_and_grad(cost)(whitened, args)  # → (), (N,)

# --- (b) the same adjoint, written out with vjp ---
# ∇_{x_k} 𝒥_{o,k} = H_kᵀ Σ_ε⁻¹ (h_k(x_k) − y_k), scattered back to the state
obs_forcing = scatter_obs(-residual / obs_std, obs_index)  # (T, M) → (T, N)
previous = jnp.concatenate([initial[None], states[:-1]])  # → (T, N), x_{0:T−1}


def backward(carry, inputs):
    state_prev, forcing = inputs
    # x†_k = ∇_{x_k} 𝒥_{o,k} + F_{k+1}ᵀ x†_{k+1}   (carry holds F_{k+1}ᵀ x†_{k+1})
    lam = forcing + carry                             # (N,)
    # vjp of one interval at x_{k−1}: v ↦ F_kᵀ v
    _, interval_vjp = jax.vjp(lambda s: forward.step(s, forward.dt), state_prev)
    # F_kᵀ x†_k
    (carry,) = interval_vjp(lam)                      # (N,) → (N,)
    return carry, None


# backward in time, from k = T down to 1
grad_initial, _ = jax.lax.scan(                       # → (N,), ∇_{x₀} 𝒥_o
    backward, jnp.zeros(N), (previous, obs_forcing), reverse=True
)
# ∇_ξ 𝒥 = ξ + (Σ_x^{1/2})ᵀ ∇_{x₀} 𝒥_o
gradient_by_hand = whitened + prior_sqrt.T.mv(grad_initial)  # (N,)

# --- adjoint test: ⟨F δx, x†⟩ = ⟨δx, Fᵀ x†⟩ to round-off ---
_, tangent = jax.jvp(step_fn, (state,), (perturbation,))  # (N,) → (N,)
_, step_vjp = jax.vjp(step_fn, state)
(cotangent,) = step_vjp(dual)                         # (N,) → (N,)
mismatch = jnp.vdot(tangent, dual) - jnp.vdot(perturbation, cotangent)  # → ()

# --- (c) checkpointing: store only the interval boundaries, recompute inside ---
checkpointed_step = jax.checkpoint(step_fn)           # same signature, less memory

# --- (d) a diffrax model: pick the adjoint per solve ---
# today (diffrax 0.7.0)
# discretise-then-optimise, binomial checkpoints: exact, the default
adjoint = dfx.RecursiveCheckpointAdjoint(checkpoints=64)
# today (diffrax 0.7.0)
# continuous adjoint ODE solved backwards: O(1) memory, approximate gradient
adjoint = dfx.BacksolveAdjoint()
# today (diffrax 0.7.0)
# forward mode: one tangent solve per control direction
adjoint = dfx.ForwardMode()

Algorithm 1 · Classic 4D-Var

per iteration one forward run + one adjoint run

Idea. Hand the full nonlinear cost and its gradient to a quasi-Newton optimiser (L-BFGS or BFGS), which builds its own curvature estimate from successive gradients. Nothing is linearised by hand; the model's nonlinearity is respected at every iteration.

\[ \gvect{\xi}^{(i+1)} = \gvect{\xi}^{(i)} - \alpha_i \hat{\gmat{\Sigma}}^{(i)}_{\xi \mid y} \nabla_{\gvect{\xi}}\mathcal{J}(\gvect{\xi}^{(i)}), \] \[ \vect{x}_0^\star = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2} \gvect{\xi}^\star. \]
Useclassic 4D-Varquasi-Newton on the full nonlinear cost
In
\(\gvect{\xi}^{(0)} = \vect{0}\)start at the background
(state,)
cost and gradientthe card above
→ (), (state,)
↓
repeat: \(\mathcal{J}, \nabla_{\gvect{\xi}}\mathcal{J}\) by forward + adjoint; L-BFGS step \(\gvect{\xi} \leftarrow \gvect{\xi} - \alpha \hat{\gmat{\Sigma}}_{\xi \mid y} \nabla_{\gvect{\xi}}\mathcal{J}\)
Out
\(\gvect{\xi}^\star\), \(\vect{x}_0^\star\)analysis
(state,)
Lorenz-96: 61 iterations, 64 forward + adjoint runs.
# Shapes: N state values (state); T times (time)

# --- the minimisation, as vardax runs it ---
# not pinned (optimistix)
# ξ⋆ = argmin_ξ 𝒥(ξ), quasi-Newton with ∇𝒥 from the adjoint
solution = optx.minimise(                             # → (N,)
    cost, optx.BFGS(rtol=1e-6, atol=1e-6), jnp.zeros(N), args=args, max_steps=200
)
# x₀⋆ = μ_x + Σ_x^{1/2} ξ⋆
analysis = background + prior_sqrt.mv(solution.value)  # (N,)

# --- the same through vardax ---
# today (vardax)
strong = vardax.StrongFourDVar(
    forward=forward,  # .step(z, dt), .dt: one observation interval
    obs_op=vardax.MaskedIdentity(),
    prior_mean=background,                            # (N,)
    prior_cov_op=prior_cov,                           # (N, N) PSD operator
    obs_cov_op=obs_cov,                               # (N, N) PSD operator
    minimiser=optx.BFGS(rtol=1e-6, atol=1e-6),
)
# today (vardax)
# batch: Batch1D(input=(1, T+1, N), mask=(1, T+1, N))
analysis = strong(batch)                              # → (1, N)

On Lorenz-96: 61 iterations, 64 forward + adjoint runs; the cost falls from 559 to 101 and the initial-state error from 1.00 to 0.20. Strengths: simple, robust to nonlinearity within the window. Weakness: every iteration runs the full nonlinear model twice (forward and adjoint), and quasi-Newton methods need many iterations on badly conditioned problems.

Algorithm 2 · Incremental 4D-Var

per outer loop one nonlinear run per inner iteration one tangent-linear + one adjoint run

Idea. Gauss–Newton. Linearise the observed trajectory around the current guess, solve the resulting quadratic problem for an increment with conjugate gradients, update, relinearise. The inner problem never runs the nonlinear model, and in operational systems it is often solved at lower resolution with simplified physics.

\[ \mathcal{J}^{(n)}(\delta\gvect{\xi}) = \frac{1}{2} \Vert \gvect{\xi}^{(n)} + \delta\gvect{\xi} \Vert^2 + \frac{1}{2} \Vert \vect{d}^{(n)} - \mat{H}_\text{win}^{(n)} \delta\gvect{\xi} \Vert^2, \] \[ \vect{d}^{(n)} = \gmat{\Sigma}_\varepsilon^{-1/2} \vect{y} - h_\text{win}(\gvect{\xi}^{(n)}), \] \[ (\mat{I} + \mat{H}_\text{win}^{(n)\top} \mat{H}_\text{win}^{(n)}) \delta\gvect{\xi} = \mat{H}_\text{win}^{(n)\top} \vect{d}^{(n)} - \gvect{\xi}^{(n)}, \] \[ \gvect{\xi}^{(n+1)} = \gvect{\xi}^{(n)} + \delta\gvect{\xi}. \]

Here \(h_\text{win}(\gvect{\xi}) = \gmat{\Sigma}_\varepsilon^{-1/2} h(f(\gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi})) \in \mathbb{R}^{T D_y}\) stacks the whitened, observed trajectory, and \(\mat{H}_\text{win}^{(n)} = \mat{J}_{h_\text{win}}(\gvect{\xi}^{(n)}) \in \mathbb{R}^{T D_y \times D_x}\) is its Jacobian at \(\gvect{\xi}^{(n)}\), applied with jax.jvp and transposed with jax.vjp.

Useincremental 4D-VarGauss–Newton outer loop, CG inner loop
In
\(\gvect{\xi}^{(n)}\)current outer iterate
(state,)
\(\vect{y}_{1:T}\) observationsunits of the observed field (m for SSH)
(time, obs)
\(h_\text{win}\), \(\mat{H}_\text{win}\), \(\mat{H}_\text{win}^\top\)nonlinear observed trajectory, its tangent linear and adjoint
(state,) → (time·obs,)
↓
outer: one nonlinear run, \(\vect{d} = \gmat{\Sigma}_\varepsilon^{-1/2}\vect{y} - h_\text{win}(\gvect{\xi})\); inner: CG on \((\mat{I} + \mat{H}_\text{win}^\top \mat{H}_\text{win}) \delta\gvect{\xi} = \mat{H}_\text{win}^\top \vect{d} - \gvect{\xi}\); then \(\gvect{\xi} \leftarrow \gvect{\xi} + \delta\gvect{\xi}\)
Out
\(\gvect{\xi}^{(n+1)}\)next outer iterate
(state,)
Lorenz-96: 4 nonlinear runs and about 104 tangent-linear + adjoint pairs.
# Shapes: N state values (state); T·M whitened observations, flattened (obs_flat)


def observe(whitened):
    # h_win(ξ) = Σ_ε^{−1/2} h(f(μ_x + Σ_x^{1/2} ξ)), flattened over the window
    initial = background + prior_sqrt.mv(whitened)    # (N,)
    states = rollout(forward, initial, num_times)     # → (T, N)
    observed = states[:, obs_index] / obs_std         # (T, N) → (T, M)
    return einx.rearrange("time obs -> (time obs)", observed)  # → (T·M,)


whitened = jnp.zeros(N)                               # (N,), the background
for _ in range(NUM_OUTER):
    # --- outer loop: one nonlinear run, linearised around it ---
    # h_win(ξ) and its tangent linear v ↦ H_win v
    predicted, tangent = jax.linearize(observe, whitened)  # → (T·M,)
    # adjoint w ↦ H_winᵀ w
    adjoint = jax.linear_transpose(tangent, whitened)
    # d = Σ_ε^{−1/2} y − h_win(ξ): whitened innovations
    innovation = observations_white - predicted       # (T·M,)

    def gauss_newton(v):
        # (I + H_winᵀ H_win) v: one tangent-linear and one adjoint run
        (projected,) = adjoint(tangent(v))            # (N,) → (N,)
        return v + projected

    hessian = lx.FunctionLinearOperator(
        gauss_newton, jax.ShapeDtypeStruct((N,), whitened.dtype),
        lx.positive_semidefinite_tag,
    )
    # b = H_winᵀ d − ξ  (= −∇_{δξ} 𝒥 at δξ = 0)
    (pulled,) = adjoint(innovation)                   # (T·M,) → (N,)
    rhs = pulled - whitened                           # (N,)
    # --- inner loop: CG on the quadratic, no nonlinear runs ---
    # not pinned (lineax)
    # δξ = (I + H_winᵀ H_win)⁻¹ b
    step = lx.linear_solve(                           # → (N,)
        hessian, rhs, solver=lx.CG(rtol=1e-8, atol=1e-8, max_steps=NUM_INNER)
    ).value
    # ξ ← ξ + δξ, then relinearise
    whitened = whitened + step                        # (N,)

# today (vardax)
# vardax's version solves in x-space with Σ_x⁻¹ applied by CG (no Σ_x^{1/2} transform)
incremental = vardax.IncrementalFourDVar(
    forward, vardax.MaskedIdentity(), background, prior_cov, obs_cov,
    config=vardax.IncrementalConfig(n_outer=4, n_inner=25),
)
Outer loop\(\mathcal{J}\) after the updateRMSE at \(t = 0\)
1203.90.83
2113.00.29
3101.90.20
4100.80.20

The first linearisation, around the background, captures only part of the nonlinear response over the window (\(\mathcal{J}\) = 204, against the minimum of 101). Three relinearisations reach the classic minimum. Strengths: few expensive nonlinear runs; inner problems are quadratic, so CG with the whitened Hessian converges quickly and predictably. Weakness: each outer loop trusts a linearisation; for long or very nonlinear windows the quadratic model can point the wrong way, and more outer loops (or a shorter window) are needed.

Algorithm 3 · Reduced-basis 4D-Var

per iteration one forward run + one adjoint run, over \(N_\Phi\) controls

Idea. Restrict the initial-state correction to the span of \(N_\Phi \ll D_x\) basis functions: \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Phi}\vect{z}_\Phi\), with a prior \(\vect{z}_\Phi \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_{z_\Phi})\). The optimiser then works in \(N_\Phi\) dimensions. The basis encodes what corrections are plausible: leading EOFs of past states or of an ensemble, Fourier or wavelet modes, radial basis functions on a coarse grid.

\[ \mathcal{J}(\vect{z}_\Phi) = \frac{1}{2} \vect{z}_\Phi^\top \gmat{\Sigma}_{z_\Phi}^{-1} \vect{z}_\Phi + \frac{1}{2} \sum_{k=1}^{T} \Vert \vect{y}_k - h_k(f_{k \leftarrow 0}(\gvect{\mu}_x + \gmat{\Phi}\vect{z}_\Phi)) \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}, \] \[ \nabla_{\vect{z}_\Phi}\mathcal{J} = \gmat{\Sigma}_{z_\Phi}^{-1} \vect{z}_\Phi + \gmat{\Phi}^\top \mat{F}_1^\top \vect{x}^\dagger_1. \]

If \(\gmat{\Phi}\) holds the leading eigenvectors of \(\gmat{\Sigma}_x\) scaled by the square roots of their eigenvalues, this is exactly the whitened full problem truncated to \(N_\Phi\) directions. Anything outside the span is left at the background.

Usereduced-basis 4D-Varoptimise \(N_\Phi\) coefficients instead of \(D_x\) state values
In
\(\gmat{\Phi}\) basisEOFs, Fourier modes, wavelets or RBFs; state units per unit coefficient
(state, basis)
\(\gmat{\Sigma}_{z_\Phi} = \op{diag}(\gvect{\sigma}^2_{z_\Phi})\)prior variance of each coefficient
(basis,)
\(\vect{y}_{1:T}\) observationsunits of the observed field (m for SSH)
(time, obs)
\(\gvect{\mu}_x\) backgroundbest prior guess of \(\vect{x}_0\); state units
(state,)
↓
\(\vect{z}_\Phi^\star = \mathrm{argmin}_{\vect{z}_\Phi} \frac{1}{2} \vect{z}_\Phi^\top \gmat{\Sigma}_{z_\Phi}^{-1} \vect{z}_\Phi + \frac{1}{2} \sum_k \Vert \vect{y}_k - h_k(f_{k \leftarrow 0}(\gvect{\mu}_x + \gmat{\Phi}\vect{z}_\Phi)) \Vert^2_{\gmat{\Sigma}_\varepsilon^{-1}}\)
Out
\(\vect{z}_\Phi^\star\)basis coefficients
(basis,)
\(\vect{x}_0^\star = \gvect{\mu}_x + \gmat{\Phi}\vect{z}_\Phi^\star\)analysis, restricted to the span of \(\gmat{\Phi}\)
(state,)
The forward and adjoint runs still cost the same; the optimiser works in \(N_\Phi\) dimensions, and forward-mode gradients become affordable.
# Shapes: N state values (state); P basis functions (basis), P ≪ N;
#         E background samples (sample)

# today (vardax)
# Φ: leading EOFs of an ensemble, Σ_{z_Φ} = diag(explained variance)
basis = vardax.eof_basis(ensemble, n_modes=16)        # (E, N) → Φ (N, P)


def cost_reduced(coeffs, args):
    forward, background, observations, obs_index, obs_std = args
    # δx₀ = Φ z_Φ
    increment = basis.operg(0.0, coeffs)              # (P,) → (N,)
    # x₀ = μ_x + Φ z_Φ
    initial = background + increment                  # (N,)
    states = rollout(forward, initial, observations.shape[0])  # → (T, N)
    # r_k = Σ_ε^{−1/2} (y_k − h_k(x_k))
    residual = (observations - states[:, obs_index]) / obs_std  # (T, M)
    # 𝒥_o = ½ ∑_k ‖r_k‖²
    obs_cost = 0.5 * einx.sum("time obs ->", residual**2)  # → ()
    # Σ_{z_Φ}⁻¹ z_Φ, diagonal
    scaled = basis.prior_inv(coeffs)                  # (P,)
    # 𝒥_b = ½ z_Φᵀ Σ_{z_Φ}⁻¹ z_Φ
    prior_cost = 0.5 * einx.dot("basis, basis ->", coeffs, scaled)  # → ()
    return prior_cost + obs_cost


# today (vardax)
# full nonlinear cost over z_Φ with BFGS; the name says "incremental", the method does not
reduced = vardax.Incremental4DVar(
    forward, vardax.MaskedIdentity(), background, prior_cov, obs_cov, basis=basis
)
# P = 16 controls: forward mode now costs only 16 tangent runs per gradient
gradient = jax.jacfwd(cost_reduced)(coeffs, args)     # (P,) → (P,)
Basis size \(N_\Phi\)Variance of \(\gmat{\Sigma}_x\) capturedRuns\(\mathcal{J}^\star\)RMSE \(t = 0\)RMSE forecast
879 %22120.00.473.56
1699 %53102.10.201.68

With 8 basis functions (79 % of the background variance) the analysis is limited by what the basis can express: error 0.47 against 0.20. With 16 (99 %) it matches the full-space analysis with fewer runs. Strengths: small control, cheap optimiser, forward-mode or even finite-difference gradients become affordable, and the basis regularises. Weakness: the analysis cannot correct what the basis cannot represent; choosing the basis is the modelling decision.

The three side by side

ClassicIncrementalReduced basis
Control\(\gvect{\xi} \in \mathbb{R}^{D_x}\)\(\delta\gvect{\xi} \in \mathbb{R}^{D_x}\), per outer loop\(\vect{z}_\Phi \in \mathbb{R}^{N_\Phi}\)
OptimiserL-BFGS / BFGSGauss–Newton + CGL-BFGS / BFGS
Nonlinear model runsevery iterationonce per outer loopevery iteration
Linearisationnonethe whole trajectory, per outer loopnone
Lorenz-96 work64 fwd + adj4 nonlinear + ~104 TL + AD53 fwd + adj (\(N_\Phi = 16\))
Lorenz-96 RMSE at \(t = 0\)0.200.200.20
Fails whenconditioning is poorthe window is too nonlinear for the linearisationthe needed correction is outside the basis
vardaxStrongFourDVarIncrementalFourDVar + IncrementalConfigIncremental4DVar(basis=…) or StrongFourDVar(parameterization=LinearParameterization(…))

Application: the Gulf Stream window

The ocean state estimation page applies the same cost to a 1.5-layer quasi-geostrophic model of the Gulf Stream, with ten daily SSH maps over a 1024 km box. The objects map one to one:

Use4D-Var on the Gulf Stream windowthe ocean state estimation page's setting
In
\(\vect{y}_{1:T}\) SSH mapsmetres; σ_ε ≈ 3 cm along-track, larger for gridded maps
(time, height, width)
\(\vect{m}_{1:T}\)observed pixels
(time, height, width)
\(\gvect{\mu}_x\) background PV or SSHs⁻¹ (PV) or m (SSH)
(height, width)
\(\gmat{\Sigma}_x\)Matérn, σ_x ≈ 0.1 m, length ≈ 100 km
(cell, cell)
\(f_k\)1.5-layer QG, one day = 48 × 30 min
(height, width) → (height, width)
↓
same cost; 10 daily windows; \(D_x = 128^2 = 16384\) controls, or \(N_\Phi\) basis coefficients
Out
\(\vect{x}_0^\star\)analysed PV / SSH at day 0
(height, width)
\(\vect{x}_{1:T}^\star\)the reanalysis of the window
(time, height, width)
ChoiceAt ocean scale
Classic16,384 controls; feasible on one GPU for a 128² QG model, with checkpointed reverse mode through the 480 model steps of a 10-day window
Incrementalthe operational standard; the inner loop can run the tangent-linear QG model at half resolution
Reduced basisthe SSH-mapping practice (MASSH-style): wavelets or radial basis functions in space and time, a few thousand coefficients, priors from the SSH spectrum
Gradientjax.checkpoint per day, or diffrax's RecursiveCheckpointAdjoint if the model is written as a diffrax solve
Diagnostics\(2\mathcal{J}^\star / N_\text{obs}\), innovation statistics, and the Gauss–Newton spread, as on the state estimation page

Notes on vardax, from reading the code

Checked against vardax main (3e0f34a) on 24 Oct 2026. Several docs and the code disagree; each point is filed as a vardax issue.

Code markers: today (vardax) names ship on vardax main (3e0f34a); today (diffrax 0.7.0) checked in that release; not pinned marks optimistix and lineax, whose APIs were not checked for this page. Lorenz-96 numbers come from a JAX run of the same algorithms, in double precision.