Skip to content

Gaussian elimination and LU with partial pivoting

ModuleM03.2 · build · Python · Pass 2 · 3 to 4 h
You buildpython/tinyllm/linalg/lu.py: lu(A) returns P,L,UP, L, U with PA=LUPA = LU; lu_solve(P, L, U, b) solves Ax=bAx = b by two triangular solves
Contractcourse/contracts/py/tinyllm/linalg/lu.pyi
Testscourse/tests/M03.2/test_lu.py (what they check: section 4)
Needsnothing to build first. Reading: M03.1 (matrices, the product, row-major layout)
Used byM03.4 inverse iteration (one lu, a lu_solve per step) · later M07.7 (one linear solve per IRLS step of logistic regression) and M10.5 (Newton steps)
MilestoneMS-P2 (the foundations gate)
Optional depthTrefethen and Bau, Numerical Linear Algebra, lectures 20 to 22 (elimination, pivoting, stability); Strang, Introduction to Linear Algebra, ch. 2; Higham, Accuracy and Stability of Numerical Algorithms, ch. 9
  • Gaussian elimination turns Ax=bAx = b into an upper triangular system by subtracting multiples of a pivot row from the rows below it; the multipliers form a unit lower triangular LL and the result is UU, with A=LUA = LU when no rows move (test_hand_example_factors).
  • Partial pivoting first swaps the row with the largest ∣entry∣\lvert\text{entry}\rvert into the pivot position, so every multiplier has ∣ℓik∣≤1\lvert \ell_{ik} \rvert \le 1; the swaps are a permutation matrix PP and PA=LUPA = LU (test_pa_equals_lu_on_random_matrices).
  • Without pivoting, a zero pivot stops elimination on an invertible matrix and a tiny one destroys the answer (test_zero_leading_pivot_needs_a_swap, test_tiny_pivot_loses_everything_without_partial_pivoting).
  • Factor once in 23n3\frac{2}{3}n^3 operations, then each new right-hand side costs only 2n22n^2: forward substitution Ly=PbLy = Pb, back substitution Ux=yUx = y (test_hand_example_solve, test_solve_matches_numpy).
  • A singular matrix still factors, with a zero on UU‘s diagonal; solving with it must fail loudly (test_singular_matrix_factors_but_does_not_solve).
Terminal window
ol start M03.2 # stubs python/tinyllm/linalg/lu.py into your repo
ol tests M03.2 # read the test catalog first: rung R0, you write no tests here
ol check M03.2 # exit code is the verdict
ol diff M03.2 # after passing: your code against the reference

Your system can multiply matrices (M03.1) but cannot undo a multiplication: given AA and bb, find the xx with Ax=bAx = b. That is the core step of every second-order method you will meet. Logistic regression by IRLS (M07.7, the classifier behind the usage-policy head) solves one linear system per iteration; Newton’s method (M10.5) solves one per step; and the next module, M03.4, finds the eigenvalue of a matrix nearest a chosen number by solving with the same matrix again and again. Calling np.linalg.solve would work, but the course builds the method from first principles so you can see why it is fast (factor once, solve many times) and why the naive version of it silently returns garbage on perfectly ordinary matrices.

SymbolMeaningType / shape
AAa square matrix, aija_{ij} its entriesfloat64[n, n]
x,bx, bthe unknown and the right-hand side of Ax=bAx = bfloat64[n] or [n, k]
LLunit lower triangular: ones on the diagonal, zeros abovefloat64[n, n]
UUupper triangular: zeros below the diagonalfloat64[n, n]
PPa permutation matrix: the identity with its rows reorderedfloat64[n, n]
ℓik\ell_{ik}the multiplier that eliminates entry (i,k)(i, k), stored in LLscalar
ukku_{kk}the kk-th pivotscalar
ε\varepsilonfloat64 unit roundoff, 2−53≈1.1×10−162^{-53} \approx 1.1 \times 10^{-16}

2.1 Elimination is a sequence of row operations

Section titled “2.1 Elimination is a sequence of row operations”

Subtracting ℓ\ell times row kk from row ii does not change the solution set of Ax=bAx = b when you do the same to bb: it combines two true equations into a third. Gaussian elimination uses that operation to clear the entries below the diagonal, one column at a time. For column kk, the pivot is ukku_{kk}, and for each row i>ki > k,

