Algorithm Search⌘K

Homotopy continuation in Python

Track solutions of a deformed system from an easy start system to the target. With the gamma trick it finds every isolated root of a polynomial system with probability one.

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

"""Homotopy continuation for square polynomial systems F(x) = 0 in C^n.

The total degree homotopy with the gamma trick is
    H(x, t) = (1 - t) gamma G(x) + t F(x),   G_i(x) = x_i^(d_i) - 1,
where d_i is the degree of F_i. At t = 0 its solutions are the d_1 ... d_n
tuples of roots of unity. Each one is tracked to t = 1 by a predictor
(classical Runge-Kutta on the Davidenko equation H_x dx/dt = -H_t) and a
corrector (Newton's method on H(., t)) with adaptive step size.

Vectors are lists of complex numbers, matrices are lists of rows. F(x)
returns a vector and JF(x) the Jacobian matrix.
"""

import cmath
import itertools
import math
import random
from collections import namedtuple

PathResult = namedtuple("PathResult", "x t steps evals status")
# status: "success" (reached t = 1 and the end point was refined),
#         "singular-end" (the step size collapsed within end_zone of t = 1,
#                         or Newton at t = 1 did not converge fast; typical
#                         for a root of multiplicity m > 1),
#         "diverged" (|x| exceeded the radius: a solution at infinity),
#         "failed" (step size below h_min or step cap reached).


def norm(v):
    return max((abs(c) for c in v), default=0.0)


def solve(A, b):
    """Gaussian elimination with partial pivoting; None if A is singular."""
    n = len(b)
    M = [list(A[i]) + [b[i]] for i in range(n)]
    for c in range(n):
        p = max(range(c, n), key=lambda r: abs(M[r][c]))
        if M[p][c] == 0:
            return None
        M[c], M[p] = M[p], M[c]
        for r in range(c + 1, n):
            f = M[r][c] / M[c][c]
            if f != 0:
                for j in range(c, n + 1):
                    M[r][j] -= f * M[c][j]
    x = [0j] * n
    for i in range(n - 1, -1, -1):
        s = M[i][n] - sum(M[i][j] * x[j] for j in range(i + 1, n))
        x[i] = s / M[i][i]
    return x


def total_degree_homotopy(F, JF, degrees, gamma):
    """H, H_x, H_t and the start solutions of the total degree homotopy."""
    n = len(degrees)

    def G(x):
        return [x[i] ** degrees[i] - 1 for i in range(n)]

    def H(x, t):
        g, f = G(x), F(x)
        return [(1 - t) * gamma * g[i] + t * f[i] for i in range(n)]

    def Hx(x, t):
        J = JF(x)
        return [[(1 - t) * gamma * (degrees[i] * x[i] ** (degrees[i] - 1) if i == j else 0)
                 + t * J[i][j] for j in range(n)] for i in range(n)]

    def Ht(x, t):
        g, f = G(x), F(x)
        return [f[i] - gamma * g[i] for i in range(n)]

    roots = [[cmath.exp(2j * math.pi * k / d) for k in range(d)] for d in degrees]
    starts = [list(s) for s in itertools.product(*roots)]
    return H, Hx, Ht, starts


def _tangent(Hx, Ht, x, t):
    """dx/dt from H_x dx/dt = -H_t; None if H_x is singular."""
    try:
        return solve(Hx(x, t), [-c for c in Ht(x, t)])
    except (OverflowError, ZeroDivisionError):
        return None  # a wild predictor point: the step is rejected


def _newton_step(H, Hx, y, t):
    try:
        return solve(Hx(y, t), [-c for c in H(y, t)])
    except (OverflowError, ZeroDivisionError):
        return None


def rk4_predict(Hx, Ht, x, t, h):
    """One classical Runge-Kutta step for dx/dt = -H_x^-1 H_t; None if singular."""
    k1 = _tangent(Hx, Ht, x, t)
    k2 = k1 and _tangent(Hx, Ht, [a + h / 2 * b for a, b in zip(x, k1)], t + h / 2)
    k3 = k2 and _tangent(Hx, Ht, [a + h / 2 * b for a, b in zip(x, k2)], t + h / 2)
    k4 = k3 and _tangent(Hx, Ht, [a + h * b for a, b in zip(x, k3)], t + h)
    if k4 is None:
        return None
    return [a + h / 6 * (p + 2 * q + 2 * r + s) for a, p, q, r, s in zip(x, k1, k2, k3, k4)]


