Algorithm Search⌘K

Algebraic multigrid in Python

Multigrid that builds its coarse levels from the matrix alone, with no grid geometry.

File
algebraic_multigrid.py, 262 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
algebraic_multigrid.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/algebraic-multigrid/. The authors are listed in prov/EZNBXD.jsonl.
"""Classical (Ruge-Stueben) algebraic multigrid: strength of connection,
C/F splitting, direct interpolation, Galerkin coarse operators and a
V-cycle with symmetric Gauss-Seidel smoothing. A matrix is a list of rows,
each a dict {column: value}. Works with float or Fraction entries."""
import heapq
import math


class Singular(ArithmeticError):
    pass


def matvec(a, x):
    return [sum(v * x[j] for j, v in row.items()) for row in a]


def nnz(a):
    return sum(len(r) for r in a)


def strength(a, theta):
    """S[i] = the j != i on which i strongly depends:
    -a_ij >= theta * max_k(-a_ik), the max over k != i."""
    s = []
    for i, row in enumerate(a):
        m = max((-v for j, v in row.items() if j != i), default=0)
        if m <= 0:
            s.append(set())
        else:
            s.append({j for j, v in row.items() if j != i and -v >= theta * m})
    return s


def split(s):
    """Ruge-Stueben C/F splitting. Returns a list of booleans, True = C."""
    n = len(s)
    st = [[] for _ in range(n)]              # st[i] = points that depend on i
    for i in range(n):
        for j in s[i]:
            st[j].append(i)
    lam = [len(st[i]) for i in range(n)]
    state = [0] * n                          # 0 undecided, 1 C, 2 F
    for i in range(n):
        if not s[i] and not st[i]:
            state[i] = 2                     # isolated: smoother handles it
    heap = [(-lam[i], i) for i in range(n) if state[i] == 0]
    heapq.heapify(heap)
    while heap:
        neg, i = heapq.heappop(heap)
        if state[i] != 0 or -neg != lam[i]:
            continue
        state[i] = 1                         # new C point
        for j in st[i]:
            if state[j] == 0:
                state[j] = 2                 # depends on a C point: F
                for k in s[j]:
                    if state[k] == 0:
                        lam[k] += 1
                        heapq.heappush(heap, (-lam[k], k))
        for j in s[i]:
            if state[j] == 0:
                lam[j] -= 1
                heapq.heappush(heap, (-lam[j], j))
    for i in range(n):                       # second pass: F-F strong pairs
        if state[i] != 2:                    # need a common C point
            continue
        ci = {j for j in s[i] if state[j] == 1}
        for j in s[i]:
            if state[j] == 2 and not (s[j] & ci) and j not in ci:
                state[j] = 1
                ci.add(j)
    return [x == 1 for x in state]


def interpolation(a, s, c):
    """Direct interpolation. Returns (P as rows {coarse index: weight}, nc)."""
    n = len(a)
    idx, nc = [-1] * n, 0
    for i in range(n):
        if c[i]:
            idx[i], nc = nc, nc + 1
    p = []
    for i in range(n):
        if c[i]:
            p.append({idx[i]: 1})
            continue
        row = a[i]
        diag = row[i] + sum(v for j, v in row.items() if j != i and v > 0)
        ci = [j for j in s[i] if c[j] and row[j] < 0]
        neg = sum(v for j, v in row.items() if j != i and v < 0)
        den = sum(row[j] for j in ci)
        if not ci or diag == 0:
            p.append({})
            continue
        alpha = neg / den
        p.append({idx[j]: -alpha * row[j] / diag for j in ci})
    return p, nc


def galerkin(a, p, nc):
    """P^T A P."""
    ap = []
    for row in a:
        acc = {}
        for j, v in row.items():
            for q, w in p[j].items():
                acc[q] = acc.get(q, 0) + v * w
        ap.append(acc)
    out = [dict() for _ in range(nc)]
    for i, prow in enumerate(p):
        for q, w in prow.items():
            o = out[q]
            for r, v in ap[i].items():
                o[r] = o.get(r, 0) + w * v
    return out


