Golub-Kahan SVD algorithm in Python
Reduce A to upper bidiagonal form with Householder reflectors, then run implicitly shifted QR steps on the bidiagonal matrix until it is diagonal. The standard dense SVD method.
- File
golub_kahan_svd_algorithm.py, 186 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/golub-kahan-svd-algorithm/. The authors are listed in prov/K72EMJ.jsonl.
"""Golub-Kahan SVD: Householder bidiagonalization, then implicit QR steps on
the bidiagonal matrix with a Wilkinson shift. Python standard library only.
svd(a) returns (u, sigma, v, steps) with a = u diag(sigma) v^T, u of size
m by r and v of size n by r, r = min(m, n), sigma[0] >= ... >= sigma[r-1] >= 0.
"""
import math
U = 2.0 ** -53
def _house(x):
"""Return (v, beta) with (I - beta v v^T) x = -sign(x0) ||x|| e1."""
alpha = math.sqrt(sum(t * t for t in x))
v = list(x)
if alpha == 0.0:
return v, 0.0
v[0] += alpha if x[0] >= 0 else -alpha
vv = sum(t * t for t in v)
return v, 2.0 / vv
def bidiagonalize(a):
"""For m >= n, return (b, uq, vq) with a = uq b vq^T and b upper bidiagonal.
b is n by n (dense list), uq is m by n with orthonormal columns, vq is n by n."""
m, n = len(a), len(a[0])
b = [row[:] for row in a]
uq = [[1.0 if i == j else 0.0 for j in range(m)] for i in range(m)]
vq = [[1.0 if i == j else 0.0 for j in range(n)] for i in range(n)]
for k in range(n):
v, beta = _house([b[i][k] for i in range(k, m)])
if beta:
for j in range(k, n):
s = beta * sum(v[i - k] * b[i][j] for i in range(k, m))
for i in range(k, m):
b[i][j] -= s * v[i - k]
for i in range(m):
s = beta * sum(uq[i][r] * v[r - k] for r in range(k, m))
for r in range(k, m):
uq[i][r] -= s * v[r - k]
for i in range(k + 1, m):
b[i][k] = 0.0
if k < n - 2:
v, beta = _house([b[k][j] for j in range(k + 1, n)])
if beta:
for i in range(k, m):
s = beta * sum(b[i][j] * v[j - k - 1] for j in range(k + 1, n))
for j in range(k + 1, n):
b[i][j] -= s * v[j - k - 1]
for i in range(n):
s = beta * sum(vq[i][j] * v[j - k - 1] for j in range(k + 1, n))
for j in range(k + 1, n):
vq[i][j] -= s * v[j - k - 1]
for j in range(k + 2, n):
b[k][j] = 0.0
sq = [row[:n] for row in b[:n]]
return sq, [row[:n] for row in uq], vq
def _givens(f, g):
"""Return (c, s, r) with c f + s g = r and -s f + c g = 0."""
if g == 0.0:
return 1.0, 0.0, f
r = math.hypot(f, g)
return f / r, g / r, r
def _rot_cols(x, i, j, c, s, rows):
for r in rows:
p, q = x[r][i], x[r][j]
x[r][i] = c * p + s * q
x[r][j] = -s * p + c * q
def _rot_rows(x, i, j, c, s, cols):
for t in cols:
p, q = x[i][t], x[j][t]
x[i][t] = c * p + s * q
x[j][t] = -s * p + c * q
def _wilkinson_shift(b, p, q):
"""Eigenvalue of the trailing 2 by 2 block of B^T B (block p..q) closer to its last entry."""
t11 = b[q - 1][q - 1] ** 2 + (b[q - 2][q - 1] ** 2 if q - 1 > p else 0.0)
t12 = b[q - 1][q - 1] * b[q - 1][q]
t22 = b[q][q] ** 2 + b[q - 1][q] ** 2
d = (t11 - t22) / 2.0
if d == 0.0 and t12 == 0.0:
return t22
return t22 - t12 * t12 / (d + math.copysign(math.hypot(d, t12), d if d != 0 else 1.0))
def gk_step(b, p, q, uq, vq, mu):
"""One implicit-shift Golub-Kahan step on the block p..q of the bidiagonal b."""
n, m = len(b), len(uq)
y = b[p][p] ** 2 - mu # @line L6
z = b[p][p] * b[p][p + 1]
for k in range(p, q): # @line L7
c, s, _ = _givens(y, z) # @line L8
_rot_cols(b, k, k + 1, c, s, range(max(p, k - 1), k + 2))
_rot_cols(vq, k, k + 1, c, s, range(len(vq)))
if k > p:
b[k - 1][k + 1] = 0.0
y, z = b[k][k], b[k + 1][k] # @line L9
c, s, _ = _givens(y, z)
_rot_rows(b, k, k + 1, c, s, range(k, min(q, k + 2) + 1))
_rot_cols(uq, k, k + 1, c, s, range(m))
b[k + 1][k] = 0.0
if k < q - 1: # @line L10
y, z = b[k][k + 1], b[k][k + 2]
def _chase_zero_diagonal(b, p, q, k, uq, vq):
"""b[k][k] is zero: rotate the block so that row k (or column q) becomes zero."""
m, n = len(uq), len(b)
if k < q:
for j in range(k + 1, q + 1):
c, s, _ = _givens(b[j][j], b[k][j])
_rot_rows(b, j, k, c, s, range(j, min(q, j + 1) + 1))
_rot_cols(uq, j, k, c, s, range(m))
b[k][j] = 0.0
else:
for j in range(q - 1, p - 1, -1):
c, s, _ = _givens(b[j][j], b[j][q])
_rot_cols(b, j, q, c, s, range(max(p, j - 1), j + 1))
_rot_cols(vq, j, q, c, s, range(n))
b[j][q] = 0.0
def svd_bidiagonal(b, uq, vq, max_steps_per_value=40, trace=None):
"""Diagonalize the upper bidiagonal b in place; returns the number of GK steps."""
n = len(b)
anorm = max([abs(b[i][i]) for i in range(n)] + [abs(b[i][i + 1]) for i in range(n - 1)] + [0.0])
steps, q = 0, n - 1
limit = max_steps_per_value * max(n, 1)
while q > 0:
for i in range(n - 1): # @line L2
if abs(b[i][i + 1]) <= U * (abs(b[i][i]) + abs(b[i + 1][i + 1])):
b[i][i + 1] = 0.0
while q > 0 and b[q - 1][q] == 0.0: # @line L3
q -= 1
if q == 0:
break
p = q - 1
while p > 0 and b[p - 1][p] != 0.0:
p -= 1
zero = [k for k in range(p, q + 1) if abs(b[k][k]) <= U * anorm]
if zero: # @line L4
b[zero[0]][zero[0]] = 0.0
_chase_zero_diagonal(b, p, q, zero[0], uq, vq)
continue
if steps >= limit:
raise ArithmeticError("Golub-Kahan iteration did not converge")
if trace is not None:
trace.append([abs(b[i][i + 1]) for i in range(n - 1)])
mu = _wilkinson_shift(b, p, q) # @line L5
gk_step(b, p, q, uq, vq, mu)
steps += 1
return steps
def transpose(a):
return [list(col) for col in zip(*a)]
def svd(a, trace=None):
"""Return (u, sigma, v, steps) with a = u diag(sigma) v^T and sigma sorted descending."""
m, n = len(a), len(a[0])
if m < n:
v, sigma, u, steps = svd(transpose(a), trace)
return u, sigma, v, steps
b, uq, vq = bidiagonalize(a) # @line L1
steps = svd_bidiagonal(b, uq, vq, trace=trace)
sigma = [b[i][i] for i in range(n)]
for i in range(n): # @line L11
if sigma[i] < 0:
sigma[i] = -sigma[i]
for r in range(n):
vq[r][i] = -vq[r][i]
order = sorted(range(n), key=lambda i: -sigma[i])
sigma = [sigma[i] for i in order]
u = [[row[i] for i in order] for row in uq]
v = [[row[i] for i in order] for row in vq]
return u, sigma, v, steps
test_golub_kahan_svd_algorithm.py 155 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/golub-kahan-svd-algorithm/. The authors are listed in prov/K72EMJ.jsonl.
import math
import random
from fractions import Fraction
from golub_kahan_svd_algorithm import U, bidiagonalize, svd
def check(a, c=10.0):
"""Run svd and assert residual and orthogonality bounds; return (sigma, steps)."""
u, s, v, steps = svd(a)
m, n = len(a), len(a[0])
r = min(m, n)
scale = max([abs(x) for row in a for x in row] + [1e-300])
for i in range(m):
for j in range(n):
got = sum(u[i][k] * s[k] * v[j][k] for k in range(r))
assert abs(a[i][j] - got) <= c * max(m, n) * U * scale * r
for x, rows in ((u, m), (v, n)):
for p in range(r):
for q in range(r):
dot = sum(x[i][p] * x[i][q] for i in range(rows))
assert abs(dot - (p == q)) <= c * max(m, n) * U
assert all(s[i] >= s[i + 1] for i in range(r - 1)) and s[-1] >= 0
return s, steps
def det_exact(a):
a = [[Fraction(x) for x in row] for row in a]
n, d = len(a), Fraction(1)
for k in range(n):
p = next((i for i in range(k, n) if a[i][k] != 0), None)
if p is None:
return Fraction(0)
if p != k:
a[k], a[p] = a[p], a[k]
d = -d
d *= a[k][k]
for i in range(k + 1, n):
f = a[i][k] / a[k][k]
for j in range(k, n):
a[i][j] -= f * a[k][j]
return d
def householder_matrix(n, seed):
rnd = random.Random(seed)
w = [rnd.gauss(0, 1) for _ in range(n)]
ww = sum(t * t for t in w)
return [[(i == j) - 2 * w[i] * w[j] / ww for j in range(n)] for i in range(n)]
def matmul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(len(b))) for j in range(len(b[0]))] for i in range(len(a))]
def test_page_example():
a = [[1, 2, 3], [4, 5, 6], [7, 8, 10], [1, 0, 1]]
b, _, _ = bidiagonalize(a)
assert abs(abs(b[0][0]) - 8.18535) < 1e-5 and abs(abs(b[0][1]) - 15.30194) < 1e-5
assert abs(abs(b[1][2]) - 0.09756) < 1e-5 and abs(abs(b[2][2]) - 0.70867) < 1e-5
trace = []
u, s, v, steps = svd(a, trace=trace)
assert steps == 4
e2 = [t[1] for t in trace]
assert e2[1] < 0.05 * e2[0] and e2[2] <= e2[1] ** 2 # at least quadratic, observed faster
ata = [[sum(Fraction(a[k][i] * a[k][j]) for k in range(4)) for j in range(3)] for i in range(3)]
assert abs(sum(x * x for x in s) - float(sum(ata[i][i] for i in range(3)))) < 1e-12 * 306
prod = s[0] ** 2 * s[1] ** 2 * s[2] ** 2
assert abs(prod - float(det_exact(ata))) < 1e-12 * abs(float(det_exact(ata)))
assert abs(s[0] - 17.450895584636637) < 1e-12
def test_known_spectra():
n = 10
ones = [[1.0 if j in (i, i + 1) else 0.0 for j in range(n)] for i in range(n)]
s, _ = check(ones)
for k in range(1, n + 1):
assert abs(s[k - 1] - 2 * math.cos(k * math.pi / (2 * n + 1))) < 20 * n * U
n = 15
toep = [[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)]
s, _ = check(toep)
exact = sorted((2 - 2 * math.cos(k * math.pi / (n + 1)) for k in range(1, n + 1)), reverse=True)
assert max(abs(x - y) for x, y in zip(s, exact)) < 20 * n * U * 4
def test_random_seeded_and_steps():
rnd = random.Random(3)
for m, n in ((40, 30), (30, 40), (50, 50)):
a = [[rnd.gauss(0, 1) for _ in range(n)] for _ in range(m)]
s, steps = check(a)
assert steps <= 3 * min(m, n) # observed about 1.9 per singular value
a = [[rnd.randint(-5, 5) for _ in range(6)] for _ in range(6)]
s, _ = check(a)
assert abs(math.prod(s) - abs(float(det_exact(a)))) <= 1e-12 * max(1.0, math.prod(s))
def test_trivial_shapes():
assert check([[-3.0]])[0] == [3.0]
s, _ = check([[3.0, 4.0]])
assert abs(s[0] - 5) < 1e-15
s, _ = check([[3.0], [0.0], [4.0]])
assert abs(s[0] - 5) < 1e-15
s, steps = check([[0.0] * 3 for _ in range(4)])
assert s == [0.0, 0.0, 0.0] and steps == 0
s, steps = check([[0.0, 0.0, -2.0], [0.0, 5.0, 0.0], [1.0, 0.0, 0.0]])
assert s == [5.0, 2.0, 1.0]
s, _ = check([[2.0, 7.0, 1.0], [0.0, -3.0, 4.0], [0.0, 0.0, 0.5]]) # triangular
assert abs(s[0] * s[1] * s[2] - 3.0) < 1e-13
def test_zero_diagonal_paths():
for d, e in (([1.0, 0.0, 2.0, 3.0], [1.0, 1.0, 1.0]),
([1.0, 2.0, 3.0, 0.0], [1.0, 1.0, 1.0]),
([0.0, 2.0, 3.0], [1.0, 1.0])):
n = len(d)
b = [[0.0] * n for _ in range(n)]
for i in range(n):
b[i][i] = d[i]
for i in range(n - 1):
b[i][i + 1] = e[i]
s, _ = check(b)
assert s[-1] == 0.0
jordan = [[1.0 if j == i + 1 else 0.0 for j in range(5)] for i in range(5)] # J_5(0)
s, _ = check(jordan)
assert s == [1.0, 1.0, 1.0, 1.0, 0.0]
def test_repeated_and_close_values():
h = householder_matrix(6, 7)
s, _ = check([[2 * x for x in row] for row in h])
assert max(abs(x - 2) for x in s) < 30 * U
s, _ = check([[0.0, -1.0], [1.0, 0.0]]) # complex eigenvalues, equal singular values
assert max(abs(x - 1) for x in s) < 4 * U
a = matmul(matmul(householder_matrix(4, 1), [[1.0, 0, 0, 0], [0, 1 + 1e-10, 0, 0], [0, 0, 3.0, 0], [0, 0, 0, 0]]),
householder_matrix(4, 2))
s, _ = check(a)
assert abs(s[1] - (1 + 1e-10)) < 50 * U and abs(s[2] - 1) < 50 * U and s[3] < 50 * U
rank1 = [[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [-1.0, -2.0, -3.0]]
s, _ = check(rank1)
assert abs(s[0] - math.sqrt(6 * 14)) < 1e-13 and s[1] < 1e-14 and s[2] < 1e-14
def test_bidiagonalization_shape():
rnd = random.Random(11)
a = [[rnd.uniform(-1, 1) for _ in range(5)] for _ in range(8)]
b, uq, vq = bidiagonalize(a)
for i in range(5):
for j in range(5):
if j != i and j != i + 1:
assert b[i][j] == 0.0
back = matmul(matmul(uq, b), [list(r) for r in zip(*vq)])
assert max(abs(back[i][j] - a[i][j]) for i in range(8) for j in range(5)) < 100 * U
assert all(vq[0][j] == (j == 0) for j in range(5)) # first column of V is e1
What the next level needs
- the tests did not run in this build