Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Structured MVN sampling dispatch — keeping the root cheap

Sampling from N(μ,Σ)\mathcal{N}(\mu, \Sigma) via the reparameterisation trick (already met as ((5))) is

x  =  μ  +  R ε,ε∼N(0,I),R R⊤=Σ.x \;=\; \mu \;+\; R\,\varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I), \qquad R\,R^\top = \Sigma.

The matrix RR is any root of Σ. The cheapest choice depends on what Σ looks like:

Σ structureBest rootCost (square n×nn \times n)
DenseCholesky LLO(n3)O(n^3)
Diagonaldiag\sqrt{\mathrm{diag}} — element-wiseO(n)O(n)
Kronecker A⊗BA \otimes Bchol(A)⊗chol(B)\mathrm{chol}(A) \otimes \mathrm{chol}(B)O(nA3+nB3)O(n_A^3 + n_B^3) instead of (nAnB)3(n_A n_B)^3
Block-diagonal diag(A1,…,AK)\mathrm{diag}(A_1, \ldots, A_K)diag(chol(A1),…,chol(AK))\mathrm{diag}(\mathrm{chol}(A_1), \ldots, \mathrm{chol}(A_K))∑kO(nk3)\sum_k O(n_k^3)
Block-tridiagonal precision Λ (Markov chain)Banded Cholesky Λ=LL⊤\Lambda = LL^\top + triangular solve x=L−⊤εx = L^{-\top}\varepsilonO(Nd3)O(N d^3) instead of (Nd)3(Nd)^3
Low-rank update Σ0+UDU⊤\Sigma_0 + UDU^\topNo closed-form root; sample L0ε0+UD ε1L_0\varepsilon_0 + U\sqrt{D}\,\varepsilon_1 as two independent drawsvaries
Toeplitz / KroneckerSum / SumKroneckerFalls back to dense in gaussx today; smart routes (FFT / eigen / Lanczos) are listed in §9 belowO(n3)O(n^3) until wired

This is exactly what gaussx.cholesky and gaussx.sqrt do — dispatch on operator type and return a structured root operator of the same family. Sampling stays cheap automatically.

This notebook walks the dispatch table:

  1. dense baseline (((5)) recap with gaussx.cholesky),
  2. diagonal — element-wise sqrt, the simplest dispatch,
  3. Kronecker root identity + Monte-Carlo cross-check,
  4. BlockDiag root identity + per-block parallelism,
  5. BlockTriDiag → LowerBlockTriDiag — banded Cholesky for Markov-chain precisions,
  6. low-rank update — what gaussx does when there’s no closed-form root,
  7. Cholesky vs symmetric square root (when each one is the right answer),
  8. timing: structured roots scale with the factor size, not the product,
  9. operators that currently fall back to dense (Toeplitz / KroneckerSum / SumKronecker) and what their smart roots would be.

Prerequisites: 0.1 — Multivariate Gaussian (especially ((5)) and ((4))), 0.2 — MultivariateNormal API.

Sampling = a single matvec against any root

Once you have a root RR with RR⊤=ΣR R^\top = \Sigma, drawing samples is a matmul:

X  =  μ  +  ε R⊤,ε∈RS×n  ∼  N(0,I).X \;=\; \mu \;+\; \varepsilon\,R^\top, \qquad \varepsilon \in \mathbb{R}^{S \times n} \;\sim\; \mathcal{N}(0, I).

gaussx.cholesky(Sigma_op) returns a structured operator LL of the same family as Σ. Multiplying noise through LL via mv (or composed Kronecker/BlockDiag ops) keeps the structure all the way down — you never materialise the dense LL.

1. Dense baseline

For an arbitrary SPD Σ the standard recipe is the Cholesky factor:

Σ  =  L L⊤,L lower triangular,x  =  μ+L ε.\Sigma \;=\; L\,L^\top, \quad L \text{ lower triangular}, \qquad x \;=\; \mu + L\,\varepsilon.

gaussx.cholesky on a MatrixLinearOperator returns the dense Cholesky as a new MatrixLinearOperator.

L type : MatrixLinearOperator
L L^T - Sigma  ||F  = 2.53e-15
dense  ||Sigma_emp - Sigma||_F  = 1.39e-01    (MC noise ~ ||Sigma||_F * n / sqrt(S))

2. Diagonal — the simplest dispatch

When Σ=diag(σ12,…,σn2)\Sigma = \mathrm{diag}(\sigma_1^2, \ldots, \sigma_n^2), the Cholesky is element-wise:

chol(diag(σ12,…,σn2))  =  diag(σ1,…,σn).\mathrm{chol}\bigl(\mathrm{diag}(\sigma_1^2, \ldots, \sigma_n^2)\bigr) \;=\; \mathrm{diag}(\sigma_1, \ldots, \sigma_n).

