Primitives¶
Layer 0: pure functions over lineax.AbstractLinearOperator with structural
dispatch — each primitive inspects the operator (diagonal, Kronecker,
block-diagonal, low-rank, block-tridiagonal, …) and routes to the cheapest exact
algorithm, falling back to a dense computation only when no structured path
exists. Where a structured operator has to be materialized anyway — cholesky
of a SumOfKroneckers — a DenseFallbackWarning
names the matrix-free alternative.
Solve, logdet & Cholesky¶
The workhorses behind Gaussian densities: \(A^{-1}b\), \(\log|A|\), and \(A = LL^\top\).
cholesky returns a lazy lower-triangular operator that preserves structure
(the Cholesky of a Kronecker is a Kronecker of Cholesky factors);
cholesky_logdet turns an existing factor into \(\log|A| = 2\sum_i \log L_{ii}\)
for free.
solve(A, b, solver=...) takes either a lineax solver, such as
lineax.CG(...), which replaces the dense fallback and is threaded into the
structural rules (per Kronecker factor, per block), or a gaussx strategy, such
as CGSolver(), which then owns the whole solve. See
Solvers.
Structured linear algebra and Gaussian primitives for JAX.
solve(operator: lx.AbstractLinearOperator, vector: Float[Array, ' n'], *, solver: lx.AbstractLinearSolver | AbstractSolveStrategy | None = None) -> Float[Array, ' n']
¶
Solve A x = b with structural dispatch.
Operators with no structural rule take a dense fallback. With the
default solver=None that fallback has numpy semantics: a singular or
numerically singular A gives a non-finite solution (nan or
inf), in the value and in its gradients, rather than an error, as
gaussx.logdet already has. A log-density built on it is then
non-finite too, which a sampler or optimiser can reject; an exception
would abort the whole run. An explicit solver keeps lineax's
behaviour instead, which raises on a singular system or an iterative
solver that does not converge.
Structured rules keep their own semantics for singular operators: a
singular gaussx.DiagonalizedOperator, for example, returns the
minimum-norm pseudo-inverse solution.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
The linear operator A. |
required |
vector
|
Float[Array, ' n']
|
The right-hand side b. |
required |
solver
|
AbstractLinearSolver | AbstractSolveStrategy | None
|
A lineax solver, which overrides the dense fallback and is
threaded into the structural rules (e.g. used per Kronecker
factor); or a gaussx strategy such as |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' n']
|
The solution x. On the default dense fallback, non-finite when |
Float[Array, ' n']
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If solver is neither a lineax solver nor a gaussx strategy. |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> A = lx.DiagonalLinearOperator(jnp.array([1.0, 2.0]))
>>> B = lx.DiagonalLinearOperator(jnp.array([4.0, 5.0]))
>>> K = gaussx.Kronecker(A, B) # diag(4, 5, 8, 10)
>>> x = gaussx.solve(K, jnp.ones(4)) # one solve per factor
>>> [round(float(v), 4) for v in x]
[0.25, 0.2, 0.125, 0.1]
Source code in src/gaussx/_primitives/_solve.py
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 | |
logdet(operator: lx.AbstractLinearOperator) -> Float[Array, '']
¶
Compute log |det(A)| with structural dispatch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
The linear operator A. |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar log |det(A)|. |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> A = lx.DiagonalLinearOperator(jnp.array([1.0, 2.0]))
>>> B = lx.DiagonalLinearOperator(jnp.array([4.0, 5.0]))
>>> K = gaussx.Kronecker(A, B) # diag(4, 5, 8, 10)
>>> round(float(gaussx.logdet(K)), 4) # n_B logdet(A) + n_A logdet(B)
7.3778
Source code in src/gaussx/_primitives/_logdet.py
cholesky(operator: lx.AbstractLinearOperator) -> lx.AbstractLinearOperator | SparseCholeskyFactor
¶
Compute Cholesky factor L such that A = L L^T.
Returns a linear operator (not a raw array). For structured
operators, the result preserves structure. A SparseOperator returns a
SparseCholeskyFactor of the fill-reducing permutation, P A Pᵀ = L Lᵀ
(gaussx.sparse_cholesky on the cached symbolic analysis of its
pattern), with solve, logdet, solve_lower_transpose and
diag_inv.
A scalar multiple c · A or A / c (c > 0) factors A and
scales the factor by √c, keeping its structured type. A concretely
negative multiple such as -A is not PSD and raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A positive-definite linear operator. |
required |
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator | SparseCholeskyFactor
|
Lower-triangular operator L, or a |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> psd = lx.positive_semidefinite_tag
>>> A = lx.MatrixLinearOperator(jnp.array([[2.0, 1.0], [1.0, 2.0]]), psd)
>>> B = lx.MatrixLinearOperator(jnp.array([[3.0, 0.5], [0.5, 1.0]]), psd)
>>> K = gaussx.Kronecker(A, B)
>>> L = gaussx.cholesky(K) # Kronecker(chol(A), chol(B))
>>> type(L).__name__
'Kronecker'
>>> bool(jnp.allclose((L @ L.T).as_matrix(), K.as_matrix(), atol=1e-5))
True
Source code in src/gaussx/_primitives/_cholesky.py
cholesky_logdet(L: Float[Array, 'N N']) -> Float[Array, '']
¶
Compute log|A| from Cholesky factor L where A = L Lᵀ.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
L
|
Float[Array, 'N N']
|
Lower-triangular Cholesky factor, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar log-determinant. |
Source code in src/gaussx/_primitives/_logdet.py
Pseudo-determinant¶
pseudo_logdet is \(\log|A|_+ = \sum_{\lambda_i>0}\log\lambda_i\) for a
symmetric PSD \(A\): half of it is the normalising constant of an intrinsic GMRF
(Besag / ICAR, RW1, RW2), whose structure matrix is singular. Each structure
takes its cheapest exact path:
| Operator | Path |
|---|---|
structure="laplacian" on a SparseOperator or a 1 × 1-block BlockTriDiag (Besag, RW1) |
Matrix-tree theorem: \(\log\operatorname{pdet}(L) = \sum_c(\log n_c + \log\lvert L_c^{(-k_c)}\rvert)\) over connected components, one sparse (or banded) Cholesky in all, sparsity kept |
null_space=B (any basis of \(\ker A\)) |
\(\log\lvert A + BB^\top\rvert - \log\lvert B^\top B\rvert\); the sum is matvec-only, so strategy=SLQLogdet() estimates it matrix-free |
KroneckerSum (a grid Laplacian) |
Factor eigenvalues, all pairwise sums \(\lambda^H_i + \lambda^W_j\); no factorisation |
| anything else | dense eigvalsh |
The eigenvalue paths drop \(\lambda \le\) rcond \(\cdot\,\lambda_{\max}\). The
matrix-tree theorem says every principal minor of a connected weighted
Laplacian equals the weighted spanning-tree count \(\tau_w(G)\) and
\(\operatorname{pdet}(L) = N\,\tau_w(G)\). \(\log|R|_+\) does not depend on the
precision scale \(\tau\) (\(\log|\tau R|_+ = \operatorname{rank}(R)\log\tau +
\log|R|_+\)), so compute it once and cache it.
import jax.numpy as jnp
import lineax as lx
import gaussx
# ICAR normalising constant for model comparison: computed once, cached
R = gaussx.besag_structure(laplacian) # a SparseOperator graph Laplacian
half_log_pdet = 0.5 * gaussx.pseudo_logdet(R, structure="laplacian")
# On a grid: no factorisation, sum the logs of the non-zero λ^H_i + λ^W_j
L_H = lx.MatrixLinearOperator(gaussx.rw1_structure(512).as_matrix(), lx.symmetric_tag)
L_W = lx.MatrixLinearOperator(gaussx.rw1_structure(512).as_matrix(), lx.symmetric_tag)
half_log_pdet_grid = 0.5 * gaussx.pseudo_logdet(gaussx.KroneckerSum(L_H, L_W))
Structured linear algebra and Gaussian primitives for JAX.
pseudo_logdet(operator: lx.AbstractLinearOperator, *, null_space: Float[ArrayLike, 'N c'] | Float[ArrayLike, ' N'] | None = None, structure: Literal['laplacian'] | None = None, rcond: float | None = None, strategy: AbstractLogdetStrategy | None = None) -> Float[Array, '']
¶
Log pseudo-determinant: the log of the product of the non-zero eigenvalues.
For a symmetric positive-semidefinite A,
\(\log|A|_+ = \sum_{\lambda_i > 0}\log\lambda_i\). Intrinsic GMRFs (Besag /
ICAR, RW1, RW2) have singular structure matrices R, and
\(\tfrac12\log|R|_+\) is their normalising constant. Paths, cheapest first:
structure="laplacian"(a weighted graph Laplacian, e.g. Besag or RW1): the matrix-tree theorem. Every principal \((n_c-1)\)-minor of a connected component's Laplacian \(L_c\) equals its weighted spanning-tree count, and \(\operatorname{pdet}(L_c) = n_c\) times it, so \(\log|L|_+ = \sum_c (\log n_c + \log|L_c^{(-k_c)}|)\). The minors are taken by replacing one node per component with a unit diagonal, which keeps the sparsity pattern, so it is one sparse Cholesky (SparseCholeskySolverby default; G4) for aSparseOperator, or one banded Cholesky for aBlockTriDiagwith1 × 1blocks (a path, as fromrw1_structure). Components are found from the sparsity pattern on the host (once per pattern), so every stored off-diagonal weight must be non-zero. Gradients are exact. AKroneckerSum(a grid Laplacian) takes the eigenvalue path below instead.null_spacegiven: \(\operatorname{pdet}(A) = \det(A + NN^\top)\) for orthonormal \(N\) spanning \(\ker A\) (each zero eigenvalue becomes 1). Any basis \(B\) of the kernel works, since \(\det(A + BB^\top) = \operatorname{pdet}(A)\det(B^\top B)\) and the second factor is subtracted.A + BBᵀis a matvec-only lineax sum, sostrategy=gaussx.SLQLogdet()estimates it matrix-free; the default isgaussx.logdetof it (dense). The matrix determinant lemma does not apply becauseAis singular.- A
KroneckerSum(recursively, with dense, diagonal orDiagonalizedOperatorfactors) or aDiagonalizedOperator: all pairwise sums of the factor eigenvalues, with no factorisation of the full operator. - Anything else: dense
eigvalsh.
The eigenvalue paths drop eigenvalues ≤ rcond · λ_max.
Constants are constant. log|τR|_+ = rank(R) log τ + log|R|_+, so
log|R|_+ never depends on hyperparameters: compute it once, outside
any θ loop, and keep it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Symmetric positive-semidefinite operator |
required |
null_space
|
Float[ArrayLike, 'N c'] | Float[ArrayLike, ' N'] | None
|
A basis of |
None
|
structure
|
Literal['laplacian'] | None
|
|
None
|
rcond
|
float | None
|
Relative cut-off for the eigenvalue paths. Defaults to
|
None
|
strategy
|
AbstractLogdetStrategy | None
|
Log-determinant strategy for the null-space path (of
|
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar \(\log|A|_+\). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If both |
TypeError
|
If |
Examples:
import jax.numpy as jnp
import lineax as lx
import numpy as np
import gaussx
# ICAR normalising constant on the path graph 0 - 1 - 2 - 3 (computed once)
n = 4
R = gaussx.SparseOperator.from_coo(
np.r_[np.arange(n), np.arange(1, n)],
np.r_[np.arange(n), np.arange(n - 1)],
jnp.r_[jnp.array([1.0, 2.0, 2.0, 1.0]), -jnp.ones(n - 1)],
(n, n),
symmetric=True,
)
half_log_pdet = 0.5 * gaussx.pseudo_logdet(R, structure="laplacian")
# pdet(R) = n · (one spanning tree) = 4
assert jnp.allclose(2 * half_log_pdet, jnp.log(4.0))
# The same through its null space, constants / √n
ones = jnp.ones(n) / jnp.sqrt(n)
assert jnp.allclose(gaussx.pseudo_logdet(R, null_space=ones), jnp.log(4.0))
# On a grid there is no factorisation: Σ log of the non-zero λ^H_i + λ^W_j
L = gaussx.rw1_structure(5).as_matrix()
grid = gaussx.KroneckerSum(
lx.MatrixLinearOperator(L, lx.symmetric_tag),
lx.MatrixLinearOperator(L, lx.symmetric_tag),
)
half_log_pdet_grid = 0.5 * gaussx.pseudo_logdet(grid)
Source code in src/gaussx/_primitives/_logdet.py
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 | |
Trace & diagonal¶
Exact where structure allows; stochastic (Hutchinson / Hutch++ / XTrace
probing) for matrix-free operators. trace_and_diag shares one probe pass
between both estimates.
Hutch++ (algorithm="hutchpp", Meyer et al., 2021) and XTrace
(algorithm="xtrace", Epperly et al., 2024) deflate a randomized low-rank
part, \(\operatorname{tr}(A) = \operatorname{tr}(Q^\top AQ) +
\operatorname{tr}\big((I-QQ^\top)A(I-QQ^\top)\big)\), and probe only the
remainder. On a decaying spectrum they reach relative error \(\varepsilon\) in
\(O(1/\varepsilon)\) matvecs, against Hutchinson's \(O(1/\varepsilon^2)\).
Examples are traces of kernel matrices, of \(A^{-1}B\) in GP
hyperparameter gradients, or of Hessians.
Structured linear algebra and Gaussian primitives for JAX.
trace(operator: lx.AbstractLinearOperator, *, stochastic: bool = False, num_probes: int = 20, key: jax.Array | None = None, sampler: SamplerName | None = None, algorithm: Literal['hutchinson', 'hutchpp', 'xtrace'] = 'hutchinson') -> Float[Array, '']
¶
Compute the trace of an operator.
When stochastic=True, uses a stochastic estimator — only requires
matvec access, no materialization. Hutchinson averages \(z^\top A z\)
over probes, with variance \(O(\|A\|_F^2 / m)\) after \(m\) matvecs. The
variance-reduced estimators deflate a low-rank part first,
with \(Q\) from a randomized range finder: the first term is exact and only the (small) remainder is probed. For PSD \(A\) with a decaying spectrum that reaches relative error \(\varepsilon\) in \(O(1/\varepsilon)\) matvecs instead of \(O(1/\varepsilon^2)\).
"hutchpp"(Hutch++, Meyer, Musco, Musco & Woodruff, 2021):num_probesis the total matvec budget \(m \ge 3\), a third each for the range sketch, the exact projection and Hutchinson on the remainder."xtrace"(Epperly, Tropp & Webber, 2024; matfree'sleave_one_out_xtrace): every probe both builds \(Q\) and estimates the remainder, by leave-one-out;num_probesprobes cost2 * num_probesmatvecs. Usually the most accurate per matvec.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A square linear operator. |
required |
stochastic
|
bool
|
If |
False
|
num_probes
|
int
|
Number of probe vectors for stochastic mode. |
20
|
key
|
Array | None
|
PRNG key for stochastic mode. |
None
|
sampler
|
SamplerName | None
|
Probe distribution ( |
None
|
algorithm
|
Literal['hutchinson', 'hutchpp', 'xtrace']
|
|
'hutchinson'
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar trace value (exact or estimated). |
Raises:
| Type | Description |
|---|---|
ValueError
|
For an unknown |
References
Meyer, R. A., Musco, C., Musco, C. & Woodruff, D. P. (2021). Hutch++: optimal stochastic trace estimation. SOSA, 142-155.
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 einx, jax.numpy as jnp, jax.random as jr, lineax as lx
>>> import gaussx as gx
>>> U, _ = jnp.linalg.qr(jr.normal(jr.key(0), (200, 200)))
>>> lam = 1.0 / jnp.arange(1.0, 201.0) ** 2 # decaying spectrum
>>> U_lam = einx.multiply("i k, k -> i k", U, lam)
>>> A = lx.MatrixLinearOperator(einx.dot("i k, j k -> i j", U_lam, U))
>>> est = gx.trace(A, stochastic=True, num_probes=30, algorithm="hutchpp")
>>> bool(jnp.abs(est - jnp.sum(lam)) / jnp.sum(lam) < 0.05)
True
Structured operators take an exact fast path:
>>> import gaussx
>>> A = lx.DiagonalLinearOperator(jnp.array([1.0, 2.0]))
>>> B = lx.DiagonalLinearOperator(jnp.array([4.0, 5.0]))
>>> K = gaussx.Kronecker(A, B) # diag(4, 5, 8, 10)
>>> float(gaussx.trace(K)) # trace(A) · trace(B)
27.0
Source code in src/gaussx/_primitives/_trace.py
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 | |
diag(operator: lx.AbstractLinearOperator, *, stochastic: bool = False, num_probes: int = 20, key: jax.Array | None = None, sampler: SamplerName = 'signs') -> Float[Array, ' n']
¶
Extract the diagonal of an operator as a 1D array.
When stochastic=True, uses Hutchinson's diagonal estimator
via matfree — only requires matvec access, no materialization.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A linear operator. |
required |
stochastic
|
bool
|
If |
False
|
num_probes
|
int
|
Number of probe vectors for stochastic mode. |
20
|
key
|
Array | None
|
PRNG key for stochastic mode. |
None
|
sampler
|
SamplerName
|
Probe distribution for stochastic mode ( |
'signs'
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' n']
|
1D array of diagonal entries (exact or estimated). |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> A = lx.DiagonalLinearOperator(jnp.array([1.0, 2.0]))
>>> B = lx.DiagonalLinearOperator(jnp.array([4.0, 5.0]))
>>> K = gaussx.Kronecker(A, B) # diag(4, 5, 8, 10)
>>> [float(v) for v in gaussx.diag(K)] # diag(A) ⊗ diag(B)
[4.0, 5.0, 8.0, 10.0]
Source code in src/gaussx/_primitives/_diag.py
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 | |
trace_and_diag(operator: lx.AbstractLinearOperator, *, num_probes: int = 20, key: jax.Array | None = None, sampler: SamplerName = 'signs') -> tuple[Float[Array, ''], Float[Array, ' n']]
¶
Jointly estimate the trace and diagonal from one probe pass.
Halves the matvec budget relative to calling
trace(..., stochastic=True) and diag(..., stochastic=True)
separately — both statistics are accumulated from the same
A @ probe products.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A square linear operator. |
required |
num_probes
|
int
|
Number of probe vectors. |
20
|
key
|
Array | None
|
PRNG key. If |
None
|
sampler
|
SamplerName
|
Probe distribution ( |
'signs'
|
Returns:
| Type | Description |
|---|---|
tuple[Float[Array, ''], Float[Array, ' n']]
|
Tuple |
Source code in src/gaussx/_primitives/_trace.py
Inverse, square root & spectral decompositions¶
inv and sqrt return lazy operators that route their matvecs through
structured solves / Lanczos; eig, eigvals, and svd take an optional rank
for partial (Krylov) decompositions.
Structured linear algebra and Gaussian primitives for JAX.
inv(operator: lx.AbstractLinearOperator, *, solver: lx.AbstractLinearSolver | None = None) -> lx.AbstractLinearOperator
¶
Return a lazy inverse operator A^{-1}.
The returned operator computes A^{-1} v via solve(A, v)
when mv is called. For structured operators, the inverse
preserves structure.
Related to lineax.invert (lineax >= 0.1.1), which wraps
lx.linear_solve in a FunctionLinearOperator. The gaussx
fallback InverseOperator differs in that its matvec routes
through the structured gaussx solve dispatch, and its
as_matrix uses a Cholesky path for PSD operators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
An invertible linear operator. |
required |
solver
|
AbstractLinearSolver | None
|
Optional lineax solver for the fallback InverseOperator. |
None
|
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator
|
An operator representing A^{-1}. |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> A = lx.DiagonalLinearOperator(jnp.array([1.0, 2.0]))
>>> B = lx.DiagonalLinearOperator(jnp.array([4.0, 5.0]))
>>> K = gaussx.Kronecker(A, B) # diag(4, 5, 8, 10)
>>> K_inv = gaussx.inv(K) # Kronecker(inv(A), inv(B)), never densified
>>> type(K_inv).__name__
'Kronecker'
>>> [round(float(v), 4) for v in K_inv.mv(jnp.ones(4))]
[0.25, 0.2, 0.125, 0.1]
Source code in src/gaussx/_primitives/_inv.py
sqrt(operator: lx.AbstractLinearOperator, *, lanczos_order: int | None = None) -> lx.AbstractLinearOperator
¶
Compute matrix square root S such that S @ S = A.
Requires A to be positive semi-definite.
When lanczos_order is given, returns a lazy SqrtOperator
that computes sqrt(A) @ v via matfree Lanczos without
materializing the full square root matrix.
A scalar multiple c · A or A / c (c > 0) takes the root of
A and scales it by √c, keeping its structured type. A concretely
negative multiple such as -A is not PSD and raises.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A PSD linear operator. |
required |
lanczos_order
|
int | None
|
Order of Lanczos iteration for matrix-free
sqrt. If |
None
|
Returns:
| Type | Description |
|---|---|
AbstractLinearOperator
|
Operator S satisfying S @ S = A. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> psd = lx.positive_semidefinite_tag
>>> A = lx.MatrixLinearOperator(jnp.array([[2.0, 1.0], [1.0, 2.0]]), psd)
>>> B = lx.MatrixLinearOperator(jnp.array([[3.0, 0.5], [0.5, 1.0]]), psd)
>>> S = gaussx.sqrt(gaussx.BlockDiag(A, B)) # one root per block
>>> type(S).__name__
'BlockDiag'
>>> M = gaussx.BlockDiag(A, B).as_matrix()
>>> bool(jnp.allclose((S @ S).as_matrix(), M, atol=1e-5))
True
Source code in src/gaussx/_primitives/_sqrt.py
eig(operator: lx.AbstractLinearOperator, *, rank: int | None = None, method: Literal['lanczos', 'randomized'] = 'lanczos', key: jax.Array | None = None) -> tuple[Array, Array]
¶
Compute eigenvalues and eigenvectors.
For symmetric operators returns real eigenvalues via eigh.
rank follows one rule shared with eigvals and gaussx.svd: an
operator with a structured decomposition (diagonal, gaussx.BlockDiag,
gaussx.Kronecker, gaussx.KroneckerSum) is decomposed exactly and
truncated to its rank largest eigenvalues (by real part,
descending). Any other operator gets a partial eigendecomposition of a
symmetric operator without matrix materialization, by method:
"lanczos"(default): matfree Lanczos."randomized":randomized_eighwith its defaults (oversample=10,n_power_iter=2,which="largest"); call it directly to tune them. Usen_power_iter >= 2for slowly decaying spectra.
Randomized methods target the top of the spectrum (the eigenvalues of largest magnitude).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A square linear operator. |
required |
rank
|
int | None
|
Number of eigenvalues to compute. If |
None
|
method
|
Literal['lanczos', 'randomized']
|
Partial-eig algorithm, |
'lanczos'
|
key
|
Array | None
|
PRNG key for the initial random vector (Lanczos) or the
Gaussian test matrix (randomized) when using partial eig.
If |
None
|
Returns:
| Type | Description |
|---|---|
Array
|
Tuple |
Array
|
shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/gaussx/_primitives/_eig.py
eigvals(operator: lx.AbstractLinearOperator, *, rank: int | None = None, key: jax.Array | None = None) -> Array
¶
Compute eigenvalues only.
When rank is given, returns the top-k eigenvalues: exactly and
truncated for an operator with a structured decomposition (as in
eig), via matfree Lanczos without matrix materialization otherwise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A square linear operator. |
required |
rank
|
int | None
|
Number of eigenvalues to compute. |
None
|
key
|
Array | None
|
PRNG key for partial eigendecomposition. |
None
|
Returns:
| Type | Description |
|---|---|
Array
|
Eigenvalues array of shape |
Source code in src/gaussx/_primitives/_eig.py
svd(operator: lx.AbstractLinearOperator, *, rank: int | None = None, method: Literal['lanczos', 'randomized'] = 'lanczos', key: jax.Array | None = None) -> tuple[Float[Array, 'm k'], Float[Array, ' k'], Float[Array, 'k n']]
¶
Compute the singular value decomposition A = U diag(s) V^T.
rank follows one rule shared with gaussx.eig and gaussx.eigvals:
an operator with a structured SVD (diagonal, gaussx.Kronecker,
gaussx.BlockDiag) is decomposed exactly and truncated to its rank
largest singular values. Any other operator gets a partial (truncated)
SVD without materializing the operator, by method:
"lanczos"(default): matfree's Golub-Kahan bidiagonalization."randomized":randomized_svdwith its defaults (oversample=10,n_power_iter=2); call it directly to tune them. Usen_power_iter >= 2for slowly decaying spectra.
Both target the top of the spectrum (the largest singular values).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A linear operator. |
required |
rank
|
int | None
|
Number of singular values to compute. If |
None
|
method
|
Literal['lanczos', 'randomized']
|
Partial-SVD algorithm, |
'lanczos'
|
key
|
Array | None
|
PRNG key for the initial random vector (Lanczos) or the
Gaussian test matrix (randomized) when using partial SVD.
If |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'm k']
|
Tuple |
Float[Array, ' k']
|
s has shape |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/gaussx/_primitives/_svd.py
frobenius_norm(operator: lx.AbstractLinearOperator, *, stochastic: bool = False, num_probes: int = 20, key: jax.Array | None = None, sampler: SamplerName = 'signs') -> Float[Array, '']
¶
Compute the Frobenius norm ||A||_F with structural dispatch.
Structured operators avoid materialization:
- Diagonal: vector 2-norm of the diagonal.
- BlockDiag: root of the sum of squared block norms.
- Kronecker:
||A (x) B||_F = ||A||_F * ||B||_F. - Scaled/negated/tagged operators delegate to the wrapped operator.
When stochastic=True, estimates ||A||_F^2 = tr(A^T A) via
matfree's Hutchinson estimator — matvec access only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A linear operator. |
required |
stochastic
|
bool
|
If |
False
|
num_probes
|
int
|
Number of probe vectors for stochastic mode. |
20
|
key
|
Array | None
|
PRNG key for stochastic mode. |
None
|
sampler
|
SamplerName
|
Probe distribution for stochastic mode ( |
'signs'
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Scalar Frobenius norm (exact or estimated). |
Source code in src/gaussx/_primitives/_frobenius.py
submatrix(operator: lx.AbstractLinearOperator, row_idx: Int[Array, ' R'], col_idx: Int[Array, ' C']) -> Float[Array, 'R C']
¶
Extract A[row_idx, col_idx] without forming the full matrix.
For structured operators, exploits the structure to avoid
materializing the full (N, N) matrix when only a sub-block is
needed (e.g., the conditional Gaussian extracts Sigma_AA,
Sigma_AB, Sigma_BB from a joint covariance).
Currently dispatches on:
lineax.DiagonalLinearOperatorlineax.IdentityLinearOperatorlineax.TaggedLinearOperator/MulLinearOperator/DivLinearOperator/NegLinearOperator/AddLinearOperator(recurse into the wrapped operators)gaussx.BlockDiaggaussx.Kroneckergaussx.LowRankUpdate(base sub-block plusU[r] diag(d) V[c]ᵀ)
Falls back to operator.as_matrix()[ix_(row_idx, col_idx)] for
other operators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Linear operator A, shape |
required |
row_idx
|
Int[Array, ' R']
|
Row indices, shape |
required |
col_idx
|
Int[Array, ' C']
|
Column indices, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, 'R C']
|
Dense sub-matrix |
Source code in src/gaussx/_primitives/_submatrix.py
Generalised eigenproblems¶
eigh_generalized solves \(A v = \lambda B v\) for symmetric \(A\) and PSD \(B\),
returning \(B\)-orthonormal eigenvectors (the minimisers of
\(\operatorname{tr}(Y^\top A Y)\) s.t. \(Y^\top B Y = I\) behind Laplacian
eigenmaps, LPP and manifold alignment). It dispatches on \(B\): a positive
diagonal stays matrix-free (Lanczos with rank=), a tagged positive-definite
\(B\) is Cholesky-whitened, and a singular \(B\) has its null space eliminated by a
Schur complement.
Structured linear algebra and Gaussian primitives for JAX.
eigh_generalized(A: lx.AbstractLinearOperator, B: lx.AbstractLinearOperator, *, rank: int | None = None, which: Literal['smallest', 'largest'] = 'smallest', rcond: float | None = None, key: PRNGKeyArray | None = None) -> tuple[Float[Array, ' K'], Float[Array, 'N K']]
¶
Solve \(A v = \lambda B v\) for symmetric \(A\) and symmetric PSD \(B\).
The eigenvectors are \(B\)-orthonormal, \(V^\top B V = I\), so the smallest \(K\) of them minimise \(\operatorname{tr}(Y^\top A Y)\) subject to \(Y^\top B Y = I\) (graph embeddings, LPP, manifold alignment). Dispatch is on the structure of \(B\):
lineax.DiagonalLinearOperatorwith positive entries: scale, \(S = B^{-1/2} A B^{-1/2}\), \(v = B^{-1/2} u\). This is matrix-free in \(A\): withrank=it runs Lanczos on \(x \mapsto B^{-1/2} A B^{-1/2} x\) and never materialises \(A\). A diagonal with zero entries (only checked outsidejit) is sent to the singular path below.- Positive definite, i.e. tagged
lineax.positive_semidefinite_tag(lineax's tag for Cholesky-factorisable operators): Cholesky whitening \(C^{-1} A C^{-\top}\) with \(B = C C^\top\). - Otherwise \(B\) is treated as PSD and possibly singular. \(B\) is
eigendecomposed, \(B = U_+ S_+ U_+^\top\), and directions with
eigenvalue \(\le\)
rcond\(\cdot \max\) span \(\ker B\). Writing \(A_{\cdot\cdot}\) for the blocks of \(U^\top A U\), the \(\ker B\) rows give \(v_0 = -A_{00}^{-1} A_{0+} v_+\), and the finite eigenpairs solve the Schur-complement pencil \((A_{++} - A_{+0} A_{00}^{-1} A_{0+}) v_+ = \lambda S_+ v_+\). \(A_{00}\) must be positive definite, otherwise the trace minimisation is unbounded along \(\ker B\) and aValueErroris raised. At most \(\operatorname{rank}(B)\) finite eigenpairs exist; asking for more warns and returns fewer. The numerical rank decides output shapes, so this path cannot run underjax.jit(tag \(B\) positive definite for a traceable path).
Krylov subspaces are shift-invariant, so the Lanczos path needs no
spectral shift for which="smallest": it runs an oversampled Krylov
space of dimension min(N, max(2 * rank + 1, rank + 20)) and keeps
the rank extreme Ritz pairs at the requested end.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
A
|
AbstractLinearOperator
|
Symmetric |
required |
B
|
AbstractLinearOperator
|
Symmetric positive semidefinite |
required |
rank
|
int | None
|
Number of eigenpairs \(K\). |
None
|
which
|
Literal['smallest', 'largest']
|
|
'smallest'
|
rcond
|
float | None
|
Relative threshold on \(B\)'s eigenvalues below which a
direction counts as \(\ker B\) (singular path only). |
None
|
key
|
PRNGKeyArray | None
|
PRNG key for the Lanczos start vector (diagonal path with
|
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' K']
|
|
Float[Array, 'N K']
|
eigenvalues in ascending order, eigenvectors \(B\)-orthonormal. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import jax.numpy as jnp
>>> import lineax as lx
>>> import gaussx
>>> # Laplacian eigenmaps on a path graph: L y = λ D y.
>>> # Its eigenvalues are 1 - cos(πk/4); rank= runs Lanczos.
>>> W = jnp.diag(jnp.ones(4), 1) + jnp.diag(jnp.ones(4), -1)
>>> degree = W @ jnp.ones(5)
>>> L = lx.MatrixLinearOperator(jnp.diag(degree) - W, lx.symmetric_tag)
>>> D = lx.DiagonalLinearOperator(degree)
>>> lam, Y = gaussx.eigh_generalized(L, D, rank=2, which="smallest")
>>> bool(jnp.allclose(lam, 1 - jnp.cos(jnp.pi * jnp.arange(2) / 4), atol=1e-5))
True
>>> # A singular B: one finite eigenpair fewer than N.
>>> B = lx.MatrixLinearOperator(jnp.diag(jnp.array([1.0, 2.0, 0.0])))
>>> A = lx.MatrixLinearOperator(jnp.eye(3) + 0.5 * jnp.ones((3, 3)))
>>> lam, V = gaussx.eigh_generalized(A, B)
>>> lam.shape
(2,)
Source code in src/gaussx/_primitives/_eig.py
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 | |
Matrix-free square-root products¶
\(A^{\pm 1/2}b\) for an operator too large to factorise, via the
Hale–Higham–Trefethen contour-integral quadrature: a weighted sum of ~15
shifted solves \((A + \sigma_j I)^{-1}b\), each routed back through solve.
Accuracy depends on the condition number only logarithmically, and
estimate_spectral_bounds supplies the contour parameters when they are not
known ahead of time.
Structured linear algebra and Gaussian primitives for JAX.
sqrt_matmul(operator: lx.AbstractLinearOperator, rhs: Float[Array, 'N C'], *, num_quadrature: int = _DEFAULT_NUM_QUADRATURE, max_lanczos_iter: int = _DEFAULT_MAX_LANCZOS_ITER, spectral_bounds: tuple[float, float] | None = None, solver: lx.AbstractLinearSolver | None = None) -> Float[Array, 'N C']
¶
Compute \(A^{1/2} b\) via contour-integral quadrature.
Shares the quadrature of sqrt_inv_matmul through the identity
\(A^{1/2} b = A \, (A^{-1/2} b)\), which costs one extra matvec per column.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Square symmetric positive-definite operator \(A\). |
required |
rhs
|
Float[Array, 'N C']
|
Right-hand side of shape |
required |
num_quadrature
|
int
|
Number of contour-quadrature nodes \(J\). |
_DEFAULT_NUM_QUADRATURE
|
max_lanczos_iter
|
int
|
Lanczos iterations used to estimate the spectral
bounds when |
_DEFAULT_MAX_LANCZOS_ITER
|
spectral_bounds
|
tuple[float, float] | None
|
Optional |
None
|
solver
|
AbstractLinearSolver | None
|
Optional lineax solver used for the shifted solves. |
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N C']
|
The array \(A^{1/2} b\) of shape |
Source code in src/gaussx/_primitives/_sqrt_matmul.py
sqrt_inv_matmul(operator: lx.AbstractLinearOperator, rhs: Float[Array, 'N C'], *, num_quadrature: int = _DEFAULT_NUM_QUADRATURE, max_lanczos_iter: int = _DEFAULT_MAX_LANCZOS_ITER, spectral_bounds: tuple[float, float] | None = None, solver: lx.AbstractLinearSolver | None = None) -> Float[Array, 'N C']
¶
Compute \(A^{-1/2} b\) via contour-integral quadrature.
Uses the integral representation
discretised by the Hale-Higham-Trefethen elliptic-integral rule, which turns it into \(J\) shifted solves
Each shifted operator is positive definite, so every solve goes through
the ordinary gaussx.solve dispatch. Accuracy improves geometrically in
\(J\) with a rate that depends only logarithmically on the condition
number \(\kappa = \lambda_{\max} / \lambda_{\min}\): \(J = 15\) reaches
1e-13 at \(\kappa = 10^3\) and 1e-7 at \(\kappa = 10^6\).
The quadrature nodes and weights are treated as constants by
jax.grad — they parameterise the contour, not the function value, and
the rule is designed to be insensitive to them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Square symmetric positive-definite operator \(A\). |
required |
rhs
|
Float[Array, 'N C']
|
Right-hand side of shape |
required |
num_quadrature
|
int
|
Number of contour-quadrature nodes \(J\). |
_DEFAULT_NUM_QUADRATURE
|
max_lanczos_iter
|
int
|
Lanczos iterations used to estimate the spectral
bounds when |
_DEFAULT_MAX_LANCZOS_ITER
|
spectral_bounds
|
tuple[float, float] | None
|
Optional |
None
|
solver
|
AbstractLinearSolver | None
|
Optional lineax solver used for the shifted solves. Pass
|
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, 'N C']
|
The array \(A^{-1/2} b\) of shape |
Source code in src/gaussx/_primitives/_sqrt_matmul.py
estimate_spectral_bounds(operator: lx.AbstractLinearOperator, *, max_lanczos_iter: int = _DEFAULT_MAX_LANCZOS_ITER, safety: float = _DEFAULT_SAFETY, key: jax.Array | None = None) -> tuple[Float[Array, ''], Float[Array, '']]
¶
Estimate \((\lambda_{\min}, \lambda_{\max})\) of a symmetric operator.
Structured operators (diagonal, identity, Kronecker, block-diagonal,
Kronecker-sum) return their exact spectra, including when wrapped in
lineax.TaggedLinearOperator or scaled by a scalar -- gaussx.eigvals
only recognises the bare classes. Everything else runs a partial Lanczos
decomposition with full reorthogonalisation, which stops at the first
breakdown: an operator such as cI + UUᵀ exhausts its Krylov space
after rank(U) + 1 steps, and continuing past that point only
normalises rounding noise into spurious Ritz values.
Ritz values interlace the true spectrum, so a partial Lanczos run brackets
it from the inside — and the smallest eigenvalue is the slowest one to
converge, so the gap can be a factor of several. The estimate is therefore
widened by safety in each direction whenever it came from a partial
run. Widening is close to free for the contour quadrature that consumes
these bounds: its node count grows only with \(\log \kappa\).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
A square symmetric positive-definite operator. |
required |
max_lanczos_iter
|
int
|
Number of Lanczos iterations (clamped to the operator size). |
_DEFAULT_MAX_LANCZOS_ITER
|
safety
|
float
|
Factor by which a partial-Lanczos bracket is widened in each direction. Ignored when the full spectrum is available. |
_DEFAULT_SAFETY
|
key
|
Array | None
|
PRNG key for the Lanczos start vector. Defaults to
|
None
|
Returns:
| Type | Description |
|---|---|
Float[Array, '']
|
Tuple |
Float[Array, '']
|
at a positive value so downstream square roots stay finite. |
Source code in src/gaussx/_primitives/_sqrt_matmul.py
Joint inverse-quadratic and log-determinant¶
inv_quad_logdet returns \(\mathrm{tr}(R^\top A^{-1}R)\) and \(\log|A|\) from a
single modified-batched-CG pass — the two halves of a Gaussian log-density at
roughly the matvec budget of one. Supplying logdet_preconditioner= \(P \approx A\)
switches on the Artemev et al. variance reduction, estimating only the
near-identity residual \(\log(P^{-1}A)\) stochastically. \(P\) approximates \(A\)
itself, unlike the preconditioner= (\(M^{-1} \approx A^{-1}\)) of the CG
solvers. The old keyword preconditioner= is a deprecated alias.
Structured linear algebra and Gaussian primitives for JAX.
inv_quad_logdet(operator: lx.AbstractLinearOperator, rhs: Float[Array, 'N C'], *, strategy: AbstractSolverStrategy | None = None, reduce_inv_quad: bool = True, logdet_preconditioner: lx.AbstractLinearOperator | None = None, preconditioner: lx.AbstractLinearOperator | None = UNSET) -> tuple[Float[Array, ' *C'], Float[Array, '']]
¶
Joint inverse-quadratic and log-determinant via shared BBMM CG.
Returns the pair
which together with the \(-\tfrac{N}{2}\log 2\pi\) constant make up the
Gaussian log-density. For a structured operator with an exact
log-determinant the default is the exact structural pair. Otherwise, with
gaussx.BBMMSolver, both come out of one modified-batched-CG pass over
[rhs | probes], so a marginal-likelihood step costs roughly half the
matvecs of a separate gaussx.solve plus gaussx.logdet.
Passing a logdet_preconditioner \(P \approx A\) applies the variance reduction
of Artemev et al. (2021): the same CG pass then produces the Lanczos
tridiagonal of \(P^{-1} A\), so the stochastic part of
only has to estimate a trace of a near-identity operator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Square symmetric positive-definite operator \(A\). |
required |
rhs
|
Float[Array, 'N C']
|
Right-hand side \(R\) of shape |
required |
strategy
|
AbstractSolverStrategy | None
|
Solver strategy. |
None
|
reduce_inv_quad
|
bool
|
Whether to sum the per-column quadratic forms into
the trace. When |
True
|
logdet_preconditioner
|
AbstractLinearOperator | None
|
Optional operator \(P \approx A\) with a cheap
log-determinant, used for variance reduction as above. This is the
approximation to \(A\) itself, not an approximate inverse like
the |
None
|
preconditioner
|
AbstractLinearOperator | None
|
Deprecated alias of |
UNSET
|
Returns:
| Type | Description |
|---|---|
Float[Array, ' *C']
|
Tuple |
Float[Array, '']
|
|
Source code in src/gaussx/_primitives/_inv_quad_logdet.py
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 | |
Root decompositions¶
Tall-factor approximations \(RR^\top \approx A\) (and \(R^- (R^-)^\top \approx A^{-1}\)) via Cholesky, pivoted Cholesky, Lanczos, or truncated SVD — the building block for low-rank posterior sampling and BBMM-style solvers.
Structured linear algebra and Gaussian primitives for JAX.
RootDecomposition
¶
Bases: Module
Tall factor R with R Rᵀ ≈ A semantics.
Attributes:
| Name | Type | Description |
|---|---|---|
root |
Float[Array, 'N k']
|
Tall factor with shape |
Source code in src/gaussx/_primitives/_root.py
rank: int
property
¶
Number of retained root directions.
root_decomposition(operator: lx.AbstractLinearOperator, rank: int = 50, method: RootMethod = 'lanczos', key: jax.Array | None = None) -> RootDecomposition
¶
Compute a tall factor R such that R Rᵀ ≈ A.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Square symmetric positive-definite operator |
required |
rank
|
int
|
Number of retained directions. Ignored by |
50
|
method
|
RootMethod
|
Decomposition method: |
'lanczos'
|
key
|
Array | None
|
PRNG key for random-start methods. |
None
|
Returns:
| Type | Description |
|---|---|
RootDecomposition
|
A |
Source code in src/gaussx/_primitives/_root.py
root_inv_decomposition(operator: lx.AbstractLinearOperator, rank: int = 50, method: RootMethod = 'lanczos', key: jax.Array | None = None) -> RootDecomposition
¶
Compute a tall factor R⁻ such that R⁻ (R⁻)ᵀ ≈ A⁻¹.
method chooses the approximation target, not only the algorithm
(gh-413):
"lanczos"/"svd":R⁻ (R⁻)ᵀisA⁻¹restricted to the dominantrank-dimensional eigenspace ofA(its largest eigenvalues, inverted) — GPyTorch's convention, used bygaussx.love_cache."pivoted_cholesky":R⁻ (R⁻)ᵀis a rank-rankapproximation ofA⁻¹itself, which favours the dominant subspace ofA⁻¹(the smallest eigenvalues ofA); on a diagonalAit keeps theranklargest1/d, the Frobenius-optimal rank-rankapproximation ofA⁻¹."cholesky": exact (rankis ignored).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
operator
|
AbstractLinearOperator
|
Square symmetric positive-definite operator |
required |
rank
|
int
|
Number of retained directions. Ignored by |
50
|
method
|
RootMethod
|
Decomposition method: |
'lanczos'
|
key
|
Array | None
|
PRNG key for random-start methods. |
None
|
Returns:
| Type | Description |
|---|---|
RootDecomposition
|
A |
Source code in src/gaussx/_primitives/_root.py
Quantiles¶
mixture_quantile inverts a CDF \(F\) at levels \(q\) by solving
\(F(x) - q = 0\) on a bracket \([\ell, u]\) — the standard remedy for a mixture
\(F_{\text{mix}} = \tfrac{1}{E}\sum_e F_e\), which has no closed-form inverse.
It runs optimistix's root_find
with Chandrupatla, a bracketing solver that mixes inverse quadratic
interpolation with bisection (Chandrupatla, 1997), so it needs no
derivatives, and differentiates through the implicit adjoint
\(\partial x_q / \partial\theta = -\partial_\theta F / p(x_q)\).
mixture_quantile_gaussian_approx is the cheap moment-matched Gaussian
alternative for Gaussian ensembles (Lakshminarayanan et al., 2017).
import jax.numpy as jnp
from jax.scipy.stats import norm
import gaussx
means = jnp.array([-1.0, 1.0]) # two-component mixture
cdf = lambda x: 0.5 * (norm.cdf(x - means[0]) + norm.cdf(x - means[1]))
gaussx.mixture_quantile(cdf, jnp.array([0.025, 0.5, 0.975]), -10.0, 10.0)
Structured linear algebra and Gaussian primitives for JAX.
Chandrupatla
¶
Bases: AbstractRootFinder
Chandrupatla's hybrid inverse-quadratic / bisection root finder.
A bracketing method for \(f(x) = 0\) that keeps the robustness of bisection and the superlinear convergence of inverse quadratic interpolation (Chandrupatla, 1997). The bracket \([a, b]\) with \(f(a) f(b) \le 0\) and the previous point \(c\) are updated each step with the new point \(x_t = (1 - t)\,a + t\,b\). The next \(t\) is the inverse quadratic interpolant through \((a, b, c)\),
accepted only when \(\xi = (a - b)/(c - b)\) and \(\Phi = (f_a - f_b)/(f_c - f_b)\) satisfy \(\Phi^2 < \xi\) and \((1 - \Phi)^2 < 1 - \xi\), i.e. when the interpolant is monotone on the bracket; otherwise \(t = 1/2\) (bisection). \(t\) is clipped to \([t_\ell, 1 - t_\ell]\) with \(t_\ell = \tfrac12\tau / |b - c|\) and \(\tau = \mathrm{rtol}\,|x_m| + \mathrm{atol}\), where \(x_m\) is whichever of \(a, b\) has the smaller \(|f|\). The solve stops when \(t_\ell > 1/2\), i.e. \(|b - c| < \tau\), which bounds the bracket \(|b - a| \le |b - c|\) and so puts \(x_m\) within \(\tau\) of the root; or when \(f(x_m) = 0\); or when the bracket has shrunk to adjacent floats and \(x_t\) can no longer move. It returns \(x_m\). (Scherer's formulation stops at \(2\tau\); halving keeps the requested tolerance.)
The solver is elementwise: y may be an array, fn must act
on each entry independently (as a CDF evaluated pointwise does), and
every entry carries its own bracket and stops on its own. The loop
ends when all entries have converged. Pass tags=frozenset(
{lineax.diagonal_tag}) to optimistix.root_find so the implicit
adjoint uses the diagonal Jacobian.
Requires options=dict(lower=..., upper=...) (broadcastable to
y), as optimistix.Bisection does. The bracket and every iterate
keep y's dtype; only the function values take fn's. An entry
whose bracket has no sign change (or a NaN endpoint value) is not
iterated: it returns the endpoint with the smaller \(|f|\), so the caller
decides whether that is an error. With has_aux=True the auxiliary
output is that of fn at the returned root (one extra evaluation).
Pseudocode (per entry, Scherer 2010, §6.1):
b, a, c = lower, upper, upper; t = 1/2
repeat:
x_t = (1 − t) a + t b; f_t = f(x_t)
if sign f_t = sign f_a: c ← a else: c ← b; b ← a
a ← x_t
x_m = argmin over a, b of |f|; t_lim = (rtol |x_m| + atol) / (2 |b − c|)
stop if f(x_m) = 0 or t_lim > 1/2 or x_t ∈ {a, b} before the update
t = IQI(a, b, c) if Φ² < ξ and (1 − Φ)² < 1 − ξ else 1/2
t = clip(t, t_lim, 1 − t_lim)
Attributes:
| Name | Type | Description |
|---|---|---|
rtol |
float
|
Relative tolerance on the root. |
atol |
float
|
Absolute tolerance on the root. |
References
Chandrupatla, T. R. (1997). A new hybrid quadratic/bisection algorithm for finding the zero of a nonlinear function without using derivatives. Advances in Engineering Software 28(3), 145-149.
Scherer, P. O. J. (2010). Computational Physics: Simulation of Classical and Quantum Systems. Springer, §6.1.
Examples:
>>> import jax.numpy as jnp
>>> import optimistix as optx
>>> import gaussx
>>> sol = optx.root_find(
... lambda x, _: x**3 - 2.0,
... gaussx.Chandrupatla(rtol=1e-10, atol=1e-12),
... jnp.array(1.0),
... options=dict(lower=0.0, upper=2.0),
... )
>>> round(float(sol.value), 6)
1.259921
Source code in src/gaussx/_primitives/_chandrupatla.py
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 | |
mixture_quantile(cdf_fn: Callable[[Float[Array, ...]], Float[Array, ...]], q: Float[ArrayLike, ...], lower: Float[ArrayLike, ...], upper: Float[ArrayLike, ...], *, solver: optx.AbstractRootFinder | None = None, rtol: float = 0.0001, atol: float = 1e-06, max_steps: int = 64, throw: bool = False) -> Float[Array, ...]
¶
Invert a CDF at one or more quantile levels by bracketed root finding.
The \(q\)-quantile of a continuous CDF \(F\) is the root of \(g_q(x) = F(x) - q\). \(F\) is monotone, so \(g_q\) changes sign exactly once on any bracket \([\ell, u]\) with \(F(\ell) \le q \le F(u)\), and a bracketing method needs neither derivatives nor a good initial guess. A mixture or ensemble CDF
has no closed-form inverse even when every \(F_e\) does; this is what
the function is for. The default solver is Chandrupatla, run through
optimistix.root_find.
Shapes. q, lower and upper broadcast together (NumPy
rules) to the output shape, and cdf_fn is called on arrays of that
shape. It must act elementwise — entry i of its output may
depend only on entry i of its input — e.g. a scalar CDF, or a
per-test-point mixture CDF with a trailing quantile axis. With Q
levels q of shape (Q,) and a batch of brackets, give the
brackets a trailing axis, (*batch, 1), to get (*batch, Q).
Gradients go through optimistix.ImplicitAdjoint: for a root
\(x_q\) of \(F(x; \theta) - q\),
with \(p = \partial_x F\) the density, evaluated with one Jacobian-vector
product (the Jacobian is tagged diagonal). Parameters closed over by
cdf_fn are differentiated too. The brackets are not.
Pseudocode:
F_lo, F_hi = cdf(lower) − q, cdf(upper) − q
if throw: error unless F_lo · F_hi ≤ 0 everywhere
x = root_find(x ↦ cdf(x) − q, Chandrupatla, bracket [lower, upper],
adjoint = implicit, Jacobian tag = diagonal)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cdf_fn
|
Callable[[Float[Array, ...]], Float[Array, ...]]
|
Elementwise CDF, mapping an array to an array of the same
shape. It must be strictly increasing where it crosses |
required |
q
|
Float[ArrayLike, ...]
|
Quantile levels in \([0, 1]\). |
required |
lower
|
Float[ArrayLike, ...]
|
Finite lower bracket, with |
required |
upper
|
Float[ArrayLike, ...]
|
Finite upper bracket, with |
required |
solver
|
AbstractRootFinder | None
|
An optimistix root finder that takes
|
None
|
rtol
|
float
|
Relative tolerance on the quantile (default solver only). |
0.0001
|
atol
|
float
|
Absolute tolerance on the quantile (default solver only). |
1e-06
|
max_steps
|
int
|
Maximum number of solver iterations. |
64
|
throw
|
bool
|
If |
False
|
Returns:
| Type | Description |
|---|---|
Float[Array, ...]
|
Quantiles, with the broadcast shape of |
Float[Array, ...]
|
|
References
Chandrupatla, T. R. (1997). A new hybrid quadratic/bisection algorithm for finding the zero of a nonlinear function without using derivatives. Advances in Engineering Software 28(3), 145-149.
Examples:
>>> import jax.numpy as jnp
>>> from jax.scipy.stats import norm
>>> import gaussx
>>> cdf = lambda x: 0.5 * (norm.cdf(x + 1.0) + norm.cdf(x - 1.0))
>>> x = gaussx.mixture_quantile(cdf, jnp.array([0.5, 0.975]), -10.0, 10.0)
>>> [round(float(v), 3) for v in x]
[0.0, 2.646]
Source code in src/gaussx/_primitives/_quantile.py
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 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 | |
mixture_quantile_gaussian_approx(means: Float[Array, '*batch E'], stds: Float[Array, '*batch E'], q: Float[ArrayLike, ' Q']) -> Float[Array, '*batch Q']
¶
Quantiles of the Gaussian matched to an ensemble's first two moments.
For an equal-weight ensemble of Gaussians \(\mathcal{N}(\mu_e, \sigma_e^2)\) the mixture has mean and variance
and its quantiles are approximated by those of
\(\mathcal{N}(\hat\mu, \hat\sigma^2)\),
\(\hat x_q = \hat\mu + \hat\sigma\,\Phi^{-1}(q)\) — the deep-ensemble
predictive of Lakshminarayanan, Pritzel & Blundell (2017). It is exact
for a single component and cheap otherwise: a diagnostic, or a seed
for the brackets of mixture_quantile. The variance is accumulated
from deviations about \(\hat\mu\), which equals the raw-moment form
\(\frac{1}{E}\sum_e(\sigma_e^2 + \mu_e^2) - \hat\mu^2\) without its
cancellation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
means
|
Float[Array, '*batch E']
|
Component means, shape |
required |
stds
|
Float[Array, '*batch E']
|
Component standard deviations, shape |
required |
q
|
Float[ArrayLike, ' Q']
|
Quantile levels, shape |
required |
Returns:
| Type | Description |
|---|---|
Float[Array, '*batch Q']
|
Approximate quantiles, shape |
References
Lakshminarayanan, B., Pritzel, A. & Blundell, C. (2017). Simple and scalable predictive uncertainty estimation using deep ensembles. NeurIPS 30.
Examples:
>>> import jax.numpy as jnp
>>> import gaussx
>>> x = gaussx.mixture_quantile_gaussian_approx(
... jnp.array([[0.0]]), jnp.array([[2.0]]), jnp.array([0.5, 0.8413447])
... )
>>> [round(float(v), 4) for v in x[0]]
[0.0, 2.0]
Source code in src/gaussx/_primitives/_quantile.py
Support types¶
Structured linear algebra and Gaussian primitives for JAX.