Skip to content

Eigenvalues, power iteration, and the spectral radius

ModuleM03.4 · build · Python · Pass 2 · 3 to 4 h
You buildpython/tinyllm/linalg/eig.py: power_iteration(matvec, n, iters, rng) (the dominant eigenpair), inverse_iteration(A, shift, iters, rng) (the eigenpair nearest a shift, one LU and many solves), spectral_radius(W, iters) (max⁡i∣λi∣\max_i \lvert\lambda_i\rvert by the QR algorithm, complex eigenvalues included)
Contractcourse/contracts/py/tinyllm/linalg/eig.pyi
Testscourse/tests/M03.4/test_eig.py (what they check: section 4)
NeedsM03.2 lu and lu_solve, M03.3 qr_householder (or --ref-deps)
Used bylater L0.5 (the training monitor reports ρ\rho of weight matrices), L3.1 (the exploding-gradient diagnostic), M10.5 (the top Hessian eigenvalue); M10.1 reads it for the condition number. No registered module calls it yet (section 6)
MilestoneMS-P2 (the foundations gate)
Optional depthTrefethen and Bau, Numerical Linear Algebra, lectures 24 to 28 (eigenvalue problems, power and inverse iteration, the QR algorithm); Strang, Introduction to Linear Algebra, ch. 6; Pascanu, Mikolov, and Bengio, “On the difficulty of training recurrent neural networks” (2013)
  • An eigenvector is a direction a matrix only stretches, Av=λvAv = \lambda v; repeated multiplication amplifies the eigenvalue of largest modulus fastest, so power iteration converges to it at the rate ∣λ2/λ1∣\lvert\lambda_2/\lambda_1\rvert per step (test_hand_example_power_iteration).
  • Return the Rayleigh quotient v⊤Avv^\top A v, not ∥Av∥\lVert Av \rVert, and renormalize every step: the first loses the sign, the second overflows (test_negative_dominant_eigenvalue_keeps_its_sign, test_renormalizes_every_step).
  • Inverse iteration runs power iteration on (A−σI)−1(A - \sigma I)^{-1}, whose dominant eigenvalue belongs to the λ\lambda nearest σ\sigma; factor A−σIA - \sigma I once with LU and solve at every step (test_inverse_iteration_factors_once).
  • A real matrix’s dominant eigenvalues can be a complex pair (a rotation), where power iteration never converges. The QR algorithm At+1=RtQtA_{t+1} = R_t Q_t exposes them as 2×22 \times 2 blocks, and the spectral radius ρ(W)=max⁡i∣λi∣\rho(W) = \max_i \lvert\lambda_i\rvert reads off the blocks (test_spectral_radius_of_a_rotation, test_spectral_radius_matches_numpy).
Terminal window
ol start M03.4 # stubs python/tinyllm/linalg/eig.py into your repo
ol tests M03.4 # read the test catalog first: rung R0, you write no tests here
ol check M03.4 # exit code is the verdict
ol check M03.4 --ref-deps # only if your M03.2 or M03.3 is not passing yet
ol diff M03.4 # after passing: your code against the reference

Training a recurrent network (L3.1) multiplies the hidden state by the same weight matrix WW at every step, and backpropagation multiplies the gradient by W⊤W^\top the same number of times. Whether that product grows without bound or fades to nothing over 200 steps is decided by one number, the spectral radius ρ(W)\rho(W): above 1 the gradients explode, below 1 they vanish. Your training monitor (L0.5) will print it for every weight matrix, and M10.5 uses the same machinery to find the largest curvature of the loss, which bounds the learning rate. numpy.linalg.eigvals would answer, but you are building the system yourself, and the method is simple: multiply, normalize, repeat. This module builds it, sees where it fails (rotations), and fixes that failure with the QR factorization from M03.3.

