GeoStack · gaussx · kernellib · geonnax · numpyro

Variational data assimilation, written as probabilistic programs

3D-Var, strong-constraint 4D-Var and weak-constraint 4D-Var, each in a full and a reduced form, are one idea at six sizes, and incremental 4D-Var solves the strong-constraint pair a second way: a Gaussian prior on whatever is uncertain, a model that maps it to observations, and the most probable value given the data. This page writes each as a numpyro model whose covariances are gaussx operators (circulant, Kronecker, low-rank) built from kernellib kernels, with reduced bases from kernellib feature maps and geonnax eigenfunctions. The cost function is then derived, not hand-written; the analysis is the model's MAP; the Laplace approximation is its uncertainty.

TL;DR

Six priors, one likelihood, two ways to solve

Intuition: one ladder

The six priors come in three pairs, each a full and a reduced version: 3D-Var, strong-constraint 4D-Var and weak-constraint 4D-Var. Incremental 4D-Var (rungs 5 and 6) adds no new prior: it solves the strong-constraint pair by Gauss–Newton. Every one answers the same question: which unknowns, a priori how large, best explain the observations? They differ only in what is unknown. Going down the ladder, the model is trusted less and the data are asked to explain more.

1
3D-Var
One state at one time. The model only carries the analysis to the next time.
\(\gvect{\xi}\), \(D_x\) values
2
Reduced 3D-Var
A few coefficients of that state's increment, in smooth patterns.
\(\vect{z}_L\), \(N_\text{feat} \ll D_x\) values
3
Strong-constraint 4D-Var
The initial state; the model is exact, so it fixes the whole trajectory.
\(\gvect{\xi}\), \(D_x\) values
4
Reduced strong-constraint 4D-Var
A few coefficients of the initial increment, in smooth patterns.
\(\vect{z}_L\), \(N_\text{feat} \ll D_x\) values
5
Incremental 4D-Var
Same control and MAP as 3, solved by Gauss–Newton: linearise, solve a linear-Gaussian program, repeat.
\(\gvect{\xi}\), \(D_x\) values
6
Reduced incremental 4D-Var
Same control and MAP as 4, solved the same way.
\(\vect{z}_L\), \(N_\text{feat} \ll D_x\) values
7
Weak-constraint 4D-Var
The initial state and a model error after every interval.
\((\gvect{\xi}, \vect{w})\), \(D_x(T+1)\) values
8
Reduced weak-constraint 4D-Var
The initial state and a few coefficients of a model-error pattern.
\((\gvect{\xi}, \gvect{\omega})\), \(D_x + N_\chi N_\Phi\) values

Written as a probabilistic program, the change from one rung to the next is a change of a few lines: which variables are numpyro.sample sites, and which covariance object their prior uses. Everything downstream (the cost, its gradient, the minimiser, the uncertainty) is shared.

The running example is the weak-constraint page's: Lorenz-96 with \(D_x = 40\) sites, truth forcing \(F = 8\), model forcing \(F = 7\) (a biased model), \(T = 20\) observation times of 10 RK4 steps, every second site observed with \(\sigma_\varepsilon = 0.5\), and a Gaussian-correlated background error (\(\sigma_x = 1\), length 2 sites).

The program behind every method

Collect a method's controls, whitened, in \(\vect{z}\). Each method is a joint density

\[ p(\vect{z}, \vect{y}) = \underbrace{p(\vect{z})}_{\text{prior: }\gmat{\Sigma}_x, \gmat{\Sigma}_\eta, \text{bases}} \prod_{k=1}^{T}\prod_{i=1}^{D_y} \underbrace{\mathcal{N}(y_{k,i}; (\mat{H} \vect{x}_k(\vect{z}))_i, \sigma_\varepsilon^2)^{m_{k,i}}}_{\text{likelihood, masked}}, \]

where \(m_{k,i}\in\{0, 1\}\) switches observation \(i\) at time \(k\) on or off, and \(\vect{x}_k(\vect{z})\) is whatever the method's dynamics make of the controls: the model run from \(\vect{x}_0\), with or without kicks \(\gvect{\eta}_k\). numpyro evaluates \(U(\vect{z}) = -\log p(\vect{z}, \vect{y})\), its potential energy. For Gaussian priors and likelihoods, \(U = \mathcal{J} + \text{const}\): the variational cost appears without being written. The analysis is the MAP, \(\vect{z}^\star = \mathrm{argmin}_{\vect{z}} U(\vect{z})\). The Laplace approximation \(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\), with \(\gmat{\Lambda}_{z \mid y}\) the Hessian of \(U\) at \(\vect{z}^\star\) (or its Gauss–Newton part), is the posterior spread.

Every prior here is Gaussian, and in the main models every one is sampled whitened (the readable 3D-Var, the low-rank model and the state formulation are kept in physical units for comparison): a standard-normal site \(\gvect{\xi}\) mapped through a square root, \(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\). This is the control-variable transform of operational 4D-Var, written as a numpyro reparameterisation. The posterior is unchanged; the optimiser sees an identity-conditioned prior instead of \(\gmat{\Sigma}_x^{-1}\), whose condition number here is about \(10^{7}\).

Notation

SymbolMeaningShapeUnits (ocean)
\(\vect{x}_k = \vect{x}(t_k)\)state at observation time \(k = 0, \ldots, T\), with \(D_x = 40\) sites\(\mathbb{R}^{D_x}\) (state)m (SSH) or s⁻¹ (PV)
\(\vect{y}_k\), \(\vect{m}_k\)observations and their mask\(\mathbb{R}^{D_y}\), \(\{0, 1\}^{D_y}\) (obs)m; —
\(\mat{H}\), \(\sigma_\varepsilon\)observation operator (here: pick every second site), observation error\(\mathbb{R}^{D_y \times D_x}\); \(\mathbb{R}\)—; m
\(\gvect{\mu}_x\), \(\gmat{\Sigma}_x\)background (prior mean) and its covariance\(\mathbb{R}^{D_x}\), \(\mathbb{R}^{D_x \times D_x}\)m, m²
\(\vect{z}\)the latent: all of a method's controls, whitened and flattened\(\mathbb{R}^{D_z}\) (latent)dimensionless
\(\gvect{\xi}\), \(\vect{z}_L\), \(\vect{w}\), \(\gvect{\omega}\)the parts of \(\vect{z}\): initial state, reduced coefficients, model errors, model-error coefficients; each standard normal\(\mathbb{R}^{D_x}\), \(\mathbb{R}^{N_\text{feat}}\), \(\mathbb{R}^{T D_x}\), \(\mathbb{R}^{N_\chi N_\Phi}\)dimensionless
\(\gvect{\eta}_k\), \(\gmat{\Sigma}_\eta = \gmat{\Sigma}_{\eta,t}\otimes\gmat{\Sigma}_{\eta,s}\)model error over interval \(k\); covariance of \(\op{vec}_r(\gvect{\eta}_{1:T})\), the errors stacked time-major\(\mathbb{R}^{D_x}\), \(\mathbb{R}^{T D_x \times T D_x}\)m per interval, (m per interval)²
\(\mat{L}\), \(N_\text{harm}\), \(N_\text{feat}\)features of \(\gmat{\Sigma}_x\) (\(\gmat{\Sigma}_x\approx \mat{L} \mat{L}^\top\)), number of harmonics, number of features \(N_\text{feat} = 2 N_\text{harm} + 1\)\(\mathbb{R}^{D_x \times N_\text{feat}}\), \(\mathbb{N}\), \(\mathbb{N}\)m, —, —
\(\ell_p\)periodic-kernel lengthscale, \(2\pi\ell/D_x\) for a correlation length \(\ell\) in sites\(\mathbb{R}\)radians
\(\gmat{\chi}\), \(\gmat{\Phi}\), \(\mat{W}\), \(\gmat{\Sigma}_{\omega,t}\otimes\gmat{\Sigma}_{\omega,s}\)temporal and spatial basis matrices (columns \(\gvect{\chi}_c\), \(\gvect{\varphi}_j\)), model-error coefficients, prior covariance of \(\op{vec}_r(\mat{W})\)\(\mathbb{R}^{T \times N_\chi}\), \(\mathbb{R}^{D_x \times N_\Phi}\), \(\mathbb{R}^{N_\chi \times N_\Phi}\), \(\mathbb{R}^{N_\chi N_\Phi \times N_\chi N_\Phi}\)—, m per interval, —
\(f_k\), \(f_{k \leftarrow 0}\), \(\mat{F}_{k \leftarrow 0}\)model over one interval; over the first \(k\) intervals; its tangent-linear model\(\mathbb{R}^{D_x} \to \mathbb{R}^{D_x}\); \(\mathbb{R}^{D_x \times D_x}\)—
\(\gmat{\Lambda}_{z \mid y}\), \(\mat{J}_r\)posterior precision (Hessian of \(U\) at the analysis); Jacobian of the whitened residuals, for \(N_\text{obs} = T D_y\) observations\(\mathbb{R}^{D_z \times D_z}\); \(\mathbb{R}^{N_\text{obs} \times D_z}\)dimensionless
\(U\), \(\mathcal{J}\)potential energy \(-\log p(\vect{z}, \vect{y})\); the variational cost\(\mathbb{R}\)dimensionless