ℓik=aikukk,rowi←rowi−ℓik rowk.\ell_{ik} = \frac{a_{ik}}{u_{kk}}, \qquad \text{row}_i \leftarrow \text{row}_i - \ell_{ik}\, \text{row}_k .

After n−1n - 1 columns the matrix is upper triangular, UU.

The row operation “row ii minus ℓ\ell times row kk” is multiplication on the left by E=I−ℓ eiek⊤E = I - \ell\, e_i e_k^\top, the identity with −ℓ-\ell at position (i,k)(i, k). Its inverse is I+ℓ eiek⊤I + \ell\, e_i e_k^\top (add the row back). Elimination computes Elast⋯E1A=UE_{\text{last}} \cdots E_1 A = U, so

A=E1−1⋯Elast−1 U=LU,A = E_1^{-1} \cdots E_{\text{last}}^{-1}\, U = L U,

and the product of those inverses, taken in this order, is simply the identity with every multiplier ℓik\ell_{ik} placed at position (i,k)(i, k): no arithmetic is needed to form LL, you write each multiplier where it was used. LL is the record of elimination, which is what lets you replay it on any bb later.

Elimination divides by ukku_{kk}, and nothing guarantees it is non-zero. (0111)\begin{pmatrix} 0 & 1 \\ 1 & 1 \end{pmatrix} is invertible, yet its first pivot is 0. Swapping the two rows (equations may be listed in any order) fixes it. A pivot that is merely small is worse, because nothing fails visibly. Take ϵ=10−20\epsilon = 10^{-20}:

(ϵ111)x=(12),x≈(1,1).\begin{pmatrix} \epsilon & 1 \\ 1 & 1 \end{pmatrix} x = \begin{pmatrix} 1 \\ 2 \end{pmatrix}, \qquad x \approx (1, 1).

Pivoting on ϵ\epsilon gives ℓ21=1020\ell_{21} = 10^{20}, and u22=1−1020u_{22} = 1 - 10^{20} rounds to −1020-10^{20}: the 1 in a22a_{22} is lost, because the spacing of float64 numbers near 102010^{20} is about 16 00016\,000. Back substitution then gets x2=1x_2 = 1 and x1=(1−x2)/ϵ=0x_1 = (1 - x_2)/\epsilon = 0. Every digit of x1x_1 is wrong.

Partial pivoting chooses, for column kk, the row p≥kp \ge k with the largest ∣upk∣\lvert u_{pk} \rvert and swaps it into row kk before eliminating. Then every multiplier satisfies ∣ℓik∣≤1\lvert \ell_{ik} \rvert \le 1, row operations never amplify entries by more than a factor 2 per step, and in practice elimination with partial pivoting is backward stable: the computed xx solves a system (A+δA)x=b(A + \delta A)x = b with ∥δA∥\lVert \delta A \rVert a small multiple of ε∥A∥\varepsilon \lVert A \rVert. Three details make it right:

  • Search rows kk and below only. Rows above kk are finished pivot rows.
  • Compare absolute values. −3-3 is a better pivot than 11.
  • Swap the whole rows of the working matrix, and also the multipliers already stored in LL for those two rows (columns 00 to k−1k - 1): a multiplier belongs to its equation, and the equation moved. LL’s diagonal stays where it is.

Ties go to the lowest row index (np.argmax returns the first maximum), which makes PP deterministic. If the best ∣upk∣\lvert u_{pk} \rvert is 0, the whole column below the diagonal is already zero: there is nothing to eliminate, so skip it. The matrix is then singular, and UU carries a 0 on its diagonal.

Record the swaps in an array perm where row ii of the permuted matrix is row perm[i] of AA; the permutation matrix has Pi,perm[i]=1P_{i,\mathrm{perm}[i]} = 1, so (PA)i,:=Aperm[i],:(PA)_{i,:} = A_{\mathrm{perm}[i],:}. PP is orthogonal, P−1=P⊤P^{-1} = P^\top, and in general P≠P⊤P \ne P^\top (a 3-cycle is the smallest example).

With PA=LUPA = LU, the system Ax=bAx = b becomes LUx=PbLUx = Pb, solved in two triangular steps:

forward:yi=(Pb)i−∑j<iℓij yj(i=0,1,… ),back:xi=yi−∑j>iuij xjuii(i=n−1,…,0).\text{forward:}\quad y_i = (Pb)_i - \sum_{j < i} \ell_{ij}\, y_j \quad (i = 0, 1, \dots), \qquad \text{back:}\quad x_i = \frac{y_i - \sum_{j > i} u_{ij}\, x_j}{u_{ii}} \quad (i = n-1, \dots, 0).