def track(H, Hx, Ht, x0, h0=0.05, h_min=1e-14, max_steps=20000,
          tol=1e-9, radius=1e6, end_zone=1e-4):
    x, t, h = list(x0), 0.0, h0  # @line L1
    steps = evals = 0
    good = 0
    while t < 1.0:  # @line L2
        if steps >= max_steps:  # @line L9
            return PathResult(x, t, steps, evals, "failed")
        steps += 1
        h = min(h, 1.0 - t)  # @line L3
        y = rk4_predict(Hx, Ht, x, t, h)  # @line L4
        evals += 4
        ok = y is not None
        if ok:
            t1 = 1.0 if h == 1.0 - t else t + h
            ok = False
            prev = None
            for _ in range(3):  # @line L5
                d = _newton_step(H, Hx, y, t1)
                evals += 1
                if d is None:
                    break
                y = [a + b for a, b in zip(y, d)]
                nd = norm(d)
                if prev is None and nd > 1e-3 * (1.0 + norm(y)):
                    break  # predictor too far from the path: reject
                if prev is not None and nd > 0.25 * prev:
                    break  # not contracting fast: reject
                prev = nd
                if nd <= tol * (1.0 + norm(y)):
                    ok = True
                    break
        if ok:  # @line L6
            x, t = y, t1
            good += 1
            if good >= 3:
                h, good = min(2.0 * h, 0.1), 0
            if norm(x) > radius:  # @line L8
                return PathResult(x, t, steps, evals, "diverged")
        else:  # @line L7
            h, good = h / 2.0, 0
            if h < h_min:
                st = "singular-end" if 1.0 - t <= end_zone else "failed"
                return PathResult(x, t, steps, evals, st)
    return PathResult(x, t, steps, evals, "success")  # @line L10


def condition(J):
    """||J^-1|| max(1, ||J||) in the max-norm; infinite if J is singular."""
    n = len(J)
    inv_norm = 0.0
    cols = []
    for j in range(n):
        c = solve(J, [1.0 if i == j else 0.0 for i in range(n)])
        if c is None:
            return math.inf
        cols.append(c)
    inv_norm = max(sum(abs(cols[j][i]) for j in range(n)) for i in range(n))
    j_norm = max(sum(abs(v) for v in row) for row in J)
    return inv_norm * max(1.0, j_norm)


def refine(F, JF, x, tol=1e-14, max_iter=50, cond_max=1e8):
    """Newton's method on F at t = 1; returns (x, settled). The end point is
    settled when Newton converged and the Jacobian there is well conditioned;
    a large condition number signals a root of multiplicity m > 1."""
    prev = None
    for _ in range(max_iter):
        d = solve(JF(x), [-c for c in F(x)])
        if d is None:
            return x, False
        x = [a + b for a, b in zip(x, d)]
        nd = norm(d)
        if nd <= tol * (1.0 + norm(x)):
            return x, condition(JF(x)) <= cond_max
        if prev is not None and nd > 0.9 * prev:
            # stagnation in rounding noise: decide by the conditioning
            return x, condition(JF(x)) <= cond_max
        prev = nd
    return x, False


def solve_system(F, JF, degrees, seed=0, **kw):
    """Track all d_1 ... d_n paths and refine their end points."""
    rng = random.Random(seed)
    gamma = cmath.exp(2j * math.pi * rng.random())
    H, Hx, Ht, starts = total_degree_homotopy(F, JF, degrees, gamma)
    out = []
    for s in starts:
        r = track(H, Hx, Ht, s, **kw)
        if r.status in ("success", "singular-end"):
            x, settled = refine(F, JF, r.x)
            ok = settled and r.status == "success"
            r = r._replace(x=x, status="success" if ok else "singular-end")
        out.append(r)
    return out


