Systems and matrices

Gaussian elimination with Python

The L, U and P factors elimination leaves behind, why they are worth keeping, and the Cholesky and QR factorisations with their domains of application.

The lesson on linear systems presented Gaussian elimination as the procedure np.linalg.solve runs internally. This one is about what that procedure leaves behind. Elimination does not merely produce a solution: it produces a factorisation of the matrix, and that factorisation is reusable.

What elimination produces

Applying Gaussian elimination with partial pivoting to AA amounts to constructing three matrices:

PA=LUPA = LU

where PP is a permutation recording the row interchanges, LL is lower triangular with ones on the diagonal and holds the multipliers used, and UU is upper triangular and is the visible result of the elimination.

In the figure, UU takes shape through elimination while LL stores each multiplier rk\ell_{rk} in the position that has just been zeroed:

P
100010001
L
100010001
U
21-1-3-12-212

P A = L U

Step 1 / 7

Nothing is discarded. Every row operation is recorded: the interchanges in PP, the multipliers in LL, and the result in UU. That is the difference between running the elimination and factorising.

from scipy.linalg import lu P, L, U = lu(A) np.allclose(P @ L @ U, A) # True

SciPy's convention differs from the usual one. It returns PP such that A=PLUA = PLU, whereas the textbooks, and the statement above, write PA=LUPA = LU. Both describe the same factorisation: since PP is a permutation, P1=PP^{-1} = P^\top, so A=PLUA = PLU is equivalent to PA=LUP^\top A = LU. SciPy's PP is the transpose of the PP in the statement.

An immediate by-product: the determinant is read off the diagonal of UU.

detA=(1)si=1nuii\det A = (-1)^{s} \prod_{i=1}^{n} u_{ii}

where ss is the number of row interchanges. For the matrix in the figure, two interchanges and a diagonal of (3,  5/3,  1/5)(-3,\; 5/3,\; 1/5) give detA=1\det A = -1. This is how np.linalg.det obtains its result: not by cofactor expansion, which would cost O(n!)O(n!), but by factorising.

Factor once, solve many times

The factorisation costs roughly 23n3\tfrac{2}{3}n^3 operations. Solving with it, by contrast, is two triangular substitutions — forward with LL, backward with UU — costing 2n22n^2 together.

That asymmetry is the reason to keep the factors. When the same AA is solved against several right-hand sides, a common situation in practice, repeating the elimination throws away all the expensive work:

from scipy.linalg import lu_factor, lu_solve lu_piv = lu_factor(A) # once: O(n³) x1 = lu_solve(lu_piv, b1) # each one: O(n²) x2 = lu_solve(lu_piv, b2)
solve k times
1.7·10⁹
factor once
93.3·10⁶

speed-up ×18.0

Flop counts: 2n³/3 to factor, 2n² per triangular substitution.

matrix 500×500 · right-hand sides k = 20

The advantage grows with nn and with the number of systems. For large nn the cost approaches that of a single factorisation, regardless of how many right-hand sides have to be processed.

If all the right-hand sides are known in advance, np.linalg.solve accepts a matrix as its second argument and factorises only once:

B = np.column_stack([b1, b2, b3]) X = np.linalg.solve(A, B) # one factorisation, three solutions

When the matrix is symmetric: Cholesky

The lesson on the inverse and transpose established that AAA^\top A is symmetric and positive semidefinite. For matrices with that structure a cheaper factorisation exists:

A=LLA = LL^\top

with LL lower triangular with positive diagonal. This is the Cholesky factorisation, and it requires AA to be symmetric and positive definite. It costs 13n3\tfrac{1}{3}n^3 operations, half of LU, because it exploits the symmetry rather than ignoring it.

L = np.linalg.cholesky(K) # fails if K is not positive definite alpha = np.linalg.solve(L.T, np.linalg.solve(L, y))

The failure is informative rather than inconvenient: if Cholesky does not go through, the matrix is not positive definite, which in a Gaussian process usually signals an ill-conditioned covariance matrix. Standard practice is to add a regularisation term to the diagonal, K+σ2IK + \sigma^2 I, which shifts every eigenvalue and restores positive definiteness.

When the system is rectangular: QR

For least squares, the previous lesson noted that forming AAA^\top A squares the condition number. The alternative is to factorise AA directly:

A=QRA = QR

with QQ having orthonormal columns and RR upper triangular. Substituting into the normal equations, the problem reduces to Rw=QbR\vec{w} = Q^\top\vec{b}, a triangular system, without ever constructing AAA^\top A.

Q, R = np.linalg.qr(A) w = np.linalg.solve(R, Q.T @ b)

This is, in essence, what np.linalg.lstsq does internally.

FactorisationRequiresCostUsed for
LUsquare, non-singular23n3\tfrac{2}{3}n^3general systems, determinant
Choleskysymmetric positive definite13n3\tfrac{1}{3}n^3covariances, Gaussian processes
QRfull column rank2mn22mn^2least squares
SVDnone20n3\sim 20n^3rank, null space, pseudoinverse

The reduced row echelon form

Elimination can be carried past UU to the reduced row echelon form, with pivots equal to one and zeros above them as well. NumPy does not provide it, and the omission is deliberate: for solving a system it adds nothing that LULU does not already give, and in floating point the decision of which entry counts as an exact zero is ambiguous.

Where it does belong is in exact algebra, with sympy:

import sympy as sp M = sp.Matrix([[2, 1, -1], [-3, -1, 2], [-2, 1, 2]]) M.rref() # exact rational arithmetic, no rounding

The distinction is one of domain, not of quality. sympy works over exact rationals and suits determining structure — rank, a basis for the null space, dependencies among rows — in small matrices. numpy and scipy work in floating point over optimised implementations and are the tool for numerical computation. Using the second to reason about exact structure, or the first to solve a system of any size, inverts both.

Why it is not hand-coded

The routines shown delegate to LAPACK, a library with decades of debugging whose implementations account for the memory hierarchy: they operate in blocks to exploit the cache, which a direct transcription of the pseudocode does not.

Writing one's own elimination is an instructive exercise and a poor production decision. It will be one to two orders of magnitude slower and, more likely than not, less stable: the pivoting, the criterion for detecting singularity and the handling of edge cases are where the difficulty concentrates, and they are precisely the parts a first attempt leaves out.


Exercise. Factorise AA with scipy.linalg.lu and check that the product of the diagonal of U, signed by the number of interchanges, reproduces np.linalg.det(A). Then determine how many right-hand sides are needed for factorising once to be cheaper than calling solve repeatedly, with n=1000n = 1000.