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 amounts to constructing three matrices:
where is a permutation recording the row interchanges, is lower triangular with ones on the diagonal and holds the multipliers used, and is upper triangular and is the visible result of the elimination.
In the figure, takes shape through elimination while stores each multiplier in the position that has just been zeroed:
P A = L U
Step 1 / 7
Nothing is discarded. Every row operation is recorded: the interchanges in , the multipliers in , and the result in . 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 such that , whereas the textbooks, and the statement above, write . Both describe the same factorisation: since is a permutation, , so is equivalent to . SciPy's is the transpose of the in the statement.
An immediate by-product: the determinant is read off the diagonal of .
where is the number of row interchanges. For the matrix in the figure, two interchanges and a diagonal of give . This is how np.linalg.det obtains its result: not by cofactor expansion, which would cost , but by factorising.
Factor once, solve many times
The factorisation costs roughly operations. Solving with it, by contrast, is two triangular substitutions — forward with , backward with — costing together.
That asymmetry is the reason to keep the factors. When the same 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)
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 and with the number of systems. For large 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 is symmetric and positive semidefinite. For matrices with that structure a cheaper factorisation exists:
with lower triangular with positive diagonal. This is the Cholesky factorisation, and it requires to be symmetric and positive definite. It costs 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, , which shifts every eigenvalue and restores positive definiteness.
When the system is rectangular: QR
For least squares, the previous lesson noted that forming squares the condition number. The alternative is to factorise directly:
with having orthonormal columns and upper triangular. Substituting into the normal equations, the problem reduces to , a triangular system, without ever constructing .
Q, R = np.linalg.qr(A)
w = np.linalg.solve(R, Q.T @ b)
This is, in essence, what np.linalg.lstsq does internally.
| Factorisation | Requires | Cost | Used for |
|---|---|---|---|
| LU | square, non-singular | general systems, determinant | |
| Cholesky | symmetric positive definite | covariances, Gaussian processes | |
| QR | full column rank | least squares | |
| SVD | none | rank, null space, pseudoinverse |
The reduced row echelon form
Elimination can be carried past 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 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 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 .