def setup(a, theta=0.25, max_coarse=20, max_levels=25, stats=None):
    """Build the hierarchy: a list of (A_l, P_l); the last has P = None."""
    levels = []
    while True:                                        # @line L10
        n = len(a)
        if n <= max_coarse or len(levels) + 1 >= max_levels:
            break
        s = strength(a, theta)                         # @line L11
        c = split(s)                                   # @line L12
        p, nc = interpolation(a, s, c)                 # @line L13
        if nc == 0 or nc >= n:
            break
        levels.append((a, p))
        a = galerkin(a, p, nc)                         # @line L14
    levels.append((a, None))                           # @line L15
    if stats is not None:
        z0, n0 = nnz(levels[0][0]), len(levels[0][0])
        stats["levels"] = len(levels)
        stats["sizes"] = [len(l[0]) for l in levels]
        stats["operator_complexity"] = sum(nnz(l[0]) for l in levels) / z0
        stats["grid_complexity"] = sum(len(l[0]) for l in levels) / n0
    return levels


def lu_solve_dense(a, b):
    """Gaussian elimination with partial pivoting; raises Singular."""
    n = len(a)
    m = [[a[i].get(j, 0) for j in range(n)] + [b[i]] for i in range(n)]
    big = max((abs(v) for r in m for v in r[:n]), default=0)
    for k in range(n):
        piv = max(range(k, n), key=lambda i: abs(m[i][k]))
        if m[piv][k] == 0 or abs(m[piv][k]) <= 1e-14 * big * n:
            raise Singular("singular coarsest matrix")
        m[k], m[piv] = m[piv], m[k]
        for i in range(k + 1, n):
            f = m[i][k] / m[k][k]
            if f:
                for j in range(k, n + 1):
                    m[i][j] -= f * m[k][j]
    x = [0] * n
    for i in range(n - 1, -1, -1):
        x[i] = (m[i][n] - sum(m[i][j] * x[j] for j in range(i + 1, n))) / m[i][i]
    return x


def gauss_seidel(a, b, x, backward=False):
    n = len(a)
    for i in (range(n - 1, -1, -1) if backward else range(n)):
        row = a[i]
        s = b[i]
        for j, v in row.items():
            if j != i:
                s -= v * x[j]
        x[i] = s / row[i]


def vcycle(levels, b, x=None, l=0, nu1=1, nu2=1):
    """One V-cycle for A_l x = b; returns the new x."""
    a, p = levels[l]
    if p is None:                                      # @line L1
        return lu_solve_dense(a, b)                    # @line L2
    x = [0 * v for v in b] if x is None else list(x)
    for _ in range(nu1):
        gauss_seidel(a, b, x)                          # @line L3
    r = [bi - ai for bi, ai in zip(b, matvec(a, x))]   # @line L4
    nc = len(levels[l + 1][0])
    rc = [0 * v for v in b[:1]] * nc
    for i, prow in enumerate(p):                       # @line L5
        for q, w in prow.items():
            rc[q] += w * r[i]
    ec = vcycle(levels, rc, None, l + 1, nu1, nu2)     # @line L6
    for i, prow in enumerate(p):                       # @line L7
        for q, w in prow.items():
            x[i] += w * ec[q]
    for _ in range(nu2):
        gauss_seidel(a, b, x, backward=True)           # @line L8
    return x                                           # @line L9


def norm2(v):
    return math.sqrt(sum(float(t) * float(t) for t in v))


