Orthogonality, Householder QR, and orthogonal initialization
Overview
Section titled “Overview”| Module | M03.3 · build · Python · Pass 2 · 3 to 4 h |
| You build | python/tinyllm/linalg/qr.py: qr_householder(A) (reduced by reflections, with ) and orthogonal_init(shape, gain, rng) (weights with orthonormal rows or columns, as torch.nn.init.orthogonal_ makes them) |
| Contract | course/contracts/py/tinyllm/linalg/qr.pyi |
| Tests | course/tests/M03.3/test_qr.py (what they check: section 4) |
| Needs | M07.0 normal draws for the Gaussian matrix (or --ref-deps). Reading: M03.2 (elimination, the contrast) |
| Used by | M03.4 the QR algorithm for the spectral radius · later L0.4 default initializers, L3.1 and L3.2 recurrent weight init, M03.5 least squares · later: L3.3, L3.6 |
| Milestone | MS-P2 (the foundations gate) |
| Optional depth | Trefethen and Bau, Numerical Linear Algebra, lectures 7 to 10 (QR, Gram-Schmidt, Householder); Saxe, McClelland, and Ganguli, “Exact solutions to the nonlinear dynamics of learning in deep linear neural networks” (2014); Mezzadri, “How to generate random matrices from the classical compact groups” (2007) |
Key Takeaways
Section titled “Key Takeaways”- A matrix with orthonormal columns () preserves lengths and angles; multiplying by it can neither blow up nor shrink a signal (
test_orthogonal_init_is_orthogonal_and_scaled). - Householder QR zeroes each column below the diagonal with a reflection ; reflections are orthogonal by construction, so stays orthogonal to machine precision even when Gram-Schmidt does not (
test_orthogonality_survives_ill_conditioning). - The reflector must send to ; the other sign cancels catastrophically (
test_cancellation_sign_choice). - Requiring makes the factorization unique, so it can be compared with LAPACK entry by entry (
test_random_matrices_match_numpy), and makes orthogonal initialization uniformly distributed (test_orthogonal_init_is_uniform_on_signs).
How to work this chapter
Section titled “How to work this chapter”ol start M03.3 # stubs python/tinyllm/linalg/qr.py into your repool tests M03.3 # read the test catalog first: rung R0, you write no tests hereol check M03.3 # exit code is the verdictol check M03.3 --ref-deps # only if your M07.0 is not passing yetol diff M03.3 # after passing: your code against the reference1. Why now
Section titled “1. Why now”In Pass 2 you start initializing networks, and the first recurrent model (L3.1) multiplies its hidden state by the same matrix at every one of hundreds of steps. If stretches some direction by 1.1, that direction grows by ; if it shrinks one by 0.9, the gradient along it falls to . A matrix that stretches nothing and shrinks nothing is an orthogonal one, and the standard way to produce a random one is the QR factorization of a Gaussian matrix. QR is also the engine of the next module: M03.4 finds every eigenvalue’s size, including the complex ones that power iteration cannot see, by repeating QR. This module builds QR with Householder reflections, the version that stays accurate on the nearly dependent columns real weight matrices have.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| inner (dot) product | scalar | |
| Euclidean length | scalar | |
| the matrix to factor, rows and columns | float64[m, n] | |
| the number of columns of in the reduced factorization | int | |
| orthonormal columns: | float64[m, k] | |
| upper triangular, non-negative diagonal | float64[k, n] | |
| vector | ||
| a unit vector defining a reflection | vector | |
| the Householder reflection across the hyperplane orthogonal to | square matrix | |
| the value a reflected column keeps in its first entry | scalar | |
| the gain of the initializer | scalar | |
| float64 unit roundoff, about |
2.1 Orthogonality
Section titled “2.1 Orthogonality”Two vectors are orthogonal when ; a set of vectors is orthonormal when every pair is orthogonal and each has length 1. A matrix whose columns are orthonormal satisfies , because entry of is . Then
preserves lengths and angles. A square with is an orthogonal matrix; its inverse is its transpose, and its columns and rows are both orthonormal.
2.2 QR and why reflections
Section titled “2.2 QR and why reflections”Every real matrix with factors as , with orthonormal columns and upper triangular: the first columns of span the first columns of , and holds the coordinates. The textbook construction, Gram-Schmidt, subtracts from each column its projections on the previous ‘s and normalizes. In floating point it computes the projections with rounding errors, and when two columns are nearly parallel (condition number ), what is left after subtracting is mostly those errors: the computed ‘s are far from orthogonal.
Householder takes the other route: it applies orthogonal matrices to until it becomes triangular, , so . Each with is a reflection: it maps to and leaves every vector orthogonal to alone. It is symmetric and its own inverse (), hence orthogonal. Since is a product of exactly orthogonal matrices, rounding errors only perturb it slightly: stays near whatever the conditioning of .
2.3 Choosing the reflector
Section titled “2.3 Choosing the reflector”Step looks at $x = $ column of the working matrix from row down, and wants a reflection that maps to , zeroing everything below the diagonal. Reflections preserve length, so , and the reflection that swaps and has along their difference:
Either sign of works in exact arithmetic. In floating point, the first entry of is : if has the same sign as and is already almost along , this subtracts two nearly equal numbers and leaves only rounding error. With , , which rounds to exactly 1, so and points the wrong way. The fix is to choose (with ): then adds two numbers of the same sign and never cancels. If the column is already zero and the step is skipped.
Applying never forms the matrix: costs one product and one outer product. The reference applies it to the trailing block of (columns onward) and accumulates as on columns onward, then writes and exact zeros into column .
2.4 The sign rule and uniqueness
Section titled “2.4 The sign rule and uniqueness”If , then also for any diagonal of ‘s, since . Fixing removes that freedom: for a matrix of full column rank the reduced factorization with a positive diagonal is unique. The contract applies the rule at the end: where , else ; then and . LAPACK (np.linalg.qr) uses its own signs, so the tests apply the same rule to numpy’s answer before comparing entries.
A wide matrix () gives : is square and is , upper trapezoidal. A tall one gives .
2.5 Orthogonal initialization
Section titled “2.5 Orthogonal initialization”torch.nn.init.orthogonal_ builds a weight of shape like this: draw with independent standard normal entries; if factor instead; take from QR with the sign rule; transpose back if needed; multiply by the gain . The result has orthonormal columns (tall) or rows (wide), scaled by , so every singular value equals and (or ).
The sign rule is what makes uniformly distributed over orthogonal matrices (the Haar measure). The Gaussian distribution of is unchanged by any orthogonal transformation, so the distribution of would be too, except that the factorization’s own sign convention couples ‘s signs to ‘s. With this module’s reflector convention, always has the opposite sign of , so without the rule would be negative every single time. With the rule, is positive exactly half the time (Mezzadri 2007).
Determinism: is normal(rng, r * c) from M07.0 reshaped in C order, so the same generator state gives the same weights in every run.
3. Worked example by hand
Section titled “3. Worked example by hand”
One reflector suffices (; a matrix has one entry below the diagonal).
| Quantity | Value |
|---|---|
| = column 0 | , |
| , length | |
| column 0 | |
| column 1 |
So and . The sign rule flips row 0 of and column 0 of (only is negative):
Check: , and the columns of are orthonormal: , . This is test_hand_example.
4. The interface
Section titled “4. The interface”def qr_householder(A: ArrayLike) -> tuple[NDArray, NDArray]: """Reduced QR, k = min(m, n): Q [m, k] with Q^T Q = I, R [k, n] upper triangular with diag(R) >= 0, A == Q @ R. A is not modified."""
def orthogonal_init(shape: tuple[int, int], gain: float, rng) -> NDArray: """gain * (orthonormal rows or columns), from the QR of normal(rng, rows * cols)."""What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example | unit, smoke | section 3’s and to | you and the tests agree on the reflector and the sign rule |
test_random_matrices_match_numpy | differential | tall, wide, and square shapes against np.linalg.qr with the same sign rule, entry by entry | the factorization is the unique one |
test_cancellation_sign_choice | boundary | a first column almost along still gives | the reflector’s sign |
test_orthogonality_survives_ill_conditioning | property | columns with condition number about : to | why Householder and not Gram-Schmidt |
test_rank_deficient_and_zero_columns | boundary | zero and dependent columns: finite, orthonormal, exact | singular weight matrices, padded inputs |
test_input_not_modified_and_2d_required | boundary | unchanged; a vector is rejected | M03.4 keeps iterating on its matrix |
test_orthogonal_init_is_orthogonal_and_scaled | property | or for five shapes | every singular value equals the gain |
test_orthogonal_init_draws_normals_in_c_order | unit | equals the sign-fixed QR of normal(rng, r * c) reshaped in C order | reproducible weights from a seed |
test_orthogonal_init_is_uniform_on_signs | statistical | over 400 seeds, about half the time | the Haar distribution |
test_orthogonal_init_rejects_bad_shapes | boundary | zero or negative dimensions raise | config errors fail early |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| accumulating from the wrong side ( instead of ) | is orthogonal but | test_random_matrices_match_numpy (mutant s01) |
| catastrophic cancellation when a column is nearly along | test_cancellation_sign_choice (mutant s02) | |
| classical Gram-Schmidt | drifts far from on ill-conditioned inputs | test_orthogonality_survives_ill_conditioning (mutant s03) |
| normalizing a zero column | division by zero, NaN everywhere | test_rank_deficient_and_zero_columns (mutant s04) |
| no sign rule | factors disagree with LAPACK’s; the initializer is biased ( every time) | test_orthogonal_init_is_uniform_on_signs, test_hand_example (mutant s05) |
| reducing the caller’s array in place | the next use of sees | test_input_not_modified_and_2d_required (mutant s06) |
| factoring a wide matrix without transposing | rows are not orthonormal | test_orthogonal_init_draws_normals_in_c_order (mutant s07) |
| applying the gain twice (or squared) | every weight scaled by | test_orthogonal_init_is_orthogonal_and_scaled (mutant s08) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Forward | L3.2 | Registered call site uses this module. |
| Forward | L3.3 | Registered call site uses this module. |
| Forward | L3.6 | Registered call site uses this module. |
| Direction | Module | How it uses this |
|---|---|---|
| Back | M07.0 | normal(rng, n) draws the Gaussian matrix |
| Back | M03.2 | elimination, the factorization this one is contrasted with (reading) |
| Forward | M03.4 | spectral_radius runs the QR algorithm, , with qr_householder |
| Forward | L0.4 | orthogonal_init is one of the default initializers of Linear |
| Forward | L3.1, L3.2 | recurrent weights start orthogonal, so the hidden state neither explodes nor vanishes at step 0 |
| Forward | M03.5 | least squares by solving |
If you skip this module, ol check M03.4 stops with BLOCKED ... needs M03.3: build it, or pass --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
qr_householder | LAPACK dgeqrf + dorgqr | stores reflectors compactly below and applies blocks of them as (the WY form) with matrix-matrix products | LAPACK SRC/dgeqrf.f, dlarft.f |
| explicit | numpy.linalg.qr(mode="reduced") | returns only when asked; least squares never forms it | numpy/linalg/_linalg.py |
orthogonal_init | torch.nn.init.orthogonal_ | the same recipe on any tensor shape (flattened to 2-D) | torch/nn/init.py |
| one QR | tall-skinny QR (TSQR) | QR of row blocks combined in a tree, for distributed and GPU settings | Demmel et al., “Communication-optimal parallel and sequential QR” (2012) |