Algorithm Search⌘K

Companion matrix method in Python

Builds the companion matrix of p, whose characteristic polynomial is p/a0, balances it, and computes all its eigenvalues by the shifted QR algorithm. Backward stable in the matrix, O(n^3) work.

File
companion_matrix_method.py, 150 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
companion_matrix_method.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/companion-matrix-method/. The authors are listed in prov/4VFJZ5.jsonl.

"""Polynomial roots as the eigenvalues of the companion matrix.

The companion matrix is balanced (Parlett-Reinsch, powers of 2) and its
eigenvalues are computed by the shifted QR iteration on the upper
Hessenberg form, with Givens rotations in complex arithmetic, Wilkinson
shifts, an exceptional shift every 10 steps, and deflation of negligible
subdiagonal entries. Coefficients are listed highest degree first.
"""

import cmath
import math
from dataclasses import dataclass
from typing import List, Sequence

EPS = 2.0 ** -52


@dataclass
class CompanionResult:
    roots: List[complex]
    sweeps: int  # QR steps on the active block
    status: str  # "converged" or "failed"


def companion(a: Sequence[complex]) -> List[List[complex]]:
    """Upper Hessenberg companion matrix: first row -a_1/a_0, ..., -a_n/a_0, ones below the diagonal."""
    n = len(a) - 1
    C = [[0j] * n for _ in range(n)]
    for j in range(n):
        C[0][j] = complex(-a[j + 1] / a[0])
    for i in range(1, n):
        C[i][i - 1] = 1 + 0j
    return C


def balance(A: List[List[complex]]) -> None:
    """In place D^-1 A D with D a diagonal of powers of 2 (exact in floating point)."""
    n = len(A)
    radix = 2.0
    done = False
    while not done:
        done = True
        for i in range(n):
            c = sum(abs(A[j][i]) for j in range(n) if j != i)
            r = sum(abs(A[i][j]) for j in range(n) if j != i)
            if c == 0 or r == 0:
                continue
            g, f, s = r / radix, 1.0, c + r
            while c < g:
                f *= radix
                c *= radix * radix
            g = r * radix
            while c > g:
                f /= radix
                c /= radix * radix
            if (c + r) / f < 0.95 * s:
                done = False
                for j in range(n):
                    A[i][j] /= f
                for j in range(n):
                    A[j][i] *= f


def _wilkinson_shift(a, b, c, d):
    """Eigenvalue of [[a, b], [c, d]] closer to d."""
    h = (a - d) / 2
    disc = cmath.sqrt(h * h + b * c)
    m1, m2 = (a + d) / 2 + disc, (a + d) / 2 - disc
    return m1 if abs(m1 - d) <= abs(m2 - d) else m2


def hessenberg_eigenvalues(H: List[List[complex]], max_iter: int = 60):
    """Eigenvalues of an upper Hessenberg matrix (modified in place).
    Returns (eigenvalues, sweeps, status)."""
    n = len(H)
    eig = []
    sweeps = 0
    hi = n - 1
    its = 0
    while hi >= 0:  # @line L4
        lo = hi  # @line L5
        while lo > 0:
            s = abs(H[lo - 1][lo - 1]) + abs(H[lo][lo])
            if s == 0:
                s = sum(abs(H[i][j]) for i in range(hi + 1) for j in range(hi + 1))
            if abs(H[lo][lo - 1]) <= EPS * s:
                H[lo][lo - 1] = 0j
                break
            lo -= 1
        if lo == hi:  # @line L6
            eig.append(H[hi][hi])
            hi -= 1
            its = 0
            continue
        if its >= max_iter:  # @line L9
            return eig, sweeps, "failed"
        its += 1
        sweeps += 1
        if its % 10 == 0:  # @line L7
            mu = H[hi][hi] + abs(H[hi][hi - 1]) * (0.75 + 0.5j)
        else:
            mu = _wilkinson_shift(H[hi - 1][hi - 1], H[hi - 1][hi], H[hi][hi - 1], H[hi][hi])
        for i in range(lo, hi + 1):  # @line L8
            H[i][i] -= mu
        rots = []
        for k in range(lo, hi):
            x, y = H[k][k], H[k + 1][k]
            r = math.hypot(abs(x), abs(y))
            if r == 0:
                c, s = 1 + 0j, 0j
            else:
                c, s = x / r, y / r
            rots.append((c, s))
            for j in range(k, hi + 1):
                u, v = H[k][j], H[k + 1][j]
                H[k][j] = c.conjugate() * u + s.conjugate() * v
                H[k + 1][j] = -s * u + c * v
        for k, (c, s) in zip(range(lo, hi), rots):
            for i in range(lo, min(k + 2, hi) + 1):
                u, v = H[i][k], H[i][k + 1]
                H[i][k] = u * c + v * s
                H[i][k + 1] = -u * s.conjugate() + v * c.conjugate()
        for i in range(lo, hi + 1):
            H[i][i] += mu
    return eig, sweeps, "converged"  # @line L10


