GeoStack · precursor to the ocean state estimation page

The six-step cycle, written in probabilities

The six-step figure is usually read with functions: a model \(f\), an operator \(h\), an emulator \(f(\cdot;\gvect{\theta}_\text{emu})\). This page rewrites every box as a statement about distributions: a prior, a likelihood, a posterior, and learned densities that approximate them. A deterministic model then becomes one special case instead of the starting point. Nothing here assumes linearity; linear and nonlinear instances appear only as worked examples underneath each step, to show where closed forms come from and why they stop.

TL;DR

Six boxes, six distributions

The pipeline: train once, use per window

The figure reads left to right, but the work splits into two phases. Offline, the simulator produces training data and the learned densities are fitted once. Online, each new window of observations goes through one of three routes to a posterior, and the posterior is then used: summarised, forecast, turned into predictions and probabilities.

Offline · once
1simulate
\(\gvect{\theta}\sim p(\gvect{\theta}), \vect{x}\sim p(\vect{x} \mid \gvect{\theta}), \vect{y}\sim p(\vect{y} \mid \vect{x})\)
S draws of \((\gvect{\theta}, \vect{x}, \vect{y})\)
↓ S draws of \((\gvect{\theta}, \vect{x}, \vect{y})\)
3fit the emulator
\(\min_{\gvect{\theta}_\text{emu}}\ \mathbb{E}[-\log q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu})]\)
out: \(\gvect{\theta}_\text{emu}\)
5fit the amortized posterior
\(\min_{\gvect{\nu}}\ \mathbb{E}[-\log q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})]\)
out: \(\gvect{\nu}\)
Online · every new window of observations
2infer with \(p\)
\(p(\gvect{\theta}, \vect{x} \mid \vect{y})\)
hours
4infer with \(q(\cdot; \gvect{\theta}_\text{emu})\)
\(p(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\theta}_\text{emu})\)
uses \(\gvect{\theta}_\text{emu}\) · seconds
5apply \(q(\cdot; \gvect{\nu})\)
\(q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})\)
uses \(\gvect{\nu}\) · milliseconds
↓ posterior draws \((\gvect{\theta}, \vect{x})^{(l)}\): the same object from every route
useestimate · forecast · predict
\(\frac{1}{L}\sum_l g(\gvect{\theta}^{(l)}, \vect{x}^{(l)})\); forecast by pushing draws through \(p\) or \(q(\cdot; \gvect{\theta}_\text{emu})\)
means, intervals, forecast ensembles, \(\mathbb{P}(A \mid \vect{y})\)
↓
6compare with references
divergences, calibration ranks, evidence ratios
inner loop, or change the model

Two things to read off it. All three online routes return the same object, posterior draws, so everything after them is shared. And the emulator appears twice: inside inference (step 4) and inside forecasting, where it pushes posterior draws forward. Combining the cheapest route (step 5) with the emulator for the forecast gives a system in which every window costs a few network calls.

Why distributions instead of functions

Every deterministic habit is a probabilistic statement with something set to zero. Writing the probability keeps the thing that was set to zero visible:

Deterministic habitThe distribution it isWhat it silently assumes
\(\vect{x}_k = f(\vect{x}_{k-1}; \gvect{\theta})\)\(p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) = \delta(\vect{x}_k - f(\vect{x}_{k-1}; \gvect{\theta}))\)the model is perfect
\(\vect{y} = h(\vect{x}) + \gvect{\varepsilon}\)\(p(\vect{y} \mid \vect{x}) = p_\varepsilon(\vect{y} - h(\vect{x}))\)additive noise of one known shape
minimise a squared errormaximise a Gaussian log-likelihood with fixed varianceGaussian errors of known, constant size
an emulator \(f(\cdot; \gvect{\theta}_\text{emu}) \approx f\)\(q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu})\) with zero spreadthe emulator is never wrong
the analysis \(\hat{\vect{x}}\)one summary (mode or mean) of \(p(\vect{x} \mid \vect{y})\)the posterior has one meaningful centre

The practical reason is that model error, emulator error and estimation error all have to live somewhere. In the probabilistic form each has a place: in \(p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta})\), in \(q(\cdot; \gvect{\theta}_\text{emu})\), and in \(p(\vect{x} \mid \vect{y})\). In the functional form they have none, and they reappear as overconfident analyses.

Notation

SymbolMeaningShape in the ocean example
\(\vect{x} = \vect{x}_{0:T}\)state over a window, with \(\vect{x}_k = \vect{x}(t_k)\)\(\mathbb{R}^{(T+1) \times D_x}\) (time+1, height, width)
\(\vect{y} = \vect{y}_{1:T}\), \(\vect{m}\)observations and their masks\(\mathbb{R}^{T \times D_y}\) each (time, height, width)
\(\vect{u}^\text{cov}\), \(\vect{u} = \vect{u}_{1:T}\)covariates and controls (known inputs; conditioned on throughout and dropped from notation where clear)\(\mathbb{R}^{N_\text{var} \times D_x}\) (variable, height, width), \(\mathbb{R}^{T \times D_x}\) (time, height, width)
\(\gvect{\theta}\), \(\gvect{\alpha}\)physical parameters, hyperparameters\(\mathbb{R}^{D_\theta}\) (param,), \(\mathbb{R}^{D_\alpha}\) (hyper,)
\(p(\cdot)\)a density of the model: prior, transition, likelihood, and everything derived from them—
\(q(\cdot; \gvect{\theta}_\text{emu})\), \(q(\cdot; \gvect{\nu})\)learned densities: emulator (\(\gvect{\theta}_\text{emu}\)), amortized posterior (\(\gvect{\nu}\))\(\mathbb{R}^{D_{\theta_\text{emu}}}\), \(\mathbb{R}^{D_\nu}\) (weights)
\(a\sim p\)\(a\) is a sample from \(p\)—
\(\delta(\cdot)\)a point mass: a "distribution" with no spread—
\(\mathrm{KL}[p \Vert q] = \mathbb{E}_p[\log p - \log q]\)Kullback–Leibler divergence, zero only when \(q = p\)\(\mathbb{R}_{\ge 0}\)

A running scalar example sits under every step, small enough to compute exactly on a grid. Observing a current's speed, not its direction: \(x\) is the along-channel current (positive eastward), the prior leans east, and an instrument reports the speed squared with noise:

\[ p(x) = \mathcal{N}(x; 0.5, 1^2), \] \[ p(y \mid x) = \mathcal{N}(y; x^2, 0.25^2). \]

