Skip to content

Orthogonality, Householder QR, and orthogonal initialization

ModuleM03.3 · build · Python · Pass 2 · 3 to 4 h
You buildpython/tinyllm/linalg/qr.py: qr_householder(A) (reduced A=QRA = QR by reflections, with diag⁡(R)≥0\operatorname{diag}(R) \ge 0) and orthogonal_init(shape, gain, rng) (weights with orthonormal rows or columns, as torch.nn.init.orthogonal_ makes them)
Contractcourse/contracts/py/tinyllm/linalg/qr.pyi
Testscourse/tests/M03.3/test_qr.py (what they check: section 4)
NeedsM07.0 normal draws for the Gaussian matrix (or --ref-deps). Reading: M03.2 (elimination, the contrast)
Used byM03.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
MilestoneMS-P2 (the foundations gate)
Optional depthTrefethen 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)
  • A matrix QQ with orthonormal columns (Q⊤Q=IQ^\top Q = I) 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 H=I−2vv⊤H = I - 2vv^\top; reflections are orthogonal by construction, so QQ stays orthogonal to machine precision even when Gram-Schmidt does not (test_orthogonality_survives_ill_conditioning).
  • The reflector must send xx to −sign⁡(x0)∥x∥e1-\operatorname{sign}(x_0)\lVert x \rVert e_1; the other sign cancels catastrophically (test_cancellation_sign_choice).
  • Requiring diag⁡(R)≥0\operatorname{diag}(R) \ge 0 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).
Terminal window
ol start M03.3 # stubs python/tinyllm/linalg/qr.py into your repo
ol tests M03.3 # read the test catalog first: rung R0, you write no tests here
ol check M03.3 # exit code is the verdict
ol check M03.3 --ref-deps # only if your M07.0 is not passing yet
ol diff M03.3 # after passing: your code against the reference

In Pass 2 you start initializing networks, and the first recurrent model (L3.1) multiplies its hidden state by the same matrix WW at every one of hundreds of steps. If WW stretches some direction by 1.1, that direction grows by 1.1100≈13 7811.1^{100} \approx 13\,781; if it shrinks one by 0.9, the gradient along it falls to 0.9100≈2.7×10−50.9^{100} \approx 2.7 \times 10^{-5}. 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.

SymbolMeaningType / shape
⟨u,v⟩=u⊤v\langle u, v \rangle = u^\top vinner (dot) productscalar
∥x∥=x⊤x\lVert x \rVert = \sqrt{x^\top x}Euclidean lengthscalar
AAthe matrix to factor, mm rows and nn columnsfloat64[m, n]
k=min⁡(m,n)k = \min(m, n)the number of columns of QQ in the reduced factorizationint
QQorthonormal columns: Q⊤Q=IkQ^\top Q = I_kfloat64[m, k]
RRupper triangular, non-negative diagonalfloat64[k, n]
e1e_1(1,0,…,0)(1, 0, \dots, 0)vector
vva unit vector defining a reflectionvector
H=I−2vv⊤H = I - 2vv^\topthe Householder reflection across the hyperplane orthogonal to vvsquare matrix
α\alphathe value a reflected column keeps in its first entryscalar
ggthe gain of the initializerscalar
ε\varepsilonfloat64 unit roundoff, about 1.1×10−161.1 \times 10^{-16}

Two vectors are orthogonal when ⟨u,v⟩=0\langle u, v \rangle = 0; a set of vectors is orthonormal when every pair is orthogonal and each has length 1. A matrix QQ whose columns are orthonormal satisfies Q⊤Q=IQ^\top Q = I, because entry (i,j)(i, j) of Q⊤QQ^\top Q is ⟨qi,qj⟩\langle q_i, q_j \rangle. Then

∥Qx∥2=x⊤Q⊤Qx=x⊤x=∥x∥2,⟨Qx,Qy⟩=⟨x,y⟩:\lVert Qx \rVert^2 = x^\top Q^\top Q x = x^\top x = \lVert x \rVert^2, \qquad \langle Qx, Qy \rangle = \langle x, y \rangle :

QQ preserves lengths and angles. A square QQ with Q⊤Q=IQ^\top Q = I is an orthogonal matrix; its inverse is its transpose, and its columns and rows are both orthonormal.

