Bayesian Inference & Ensembles¶
Layer 3 recipes for conjugate updates, second-order variational steps, and ensemble data assimilation. All covariances are operators, so the updates inherit structured solves; all stochastic routines take explicit PRNG keys.
Bayesian linear regression¶
Closed-form Gaussian posterior updates — full covariance or diagonal-only — plus the marginal likelihood and expected log-likelihood that score them.
Structured linear algebra and Gaussian primitives for JAX.
blr_full_update(nat1: Float[Array, ' d'], nat2: Float[Array, 'd d'], grad: Float[Array, ' d'], hessian: Float[Array, 'd d'], lr: float, *, solver: AbstractSolverStrategy | None = None) -> tuple[Float[Array, ' d'], Float[Array, 'd d']]
¶
Full-rank natural parameter BLR update step.
Computes the damped update for full-rank variational parameters:
nat2_{new} = (1 - lr) \cdot nat2 + lr \cdot (-\tfrac{1}{2}(-H))
\mu = solve(-2 \cdot nat2, nat1)
nat1_{new} = (1 - lr) \cdot nat1 + lr \cdot (grad - H \mu)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nat1
|
Float[Array, ' d']
|
Current natural location, shape |
required |
nat2
|
Float[Array, 'd d']
|
Current natural precision matrix (eta2), shape |
required |
grad
|
Float[Array, ' d']
|
Gradient of log-likelihood, shape |
required |
hessian
|
Float[Array, 'd d']
|
Hessian of log-likelihood (negative for log-concave),
shape |
required |
lr
|
float
|
Learning rate / damping factor. |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for structured linear algebra.
When |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ' d'], Float[Array, 'd d']]
|
Tuple |
Source code in src/gaussx/_inference/_blr.py
blr_diag_update(nat1: Float[Array, ' d'], nat2_diag: Float[Array, ' d'], grad: Float[Array, ' d'], hessian_diag: Float[Array, ' d'], lr: float) -> tuple[Float[Array, ' d'], Float[Array, ' d']]
¶
Diagonal natural parameter BLR update step.
Computes the damped update for diagonal variational parameters:
\mu = nat1 / (-2 \cdot nat2)
eta2_{target} = -\tfrac{1}{2}(-hessian\_diag) = 0.5 \cdot hessian\_diag
eta1_{target} = grad - hessian\_diag \cdot \mu
nat1_{new} = (1 - lr) \cdot nat1 + lr \cdot eta1_{target}
nat2_{new} = (1 - lr) \cdot nat2 + lr \cdot eta2_{target}
where nat2 (eta2) stores -\tfrac{1}{2} \lambda with
\lambda = -hessian\_diag (diagonal precision).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nat1
|
Float[Array, ' d']
|
Current natural location, shape |
required |
nat2_diag
|
Float[Array, ' d']
|
Current diagonal natural precision (eta2), shape |
required |
grad
|
Float[Array, ' d']
|
Gradient of log-likelihood, shape |
required |
hessian_diag
|
Float[Array, ' d']
|
Diagonal of Hessian (negative for log-concave),
shape |
required |
lr
|
float
|
Learning rate / damping factor. |
required |
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ' d'], Float[Array, ' d']]
|
Tuple |
Source code in src/gaussx/_inference/_blr.py
log_marginal_likelihood(loc: Float[Array, ' N'], cov_operator: lx.AbstractLinearOperator, y: Float[Array, ' N'], *, solver: AbstractSolverStrategy | None = None) -> Float[Array, '']
¶
GP log marginal likelihood.
Computes:
log p(y) = -0.5 * (y-mu)^T K^{-1} (y-mu) - 0.5 * log|K| - N/2 * log(2pi)
Delegates to gaussx.gaussian_log_prob.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
loc
|
Float[Array, ' N']
|
Prior mean, shape |
required |
cov_operator
|
AbstractLinearOperator
|
Covariance operator K, shape |
required |
y
|
Float[Array, ' N']
|
Observations, shape |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy. When |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar log marginal likelihood. |
Source code in src/gaussx/_inference/_inference.py
gaussian_expected_log_lik(y: Float[Array, ' N'], q_mu: Float[Array, ' N'], q_cov: lx.AbstractLinearOperator, noise: lx.AbstractLinearOperator, *, solver: AbstractSolverStrategy | None = None) -> Float[Array, '']
¶
Expected log-likelihood E_q[log N(y | f, R)].
Computes:
E_q[log N(y|f,R)] = log N(y | q_mu, R) - 0.5 * tr(R^{-1} q_cov)
Core to variational inference ELBO computation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y
|
Float[Array, ' N']
|
Observations, shape |
required |
q_mu
|
Float[Array, ' N']
|
Variational mean, shape |
required |
q_cov
|
AbstractLinearOperator
|
Variational covariance operator, shape |
required |
noise
|
AbstractLinearOperator
|
Noise covariance operator R, shape |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy. When |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar expected log-likelihood. |
Source code in src/gaussx/_inference/_inference.py
Newton & natural-gradient updates¶
Second-order variational steps: Newton's method on the variational objective, Gauss-Newton curvature (exact diagonal or Hutchinson-estimated), damped natural-gradient steps, and the PSD projection that keeps Riemannian updates on the manifold.
Structured linear algebra and Gaussian primitives for JAX.
newton_update(mean: Float[Array, ' N'], jacobian: Float[Array, ' N'], hessian: Float[Array, 'N N'] | Float[Array, ' N'], *, precision_floor: float = 1e-06) -> tuple[Float[Array, ' N'], Float[Array, 'N N'] | Float[Array, ' N']]
¶
Convert a Newton step to natural pseudo-likelihood parameters.
Computes:
nat1 = jacobian - hessian @ mean
nat2 = -hessian
Used in Laplace/Newton-based approximate inference to convert function-space derivatives into site natural parameters.
Passing hessian as an (N,) array of per-site second
derivatives — the shape site-based EP / Laplace inference over N
scalar latents actually has — takes an elementwise O(N) path
instead of forming the (N, N) matrix product:
Note
Both forms use the nat2 = +Λ (positive precision) convention,
matching gaussx.cavity_distribution and
gaussx.damped_natural_update. This differs from
gaussx.mean_cov_to_natural / gaussx.natural_to_mean_cov, which
use the exponential-family convention η₂ = −Λ/2.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mean
|
Float[Array, ' N']
|
Current mean, shape |
required |
jacobian
|
Float[Array, ' N']
|
First derivative of log-likelihood, shape |
required |
hessian
|
Float[Array, 'N N'] | Float[Array, ' N']
|
Second derivative (negative definite), either the full
|
required |
precision_floor
|
float
|
Lower bound on the returned precision, applied
only on the diagonal path. Keeps sites from a non-log-concave
likelihood (positive |
1e-06
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
Tuple |
Float[Array, 'N N'] | Float[Array, ' N']
|
the shape of |
Source code in src/gaussx/_inference/_inference.py
damped_natural_update(nat1_old: Float[Array, ' d'], nat2_old: lx.AbstractLinearOperator | Float[Array, 'd d'], nat1_target: Float[Array, ' d'], nat2_target: lx.AbstractLinearOperator | Float[Array, 'd d'], lr: float = 1.0) -> tuple[Float[Array, ' d'], lx.AbstractLinearOperator | Float[Array, 'd d']]
¶
Damped update in natural parameter space.
The universal primitive for iterative approximate inference (EP, VI, Newton, PL). Every method reduces to computing target natural parameters and applying this damped update:
nat1_{new} = (1 - lr) \cdot nat1_{old} + lr \cdot nat1_{target}
nat2_{new} = (1 - lr) \cdot nat2_{old} + lr \cdot nat2_{target}
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nat1_old
|
Float[Array, ' d']
|
Current natural location parameter. |
required |
nat2_old
|
AbstractLinearOperator | Float[Array, 'd d']
|
Current natural precision-like parameter.
Can be an array, |
required |
nat1_target
|
Float[Array, ' d']
|
Target natural location parameter. |
required |
nat2_target
|
AbstractLinearOperator | Float[Array, 'd d']
|
Target natural precision-like parameter. |
required |
lr
|
float
|
Learning rate / damping factor. |
1.0
|
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ' d'], AbstractLinearOperator | Float[Array, 'd d']]
|
Tuple |
Source code in src/gaussx/_inference/_natural_gradient.py
gauss_newton_precision(jacobian: Float[Array, 'D_obs D_latent']) -> lx.AbstractLinearOperator
¶
Gauss-Newton precision matrix J^T J.
For likelihoods with residual structure r(f), the Gauss-Newton
Hessian approximation is -J_r^T J_r which gives precision
\Lambda = J^T J (always PSD).
When D_{obs} < D_{latent}, returns a LowRankUpdate
to enable efficient Woodbury-based solves downstream.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
jacobian
|
Float[Array, 'D_obs D_latent']
|
Jacobian of the residual, shape |
required |
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator
|
PSD precision operator of shape |
Source code in src/gaussx/_inference/_natural_gradient.py
ggn_diagonal(jacobian: Float[Array, 'N d']) -> Float[Array, ' d']
¶
Generalized Gauss-Newton diagonal approximation.
Computes \mathrm{diag}(J^T J) = \sum_i J_{i,:}^2, the diagonal
of the Gauss-Newton Hessian approximation. Always non-negative,
guaranteeing PSD precision updates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
jacobian
|
Float[Array, 'N d']
|
Jacobian matrix, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' d']
|
Diagonal of |
Source code in src/gaussx/_inference/_blr.py
hutchinson_hessian_diag(hvp_fn: Callable[[Float[Array, ' d']], Float[Array, ' d']], key: jax.Array, d: int, n_samples: int = 1, dtype: DTypeLike | None = None) -> Float[Array, ' d']
¶
Stochastic Hessian diagonal via Hutchinson with Rademacher probes.
Estimates \mathrm{diag}(H) using the identity
\mathrm{diag}(H) = E[z \odot (H z)] where z is a
Rademacher random vector (entries \pm 1 with equal probability).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hvp_fn
|
Callable[[Float[Array, ' d']], Float[Array, ' d']]
|
Hessian-vector product function |
required |
key
|
Array
|
PRNG key for random probe generation. |
required |
d
|
int
|
Dimension of the Hessian. |
required |
n_samples
|
int
|
Number of random probes. More samples give better
estimates. Default |
1
|
dtype
|
DTypeLike | None
|
Floating-point dtype for the Rademacher probes. Defaults to the current JAX default floating dtype. |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' d']
|
Estimated diagonal of the Hessian, shape |
Source code in src/gaussx/_inference/_blr.py
riemannian_psd_correction(hessian: Float[Array, 'd d'], site_precision: Float[Array, 'd d'], site_covariance: Float[Array, 'd d'], lr: float = 1.0) -> Float[Array, 'd d']
¶
Riemannian gradient correction for PSD precision updates.
Ensures the corrected Hessian remains negative semi-definite, stabilizing Newton/EP/VI when the raw Hessian is indefinite:
G = site\_precision + hessian
H_{psd} = hessian - 0.5 \cdot lr \cdot G \cdot S \cdot G
where S is the site covariance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hessian
|
Float[Array, 'd d']
|
Raw second derivative, shape |
required |
site_precision
|
Float[Array, 'd d']
|
Current site precision, shape |
required |
site_covariance
|
Float[Array, 'd d']
|
Current site covariance, shape |
required |
lr
|
float
|
Learning rate. Default |
1.0
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'd d']
|
Corrected Hessian, shape |
Source code in src/gaussx/_inference/_natural_gradient.py
cavity_distribution(post_mean: Float[Array, ' N'], post_cov: lx.AbstractLinearOperator | Float[Array, ' N'], site_nat1: Float[Array, ' N'], site_nat2: lx.AbstractLinearOperator | Float[Array, ' N'], power: float = 1.0) -> tuple[Float[Array, ' N'], lx.AbstractLinearOperator | Float[Array, ' N']]
¶
Compute EP cavity distribution by removing a site.
Computes:
cav_prec = post_prec - power * site_nat2
cav_cov = inv(cav_prec)
cav_mean = cav_cov @ (post_prec @ post_mean - power * site_nat1)
Two forms are dispatched on the argument types. Passing post_cov
and site_nat2 as operators takes the full-covariance path. Passing
both as (N,) arrays — the marginal variances and per-site
precisions of N scalar latents, as site-based EP over GPs
represents them — takes an elementwise fast path costing O(N)
rather than the O(N²) of wrapping them in a
lineax.DiagonalLinearOperator:
Note
Both forms use the nat2 = +Λ (positive precision) convention,
matching gaussx.newton_update and gaussx.damped_natural_update.
This differs from gaussx.mean_cov_to_natural /
gaussx.natural_to_mean_cov, which use the exponential-family
convention η₂ = −Λ/2.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
post_mean
|
Float[Array, ' N']
|
Posterior mean, shape |
required |
post_cov
|
AbstractLinearOperator | Float[Array, ' N']
|
Posterior covariance operator, or |
required |
site_nat1
|
Float[Array, ' N']
|
Site natural parameter (precision-weighted mean),
shape |
required |
site_nat2
|
AbstractLinearOperator | Float[Array, ' N']
|
Site natural parameter (precision) as an operator, or
|
required |
power
|
float
|
Power EP fraction (default 1.0 for standard EP). |
1.0
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
Tuple |
AbstractLinearOperator | Float[Array, ' N']
|
operator path and an |
tuple[Float[Array, ' N'], AbstractLinearOperator | Float[Array, ' N']]
|
path. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Source code in src/gaussx/_inference/_inference.py
trace_correction(K_xx: lx.AbstractLinearOperator, K_xz: Float[Array, 'N M'], K_zz: lx.AbstractLinearOperator, *, solver: AbstractSolveStrategy | None = None) -> Float[Array, '']
¶
Trace term in Titsias collapsed ELBO.
Computes:
tr(K_xx) - tr(K_xz^T K_zz^{-1} K_xz)
This is the "trace correction" that penalizes the Nystrom approximation error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
K_xx
|
AbstractLinearOperator
|
Full covariance, shape |
required |
K_xz
|
Float[Array, 'N M']
|
Cross-covariance, shape |
required |
K_zz
|
AbstractLinearOperator
|
Inducing covariance, shape |
required |
solver
|
AbstractSolveStrategy | None
|
Optional solve strategy. When |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar trace correction. |
Source code in src/gaussx/_inference/_inference.py
Ensemble covariances, gain & analysis¶
Bessel-corrected empirical (cross-)covariances from ensemble members, the ensemble Kalman gain built from them, and the analysis step that applies it.
The gain functions are the pieces; enkf_analysis is the step -- the
stochastic (perturbed-observation) update that turns a prior ensemble and an
observation into a posterior ensemble. etkf_transform is its deterministic
square-root counterpart.
A caveat worth stating up front: the Gaussian assumption in an ensemble Kalman filter is a property of the coordinates, not of the algorithm. Applied to a non-Gaussian prior the update is biased, and the bias does not shrink with ensemble size. Conjugating the update with a bijection that Gaussianises the prior -- warp, analyse, warp back -- removes it.
That conjugated update is exact Bayes only under conditions worth stating
precisely, since they are easy to over-claim. It holds in the population
limit -- with a finite ensemble the gain is empirical and the perturbations
are Monte Carlo, so the result is an estimate regardless -- and only when the
observation model is affine with additive Gaussian noise in the same latent
coordinates that Gaussianise the prior. A Gaussian conditional likelihood is not
sufficient on its own: y = z² + ε has Gaussian noise and a non-Gaussian
posterior that no Kalman update reproduces. Outside those conditions
conjugation is an approximation with no guaranteed ordering against the
physical-space update -- usually much better, but a badly matched warp can make
the latent joint less Gaussian and do worse.
Structured linear algebra and Gaussian primitives for JAX.
ensemble_covariance(particles: Float[Array, 'J N'], *, bessel: bool = False) -> LowRankUpdate
¶
Empirical covariance from an ensemble as a low-rank operator.
Returns C = c X'^T X' with c = 1 / J when bessel=False
(default, maximum likelihood) and c = 1 / (J - 1) when
bessel=True (unbiased / ensemble Kalman filter convention).
The result is a LowRankUpdate of rank <= J-1 rather than
materializing the full (N, N) matrix. Efficient when
J << N.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Ensemble of shape |
required |
bessel
|
bool
|
If True, apply the |
False
|
Returns:
| Type | Description |
|---|---|
LowRankUpdate
|
A |
LowRankUpdate
|
covariance, with a zero base and |
Source code in src/gaussx/_inference/_ensemble.py
ensemble_cross_covariance(particles_theta: Float[Array, 'J N'], particles_G: Float[Array, 'J M'], *, bessel: bool = False) -> Float[Array, 'N M']
¶
Cross-covariance between two ensemble sets.
Computes C^{theta,G} = c sum_j (theta_j - bar)(G_j - bar)^T
with c = 1 / J by default or c = 1 / (J - 1) when
bessel=True.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles_theta
|
Float[Array, 'J N']
|
First ensemble, shape |
required |
particles_G
|
Float[Array, 'J M']
|
Second ensemble, shape |
required |
bessel
|
bool
|
If True, apply the |
False
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N M']
|
Cross-covariance array of shape |
Source code in src/gaussx/_inference/_ensemble.py
ensemble_kalman_gain(particles: Float[Array, 'J N'], obs_particles: Float[Array, 'J M'], obs_noise: lx.AbstractLinearOperator, *, solver: AbstractSolverStrategy | None = None, bessel: bool = True) -> Float[Array, 'N M']
¶
Kalman gain from an ensemble and its image in observation space.
Computes K = C^{xH} (C^{HH} + R)^{-1}, where C^{xH} is the
state-observation cross-covariance and C^{HH} is the
observation-space ensemble covariance. The innovation covariance
S = C^{HH} + R is assembled as a LowRankUpdate so
solve_rows can use structural dispatch via the Woodbury identity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'J M']
|
Prior ensemble in observation space, shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance operator, shape |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy. |
None
|
bessel
|
bool
|
Defaults to True, unlike the lower-level covariance helpers,
because this recipe follows the unbiased EnKF convention. Use
False for maximum-likelihood recipes with a |
True
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N M']
|
Dense Kalman gain of shape |
Source code in src/gaussx/_inference/_ensemble.py
enkf_analysis(particles: Float[Array, 'J N'], obs_particles: Float[Array, 'J M'], observation: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, *, key: PRNGKeyArray | None = None, perturbed_obs: Float[Array, 'J M'] | None = None, localization: Float[Array, 'N M'] | None = None, obs_localization: Float[Array, 'M M'] | None = None, solver: AbstractSolverStrategy | None = None, dense_innovation: bool | None = None, bessel: bool = True) -> Float[Array, 'J N']
¶
Stochastic (perturbed-observation) ensemble Kalman analysis step.
Updates a prior ensemble \(X^f\) toward an observation \(y\):
with \(K\) from ensemble_kalman_gain (or localized_kalman_gain when
localization is given). The observation operator enters only through
obs_particles -- the image \(\mathcal{H}(X^f)\) of the prior ensemble in
observation space -- so nonlinear operators need no special handling.
The perturbation \(\varepsilon_j\) is what keeps the analysis spread correct.
The deterministic update \(X^a_j = X^f_j + K(y - \mathcal{H}(X^f_j))\) drives
the ensemble covariance to \((I - KH)P(I - KH)^\top\) instead of \((I - KH)P\),
i.e. under-dispersive. There is deliberately no perturb=False flag: the
deterministic alternative is a different filter (the square-root / ETKF
family, see etkf_transform), not an option on this one.
Two ways to supply the observation perturbations:
key-- draw \(\varepsilon_j \sim N(0, R)\) internally, via a Cholesky factor ofobs_noise.perturbed_obs-- pass a pre-built perturbed-observation ensemble \(y + \varepsilon_j\). Preferred when the same noise realisation must be reused across filters, and when the perturbations come from a nonlinear observation model rather than an additive \(R\).
Exactly one of key / perturbed_obs must be given.
Known limitation. The update is a Gaussian one, applied in whatever
coordinates the caller supplies. For a non-Gaussian prior it is biased, and
the bias does not shrink with ensemble size -- it is an error of
coordinates, not of sampling. On the lognormal / logit-normal prior of
Chipilski (2025), whose exact posterior mean is [0.548062, 0.353937],
the physical-space update plateaus several percent off that value and stays
there as \(J\) grows by two orders of magnitude.
The fix is to conjugate the update with a bijection \(\Gamma\) that
Gaussianises the prior -- call this function on \(\Gamma^{-1}(X^f)\) and map
the result back through \(\Gamma\): the ensemble Kalman filter's Gaussian
assumption is a statement about coordinates, not about the algorithm. Pass
the same perturbed_obs through both routes to compare them on one noise
realisation.
That conjugated update is exact Bayes only under conditions worth stating precisely, because it is easy to over-claim. It needs the population limit -- with a finite ensemble the gain is empirical and the perturbations are Monte Carlo, so the result is an estimate either way -- and it needs the observation model to be affine with additive Gaussian noise in the same latent coordinates that Gaussianise the prior. A merely "Gaussian likelihood" is not enough: \(y = \zeta^2 + \varepsilon\) has Gaussian noise and a non-Gaussian posterior that no Kalman update reproduces. Outside those conditions conjugation is an approximation with no guaranteed ordering against the physical-space update -- usually much better, but a badly matched \(\Gamma\) can make the latent joint less Gaussian and do worse.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'J M']
|
Prior ensemble in observation space, shape |
required |
observation
|
Float[Array, ' M']
|
The observation, shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance \(R\), shape |
required |
key
|
PRNGKeyArray | None
|
PRNG key for internally drawn perturbations. Mutually exclusive
with |
None
|
perturbed_obs
|
Float[Array, 'J M'] | None
|
Pre-built perturbed observation ensemble, shape
|
None
|
localization
|
Float[Array, 'N M'] | None
|
Optional state-observation taper \(\rho_{xy}\), shape
|
None
|
obs_localization
|
Float[Array, 'M M'] | None
|
Optional observation-observation taper \(\rho_{yy}\),
shape |
None
|
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for the innovation solve. |
None
|
dense_innovation
|
bool | None
|
Whether to form the |
None
|
bessel
|
bool
|
Use the \(1/(J-1)\) divisor. Defaults to |
True
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
Analysis ensemble, shape |
Note
How the innovation covariance \(C^{HH} + R\) is assembled defaults to a
choice made from the static shapes, because the two regimes have wildly
different costs. With \(J < M\) the gain comes from
ensemble_kalman_gain, which keeps the ensemble term low-rank and
inverts a \((J, J)\) Woodbury capacitance -- the right choice for the
geoscience regime of a few dozen members against many observations.
With \(J \ge M\) that capacitance is the larger of the two (320 GB at
\(J = 200{,}000\)), so the \((M, M)\) innovation is formed densely instead.
Both routes solve the same system and agree to round-off.
Shapes are the wrong criterion in two cases, which is why
dense_innovation exists to override it:
- A matrix-free solver. With \(J \ge M\) and \(M\) still large, the
dense assembly allocates an \((M, M)\) array before the solver is ever
called -- around 40 GB at \(M = 100{,}000\) in float32 -- even though
an iterative strategy could work through matvecs on the structured
operator. Pass
dense_innovation=False. - Singular observation noise. The Woodbury route solves against
\(R\) itself, so a positive semi-definite \(R\) divides by zero and
returns infinities or
NaNeven when \(C^{HH} + R\) is perfectly invertible -- e.g. \(R = \mathrm{diag}(1, 1, 0)\) with ensemble anomalies spanning the third observation direction. The \(J < M\) path therefore requires \(R\) to be positive definite; with a singular \(R\), passdense_innovation=Trueto solve the full innovation instead. This is not checked: PSD-ness of an arbitrary operator is not something this function can establish cheaply, and certainly not underjit.
Raises:
| Type | Description |
|---|---|
ValueError
|
If neither or both of |
Example
import jax.numpy as jnp import jax.random as jr import lineax as lx from gaussx import enkf_analysis key, subkey = jr.split(jr.key(0)) prior = jr.normal(subkey, (500, 3)) # (J, N) H = jnp.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) obs_prior = prior @ H.T # (J, M) R = lx.DiagonalLinearOperator(0.1 * jnp.ones(2)) posterior = enkf_analysis( ... prior, obs_prior, jnp.array([1.0, -1.0]), R, key=key ... ) posterior.shape (500, 3)
Source code in src/gaussx/_inference/_ensemble.py
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 | |
etkf_transform(obs_particles: Float[Array, 'J M'], y: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, *, inflation: float = 1.0) -> tuple[Float[Array, ' J'], Float[Array, 'J J']]
¶
Ensemble Transform Kalman Filter (ETKF) analysis weights.
Deterministic (perturbed-obs-free) ensemble square-root analysis in the
J-dimensional ensemble space (Bishop et al. 2001; Hunt et al. 2007).
With raw observation perturbations Y = H X'^f (columns are members) and
d = y - H x_bar^f,
where lambda is the (multiplicative) inflation and W is the
symmetric square root. The analysis ensemble is reconstructed as
The symmetric (eigendecomposition) square root -- not a Cholesky factor --
is required: because the observation perturbations are zero-mean, 1 is
an eigenvector of W with eigenvalue 1, which makes the transform
exactly mean-preserving (sum_j X'^a_j = 0).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obs_particles
|
Float[Array, 'J M']
|
Forecast ensemble in observation space, shape |
required |
y
|
Float[Array, ' M']
|
Observation vector, shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance operator |
required |
inflation
|
float
|
Multiplicative covariance inflation |
1.0
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' J']
|
|
Float[Array, 'J J']
|
|
tuple[Float[Array, ' J'], Float[Array, 'J J']]
|
perturbations |
tuple[Float[Array, ' J'], Float[Array, 'J J']]
|
|
Source code in src/gaussx/_inference/_ensemble.py
Ensemble Kalman inversion¶
eki_step is the same Kalman update as enkf_analysis with two knobs added,
and reduces to it exactly at dt=1. It is the inverse problem reading of the
ensemble filter: one fixed observation, no time axis, and a schedule of tempered
steps instead of a sequence of assimilation windows.
dt is the observation-side tempering step, replacing R by R / dt. Over a
schedule with sum(dt) = 1 the composition is exactly one Bayesian update in
the linear-Gaussian population limit -- the precisions add -- so the sum
condition is what makes a schedule a tempering path rather than a heuristic.
step is the state-side operator in the gradient-flow view: it multiplies each
member's increment, so a BlockDiag of scaled identities gives a different rate
per state block. It changes the trajectory, not the fixed point.
The two helpers cover the standard variations. tikhonov_augment puts a prior
N(m0, C0) into the step by observation augmentation (TEKI) -- a helper rather
than a flag, so C0 stays an operator and the step itself knows nothing about
priors. discrepancy_step_size is the tuning-parameter-free data misfit
controller of Iglesias & Yang (2021): a pure function of the ensemble misfits,
so it belongs here rather than in whatever drives the iteration.
The iteration loop, the stopping rule, and the forward model itself are all out of scope: these are array-in / array-out steps.
Structured linear algebra and Gaussian primitives for JAX.
eki_step(particles: Float[Array, 'J N'], obs_particles: Float[Array, 'J M'], observation: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, *, dt: float | Float[Array, ''] = 1.0, step: lx.AbstractLinearOperator | None = None, key: PRNGKeyArray | None = None, perturbed_obs: Float[Array, 'J M'] | None = None, deterministic: bool = False, localization: Float[Array, 'N M'] | None = None, obs_localization: Float[Array, 'M M'] | None = None, solver: AbstractSolverStrategy | None = None, dense_innovation: bool | None = None, bessel: bool = True) -> Float[Array, 'J N']
¶
One ensemble Kalman inversion (EKI) update.
A single tempered Kalman update of an ensemble against a fixed observation (Iglesias, Law & Stuart 2013). The gain is \(K = C^{uG}(C^{GG} + R/\Delta t)^{-1}\), and the stochastic (perturbed-observation) update is
This is enkf_analysis with two knobs added, and reduces to it exactly at
dt=1, step=None. Everything the forward model does enters through
obs_particles, so this function is pure array-in / array-out: there is
no iteration, no stopping rule, and no \(\mathcal{G}\). The driver that
supplies the schedule lives outside this package.
Tempering (dt). One iteration replaces \(R\) by \(R/\Delta t\), i.e.
a likelihood raised to the power \(\Delta t\). Over a schedule with
\(\sum_n \Delta t_n = 1\) the composition is exactly one Bayesian update
in the linear-Gaussian population limit -- the precisions add,
\(C_N^{-1} = C_0^{-1} + \sum_n \Delta t_n\, A^\top R^{-1} A\) -- which is
what makes an EKI schedule a tempering path rather than a heuristic. The
sum condition is load-bearing: at \(\sum \Delta t_n \neq 1\) the result is
the posterior of a different problem, over- or under-weighting the data.
dt may be a traced scalar, so an adaptive schedule (see
discrepancy_step_size) stays inside jit.
State-side step (step). In the gradient-flow view
\(\dot{u} = -C^{uu}\nabla\Phi(u)\), step is the operator \(\Lambda\) in
the Euler step \(u \leftarrow u + \Lambda C^{uG} S^{-1}(y - \mathcal{G}(u))\).
It is applied by step.mv to each member's increment, so a
gaussx.BlockDiag of scaled identities gives a different rate per state
block -- parameters, latents, initial conditions -- without densifying an
\((N, N)\) matrix. \(\Lambda\) changes the trajectory, not the fixed point:
where \(K(y - \bar{\mathcal{G}}) = 0\) the increment is zero for every
\(\Lambda\), invertible or not.
Deterministic variant. deterministic=True replaces the perturbed
observations with an ETKF square-root transform (etkf_transform at
\(R/\Delta t\)), applied to the increment so that \(\Lambda\) still acts on a
difference:
At \(\Lambda = I\) the anomaly update collapses to \(U'^a = W U'^f\), i.e.
plain etkf_transform. This is the variant to use for the exactness
property above: the stochastic one is exact only in expectation, so a
finite ensemble carries Monte Carlo error on top of the tempering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'J M']
|
Its image \(\mathcal{G}(u_j)\) in observation space,
shape |
required |
observation
|
Float[Array, ' M']
|
The observation \(y\), shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance \(R\), shape |
required |
dt
|
float | Float[Array, '']
|
Observation-side tempering step \(\Delta t > 0\). Positivity is not checked -- it may be traced. |
1.0
|
step
|
AbstractLinearOperator | None
|
State-side operator \(\Lambda\), shape |
None
|
key
|
PRNGKeyArray | None
|
PRNG key for internally drawn perturbations
\(\varepsilon_j \sim N(0, R)\), which are then scaled by
\(1/\sqrt{\Delta t}\) to match \(R/\Delta t\). Mutually exclusive with
|
None
|
perturbed_obs
|
Float[Array, 'J M'] | None
|
Pre-built perturbed observation ensemble, shape
|
None
|
deterministic
|
bool
|
Use the ETKF square-root transform instead of perturbed observations. |
False
|
localization
|
Float[Array, 'N M'] | None
|
Optional state-observation taper \(\rho_{xy}\), shape
|
None
|
obs_localization
|
Float[Array, 'M M'] | None
|
Optional observation-observation taper \(\rho_{yy}\),
shape |
None
|
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for the innovation solve. |
None
|
dense_innovation
|
bool | None
|
Whether to form the |
None
|
bessel
|
bool
|
Use the \(1/(J-1)\) divisor. Must stay |
True
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
The updated ensemble, shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the shapes disagree; if |
Note
deterministic=True rejects localization rather than ignoring
it. Schur-product localization has no square-root analogue: tapering
the gain but not the transform would localize the mean update and
leave the anomalies unlocalized, an inconsistent analysis that looks
like a working one. (The LETKF localizes by domain decomposition
instead, which is a different construction, not this argument.)
Example
import jax.numpy as jnp import jax.random as jr import lineax as lx from gaussx import eki_step key, subkey = jr.split(jr.key(0)) u = jr.normal(subkey, (200, 3)) # (J, N) A = jnp.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) G = u @ A.T # (J, M) R = lx.DiagonalLinearOperator(0.1 * jnp.ones(2)) eki_step(u, G, jnp.array([1.0, -1.0]), R, dt=0.5, key=key).shape (200, 3)
Source code in src/gaussx/_inference/_ensemble.py
944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 | |
tikhonov_augment(particles: Float[Array, 'J N'], obs_particles: Float[Array, 'J M'], observation: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, prior_mean: Float[Array, ' N'], prior_cov: lx.AbstractLinearOperator) -> tuple[Float[Array, 'J M+N'], Float[Array, ' M+N'], lx.AbstractLinearOperator]
¶
Observation augmentation for Tikhonov-regularised EKI (TEKI).
Puts the prior \(N(m_0, C_0)\) into an EKI step by treating the state as its own observation (Chada, Stuart & Tong 2020):
so the augmented least-squares functional is the regularised one, \(\tfrac12\|y - \mathcal{G}(u)\|_R^2 + \tfrac12\|u - m_0\|_{C_0}^2\). Unregularised EKI collapses onto the data-misfit minimiser and, for an ill-posed problem, keeps going; the prior term is what stops it.
A helper rather than a flag on eki_step: the triple goes straight into
eki_step (and into discrepancy_step_size, whose \(M\) is then \(M + N\)),
nothing inside the step knows about priors, and \(C_0\) stays an operator, so
a gaussx.Kronecker prior keeps its structured solve inside the
gaussx.BlockDiag.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'J M']
|
Its image \(\mathcal{G}(u_j)\), shape |
required |
observation
|
Float[Array, ' M']
|
The observation \(y\), shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance \(R\), shape |
required |
prior_mean
|
Float[Array, ' N']
|
Prior mean \(m_0\), shape |
required |
prior_cov
|
AbstractLinearOperator
|
Prior covariance \(C_0\), shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'J M+N']
|
|
Float[Array, ' M+N']
|
|
AbstractLinearOperator
|
|
tuple[Float[Array, 'J M+N'], Float[Array, ' M+N'], AbstractLinearOperator]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If any of the shapes disagree. |
Example
import jax.numpy as jnp import jax.random as jr import lineax as lx from gaussx import eki_step, tikhonov_augment key, subkey = jr.split(jr.key(0)) u = jr.normal(subkey, (200, 3)) A = jnp.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) R = lx.DiagonalLinearOperator(0.1 * jnp.ones(2)) C0 = lx.DiagonalLinearOperator(jnp.ones(3)) G_aug, y_aug, R_aug = tikhonov_augment( ... u, u @ A.T, jnp.array([1.0, -1.0]), R, jnp.zeros(3), C0 ... ) eki_step(u, G_aug, y_aug, R_aug, key=key).shape (200, 3)
Source code in src/gaussx/_inference/_ensemble.py
1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 | |
discrepancy_step_size(obs_particles: Float[Array, 'J M'], observation: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, *, remaining: Float[Array, ''], bessel: bool = True) -> Float[Array, '']
¶
Adaptive EKI tempering step: the data misfit controller.
Iglesias & Yang (2021), eq. (14) -- the selection rule of their EKI-DMC (Algorithm 3), which needs no tuning parameter. With the per-particle least-squares functional
and \(\bar\Phi\), \(\sigma^2_\Phi\) its empirical mean and variance across the ensemble,
The two candidates are the paper's statistical discrepancy principle applied to the tempered sub-problem \(y = \mathcal{G}(u) + \sqrt{\alpha}\eta\) with \(\alpha = 1/\Delta t\): since \(\|R^{-1/2}(y - \mathcal{G}(u))\|^2\) is \(\chi^2_M\) under the correct model, it has mean \(M\) (their C1, accuracy, giving \(M/2\bar\Phi\)) and variance \(2M\) (their C2, uncertainty, giving \(\sqrt{M/2\sigma^2_\Phi}\)). The max is deliberate and enforces at least one of the two, not both: their Remark 2 notes that a wide prior makes \(\bar\Phi \ll \sigma_\Phi\), so C1 binds; a narrow prior centred far from the truth flips it, and C2 then licenses the larger step. The outer min is the tempering budget \(1 - t_n\), which is also the stopping rule -- the driver halts on the iteration where it binds.
Note
gh-230 specified both terms in the misfit of the ensemble mean,
\(\|R^{-1/2}(y - \bar{\mathcal{G}})\|^2\). This follows the paper
instead: eq. (13) defines \(\Phi_n\) as the set of per-particle
functionals and eq. (14) takes their mean and variance. The
distinction is not cosmetic -- the second term's \(\sigma^2_\Phi\) is
identically zero for any single vector, so a mean-misfit reading would
make C2 infinite and the max vacuous.
Only obs_noise solves are used; \(R^{-1/2}\) is never formed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
obs_particles
|
Float[Array, 'J M']
|
Ensemble in observation space \(\mathcal{G}(u_j)\), shape
|
required |
observation
|
Float[Array, ' M']
|
The observation \(y\), shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance \(R\), shape |
required |
remaining
|
Float[Array, '']
|
Tempering budget left, \(1 - \sum_{k<n}\Delta t_k\). May be traced. |
required |
bessel
|
bool
|
Use the \(1/(J-1)\) divisor for \(\sigma^2_\Phi\). Defaults to
|
True
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
The step \(\Delta t\), a scalar. Never exceeds |
Float[Array, '']
|
positive whenever |
Float[Array, '']
|
(\(\sigma^2_\Phi = 0\)) sends the second candidate to \(+\infty\), so the |
Float[Array, '']
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the shapes disagree, or if |
Example
import jax.numpy as jnp import jax.random as jr import lineax as lx from gaussx import discrepancy_step_size G = jr.normal(jr.key(0), (50, 4)) R = lx.DiagonalLinearOperator(jnp.ones(4)) dt = discrepancy_step_size( ... G, jnp.zeros(4), R, remaining=jnp.asarray(1.0) ... ) bool(0.0 < dt <= 1.0) True
Source code in src/gaussx/_inference/_ensemble.py
1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 | |
Localization & inflation¶
The standard fixes for small-ensemble rank deficiency: Schur-product localization with a taper (Gaspari-Cohn by default) and multiplicative / RTPP / RTPS inflation.
Structured linear algebra and Gaussian primitives for JAX.
localization_matrix(coords_a: Float[Array, 'Na D'], coords_b: Float[Array, 'Nb D'], c: float, metric: Callable[[Float[Array, 'Na D'], Float[Array, 'Nb D']], Float[Array, 'Na Nb']] = euclidean_distance) -> Float[Array, 'Na Nb']
¶
Pairwise Gaspari-Cohn taper rho(dist(a_i, b_j); c).
Use this to build the rho_xy (state-obs) and rho_yy (obs-obs)
localization matrices consumed by localized_kalman_gain.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coords_a
|
Float[Array, 'Na D']
|
First set of points, shape |
required |
coords_b
|
Float[Array, 'Nb D']
|
Second set of points, shape |
required |
c
|
float
|
Gaspari-Cohn compact-support radius. |
required |
metric
|
Callable[[Float[Array, 'Na D'], Float[Array, 'Nb D']], Float[Array, 'Na Nb']]
|
Pairwise distance function returning an |
euclidean_distance
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'Na Nb']
|
Localization matrix of shape |
Source code in src/gaussx/_inference/_ensemble.py
localized_kalman_gain(particles: Float[Array, 'J N'], obs_particles: Float[Array, 'J M'], obs_noise: lx.AbstractLinearOperator, rho_xy: Float[Array, 'N M'], rho_yy: Float[Array, 'M M'], *, solver: AbstractSolverStrategy | None = None, bessel: bool = True) -> Float[Array, 'N M']
¶
Ensemble Kalman gain with Hadamard (Schur-product) localization.
Computes
where P_xy is the state-observation cross-covariance and P_yy the
observation-space ensemble covariance. Tapering kills spurious long-range
sample correlations; because Gaspari-Cohn is positive-definite, the Schur
product theorem keeps rho_yy . P_yy PSD, so the innovation covariance
stays invertible.
This is the localized counterpart of ensemble_kalman_gain. Unlike
that routine, the Hadamard product destroys the low-rank structure, so the
innovation covariance is materialized densely and the solve is
O(N M + M^3). Recover the unlocalized gain as the c -> inf limit
(rho_xy = rho_yy = 1).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
particles
|
Float[Array, 'J N']
|
Prior ensemble in state space, shape |
required |
obs_particles
|
Float[Array, 'J M']
|
Prior ensemble in observation space, shape |
required |
obs_noise
|
AbstractLinearOperator
|
Observation error covariance operator |
required |
rho_xy
|
Float[Array, 'N M']
|
State-observation localization matrix, shape |
required |
rho_yy
|
Float[Array, 'M M']
|
Observation-observation localization matrix, shape |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for the dense innovation solve. |
None
|
bessel
|
bool
|
Use the |
True
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N M']
|
Dense localized Kalman gain of shape |
Source code in src/gaussx/_inference/_ensemble.py
gaspari_cohn(r: Float[Array, '*shape'], c: float) -> Float[Array, '*shape']
¶
Gaspari-Cohn (1999) fifth-order compactly-supported taper.
The standard positive-definite, approximately-Gaussian localization
function. With z = 2 |r| / c it is the piecewise-rational
so rho(0) = 1 and rho = 0 for |r| >= c (c is the
compact-support radius, not a Gaussian length scale). The taper is
only \(C^1\) at the knots z = 1, 2.
Differentiability: the 2 / (3 z) term in the middle branch is guarded
with a safe denominator so reverse-mode gradients are finite at r = 0
(which would otherwise produce NaN via the standard where pitfall).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
r
|
Float[Array, '*shape']
|
Distances (any shape), e.g. a pairwise distance matrix. |
required |
c
|
float
|
Compact-support radius; |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '*shape']
|
Taper values in |
Source code in src/gaussx/_inference/_ensemble.py
inflate_multiplicative(ensemble: Float[Array, 'J N'], factor: float) -> Float[Array, 'J N']
¶
Multiplicative ensemble inflation about the mean.
Restores ensemble spread lost to sampling error / model collapse by scaling
perturbations: x_j <- x_bar + factor (x_j - x_bar).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ensemble
|
Float[Array, 'J N']
|
Ensemble of shape |
required |
factor
|
float
|
Inflation factor |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
Inflated ensemble, shape |
Source code in src/gaussx/_inference/_ensemble.py
inflate_rtpp(posterior: Float[Array, 'J N'], prior: Float[Array, 'J N'], alpha: float) -> Float[Array, 'J N']
¶
Relaxation to prior perturbations (RTPP; Zhang et al. 2004).
Relaxes posterior perturbations toward the prior perturbations while keeping
the posterior mean: x'^a <- (1 - alpha) x'^a + alpha x'^f, where the
perturbations are taken about each ensemble's own mean.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
posterior
|
Float[Array, 'J N']
|
Analysis ensemble, shape |
required |
prior
|
Float[Array, 'J N']
|
Forecast ensemble, shape |
required |
alpha
|
float
|
Relaxation weight in |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
Relaxed analysis ensemble, shape |
Float[Array, 'J N']
|
preserved. |
Source code in src/gaussx/_inference/_ensemble.py
inflate_rtps(posterior: Float[Array, 'J N'], prior: Float[Array, 'J N'], beta: float, eps: float = 1e-12) -> Float[Array, 'J N']
¶
Relaxation to prior spread (RTPS; Whitaker & Hamill 2012).
Scales each posterior perturbation, per coordinate, so the analysis spread
relaxes back toward the prior spread:
x'^a <- x'^a [ (1 - beta) + beta sigma^f / sigma^a ], with sigma the
per-coordinate ensemble standard deviation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
posterior
|
Float[Array, 'J N']
|
Analysis ensemble, shape |
required |
prior
|
Float[Array, 'J N']
|
Forecast ensemble, shape |
required |
beta
|
float
|
Relaxation weight in |
required |
eps
|
float
|
Floor on the posterior std to avoid division by zero. |
1e-12
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
Spread-restored analysis ensemble, shape |
Float[Array, 'J N']
|
is preserved. |
Source code in src/gaussx/_inference/_ensemble.py
Distances¶
Structured linear algebra and Gaussian primitives for JAX.
euclidean_distance(coords_a: Float[Array, 'Na D'], coords_b: Float[Array, 'Nb D']) -> Float[Array, 'Na Nb']
¶
Pairwise Euclidean distances ||a_i - b_j||.
A default metric for localization_matrix. Builds on
stable_squared_distances and takes a gradient-safe square root so
zero distances (e.g. the diagonal of a self-distance matrix) do not produce
NaN gradients.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coords_a
|
Float[Array, 'Na D']
|
First set of points, shape |
required |
coords_b
|
Float[Array, 'Nb D']
|
Second set of points, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'Na Nb']
|
Distance matrix of shape |
Source code in src/gaussx/_inference/_ensemble.py
haversine_distance(coords_a: Float[Array, 'Na 2'], coords_b: Float[Array, 'Nb 2'], radius: float = 6371000.0) -> Float[Array, 'Na Nb']
¶
Pairwise great-circle (haversine) distances on a sphere.
A metric for localization_matrix on geophysical grids.
Coordinates are (latitude, longitude) in radians.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
coords_a
|
Float[Array, 'Na 2']
|
First set of points |
required |
coords_b
|
Float[Array, 'Nb 2']
|
Second set of points |
required |
radius
|
float
|
Sphere radius in the units of the returned distance (default the
Earth mean radius, |
6371000.0
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'Na Nb']
|
Great-circle distance matrix of shape |