Step 1 · The simple model is a joint distribution

A state-space model is four conditional densities. Their product is the joint distribution of everything, observed or not:

\[ p(\gvect{\theta}, \vect{x}_{0:T}, \vect{y}_{1:T}) = p(\gvect{\theta}) p(\vect{x}_0 \mid \gvect{\theta}) \prod_{k=1}^{T} p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}, \vect{u}^\text{cov}, \vect{u}_k) \prod_{k=1}^{T} p(\vect{y}_k \mid \vect{x}_k, \vect{m}_k). \]
  α ─────────► θ
               │
               ▼
  x₀ ──► x₁ ──► x₂ ──► … ──► x_T        transition  p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ)
         │      │             │
         ▼      ▼             ▼
         y₁     y₂            y_T        likelihood  p(yₖ | xₖ, mₖ)

"Physics you can simulate" means you can draw from this joint by ancestral sampling: draw each variable given its parents, in order. That is all step 1 has to provide, and it is all steps 3 and 5 need for training. The prior predictive \(p(\vect{y}) = \iint p(\gvect{\theta}) p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x}) \mathrm{d}\vect{x} \mathrm{d}\gvect{\theta}\) is what the model expects to observe before seeing any data; drawing from it and comparing with real maps is the first check of a model.

import einx
import equinox as eqx
import jax
from jaxtyping import Array, Float, PRNGKeyArray

# Shapes: T days (time); N state values (state); M observations per day (obs);
#         P parameters (param); D any density's event shape

# not pinned (distribution library: numpyro / distrax / tfp-style)
# Dist: an object with .sample(key) and .log_prob(value)


class StateSpaceModel(eqx.Module):
    """p(θ) p(x₀ | θ) ∏ₖ p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ) p(yₖ | xₖ, mₖ): four conditionals."""

    # p(θ)
    def prior_params(self) -> Dist: ...

    # p(x₀ | θ)
    def prior_state(self, params) -> Dist: ...

    # p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ): physics, a hybrid, or an emulator
    def transition(self, state, params, covariates, control) -> Dist: ...

    # p(yₖ | xₖ, mₖ)
    def likelihood(self, state, mask) -> Dist: ...


def sample_joint(
    model: StateSpaceModel,
    covariates: Float[Array, "variable state"],
    controls: Float[Array, "time state"],
    masks: Float[Array, "time obs state"],
    *,
    key: PRNGKeyArray,
) -> tuple[Float[Array, "param"], Float[Array, "time state"], Float[Array, "time obs"]]:
    params_key, state_key, time_key = jax.random.split(key, 3)
    # θ ~ p(θ)
    params = model.prior_params().sample(params_key)  # → (P,)
    # x₀ ~ p(x₀ | θ)
    state = model.prior_state(params).sample(state_key)  # → (N,)

    def day(state, inputs):
        control, mask, day_key = inputs
        transition_key, obs_key = jax.random.split(day_key)
        # xₖ ~ p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ)
        state = model.transition(state, params, covariates, control).sample(
            transition_key
        )                                             # (N,) → (N,)
        # yₖ ~ p(yₖ | xₖ, mₖ)
        obs = model.likelihood(state, mask).sample(obs_key)  # (N,) → (M,)
        return state, (state, obs)

    day_keys = jax.random.split(time_key, controls.shape[0])  # → (T, 2)
    # ancestral sampling: one draw of (θ, x₁:T, y₁:T) from the joint
    _, (states, observations) = jax.lax.scan(         # → (T, N), (T, M)
        day, state, (controls, masks, day_keys)
    )
    return params, states, observations

Markers: not pinned marks the density interface (.sample, .log_prob), which any of the JAX distribution libraries provides; user code is defined on this page. Shapes follow the code-sketch standard.

Usesimulatedraw one window from the joint
In
\(\vect{u}^\text{cov}\) covariatesknown fields
(variable, state)
\(\vect{u}_{1:T}\) controlsforcing per day
(time, state)
\(\vect{m}_{1:T}\) maskswhich values are observed
(time, obs, state)
random keythe only source of randomness
()
↓
\(\gvect{\theta}\sim p(\gvect{\theta})\), \(\vect{x}_0\sim p(\vect{x}_0 \mid \gvect{\theta})\), \(\vect{x}_k\sim p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta},\vect{u}^\text{cov},\vect{u}_k)\), \(\vect{y}_k\sim p(\vect{y}_k \mid \vect{x}_k,\vect{m}_k)\)
Out
\(\gvect{\theta}\)parameters
(param,)
\(\vect{x}_{0:T}\)a state trajectory
(time+1, state)
\(\vect{y}_{1:T}\) observationsone window
(time, obs)
Usescoreevaluate how probable a full configuration is
In
\(\gvect{\theta}\)parameters
(param,)
\(\vect{x}_{0:T}\)a state trajectory
(time+1, state)
\(\vect{y}_{1:T}\) observationsone window
(time, obs)
↓
\(\log p(\gvect{\theta}) + \log p(\vect{x}_0 \mid \gvect{\theta}) + \sum_k\log p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) + \sum_k\log p(\vect{y}_k \mid \vect{x}_k)\)
Out
\(\log p(\gvect{\theta}, \vect{x}, \vect{y})\)the log joint
()
Nothing is trained in step 1. Its hyperparameters α can be calibrated against data, which is step 6's job.
Linear-Gaussian instance \[ p(\vect{x}_k \mid \vect{x}_{k-1}) = \mathcal{N}(\mat{F} \vect{x}_{k-1}, \gmat{\Sigma}_\eta), \] \[ p(\vect{y}_k \mid \vect{x}_k) = \mathcal{N}(\mat{H} \vect{x}_k, \gmat{\Sigma}_\varepsilon). \] Every marginal and conditional of the joint is Gaussian, so the whole joint is one (very large) Gaussian.
Nonlinear instance \[ p(\vect{x}_k \mid \vect{x}_{k-1}) = \mathcal{N}(f(\vect{x}_{k-1}; \gvect{\theta}), \gmat{\Sigma}_\eta), \] \[ p(\vect{y}_k \mid \vect{x}_k) = \mathcal{N}(h(\vect{x}_k), \gmat{\Sigma}_\varepsilon). \] Each conditional is still Gaussian, but the joint is not: pushing a Gaussian through \(f(\cdot; \gvect{\theta})\) or \(h\) bends it. The QG model of the other pages is this case.

