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 learning rule¶
Damped natural-parameter updates of the Bayesian learning rule (BLR) — full covariance or diagonal-only — plus the marginal likelihood and expected log-likelihood that score Gaussian posteriors.
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 | Float[Array, ''], *, solver: AbstractSolverStrategy | None = None, convention: NaturalConvention = 'expfam') -> 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 | Float[Array, '']
|
Learning rate / damping factor. |
required |
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for structured linear algebra.
When |
None
|
convention
|
NaturalConvention
|
Convention of |
'expfam'
|
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ' d'], Float[Array, 'd d']]
|
Tuple |
Note
By default nat2 is the exponential-family eta2 = -Lambda/2,
matching gaussx.mean_cov_to_natural. gaussx.newton_update and
gaussx.cavity_distribution use nat2 = +Lambda instead; convert
with nat2_plus = -2 * eta2, or pass convention="precision" to
read and return +Lambda directly. gaussx.damped_natural_update
is linear, so it works in either convention as long as both of its
arguments share it.
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 | Float[Array, ''], *, convention: NaturalConvention = 'expfam') -> 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 | Float[Array, '']
|
Learning rate / damping factor. |
required |
convention
|
NaturalConvention
|
Convention of |
'expfam'
|
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ' d'], Float[Array, ' d']]
|
Tuple |
Note
By default nat2 is the exponential-family eta2 = -Lambda/2,
matching gaussx.mean_cov_to_natural. gaussx.newton_update and
gaussx.cavity_distribution use nat2 = +Lambda instead; convert
with nat2_plus = -2 * eta2, or pass convention="precision" to
read and return +Lambda directly. gaussx.damped_natural_update
is linear, so it works in either convention as long as both of its
arguments share it.
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. This differs from
gaussx.mean_cov_to_natural / gaussx.natural_to_mean_cov and the
default of gaussx.blr_diag_update / gaussx.blr_full_update, which
use the exponential-family convention η₂ = −Λ/2; convert with
nat2 = −2 η₂, or call the BLR updates with
convention="precision". gaussx.damped_natural_update is
convention-agnostic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mean
|
Float[Array, ' N']
|
Current mean, shape |
required |
jacobian
|
Float[Array, ' N']
|
Gradient (first derivative) of the log-likelihood with
respect to the latent, 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 | Float[Array, ''] = 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}
Note
The update is linear, so it is convention-agnostic: it works with
the nat2 = +Λ convention of gaussx.newton_update /
gaussx.cavity_distribution and with the exponential-family
η₂ = −Λ/2 of gaussx.blr_diag_update /
gaussx.mean_cov_to_natural alike. Both arguments must use the same
one; mixing them silently flips the sign and halves the precision.
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 | Float[Array, '']
|
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'], *, base: lx.AbstractLinearOperator | None = None) -> lx.AbstractLinearOperator
¶
Gauss-Newton precision matrix J^T J, optionally plus a prior.
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).
With base (a prior precision \Lambda_0), returns the posterior
precision \Lambda_0 + J^T J as a LowRankUpdate on base, for
any D_obs. gaussx.solve and gaussx.logdet then use the Woodbury
identity and base's own structured solve. This is the form to use
whenever the precision will be solved against.
Without base, J^T J has rank at most D_obs. When
D_{obs} < D_{latent} it is returned as a LowRankUpdate on a zero
base, which is singular: it is meant as a summand or for mv,
and gaussx.solve / gaussx.logdet on it are undefined (NaN).
Adding a prior afterwards (gauss_newton_precision(J) + prior) gives
a plain lineax sum that is solved densely; pass base=prior instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
jacobian
|
Float[Array, 'D_obs D_latent']
|
Jacobian of the residual, shape |
required |
base
|
AbstractLinearOperator | None
|
Optional prior precision of shape |
None
|
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 | Float[Array, ''] = 1.0) -> Float[Array, 'd d']
¶
Riemannian gradient correction for PSD precision updates.
Stabilises 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.
The corrected Hessian itself is not guaranteed to be negative
semi-definite (with H = diag(-1, 2), Lambda = I and lr = 0.1
it has eigenvalues [-1, 1.55]). What the correction guarantees is
that the damped precision update stays positive definite: with
S = Lambda^{-1},
which follows by expanding both sides to
Lambda - lr G + lr^2/2 G S G. The right-hand side is a positive
definite matrix plus a positive semi-definite one.
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 | Float[Array, '']
|
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, *, precision_floor: float | None = None) -> 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. This differs from
gaussx.mean_cov_to_natural / gaussx.natural_to_mean_cov and the
default of gaussx.blr_diag_update / gaussx.blr_full_update, which
use the exponential-family convention η₂ = −Λ/2; convert with
nat2 = −2 η₂, or call the BLR updates with
convention="precision". gaussx.damped_natural_update is
convention-agnostic.
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
|
precision_floor
|
float | None
|
Optional lower bound on the cavity precision,
applied only on the diagonal path. A site that has absorbed
more precision than the posterior holds leaves a negative
cavity precision, which EP loops guard against by clipping;
|
None
|
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
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | |
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
Natural-parameter conventions¶
Two conventions for the second natural parameter coexist. Both share the first one, \(\eta_1 = \Lambda\mu\). Feeding the output of one family into the other silently flips the sign and halves the precision. Convert with \(\text{nat2} = -2\,\eta_2\).
| Function | Convention of nat2 |
|---|---|
blr_diag_update, blr_full_update |
\(\eta_2 = -\tfrac12\Lambda\) by default; \(+\Lambda\) with convention="precision" |
mean_cov_to_natural, natural_to_mean_cov |
\(\eta_2 = -\tfrac12\Lambda\) |
newton_update, cavity_distribution |
\(+\Lambda\) |
damped_natural_update |
either (it is linear), as long as both arguments share it |
Precision-form Laplace (INLA)¶
laplace_mode is the inner loop of INLA: the Gaussian approximation
\(\mathcal N(\hat x, H^{-1})\) of a latent Gaussian model at fixed
hyperparameters \(\theta\), computed entirely in precision form. With
\(\eta = Ax + o\), \(g = \partial_\eta\log p(y\mid\eta)\) and
\(W = -\operatorname{diag}(\partial^2_\eta\log p)\), each Newton step solves
with a matrix whose structure never changes: an identity or row-selection
projector keeps a BlockTriDiag prior banded, and anything else becomes a
SparseOperator on a pattern whose sparse Cholesky analysis is shared by every
step and every \(\theta\). Hard sum-to-zero (null-space) constraints of an
IntrinsicGMRF are applied by kriging each solve. At the mode,
with the constraint corrections of Rue et al. (2009) for intrinsic priors. The
mode is differentiated implicitly (\(\partial_\theta\hat x =
H^{-1}\partial_\theta\nabla\ell\), via jax.lax.custom_root) and the
log-determinants through their structured or sparse (Takahashi) VJPs, so
jax.grad and the reverse-over-reverse Hessian that theta_design uses are
exact.
import jax
import jax.numpy as jnp
import numpy as np
import gaussx as gx
# Poisson counts with an RW2 seasonal effect; θ = log τ
n_days = 364 # even: rw2_structure(n) pads odd n with one node (see below)
t = jnp.arange(n_days, dtype=float)
counts = jnp.asarray(np.random.default_rng(0).poisson(np.exp(1 + np.sin(t / 58))))
rw2_null = jnp.column_stack([jnp.ones(n_days), t - t.mean()])
def log_marginal(log_tau):
prior = gx.IntrinsicGMRF(
jnp.zeros(n_days),
jnp.exp(log_tau),
gx.rw2_structure(n_days),
null_space=rw2_null,
constraint="hard",
)
result = gx.laplace_mode(prior, gx.PoissonLikelihood(counts))
return result.log_marginal # H stays BlockTriDiag
value, grad = jax.value_and_grad(log_marginal)(
0.0
) # exact: implicit diff + block Cholesky
The likelihood holds the observations, so laplace_mode(prior, likelihood)
takes no separate y. For an odd number of nodes rw2_structure(n) returns
n + 1 rows (a decoupled padding node): build the field on n + 1 nodes with
null_space zero on the padding row, observe the first n through a row
selection SparseOperator.from_coo(np.arange(n), np.arange(n), jnp.ones(n), (n, n + 1))
(which keeps \(H\) banded), and drop mode[n].
Structured linear algebra and Gaussian primitives for JAX.
LaplaceResult
¶
Bases: Module
The Gaussian approximation \(\mathcal N(\hat x, H^{-1})\) at the mode.
Attributes:
| Name | Type | Description |
|---|---|---|
mode |
Float[Array, ' N']
|
\(\hat x\), shape |
hessian |
AbstractLinearOperator
|
\(H = Q + A^\top WA\) at the mode: a |
factor |
Any
|
The factorisation of \(H\), with |
log_marginal |
Float[Array, '']
|
\(\log\tilde\pi(y\mid\theta)\); see |
n_iter |
Int[Array, '']
|
Newton steps taken. |
converged |
Bool[Array, '']
|
Whether the last step moved \(x\) by less than the
tolerance. Implicit gradients assume it; re-run or discard a
\(\theta\) where it is |
Source code in src/gaussx/_inference/_laplace.py
laplace_mode(prior: GaussianMRF | IntrinsicGMRF, likelihood: AbstractLikelihood, *, projector: lx.AbstractLinearOperator | None = None, offset: Float[ArrayLike, ' M'] | Float[ArrayLike, ''] | None = None, init: Float[ArrayLike, ' N'] | None = None, max_iter: int = 50, tol: float = 1e-08, damping: float = 1.0) -> LaplaceResult
¶
Mode, Hessian and log-marginal of the Laplace approximation, in precision form.
The latent Gaussian model is \(x \sim\) prior (precision \(Q\), mean
\(\mu\)), \(\eta = Ax + o\) and \(y \mid \eta \sim\) likelihood, which
holds \(y\) and must factorise over sites (its Hessian in \(\eta\) is
diagonal). Newton on \(\ell(x) = \log p(y\mid Ax+o) + \log\pi(x)\):
with \(g_t\), \(W_t = \max(-f''_t, 10^{-6})\) from the likelihood's
site_derivatives (newton_update's diagonal path), until
\(\|x_{t+1}-x_t\|_\infty \le \text{tol}\,(1 + \|x_{t+1}\|_\infty)\)
(tol is floored at 64 machine epsilons of the dtype). \(H_t\) is
rebuilt values-only on a structure fixed by \(Q\) and \(A\):
- \(A\) the identity (
None) or one entry per row (a row selection, or a scaled one): \(A^\top WA\) is diagonal, so aBlockTriDiag\(Q\) (RW1, RW2, AR(1)) staysBlockTriDiag, and aSparseOperator, diagonal or dense \(Q\) keeps its class; - a general
SparseOperator\(A\) (a FEM projector, an intercept and covariates):SparseOperator.congruenceon the union pattern, with a banded or diagonal \(Q\) converted to sparse; - a dense \(A\) or \(Q\): dense; any other \(Q\) (a grid
SpectralFunctionorKroneckerSumwith a non-diagonal update): the matrix-free sum, solved and log-determined bygaussx.solve/gaussx.logdetdispatch.
A sparse \(H\) is factored by SparseCholeskySolver, whose symbolic
analysis is cached per pattern and shared by every step and every
\(\theta\). A Gaussian likelihood converges in one step (a second confirms
it).
Intrinsic priors. An IntrinsicGMRF contributes \(Q = \tau R\). With
constraint="hard" the field lives on \(V^\top x = 0\) (\(V\) =
null_space): each Newton solve is kriged onto it (c extra
solves with the same factor), and the Gaussian approximation is the
constrained density (Rue et al., 2009, eq. 3; Rue & Held, 2005,
eq. 2.30). \(H\) must be positive definite on the whole space, which holds
when the observations see \(\ker R\) (an RW2 observed at two or more
times, a Besag field observed in every connected component).
constraint="none" runs unconstrained; "soft" is not supported.
The log-marginal. At the mode \(\hat x\),
\(\log\tilde\pi(y\mid\theta) = \log p(y\mid A\hat x + o) + \log\pi(\hat x)
- \log\tilde\pi_G(\hat x)\), where \(\log\pi\) is the prior's log_prob
and
the bracket for the hard constraint. For a GaussianMRF this is
\(f(A\hat x + o) - \tfrac12(\hat x-\mu)^\top Q(\hat x-\mu)
+ \tfrac12\log|Q| - \tfrac12\log|H|\), the exact log-evidence for a
Gaussian likelihood. For an IntrinsicGMRF it is exact with
include_normalizer=True and otherwise off by the θ-free constant
\(\tfrac12\log|R|_+ - \tfrac{N-c}2\log 2\pi\). The likelihood's own
normalising terms are included.
Gradients. Every array in prior, likelihood, projector
and offset is part of \(\theta\). The mode is differentiated by the
implicit function theorem through jax.lax.custom_root
(\(\partial_\theta\hat x = H^{-1}\partial_\theta\nabla\ell\), one solve
with a factor of \(H\) at the mode), and the log-determinants through
their structured or sparse (Takahashi) VJPs. Reverse-over-reverse
(jax.jacrev(jax.jacrev(...)), theta_design's default Hessian)
is exact too. The loop itself is not differentiated, so gradients assume
converged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prior
|
GaussianMRF | IntrinsicGMRF
|
The latent field, a |
required |
likelihood
|
AbstractLikelihood
|
A site-factorising |
required |
projector
|
AbstractLinearOperator | None
|
\(A\), shape |
None
|
offset
|
Float[ArrayLike, ' M'] | Float[ArrayLike, ''] | None
|
\(o\), a scalar or shape |
None
|
init
|
Float[ArrayLike, ' N'] | None
|
Starting point, shape |
None
|
max_iter
|
int
|
Maximum Newton steps. |
50
|
tol
|
float
|
Relative tolerance on the step's sup-norm. |
1e-08
|
damping
|
float
|
Step size in |
1.0
|
Returns:
| Type | Description |
|---|---|
LaplaceResult
|
The |
Raises:
| Type | Description |
|---|---|
TypeError
|
For another prior class. |
NotImplementedError
|
For an |
ValueError
|
If |
Examples:
import jax
import jax.numpy as jnp
import numpy as np
import gaussx as gx
# Poisson counts with an RW2 seasonal effect; θ = log τ. Even n keeps
# rw2_structure(n) at size n (odd n adds a padding node: see below).
n = 60
t = jnp.arange(n, dtype=float)
counts = jnp.asarray(np.random.default_rng(0).poisson(np.exp(np.sin(t / 9.0))))
null = jnp.column_stack([jnp.ones(n), t - t.mean()])
def log_marginal(log_tau):
prior = gx.IntrinsicGMRF(
jnp.zeros(n), jnp.exp(log_tau), gx.rw2_structure(n), null
)
result = gx.laplace_mode(prior, gx.PoissonLikelihood(counts))
return result.log_marginal # H stays BlockTriDiag
value, grad = jax.value_and_grad(log_marginal)(2.0) # exact
# Odd n: rw2_structure(n) has n + 1 rows. Observe the first n nodes
# through a row selection (H stays BlockTriDiag), with the null
# space zero on the padding node.
m = 61
s = jnp.arange(m, dtype=float)
V = jnp.zeros((m + 1, 2)).at[:m, 0].set(1.0).at[:m, 1].set(s - s.mean())
select = gx.SparseOperator.from_coo(
np.arange(m), np.arange(m), jnp.ones(m), (m, m + 1)
)
prior = gx.IntrinsicGMRF(jnp.zeros(m + 1), 10.0, gx.rw2_structure(m), V)
y = jnp.ones(m)
fit = gx.laplace_mode(prior, gx.PoissonLikelihood(y), projector=select)
trend = fit.mode[:m] # drop the padding node
Source code in src/gaussx/_inference/_laplace.py
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 | |
Low-rank VB mean correction (INLA)¶
Under a skewed likelihood (Bernoulli, Poisson with small counts) the Laplace
mode is a biased estimate of the posterior mean. vb_mean_correction
(Van Niekerk & Rue, 2024; R-INLA's default control.vb correction) keeps the
Laplace covariance \(H^{-1}\) and shifts the mean within a \(p\)-dimensional
subspace, \(\bar x = \hat x + H^{-1}S\delta\), with \(\delta\) maximising
\(m = A\bar x + o\), \(v_i = [AH^{-1}A^\top]_{ii}\). The expectations are 1-D Gauss-Hermite per site, the predictor variances come from the same Takahashi selected inverse as the marginal variances, and the Newton steps on \(\delta\) are \(p\)-dimensional, so it costs \(p\) solves with the Laplace factor. For a Gaussian likelihood the correction is exactly zero. Typically \(S\) spans the fixed effects:
import jax.numpy as jnp
import numpy as np
import gaussx as gx
# Rare detections along a transect: logit p_i = β₀ + u_i, u an RW2 under
# Σu = Σ(t − t̄)u = 0 and β₀ ~ N(0, 10³); latent x = (u, β₀)
n = 40
t = np.arange(n, dtype=float)
p_true = 1 / (1 + np.exp(2 - np.sin(t / 5)))
detected = jnp.asarray(np.random.default_rng(0).random(n) < p_true, float)
R = np.asarray(gx.rw2_structure(n).as_matrix())
rows, cols = np.nonzero(np.tril(R))
Q = gx.SparseOperator.from_coo(
np.r_[rows, n],
np.r_[cols, n],
jnp.r_[2.0 * R[rows, cols], 1e-3],
(n + 1, n + 1),
symmetric=True,
)
V = jnp.zeros((n + 1, 2)).at[:n, 0].set(1.0).at[:n, 1].set(t - t.mean())
prior = gx.IntrinsicGMRF(jnp.zeros(n + 1), 1.0, Q, V)
A = gx.SparseOperator.from_coo( # η = u + β₀
np.r_[np.arange(n), np.arange(n)],
np.r_[np.arange(n), np.full(n, n)],
jnp.ones(2 * n),
(n, n + 1),
)
detection = gx.BernoulliLikelihood(detected)
res = gx.laplace_mode(prior, detection, projector=A)
fixed_effect_idx = jnp.array([n])
mean_vb = gx.vb_mean_correction(
res, prior, detection, projector=A, subspace=fixed_effect_idx
)
As with laplace_mode, the likelihood holds the observations, so there is no
separate y argument; pass the same projector and offset as to
laplace_mode.
Structured linear algebra and Gaussian primitives for JAX.
vb_mean_correction(result: LaplaceResult, prior: GaussianMRF | IntrinsicGMRF, likelihood: AbstractLikelihood, *, subspace: Int[ArrayLike, ' p'] | Float[ArrayLike, 'N p'], projector: lx.AbstractLinearOperator | None = None, offset: Float[ArrayLike, ' M'] | Float[ArrayLike, ''] | None = None, n_iter: int = 5, integrator: AbstractIntegrator = _GAUSS_HERMITE_20) -> Float[Array, ' N']
¶
Low-rank VB correction of the Laplace mean (Van Niekerk & Rue, 2024).
The Laplace approximation \(\mathcal N(\hat x, H^{-1})\) is centred at the
mode, which under a skewed likelihood (Poisson with small counts,
Bernoulli) is a biased estimate of the posterior mean. Keeping the
covariance \(H^{-1}\), shift the mean within the span of \(S\) (subspace,
\(N\times p\)),
and choose \(\delta\) to maximise the evidence lower bound, whose covariance terms do not depend on \(\delta\):
with \(m(\delta) = A\bar x(\delta) + o\) and \(v_i = [AH^{-1}A^\top]_{ii}\).
With \(U = H^{-1}S\), \(B = AU\) and the site expectations
\(\bar g_i = \mathbb E[\partial_\eta\log p(y_i\mid\eta_i)]\),
\(\bar h_i = \mathbb E[\partial^2_\eta\log p(y_i\mid\eta_i)]\) (1-D
quadrature by integrator on each site), n_iter Newton steps
on the \(p\)-dimensional \(\delta\) (from \(\delta = 0\)) use
For a Gaussian likelihood the mode is the exact posterior mean and the correction is zero.
Cost. \(p\) solves with result.factor (plus \(c\) for a hard
constraint), then \(p\)-dimensional Newton steps whose site expectations
are order × M likelihood derivatives. The predictor variances come
from the selected inverse, not from solves: for an identity or
row-selection projector they are \(A_{ij}^2\Sigma_{jj}\) (a
BlockTriDiag, sparse or diagonal \(H\)), and for a general sparse
projector (a FEM projector, fixed-effect columns) every pair of nodes
in a row of \(A\) is coupled in \(A^\top WA\), so each \(\Sigma_{ab}\) needed
lies in Takahashi's pattern(L + Lᵀ). A dense \(A\) or \(H\) costs one
solve per row of \(A\). The Newton step is the minimum-norm one, so
directions of \(S\) that do not move \(\bar x\) (dependent columns, or
ones in the constrained-out span of \(V\)) are harmless.
Intrinsic priors. With constraint="hard" both the covariance
and the shift are those of the constrained approximation, kriged onto
\(V^\top x = 0\): \(\Sigma = H^{-1} - H^{-1}V(V^\top H^{-1}V)^{-1}V^\top H^{-1}\)
and \(\bar x(\delta) = \hat x + \Sigma S\delta\).
Gradients. The result is an ordinary JAX function of the arrays in
result, prior, likelihood and projector, so it can be
differentiated in reverse mode (through laplace_mode's implicit
mode and the factor's solves); n_iter fixed Newton steps are
differentiated as unrolled.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
LaplaceResult
|
|
required |
prior
|
GaussianMRF | IntrinsicGMRF
|
The latent field passed to |
required |
likelihood
|
AbstractLikelihood
|
The site-factorising likelihood passed to |
required |
subspace
|
Int[ArrayLike, ' p'] | Float[ArrayLike, 'N p']
|
The span of the mean shift: an integer index array
( |
required |
projector
|
AbstractLinearOperator | None
|
\(A\) as passed to |
None
|
offset
|
Float[ArrayLike, ' M'] | Float[ArrayLike, ''] | None
|
\(o\) as passed to |
None
|
n_iter
|
int
|
Newton steps on \(\delta\). |
5
|
integrator
|
AbstractIntegrator
|
A point-based 1-D rule for the site expectations; its points and weights for \(\mathcal N(0, 1)\) are reused for every site. |
_GAUSS_HERMITE_20
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
The corrected mean \(\bar x(\delta)\), shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
import jax.numpy as jnp
import numpy as np
import gaussx as gx
# Rare detections along a transect: logit p_i = β₀ + u_i, u an RW2
# under Σu = Σ(t − t̄)u = 0 and β₀ ~ N(0, 10³), latent x = (u, β₀).
n = 40
t = np.arange(n, dtype=float)
p_true = 1 / (1 + np.exp(2 - np.sin(t / 5)))
detected = jnp.asarray(np.random.default_rng(0).random(n) < p_true, float)
R = np.asarray(gx.rw2_structure(n).as_matrix())
rows, cols = np.nonzero(np.tril(R))
Q = gx.SparseOperator.from_coo(
np.r_[rows, n], np.r_[cols, n], jnp.r_[2.0 * R[rows, cols], 1e-3],
(n + 1, n + 1), symmetric=True,
)
V = jnp.zeros((n + 1, 2)).at[:n, 0].set(1.0).at[:n, 1].set(t - t.mean())
prior = gx.IntrinsicGMRF(jnp.zeros(n + 1), 1.0, Q, V)
A = gx.SparseOperator.from_coo( # η = u + β₀
np.r_[np.arange(n), np.arange(n)], np.r_[np.arange(n), np.full(n, n)],
jnp.ones(2 * n), (n, n + 1),
)
lik = gx.BernoulliLikelihood(detected)
res = gx.laplace_mode(prior, lik, projector=A)
fixed_effect_idx = jnp.array([n])
mean_vb = gx.vb_mean_correction(
res, prior, lik, projector=A, subspace=fixed_effect_idx
)
Source code in src/gaussx/_inference/_vb_correction.py
142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | |
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 |
Note
The bessel default differs across this module: these two
covariance helpers default to bessel=False (1 / J), while
ensemble_kalman_gain, localized_kalman_gain, enkf_analysis,
eki_step and discrepancy_step_size default to True
(1 / (J - 1)) and etkf_transform is 1 / (J - 1)
throughout. A gain assembled by hand from these helpers with their
defaults therefore differs from ensemble_kalman_gain's; pass
bessel=True to match it.
Source code in src/gaussx/_inference/_ensemble_stats.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 |
Note
The bessel default differs across this module: these two
covariance helpers default to bessel=False (1 / J), while
ensemble_kalman_gain, localized_kalman_gain, enkf_analysis,
eki_step and discrepancy_step_size default to True
(1 / (J - 1)) and etkf_transform is 1 / (J - 1)
throughout. A gain assembled by hand from these helpers with their
defaults therefore differs from ensemble_kalman_gain's; pass
bessel=True to match it.
Source code in src/gaussx/_inference/_ensemble_stats.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, dense_innovation: bool | None = None) -> 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 by one of two routes, which solve the
same system and agree to round-off:
- Woodbury (
dense_innovation=False):Sis aLowRankUpdateofR, andsolve_rowsinverts a(J, J)capacitance throughR's own structured solve. CostsO(J^3 + J^2 M)and never materialisesR-- the route for a few dozen members against many observations. It solves againstRitself, soRmust be positive definite. - Dense (
dense_innovation=True):Sis formed as an(M, M)matrix, callingobs_noise.as_matrix(), and solved directly. CostsO(J M^2 + M^3)-- the route for many members against few observations, where the capacitance would be the larger matrix (3.2 GB in float64 atJ = 20 000). It also handles a positive semi-definiteR.
dense_innovation=None (the default) picks dense when J >= M and
Woodbury otherwise, the same rule as enkf_analysis and eki_step.
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
|
dense_innovation
|
bool | None
|
Whether to form the |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N M']
|
Dense Kalman gain of shape |
Source code in src/gaussx/_inference/_enkf.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 an exact PSD factor \(L L^\top = R\) ofobs_noisethat keeps its structure (a symmetric square root at dense leaves, so a singular \(R\) is fine).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/_enkf.py
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 | |
etkf_transform(obs_particles: Float[Array, 'J M'], y: Float[Array, ' M'], obs_noise: lx.AbstractLinearOperator, *, inflation: float | Float[Array, ''] = 1.0, solver: AbstractSolverStrategy | None = None) -> 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 sqrt(lambda)), which makes the
transform exactly mean-preserving (sum_j X'^a_j = 0).
Cost. R^{-1} is applied to the J + 1 right-hand sides
[Y^T, d] in one solve_rows call, so a structured R (diagonal,
gaussx.BlockDiag, gaussx.Kronecker, ...) keeps its own solve and is
never materialised, and a dense R is factored once. The ensemble-space
algebra then takes a single (J, J) eigendecomposition
\tilde{A}^{-1} = V \operatorname{diag}(s) V^T, from which both
\tilde{A} = V \operatorname{diag}(1/s) V^T and
W = V \operatorname{diag}(\sqrt{(J-1)/s}) V^T are read off (Hunt et
al. 2007), with no explicit inverse. Its derivative is taken in closed
form in the eigenbasis (Daleckii-Krein), which stays finite at the
repeated prior eigenvalue (J - 1)/\lambda that has multiplicity
J - M whenever M < J.
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 | Float[Array, '']
|
Multiplicative covariance inflation |
1.0
|
solver
|
AbstractSolverStrategy | None
|
Optional solver strategy for the |
None
|
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']]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/gaussx/_inference/_etkf.py
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | |
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/_eki.py
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | |
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/_eki.py
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | |
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/_eki.py
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 | |
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 | Float[Array, ''], 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 | Float[Array, '']
|
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/_localization.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/_localization.py
gaspari_cohn(r: Float[Array, '*shape'], c: float | Float[Array, '']) -> 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 | Float[Array, '']
|
Compact-support radius; |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '*shape']
|
Taper values in |
Source code in src/gaussx/_inference/_localization.py
inflate_multiplicative(ensemble: Float[Array, 'J N'], factor: float | Float[Array, '']) -> 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 | Float[Array, '']
|
Inflation factor |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'J N']
|
Inflated ensemble, shape |
Source code in src/gaussx/_inference/_inflation.py
inflate_rtpp(posterior: Float[Array, 'J N'], prior: Float[Array, 'J N'], alpha: float | Float[Array, '']) -> 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 | Float[Array, '']
|
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/_inflation.py
inflate_rtps(posterior: Float[Array, 'J N'], prior: Float[Array, 'J N'], beta: float | Float[Array, ''], 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 | Float[Array, '']
|
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/_inflation.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/_localization.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 |