Architecture¶
gaussx is organised as a layered stack on top of lineax and matfree. Each layer is usable on its own, and each layer builds on the ones beneath it (with one exception, the dispatch core, described under Import structure). You can enter wherever your problem lives: grab a single primitive, build a structured operator, swap a solver strategy, or call a finished recipe.
The stack at a glance¶
flowchart TB
subgraph L3["Layer 3 · Recipes"]
direction LR
GP["Gaussian processes<br/><small>conditioning · ELBOs · whitening · LOVE · OILMM</small>"]
SSM["State-space models<br/><small>SDE kernels · Kalman · RTS · SpInGP · CVI</small>"]
QUAD["Quadrature<br/><small>Gauss-Hermite · unscented · Taylor · MC</small>"]
INF["Inference and ensembles<br/><small>BLR · natural gradient · EnKF</small>"]
end
subgraph L2["Layer 2 · Distributions and sugar"]
direction LR
DIST["MultivariateNormal<br/>MultivariateNormalPrecision"]
SUGAR["log_prob · entropy · KL<br/>conditional · Joseph · project"]
EF["Exponential family<br/><small>natural ↔ expectation · Fisher</small>"]
end
subgraph L15["Layer 1.5 · Strategies and preconditioners"]
direction LR
STRAT["DenseSolver · CGSolver · BBMM<br/>MINRES · LSMR · SLQ logdet"]
PRE["Jacobi · Nyström<br/>PartialCholesky · Operator"]
FRONT["linear_solve<br/><small>the front door</small>"]
end
subgraph L1["Layer 1 · Operators"]
direction LR
OPS["Kronecker · KroneckerSum · BlockDiag<br/>BlockTriDiag · LowRankUpdate · Toeplitz<br/>Interpolated · Masked · Circulant"]
TAGS["Structural tags<br/><small>_tags.py</small>"]
end
subgraph L0["Layer 0 · Primitives"]
PRIM["solve · logdet · cholesky · diag · trace<br/>sqrt · inv · eig · svd · root_decomposition"]
LINALG["Linear-algebra utilities<br/><small>Woodbury · Schur · safe_cholesky · tridiagonal</small>"]
end
subgraph EXT["Foundations"]
direction LR
LINEAX["lineax<br/><small>solvers · AbstractLinearOperator · tags</small>"]
MATFREE["matfree<br/><small>Lanczos · SLQ · Hutchinson</small>"]
EQXDEP["equinox · jaxtyping · einx"]
end
L3 --> L2
L2 --> L15
L15 --> L1
L1 --> L0
L0 --> EXT
| Layer | Name | Owns | Entry point |
|---|---|---|---|
| 0 | Primitives | Pure functions with structural dispatch | solve, logdet, cholesky, … |
| 1 | Operators | Structured matrices + tags | Kronecker, BlockDiag, … |
| 1.5 | Strategies | How a solve/logdet is computed | DenseSolver, CGSolver, … |
| 2 | Distributions | Gaussians over operators, sugar ops, exp-family | MultivariateNormal, … |
| 3 | Recipes | Domain sequences wiring layers 0–2 | GP · SSM · quadrature · inference |
Layer 0 --- Primitives¶
Pure functions that match the equations in papers. Every one takes a
lineax.AbstractLinearOperator and returns arrays or operators:
x = gaussx.solve(A, b) # solve A x = b
ld = gaussx.logdet(A) # log|det(A)|
L = gaussx.cholesky(A) # A = L L^T (lazy, structure-preserving)
d = gaussx.diag(A) # diagonal entries
t = gaussx.trace(A) # tr(A)
S = gaussx.sqrt(A) # S with S S = A
A_inv = gaussx.inv(A) # lazy A^{-1}
Two properties are worth internalising:
- Lazy over dense.
cholesky,sqrt, andinvreturn operators. The Cholesky of aKroneckeris aKroneckerof Cholesky factors; nothing is materialized until you call.as_matrix(). - The expensive surprise is flagged.
cholesky(SumOfKroneckers)is the one path where a structured operator you would reasonably expect to stay structured must materialize the dense covariance, so it raises aDenseFallbackWarningand points you atsqrt(...)/sum_of_kroneckers_sample(...)instead. Ordinary unstructured operators densify quietly --- there was never a fast path to lose.
How dispatch actually works¶
No registry, no metaclass, no plugin system. Each primitive is a chain of
isinstance checks in a single readable function, ending in a dense fallback:
flowchart TD
A["gaussx.solve(A, b)"] --> ID{"Identity?"}
ID -- yes --> RID["return b"]
ID -- no --> IV{"InverseOperator?"}
IV -- yes --> RIV["one matvec with the original"]
IV -- no --> DG{"Diagonal / Diagonalized?"}
DG -- yes --> RDG["divide by the eigenvalues — O(n) / transform pair"]
DG -- no --> MK{"Masked with a capacitance?"}
MK -- yes --> RMK["capacitance solve"]
MK -- no --> BD{"BlockDiag / Kronecker?"}
BD -- yes --> RBD["per block / Roth's column lemma"]
BD -- no --> LR{"LowRankUpdate?"}
LR -- yes --> RLR["Woodbury identity"]
LR -- no --> KS{"SumOfKroneckers / KroneckerSum(Sqrt) /<br/>SpectralFunction?"}
KS -- yes --> RKS["joint eigenbasis"]
KS -- no --> BT{"Block-tridiagonal / bidiagonal /<br/>SparseOperator?"}
BT -- yes --> RBT["block-banded substitution / CG"]
BT -- no --> WR{"Wrapper?<br/><small>Tagged · Mul · Div · Neg · Composed</small>"}
WR -- yes --> RWR["unwrap, recurse,<br/>fix up the scalar"]
WR -- no --> AD{"Add of two Kroneckers?"}
AD -- yes --> RAD["sum-of-Kroneckers eigen reduction"]
AD -- no --> FB["dense / iterative fallback<br/><small>lineax solver chosen from tags</small>"]
style FB stroke-dasharray: 5 5
logdet, cholesky, diag, trace, sqrt, and inv follow the same shape
with their own fast paths. Coverage differs per primitive --- sqrt, for
instance, has no block-tridiagonal path and falls back there. In the table
below, dense means the operator has no branch in that primitive and takes
the fallback in the last row, and lazy means inv wraps it in an
InverseOperator whose matvec is a solve. tests/test_docs_dispatch_table.py
checks every row against the isinstance chains in src/gaussx/_primitives/,
so a new branch without a row, or a dense / lazy cell that gains a
branch, fails the test suite.
| Operator | solve |
logdet |
cholesky |
diag / trace |
sqrt |
inv |
|---|---|---|---|---|---|---|
Identity / Diagonal |
O(n) | O(n) | O(n) | O(n) | O(n) | O(n) |
BlockDiag |
per block | sum of logdets | per block | per block | per block | per block |
Kronecker |
Roth's lemma | scaled sum | per factor | per factor | per factor | per factor |
KroneckerSum |
joint eigenbasis | \(\sum \log(\lambda_i + \mu_j)\) | dense | from the factors' diagonals / traces | KroneckerSumSqrt |
lazy |
KroneckerSumSqrt |
joint eigenbasis | dense | dense | dense | dense | lazy |
SumOfKroneckers |
two terms: whiten + per-factor eigh; else dense | two terms: eigenvalue sum; else dense | dense (warns) | per term | SumOfKroneckersSqrt |
lazy |
SpectralFunction |
per-axis eigenbasis | \(\sum \log\lvert f(\lambda)\rvert\) | dense | diag: per-axis eigenbasis; trace: dense | dense | lazy |
LowRankUpdate |
Woodbury | determinant lemma | dense | base + update | orthonormal \(U\) with scaled-identity base: closed form; else dense | square base: Woodbury |
BlockTriDiag |
symmetric: block-banded; else dense | symmetric: block Cholesky; else dense | block Cholesky | per block | dense | lazy |
LowerBlockTriDiag / UpperBlockTriDiag |
block forward / back substitution | sum over the diagonal blocks | dense | per block | dense | lazy |
DiagonalizedOperator / circulant |
transform pair | \(\sum \log\lvert\lambda\rvert\) | dense (warns; use sqrt) |
trace: \(\sum \lambda\); diag: \(\bar\lambda\) for the FFT pair, else dense (warns) | eigenvalues | eigenvalues |
SparseOperator |
large and PSD: CG; else dense | large and PSD: SLQ estimate; else dense | sparse Cholesky | diag: stored diagonal; trace: dense | dense | lazy |
MaskedOperator |
with a capacitance: capacitance solve; else dense | dense | dense | dense | dense | lazy |
Toeplitz |
dense | dense | dense | \(c_0\) (constant diagonal) | dense | lazy |
InterpolatedOperator |
dense | dense | dense | dense | dense | lazy |
InverseOperator (from inv) |
one matvec with \(A\) | dense | dense | dense | dense | returns \(A\) |
Tagged |
unwrap + recurse | unwrap + recurse | unwrap + recurse | unwrap + recurse | unwrap + recurse | lazy |
Mul / Div / Neg |
unwrap + recurse, fix up the scalar | unwrap + recurse, add \(\pm n \log\lvert c\rvert\) | \(\sqrt{c}\) folded into the factor; -A raises |
unwrap + recurse, scale | \(\sqrt{c}\) folded into the root; -A raises |
unwrap + recurse, scale by \(1/c\) |
Composed |
\(B^{-1}(A^{-1} b)\) | square factors: sum of logdets; else dense | dense | dense | dense | square factors: \(B^{-1} A^{-1}\); else lazy |
Add |
two Kronecker terms: eigen reduction; else dense | two Kronecker terms: eigenvalue sum; else dense | dense | sum of the two parts | dense | lazy |
| Everything else | lineax solver chosen from the tags | slogdet |
jax.scipy Cholesky |
dense | dense eigh | lazy InverseOperator |
Several of the dense cells have an opt-in matrix-free escape hatch backed by matfree:
diag(A, stochastic=True)andtrace(A, stochastic=True)switch to Hutchinson / XTrace probing.trace_and_diagshares a single probe pass between both estimates.sqrt(A, lanczos_order=k)returns a lazySqrtOperatorthat routes its matvecs through Lanczos instead of an eigendecomposition.
For logdet, the matrix-free route is a Layer 1.5 concern rather than a
primitive flag: pass SLQLogdet() (or a ComposedSolver) as the solver=.
Layer 1 --- Operators¶
Structured operators extending lineax.AbstractLinearOperator. Each is an
equinox.Module --- an immutable PyTree, safe under jit, grad, and vmap
--- supporting mv, as_matrix, transpose, and carrying structural tags.
flowchart LR
ALO["lineax.AbstractLinearOperator"]
ALO --> P["Products and sums"]
ALO --> LRK["Low-rank"]
ALO --> B["Banded and structured"]
ALO --> K["Interpolated and masked"]
ALO --> LZ["Lazy algebra"]
P --> P1["Kronecker"]
P --> P2["KroneckerSum"]
P --> P3["SumOfKroneckers"]
P --> P4["BlockDiag"]
LRK --> L1["LowRankUpdate"]
LRK --> L2["SVDLowRankUpdate"]
B --> B1["BlockTriDiag"]
B --> B2["Lower/UpperBlockTriDiag"]
B --> B3["Toeplitz"]
B --> B4["ToeplitzCholesky"]
K --> K1["InterpolatedOperator"]
K --> K2["MaskedOperator"]
LZ --> Z1["sum_operator"]
LZ --> Z2["scaled_operator"]
LZ --> Z3["product_operator"]
| Operator | Represents | Why it is worth it |
|---|---|---|
Kronecker(A, B, ...) |
\(A \otimes B \otimes \cdots\) | \(O(\sum_i n_i^3)\) instead of \(O((\prod_i n_i)^3)\); matvec is Roth's column lemma via einx |
KroneckerSum(A, B) |
\(A \oplus B = A \otimes I + I \otimes B\) | Diagonalises in the joint eigenbasis, eigenvalues \(\lambda_i + \mu_j\) |
SumOfKroneckers |
\(\sum_k A_k \otimes B_k\) | Structured Cholesky/sqrt for separable-plus-noise covariances. Not the same as KroneckerSum; was named SumKronecker, which survives as a deprecated alias |
BlockDiag(A, B, ...) |
\(\mathrm{diag}(A, B, \ldots)\) | Every primitive splits per block |
BlockTriDiag(D, A) |
Symmetric block-tridiagonal precision | \(O(N d^3)\) --- the precision structure of Markovian GPs |
LowRankUpdate(L, U, d, V) |
\(L + U\,\mathrm{diag}(d)\,V^\top\) | Woodbury solves, determinant-lemma logdets; pass orthonormal=True for SVD/Nyström factors |
SVDLowRankUpdate |
Deprecated --- use LowRankUpdate(..., orthonormal=True) |
Kept as a subclass so isinstance checks keep working; warns on construction |
Toeplitz |
Symmetric Toeplitz | \(O(n \log n)\) matvecs and sampling via FFT circulant embedding |
InterpolatedOperator |
\(W K_{uu} W^\top\) (KISS-GP style) | Grid interpolation weights, sparse in effect |
MaskedOperator |
Row/column sub-selection of a base operator | Irregular domains, held-out indices |
Arithmetic composes with lineax's own operators, and the primitives unwrap the composition automatically:
K = gaussx.Kronecker(A, B)
perturbed = K + 0.1 * lx.IdentityLinearOperator(K.in_structure())
gaussx.solve(perturbed, y) # dispatches through the sum
Kernel operators live in kernellib
KernelOperator, ImplicitKernelOperator and ImplicitCrossKernelOperator
moved to kernellib in gaussx
0.2.0, with Nyström / RFF operators, HSIC / MMD, Falkon and EigenPro.
They are lineax operators, so every primitive and strategy here works on
them unchanged --- as long as their structural tags are set: tags are
claims, not inferences.
Structural tags¶
Tags mark structure and properties; the is_* predicates are what the
primitives consult. gaussx re-exports lineax's property tags so user code needs
only one import.
| Tag | Source | Consulted by |
|---|---|---|
symmetric_tag |
lineax | solve, logdet, solver selection |
positive_semidefinite_tag |
lineax | cholesky, sqrt, CG, SLQ |
negative_semidefinite_tag |
lineax | linear_solve negation path |
diagonal_tag |
lineax | all primitives (O(n) paths) |
lower_triangular_tag / upper_triangular_tag |
lineax | triangular solves |
unit_diagonal_tag / tridiagonal_tag |
lineax | specialised solves |
kronecker_tag |
gaussx | all primitives (per factor) |
kronecker_sum_tag |
gaussx | eigenbasis paths |
block_diagonal_tag |
gaussx | all primitives (per block) |
block_tridiagonal_tag |
gaussx | banded Cholesky |
low_rank_tag |
gaussx | solve (Woodbury), logdet (determinant lemma) |
Query with is_kronecker, is_low_rank, is_positive_semidefinite, … to
inspect an operator without knowing its concrete type. Full list in the
Operators & Tags reference.
Layer 1.5 --- Strategies & preconditioners¶
A strategy bundles a solve and a logdet algorithm behind one object.
Anything in gaussx that takes a solver= keyword takes one of these, and
solver=None means "use structural dispatch". inv_quad_logdet follows the
same rule: with strategy=None it takes the exact structural solve and
logdet whenever the operator has them, and only falls back to BBMMSolver's
shared CG pass (a stochastic log-determinant) for operators that do not.
flowchart TB
CALL["caller with solver=..."] --> DISP{"solver is None?"}
DISP -- yes --> PRIMS["Layer 0 primitives<br/><small>structural dispatch</small>"]
DISP -- no --> STRAT["AbstractSolverStrategy"]
STRAT --> DENSE["DenseSolver<br/><small>structural + dense</small>"]
STRAT --> AUTO["AutoSolver<br/><small>picks by type + size</small>"]
STRAT --> ITER["CGSolver · PreconditionedCGSolver<br/>MINRESSolver · LSMRSolver · BBMMSolver"]
STRAT --> COMP["ComposedSolver<br/><small>solve from one, logdet from another</small>"]
COMP --> ITER
COMP --> LOGDET["DenseLogdet · SLQLogdet<br/>IndefiniteSLQLogdet"]
ITER -->|"preconditioner="| PRECOND["AbstractPreconditioner"]
PRECOND --> PJ["JacobiPreconditioner"]
PRECOND --> PN["NystromPreconditioner"]
PRECOND --> PP["PartialCholeskyPreconditioner"]
PRECOND --> PO["OperatorPreconditioner<br/><small>bring your own M⁻¹</small>"]
AUTO --> DENSE
AUTO --> ITER
| Strategy | Algorithm | Best for |
|---|---|---|
DenseSolver |
Structural dispatch, Cholesky for PSD | Small/medium, structured |
AutoSolver |
Structured → dense; small → dense; large PSD → CG | When you would rather not choose |
CGSolver |
Conjugate gradients (+ optional preconditioner) | Large PSD, matrix-free |
PreconditionedCGSolver |
CG with a required preconditioner | Ill-conditioned kernel matrices |
MINRESSolver |
MINRES | Symmetric indefinite |
LSMRSolver |
LSMR | Least squares / rectangular |
BBMMSolver |
Batched blocked matrix-matrix CG | Many right-hand sides at once |
ComposedSolver |
Any solve + any logdet | The standard large-kernel recipe: CG solve, SLQ logdet |
DenseLogdet |
Eigendecomposition | Exactness |
SLQLogdet / IndefiniteSLQLogdet |
Stochastic Lanczos quadrature | Large PSD / symmetric-indefinite estimates |
AutoSolver's rule is deliberately boring and readable: structured operators go
to DenseSolver (structural dispatch is already the fast path), dense operators
below size_threshold (default 1000) go to DenseSolver, large PSD operators
go to CGSolver, everything else falls back to DenseSolver. "Structured" is
every operator with an exact structural logdet (including through
TaggedLinearOperator, scalar multiples and negation), so a large
BlockTriDiag or KroneckerSum stays exact. The large-PSD branch's logdet
is a stochastic, fixed-seed SLQ estimate; pass
ComposedSolver(CGSolver(), DenseLogdet()) for an exact one.
The front door¶
linear_solve is the high-level entry
point. It accepts a lineax operator or a bare (matvec, in_structure) pair,
normalises negative-definite systems, picks an iterative solver from the
operator's tags, and threads a preconditioner through.
as_linear_operator wraps a raw matvec callable into a tagged
FunctionLinearOperator for matrix-free workflows.
AbstractPreconditioner.as_operator(A) receives the system operator, so
static preconditioners (Jacobi, or an externally supplied approximate inverse)
can ignore it while data-dependent ones (partial Cholesky) build their factor
lazily at solve time. This is the seam through which PDE packages inject their
own approximate inverses --- a spectral solve or a multigrid V-cycle is wrapped
in OperatorPreconditioner and passed in, so gaussx never imports the
packages that build them.
Layer 2 --- Distributions & sugar¶
flowchart LR
subgraph DISTS["Distributions (NumPyro-compatible)"]
MVN["MultivariateNormal<br/><small>carries Σ as an operator</small>"]
MVNP["MultivariateNormalPrecision<br/><small>carries Λ = Σ⁻¹ directly</small>"]
end
subgraph OPSUGAR["Sugar operations"]
LP["gaussian_log_prob · gaussian_entropy<br/>quadratic_form · add_jitter"]
KL["kl_standard_normal<br/>gaussian_kl"]
COND["conditional · project<br/>joseph_update"]
end
subgraph EFAM["Exponential family"]
NAT["η₁ = Λμ, η₂ = −½Λ"]
CONV["natural ↔ mean/cov ↔ expectation"]
FISH["log_partition · fisher_info<br/>sufficient_stats · kl_divergence"]
end
MVN --> LP
MVNP --> LP
LP --> P0["Layer 0: solve · logdet · cholesky"]
KL --> P0
COND --> P0
NAT --> CONV --> FISH --> P0
MultivariateNormal(loc, cov_operator, solver=...)takes any covariance operator and any solver strategy. The distribution owns the math; the strategy owns the numerics.MultivariateNormalPrecisioncarries \(\Lambda = \Sigma^{-1}\) directly --- the natural home for natural-parameter guides, where materializing \(\Sigma\) would be wasted work.- Both require
numpyro, which is an optional dependency. The rest of gaussx imports fine without it (see the guarded import at the bottom ofsrc/gaussx/__init__.py). - Sugar ops evaluate \(\log\mathcal{N}(x \mid \mu, \Sigma)\), entropy,
quadratic forms, KLs, conditioning, and the numerically stable Joseph-form
covariance update --- all through structured
solve+logdet. - Exponential family provides the conversions (mean/cov ↔ natural ↔ expectation), log-partition, Fisher information, and sufficient statistics that natural-gradient and EP updates are built from.
Layer 3 --- Recipes¶
Layer 3 is thin wiring: domain-specific sequences of Layer 0–2 operations. It is where the library stops being linear algebra and starts being someone's method. Five families, each with its own API page.
flowchart TB
subgraph R["Layer 3"]
direction TB
A["Gaussian processes<br/><small>_gp/</small>"]
B["State-space models<br/><small>_ssm/</small>"]
C["Quadrature and moments<br/><small>_quadrature/</small>"]
D["Inference and ensembles<br/><small>_inference/</small>"]
end
A -->|"conditioning · ELBO · whitening"| A1["sparse_conditional · collapsed_elbo<br/>unwhiten · love_variance · oilmm_project"]
B -->|"filtering · smoothing"| B1["kalman_filter · rts_smoother<br/>parallel_* · spingp_* · cvi_update_sites"]
C -->|"expectations under a Gaussian"| C1["GaussHermite · Unscented · Taylor · MC<br/>ep_tilted_moments · uncertain_gp_predict"]
D -->|"posterior updates"| D1["blr_full_update · damped_natural_update<br/>ensemble_kalman_gain · gaspari_cohn"]
Gaussian processes (API) --- posterior conditioning, prediction caches, Matheron pathwise sampling, whitened parameterizations, Gaussian and MC ELBOs, the collapsed (Titsias) bound, LOVE predictive variances, closed-form leave-one-out CV, Kronecker GP marginal likelihood, and OILMM multi-output projections.
State-space models (API) --- stationary 1-D kernels with rational spectral densities have exact SDE representations, turning \(O(N^3)\) GP inference into \(O(N d^3)\) Kalman filtering. Ships the SDE kernel zoo (Matérn, periodic, quasi-periodic, cosine, constant, plus sum/product composition), the sequential filter and RTS smoother, their \(O(\log N)\) parallel associative-scan counterparts (optionally PSD-projected), steady-state (infinite-horizon) filters via the discrete algebraic Riccati equation, SpInGP, and CVI site machinery for non-conjugate likelihoods.
Quadrature & moment matching (API) --- one
AbstractIntegrator interface over Gauss-Hermite, unscented/cubature, Taylor,
and Monte Carlo rules. Everything needing an expectation (expected
log-likelihoods, EP tilted moments, uncertain-input GP prediction, \(\Psi\)
statistics) takes an integrator argument, so swapping the rule never touches the
model.
Bayesian inference & ensembles (API) --- conjugate BLR updates (full and diagonal), Newton and damped natural-gradient steps, Gauss-Newton curvature, Riemannian PSD correction, ensemble covariances and Kalman gain, ETKF transforms, Gaspari-Cohn localization, and the RTPP/RTPS inflation family.
Two API constraints worth knowing up front:
kronecker_posterior_predictive(...)needs exact test prior diagonals viaK_test_diag_factors=for predictive variances.ssm_to_naturals(A, Q, mu_0, P_0)takes the transition noiseQof shape(N-1, d, d)andP_0separately, asMarkovGaussiandoes. The older stacked layout (Q[0] == P_0) is deprecated until 0.5.0; with it, an inconsistent initial covariance raises anEquinoxRuntimeError(viaequinox.error_if), eagerly and at run time underjax.jit/jax.vmap.
Package layout¶
Every subpackage is private (_-prefixed); the public API is re-exported
through src/gaussx/__init__.py, which is the single source of truth for
what is supported.
src/gaussx/
├── __init__.py # Public API (`gaussx.__all__`)
├── _tags.py # Structural tags + is_* predicates
├── _einx.py # einx wrappers: rearrange, reduce, repeat, einsum
├── _solve_frontend.py # linear_solve + as_linear_operator
├── _testing.py # Test utilities (random PD matrices, assertions)
│
├── _primitives/ # Layer 0 — solve, logdet, cholesky, diag, trace,
│ # sqrt, inv, eig, svd, root, frobenius, submatrix
├── _linalg/ # Layer 0 — Woodbury, Schur, safe_cholesky,
│ # tridiagonal, Lyapunov, symmetrize
│
├── _operators/ # Layer 1 — Kronecker, KroneckerSum, SumOfKroneckers,
│ # BlockDiag, BlockTriDiag, LowRankUpdate, SVD
│ # low-rank, Toeplitz, interpolated/masked operators,
│ # KISS-GP grids, lazy algebra, capacitance
├── _sparse/ # Layer 1 — symbolic + numeric sparse Cholesky for
│ # SparseOperator
├── _gmrf/ # Layer 1 — GMRF precision builders (RW, AR(1),
│ # Besag/BYM2, SPDE Matérn)
│
├── _strategies/ # Layer 1.5 — Dense, Auto, CG, PreconditionedCG,
│ # MINRES, LSMR, BBMM, Composed, SLQ logdets
├── _preconditioners/ # Layer 1.5 — Jacobi, Nyström, PartialCholesky,
│ # Operator (bring-your-own M⁻¹)
│
├── _distributions/ # Layer 2 — MultivariateNormal(+Precision),
│ # log-prob/entropy/KL, conditional, Joseph, project
├── _expfam/ # Layer 2 — natural ↔ expectation, Fisher, partition
│
├── _gp/ # Layer 3 — conditioning, ELBOs, whitening, LOVE,
│ # LOO, Matheron, OILMM, Kronecker GP, caches
├── _ssm/ # Layer 3 — SDE kernels, Kalman/RTS (sequential,
│ # parallel, sqrt, infinite-horizon), SpInGP, CVI
├── _quadrature/ # Layer 3 — integrators, likelihoods, expectations,
│ # Ψ-statistics, uncertain-input GP prediction, ADF
├── _inference/ # Layer 3 — BLR, natural gradient, EnKF, localization
│
├── _sketching/ # Outside the stack — random subspace embeddings
└── _randomized/ # Outside the stack — randomized SVD / eigh,
# Nyström, RP-Cholesky
Layer 3 lives in named subpackages (_gp/, _ssm/, …) rather than a single
_recipes/ directory --- the families grew large enough to deserve their own
namespaces, and each maps one-to-one onto an API reference page.
Import structure¶
Layers 3 and 2 import only from below. Layers 0, 1 and 1.5 form one
dispatch core instead: the primitives isinstance-dispatch on the
operator classes (Layer 0 imports Layer 1), some operators call primitives
for their own factorisations (SumOfKroneckers → cholesky / sqrt /
solve, MaskedOperator → solve), and the primitives fall back to
strategy objects (AutoSolver, CGSolver, SLQLogdet) that themselves
call solve / logdet. _sparse joins the cycle through the sparse
Cholesky path.
gaussx imports its own modules at module scope except on the edges that
close one of these cycles. Each such import sits inside the function that
needs it, with a # lazy import, cycle: ... comment naming the module chain
that imports back, and tests/test_lazy_imports.py keeps the list of those
edges explicit, so a new one has to be added deliberately.
Dependencies¶
| Package | Role | Required |
|---|---|---|
jax / jaxlib |
Array backend | Yes |
equinox |
Module system, PyTrees | Yes |
lineax |
Base operators, solvers, property tags | Yes |
matfree |
Lanczos, SLQ, Hutchinson | Yes |
jaxtyping |
Array shape annotations | Yes |
einx |
Tensor reshaping/contraction | Yes |
numpyro |
MultivariateNormal distributions |
Optional (gaussx[numpyro]) |
Where gaussx sits in the ecosystem¶
gaussx is deliberately a floor, not a building. It owns structured operators and the primitives over them; everything domain-specific is somebody else's library.
flowchart TB
subgraph UP["Built on"]
LINEAX["lineax"]
MATFREE["matfree"]
EQX["equinox"]
end
GX["<b>gaussx</b><br/><small>structured operators · primitives · Gaussians</small>"]
subgraph PROB["Probabilistic modelling"]
KL["kernellib<br/><small>kernels, kernel operators, kernel methods</small>"]
PYROX["pyrox-gp<br/><small>GP models, kernels, guides on NumPyro</small>"]
FILTERAX["filterax<br/><small>ensemble filtering</small>"]
OPTAXB["optax_bayes<br/><small>natural-gradient optimizers</small>"]
end
subgraph PDE["PDE / geoscience"]
FVX["finitevolX<br/><small>finite-volume ops, Arakawa C-grids</small>"]
SDX["spectraldiffx<br/><small>pseudospectral discretization</small>"]
end
LINEAX --> GX
MATFREE --> GX
EQX --> GX
GX -->|"LowRankUpdate · solvers · trace_product"| KL
KL -->|"kernels · kernel operators"| PYROX
GX -->|"operators · solvers · distributions"| PYROX
GX -.->|"ensemble covariance · Kalman gain"| FILTERAX
GX -.->|"Fisher · natural params"| OPTAXB
GX -->|"CG · Nyström precond · tridiagonal"| FVX
GX -.->|"capacitance / Woodbury correction<br/>(planned)"| SDX
click LINEAX "https://github.com/patrick-kidger/lineax" _blank
click MATFREE "https://github.com/pnkraemer/matfree" _blank
click EQX "https://github.com/patrick-kidger/equinox" _blank
click KL "https://github.com/jejjohnson/kernellib" _blank
click PYROX "https://github.com/jejjohnson/pyrox" _blank
click FILTERAX "https://github.com/jejjohnson/filterax" _blank
click OPTAXB "https://github.com/jejjohnson/optax_bayes" _blank
click FVX "https://github.com/jejjohnson/finitevolX" _blank
click SDX "https://github.com/jejjohnson/spectraldiffx" _blank
pyrox-gp is the largest consumer. It
is the modelling shell gaussx deliberately refuses to be: kernels with
hyperparameter priors, NumPyro sample sites, guides, likelihoods, sparse and
Markov GP models. It reaches into gaussx for more than forty public symbols ---
Kronecker, BlockDiag, the solver strategies,
MultivariateNormal(Precision), base_conditional,
variational_elbo_gaussian, whitened_svgp_predict, kalman_filter,
rts_smoother, ep_tilted_moments, the OILMM projections, and the
natural-parameter conversions.
kernellib owns everything with a
kernel in it: kernel functions and objects, the kernel operators
(KernelOperator, ImplicitKernelOperator, ImplicitCrossKernelOperator),
Nyström / RFF low-rank operators, HSIC / CKA / MMD, Falkon and EigenPro. They
moved out of gaussx in 0.2.0. kernellib builds on gaussx's LowRankUpdate,
solver strategies and trace_product; gaussx never imports kernellib.
finitevolX takes the raw solver
substrate: its tridiagonal solves are gaussx.tridiagonal_solve /
tridiagonal_solve_batched, and its Nyström preconditioner wraps
gaussx.NystromPreconditioner around a gaussx.as_linear_operator view of the
(PSD-probed) finite-volume operator.
spectraldiffx is the planned
next adopter: it supplies the base spectral solve and the boundary indices, and
gaussx returns the reusable capacitance operator that corrects it
(gaussx.CapacitanceSolver, already implemented on the gaussx side). That
correction is the Woodbury identity --- the same code path that solves a
low-rank-plus-diagonal covariance. Likewise
filterax and
optax_bayes are the intended homes
for the ensemble-filtering and natural-gradient-optimizer layers built on the
Layer 3 primitives gaussx already ships.
That overlap is not a coincidence. Under the SPDE view, elliptic differential operators and Gaussian precision operators are the same objects, so one tested CG loop, one Nyström preconditioner, and one Woodbury correction can serve a GP marginal likelihood and a masked Poisson solve alike. The Unified Solvers design note works through where that boundary is drawn, and what gaussx will never accept (grids, coordinates, boundary conditions, spectral transforms, multigrid V-cycles --- those stay upstairs).
For the philosophy behind these boundaries, see Vision. For the symbol-level reference, see the API docs.