Algorithm Search⌘K

Bernoulli's method in Python

Runs the linear recurrence whose characteristic polynomial is p and takes the ratio of consecutive terms. It converges linearly, with rate |r2/r1|, to a root of p of strictly largest modulus.

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

"""Bernoulli's method: the dominant root of a polynomial as the limit of
y_(k+1) / y_k, where y_k solves the linear recurrence with p as its
characteristic polynomial.

Coefficients are real and listed highest degree first: a[0] x^n + ... + a[n].
The start is the power sums s_0, ..., s_(n-1) of the roots, from Newton's
identities, so that y_k = s_k = sum of the k-th powers of the roots.
"""

import math
from dataclasses import dataclass, field
from typing import List, Sequence


@dataclass
class BernoulliResult:
    root: float
    iterations: int  # recurrence steps
    status: str  # "converged", "max-iter"
    ratios: List[float] = field(default_factory=list)


def power_sums(a: Sequence[float], count: int) -> List[float]:
    """s_0, ..., s_(count-1): Newton's identities for k < n, the recurrence after."""
    n = len(a) - 1
    s = [float(n)]
    for k in range(1, count):
        if k < n:
            acc = sum(a[j] * s[k - j] for j in range(1, k)) + k * a[k]
        else:
            acc = sum(a[j] * s[k - j] for j in range(1, n + 1))
        s.append(-acc / a[0])
    return s


