Algorithm Search⌘K

Levinson-Durbin recursion in Python

Solve Toeplitz systems in O(n^2) by recursing on the matrix size.

File
levinson_durbin_recursion.py, 140 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
levinson_durbin_recursion.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/levinson-durbin-recursion/. The authors are listed in prov/XXKK03.jsonl.

"""The Levinson-Durbin recursion for symmetric Toeplitz systems.

T is the symmetric Toeplitz matrix with first row (r[0], r[1], ..., r[n-1]),
so T[i][j] = r[|i - j|]. durbin() solves the Yule-Walker equations
T_n y = -(r[1], ..., r[n]) and levinson() solves T x = b; both run in
O(n^2) flops and O(n) storage. Entries may be floats or Fractions.
Standard library only.
"""


class NotPositiveDefinite(ValueError):
    """Some reflection coefficient has |alpha| >= 1, so T is not positive definite."""


class Breakdown(ZeroDivisionError):
    """A leading principal minor is zero, so the recursion cannot continue."""


def _check(beta, check_pd, k):
    if check_pd and beta <= 0:
        raise NotPositiveDefinite("leading minor of order %d is not positive" % (k + 1))
    if beta == 0:
        raise Breakdown("leading minor of order %d is zero" % (k + 1))


def durbin(r, check_pd=True, stats=None):
    """Solve T_n y = -(r[1], ..., r[n]) where T_n has first row r[0..n-1], r[0] = 1.

    r has length n + 1. Returns (y, alphas, betas): alphas[k] is the reflection
    coefficient (the last entry of the order k+1 solution) and
    betas[k] = det T_{k+1} / det T_k for k = 0..n-1. Cost 2 n^2 + 2 n - 4 flops.
    """
    n = len(r) - 1
    if n <= 0:
        return [], [], []
    if r[0] != 1:
        raise ValueError("normalize so that r[0] = 1")
    one = r[0]
    flops = 0
    y = [-r[1]]  # @line L1
    alpha, beta = -r[1], one
    alphas, betas = [alpha], [beta]
    for k in range(1, n):  # @line L2
        beta = (one - alpha * alpha) * beta  # @line L3
        flops += 3
        _check(beta, check_pd, k)
        s = r[k + 1]
        for j in range(k):  # @line L4
            s = s + r[k - j] * y[j]
        alpha = -s / beta
        flops += 2 * k + 1
        y = [y[j] + alpha * y[k - 1 - j] for j in range(k)] + [alpha]  # @line L5
        flops += 2 * k
        alphas.append(alpha)
        betas.append(beta)
    if stats is not None:
        stats["flops"] = flops
    return y, alphas, betas


def levinson(r, b, check_pd=True, stats=None):
    """Solve T x = b with T[i][j] = r[|i - j|], T of order n = len(b), r of length >= n.

    Any r[0] != 0 is allowed (the system is scaled by 1/r[0]). With check_pd,
    raise NotPositiveDefinite unless T is positive definite; otherwise raise
    Breakdown only at an exactly zero leading minor. Cost 4 n^2 + O(n) flops.
    With integer entries and r[0] != 1 the scaling produces floats; pass
    Fractions for exact results.
    """
    n = len(b)
    if n == 0:
        return []
    r0 = r[0]
    if check_pd and r0 <= 0:
        raise NotPositiveDefinite("r[0] must be positive")
    if r0 == 0:
        raise Breakdown("r[0] is zero")
    if r0 == 1:  # @line L6
        rr, bb, flops = list(r[:n]), list(b), 0
    else:
        rr = [r0 / r0] + [v / r0 for v in r[1:n]]
        bb = [v / r0 for v in b]
        flops = 2 * n - 1
    one = rr[0]
    x = [bb[0]]  # @line L7
    if n == 1:
        if stats is not None:
            stats["flops"] = flops
        return x
    y = [-rr[1]]
    alpha, beta = -rr[1], one
    for k in range(1, n):  # @line L8
        beta = (one - alpha * alpha) * beta  # @line L9
        flops += 3
        _check(beta, check_pd, k)
        s = bb[k]
        for j in range(k):  # @line L10
            s = s - rr[j + 1] * x[k - 1 - j]
        mu = s / beta
        flops += 2 * k + 1
        x = [x[j] + mu * y[k - 1 - j] for j in range(k)] + [mu]  # @line L11
        flops += 2 * k
        if k < n - 1:  # @line L12
            s = rr[k + 1]
            for j in range(k):
                s = s + rr[j + 1] * y[k - 1 - j]
            alpha = -s / beta
            y = [y[j] + alpha * y[k - 1 - j] for j in range(k)] + [alpha]  # @line L13
            flops += 4 * k + 1
    if stats is not None:
        stats["flops"] = flops
    return x  # @line L14


