Covariance flavours for the matched filter¶
The MF score formula is agnostic to the structure of — but the right choice of estimator makes the difference between a detector that works and one that fires on every surface reflectance feature. This notebook runs four covariance models on a simulated two-surface scene (high-albedo + low-albedo halves with a plume straddling the boundary) and shows how each one changes the detection map.
Same HAPI methane LUT as 01_mf_vs_3dvar.ipynb. Four flavours:
Empirical — sample covariance with a small ridge.
Ledoit–Wolf shrunk — shrinkage toward a scaled identity, PD even when .
Low-rank + Tikhonov — from sklearn’s randomised SVD, Woodbury-solved via
gaussx.LowRankUpdate.Cluster-specific (GMM) — one per GMM component, so each surface type gets its own background statistics.
import time
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
jax.config.update("jax_enable_x64", True)
from plumax.assimilation import RadianceObservationModel
from plumax.matched_filter import (
apply_image,
estimate_cov_empirical,
estimate_cov_lowrank,
estimate_cov_shrunk,
estimate_mean,
gmm_cluster_background,
nonlinear_target_from_obs,
)
from plumax.radtran.srf import SpectralResponseFunction1. Build the forward operator (same HAPI LUT as the 3D-Var notebook)¶
# Built by hapi_lut/01_hapi_lut_ch4.ipynb into a per-user cache.
LUT_PATH = Path.home() / ".cache" / "plumax" / "hapi_lut" / "ch4_absorption_lut.nc"
lut_full = xr.open_dataset(LUT_PATH)
lut = lut_full.isel(wavenumber=slice(None, None, 100))
nu = lut["wavenumber"].values
wl = 1e7 / nu
sort = np.argsort(wl)
srf = SpectralResponseFunction(
wavelengths_hr_nm=wl[sort],
band_centers_nm=wl[sort],
band_widths_nm=np.full(wl.size, 5.0),
band_names=tuple(f"c{i}" for i in range(wl.size)),
srf_type="gaussian",
)
obs = RadianceObservationModel.from_lut(
lut,
srf=srf,
T_K=280.0,
p_atm=1.0,
path_length_cm=8.4e5,
amf=2.0,
vmr_reference=0.0,
)2. Synthesise a heterogeneous scene¶
Two surface types (bright, dark) with distinct reflectance spectra, plus a compact rectangular plume that crosses the boundary. The key point: a global mean/covariance will smear the two surface populations together; only the GMM flavour can represent them separately.
rng = np.random.default_rng(0)
ny, nx = 40, 64
# Two-surface reflectance pattern: left half bright, right half dark, with
# band-to-band variation (random but reproducible).
reflectance_bright = 1.0 + 0.15 * rng.standard_normal(obs.n_bands)
reflectance_dark = 0.6 + 0.08 * rng.standard_normal(obs.n_bands)
surface_map = np.zeros((ny, nx), dtype=int)
surface_map[:, nx // 2 :] = 1 # right half = dark
reflectance_cube = np.where(
surface_map[..., None] == 0,
reflectance_bright[None, None, :],
reflectance_dark[None, None, :],
)
# Inject a 12×8 plume at 3 ppm peak that straddles both surfaces.
plume_amp = np.zeros((ny, nx), dtype=float)
plume_amp[15:27, 22:38] = 3e-6
vmr_field = jnp.asarray(plume_amp)
# Forward model: apply Beer–Lambert to a "surface radiance × exp(−τ)" proxy.
# We multiply the per-pixel reflectance spectrum by the methane transmittance.
forward = obs.make_forward(linear=False)
transmittance_at_plume = np.asarray(
forward(vmr_field) / forward(jnp.zeros_like(vmr_field))
)
# transmittance_at_plume has shape (ny, nx, n_bands). When VMR=0 it is all 1.
radiance_clean = reflectance_cube * transmittance_at_plume
noise_std = 0.02 * radiance_clean.mean()
observed = radiance_clean + noise_std * rng.standard_normal(radiance_clean.shape)
print(f"scene {ny}×{nx}, {obs.n_bands} bands, noise_std {noise_std:.3e}")scene 40×64, 100 bands, noise_std 1.587e-02
3. Target signature¶
The target is the transmittance spectral derivative — it is surface-albedo-independent in the linear approximation, so we compute it with the zero-VMR background. For this comparison we use the nonlinear target at 3 ppm (matches the injected peak); in the linear regime the choice barely matters.
# Transmittance derivative ∂T/∂α at the zero-VMR background — negative for
# methane absorption bands because more VMR → less radiance.
dT_dalpha = np.asarray(
nonlinear_target_from_obs(
obs, jnp.zeros((ny, nx)), amplitude=3e-6, pattern="uniform"
)
/ 3e-6
)
# The observed radiance model is ``x(i,j) = ρ(i,j) · T(α) + noise``.
# Its ∂/∂α is ``ρ(i,j) · ∂T/∂α``. We use the scene-average reflectance for the
# global MF, and the per-cluster reflectance for the GMM MF.
scene_mean_reflectance = observed.reshape(-1, obs.n_bands).mean(axis=0)
t_global = jnp.asarray(scene_mean_reflectance * dT_dalpha)4. Run all four covariance estimators¶
mu_global = estimate_mean(observed, method="median")
timings = {}
scores = {}
# Empirical
t0 = time.perf_counter()
cov_emp = estimate_cov_empirical(observed, mean=mu_global, ridge=1e-6)
scores["Empirical"] = np.asarray(
apply_image(
jnp.asarray(observed),
mean=jnp.asarray(mu_global),
cov_op=cov_emp,
target=t_global,
)
)
timings["Empirical"] = time.perf_counter() - t0
# Ledoit–Wolf shrunk
t0 = time.perf_counter()
cov_lw = estimate_cov_shrunk(observed, mean=mu_global, method="ledoit_wolf")
scores["Ledoit–Wolf"] = np.asarray(
apply_image(
jnp.asarray(observed),
mean=jnp.asarray(mu_global),
cov_op=cov_lw,
target=t_global,
)
)
timings["Ledoit–Wolf"] = time.perf_counter() - t0
# Low-rank + Tikhonov (rank 8 captures the surface-type split plus a few extras)
t0 = time.perf_counter()
cov_lr = estimate_cov_lowrank(
observed, mean=mu_global, rank=8, tikhonov=noise_std**2, random_state=0
)
scores["Low-rank (rank 8)"] = np.asarray(
apply_image(
jnp.asarray(observed),
mean=jnp.asarray(mu_global),
cov_op=cov_lr,
target=t_global,
)
)
timings["Low-rank (rank 8)"] = time.perf_counter() - t0
# GMM cluster background — 2 components for the two surface types.
t0 = time.perf_counter()
clusters = gmm_cluster_background(
observed, n_clusters=2, cov_estimator="ledoit_wolf", random_state=0
)
gmm_scores = np.zeros((ny, nx))
# Per-cluster target: match the cluster's reflectance scaling.
reflectances = [reflectance_bright, reflectance_dark]
for k, (mu_k, cov_k) in enumerate(
zip(clusters.means, clusters.cov_operators, strict=False)
):
mask = clusters.labels == k
if not mask.any():
continue
# Pick the reflectance closest to this cluster's mean.
# (Avoids assuming which label GMM assigned to bright vs dark.)
idx = int(np.argmin([np.linalg.norm(mu_k - r) for r in reflectances]))
t_k = jnp.asarray(reflectances[idx] * dT_dalpha)
# Apply the MF to the whole cube with this (μ_k, Σ_k, t_k), then mask.
score_k = np.asarray(
apply_image(
jnp.asarray(observed), mean=jnp.asarray(mu_k), cov_op=cov_k, target=t_k
)
)
gmm_scores[mask] = score_k[mask]
scores["GMM (per-surface)"] = gmm_scores
timings["GMM (per-surface)"] = time.perf_counter() - t0
for name, dt in timings.items():
print(f"{name:22s} {dt * 1000:6.1f} ms")Empirical 688.3 ms
Ledoit–Wolf 88.3 ms
Low-rank (rank 8) 1411.8 ms
GMM (per-surface) 517.2 ms
5. Score maps¶
Each panel shows the MF score on the same colour scale. The plume rectangle is outlined in black. Look for:
How clean the off-plume background is (ideally flat and zero — high contrast = weak detector).
How uniform the on-plume score is across the vertical midline (the surface-type boundary). A global covariance will show a discontinuity at the boundary because and are wrong on one of the two surfaces; per-cluster estimation should not.
from matplotlib.patches import Rectangle
fig, axes = plt.subplots(2, 2, figsize=(12, 9))
vmax = float(np.max([s.max() for s in scores.values()]))
vmin = -vmax * 0.3
for ax, (name, field) in zip(axes.flat, scores.items(), strict=False):
im = ax.imshow(field, origin="lower", cmap="RdBu_r", vmin=vmin, vmax=vmax)
ax.add_patch(
Rectangle(
(22 - 0.5, 15 - 0.5), 16, 12, fill=False, edgecolor="black", linewidth=1.5
)
)
# Mark the surface-type boundary.
ax.axvline(nx / 2 - 0.5, color="grey", ls="--", lw=0.8)
ax.set_title(f"{name}\n(compute: {timings[name] * 1000:.1f} ms)")
ax.set_xlabel("x (pixel)")
ax.set_ylabel("y (pixel)")
plt.colorbar(im, ax=ax, shrink=0.8, label="MF score (α)")
fig.tight_layout()
plt.show()
6. Quantitative detection metrics¶
For each flavour, compute:
Contrast = (mean score inside plume) − (mean score outside plume).
Off-plume std = spatial std of the score outside the plume rectangle — this approximates the detection noise floor.
Detection SNR = contrast / off-plume std.
The GMM flavour should have the lowest off-plume std because it does not confuse surface-type variation for a plume signal. The low-rank flavour should have comparable SNR at a fraction of the precompute cost.
in_plume = np.zeros((ny, nx), dtype=bool)
in_plume[15:27, 22:38] = True
off_plume = ~in_plume
truth_peak = 3e-6
print(
f"{'Flavour':22s} {'contrast':>12s} {'off-σ':>12s} {'det-SNR':>8s} {'bias@peak':>12s}"
)
for name, field in scores.items():
c = field[in_plume].mean() - field[off_plume].mean()
s = field[off_plume].std()
snr = c / s if s > 0 else float("inf")
bias = field[in_plume].mean() - truth_peak
print(f"{name:22s} {c:12.2e} {s:12.2e} {snr:8.1f} {bias:12.2e}")Flavour contrast off-σ det-SNR bias@peak
Empirical 2.67e-06 2.49e-07 10.7 -5.40e-07
Ledoit–Wolf 2.68e-06 2.43e-07 11.0 -5.31e-07
Low-rank (rank 8) 2.72e-06 2.39e-07 11.4 -4.91e-07
GMM (per-surface) 2.78e-06 2.02e-07 13.8 -4.32e-07
7. Take-aways¶
Empirical and Ledoit–Wolf are almost indistinguishable on this well-populated scene (2560 pixels, 100 bands → ). Shrinkage matters when that inequality reverses.
Low-rank + Tikhonov produces a cleaner off-plume background at a fraction of the precompute cost (typically 5–20× faster on the covariance estimation and apply). The
gaussx.LowRankUpdate/ Woodbury path is the right default for hyperspectral scenes where the spectral covariance is approximately rank ~O(10).GMM per-surface gives the best detection SNR here because it removes the surface-type population as a nuisance component — the bright/dark boundary no longer leaks into the “plume” direction. The cost is more sensitive to the number of components and the GMM fit quality; for production one typically runs
BayesianGaussianMixtureso the effective rank is learned.All four succeed at localising the plume — the failure modes are in (i) false-positive rate on the wrong-surface half and (ii) amplitude bias from using the wrong reflectance in the target. The GMM approach fixes both at once by making both and cluster-local.
The unifying point: switching the covariance model means swapping one call to an estimator function. The MF kernel itself — apply_image — is a single implementation that dispatches through gaussx.solve.