Algorithm Search⌘K

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
implicitly_restarted_arnoldi.pyRawGitHub
# 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