def toeplitz_from_reflections(alphas, one=1):
    """Return r[0..n] with r[0] = 1 whose Durbin reflection coefficients are alphas.

    Any alphas with |alpha| < 1 give a positive definite Toeplitz matrix
    (the step-up recursion of linear prediction). Exact with Fractions.
    """
    n = len(alphas)
    r = [one]
    if n == 0:
        return r
    r.append(-alphas[0])
    y, beta = [alphas[0]], one
    for k in range(1, n):
        a = alphas[k]
        beta = (one - y[-1] * y[-1]) * beta
        s = sum(r[k - j] * y[j] for j in range(k))
        r.append(-a * beta - s)
        y = [y[j] + a * y[k - 1 - j] for j in range(k)] + [a]
    return r


def toeplitz(r, n):
    return [[r[abs(i - j)] for j in range(n)] for i in range(n)]
test_levinson_durbin_recursion.py 273 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/levinson-durbin-recursion/. The authors are listed in prov/XXKK03.jsonl.

import math
import random
from fractions import Fraction as F

import pytest

from levinson_durbin_recursion import (
    Breakdown,
    NotPositiveDefinite,
    durbin,
    levinson,
    toeplitz,
    toeplitz_from_reflections,
)

U = 2.0 ** -53


def mv(a, x):
    return [sum(p * q for p, q in zip(row, x)) for row in a]


def ge_pivots(a):
    """Exact pivots of Gaussian elimination without pivoting (None after a zero pivot)."""
    n = len(a)
    a = [row[:] for row in a]
    piv = []
    for k in range(n):
        piv.append(a[k][k])
        if a[k][k] == 0:
            return piv
        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 piv