def polynomial_roots(a, seed=0, **kw):
    """All roots of a0 x^n + ... + an (a0 != 0) by the homotopy from z^n - 1.

    The variable is scaled, x = s z with s = |an/a0|^(1/n), the geometric
    mean of the root moduli, so that the start roots have the right size.
    A root x = 0 of multiplicity k (k trailing zero coefficients) is split
    off first and returned as k exact paths."""
    a = [complex(c) for c in a]
    if any(math.isnan(abs(c)) or math.isinf(abs(c)) for c in a):
        raise ValueError("coefficients must be finite")
    while a and a[0] == 0:
        a.pop(0)
    if len(a) < 2:
        raise ValueError("need a polynomial of degree at least 1")
    zeros = []
    while len(a) > 1 and a[-1] == 0:
        a.pop()
        zeros.append(PathResult([0j], 1.0, 0, 0, "success"))
    n = len(a) - 1
    if n == 0:
        return zeros
    sc = abs(a[n] / a[0]) ** (1.0 / n)
    b = [a[j] * sc ** (n - j) / (a[0] * sc ** n) for j in range(n + 1)]

    def F(z):
        s = 0j
        for c in b:
            s = s * z[0] + c
        return [s]

    def JF(z):
        s = d = 0j
        for c in b:
            d = d * z[0] + s
            s = s * z[0] + c
        return [[d]]

    res = solve_system(F, JF, [n], seed=seed, **kw)
    return [r._replace(x=[sc * r.x[0]]) for r in res] + zeros
test_homotopy_continuation.py 140 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/homotopy-continuation/. The authors are listed in prov/DVD26W.jsonl.

import cmath
import math
import random
import unittest

from homotopy_continuation import (
    condition, polynomial_roots, rk4_predict, solve_system, total_degree_homotopy, track)


def poly(roots):
    c = [1 + 0j]
    for r in roots:
        c = [x - r * y for x, y in zip(c + [0j], [0j] + c)]
    return c


def F1(x):
    return [x[0] ** 2 + x[1] ** 2 - 5, x[0] * x[1] - 2]


def J1(x):
    return [[2 * x[0], 2 * x[1]], [x[1], x[0]]]