Every real m×nm \times n matrix with m≥nm \ge n factors as A=QRA = QR, QQ with orthonormal columns and RR upper triangular: the first jj columns of QQ span the first jj columns of AA, and RR holds the coordinates. The textbook construction, Gram-Schmidt, subtracts from each column its projections on the previous qq‘s and normalizes. In floating point it computes the projections with rounding errors, and when two columns are nearly parallel (condition number 101010^{10}), what is left after subtracting is mostly those errors: the computed qq‘s are far from orthogonal.

Householder takes the other route: it applies orthogonal matrices to AA until it becomes triangular, Hk−1⋯H1H0 A=RH_{k-1} \cdots H_1 H_0\, A = R, so Q=H0H1⋯Hk−1Q = H_0 H_1 \cdots H_{k-1}. Each Hj=I−2vv⊤H_j = I - 2 v v^\top with ∥v∥=1\lVert v \rVert = 1 is a reflection: it maps vv to −v-v and leaves every vector orthogonal to vv alone. It is symmetric and its own inverse (H2=I−4vv⊤+4v(v⊤v)v⊤=IH^2 = I - 4vv^\top + 4v(v^\top v)v^\top = I), hence orthogonal. Since QQ is a product of exactly orthogonal matrices, rounding errors only perturb it slightly: ∥Q⊤Q−I∥\lVert Q^\top Q - I \rVert stays near ε\varepsilon whatever the conditioning of AA.

Step jj looks at $x = $ column jj of the working matrix from row jj down, and wants a reflection that maps xx to αe1\alpha e_1, zeroing everything below the diagonal. Reflections preserve length, so ∣α∣=∥x∥\lvert \alpha \rvert = \lVert x \rVert, and the reflection that swaps xx and αe1\alpha e_1 has vv along their difference:

v=x−αe1∥x−αe1∥,Hx=αe1.v = \frac{x - \alpha e_1}{\lVert x - \alpha e_1 \rVert}, \qquad H x = \alpha e_1 .

Either sign of α\alpha works in exact arithmetic. In floating point, the first entry of x−αe1x - \alpha e_1 is x0−αx_0 - \alpha: if α\alpha has the same sign as x0x_0 and xx is already almost along e1e_1, this subtracts two nearly equal numbers and leaves only rounding error. With x=(1,10−9,2×10−9)x = (1, 10^{-9}, 2 \times 10^{-9}), ∥x∥=1+2.5×10−18\lVert x \rVert = 1 + 2.5 \times 10^{-18}, which rounds to exactly 1, so x0−∥x∥=0x_0 - \lVert x \rVert = 0 and vv points the wrong way. The fix is to choose α=−sign⁡(x0)∥x∥\alpha = -\operatorname{sign}(x_0) \lVert x \rVert (with sign⁡(0)=+1\operatorname{sign}(0) = +1): then x0−α=x0+sign⁡(x0)∥x∥x_0 - \alpha = x_0 + \operatorname{sign}(x_0)\lVert x \rVert adds two numbers of the same sign and never cancels. If ∥x∥=0\lVert x \rVert = 0 the column is already zero and the step is skipped.

Applying HH never forms the m×mm \times m matrix: HB=B−2v(v⊤B)H B = B - 2 v (v^\top B) costs one product and one outer product. The reference applies it to the trailing block of RR (columns jj onward) and accumulates Q←QHQ \leftarrow Q H as Q−2(Qv)v⊤Q - 2(Qv)v^\top on columns jj onward, then writes α\alpha and exact zeros into column jj.

If A=QRA = QR, then also A=(QD)(DR)A = (QD)(DR) for any diagonal DD of ±1\pm 1‘s, since D2=ID^2 = I. Fixing diag⁡(R)≥0\operatorname{diag}(R) \ge 0 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: di=−1d_i = -1 where Rii<0R_{ii} < 0, else +1+1; then Q←Qdiag⁡(d)Q \leftarrow Q\operatorname{diag}(d) and R←diag⁡(d)RR \leftarrow \operatorname{diag}(d) R. 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 (m<nm < n) gives k=mk = m: QQ is square and RR is m×nm \times n, upper trapezoidal. A tall one gives k=nk = n.

