~/blog

Eigenvalue Computation: From Math Derivation to Native C++

Aug 23, 202617 min readBy Mohammed Vasim
Linear AlgebraAlgorithmsC++LAPACKNumPy

The Algebraic Illusion

Every engineer learns eigenvalue computation the exact same way in introductory linear algebra: write down the matrix , construct the characteristic polynomial by computing , factor the polynomial to find its roots , and solve the resulting homogeneous system for the null space basis vectors.

On paper, this method feels satisfying and complete. In production software, it is an absolute disaster.

The breakdown happens for two distinct reasons: algebraic impossibility and catastrophic numerical instability. First, by the Abel-Ruffini theorem, there is no general algebraic closed-form solution for polynomial roots of degree . Second, even for modest matrices, computing eigenvalues by finding the roots of an expanded characteristic polynomial is notoriously ill-conditioned. A classic example is Wilkinson's polynomial: shifting a single coefficient in a 20th-degree polynomial by as little as can shift its roots from distinct integers into widely scattered complex numbers.

Numerical linear algebra solves eigensystems by discarding root-finding entirely. Instead of expanding polynomials, production solvers rely on iterative orthogonal similarity transformations that preserve the spectrum while progressively driving off-diagonal elements toward zero.

Understanding how we transition from pencil-and-paper derivations to production-grade LAPACK routines and SIMD-vectorized C++ is essential for building fast, stable systems in robotics, graphics, computer vision, and machine learning.

Arbitrary Vector: Rotation & Scaling Eigenvector: Pure Directional Scaling x Ax (Rotated & Stretched) θ ≠ 0 v Av = λv (Same Axis, Scaled by λ)

Spectral Mechanics and Mathematical Invariants

Let denote a real square matrix representing a linear transformation on the vector space . A non-zero vector is an eigenvector of associated with eigenvalue if and only if:

A non-trivial solution exists if and only if the linear operator is singular, which requires its determinant to vanish:

The characteristic polynomial is an -th degree monic polynomial in . By the Fundamental Theorem of Algebra, has exactly complex roots (counting algebraic multiplicity , which is the multiplicity of as a root of ).

Two fundamental algebraic invariants tie the operator directly to its spectrum:

  1. Trace Invariant: The sum of all diagonal elements equals the sum of all eigenvalues:

  2. Determinant Invariant: The determinant equals the product of all eigenvalues:

Structural Matrix Taxonomy

The matrix structure determines the spectral nature and whether the transformation can be decoupled into orthogonal principal components:

Loading diagram…
  • Real Symmetric Matrices (): The spectral theorem guarantees that all eigenvalues are strictly real. Eigenvectors corresponding to distinct eigenvalues are mutually orthogonal. Consequently, admits an orthogonal diagonalization , where is an orthogonal matrix () whose columns are normalized eigenvectors, and .
  • Real Non-Symmetric Matrices (): Eigenvalues can appear as complex conjugate pairs , accompanied by complex conjugate eigenvector pairs .
  • Defective Matrices: A matrix is defective if it possesses an eigenvalue whose geometric multiplicity is strictly less than its algebraic multiplicity : Defective matrices lack a complete basis of linearly independent eigenvectors, making them non-diagonalizable. Their canonical reduction requires the Jordan normal form with non-trivial Jordan blocks.

Step-by-Step Analytical Derivations

Working through three classical matrix topologies demonstrates how real distinct, defective, and complex spectra behave when evaluated analytically.

1. Distinct Real Spectrum ()

Consider the non-symmetric matrix:

Formulating the characteristic equation:

Factoring gives , yielding two distinct real eigenvalues: and .

To compute the eigenspace for :

Choosing gives the basis vector . Normalizing under the Euclidean norm :

To compute the eigenspace for :

Choosing gives . Normalizing under :

Checking trace and determinant invariants:


2. Defective Operator with Repeated Roots ()

Consider the matrix:

Evaluating the characteristic equation :

The spectrum consists of with algebraic multiplicity , and with algebraic multiplicity .

For :

From row 2, . Substituting into row 3 gives . Setting yields the eigenvector basis vector .

For the repeated root :

Row 2 forces . Substituting into row 1 and row 3 leaves .

Setting gives .

Because there is only one free variable, the geometric multiplicity is . Since , matrix is defective. No invertible matrix exists such that is purely diagonal; can only be reduced to a Jordan form containing a Jordan block.


3. Planar Rotation Matrix with Complex Conjugate Spectrum

Consider the standard planar rotation matrix:

The characteristic equation:

For :

Setting yields the unnormalized vector . Normalizing with the Hermitian inner product :

For , taking the complex conjugate gives:

Because rotation preserves vector lengths while continuously changing their directions, no real vector can maintain its direction under . The eigenvectors exist strictly in the complexified vector space .


Numerical Algorithms for Dense Eigensystems

Because characteristic polynomial root-finding fails for general matrices, practical algorithms rely on iterative transformations.

Power Iteration: Isolating the Dominant Mode

Power iteration finds the largest eigenvalue in absolute value () and its corresponding eigenvector.

Loading diagram…

Starting with an initial vector with non-zero component along the dominant eigenvector , the recurrence relation applies repeatedly:

As , the sequence aligns with the dominant eigenvector . The dominant eigenvalue is then extracted using the Rayleigh quotient:

The asymptotic error at iteration scales as:

When the spectral gap is narrow (), Power Iteration converges very slowly. In such scenarios, acceleration methods like Rayleigh Quotient Iteration or spectral shifts are required.


The QR Algorithm: Extracting the Full Spectrum

The QR algorithm extracts the entire spectrum of a matrix by executing iterative orthogonal similarity transformations.

Matrix A_k Dense Operator QR Factor Q_k · R_k Orthogonal · Upper Tri Reverse Mult A_{k+1} = R_k · Q_k Similar to A_k Iterate until off-diagonals vanish Schur Form

At step :

  1. Decompose the current matrix into an orthogonal matrix and an upper triangular matrix :
  2. Recombine them in reverse order to form :

Substituting into the second equation:

Because is an orthogonal similarity transformation of , both matrices have identical eigenvalues across all iterations. As , converges to an upper triangular Schur matrix (or quasi-triangular real Schur form with blocks for complex conjugate pairs), where the eigenvalues sit directly on the main diagonal.

In production numerical libraries (LAPACK, Eigen3), this raw QR loop is structured into an optimized pipeline:

Loading diagram…

Code Implementations Across Four Software Paradigms

Let us implement and compare eigensystem routines across four different software layers: symbolic CAS, pure NumPy algorithms, production SciPy/LAPACK, and native SIMD-accelerated C++ with Eigen3.

1. Exact Symbolic Computation via SymPy

SymPy provides exact algebraic derivations without floating-point rounding errors:

python
import sympy as sp

# Define the 3x3 matrix from our analytical derivation
A = sp.Matrix([
    [ 1, 2, 2],
    [ 0, 2, 1],
    [-1, 2, 2]
])

lam = sp.Symbol('lambda')

# 1. Compute characteristic polynomial symbolically
char_poly = A.charpoly(lam)
print("Characteristic Polynomial:", char_poly.as_expr())

# 2. Extract exact eigenvalues and algebraic multiplicities
evals = A.eigenvals()
print("Eigenvalues (Value: Multiplicity):", evals)

# 3. Extract exact eigenspaces (value, multiplicity, [basis vectors])
evecs = A.eigenvects()
for val, mult, basis in evecs:
    print(f"Eigenvalue {val} (Alg Multiplicity: {mult}, Geo Multiplicity: {len(basis)}):")
    for vec in basis:
        print("  Basis Vector:", vec.T)

Running this script outputs:

text
Characteristic Polynomial: lambda**3 - 5*lambda**2 + 8*lambda - 4
Eigenvalues (Value: Multiplicity): {1: 1, 2: 2}
Eigenvalue 1 (Alg Multiplicity: 1, Geo Multiplicity: 1):
  Basis Vector: Matrix([[0, -1, 1]])
Eigenvalue 2 (Alg Multiplicity: 2, Geo Multiplicity: 1):
  Basis Vector: Matrix([[2, 1, 0]])

The output confirms our manual derivation: has algebraic multiplicity 2 but only a 1-dimensional eigenspace, proving defectivity.


2. Custom Iterative Solvers in NumPy

The following script implements basic Power Iteration and the QR Algorithm directly from scratch:

python
import numpy as np

def power_iteration(A: np.ndarray, num_iterations: int = 100) -> tuple[float, np.ndarray]:
    np.random.seed(42)
    b_k = np.random.rand(A.shape[1])
    b_k = b_k / np.linalg.norm(b_k)
    
    for _ in range(num_iterations):
        b_k1 = np.dot(A, b_k)
        norm = np.linalg.norm(b_k1)
        if norm == 0:
            break
        b_k = b_k1 / norm
        
    dominant_eigenvalue = float(np.dot(b_k.T, np.dot(A, b_k)) / np.dot(b_k.T, b_k))
    return dominant_eigenvalue, b_k

def qr_algorithm(A: np.ndarray, num_iterations: int = 50) -> tuple[np.ndarray, np.ndarray]:
    A_k = np.copy(A).astype(float)
    n = A.shape[0]
    Q_accum = np.eye(n)
    
    for _ in range(num_iterations):
        Q, R = np.linalg.qr(A_k)
        A_k = R @ Q
        Q_accum = Q_accum @ Q
        
    eigenvalues = np.diag(A_k)
    return eigenvalues, Q_accum