Step 2 · Model-based inference is the posterior of that joint

Condition the joint on what was observed. Bayes' rule gives the target and its normaliser, the evidence:

\[ p(\gvect{\theta}, \vect{x} \mid \vect{y}) = \frac{p(\gvect{\theta}) p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x})}{p(\vect{y})}, \] \[ p(\vect{y}) = \iint p(\gvect{\theta}) p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x}) \mathrm{d}\vect{x} \mathrm{d}\gvect{\theta} . \]

Two marginals are usually what is wanted: the state, \(p(\vect{x} \mid \vect{y}) = \int p(\gvect{\theta}, \vect{x} \mid \vect{y}) \mathrm{d}\gvect{\theta}\), and the parameters, \(p(\gvect{\theta} \mid \vect{y})\propto p(\gvect{\theta}) p(\vect{y} \mid \gvect{\theta})\), whose likelihood \(p(\vect{y} \mid \gvect{\theta}) = \int p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x}) \mathrm{d}\vect{x}\) integrates the state out. For a state-space model the integrals over \(\vect{x}\) split day by day into two recursions that hold for any densities:

\[ \begin{aligned} \text{predict:} & p(\vect{x}_k \mid \vect{y}_{1:k-1}) = \int p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) p(\vect{x}_{k-1} \mid \vect{y}_{1:k-1}) \mathrm{d}\vect{x}_{k-1},\\ \text{update:} & p(\vect{x}_k \mid \vect{y}_{1:k}) = \frac{p(\vect{y}_k \mid \vect{x}_k) p(\vect{x}_k \mid \vect{y}_{1:k-1})}{p(\vect{y}_k \mid \vect{y}_{1:k-1})},\\ & p(\vect{y}_k \mid \vect{y}_{1:k-1}) = \int p(\vect{y}_k \mid \vect{x}_k) p(\vect{x}_k \mid \vect{y}_{1:k-1}) \mathrm{d}\vect{x}_k,\\ \text{evidence:} & p(\vect{y}_{1:T} \mid \gvect{\theta}) = \prod_{k=1}^{T} p(\vect{y}_k \mid \vect{y}_{1:k-1},\gvect{\theta}),\\ \text{smooth:} & p(\vect{x}_k \mid \vect{y}_{1:T}) = p(\vect{x}_k \mid \vect{y}_{1:k})\int\frac{p(\vect{x}_{k+1} \mid \vect{x}_k,\gvect{\theta}) p(\vect{x}_{k+1} \mid \vect{y}_{1:T})}{p(\vect{x}_{k+1} \mid \vect{y}_{1:k})} \mathrm{d}\vect{x}_{k+1}. \end{aligned} \]

Filtering is predict-then-update, forward in time; smoothing (reanalysis) runs backward once the window is filtered. Every algorithm in data assimilation is a way of evaluating these integrals, or the posterior they define, approximately. All of them start from the log joint:

# Shapes: T days (time); N state values (state); M observations per day (obs)


def log_joint(
    model, params, initial, states, observations, covariates, controls, masks
):
    # log p(θ)
    log_prior_params = model.prior_params().log_prob(params)  # → ()
    # log p(x₀ | θ)
    log_prior_state = model.prior_state(params).log_prob(initial)  # → ()
    previous = einx.rearrange(                        # (N,), (T, N) → (T, N)
        "state, time state -> (1 + time) state", initial, states
    )[:-1]

    def day_terms(prev, state, control, mask, obs):
        # log p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ)
        log_trans = model.transition(prev, params, covariates, control).log_prob(state)
        # log p(yₖ | xₖ, mₖ)
        log_lik = model.likelihood(state, mask).log_prob(obs)
        return log_trans, log_lik

    log_trans, log_lik = jax.vmap(day_terms)(         # → (T,), (T,)
        previous, states, controls, masks, observations
    )
    # log p(θ, x₀:T, y₁:T)
    #   = log p(θ) + log p(x₀|θ) + ∑ₖ log p(xₖ|xₖ₋₁, θ) + ∑ₖ log p(yₖ|xₖ)
    total = log_prior_params + log_prior_state        # → ()
    total = total + einx.sum("time ->", log_trans)    # → ()
    total = total + einx.sum("time ->", log_lik)      # → ()
    return total

The most general way to evaluate the recursions is to represent each density by samples: the bootstrap particle filter. It assumes nothing about linearity or Gaussianity, and it also returns an estimate of \(\log p(\vect{y} \mid \gvect{\theta})\):

# Shapes: T days (time); N state values (state); M obs per day (obs);
#         P_n = 1,000 particles (particle)


def particle_filter(model, params, observations, covariates, controls, masks, *, key):
    """Bootstrap filter: p(xₖ | y₁:ₖ) as weighted samples, and log p(y₁:T | θ)."""
    init_key, time_key = jax.random.split(key)
    init_keys = jax.random.split(init_key, NUM_PARTICLES)  # → (P_n, 2)
    # x₀⁽ⁱ⁾ ~ p(x₀ | θ)
    particles = jax.vmap(                             # → (P_n, N)
        lambda k: model.prior_state(params).sample(k)
    )(init_keys)

    def day(particles, inputs):
        obs, control, mask, day_key = inputs
        move_key, resample_key = jax.random.split(day_key)
        move_keys = jax.random.split(move_key, NUM_PARTICLES)  # → (P_n, 2)
        # predict: xₖ⁽ⁱ⁾ ~ p(xₖ | xₖ₋₁⁽ⁱ⁾, θ)  (Monte Carlo Chapman–Kolmogorov)
        particles = jax.vmap(                         # (P_n, N) → (P_n, N)
            lambda z, k: model.transition(z, params, covariates, control).sample(k)
        )(particles, move_keys)
        # update: log wₖ⁽ⁱ⁾ = log p(yₖ | xₖ⁽ⁱ⁾)
        log_weights = jax.vmap(                       # (P_n, N) → (P_n,)
            lambda z: model.likelihood(z, mask).log_prob(obs)
        )(particles)
        # log p(yₖ | y₁:ₖ₋₁) ≈ log (1/P_n) ∑ᵢ wₖ⁽ⁱ⁾
        log_sum = einx.logsumexp("[particle]", log_weights)  # (P_n,) → ()
        log_increment = log_sum - jnp.log(NUM_PARTICLES)  # → ()
        # resample: i ~ Categorical(wₖ / ∑ w)
        ancestors = jax.random.categorical(           # → (P_n,)
            resample_key, log_weights, shape=(NUM_PARTICLES,)
        )
        particles = particles[ancestors]              # (P_n, N)
        return particles, (particles, log_increment)

    day_keys = jax.random.split(time_key, observations.shape[0])  # → (T, 2)
    _, (filtered, log_increments) = jax.lax.scan(     # → (T, P_n, N), (T,)
        day, particles, (observations, controls, masks, day_keys)
    )
    # log p(y₁:T | θ) = ∑ₖ log p(yₖ | y₁:ₖ₋₁)
    log_evidence = einx.sum("time ->", log_increments)  # (T,) → ()
    return filtered, log_evidence