SymbolMeaningType / shape
A,WA, Wa real square matrixfloat64[n, n]
λi,vi\lambda_i, v_ieigenvalues and eigenvectors: Avi=λiviA v_i = \lambda_i v_i, vi≠0v_i \ne 0complex scalar, vector
∣λ1∣>∣λ2∣≥…\lvert\lambda_1\rvert > \lvert\lambda_2\rvert \ge \dotseigenvalues ordered by modulus
ρ(A)=max⁡i∣λi∣\rho(A) = \max_i \lvert\lambda_i\rvertthe spectral radiusscalar
vtv_tthe unit vector after tt steps of an iterationfloat64[n]
r(v)=v⊤Avr(v) = v^\top A vthe Rayleigh quotient of a unit vector vvscalar
σ\sigmaa shift: a guess near the wanted eigenvaluescalar
At=QtRtA_t = Q_t R_tthe QR factorization at step tt of the QR algorithm
iithe imaginary unit, i2=−1i^2 = -1

A non-zero vector vv is an eigenvector of AA with eigenvalue λ\lambda when Av=λvAv = \lambda v: AA maps vv onto its own line. The eigenvalues are the roots of the characteristic polynomial det⁡(A−λI)=0\det(A - \lambda I) = 0, a polynomial of degree nn, so there are nn of them counted with multiplicity, possibly complex (a real polynomial’s complex roots come in conjugate pairs a±bia \pm bi). For a 2×22 \times 2 matrix (abcd)\begin{pmatrix} a & b \\ c & d \end{pmatrix} the polynomial is λ2−(a+d)λ+(ad−bc)\lambda^2 - (a + d)\lambda + (ad - bc): trace and determinant, solved by the quadratic formula. A symmetric matrix has only real eigenvalues and an orthonormal basis of eigenvectors.

Write a start vector in the eigenvector basis, v0=∑iciviv_0 = \sum_i c_i v_i. Then

Atv0=∑iciλitvi=λ1t(c1v1+∑i≥2ci(λiλ1)tvi).A^t v_0 = \sum_i c_i \lambda_i^t v_i = \lambda_1^t \Big( c_1 v_1 + \sum_{i \ge 2} c_i \big(\tfrac{\lambda_i}{\lambda_1}\big)^t v_i \Big).

If ∣λ1∣>∣λ2∣\lvert\lambda_1\rvert > \lvert\lambda_2\rvert and c1≠0c_1 \ne 0, every other term shrinks like ∣λ2/λ1∣t\lvert\lambda_2/\lambda_1\rvert^t, so the direction of Atv0A^t v_0 converges to v1v_1. Three practical rules follow.

  1. Normalize every step, vt+1=Avt/∥Avt∥v_{t+1} = Av_t / \lVert Av_t \rVert. The direction is all that matters, and λ1t\lambda_1^t overflows: 1040010^{400} is inf in float64, and inf/inf is NaN.
  2. Start at random. A start with c1=0c_1 = 0 never finds v1v_1 (in exact arithmetic): e1e_1 is exactly such a start for diag⁡(1,3)\operatorname{diag}(1, 3). Random entries make c1=0c_1 = 0 an event of probability zero. The contract draws v0v_0 from the caller’s generator, 2u−12u - 1 for nn uniforms, so runs are reproducible.
  3. Report the Rayleigh quotient r(v)=v⊤Avr(v) = v^\top A v. For an exact eigenvector it equals λ\lambda, with its sign; ∥Av∥\lVert Av \rVert equals ∣λ∣\lvert\lambda\rvert and turns −5-5 into 55. For a symmetric matrix its error is the square of the direction’s error, so it converges twice as fast.

If some AvtAv_t is exactly zero, vtv_t lies in the null space: the eigenvalue is 0, and dividing would produce NaN, so return 0.

The matrix (A−σI)−1(A - \sigma I)^{-1} has the same eigenvectors as AA, with eigenvalues 1/(λi−σ)1/(\lambda_i - \sigma). The largest of those belongs to the λi\lambda_i nearest σ\sigma, so power iteration on (A−σI)−1(A - \sigma I)^{-1} finds it, at the rate ∣λnear−σ∣/∣λnext−σ∣\lvert\lambda_{\text{near}} - \sigma\rvert / \lvert\lambda_{\text{next}} - \sigma\rvert per step: very fast for a good shift. Each step needs w=(A−σI)−1vw = (A - \sigma I)^{-1} v, that is, a solve with A−σIA - \sigma I. Never form the inverse, and never refactor: lu once (23n3\frac{2}{3}n^3 operations, M03.2), then lu_solve per step (2n22n^2). The answer reported is v⊤Avv^\top A v, an eigenvalue of AA itself. If σ\sigma is exactly an eigenvalue, A−σIA - \sigma I is singular and there is nothing to solve with: raise.

