Files
sim-numerics/simroot/baselines.py
T
vvsandClaude Opus 5 1b6c626f67 Valeri's decisions (1) competitors from the library, (2) variant a. (1) Chandrupatla is scipy.optimize.elementwise.find_root, Brent is brentq, no more per-problem best_of. Our Chandrupatla stopped on a bracket twice as wide as every other method (the factor the protocol corrects for ITP); fixed (+0.5*xtol) in baselines.py and in the two copies found by grep (stage16_blend, stage19_modern) and the traced mirror, and pinned: ours == library on every problem. Regenerating sweep_full.json changed exactly three rows - chandrupatla APS 2785->2787, CHA 2423->2441, brent CHA 4099->4115 - and nothing else (diff_sweeps.py: 0 unexpected).
(2) The proposed method is again the scheduled projection (2806/3220/6026), its budget multiplier DECLARED as the one free constant: the canonical total is the same for every multiplier 1.25-4, the wide-bracket one is not. The earned projection is the constant-free variant (3568/3020/6588). What the old tables hid: problem by problem the proposed rule beats Chandrupatla on the canonical set 87/24/42, exact sign test p = 9e-5, while trailing him by 0.7% in total; on the wide-bracket set he is genuinely stronger (26 to 9, p = 0.006). Gap decomposition recomputed with the library: 244 (was 262), 160 of it on the flat families (two thirds), fun1 58 (a quarter, not a third as the abstract said).

Also corrected on the way: the 'subsumed by Chandrupatla' pin claimed he is better on the worst single problem - true only with the looser tolerance (84 against the credit rule's 83 once fixed); §8.3 still called zero slack degenerate, which Table 1a refutes; §5.2's Brent figure is our pure-Python implementation and now says so. verify_article.py 286/286, 802 tests pass twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:53:00 +01:00

527 lines
18 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 _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 fa * fx < 0.0:
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 fa * fx < 0.0:
# 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 fc * fx < 0.0:
a, fa, b, fb = (c, fc, x, fx) if c < x else (x, fx, c, fc)
elif fa * fx < 0.0:
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 fb * fc > 0.0:
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 fx * fa < 0.0:
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,
}