def companion_roots(coeffs: Sequence[float], balanced: bool = True, max_iter: int = 60) -> CompanionResult:
    """All roots of the polynomial, with multiplicity, as companion matrix eigenvalues."""
    a = [complex(x) for x in coeffs]
    if not all(math.isfinite(z.real) and math.isfinite(z.imag) for z in a):
        raise ValueError("coefficients must be finite")
    while a and a[0] == 0:  # @line L1
        a.pop(0)
    if not a:
        raise ValueError("the zero polynomial has every number as a root")
    zeros = 0
    while len(a) > 1 and a[-1] == 0:
        a.pop()
        zeros += 1
    if len(a) == 1:
        return CompanionResult([0j] * zeros, 0, "converged")
    C = companion(a)  # @line L2
    if balanced:
        balance(C)  # @line L3
    eig, sweeps, status = hessenberg_eigenvalues(C, max_iter)
    return CompanionResult(eig + [0j] * zeros, sweeps, status)
test_companion_matrix_method.py 133 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/companion-matrix-method/. The authors are listed in prov/4VFJZ5.jsonl.

import cmath
import math
import random
import unittest

from companion_matrix_method import balance, companion, companion_roots, hessenberg_eigenvalues


def from_roots(rs):
    p = [1 + 0j]
    for r in rs:
        p = [x - r * y for x, y in zip(p + [0], [0] + p)]
    return p


def match_error(found, expected):
    left = list(found)
    err = 0.0
    for r in expected:
        j = min(range(len(left)), key=lambda i: abs(left[i] - r))
        err = max(err, abs(left[j] - r))
        left.pop(j)
    return err


def char_poly_at(C, z):
    """det(zI - C) by Gaussian elimination, for checking the companion matrix."""
    n = len(C)
    M = [[(z if i == j else 0) - C[i][j] for j in range(n)] for i in range(n)]
    det = 1 + 0j
    for c in range(n):
        p = max(range(c, n), key=lambda r: abs(M[r][c]))
        if M[p][c] == 0:
            return 0j
        if p != c:
            M[c], M[p] = M[p], M[c]
            det = -det
        det *= M[c][c]
        for r in range(c + 1, n):
            f = M[r][c] / M[c][c]
            for j in range(c, n):
                M[r][j] -= f * M[c][j]
    return det