The rotation W=(0−220)W = \begin{pmatrix} 0 & -2 \\ 2 & 0 \end{pmatrix} has trace 0 and determinant 4, so λ2+4=0\lambda^2 + 4 = 0 and λ=±2i\lambda = \pm 2i: equal moduli, no real eigenvector. Power iteration sends (1,0)(1, 0) to (0,2)(0, 2), then (−4,0)(-4, 0), then (0,−8)(0, -8): the direction turns by 90°90° every step and never settles. Recurrent weight matrices are full of such rotations.

The QR algorithm handles them. Start from A0=WA_0 = W and repeat

At=QtRt (QR factorization, M03.3),At+1=RtQt.A_t = Q_t R_t \ \text{(QR factorization, M03.3)}, \qquad A_{t+1} = R_t Q_t .

Since Rt=Qt⊤AtR_t = Q_t^\top A_t, At+1=Qt⊤AtQtA_{t+1} = Q_t^\top A_t Q_t: a similarity transform, which never changes the eigenvalues. It is power iteration on all nn directions at once, kept orthonormal by the QR step: the first column follows λ1\lambda_1, the first two span the dominant 2-dimensional invariant subspace, and so on. When the moduli separate, AtA_t converges to block upper triangular form (the real Schur form): entry (j+1,j)(j+1, j) decays like ∣λj+1/λj∣t\lvert\lambda_{j+1}/\lambda_j\rvert^t, and what remains on the diagonal are 1×11 \times 1 blocks (real eigenvalues) and 2×22 \times 2 blocks (complex pairs, whose subdiagonal never vanishes). The spectral radius is the largest modulus over the blocks: ∣a∣\lvert a \rvert for a 1×11 \times 1 block, the quadratic formula of 2.1 for a 2×22 \times 2 one. spectral_radius treats a subdiagonal entry below 10−1210^{-12} times the largest entry as zero.

2.5 Why the spectral radius decides explosion

Section titled “2.5 Why the spectral radius decides explosion”

For any vector norm, ∥Wt∥1/t→ρ(W)\lVert W^t \rVert^{1/t} \to \rho(W) as t→∞t \to \infty (Gelfand’s formula), so ∥Wth∥\lVert W^t h \rVert grows like ρt\rho^t when ρ>1\rho > 1 and shrinks like ρt\rho^t when ρ<1\rho < 1, up to factors polynomial in tt. Backpropagation through tt steps of ht=Wht−1h_t = W h_{t-1} multiplies by (W⊤)t(W^\top)^t, which has the same spectral radius. Note ρ(W)≤∥W∥2\rho(W) \le \lVert W \rVert_2, with equality for symmetric (and orthogonal) WW: an orthogonal initialization (M03.3) starts every recurrent weight at exactly ρ=1\rho = 1.

Power iteration on A=(2112)A = \begin{pmatrix} 2 & 1 \\ 1 & 2 \end{pmatrix}, with eigenvalues 3 (eigenvector (1,1)(1, 1)) and 1 (eigenvector (1,−1)(1, -1)). Take the start (1,0)=12(1,1)+12(1,−1)(1, 0) = \tfrac12 (1, 1) + \tfrac12 (1, -1) (the tests draw a random one) and skip the normalization to keep integers:

ttAtv0=123t(1,1)+12(1,−1)A^t v_0 = \tfrac12 3^t (1, 1) + \tfrac12 (1, -1)Rayleigh quotient x⊤Axx⊤x\dfrac{x^\top A x}{x^\top x}
0(1,0)(1, 0)2/1=22/1 = 2
1(2,1)(2, 1)(2⋅5+1⋅4)/5=14/5=2.8(2 \cdot 5 + 1 \cdot 4)/5 = 14/5 = 2.8
2(5,4)(5, 4)(5⋅14+4⋅13)/41=122/41≈2.9756(5 \cdot 14 + 4 \cdot 13)/41 = 122/41 \approx 2.9756
3(14,13)(14, 13)(14⋅41+13⋅40)/365=1094/365≈2.99726(14 \cdot 41 + 13 \cdot 40)/365 = 1094/365 \approx 2.99726

