Linear-Algebra Utilities¶
Numerically careful building blocks shared by the higher layers: robust factorizations, classical matrix identities in operator form, and batched / matrix-RHS solve helpers.
Robust factorization & hygiene¶
safe_cholesky retries with geometrically growing diagonal jitter inside a
statically-bounded jax.lax.fori_loop (JIT-compatible and reverse-mode
differentiable) when a matrix is not numerically positive-definite;
symmetrize removes the floating-point asymmetry that accumulates in
covariance updates.
Structured linear algebra and Gaussian primitives for JAX.
safe_cholesky(operator: lx.AbstractLinearOperator, *, initial_jitter: float = 1e-08, max_jitter: float = 0.01, max_retries: int = 5, growth_factor: float = 10.0) -> Float[Array, 'N N']
¶
Cholesky decomposition with adaptive jitter for near-singular matrices.
The first attempt routes through gaussx._primitives.cholesky, so
structured operators (DiagonalLinearOperator, BlockDiag,
Kronecker, BlockTriDiag) keep their structure on the happy path.
If the result contains NaNs (the matrix is not numerically
positive-definite), retries with geometrically increasing diagonal jitter:
cholesky(A + eps * I) where eps starts at initial_jitter and
grows by growth_factor each retry, up to max_jitter. Jittering in
general destroys structure, so retries operate on the dense matrix form.
The retry loop is a jax.lax.fori_loop with static bounds
(max_retries is a Python int), so the function is both
JIT-compatible and reverse-mode differentiable — lax.while_loop
would forbid the latter. Each iteration is a lax.cond that becomes
a no-op once the factor is clean, so a matrix that succeeds on attempt
k still pays for exactly k factorisations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A lineax linear operator whose Cholesky factor is required. Must be square and positive-definite. |
required |
initial_jitter
|
float
|
Starting jitter magnitude added to the diagonal. |
1e-08
|
max_jitter
|
float
|
Upper bound on jitter (clamped after growth). |
0.01
|
max_retries
|
int
|
Maximum number of jittered retries after the initial attempt. Must be a static Python int — it sets the loop's trip count, so it cannot be a traced value (e.g. a jitted function argument). |
5
|
growth_factor
|
float
|
Multiplicative factor applied to jitter each retry. |
10.0
|
Returns:
| Name | Type | Description |
|---|---|---|
Float[Array, 'N N']
|
Lower-triangular Cholesky factor as a dense array. |
|
Float[Array, 'N N']
|
If all attempts fail the result will contain NaNs — this is |
|
intentional |
Float[Array, 'N N']
|
JAX cannot raise exceptions inside |
Float[Array, 'N N']
|
code, so callers should check for NaNs when robustness matters. |
Source code in src/gaussx/_linalg/_safe_cholesky.py
symmetrize(mat: Float[Array, '... n n']) -> Float[Array, '... n n']
¶
Symmetrize the trailing two axes: 0.5 * (X + X^T).
Eliminates residual floating-point asymmetry after covariance
updates. Works on batched stacks (..., n, n).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mat
|
Float[Array, '... n n']
|
Square matrix or batched stack of square matrices. |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '... n n']
|
The symmetric part of |
Source code in src/gaussx/_linalg/_symmetrize.py
Matrix identities¶
Woodbury, Schur complements, and conditional (Schur-complement) variances — the identities behind every Gaussian conditioning step, exposed directly so recipes never re-derive them.
Legacy conditional_variance signature
The pre-#152 three-positional form
conditional_variance(base_diag, A_X, S_u) is still accepted until
gaussx 0.7.0 — it is
detected when the second positional argument is a
lineax.AbstractLinearOperator — but it emits a DeprecationWarning and
skips the \(K_{XZ}\)-based Schur subtraction, treating its first argument as
an already-computed Schur diagonal. Call
conditional_variance(K_XX_diag, K_XZ, A_X, S_u=S_u) instead.
Structured linear algebra and Gaussian primitives for JAX.
woodbury_solve(base: lx.AbstractLinearOperator, U: Float[Array, 'N k'], D: Float[Array, ' k'], b: Float[Array, ' N']) -> Float[Array, ' N']
¶
Standalone Woodbury identity solve: (L + U diag(D) U^T)^{-1} b.
Convenience function for cases where the user has the components
but doesn't want to construct a LowRankUpdate operator.
Uses the identity:
(L + U D U^T)^{-1} b = L^{-1}b - L^{-1}U C^{-1} U^T L^{-1}b
where C = D^{-1} + U^T L^{-1} U is the (k, k) capacitance
matrix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
AbstractLinearOperator
|
Base operator L, shape |
required |
U
|
Float[Array, 'N k']
|
Low-rank factor, shape |
required |
D
|
Float[Array, ' k']
|
Diagonal scaling, shape |
required |
b
|
Float[Array, ' N']
|
Right-hand side, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
Solution x, shape |
Source code in src/gaussx/_linalg/_woodbury.py
schur_complement(K_XX: lx.AbstractLinearOperator, K_XZ: Float[Array, 'N M'], K_ZZ: lx.AbstractLinearOperator) -> LowRankUpdate
¶
Schur complement: K_XX - K_XZ @ K_ZZ^{-1} @ K_ZX.
Central to GP conditional distributions. Returns a
LowRankUpdate operator so that downstream operations
(solve, logdet) can exploit the low-rank structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
K_XX
|
AbstractLinearOperator
|
Prior covariance, shape |
required |
K_XZ
|
Float[Array, 'N M']
|
Cross-covariance, shape |
required |
K_ZZ
|
AbstractLinearOperator
|
Inducing covariance, shape |
required |
Returns:
| Type | Description |
|---|---|
LowRankUpdate
|
A |
Source code in src/gaussx/_linalg/_schur.py
conditional_variance(K_XX_diag: Float[Array, ' N'], K_XZ: Float[Array, 'N M'] | lx.AbstractLinearOperator | None = None, A_X: Float[Array, 'N M'] | None = None, S_u: lx.AbstractLinearOperator | None = None) -> Float[Array, ' N']
¶
Predictive variance: Schur complement diagonal plus optional variational correction.
Computes the diagonal of the conditional covariance:
diag(K_XX - A_X K_XZ^T) + diag(A_X S_u A_X^T)
where A_X = K_XZ K_ZZ^{-1} is the projection matrix.
Negative base variances are clamped to zero.
Without S_u this is the exact GP predictive variance (diagonal of the
Schur complement). With S_u it is the sparse GP variational predictive
variance that adds a variational covariance correction.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
K_XX_diag
|
Float[Array, ' N']
|
Prior diagonal variances, shape |
required |
K_XZ
|
Float[Array, 'N M'] | AbstractLinearOperator | None
|
Cross-covariance matrix, shape |
None
|
A_X
|
Float[Array, 'N M'] | None
|
Projection matrix |
None
|
S_u
|
AbstractLinearOperator | None
|
Optional variational covariance, shape |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
Predictive variances, shape |
Note
Until gaussx 0.7.0 the legacy three-positional-argument form
conditional_variance(base_diag, A_X, S_u) (where
base_diag was the Schur diagonal already, A_X was the
projection, and S_u was the variational covariance) is
still accepted: it is detected when the second positional
argument is a lineax.AbstractLinearOperator (the old
S_u slot type). Such calls emit a
DeprecationWarning and compute
base_diag + diag(A_X S_u A_X^T) without the
Schur subtraction.
Source code in src/gaussx/_linalg/_schur.py
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 | |
diag_conditional_variance(K_XX_diag: Float[Array, ' N'], K_XZ: Float[Array, 'N M'], A_X: Float[Array, 'N M']) -> Float[Array, ' N']
¶
Diagonal of Schur complement: diag(K_XX - A K_ZX).
Thin wrapper around gaussx.conditional_variance (defined in
gaussx._linalg._schur.conditional_variance) without a
variational covariance. Use gaussx.conditional_variance
directly when a variational correction S_u is also needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
K_XX_diag
|
Float[Array, ' N']
|
Prior diagonal variances, shape |
required |
K_XZ
|
Float[Array, 'N M']
|
Cross-covariance, shape |
required |
A_X
|
Float[Array, 'N M']
|
Projection matrix |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
Conditional variances, shape |
Source code in src/gaussx/_linalg/_linalg.py
cov_transform(J: Float[Array, 'M N'] | lx.AbstractLinearOperator, cov_operator: lx.AbstractLinearOperator) -> lx.AbstractLinearOperator
¶
Covariance propagation through a linear map: J @ Sigma @ J^T.
Used in error propagation, Kalman filter updates, and first-order uncertainty propagation.
Exploits structure where it can:
- Operator-valued
J: routes throughsandwich, which preserves matchedKronecker/BlockDiagstructure and avoids materialisingSigmawhen eitherJorcov_operatoris diagonal. - Diagonal
cov_operator(denseJ): computes(J * d) @ J^Tdirectly, skipping the(N, N)materialization ofSigma.
Otherwise materializes Sigma and forms the dense product. The
returned operator is tagged symmetric (and positive-semidefinite
when the input is).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
J
|
Float[Array, 'M N'] | AbstractLinearOperator
|
Jacobian or linear map, shape |
required |
cov_operator
|
AbstractLinearOperator
|
Input covariance, shape |
required |
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator
|
Transformed covariance operator, shape |
AbstractLinearOperator
|
operator-valued |
AbstractLinearOperator
|
follows |
AbstractLinearOperator
|
|
Source code in src/gaussx/_linalg/_linalg.py
sandwich(A: lx.AbstractLinearOperator, P: lx.AbstractLinearOperator) -> lx.AbstractLinearOperator
¶
Return A @ P @ A.T exploiting compatible operator structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
A
|
AbstractLinearOperator
|
Linear map with shape |
required |
P
|
AbstractLinearOperator
|
Covariance operator with shape |
required |
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator
|
Transformed covariance operator with shape |
Examples:
Source code in src/gaussx/_linalg/_linalg.py
trace_product(A: lx.AbstractLinearOperator, B: lx.AbstractLinearOperator) -> Float[Array, '']
¶
Trace of a matrix product: tr(A @ B) with structural dispatch.
Uses operator structure where possible to avoid materialization:
- Both diagonal:
sum(diag(A) * diag(B)). - Diagonal × general (or vice versa): contract the diagonal with the diagonal of the other operator (no full materialization).
- Matched
gaussx.BlockDiag(same block sizes): sum of per-blocktrace_product. - Matched
gaussx.Kronecker(same factor structure):prod_i tr(A_i @ B_i). - Both
gaussx.LowRankUpdate(L + U diag(d) V^T): the base-base term recurses, the two cross terms needkmatvecs with the other base, and the low-rank-low-rank term costsO(N k_A k_B). For pure low-rank operators (zero-diagonal base, as Nystrom and RFF factorizations produce) this replaces theO(N^2)dense fallback. - Otherwise falls back to
sum(A * B^T)on the materialized matrices — the same O(N²) cost the previous implementation paid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
A
|
AbstractLinearOperator
|
Linear operator, shape |
required |
B
|
AbstractLinearOperator
|
Linear operator, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar |
Source code in src/gaussx/_linalg/_linalg.py
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 | |
Marginal variances & selected inverses¶
diag_inv returns \(\operatorname{diag}(Q^{-1})\) — the marginal variances
of a Gaussian with precision \(Q\) — and dispatches on structure before
falling back to dense Cholesky or Hutchinson:
| Structure | Path | Cost |
|---|---|---|
BlockTriDiag (rw1, rw2, ar1, temporal SDE priors) |
block Takahashi recursion, selected_inverse |
\(O(N d^3)\) |
Kronecker \(A \otimes B\) |
\(\operatorname{diag}(A^{-1}) \otimes \operatorname{diag}(B^{-1})\) | per factor |
KroneckerSum \(A \oplus B\) (grid Laplacians, SPDE on a raster) |
\((U_A \circ U_A)\,M\,(U_B \circ U_B)^\top\), \(M_{ij} = 1/(\lambda^A_i + \lambda^B_j)\) | \(O(H^2 W + H W^2)\) |
| \(A \otimes B + cI\) | same, with \(M_{ij} = 1/(\lambda^A_i \lambda^B_j + c)\) | per factor |
pinv=True drops the zero eigenvalues of an intrinsic precision on a
grid. A factor that already carries its eigenbasis (DiagonalizedOperator,
KroneckerSum) keeps it, so solve, logdet and diag_inv of a
space-time \(A \otimes B + cI\) never form the spatial factor \(B\).
# Posterior sd of an AR(1) trend under Gaussian noise: Q + σ⁻² I stays block-tridiagonal
H = gaussx.BlockTriDiag(Q.diagonal + jnp.eye(1) / sigma**2, Q.sub_diagonal)
sd = jnp.sqrt(gaussx.diag_inv(H)) # O(N), not O(N³)
# Prior sd of an intrinsic field on an H × W grid: two small matrix products
sd = jnp.sqrt(gaussx.diag_inv(gaussx.KroneckerSum(L_H, L_W), pinv=True))
For a precision too large to factor (a 10⁶-node mesh, where Cholesky fill is
prohibitive), method="xdiag" is the variance-reduced estimator of Epperly,
Tropp & Webber (2024), wrapping matfree's leave_one_out_xdiag. It computes
the range of \(Q^{-1}\Omega\) exactly and runs Hutchinson only on the remainder,
reusing every probe for both. With \(k\) probes it costs \(2k\) solves through
solver (Jacobi-preconditioned CG for a precision matrix), and it beats
Hutchinson at equal solve counts whenever \(Q^{-1}\) has a few dominant
directions, as GMRF covariances do. method="auto" still picks Hutchinson
above \(N = 2048\), so XDiag is opt-in.
sd = jnp.sqrt(
gaussx.diag_inv(
H,
method="xdiag",
num_probes=64,
key=key,
solver=gaussx.PreconditionedCGSolver(
preconditioner=gaussx.JacobiPreconditioner()
),
)
)
Structured linear algebra and Gaussian primitives for JAX.
diag_inv(operator: lx.AbstractLinearOperator, *, method: str = 'auto', num_probes: int = 30, key: jax.Array | None = None, solver: AbstractSolveStrategy | None = None, pinv: bool = False) -> Float[Array, ' N']
¶
Compute the diagonal of the inverse of a linear operator.
Returns diag(A⁻¹) without forming the full inverse matrix — the
marginal variances of a Gaussian with precision A.
With method="auto" the operator's structure picks an exact path
first:
DiagonalLinearOperator:1 / d;BlockDiag: per block, each dispatched in turn; a symmetricLowRankUpdate: the diagonal of its Woodbury inverse,O(N k²);c · A,A / cand-A: the structured path ofA, rescaled (gh-365).BlockTriDiag(symmetric): the diagonal blocks ofgaussx.selected_inverse,O(N d³).KroneckerA ⊗ B:diag_inv(A) ⊗ diag_inv(B), each factor dispatched in turn.KroneckerSumA ⊕ B(and aDiagonalizedOperator, or aSpectralFunctionf(A ⊕ B), withM_ij = 1/f(λ^A_i + λ^B_j)): withA = U_A Λ_A U_Aᵀ,B = U_B Λ_B U_Bᵀ,diag((A ⊕ B)⁻¹) = (U_A ∘ U_A) M (U_B ∘ U_B)ᵀwithM_ij = 1/(λ^A_i + λ^B_j)— two small matrix products,O(H²W + HW²)on anH × Wgrid. A factor that carries its own eigenbasis keeps it; others are eigendecomposed individually.- Shifted Kronecker products
A ⊗ B + c·I(aSumOfKroneckersor the equivalent lineax sum): the same formula withM_ij = 1/(λ^A_i λ^B_j + c). SparseOperatorwithsolver=SparseCholeskySolver(...): Takahashi's selected inverse through the sparse Cholesky factor, exact atO(Σ_j |struct(L_{:,j})|²), at any size.
Anything else uses Cholesky for N ≤ 2048 (the sparse factor for a
SparseOperator, dense otherwise) and Hutchinson above.
XDiag (method="xdiag", Epperly, Tropp & Webber, 2024) is the
variance-reduced estimator for operators too large to factor (e.g. the
Laplace Hessian of a GMRF on a 10⁶-node mesh). For \(B = A^{-1}\), probes
\(\Omega = [\omega_1, \dots, \omega_k]\) (random signs; Gaussian when
\(N\) is so small that \(k\) sign vectors could be linearly dependent, see
below) and
\(Q = \operatorname{orth}(B\Omega)\),
the first term exact and the second estimated by Hutchinson,
\(\operatorname{diag}(M) \approx \omega \odot M\omega\). XDiag uses every
probe for both terms: the \(i\)-th estimate builds \(Q_{-i}\) from the
other \(k - 1\) probes and applies Hutchinson with \(\omega_i\), and the
result is the mean of the \(k\) leave-one-out estimates. A GMRF covariance
has a few large-variance (smooth, large-scale) directions where plain
Hutchinson is noisy; the low-rank part captures exactly those. It costs
\(2k\) solves (\(B\Omega\) and \(B^\top Q\)), so compare it with Hutchinson
at num_probes = 2k. With \(2k \ge N\) it returns the exact diagonal
from \(N\) solves instead.
Ω = signs(key, N × k)
Y = A⁻¹ Ω; Q, R = qr(Y); Z = A⁻ᵀ Q # 2k solves
for i in 1..k: # in closed form, vectorised
dᵢ = diag(Q₋ᵢ Q₋ᵢᵀ B) + ωᵢ ⊙ (I − Q₋ᵢ Q₋ᵢᵀ) B ωᵢ
return mean(dᵢ)
It wraps matfree's stochtrace.leave_one_out_xdiag, which reads a
rank-deficient \(A^{-1}\Omega\) as a low-rank \(A^{-1}\) and returns the
projected diagonal as exact. \(k\) random-sign vectors in \(N\) dimensions
are dependent with probability about \(k^2 2^{-N}\) (two equal or opposite
columns), so signs, which estimate a diagonal with less variance, are
used only when that is below \(2^{-60}\); smaller problems use Gaussian
probes, dependent with probability zero. The solves go
through solver (any solve strategy; for a precision matrix,
Jacobi-preconditioned CG, since the Nyström and RPCholesky
preconditioners are for covariance form). Prefer an exact path (the
structured ones above, or Takahashi via SparseCholeskySolver)
whenever the operator factors; XDiag beats Hutchinson whenever
\(A^{-1}\)'s spectrum decays, and matches it when the spectrum is flat.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A linear operator representing A. |
required |
method
|
str
|
Algorithm to use. One of |
'auto'
|
num_probes
|
int
|
Number of Rademacher probe vectors for the hutchinson and xdiag methods (xdiag: \(1 \le k \le N\), and \(2k\) solves). |
30
|
key
|
Array | None
|
PRNG key for probe generation in the hutchinson and xdiag
methods. When |
None
|
solver
|
AbstractSolveStrategy | None
|
Optional solve strategy for the |
None
|
pinv
|
bool
|
Return the diagonal of the pseudo-inverse instead: eigenvalues
below |
False
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' N']
|
1D array of shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
For an unknown |
References
Epperly, E. N., Tropp, J. A. & Webber, R. J. (2024). XTrace: making the most of every sample in stochastic trace estimation. SIAM J. Matrix Anal. Appl., 45(1), 1-23.
Examples:
import jax.numpy as jnp
import lineax as lx
import gaussx
# Prior sd of an intrinsic field on a 4 x 5 grid: L_H ⊕ L_W is
# singular (constants), so take the pseudo-inverse.
def path_laplacian(n):
off = -jnp.ones(n - 1)
L = jnp.diag(jnp.r_[1.0, 2.0 * jnp.ones(n - 2), 1.0])
L = L + jnp.diag(off, 1) + jnp.diag(off, -1)
return lx.MatrixLinearOperator(L, lx.symmetric_tag)
Q = gaussx.KroneckerSum(path_laplacian(4), path_laplacian(5))
variances = gaussx.diag_inv(Q, pinv=True)
dense = jnp.diag(jnp.linalg.pinv(Q.as_matrix()))
assert jnp.allclose(variances, dense, atol=1e-5)
Marginal sd of a precision too large to factor (e.g. a 2M-node
global mesh; H a SparseOperator tagged
lx.positive_semidefinite_tag, which CG needs), with
Jacobi-preconditioned CG for the solves:
sd = jnp.sqrt(
gaussx.diag_inv(
H,
method="xdiag",
num_probes=64,
key=key,
solver=gaussx.PreconditionedCGSolver(
preconditioner=gaussx.JacobiPreconditioner()
),
)
)
Source code in src/gaussx/_linalg/_diag_inv.py
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 | |
selected_inverse(operator: BlockTriDiag) -> BlockTriDiag
¶
The band of Σ = Q⁻¹ for a symmetric positive-definite BlockTriDiag.
Takahashi's recursion in block form — the RTS smoother written in
precision form. With the block Cholesky factor Q = L Lᵀ (diagonal
blocks L_k, sub-diagonal blocks B_k at (k+1, k)), the identity
Lᵀ Σ = L⁻¹ gives, sweeping backwards from
Σ_NN = L_N⁻ᵀ L_N⁻¹,
Only the diagonal and first off-diagonal blocks are computed, at
O(N d³) cost in a lax.scan; with d = 1 this is Rue & Held's
scalar recursion. gaussx.diag_inv takes the marginal variances from the
diagonal blocks; the off-diagonal blocks are the lag-one covariances.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
BlockTriDiag
|
A symmetric positive-definite block-tridiagonal precision. |
required |
Returns:
| Type | Description |
|---|---|
BlockTriDiag
|
A |
BlockTriDiag
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Examples:
import jax.numpy as jnp
import gaussx
# An AR(1) precision with unit innovation variance and phi = 0.5
phi, n = 0.5, 6
diagonal = jnp.full((n, 1, 1), 1.0 + phi**2).at[-1].set(1.0)
sub_diagonal = jnp.full((n - 1, 1, 1), -phi)
Q = gaussx.BlockTriDiag(diagonal, sub_diagonal)
band = gaussx.selected_inverse(Q)
Sigma = jnp.linalg.inv(Q.as_matrix())
assert jnp.allclose(band.diagonal[:, 0, 0], jnp.diag(Sigma), atol=1e-5)
assert jnp.allclose(band.sub_diagonal[:, 0, 0], jnp.diag(Sigma, -1), atol=1e-5)
Source code in src/gaussx/_linalg/_selected_inverse.py
Matrix-RHS & batched solves¶
Solve \(AX = B\) for matrix right-hand sides with one factorization
(solve_matrix), per-column or per-row structured dispatch
(solve_columns / solve_rows), or \(O(n)\) Thomas-algorithm tridiagonal
solves.
Structured linear algebra and Gaussian primitives for JAX.
solve_matrix(operator: lx.AbstractLinearOperator, matrix: Float[Array, 'N K'], *, solver: AbstractSolveStrategy | None = None) -> Float[Array, 'N K']
¶
Solve A X = B with a single factorization on the matrix RHS.
When solver is None and A is a positive semidefinite
operator without a structured solve (dense, possibly behind Tagged /
scalar wrappers, or a composition or sum), this factors
A = L L^T once via gaussx.cholesky and then uses a single
cho_solve on the full matrix RHS — avoiding the per-column
re-factorization incurred by solve_columns.
Structured operators (LowRankUpdate, Kronecker, BlockDiag,
...), non-PSD operators and a custom solver go to solve_columns,
whose per-column structured gaussx.solve (Woodbury, Roth's lemma,
per block) is cheaper than densifying a Cholesky factor (gh-356).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Linear operator A, shape |
required |
matrix
|
Float[Array, 'N K']
|
Right-hand side B, shape |
required |
solver
|
AbstractSolveStrategy | None
|
Optional solver strategy. When provided, dispatch is
delegated column-by-column via |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N K']
|
Solution X = A⁻¹ B, shape |
Source code in src/gaussx/_linalg/_linalg.py
solve_columns(operator: lx.AbstractLinearOperator, matrix: Float[Array, 'N K'], *, solver: AbstractSolveStrategy | None = None) -> Float[Array, 'N K']
¶
Solve A X = B column-by-column via vmap.
Equivalent to A⁻¹ @ B but uses structured dispatch per column.
Use this when A is a lineax operator with efficient per-vector
solve (e.g., Cholesky, diagonal, block-diagonal).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Linear operator A, shape |
required |
matrix
|
Float[Array, 'N K']
|
Right-hand side B, shape |
required |
solver
|
AbstractSolveStrategy | None
|
Optional solver strategy. |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N K']
|
Solution X = A⁻¹ B, shape |
Source code in src/gaussx/_linalg/_linalg.py
solve_rows(operator: lx.AbstractLinearOperator, matrix: Float[Array, 'K N'], *, solver: AbstractSolveStrategy | None = None) -> Float[Array, 'K N']
¶
Solve A x = bᵢ for each row bᵢ of a matrix via vmap.
Equivalent to B @ A⁻¹ row-by-row. Used in Kalman gain, Schur
complement, and GP prediction computations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Linear operator A, shape |
required |
matrix
|
Float[Array, 'K N']
|
Rows of right-hand sides, shape |
required |
solver
|
AbstractSolveStrategy | None
|
Optional solver strategy. |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'K N']
|
Solutions, shape |
Source code in src/gaussx/_linalg/_linalg.py
tridiagonal_solve(lower: Float[Array, ' n_minus_1'], diag: Float[Array, ' n'], upper: Float[Array, ' n_minus_1'], rhs: Float[Array, ' n']) -> Float[Array, ' n']
¶
Solve a tridiagonal system A x = d.
Thin wrapper over lineax.TridiagonalLinearOperator, which delegates
to jax.lax.linalg.tridiagonal_solve (LAPACK / cuSPARSE).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lower
|
Float[Array, ' n_minus_1']
|
Sub-diagonal of |
required |
diag
|
Float[Array, ' n']
|
Main diagonal of |
required |
upper
|
Float[Array, ' n_minus_1']
|
Super-diagonal of |
required |
rhs
|
Float[Array, ' n']
|
Right-hand side, length |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, ' n']
|
The solution |
Source code in src/gaussx/_linalg/_tridiagonal.py
tridiagonal_solve_batched(lower: Float[Array, '*batch n_minus_1'], diag: Float[Array, '*batch n'], upper: Float[Array, '*batch n_minus_1'], rhs: Float[Array, '*batch n']) -> Float[Array, '*batch n']
¶
Solve independent tridiagonal systems over leading batch dimensions.
Applies tridiagonal_solve vmapped over all leading dimensions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lower
|
Float[Array, '*batch n_minus_1']
|
Sub-diagonals, shape |
required |
diag
|
Float[Array, '*batch n']
|
Main diagonals, shape |
required |
upper
|
Float[Array, '*batch n_minus_1']
|
Super-diagonals, shape |
required |
rhs
|
Float[Array, '*batch n']
|
Right-hand sides, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '*batch n']
|
Solutions, shape |
Source code in src/gaussx/_linalg/_tridiagonal.py
Matrix diagonalization & shifted Kronecker-sum solves¶
Factor each 1D operator once with EigenDecomposition (non-symmetric
diagonalizable factors with a real spectrum are supported), then solve
\((A_0 \oplus A_1 \oplus \dots - \sigma I)\,x = b\) in tensor form for any
shift \(\sigma\) with kronecker_sum_solve — the matrix-diagonalization method
for separable operators such as tensor-product spectral Laplacians.
Structured linear algebra and Gaussian primitives for JAX.
EigenDecomposition
¶
Bases: Module
Precomputed eigendecomposition A = V diag(λ) V⁻¹ of a square matrix.
Built once (on the host, outside jit) and then reused for any number
of shifted solves (A − σI)⁻¹ b, each costing two dense mat-vecs —
O(n²) — for any shift σ, which may be a traced value (so solves
are jit/grad-compatible in σ).
Only real spectra are supported: the factorization is kept in real
arithmetic, and from_matrix rejects matrices whose eigenvalues have
non-negligible imaginary parts.
Attributes:
| Name | Type | Description |
|---|---|---|
eigenvalues |
Float[Array, ' n']
|
Eigenvalues |
eigenvectors |
Float[Array, 'n n']
|
Right eigenvectors |
eigenvectors_inv |
Float[Array, 'n n']
|
|
Example
Source code in src/gaussx/_linalg/_eigen_factorization.py
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 | |
from_matrix(matrix: ArrayLike | lx.AbstractLinearOperator, *, symmetric: bool | None = None) -> EigenDecomposition
classmethod
¶
Eigendecompose a concrete square matrix on the host.
Uses NumPy (eigh for symmetric input, eig otherwise), so it
works for non-symmetric matrices on every JAX backend. Because of
that, matrix must be concrete: call this outside jit (e.g.
when constructing a solver), not on traced values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
matrix
|
ArrayLike | AbstractLinearOperator
|
Square matrix, or a lineax operator (materialized via
|
required |
symmetric
|
bool | None
|
Use the symmetric path. Defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
EigenDecomposition
|
The factorization. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/gaussx/_linalg/_eigen_factorization.py
as_matrix() -> Float[Array, 'n n']
¶
solve_shifted(rhs: Float[Array, ' n *batch'], shift: float | Float[Array, ''] = 0.0, *, drop: Bool[Array, ' n'] | None = None) -> Float[Array, ' n *batch']
¶
Solve (A − shift·I) x = rhs along the leading axis of rhs.
x = V diag(1 / (λ − shift)) V⁻¹ rhs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rhs
|
Float[Array, ' n *batch']
|
Right-hand side(s), shape |
required |
shift
|
float | Float[Array, '']
|
Scalar shift |
0.0
|
drop
|
Bool[Array, ' n'] | None
|
Optional boolean mask over eigenmodes. Masked modes get a
zero coefficient instead of |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' n *batch']
|
The solution, same shape as |
Source code in src/gaussx/_linalg/_eigen_factorization.py
kronecker_sum_solve(factors: Sequence[EigenDecomposition], rhs: Float[Array, '*dims'], shift: float | Float[Array, ''] = 0.0, *, drop: Bool[Array, '*dims'] | None = None) -> Float[Array, '*dims']
¶
Solve a shifted Kronecker-sum system in tensor (unvectorized) form.
With A_k acting on axis k of the unknown tensor X (shape
(n_0, …, n_{d−1}), possibly followed by trailing batch axes), solves
i.e. (A_0 ⊕ A_1 ⊕ … ⊕ A_{d−1} − σ I) vec(X) = vec(R). In 2D this is
the Sylvester equation A_0 X + X A_1ᵀ − σX = R. With
A_k = V_k Λ_k V_k⁻¹:
X̂ = R ×_0 V_0⁻¹ ×_1 V_1⁻¹ …(rotate into the eigenbasis),X̂[i_0, …] /= λ⁰_{i_0} + λ¹_{i_1} + … − σ,X = X̂ ×_0 V_0 ×_1 V_1 …(rotate back).
Cost is O(N · Σ_k n_k) for N = Π_k n_k unknowns, and the
N × N Kronecker matrix is never formed; factors need not be
symmetric.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factors
|
Sequence[EigenDecomposition]
|
One |
required |
rhs
|
Float[Array, '*dims']
|
Right-hand side, shape |
required |
shift
|
float | Float[Array, '']
|
Scalar shift |
0.0
|
drop
|
Bool[Array, '*dims'] | None
|
Optional boolean mask of shape |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '*dims']
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the factor sizes do not match the leading axes of
|
Source code in src/gaussx/_linalg/_eigen_factorization.py
Stable distances & Lyapunov¶
Structured linear algebra and Gaussian primitives for JAX.
stable_squared_distances(X: Float[Array, 'N D'], Z: Float[Array, 'M D'], *, compute_dtype: DTypeLike = jnp.float32, accumulate_dtype: DTypeLike | None = None) -> Float[Array, 'N M']
¶
Squared Euclidean distances with mixed-precision stability.
The expansion ||x - z||^2 = ||x||^2 + ||z||^2 - 2 x^T z suffers
catastrophic cancellation when the points are far from the origin
relative to their separation, producing negative distances and
non-PSD kernels. Two measures keep it accurate:
- Both point sets are centred on their common mean first. Distances are translation-invariant, and centring removes the large shared offset that the expansion would otherwise cancel. This is what protects near-duplicate points, in any dtype.
- The norms, the cross term and the subtraction are all computed in
accumulate_dtype. Widening only the subtraction would not help: the digits are already lost in the rounded norms and cross term.
The result is cast back to compute_dtype.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
X
|
Float[Array, 'N D']
|
First set of points, shape |
required |
Z
|
Float[Array, 'M D']
|
Second set of points, shape |
required |
compute_dtype
|
DTypeLike
|
Dtype of the inputs and the result (default float32). |
float32
|
accumulate_dtype
|
DTypeLike | None
|
Dtype for the centred norms, cross term and
subtraction. |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N M']
|
Squared distances, shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/gaussx/_linalg/_mixed_precision.py
discrete_lyapunov_solve(G: Float[Array, 'N N'], Q: Float[Array, 'N N']) -> Float[Array, 'N N']
¶
Solve the discrete Lyapunov equation P - G P G^T = Q.
Uses the eigendecomposition G = V Λ V^{-1} so that
and then P = V \tilde{P} V^T. This avoids materializing the
(N², N²) Kronecker matrix the vectorized formulation requires.
Cost is O(N³) (one general eigendecomposition + a couple of
matrix multiplies) versus O(N⁶) for the vectorized solve, and
the memory footprint drops from O(N⁴) to O(N²).
The discrete Lyapunov equation has a unique solution iff
λ_i λ_j ≠ 1 for all eigenvalue pairs (λ_i, λ_j) of G.
The standard sufficient condition — G is stable
(spectral radius < 1) — guarantees this.
Warning
This implementation assumes G is diagonalizable. For
defective G (e.g., a Jordan block with eigenvalue magnitude
< 1) the eigenvector matrix V is singular and this
solve will return NaN / inf. The discrete Lyapunov
equation still has a unique solution in that case, but
recovering it requires a Schur decomposition (Bartels-Stewart),
which JAX does not currently expose. Fall back to the
vectorized (I − G ⊗ G) solve for those operators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
G
|
Float[Array, 'N N']
|
Square matrix, shape |
required |
Q
|
Float[Array, 'N N']
|
Right-hand side, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'N N']
|
Symmetric matrix |
Float[Array, 'N N']
|
|