It is exact as the number of particles grows, but in high dimension the weights collapse onto one particle unless that number grows exponentially. That is why ocean data assimilation uses the cheaper families below, each with its own assumption.

FamilyRepresents the posterior byAssumesFails when
Kalman filter and smoothermean and covariance, exactlylinear-Gaussian modelanything is nonlinear
4D-Var (MAP) + Laplacethe mode and the curvature thereone dominant, near-Gaussian modeskew, several modes
Ensemble Kalmansamples, updated with a Gaussian ruleGaussian enough at each updatestrongly non-Gaussian updates
Particle filter, MCMCsamples, weighted or chainednothing (asymptotically)high dimension, limited budget
Variational inferencethe closest member of a chosen family \(q\)the family can represent the posteriorthe family is too narrow

Step 2 is the oracle not because an algorithm is exact, but because the posterior it targets is the correct answer under the model: everything in steps 3–5 is judged by how close it gets to it.

Useinfersolve one window, from scratch
In
\(\vect{y}_{1:T}\) observationsone window
(time, obs)
\(\vect{m}_{1:T}\) maskswhich values are observed
(time, obs, state)
\(\vect{u}^\text{cov}\) covariatesknown fields
(variable, state)
\(\vect{u}_{1:T}\) controlsforcing per day
(time, state)
the modelprior, transition, likelihood
densities
↓
\(p(\gvect{\theta}, \vect{x} \mid \vect{y})\propto p(\gvect{\theta}) p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x})\), approximated by particles, an ensemble, MCMC, or 4D-Var with Laplace
Out
\((\gvect{\theta}, \vect{x})^{(l)}\) posterior drawssamples; or a density you can evaluate
(draw, param), (draw, time+1, state)
\(\log p(\vect{y})\)the evidence, or an estimate of it
()
No training: every new window is a new solve, with many transition evaluations. That cost is what steps 3–5 remove.

Why the linear case closes and the nonlinear case does not

Linear-Gaussian Gaussians stay Gaussian under linear maps, sums and conditioning, so every integral above is matrix algebra: the predict step is \(\mat{F} \gvect{\mu}_{x,k-1 \mid k-1}\), \(\mat{F} \gmat{\Sigma}_{x,k-1 \mid k-1} \mat{F}^\top + \gmat{\Sigma}_\eta\); the update is the Kalman gain. Three consequences:
  • the posterior is one Gaussian;
  • its mean is linear in \(\vect{y}\);
  • its covariance does not depend on \(\vect{y}\) at all.
Nonlinear The predict integral pushes a density through \(f(\cdot; \gvect{\theta})\) and the update multiplies by a likelihood through \(h\); neither keeps a Gaussian Gaussian. The posterior can skew or split into several modes, its mean is a nonlinear function of \(\vect{y}\), and its spread depends on what was observed. No finite set of numbers describes it in general.

The scalar example shows all three effects. With a speed-squared observation, the posterior has a branch for each sign of the current, weighted by the prior, and its shape changes with \(y\):

y = −0.3 · P(x<0) = 0.45-202y = +0.3 · P(x<0) = 0.39-202y = +1.2 · P(x<0) = 0.26-202y = +2.5 · P(x<0) = 0.17-202x = current (+ east) · dashed: prior · solid: posterior
Exact posteriors for four observed values. Below zero, the posterior is one narrow bump near \(x = 0\). Above zero it splits into an eastward and a westward branch; the prior's eastward lean makes the eastward one heavier.
y = +1.2: exact posterior and two Gaussian shortcuts-202exact: mean +0.52, sd 0.93Laplace at the mode: +1.09 ± 0.11linearised at the prior mean: +1.39 ± 0.24prior
At \(y = 1.2\). The Laplace approximation sits on the eastward mode and is about eight times too narrow; linearising \(x^2\) at the prior mean puts the mean at 1.39, past the eastward mode, and misses the westward branch entirely.
Observed \(y\)Exact meanExact sd\(\mathbb{P}(x<0 \mid y)\)Laplace mode ± sdLinearised mean ± sd
−0.3+0.030.250.45+0.05 ± 0.30−0.02 ± 0.24
+0.3+0.120.480.39+0.54 ± 0.22+0.55 ± 0.24
+1.2+0.520.930.26+1.09 ± 0.11+1.39 ± 0.24
+2.5+1.031.190.17+1.57 ± 0.08+2.62 ± 0.24

Two patterns to keep. The linearised standard deviation is 0.24 for every \(y\): that is the linear-Gaussian property that the posterior covariance ignores the data, carried into a problem where it is false (the exact sd runs from 0.25 to 1.19). And the Laplace approximation becomes more confident as the evidence for two branches grows: a single Gaussian cannot say "east or west". (For a truly linear observation, \(y = x + \varepsilon\), the grid and the closed-form Kalman update agree to four decimals.)

Step 3 · The emulator is a learned conditional density

The expensive object in step 2 is the transition: it is evaluated or sampled for every particle, ensemble member or iteration. An emulator replaces that one density with a learned one, keeping the same interface:

\[ q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}_\text{emu}) \approx p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}, \vect{u}^\text{cov}, \vect{u}_k). \]

Train it on transitions drawn by running step 1, by maximum likelihood. That is the same as minimising the KL divergence from the simulator's transition to the emulator's, averaged over the states and parameters the simulations visit:

\[ \gvect{\theta}_\text{emu}^\star = \mathrm{argmin}_{\gvect{\theta}_\text{emu}}\ \mathbb{E}_{p(\vect{x}_{k-1},\gvect{\theta})}[\mathrm{KL}[p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}) \Vert q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu})]] = \mathrm{argmin}_{\gvect{\theta}_\text{emu}}\ \mathbb{E}_{p(\vect{x}_{k-1},\gvect{\theta},\vect{x}_k)}[-\log q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu})]. \]
# Shapes: S simulated transitions (sim); N state values (state); P parameters (param)