The machinery: priors as structured objects

Three structures cover all eight methods. A stationary covariance on a periodic grid is circulant: the FFT diagonalises it, so its solve, log-determinant and square root are elementwise in Fourier space. A space–time covariance that separates is a Kronecker product: its square root is the Kronecker product of the factors' square roots, never a \(T D_x \times T D_x\) matrix. And a stationary covariance has a Mercer expansion: a few weighted Fourier modes reproduce most of it, which gives a reduced control and its prior in one step.

kernellib supplies the kernels. \(\gmat{\Sigma}_x\) uses its periodic kernel with \(\ell_p = 2\pi\ell/D_x\), the ring version of a Gaussian correlation of \(\ell = 2\) sites; its first column, put into gx.circulant, is the whole operator. The reduced methods use kl.PeriodicFeatures, the exact truncated Fourier series of that kernel: \(N_\text{harm}\) harmonics give \(N_\text{feat} = 2 N_\text{harm} + 1\) features \(\mat{L}\), already weighted so that \(\gmat{\Sigma}_x\approx \mat{L} \mat{L}^\top\).

from collections.abc import Callable

import einx
import gaussx as gx
import jax
import jax.numpy as jnp
import kernellib as kl
import lineax as lx
import numpyro
import numpyro.distributions as dist
from geonnax.basis import (
    fourier_basis_1d,
    fourier_eigenvalues_1d,
    legendre_polynomials,
)
from jaxtyping import Array, Bool, Float, Int
from numpyro.contrib.control_flow import scan

# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         K harmonics, R = 2K + 1 features (basis)

# --- Σ_x: a stationary covariance on the ring is circulant
# s_n = n, the site positions as kernel inputs
site_inputs = einx.id("n -> n 1", sites)                    # (N,) → (N, 1)
# today (kernellib)
# k(s, s′) = σ_x² exp(−2 sin²(π(s − s′)/N) / ℓ_p²), periodic with period N
prior_kernel = kl.Periodic(
    variance=prior_var, lengthscale=prior_length, period=num_sites
)
# c_n = k(s_n, s_0) (+ jitter at n = 0), the first column of Σ_x
first_column = prior_kernel(site_inputs, site_inputs[:1])[:, 0]  # (N, 1) → (N,)
first_column = first_column.at[0].add(jitter)               # (N,)
# today (gaussx)
# Σ_x = 𝔉⁻¹ diag(𝔉 c) 𝔉, diagonalised by the FFT
prior_cov = gx.circulant(                                   # (N, N) PSD op
    first_column, symmetric=True, tags=lx.positive_semidefinite_tag
)
# today (gaussx)
# Σ_x^{1/2} = 𝔉⁻¹ diag((𝔉 c)^{1/2}) 𝔉, still an FFT
prior_sqrt = gx.sqrt(prior_cov)                             # (N, N) op

# --- the same Σ_x in R features: exact truncated Fourier series of k
# today (kernellib)
# L = [√a₀, √a_j cos(2πjs/N), √a_j sin(2πjs/N)]_{j ≤ K}, so Σ_x ≈ L Lᵀ
feature_map = kl.PeriodicFeatures(num_harmonics).fit(prior_kernel, site_inputs)
prior_features = feature_map(site_inputs)                   # (N, 1) → (N, R)

# --- Σ_η: model error correlated in time and in space
# |t − t′|
time_lag = jnp.abs(einx.subtract("s, t -> s t", times, times))  # (T, T)
# today (lineax)
# Σ_η,t[t, t′] = exp(−|t − t′| / τ)
time_cov = lx.MatrixLinearOperator(                         # (T, T) PSD op
    jnp.exp(-time_lag / error_time_scale), lx.positive_semidefinite_tag
)
# today (kernellib)
# Σ_η,s: σ_η² × a periodic kernel of length ℓ_η
error_kernel = kl.Periodic(
    variance=error_std**2, lengthscale=error_length, period=num_sites
)
error_column = error_kernel(site_inputs, site_inputs[:1])[:, 0]  # (N,)
# today (gaussx)
space_cov = gx.circulant(                                   # (N, N) PSD op
    error_column, symmetric=True, tags=lx.positive_semidefinite_tag
)
# today (gaussx)
# Σ_η = Σ_η,t ⊗ Σ_η,s over the stacked errors (η₁, …, η_T)
error_cov = gx.Kronecker(time_cov, space_cov)               # (T·N, T·N) PSD op
# today (gaussx)
# Σ_η^{1/2} = Σ_η,t^{1/2} ⊗ Σ_η,s^{1/2}: never formed as a T·N square matrix
error_sqrt = gx.sqrt(error_cov)                             # (T·N, T·N) op

With \(N_\text{harm} = 4\) harmonics (9 features) \(\mat{L} \mat{L}^\top\) holds 85% of \(\gmat{\Sigma}_x\)'s variance; with \(N_\text{harm} = 8\) (17 features), 99%. For a domain without a closed-form series (a box, a sphere, a mesh), the same idea is a basis of Laplacian eigenfunctions weighted by the kernel's spectral density: geonnax evaluates the basis and kernellib the density, as the reduced weak-constraint model below does; kernellib also packages it as LaplaceEigenfunctionFeatures, and pyrox as the numpyro layer pyrox_nn.HSGPFeatures.

1 · 3D-Var

Intuition. At each observation time, correct one state: pull the forecast toward the observations, by an amount set by \(\gmat{\Sigma}_x\) against \(\sigma_\varepsilon^2\), and spread the correction to unobserved sites through \(\gmat{\Sigma}_x\)'s correlations. The model only carries the analysis forward; it plays no part in the fit.

\[ \begin{aligned} \mathcal{J}(\vect{x}) &= \frac{1}{2}\Vert \vect{x} - \vect{x}_{k \mid k-1}\Vert^2_{\gmat{\Sigma}_x^{-1}} + \frac{1}{2}\Vert \vect{y}_k - \mat{H} \vect{x}\Vert^2_{\sigma_\varepsilon^{-2}} \\ \vect{x} &= \vect{x}_{k \mid k-1} + \gmat{\Sigma}_x^{1/2}\gvect{\xi} \\ \mathcal{J}(\gvect{\xi}) &= \frac{1}{2}\Vert \gvect{\xi}\Vert^2 + \frac{1}{2}\Vert \vect{y}_k - \mat{H}(\vect{x}_{k \mid k-1} + \gmat{\Sigma}_x^{1/2}\gvect{\xi})\Vert^2_{\sigma_\varepsilon^{-2}} \end{aligned} \]
Usethree_dvarone time, one state
In
\(\vect{y}_k\)observations at one time; observation units
(obs,)
\(\vect{x}_{k \mid k-1}\) forecastthe previous analysis run forward; state units
(state,)
\(\gmat{\Sigma}_x^{1/2}\)square root of the background covariance (gaussx circulant); state units
(state, state)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
↓
\(\gvect{\xi}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\vect{x} = \vect{x}_{k \mid k-1} + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}, \sigma_\varepsilon^2\mat{I})\)
where
\(\gvect{\xi}\) whitened control, standard normal; (state,), dimensionless
\(\vect{x}\) the state being analysed; (state,), state units
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{x}^\star\) analysisstate units
(state,)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \gvect{\xi}\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
Linear and Gaussian: the Laplace posterior is the exact posterior, and Gauss–Newton converges in one step.
# Shapes: N = 40 sites (state); M = 20 observed sites (obs)


def three_dvar_readable(
    forecast: Float[Array, "state"],
    prior_cov: lx.AbstractLinearOperator,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "obs"] | None = None,
    obs_mask: Bool[Array, "obs"] | bool = True,
):
    # today (gaussx)
    # x ~ 𝒩(x_k|k−1, Σ_x)
    state = numpyro.sample(                                 # event (N,)
        "state", gx.MultivariateNormal(forecast, prior_cov)
    )
    # y ~ 𝒩(H x, σ_ε² I), masked where m = 0
    likelihood = dist.Normal(state[obs_index], obs_std)     # batch (M,)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)