gaussx.cholesky(DiagonalLinearOperator(d)) returns another DiagonalLinearOperator — no n×nn \times n matrix is ever materialised. Sampling is one element-wise multiply.

This is the structural foundation of mean-field variational inference (where Σ is constrained diagonal by design) and of factorised Gaussian priors.

L type : DiagonalLinearOperator
L diag : [2.         1.         3.         0.5        1.58113883 0.70710678
 4.         1.22474487]
L diag = sqrt(var)? True

empirical diag(Sigma): [ 4.02239356  0.99896439  8.97460525  0.25057673  2.50498229  0.49966097
 15.99077588  1.51320825]
true diag(Sigma)     : [ 4.    1.    9.    0.25  2.5   0.5  16.    1.5 ]
max relative error    = 8.81e-03

3. Kronecker — the canonical dispatch win

For SPD A∈RnA×nAA \in \mathbb{R}^{n_A \times n_A} and B∈RnB×nBB \in \mathbb{R}^{n_B \times n_B},

chol(A⊗B)  =  chol(A)⊗chol(B).\mathrm{chol}(A \otimes B) \;=\; \mathrm{chol}(A) \otimes \mathrm{chol}(B).

So instead of factorising the nAnB×nAnBn_A n_B \times n_A n_B Kronecker product, factorise the two small blocks and Kronecker their Choleskys. gaussx.cholesky(Kronecker(A_op, B_op)) does exactly this and returns another Kronecker — sampling stays O(nA3+nB3)O(n_A^3 + n_B^3) instead of O((nAnB)3)O((n_A n_B)^3).

L type            : Kronecker
L_K factor types  : ['MatrixLinearOperator', 'MatrixLinearOperator']

L_K - chol(A) ⊗ chol(B)  ||F  = 0.00e+00
||Sigma_emp - kron(A,B)||_F  = 9.84e-01    (MC noise ~ ||Sigma||_F * n / sqrt(S))

4. Block-diagonal — embarrassingly parallel

chol(diag(A1,…,AK))  =  diag(chol(A1),…,chol(AK)).\mathrm{chol}\bigl(\mathrm{diag}(A_1, \ldots, A_K)\bigr) \;=\; \mathrm{diag}\bigl(\mathrm{chol}(A_1), \ldots, \mathrm{chol}(A_K)\bigr).

Each block factorises independently — perfect for vmap if all AkA_k have the same shape, or for jax.tree_map if they don’t. gaussx.cholesky(BlockDiag(...)) returns another BlockDiag whose constituents are the per-block Choleskys.

L type         : BlockDiag
inner blocks   : ['MatrixLinearOperator', 'MatrixLinearOperator', 'MatrixLinearOperator']
per-block ||L_dispatch - L_ref||_F: ['0.00e+00', '0.00e+00', '0.00e+00']

||Sigma_emp - block-diag||_F  = 1.59e-01    (MC noise ~ ||Sigma||_F * n / sqrt(S))

5. Block-tridiagonal precision — banded Cholesky + triangular solve

A block-tridiagonal precision Λ is the natural representation of a Markov chain over NN blocks of size dd — only adjacent time steps interact, so all but the main and one off-diagonal block are zero. Its Cholesky factor is itself block-bidiagonal:

chol(BlockTriDiag)  =  LowerBlockTriDiag,Λ  =  L L⊤.\mathrm{chol}\bigl(\mathrm{BlockTriDiag}\bigr) \;=\; \mathrm{LowerBlockTriDiag}, \qquad \Lambda \;=\; L\,L^\top.

gaussx.cholesky(BlockTriDiag(diag_blocks, sub_blocks)) returns a LowerBlockTriDiag operator whose mv and triangular solve both run in O(Nd3)O(N d^3) instead of O((Nd)3)O((N d)^3).

Sampling from N(0,Λ−1)\mathcal{N}(0, \Lambda^{-1}) — the actual Markov-chain marginal — does not multiply ε\varepsilon by LL (that would give a draw from N(0,Λ)\mathcal{N}(0, \Lambda), not N(0,Λ−1)\mathcal{N}(0, \Lambda^{-1})). The correct recipe is the triangular back-solve

x  =  L−⊤ ε,ε∼N(0,I)⟹Cov(x)  =  L−⊤ L−1  =  (LL⊤)−1  =  Λ−1,x \;=\; L^{-\top}\,\varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I) \quad\Longrightarrow\quad \mathrm{Cov}(x) \;=\; L^{-\top}\,L^{-1} \;=\; (L L^\top)^{-1} \;=\; \Lambda^{-1},