class EmulatedTransition(eqx.Module):
    """q(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ; θ_emu): a learned conditional density, same interface as p."""

    network: eqx.Module

    def __call__(self, state, params, covariates, control) -> Dist:
        # user
        # (μ, log σ) = network(xₖ₋₁, θ, uᶜᵒᵛ, uₖ; θ_emu)
        mean, log_scale = self.network(state, params, covariates, control)  # → (N,) ×2
        # not pinned (distribution library)
        # q(xₖ | ·; θ_emu) = 𝒩(μ, diag σ²); a flow or a diffusion model also fits here
        return Normal(mean, jnp.exp(log_scale))


def emulator_loss(emulator, previous, params, covariates, controls, nexts):
    # pairs (xₖ₋₁, θ, uₖ, xₖ) drawn by running step 1: xₖ ~ p(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ)
    def nll(prev, theta, control, nxt):
        # −log q(xₖ | xₖ₋₁, θ, uᶜᵒᵛ, uₖ; θ_emu)
        return -emulator(prev, theta, covariates, control).log_prob(nxt)

    losses = jax.vmap(nll)(previous, params, controls, nexts)  # → (S,)
    # 𝔼_p[−log q(·; θ_emu)] = 𝔼_{xₖ₋₁,θ}[ KL(p(xₖ|·) ‖ q(xₖ|·; θ_emu)) ] + const
    return einx.mean("sim ->", losses)                # (S,) → ()

The deterministic emulator is the case \(q(\cdot; \gvect{\theta}_\text{emu}) = \delta(\vect{x}_k - f(\vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu}))\). Its log-likelihood is undefined, so it is trained by mean squared error instead, which is the Gaussian log-likelihood with a fixed variance. What is lost is exactly the spread: a deterministic emulator claims to be right everywhere, and step 4 then trusts it fully. A learned \(\gvect{\sigma}\) reports where the emulator is unsure, and that uncertainty flows into the posterior like model error.

The expectation is over \(p(\vect{x}_{k-1},\gvect{\theta})\): the states and parameters the training simulations visit. An emulator is only trained where step 1 went. Step 4 will use it where the posterior puts mass, which may be a different place.

Trainfit the emulatoronce, offline
In
transitions \((\vect{x}_{k-1}, \gvect{\theta}, \vect{u}_k, \vect{x}_k)\)drawn by running step 1
(sim, state) ×2, (sim, param)
↓
\(\min_{\gvect{\theta}_\text{emu}}\ \mathbb{E}[-\log q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta},\vect{u}^\text{cov},\vect{u}_k; \gvect{\theta}_\text{emu})]\)
Out
\(\gvect{\theta}_\text{emu}\)emulator weights
—
Usestepanywhere the simulator's transition was used
In
\(\vect{x}_{k-1}\)today's state
(state,)
\(\gvect{\theta}\)parameters
(param,)
\(\vect{u}_k\)today's forcing
(state,)
\(\gvect{\theta}_\text{emu}\)trained weights
—
↓
\(\vect{x}_k\sim q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta},\vect{u}^\text{cov},\vect{u}_k; \gvect{\theta}_\text{emu})\), or evaluate \(\log q(\cdot; \gvect{\theta}_\text{emu})\) at a given \(\vect{x}_k\)
Out
\(\vect{x}_k\)a sample, or a density
(state,)
Useforecastroll the emulator forward from many starts
In
\(\vect{x}_T^{(l)}\)start states, e.g. posterior draws
(draw, state)
\(\gvect{\theta}\)parameters
(param,)
\(\vect{u}_{T+1:T+J}\)future forcing
(lead, state)
\(\gvect{\theta}_\text{emu}\)trained weights
—
↓
ancestral sampling with \(q(\cdot; \gvect{\theta}_\text{emu})\), one path per start
Out
forecast ensemblepaths with spread from both the starts and \(q(\cdot; \gvect{\theta}_\text{emu})\)
(draw, lead, state)
Linear-Gaussian If \(p(\vect{x}_k \mid \vect{x}_{k-1}) = \mathcal{N}(\mat{F} \vect{x}_{k-1},\gmat{\Sigma}_\eta)\) and \(q(\cdot; \gvect{\theta}_\text{emu})\) is linear-Gaussian too, the family contains the truth: with enough data, \(q(\cdot; \gvect{\theta}_\text{emu}^\star) = p\) exactly. Any residual error just looks like a larger \(\gmat{\Sigma}_\eta\).
Nonlinear The family generally cannot contain the truth, and the error has structure: it is small where training data were dense and can be large in rare states, exactly the fronts and extreme events a posterior may need. A Gaussian \(q(\cdot; \gvect{\theta}_\text{emu})\) also cannot represent a transition that is itself skewed or multimodal (for example, eddy shedding that may or may not happen).

In the scalar example the "simulator" is the observation \(p(y \mid x) = \mathcal{N}(x^2, 0.25^2)\). An emulator \(q(y \mid x; \gvect{\theta}_\text{emu})\) trained only on the prior's typical range \(\vert x \vert \lesssim 2\) is unconstrained beyond it; a posterior that reaches past it, such as the two branches near \(\pm2.2\) after observing \(y = 5\), rests on the emulator's extrapolation.

Step 4 · Emulator-based inference: the same posterior, one density swapped

Put \(q(\cdot; \gvect{\theta}_\text{emu})\) in place of the simulator's transition and run step 2 unchanged. The target becomes

\[ p(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\theta}_\text{emu}) = \frac{p(\gvect{\theta}) q(\vect{x} \mid \gvect{\theta}; \gvect{\theta}_\text{emu}) p(\vect{y} \mid \vect{x})}{p(\vect{y}; \gvect{\theta}_\text{emu})}, \] \[ q(\vect{x} \mid \gvect{\theta}; \gvect{\theta}_\text{emu}) = p(\vect{x}_0 \mid \gvect{\theta})\prod_k q(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}; \gvect{\theta}_\text{emu}). \]

In code this is one argument: pass the emulator as the model's transition to the same log_joint or particle_filter. Dividing the two posteriors shows exactly how emulator error enters:

