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.

Numerical stability — jitter, safe Cholesky, condition number

In exact arithmetic every SPD matrix has a Cholesky factor. In float64 most do. In float32 a lot don’t — and even in float64, kernel matrices on dense / nearby points produce eigenvalues so small that LAPACK returns NaNs and JAX silently propagates them downstream.

This notebook is the damage-control toolkit for that situation. We cover:

  1. The condition number κ(A)=λmax⁡/λmin⁡\kappa(A) = \lambda_{\max}/\lambda_{\min} as a one-number diagnostic for “how close to singular am I?”.
  2. gaussx.add_jitter(A, jitter) — the cheap one-liner that shifts eigenvalues by ε\varepsilon and turns the worst near-singular cases into well-conditioned ones.
  3. gaussx.safe_cholesky(A) — the JIT-compatible adaptive-jitter Cholesky that retries with growing ε\varepsilon until the factor is finite.
  4. The numerical-stability trade-off: jitter biases your covariance — too little doesn’t fix the problem, too much corrupts your inference. Where to set it, and how to tell.
  5. The connection to 0.5 — Joseph form: jitter is a recovery patch (after the fact); Joseph is preservation (PSD by construction). They live at different stages of a pipeline.

Prerequisites: 0.5 — Joseph form, 0.9 — primitives tour, 0.10 — differentiating solve.

The condition number — one number that summarises trouble

For an SPD matrix AA with eigenvalues λ1≥⋯≥λn>0\lambda_1 \ge \dots \ge \lambda_n > 0, the 2-norm condition number is

κ2(A)  =  λmax⁡(A)λmin⁡(A)  =  ∥A∥2⋅∥A−1∥2.\kappa_2(A) \;=\; \frac{\lambda_{\max}(A)}{\lambda_{\min}(A)} \;=\; \|A\|_2 \cdot \|A^{-1}\|_2.

It governs how much round-off error a linear solve amplifies:

∥x^−x∥∥x∥  ≲  κ2(A) ⋅ εmach,\frac{\|\hat x - x\|}{\|x\|} \;\lesssim\; \kappa_2(A)\,\cdot\,\varepsilon_{\text{mach}},

where εmach≈1.1×10−16\varepsilon_{\text{mach}} \approx 1.1 \times 10^{-16} for float64 and ≈5.96×10−8\approx 5.96 \times 10^{-8} for float32. Cholesky’s failure threshold is roughly κ(A)⋅εmach≈1\kappa(A) \cdot \varepsilon_{\text{mach}} \approx 1 — once the condition number reaches the reciprocal of machine epsilon, λmin⁡\lambda_{\min} has been swamped by round-off.

Precisionεmach\varepsilon_{\text{mach}}“danger zone” κ
float64≈1.1×10−16\approx 1.1 \times 10^{-16}≳1012\gtrsim 10^{12}
float32≈5.96×10−8\approx 5.96 \times 10^{-8}≳106\gtrsim 10^{6}

A kernel matrix on nn nearby points easily hits κ∼1014\kappa \sim 10^{14} in float64 — already in the danger zone. Hence: jitter.

K shape:                     (40, 40)
smallest eigenvalue lambda_min:    -5.757e-15
largest eigenvalue  lambda_max:    3.986e+01
condition number  kappa_2(K):       3.986e+301
plain cholesky has NaNs:       True

κ(K)→∞\kappa(K)\to\infty — well past the float64 danger zone. The plain Cholesky returns NaNs; from this point on every downstream solve, logdet, or sample is poisoned.

add_jitter — the one-liner

The simplest cure is to add a small multiple of the identity:

Aε  =  A+εI,λi(Aε)  =  λi(A)+ε.A_\varepsilon \;=\; A + \varepsilon I, \qquad \lambda_i(A_\varepsilon) \;=\; \lambda_i(A) + \varepsilon.

This uniformly shifts the spectrum by ε\varepsilon — every eigenvalue grows by the same amount, no eigenvector rotates, and the condition number drops from λmax⁡/λmin⁡\lambda_{\max}/\lambda_{\min} to (λmax⁡+ε)/(λmin⁡+ε)(\lambda_{\max} + \varepsilon)/(\lambda_{\min} + \varepsilon). For our numerically-singular kernel, ε=10−6\varepsilon = 10^{-6} gives κ≈4×107\kappa \approx 4 \times 10^7 — back into safe territory.

