Implicitly restarted Arnoldi method in Python
Keeps an m-step Arnoldi factorization and compresses it to k steps with shifted QR steps on H_m. This restarts with a polynomial filter applied to the start vector, at no cost in products with A.
- File
implicitly_restarted_arnoldi.py, 234 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/implicitly-restarted-arnoldi/. The authors are listed in prov/PAMN7C.jsonl.
"""Implicitly restarted Arnoldi method (Sorensen 1992) with exact shifts, in complex arithmetic.
iram(matvec, b, k, m) returns (theta, x, est, restarts, matvecs, converged): the k wanted Ritz
values, their Ritz vectors, residual norm estimates, the number of implicit restarts, the number
of products with A and whether every wanted pair met the tolerance. which="LM" wants largest
modulus, "LR" largest real part. Standard library only; vectors are lists of numbers.
"""
import cmath
import math
U = 2.0 ** -53 # unit roundoff of IEEE double precision
EPS23 = (2 * U) ** (2.0 / 3.0) # ARPACK's floor for the relative test
def dot(x, y):
"""Inner product x* y (conjugate-linear in x)."""
return sum(complex(a).conjugate() * b for a, b in zip(x, y))
def norm2(x):
return math.sqrt(sum(abs(a) ** 2 for a in x))
def extend(matvec, v, h, f, j0, m, counter):
"""Extend A V_j0 = V_j0 H_j0 + f e_j0^T to m steps. Returns (f, j): j < m on breakdown."""
n = len(f)
for j in range(j0, m):
beta = norm2(f)
if j > 0:
if beta <= 10.0 * n * U * counter["scale"]:
return [0j] * n, j
h[j][j - 1] = beta
v.append([x / beta for x in f])
w = [complex(x) for x in matvec(v[j])]
counter["matvecs"] += 1
counter["scale"] = max(counter["scale"], norm2(w))
for _ in range(2): # classical Gram-Schmidt run twice
c = [dot(v[i], w) for i in range(j + 1)]
for i in range(j + 1):
h[i][j] += c[i]
for i in range(j + 1):
ci, vi = c[i], v[i]
w = [wv - ci * qv for wv, qv in zip(w, vi)]
f = w
return f, m
def _wilkinson_shift(a, b, c, d):
"""Eigenvalue of [[a, b], [c, d]] closer to d."""
half = (a - d) / 2
disc = cmath.sqrt(half * half + b * c)
m1 = (a + d) / 2 + disc
m2 = (a + d) / 2 - disc
return m1 if abs(m1 - d) <= abs(m2 - d) else m2
def hessenberg_eigvals(hm):
"""All eigenvalues of a small upper Hessenberg matrix by complex shifted QR with Givens."""
m = len(hm)
a = [[complex(hm[i][j]) for j in range(m)] for i in range(m)]
hnorm = math.sqrt(sum(abs(v) ** 2 for row in a for v in row)) or 1.0
out = []
hi = m - 1
its = 0
total = 0
while hi >= 0:
l = hi
while l > 0:
tol = U * (abs(a[l][l]) + abs(a[l - 1][l - 1]))
if tol == 0.0:
tol = U * hnorm
if abs(a[l][l - 1]) <= tol:
a[l][l - 1] = 0j
break
l -= 1
if l == hi:
out.append(a[hi][hi])
hi -= 1
its = 0
continue
its += 1
total += 1
if total > 60 * m:
raise RuntimeError("Hessenberg QR did not converge")
if its % 11 == 10:
mu = a[hi][hi] + 1.5 * abs(a[hi][hi - 1])
else:
mu = _wilkinson_shift(a[hi - 1][hi - 1], a[hi - 1][hi], a[hi][hi - 1], a[hi][hi])
for i in range(l, hi + 1):
a[i][i] -= mu
rots = []
for p in range(l, hi):
x, y = a[p][p], a[p + 1][p]
r = math.hypot(abs(x), abs(y))
if r == 0.0:
c, s = 1.0 + 0j, 0j
else:
c, s = x / r, y / r
rots.append((c, s))
for col in range(p, hi + 1):
u, v = a[p][col], a[p + 1][col]
a[p][col] = c.conjugate() * u + s.conjugate() * v
a[p + 1][col] = -s * u + c * v
for p, (c, s) in zip(range(l, hi), rots):
for row in range(l, min(p + 2, hi) + 1):
u, v = a[row][p], a[row][p + 1]
a[row][p] = u * c + v * s
a[row][p + 1] = -u * s.conjugate() + v * c.conjugate()
for i in range(l, hi + 1):
a[i][i] += mu
return out
def _solve(mat, rhs):
"""Gaussian elimination with partial pivoting; tiny pivots are replaced by U times the norm."""
m = len(mat)
a = [row[:] + [rhs[i]] for i, row in enumerate(mat)]
big = max(abs(v) for row in mat for v in row) or 1.0
for c in range(m):
p = max(range(c, m), key=lambda r: abs(a[r][c]))
a[c], a[p] = a[p], a[c]
if abs(a[c][c]) < U * big:
a[c][c] = U * big
for r in range(c + 1, m):
f = a[r][c] / a[c][c]
if f != 0:
for cc in range(c, m + 1):
a[r][cc] -= f * a[c][cc]
x = [0j] * m
for r in range(m - 1, -1, -1):
x[r] = (a[r][m] - sum(a[r][cc] * x[cc] for cc in range(r + 1, m))) / a[r][r]
return x
def ritz_pairs(hm, beta):
"""Ritz values of the Hessenberg matrix hm, with eigenvectors y and estimates beta |e_m^* y|."""
m = len(hm)
out = []
for theta in hessenberg_eigvals(hm):
shifted = [[hm[i][j] - (theta if i == j else 0) for j in range(m)] for i in range(m)]
y = [1.0 + 0j] * m
for _ in range(3):
y = _solve(shifted, y)
ny = norm2(y)
y = [val / ny for val in y]
out.append((theta, y, beta * abs(y[m - 1])))
return out
def order_key(which):
if which == "LM":
return lambda t: (-abs(t), -t.real, -t.imag)
if which == "LR":
return lambda t: (-t.real, -abs(t.imag), -t.imag)
raise ValueError("which must be LM or LR")
def shifted_qr(h, mu, qacc):
"""One explicit shifted QR step H - mu I = Q R, H <- R Q + mu I, on the full m x m H."""
m = len(h)
for i in range(m):
h[i][i] -= mu
rots = []
for p in range(m - 1):
x, y = h[p][p], h[p + 1][p]
r = math.hypot(abs(x), abs(y))
c, s = (1.0 + 0j, 0j) if r == 0.0 else (x / r, y / r)
rots.append((c, s))
for col in range(p, m):
a1, a2 = h[p][col], h[p + 1][col]
h[p][col] = c.conjugate() * a1 + s.conjugate() * a2
h[p + 1][col] = -s * a1 + c * a2
for p, (c, s) in enumerate(rots):
for row in range(0, min(p + 2, m - 1) + 1):
a1, a2 = h[row][p], h[row][p + 1]
h[row][p] = a1 * c + a2 * s
h[row][p + 1] = -a1 * s.conjugate() + a2 * c.conjugate()
for row in range(m):
a1, a2 = qacc[row][p], qacc[row][p + 1]
qacc[row][p] = a1 * c + a2 * s
qacc[row][p + 1] = -a1 * s.conjugate() + a2 * c.conjugate()
for i in range(m):
h[i][i] += mu
def iram(matvec, b, k, m, which="LM", tol=1e-10, max_restarts=300):
"""Compute k wanted eigenvalues with a basis of at most m vectors (k < m)."""
n = len(b)
nb = norm2(b)
if nb == 0.0:
raise ValueError("start vector is zero")
m = min(m, n)
if not 0 < k < m:
raise ValueError("need 0 < k < m <= n")
key = order_key(which)
counter = {"matvecs": 0, "scale": 0.0}
v = []
h = [[0j] * m for _ in range(m)]
f, j = extend(matvec, v, h, [complex(x) for x in b], 0, k, counter) # @line L1
restarts = 0
while True: # @line L2
f, j = extend(matvec, v, h, f, j, m, counter) # @line L3
hm = [row[:j] for row in h[:j]]
beta = norm2(f)
pairs = ritz_pairs(hm, beta) # @line L4
pairs.sort(key=lambda t: key(t[0]))
kk = min(k, j)
wanted = pairs[:kk]
done = all(est <= tol * max(abs(th), EPS23) for th, _, est in wanted)
if done or j < m or restarts >= max_restarts: # @line L5
x = [[sum(v[c][i] * y[c] for c in range(j)) for i in range(n)] for _, y, _ in wanted]
return ([t[0] for t in wanted], x, [t[2] for t in wanted], restarts,
counter["matvecs"], done or j < m)
restarts += 1
qacc = [[1.0 + 0j if r == c else 0j for c in range(m)] for r in range(m)] # @line L6
for mu, _, _ in pairs[k:]: # @line L7
shifted_qr(h, mu, qacc) # @line L8
sigma = qacc[m - 1][k - 1]
vnew = [[sum(v[c][i] * qacc[c][col] for c in range(m)) for i in range(n)]
for col in range(k + 1)]
hk1 = h[k][k - 1]
f = [hk1 * a + sigma * fv for a, fv in zip(vnew[k], f)] # @line L9
v = vnew[:k] # @line L10
for r in range(m):
for c in range(m):
if r >= k or c >= k:
h[r][c] = 0j
j = k
test_implicitly_restarted_arnoldi.py 175 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/implicitly-restarted-arnoldi/. The authors are listed in prov/PAMN7C.jsonl.
import math
import random
import pytest
from implicitly_restarted_arnoldi import U, extend, iram, norm2, ritz_pairs, shifted_qr
def dense(a):
return lambda x: [sum(r[j] * x[j] for j in range(len(x))) for r in a]
def bidiag(n):
"""Upper bidiagonal: diagonal 1..n, superdiagonal 1. Eigenvalues 1..n."""
return lambda x: [(i + 1) * x[i] + (x[i + 1] if i + 1 < n else 0.0) for i in range(n)]
def householder_similarity(a, seed):
rng = random.Random(seed)
n = len(a)
v = [rng.uniform(-1, 1) for _ in range(n)]
nv = norm2(v)
v = [x / nv for x in v]
p = [[(1.0 if i == j else 0.0) - 2 * v[i] * v[j] for j in range(n)] for i in range(n)]
pa = [[sum(p[i][t] * a[t][j] for t in range(n)) for j in range(n)] for i in range(n)]
return [[sum(pa[i][t] * p[t][j] for t in range(n)) for j in range(n)] for i in range(n)]
def diag(d):
return [[d[i] if i == j else 0.0 for j in range(len(d))] for i in range(len(d))]
def close_to(found, expected, tol):
pool = list(found)
for lam in expected:
best = min(range(len(pool)), key=lambda i: abs(pool[i] - lam))
assert abs(pool[best] - lam) <= tol, (lam, pool[best])
pool.pop(best)
def test_page_example():
n = 100
rng = random.Random(1)
b = [rng.uniform(-1, 1) for _ in range(n)]
seen = {}
for k, m in ((1, 6), (3, 10), (4, 12), (4, 20)):
th, x, est, restarts, matvecs, ok = iram(bidiag(n), b, k, m)
assert ok
close_to(th, [100, 99, 98, 97][:k], 1e-8)
assert matvecs == k + restarts * (m - k) + (m - k)
seen[(k, m)] = restarts
for t, xv, e in zip(th, x, est):
r = norm2([a - t * c for a, c in zip(bidiag(n)(xv), xv)])
assert r == pytest.approx(e, rel=1e-4, abs=1e-12)
assert seen[(4, 20)] < seen[(4, 12)]
assert 40 <= seen[(1, 6)] <= 50 and 5 <= seen[(4, 20)] <= 9
def test_exact_shift_filter_and_relation():
rng = random.Random(4)
n, k, m = 30, 3, 10
a = [[rng.gauss(0, 1) for _ in range(n)] for _ in range(n)]
mv = dense(a)
b = [rng.gauss(0, 1) for _ in range(n)]
counter = {"matvecs": 0, "scale": 0.0}
v, h = [], [[0j] * m for _ in range(m)]
f, j = extend(mv, v, h, [complex(x) for x in b], 0, m, counter)
assert j == m
pairs = ritz_pairs([row[:] for row in h], norm2(f))
pairs.sort(key=lambda t: -abs(t[0]))
shifts = [t[0] for t in pairs[k:]]
q = [[1.0 + 0j if r == c else 0j for c in range(m)] for r in range(m)]
for mu in shifts:
shifted_qr(h, mu, q)
v1 = [sum(v[c][i] * q[c][0] for c in range(m)) for i in range(n)]
w = list(v[0])
for mu in shifts:
w = [s - mu * t for s, t in zip(mv(w), w)]
nw = norm2(w)
w = [t / nw for t in w]
overlap = abs(sum(x.conjugate() * y for x, y in zip(v1, w)))
assert overlap == pytest.approx(1.0, abs=1e-8)
for c in range(k - 1):
assert abs(q[m - 1][c]) <= 1e-12
vk = [[sum(v[c][i] * q[c][col] for c in range(m)) for i in range(n)] for col in range(k + 1)]
fk = [h[k][k - 1] * s + q[m - 1][k - 1] * t for s, t in zip(vk[k], f)]
fro = math.sqrt(sum(x * x for row in a for x in row))
for col in range(k):
av = mv(vk[col])
rhs = [sum(vk[r][i] * h[r][col] for r in range(k)) + (fk[i] if col == k - 1 else 0) for i in range(n)]
assert norm2([s - t for s, t in zip(av, rhs)]) <= 1e3 * n * U * fro
for r in range(k + 1):
for c in range(k + 1):
g = sum(x.conjugate() * y for x, y in zip(vk[r], vk[c]))
assert abs(g - (1.0 if r == c else 0.0)) <= 100 * n * U
assert all(abs(sum(x.conjugate() * y for x, y in zip(vk[r], fk))) <= 1e-12 * norm2(f) + 1e-13 for r in range(k))
def test_known_spectra():
n = 40
t = [[2.0 if i == j else (-1.0 if abs(i - j) == 1 else 0.0) for j in range(n)] for i in range(n)]
th, _, _, _, _, ok = iram(dense(t), [1.0 + 0.01 * i for i in range(n)], 3, 12)
assert ok
close_to(th, [2 - 2 * math.cos(j * math.pi / (n + 1)) for j in (n, n - 1, n - 2)], 1e-8)
a = householder_similarity(diag([float(i) for i in range(1, 31)]), 2)
th, _, _, _, _, ok = iram(dense(a), [1.0] * 30, 2, 8)
assert ok
close_to(th, [30, 29], 1e-8)
def test_complex_pair_and_which():
base = diag([float(i) for i in range(1, 21)] + [0.0, 0.0])
base[20][20], base[20][21], base[21][20], base[21][21] = 15.0, 15.0, -15.0, 15.0
a = householder_similarity(base, 9)
rng = random.Random(3)
b = [rng.uniform(-1, 1) for _ in range(22)]
th, _, _, _, _, ok = iram(dense(a), b, 2, 8, which="LM")
assert ok
close_to(th, [15 + 15j, 15 - 15j], 1e-8)
th, _, _, _, _, ok = iram(dense(a), b, 1, 8, which="LR")
assert ok
close_to(th, [20], 1e-8)
d = [-10.0] + [float(i) for i in range(1, 10)]
th, _, _, _, _, _ = iram(dense(householder_similarity(diag(d), 1)), [1.0] * 10, 1, 4, which="LM")
close_to(th, [-10], 1e-8)
th, _, _, _, _, _ = iram(dense(householder_similarity(diag(d), 1)), [1.0] * 10, 1, 4, which="LR")
close_to(th, [9], 1e-8)
def test_breakdown_trivial_and_errors():
d = [5.0, 4.0, 3.0, 2.0, 1.0, 0.5]
th, _, est, restarts, _, ok = iram(dense(diag(d)), [1.0, 1.0, 1.0, 0, 0, 0], 2, 5)
assert ok and restarts == 0
close_to(th, [5, 4], 1e-13)
th, _, _, _, _, ok = iram(dense(diag([0.0] * 4)), [1.0, 2.0, 3.0, 4.0], 1, 3)
assert ok and abs(th[0]) == 0.0
th, _, _, _, _, ok = iram(dense([[1.0, 2.0], [3.0, 4.0]]), [1.0, 0.0], 1, 2)
assert ok
close_to(th, [(5 + math.sqrt(33)) / 2], 1e-12)
with pytest.raises(ValueError):
iram(dense([[1.0]]), [1.0], 1, 2)
with pytest.raises(ValueError):
iram(dense(diag([1.0, 2.0, 3.0])), [0.0, 0.0, 0.0], 1, 2)
with pytest.raises(ValueError):
iram(dense(diag([1.0, 2.0, 3.0])), [1.0, 1.0, 1.0], 1, 3, which="SM")
def test_repeated_jordan_equal_modulus_nearly_equal():
rng = random.Random(5)
b = [rng.uniform(0.5, 1.5) for _ in range(12)]
d = [3.0, 3.0] + [0.1 * i for i in range(1, 11)]
th, _, _, _, _, ok = iram(dense(householder_similarity(diag(d), 4)), b, 1, 5)
assert ok
close_to(th, [3], 1e-9)
j = diag([4.0, 4.0, 4.0] + [0.1 * i for i in range(1, 10)])
j[0][1] = j[1][2] = 1.0
# one wanted value inside a defective cluster: the shifts purge the cluster and it stalls
th, _, _, restarts, _, ok = iram(dense(householder_similarity(j, 6)), b, 1, 6, max_restarts=50)
assert not ok and restarts == 50
# the whole cluster wanted: converges, with error about the cube root of the residual
th, _, est, _, _, ok = iram(dense(householder_similarity(j, 6)), b, 3, 6)
assert ok
assert all(abs(t - 4.0) <= 1e-4 for t in th)
assert all(e <= 1e-10 for e in est)
d = [2.0, -2.0] + [0.1 * i for i in range(1, 11)]
th, _, _, _, _, ok = iram(dense(householder_similarity(diag(d), 7)), b, 2, 6)
assert ok
close_to(th, [2, -2], 1e-9)
d = [2.0, 2.0 + 1e-6] + [0.1 * i for i in range(1, 11)]
th, _, _, _, _, ok = iram(dense(householder_similarity(diag(d), 8)), b, 2, 6)
assert ok
close_to(th, [2, 2 + 1e-6], 1e-9)
What the next level needs
- the tests did not run in this build
Read the full page about Implicitly restarted Arnoldi method