def solve(a, b, tol=1e-8, maxit=100, levels=None, **kw):
    """Stand-alone AMG iteration. Returns (x, info) where info has
    'converged', 'iterations' and 'history' (residual 2-norms)."""
    if levels is None:
        levels = setup(a, **kw)
    x = [0.0] * len(b)
    nb = norm2(b) or 1.0
    hist = [norm2(b)]
    for it in range(1, maxit + 1):
        x = vcycle(levels, b, x)
        r = norm2([bi - ai for bi, ai in zip(b, matvec(a, x))])
        hist.append(r)
        if not math.isfinite(r) or r > 1e8 * nb:
            return x, {"converged": False, "iterations": it, "history": hist}
        if r <= tol * nb:
            return x, {"converged": True, "iterations": it, "history": hist}
    return x, {"converged": False, "iterations": maxit, "history": hist}


def pcg(a, b, tol=1e-8, maxit=200, levels=None, **kw):
    """Conjugate gradients preconditioned by one V-cycle from zero."""
    if levels is None:
        levels = setup(a, **kw)
    n = len(b)
    x = [0.0] * n
    r = list(b)
    zv = vcycle(levels, r)
    d = list(zv)
    rz = sum(ri * zi for ri, zi in zip(r, zv))
    nb = norm2(b) or 1.0
    for it in range(1, maxit + 1):
        ad = matvec(a, d)
        alpha = rz / sum(di * adi for di, adi in zip(d, ad))
        x = [xi + alpha * di for xi, di in zip(x, d)]
        r = [ri - alpha * adi for ri, adi in zip(r, ad)]
        if norm2(r) <= tol * nb:
            return x, {"converged": True, "iterations": it}
        zv = vcycle(levels, r)
        rz2 = sum(ri * zi for ri, zi in zip(r, zv))
        d = [zi + (rz2 / rz) * di for zi, di in zip(zv, d)]
        rz = rz2
    return x, {"converged": False, "iterations": maxit}


def poisson2d(k, eps=1.0):
    """5-point matrix of -eps u_xx - u_yy on a k by k grid, Dirichlet."""
    a = []
    for r in range(k):
        for c in range(k):
            row = {r * k + c: 2 * eps + 2.0}
            if c > 0:
                row[r * k + c - 1] = -eps
            if c + 1 < k:
                row[r * k + c + 1] = -eps
            if r > 0:
                row[(r - 1) * k + c] = -1.0
            if r + 1 < k:
                row[(r + 1) * k + c] = -1.0
            a.append(row)
    return a
test_algebraic_multigrid.py 225 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/algebraic-multigrid/. The authors are listed in prov/EZNBXD.jsonl.
import math
import random
from fractions import Fraction as F

import pytest

from algebraic_multigrid import (Singular, matvec, strength, split, interpolation,
                                 galerkin, setup, lu_solve_dense, gauss_seidel,
                                 vcycle, solve, pcg, poisson2d)


def tridiag(n, d=2, o=-1, kind=F):
    a = [{i: kind(d)} for i in range(n)]
    for i in range(n - 1):
        a[i][i + 1] = kind(o)
        a[i + 1][i] = kind(o)
    return a


def rand_spd(rng, n, kind=float, positive=False):
    """Symmetric, strictly diagonally dominant with positive diagonal,
    hence SPD. Off-diagonals mostly negative; some positive if asked."""
    a = [dict() for _ in range(n)]
    p = rng.choice([0.15, 0.3, 0.6])
    for i in range(n):
        for j in range(i):
            if rng.random() < p:
                v = -rng.randint(1, 9)
                if positive and rng.random() < 0.2:
                    v = -v
                a[i][j] = a[j][i] = kind(v)
    for i in range(n):
        a[i][i] = kind(sum(abs(v) for v in a[i].values()) + rng.randint(0, 3) + 1)
    return a


def dense(a, n):
    return [[a[i].get(j, 0) for j in range(n)] for i in range(n)]


def energy(a, e):
    return sum(x * y for x, y in zip(e, matvec(a, e)))


