Algorithm Search⌘K

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

Read the full page about Golub-Kahan SVD algorithm