torch.nn.init.orthogonal_ builds a weight of shape (r,c)(r, c) like this: draw GG with independent standard normal entries; if r<cr < c factor G⊤G^\top instead; take QQ from QR with the sign rule; transpose back if needed; multiply by the gain gg. The result has orthonormal columns (tall) or rows (wide), scaled by gg, so every singular value equals gg and W⊤W=g2IW^\top W = g^2 I (or WW⊤=g2IWW^\top = g^2 I).

The sign rule is what makes QQ uniformly distributed over orthogonal matrices (the Haar measure). The Gaussian distribution of GG is unchanged by any orthogonal transformation, so the distribution of QQ would be too, except that the factorization’s own sign convention couples QQ‘s signs to RR‘s. With this module’s reflector convention, R00=αR_{00} = \alpha always has the opposite sign of G00G_{00}, so without the rule Q00=G00/R00Q_{00} = G_{00}/R_{00} would be negative every single time. With the rule, Q00=G00/∥G:,0∥Q_{00} = G_{00}/\lVert G_{:,0} \rVert is positive exactly half the time (Mezzadri 2007).

Determinism: GG is normal(rng, r * c) from M07.0 reshaped in C order, so the same generator state gives the same weights in every run.

A=(3142).A = \begin{pmatrix} 3 & 1 \\ 4 & 2 \end{pmatrix}.

One reflector suffices (j=0j = 0; a 2×22 \times 2 matrix has one entry below the diagonal).

QuantityValue
xx = column 0(3,4)(3, 4), ∥x∥=5\lVert x \rVert = 5
α=−sign⁡(3)⋅5\alpha = -\operatorname{sign}(3) \cdot 5−5-5
x−αe1x - \alpha e_1(3+5,4)=(8,4)(3 + 5, 4) = (8, 4), length 80\sqrt{80}
H=I−2 (8,4)(8,4)⊤80H = I - 2\,\frac{(8, 4)(8, 4)^\top}{80}I−140(64323216)=(−0.6−0.8−0.80.6)I - \frac{1}{40}\begin{pmatrix} 64 & 32 \\ 32 & 16 \end{pmatrix} = \begin{pmatrix} -0.6 & -0.8 \\ -0.8 & 0.6 \end{pmatrix}
H⋅H \cdot column 0(−0.6⋅3−0.8⋅4, −0.8⋅3+0.6⋅4)=(−5,0)(-0.6 \cdot 3 - 0.8 \cdot 4,\ -0.8 \cdot 3 + 0.6 \cdot 4) = (-5, 0)
H⋅H \cdot column 1(−0.6−1.6, −0.8+1.2)=(−2.2,0.4)(-0.6 - 1.6,\ -0.8 + 1.2) = (-2.2, 0.4)

So R=(−5−2.200.4)R = \begin{pmatrix} -5 & -2.2 \\ 0 & 0.4 \end{pmatrix} and Q=HQ = H. The sign rule flips row 0 of RR and column 0 of QQ (only R00R_{00} is negative):

Q=(0.6−0.80.80.6),R=(52.200.4).Q = \begin{pmatrix} 0.6 & -0.8 \\ 0.8 & 0.6 \end{pmatrix}, \qquad R = \begin{pmatrix} 5 & 2.2 \\ 0 & 0.4 \end{pmatrix}.