def test_page_example():
    a = tridiag(7)
    s = strength(a, 0.25)
    c = split(s)
    assert [i + 1 for i in range(7) if c[i]] == [2, 4, 6]
    p, nc = interpolation(a, s, c)
    assert nc == 3 and p[0] == {0: F(1, 2)} and p[2] == {0: F(1, 2), 1: F(1, 2)}
    ac = galerkin(a, p, nc)
    assert dense(ac, 3) == [[1, F(-1, 2), 0], [F(-1, 2), 1, F(-1, 2)], [0, F(-1, 2), 1]]
    lv = setup(a, max_coarse=3)
    assert len(lv) == 2
    b = [F(1)] * 7
    xs = [F(i * (8 - i), 2) for i in range(1, 8)]
    x0 = [F(0)] * 7
    x1 = vcycle(lv, b, x0)
    assert x1[0] == F(27601, 8192) and x1[6] == F(7, 2)
    e0 = [u - v for u, v in zip(xs, x0)]
    e1 = [u - v for u, v in zip(xs, x1)]
    assert energy(a, e1) / energy(a, e0) == F(1952217, 469762048)


def test_zero_row_sums_preserved_exactly():
    rng = random.Random(31)
    for _ in range(500):
        n = rng.randint(2, 14)
        a = rand_spd(rng, n, F)
        for i in range(n):  # make row sums zero: a singular M-matrix
            a[i][i] = -sum(v for j, v in a[i].items() if j != i)
        s = strength(a, 0.25)
        c = split(s)
        p, nc = interpolation(a, s, c)
        for i in range(n):
            if not c[i] and p[i]:
                assert sum(p[i].values()) == 1
        if nc:
            ac = galerkin(a, p, nc)
            for row in ac:
                assert sum(row.values()) == 0


def test_galerkin_against_dense_product():
    rng = random.Random(8)
    for _ in range(500):
        n = rng.randint(1, 12)
        a = rand_spd(rng, n, F, positive=True)
        s = strength(a, rng.choice([0.25, 0.5]))
        c = split(s)
        p, nc = interpolation(a, s, c)
        ad = dense(a, n)
        pd = [[p[i].get(j, 0) for j in range(nc)] for i in range(n)]
        ref = [[sum(pd[k][i] * ad[k][l] * pd[l][j] for k in range(n) for l in range(n))
                for j in range(nc)] for i in range(nc)]
        assert dense(galerkin(a, p, nc), nc) == ref
        for i in range(n):  # splitting property used by direct interpolation
            if not c[i]:
                ci = {j for j in s[i] if c[j]}
                assert ci or not any(a[i][j] < 0 for j in s[i])
                for j in s[i]:
                    if not c[j]:
                        assert s[j] & ci


def test_vcycle_contracts_energy_norm():
    rng = random.Random(2024)
    worst = 0.0
    for t in range(500):
        n = rng.randint(1, 30)
        a = rand_spd(rng, n, float, positive=(t % 3 == 0))
        lv = setup(a, max_coarse=rng.choice([1, 3, 6]))
        xs = [rng.uniform(-1, 1) for _ in range(n)]
        b = matvec(a, xs)
        x0 = [rng.uniform(-1, 1) for _ in range(n)]
        x1 = vcycle(lv, b, x0)
        e0 = [u - v for u, v in zip(xs, x0)]
        e1 = [u - v for u, v in zip(xs, x1)]
        q = math.sqrt(energy(a, e1) / energy(a, e0))
        assert q < 1 + 1e-9
        worst = max(worst, q)
    assert worst < 0.9  # observed on these diagonally dominant matrices


def test_vcycle_preconditioner_symmetric():
    rng = random.Random(6)
    for _ in range(60):
        n = rng.randint(2, 25)
        a = rand_spd(rng, n, float)
        lv = setup(a, max_coarse=2)
        u = [rng.uniform(-1, 1) for _ in range(n)]
        v = [rng.uniform(-1, 1) for _ in range(n)]
        bu, bv = vcycle(lv, u), vcycle(lv, v)
        lhs = sum(x * y for x, y in zip(u, bv))
        rhs = sum(x * y for x, y in zip(v, bu))
        assert abs(lhs - rhs) <= 1e-10 * (abs(lhs) + 1)
        assert sum(x * y for x, y in zip(u, bu)) > 0


