Impact, every sweep regenerated: the paper's key configurations are unchanged to the evaluation (proposed 2806/3220, credit 3568/3020, Chandrupatla, TOMS 748, Brent, bisection); 46 stated numbers move (ITP CHA 2857 -> 2896, ITP oracle 2611 -> 2647, record CHA figures by 14-18, the grid means by ~1%) and one finding weakens: the measured-order exponent of section 7.1 was cheaper in 29 of 60 far cells and 7 of 7 safeguards; with the fix 12 of 61 and 6 of 7, and on the wide-bracket set it now loses in every safeguard and drops 5 problems. PREREG G1-G3 outcome on the fixed code: G2 (repeating step pattern, period from the data) PASSES Y1-Y4 and is ACCEPTED by the stop rule - proposed rule APS 2806 -> 2782 (below Chandrupatla's 2787), CHA 3220 -> 3169, both 6026 -> 5951, per problem vs Chandrupatla on APS 95/20/38 p = 8e-7. G1 fails Y1/Y2 (its Y3 miss was the sign bug), G3 fails Y2, A fails Y2. Regression 887 pass; the two red tests are the paper-number check (awaiting the paper update) and test_blend (fixed since, copy in stage16). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
19 KiB
Python
542 lines
19 KiB
Python
"""Classical and state-of-the-art bracketing root finders, all with a common
|
|
signature and a shared function-evaluation counter.
|
|
|
|
Every method returns (x, nfev, converged).
|
|
|
|
Included:
|
|
bisection - the null model
|
|
regula_falsi - the raw "step proportional to dissimilarity" rule
|
|
illinois - Hafner/Dowell 1971 stalling fix (halve the stale end)
|
|
pegasus - Dowell & Jarratt 1972
|
|
anderson_bjorck - Anderson & Bjorck 1973
|
|
ridders - Ridders 1979
|
|
brent - Brent 1973 (own implementation, verified vs scipy)
|
|
toms748 - Alefeld, Potra & Shi 1995 (via scipy)
|
|
itp - Oliveira & Takahashi, ACM TOMS 2021 (own implementation)
|
|
|
|
The ITP method is the current state of the art for bracketed root finding:
|
|
superlinear average performance with a worst case that is provably no worse
|
|
than bisection. Note that it carries THREE tuning parameters (kappa1, kappa2,
|
|
n0); the authors' recommended defaults are used here and are reported as such.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from scipy.optimize import toms748 as _scipy_toms748
|
|
|
|
|
|
class Counter:
|
|
"""Counts calls to f and caches nothing (evaluations are the cost model)."""
|
|
|
|
__slots__ = ("f", "n", "limit")
|
|
|
|
def __init__(self, f, limit):
|
|
self.f = f
|
|
self.n = 0
|
|
self.limit = limit
|
|
|
|
def __call__(self, x):
|
|
if self.n >= self.limit:
|
|
raise _Budget()
|
|
self.n += 1
|
|
return self.f(x)
|
|
|
|
|
|
class _Budget(Exception):
|
|
pass
|
|
|
|
|
|
def _opposite(u, v):
|
|
"""True when u and v have strictly opposite signs. NOT u * v < 0: for the tiny
|
|
values of a flat function near its root (x^19 there is ~1e-200) the product
|
|
underflows to -0.0, '-0.0 < 0.0' is False, and a bracket update then moves the
|
|
wrong end and loses the root while still reporting convergence (found 2026-09-21
|
|
on fun6.5, x^19 over [-10, 100])."""
|
|
return (u < 0.0 < v) or (v < 0.0 < u)
|
|
|
|
|
|
def _same_sign(u, v):
|
|
"""True when u and v are both strictly positive or both strictly negative; see
|
|
_opposite for why this is not u * v > 0."""
|
|
return (u > 0.0 and v > 0.0) or (u < 0.0 and v < 0.0)
|
|
|
|
|
|
def _converged(a, b, xtol):
|
|
return abs(b - a) <= xtol
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# null model
|
|
# --------------------------------------------------------------------------
|
|
def bisection(f, a, b, xtol, maxfev):
|
|
g = Counter(f, maxfev)
|
|
try:
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
x = 0.5 * (a + b)
|
|
while not _converged(a, b, xtol):
|
|
x = 0.5 * (a + b)
|
|
fx = g(x)
|
|
if fx == 0.0:
|
|
return x, g.n, True
|
|
if _opposite(fa, fx):
|
|
b, fb = x, fx
|
|
else:
|
|
a, fa = x, fx
|
|
return 0.5 * (a + b), g.n, True
|
|
except _Budget:
|
|
return 0.5 * (a + b), g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the false-position family (all are "similarity-proportional steps" with
|
|
# different stale-endpoint corrections)
|
|
# --------------------------------------------------------------------------
|
|
def _false_position_family(f, a, b, xtol, maxfev, correction):
|
|
g = Counter(f, maxfev)
|
|
x = 0.5 * (a + b) # defined before the first evaluation: a budget that
|
|
# runs out on f(a) must give an honest failure, not
|
|
# an UnboundLocalError
|
|
try:
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
while not _converged(a, b, xtol):
|
|
denom = fb - fa
|
|
if denom == 0.0:
|
|
x = 0.5 * (a + b)
|
|
else:
|
|
x = (a * fb - b * fa) / denom
|
|
lo, hi = (a, b) if a < b else (b, a)
|
|
if not (lo < x < hi) or not math.isfinite(x):
|
|
x = 0.5 * (a + b)
|
|
fx = g(x)
|
|
if fx == 0.0:
|
|
return x, g.n, True
|
|
if _opposite(fa, fx):
|
|
# b replaced; a became stale -> correct fa
|
|
fa = correction(fa, fb, fx)
|
|
b, fb = x, fx
|
|
else:
|
|
fb = correction(fb, fa, fx)
|
|
a, fa = x, fx
|
|
return 0.5 * (a + b), g.n, True
|
|
except _Budget:
|
|
return x, g.n, False
|
|
|
|
|
|
def regula_falsi(f, a, b, xtol, maxfev):
|
|
return _false_position_family(f, a, b, xtol, maxfev,
|
|
lambda f_stale, f_moved, fx: f_stale)
|
|
|
|
|
|
def illinois(f, a, b, xtol, maxfev):
|
|
return _false_position_family(f, a, b, xtol, maxfev,
|
|
lambda f_stale, f_moved, fx: 0.5 * f_stale)
|
|
|
|
|
|
def pegasus(f, a, b, xtol, maxfev):
|
|
return _false_position_family(
|
|
f, a, b, xtol, maxfev,
|
|
lambda f_stale, f_moved, fx: f_stale * f_moved / (f_moved + fx))
|
|
|
|
|
|
def _ab_corr(f_stale, f_moved, fx):
|
|
m = 1.0 - fx / f_moved
|
|
if m <= 0.0:
|
|
m = 0.5
|
|
return f_stale * m
|
|
|
|
|
|
def anderson_bjorck(f, a, b, xtol, maxfev):
|
|
return _false_position_family(f, a, b, xtol, maxfev, _ab_corr)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Ridders 1979
|
|
# --------------------------------------------------------------------------
|
|
def ridders(f, a, b, xtol, maxfev):
|
|
g = Counter(f, maxfev)
|
|
x = 0.5 * (a + b)
|
|
try:
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
while not _converged(a, b, xtol):
|
|
c = 0.5 * (a + b)
|
|
fc = g(c)
|
|
if fc == 0.0:
|
|
return c, g.n, True
|
|
d = fc * fc - fa * fb
|
|
if d <= 0.0:
|
|
x = c
|
|
else:
|
|
s = math.sqrt(d)
|
|
x = c + (c - a) * (math.copysign(1.0, fa - fb) * fc) / s
|
|
fx = g(x)
|
|
if fx == 0.0:
|
|
return x, g.n, True
|
|
# rebuild the tightest bracket among a, c, x, b
|
|
if _opposite(fc, fx):
|
|
a, fa, b, fb = (c, fc, x, fx) if c < x else (x, fx, c, fc)
|
|
elif _opposite(fa, fx):
|
|
b, fb = x, fx
|
|
else:
|
|
a, fa = x, fx
|
|
return 0.5 * (a + b), g.n, True
|
|
except _Budget:
|
|
return x, g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Brent 1973
|
|
# --------------------------------------------------------------------------
|
|
def brent(f, a, b, xtol, maxfev, stats=None):
|
|
"""stats: optional dict; on return it carries 'bisect' = how many of the
|
|
steps taken were Brent's midpoint fallback rather than an interpolation.
|
|
Used to explain, rather than assert, where the evaluations went."""
|
|
g = Counter(f, maxfev)
|
|
n_bisect = 0
|
|
b_ = b
|
|
try:
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
if stats is not None:
|
|
stats['bisect'] = 0
|
|
c, fc = a, fa
|
|
d = e = b - a
|
|
while True:
|
|
if _same_sign(fb, fc):
|
|
c, fc = a, fa
|
|
d = e = b - a
|
|
if abs(fc) < abs(fb):
|
|
a, fa = b, fb
|
|
b, fb = c, fc
|
|
c, fc = a, fa
|
|
tol1 = 2.0 * 2.22e-16 * abs(b) + 0.5 * xtol
|
|
xm = 0.5 * (c - b)
|
|
if abs(xm) <= tol1 or fb == 0.0:
|
|
if stats is not None:
|
|
stats['bisect'] = n_bisect
|
|
return b, g.n, True
|
|
if abs(e) >= tol1 and abs(fa) > abs(fb):
|
|
s = fb / fa
|
|
if a == c:
|
|
p = 2.0 * xm * s
|
|
q = 1.0 - s
|
|
else:
|
|
q = fa / fc
|
|
r = fb / fc
|
|
p = s * (2.0 * xm * q * (q - r) - (b - a) * (r - 1.0))
|
|
q = (q - 1.0) * (r - 1.0) * (s - 1.0)
|
|
if p > 0.0:
|
|
q = -q
|
|
p = abs(p)
|
|
if 2.0 * p < min(3.0 * xm * q - abs(tol1 * q), abs(e * q)):
|
|
e = d
|
|
d = p / q
|
|
else:
|
|
d = xm
|
|
e = d
|
|
n_bisect += 1
|
|
else:
|
|
d = xm
|
|
e = d
|
|
n_bisect += 1
|
|
a, fa = b, fb
|
|
b = b + (d if abs(d) > tol1 else math.copysign(tol1, xm))
|
|
b_ = b
|
|
fb = g(b)
|
|
except _Budget:
|
|
if stats is not None:
|
|
stats['bisect'] = n_bisect
|
|
return b_, g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# TOMS 748 (Alefeld, Potra & Shi 1995) - via scipy, counted by the wrapper
|
|
# --------------------------------------------------------------------------
|
|
def toms748(f, a, b, xtol, maxfev):
|
|
g = Counter(f, maxfev)
|
|
try:
|
|
x, r = _scipy_toms748(g, a, b, xtol=xtol, rtol=8.9e-16,
|
|
maxiter=maxfev, full_output=True, disp=False)
|
|
return x, g.n, bool(r.converged)
|
|
except _Budget:
|
|
return 0.5 * (a + b), g.n, False
|
|
except Exception:
|
|
return 0.5 * (a + b), g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# ITP - Oliveira & Takahashi, ACM TOMS 47(1), 2021
|
|
# Three tuning parameters, authors' recommended defaults.
|
|
# --------------------------------------------------------------------------
|
|
def itp(f, a, b, xtol, maxfev, kappa1=None, kappa2=2.0, n0=1):
|
|
"""ITP is natively written against a half-width epsilon: it terminates on
|
|
(b - a) <= 2*eps. Every other method here terminates on (b - a) <= xtol.
|
|
Feeding xtol straight in as eps would let ITP stop at twice the bracket
|
|
width of its competitors and win the benchmark on the stopping rule rather
|
|
than on the search. eps = xtol/2 is therefore used, which makes the final
|
|
bracket identical across all methods. Requires a < b."""
|
|
if not a < b:
|
|
raise ValueError("itp requires a < b")
|
|
g = Counter(f, maxfev)
|
|
x = 0.5 * (a + b)
|
|
eps = 0.5 * xtol
|
|
try:
|
|
if kappa1 is None:
|
|
kappa1 = 0.2 / (b - a)
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
n_half = math.ceil(math.log2((b - a) / (2.0 * eps))) if b - a > 2 * eps else 0
|
|
n_max = n_half + n0
|
|
j = 0
|
|
while (b - a) > 2.0 * eps:
|
|
# interpolation (regula falsi)
|
|
x_f = (b * fa - a * fb) / (fa - fb)
|
|
# truncation
|
|
x_half = 0.5 * (a + b)
|
|
sigma = math.copysign(1.0, x_half - x_f)
|
|
delta = kappa1 * (b - a) ** kappa2
|
|
x_t = x_f + sigma * delta if delta <= abs(x_half - x_f) else x_half
|
|
# projection
|
|
r = 2.0 ** (n_max - j) * eps - 0.5 * (b - a)
|
|
x = x_t if abs(x_t - x_half) <= r else x_half - sigma * r
|
|
fx = g(x)
|
|
if fx == 0.0:
|
|
return x, g.n, True
|
|
if _opposite(fx, fa):
|
|
b, fb = x, fx
|
|
else:
|
|
a, fa = x, fx
|
|
j += 1
|
|
return 0.5 * (a + b), g.n, True
|
|
except _Budget:
|
|
return x, g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Chandrupatla 1997 - the hybrid quadratic/bisection method. It owns one of
|
|
# the two benchmark sets used here and is the closest relative of the proposed
|
|
# rule, so its absence from the comparison would be the first thing a reader
|
|
# asked about.
|
|
# --------------------------------------------------------------------------
|
|
def chandrupatla(f, a, b, xtol, maxfev):
|
|
g = Counter(f, maxfev)
|
|
x1, x2 = b, a
|
|
# defined before the first evaluation: a budget exhausted on f(b) must be
|
|
# an honest failure, not an UnboundLocalError in the handler
|
|
f1 = f2 = 0.0
|
|
try:
|
|
f1, f2 = g(x1), g(x2)
|
|
if f1 == 0.0:
|
|
return x1, g.n, True
|
|
if f2 == 0.0:
|
|
return x2, g.n, True
|
|
x3, f3 = x2, f2
|
|
t = 0.5
|
|
while True:
|
|
x = x1 + t * (x2 - x1)
|
|
fx = g(x)
|
|
if (fx > 0.0) == (f1 > 0.0):
|
|
x3, f3 = x1, f1
|
|
x1, f1 = x, fx
|
|
else:
|
|
x3, f3 = x2, f2
|
|
x2, f2 = x1, f1
|
|
x1, f1 = x, fx
|
|
xm, fm = (x1, f1) if abs(f1) < abs(f2) else (x2, f2)
|
|
if fm == 0.0:
|
|
return xm, g.n, True
|
|
# tl > 1/2 stops once span < 2*tol, so the absolute term must be xtol/2 for
|
|
# this method to stop on the same bracket width as every other one (<= xtol).
|
|
# Until 2026-09-21 it was xtol, a bracket twice as wide - the factor the
|
|
# protocol corrects for ITP and had not corrected here. With xtol/2 this
|
|
# code and SciPy's elementwise.find_root agree on all 199 problems exactly
|
|
# (audit_chandrupatla_library.py).
|
|
tol = 2.0 * 2.22e-16 * abs(xm) + 0.5 * xtol
|
|
span = abs(x2 - x1)
|
|
if span == 0.0:
|
|
return xm, g.n, True
|
|
tl = tol / span
|
|
if tl > 0.5:
|
|
return xm, g.n, True
|
|
# accept inverse quadratic interpolation only inside the region
|
|
# where Chandrupatla shows it is safe
|
|
t = 0.5
|
|
if x3 != x2 and f3 != f2:
|
|
xi = (x1 - x2) / (x3 - x2)
|
|
ph = (f1 - f2) / (f3 - f2)
|
|
if 0.0 < xi < 1.0 and 1.0 - math.sqrt(1.0 - xi) < ph < math.sqrt(xi):
|
|
d1, d2, d3 = f1 - f2, f1 - f3, f2 - f3
|
|
if d1 != 0.0 and d2 != 0.0 and d3 != 0.0:
|
|
t = (f1 / d1) * (f3 / (-d3)) + \
|
|
((x3 - x1) / (x2 - x1)) * (f1 / d2) * (f2 / d3)
|
|
if not math.isfinite(t):
|
|
t = 0.5
|
|
t = min(max(t, tl), 1.0 - tl)
|
|
except _Budget:
|
|
return (x1 if abs(f1) < abs(f2) else x2), g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Dekker 1969 - secant with a bisection fallback, the direct ancestor of Brent
|
|
# --------------------------------------------------------------------------
|
|
def dekker(f, a, b, xtol, maxfev):
|
|
g = Counter(f, maxfev)
|
|
bb = b
|
|
try:
|
|
fa, fb = g(a), g(b)
|
|
if fa == 0.0:
|
|
return a, g.n, True
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
if abs(fa) < abs(fb):
|
|
a, b, fa, fb = b, a, fb, fa
|
|
c, fc = a, fa
|
|
while abs(b - c) > xtol:
|
|
m = 0.5 * (c + b)
|
|
if fb != fa:
|
|
s = b - fb * (b - a) / (fb - fa)
|
|
else:
|
|
s = m
|
|
lo, hi = (b, m) if b < m else (m, b)
|
|
bnew = s if lo < s < hi else m
|
|
a, fa = b, fb
|
|
b = bnew
|
|
bb = b
|
|
fb = g(b)
|
|
if (fb > 0.0) == (fc > 0.0):
|
|
c, fc = a, fa
|
|
if abs(fc) < abs(fb):
|
|
b, c = c, b
|
|
fb, fc = fc, fb
|
|
if fb == 0.0:
|
|
return b, g.n, True
|
|
return b, g.n, True
|
|
except _Budget:
|
|
return bb, g.n, False
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Library implementations. Where SciPy ships the method, its implementation
|
|
# is the one a reader can check and the one a user would actually run, so it
|
|
# belongs in the comparison. Ours are kept beside them for two reasons: the
|
|
# timing section needs pure Python throughout, and a competitor is entitled to
|
|
# the better of the two counts (see `best_of` below).
|
|
#
|
|
# Measured difference on the 199 problems: brentq 7004 against our 6991,
|
|
# _chandrupatla 5228 against our 5209 - that is, OUR versions are slightly
|
|
# cheaper, so taking the library number alone would flatter us.
|
|
# --------------------------------------------------------------------------
|
|
def brent_scipy(f, a, b, xtol, maxfev):
|
|
from scipy.optimize import brentq
|
|
g = Counter(f, maxfev)
|
|
try:
|
|
x = brentq(g, a, b, xtol=xtol, rtol=8.9e-16, maxiter=maxfev)
|
|
return x, g.n, True
|
|
except _Budget:
|
|
return 0.5 * (a + b), g.n, False
|
|
except Exception:
|
|
return 0.5 * (a + b), g.n, False
|
|
|
|
|
|
def chandrupatla_scipy(f, a, b, xtol, maxfev):
|
|
import numpy as np
|
|
from scipy.optimize._chandrupatla import _chandrupatla
|
|
g = Counter(f, maxfev)
|
|
|
|
def vec(x):
|
|
return np.asarray([g(float(xi)) for xi in np.atleast_1d(x)]).reshape(
|
|
np.shape(x))
|
|
|
|
try:
|
|
res = _chandrupatla(vec, a, b, xatol=xtol, xrtol=8.9e-16,
|
|
fatol=0.0, frtol=0.0, maxiter=maxfev)
|
|
return (float(np.asarray(res.x).ravel()[0]), g.n,
|
|
bool(np.all(np.asarray(res.success))))
|
|
except _Budget:
|
|
return 0.5 * (a + b), g.n, False
|
|
except Exception:
|
|
return 0.5 * (a + b), g.n, False
|
|
|
|
|
|
def chandrupatla_lib(f, a, b, xtol, maxfev):
|
|
"""Chandrupatla from the library's PUBLIC interface, the benchmark's baseline since
|
|
2026-09-21 (Valeri: remove the risk of our own implementation). Same stopping rule
|
|
as every other method - bracket narrower than xtol, relative term at machine
|
|
precision, no stopping on |f| - and evaluations counted by the same Counter."""
|
|
import numpy as np
|
|
from scipy.optimize import elementwise
|
|
g = Counter(f, maxfev)
|
|
|
|
def vec(x):
|
|
return np.asarray([g(float(xi)) for xi in np.atleast_1d(x)]).reshape(np.shape(x))
|
|
|
|
try:
|
|
res = elementwise.find_root(vec, (a, b), maxiter=maxfev,
|
|
tolerances=dict(xatol=xtol, xrtol=8.9e-16,
|
|
fatol=0.0, frtol=0.0))
|
|
return (float(np.asarray(res.x).ravel()[0]), g.n,
|
|
bool(np.all(np.asarray(res.success))))
|
|
except _Budget:
|
|
return 0.5 * (a + b), g.n, False
|
|
except Exception:
|
|
return 0.5 * (a + b), g.n, False
|
|
|
|
|
|
# The competitors as benchmarked: the library alone wherever the library ships the
|
|
# method. Until 2026-09-21 they were best_of(ours, library) problem by problem, which
|
|
# let a tolerance slip in OUR Chandrupatla leak into the reported numbers.
|
|
CHANDRUPATLA_BASELINE = chandrupatla_lib
|
|
BRENT_BASELINE = brent_scipy
|
|
|
|
|
|
def best_of(*methods):
|
|
"""Give the competitor the cheaper of its implementations on every
|
|
problem. The same concession the paper already makes to ITP by reporting
|
|
a per-problem oracle over its constants."""
|
|
def run(f, a, b, xtol, maxfev):
|
|
best = None
|
|
for m in methods:
|
|
try:
|
|
x, n, conv = m(f, a, b, xtol, maxfev)
|
|
except Exception:
|
|
continue
|
|
if conv and (best is None or n < best[1]):
|
|
best = (x, n, True)
|
|
return best if best is not None else (0.5 * (a + b), maxfev, False)
|
|
return run
|
|
|
|
|
|
BASELINES = {
|
|
"chandrupatla": chandrupatla,
|
|
"chandrupatla_scipy": chandrupatla_scipy,
|
|
"brent_scipy": brent_scipy,
|
|
"dekker": dekker,
|
|
"bisection": bisection,
|
|
"regula_falsi": regula_falsi,
|
|
"illinois": illinois,
|
|
"pegasus": pegasus,
|
|
"anderson_bjorck": anderson_bjorck,
|
|
"ridders": ridders,
|
|
"brent": brent,
|
|
"toms748": toms748,
|
|
"itp": itp,
|
|
}
|