\[ \frac{p(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\theta}_\text{emu})}{p(\gvect{\theta}, \vect{x} \mid \vect{y})} = \frac{q(\vect{x} \mid \gvect{\theta}; \gvect{\theta}_\text{emu})}{p(\vect{x} \mid \gvect{\theta})}\cdot\frac{p(\vect{y})}{p(\vect{y}; \gvect{\theta}_\text{emu})} . \]

The posterior is distorted by the emulator's density ratio, renormalised. Errors where the posterior has no mass do not matter; errors where it does matter fully. So the emulator's training distribution (step 3's expectation) should cover the regions that posteriors visit, which is why emulators are often refined with simulations drawn near the posterior.

Useinfer with the emulatorsolve one window, faster
In
\(\vect{y}_{1:T}\) observationsone window
(time, obs)
\(\vect{m}_{1:T}\) maskswhich values are observed
(time, obs, state)
\(\vect{u}^\text{cov}\) covariatesknown fields
(variable, state)
\(\vect{u}_{1:T}\) controlsforcing per day
(time, state)
\(\gvect{\theta}_\text{emu}\)from step 3
—
↓
\(p(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\theta}_\text{emu})\propto p(\gvect{\theta}) q(\vect{x} \mid \gvect{\theta}; \gvect{\theta}_\text{emu}) p(\vect{y} \mid \vect{x})\), same algorithm as step 2
Out
\((\gvect{\theta}, \vect{x})^{(l)}\) posterior drawssamples; or a density you can evaluate
(draw, param), (draw, time+1, state)
\(\log p(\vect{y}; \gvect{\theta}_\text{emu})\)evidence under the emulator
()
No new training. Each window is still a solve, but each transition evaluation now costs one network call.
Linear-Gaussian A Gaussian emulator with a slightly wrong \(\mat{F}\) or a larger noise shifts the posterior mean and widens its covariance. The posterior stays Gaussian, so the damage is measured by two moments.
Nonlinear Emulator error can move, merge or create modes. If the emulator smooths the dynamics, a two-branch posterior can collapse into one; if it extrapolates badly, a spurious branch can appear. Two moments no longer describe the damage; the comparison with step 2 has to be on the whole distribution.

Step 5 · Amortized inference: learn the posterior as a function of the data

Steps 2 and 4 solve a new problem for every window of observations. Step 5 learns a conditional density that answers for all of them at once:

\[ q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu}) \approx p(\gvect{\theta}, \vect{x} \mid \vect{y}) \text{ for every } \vect{y} . \]

The forward KL, averaged over the data the model can produce, needs only samples of the joint from step 1. The intractable posterior drops out because it does not depend on \(\gvect{\nu}\):

\[ \mathbb{E}_{p(\vect{y})}[\mathrm{KL}[p(\gvect{\theta}, \vect{x} \mid \vect{y}) \Vert q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})]] = \mathbb{E}_{p(\gvect{\theta}, \vect{x}, \vect{y})}[-\log q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})] + \text{const}. \]

That is simulation-based inference: simulate \((\gvect{\theta}, \vect{x}, \vect{y})\) triples, fit a conditional density, done. The alternative is the reverse KL per window, the ELBO, which needs the likelihood but no simulated truth:

\[ \mathrm{ELBO}(\gvect{\nu}; \vect{y}) = \mathbb{E}_{q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})}[\log p(\gvect{\theta}) p(\vect{x} \mid \gvect{\theta}) p(\vect{y} \mid \vect{x}) - \log q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})] \le \log p(\vect{y}). \]

Forward KL tends to spread mass over every mode (it is penalised for missing one); reverse KL tends to pick one (it is penalised for putting mass where the posterior has none). The amortization gap is what one network loses against solving each window separately.

# Shapes: S simulated windows (sim); P parameters (param); T, N, M as before;
#         L posterior draws (draw)


def amortized_loss(posterior_net, params, states, observations, masks):
    # (θ, x, y) ~ p(θ, x, y): S draws of sample_joint, nothing else is needed
    def nll(theta, z, y, m):
        # user
        # q(θ, x | y, m; ν): a conditional flow, diffusion or mixture
        density = posterior_net(y, m)
        # −log q(θ, x | y; ν)
        return -density.log_prob((theta, z))

    losses = jax.vmap(nll)(params, states, observations, masks)  # → (S,)
    # 𝔼_{p(y)}[ KL(p(θ, x | y) ‖ q(θ, x | y; ν)) ] = 𝔼_{p(θ,x,y)}[−log q(·; ν)] + const
    return einx.mean("sim ->", losses)                # (S,) → ()


def calibration_rank(posterior_net, model, *, key, num_draws=99):
    """Simulation-based calibration: the rank of the true θ among posterior draws."""
    sim_key, draw_key = jax.random.split(key)
    # θ_true ~ p(θ), y ~ p(y | θ_true)
    true_params, _, observations = sample_joint(model, *INPUTS, key=sim_key)
    draw_keys = jax.random.split(draw_key, num_draws)  # → (L, 2)
    # θ⁽ˡ⁾ ~ q(θ | y; ν)
    draws = jax.vmap(                                 # → (L, P)
        lambda k: posterior_net(observations, MASKS).sample_params(k)
    )(draw_keys)
    below = draws < true_params                       # (L, P), (P,) → (L, P)
    # rank = #{l : θ⁽ˡ⁾ < θ_true}, uniform on 0…L when q(·; ν) is calibrated
    return einx.sum("draw param -> param", below)     # (L, P) → (P,)
Trainfit the amortized posterioronce, offline
In
triples \((\gvect{\theta}, \vect{x}, \vect{y})\)S draws of step 1's simulate
(sim, param), (sim, time+1, state), (sim, time, obs)
↓
\(\min_{\gvect{\nu}}\ \mathbb{E}_{p(\gvect{\theta},\vect{x},\vect{y})}[-\log q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})]\)
Out
\(\gvect{\nu}\)variational parameters
—
Useinfer, amortizedone forward pass per window
In
\(\vect{y}_{1:T}\) observationsone window
(time, obs)
\(\vect{m}_{1:T}\) maskswhich values are observed
(time, obs, state)
\(\gvect{\nu}\)trained weights
—
↓
\((\gvect{\theta}, \vect{x})^{(l)}\sim q(\gvect{\theta}, \vect{x} \mid \vect{y}; \gvect{\nu})\), or evaluate \(\log q(\cdot; \gvect{\nu})\)
Out
\((\gvect{\theta}, \vect{x})^{(l)}\) posterior drawssamples; or a density you can evaluate
(draw, param), (draw, time+1, state)
Same outputs as step 2, so everything downstream (estimates, forecasts, checks) is unchanged.
Linear-Gaussian The posterior is \(\mathcal{N}(\mat{G} \vect{y} + \vect{b}, \gmat{\Sigma}_{x \mid y})\): mean linear in \(\vect{y}\), covariance fixed. A Gaussian \(q(\cdot; \gvect{\nu})\) with a linear mean and one learned covariance is exact, and forward-KL training is linear regression.
Nonlinear The posterior's shape changes with \(\vect{y}\). In the scalar example one network must output a single narrow bump at \(y = -0.3\), a broad skewed one at \(y = 0.3\), and two branches weighted 74 : 26 at \(y = 1.2\). A Gaussian \(q(\cdot; \gvect{\nu})\), however good its mean, cannot; conditional flows, mixtures and diffusion models can.

