Linear Algebra: Cholesky Decomposition Explained

Cholesky decomposition is a matrix factorization technique that breaks down a positive definite matrix A into the product of a lower triangular matrix L and its transpose: A = L·L^T. Named after...

Key Insights

  • Cholesky decomposition factorizes positive definite matrices into L·L^T, running twice as fast as LU decomposition with half the memory footprint—critical for large-scale numerical computing.
  • The algorithm fails spectacularly on non-positive definite matrices, making it an excellent diagnostic tool for detecting numerical issues in covariance matrices and optimization problems.
  • From Monte Carlo simulations to Kalman filters, Cholesky decomposition is the workhorse behind correlated random variable generation and efficient linear system solving in quantitative finance and machine learning.

Introduction to Cholesky Decomposition

Cholesky decomposition is a matrix factorization technique that breaks down a positive definite matrix A into the product of a lower triangular matrix L and its transpose: A = L·L^T. Named after André-Louis Cholesky, a French military officer who developed it for geodetic calculations, this decomposition has become fundamental to modern numerical computing.

The power of Cholesky lies in its efficiency. When applicable, it’s roughly twice as fast as LU decomposition and requires half the storage. For a matrix of size n×n, Cholesky needs only n(n+1)/2 elements compared to n² for full LU decomposition. This matters enormously when you’re working with 10,000×10,000 covariance matrices in quantitative finance or machine learning.

You’ll encounter Cholesky decomposition everywhere in computational statistics: solving normal equations in least squares, generating correlated random variables for Monte Carlo simulations, computing multivariate normal densities, and implementing Kalman filters. Understanding it deeply makes you a better computational practitioner.

Mathematical Foundation

Cholesky decomposition only works on positive definite matrices. A matrix A is positive definite if it’s symmetric and all its eigenvalues are positive. Equivalently, x^T·A·x > 0 for all non-zero vectors x. Covariance matrices from real data are naturally positive semi-definite, and strictly positive definite when you have more samples than dimensions.

The decomposition states: A = L·L^T, where L is lower triangular with positive diagonal elements. This is unique—there’s exactly one Cholesky decomposition for each positive definite matrix.

Compare this to LU decomposition: A = L·U, where L is lower triangular and U is upper triangular. LU works on any square matrix (with pivoting), but requires both L and U. When your matrix is symmetric and positive definite, Cholesky exploits this structure: U is simply L^T, cutting computation and storage in half.

Here’s how to verify if a matrix is positive definite:

import numpy as np

def is_positive_definite(A):
    """Check if matrix is positive definite using eigenvalues."""
    if not np.allclose(A, A.T):
        return False, "Matrix is not symmetric"
    
    eigenvalues = np.linalg.eigvals(A)
    
    if np.all(eigenvalues > 0):
        return True, "Matrix is positive definite"
    elif np.all(eigenvalues >= 0):
        return False, "Matrix is positive semi-definite"
    else:
        return False, "Matrix has negative eigenvalues"

# Test with a covariance matrix
A = np.array([[4, 2, 1],
              [2, 5, 3],
              [1, 3, 6]])

result, message = is_positive_definite(A)
print(f"{message}")
print(f"Eigenvalues: {np.linalg.eigvals(A)}")

The Algorithm Step-by-Step

The Cholesky algorithm computes L element by element. For each element L[i,j], we use previously computed elements. The formulas are:

  • Diagonal elements: L[i,i] = sqrt(A[i,i] - sum(L[i,k]² for k < i))
  • Off-diagonal elements: L[i,j] = (A[i,j] - sum(L[i,k]·L[j,k] for k < j)) / L[j,j]

Let’s manually decompose a 3×3 matrix:

A = [[4, 2, 1],
     [2, 5, 3],
     [1, 3, 6]]

Step 1: L[0,0] = sqrt(4) = 2

Step 2: L[1,0] = 2/2 = 1, L[2,0] = 1/2 = 0.5

Step 3: L[1,1] = sqrt(5 - 1²) = sqrt(4) = 2

Step 4: L[2,1] = (3 - 0.5·1)/2 = 1.25

Step 5: L[2,2] = sqrt(6 - 0.5² - 1.25²) = sqrt(4.1875) ≈ 2.046

The computational complexity is O(n³/3), exactly half of LU decomposition’s O(2n³/3). For large matrices, this difference is substantial.

Here’s a from-scratch implementation:

import numpy as np

def cholesky_decomposition(A):
    """Implement Cholesky decomposition from scratch."""
    n = A.shape[0]
    L = np.zeros_like(A, dtype=float)
    
    for i in range(n):
        for j in range(i + 1):
            if i == j:
                # Diagonal element
                sum_sq = sum(L[i, k]**2 for k in range(j))
                value = A[i, i] - sum_sq
                
                if value <= 0:
                    raise ValueError(f"Matrix is not positive definite at position ({i},{i})")
                
                L[i, j] = np.sqrt(value)
            else:
                # Off-diagonal element
                sum_prod = sum(L[i, k] * L[j, k] for k in range(j))
                L[i, j] = (A[i, j] - sum_prod) / L[j, j]
    
    return L

# Test implementation
A = np.array([[4, 2, 1],
              [2, 5, 3],
              [1, 3, 6]], dtype=float)

L = cholesky_decomposition(A)
print("L matrix:")
print(L)
print("\nVerification (L·L^T):")
print(L @ L.T)
print("\nOriginal A:")
print(A)

Practical Applications