gaussx.add_jitter(A, jitter) returns A + eps * I as a lineax AddLinearOperator, preserving the operator type for downstream dispatch. It’s a structural addition — the original AA is not materialised.

       eps      lambda_min      lambda_max         kappa    cholesky ok?
     1e-12       9.939e-13       3.986e+01     4.011e+13             yes
     1e-10       9.999e-11       3.986e+01     3.986e+11             yes
     1e-08       1.000e-08       3.986e+01     3.986e+09             yes
     1e-06       1.000e-06       3.986e+01     3.986e+07             yes
     1e-04       1.000e-04       3.986e+01     3.986e+05             yes

ε=10−12\varepsilon = 10^{-12} already restores Cholesky here, but κ≈4×1013\kappa \approx 4 \times 10^{13} is still in the float64 danger zone — downstream solve will lose ~13 digits of precision. ε=10−6\varepsilon = 10^{-6} tames everything. The default in gaussx.add_jitter is 10-6 — a good starting point for kernel matrices, but always check the resulting condition number against your precision’s danger zone.

safe_cholesky — adaptive jitter inside jit

For batched / jit-compiled code you can’t try / except your way out of a NaN. gaussx.safe_cholesky does the retry loop inside JAX with a lax.while_loop:

  1. Try cholesky(A) first — if structured (Diagonal/Kronecker/BlockDiag/BlockTriDiag), that’s the fast path and we’re done.
  2. If the factor has any NaNs, retry with cholesky(A + eps_k * I) where εk=ε0⋅ρk\varepsilon_k = \varepsilon_0 \cdot \rho^k grows geometrically.
  3. Stop when the factor is finite or after max_retries. If we exhaust retries the result has NaNs — that’s intentional: JAX can’t raise inside jit, so callers should check.

Defaults: ε0=10−8\varepsilon_0 = 10^{-8}, ρ=10\rho = 10, εmax⁡=10−2\varepsilon_{\max} = 10^{-2}, max_retries = 5. This sweeps ε∈{10−8,10−7,…,10−3}\varepsilon \in \{10^{-8}, 10^{-7}, \dots, 10^{-3}\} before giving up.

safe_cholesky has NaNs:        False
||L_safe @ L_safe.T - K||_F:    6.325e-08
effective jitter used:          1.000e-08    (= mean diagonal residual)

The bias–stability trade-off

Jitter is not free: every ε\varepsilon added to the diagonal biases your covariance. A larger ε\varepsilon inflates the predictive variance, dampens the gradient, and shifts the marginal likelihood. At the extreme, ε=1\varepsilon = 1 on a kernel of magnitude ∼1\sim 1 turns your GP prior into white noise.

The right ε\varepsilon minimises the sum of two errors:

O(κ(A)⋅εmach)⏟round-off, decreases with ε  +  O(ε/λmin⁡(A))⏟bias, increases with ε.\underbrace{\mathcal{O}(\kappa(A) \cdot \varepsilon_{\text{mach}})}_{\text{round-off, decreases with }\varepsilon} \;+\; \underbrace{\mathcal{O}(\varepsilon / \lambda_{\min}(A))}_{\text{bias, increases with }\varepsilon}.

A handy rule of thumb: pick ε≈κtarget⋅εmach⋅tr(A)/n\varepsilon \approx \sqrt{\kappa_{\text{target}} \cdot \varepsilon_{\text{mach}} \cdot \mathrm{tr}(A)/n} where κtarget∼108\kappa_{\text{target}} \sim 10^{8} (float64) or 104 (float32). For a kernel matrix at scale, ε=10−6σf2\varepsilon = 10^{-6} \sigma_f^2 is a reasonable default.

The plot below sweeps ε∈[10−14,10−2]\varepsilon \in [10^{-14}, 10^{-2}] on our near-singular KK and tracks (a) the relative residual ∥Kε−K∥F/∥K∥F\|K_\varepsilon - K\|_F/\|K\|_F — the bias — and (b) the relative solve error ∥Kε−1v−Kref−1v∥/∥Kref−1v∥\|K_\varepsilon^{-1} v - K^{-1}_{\text{ref}} v\| / \|K^{-1}_{\text{ref}} v\| for a random vv.