Using a posterior: estimate, forecast, predict

Training produces \(\gvect{\theta}_\text{emu}\) and \(\gvect{\nu}\); inference produces a posterior. Neither is the end product. What is used is a handful of integrals against the posterior, and with posterior draws \((\gvect{\theta}, \vect{x})^{(l)}\), \(l = 1,\ldots,L\), every one of them is an average:

\[ \mathbb{E}[g(\gvect{\theta}, \vect{x}) \mid \vect{y}] = \int g(\gvect{\theta}, \vect{x}) p(\gvect{\theta}, \vect{x} \mid \vect{y}) \mathrm{d}\gvect{\theta} \mathrm{d}\vect{x} \approx \frac{1}{L}\sum_{l=1}^{L} g(\gvect{\theta}^{(l)}, \vect{x}^{(l)}). \]
UseDistributionFrom the draws
Estimate the state now\(p(\vect{x}_T \mid \vect{y})\)mean, quantiles, marginal histograms of \(\vect{x}_T^{(l)}\)
Forecast the state later\(p(\vect{x}_{T+j} \mid \vect{y}) = \int p(\vect{x}_{T+j} \mid \vect{x}_T,\gvect{\theta}) p(\vect{x}_T,\gvect{\theta} \mid \vect{y}) \mathrm{d}\vect{x}_T \mathrm{d}\gvect{\theta}\)push each draw through the transition (simulator or emulator)
Predict a new observation\(p(\vect{y}_* \mid \vect{y}) = \int p(\vect{y}_* \mid \vect{x}) p(\vect{x} \mid \vect{y}) \mathrm{d}\vect{x}\)push each (forecast) draw through the likelihood
Decide on an event \(A\)\(\mathbb{P}(A \mid \vect{y}) = \mathbb{E}[\mathbf{1}_A(\vect{x}) \mid \vect{y}]\)fraction of draws in which \(A\) happens
Useestimate · forecast · predictafter any of steps 2, 4, 5
In
\((\gvect{\theta}, \vect{x})^{(l)}\) posterior drawssamples; or a density you can evaluate
(draw, param), (draw, time+1, state)
\(\vect{u}_{T+1:T+J}\)future forcing
(lead, state)
a transitionthe simulator \(p\), or the emulator \(q(\cdot; \gvect{\theta}_\text{emu})\)
density
↓
summaries of the draws; push them through the transition; then through the likelihood
Out
posterior mean, intervals\(\mathbb{E}[\vect{x}_T \mid \vect{y}]\), quantiles
(state,), (state, 2)
forecastdraws of \(p(\vect{x}_{T+1:T+J} \mid \vect{y})\)
(draw, lead, state)
posterior predictivedraws of \(p(\vect{y}_* \mid \vect{y})\)
(draw, obs)
\(\mathbb{P}(A \mid \vect{y})\)probability of an event
()
# Shapes: L posterior draws (draw); N state values (state); J forecast days (lead);
#         M observations per day (obs)


def use_posterior(model, posterior_draws, covariates, controls, mask, *, key):
    """From posterior samples of (θ, x_T) to estimates, a forecast and a predictive."""
    params, final_states = posterior_draws            # (L, P), (L, N)

    # --- estimate: summaries of p(x_T | y) ---
    # 𝔼[x_T | y] ≈ (1/L) ∑ₗ x_T⁽ˡ⁾
    mean = einx.mean("draw state -> state", final_states)  # (L, N) → (N,)
    per_state = einx.rearrange("draw state -> state draw", final_states)  # → (N, L)
    # 5 % and 95 % quantiles of each state value
    bounds = jax.vmap(lambda v: jnp.quantile(v, LEVELS))(per_state)  # → (N, 2)

    # --- forecast: p(x_{T+j} | y) = ∫ p(x_{T+j} | x_T, θ) p(x_T, θ | y) dx_T dθ ---
    def rollout(theta, state, rollout_key):
        def day(state, inputs):
            control, day_key = inputs
            # x_{T+j} ~ p(x_{T+j} | x_{T+j−1}, θ): simulator, or emulator q(·; θ_emu)
            state = model.transition(state, theta, covariates, control).sample(day_key)
            return state, state

        day_keys = jax.random.split(rollout_key, controls.shape[0])  # → (J, 2)
        _, path = jax.lax.scan(day, state, (controls, day_keys))  # → (J, N)
        return path

    forecast_key, predictive_key = jax.random.split(key)
    rollout_keys = jax.random.split(forecast_key, final_states.shape[0])  # → (L, 2)
    # one forecast path per posterior draw: an ensemble with honest spread
    forecast = jax.vmap(rollout)(params, final_states, rollout_keys)  # → (L, J, N)

    # --- predict: p(y_* | y) = ∫ p(y_* | x) p(x | y) dx, for a new observation ---
    predictive_keys = jax.random.split(predictive_key, final_states.shape[0])
    # y_*⁽ˡ⁾ ~ p(y_* | x⁽ˡ⁾)
    predictive = jax.vmap(                            # (L, N) → (L, M)
        lambda z, k: model.likelihood(z, mask).sample(k)
    )(forecast[:, 0], predictive_keys)

    # --- event probability: P(A | y) = 𝔼[𝟙_A(x) | y] ---
    # user
    # 𝟙_A(x): e.g. "the jet sits north of 40° N", per draw
    in_event = event_indicator(forecast[:, -1])       # (L, N) → (L,)
    probability = einx.mean("draw ->", in_event)      # (L,) → ()
    return mean, bounds, forecast, predictive, probability

The scalar example, used

Take the posterior at \(y = 1.2\) and a simple transition for tomorrow's current, which persists and decays: \(p(x' \mid x) = \mathcal{N}(x'; 0.8 x, 0.3^2)\). Compare the exact posterior with the Laplace approximation from step 2:

QuantityExact posteriorLaplace approximation
Point estimate of \(x\)mean +0.52, mode +1.09mode +1.09
Density at the posterior mean, relative to the peak0.1 %—
90 % interval for \(x\)[−1.16, +1.24][+0.90, +1.28]
\(\mathbb{P}(\text{eastward today} \mid y)\)0.741.00
Forecast mean of \(x'\)+0.42+0.87
90 % forecast interval for \(x'\)[−1.12, +1.32][+0.35, +1.39]
\(\mathbb{P}(\text{eastward tomorrow} \mid y)\)0.740.997
90 % interval for a new observation \(y_*\) of today's \(x\)[+0.56, +1.73]—

Three lessons that hold well beyond the toy. The posterior mean is a fine summary of a unimodal posterior and a misleading one here: it sits in the valley between the branches, at a current the water almost certainly does not have. The forecast inherits the posterior's shape: pushing both branches forward keeps a 26 % chance of a westward current tomorrow, which the single-Gaussian forecast reduces to 0.3 %. And every use above needs draws, not a single field: a deterministic analysis can be estimated from, but it cannot be forecast with honest spread or turned into a probability.

Step 6 · Improve: compare distributions, then change the structure

Each learned density has a reference: the distribution it was built to approximate. The whole model has one more reference, the data.

StepCompared withDivergencePractical check
1 · modelreal observationsevidence \(p(\vect{y} \mid \mathcal{M})\)prior and posterior predictive checks
2 · posteriorthe true state, in simulationscalibration of \(p(\vect{x} \mid \vect{y})\)coverage of credible intervals in OSSEs
3 · emulatorstep 1's transition\(\mathbb{E} \mathrm{KL}[p(\vect{x}_k \mid \cdot) \Vert q(\vect{x}_k \mid \cdot; \gvect{\theta}_\text{emu})]\)held-out negative log-likelihood; rollouts
4 · emulated posteriorstep 2's posterior\(\mathrm{KL}[p(\cdot \mid \vect{y}) \Vert p(\cdot \mid \vect{y}; \gvect{\theta}_\text{emu})]\)compare samples or marginals window by window
5 · amortized posteriorstep 2's posterior\(\mathbb{E}_{p(\vect{y})}\mathrm{KL}[p(\cdot \mid \vect{y}) \Vert q(\cdot \mid \vect{y}; \gvect{\nu})]\)simulation-based calibration ranks; per-window comparison with step 2

The posterior predictive is the model's forecast of new data after seeing the old: \(p(\vect{y}_* \mid \vect{y}) = \int p(\vect{y}_* \mid \vect{x}) p(\vect{x} \mid \vect{y}) \mathrm{d}\vect{x}\). If held-out observations fall in its tails more or less often than its stated probabilities, something is wrong. If the misfit is random, the inner loop (re-estimate \(\vect{x}\) and \(\gvect{\theta}\)) is enough. If it has structure, the outer loop changes the model \(\mathcal{M}\): a different transition family, a richer likelihood, other priors. Competing structures are compared by their evidence:

\[ \frac{p(\mathcal{M}_1 \mid \vect{y})}{p(\mathcal{M}_2 \mid \vect{y})} = \frac{p(\vect{y} \mid \mathcal{M}_1)}{p(\vect{y} \mid \mathcal{M}_2)}\cdot\frac{p(\mathcal{M}_1)}{p(\mathcal{M}_2)}, \] \[ p(\vect{y} \mid \mathcal{M}) = \text{step 2's normaliser}. \]
Usediagnoseacross many windows
In
draws from steps 2, 4, 5the same windows
(window, draw, …)
simulated windows with known truthfrom step 1
(sim, …)
held-out observationsnot used in inference
(window, time, obs)
↓
calibration ranks, held-out log-likelihoods, divergences to each reference, evidence ratios
Out
rank statisticsuniform when calibrated
(sim, param)
scores and evidence ratiosper step, per model
()
next actioninner loop (re-estimate) or outer loop (change the model)
1 of 2
On the figure's bottom row

In six_steps.svg each improve box lists the previous step as its ground truth. Written as distributions, the references are: step 3 against step 1 (an emulator approximates the simulator, not a posterior), and steps 4 and 5 both against step 2. The axes-matrix figure in the same overview already says this.

Linear versus nonlinear, side by side

QuestionLinear-GaussianGeneral
Predict integralmatrix productno closed form
Posterior familyone Gaussiananything: skewed, heavy-tailed, multimodal
Does the posterior spread depend on \(\vect{y}\)?noyes
Mode = mean?yesno
Laplace / 4D-Var + Hessianexactlocal; misses other modes
Evidence \(p(\vect{y})\)closed formestimated (particles, bounds, Laplace)
Emulator errorlooks like extra noisecan move or split the posterior
Amortized posteriorlinear mean, fixed covarianceshape must change with \(\vect{y}\)

Bridge to ocean state estimation

This pageOn the ocean state estimation page
\(p(\vect{x}_0 \mid \gvect{\theta})\)\(\mathcal{N}(\gvect{\mu}_x, \gmat{\Sigma}_x)\), the background
\(p(\vect{x}_k \mid \vect{x}_{k-1},\gvect{\theta}, \vect{u}^\text{cov}, \vect{u}_k)\)\(\mathcal{N}(\mathcal{F}[\vect{x}_{k-1}, \vect{u}^\text{cov}, \vect{u}_k; \gvect{\theta}], \gmat{\Sigma}_\eta)\), the QG model with model error; strong-constraint 4D-Var is the point-mass case \(\gmat{\Sigma}_\eta \to \mat{0}\)
\(p(\vect{y}_k \mid \vect{x}_k, \vect{m}_k)\)\(\mathcal{N}(\vect{m}_k\odot \vect{x}_k, \gmat{\Sigma}_{\varepsilon,k})\), masked SSH maps
step 2 algorithms4D-Var (MAP + Laplace) and the EnKF/EnKS
\(q(\cdot; \gvect{\theta}_\text{emu})\)the neural ocean model, with a learned spread
\(q(\cdot; \gvect{\nu})\)the direct analysis, as a conditional density

The ocean page then writes these Gaussians out and works the algorithms through. Everything it assumes beyond this page is a choice of density family.