Multifrontal method in Python
Sparse direct factorization that organizes work into dense frontal matrices along the elimination tree.
- File
multifrontal_method.py, 191 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/multifrontal-method/. The authors are listed in prov/QT9W33.jsonl.
"""Multifrontal Cholesky (and exact LDL^T) factorization of a sparse symmetric
positive definite matrix, along the elimination tree, with frontal matrices,
update matrices on a stack, and extend-add.
The matrix is given as a dense list of lists (zeros are structural zeros) that is
already in the desired elimination order. Entries may be floats (mode
"cholesky", A = L L^T) or Fractions (mode "ldlt", A = L D L^T with unit L, exact).
Everything is iterative; there is no recursion.
"""
import math
def elimination_tree(a) -> list[int]:
"""Liu's algorithm with path compression; parent[j] = -1 for roots."""
n = len(a)
parent = [-1] * n
anc = [-1] * n
for i in range(n):
for k in range(i):
if a[i][k] != 0:
while k != -1 and k != i: # climb from k to the root of its current subtree
nxt = anc[k]
anc[k] = i
if nxt == -1:
parent[k] = i
k = nxt
return parent
def column_structures(a, parent) -> list[list[int]]:
"""struct[j] = sorted rows i > j with l_ij != 0: the pattern of column j of A
below the diagonal, merged with the structures of the children minus j."""
n = len(a)
s = [set(i for i in range(j + 1, n) if a[i][j] != 0) for j in range(n)]
for j in range(n): # children have smaller indices, so they are complete
p = parent[j]
if p != -1:
s[p] |= s[j] - {p}
return [sorted(x) for x in s]
def children_lists(parent) -> list[list[int]]:
ch = [[] for _ in parent]
for j, p in enumerate(parent):
if p != -1:
ch[p].append(j)
return ch
def postorder(parent, child_order=None) -> list[int]:
"""Iterative postorder; child_order[j] lists the children of j in visiting order."""
ch = child_order if child_order is not None else children_lists(parent)
out = []
for r in [j for j, p in enumerate(parent) if p == -1]:
stack = [(r, 0)]
while stack:
v, t = stack.pop()
if t < len(ch[v]):
stack.append((v, t + 1))
stack.append((ch[v][t], 0))
else:
out.append(v)
return out
def tri(k: int) -> int:
return k * (k + 1) // 2
def stack_peaks(parent, struct, child_order):
"""Liu's recurrence for the working storage (lower triangles) of the
multifrontal stack: peak[j] over the subtree of j, front included."""
n = len(parent)
peak = [0] * n
for j in postorder(parent, child_order):
acc, best = 0, 0
for c in child_order[j]:
best = max(best, acc + peak[c])
acc += tri(len(struct[c]))
peak[j] = max(best, acc + tri(len(struct[j]) + 1))
return peak
def liu_child_order(parent, struct):
"""Children sorted by decreasing peak - update size (Liu, 1986), bottom up."""
ch = children_lists(parent)
n = len(parent)
peak = [0] * n
for j in postorder(parent, ch):
ch[j].sort(key=lambda c: -(peak[c] - tri(len(struct[c]))))
acc, best = 0, 0
for c in ch[j]:
best = max(best, acc + peak[c])
acc += tri(len(struct[c]))
peak[j] = max(best, acc + tri(len(struct[j]) + 1))
return ch
def multifrontal(a, mode: str = "cholesky", child_order: str = "liu", stats=None):
"""Return L (mode "cholesky") or (L, D) (mode "ldlt"), both dense lists.
If stats is a dict it receives: flops (multiplications, divisions and
subtractions of the partial factorizations), assembly (additions in
extend-add), peak (largest number of words on the stack plus the current
front, lower triangles), fronts (front orders by column).
"""
n = len(a)
parent = elimination_tree(a) # @line L1
struct = column_structures(a, parent)
ch = liu_child_order(parent, struct) if child_order == "liu" else children_lists(parent)
zero = a[0][0] * 0 if n else 0
lmat = [[zero] * n for _ in range(n)]
dvec = [zero] * n
stack = [] # @line L2
flops = assembly = peak = used = 0
fronts = [0] * n
for j in postorder(parent, ch): # @line L3
idx = [j] + struct[j]
pos = {g: t for t, g in enumerate(idx)}
f = len(idx)
fronts[j] = f
front = [[zero] * (t + 1) for t in range(f)] # @line L4
peak = max(peak, used + tri(f))
for t, g in enumerate(idx): # @line L5
front[t][0] = a[g][j]
for _ in ch[j]: # @line L6
uidx, u = stack.pop() # @line L7
used -= tri(len(uidx))
loc = [pos[g] for g in uidx]
for r, lr in enumerate(loc):
row = u[r]
fr = front[lr]
for c in range(r + 1):
fr[loc[c]] += row[c]
assembly += 1
piv = front[0][0] # @line L8
if mode == "cholesky":
if not piv > 0:
raise ValueError("matrix is not positive definite")
d = math.sqrt(piv)
col = [front[t][0] / d for t in range(1, f)]
lmat[j][j] = d
mult = col
flops += 1 + (f - 1)
else:
if piv == 0:
raise ZeroDivisionError("zero pivot")
dvec[j] = piv
lmat[j][j] = piv / piv
col = [front[t][0] / piv for t in range(1, f)]
mult = [front[t][0] for t in range(1, f)]
flops += f - 1
for t in range(1, f):
lmat[idx[t]][j] = col[t - 1]
upd = [] # @line L9
for r in range(1, f):
fr = front[r]
lr = col[r - 1]
upd.append([fr[c] - lr * mult[c - 1] for c in range(1, r + 1)])
flops += 2 * r
if upd: # @line L10
stack.append((struct[j], upd))
used += tri(len(struct[j]))
if stats is not None:
stats.update(flops=flops, assembly=assembly, peak=peak, fronts=fronts,
parent=parent, struct=struct)
if mode == "cholesky":
return lmat # @line L11
return lmat, dvec
def dense_ldlt_exact(a):
"""Reference right-looking LDL^T in the arithmetic of the entries (use Fractions)."""
n = len(a)
b = [row[:] for row in a]
lmat = [[b[0][0] * 0] * n for _ in range(n)]
d = [0] * n
for k in range(n):
d[k] = b[k][k]
lmat[k][k] = d[k] / d[k]
for i in range(k + 1, n):
lmat[i][k] = b[i][k] / d[k]
for i in range(k + 1, n):
for j in range(k + 1, i + 1):
b[i][j] -= lmat[i][k] * b[j][k]
b[j][i] = b[i][j]
return lmat, d
test_multifrontal_method.py 179 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/multifrontal-method/. The authors are listed in prov/QT9W33.jsonl.
import itertools
import math
import random
from fractions import Fraction
import pytest
from multifrontal_method import (
children_lists, column_structures, dense_ldlt_exact, elimination_tree, liu_child_order,
multifrontal, postorder, stack_peaks, tri,
)
U = 2.0 ** -53
def random_spd(rng, n, p, exact=False):
a = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(i):
if rng.random() < p:
v = rng.randint(-9, 9) or 1
a[i][j] = a[j][i] = v
for i in range(n):
a[i][i] = sum(abs(x) for x in a[i]) + rng.randint(1, 5)
if exact:
return [[Fraction(x) for x in row] for row in a]
return [[float(x) * rng.uniform(0.5, 2.0) if x and i != j else float(x) for j, x in enumerate(row)]
for i, row in enumerate(a)]
def symmetrize(a):
n = len(a)
for i in range(n):
for j in range(i):
a[j][i] = a[i][j]
for i in range(n):
a[i][i] = math.fsum(abs(a[i][j]) for j in range(n) if j != i) + 1.0
return a
def grid_matrix(k):
n = k * k
a = [[0.0] * n for _ in range(n)]
for i in range(k):
for j in range(k):
v = i * k + j
a[v][v] = 4.0
for w in ((v + 1) if j + 1 < k else None, (v + k) if i + 1 < k else None):
if w is not None:
a[v][w] = a[w][v] = -1.0
return a
def test_edge_cases():
assert multifrontal([[4.0]]) == [[2.0]]
l = multifrontal([[4.0, 2.0], [2.0, 5.0]])
assert l == [[2.0, 0.0], [1.0, 2.0]]
diag = [[float(i + 1) if i == j else 0.0 for j in range(5)] for i in range(5)]
st = {}
l = multifrontal(diag, stats=st)
assert all(l[i][i] == math.sqrt(i + 1) for i in range(5))
assert st["parent"] == [-1] * 5 and st["assembly"] == 0
with pytest.raises(ValueError):
multifrontal([[1.0, 2.0], [2.0, 1.0]])
def test_exact_ldlt_matches_dense_reference():
rng = random.Random(31)
for _ in range(60):
n = rng.randint(1, 14)
a = random_spd(rng, n, rng.uniform(0.1, 0.5), exact=True)
ref_l, ref_d = dense_ldlt_exact(a)
for order in ("liu", "natural"):
l, d = multifrontal(a, mode="ldlt", child_order=order)
assert l == ref_l and d == ref_d
def test_elimination_tree_and_structures():
rng = random.Random(32)
for _ in range(60):
n = rng.randint(1, 16)
a = random_spd(rng, n, rng.uniform(0.05, 0.4), exact=True)
parent = elimination_tree(a)
struct = column_structures(a, parent)
ref_l, _ = dense_ldlt_exact(a)
for j in range(n):
nz = [i for i in range(j + 1, n) if ref_l[i][j] != 0]
assert set(nz) <= set(struct[j]) # exact cancellation can only remove entries
assert parent[j] == (struct[j][0] if struct[j] else -1)
assert all(c < j for c in children_lists(parent)[j])
po = postorder(parent)
assert sorted(po) == list(range(n))
seen = set()
for j in po:
assert all(c in seen for c in children_lists(parent)[j])
seen.add(j)
def test_backward_error_of_float_cholesky():
rng = random.Random(33)
for _ in range(40):
n = rng.randint(1, 12)
a = random_spd(rng, n, rng.uniform(0.1, 0.5))
a = symmetrize(a)
l = multifrontal(a)
gam = (n + 1) * U / (1 - (n + 1) * U)
for i in range(n):
for j in range(i + 1):
exact = sum(Fraction(l[i][t]) * Fraction(l[j][t]) for t in range(j + 1))
absl = sum(abs(l[i][t]) * abs(l[j][t]) for t in range(j + 1))
assert abs(Fraction(a[i][j]) - exact) <= Fraction(gam * absl)
def test_flop_and_assembly_counts():
rng = random.Random(34)
for _ in range(40):
n = rng.randint(1, 20)
a = symmetrize(random_spd(rng, n, rng.uniform(0.05, 0.4)))
st = {}
multifrontal(a, stats=st)
c = [len(s) for s in st["struct"]]
assert st["flops"] == sum(1 + cj + cj * (cj + 1) for cj in c)
assert st["assembly"] == sum(tri(c[j]) for j in range(n) if st["parent"][j] != -1)
assert st["fronts"] == [cj + 1 for cj in c]
def test_stack_peak_matches_liu_recurrence_and_is_optimal():
rng = random.Random(35)
tried = 0
for _ in range(80):
n = rng.randint(2, 11)
a = symmetrize(random_spd(rng, n, rng.uniform(0.05, 0.3)))
parent = elimination_tree(a)
struct = column_structures(a, parent)
roots = [j for j in range(n) if parent[j] == -1]
for mode in ("liu", "natural"):
st = {}
multifrontal(a, child_order=mode, stats=st)
ch = liu_child_order(parent, struct) if mode == "liu" else children_lists(parent)
assert st["peak"] == max(stack_peaks(parent, struct, ch)[r] for r in roots)
ch0 = children_lists(parent)
if math.prod(math.factorial(len(c)) for c in ch0) > 2000:
continue
tried += 1
liu = stack_peaks(parent, struct, liu_child_order(parent, struct))
best = min(max(stack_peaks(parent, struct, list(map(list, combo)))[r] for r in roots)
for combo in itertools.product(*[itertools.permutations(c) for c in ch0]))
assert max(liu[r] for r in roots) == best
assert tried > 40
def test_grid_fronts_and_agreement_with_dense_cholesky():
k = 6
a = grid_matrix(k)
n = k * k
st = {}
l = multifrontal(a, stats=st)
# natural order: band of width k, so every front has order at most k + 1
assert max(st["fronts"]) == k + 1
# compare with a plain dense Cholesky
r = [[0.0] * n for _ in range(n)]
for j in range(n):
s = a[j][j] - math.fsum(r[j][t] ** 2 for t in range(j))
r[j][j] = math.sqrt(s)
for i in range(j + 1, n):
r[i][j] = (a[i][j] - math.fsum(r[i][t] * r[j][t] for t in range(j))) / r[j][j]
assert all(abs(l[i][j] - r[i][j]) <= 1e-14 * 4 for i in range(n) for j in range(n))
def test_worked_example():
a = [[4.0, 0.0, 2.0, 0.0], [0.0, 9.0, 3.0, 3.0], [2.0, 3.0, 6.0, -1.0], [0.0, 3.0, -1.0, 6.0]]
st = {}
l = multifrontal(a, stats=st)
assert st["parent"] == [2, 2, 3, -1]
assert st["struct"] == [[2], [2, 3], [3], []]
assert l == [[2.0, 0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0], [1.0, 1.0, 2.0, 0.0], [0.0, 1.0, -1.0, 2.0]]
What the next level needs
- the tests did not run in this build