Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The Observation Model

CSIC
UCM
IGEO

The observation model is the bridge between the state we want to estimate and the data we actually measure. It answers a single question: given a state x\boldsymbol{x}, what observations y\boldsymbol{y} should we expect to see? Everything downstream — the likelihood, the variational cost, the tangent-linear and adjoint operators in 4DVar — is built on top of the observation operator HH.

The code below is deliberately library-agnostic: shapes are pinned with jaxtyping, array operations are written with einx, and the tangent-linear / adjoint operators come for free from JAX autodiff.

The Likelihood Model

We assume Gaussian observation errors,

y=H(x)+ε,εN(0,R),\boldsymbol{y} = H(\boldsymbol{x}) + \boldsymbol{\varepsilon}, \qquad \boldsymbol{\varepsilon} \sim \mathcal{N}(\boldsymbol{0}, \mathbf{R}),

which yields the likelihood p(yx)=N(y;H(x),R)p(\boldsymbol{y} \mid \boldsymbol{x}) = \mathcal{N}(\boldsymbol{y}; H(\boldsymbol{x}), \mathbf{R}). The negative log-likelihood (up to a constant) is the observation cost that appears in every variational problem,

Jobs(x)=12yH(x)R12=12(yH(x))R1(yH(x)).J_{\text{obs}}(\boldsymbol{x}) = \tfrac{1}{2} \, \| \boldsymbol{y} - H(\boldsymbol{x}) \|^2_{\mathbf{R}^{-1}} = \tfrac{1}{2} \, (\boldsymbol{y} - H(\boldsymbol{x}))^\top \mathbf{R}^{-1} (\boldsymbol{y} - H(\boldsymbol{x})).
import einx
from jaxtyping import Array, Float

def obs_cost(
    y:     Float[Array, "M"],
    Hx:    Float[Array, "M"],
    R_inv: Float[Array, "M M"],   # applied matrix-free in practice (see below)
) -> Float[Array, ""]:
    """Jₒᵦₛ = ½ (y - H(x))ᵀ R⁻¹ (y - H(x))."""
    r = y - Hx
    return 0.5 * einx.dot("M, M ->", r, einx.dot("M K, K -> M", R_inv, r))

The Observation Operator

An observation operator is any differentiable map from state space to observation space. That is the entire contract:

from typing import Protocol
from jaxtyping import Array, Float

class ObservationOperator(Protocol):
    def __call__(self, x: Float[Array, "N"]) -> Float[Array, "M"]:
        """Forward map H : state space (N) -> observation space (M)."""
        ...

Four Operator Families

Masked Identity
Linear Projection
Averaging Kernel
Multi-Instrument Fusion

The simplest case observes the state wherever a mask is non-zero:

H(x)=mx,m{0,1}Dx.H(\boldsymbol{x}) = \boldsymbol{m} \odot \boldsymbol{x}, \qquad \boldsymbol{m} \in \{0, 1\}^{D_x}.

Tangent-linear: H(x)=diag(m)H'(\boldsymbol{x}) = \operatorname{diag}(\boldsymbol{m}). Use cases include SSH altimetry along-track gaps, SST under cloud masks, and in-situ samples on a grid.

def masked_identity(
    x:    Float[Array, "N"],
    mask: Float[Array, "N"],   # 0/1 weights
) -> Float[Array, "N"]:
    return einx.multiply("N, N -> N", mask, x)

Tangent-Linear and Adjoint

Incremental 4DVar and the Gauss–Newton inner loop need the tangent-linear operator H(x)H'(\boldsymbol{x}) (forward) and its adjoint H(x)H'(\boldsymbol{x})^\top (backward). Both are autodiff primitives — no hand-coding:

import jax

def tlm(H, x: Float[Array, "N"], dx: Float[Array, "N"]) -> Float[Array, "M"]:
    """Forward tangent-linear:  H'(x) · dx   (a JVP)."""
    _, dy = jax.jvp(H, (x,), (dx,))
    return dy

def adjoint(H, x: Float[Array, "N"], r: Float[Array, "M"]) -> Float[Array, "N"]:
    """Adjoint:  H'(x)ᵀ · r   (a VJP)."""
    _, vjp_fn = jax.vjp(H, x)
    (out,) = vjp_fn(r)
    return out

The adjoint test verifies the inner-product identity

Hu,v=u,(H)vu,v\langle H' \boldsymbol{u}, \boldsymbol{v} \rangle = \langle \boldsymbol{u}, (H')^\top \boldsymbol{v} \rangle \qquad \forall \, \boldsymbol{u}, \boldsymbol{v}

up to floating-point tolerance. A failure means the gradient flowing back through HH is wrong, so downstream optimizers take incorrect steps.

import jax.numpy as jnp

def adjoint_test(H, x, u: Float[Array, "N"], v: Float[Array, "M"]) -> bool:
    lhs = jnp.vdot(tlm(H, x, u), v)        # ⟨H'u, v⟩
    rhs = jnp.vdot(u, adjoint(H, x, v))    # ⟨u, (H')ᵀv⟩
    return bool(jnp.allclose(lhs, rhs))

Observation-Error Covariance R\mathbf{R}

R\mathbf{R} enters JobsJ_{\text{obs}} only through R1\mathbf{R}^{-1}, which is applied lazily (e.g. via conjugate gradients) and never materialized. Common parameterizations:

def apply_R_inv_diag(
    variances: Float[Array, "M"],   # σ²
    r:         Float[Array, "M"],
) -> Float[Array, "M"]:
    """R⁻¹ r for diagonal R — no inverse materialized."""
    return einx.divide("M, M -> M", r, variances)

When the Observation Model Is Wrong

If HH is misspecified — wrong averaging kernel, missing instrument bias, ignored representation error — the MAP estimate x\boldsymbol{x}^\star converges to the wrong answer with a confident-looking posterior. Three diagnostics catch this: