Observation Operators¶
Chapter 2 introduced the observation model — the likelihood
\(p(y \mid x) = \mathcal{N}(H(x), R)\) and the ObservationOperator
protocol. This chapter goes deeper into the operator family vardax
ships: masked identity, linear projection, averaging kernel, and
multi-instrument fusion.
All four satisfy pipekit_cycle.ObservationOperator (the first three
directly; multi-instrument fusion via its .to_observation_operator()
adapter — chapter 2). All four expose __call__(x) and linearize(x),
with the tangent-linear / adjoint contract enforced by the planned
tests/test_pipekit_protocols.py conformance suite (Epic 1; not yet
present in the v0.1.6 codebase).
Masked identity¶
from vardax import MaskedIdentity
obs_op = MaskedIdentity()
y_pred = obs_op(x, mask=batch.mask)
H_lin = obs_op.linearize(x) # diag(m), via lineax.JacobianLinearOperator
Use case: SSH altimetry along-track gaps, SST with cloud masks, sparse in-situ samples on a grid.
The mask can be soft (\(m_i \in [0, 1]\)) for fuzzy observations — the operator still works, but the obs-error covariance interpretation changes (a soft mask implicitly inflates \(R\) where \(m\) is small).
Linear projection¶
from vardax import LinearObs
obs_op = LinearObs(operator=footprint_op) # AbstractLinearOperator
y_pred = obs_op(x)
H_lin = obs_op.linearize(x) # the operator itself
Use cases:
- Lagrangian footprint — particle simulator produces a footprint
matrix mapping source field to receptor observation. The matrix is
often sparse (only cells with non-zero footprint contribute) — wrap
as a
lineax.SparseLinearOperator. - Spectral filtering — observe specific frequency bands. Store
\(H_\text{mat} = F^{-1} S F\) as a composition of
lineaxoperators. - Spatial interpolation — bilinear / bicubic projection from grid to observation points. The interpolation matrix is sparse.
The matrix is stored as AbstractLinearOperator so structure
(sparsity, low-rank, Kronecker) can be exploited without dense
materialisation.
Averaging kernel¶
The averaging kernel is the right model for RTM-derived L2 satellite products. Chapter 2 introduced it; here we go deeper into structure and validation.
The math, restated¶
The kernel matrix \(A\) smooths the model state through the retrieval's sensitivity profile. The weighting vector \(h\) tells you how much of the L2 signal comes from the atmosphere (\(h \to 1\) in the lower troposphere for column CH₄) versus the retrieval prior (\(h \to 0\) in the stratosphere). The fallback prior \(x_a\) is what the retrieval assumes outside the observed information content.
Tangent-linear: \(H'(x) = A \cdot \mathrm{diag}(h)\). The structure of \(H'\) is the structure of \(A\) — sparse, Kronecker, low-rank, etc. — inherited cleanly.
Constructing from L2 metadata¶
L2 products typically include the averaging-kernel matrix, prior, and weighting as sidecar metadata in the netCDF / HDF5 file:
import xarray as xr
import lineax as lx
from vardax import AveragingKernel
def averaging_kernel_from_l2(ds: xr.Dataset) -> AveragingKernel:
return AveragingKernel(
kernel_matrix=lx.MatrixLinearOperator(jnp.asarray(ds.averaging_kernel.values)),
prior_profile=jnp.asarray(ds.retrieval_prior.values),
pressure_weights=jnp.asarray(ds.weighting_vector.values),
)
tropomi = averaging_kernel_from_l2(tropomi_ds)
Structured kernels¶
For separable kernels (e.g. independent vertical and horizontal smoothing), use Kronecker structure:
import gaussx as gx
A_kron = gx.KroneckerLinearOperator([
gx.MatrixLinearOperator(vertical_kernel),
gx.MatrixLinearOperator(horizontal_kernel),
])
ak = AveragingKernel(kernel_matrix=A_kron, prior_profile=xa, pressure_weights=h)
Kronecker structure means \(A\) never materialised; mat-vec \(\mathrm{vec}(A \cdot X)\) becomes two small mat-vecs instead of one giant one.
For low-rank kernels (information content \(\le\) rank), use low-rank operators:
Both saves memory and respects the underlying retrieval physics (retrievals with low DOF should have low-rank \(A\)).
Why skipping the AK is wrong¶
Treating an L2 retrieval as \(y = x + \varepsilon\) (instead of \(y = A(h \cdot x + (1-h) x_a) + \varepsilon\)) gives systematically biased posteriors when \(x_a \ne x_b\). The retrieval folds in information from \(x_a\) that we then attribute to the data. The MAP is pulled toward \(x_a\).
The bias is largest when:
- Information content is low (rank-deficient \(A\), large \(1-h\) fraction)
- \(x_a\) differs from \(x_b\) (e.g. inventory prior vs. climatology)
- Long inversion windows compound the bias
The fix is to always use AveragingKernel. The cost is one extra
matrix mat-vec per observation — negligible compared to forward-model
cost.
Multi-instrument fusion¶
For multi-satellite inversions:
from vardax import (
MultiInstrumentFusion, InstrumentRegistry, InstrumentSpec,
)
tropomi_spec = InstrumentSpec(
obs_op=AveragingKernel(kernel_matrix=A_t, prior_profile=xa_t, pressure_weights=h_t),
mask=tropomi_qa,
obs_cov_op=lx.DiagonalLinearOperator(tropomi_uncertainty),
instrument_id="TROPOMI",
)
emit_spec = InstrumentSpec(
obs_op=AveragingKernel(kernel_matrix=A_e, prior_profile=xa_e, pressure_weights=h_e),
mask=emit_qa,
obs_cov_op=lx.DiagonalLinearOperator(emit_uncertainty),
instrument_id="EMIT",
)
fusion = MultiInstrumentFusion(
registry=InstrumentRegistry(entries={
"TROPOMI": tropomi_spec,
"EMIT": emit_spec,
}),
)
# Returns dict[str, Array] — per-instrument predicted observations
predictions = fusion(x, batch)
The fusion happens at the likelihood level: each instrument contributes a term to \(J_\text{obs}\), with its own \(H_i\), \(m_i\), \(R_i\). No pre-regridding to a common grid. No assumption of independent vs. correlated cross-instrument errors (the default is independent; correlated errors require block-non-diagonal \(R\)).
Per-pixel instrument index on Batch* selects the operator. Quality
masks zero-weight bad pixels — they contribute zero log-likelihood but
remain in the audit count.
Cross-instrument bias¶
Real instruments disagree systematically. TROPOMI and GHGSat differ by \(\sim 5\)–\(20\) ppb on methane retrievals due to spectral calibration, retrieval algorithm differences, and footprint effects. Pretending they agree biases the joint analysis.
For joint inversion with bias estimation (Epic 9), introduce a per-instrument bias state \(b_i\):
class BiasedFusion(eqx.Module):
base: MultiInstrumentFusion
bias_prior: dict[str, GaussianPrior] # b_i ~ N(0, σ_b²)
def __call__(self, x, bias_dict, batch):
return {
inst_id: self.base.registry.entries[inst_id].obs_op(x) + bias_dict[inst_id]
for inst_id in self.base.registry.entries
}
b_i becomes a joint state variable; the analysis solves for \((x,
\boldsymbol{b})\) together. The bias prior tames the
underdeterminacy. Hierarchical priors per (instrument, basin, season)
are the operational pattern.
This is not yet shipped in vardax; the design is documented for Epic 9.
Scattered point observations¶
Along-track altimetry, drifters and station networks deliver observations
at scattered coordinates and times, not on the state grid. Scattering them
into a mostly-NaN grid would discard their positions and force one \(R\)
for all times. A PointObsBatch carries them as they are, padded to a
fixed count \(n_\text{obs}\) so shapes stay static under jit:
| Field | Shape | Meaning |
|---|---|---|
coords |
(N_obs, D) |
\(s_i\), e.g. (x,), (y, x) or (lon, lat, t) |
values |
(N_obs,) |
\(y_i\) |
sigma |
(N_obs,) |
per-observation error std \(\sigma_i\) |
valid |
(N_obs,) |
\(m_i\), False on padding |
time_index |
(N_obs,) or None |
window step \(\tau_i\) for gridded-state models |
n_times |
static int |
states in the window (rollout steps + 1) |
The observation cost is
Padding rows contribute exactly zero value and zero gradient.
point_obs_cost masks the whitened residual with jnp.where before
squaring, and first replaces the padding's coordinates, values and
\(\sigma\) with finite placeholders. Multiplying by \(m_i\) after squaring
would not be enough: \(0 \cdot \text{NaN} = \text{NaN}\) in the gradient of
a padding row whose value or \(\sigma = 0\) is garbage.
Two operators evaluate \(\mathcal H(x)\) at the points:
GridPointObs(grid_axes): for a gridded state. The state has a leading time axis, either a 4D-Var trajectory \((x_0, \dots, x_T)\) or a 3D-Var state(T, N). Observation \(i\) reads \(x_{\tau_i}\) and interpolates it linearly (bilinearly on a 2-D grid) at \(s_i\), clamped at the grid edge. This isInterpObs'sorder=1stencil, computed at call time so that the coordinates may be traced.FieldPointObs(): for a state that is a callable field \(f(s)\), such as a neural field. It returns \(\hat y_i = f(s_i)\) and passes allDcolumns to the field, a time column included.
ThreeDVar, StrongFourDVar, WeakFourDVar, ReducedBasisFourDVar and
IncrementalFourDVar choose their path from the batch type. A
PointObsBatch needs obs_op=GridPointObs(...), and the per-observation
\(\sigma_i\) replaces obs_cov_op, which must be None (or an identity
operator; anything else raises ValueError rather than being silently
ignored). 4D-Var models compare observation \(i\)
with the trajectory state at step \(\tau_i\), so \(\mathcal H_t\) and \(R_t\)
can change from step to step. ThreeDVar also accepts FieldPointObs
together with a NeuralFieldParameterization, which evaluates the field
at the observation coordinates without a grid. A leading batch axis on
the batch's arrays is optional.
import vardax
batch = vardax.PointObsBatch(
coords=track_xy, values=ssh, sigma=ssh_err, valid=valid,
time_index=step_of_obs, n_times=T + 1,
)
model = vardax.StrongFourDVar(
forward=qg_model, obs_op=vardax.GridPointObs((y_axis, x_axis)),
prior_mean=x_b, prior_cov_op=B_op, obs_cov_op=None,
)
x0_star = model(batch)
When the points cover every grid cell, the point cost equals the masked
grid cost with \(R = \mathrm{diag}(\sigma^2)\). The analyses then agree, and
the linear-Gaussian agreement with OptimalInterpolation holds for point
batches too (tests/test_point_observations.py).
Adjoint test¶
Every observation operator's linearize() must pass the adjoint test:
Implementation:
def adjoint_test(obs_op, x, key, n_probes=10):
H_lin = obs_op.linearize(x)
for _ in range(n_probes):
u = jax.random.normal(key, x.shape)
v = jax.random.normal(key, H_lin(u).shape)
forward_inner = jnp.dot(H_lin @ u, v)
backward_inner = jnp.dot(u, H_lin.T @ v)
assert jnp.allclose(forward_inner, backward_inner, atol=1e-5)
The test is in tests/test_pipekit_protocols.py and runs against every
operator. Failure means the gradient computation through that operator
is wrong; any 4DVar built on it will take wrong steps.
See also¶
- Chapter 2 — observation model + ObservationOperator protocol
- Chapter 8 — incremental 4DVar uses
linearize()for tangent-linear obs - Chapter 17 — methane example exercises multi-instrument fusion
- Design doc:
design/api/observation_operators.md - Design doc:
design/decisions.md#d9