The wrong component stays 12(1,−1)\tfrac12(1, -1) while the right one triples, so the angle error shrinks by 1/31/3 per step, and the Rayleigh quotient’s error by 1/91/9: 3−2.8=0.23 - 2.8 = 0.2, 3−2.9756=0.02443 - 2.9756 = 0.0244, 3−2.99726=0.00273 - 2.99726 = 0.0027. After 60 steps both are exact to rounding: test_hand_example_power_iteration checks λ=3\lambda = 3 to 10−1210^{-12} and ∣v∣=(2−1/2,2−1/2)\lvert v \rvert = (2^{-1/2}, 2^{-1/2}).

The spectral radius of a scaled rotation, W=1.5(cos⁡0.7−sin⁡0.7sin⁡0.7cos⁡0.7)W = 1.5 \begin{pmatrix} \cos 0.7 & -\sin 0.7 \\ \sin 0.7 & \cos 0.7 \end{pmatrix} (test_spectral_radius_of_a_rotation). Its QR factorization is Q=Q = the rotation itself, R=1.5IR = 1.5 I, so A1=RQ=WA_1 = RQ = W: the QR algorithm leaves it unchanged, and the 2×22 \times 2 block never becomes triangular. That is the expected outcome for a complex pair. The block’s trace is 3cos⁡0.73\cos 0.7 and its determinant 2.252.25, so

λ=1.5cos⁡0.7±2.25cos⁡20.7−2.25=1.5(cos⁡0.7±isin⁡0.7),∣λ∣=1.5.\lambda = 1.5\cos 0.7 \pm \sqrt{2.25\cos^2 0.7 - 2.25} = 1.5(\cos 0.7 \pm i \sin 0.7), \qquad \lvert\lambda\rvert = 1.5 .

def power_iteration(matvec, n: int, iters: int, rng) -> tuple[float, NDArray]:
"""v0 = n draws of 2 * rng.uniform() - 1, normalized; iters steps of v = Av / ||Av||;
returns (v . A v, v). (0.0, v) if some Av is exactly 0."""
def inverse_iteration(A, shift: float, iters: int, rng) -> tuple[float, NDArray]:
"""power_iteration on (A - shift I)^-1 via one lu and a lu_solve per step;
returns (v . A v, v). ValueError if A - shift I is singular."""
def spectral_radius(W, iters: int = 100) -> float:
"""max |lambda| by the unshifted QR algorithm with qr_householder; 0.0 for 0 x 0."""

power_iteration takes a function, not a matrix, so M10.5 can pass a Hessian-vector product without ever forming the Hessian.