def test_poisson_iterations_independent_of_size():
    its = []
    for k in (8, 16, 32):
        a = poisson2d(k)
        st = {}
        lv = setup(a, stats=st)
        rng = random.Random(k)
        b = [rng.uniform(-1, 1) for _ in a]
        x, info = solve(a, b, levels=lv)
        h = info["history"]
        assert info["converged"]
        assert max(h[i + 1] / h[i] for i in range(2, len(h) - 1)) < 0.25  # observed
        assert st["operator_complexity"] < 2.5 and st["grid_complexity"] < 1.8
        r = [bi - ai for bi, ai in zip(b, matvec(a, x))]
        assert math.sqrt(sum(t * t for t in r)) <= 1e-8 * math.sqrt(sum(t * t for t in b))
        _, info2 = pcg(a, b, levels=lv)
        assert info2["converged"] and info2["iterations"] < info["iterations"]
        its.append(info["iterations"])
    assert max(its) - min(its) <= 1 and max(its) <= 12


def test_anisotropic_semicoarsening():
    for eps in (0.001, 0.01):
        a = poisson2d(24, eps)
        st = {}
        lv = setup(a, stats=st)
        assert st["sizes"][1] == 288  # coarsened along the strong direction only
        b = [1.0] * len(a)
        _, info = solve(a, b, levels=lv)
        assert info["converged"] and info["iterations"] <= 13


def test_edge_cases():
    x, info = solve([{0: 3.0}], [6.0])
    assert x == [2.0] and info["iterations"] == 1
    d = [{i: float(i + 1)} for i in range(30)]
    st = {}
    lv = setup(d, stats=st)
    assert st["levels"] == 1
    x, info = solve(d, [1.0] * 30, levels=lv)
    assert info["iterations"] == 1 and abs(x[29] - 1 / 30) < 1e-15
    low = [{i: 4.0} for i in range(40)]
    for i in range(1, 40):
        low[i][i - 1] = -1.0
        if i > 1:
            low[i][i - 2] = -1.0
    x, info = solve(low, [1.0] * 40, max_coarse=4)
    assert info["iterations"] == 1  # forward Gauss-Seidel is exact
    assert lu_solve_dense([{1: F(1)}, {0: F(2)}], [F(3), F(4)]) == [2, 3]  # pivot swap
    h = [{j: F(1, i + j + 1) for j in range(8)} for i in range(8)]
    st = {}
    lv = setup(h, max_coarse=2, stats=st)
    assert st["levels"] == 1  # no negative couplings: no coarse grid
    xs = [F(1)] * 8
    assert vcycle(lv, matvec(h, xs)) == xs


def test_wilkinson_growth_in_coarse_solver():
    n = 12
    w = [{j: F(1) if j == i or j == n - 1 else F(-1) if j < i else F(0)
          for j in range(n)} for i in range(n)]
    xs = [F(i - 5) for i in range(n)]
    assert lu_solve_dense(w, matvec(w, xs)) == xs


def test_singular_and_indefinite_reported():
    a = tridiag(9, kind=float)
    a[0][0] = a[8][8] = 1.0  # Neumann: row sums zero, singular
    with pytest.raises(Singular):
        solve(a, [0.0] * 9, max_coarse=3)
    shifted = poisson2d(12)
    for row_i, row in enumerate(shifted):
        row[row_i] -= 1.0  # indefinite: smallest eigenvalue < 0
    _, info = solve(shifted, [1.0] * 144, maxit=60)
    assert not info["converged"]


def test_iteration_limit_reported():
    a = poisson2d(10)
    _, info = solve(a, [1.0] * 100, tol=1e-30, maxit=4)
    assert not info["converged"] and info["iterations"] == 4
    h = info["history"]
    assert all(h[i + 1] < h[i] for i in range(len(h) - 1))

What the next level needs

  • the tests did not run in this build

Read the full page about Algebraic multigrid