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