Solving linear systems Ax = b is more efficient with Cholesky when A is positive definite. Instead of solving Ax = b directly, you decompose A = L·L^T, then solve two triangular systems: Ly = b (forward substitution), then L^T·x = y (backward substitution). Triangular systems solve in O(n²) time.

The killer application is generating correlated random variables. To sample from a multivariate normal distribution N(μ, Σ), you:

  1. Decompose Σ = L·L^T using Cholesky
  2. Generate independent standard normals z ~ N(0, I)
  3. Transform: x = μ + L·z

This gives you x ~ N(μ, Σ). Here’s how:

import numpy as np
import matplotlib.pyplot as plt

def generate_correlated_samples(mean, cov, n_samples):
    """Generate correlated multivariate normal samples using Cholesky."""
    L = np.linalg.cholesky(cov)
    
    # Generate independent standard normals
    z = np.random.randn(n_samples, len(mean))
    
    # Transform to correlated samples
    x = mean + z @ L.T
    
    return x

# Define mean and covariance
mean = np.array([0, 0])
cov = np.array([[1.0, 0.8],
                [0.8, 1.0]])

# Generate samples
samples = generate_correlated_samples(mean, cov, 1000)

# Verify empirical covariance matches
print("Target covariance:")
print(cov)
print("\nEmpirical covariance:")
print(np.cov(samples.T))

In Kalman filtering, you constantly update covariance matrices. Cholesky decomposition ensures these remain positive definite and enables efficient computation of the Kalman gain.

Implementation and Best Practices

In production, use optimized library implementations. NumPy wraps LAPACK’s dpotrf routine, which is battle-tested and uses cache-efficient blocked algorithms:

import numpy as np
from scipy import linalg
import time

# Create a large positive definite matrix
n = 1000
A = np.random.randn(n, n)
A = A @ A.T + np.eye(n) * 0.1  # Ensure positive definite

# Compare Cholesky vs standard solve
b = np.random.randn(n)

# Using Cholesky
start = time.time()
L = np.linalg.cholesky(A)
y = linalg.solve_triangular(L, b, lower=True)
x_chol = linalg.solve_triangular(L.T, y, lower=False)
time_chol = time.time() - start

# Using standard solve
start = time.time()
x_std = np.linalg.solve(A, b)
time_std = time.time() - start

print(f"Cholesky solve: {time_chol:.4f} seconds")
print(f"Standard solve: {time_std:.4f} seconds")
print(f"Speedup: {time_std/time_chol:.2f}x")
print(f"Solution difference: {np.linalg.norm(x_chol - x_std):.2e}")

When Cholesky fails, your matrix isn’t positive definite. Common causes:

  • Insufficient data (more features than samples)
  • Perfect multicollinearity
  • Numerical precision issues
  • Data entry errors

Add regularization to fix it: A’ = A + λI, where λ is small (1e-6 to 1e-4). This makes the matrix positive definite while minimally changing its structure.

Real-World Use Case: Portfolio Optimization

In quantitative finance, portfolio risk calculation requires computing x^T·Σ·x repeatedly, where Σ is the covariance matrix of returns. Cholesky decomposition makes this efficient:

import numpy as np

def portfolio_risk_analysis(returns, weights):
    """Calculate portfolio risk using Cholesky decomposition."""
    # Compute covariance matrix
    cov_matrix = np.cov(returns.T)
    
    # Cholesky decomposition
    L = np.linalg.cholesky(cov_matrix)
    
    # Portfolio variance: w^T·Σ·w = w^T·L·L^T·w = ||L^T·w||²
    Lw = L.T @ weights
    portfolio_variance = np.sum(Lw**2)
    portfolio_std = np.sqrt(portfolio_variance)
    
    return portfolio_std, L

# Simulate daily returns for 5 assets over 252 trading days
np.random.seed(42)
n_assets = 5
n_days = 252

returns = np.random.randn(n_days, n_assets) * 0.01  # 1% daily volatility

# Equal-weighted portfolio
weights = np.ones(n_assets) / n_assets

# Calculate risk
risk, L = portfolio_risk_analysis(returns, weights)

print(f"Portfolio daily volatility: {risk:.4f} ({risk*100:.2f}%)")
print(f"Annualized volatility: {risk*np.sqrt(252):.4f} ({risk*np.sqrt(252)*100:.2f}%)")

# Monte Carlo simulation using Cholesky
n_simulations = 10000
simulated_returns = (np.random.randn(n_simulations, n_assets) @ L.T)
portfolio_returns = simulated_returns @ weights

print(f"\nMonte Carlo VaR (95%): {np.percentile(portfolio_returns, 5):.4f}")
print(f"Monte Carlo CVaR (95%): {portfolio_returns[portfolio_returns <= np.percentile(portfolio_returns, 5)].mean():.4f}")

Conclusion

Cholesky decomposition is the right tool when you have symmetric positive definite matrices. It’s faster than alternatives, numerically stable, and elegant in its simplicity. Use it for solving linear systems with covariance matrices, generating correlated random variables, and any application where you repeatedly compute quadratic forms.

Choose Cholesky over LU decomposition when your matrix is symmetric and positive definite—you’ll get 2x speedup and better numerical properties. Choose it over eigendecomposition when you need efficiency over interpretability. Choose it over inverse computation when solving linear systems—never compute matrix inverses explicitly if you can avoid it.

The decomposition’s failure on non-positive definite matrices is a feature, not a bug. It alerts you to numerical problems in your data or model. When it fails, investigate rather than blindly adding regularization.

Master Cholesky decomposition, and you’ll write faster, more robust numerical code across statistics, machine learning, and quantitative finance.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.