Forward substitution divides by nothing (LL has a unit diagonal); back substitution divides by each pivot, so a zero pivot means the system has no unique solution and lu_solve raises. Several right-hand sides at once (bb of shape [n,k][n, k]) use the same formulas column by column.

Eliminating column kk updates an (n−k−1)×(n−k)(n-k-1) \times (n-k) block with one multiply and one subtract per entry, so the factorization costs ∑k2(n−k)2≈23n3\sum_k 2(n-k)^2 \approx \frac{2}{3} n^3 floating-point operations. Each triangular solve costs about n2n^2. For n=1000n = 1000: about 6.7×1086.7 \times 10^8 operations to factor and 2×1062 \times 10^6 per solve. That 300-fold gap is why M03.4 factors once and solves at every step.

A=(2114−60−272),b=(5−29).A = \begin{pmatrix} 2 & 1 & 1 \\ 4 & -6 & 0 \\ -2 & 7 & 2 \end{pmatrix}, \qquad b = \begin{pmatrix} 5 \\ -2 \\ 9 \end{pmatrix}.

Column 0. The candidates are 2,4,−22, 4, -2; the largest absolute value is 44 in row 1, so swap rows 0 and 1 (perm = [1, 0, 2]). The pivot is 4; the multipliers are ℓ10=2/4=0.5\ell_{10} = 2/4 = 0.5 and ℓ20=−2/4=−0.5\ell_{20} = -2/4 = -0.5:

row1:(2,1,1)−0.5 (4,−6,0)=(0,4,1),row2:(−2,7,2)+0.5 (4,−6,0)=(0,4,2).\text{row}_1: (2, 1, 1) - 0.5\,(4, -6, 0) = (0, 4, 1), \qquad \text{row}_2: (-2, 7, 2) + 0.5\,(4, -6, 0) = (0, 4, 2).

Column 1. The candidates (rows 1 and 2) are 44 and 44: a tie, so row 1 stays. ℓ21=4/4=1\ell_{21} = 4/4 = 1 and row2:(0,4,2)−(0,4,1)=(0,0,1)\text{row}_2: (0, 4, 2) - (0, 4, 1) = (0, 0, 1).

P=(010100001),L=(1000.510−0.511),U=(4−60041001).P = \begin{pmatrix} 0 & 1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{pmatrix}, \quad L = \begin{pmatrix} 1 & 0 & 0 \\ 0.5 & 1 & 0 \\ -0.5 & 1 & 1 \end{pmatrix}, \quad U = \begin{pmatrix} 4 & -6 & 0 \\ 0 & 4 & 1 \\ 0 & 0 & 1 \end{pmatrix}.

Check one entry of LULU: row 2 of LL times column 0 of UU is −0.5⋅4=−2=(PA)20-0.5 \cdot 4 = -2 = (PA)_{20}. This is test_hand_example_factors.

Solve. Pb=(−2,5,9)Pb = (-2, 5, 9). Forward: y0=−2y_0 = -2, y1=5−0.5⋅(−2)=6y_1 = 5 - 0.5 \cdot (-2) = 6, y2=9−(−0.5)(−2)−1⋅6=2y_2 = 9 - (-0.5)(-2) - 1 \cdot 6 = 2. Back: x2=2/1=2x_2 = 2 / 1 = 2, x1=(6−1⋅2)/4=1x_1 = (6 - 1 \cdot 2) / 4 = 1, x0=(−2−(−6)⋅1−0⋅2)/4=1x_0 = (-2 - (-6) \cdot 1 - 0 \cdot 2) / 4 = 1. So x=(1,1,2)x = (1, 1, 2), and indeed 2+1+2=52 + 1 + 2 = 5, 4−6+0=−24 - 6 + 0 = -2, −2+7+4=9-2 + 7 + 4 = 9. This is test_hand_example_solve. Every number here is a small dyadic fraction, so the code gets them exactly.