def three_dvar(
    forecast: Float[Array, "state"],
    prior_sqrt: lx.AbstractLinearOperator,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "obs"] | None = None,
    obs_mask: Bool[Array, "obs"] | bool = True,
):
    # ξ ~ 𝒩(0, I)
    whitened = numpyro.sample(                              # event (N,)
        "whitened", dist.Normal(0.0, 1.0).expand(forecast.shape).to_event(1)
    )
    # x = x_k|k−1 + Σ_x^{1/2} ξ, the control-variable transform
    state = numpyro.deterministic("state", forecast + prior_sqrt.mv(whitened))  # (N,)
    # y ~ 𝒩(H x, σ_ε² I)
    likelihood = dist.Normal(state[obs_index], obs_std)     # batch (M,)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)

Both functions define the same posterior; on the first analysis they differ by at most 0.0014 at any site. The readable one, a gaussx MultivariateNormal site in physical units, took 1,952 L-BFGS iterations per analysis; the whitened one took 27. Cycled over the window, 3D-Var reaches a window error of 1.80 (background: 3.50).

2 · Reduced 3D-Var

Intuition. Correct the forecast only in a few smooth patterns. The same reduction as reduced 4D-Var, applied to one time: the increment is \(\mat{L} \vect{z}_L\), with \(N_\text{feat}\) coefficients instead of \(D_x\) values. It is cheap and well conditioned, but whatever the features cannot express is never corrected, and in a cycled system those uncorrected scales keep growing from one analysis to the next.

\[ \begin{aligned} \vect{x} &= \vect{x}_{k \mid k-1} + \mat{L} \vect{z}_L \\ \vect{z}_L &\sim \mathcal{N}(\vect{0}, \mat{I}_{N_\text{feat}}) \\ \mathcal{J}(\vect{z}_L) &= \frac{1}{2}\Vert \vect{z}_L\Vert^2 + \frac{1}{2}\Vert \vect{y}_k - \mat{H}(\vect{x}_{k \mid k-1} + \mat{L} \vect{z}_L)\Vert^2_{\sigma_\varepsilon^{-2}} \end{aligned} \]
Usereduced_three_dvarone time, a few features
In
\(\vect{y}_k\)observations at one time; observation units
(obs,)
\(\vect{x}_{k \mid k-1}\) forecastthe previous analysis run forward; state units
(state,)
\(\mat{L}\) featurestruncated Fourier features of the background kernel (kernellib PeriodicFeatures); state units
(state, basis)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
↓
\(\vect{z}_L\sim\mathcal{N}(\vect{0}, \mat{I}_{N_\text{feat}})\)
\(\vect{x} = \vect{x}_{k \mid k-1} + \mat{L} \vect{z}_L\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}, \sigma_\varepsilon^2\mat{I})\)
where
\(\vect{z}_L\) whitened coefficients, standard normal; (basis,), dimensionless
\(N_\text{feat}\) number of features, \(2 N_\text{harm} + 1\) for \(N_\text{harm}\) harmonics; \(\mat{I}_{N_\text{feat}}\) is the \(N_\text{feat}\times N_\text{feat}\) identity
\(\mat{L} \vect{z}_L\) the increment: Gaussian with covariance \(\mat{L} \mat{L}^\top\approx\gmat{\Sigma}_x\)
\(\vect{x}\) the state being analysed; (state,), state units
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{x}^\star\) analysisstate units
(state,)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \vect{z}_L\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
\(N_\text{feat}\) controls per analysis. Also linear and Gaussian, so the Laplace posterior is exact, but only within the span of the features.
# Shapes: N = 40 sites (state); M = 20 observed sites (obs);
#         R = 2K + 1 features (basis)


def reduced_three_dvar(
    forecast: Float[Array, "state"],
    features: Float[Array, "state basis"],
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "obs"] | None = None,
    obs_mask: Bool[Array, "obs"] | bool = True,
):
    # z_L ~ 𝒩(0, I_R)
    whitened = numpyro.sample(                              # event (R,)
        "whitened", dist.Normal(0.0, 1.0).expand([features.shape[1]]).to_event(1)
    )
    # δx = L z_L, so δx ~ 𝒩(0, L Lᵀ) ≈ 𝒩(0, Σ_x)
    increment = einx.dot("state basis, basis -> state", features, whitened)  # (N,)
    # x = x_k|k−1 + L z_L
    state = numpyro.deterministic("state", forecast + increment)  # (N,)
    # y ~ 𝒩(H x, σ_ε² I)
    likelihood = dist.Normal(state[obs_index], obs_std)     # batch (M,)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)
IncrementControls per analysisIterations per analysisRMSE windowRMSE end of window
\(N_\text{harm} = 4\), 9 features9152.824.57
\(N_\text{harm} = 8\), 17 features17222.394.05
full \(\gmat{\Sigma}_x\) (3D-Var)40271.803.10

Cycling makes the truncation costly: the small scales the features leave out are never corrected, the chaotic model amplifies them, and the window error is 2.82 with 9 features and 2.39 with 17, against 1.80 for full 3D-Var.

3 · Strong-constraint 4D-Var

Intuition. Trust the model completely. Then the trajectory is a function of its starting point, and the only unknown is \(\vect{x}_0\): every observation in the window pulls on it through the dynamics. In the program, the rollout is a numpyro.deterministic site, so the posterior lives on \(\gvect{\xi}\) alone.

\[ p(\gvect{\xi}\mid \vect{y}) \propto \mathcal{N}(\gvect{\xi}; \vect{0}, \mat{I})\prod_k\mathcal{N}(\vect{y}_k; \mat{H}f_{k \leftarrow 0}(\gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}), \sigma_\varepsilon^2\mat{I}). \]
Usestrong_4dvarone window, a trusted model
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(\gmat{\Sigma}_x^{1/2}\)square root of the background covariance (gaussx circulant); state units
(state, state)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
↓
\(\gvect{\xi}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\)
\(\vect{x}_k = f_{k \leftarrow 0}(\vect{x}_0)\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}_k, \sigma_\varepsilon^2\mat{I})\)
where
\(\gvect{\xi}\) whitened initial control, standard normal; (state,), dimensionless
\(\vect{x}_0\) initial state; (state,), state units
\(\vect{x}_k\) state at observation time \(k = 1, \ldots, T\); (state,), state units
\(f_{k \leftarrow 0}\) the model run over the first \(k\) intervals; a deterministic site, not a control
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{x}_0^\star\)state units
(state,)
\(\vect{x}_{1:T}^\star\)a model trajectory
(time, state)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \gvect{\xi}\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         M = 20 observed sites (obs)


def rollout(model_step, initial, errors=None, num_times=None):
    """x₁:T from x₀, with an optional kick η_k after every interval."""

    def interval(state, error):
        # user (model_step: one observation interval of the dynamics)
        # x_k = f_k(x_{k−1}) (+ η_k)
        state = model_step(state) if error is None else model_step(state) + error
        return state, state

    _, states = jax.lax.scan(interval, initial, errors, length=num_times)  # → (T, N)
    return states


def strong_4dvar(
    model_step: Callable,
    background: Float[Array, "state"],
    prior_sqrt: lx.AbstractLinearOperator,
    num_times: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    # ξ ~ 𝒩(0, I)
    whitened = numpyro.sample(                              # event (N,)
        "whitened", dist.Normal(0.0, 1.0).expand(background.shape).to_event(1)
    )
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial = numpyro.deterministic(                        # (N,)
        "initial", background + prior_sqrt.mv(whitened)
    )
    # x_k = f_{k←0}(x₀): deterministic, so the posterior lives on ξ alone
    states = numpyro.deterministic(                         # (T, N)
        "states", rollout(model_step, initial, num_times=num_times)
    )
    # y_k ~ 𝒩(H x_k, σ_ε² I) for every k
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)

Analysis: \(\mathcal{J}^\star = \) 509, \(2\mathcal{J}^\star/N_\text{obs} = \) 2.55 (expected about 1 for a correct model), window error 0.64. The Laplace spread of \(\vect{x}_0\) is 0.12, but its actual error is 0.85: the posterior is confident and wrong, because the model it trusts is biased. A ratio of error to spread far above 1 is the same alarm as \(2\mathcal{J}^\star/N_\text{obs}\gg 1\).

4 · Reduced strong-constraint 4D-Var

Intuition. Correct the initial state only in a few smooth patterns. The control shrinks from \(D_x\) values to \(N_\text{feat}\) coefficients; the price is that variance outside the features cannot be corrected. Here the features are the truncated Fourier series of the background kernel, so the reduced prior is a truncation of \(\gmat{\Sigma}_x\) itself.