executed via gaussx.solve(L.T, eps). Because LL is block-bidiagonal, this back-solve runs in O(Nd3)O(N d^3) — the same cost class as the matvec, never materialising Σ=Λ−1\Sigma = \Lambda^{-1} (which would be dense). This is the workhorse for state-space / Markov-GP posterior sampling: the precision of f1:Nf_{1:N} in a linear-Gaussian SSM is block-tridiagonal by construction, and Σ is dense, so we sample by never inverting Λ explicitly.

Lambda type : BlockTriDiag
L type      : LowerBlockTriDiag
||L L^T - Lambda||_F  =  5.11e-15
||Sigma_emp - Lambda^-1||_F  =  4.05e-02    (MC noise ~ ||Sigma||_F * n / sqrt(S))
||Cov(L @ eps) - Lambda||_F  =  1.12e+00    (this would be wrong target; matches Lambda, not Lambda^-1)

6. Low-rank update — when there is no closed-form root

For Σ=Σ0+UDU⊤\Sigma = \Sigma_0 + U D U^\top with Σ0\Sigma_0 already factorised as L0L0⊤L_0 L_0^\top, you can build a sampling root via the whitening trick:

x  =  μ  +  L0 ε0  +  U D ε1,ε0,ε1∼N(0,I).x \;=\; \mu \;+\; L_0\,\varepsilon_0 \;+\; U\,\sqrt{D}\,\varepsilon_1, \qquad \varepsilon_0,\varepsilon_1 \sim \mathcal{N}(0, I).

The two noise vectors are independent so Cov(x)=L0L0⊤+UDU⊤=Σ\mathrm{Cov}(x) = L_0 L_0^\top + U D U^\top = \Sigma. No need for a closed-form Cholesky of the sum — the rank correction stays separate. This is how ensemble Kalman, low-rank GP priors, and rank-revealing covariance estimates sample at scale.

gaussx.cholesky(LowRankUpdate(...)) does not return a structured root for the general case (no closed form), but the additive sampling identity above is straightforward to implement in user code — and the operator stays cheap because L0L_0 and UU keep their structure.

||Sigma_emp - (Sigma0 + U D U^T)||_F  = 1.25e-01    (MC noise ~ ||Sigma||_F * n / sqrt(S))

7. Cholesky vs symmetric square root

There’s more than one root. The symmetric square root SS satisfies

S  =  S⊤,S S  =  Σ,S \;=\; S^\top, \qquad S\,S \;=\; \Sigma,

and is computed via the eigendecomposition Σ=QΛQ⊤⇒S=QΛ1/2Q⊤\Sigma = Q \Lambda Q^\top \Rightarrow S = Q \Lambda^{1/2} Q^\top (you’ve already seen Σ=QΛQ⊤\Sigma = Q \Lambda Q^\top as ((4))). gaussx.sqrt returns this.

Both LL (Cholesky) and SS (symmetric) generate the same Gaussian — but they map a fixed ε\varepsilon to different points. When does this matter?

  • Sampling-only workflows: doesn’t matter. Any root produces correct samples.
  • Path-coupled samples (e.g. ((5)) “same noise, three roots”): the choice of root induces correlated draws — useful in QMC, antithetic sampling, and reparameterised gradient estimators where you want the same ε\varepsilon to produce a “matched pair” across two distributions.
  • Numerical stability: Cholesky can fail or amplify error on near-singular Σ; symmetric sqrt is safer (eigendecomposition is more forgiving) but ~3× more expensive.
  • Symmetric SS has the property tr(S)=∑iλi\mathrm{tr}(S) = \sum_i \sqrt{\lambda_i}, which appears in 2-Wasserstein distances between Gaussians — Cholesky LL doesn’t.
symmetric: S = S^T?          True
symmetric: S @ S = Sigma?    1.49e-14
Cholesky:  L L^T = Sigma?    2.53e-15

Same eps, different roots:
  ||cov(X_chol) - cov(X_sqrt)||_F = 6.08e-01
  ||X_chol - X_sqrt||_F           = 2.09e+02    (large -- different points)

8. Timing — when dispatch saves you

The Kronecker dispatch is the textbook win: factorise two small matrices instead of one giant one. We compare three sampling pipelines for Σ=A⊗B\Sigma = A \otimes B at growing factor sizes:

  1. Dense Cholesky: build the full nAnB×nAnBn_A n_B \times n_A n_B Kronecker product, factorise, sample.
  2. Structured Cholesky: gaussx.cholesky(Kronecker(A_op, B_op)) — two small Choleskys, lazy product.
  3. Reference: the explicit Kronecker-of-Choleskys identity by hand.