# Real symmetric test matrix
A_sym = np.array([
    [ 6.0, -2.0,  1.0],
    [-2.0,  3.0, -2.0],
    [ 1.0, -2.0,  6.0]
], dtype=float)

dom_val, dom_vec = power_iteration(A_sym, num_iterations=100)
qr_vals, qr_evecs = qr_algorithm(A_sym, num_iterations=50)

print(f"Power Iteration Dominant Eigenvalue: {dom_val:.6f}")
print(f"Power Iteration Dominant Eigenvector: {dom_vec}")
print(f"QR Algorithm Extracted Spectrum: {np.sort(qr_vals)[::-1]}")

Running this code produces:

text
Power Iteration Dominant Eigenvalue: 8.000000
Power Iteration Dominant Eigenvector: [ 0.70710678 -0.         -0.70710678]
QR Algorithm Extracted Spectrum: [8. 5. 2.]

Power Iteration isolates the dominant mode , while the QR algorithm extracts the entire spectrum .


3. Production LAPACK Drivers via NumPy and SciPy

High-level Python libraries wrap Fortran LAPACK routines (dgeev for general non-symmetric matrices, dsyev for symmetric matrices, and dggev for generalized eigensystems):

python
import numpy as np
import scipy.linalg as la

# 1. Real Symmetric Matrix -> dsyev (faster, guaranteed orthogonal real vectors)
A_sym = np.array([[6.0, -2.0, 1.0], [-2.0, 3.0, -2.0], [1.0, -2.0, 6.0]])
evals_sym, evecs_sym = la.eigh(A_sym)
print("Symmetric Eigenvalues (la.eigh -> dsyev):", evals_sym)

# 2. General Non-Symmetric Matrix -> dgeev
A_gen = np.array([[4.0, 1.0], [2.0, 3.0]])
evals_gen, evecs_gen = la.eig(A_gen)
print("General Eigenvalues (la.eig -> dgeev):", evals_gen.real)

# 3. Planar Rotation Matrix (Complex Spectrum)
D_rot = np.array([[0.0, -1.0], [1.0, 0.0]])
evals_rot, evecs_rot = la.eig(D_rot)
print("Rotation Matrix Complex Eigenvalues:", evals_rot)

# 4. Generalized Eigensystem: A * v = lambda * B * v
B_metric = np.array([[2.0, 1.0], [1.0, 2.0]])
gen_evals, gen_evecs = la.eig(A_gen, B_metric)
print("Generalized Eigenvalues:", gen_evals.real)

Running this script yields:

text
Symmetric Eigenvalues (la.eigh -> dsyev): [2. 5. 8.]
General Eigenvalues (la.eig -> dgeev): [5. 2.]
Rotation Matrix Complex Eigenvalues: [0.+1.j 0.-1.j]
Generalized Eigenvalues: [1. 3.]

For defective matrices, numerical eigensolvers return duplicate eigenvectors that are linearly dependent within machine precision tolerances (). When complex conjugate roots appear, NumPy automatically promotes real floating-point buffers to complex128.


4. Native C++ with Eigen3 and SIMD Vectorization

In latency-critical applications (such as robotics state estimation, physics engines, and high-frequency trading), Python's FFI overhead is prohibitive. Compiled C++ using Eigen3 executes matrix decomposition directly on CPU registers:

cpp
#include <iostream>
#include <complex>
#include <Eigen/Dense>
#include <Eigen/Eigenvalues>

int main() {
    // Stack-allocated 3x3 double-precision matrix
    Eigen::Matrix3d A;
    A <<  6.0, -2.0,  1.0,
         -2.0,  3.0, -2.0,
          1.0, -2.0,  6.0;

    // SelfAdjointEigenSolver exploits symmetry for maximum performance
    Eigen::SelfAdjointEigenSolver<Eigen::Matrix3d> solver(A);

    if (solver.info() != Eigen::Success) {
        std::cerr << "Eigensolver computation failed to converge!" << std::endl;
        return 1;
    }

    Eigen::Vector3d eigenvalues = solver.eigenvalues();
    Eigen::Matrix3d eigenvectors = solver.eigenvectors();

    std::cout << "Computed Real Eigenvalues:\n" << eigenvalues << "\n\n";
    std::cout << "Computed Orthogonal Eigenvectors (Columns):\n" << eigenvectors << "\n\n";

    // Verify residual norm: ||A * v - lambda * v||_2 for dominant mode
    double lambda_dom = eigenvalues[2]; // Sorted in increasing order
    Eigen::Vector3d v_dom = eigenvectors.col(2);

    Eigen::Vector3d residual = A * v_dom - lambda_dom * v_dom;
    std::cout << "Residual Norm ||A*v - lambda*v||_2: " << residual.norm() << std::endl;

    return 0;
}