\[ \begin{aligned} \vect{x}_0 &= \gvect{\mu}_x + \mat{L} \vect{z}_L \\ \vect{z}_L &\sim \mathcal{N}(\vect{0}, \mat{I}_{N_\text{feat}}) \\ \gmat{\Sigma}_x &\approx \mat{L} \mat{L}^\top \end{aligned} \]
Usereduced_strong_4dvarone window, a few features
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
\(\mat{L}\) featurestruncated Fourier features of the background kernel (kernellib PeriodicFeatures); state units
(state, basis)
↓
\(\vect{z}_L\sim\mathcal{N}(\vect{0}, \mat{I}_{N_\text{feat}})\)
\(\vect{x}_0 = \gvect{\mu}_x + \mat{L} \vect{z}_L\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H}f_{k \leftarrow 0}(\vect{x}_0), \sigma_\varepsilon^2\mat{I})\)
where
\(\vect{z}_L\) whitened coefficients, standard normal; (basis,), dimensionless
\(N_\text{feat}\) number of features, \(2 N_\text{harm} + 1\) for \(N_\text{harm}\) harmonics; \(\mat{I}_{N_\text{feat}}\) is the \(N_\text{feat}\times N_\text{feat}\) identity
\(\mat{L} \vect{z}_L\) the initial increment: Gaussian with covariance \(\mat{L} \mat{L}^\top\approx\gmat{\Sigma}_x\)
\(f_{k \leftarrow 0}\) the model run over the first \(k\) intervals
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{z}_L^\star\)whitened coefficients of the increment
(basis,)
\(\vect{x}_{0:T}^\star\)a model trajectory
(time+1, state)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \vect{z}_L\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
\(N_\text{feat}\) controls instead of \(D_x\). The variance outside the features is simply not correctable.
# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         M = 20 observed sites (obs); R = 2K + 1 features (basis)


def reduced_strong_4dvar(
    model_step: Callable,
    background: Float[Array, "state"],
    features: Float[Array, "state basis"],
    num_times: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    # z_L ~ 𝒩(0, I_R)
    whitened = numpyro.sample(                              # event (R,)
        "whitened", dist.Normal(0.0, 1.0).expand([features.shape[1]]).to_event(1)
    )
    # δx₀ = L z_L
    increment = einx.dot("state basis, basis -> state", features, whitened)  # (N,)
    # x₀ = μ_x + L z_L
    initial = numpyro.deterministic("initial", background + increment)  # (N,)
    states = numpyro.deterministic(                         # (T, N)
        "states", rollout(model_step, initial, num_times=num_times)
    )
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)


def lowrank_strong_4dvar(
    model_step: Callable,
    background: Float[Array, "state"],
    features: Float[Array, "state basis"],
    nugget: float,
    num_times: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    # today (gaussx)
    # Σ_x ≈ σ_nug² I + L Lᵀ: solves and log-determinants by Woodbury
    prior_cov = gx.low_rank_plus_diag(                      # (N, N) PSD op
        nugget * jnp.ones_like(background), features, psd=True
    )
    # today (gaussx)
    # x₀ ~ 𝒩(μ_x, σ_nug² I + L Lᵀ): every value is a control again
    initial = numpyro.sample(                               # event (N,)
        "initial", gx.MultivariateNormal(background, prior_cov)
    )
    states = numpyro.deterministic(                         # (T, N)
        "states", rollout(model_step, initial, num_times=num_times)
    )
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)
Prior on \(\vect{x}_0\)ControlsVariance of \(\gmat{\Sigma}_x\) kept\(\mathcal{J}^\star\)RMSE windowIterations, final stage
\(N_\text{harm} = 2\), 5 features557%37022.1738
\(N_\text{harm} = 4\), 9 features985%13221.1955
\(N_\text{harm} = 8\), 17 features1799%6470.78267
full \(\gmat{\Sigma}_x\) (strong 4D-Var)40100%5090.64568
9 features + nugget, low-rank MVN40100%377*0.49286

The second function keeps every value as a control but gives \(\vect{x}_0\) a low_rank_plus_diag prior: the 9 features plus a white nugget carrying the remaining 0.15 of variance per site. gaussx solves and log-determines it with the Woodbury identity. It is a different prior, so its \(\mathcal{J}^\star\) (*) is not comparable with the rows above; its window error is.

5 · Incremental 4D-Var

Intuition. Strong-constraint 4D-Var is hard because the model is nonlinear. Incremental 4D-Var takes that difficulty out of the inner solve: run the nonlinear model once from the current analysis, linearise it around that run, and solve the resulting linear-Gaussian problem exactly. Then rerun the model from the new analysis and repeat. It is Gauss–Newton on the strong-constraint cost: it targets the same MAP, and its Laplace spread uses the Gauss–Newton Hessian. In numpyro terms, the inner problem is the strong-constraint program with the rollout replaced by its tangent-linear approximation, which jax.linearize provides.

\[ \begin{aligned} \vect{x}_0^{(i)} &= \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}^{(i)} \\ \vect{x}_k &\approx \vect{x}_k^{(i)} + \mat{F}_{k \leftarrow 0}(\vect{x}_0 - \vect{x}_0^{(i)}) \\ \mathcal{J}_{\text{inner}}(\gvect{\xi}) &= \frac{1}{2}\Vert \gvect{\xi}\Vert^2 + \frac{1}{2}\sum_k\Vert \vect{y}_k - \mat{H} \vect{x}_k^{(i)} - \mat{H}\mat{F}_{k \leftarrow 0}\gmat{\Sigma}_x^{1/2}(\gvect{\xi} - \gvect{\xi}^{(i)})\Vert^2_{\sigma_\varepsilon^{-2}} \\ \gvect{\xi}^{(i+1)} &= \gvect{\xi}^{(i)} + \alpha (\gvect{\xi}^\star - \gvect{\xi}^{(i)}) \\ \alpha &\in \{1, \frac{1}{2}, \frac{1}{4}, \ldots\} \end{aligned} \]

The inner cost is quadratic in \(\gvect{\xi}\), so the linearised problem's posterior is exactly Gaussian and its Laplace approximation is exact; for the nonlinear problem it is the Gauss–Newton approximation at the analysis. The step length \(\alpha\) is halved until the nonlinear cost decreases: on this chaotic window, plain Gauss–Newton steps overshoot. Without the halving, the cost over the last four outer loops went 514.2 → 511.0 → 512.0 → 512.2 for the full increment and 1362 → 1413 → 1371 → 1442 with 9 features.

Useincremental_4dvarone window, outer and inner loops
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(\gmat{\Sigma}_x^{1/2}\)square root of the background covariance (gaussx circulant); state units
(state, state)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
outer loopsrelinearisations per window stage
()
↓
\(\vect{x}_0^{(i)} = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}^{(i)}\)
\(\vect{x}_k^{(i)} = f_{k \leftarrow 0}(\vect{x}_0^{(i)})\), \(\mat{F}_{k \leftarrow 0} = \mat{J}_{f_{k \leftarrow 0}}(\vect{x}_0^{(i)})\)
\(\gvect{\xi}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\vect{x}_k = \vect{x}_k^{(i)} + \mat{F}_{k \leftarrow 0}(\gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi} - \vect{x}_0^{(i)})\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}_k, \sigma_\varepsilon^2\mat{I})\)
\(\gvect{\xi}^{(i+1)} = \gvect{\xi}^{(i)} + \alpha(\gvect{\xi}^\star - \gvect{\xi}^{(i)})\)
where
\(\gvect{\xi}^{(i)}\), \(\vect{x}^{(i)}\) the reference at outer loop \(i\): current analysis and its nonlinear trajectory
\(\mat{F}_{k \leftarrow 0}\) tangent-linear model over the first \(k\) intervals, from jax.linearize
\(\gvect{\xi}^\star\) the inner (linear-Gaussian) program's MAP
\(\alpha\) step length: 1, then halved until the nonlinear cost decreases
\(\gvect{\xi}\) whitened initial control, standard normal; (state,), dimensionless
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{x}_0^\star\)state units
(state,)
\(\mathcal{J}^\star\)nonlinear cost at the analysis
()
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \gvect{\xi}\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
Each outer loop: one nonlinear run. Each inner iteration: one tangent-linear run and one adjoint run.
# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         M = 20 observed sites (obs); R = 2K + 1 features (basis);
#         D = N or R controls (latent)


