Ocean state estimation, from the generative story to each box
The traditional formulation of estimating the ocean state from sea-level maps, written once, then read box by box. Steps 1 and 2 are the classical problem. Steps 3 to 6 are what that problem turns into when parts of it are learned.
Every box is an operator
Before any equation, fix what goes in and what comes out. For this problem the answer is short:
- Forward boxes (1, 3) map a sea-level field and two physical parameters to the next field. Box 1 chains that map into a cube of states and a cube of synthetic maps.
- Inverse boxes (2, 4, 5) all have one signature: a cube of daily SSH maps with their masks and errors in, a cube of states plus the parameters and an uncertainty out. They differ only in what is inside and in what each query costs.
- Box 6 takes many windows of those outputs and returns diagnostics and one decision: rerun the inner loop, or change the model.
So the task picks the signature, and the box picks the implementation. Swapping box 2 for box 5 changes no wiring around it.
| Kind | Dimensions | Example | |
|---|---|---|---|
| scalar | () | a cost, a score | |
| vector | (param,) | θ = (L_d, ν_visc); α uses (hyper,) | |
| field | (height, width) | one SSH snapshot | |
| mask | (time, height, width) | observed pixels, boolean | |
| cube | (time, height, width) | a window of maps or states | |
| ensemble | (member, time, height, width) | EnKS members, posterior samples | |
| dataset | (sim, time, height, width) | simulated windows; real ones use window | |
| weights | — | network parameters θ_emu, ν |
Dimension names: time is days in the window (states include day 0, so they have one more), height and width are the grid rows and columns, variable stacks several fields, member is an ensemble member, sim a simulated window, window a real one, param and hyper index θ and α.
The problem and the data
Take a box in the Gulf Stream extension, about 1000 km on a side, centred near 40°N. Satellite altimetry has already been gridded into daily maps of sea surface height: that was the L2 → L3 step, optimal interpolation from along-track data to a map with an error estimate. What we want is the L4 product: the full dynamical ocean state over a window of days, consistent with the physics and with the maps. Run forward in real time, this is an analysis and forecast. Run backward over a whole window, it is a reanalysis.
The difference from L2 → L3 is the model. An optimal-interpolation map uses only a covariance. A state estimate uses a dynamical model that carries information between days, so a feature seen on Monday constrains the field on Thursday.
| Running example | Value |
|---|---|
| Domain, grid | 1024 km × 1024 km, 128 × 128, Δs = 8 km |
| Coriolis at 40°N, its gradient | f₀ ≈ 9.4 × 10⁻⁵ s⁻¹, β ≈ 1.75 × 10⁻¹¹ m⁻¹ s⁻¹ |
| Rossby deformation radius | L_d ≈ 30 km |
| Model time step, observation interval | Δt = 30 min, one SSH map per day (48 steps) |
| Assimilation window | 10 days: T = 10 maps, 480 model steps |
| State dimension | D_x = 128² = 16 384 |
Notation
One letter per role, used the same way in every box.
| Symbol | Role | In the running example | Kind, shape |
|---|---|---|---|
| \(\vect{x}_k\) | state, what we estimate | SSH field \(x(\vect{s}, t_k)\) on the grid at day k | field \(\mathbb{R}^{D_x}\) (height, width); cube \(\mathbb{R}^{(T+1) \times D_x}\) (time, height, width) over the window |
| \(\vect{u}^\text{cov}\) | covariates: known, read by the model, never estimated | planetary vorticity βs₂, grid coordinates | \(\mathbb{R}^{N_\text{var} \times D_x}\) (variable, height, width) |
| \(\vect{u}_k\) | controls: external forcing | daily wind-stress curl | \(\mathbb{R}^{D_x}\) per day; cube \(\mathbb{R}^{T \times D_x}\) (time, height, width) |
| \(\vect{y}_k\) | observations | daily L3 SSH map | \(\mathbb{R}^{D_{y,k}}\) per day; cube (time, height, width) |
| \(\vect{m}_k\) | mask: which pixels of \(\vect{y}_k\) are observed | coverage of each map | \(\{0, 1\}^{D_x}\) per day; boolean cube (time, height, width) |
| \(\gvect{\theta}\) | parameters of the physics | \((L_d, \nu_\text{visc})\) | \(\mathbb{R}^{D_\theta}\) (param,) |
| \(\gvect{\alpha}\) | hyperparameters of the probability model | \(\gmat{\Sigma}_x\)'s scale and length \((\sigma_x, \ell_x)\), θ's prior \((\gvect{\mu}_\theta, \gmat{\Sigma}_\theta)\), \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_\eta\) scales, localisation radius, inflation \(\alpha_\text{infl}\) | \(\mathbb{R}^{D_\alpha}\) (hyper,) |
| \(\gvect{\theta}_\text{emu}\) | emulator weights | box 3's network | \(\mathbb{R}^{D_{\theta_\text{emu}}}\) (weights) |
| \(\gvect{\nu}\) | variational parameters | box 5's conditional density \(q(\cdot; \gvect{\nu})\) | \(\mathbb{R}^{D_\nu}\) (weights) |
DA papers call the variable 4D-Var optimises the "control vector". Here that is \((\vect{x}_0,\gvect{\theta})\), not \(\vect{u}\); \(\vect{u}\) is forcing we are given. And DA papers write the background as \(\mathbf{x}_b\) and \(\mathbf{B}\); here they are the prior mean \(\gvect{\mu}_x\) and covariance \(\gmat{\Sigma}_x\).
The mathematical objects
Every symbol below is used in the rest of the page. Index \(k = 0,1,\ldots,T\) counts observation times \(t_k\), one per day.
State
The state \(\vect{x}_k \in \mathbb{R}^{D_x}\) is the sea surface height field \(x(\vect{s}, t)\) on the grid at time \(t_k\), flattened to a vector. In the quasi-geostrophic (QG) model the streamfunction and potential vorticity are diagnosed from it, so \(x\) alone is a complete state.
Dynamics: the 1.5-layer QG model
Let \(g\) be gravity, \(\psi\) the streamfunction, \(q\) the potential vorticity, \(\vect{s} = (s_1, s_2)\) the eastward and northward coordinates, \(\nabla^2\) the horizontal Laplacian, \(J(a,b) = \partial_{s_1} a \partial_{s_2} b - \partial_{s_2} a \partial_{s_1} b\) the Jacobian (advection of \(b\) by the flow of \(a\)), and \(\nu_\text{visc}\) a biharmonic viscosity. Then
\[ \psi = \frac{g}{f_0} x, \] \[ q = \nabla^2\psi - \frac{\psi}{L_d^2} + \beta s_2, \] \[ \partial_t q + J(\psi, q) = -\nu_\text{visc} \nabla^6\psi + u . \]One time step advects \(q\) (Arakawa Jacobian), then inverts the Helmholtz operator \(\nabla^2 - L_d^{-2}\) to recover \(\psi\), hence \(x\). The forcing \(u = \nabla\times\gvect{\tau}/(\rho_0 H_0)\) is the wind-stress curl from an atmospheric reanalysis, with \(\rho_0\) the reference density and \(H_0\) the layer depth; it is a control \(\vect{u}_k\), given for each day. The planetary vorticity \(\beta s_2\) and the grid coordinates are covariates \(\vect{u}^\text{cov}\). Collect the physical parameters in \(\gvect{\theta} = (L_d, \nu_\text{visc})\). Integrating 48 steps from \(t_{k-1}\) to \(t_k\) defines the model operator \(\mathcal{F}[\cdot; \gvect{\theta}] : \mathbb{R}^{D_x} \to \mathbb{R}^{D_x}\) and its composition \(\mathcal{F}_{k \leftarrow 0}\) from \(t_0\) to \(t_k\):
\[ \vect{x}_k = \mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}] + \gvect{\eta}_k, \] \[ \mathcal{F}_{k \leftarrow 0} = \underbrace{\mathcal{F} \circ \ldots \circ \mathcal{F}}_{k \text{ times}} . \]Here \(\gvect{\eta}_k \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_\eta)\) is model error, with covariance \(\gmat{\Sigma}_\eta \in \mathbb{R}^{D_x\times D_x}\). Its Jacobian \(\mat{F}_k = \mat{J}_{\mathcal{F}}(\vect{x}_{k-1})\) is the tangent-linear model; \(\mat{F}_k^\top\) is the adjoint.
Observations
The map on day \(k\) has \(D_{y,k} \le D_x\) usable pixels (land, ice and high-error pixels are removed). Write \(\vect{y}_k \in \mathbb{R}^{D_{y,k}}\) for those pixels and \(\mat{H}_k \in \{0,1\}^{D_{y,k}\times D_x}\) for the selection matrix that picks them out of the state:
\[ \vect{y}_k = \mat{H}_k \vect{x}_k + \gvect{\varepsilon}_k, \] \[ \gvect{\varepsilon}_k \sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_{\varepsilon,k}). \]\(\gmat{\Sigma}_{\varepsilon,k} \in \mathbb{R}^{D_{y,k}\times D_{y,k}}\) is the observation-error covariance. For a gridded map it is the mapping error from the L2 → L3 optimal interpolation, plus representation error (the QG model has no tides, no steric seasonal cycle).
Assimilating maps instead of tracks is convenient but not innocent. The map's errors are spatially correlated, so a diagonal \(\gmat{\Sigma}_{\varepsilon,k}\) overweights the data, and the map already contains a prior from the L2 → L3 step that is counted again here. The cleaner traditional choice is to assimilate the L2 along-track heights directly, with \(\mat{H}_k\) an interpolation from the grid to the track points. The algebra below is identical in both cases.
Prior
The background \(\gvect{\mu}_x\) is our best guess of \(\vect{x}_0\) before seeing this window, typically the previous window's analysis. Its error covariance \(\gmat{\Sigma}_x \in \mathbb{R}^{D_x\times D_x}\) is usually a stationary kernel, for example Matérn with a standard deviation of 10 cm and a length scale of 100 km. The parameters get their own prior \(\gvect{\theta} \sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta)\). Everything that shapes these distributions but is not itself estimated in a window (\(\sigma_x, \ell_x, \gvect{\mu}_\theta, \gmat{\Sigma}_\theta\), the scales of \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_\eta\)) is a hyperparameter, collected in \(\gvect{\alpha}\).
\[ \vect{x}_0 \sim \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x), \] \[ \gvect{\theta} \sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta). \]Dictionary to the figure. The figure writes the generic problem as \(y = H(f(\theta)) + \varepsilon\), where \(\theta\) is "the quantity we want". For ocean state estimation that quantity is the unknown initial state and parameters, the pair \((\vect{x}_0, \gvect{\theta})\). The figure's \(f\) is the trajectory map \((\vect{x}_0,\gvect{\theta}) \mapsto (\mathcal{F}_{1 \leftarrow 0}[\vect{x}_0; \gvect{\theta}],\ldots,\mathcal{F}_{T \leftarrow 0}[\vect{x}_0; \gvect{\theta}])\), and its \(H\) is the stack of the \(\mat{H}_k\). The figure's θ is therefore our \((\vect{x}_0, \gvect{\theta})\), and its "inner loop: x, θ" means our \(\vect{x}\) and \(\gvect{\theta}\). The covariates \(\vect{u}^\text{cov}\) and controls \(\vect{u}\) are inside \(f\): known inputs, not unknowns.
Simple model: the generative story
(param,)(height, width)(variable, height, width)(time, height, width)(time, height, width)(time, height, width)(time, height, width)(time, height, width)step_day 𝓕[·; θ], (state field, covariate field, control field, θ) → state field (48 QG steps). Called S times it builds the OSSE dataset that boxes 3 and 5 train on: states (sim, time, height, width), maps and masks (sim, time, height, width), controls (sim, time, height, width), θ (sim, param).The objects above define a state-space model, a hidden Markov chain with Gaussian noise. Read top to bottom, it is a recipe for simulating an ocean and the satellite data it would produce:
\[ \begin{aligned} \gvect{\theta} &\sim \mathcal{N}(\gvect{\mu}_\theta, \gmat{\Sigma}_\theta) &&\text{draw the physics}\\ \vect{x}_0 &\sim \mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x) &&\text{draw an initial sea level}\\ \vect{x}_k &= \mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}] + \gvect{\eta}_k, && k = 1, \ldots, T, \text{ run the QG model}\\ \vect{y}_k &= \mat{H}_k \vect{x}_k + \gvect{\varepsilon}_k, && k = 1, \ldots, T, \text{ observe it} \end{aligned} \]The recipe is equivalent to a joint density, which is everything step 2 needs:
\[ p(\vect{x}_{0:T}, \gvect{\theta}, \vect{y}_{1:T}) = p(\gvect{\theta}) p(\vect{x}_0)\prod_{k=1}^{T} \underbrace{p(\vect{x}_k \mid \vect{x}_{k-1}, \gvect{\theta})}_{\mathcal{N}(\mathcal{F}[\vect{x}_{k-1}; \gvect{\theta}], \gmat{\Sigma}_\eta)} \underbrace{p(\vect{y}_k \mid \vect{x}_k)}_{\mathcal{N}(\mat{H}_k\vect{x}_k, \gmat{\Sigma}_{\varepsilon,k})} . \]The arrow from step 1 to steps 3 and 5 in the figure is this recipe run many times. In DA vocabulary that is an observing system simulation experiment (OSSE): a "nature run" \(\vect{x}^\text{true}_{0:T}\) plus synthetic maps \(\vect{y}_{1:T}\) made with the real \(\mat{H}_k\) and \(\gmat{\Sigma}_{\varepsilon,k}\). Each OSSE gives a pair \((\vect{x}_{0:T}, \vect{y}_{1:T})\) whose answer is known exactly.
Model-based inference: the classical ways
(time, height, width)(time, height, width)(time, height, width)(height, width)(variable, height, width)(time, height, width)(hyper,)(time, height, width)(param,)(member, time, height, width)Bayes' rule turns the joint density into the target. Everything in classical DA is a way of computing, approximating or summarising one of two posteriors:
\[ p(\vect{x}_{0:T}, \gvect{\theta} \mid \vect{y}_{1:T}) \propto p(\gvect{\theta}) p(\vect{x}_0)\prod_{k=1}^{T} p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) p(\vect{y}_k \mid \vect{x}_k). \]| Posterior | Name in DA | Product |
|---|---|---|
| \(p(\vect{x}_k \mid \vect{y}_{1:k})\) | filtering | real-time analysis, initial conditions for a forecast |
| \(p(\vect{x}_{0:T} \mid \vect{y}_{1:T})\) | smoothing | reanalysis: every day uses past and future maps |
The exact case: the Kalman filter
If \(\mathcal{F}\) were linear, \(\mathcal{F}[\vect{x}; \gvect{\theta}] = \mat{F}\vect{x}\), every density above would stay Gaussian and the filter would be exact. Write \(\vect{x}_{k \mid k-1}, \gmat{\Sigma}_{x,k \mid k-1}\) for the forecast mean and covariance and \(\vect{x}_{k \mid k}, \gmat{\Sigma}_{x,k \mid k}\) for the analysis:
\[ \begin{aligned} \text{forecast:} & \vect{x}_{k \mid k-1} = \mat{F}\vect{x}_{k-1 \mid k-1}, && \gmat{\Sigma}_{x,k \mid k-1} = \mat{F}\gmat{\Sigma}_{x,k-1 \mid k-1}\mat{F}^\top + \gmat{\Sigma}_\eta,\\ \text{gain:} & \mat{G}_k = \gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top(\mat{H}_k\gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k})^{-1}, &&\\ \text{analysis:} & \vect{x}_{k \mid k} = \vect{x}_{k \mid k-1} + \mat{G}_k(\vect{y}_k - \mat{H}_k\vect{x}_{k \mid k-1}), && \gmat{\Sigma}_{x,k \mid k} = (\mat{I} - \mat{G}_k\mat{H}_k)\gmat{\Sigma}_{x,k \mid k-1} . \end{aligned} \]Two facts kill it for the ocean. QG advection \(J(\psi,q)\) is quadratic, so \(\mathcal{F}\) is nonlinear. And \(\gmat{\Sigma}_{x,k \mid k-1}\) has \(D_x^2 \approx 2.7\times 10^8\) entries (about 1 GB in float32) and propagating it costs \(D_x\) tangent-linear runs per day. Every practical method below drops one of these two ingredients: 4D-Var drops the covariance, the EnKF drops the full rank.
3D-Var: one analysis as an optimisation
For a single time, the Kalman analysis mean is also the minimiser of a quadratic cost. Write \( \Vert \vect{r} \Vert^2_{\mat{A}} = \vect{r}^\top \mat{A} \vect{r}\):
\[ \mathcal{J}_{\text{3D}}(\vect{x}) = \frac{1}{2} \Vert \vect{x} - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \Vert \vect{y} - \mat{H}\vect{x} \Vert^2_{\gmat{\Sigma}_\varepsilon^{-1}} . \]This is \(-\log\) of prior times likelihood, so its minimiser is the MAP estimate. With \(\gvect{\mu}_x = \vect{x}_{k \mid k-1}\) and \(\gmat{\Sigma}_x = \gmat{\Sigma}_{x,k \mid k-1}\) it equals \(\vect{x}_{k \mid k}\) exactly. 3D-Var ignores time: it is the L2 → L3 optimal interpolation written as a cost function.
Strong-constraint 4D-Var: the whole window through the model
Assume the model is perfect, \(\gmat{\Sigma}_\eta = \mat{0}\). Then the trajectory is fixed by \(\vect{x}_0\) and \(\gvect{\theta}\), and the negative log posterior is a function of those alone:
\[ \mathcal{J}(\vect{x}_0,\gvect{\theta}) = \underbrace{\frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}}}_{\text{background}} + \underbrace{\frac{1}{2} \Vert \gvect{\theta} - \gvect{\mu}_\theta \Vert^2_{\gmat{\Sigma}_\theta^{-1}}}_{\text{parameters}} + \underbrace{\frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\mathcal{F}_{k \leftarrow 0}[\vect{x}_0; \gvect{\theta}] \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}}}_{\text{misfit to 10 maps}} . \]The minimiser \((\vect{x}_0^\star,\gvect{\theta}^\star)\) is the MAP estimate, and running the model from it gives the analysed trajectory. The minimisation needs \(\nabla_{\vect{x}_0}\mathcal{J}\). Let \(\vect{d}_k = \gmat{\Sigma}_{\varepsilon,k}^{-1}(\vect{y}_k - \mat{H}_k\vect{x}_k)\) be the weighted misfit on day \(k\). Run the adjoint backwards, starting from \(\vect{x}^\dagger_{T+1} = \vect{0}\):
\[ \vect{x}^\dagger_k = \mat{H}_k^\top\vect{d}_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 . \]The backward recursion is reverse-mode automatic differentiation of the model. In JAX it is jax.grad of \(\mathcal{J}\); in a Fortran ocean model it is a separately maintained adjoint code. One gradient costs about one forward run plus one adjoint run, two to three forward runs in total. Adding \(\gvect{\theta}\) to the control vector, as above, is parameter estimation by the same machinery.
In practice the optimisation variable is changed to \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\), so the background term becomes \(\frac{1}{2} \Vert \gvect{\xi} \Vert^2\) and the problem is well conditioned. Applying \(\gmat{\Sigma}_x^{1/2}\) matrix-free (a Kronecker or spectral factor of the Matérn kernel) is a linear-algebra job, which is where gaussx sits.
import einx import equinox as eqx import finitevolx as fvx import gaussx as gx import jax import jax.numpy as jnp import lineax as lx import optimistix as optx from jaxtyping import Array, Bool, Float # Shapes: T = 10 daily maps (time); H, W = 130 × 130 grid with ghost ring # (height, width); N = H·W = 16,900 cells (cell); V covariates # (variable); P = 2 parameters L_d, ν_visc (param) Op = lx.AbstractLinearOperator class Window(eqx.Module): """One 10-day window: maps y, masks m, errors σ², background μ_x, priors.""" maps: Float[Array, "time height width"] masks: Bool[Array, "time height width"] obs_var: Float[Array, "time height width"] background: Float[Array, "cell"] covariates: Float[Array, "variable height width"] forcing: Float[Array, "time height width"] prior_cov: Op param_mean: Float[Array, "param"] param_cov: Op grid: fvx.CartesianGrid2D def strong_4dvar_cost( control: tuple[Float[Array, "cell"], Float[Array, "param"]], window: Window, ) -> Float[Array, ""]: whitened, params = control height = window.maps.shape[1] # user (taxonomy page) qg_params = QGParams(deformation_radius=params[0], viscosity=params[1]) # --- background --- # today* (gaussx) # Σ_x^{1/2} prior_sqrt = gx.sqrt(window.prior_cov) # (N, N) PSD op # x₀ − μ_x = Σ_x^{1/2} ξ increment = prior_sqrt.mv(whitened) # (N,) → (N,) # x₀ = μ_x + Σ_x^{1/2} ξ initial_flat = window.background + increment # (N,) → (N,) initial_state = einx.rearrange( # (N,) → (H, W) "(height width) -> height width", initial_flat, height=height ) # ‖ξ‖² whitened_norm = einx.dot("cell, cell ->", whitened, whitened) # → () # 𝒥_b = ½‖ξ‖² background_cost = 0.5 * whitened_norm # → () # --- parameters --- # θ − μ_θ param_departure = params - window.param_mean # (P,) → (P,) # today (gaussx) # (θ − μ_θ)ᵀ Σ_θ⁻¹ (θ − μ_θ) param_quad = gx.quadratic_form(window.param_cov, param_departure) # → () # 𝒥_θ = ½‖θ − μ_θ‖²_{Σ_θ⁻¹} param_cost = 0.5 * param_quad # → () # --- observations, one day per scan step --- def assimilate_day(state, day): observed_map, mask, obs_var, daily_forcing = day # user (qg_day, taxonomy page) # x_k = 𝓕[x_{k−1}, u^cov, u_k; θ], 48 RK4 steps state = qg_day( # (H, W) → (H, W) state, window.covariates, daily_forcing, qg_params, window.grid ) # y_k − x_k misfit = observed_map - state # (H, W) # r_k = m_k ⊙ (y_k − x_k) residual = jnp.where(mask, misfit, 0.0) # (H, W) # r_k² squared = residual**2 # (H, W) # r_k² / σ_k² weighted = squared / obs_var # (H, W) # ½ ∑ r_k² / σ_k² day_sum = einx.sum("height width ->", weighted) # → () day_cost = 0.5 * day_sum # → () return state, day_cost days = (window.maps, window.masks, window.obs_var, window.forcing) # 𝒥_o = ∑_k ½‖m_k ⊙ (y_k − x_k)‖²_{Σ_ε,k⁻¹} _, day_costs = jax.lax.scan(assimilate_day, initial_state, days) # → (T,) obs_cost = einx.sum("time ->", day_costs) # (T,) → () # 𝒥 = 𝒥_b + 𝒥_θ + 𝒥_o total_cost = background_cost + param_cost + obs_cost # → () return total_cost # not pinned (optimistix) # (ξ⋆, θ⋆) = argmin 𝒥(ξ, θ); ∇𝒥 by reverse mode is the adjoint recursion solution = optx.minimise( # → ((N,), (P,)) strong_4dvar_cost, optx.BFGS(rtol=1e-6, atol=1e-6), (whitened_init, params_init), args=window, ) whitened_star, params_star = solution.value
Markers: today ships on main (checked 24 Oct 2026: finitevolX 831c69f, gaussx eeda235, somax 9cb19b3); today* ships, arguments sketched; user is defined on these pages; not pinned is a library whose API was not checked. Shape column letters are defined in the header.
vardax ships a strong-constraint 4D-Var (StrongFourDVar) but no Σx1/2 change of variables, so the sketch builds that step with gaussx.
Writing \(\mat{H}_k\) as the mask \(\vect{m}_k\) applied to the full grid keeps every day the same shape, (height, width), so the scan needs no padding. The hyperparameters \(\gvect{\alpha}\) live inside prior_cov, obs_var and param_cov: fixed here, tuned in step 6.
Weak-constraint 4D-Var: admitting the model is wrong
With \(\gmat{\Sigma}_\eta \neq \mat{0}\) the whole trajectory becomes the control variable, and the cost is exactly the negative log of the smoothing posterior:
\[ \mathcal{J}(\vect{x}_{0:T}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{x}_k - \mathcal{F}[\vect{x}_{k-1}; \gvect{\theta}] \Vert^2_{\gmat{\Sigma}_\eta^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\vect{x}_k \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}} . \]Strong-constraint 4D-Var is the limit \(\gmat{\Sigma}_\eta \to \mat{0}\). The control vector grows from \(D_x\) to \((T+1)D_x\), but the terms decouple in time, so the days can be evaluated in parallel.
Uncertainty from 4D-Var
4D-Var returns a mode, not a distribution. The Laplace approximation recovers a Gaussian around it, with the Gauss–Newton Hessian. Let \(\mat{F}_{k \leftarrow 0} = \partial \vect{x}_k / \partial \vect{x}_0 = \mat{F}_k \ldots \mat{F}_1\):
\[ \gmat{\Sigma}_{x_0 \mid y} \approx (\gmat{\Sigma}_x^{-1} + \sum_{k=1}^{T}\mat{F}_{k \leftarrow 0}^\top\mat{H}_k^\top\gmat{\Sigma}_{\varepsilon,k}^{-1}\mat{H}_k\mat{F}_{k \leftarrow 0})^{-1}. \]It is never formed. Each Hessian–vector product is one tangent-linear plus one adjoint run, and Lanczos extracts its leading eigenpairs: the directions the ten maps actually constrain.
The ensemble Kalman filter: sampling the covariance
The EnKF keeps the Kalman equations and replaces \(\gmat{\Sigma}_{x,k \mid k-1}\) with a sample. Run \(N_{\mathcal{E}}\) members \(\vect{x}^{(e)}_k\), say \(N_{\mathcal{E}} = 32\), each through the full nonlinear model. Stack the scaled anomalies into \(\tilde{\mat{X}}_k \in \mathbb{R}^{D_x\times N_{\mathcal{E}}}\):
\[ \tilde{\mat{X}}_k = \frac{1}{\sqrt{N_{\mathcal{E}}-1}}[\vect{x}^{(1)}_k - \bar{\vect{x}}_k, \ldots, \vect{x}^{(N_{\mathcal{E}})}_k - \bar{\vect{x}}_k], \] \[ \gmat{\Sigma}_{x,k \mid k-1} \approx \tilde{\mat{X}}_k\tilde{\mat{X}}_k^\top . \]The gain becomes \(\mat{G}_k = \tilde{\mat{X}}_k(\mat{H}_k\tilde{\mat{X}}_k)^\top(\mat{H}_k\tilde{\mat{X}}_k(\mat{H}_k\tilde{\mat{X}}_k)^\top + \gmat{\Sigma}_{\varepsilon,k})^{-1}\), and each member is updated with it. The matrix to invert is \(D_{y,k}\times D_{y,k}\), or \(N_{\mathcal{E}}\times N_{\mathcal{E}}\) in the ensemble-transform form. The sample covariance has rank at most \(N_{\mathcal{E}} - 1 = 31\) against \(D_x = 16\ 384\), so two fixes are always needed. Localisation multiplies the covariance elementwise by a compact taper \(\mat{C}_\text{loc}\) (for example 150 km), \(\gmat{\Sigma}_{x,k \mid k-1} \approx \mat{C}_\text{loc} \odot \tilde{\mat{X}}\tilde{\mat{X}}^\top\), which removes spurious long-range correlations. Inflation scales the anomalies by \(\alpha_\text{infl} \gtrsim 1\) to stop the spread collapsing. Parameters are estimated by appending \(\gvect{\theta}\) to each member's state. The ensemble smoother (EnKS) gives the reanalysis.
Cost of the oracle
| Method | Model work per 10-day window | What you get |
|---|---|---|
| 4D-Var | n_iter × (forward + adjoint) × 480 steps | MAP trajectory, Hessian for error bars |
| EnKF / EnKS | \(N_{\mathcal{E}}\) × 480 steps + \(T\) analyses | ensemble mean and spread |
With 50 iterations, 4D-Var costs about 50 × 3 × 480 ≈ 7 × 10⁴ QG steps. At 128² that is seconds to minutes on one GPU. The "hours" on the figure describe a realistic primitive-equation model at 1/12°, where one model day can take minutes on hundreds of cores. For this example, step 2 is cheap enough to be the oracle for everything to its right.
Emulator: replace 𝓕 by a network
(sim, time, height, width)(sim, param), (sim, time, height, width)—(height, width)(height, width) each(param,)—(height, width)step_day in box 1, plus weights. That is what makes it a drop-in replacement for 𝓕.Look at where the cost of step 2 lives: in \(\mathcal{F}\), evaluated hundreds of times per iteration, and in its adjoint. Step 3 replaces that one object. A network \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\) with weights \(\gvect{\theta}_\text{emu}\) learns the one-day map from the step-1 nature runs:
\[ \gvect{\theta}_\text{emu}^\star = \mathrm{argmin}_{\gvect{\theta}_\text{emu}} \mathbb{E}_{\gvect{\theta}, \vect{x}_0\sim p}\sum_{k=1}^{T} \Vert \vect{x}_k - \mathcal{F}_{k \leftarrow 0}[\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}] \Vert^2, \] \[ \vect{x}_k = \mathcal{F}_{k \leftarrow 0}[\vect{x}_0; \gvect{\theta}]. \]With \(T = 1\) this is one-step training; with \(T > 1\) it is a rollout loss (somax's rollout_loss on a scanned cycle). Its reference is step 1: the emulator is scored against the simulator it replaces, not against data.
The figure's "skip if cheap" applies here. One emulator call replaces 48 QG steps, but 48 QG steps at 128² already take milliseconds in JAX, and the QG model is already differentiable. For this running example the emulator buys little. It pays off when \(\mathcal{F}\) is a large ocean model with no adjoint.
4D-Var uses the adjoint \(\mat{F}_k^\top\), not only \(\mathcal{F}\). A network that matches the forward map can still have the wrong Jacobian, and then the gradients in step 4 point the wrong way. The objective above only constrains values. If the emulator is meant for step 4, add a Jacobian term with random directions \(\vect{v}\): \( \Vert \mat{F}_k\vect{v} - \hat{\mat{F}}_k\vect{v} \Vert^2\), two JVPs per sample. An ensemble method in step 4 needs forward fidelity only.
Emulator-based inference: same cost, new model
3 × (time, height, width)(height, width), …(hyper,)—(time, height, width)(param,)Step 4 is step 2 with one substitution, \(\mathcal{F}[\cdot; \gvect{\theta}] \to \mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\). Nothing else changes: same \(\gmat{\Sigma}_x\), \(\gmat{\Sigma}_{\varepsilon,k}\), \(\mat{H}_k\), same optimiser or ensemble.
\[ \mathcal{J}(\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}) = \frac{1}{2} \Vert \vect{x}_0 - \gvect{\mu}_x \Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2} \Vert \gvect{\theta} - \gvect{\mu}_\theta \Vert^2_{\gmat{\Sigma}_\theta^{-1}} + \frac{1}{2}\sum_{k=1}^{T} \Vert \vect{y}_k - \mat{H}_k\mathcal{F}_{k \leftarrow 0}[\vect{x}_0, \gvect{\theta}; \gvect{\theta}_\text{emu}] \Vert^2_{\gmat{\Sigma}_{\varepsilon,k}^{-1}} . \]Its reference is step 2: the emulator-based analysis \(\hat{\vect{x}}^\text{emu}_{0:T}\) is compared with the classical analysis \(\hat{\vect{x}}_{0:T}\) on the same windows and the same maps, and in an OSSE both are compared with \(\vect{x}^\text{true}\). Each query still solves an optimisation, so step 4 still needs the observations, the prior and a solver at run time. That is the difference from step 5.
Amortized inference: learn the inverse
(sim, time, height, width) × 3(sim, time, height, width)—3 × (time, height, width)(time, height, width)—(time, height, width)(member, time, height, width)Steps 2 and 4 solve a new optimisation for every window. Step 5 learns the answer as a function of the data. A conditional density \(q(\cdot; \gvect{\nu})\) with weights \(\gvect{\nu}\) is trained on step-1 OSSE pairs:
\[ \gvect{\nu}^\star = \mathrm{argmin}_{\gvect{\nu}} \mathbb{E}_{(\vect{x}_{0:T}, \vect{y}_{1:T})\sim p}[-\log q(\vect{x}_{0:T} \mid \vect{y}_{1:T}; \gvect{\nu})], \] \[ q(\vect{x}_{0:T} \mid \vect{y}_{1:T}; \gvect{\nu}^\star) \approx p(\vect{x}_{0:T} \mid \vect{y}_{1:T}). \]Minimising this expectation minimises \(\mathbb{E}_{\vect{y}}[\mathrm{KL}[p(\cdot \mid \vect{y}) \Vert q(\cdot \mid \vect{y}; \gvect{\nu})]]\), so a flexible \(q(\cdot; \gvect{\nu})\) converges to the true posterior. The simplest case is a Gaussian with fixed variance and learned mean \(\gvect{\mu}_{x \mid y}(\vect{y}; \gvect{\nu})\). The loss becomes mean squared error, whose minimiser is the posterior mean:
\[ \min_{\gvect{\nu}} \mathbb{E} \Vert \vect{x}_{0:T} - \gvect{\mu}_{x \mid y}(\vect{y}_{1:T}; \gvect{\nu}) \Vert^2 \Longrightarrow \gvect{\mu}_{x \mid y}(\vect{y}_{1:T}; \gvect{\nu}^\star) = \mathbb{E}[\vect{x}_{0:T} \mid \vect{y}_{1:T}]. \]That is the "direct analysis": a network from ten SSH maps to the state, at the cost of one forward pass.
Mean versus mode. An MSE-trained network estimates the posterior mean, while 4D-Var returns the posterior mode. They coincide only in the linear-Gaussian case. Compare step 5 with the step-2 ensemble mean, or with 4D-Var's mode only after checking the posterior is close to Gaussian.
The prior is implicit. Steps 2 and 4 state \(\gmat{\Sigma}_x\) and \(p(\gvect{\theta})\) explicitly. Step 5 learns its prior from the distribution of nature runs. If those runs do not look like the real Gulf Stream, the amortized posterior is wrong in a way no single-window check reveals.
The observing system is part of the input. \(\mat{H}_k\) and \(\gmat{\Sigma}_{\varepsilon,k}\) change every day (clouds do not matter for altimetry, but coverage, land masks and pixel errors do). The network must see the mask and error map as inputs, \(q(\vect{x} \mid \vect{y},\mat{H},\gmat{\Sigma}_\varepsilon; \gvect{\nu})\), and training must sample them, or it will fail on the first unusual day.
Improve: the misfit decides which loop runs
(window, time, height, width)(window, time, height, width) each(window, time, height, width)(height, width)(window,)(box, time)1 of 2The formulation contains its own test. If steps 1 and 2 are right, the innovations, \(\vect{d}_k = \vect{y}_k - \mat{H}_k\vect{x}_{k \mid k-1}\), must be zero-mean with a known covariance, because they are the sum of two independent Gaussian errors:
\[ \mathbb{E}[\vect{d}_k] = \vect{0}, \] \[ \mathbb{E}[\vect{d}_k\vect{d}_k^\top] = \mat{H}_k\gmat{\Sigma}_{x,k \mid k-1}\mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k} . \]Two scalar checks follow. With \(D_y = \sum_k D_{y,k}\) observations, the minimum of a correctly specified cost satisfies \(\mathbb{E}[2 \mathcal{J}(\vect{x}^\star)]\approx D_y\), so \(2\mathcal{J}^\star/D_y\) should be near 1. These checks are how the hyperparameters \(\gvect{\alpha}\) get tuned. The Desroziers identity checks \(\gmat{\Sigma}_\varepsilon\) on its own: \(\mathbb{E}[(\vect{y}_k - \mat{H}_k \vect{x}_{k \mid k})(\vect{y}_k - \mat{H}_k \vect{x}_{k \mid k-1})^\top] \approx \gmat{\Sigma}_{\varepsilon,k}\).
| Misfit | Signature | Loop | What changes |
|---|---|---|---|
| Random | \(\bar{\vect{d}}\approx\vect{0}\), \(2\mathcal{J}^\star/D_y\approx 1\) | inner | \(\vect{x}\) and \(\gvect{\theta}\) only: rerun step 2 |
| Systematic | mean innovation with spatial structure, \(2\mathcal{J}^\star/D_y\gg 1\) | outer | structure: \(\mathcal{F}\) (1.5 → 2 layers, wind forcing, topography), \(\mat{H}\) (mean dynamic topography), the forms of \(\gmat{\Sigma}_\varepsilon\) and \(\gmat{\Sigma}_x\) (correlations), or just \(\gvect{\alpha}\) |
Each box downstream is improved against its reference. The formulation fixes those references precisely, because each step approximates one object defined earlier:
| Improve | It approximates | Reference |
|---|---|---|
| Step 2 | \(p(\vect{x} \mid \vect{y})\) under the step-1 model | step 1: \(\vect{x}^\text{true}\) in an OSSE |
| Step 3 | \(\mathcal{F}\) | step 1: the simulator |
| Step 4 | step 2's posterior, with \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}]\) | step 2 |
| Step 5 | step 2's posterior, without an inversion | step 2 (step 4 as a cheaper stand-in) |
The six-step figure's bottom row lists the ground truth for step 3 as step 2, and for steps 4 and 5 as the step before each. The formulation says step 3's reference is step 1 (an emulator approximates \(\mathcal{F}\), not a posterior), and steps 4 and 5 are both scored against the step-2 posterior. The axes-matrix figure in the same overview already says this ("step 3 scored against step 1; steps 4 and 5 scored against step 2"). The bottom row of six_steps.svg should match it.
The six boxes, read from the formulation
| Box | Mathematical object | For the Gulf Stream example | In the stack |
|---|---|---|---|
| 1 Simple model | \(p(\gvect{\theta})p(\vect{x}_0)\prod_k p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) p(\vect{y}_k \mid \vect{x}_k)\) | 1.5-layer QG, daily SSH maps, OSSEs | somax (QG), finitevolX (Arakawa Jacobian, Helmholtz) |
| 2 Model-based inference | \(p(\vect{x}_{0:T},\gvect{\theta} \mid \vect{y}_{1:T})\) via \(\mathrm{argmin}\mathcal{J}\) or EnKF | 10-day 4D-Var or EnKS reanalysis | DA cycle with classical analysis, gaussx for \(\gmat{\Sigma}_x^{1/2}\) |
| 3 Emulator | \(\mathcal{F}[\cdot; \gvect{\theta}_\text{emu}] \approx \mathcal{F}[\cdot; \gvect{\theta}]\) | learned one-day step | training on simulated trajectories |
| 4 Emulator-based inference | \(\mathrm{argmin}\mathcal{J}(\cdot; \gvect{\theta}_\text{emu})\) | same 4D-Var, emulator inside | DA cycle with a neural forward model |
| 5 Amortized inference | \(q(\vect{x} \mid \vect{y}; \gvect{\nu})\approx p(\vect{x} \mid \vect{y})\) | maps → state in one pass | conditional flows or a Gaussian head |
| 6 Improve | \(\mathbb{E}[\vect{d}_k \vect{d}_k^\top]\) vs \(\mat{H}_k \gmat{\Sigma}_{x,k \mid k-1} \mat{H}_k^\top + \gmat{\Sigma}_{\varepsilon,k}\) | innovation and \(\chi^2\) diagnostics | benchmarks rerun against their references |
Moving right, the work moves from query time to training time. Step 2 pays the model and its adjoint on every window. Step 4 pays a cheaper model on every window. Step 5 pays once, during training, and then each window costs one network evaluation.