<Figure size 880x495 with 1 Axes>
sweet spot: eps* ≈ 1.37e-10    (total error 2.720e-01)

The U-curve is universal: too little jitter → solve blows up; too much → bias dominates. The sweet spot lands near ε≈10−10\varepsilon \approx 10^{-10} for this numerically-singular matrix in float64 — any smaller and the conditioning is too poor for the solve to track the reference; any larger and the bias term starts to dominate. The exact location depends on the reference baseline (here we used εref=10−10\varepsilon_{\rm ref} = 10^{-10} for the well-conditioned KrefK_{\rm ref}, so the sweet spot collapses near that value). For real workloads, ε≈10−6σf2\varepsilon \approx 10^{-6}\sigma_f^2 is the practical default.

Float32 stress — what jitter buys you in lower precision

Float32 has εmach≈6×10−8\varepsilon_{\text{mach}} \approx 6 \times 10^{-8}, so its danger zone starts at κ∼106\kappa \sim 10^6 — easy to hit on any reasonable kernel. We compare three pipelines on the same near-singular KK:

  • plain float32 Cholesky;
  • add_jitter with default ε=10−6\varepsilon = 10^{-6};
  • safe_cholesky with adaptive jitter.
  plain  cholesky float32:  NaN=True
  add_jitter (eps=1e-6):    NaN=False     ||LL^T - K||_F = 6.324e-06
  safe_cholesky:            NaN=False    ||LL^T - K||_F = 6.324e-06

Where each tool fits

StageSymptomTool
Pre-Cholesky covariance assemblymatrix is symbolically PSD but eigenvalues compress under round-offadd_jitter(A, jitter) — once, deterministic
Inside a jit-compiled solvecan’t try/except; need a single function that’s robust to occasional near-singularitysafe_cholesky(A) — adaptive
Posterior covariance updateresult should be PSD by construction but cancellation breaks itJoseph form (0.5) — preservation, not recovery
Hyperparameter optimisationgradient blows up at low jitterε\varepsilon schedule: anneal during training, lower at convergence
Sampling from N(0,A)\mathcal{N}(0, A)factor must be SPD; one bad sample contaminates the whole batchsafe_cholesky then L @ eps

Joseph form vs jitter is the key contrast: Joseph form prevents asymmetry and indefiniteness from arising, jitter fixes an already-broken matrix. They live at different stages of a pipeline — a Kalman filter with Joseph updates and a jitter-protected initial covariance is belt-and-braces.

A practical recipe — kernel matrices the safe way

Putting it together, a robust GP kernel-matrix construction looks like:

def Ky(X, sigma_f, ell, sigma_n, jitter=1e-6):
    diff = X - X.T
    K = sigma_f**2 * jnp.exp(-0.5 * diff**2 / ell**2)
    K = K + (sigma_n**2 + jitter) * jnp.eye(n)   # noise + structural jitter
    return psd_op(K)

# Then: gaussx.solve / gaussx.logdet on Ky(X, ...) — gradients flow through both.

The structural jitter 10-6 is added on top of the noise variance σn2\sigma_n^2. It’s small enough not to matter for the model, large enough to keep κ<1012\kappa < 10^{12} in practical regimes. If you ever see NaNs in your gradient, raise it by a factor of 10 and re-run.

Recap

  • The condition number κ(A)\kappa(A) ((1)) is the one-number diagnostic — pair it with 1/εmach1/\varepsilon_{\text{mach}} to predict Cholesky failure.
  • gaussx.add_jitter(A, jitter) gives the cheap structural shift ((3)) — a fixed insurance premium.
  • gaussx.safe_cholesky(A) adapts inside jit, retrying with growing ε\varepsilon until the factor is finite — the JAX-friendly guarantee.
  • The bias–stability trade-off ((4)) is a U-curve: too little jitter → blow-up, too much → bias. The sweet spot lives around ε≈κtargetεmach\varepsilon \approx \sqrt{\kappa_{\text{target}} \varepsilon_{\text{mach}}}.
  • Jitter is recovery; Joseph form (0.5) is preservation. Real pipelines use both.