def incremental_inner(
    reference_initial: Float[Array, "state"],
    reference_states: Float[Array, "time state"],
    tangent: Callable,
    initial_of: Callable,
    num_controls: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    """The inner problem: the strong-constraint program with the model linearised."""
    # z ~ 𝒩(0, I): ξ for the full increment, z_L for the reduced one
    whitened = numpyro.sample(                              # event (D,)
        "whitened", dist.Normal(0.0, 1.0).expand([num_controls]).to_event(1)
    )
    # x₀ = μ_x + Σ_x^{1/2} ξ, or μ_x + L z_L
    initial = numpyro.deterministic("initial", initial_of(whitened))  # (N,)
    # δx₀ = x₀ − x₀⁽ⁱ⁾, the departure from the outer loop's reference
    increment = initial - reference_initial                 # (N,)
    # x_k ≈ x_k⁽ⁱ⁾ + F_{k←0} δx₀: tangent-linear, so x is linear in z
    states = numpyro.deterministic(                         # (T, N)
        "states", reference_states + tangent(increment)
    )
    # y_k ~ 𝒩(H x_k, σ_ε² I): linear-Gaussian, so the posterior is Gaussian
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)


def nonlinear_cost(model_step, initial_of, whitened, num_times, obs_index, obs_std,
                   obs, obs_mask=True):
    """𝒥(z) = ½‖z‖² + ½ Σ_k ‖y_k − H f_{k←0}(x₀(z))‖²/σ_ε², the outer-loop cost."""
    states = rollout(model_step, initial_of(whitened), num_times=num_times)  # (T, N)
    # r_k = (y_k − H x_k) / σ_ε, zero where m = 0
    residual = (obs - states[:, obs_index]) / obs_std       # (T, M)
    residual = jnp.where(obs_mask, residual, 0.0)           # (T, M)
    return 0.5 * jnp.sum(whitened**2) + 0.5 * jnp.sum(residual**2)  # → ()


def incremental_4dvar(
    model_step: Callable,
    initial_of: Callable,
    num_controls: int,
    num_times: int,
    num_outer: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"],
    obs_mask: Bool[Array, "time obs"] | bool = True,
    init_values: dict | None = None,
):
    """Gauss–Newton: relinearise, solve the linear-Gaussian inner program, repeat."""
    values = init_values or {"whitened": jnp.zeros(num_controls)}
    data = (num_times, obs_index, obs_std, obs, obs_mask)
    cost = nonlinear_cost(model_step, initial_of, values["whitened"], *data)
    inner_steps = 0
    for _ in range(num_outer):
        # x₀⁽ⁱ⁾, the current analysis
        reference_initial = initial_of(values["whitened"])  # (N,)

        def trajectory(initial):
            return rollout(model_step, initial, num_times=num_times)  # (N,) → (T, N)

        # x_k⁽ⁱ⁾ = f_{k←0}(x₀⁽ⁱ⁾) and δx₀ ↦ F_{k←0} δx₀: one nonlinear run per outer loop
        reference_states, tangent = jax.linearize(trajectory, reference_initial)
        inner, solution, potential_fn, unravel = fit_map(
            incremental_inner,
            values,
            reference_initial,
            reference_states,
            tangent,
            initial_of,
            num_controls,
            obs_index,
            obs_std,
            obs=obs,
            obs_mask=obs_mask,
        )
        inner_steps += int(solution.stats["num_steps"])
        # Δz, the Gauss–Newton step
        step = inner["whitened"] - values["whitened"]       # (D,)
        # backtrack: halve Δz until the nonlinear 𝒥 decreases
        for _ in range(6):
            trial = values["whitened"] + step               # (D,)
            trial_cost = nonlinear_cost(model_step, initial_of, trial, *data)
            if trial_cost < cost:
                values, cost = {"whitened": trial}, trial_cost
                break
            step = 0.5 * step                               # (D,)
    return values, cost, inner_steps


# full increment: ξ ∈ ℝᴺ, x₀ = μ_x + Σ_x^{1/2} ξ
def full_initial(whitened):
    return background + prior_sqrt.mv(whitened)             # (N,) → (N,)


# reduced increment: z_L ∈ ℝᴿ, x₀ = μ_x + L z_L
def reduced_initial(whitened):
    return background + einx.dot(                           # (R,) → (N,)
        "state basis, basis -> state", prior_features, whitened
    )
SolverOuter loopsInner iterations\(\mathcal{J}^\star\)RMSE window
incremental, quasi-static225,530509.40.64
incremental from the background, no schedule85,2849710.94
strong 4D-Var, L-BFGS on the nonlinear cost——5090.64

With the quasi-static schedule (two outer loops per window stage, four on the full window), incremental 4D-Var reaches the same minimum as the direct solve; the cost over the last four outer loops was 514.2 → 511.0 → 509.5 → 509.4. From the background in one go, eight outer loops had only brought \(\mathcal{J}\) down to 971. Each outer loop costs one nonlinear run; every inner iteration costs only a tangent-linear run and its adjoint, which is the point of the method at scale.

6 · Reduced incremental 4D-Var

Intuition. The same outer loop, with the inner problem restricted to the features: the increment is \(\mat{L}(\vect{z}_L - \vect{z}_L^{(i)})\), so each inner problem has only \(N_\text{feat}\) unknowns and an \(N_\text{feat}\times N_\text{feat}\) Hessian. The outer loop still runs the full nonlinear model. Reduced-order incremental 4D-Var in the literature usually takes the basis from EOFs of a model run; here kernellib's features take that place.

\[ \begin{aligned} \vect{x}_0 &= \gvect{\mu}_x + \mat{L} \vect{z}_L \\ \vect{x}_k &\approx \vect{x}_k^{(i)} + \mat{F}_{k \leftarrow 0}\mat{L}(\vect{z}_L - \vect{z}_L^{(i)}) \\ \mathcal{J}_{\text{inner}}(\vect{z}_L) &= \frac{1}{2}\Vert \vect{z}_L\Vert^2 + \frac{1}{2}\sum_k\Vert \vect{y}_k - \mat{H} \vect{x}_k^{(i)} - \mat{H}\mat{F}_{k \leftarrow 0}\mat{L}(\vect{z}_L - \vect{z}_L^{(i)})\Vert^2_{\sigma_\varepsilon^{-2}} \end{aligned} \]
Useincremental_4dvar(reduced_initial)one window, increments in \(N_\text{feat}\) features
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
\(\mat{L}\) featurestruncated Fourier features of the background kernel (kernellib PeriodicFeatures); state units
(state, basis)
outer loopsrelinearisations per window stage
()
↓
\(\vect{x}_0^{(i)} = \gvect{\mu}_x + \mat{L} \vect{z}_L^{(i)}\)
\(\vect{x}_k^{(i)} = f_{k \leftarrow 0}(\vect{x}_0^{(i)})\), \(\mat{F}_{k \leftarrow 0}\) at \(\vect{x}_0^{(i)}\)
\(\vect{z}_L\sim\mathcal{N}(\vect{0}, \mat{I}_{N_\text{feat}})\)
\(\vect{x}_k = \vect{x}_k^{(i)} + \mat{F}_{k \leftarrow 0}\mat{L}(\vect{z}_L - \vect{z}_L^{(i)})\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}_k, \sigma_\varepsilon^2\mat{I})\)
\(\vect{z}_L^{(i+1)} = \vect{z}_L^{(i)} + \alpha(\vect{z}_L^\star - \vect{z}_L^{(i)})\)
where
\(\vect{z}_L^{(i)}\), \(\vect{x}^{(i)}\) the reference coefficients and their nonlinear trajectory
\(\vect{z}_L\) whitened coefficients, standard normal; (basis,), dimensionless
\(\mat{F}_{k \leftarrow 0}\) tangent-linear model over the first \(k\) intervals
\(\alpha\) step length, halved until the nonlinear cost decreases
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{z}_L^\star\)whitened coefficients of the analysis increment
(basis,)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = \vect{z}_L\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
The inner Hessian is \(N_\text{feat} \times N_\text{feat}\): small enough to factor directly.

The snippet above covers both: reduced_initial replaces full_initial, and nothing else changes.

FeaturesOuter loopsInner iterations\(\mathcal{J}^\star\) incremental\(\mathcal{J}^\star\) direct (section 4)RMSE window
\(N_\text{harm} = 4\), 9 features22849132913221.20
\(N_\text{harm} = 8\), 17 features222,8376486470.78

7 · Weak-constraint 4D-Var

Intuition. Stop trusting the model completely: after every interval the trajectory may jump by \(\gvect{\eta}_k\), at a price set by \(\gmat{\Sigma}_\eta\). The controls become the whole trajectory's worth of values. With \(\gmat{\Sigma}_\eta = \gmat{\Sigma}_{\eta,t}\otimes\gmat{\Sigma}_{\eta,s}\) the jumps can be correlated in time (a persistent error) and in space (a smooth one), and the whitened errors are mapped through the Kronecker square root.