class TestCompanion(unittest.TestCase):
    def test_characteristic_polynomial(self):
        a = [2, -3, 5, 7, -11]
        C = companion(a)
        for z in (0.5, -1.25, 2 + 1j):
            p = sum(c * z ** (len(a) - 1 - i) for i, c in enumerate(a)) / a[0]
            self.assertAlmostEqual(abs(char_poly_at(C, z) - p), 0, delta=1e-12)

    def test_balancing_is_a_similarity(self):
        a = [complex(x) for x in from_roots(range(1, 9))]
        C = companion(a)
        B = [row[:] for row in C]
        balance(B)
        for z in (0.5, 3.3, 7 + 2j):
            self.assertLess(abs(char_poly_at(B, z) - char_poly_at(C, z)), 1e-6 * (1 + abs(char_poly_at(C, z))))

    def test_small_examples(self):
        r = companion_roots([1, -6, 11, -6])
        self.assertEqual(r.status, "converged")
        self.assertLess(match_error(r.roots, [1, 2, 3]), 1e-14)
        r = companion_roots([1, 0, 3, 6, 10])
        self.assertLess(match_error(r.roots, [-1 + 1j, -1 - 1j, 1 + 2j, 1 - 2j]), 1e-14)

    def test_roots_of_unity(self):
        r = companion_roots([1] + [0] * 29 + [-1])
        self.assertLess(match_error(r.roots, [cmath.exp(2j * math.pi * k / 30) for k in range(30)]), 1e-13)

    def test_wilkinson_balancing_matters(self):
        w = [x.real for x in from_roots(range(1, 21))]
        bal = companion_roots(w)
        raw = companion_roots(w, balanced=False)
        self.assertEqual(bal.status, "converged")
        self.assertLess(match_error(bal.roots, range(1, 21)), 0.1)
        self.assertGreater(match_error(raw.roots, range(1, 21)), 1.0)
        w10 = [x.real for x in from_roots(range(1, 11))]
        self.assertLess(match_error(companion_roots(w10).roots, range(1, 11)), 1e-8)

    def test_multiple_roots_lose_accuracy_like_eps_to_1_over_m(self):
        for m, bound in ((2, 1e-7), (3, 1e-4), (4, 1e-3)):
            r = companion_roots([x.real for x in from_roots([1] * m + [-2])])
            self.assertLess(match_error(r.roots, [1] * m + [-2]), bound)

    def test_zero_roots_and_low_degree(self):
        self.assertEqual(companion_roots([3, 0, 0]).roots, [0j, 0j])
        r = companion_roots([1, -3, 2, 0])  # x (x - 1)(x - 2)
        self.assertLess(match_error(r.roots, [0, 1, 2]), 1e-14)
        self.assertEqual(companion_roots([2, -4]).roots, [2 + 0j])
        self.assertEqual(companion_roots([0, 0, 1, -5]).roots, [5 + 0j])  # leading zeros stripped

    def test_bad_input_and_cap(self):
        for bad in ([1, float("nan")], [1, math.inf], [0, 0], []):
            with self.assertRaises(ValueError):
                companion_roots(bad)
        r = companion_roots([1, 0, 3, 6, 10], max_iter=0)
        self.assertEqual(r.status, "failed")

    def test_hessenberg_eigenvalues_triangular(self):
        H = [[1 + 0j, 5, 7], [0j, 2, 4], [0j, 0j, 3]]
        eig, sweeps, status = hessenberg_eigenvalues(H)
        self.assertEqual((sorted(e.real for e in eig), sweeps, status), ([1, 2, 3], 0, "converged"))

    def test_random_polynomials(self):
        rng = random.Random(20261108)
        sweeps = 0
        for _ in range(500):
            n = rng.randint(2, 12)
            rs = []
            while len(rs) < n:
                if n - len(rs) >= 2 and rng.random() < 0.5:
                    z = complex(rng.uniform(-3, 3), rng.uniform(0.3, 3))
                    cand = [z, z.conjugate()]
                else:
                    cand = [complex(rng.uniform(-3, 3))]
                if all(abs(u - v) > 0.3 for u in cand for v in rs):
                    rs += cand
            r = companion_roots([x.real for x in from_roots(rs)])
            self.assertEqual(r.status, "converged")
            self.assertEqual(len(r.roots), n)
            self.assertLess(match_error(r.roots, rs), 1e-9)
            sweeps += r.sweeps / n
        self.assertLess(sweeps / 500, 5)


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Companion matrix method