Gaussian elimination and LU with partial pivoting
Overview
Section titled “Overview”| Module | M03.2 · build · Python · Pass 2 · 3 to 4 h |
| You build | python/tinyllm/linalg/lu.py: lu(A) returns with ; lu_solve(P, L, U, b) solves by two triangular solves |
| Contract | course/contracts/py/tinyllm/linalg/lu.pyi |
| Tests | course/tests/M03.2/test_lu.py (what they check: section 4) |
| Needs | nothing to build first. Reading: M03.1 (matrices, the product, row-major layout) |
| Used by | M03.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) |
| Milestone | MS-P2 (the foundations gate) |
| Optional depth | Trefethen 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 |
Key Takeaways
Section titled “Key Takeaways”- Gaussian elimination turns into an upper triangular system by subtracting multiples of a pivot row from the rows below it; the multipliers form a unit lower triangular and the result is , with when no rows move (
test_hand_example_factors). - Partial pivoting first swaps the row with the largest into the pivot position, so every multiplier has ; the swaps are a permutation matrix and (
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 operations, then each new right-hand side costs only : forward substitution , back substitution (
test_hand_example_solve,test_solve_matches_numpy). - A singular matrix still factors, with a zero on ‘s diagonal; solving with it must fail loudly (
test_singular_matrix_factors_but_does_not_solve).
How to work this chapter
Section titled “How to work this chapter”ol start M03.2 # stubs python/tinyllm/linalg/lu.py into your repool tests M03.2 # read the test catalog first: rung R0, you write no tests hereol check M03.2 # exit code is the verdictol diff M03.2 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Your system can multiply matrices (M03.1) but cannot undo a multiplication: given and , find the with . 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.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| a square matrix, its entries | float64[n, n] | |
| the unknown and the right-hand side of | float64[n] or [n, k] | |
| unit lower triangular: ones on the diagonal, zeros above | float64[n, n] | |
| upper triangular: zeros below the diagonal | float64[n, n] | |
| a permutation matrix: the identity with its rows reordered | float64[n, n] | |
| the multiplier that eliminates entry , stored in | scalar | |
| the -th pivot | scalar | |
| float64 unit roundoff, |
2.1 Elimination is a sequence of row operations
Section titled “2.1 Elimination is a sequence of row operations”Subtracting times row from row does not change the solution set of when you do the same to : 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 , the pivot is , and for each row ,
After columns the matrix is upper triangular, .
2.2 The multipliers form L
Section titled “2.2 The multipliers form L”The row operation “row minus times row ” is multiplication on the left by , the identity with at position . Its inverse is (add the row back). Elimination computes , so
and the product of those inverses, taken in this order, is simply the identity with every multiplier placed at position : no arithmetic is needed to form , you write each multiplier where it was used. is the record of elimination, which is what lets you replay it on any later.
2.3 Pivoting
Section titled “2.3 Pivoting”Elimination divides by , and nothing guarantees it is non-zero. 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 :
Pivoting on gives , and rounds to : the 1 in is lost, because the spacing of float64 numbers near is about . Back substitution then gets and . Every digit of is wrong.
Partial pivoting chooses, for column , the row with the largest and swaps it into row before eliminating. Then every multiplier satisfies , 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 solves a system with a small multiple of . Three details make it right:
- Search rows and below only. Rows above are finished pivot rows.
- Compare absolute values. is a better pivot than .
- Swap the whole rows of the working matrix, and also the multipliers already stored in for those two rows (columns to ): a multiplier belongs to its equation, and the equation moved. ’s diagonal stays where it is.
Ties go to the lowest row index (np.argmax returns the first maximum), which makes deterministic. If the best 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 carries a 0 on its diagonal.
2.4 Permutation matrices and solving
Section titled “2.4 Permutation matrices and solving”Record the swaps in an array perm where row of the permuted matrix is row perm[i] of ; the permutation matrix has , so . is orthogonal, , and in general (a 3-cycle is the smallest example).
With , the system becomes , solved in two triangular steps:
Forward substitution divides by nothing ( 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 ( of shape ) use the same formulas column by column.
2.5 Cost
Section titled “2.5 Cost”Eliminating column updates an block with one multiply and one subtract per entry, so the factorization costs floating-point operations. Each triangular solve costs about . For : about operations to factor and per solve. That 300-fold gap is why M03.4 factors once and solves at every step.
3. Worked example by hand
Section titled “3. Worked example by hand”
Column 0. The candidates are ; the largest absolute value is in row 1, so swap rows 0 and 1 (perm = [1, 0, 2]). The pivot is 4; the multipliers are and :
Column 1. The candidates (rows 1 and 2) are and : a tie, so row 1 stays. and .
Check one entry of : row 2 of times column 0 of is . This is test_hand_example_factors.
Solve. . Forward: , , . Back: , , . So , and indeed , , . This is test_hand_example_solve. Every number here is a small dyadic fraction, so the code gets them exactly.
4. The interface
Section titled “4. The interface”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."""What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_factors | unit, smoke | section 3’s , , exactly | you and the tests agree on the pivot and tie rules |
test_hand_example_solve | unit, smoke | exactly | forward then back substitution |
test_pa_equals_lu_on_random_matrices | property | , a permutation, unit lower with , upper, sizes 1 to 12 | the structure every caller relies on |
test_solve_matches_numpy | differential | against np.linalg.solve, one and three right-hand sides, up to 40 | IRLS in M07.7 |
test_zero_leading_pivot_needs_a_swap | boundary | factors and solves | pivoting is required, not optional |
test_tiny_pivot_loses_everything_without_partial_pivoting | boundary | the system gives | silent garbage without pivoting |
test_pivot_uses_absolute_value | boundary | beats as the pivot | |
test_pivot_search_ignores_finished_rows | boundary | a large entry in a finished row is not chosen | the search covers rows and below |
test_singular_matrix_factors_but_does_not_solve | boundary | a rank-2 matrix and the zero matrix factor; solving raises | a zero pivot is reported, never divided by |
test_input_is_not_modified_and_shapes_are_checked | boundary | unchanged; non-square inputs and a wrong-length raise | callers reuse |
test_permutation_applied_to_b_not_its_transpose | unit | a 3-cycle permutation: , not | the permutation direction |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| no pivoting at all | division by zero on invertible matrices, or every digit lost to a tiny pivot | test_tiny_pivot_loses_everything_without_partial_pivoting (mutant s01) |
| choosing the largest value instead of the largest absolute value | multipliers above 1 and error growth | test_pivot_uses_absolute_value (mutant s02) |
| swapping rows of but not the multipliers already in | as soon as a later column swaps | test_pa_equals_lu_on_random_matrices (mutant s03) |
| applying to instead of | right answers whenever is its own inverse, wrong otherwise | test_permutation_applied_to_b_not_its_transpose (mutant s04) |
| forgetting to divide by the pivot in back substitution | scaled wrongly except where pivots are 1 | test_hand_example_solve (mutant s05) |
| storing the multiplier with the wrong sign | test_hand_example_factors (mutant s06) | |
| dividing by a zero pivot when solving | inf and nan instead of an error | test_singular_matrix_factors_but_does_not_solve (mutant s07) |
| searching the whole column for the pivot | a finished row is swapped back down | test_pivot_search_ignores_finished_rows (mutant s08) |
| eliminating in the caller’s array | the next solve with uses | test_input_is_not_modified_and_shapes_are_checked (mutant s09) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M03.1 | row-major matrices and the matrix product (reading) |
| Forward | M03.4 | inverse_iteration factors once and calls lu_solve at every step |
| Forward | M07.7 | IRLS solves at every iteration of logistic regression |
| Forward | M10.5 | Newton 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.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
lu | LAPACK dgetrf | blocked (right-looking) factorization that spends almost all its time in matrix-matrix products, so it runs near the speed of M03.1’s GEMM | LAPACK SRC/dgetrf.f, dgetrf2.f |
lu_solve | LAPACK dgetrs, scipy.linalg.lu_solve | triangular solves with many right-hand sides at once | scipy/linalg/_decomp_lu.py |
| partial pivoting | rook and complete pivoting | stronger growth bounds at extra search cost | Higham, ch. 9 |
| dense LU | sparse LU (SuperLU, UMFPACK) | reorders rows and columns to keep and sparse | scipy.sparse.linalg.splu |
| explicit solve | Cholesky for symmetric positive definite systems | half the work, no pivoting needed; the natural choice for IRLS’s | LAPACK dpotrf |