The first scales as (nAnB)3(n_A n_B)^3; the second and third scale as nA3+nB3n_A^3 + n_B^3.

 n_A=n_B    dense (s)   struct (s)    ref (s)  speedup
       4       0.0001       0.0001     0.0000     1.2x
       8       0.0001       0.0002     0.0001     1.0x
      16       0.0010       0.0006     0.0005     1.7x
      24       0.0037       0.0010     0.0011     3.7x
      32       0.4464       0.0017     0.0029   269.7x
<Figure size 770x440 with 1 Axes>

What doesn’t dispatch yet

A few operators in gaussx currently have no closed-form structured Cholesky and fall back to materialising the matrix and calling jnp.linalg.cholesky. Each has a known smarter route — listing them here as future work and as a reminder that gaussx.cholesky(op) always returns something correct, just not always something cheap.

OperatorSmart route (not yet wired)Reference
Toeplitz (Tij=ti−jT_{ij} = t_{i-j})Circulant embedding + FFT — O(nlog⁡n)O(n \log n) sampling for stationary 1D processesWood & Chan (1994), J. Comp. Graph. Stat.; gaussx#168
KroneckerSum (A⊕B=A⊗I+I⊗BA \oplus B = A \otimes I + I \otimes B)Eigendecomposition: λijA⊕B=λiA+λjB\lambda_{ij}^{A \oplus B} = \lambda_i^A + \lambda_j^B, VA⊕B=VA⊗VBV^{A \oplus B} = V^A \otimes V^BSaatçi (2011), thesis; gaussx#169
SumKronecker (∑kAk⊗Bk\sum_k A_k \otimes B_k)Conjugate-gradient + matfree Lanczos for Σ1/2ε\Sigma^{1/2} \varepsilonPleiss et al. (2018), GPyTorch; gaussx#170

For now, on these operators gaussx.cholesky produces a dense MatrixLinearOperator and you sample as in section 1 (the dispatch path is just slower than it could be). The three tracking issues above carry full math + proposed APIs.

Where the dispatch wins

SettingΣ structureRoot usedWhy
Mean-field VI / factorised priorsDiagonal((4))Element-wise sqrt — no matrix anywhere
Spatio-temporal GPs (separable)K=Kspace⊗KtimeK = K_{\rm space} \otimes K_{\rm time}((5))Two small Choleskys instead of one (NsNt)3(N_s N_t)^3
Independent ensembles / multi-output GPsBlockDiag(Σ1,…\Sigma_1, \ldots)((6))Embarrassingly parallel per output
State-space / Markov GPs (part 7)Λ block-tridiagonal((8))Banded Cholesky → O(Nd3)O(N d^3) posterior sampling
Ensemble Kalman / low-rank GP priorsΣ0+UDU⊤\Sigma_0 + U D U^\top((9))Sample L0ε0+UD ε1L_0\varepsilon_0 + U\sqrt{D}\,\varepsilon_1 — never factorise the sum
2-Wasserstein / OT between Gaussianssymmetric sqrt SS((10))Closed-form W22W_2^2 uses tr(Σ1+Σ2−2(S1Σ2S1)1/2)\mathrm{tr}(\Sigma_1 + \Sigma_2 - 2(S_1 \Sigma_2 S_1)^{1/2})
Antithetic / coupled samplingany deterministic root((1))Same ε\varepsilon across two distributions ⇒ correlated estimators
Reparameterised VI gradientsCholesky LL((5))∇θL⋅ε\nabla_\theta L \cdot \varepsilon propagates cleanly through jax.grad

Recap

  • Sampling = matvec against any root RR of Σ (((1))). gaussx exposes both Cholesky and symmetric sqrt; structured operators get structured roots.
  • chol(A⊗B)=chol(A)⊗chol(B)\mathrm{chol}(A \otimes B) = \mathrm{chol}(A) \otimes \mathrm{chol}(B) (((5))) and chol(BlockDiag)=BlockDiag(chol)\mathrm{chol}(\mathrm{BlockDiag}) = \mathrm{BlockDiag}(\mathrm{chol}) (((6))) are both built into gaussx.cholesky.
  • Low-rank updates avoid the closed-form root by injecting the rank correction as a second independent draw (((9))).
  • Symmetric S=S⊤S = S^\top (((10))) is the right choice for 2-Wasserstein, antithetic sampling, and any setting that wants S2=ΣS^2 = \Sigma.

References

  • Rasmussen, C. E. & Williams, C. K. I. (2006). Gaussian Processes for Machine Learning. MIT Press, App. A.4.
  • Saatçi, Y. (2011). Scalable Inference for Structured Gaussian Process Models. PhD thesis, Cambridge.
  • Higham, N. J. (2008). Functions of Matrices: Theory and Computation. SIAM. (Symmetric square roots.)
  • Kingma, D. P. & Welling, M. (2014). Auto-encoding variational Bayes. ICLR. (Reparameterisation trick.)