SVD, Eckart-Young low rank, and least squares
Overview
Section titled “Overview”| Module | M03.5 · build · Python · Pass 3 · 4 to 5 h |
| You build | python/tinyllm/linalg/svd.py: svd(A) (reduced SVD by one-sided Jacobi rotations, singular values descending, a fixed sign rule), low_rank(A, r) (the best rank- approximation as balanced factors ), and lstsq(A, b) (least squares by QR) |
| Contract | course/contracts/py/tinyllm/linalg/svd.pyi |
| Tests | course/tests/M03.5/test_svd.py (what they check: section 4) |
| Needs | M03.3 qr_householder, which lstsq factors with (or --ref-deps). Reading: M03.4 (eigenvalues; singular values are their square roots for ), S-M03a |
| Used by | later L2.3 PPMI-SVD word vectors, L6.6 LoRA’s PiSSA initialization, L7.6 the MLA conversion, C1 the scaling-law fit, M10.6 Muon (each joins the registry with its batch) · later: M09.3 |
| Milestone | MS-P3 (the tokens-and-data gate) |
| Optional depth | Trefethen and Bau, Numerical Linear Algebra, lectures 4, 5, 11, 31; Demmel and Veselic, “Jacobi’s method is more accurate than QR” (1992); Eckart and Young, “The approximation of one matrix by another of lower rank” (1936); Meng, Wang, and Zhang, “PiSSA” (2024) |
Key Takeaways
Section titled “Key Takeaways”- Every real matrix is : rotate, stretch along the axes by the singular values, rotate again. The singular values are the square roots of the eigenvalues of , and the largest is the most can stretch a unit vector (
test_hand_example_svd,test_random_matrices_match_numpy). - One-sided Jacobi rotates pairs of columns until all are orthogonal; the test for “orthogonal enough” must be relative to the column lengths, or a matrix scaled by is never rotated (
test_scale_invariance). - Keeping the largest singular triples gives the closest rank- matrix, and the error is exactly the first dropped singular value (Eckart-Young,
test_eckart_young_error); splitting into each factor makes them start at the same scale (test_low_rank_factors_are_balanced). - Least squares goes through QR, never : the normal equations square the condition number and lose half the digits (
test_lstsq_ill_conditioned_beats_normal_equations).
How to work this chapter
Section titled “How to work this chapter”ol start M03.5 # stubs python/tinyllm/linalg/svd.py into your repool tests M03.5 # read the test catalog first: rung R0, you write no tests hereol check M03.5 # exit code is the verdictol check M03.5 --ref-deps # only if your M03.3 is not passing yetol diff M03.5 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Pass 3 starts treating text as numbers you can factor. L2.3 counts which words appear near which others in a matrix with tens of thousands of rows and asks for 64 numbers per word that keep most of its structure: that is the best rank-64 approximation of the matrix, and the singular value decomposition is the only tool that gives it with a proof of optimality. The same decomposition comes back three more times in the system: L6.6 starts a LoRA adapter on the top singular directions of a weight matrix, L7.6 compresses attention’s keys and values into a low-rank latent, and the capstone fits a scaling law to a handful of runs by least squares. You already have the pieces: orthogonal matrices and QR (M03.3) and eigenvalues (M03.4). This module puts them together.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| the matrix, rows and columns | float64[m, n] | |
| number of singular values | int | |
| left singular vectors, orthonormal columns () | float64[m, k] | |
| singular values; | float64[k] | |
| right singular vectors, orthonormal columns; the contract returns | float64[n, k] | |
| column of and of | vectors | |
| Euclidean length | scalar | |
| spectral norm | scalar | |
| Frobenius norm | scalar | |
| the truncated SVD | float64[m, n] | |
| two columns of the working matrix | vectors | |
| , , | scalars | |
| rotation parameters: , , | scalars | |
| float64 machine epsilon, | ||
| condition number | scalar | |
the QR factors of M03.3 | float64[m, k], float64[k, n] |
2.1 What the SVD says
Section titled “2.1 What the SVD says”Every real matrix factors as
with orthonormal columns in and and non-negative in descending order. Read it right to left on a vector : takes the coordinates of along the directions , stretches coordinate by , and sends the result out along the directions . So : the unit sphere goes to an ellipsoid whose semi-axes are . This is the reduced SVD ( is ); the full one pads to a square matrix, which nothing in the course needs.
Each pair can be negated together without changing (). When the singular values are distinct this is the only freedom, so the contract fixes it with a sign rule: the entry of largest absolute value in each row of is positive (the first such entry on ties). With it, your factors and LAPACK’s (after the same rule) agree entry by entry.
2.2 Singular values, eigenvalues, and norms
Section titled “2.2 Singular values, eigenvalues, and norms”Multiply out : the are eigenvectors of the symmetric matrix with eigenvalues (S-M03b q8 asks you to prove it). So everything M03.4 said about eigenvalues applies, with two differences: singular values exist for every matrix, square or not, and they are never negative or complex.
Two norms come straight from them. For a unit with coordinates , , with equality at : the spectral norm is . Orthogonal factors do not change the sum of squares of entries, so the Frobenius norm is .
Forming and calling an eigensolver would compute the SVD, but badly: squaring the singular values squares the condition number, and a below drowns in the rounding of . Jacobi works on itself.
2.3 One-sided Jacobi
Section titled “2.3 One-sided Jacobi”Work on a tall matrix (; for a wide one, decompose and swap and at the end). Start with and , and keep the invariant with orthogonal. If all columns of were mutually orthogonal, their lengths would be the singular values and their directions the : , so .
To make columns and orthogonal, rotate them in their plane:
and apply the same rotation to columns and of (so still holds; the rotation is orthogonal, so stays orthogonal). The new inner product is . Setting it to zero with gives , . Take the smaller root,
with : it is the rotation by at most , the one that converges. A sweep visits every pair once. A rotation for one pair can spoil an earlier pair a little, but the off-diagonal mass shrinks every sweep and, near the end, quadratically; random matrices finish in 7 or 8 sweeps, the last one only confirming that nothing needs rotating.
When is a pair “orthogonal enough”? The test must be relative: rotate only when , that is when the cosine of the angle between the columns exceeds . A fixed threshold such as skips every rotation on a matrix scaled by (all inner products are about ) and returns garbage; scaled by it never stops. Stop after the first sweep that rotates nothing (the contract caps sweeps at 64).
Finally , sorted descending (stable), , and the sign rule.
2.4 Rank deficiency
Section titled “2.4 Rank deficiency”If has rank , then singular values are zero and their columns are (numerically) zero: is . The contract treats as zero and fills with a unit vector orthogonal to the ‘s already chosen: take the first standard basis vector that has more than half its length left after projecting out the previous columns (, done twice for accuracy), and normalize it. Any such completion is a valid SVD, because it multiplies a zero singular value; the contract fixes this one so the result is deterministic.
2.5 Eckart-Young and the balanced split
Section titled “2.5 Eckart-Young and the balanced split”The truncated SVD has rank , and is itself an SVD, so
The Eckart-Young theorem says no matrix of rank at most does better in either norm: the truncated SVD is the best low-rank approximation. That is why it is the right way to compress a co-occurrence matrix (L2.3) or initialize a low-rank adapter (L6.6).
low_rank returns as two factors () and () with . Many splits multiply to the same product; the contract uses the balanced one,
so : both factors carry the same scale. PiSSA initializes LoRA this way because gradient descent on updates each factor in proportion to the other’s size; a split such as trains one factor much faster than the other.
2.6 Least squares by QR
Section titled “2.6 Least squares by QR”For a tall with independent columns, usually has no solution; least squares picks minimizing . The minimizer makes the residual orthogonal to every column of : , the normal equations . Solving them as written is the classic mistake. , so a polynomial fit with (degree 9 at 40 points of ) recovers its coefficients only to about .
QR avoids the square. With (M03.3), has orthonormal columns, so the residual splits into a part inside the column space and a part orthogonal to it, and only the first depends on :
So solves the triangular system , by back substitution from the last row up: with . The error now grows like : for the same fit. If some is tiny (), the columns are dependent, the minimizer is not unique, and the contract raises instead of dividing by almost zero.
3. Worked example by hand
Section titled “3. Worked example by hand”
Jacobi. One pair, .
| Quantity | Value |
|---|---|
| , , | , , |
| , so , (a rotation) | |
| new | , length |
| new | , length |
| new columns | , |
| second sweep | : nothing to rotate, stop |
Sorting puts first, then ; check: , and . Dividing each by its length:
The sign rule holds already: each row of has its largest entries tied, and the first is positive. This is test_hand_example_svd.
Rank 1. . The error has spectral and Frobenius norm , as Eckart-Young says. The balanced factors are and . This is test_hand_example_rank_one.
Least squares. Fit a line through , , : , . By hand, the residual condition reads and , so and . Check: the residual sums to 0 (orthogonal to the first column) and (orthogonal to the second). Writing out the normal equations is fine for a system on paper with exact fractions; your code must still go through QR, because in floating point the square of the condition number is what hurts. This is test_hand_example_lstsq.
4. The interface
Section titled “4. The interface”def svd(A: ArrayLike) -> tuple[NDArray, NDArray, NDArray]: """Reduced SVD by one-sided Jacobi: U [m, k], S [k] descending, Vt [k, n], A == U @ diag(S) @ Vt, the largest |entry| of each Vt row positive."""
def low_rank(A: ArrayLike, r: int) -> tuple[NDArray, NDArray]: """B = U_r sqrt(S_r) [m, r], C = sqrt(S_r) Vt_r [r, n]: B @ C is the best rank-r approximation."""
def lstsq(A: ArrayLike, b: ArrayLike) -> NDArray: """argmin ||A x - b|| for a tall, full-column-rank A, by QR and back substitution."""What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_svd | unit, smoke | section 3’s , , to | you and the tests agree on the rotation, the order, and the sign rule |
test_hand_example_rank_one | unit, smoke | , the balanced factors, and the error | the numbers of section 3 |
test_hand_example_lstsq | unit, smoke | the line fit of section 3 | |
test_random_matrices_match_numpy | differential | tall, wide, square, and shapes against np.linalg.svd with the same sign rule, entry by entry | the decomposition is the unique one |
test_scale_invariance | property | svd(c A) is times svd(A) for | the rotation test is relative |
test_rank_deficient_completes_u | boundary | a rank-2 matrix and the zero matrix: orthonormal, no NaN | co-occurrence matrices are often rank deficient |
test_eckart_young_error | property | spectral and Frobenius error of low_rank for | the optimality L2.3 and L6.6 rely on |
test_low_rank_factors_are_balanced | property | PiSSA’s equal-scale factors | |
test_low_rank_rejects_bad_rank | boundary | , , raise | config errors fail early |
test_lstsq_matches_numpy | differential | one and three right-hand sides against np.linalg.lstsq | the minimizer is unique |
test_lstsq_residual_is_orthogonal | property | the defining property | |
test_lstsq_ill_conditioned_beats_normal_equations | boundary | a degree-9 polynomial fit () recovers its coefficients to | the capstone’s scaling-law fit |
test_lstsq_rejects_rank_deficient_and_wide | boundary | dependent columns, a wide system, and a wrong-length raise | no division by a zero pivot |
test_inputs_not_modified_and_validated | boundary | is unchanged; 1-D and NaN inputs raise | L6.6 keeps the weight it decomposes |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. solving the normal equations | fine on easy fits, three correct digits on a degree-9 polynomial fit | test_lstsq_ill_conditioned_beats_normal_equations (mutant s01) |
| 2. leaving singular values in sweep order | low_rank keeps the wrong directions; the error is not | test_hand_example_svd, test_eckart_young_error (mutant s02) |
| 3. the rotation with the wrong sign () | the columns never become orthogonal; 64 sweeps of noise | test_random_matrices_match_numpy (mutant s03) |
| 4. stopping after one sweep | the columns are far from orthogonal: singular values of a random matrix off by 0.2 to 0.8 | test_random_matrices_match_numpy (mutant s04) |
| 5. the unbalanced split | same product, factors at different scales | test_low_rank_factors_are_balanced, test_hand_example_rank_one (mutant s06) |
| 6. dividing a zero column by its zero length | NaN in for rank-deficient matrices | test_rank_deficient_completes_u (mutant s07) |
| 7. no sign rule | factors disagree with LAPACK’s on some columns; downstream tests that fix a seed see flipped vectors | test_random_matrices_match_numpy (mutant s11) |
| 8. an absolute threshold | tiny-scaled matrices are returned unrotated | test_scale_invariance (mutant s12) |
| decomposing a wide matrix without transposing | shapes come out wrong | test_random_matrices_match_numpy (mutant s05) |
| back substitution from the top row | wrong whenever is not diagonal | test_hand_example_lstsq, test_lstsq_matches_numpy (mutant s08) |
no rank check in lstsq | division by a zero diagonal entry of : inf | test_lstsq_rejects_rank_deficient_and_wide (mutant s09) |
| rotating the caller’s array in place | the weight you decomposed is now | test_inputs_not_modified_and_validated (mutant s10) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Forward | M09.3 | Registered call site uses this module. |
| Direction | Module | How it uses this |
|---|---|---|
| Back | M03.3 | lstsq factors with qr_householder and solves |
| Back | M03.4 | singular values are the square roots of the eigenvalues of (reading) |
| Forward | L2.3 | ppmi_svd_embeddings keeps the top singular directions of the PPMI matrix as word vectors |
| Forward | L6.6 | PiSSA initializes LoRA with low_rank(W, r) and trains the residual |
| Forward | L7.6 | mha_to_mla compresses the key and value projections to a rank- latent |
| Forward | C1 | the scaling-law fit is lstsq on loss against parameters |
| Forward | M10.6 | Muon’s Newton-Schulz iteration approximates , checked against this SVD |
If you skip this module, the modules above stop with BLOCKED ... needs M03.5 once they land: build it, or pass --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
| one-sided Jacobi | LAPACK dgesvj (Drmac and Veselic) | preconditioning by QR with column pivoting, de Rijk’s pivoting, blocked rotations; Jacobi’s high relative accuracy at near-LAPACK speed | LAPACK SRC/dgesvj.f, dgejsv.f |
svd | LAPACK dgesdd (what numpy calls) | Householder bidiagonalization, then divide and conquer on the bidiagonal matrix; much faster for large matrices | numpy/linalg/_linalg.py, LAPACK SRC/dgesdd.f |
low_rank | randomized SVD (Halko, Martinsson, Tropp 2011) | the top triples of a huge sparse matrix from a few matrix products with random vectors | sklearn.utils.extmath.randomized_svd |
lstsq | numpy.linalg.lstsq (LAPACK dgelsd) | the minimum-norm solution for rank-deficient , via the SVD | numpy/linalg/_linalg.py |
low_rank for LoRA | PEFT’s PiSSA initializer | the same balanced split on GPU, then the residual frozen as the base weight | peft/tuners/lora/layer.py (pissa_init) |