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