\[ \begin{aligned} \vect{x}_k &= f_k(\vect{x}_{k-1}) + \gvect{\eta}_k \\ \op{vec}_r(\gvect{\eta}_{1:T}) &\sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_{\eta,t}\otimes\gmat{\Sigma}_{\eta,s}) \\ \mathcal{J} &= \frac{1}{2}\Vert \gvect{\xi}\Vert^2 + \frac{1}{2}\Vert \vect{w}\Vert^2 + \frac{1}{2}\sum_k\Vert \vect{y}_k - \mat{H} \vect{x}_k\Vert^2_{\sigma_\varepsilon^{-2}} \end{aligned} \]
Useweak_4dvarone window, an imperfect model
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(\gmat{\Sigma}_x^{1/2}\)square root of the background covariance (gaussx circulant); state units
(state, state)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
\(\gmat{\Sigma}_\eta^{1/2} = (\gmat{\Sigma}_{\eta,t}\otimes\gmat{\Sigma}_{\eta,s})^{1/2}\)square root of the model-error covariance (gaussx Kronecker); state units per interval
(time·state, time·state)
↓
\(\gvect{\xi}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\)
\(\vect{w}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\op{vec}_r(\gvect{\eta}_{1:T}) = \gmat{\Sigma}_\eta^{1/2}\vect{w}\)
\(\vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k\)
\(\vect{y}_k\sim\mathcal{N}(\mat{H} \vect{x}_k, \sigma_\varepsilon^2\mat{I})\)
where
\(\gvect{\xi}\) whitened initial control, standard normal; (state,), dimensionless
\(\vect{w}\) whitened model errors, standard normal; (time·state,), dimensionless
\(\gvect{\eta}_{1:T} = (\gvect{\eta}_1, \ldots, \gvect{\eta}_T)\) model error added after each interval; (time, state), state units per interval; \(\op{vec}_r\) stacks it time-major, as the code's (t n)
\(\gmat{\Sigma}_{\eta,t}\), \(\gmat{\Sigma}_{\eta,s}\) time and space factors of the model-error covariance; \(\otimes\) is the Kronecker product
\(\vect{x}_k\) state at observation time \(k = 1, \ldots, T\); (state,), state units
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\vect{x}_{0:T}^\star\)no longer a model trajectory
(time+1, state)
\(\gvect{\eta}_{1:T}^\star\) model errorsstate units per interval
(time, state)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = (\gvect{\xi}, \vect{w})\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         M = 20 observed sites (obs)


def weak_4dvar(
    model_step: Callable,
    background: Float[Array, "state"],
    prior_sqrt: lx.AbstractLinearOperator,
    error_sqrt: lx.AbstractLinearOperator,
    num_times: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    num_state = background.shape[0]
    # ξ ~ 𝒩(0, I)
    whitened = numpyro.sample(                              # event (N,)
        "whitened", dist.Normal(0.0, 1.0).expand([num_state]).to_event(1)
    )
    # x₀ = μ_x + Σ_x^{1/2} ξ
    initial = numpyro.deterministic(                        # (N,)
        "initial", background + prior_sqrt.mv(whitened)
    )
    # w ~ 𝒩(0, I_{T·N})
    whitened_errors = numpyro.sample(                       # event (T·N,)
        "whitened_errors",
        dist.Normal(0.0, 1.0).expand([num_times * num_state]).to_event(1),
    )
    # (η₁, …, η_T) = (Σ_η,t ⊗ Σ_η,s)^{1/2} w
    stacked_errors = error_sqrt.mv(whitened_errors)         # (T·N,)
    errors = einx.id("(t n) -> t n", stacked_errors, t=num_times)  # (T·N,) → (T, N)
    numpyro.deterministic("errors", errors)
    # x_k = f_k(x_{k−1}) + η_k
    states = numpyro.deterministic(                         # (T, N)
        "states", rollout(model_step, initial, errors)
    )
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)


def weak_4dvar_states(
    model_step: Callable,
    background: Float[Array, "state"],
    prior_sqrt: lx.AbstractLinearOperator,
    error_std: float,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"],
):
    whitened = numpyro.sample(                              # event (N,)
        "whitened", dist.Normal(0.0, 1.0).expand(background.shape).to_event(1)
    )
    initial = numpyro.deterministic(                        # (N,)
        "initial", background + prior_sqrt.mv(whitened)
    )

    def transition(previous, obs_k):
        # x_k ~ 𝒩(f_k(x_{k−1}), σ_η² I): every state is a control
        state = numpyro.sample(                             # event (N,)
            "state", dist.Normal(model_step(previous), error_std).to_event(1)
        )
        # y_k ~ 𝒩(H x_k, σ_ε² I)
        likelihood = dist.Normal(state[obs_index], obs_std)  # batch (M,)
        numpyro.sample("obs", likelihood.to_event(1), obs=obs_k)
        return state, None

    # today (numpyro)
    scan(transition, initial, obs)
Model-error prior\(\mathcal{J}^\star\)\(2\mathcal{J}^\star/N_\text{obs}\)RMSE windowMean \(\eta\) as \(\Delta F\)Iterations, final stage
white, \(\sigma_\eta = 0.1\)2441.220.280.18596
white, \(\sigma_\eta = 0.3\)1460.730.270.40678
Kronecker, \(\sigma_\eta = 0.3\), \(\tau = 5\) intervals, periodic kernel of length 2 sites1540.770.260.83941

The time-correlated prior fits the window equally well, but attributes far more of the misfit to a persistent error (the mean kick corresponds to \(\Delta F\) = 0.83 instead of 0.40; truth 1.0). The second function is the state formulation as a numpyro scan: each \(\vect{x}_k\) is a sample site with a Gaussian transition. With \(\sigma_\eta = 0.3\) it converged to the same minimum as the forcing form (\(\mathcal{J}^\star\) = 146.4) in 133 iterations from the background trajectory, without the quasi-static schedule.

8 · Reduced weak-constraint 4D-Var

Intuition. If the model's error has a shape you can name (a bias, a slow drift, a large-scale pattern), estimate the few numbers of that shape instead of a kick at every point and time. A temporal basis \(\chi_c(t)\) (Legendre polynomials over the window) and a spatial basis \(\varphi_j(s)\) carry a small coefficient matrix \(\mat{W}\) with a Kronecker prior. Below, \(\gmat{\chi}\in\mathbb{R}^{T\times N_\chi}\) and \(\gmat{\Phi}\in\mathbb{R}^{D_x \times N_\Phi}\) are the basis matrices, and \(\op{vec}_r\) stacks a matrix row by row, as the code does.

