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
# 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