class TestHomotopy(unittest.TestCase):
    def test_cubic_all_roots(self):
        res = polynomial_roots(poly([1, 2, 3]))
        self.assertTrue(all(r.status == "success" for r in res))
        xs = sorted(r.x[0].real for r in res)
        for x, y in zip(xs, [1, 2, 3]):
            self.assertAlmostEqual(x, y, delta=1e-13)
        self.assertEqual(sum(r.steps for r in res), 36)

    def test_complex_roots(self):
        res = polynomial_roots([1, 0, 3, 6, 10])
        want = [-1 + 1j, -1 - 1j, 1 + 2j, 1 - 2j]
        for z in want:
            self.assertLess(min(abs(r.x[0] - z) for r in res), 1e-13)

    def test_system_with_four_solutions(self):
        res = solve_system(F1, J1, [2, 2])
        self.assertEqual(len(res), 4)
        found = sorted((round(r.x[0].real, 10), round(r.x[1].real, 10)) for r in res)
        self.assertEqual(found, [(-2, -1), (-1, -2), (1, 2), (2, 1)])
        self.assertTrue(all(r.status == "success" for r in res))

    def test_paths_to_infinity(self):
        # x y = 1, x y + x = 2 has one finite solution; 3 of the 4 paths diverge
        F = lambda x: [x[0] * x[1] - 1, x[0] * x[1] + x[0] - 2]
        J = lambda x: [[x[1], x[0]], [x[1] + 1, x[0]]]
        res = solve_system(F, J, [2, 2])
        st = sorted(r.status for r in res)
        self.assertEqual(st, ["diverged", "diverged", "diverged", "success"])
        ok = [r for r in res if r.status == "success"][0]
        self.assertLess(abs(ok.x[0] - 1) + abs(ok.x[1] - 1), 1e-13)

    def test_double_root_flagged(self):
        res = polynomial_roots(poly([1, 1, 2]))
        st = sorted(r.status for r in res)
        self.assertEqual(st, ["singular-end", "singular-end", "success"])
        for r in res:
            if r.status == "singular-end":
                self.assertLess(abs(r.x[0] - 1), 1e-6)  # about sqrt(eps)

    def test_start_is_already_a_solution(self):
        # the target equals the start system: every path stays put exactly
        res = polynomial_roots([1, 0, 0, -1])
        for k, r in enumerate(res):
            self.assertEqual(r.status, "success")
            self.assertLess(abs(r.x[0] - cmath.exp(2j * math.pi * k / 3)), 1e-15)

    def test_rk4_predictor_order(self):
        # path of x^2 - (1 + t) = 0 is sqrt(1 + t); local error O(h^5)
        Hx = lambda x, t: [[2 * x[0]]]
        Ht = lambda x, t: [-1.0]
        errs = [abs(rk4_predict(Hx, Ht, [1.0], 0.0, h)[0] - math.sqrt(1 + h))
                for h in (0.1, 0.05)]
        ratio = errs[0] / errs[1]
        self.assertGreater(ratio, 25)
        self.assertLess(ratio, 40)

    def test_evaluation_count(self):
        H, Hx, Ht, starts = total_degree_homotopy(F1, J1, [2, 2], cmath.exp(0.7j))
        r = track(H, Hx, Ht, starts[0])
        self.assertEqual(r.status, "success")
        self.assertGreaterEqual(r.evals, 5 * r.steps)  # 4 predictor + at least 1 corrector
        self.assertLessEqual(r.evals, 7 * r.steps)

    def test_step_cap_and_nan(self):
        H, Hx, Ht, starts = total_degree_homotopy(F1, J1, [2, 2], cmath.exp(0.7j))
        r = track(H, Hx, Ht, starts[0], max_steps=3)
        self.assertEqual((r.status, r.steps), ("failed", 3))
        Fn = lambda x: [float("nan"), float("nan")]
        r = solve_system(Fn, J1, [2, 2])[0]
        self.assertEqual(r.status, "failed")
        self.assertEqual(r.t, 0.0)

    def test_root_exactly_zero_and_wilkinson_10(self):
        res = polynomial_roots([1, -3, 2, 0, 0])
        self.assertEqual(sum(1 for r in res if r.x == [0j]), 2)
        res = polynomial_roots(poly(range(1, 11)))
        self.assertTrue(all(r.status == "success" for r in res))
        err = max(min(abs(k - r.x[0]) for r in res) for k in range(1, 11))
        self.assertLess(err, 1e-8)

    def test_condition(self):
        self.assertEqual(condition([[1.0, 0.0], [0.0, 1.0]]), 1.0)
        self.assertEqual(condition([[1.0, 2.0], [2.0, 4.0]]), math.inf)

    def test_bad_inputs(self):
        with self.assertRaises(ValueError):
            polynomial_roots([0, 5])
        with self.assertRaises(ValueError):
            polynomial_roots([1, float("nan")])
        res = polynomial_roots([0, 2, -4])  # leading zero stripped
        self.assertAlmostEqual(res[0].x[0], 2, delta=1e-14)

    def test_random_polynomials(self):
        rng = random.Random(77)
        worst = 0.0
        for k in range(500):
            n = rng.randint(1, 5)
            roots = [complex(rng.uniform(-2, 2), rng.uniform(-2, 2)) for _ in range(n)]
            res = polynomial_roots(poly(roots), seed=k)
            self.assertTrue(all(r.status == "success" for r in res))
            xs = [r.x[0] for r in res]
            # every root is the end of exactly one path
            for z in roots:
                d = sorted(abs(z - x) for x in xs)
                worst = max(worst, d[0])
                if n > 1:
                    self.assertGreater(d[1], 1e-6)
        self.assertLess(worst, 1e-10)


if __name__ == "__main__":
    unittest.main()

What the next level needs

  • the tests did not run in this build

Read the full page about Homotopy continuation