\[ \begin{aligned} \eta(s_n, t_k) &= \sum_{c=1}^{N_\chi}\sum_{j=1}^{N_\Phi}\chi_c(t_k) W_{c,j} \varphi_j(s_n) \\ \op{vec}_r(\mat{W}) &\sim \mathcal{N}(\vect{0}, \gmat{\Sigma}_{\omega,t}\otimes\gmat{\Sigma}_{\omega,s}) \\ \gmat{\Sigma}_\eta &= (\gmat{\chi}\otimes\gmat{\Phi})(\gmat{\Sigma}_{\omega,t}\otimes\gmat{\Sigma}_{\omega,s})(\gmat{\chi}\otimes\gmat{\Phi})^\top \end{aligned} \]
Usereduced_weak_4dvarone window, a structured model error
In
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
\(\gvect{\mu}_x\) backgroundstate units
(state,)
\(\gmat{\Sigma}_x^{1/2}\)square root of the background covariance (gaussx circulant); state units
(state, state)
\(f_k\) model stepone observation interval
(state,) → (state,)
\(\mat{H}\) observation operatorpicks the observed sites (obs_index)
(state,) → (obs,)
\(\sigma_\varepsilon\) observation errorstandard deviation; observation units
()
\(\varphi_j(s)\), \(\chi_c(t)\)spatial (geonnax periodic Fourier) and temporal (geonnax Legendre) bases
(state, basis), (time, temporal)
\((\gmat{\Sigma}_{\omega,t}\otimes\gmat{\Sigma}_{\omega,s})^{1/2}\)square root of the coefficient prior covariance (gaussx Kronecker of diagonals); \(\gmat{\Sigma}_{\omega,s}\) from a kernellib RBF spectral density
(temporal·basis, temporal·basis)
↓
\(\gvect{\xi}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\vect{x}_0 = \gvect{\mu}_x + \gmat{\Sigma}_x^{1/2}\gvect{\xi}\)
\(\gvect{\omega}\sim\mathcal{N}(\vect{0}, \mat{I})\)
\(\op{vec}_r(\mat{W}) = (\gmat{\Sigma}_{\omega,t}\otimes\gmat{\Sigma}_{\omega,s})^{1/2}\gvect{\omega}\)
\(\eta(s_n, t_k) = \sum_{c,j}\chi_c(t_k) W_{c,j} \varphi_j(s_n)\)
\(\vect{x}_k = f_k(\vect{x}_{k-1}) + \gvect{\eta}_k\), with \(\gvect{\eta}_k = (\eta(s_n, t_k))_n\)
where
\(\gvect{\xi}\) whitened initial control, standard normal; (state,), dimensionless
\(\gvect{\omega}\) whitened coefficients, standard normal; (temporal·basis,), dimensionless
\(\op{vec}_r\) stacks a matrix row by row (time-major), matching the code's (c p) layout
\(\mat{W}\) model-error coefficients, one row per temporal mode \(c\), one column per spatial mode \(j\); (temporal, basis), state units per interval
\(\gmat{\Sigma}_{\omega,t}\), \(\gmat{\Sigma}_{\omega,s}\) diagonal prior variances of the temporal and spatial coefficients
\(s_n\), \(t_k\) position of site \(n\); time of observation \(k\)
\(\eta(s_n, t_k)\) model error at site \(n\) after interval \(k\); state units per interval
\(\vect{x}_k\) state at observation time \(k = 1, \ldots, T\); (state,), state units
\(\mathcal{N}(\gvect{\mu}, \gmat{\Sigma})\) Gaussian with mean \(\gvect{\mu}\) and covariance \(\gmat{\Sigma}\); \(\mat{I}\) is the identity
Out
\(\mat{W}^\star\)the model-error pattern, e.g. a bias
(temporal, basis)
\(\vect{x}_{0:T}^\star\)trajectory with the corrected model
(time+1, state)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z} = (\gvect{\xi}, \gvect{\omega})\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
# Shapes: N = 40 sites (state); T = 20 observation times (time);
#         M = 20 observed sites (obs); P spatial modes (basis);
#         C temporal modes (temporal)

# today (geonnax)
# χ_c(t): Legendre polynomials 1, t, … on the window mapped to [−1, 1]
window_time = jnp.linspace(-1.0, 1.0, num_times)            # (T,)
temporal_basis = legendre_polynomials(window_time, num_temporal - 1)  # (T,) → (T, C)
# today (geonnax)
# φ_j(s): 1/√(2b), cos(kπs/b)/√b, sin(kπs/b)/√b, orthonormal on [−b, b]
spatial_basis = fourier_basis_1d(                           # (N, P)
    positions, num_spatial, half_width, boundary="periodic"
)
# today (geonnax)
# κ_j² = (kπ / b)², the Laplacian eigenvalue of each mode
eigvals = fourier_eigenvalues_1d(                           # (P,)
    num_spatial, half_width, boundary="periodic", dtype=positions.dtype
)
# today (kernellib)
# Σ_ω,s = diag(S_k(κ_j)): the error kernel's spectral density at each mode
error_rbf = kl.RBF(variance=error_coeff_var, lengthscale=error_coeff_length)
frequencies = einx.id("p -> p 1", jnp.sqrt(eigvals))        # (P,) → (P, 1)
error_spectrum = error_rbf.spectral_density(frequencies)    # (P, 1) → (P,)
# today (gaussx)
# vec_r W ~ 𝒩(0, Σ_ω,t ⊗ Σ_ω,s)
coeff_cov = gx.Kronecker(                                   # (C·P, C·P) PSD op
    lx.DiagonalLinearOperator(temporal_var), lx.DiagonalLinearOperator(error_spectrum)
)
# today (gaussx)
coeff_sqrt = gx.sqrt(coeff_cov)                             # (C·P, C·P) op


def reduced_weak_4dvar(
    model_step: Callable,
    background: Float[Array, "state"],
    prior_sqrt: lx.AbstractLinearOperator,
    spatial_basis: Float[Array, "state basis"],
    temporal_basis: Float[Array, "time temporal"],
    coeff_sqrt: lx.AbstractLinearOperator,
    num_times: int,
    obs_index: Int[Array, "obs"],
    obs_std: float,
    obs: Float[Array, "time obs"] | None = None,
    obs_mask: Bool[Array, "time obs"] | bool = True,
):
    num_temporal = temporal_basis.shape[1]
    num_spatial = spatial_basis.shape[1]
    whitened = numpyro.sample(                              # event (N,)
        "whitened", dist.Normal(0.0, 1.0).expand(background.shape).to_event(1)
    )
    initial = numpyro.deterministic(                        # (N,)
        "initial", background + prior_sqrt.mv(whitened)
    )
    # ω ~ 𝒩(0, I_{C·P})
    whitened_coeffs = numpyro.sample(                       # event (C·P,)
        "whitened_coeffs",
        dist.Normal(0.0, 1.0).expand([num_temporal * num_spatial]).to_event(1),
    )
    # vec_r W = (Σ_ω,t ⊗ Σ_ω,s)^{1/2} ω
    stacked_coeffs = coeff_sqrt.mv(whitened_coeffs)         # (C·P,)
    coeffs = einx.id("(c p) -> c p", stacked_coeffs, c=num_temporal)  # → (C, P)
    numpyro.deterministic("coeffs", coeffs)
    # η(s_n, t_k) = Σ_c Σ_j χ_c(t_k) W_cj φ_j(s_n)
    # (T, C), (C, P), (N, P) → (T, N)
    errors = einx.dot(
        "t c, c p, n p -> t n", temporal_basis, coeffs, spatial_basis
    )
    numpyro.deterministic("errors", errors)
    states = numpyro.deterministic(                         # (T, N)
        "states", rollout(model_step, initial, errors)
    )
    likelihood = dist.Normal(states[:, obs_index], obs_std)  # batch (T, M)
    numpyro.sample("obs", likelihood.mask(obs_mask).to_event(1), obs=obs)
BasesExtra controls\(2\mathcal{J}^\star/N_\text{obs}\)RMSE windowRecovered \(\Delta F\)Forecast RMSE, with / without \(\eta\)
constant in time and space (one bias)10.980.121.00 ± 0.041.47 / 2.40
Legendre {1, t} × 9 Fourier modes180.970.120.982.51 / 2.78

One coefficient recovers the bias to within its Laplace standard deviation, and the forecast with it (1.47) beats a forecast from the true state with the uncorrected model (1.81). The richer basis fits the window as well but spends its freedom on a time trend; extrapolating that trend past the window hurts the forecast.

Inference: MAP, quasi-static windows, Laplace

One routine fits all eight models (for the incremental ones, each inner problem). numpyro's initialize_model returns the potential energy over the flattened latent sites; L-BFGS minimises it. The mask in the likelihood does double duty: it marks missing observations, and it lets the window grow a few observation times at a time.

import jax
import optimistix as optx
from jax.flatten_util import ravel_pytree
from numpyro.infer.util import init_to_value, initialize_model

# Shapes: D latent values in total (latent); T = 20 observation times (time)


def fit_map(model, init_values, *args, **kwargs):
    """The variational analysis: the mode of the model's posterior."""
    # today (numpyro)
    # U(z) = −log p(z, y), the potential energy: 𝒥(z) + const
    info = initialize_model(
        jax.random.key(0),
        model,
        model_args=args,
        model_kwargs=kwargs,
        init_strategy=init_to_value(values=init_values),
    )
    # z: every latent site, flattened
    flat_init, unravel = ravel_pytree(info.param_info.z)    # (D,)

    def potential(flat, _):
        return info.potential_fn(unravel(flat))             # (D,) → ()

    # not pinned (optimistix)
    # z⋆ = argmin U(z)
    solution = optx.minimise(
        potential,
        optx.LBFGS(rtol=1e-9, atol=1e-6),
        flat_init,
        max_steps=20_000,
        throw=False,
    )
    mode = unravel(solution.value)
    return mode, solution, info.potential_fn, unravel


def quasi_static(model, init_values, *args, num_times, stride=2, **kwargs):
    """Lengthen the window a few observation times at a time, warm-started."""
    values = init_values
    for window in range(stride, num_times + 1, stride):
        # m_k = 1 for k < window, so later observations are switched off
        in_window = jnp.arange(num_times) < window          # (T,)
        obs_mask = einx.id("t -> t 1", in_window)           # (T,) → (T, 1)
        values, solution, potential_fn, unravel = fit_map(
            model, values, *args, obs_mask=obs_mask, **kwargs
        )
    return values, solution, potential_fn, unravel