def lu(A: ArrayLike) -> tuple[NDArray, NDArray, NDArray]:
"""P, L, U (float64 [n, n]) with P @ A == L @ U; |L| <= 1; ties to the lowest
row; zero columns skipped (singular A still factors). A is not modified."""
def lu_solve(P: ArrayLike, L: ArrayLike, U: ArrayLike, b: ArrayLike) -> NDArray:
"""x with A @ x == b for b of shape [n] or [n, k]. ValueError on a zero pivot."""
TestKINDChecksWhy it matters downstream
test_hand_example_factorsunit, smokesection 3’s PP, LL, UU exactlyyou and the tests agree on the pivot and tie rules
test_hand_example_solveunit, smokex=(1,1,2)x = (1, 1, 2) exactlyforward then back substitution
test_pa_equals_lu_on_random_matricespropertyPA=LUPA = LU, PP a permutation, LL unit lower with ∣L∣≤1\lvert L \rvert \le 1, UU upper, sizes 1 to 12the structure every caller relies on
test_solve_matches_numpydifferentialagainst np.linalg.solve, one and three right-hand sides, nn up to 40IRLS in M07.7
test_zero_leading_pivot_needs_a_swapboundary(0111)\begin{pmatrix} 0 & 1 \\ 1 & 1 \end{pmatrix} factors and solvespivoting is required, not optional
test_tiny_pivot_loses_everything_without_partial_pivotingboundarythe ϵ=10−20\epsilon = 10^{-20} system gives (1,1)(1, 1)silent garbage without pivoting
test_pivot_uses_absolute_valueboundary−3-3 beats 11 as the pivot∣L∣≤1\lvert L \rvert \le 1
test_pivot_search_ignores_finished_rowsboundarya large entry in a finished row is not chosenthe search covers rows kk and below
test_singular_matrix_factors_but_does_not_solveboundarya rank-2 matrix and the zero matrix factor; solving raisesa zero pivot is reported, never divided by
test_input_is_not_modified_and_shapes_are_checkedboundaryAA unchanged; non-square inputs and a wrong-length bb raisecallers reuse AA
test_permutation_applied_to_b_not_its_transposeunita 3-cycle permutation: PbP b, not P⊤bP^\top bthe permutation direction
PitfallSymptomCaught by
no pivoting at alldivision by zero on invertible matrices, or every digit lost to a tiny pivottest_tiny_pivot_loses_everything_without_partial_pivoting (mutant s01)
choosing the largest value instead of the largest absolute valuemultipliers above 1 and error growthtest_pivot_uses_absolute_value (mutant s02)
swapping rows of UU but not the multipliers already in LLPA≠LUPA \ne LU as soon as a later column swapstest_pa_equals_lu_on_random_matrices (mutant s03)
applying P⊤P^\top to bb instead of PPright answers whenever PP is its own inverse, wrong otherwisetest_permutation_applied_to_b_not_its_transpose (mutant s04)
forgetting to divide by the pivot in back substitutionxx scaled wrongly except where pivots are 1test_hand_example_solve (mutant s05)
storing the multiplier with the wrong signLU≠PAL U \ne PAtest_hand_example_factors (mutant s06)
dividing by a zero pivot when solvinginf and nan instead of an errortest_singular_matrix_factors_but_does_not_solve (mutant s07)
searching the whole column for the pivota finished row is swapped back downtest_pivot_search_ignores_finished_rows (mutant s08)
eliminating in the caller’s arraythe next solve with AA uses UUtest_input_is_not_modified_and_shapes_are_checked (mutant s09)
DirectionModuleHow it uses this
BackM03.1row-major matrices and the matrix product (reading)
ForwardM03.4inverse_iteration factors A−σIA - \sigma I once and calls lu_solve at every step
ForwardM07.7IRLS solves (X⊤WX) δ=X⊤(y−p)(X^\top W X)\,\delta = X^\top (y - p) at every iteration of logistic regression
ForwardM10.5Newton steps solve with the Hessian

If you skip this module, ol check M03.4 stops with BLOCKED ... needs M03.2: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
luLAPACK dgetrfblocked (right-looking) factorization that spends almost all its time in matrix-matrix products, so it runs near the speed of M03.1’s GEMMLAPACK SRC/dgetrf.f, dgetrf2.f
lu_solveLAPACK dgetrs, scipy.linalg.lu_solvetriangular solves with many right-hand sides at oncescipy/linalg/_decomp_lu.py
partial pivotingrook and complete pivotingstronger growth bounds at extra search costHigham, ch. 9
dense LUsparse LU (SuperLU, UMFPACK)reorders rows and columns to keep LL and UU sparsescipy.sparse.linalg.splu
explicit solveCholesky for symmetric positive definite systemshalf the work, no pivoting needed; the natural choice for IRLS’s X⊤WXX^\top W XLAPACK dpotrf