Primitives¶
Pure functions on arrays and operators. Stateless, differentiable,
composable; compatible with jax.jit, jax.grad, and eqx.filter_vmap.
Every filter and smoother in the package is assembled from these.
Heavy linear algebra is delegated to
gaussx:
ensemble_covariance returns a
gaussx.LowRankUpdate operator (rank N_e, never a dense (N_x, N_x)
matrix), and the solves inside kalman_gain and
log_likelihood dispatch through gaussx's
structural solver — a Woodbury / matrix-determinant-lemma path for low-rank
updates — with an optional solver= keyword to override the strategy.
Localization tapers and inflation primitives have their own pages: Localization and Inflation.
Ensemble statistics¶
Bessel-corrected moments of an (N_e, N_x) ensemble: mean, centred
anomalies, and the (cross-)covariances built from them.
filterax — differentiable ensemble Kalman filters and processes for JAX.
ensemble_mean(particles: Float[Array, 'N_e N_x']) -> Float[Array, ' N_x']
¶
Ensemble mean x̄ = Nₑ⁻¹ Σⱼ x⁽ʲ⁾.
Computed as an einx reduction over the ensemble axis. O(Nₑ Nₓ).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Ensemble of shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' N_x']
|
Mean vector of shape |
Examples:
>>> import jax.numpy as jnp
>>> from filterax import ensemble_mean
>>> ens = jnp.array([[0.0, 1.0], [2.0, 3.0]])
>>> ensemble_mean(ens)
Array([1., 2.], dtype=float32)
Source code in src/filterax/_primitives/_statistics.py
ensemble_anomalies(particles: Float[Array, 'N_e N_x']) -> Float[Array, 'N_e N_x']
¶
Centred perturbations X′ = X − 𝟙 x̄ᵀ.
The rows of X′ sum to zero by construction. X′ is the
fundamental input to the cross-covariance and Kalman-gain recipes:
every ensemble Kalman filter is some choice of square root applied to
these anomalies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Ensemble of shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'N_e N_x']
|
Centred anomaly matrix of shape |
Examples:
>>> import jax.numpy as jnp
>>> from filterax import ensemble_anomalies
>>> ens = jnp.array([[0.0, 1.0], [2.0, 3.0]])
>>> ensemble_anomalies(ens) # rows sum to zero
Array([[-1., -1.],
[ 1., 1.]], dtype=float32)
Source code in src/filterax/_primitives/_statistics.py
ensemble_covariance(particles: Float[Array, 'N_e N_x']) -> gaussx.LowRankUpdate
¶
Sample covariance P = (Nₑ − 1)⁻¹ X′ᵀ X′ as a low-rank operator.
Delegates to :func:gaussx.ensemble_covariance with the EnKF Bessel
convention. Returns a :class:gaussx.LowRankUpdate of rank
≤ Nₑ − 1; the dense (Nₓ, Nₓ) matrix is never materialised.
Downstream solve / logdet calls exploit the low-rank structure
via the Woodbury identity and matrix-determinant lemma — see
:mod:gaussx dispatch.
Complexity: O(Nₑ Nₓ) to construct; structure-aware solves cost
O(Nₑ² Nₓ + Nₑ³) instead of O(Nₓ³).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Ensemble of shape |
required |
Returns:
| Type | Description |
|---|---|
LowRankUpdate
|
class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import gaussx
>>> import jax.numpy as jnp
>>> from filterax import ensemble_covariance
>>> ens = jnp.array([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]])
>>> P = ensemble_covariance(ens)
>>> isinstance(P, gaussx.LowRankUpdate)
True
>>> P.as_matrix().shape
(2, 2)
Source code in src/filterax/_primitives/_statistics.py
cross_covariance(particles: Float[Array, 'N_e N_x'], obs_particles: Float[Array, 'N_e N_y']) -> Float[Array, 'N_x N_y']
¶
Cross-covariance Cˣᴴ = (Nₑ − 1)⁻¹ X′ᵀ (HX)′.
Returned as a dense (Nₓ, Nᵧ) array because Nᵧ is typically
small (instrument footprint, point observations). For nonlinear
observation operators this is the ensemble's implicit derivative-free
linearisation of ∇H — see Evensen (2003) §5.
Complexity: O(Nₑ Nₓ Nᵧ).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'N_e N_y']
|
Prior ensemble mapped to obs space, shape
|
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'N_x N_y']
|
Dense cross-covariance of shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import jax.numpy as jnp
>>> from filterax import cross_covariance
>>> particles = jnp.array([[0.0, 0.0], [2.0, 2.0]])
>>> obs_particles = jnp.array([[0.0], [1.0]])
>>> cross_covariance(particles, obs_particles)
Array([[1.],
[1.]], dtype=float32)
Source code in src/filterax/_primitives/_statistics.py
Kalman gain¶
For the Schur-tapered variant that suppresses spurious long-range
correlations, see
localized_kalman_gain on the
Localization page.
filterax — differentiable ensemble Kalman filters and processes for JAX.
kalman_gain(particles: Float[Array, 'N_e N_x'], obs_particles: Float[Array, 'N_e N_y'], obs_noise: lx.AbstractLinearOperator, *, solver: gaussx.AbstractSolverStrategy | None = None) -> Float[Array, 'N_x N_y']
¶
Ensemble Kalman gain K = Cˣᴴ (Cᴴᴴ + R)⁻¹.
Thin wrapper over :func:gaussx.ensemble_kalman_gain with the EnKF
Bessel convention. The innovation covariance S = Cᴴᴴ + R is
assembled as a :class:gaussx.LowRankUpdate so structural dispatch
routes the solve through the Woodbury identity:
Total cost
O(Nₑ² Nᵧ + Nₑ³) for low-rank Woodbury, instead of O(Nᵧ³) for
the dense fallback. The Woodbury route is not unconditional: from
gaussx v0.6.5, :func:gaussx.ensemble_kalman_gain forms the (Nᵧ, Nᵧ)
innovation densely and solves it directly when Nₑ ≥ Nᵧ, and uses
Woodbury only otherwise; the two routes agree to rounding. On the
Woodbury route the dense (Nᵧ, Nᵧ) matrix is never materialised when
R carries structure (diagonal, low-rank, Toeplitz, …).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'N_e N_y']
|
|
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance |
required |
solver
|
AbstractSolverStrategy | None
|
Optional :class: |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N_x N_y']
|
Dense Kalman gain of shape |
Float[Array, 'N_x N_y']
|
intentional — it is consumed once by the analysis update and is |
Float[Array, 'N_x N_y']
|
usually small ( |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> from filterax import kalman_gain
>>> particles = jnp.array([[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])
>>> obs_particles = particles[:, :1] # H observes the first component
>>> R = lx.DiagonalLinearOperator(0.5 * jnp.ones(1))
>>> kalman_gain(particles, obs_particles, R)
Array([[0.6666666],
[0. ]], dtype=float32)
Source code in src/filterax/_primitives/_gain.py
Likelihood & innovation diagnostics¶
The innovation covariance S = Cᴴᴴ + R as a low-rank update, the Gaussian
log-likelihood of an innovation under it, and the bundled per-cycle
innovation statistics.
filterax — differentiable ensemble Kalman filters and processes for JAX.
InnovationStatistics
¶
Bases: TypedDict
Return type of :func:innovation_statistics.
Keys
innovation: v = y − H x̄.
innovation_cov: S = Cᴴᴴ + R as a :class:gaussx.LowRankUpdate.
normalized_innovation: S⁻¹ᐟ² v (whitened residual).
log_likelihood: Scalar Gaussian log-probability of v under S.
Source code in src/filterax/_primitives/_likelihood.py
log_likelihood(innovation: Float[Array, ' N_y'], innovation_cov: lx.AbstractLinearOperator, *, solver: gaussx.AbstractSolverStrategy | None = None) -> Float[Array, '']
¶
Gaussian log-probability of an innovation vector.
where v is the innovation and S the innovation covariance.
Both log|S| and S⁻¹ v flow through :mod:gaussx dispatch —
when S is a :class:gaussx.LowRankUpdate (the standard case),
log-determinants use the matrix-determinant lemma and solves use
Woodbury, both at O(Nₑ² Nᵧ + Nₑ³).
This is the training signal for differentiable data assimilation: the
gradient of this scalar with respect to dynamics or observation-model
parameters provides an end-to-end learning loop without ever forming
S explicitly. See Decision D9 in the design docs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
innovation
|
Float[Array, ' N_y']
|
Innovation |
required |
innovation_cov
|
AbstractLinearOperator
|
Innovation covariance |
required |
solver
|
AbstractSolverStrategy | None
|
Optional :class: |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar log-probability. |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> from filterax import log_likelihood
>>> innovation = jnp.array([0.5, -0.5])
>>> S = lx.DiagonalLinearOperator(jnp.ones(2))
>>> log_likelihood(innovation, S)
Array(-2.087877, dtype=float32)
Source code in src/filterax/_primitives/_likelihood.py
innovation_covariance(obs_particles: Float[Array, 'N_e N_y'], obs_noise: lx.AbstractLinearOperator) -> gaussx.LowRankUpdate
¶
Innovation covariance S = Cᴴᴴ + R as a low-rank update.
Wraps :func:gaussx.ensemble_covariance (Bessel-corrected) to build
the low-rank factor U = (HX)′ᵀ / √(Nₑ − 1) and composes it with the
structured R base. The result is a :class:gaussx.LowRankUpdate
so all downstream linear algebra (solve, logdet, sample, …) takes
advantage of the rank-(Nₑ − 1) ensemble term without densifying.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obs_particles
|
Float[Array, 'N_e N_y']
|
|
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance |
required |
Returns:
| Type | Description |
|---|---|
LowRankUpdate
|
class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> from filterax import innovation_covariance
>>> obs_particles = jnp.array([[0.0], [1.0]]) # Cᴴᴴ = 0.5
>>> R = lx.DiagonalLinearOperator(0.1 * jnp.ones(1))
>>> S = innovation_covariance(obs_particles, R)
>>> S.as_matrix() # 0.5 + 0.1
Array([[0.6]], dtype=float32)
Source code in src/filterax/_primitives/_likelihood.py
innovation_statistics(particles: Float[Array, 'N_e N_x'], obs: Float[Array, ' N_y'], obs_op: Callable[[Float[Array, ' N_x']], Float[Array, ' N_y']], obs_noise: lx.AbstractLinearOperator, *, solver: gaussx.AbstractSolverStrategy | None = None) -> InnovationStatistics
¶
Innovation diagnostics for a forecast ensemble.
Returns v, S, the whitened residual S⁻¹ᐟ² v, and
log p(y | forecast). The whitened residual is useful for χ² /
Desroziers-style consistency checks (under correctly-specified S
each component is unit-variance Gaussian).
The whitening solve goes through S.root_inv_decomposition() —
structural dispatch in gaussx picks either Woodbury for
:class:gaussx.LowRankUpdate or Cholesky for dense S, so the
dense matrix is not materialised in the common case.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'N_e N_x']
|
Forecast ensemble, shape |
required |
obs
|
Float[Array, ' N_y']
|
Observation vector, shape |
required |
obs_op
|
Callable[[Float[Array, ' N_x']], Float[Array, ' N_y']]
|
|
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy (used for both logdet and solve). |
None
|
Returns:
| Type | Description |
|---|---|
InnovationStatistics
|
class: |
InnovationStatistics
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> from filterax import innovation_statistics
>>> particles = jnp.array([[0.0, 0.0], [1.0, 1.0]])
>>> obs = jnp.array([0.5])
>>> R = lx.DiagonalLinearOperator(jnp.ones(1))
>>> stats = innovation_statistics(particles, obs, lambda x: x[:1], R)
>>> stats["innovation"] # y − H x̄ = 0.5 − 0.5
Array([0.], dtype=float32)
>>> stats["log_likelihood"]
Array(-1.1216711, dtype=float32)
Source code in src/filterax/_primitives/_likelihood.py
Perturbed observations¶
Stochastic-EnKF observation perturbations, drawn with an explicit PRNG key.
filterax — differentiable ensemble Kalman filters and processes for JAX.
perturbed_observations(key: PRNGKeyArray, obs: Float[Array, ' N_y'], obs_noise: lx.AbstractLinearOperator, n_ensemble: int) -> Float[Array, 'N_e N_y']
¶
Generate perturbed observations for the stochastic EnKF.
Sampling delegates to :func:gaussx.sample_mvn. Every path draws
ε = L z with z ~ 𝒩(0, I) and L Lᵀ = R exactly, and only the
factor depends on the structure of R:
- Diagonal
R:ε = √diag(R) ⊙ z. No matrix is ever formed; cost isO(Nₑ Nᵧ). gaussxblock-diagonal, Kronecker, Toeplitz and low-rank-updateR: a factor that keeps the structure, avoiding the cubic cost.- Any other
R: a dense symmetric square root. It costsO(Nᵧ³)once per call and also handles a singularR.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
PRNGKeyArray
|
PRNG key consumed by this call. |
required |
obs
|
Float[Array, ' N_y']
|
Observation vector |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance |
required |
n_ensemble
|
int
|
Number of perturbed copies |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'N_e N_y']
|
Perturbed observation matrix of shape |
Examples:
>>> import jax
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> from filterax import perturbed_observations
>>> obs = jnp.array([1.0, 2.0])
>>> R = lx.DiagonalLinearOperator(0.1 * jnp.ones(2))
>>> y_pert = perturbed_observations(jax.random.key(0), obs, R, 4)
>>> y_pert.shape
(4, 2)
Reference
Burgers, G., van Leeuwen, P. J., & Evensen, G. (1998). Analysis scheme in the ensemble Kalman filter. Mon. Wea. Rev., 126, 1719–1724.