def laplace(solution, potential_fn, unravel):
    """𝒩(z⋆, Λ_z|y⁻¹): the posterior spread at the analysis."""
    # Λ_z|y = ∇² U(z⋆), the posterior precision, dense: for small D only
    hessian = jax.hessian(                                  # (D, D)
        lambda flat: potential_fn(unravel(flat))
    )(solution.value)
    # today (lineax)
    precision = lx.MatrixLinearOperator(                    # (D, D) PSD op
        hessian, lx.positive_semidefinite_tag
    )
    # today (gaussx)
    # p(z | y) ≈ 𝒩(z⋆, Λ_z|y⁻¹), stored by its precision Λ_z|y
    return gx.MultivariateNormalPrecision(solution.value, precision)  # event (D,)

Why quasi-static. A 20-interval Lorenz-96 window spans several error-doubling times, and the strong-constraint cost has many minima. From six starting points, L-BFGS found 6 different minima, with \(\mathcal{J}^\star\) from 1,455 to 4,793 and window errors from 1.50 to 2.56; on the first 10 intervals all six reached the same minimum (\(\mathcal{J}^\star = \) 140.5). Lengthening the window two observation times at a time, warm-starting each stage, found a deeper minimum than any of the six (\(\mathcal{J}^\star = \) 509, window error 0.64). It is a heuristic, not a guarantee. Every 4D-Var analysis on this page uses it, except the state formulation (which converged without it), the no-schedule incremental row and the multistart experiment itself.

Laplace at scale. The dense Hessian above is fine for 40 controls and affordable for 840. At ocean size, use the Gauss–Newton precision \(\mat{I} + \mat{J}_r^\top\mat{J}_r\), which gaussx keeps as a low-rank update of the identity: Woodbury solves and log-determinants of the size of the observation vector. Drawing samples from it still factors the precision densely in gaussx today. On the weak-constraint analysis (840 controls, 400 observations) the dense Hessian took about 10 s; the Gauss–Newton marginal standard deviations were within 1% of it at every control.

# Shapes: D latent values (latent); p = T·M observations (obs)


def whitened_residuals(flat):
    """r(z) = (y − H x(z)) / σ_ε for every observation in the window."""
    # user (the model's deterministic states at the latent values z)
    states = states_of(unravel(flat))                       # (D,) → (T, N)
    # r = (y − H x) / σ_ε
    residual = (obs - states[:, obs_index]) / obs_std       # (T, M)
    return einx.id("t m -> (t m)", residual)                # → (p,)


# J_r = ∂r/∂z at the analysis
residual_jacobian = jax.jacfwd(whitened_residuals)(solution.value)  # (p, D)
# today (lineax)
latent_struct = jax.eval_shape(lambda: solution.value)
identity = lx.IdentityLinearOperator(latent_struct)         # (D, D) op
# today (gaussx)
# Λ_z|y^GN = I + J_rᵀ J_r, a LowRankUpdate on I: Woodbury solves of size p
precision = gx.gauss_newton_precision(                      # (D, D) PSD op
    residual_jacobian, base=identity
)
# today (gaussx)
# p(z | y) ≈ 𝒩(z⋆, (Λ_z|y^GN)⁻¹)
posterior = gx.MultivariateNormalPrecision(solution.value, precision)  # event (D,)
# today (gaussx)
# draws from it: an ensemble of analyses (a dense Cholesky of Λ_z|y^GN today)
members = posterior.sample(jax.random.key(1), (16,))        # → (16, D)
Usefit_map → laplaceany of the eight models
In
numpyro modelprior sites, deterministic rollout, masked likelihood
callable
initial valuesper latent site, e.g. zeros
dict
\(\vect{y}_{1:T}\) observationsobserved-field units (m for SSH)
(time, obs)
\(\vect{m}_{1:T}\) mask1 where observed and in the window
(time, obs)
↓
\(U(\vect{z}) = -\log p(\vect{z}, \vect{y})\)
\(U(\vect{z}) = \mathcal{J}(\vect{z}) + \text{const}\)
\(\vect{z}^\star = \mathrm{argmin}_{\vect{z}} U(\vect{z})\)
\(\gmat{\Lambda}_{z \mid y} = \nabla^2 U(\vect{z}^\star)\)
\(\gmat{\Lambda}_{z \mid y}^\text{GN} = \mat{I} + \mat{J}_r^\top\mat{J}_r\)
where
\(\vect{z}\) all latent sites of the model, whitened and flattened; (latent,)
\(p(\vect{z}, \vect{y})\) the model's joint density of controls and observations
\(U\) numpyro's potential energy; \(\mathcal{J}\) the variational cost
\(\vect{z}^\star\) the analysis (MAP)
\(\gmat{\Lambda}_{z \mid y}\) posterior precision: the Hessian of \(U\) at \(\vect{z}^\star\); (latent, latent)
\(\mat{J}_r\) Jacobian of the whitened residuals \(\vect{r}(\vect{z}) = (\vect{y} - \mat{H} \vect{x}(\vect{z}))/\sigma_\varepsilon\); (time·obs, latent)
Out
\(\vect{z}^\star\)the variational analysis, whitened
(latent,)
\(\mathcal{N}(\vect{z}^\star, \gmat{\Lambda}_{z \mid y}^{-1})\)Laplace posterior over the controls \(\vect{z}\); \(\gmat{\Lambda}_{z \mid y}\) is its precision
event (latent,)
The cost is never written by hand: numpyro derives it from the model.

The eight side by side

background (no assimilation)3.503D-Var, cycled1.80reduced 3D-Var, 17 features2.39strong 4D-Var0.64reduced strong, 9 features1.19reduced strong, 17 features0.78weak 4D-Var, white Σ_η0.27weak 4D-Var, Kronecker Σ_η0.26reduced weak, one bias0.120123
RMSE over the 20-interval window, Lorenz-96 with a biased model (truth \(F = 8\), model \(F = 7\)). Teal: the model is trusted; amber: model error is a control.
MethodControlsPrior objectRMSE window\(2\mathcal{J}^\star/N_\text{obs}\)Use when
3D-Var\(\gvect{\xi}\): 40 per timegx.circulant square root1.80—cheap cycling, no adjoint
Reduced 3D-Var\(\vect{z}_L\): 9 or 17 per timekl.PeriodicFeatures2.82 / 2.39—the increment really is smooth, or as a cheap first guess
Strong 4D-Var\(\gvect{\xi}\): 40gx.circulant square root0.642.55the model is good over the window
Reduced strong 4D-Var\(\vect{z}_L\): 9 or 17kl.PeriodicFeatures1.19 / 0.786.61 / 3.24a few patterns explain the increment
Incremental 4D-Var\(\gvect{\xi}\): 40, in linear inner problemsgx.circulant square root, jax.linearize0.642.55the adjoint is cheap and the model nearly linear between relinearisations
Reduced incremental 4D-Var\(\vect{z}_L\): 9 or 17, in linear inner problemskl.PeriodicFeatures, jax.linearize1.20 / 0.786.65 / 3.24as reduced strong, with a cheap inner solve
Weak 4D-Var\((\gvect{\xi}, \vect{w})\): 840gx.Kronecker square root0.270.73the model error is unknown in shape
Reduced weak 4D-Var\((\gvect{\xi}, \gvect{\omega})\): 41 or 58geonnax basis, kernellib spectrum, gx.Kronecker0.120.98the model error has a known shape

At ocean scale

ObjectLorenz-96 hereGulf Stream QG window
\(\gmat{\Sigma}_x\)kl.Periodic column into gx.circulanta Kronecker of two 1D kernellib Grams on the 128 × 128 grid; a Matérn SPDE precision (gx.spde_precision_grid) with MultivariateNormalPrecision; kl.DivergenceFree for a velocity state
Reduced basiskl.PeriodicFeatureskl.LaplaceEigenfunctionFeatures or pyrox_nn.HSGPFeatures (Dirichlet box), or EOFs of a free run with geonnax.basis.eof_basis
\(\gmat{\Sigma}_\eta\)gx.Kronecker(time, space)the same, with a smooth spatial factor (a wind-stress-curl error, daily to weekly correlation); a non-separable space–time kernel such as kl.Gneiting if errors propagate
Spreaddense Hessiangx.gauss_newton_precision, or the leading eigenpairs of it
Windowquasi-static, 2 times per stagequasi-static by days, or overlapping short windows

Notes from building this page

Code markers: today (pkg) names were imported and run at gaussx 0.6.5 (eeda235), kernellib 0.0.22 (c50e01f), geonnax 11d850c, numpyro 0.22.0 and lineax 0.1.1; user marks names this page defines; not pinned marks optimistix 0.1.0, used but not reviewed. Numbers come from running these snippets as printed, in double precision.