Check: QR=(0.6⋅50.6⋅2.2−0.8⋅0.40.8⋅50.8⋅2.2+0.6⋅0.4)=(3142)QR = \begin{pmatrix} 0.6 \cdot 5 & 0.6 \cdot 2.2 - 0.8 \cdot 0.4 \\ 0.8 \cdot 5 & 0.8 \cdot 2.2 + 0.6 \cdot 0.4 \end{pmatrix} = \begin{pmatrix} 3 & 1 \\ 4 & 2 \end{pmatrix}, and the columns of QQ are orthonormal: 0.36+0.64=10.36 + 0.64 = 1, −0.48+0.48=0-0.48 + 0.48 = 0. This is test_hand_example.

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)."""
TestKINDChecksWhy it matters downstream
test_hand_exampleunit, smokesection 3’s QQ and RR to 10−1510^{-15}you and the tests agree on the reflector and the sign rule
test_random_matrices_match_numpydifferentialtall, wide, and square shapes against np.linalg.qr with the same sign rule, entry by entrythe factorization is the unique one
test_cancellation_sign_choiceboundarya first column almost along e1e_1 still gives A=QRA = QRthe reflector’s sign
test_orthogonality_survives_ill_conditioningpropertycolumns with condition number about 101010^{10}: Q⊤Q=IQ^\top Q = I to 10−1310^{-13}why Householder and not Gram-Schmidt
test_rank_deficient_and_zero_columnsboundaryzero and dependent columns: finite, orthonormal, exactsingular weight matrices, padded inputs
test_input_not_modified_and_2d_requiredboundaryAA unchanged; a vector is rejectedM03.4 keeps iterating on its matrix
test_orthogonal_init_is_orthogonal_and_scaledpropertyW⊤W=g2IW^\top W = g^2 I or WW⊤=g2IWW^\top = g^2 I for five shapesevery singular value equals the gain
test_orthogonal_init_draws_normals_in_c_orderunitequals the sign-fixed QR of normal(rng, r * c) reshaped in C orderreproducible weights from a seed
test_orthogonal_init_is_uniform_on_signsstatisticalover 400 seeds, W00>0W_{00} > 0 about half the timethe Haar distribution
test_orthogonal_init_rejects_bad_shapesboundaryzero or negative dimensions raiseconfig errors fail early
PitfallSymptomCaught by
accumulating QQ from the wrong side (HQHQ instead of QHQH)QQ is orthogonal but QR≠AQR \ne Atest_random_matrices_match_numpy (mutant s01)
α=+sign⁡(x0)∥x∥\alpha = +\operatorname{sign}(x_0)\lVert x \rVertcatastrophic cancellation when a column is nearly along e1e_1test_cancellation_sign_choice (mutant s02)
classical Gram-SchmidtQ⊤QQ^\top Q drifts far from II on ill-conditioned inputstest_orthogonality_survives_ill_conditioning (mutant s03)
normalizing a zero columndivision by zero, NaN everywheretest_rank_deficient_and_zero_columns (mutant s04)
no sign rulefactors disagree with LAPACK’s; the initializer is biased (W00<0W_{00} < 0 every time)test_orthogonal_init_is_uniform_on_signs, test_hand_example (mutant s05)
reducing the caller’s array in placethe next use of AA sees RRtest_input_not_modified_and_2d_required (mutant s06)
factoring a wide matrix without transposingrows are not orthonormaltest_orthogonal_init_draws_normals_in_c_order (mutant s07)
applying the gain twice (or squared)every weight scaled by g2g^2test_orthogonal_init_is_orthogonal_and_scaled (mutant s08)

| 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. |

DirectionModuleHow it uses this
BackM07.0normal(rng, n) draws the Gaussian matrix GG
BackM03.2elimination, the factorization this one is contrasted with (reading)
ForwardM03.4spectral_radius runs the QR algorithm, At+1=RtQtA_{t+1} = R_t Q_t, with qr_householder
ForwardL0.4orthogonal_init is one of the default initializers of Linear
ForwardL3.1, L3.2recurrent weights start orthogonal, so the hidden state neither explodes nor vanishes at step 0
ForwardM03.5least squares min⁡∥Ax−b∥\min \lVert Ax - b \rVert by solving Rx=Q⊤bRx = Q^\top b

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

Your pieceProduction equivalentWhat it addsWhere to look
qr_householderLAPACK dgeqrf + dorgqrstores reflectors compactly below RR and applies blocks of them as I−VTV⊤I - VTV^\top (the WY form) with matrix-matrix productsLAPACK SRC/dgeqrf.f, dlarft.f
explicit QQnumpy.linalg.qr(mode="reduced")returns QQ only when asked; least squares never forms itnumpy/linalg/_linalg.py
orthogonal_inittorch.nn.init.orthogonal_the same recipe on any tensor shape (flattened to 2-D)torch/nn/init.py
one QRtall-skinny QR (TSQR)QR of row blocks combined in a tree, for distributed and GPU settingsDemmel et al., “Communication-optimal parallel and sequential QR” (2012)