Problem
The eigen-decomposition and
SVD explain what a matrix does, but they are expensive and are rarely how a computer solves
anything. Most numerical work reduces to two questions. Given a square A and a vector b, find
x with Ax = b. Given a tall A with more equations than unknowns, find the x that makes Ax as close
to b as possible. The first appears in Newton steps, Gaussian processes and every
solve call; the second is
linear regression.
The textbook answers, x = A−1b and x = (ATA)−1ATb, are exactly the ones not to compute. Forming an inverse costs more than solving, and forming ATA squares the condition number, so a problem that floating point can solve to 10 digits comes back with 3, or not at all. Libraries instead factor A once into triangular and orthogonal pieces, which are cheap to solve with and lose almost no accuracy.
Intuition
A triangular system is easy. If L is lower triangular, the first equation has one unknown, the second has two of which one is already known, and so on down: forward substitution, n²/2 multiplications. An upper triangular system is solved the same way from the bottom up, back substitution. An orthogonal matrix Q is even easier, since Q−1 = QT. So the whole game is to rewrite A as a product of triangular and orthogonal matrices.
- LU records Gaussian elimination: PA = LU, where P reorders the rows, L holds the multipliers and U is what elimination leaves. It works for any invertible square A.
- Cholesky is LU for a symmetric positive-definite A, where symmetry lets the two factors be transposes of each other: A = LLT. Half the work, no pivoting.
- QR makes A's columns orthonormal one at a time: A = QR. It works for tall matrices too, which is why it solves least squares.
Elimination on A = [[2, 1, 1], [4, -6, 0], [-2, 7, 2]] first swaps rows 1 and 2, because 4 is the largest entry in the
first column. Each amber number is the multiple of a pivot row that was subtracted to put a zero
below it, and U is the matrix left at the end. Multiplying L by U gives back the row-swapped A
exactly. Computed by lu_factor, a stdlib copy of what
torch.linalg.lu_factor returns unpacked.
Mechanics
LU with partial pivoting
Gaussian elimination clears column k below the diagonal by subtracting ℓik = aik/akk times row k from each row i > k. Storing each ℓik in L, below a unit diagonal, gives A = LU. The pivot akk can be zero, or tiny, and dividing by a tiny pivot makes the multipliers huge and swamps the other entries in rounding error. So before clearing column k, partial pivoting swaps in the row with the largest |aik|, which keeps every |ℓik| ≤ 1. The swaps collect in a permutation P, and the factorization is PA = LU. It costs about (2/3)n³ operations.
Solving Ax = b then takes two triangular solves: Ly = Pb by forward substitution, then Ux = y by
back substitution, each O(n²). Once A is factored, every new right-hand side is cheap. That is
the reason to keep the factors (lu_factor then lu_solve) when the same
matrix meets many vectors. torch.linalg.solve does both steps for you, and its
docstring notes it is faster and more stable than A.inverse() @ B. PyTorch returns
P as LAPACK does, as a 1-indexed vector of row swaps: pivots[i] == j means row i was
swapped with row j − 1, in that order.
Cholesky for positive-definite matrices
A covariance matrix, a kernel matrix plus noise, a Hessian at a minimum, and any ATA of full column rank are symmetric positive-definite: xTAx > 0 for every x ≠ 0. For these, A = LLT with L lower triangular and a positive diagonal. Entry by entry,
ℓjj = √(ajj − Σk<j ℓjk²), ℓij = (aij − Σk<j ℓikℓjk) / ℓjj for i > j.
It costs about n³/3, half of LU, needs no pivoting, and is the fastest way to test positive
definiteness: if a square root's argument is not positive, the matrix is not positive-definite.
cholesky_ex exists to return that as an info code instead of raising,
and its docstring calls this the faster way to check. Cholesky also turns independent noise into
correlated noise: if z ~ N(0, I) then Lz has covariance L·I·LT = Σ. That is how
MultivariateNormal samples, and how a Gaussian process draws functions and evaluates
its likelihood.
Target Σ has var(x₁) = 4, var(x₂) = 10 and covariance
2; 4000 samples from random.Random(7)
land within a few percent of each. Every sample cost one triangular matrix-vector product.
QR: Gram–Schmidt and Householder
Gram–Schmidt builds Q one column at a time: take column j of A, subtract its projection onto
each earlier qk (the coefficient rkj = qk·aj), and
normalize what is left (its length is rjj). The coefficients form an upper triangular
R with A = QR. For an m × n matrix with m ≥ n, the reduced QR has Q of shape m × n with
orthonormal columns and R of shape n × n, which is torch.linalg.qr's default
mode='reduced'.
Written the obvious way, classical Gram–Schmidt projects the original column onto every
qk, and rounding errors leave the q's less and less orthogonal on ill-conditioned
input. Modified Gram–Schmidt projects the running remainder instead, the same arithmetic
in a different order, and loses far less. On the 6 × 6 Hilbert matrix (condition number
1.5e+07), ‖QTQ − I‖F is 7.1e-05
for classical and 2.7e-10 for modified. Libraries go further and use
Householder reflections: each step reflects the remaining part of a column onto a
coordinate axis with an exactly orthogonal matrix I − 2vvT/vTv, so Q is
orthogonal to machine precision whatever the conditioning. LAPACK's geqrf, which
PyTorch calls, stores Q as those reflectors rather than as a matrix.
Least squares: QR versus the normal equations
With more equations than unknowns, minimize ‖Ax − b‖2. Setting the gradient to zero gives the normal equations ATAx = ATb, solvable by Cholesky. They are fast and correct in exact arithmetic, but κ(ATA) = κ(A)², so they lose twice as many digits as the problem requires. QR avoids forming ATA: since Q has orthonormal columns, ‖Ax − b‖ is minimized by solving Rx = QTb, a single back substitution, with error proportional to κ(A) for small-residual problems.
At ε = 10⁻⁷, κ(A) = 1.4e+07 and κ(ATA) ≈ 2·10¹⁴: the normal equations are off by 1.1e-03. At ε = 10⁻⁸, 1 + ε² rounds to 1, ATA becomes [[1, 1], [1, 1]] and there is nothing left to solve. QR gets [1, −1] at every ε.
Matrix norms and the condition number
A matrix norm measures the size of a matrix. torch.linalg.matrix_norm supports the
ones that matter, shown here for the worked example's A = [[2, 1, 1], [4, -6, 0], [-2, 7, 2]]:
ord | Name | Definition | Value for A |
|---|---|---|---|
'fro' (default) | Frobenius | √Σ aij² = √Σ σi² | 10.72 |
'nuc' | nuclear | Σ σi | 13.9 |
2 | spectral | σmax, the most any unit vector is stretched | 10.23 |
-2 | σmin | 0.4911 | |
1 | max column sum | maxj Σi |aij| | 14 |
inf | max row sum | maxi Σj |aij| | 11 |
The spectral norm is the one most results are stated in: ‖Ax‖ ≤ ‖A‖2‖x‖, and ‖A‖2 ≤ ‖A‖F ≤ ‖A‖nuc. The condition number κ(A) = ‖A‖‖A−1‖, which for the 2-norm is σmax/σmin = 20.83 here, bounds how much a relative error in b can grow in x. A rule of thumb: with float64's 16 digits, expect to lose about log10κ of them. The same norms appear elsewhere in ML: weight decay penalizes the squared Frobenius norm, spectral normalization divides a weight by its spectral norm, the nuclear norm is the convex stand-in for rank in matrix completion, and Muon sets every singular value of its update to about 1.
Where they appear in ML
- Linear and ridge regression: least squares with QR or SVD, or Cholesky on ATA + λI, where the ridge term λ also fixes the conditioning.
- Gaussian processes and Gaussians: Cholesky of the kernel matrix gives both samples and log det Σ = 2Σ log ℓii, needed in the likelihood.
- Orthogonal initialization:
nn.init.orthogonal_takes the QR of a Gaussian matrix and multiplies Q's columns by the signs of R's diagonal, which makes Q uniformly distributed over orthogonal matrices rather than biased by the QR convention. - Second-order methods and natural gradients: each step solves a linear system in a Hessian or Fisher matrix, usually by Cholesky with damping.
- Orthogonalized updates: Muon's Newton–Schulz iteration approximates the orthogonal polar factor of the gradient using only matrix products, because QR and SVD are slow on GPUs in bfloat16.
| Term | Shape/Params | Meaning | Source |
|---|---|---|---|
linalg.lu_factor |
[*, m, n] → (LU, pivots [*, min(m, n)]), pivot=True |
L and U packed in one matrix; 1-indexed LAPACK row swaps. | lu_factor |
linalg.lu |
[*, m, n] → (P, L, U) |
The same factorization unpacked into three matrices, A = PLU. | lu |
linalg.solve |
A [*, n, n], B [*, n] or [*, n, k], left=True |
X with AX = B, assuming A is invertible; faster and more stable than A.inverse() @ B. |
solve |
linalg.solve_triangular |
A, B, upper, left=True, unitriangular=False |
Forward or back substitution. | solve_triangular |
linalg.cholesky |
[*, n, n] → L [*, n, n], upper=False |
A = LLH for Hermitian positive-definite A; raises otherwise. | cholesky |
linalg.cholesky_ex |
→ (L, info), check_errors=False |
Cholesky that reports failure in info: a fast positive-definiteness test. |
cholesky_ex |
linalg.qr |
[*, m, n] → (Q [*, m, k], R [*, k, n]), mode='reduced' |
A = QR; 'complete' gives square Q, 'r' skips Q. |
qr |
linalg.lstsq |
A [*, m, n], B [*, m, k], rcond, driver |
min ‖AX − B‖F; CPU default 'gelsy' (QR with pivoting), CUDA only 'gels'. |
lstsq |
linalg.matrix_norm |
ord='fro', dim=(-2, -1) |
Frobenius, nuclear, ±1, ±inf and ±2 norms. | matrix_norm |
linalg.cond |
p=None (2-norm) |
κ(A) = ‖A‖p‖A−1‖p. | cond |
nn.init.orthogonal_ |
tensor with ≥ 2 dims, gain=1 |
QR of a Gaussian matrix, Q's columns sign-corrected by diag(R), times gain. |
init.py |
Worked example
Solving a 3 × 3 system with LU
Solve Ax = b with A = [[2, 1, 1], [4, -6, 0], [-2, 7, 2]] and b = [5, -2, 9].
- Column 1: the largest |entry| is 4, in row 2, so swap rows 1 and 2 (pivot 2). Subtract 0.5 × row 1 from row 2 and -0.5 × row 1 from row 3.
- Column 2: the remaining entries are 4 and 4; the first is kept (pivot 2, no swap). Subtract 1 × row 2 from row 3.
- That leaves U = [[4, -6, 0], [0, 4, 1], [0, 0, 1]], with L = [[1, 0, 0], [0.5, 1, 0], [-0.5, 1, 1]] and pivots [2, 2, 3] in PyTorch's 1-indexed form.
- Permute b to Pb = [-2, 5, 9]. Forward substitution in Ly = Pb gives y = [−2, 5 − 0.5·(−2), 9 + 0.5·(−2) − 6] = [−2, 6, 2].
- Back substitution in Ux = y gives x₃ = 2, x₂ = (6 − 2)/4 = 1, x₁ = (−2 + 6)/4 = 1, so x = [1, 1, 2].
Cholesky by hand
Factor Σ = [[4, 2, 2], [2, 10, 7], [2, 7, 21]]. Column 1: ℓ₁₁ = √4 = 2, ℓ₂₁ = 2/2 = 1, ℓ₃₁ = 2/2 = 1. Column 2: ℓ₂₂ = √(10 − 1²) = 3, ℓ₃₂ = (7 − 1·1)/3 = 2. Column 3: ℓ₃₃ = √(21 − 1² − 2²) = 4. So L = [[2, 0, 0], [1, 3, 0], [1, 2, 4]]. To solve Σx = [10, 26, 51], forward-substitute Ly = b to get y = [5, 7, 8], then back-substitute LTx = y to get x = [1, 1, 2]. Its determinant is (2·3·4)² = 576, and its log-determinant 2(ln 2 + ln 3 + ln 4) = 2 ln 24 ≈ 6.356, read straight off the diagonal of L.
Fitting a line with QR
Fit y ≈ c + mx to the points x = 0, 1, 2, 3, y = [1, 2, 2, 4]. The design matrix has a column of ones and a column of x. Gram–Schmidt gives the first column of Q as [0.5, 0.5, 0.5, 0.5] with r₁₁ = 2 and r₁₂ = 3, and the second as [-0.6708, -0.2236, 0.2236, 0.6708] with r₂₂ = √5 ≈ 2.236. Then QTy = [4.5, 2.012], and back substitution in Rx = QTy gives c = 0.9 and m = 0.9. The normal equations give the same line here, because this matrix is well conditioned.
Implementation
import torch
A = torch.tensor([[2., 1., 1.], [4., -6., 0.], [-2., 7., 2.]])
b = torch.tensor([5., -2., 9.])
torch.linalg.solve(A, b) # tensor([1., 1., 2.])
LU, pivots = torch.linalg.lu_factor(A) # pivots = tensor([2, 2, 3], dtype=torch.int32)
torch.linalg.lu_solve(LU, pivots, b[:, None]) # reuse the factors for any right-hand side
P, L, U = torch.linalg.lu(A) # A == P @ L @ U
S = torch.tensor([[4., 2., 2.], [2., 10., 7.], [2., 7., 21.]])
L = torch.linalg.cholesky(S) # [[2, 0, 0], [1, 3, 0], [1, 2, 4]]
L, info = torch.linalg.cholesky_ex(-S) # info > 0: not positive-definite, no exception
z = torch.randn(3, 10_000)
x = torch.linalg.cholesky(S) @ z # samples with covariance S
torch.cov(x)
X = torch.tensor([[1., 0.], [1., 1.], [1., 2.], [1., 3.]])
y = torch.tensor([[1.], [2.], [2.], [4.]])
Q, R = torch.linalg.qr(X) # reduced: Q [4, 2], R [2, 2]; signs may differ
torch.linalg.solve_triangular(R, Q.T @ y, upper=True) # [[0.9], [0.9]]
torch.linalg.lstsq(X, y).solution # same, through LAPACK's QR
torch.linalg.matrix_norm(A), torch.linalg.matrix_norm(A, ord=2), torch.linalg.cond(A)
The same pieces from scratch, in plain Python:
import math
def lu_factor(a): # partial pivoting; returns order, L, U with A[order] = L U
n = len(a); u = [list(map(float, r)) for r in a]
lo = [[0.0] * n for _ in range(n)]; order = list(range(n))
for k in range(n):
p = max(range(k, n), key=lambda r: abs(u[r][k]))
u[k], u[p] = u[p], u[k]; lo[k], lo[p] = lo[p], lo[k]; order[k], order[p] = order[p], order[k]
for r in range(k + 1, n):
lo[r][k] = u[r][k] / u[k][k]
u[r] = [x - lo[r][k] * y for x, y in zip(u[r], u[k])]
for i in range(n):
lo[i][i] = 1.0
return order, lo, u
def forward(lo, b):
y = []
for i, row in enumerate(lo):
y.append((b[i] - sum(row[j] * y[j] for j in range(i))) / row[i])
return y
def back(u, y):
n = len(u); x = [0.0] * n
for i in reversed(range(n)):
x[i] = (y[i] - sum(u[i][j] * x[j] for j in range(i + 1, n))) / u[i][i]
return x
def cholesky(a):
n = len(a); lo = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1):
s = a[i][j] - sum(lo[i][k] * lo[j][k] for k in range(j))
lo[i][j] = math.sqrt(s) if i == j else s / lo[j][j] # sqrt of a negative: not PD
return lo
order, lo, u = lu_factor([[2, 1, 1], [4, -6, 0], [-2, 7, 2]])
b = [5, -2, 9]
back(u, forward(lo, [b[i] for i in order])) # [1.0, 1.0, 2.0]
Tradeoffs
-
Never invert to solve.
inv(A) @ bdoes the LU anyway, then extra work, and is less accurate. Keep the factors when the matrix is reused. -
Normal equations versus QR versus SVD. Normal equations with Cholesky cost about
mn² + n³/3 and are fine when A is well conditioned; QR costs about 2mn² and loses only κ(A);
the SVD (
gelsd) costs several times more but handles rank deficiency by dropping singular values belowrcond. On CUDAlstsqonly offers'gels', which assumes full rank. -
Pivoting is not optional. Without it, elimination on [[ε, 1], [1, 1]] divides by
ε and wipes out the answer in rounding.
pivot=Falseexists only on CUDA, for matrices known to be safe, such as diagonally dominant ones. -
Positive-definite in theory, not in floating point. A covariance or kernel
matrix can have eigenvalues that round below zero. Adding a small jitter εI before Cholesky is
standard in Gaussian-process code; check
cholesky_ex'sinfoinstead of catching exceptions. - GPU and precision. These factorizations are sequential column by column and run in float32 or float64, not bfloat16. On GPUs, for many small matrices batch them; for one big orthogonalization in low precision, matrix-product iterations such as Newton–Schulz can be faster than QR.
- Non-uniqueness. LU depends on pivot choices and QR on sign conventions, so different devices can return different valid factors. Compare products or solutions, not factors.
Check yourself
You need to solve Ax = b for 1,000 different b with the same 2,000 × 2,000 A. What do you compute once?
The LU factors (lu_factor), about (2/3)n³ operations once. Each b then costs two triangular solves, O(n²), with lu_solve. Calling solve 1,000 times would redo the factorization every time.
κ(A) = 10⁶. Roughly how many correct digits does float64 least squares give via QR, and via the normal equations?
About 16 − 6 = 10 via QR, and about 16 − 12 = 4 via the normal equations, since κ(AᵀA) = 10¹².
Cholesky of a 3 × 3 covariance hits √(−0.0001) at the last step. What does that tell you, and what do people do?
The matrix is not positive-definite in floating point, usually because it is only positive semi-definite or nearly singular. Adding a small multiple of the identity (jitter) makes it positive-definite; check with cholesky_ex.
Why does orthogonal_ multiply Q by the signs of R's diagonal?
QR fixes signs by convention, so Q from a Gaussian matrix is not uniformly distributed over orthogonal matrices. Making R's diagonal positive pins the decomposition to a unique one, and then Q is uniform (Haar) distributed.
Connections
Read first
References
- Trefethen and Bau. Numerical Linear Algebra. SIAM, 1997: lectures 7–11 on QR and least squares, 20–23 on LU and Cholesky.
- Golub and Van Loan. Matrix Computations, 4th ed. Johns Hopkins University Press, 2013: chapters 3–5.
- Higham. Accuracy and Stability of Numerical Algorithms, 2nd ed. SIAM, 2002: chapter 19 on QR, 20 on least squares.
- Läuchli. Jordan-Elimination und Ausgleichung nach kleinsten Quadraten. Numerische Mathematik 3, 226–240, 1961.
- Mezzadri. How to generate random matrices from the classical compact groups. 2006. arXiv:math-ph/0609050 (cited by
orthogonal_). - Rasmussen and Williams. Gaussian Processes for Machine Learning. MIT Press, 2006: Algorithm 2.1, Cholesky-based GP regression.
- PyTorch v2.15.0:
linalg.lu_factor,linalg.cholesky,linalg.qr,linalg.lstsq,linalg.matrix_normandnn.init.orthogonal_.