Algorithm Search⌘K

Jenkins-Traub algorithm in Python

Three-stage shifted iteration for polynomial roots with guaranteed convergence. It finds the roots one at a time, smallest first, and removes each by deflation.

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

"""The Jenkins-Traub algorithm for the roots of a complex polynomial.

Coefficients are given highest degree first: a[0] z^n + ... + a[n]. Each root
is found by three stages of shifted H-polynomial iteration, then removed by
deflation. This follows the structure of CPOLY (Jenkins and Traub, 1970) in
plain complex arithmetic, without its scaling and cluster refinements.
"""

import cmath
import math
from collections import namedtuple

U = 2.0 ** -53  # unit roundoff of IEEE double
JTResult = namedtuple("JTResult", "roots evals steps status trace")


def _divide(c, s):
    """Synthetic division of c by (z - s): returns (quotient, value c(s))."""
    q = [c[0]]
    for x in c[1:]:
        q.append(q[-1] * s + x)
    return q[:-1], q[-1]


def _bound(a, s):
    """Rounding error bound for Horner's rule at s: sum of |a_k| |s|^(n-k)."""
    r, b = abs(s), 0.0
    for x in a:
        b = b * r + abs(x)
    return b


def jt_step(p, H, s):
    """One shifted step: returns (H_next normalized to leading coefficient 1,
    the new estimate t, p(s)). H_next = (H(z) - H(s)/p(s) p(z)) / (z - s)."""
    qp, ps = _divide(p, s)
    qh, hs = _divide(H, s)
    if ps == 0:  # s is a root: the limit of the iteration is p(z)/(z - s)
        return [x / qp[0] for x in qp], s, ps
    c = hs / ps
    Hn = [-c * qp[0]] + [qh[i] - c * qp[i + 1] for i in range(len(qh))]
    lead = Hn[0]
    if lead == 0 or not cmath.isfinite(lead):
        return [x / qp[0] for x in qp], s, ps  # restart H from p/(z - s)
    Hn = [x / lead for x in Hn]
    _, hns = _divide(Hn, s)
    t = s - ps / hns if hns != 0 else s
    return Hn, t, ps


def lower_bound(p):
    """Cauchy lower bound: the positive root of |a0| x^n + ... + |a_{n-1}| x - |a_n|."""
    c = [abs(x) for x in p[:-1]] + [-abs(p[-1])]
    lo, hi = 0.0, 1.0
    while _divide(c, hi)[1] < 0:
        hi *= 2.0
    for _ in range(100):  # bisection: the polynomial is increasing for x > 0
        mid = 0.5 * (lo + hi)
        if _divide(c, mid)[1] < 0:
            lo = mid
        else:
            hi = mid
    return lo


def jenkins_traub(a, M=5, L=20, max_shifts=20, max_steps=100000):
    a = [complex(x) for x in a]
    if any(not cmath.isfinite(x) for x in a):
        raise ValueError("coefficients must be finite")
    if not a or a[0] == 0:
        raise ValueError("leading coefficient must be nonzero")
    roots, trace = [], []
    while len(a) > 1 and a[-1] == 0:  # @line L1
        roots.append(0j)
        a.pop()
    p = [x / a[0] for x in a]
    evals = steps = 0
    while len(p) > 2:  # @line L2
        n = len(p) - 1
        H = [(n - k) * p[k] / n for k in range(n)]  # @line L3
        for _ in range(M):
            H, _, _ = jt_step(p, H, 0j)
            evals += 2
            steps += 1
        beta = lower_bound(p)  # @line L4
        theta, found = math.radians(49), None
        for attempt in range(1, max_shifts + 1):  # @line L5
            theta += math.radians(94)
            s = beta * cmath.exp(1j * theta)  # @line L6
            t_prev = t_prev2 = None
            for _ in range(L * attempt):  # @line L7
                if steps >= max_steps:
                    return JTResult(roots, evals, steps, "max-iter", trace)
                H, t, ps = jt_step(p, H, s)
                evals += 2
                steps += 1
                if ps == 0:
                    found = s
                    break
                weak = (t_prev is not None and t_prev2 is not None
                        and abs(t - t_prev) <= 0.5 * abs(t_prev)
                        and abs(t_prev - t_prev2) <= 0.5 * abs(t_prev2))
                t_prev2, t_prev = t_prev, t
                if weak:  # @line L8
                    found, H, e, k = _stage3(p, H, t, max_steps - steps, trace)
                    evals += e
                    steps += k
                    if found is not None:
                        break
                    t_prev = t_prev2 = None
            if found is not None:  # @line L9
                break
        if found is None:  # @line L10
            status = "max-iter" if steps >= max_steps else "failed"
            return JTResult(roots, evals, steps, status, trace)
        roots.append(found)  # @line L11
        p, _ = _divide(p, found)
    if len(p) == 2:
        roots.append(-p[1] / p[0])  # @line L12
    return JTResult(roots, evals, steps, "done", trace)


def _stage3(p, H, s, budget, trace):
    """Variable-shift stage: s <- s - p(s)/H(s) with H updated at each s.
    Accept s when |p(s)| is within 20 times the Horner rounding bound."""
    evals = steps = 0
    path, prev = [s], None
    for _ in range(min(10, budget)):
        ps = _divide(p, s)[1]
        evals += 1
        if abs(ps) <= 20 * U * _bound(p, s):  # converged to rounding level
            trace.append(path)
            return s, H, evals, steps
        if prev is not None and abs(ps) > 10 * prev:  # diverging: give up
            break
        prev = abs(ps)
        H, s, _ = jt_step(p, H, s)
        evals += 2
        steps += 1
        path.append(s)
    return None, H, evals, steps
