Cholesky Decomposition
Positive numbers have square roots: . For matrices you have to be careful about what “square root” means and which matrices have one — and for symmetric positive definite matrices there is a clean answer, with a unique factor, computable in one pass, and useful three different ways.
That answer is the Cholesky decomposition:
with lower triangular and its diagonal strictly positive.
What you’ll learn
Section titled “What you’ll learn”- Theorem 4.18: the statement, the uniqueness, and exactly which matrices qualify.
- The recursive formulas (Equations 4.47 and 4.48) that produce entry by entry, worked on a with integer answers.
- Why the factorisation is the definiteness test, and why it needs no tolerance.
- , and the measured case where that is more accurate than a general determinant.
- Sampling: has covariance when is standard normal — the transformation behind Gaussian sampling and the reparametrisation trick.
- The log determinant, which never overflows where the determinant does.
Intuition: a triangular square root
Section titled “Intuition: a triangular square root”You already know one matrix square root for an SPD matrix: the spectral theorem gives with diagonal and positive, so . That works and it costs an eigendecomposition.
Cholesky asks for less and delivers more cheaply: instead of demanding that the factor be orthogonal-times-diagonal, demand only that it be triangular. There is exactly one such factor with a positive diagonal, and computing it is a single sweep with no iteration at all — because a triangular system can be solved one entry at a time, working down from the top-left corner.
flowchart TD SPD["A symmetric positive definite"] SPD --> THM["Theorem 4.18: A = L Lᵀ
with L lower triangular,
positive diagonal, UNIQUE"] THM --> SOLVE["solve Ax = b as two
triangular solves: O(n²) each"] THM --> DET["det A = ∏ lᵢᵢ²
and log det A = 2 Σ log lᵢᵢ"] THM --> SAMPLE["z ~ N(0, I) ⟹ Lz ~ N(0, A)
Ch 6, and the reparametrisation trick"] THM --> TEST["the factorisation FAILS exactly when
A is not positive definite
— so it is also the test"] DET --> NOOVER["log det never overflows
where det does, past n ≈ 150"]
The math
Section titled “The math”Three parts of that statement earn their place.
Symmetric is needed for the shape to make sense: is symmetric for any whatsoever, so a non-symmetric cannot possibly be written this way.
Positive definite is needed for the diagonal to be real. The formulas below take square roots of quantities built from ‘s entries, and positive definiteness is exactly the condition that keeps those quantities positive.
Unique is the part that makes it useful as a canonical form. Contrast the eigendecomposition, where column signs and the ordering of repeated eigenvalues are arbitrary; the Cholesky factor of a given SPD matrix is one specific matrix, so it can be stored, compared and regression-tested.
Where the formulas come from
Section titled “Where the formulas come from”Multiply out the case (Equation 4.45) and compare entries with :
Now read it off. The top-left entry gives immediately. Knowing , the first column of gives and . Knowing those, the entry gives . And so on — each entry of is determined by entries of and entries of already computed. The pattern:
In general, for :
And there is the definiteness test, sitting in the second formula. If is not positive definite, the quantity under some square root comes out negative and the algorithm stops. Not “produces a bad answer” — stops, with no tolerance to choose. That is why every numerical library uses Cholesky to test definiteness and why the Inner Products page recommended it over comparing eigenvalues to zero.
What it buys
Section titled “What it buys”Solving systems. becomes : solve by forward substitution, then by back substitution. Two sweeps after one factorisation — and if you have many right-hand sides you pay the factorisation once. General LU costs about , so Cholesky is a factor of two cheaper, exactly because symmetry means half the matrix is redundant.
Determinants. Since for a triangular matrix (§4.1, Equation 4.8),
The book notes this is why many numerical packages compute determinants this way.
Sampling and the reparametrisation trick. If then has covariance
So to sample from a Gaussian with a prescribed covariance, factor the covariance and multiply white noise by the factor. The book points forward to §6.5 for the distribution and to the variational autoencoder literature for why this matters: the transformation is a differentiable function of , so gradients pass through the sampling step. That is the reparametrisation trick.
Worked example by hand
Section titled “Worked example by hand”Take
Symmetric by inspection. Now run Equations 4.47 and 4.48 in order.
| step | formula | working | value |
|---|---|---|---|
Check by multiplying back. The entry: ✓. The entry: ✓. The entry: ✓.
Every entry of is an integer, so reproduces exactly — largest gap , not .
The determinant, two ways. From the Cholesky factor:
exactly. np.linalg.det, which runs an LU factorisation in floating point, returns
. Multiplying the eigenvalues gives — worse still, because the
eigenvalues themselves are computed iteratively. Here the Cholesky route is the most accurate of the
three, and it is the cheapest.
Note also that this matrix is not especially well behaved: its eigenvalues are , and , so . The smallest eigenvalue is close to zero and the factorisation still comes out in exact integers, because positive definiteness is a strict inequality and .
import numpy as np
A = np.array([[4.0, 12.0, -16.0], [12.0, 37.0, -43.0], [-16.0, -43.0, 98.0]])
L = np.linalg.cholesky(A)
print("L =")
print(L)
print("L L^T reproduces A exactly:", np.array_equal(L @ L.T, A),
" largest gap:", f"{float(np.abs(L @ L.T - A).max()):.2e}")
print()
print("det from Cholesky: ", float(np.prod(np.diag(L)) ** 2))
print("det from numpy: ", float(np.linalg.det(A)))
print("det from eigenvalues:", float(np.prod(np.linalg.eigvalsh(A))))
print("eigenvalues:", np.round(np.linalg.eigvalsh(A), 6),
" condition number:", round(float(np.linalg.cond(A)), 1))
# Solving with L: forward substitution, then back substitution.
def forward(L, b):
y = np.zeros_like(b)
for i in range(len(b)):
y[i] = (b[i] - L[i, :i] @ y[:i]) / L[i, i]
return y
def backward(U, y):
x = np.zeros_like(y)
for i in reversed(range(len(y))):
x[i] = (y[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i]
return x
rng = np.random.default_rng(5)
b = rng.normal(size=3)
x_chol = backward(L.T, forward(L, b))
x_solve = np.linalg.solve(A, b)
print()
print("x via two triangular solves:", np.round(x_chol, 10))
print("x via np.linalg.solve: ", np.round(x_solve, 10))
print("gap:", f"{float(np.abs(x_chol - x_solve).max()):.2e}")L =
[[ 2. 0. 0.]
[ 6. 1. 0.]
[-8. 5. 3.]]
L L^T reproduces A exactly: True largest gap: 0.00e+00
det from Cholesky: 36.0
det from numpy: 35.999999999999936
det from eigenvalues: 35.99999999999596
eigenvalues: [ 0.018805 15.503963 123.477232] condition number: 6566.2
x via two triangular solves: [-22.15612322 6.00547068 -0.98480708]
x via np.linalg.solve: [-22.15612322 6.00547068 -0.98480708]
gap: 3.91e-14np.array_equal(L @ L.T, A) returning True is worth pausing on — that is bit-for-bit equality, not
approximate agreement, and it happens because every intermediate value in the factorisation is a small
integer.
See it move
Section titled “See it move”The first sketch runs Equations 4.47 and 4.48 entry by entry, with the matrix under your control so you can watch the algorithm fail at the moment a square root goes negative.
The second sketch is the sampling application: white noise in, prescribed covariance out.
From scratch
Section titled “From scratch”import numpy as np
def cholesky(A, tol=0.0):
"""Equations 4.47 and 4.48, in general form. Raises on non-SPD input."""
A = np.asarray(A, dtype=float)
n = A.shape[0]
if not np.allclose(A, A.T):
raise ValueError("not symmetric")
L = np.zeros((n, n))
for i in range(n):
for j in range(i + 1):
s = float(A[i, j] - L[i, :j] @ L[j, :j])
if i == j:
if s <= tol:
raise ValueError(f"not positive definite: "
f"sqrt of {s:.6g} at position ({i}, {i})")
L[i, i] = np.sqrt(s)
else:
L[i, j] = s / L[j, j]
return L
A = np.array([[4.0, 12.0, -16.0], [12.0, 37.0, -43.0], [-16.0, -43.0, 98.0]])
L = cholesky(A)
print("our L:"); print(L)
print("numpy agrees:", np.allclose(L, np.linalg.cholesky(A)))
print("uniqueness — every entry identical:", np.array_equal(L, np.linalg.cholesky(A)))
# It is also the definiteness test, with no tolerance to pick.
print()
for a in (0.85, 0.81, 0.80):
M = np.array([[1.0, 0.9], [0.9, a]])
try:
cholesky(M)
verdict = "positive definite"
except ValueError as e:
verdict = str(e)
print(f"a = {a}: det {float(np.linalg.det(M)):+.6f} "
f"smallest eigenvalue {float(np.linalg.eigvalsh(M)[0]):+.8f} -> {verdict}")
# The log determinant, which never overflows.
print()
rng = np.random.default_rng(23)
for n in (10, 100, 150, 175):
M = rng.normal(size=(n, n))
S = M @ M.T + n * np.eye(n)
Ln = np.linalg.cholesky(S)
det = float(np.linalg.det(S))
logdet = 2.0 * float(np.sum(np.log(np.diag(Ln))))
print(f"n = {n:4}: det {det:12.4e} finite {str(np.isfinite(det) and det > 0):5} "
f"2*sum(log diag L) = {logdet:.4f}")our L:
[[ 2. 0. 0.]
[ 6. 1. 0.]
[-8. 5. 3.]]
numpy agrees: True
uniqueness — every entry identical: True
a = 0.85: det +0.040000 smallest eigenvalue +0.02188041 -> positive definite
a = 0.81: det +0.000000 smallest eigenvalue +0.00000000 -> not positive definite: sqrt of 0 at position (1, 1)
a = 0.8: det -0.010000 smallest eigenvalue -0.00553851 -> not positive definite: sqrt of -0.01 at position (1, 1)
n = 10: det 8.1875e+12 finite True 2*sum(log diag L) = 29.7336
n = 100: det 1.8120e+225 finite True 2*sum(log diag L) = 518.6761
n = 150: det inf finite False 2*sum(log diag L) = 837.9401
n = 175: det inf finite False 2*sum(log diag L) = 1005.9729Three results. The from-scratch factor is bit-for-bit identical to numpy’s, which is what
“unique” means made concrete. The boundary case fails with sqrt of 0 — a determinant of
exactly zero and a smallest eigenvalue of exactly zero, and the algorithm rejects it without being asked
to compare anything against a tolerance. And by the determinant has overflowed to inf while
the log determinant is a perfectly ordinary .
On real data
Section titled “On real data”Reading the plot
Section titled “Reading the plot”From the sampling figure. The identity being verified is , and the check has two halves. The algebraic half is exact: reproduces the target to the printed precision. The empirical half is not, and should not be: the measured covariance of samples differs from the target by at most , which is sampling error.
That distinction matters when debugging. If the measured covariance is off, first check whether the gap shrinks as : measured here at for samples, for , for and for — a factor of about per factor of , which is . If it does not shrink that way, the bug is in the transformation, not the sample size.
From the boundary figure. The three tests agree on where positive definiteness ends, and they differ in what they need from you. The determinant crossing zero and the smallest eigenvalue crossing zero are both continuous quantities you must compare against a threshold. Cholesky is a branch: the square root either has a positive argument or it does not.
At exactly, the matrix has eigenvalues and — positive semidefinite. Definition
3.2 asks for the strict inequality, so the right answer is to reject it, and Cholesky does. A test
written as all(eigvalsh(A) >= 0) would accept it, which is the failure mode the
Inner Products page measured.
From the determinant figure. The right panel is the practical one. The direct determinant is finite
up to about and inf from about ; the log determinant is there and keeps
going. Nothing has gone wrong with the matrix — a SPD matrix with entries of order
simply has a determinant around — past the largest double, whose exponent
stops near .
The left panel is the accuracy story, and it is more subtle. On well-conditioned input both routes agree to machine precision. On input with they diverge — and note which direction the argument runs: the worked on this page has and the Cholesky determinant is exactly while numpy’s general determinant gives . Cholesky is more accurate there because it exploits symmetry and does half the arithmetic. But it is not universally more accurate; for a nearly-singular matrix the square roots amplify error too, which is what the ill conditioned curve shows.
Pitfalls
Section titled “Pitfalls”Compare
Section titled “Compare”| decomposition | needs | gives | cost | unique? |
|---|---|---|---|---|
| Cholesky | symmetric positive definite | a triangular square root | yes | |
| LU | square, invertible (with pivoting) | two triangular factors | up to pivoting | |
| QR | any | orthonormal triangular | up to column signs | |
| eigendecomposition | square, non-defective | eigenbasis diagonal | no: signs, ordering | |
| SVD | any | rotation scale rotation | up to paired signs |
Cholesky is the cheapest and the most restrictive, which is the usual trade. When your matrix qualifies — and covariance matrices, kernel matrices and Hessians at a minimum routinely do — it is the right tool.
-
Why does Theorem 4.18 require A to be symmetric, separately from requiring positive definiteness?
The two hypotheses do different jobs. Symmetry makes the shape possible; positive definiteness makes the arithmetic possible, because Equation 4.47 takes square roots of quantities that positive definiteness keeps positive.
pch.quizShowAnswer
B — Because L L-transpose is symmetric for any L whatsoever, so a non-symmetric A cannot be written in that form at all — while positive definiteness is what keeps the square roots real — The two hypotheses do different jobs. Symmetry makes the shape possible; positive definiteness makes the arithmetic possible, because Equation 4.47 takes square roots of quantities that positive definiteness keeps positive.
-
The from-scratch Cholesky of the worked 3x3 is bit-for-bit identical to numpy's. What does that illustrate?
Bit-for-bit agreement here also depends on every intermediate value being a small integer. On generic input the agreement would be to machine precision rather than exact, but the factor would still be mathematically unique.
pch.quizShowAnswer
B — Uniqueness: Theorem 4.18 says the Cholesky factor of a given SPD matrix is one specific matrix, so any correct implementation must return it — unlike an eigendecomposition, where signs and orderings are free — Bit-for-bit agreement here also depends on every intermediate value being a small integer. On generic input the agreement would be to machine precision rather than exact, but the factor would still be mathematically unique.
-
At a = 0.81 the matrix [[1, 0.9], [0.9, a]] has eigenvalues 0 and 1.81. Cholesky raises. Is that right?
The error message is 'sqrt of 0', which is the algorithm's own report of the boundary. That branch-not-threshold behaviour is the whole reason to prefer Cholesky as a definiteness test.
pch.quizShowAnswer
B — Yes. The matrix is positive SEMIdefinite and Definition 3.2 asks for the strict inequality, so it must be rejected — and Cholesky rejects it without needing any tolerance, where a test written as all eigenvalues at least zero would wrongly accept it — The error message is 'sqrt of 0', which is the algorithm's own report of the boundary. That branch-not-threshold behaviour is the whole reason to prefer Cholesky as a definiteness test.
-
For a 150x150 SPD matrix, np.linalg.det returns inf while 2 times the sum of the log diagonal of L returns 838.93. What should you conclude?
Its condition number is fine. This is why every Gaussian log-likelihood is written with a log determinant term computed from a Cholesky factor rather than by taking the log of a determinant.
pch.quizShowAnswer
B — Nothing is wrong with the matrix — its determinant is genuinely around e to the 838, which is about 10 to the 364 and past the largest double at 10 to the 308, so the quantity to compute is the log determinant rather than the determinant — Its condition number is fine. This is why every Gaussian log-likelihood is written with a log determinant term computed from a Cholesky factor rather than by taking the log of a determinant.
-
In the sampling figure the measured covariance differs from the target by 0.0047 at 50000 samples. How do you tell whether that is a bug?
The algebraic identity Cov[Lz] = L L-transpose is exact; what remains is sampling error. If the gap does not shrink at that rate, the transformation is wrong — a transposed factor being the classic cause.
pch.quizShowAnswer
B — Check whether it shrinks like one over the square root of the sample count — measured 0.39 at 100 samples, 0.12 at 1000, 0.0096 at 10000, 0.0047 at 50000, which is roughly a factor of 3 per factor of 10 in n — The algebraic identity Cov[Lz] = L L-transpose is exact; what remains is sampling error. If the gap does not shrink at that rate, the transformation is wrong — a transposed factor being the classic cause.
🧪 Try It Yourself
Section titled “🧪 Try It Yourself”Exercise 1 – Cholesky by hand
Section titled “Exercise 1 – Cholesky by hand”Exercise 2 – The general algorithm
Section titled “Exercise 2 – The general algorithm”Exercise 3 – Cholesky as the definiteness test
Section titled “Exercise 3 – Cholesky as the definiteness test”Exercise 4 – Solve a system with the factor
Section titled “Exercise 4 – Solve a system with the factor”Exercise 5 – The log determinant does not overflow
Section titled “Exercise 5 – The log determinant does not overflow”Recall card
Section titled “Recall card”- Cholesky factors a symmetric positive definite matrix as L L-transpose, with L lower triangular and positive on the diagonal, and L is unique — unlike an eigendecomposition, where signs and orderings are free.
- Symmetry makes the shape possible and positive definiteness makes the arithmetic possible: L L-transpose is symmetric for any L, and positive definiteness is what keeps every square root real.
- The entries come out one at a time, each from entries of A and entries of L already known, in a single sweep with no iteration.
- The factorisation is the definiteness test. It stops when a square root would go negative, so it needs no tolerance — and at the semidefinite boundary it correctly rejects a matrix that “all eigenvalues at least zero” would accept.
- The determinant is the squared product of the diagonal, which is cheaper than a general determinant and, on the worked example, more accurate: exactly 36 against numpy’s 35.999999999999936.
- Use the log determinant, twice the sum of the logs of the diagonal. A direct determinant overflows to infinity by about n = 150 on a perfectly well-conditioned matrix.
- L z has covariance L L-transpose when z is standard normal, which is how you sample a Gaussian with a prescribed covariance — and because the map is differentiable in L, it is the reparametrisation trick.
- It costs half of an LU factorisation, n cubed over three against two n cubed over three, because symmetry makes half the matrix redundant.
- Adding jitter to make it succeed changes the model, not just the arithmetic. Report the epsilon.
Next: Eigendecomposition and Diagonalization — the factorisation that uses §4.2’s eigenvectors as its basis.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading