Bisection for tridiagonal eigenvalues in Python
Find any chosen eigenvalue of a symmetric tridiagonal matrix by halving an interval, using the count of negative pivots of T − xI, which equals the number of eigenvalues below x.
- File
bisection_eigenvalue_algorithm.py, 67 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/bisection-eigenvalue-algorithm/. The authors are listed in prov/SXNY4P.jsonl.
"""Bisection with Sturm counts for a real symmetric tridiagonal matrix T.
T has the diagonal a[0..n-1] and the off-diagonal b[0..n-2]. negcount(a, b, x) is the number of
eigenvalues of T that are smaller than x. kth_eigenvalue(a, b, k) returns the k-th smallest
eigenvalue (k = 1..n) and the number of bisection steps. Standard library only.
"""
import sys
U = 2.0 ** -53 # unit roundoff of IEEE double precision
SAFMIN = sys.float_info.min
def pivmin(b):
return SAFMIN * max([1.0] + [t * t for t in b])
def negcount(a, b, x, pmin=None):
"""Number of negative pivots of T - xI = L D L^T, which is #{eigenvalues < x}."""
if pmin is None:
pmin = pivmin(b)
c = 0 # @line L7
d = 1.0
for i in range(len(a)): # @line L8
d = (a[i] - x) - (b[i - 1] * b[i - 1] / d if i > 0 else 0.0)
if abs(d) < pmin:
d = -pmin
if d < 0.0: # @line L9
c += 1
return c # @line L10
def gershgorin_interval(a, b):
n = len(a)
lo = min(a[i] - (abs(b[i - 1]) if i > 0 else 0.0) - (abs(b[i]) if i < n - 1 else 0.0) for i in range(n))
hi = max(a[i] + (abs(b[i - 1]) if i > 0 else 0.0) + (abs(b[i]) if i < n - 1 else 0.0) for i in range(n))
return lo, hi
def kth_eigenvalue(a, b, k, abstol=0.0):
"""The k-th smallest eigenvalue of T, to full relative accuracy by default."""
n = len(a)
if not 1 <= k <= n:
raise ValueError("k out of range")
pmin = pivmin(b)
lo, hi = gershgorin_interval(a, b) # @line L1
pad = 2.0 * n * U * max(abs(lo), abs(hi)) + 2.0 * pmin
lo, hi = lo - pad, hi + pad # now negcount(lo) < k <= negcount(hi)
steps = 0
while hi - lo > max(abstol, 2.0 * U * max(abs(lo), abs(hi))): # @line L2
x = 0.5 * (lo + hi)
if x <= lo or x >= hi: # @line L3: no float strictly between
break
steps += 1
if negcount(a, b, x, pmin) >= k: # @line L4
hi = x # @line L5
else:
lo = x
return 0.5 * (lo + hi), steps # @line L6
def eigenvalues(a, b, abstol=0.0):
"""All eigenvalues of T in increasing order."""
return [kth_eigenvalue(a, b, k, abstol)[0] for k in range(1, len(a) + 1)]
test_bisection_eigenvalue_algorithm.py 138 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bisection-eigenvalue-algorithm/. The authors are listed in prov/SXNY4P.jsonl.
import math
import random
from fractions import Fraction
from bisection_eigenvalue_algorithm import U, eigenvalues, gershgorin_interval, kth_eigenvalue, negcount
def exact_count(a, b, x):
"""Exact number of eigenvalues < x by the Sturm sequence of leading minors in Fraction.
Zero minors are handled by the sign rule: a zero p_i takes the sign opposite to p_(i-1)."""
x = Fraction(x)
a = [Fraction(t) for t in a]
b = [Fraction(t) for t in b]
p_prev, p = Fraction(1), a[0] - x
signs = [1, p]
for i in range(1, len(a)):
p_prev, p = p, (a[i] - x) * p - b[i - 1] ** 2 * p_prev
signs.append(p)
# number of sign agreements of (p_0, ..., p_n) for det(xI - T) style counting:
# eigenvalues < x equal the sign changes of p_i(x) = det(T_i - x I)
c, last = 0, 1
for s in signs[1:]:
if s == 0:
s = -last
if (s > 0) != (last > 0):
c += 1
last = s
return c
def check_bracket(a, b, lam, rel=8 * U, absol=0.0):
"""For each computed eigenvalue, exact counts show an eigenvalue in [lam - d, lam + d]."""
n = len(a)
for k, x in enumerate(lam, start=1):
d = Fraction(rel) * abs(Fraction(x)) + Fraction(absol)
assert exact_count(a, b, Fraction(x) - d) <= k - 1
assert exact_count(a, b, Fraction(x) + d) >= k
def toeplitz(n):
return [2.0] * n, [-1.0] * (n - 1)
def test_page_example():
a, b = toeplitz(4)
assert gershgorin_interval(a, b) == (0.0, 4.0)
assert [negcount(a, b, x) for x in (0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5)] == [1, 1, 2, 2, 2, 3, 3]
x, steps = kth_eigenvalue(a, b, 2)
assert abs(x - (3 - math.sqrt(5)) / 2 - 1) < 4 * U and steps == 54
def test_float_count_matches_exact_count():
rng = random.Random(1)
for trial in range(40):
n = rng.randint(1, 12)
a = [float(rng.randint(-5, 5)) for _ in range(n)]
b = [float(rng.randint(-3, 3)) for _ in range(n - 1)]
for _ in range(10):
x = rng.randint(-200, 200) / 16.0 + 1.0 / 64
assert negcount(a, b, x) == exact_count(a, b, x)
def test_toeplitz_known_spectra():
for n in (1, 2, 3, 7, 20, 31):
a, b = toeplitz(n)
got = eigenvalues(a, b)
want = [2.0 - 2.0 * math.cos(k * math.pi / (n + 1)) for k in range(1, n + 1)]
assert all(got[i] <= got[i + 1] for i in range(n - 1))
assert max(abs(x - y) for x, y in zip(got, want)) < 8 * U * 4
def test_random_tridiagonal_bracketed_exactly():
rng = random.Random(2026)
for trial in range(12):
n = rng.randint(2, 10)
a = [rng.uniform(-4, 4) for _ in range(n)]
b = [rng.uniform(-2, 2) for _ in range(n - 1)]
lam = eigenvalues(a, b)
check_bracket(a, b, lam, rel=0, absol=8 * n * U * max(abs(t) for t in gershgorin_interval(a, b)))
assert abs(sum(lam) - sum(a)) < 1e-12 * n
def test_trivial_cases():
assert eigenvalues([3.5], []) == [3.5]
assert all(abs(t) < 1e-300 for t in eigenvalues([0.0, 0.0, 0.0], [0.0, 0.0]))
got = eigenvalues([1.0, 1.0], [1.0])
assert abs(got[0]) <= 2 * U * 2 and abs(got[1] - 2.0) < 4 * U # absolute error of order u ||T||
def test_repeated_eigenvalues_from_split_matrix():
a = [1.0, 3.0, 1.0, 3.0, 2.0]
b = [1.0, 0.0, 1.0, 0.0]
got = eigenvalues(a, b)
want = sorted([2 - math.sqrt(2), 2 - math.sqrt(2), 2 + math.sqrt(2), 2 + math.sqrt(2), 2.0])
assert max(abs(x - y) for x, y in zip(got, want)) < 8 * U * 4
def test_wilkinson_nearly_equal_pair():
"""W21+: the two largest eigenvalues agree to about 13 significant digits but are separated."""
a = [float(abs(10 - i)) for i in range(21)]
b = [1.0] * 20
lam = eigenvalues(a, b)
gap = lam[-1] - lam[-2]
assert 1e-14 < gap < 1e-12
mid = Fraction(lam[-2]) + Fraction(gap) / 2
assert exact_count(a, b, mid) == 20
def test_relative_accuracy_zero_diagonal_graded():
"""Zero diagonal and graded off-diagonal: tiny eigenvalues to high relative accuracy."""
b = [1.0, 1e-6, 1e-12, 1e-18]
a = [0.0] * 5
lam = eigenvalues(a, b)
check_bracket(a, b, lam, rel=64 * U, absol=1e-300)
assert min(abs(t) for t in lam if t != 0.0) < 1e-17
def test_halving_rate():
"""Each step halves the interval: steps = number of halvings from the padded Gershgorin width."""
a, b = toeplitz(10)
x, steps = kth_eigenvalue(a, b, 3, abstol=1e-6)
lo, hi = gershgorin_interval(a, b)
width = (hi - lo) * (1 + 1e-12)
assert steps == math.ceil(math.log2(width / 1e-6))
want = 2.0 - 2.0 * math.cos(3 * math.pi / 11)
assert abs(x - want) <= 0.5e-6
def test_count_is_monotone():
rng = random.Random(4)
a = [rng.uniform(-1, 1) for _ in range(30)]
b = [rng.uniform(-1, 1) * 10 ** rng.randint(-8, 0) for _ in range(29)]
xs = sorted(rng.uniform(-3, 3) for _ in range(400))
cs = [negcount(a, b, x) for x in xs]
assert all(cs[i] <= cs[i + 1] for i in range(len(cs) - 1))
What the next level needs
- the tests did not run in this build
Read the full page about Bisection for tridiagonal eigenvalues