Compiling with aggressive compiler flags:

bash
g++ -O3 -march=native -DNDEBUG -I/usr/include/eigen3 eigen_demo.cpp -o eigen_demo
./eigen_demo

Output:

text
Computed Real Eigenvalues:
2
5
8

Computed Orthogonal Eigenvectors (Columns):
 0.408248 -0.57735   0.707107
 0.816497  0.57735  -0.000000
-0.408248  0.57735   0.707107

Residual Norm ||A*v - lambda*v||_2: 0

Architectural Comparison and Hardware Performance

The choice of execution paradigm introduces fundamental trade-offs across mathematical precision, memory allocation, and instruction-level parallelism.

Execution ParadigmPrimary EnginePrecision & TypeComputational ComplexityDefective Matrix HandlingTarget Applications
Symbolic CAS (SymPy)Python object tree / ASTExact rationals / radicalsExponential Full Jordan canonical formAnalytical proofs, edge-case analysis
Manual Hand AlgebraPaper / Determinant expansionExact fractions / surdsHuman-limited ()Manual null space derivationTheoretical coursework, verification
Custom Python LoopsNumPy array primitivesIEEE 754 Float64Power: , QR: Fails on defectivity without shiftsAlgorithm prototyping, education
Production LAPACKC/Fortran (dgeev/dsyev)Hardware Float32/Float64Optimized Duplicated collinear eigenvectorsOffline ML, data analysis, SciPy
Native C++ (Eigen3)Expression templates + SIMDFloat32/Float64/ComplexHardware-vectorized Specialized solver classesReal-time robotics, physics, HFT
Interpreted Python + NumPy / SciPy Native C++ (Eigen3 with AVX-512) CPython Bytecode Interpreter (GIL Active) C-FFI Layer (Pointer Marshaling & Heap Alloc) Compiled Fortran LAPACK Subroutine (dgeev) Latency dominated by FFI boundaries for small N Expression Templates Compile-time lazy evaluation, zero temporaries Stack-Allocated Fixed-Size Matrices Eigen::Matrix3d (No malloc/free overhead) AVX-512 / FMA Vectorization Inlined hardware SIMD instructions

The Foreign Function Interface (FFI) Tax

When computing eigensystems for massive matrices (), NumPy and SciPy perform almost identically to native C++ because the execution time is entirely dominated by compute-intensive Level-3 BLAS operations inside the LAPACK routine.

However, for low-dimensional systems () evaluated millions of times inside tight real-time loops (e.g., orientation kinematics, extended Kalman filtering, surface normal estimation):

  • Python FFI Overhead: Every call to np.linalg.eig or scipy.linalg.eigh pays for Python function call overhead, argument type checking, C-FFI pointer marshaling, temporary buffer allocations, and GIL acquisition. This overhead often requires 1.5 to 5 microseconds per call.
  • Native Eigen3 Stack Execution: A fixed-size matrix (Eigen::Matrix3d) allocates its 9 double values directly on the stack ( bytes). The compiler inlines the solver, unrolls all transformation loops, and generates direct vectorized SIMD instructions (vfmadd213pd), executing the entire eigensystem extraction in under 40 nanoseconds.

When to Use Which Tool

Selecting the right eigensystem strategy depends directly on your system's scale, sparsity, and latency budget:

Loading diagram…
  • Use SymPy when deriving theoretical proofs, checking algebraic vs. geometric multiplicities, or verifying exact rational eigenspaces of small symbolic operators.
  • Use Custom Iterative Solvers when studying convergence dynamics, spectral shifts, or testing specialized numerical preconditions.
  • Use NumPy / SciPy (scipy.linalg.eigh / eig) for data pipelines, offline ML research, PCA, clustering, and general scientific computing where development velocity outweighs sub-microsecond latency.
  • Use Native C++ (Eigen3) for real-time control, robotics kinematics, physical simulations, and embedded hardware where deterministic execution and stack-allocated SIMD performance are non-negotiable.

When matrices become sparse and high-dimensional (, such as web graphs or graph neural networks), dense solvers become completely intractable. In those regimes, the focus shifts to Krylov subspace methods: Arnoldi iteration for non-symmetric matrices and Lanczos factorization for symmetric systems, computing only the extremal eigenvalues without ever materializing the full dense operator.

Stay in the loop

New posts on ML, AI engineering, and building things — straight to your inbox. No spam.

Comments (0)

No comments yet. Be the first to comment!

Leave a comment