def solve_exact(a, b):
    n = len(a)
    m = [row[:] + [v] for row, v in zip(a, b)]
    for k in range(n):
        p = next(i for i in range(k, n) if m[i][k] != 0)
        m[k], m[p] = m[p], m[k]
        for i in range(k + 1, n):
            f = m[i][k] / m[k][k]
            for j in range(k, n + 1):
                m[i][j] -= f * m[k][j]
    x = [F(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 kappa1(r):
    """Exact 1-norm condition number of the SPD Toeplitz matrix with first row r."""
    n = len(r)
    cols = [levinson(r, [F(int(i == j)) for i in range(n)]) for j in range(n)]
    ninv = max(sum(abs(v) for v in c) for c in cols)
    nt = max(sum(abs(r[abs(i - j)]) for j in range(n)) for i in range(n))
    return float(ninv * nt)


def eta(a, b, x):
    r = [p - q for p, q in zip(b, mv(a, x))]
    na = max(sum(abs(v) for v in row) for row in a)
    return max(map(abs, r)) / (na * max(map(abs, x)) + max(map(abs, b)))


def cholesky_solve(a, b):
    n = len(a)
    l = [[0.0] * n for _ in range(n)]
    for j in range(n):
        l[j][j] = math.sqrt(a[j][j] - sum(l[j][k] ** 2 for k in range(j)))
        for i in range(j + 1, n):
            l[i][j] = (a[i][j] - sum(l[i][k] * l[j][k] for k in range(j))) / l[j][j]
    y = [0.0] * n
    for i in range(n):
        y[i] = (b[i] - sum(l[i][k] * y[k] for k in range(i))) / l[i][i]
    x = [0.0] * n
    for i in range(n - 1, -1, -1):
        x[i] = (y[i] - sum(l[k][i] * x[k] for k in range(i + 1, n))) / l[i][i]
    return x


def rand_alphas(rng, n, den=16):
    return [F(rng.randint(-den + 1, den - 1), den) for _ in range(n)]


def test_page_example():
    r, b = [F(1), F(1, 2), F(1, 4)], [F(1), F(2), F(3)]
    assert levinson(r, b) == [0, F(2, 3), F(8, 3)]
    y, al, be = durbin([F(1), F(1, 2), F(1, 4), F(1, 8)])
    assert y == [F(-1, 2), 0, 0] and al == [F(-1, 2), 0, 0] and be == [1, F(3, 4), F(3, 4)]
    r = toeplitz_from_reflections([F(1, 2), F(-1, 3), F(1, 4), F(0)])
    assert r == [1, F(-1, 2), F(1, 2), F(-1, 2), F(3, 8)]


def test_exact_against_dense_solve_500():
    rng = random.Random(1)
    for _ in range(500):
        n = rng.randint(1, 9)
        al = rand_alphas(rng, n, 8)
        r = toeplitz_from_reflections(al)
        t = toeplitz(r, n)
        y, a2, be = durbin(r)
        assert a2 == al
        assert mv(t, y) == [-v for v in r[1 : n + 1]]
        piv = ge_pivots(t)
        assert be == piv  # beta_k = det T_{k+1} / det T_k, the k-th pivot
        assert all(p == math.prod(1 - a * a for a in al[:k]) for k, p in enumerate(piv))
        b = [F(rng.randint(-9, 9)) for _ in range(n)]
        assert levinson(r[:n], b) == solve_exact(t, b)
        # Unnormalized r[0].
        s = F(rng.randint(2, 7))
        if n <= 5:
            assert levinson([s * v for v in r[:n]], b) == solve_exact([[s * v for v in row] for row in t], b)


def test_positive_definiteness_detection_500():
    rng = random.Random(2)
    pd = notpd = 0
    for _ in range(500):
        n = rng.randint(1, 9)
        r = [F(rng.randint(1, 6))] + [F(rng.randint(-3, 3)) for _ in range(n - 1)]
        is_pd = all(p > 0 for p in ge_pivots(toeplitz(r, n)))
        b = [F(1)] * n
        if is_pd:
            pd += 1
            assert levinson(r, b) == solve_exact(toeplitz(r, n), b)
        else:
            notpd += 1
            with pytest.raises(NotPositiveDefinite):
                levinson(r, b)
    assert pd > 50 and notpd > 50


def test_indefinite_and_breakdown_without_pd_check():
    rng = random.Random(3)
    count = 0
    for _ in range(300):
        n = rng.randint(2, 8)
        r = [F(1)] + [F(rng.randint(-4, 4), 2) for _ in range(n - 1)]
        t = toeplitz(r, n)
        piv = ge_pivots(t)
        b = [F(rng.randint(-5, 5)) for _ in range(n)]
        if len(piv) == n and piv[-1] != 0:
            assert levinson(r, b, check_pd=False) == solve_exact(t, b)
            count += 1
        else:
            with pytest.raises(Breakdown):
                levinson(r, b, check_pd=False)
    assert count > 100
    # T_2 singular but T_3 nonsingular: the recursion breaks down anyway.
    r = [F(1), F(1), F(0)]
    assert ge_pivots(toeplitz(r, 3))[1] == 0
    with pytest.raises(Breakdown):
        levinson(r, [F(1)] * 3, check_pd=False)


def test_singular_reports_failure():
    with pytest.raises(NotPositiveDefinite):
        levinson([1.0, 1.0, 1.0], [1.0, 2.0, 3.0])
    with pytest.raises(Breakdown):
        levinson([F(1), F(1), F(1)], [F(1)] * 3, check_pd=False)
    with pytest.raises(NotPositiveDefinite):
        durbin([F(1), F(1), F(1), F(1)])
    with pytest.raises(NotPositiveDefinite):
        levinson([0.0, 1.0], [1.0, 1.0])
    with pytest.raises(Breakdown):
        levinson([0.0, 1.0], [1.0, 1.0], check_pd=False)


def test_flop_counts():
    for n in range(2, 40):
        st = {}
        durbin([1.0] + [0.1] * n, stats=st)
        assert st["flops"] == 2 * n * n + 2 * n - 4
        st = {}
        levinson([1.0] + [0.1] * (n - 1), [1.0] * n, stats=st)
        assert st["flops"] == 4 * n * n - 3 * n - 2


def test_weak_stability_observed():
    """Observed on random SPD Toeplitz matrices: forward error <= 20 u kappa_1(T)
    and backward error <= n u prod(1 + |alpha_k|)."""
    rng = random.Random(4)
    checked = 0
    for t in range(120):
        n = rng.randint(2, 12)
        mag = rng.choice([4, 10, 14, 15])
        if t % 3:
            al = rand_alphas(rng, n, 16)
        else:
            al = [F(mag, 16) * (1 if t % 2 else (-1) ** k) for k in range(n)]
        rf = toeplitz_from_reflections(al)[:n]
        r = [float(v) for v in rf]
        xt = [rng.randint(-5, 5) or 1 for _ in range(n)]
        bf = [sum(rf[abs(i - j)] * xt[j] for j in range(n)) for i in range(n)]
        b = [float(v) for v in bf]
        x = levinson(r, b)
        k1 = kappa1(rf)
        fe = max(abs(p - q) for p, q in zip(x, xt)) / max(map(abs, xt))
        if U * k1 < 1e-3:
            assert fe <= 20 * U * k1
            checked += 1
        prod = math.prod(1 + abs(float(a)) for a in al[: n - 1])
        assert eta(toeplitz(r, n), b, x) <= n * U * prod
    assert checked > 100


def test_levinson_not_backward_stable_but_weakly_stable():
    """All reflection coefficients 1/2, n = 30: the residual is far larger than
    Cholesky's, while the forward error is still of the size u kappa."""
    n = 30
    rf = toeplitz_from_reflections([F(1, 2)] * n)[:n]
    r = [float(v) for v in rf]
    a = toeplitz(r, n)
    b = [sum(r[abs(i - j)] for j in range(n)) for i in range(n)]
    bf = [sum(rf[abs(i - j)] for j in range(n)) for i in range(n)]
    assert [float(v) for v in bf] == b  # the right-hand side is exact
    x = levinson(r, b)
    xc = cholesky_solve(a, b)
    assert eta(a, b, x) > 1000 * U
    assert eta(a, b, xc) < 2 * U
    k1 = kappa1(rf)
    assert 1e12 < k1 < 1e14
    assert max(abs(v - 1) for v in x) < 5 * U * k1
    # One step of fixed precision iterative refinement repairs the residual.
    res = [p - q for p, q in zip(b, mv(a, x))]
    x2 = [p + q for p, q in zip(x, levinson(r, res))]
    assert eta(a, b, x2) < 2 * U


def test_indefinite_with_tiny_minor_is_unstable():
    """T well conditioned, but T_2 nearly singular: Levinson (any version
    without pivoting) loses about 12 digits."""
    d = 1e-12
    r = [1.0, 1.0 + d, 0.0]
    a = toeplitz(r, 3)
    b = [1.0, 2.0, 3.0]
    x = levinson(r, b, check_pd=False)
    xe = solve_exact([[F(v) for v in row] for row in a], [F(v) for v in b])
    inv = [solve_exact([[F(v) for v in row] for row in a], [F(int(i == j)) for i in range(3)]) for j in range(3)]
    k1 = float(max(sum(abs(v) for v in c) for c in inv) * max(sum(abs(v) for v in row) for row in a))
    assert k1 < 20
    assert eta(a, b, x) > 1e6 * U
    assert max(abs(x[i] - float(xe[i])) for i in range(3)) > 1e-6


def test_edge_cases():
    assert levinson([2.0], [3.0]) == [1.5]
    assert levinson([], []) == []
    assert durbin([1.0]) == ([], [], [])
    with pytest.raises(ValueError):
        durbin([2.0, 1.0])
    # Diagonal: r = (c, 0, ..., 0).
    assert levinson([F(4), 0, 0, 0], [F(1), 2, 3, 4]) == [F(1, 4), F(1, 2), F(3, 4), 1]
    # KMS matrix r_k = rho^k: only the first reflection coefficient is nonzero.
    rho = F(-2, 3)
    y, al, be = durbin([rho ** k for k in range(9)])
    assert al == [-rho] + [0] * 7 and y == [-rho] + [0] * 7
    # Floats close to the exact solution on a well conditioned case.
    rng = random.Random(5)
    for _ in range(50):
        n = rng.randint(1, 60)
        r = [1.0] + [0.3 * 0.5 ** k * rng.choice([-1, 1]) for k in range(1, n)]
        b = [rng.uniform(-1, 1) for _ in range(n)]
        x = levinson(r, b)
        assert eta(toeplitz(r, n), b, x) < 50 * U

What the next level needs

  • the tests did not run in this build

Read the full page about Levinson-Durbin recursion