def _check(coeffs: Sequence[float]) -> List[float]:
    a = [float(x) for x in coeffs]
    if not all(math.isfinite(x) for x 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")
    return a


def bernoulli(coeffs: Sequence[float], tol: float = 1e-12, max_iter: int = 10000) -> BernoulliResult:
    """Dominant root of the polynomial, assuming one root of largest modulus.

    Stops with "converged" when two consecutive ratios differ by at most
    tol (1 + |ratio|), and with "max-iter" otherwise. The window of the
    last n terms is rescaled every step, so it never overflows.
    """
    a = _check(coeffs)
    n = len(a) - 1
    y = power_sums(a, n)  # @line L1
    ratios = []
    prev = None
    for k in range(1, max_iter + 1):  # @line L2
        nxt = -sum(a[j] * y[-j] for j in range(1, n + 1)) / a[0]  # @line L3
        last = y[-1]
        y.append(nxt)
        y.pop(0)
        scale = max(abs(t) for t in y)  # @line L4
        if scale == 0:
            return BernoulliResult(0.0, k, "converged", ratios)  # all roots are 0
        y = [t / scale for t in y]
        if last == 0:  # @line L5
            prev = None
            continue
        rho = nxt / last  # @line L6
        ratios.append(rho)
        if prev is not None and abs(rho - prev) <= tol * (1 + abs(rho)):  # @line L7
            return BernoulliResult(rho, k, "converged", ratios)
        prev = rho
    return BernoulliResult(prev if prev is not None else math.nan, max_iter, "max-iter", ratios)  # @line L8


def bernoulli_pair(coeffs: Sequence[float], tol: float = 1e-12, max_iter: int = 10000):
    """Dominant complex pair: fit y_(k+1) = S y_k - P y_(k-1) to four
    consecutive terms and return (S, P, iterations, status); the pair is
    the roots of x^2 - S x + P."""
    a = _check(coeffs)
    n = len(a) - 1
    if n < 2:
        raise ValueError("need degree at least 2")
    w = max(n, 4)
    y = power_sums(a, w)
    prev = None
    for k in range(1, max_iter + 1):
        nxt = -sum(a[j] * y[-j] for j in range(1, n + 1)) / a[0]
        y.append(nxt)
        y.pop(0)
        scale = max(abs(t) for t in y)
        if scale == 0:
            return 0.0, 0.0, k, "converged"
        y = [t / scale for t in y]
        y0, y1, y2, y3 = y[-4:]
        det = y1 * y1 - y0 * y2
        if det == 0:
            prev = None
            continue
        S = (y1 * y2 - y0 * y3) / det
        P = (y2 * y2 - y1 * y3) / det
        if prev is not None and abs(S - prev[0]) + abs(P - prev[1]) <= tol * (1 + abs(S) + abs(P)):
            return S, P, k, "converged"
        prev = (S, P)
    S, P = prev if prev is not None else (math.nan, math.nan)
    return S, P, max_iter, "max-iter"
test_bernoulli_method.py 93 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/bernoulli-method/. The authors are listed in prov/QQR2VY.jsonl.

import math
import random
import unittest

from bernoulli_method import bernoulli, bernoulli_pair, power_sums


def from_roots(rs):
    p = [1.0]
    for r in rs:
        p = [x - r * y for x, y in zip(p + [0.0], [0.0] + p)]
    return p


CUBIC = [1, -6, 11, -6]  # (x - 1)(x - 2)(x - 3)


class TestBernoulli(unittest.TestCase):
    def test_power_sums(self):
        self.assertEqual(power_sums(CUBIC, 8), [1.0 + 2 ** k + 3 ** k for k in range(8)])
        self.assertEqual(power_sums([2, 0, -8], 5), [2.0, 0.0, 8.0, 0.0, 32.0])

    def test_example_ratios_and_rate(self):
        r = bernoulli(CUBIC)
        self.assertEqual(r.status, "converged")
        self.assertAlmostEqual(r.root, 3.0, delta=1e-11)
        # ratio k is s_(j+1) / s_j with j = n + k - 2; its error is (-2 - 2^j) / (1 + 2^j + 3^j)
        for k, rho in enumerate(r.ratios[:20], start=1):
            j = 3 + k - 2
            self.assertAlmostEqual(rho - 3, (-2 - 2 ** j) / (1 + 2 ** j + 3 ** j), delta=1e-12)
        e = [abs(x - 3) for x in r.ratios]
        self.assertAlmostEqual(e[30] / e[29], 2 / 3, delta=1e-4)
        self.assertLessEqual(r.iterations, 70)

    def test_negative_and_double_dominant(self):
        self.assertAlmostEqual(bernoulli([1, 4, 1, -6]).root, -3.0, delta=1e-10)
        self.assertAlmostEqual(bernoulli([1, -7, 15, -9]).root, 3.0, delta=1e-10)  # (x - 3)^2 (x - 1)
        r = bernoulli([1, -4, 4])  # (x - 2)^2: y_k = 2 * 2^k, exact at once
        self.assertEqual((r.status, r.root, r.iterations), ("converged", 2.0, 2))

    def test_degree_one_and_zero_roots(self):
        self.assertEqual(bernoulli([1, -3]).root, 3.0)
        r = bernoulli([1, 0, 0, 0])
        self.assertEqual((r.status, r.root), ("converged", 0.0))
        self.assertAlmostEqual(bernoulli([1, -5, 6, 0]).root, 3.0, delta=1e-10)  # root at 0 too

    def test_tie_hits_max_iter(self):
        r = bernoulli([1, 0, -1], max_iter=500)  # roots +1 and -1 have the same modulus
        self.assertEqual((r.status, r.iterations), ("max-iter", 500))
        r = bernoulli([1, 0, 4, 0], max_iter=300)  # dominant pair +-2i
        self.assertEqual(r.status, "max-iter")

    def test_complex_pair(self):
        S, P, k, status = bernoulli_pair([1, 0, 3, 6, 10])  # dominant pair 1 +- 2i
        self.assertEqual(status, "converged")
        self.assertAlmostEqual(S, 2.0, delta=1e-9)
        self.assertAlmostEqual(P, 5.0, delta=1e-9)
        S, P, k, status = bernoulli_pair([1, 1, 1])
        self.assertEqual((status, S, P), ("converged", -1.0, 1.0))

    def test_no_overflow(self):
        r = bernoulli(from_roots([2.0, 1.999]), tol=0.0, max_iter=5000)
        self.assertEqual(r.status, "max-iter")
        self.assertTrue(math.isfinite(r.root))
        self.assertAlmostEqual(r.root, 2.0, delta=1e-3)
        self.assertAlmostEqual(bernoulli([1.0, -(1e100 + 1), 1e100]).root / 1e100, 1.0, delta=1e-12)

    def test_bad_input(self):
        for bad in ([1, float("nan")], [1, math.inf, 2], [0, 0], [5]):
            with self.assertRaises(ValueError):
                bernoulli(bad)
        with self.assertRaises(ValueError):
            bernoulli_pair([1, 2])

    def test_random_real_roots(self):
        rng = random.Random(20261108)
        for _ in range(600):
            n = rng.randint(2, 7)
            r1 = rng.choice([-1, 1]) * rng.uniform(1, 10)
            rest = [rng.uniform(-0.8, 0.8) * abs(r1) for _ in range(n - 1)]
            lam = max(abs(x) for x in rest) / abs(r1)
            res = bernoulli(from_roots([r1] + rest), tol=1e-13)
            self.assertEqual(res.status, "converged")
            # error is about diff * lam / (1 - lam) <= 4 * diff
            self.assertLess(abs(res.root - r1), 1e-10 * (1 + abs(r1)))
            self.assertLessEqual(res.iterations, int(40 / -math.log(lam)) + 20)


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

What the next level needs

  • the tests did not run in this build

Read the full page about Bernoulli's method