TestKINDChecksWhy it matters downstream
test_hand_example_power_iterationunit, smokesection 3: λ=3\lambda = 3, v=±(1,1)/2v = \pm(1, 1)/\sqrt 2you and the tests agree on the method
test_negative_dominant_eigenvalue_keeps_its_signboundarydiag⁡(−5,1,2)\operatorname{diag}(-5, 1, 2) gives −5-5M10.5 tells a maximum from a saddle by the sign
test_random_start_finds_the_dominant_directionboundarydiag⁡(1,3)\operatorname{diag}(1, 3) gives 3, not 1a fixed start can be orthogonal to v1v_1
test_start_vector_draws_from_rng_in_orderunitwith 0 steps, vv is the normalized 2u−12u - 1 drawsreproducible from the seed (P11)
test_renormalizes_every_stepboundaryλ=10\lambda = 10 over 400 steps stays finitelong runs
test_symmetric_matrices_match_numpydifferential20 symmetric matrices, dominant ±2\pm 2 against 1.51.5, versus np.linalg.eighthe sign and the vector, at a slow ratio
test_zero_map_returns_zeroboundaryAv=0Av = 0 gives 0 without NaN; bad n and iters raiserank-deficient weights
test_inverse_iteration_finds_the_eigenvalue_nearest_the_shiftunitspectrum {1,3,7}\{1, 3, 7\}: shift 2.9 finds 3, shift 0 finds 1; an exact eigenvalue as the shift raisesthe smallest Hessian eigenvalue in M10.5
test_inverse_iteration_factors_onceunitlu is called exactly once for 25 stepsthe point of a factorization
test_spectral_radius_of_a_rotationunit, smokesection 3: ρ=1.5\rho = 1.5 for a scaled rotationrecurrent weights rotate
test_spectral_radius_matches_numpydifferentialfive non-symmetric matrices with real, negative, and complex dominant eigenvalues, versus np.linalg.eigvalsthe L3.1 diagnostic on realistic matrices
test_spectral_radius_edge_casesboundary1×11 \times 1, 0×00 \times 0, a nilpotent matrix (all eigenvalues 0), non-square input
test_input_not_modifiedboundarythe caller’s WW is unchangedthe monitor reads live weights
PitfallSymptomCaught by
returning ∥Av∥\lVert Av \rVert instead of v⊤Avv^\top A v+5+5 reported for eigenvalue −5-5test_negative_dominant_eigenvalue_keeps_its_sign (mutant s01)
a fixed start such as e1e_1the wrong eigenvalue whenever e1e_1 is orthogonal to v1v_1test_random_start_finds_the_dominant_direction (mutant s02)
normalizing only at the endinf, then NaN, after a few hundred stepstest_renormalizes_every_step (mutant s03)
dividing by ∥Av∥=0\lVert Av \rVert = 0NaN for a vector in the null spacetest_zero_map_returns_zero (mutant s04)
iterating with A−σIA - \sigma I instead of its inversefinds the eigenvalue farthest from σ\sigmatest_inverse_iteration_finds_the_eigenvalue_nearest_the_shift (mutant s05)
refactoring at every stepcorrect but iters times slowertest_inverse_iteration_factors_once (mutant s06)
spectral radius by power iterationnever converges on rotations; wrong ρ\rho for complex pairstest_spectral_radius_of_a_rotation (mutant s07)
reading only the diagonal after the QR algorithmcomplex pairs reported as their real partstest_spectral_radius_matches_numpy (mutant s08)
iterating in the caller’s arraythe weights being monitored changetest_input_not_modified (mutant s09)
DirectionModuleHow it uses this
BackM03.2lu once and lu_solve per step in inverse_iteration
BackM03.3qr_householder drives the QR algorithm in spectral_radius
ForwardL0.5the training monitor logs ρ\rho of each weight matrix
ForwardL3.1the exploding-gradient diagnostic: gradient norms grow when ρ(Whh)>1\rho(W_{hh}) > 1
ForwardM10.5the top Hessian eigenvalue by power_iteration on Hessian-vector products; the edge of stability 2/λmax⁡2/\lambda_{\max}
ForwardM10.1the condition number κ=L/μ\kappa = L/\mu of a quadratic is λmax⁡/λmin⁡\lambda_{\max}/\lambda_{\min} (reading)

None of the forward modules is in the registry with M03.4 in its deps yet, so ol verify course M03.4 reports no call site until one lands (course/DEVIATIONS.md, M034-02).

Your pieceProduction equivalentWhat it addsWhere to look
unshifted QR algorithmLAPACK dhseqr (via numpy.linalg.eigvals)reduction to Hessenberg form first (O(n2)O(n^2) per step instead of O(n3)O(n^3)), Wilkinson and Francis double shifts for quadratic convergence, deflation, aggressive early deflationLAPACK SRC/dhseqr.f, dlahqr.f
power iteration on a matvecLanczos and Arnoldi (ARPACK, scipy.sparse.linalg.eigsh)keep every iterate and extract eigenvalues from the whole Krylov subspace: many eigenpairs, far fewer productsscipy/sparse/linalg/_eigen/arpack/
Hessian top eigenvalue (M10.5)PyHessianpower iteration and Lanczos on Hessian-vector products of a real networkYao et al., “PyHessian” (2020)
spectral radius monitoringspectral normalization in GANsone power-iteration step per training step to keep ∥W∥2≤1\lVert W \rVert_2 \le 1torch.nn.utils.parametrizations.spectral_norm