test_jenkins_traub_algorithm.py 121 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/jenkins-traub-algorithm/. The authors are listed in prov/W75WVA.jsonl.

import math
import random
import unittest

from jenkins_traub_algorithm import _bound, _divide, jenkins_traub, jt_step, lower_bound


def poly(rs):
    c = [1 + 0j]
    for r in rs:
        c = c + [0j]
        for i in range(len(c) - 1, 0, -1):
            c[i] -= r * c[i - 1]
    return c


def match(test, got, want, tol):
    got = list(got)
    test.assertEqual(len(got), len(want))
    for w in want:
        j = min(range(len(got)), key=lambda i: abs(got[i] - w))
        test.assertLess(abs(got[j] - w), tol)
        got.pop(j)


class TestJenkinsTraub(unittest.TestCase):
    def test_cubic_and_counts(self):
        r = jenkins_traub(poly([1, 2, 3]))
        self.assertEqual(r.status, "done")
        match(self, r.roots, [1, 2, 3], 1e-13)
        self.assertEqual((r.evals, r.steps), (49, 21))

    def test_complex_roots_of_real_polynomial(self):
        match(self, jenkins_traub([1, 0, 1]).roots, [1j, -1j], 1e-15)
        match(self, jenkins_traub(poly([1 + 2j, 1 - 2j, -3, 0.5j])).roots,
              [1 + 2j, 1 - 2j, -3, 0.5j], 1e-12)

    def test_zero_roots_and_low_degree(self):
        r = jenkins_traub([1, -1, 0, 0])
        match(self, r.roots, [0, 0, 1], 1e-15)
        self.assertEqual(jenkins_traub([3, -6]).roots, [2])
        self.assertEqual(jenkins_traub([5]).roots, [])

    def test_double_root(self):
        r = jenkins_traub(poly([1, 1, -2]))
        self.assertEqual(r.status, "done")
        match(self, r.roots, [1, 1, -2], 1e-7)

    def test_wilkinson_10(self):
        r = jenkins_traub(poly(range(1, 11)))
        match(self, r.roots, list(range(1, 11)), 1e-8)

    def test_stage2_linear_rate(self):
        # fixed shift s: t converges to the root closest to s with ratio
        # |a1 - s| / |a2 - s| where a2 is the second closest root
        roots = [1, 2.5, -3]
        p, s = poly(roots), 0.6 + 0.4j
        H = [(3 - k) * p[k] / 3 for k in range(3)]
        errs = []
        for _ in range(25):
            H, t, _ = jt_step(p, H, s)
            errs.append(abs(t - 1))
        ratio = abs(1 - s) / abs(2.5 - s)
        for k in range(15, 24):
            self.assertAlmostEqual(errs[k + 1] / errs[k], ratio, delta=0.02)

    def test_stage3_observed_order(self):
        roots = [1, 2, 3, 4 + 1j, -2]
        r = jenkins_traub(poly(roots))
        orders = []
        for path in r.trace:
            z = min(roots, key=lambda w: abs(w - path[-1]))
            e = [abs(x - z) for x in path]
            for k in range(1, len(e) - 1):
                if e[k + 1] > 1e-11:
                    orders.append(math.log(e[k + 1] / e[k]) / math.log(e[k] / e[k - 1]))
        self.assertTrue(orders)
        self.assertGreater(min(orders), 2.0)

    def test_shift_at_exact_root(self):
        p = poly([1, 2, 3])
        H, t, ps = jt_step(p, [1, 0, 0], 2 + 0j)
        self.assertEqual(ps, 0)
        self.assertEqual(t, 2)
        match(self, [-H[1] / 2 + d for d in (1, -1)], [1, 3], 1e-15)  # H = (z-1)(z-3)

    def test_lower_bound(self):
        p = poly([2, -3, 4j])
        b = lower_bound(p)
        self.assertLessEqual(b, 2)
        self.assertGreater(b, 0.5)

    def test_max_steps_and_bad_input(self):
        r = jenkins_traub(poly([1, 2, 3, 4, 5]), max_steps=3)
        self.assertEqual(r.status, "max-iter")
        self.assertLessEqual(len(r.roots), 4)
        for bad in ([0, 1, 2], [], [1, float("nan")], [1, math.inf, 2]):
            with self.assertRaises(ValueError):
                jenkins_traub(bad)

    def test_random_polynomials(self):
        rng = random.Random(5)
        for _ in range(500):
            n = rng.randint(2, 10)
            while True:
                rs = [complex(rng.uniform(-2, 2), rng.uniform(-2, 2)) for _ in range(n)]
                if min(abs(x - y) for i, x in enumerate(rs) for y in rs[:i]) > 0.1:
                    break
            c = poly(rs)
            r = jenkins_traub(c)
            self.assertEqual(r.status, "done")
            match(self, r.roots, rs, 1e-9)
            for z in r.roots:  # small backward error for the original polynomial
                self.assertLess(abs(_divide(c, z)[1]), 1e-12 * _bound(c, z))


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

What the next level needs

  • the tests did not run in this build

Read the full page about Jenkins-Traub algorithm