baseline: the published sim-numerics artifact as it stands (490 tests, verify_article.py checks all 72 paper numbers against sweep_full.json)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
vvs
2026-09-20 22:38:17 +01:00
co-authored by Claude Opus 5
commit 4a23aacf96
75 changed files with 12175 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from .baselines import BASELINES
from .pure import GUARDS, PURE_SIMS, pure_root
from .sim import SIMILARITIES, sim_root
__all__ = ["BASELINES", "GUARDS", "PURE_SIMS", "SIMILARITIES", "pure_root",
"sim_root"]
+489
View File
@@ -0,0 +1,489 @@
"""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
tol = 2.0 * 2.22e-16 * abs(xm) + 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 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,
}
+593
View File
@@ -0,0 +1,593 @@
"""The pure similarity root finder: one similarity function, one step rule.
direction : the side of the bracket that contains the sign change
length : (1 - S(z)) * (distance to the far end), z = f(x) / scale
Everything about the method is in S and in where `scale` comes from.
scale CHOSEN (a fixed reference value) -> linear convergence at a rate
that is a property of the problem, not of the method, and
therefore fails on some problems (stage 0b, 5-8 of 8)
scale DERIVED (the local slope of f, read off the two most recent
evaluations) -> the step becomes |f|/|f'| and
the convergence is superlinear (stage 0d/0e)
The derivation, from the contraction law measured in stage 0c:
e_{n+1} = e_n * |1 - c*|f'(r)|*D/scale| , c = slope of (1 - S) at 0
rate = 0 <=> c = scale / (|f'(r)|*D)
so the one quantity the pure rule needs is the LOCAL SCALE OF THE VALUE AXIS.
Choosing it is what makes the method brittle; reading it off the data is what
makes it parameter free - the same move the SIM control paper makes when it
normalises the input space from the data instead of choosing membership
widths by hand.
Safeguards
----------
An interpolation-only rule has no worst-case bound. Two parameter-free
safeguards are provided and are measured against each other:
"bracket" bisect whenever a similarity step failed to halve the BRACKET.
Sound, but expensive: a secant step routinely collapses the
error while barely moving the proven bracket, so the guard
fires on steps that were in fact excellent (measured: 150 vs
86 evaluations over the stage-0 problem set).
"step" bisect whenever the proposed step is not at most half the step
taken two evaluations ago. Step lengths are then forced to
decrease geometrically, and a converging secant sequence -
whose steps shrink far faster than by half - never triggers
it. This is the criterion Brent's method uses internally,
stated without his tuning constants.
It does NOT on its own bound the worst case, and an earlier
version of this docstring claimed that it did. A sequence
that creeps towards a non-root from one side with steps that
halve every time has small steps by construction, so the guard
stays silent, the sign never changes, and the proven bracket
never moves. Cheap, but not sound alone.
"both" the step guard, with the bracket guard underneath it as the
backstop that actually carries the worst-case bound. The step
guard does the work on ordinary problems, so the bracket guard
almost never fires and almost never costs anything.
"project" do not REPLACE the similarity step at all - PROJECT it onto the
largest interval still consistent with the bisection bound:
allowed_j = 2^(n_max - j) * (xtol/2)
x = clamp(x_sim, hi - allowed_j, lo + allowed_j)
with n_max = 2*ceil(log2(w0 / xtol)) fixed by the initial
bracket. The next bracket is at most max(x - lo, hi - x), so
the clamp gives w_{j+1} <= allowed_j directly, and induction
bounds the run by n_max steps in exact arithmetic - one more
when the bracket lands exactly on the boundary in floating
point. The guarantee is therefore
2*ceil(log2(w0/xtol)) + 3 evaluations: twice bisection plus
one step.
Zero slack (n_max = ceil(log2(w0/xtol)), a worst case of
exactly bisection) is degenerate and was measured as such: it
demands a halving at every single step, leaving the projection
no freedom, and the guard behaves as bisection.
The three guards above discard a computed step; this one keeps
as much of it as the bound allows, which is why it does not
flatten the order. The construction is ITP's projection
stage. ITP's three constants (kappa1, kappa2, n0) all live in
its TRUNCATION stage, which exists to repair regula falsi;
the similarity step needs no repair, so the truncation - and
with it every constant - is dropped.
"safe" the step guard for cheapness, with the projection carrying the
bound. This is the configuration the paper proposes: a proven
worst case AND the full order, which the three earlier guards
could not deliver together.
The only constant in any of them is 1/2, which is the definition of bisection
rather than a tuned value.
"""
from __future__ import annotations
import math
from .baselines import Counter, _Budget
# --------------------------------------------------------------------------
# [0,1] similarity functions of a normalised residual z: S(0) = 1 (identical
# to the expectation), S -> 0 far from it.
# --------------------------------------------------------------------------
PURE_SIMS = {
# the ratio form S = 1 - |f|/scale. Note that the symmetric ratio
# min(|x|,|y|)/max(|x|,|y|) is identically 0 when one argument is the
# target, so this is the only usable reading of "ratio similarity to 0".
"ratio": lambda z: max(0.0, 1.0 - abs(z)),
"sqrt": lambda z: max(0.0, 1.0 - math.sqrt(abs(z))),
"cbrt": lambda z: max(0.0, 1.0 - abs(z) ** (1.0 / 3.0)),
"rational": lambda z: 1.0 / (1.0 + abs(z)),
"ratio_soft": lambda z: 1.0 / (1.0 + abs(z)) ** 2,
"exp_abs": lambda z: math.exp(-abs(z)),
# similarities that approach 1 quadratically: c = 0, predicted rate 1,
# kept because the law has to be falsifiable in both directions
"rat_sq": lambda z: 1.0 / (1.0 + z * z),
"exp_sq": lambda z: math.exp(-z * z),
"cosine": lambda z: 1.0 / math.sqrt(1.0 + z * z),
}
GUARDS = ("none", "bracket", "step", "both", "project", "safe", "credit")
class _Safeguard:
"""Parameter-free worst-case protection. See the module docstring."""
__slots__ = ("mode", "guard_width", "pending", "step_2", "step_1",
"n_half", "eps", "j", "allow", "credit")
def __init__(self, mode, width, xtol=None, budget_factor=2.0,
budget_extra=None):
if mode not in GUARDS:
raise ValueError(f"unknown guard {mode!r}, expected one of {GUARDS}")
self.mode = mode
self.guard_width = width
self.pending = False
self.step_2 = width # step two evaluations ago
self.step_1 = width # step one evaluation ago
# projection state: the bisection budget, fixed by the initial bracket
self.eps = 0.5 * xtol if xtol else 0.0
self.j = 0
# set for every mode: the step loop decrements it unconditionally, and
# leaving it to the projection branch made every other guard raise
self.allow = 0.0
self.n_half = 0
self.credit = 1.0 # one bit of endowment; see project_credit
if mode in ("project", "safe"):
if not xtol or width <= 0.0:
raise ValueError("the projection guard needs xtol and a "
"positive initial width")
half = math.ceil(math.log2(width / xtol)) if width > xtol else 0
# The budget is TWICE the bisection budget. Zero slack (budget =
# half) is degenerate: a worst case of exactly bisection demands a
# halving at every single step, so the projection leaves no
# freedom at all - measured, r_0 was 0.05 of a unit bracket and
# the guard behaved as bisection. Doubling is not a tuned
# constant: it is the same factor 2 already claimed as the bound,
# and it buys total freedom until the budget is nearly spent.
# Two ways to give the projection room, and the difference
# matters for whether this method contains a constant at all.
#
# budget_extra = k -> n_max = half + k. The requirement is
# only r_0 >= 0, i.e. n_max >= half; at n_max = half there
# is no room and the guard degenerates to bisection. The
# MINIMAL non-degenerate choice is therefore k = 1 - not a
# tuned value but the smallest integer for which the
# construction works at all.
# budget_factor = c -> n_max = ceil(c * half). This is what
# the paper used, at c = 2, and the justification given for
# it ("the factor already in the bound") was circular: the
# bound is 2x because 2 was chosen. Kept as an ablation
# knob so the circularity can be measured rather than
# argued about.
if budget_extra is not None:
self.n_half = half + int(budget_extra)
else:
self.n_half = max(half, int(math.ceil(budget_factor * half)))
# The allowance 2^(n_max - j).eps is halved once per step, so it
# is carried forward rather than recomputed: an exponentiation in
# the inner loop was costing more than the projection itself.
# Halving is exact in binary floating point, so this changes no
# iterate and no evaluation count - only the arithmetic.
try:
self.allow = 2.0 ** self.n_half * self.eps
except OverflowError:
self.allow = math.inf
def project_credit(self, x_sim, lo, hi):
"""The projection with the budget EARNED rather than scheduled.
The scheduled version fixes the allowance in advance from the initial
width, so the multiplier in n_max = c*half is a free constant, and the
argument that c = 2 is "the factor already in the bound" is circular:
the bound is 2x because 2 was chosen. Measured, c = 1.25 is 8%
better, so the constant is not neutral either.
Here the method pays its own way. Let s_j = log2(w_j / w_{j+1}) be
the shrink a step delivers in bits; bisection delivers exactly 1. Keep
C <- C + s_j - 1, C >= 0,
the surplus banked so far, and allow a step to under-deliver only by
what has been banked: the width after the step may be at most
2^(C-1) * w. Since C never goes negative, the shrinks over j steps
sum to at least j - C_0, so the run costs at most half + C_0 steps.
C_0 = 1 - one bit of endowment - is the smallest integer for which the
rule is not degenerate: at C_0 = 0 the allowance is exactly w/2 on the
first step, which forces the midpoint and makes the method bisection.
So the guarantee is bisection plus one step, and the constant is the
promise, not a tuning knob.
"""
w = hi - lo
# The allowance is w * 2^(C-1), so it exceeds w exactly when C >= 1,
# and then it restricts nothing. Testing that FIRST removes the
# exponentiation from the common path and, more importantly, removes
# a crash: C accumulates the log of every shrink and is unbounded, so
# 2**(C-1) overflows a double at C ~ 1025. That is reachable - sixty
# nine-decade steps reach C = 1012, and a bracket of width 1e300 with
# a tolerance of 1e-8 has about 1023 bits of shrink available - and
# the OverflowError would escape, since only the budget exception is
# caught here.
if self.credit >= 1.0:
return x_sim
allowed = w * (2.0 ** (self.credit - 1.0))
left, right = hi - allowed, lo + allowed
if left > right:
return 0.5 * (lo + hi)
return min(max(x_sim, left), right)
def note_shrink(self, w_before, w_after):
"""Bank what the step delivered beyond a halving, or draw on the bank
for what it fell short."""
if w_before <= 0.0 or w_after <= 0.0:
return
s = math.log2(w_before / w_after)
self.credit = max(0.0, self.credit + s - 1.0)
def project(self, x_sim, lo, hi):
"""Pull the similarity step back to the edge of the interval that the
bisection budget still allows. Returns the admissible iterate.
The budget says the next bracket may be at most
`allowed = 2^(n_max - j) * eps`. Whichever side the sign change turns
out to be on, that bracket is at most max(x - lo, hi - x), so the
admissible set is exactly
[hi - allowed, lo + allowed].
Writing it this way rather than as a radius r = allowed - (hi-lo)/2
around the midpoint matters numerically: r is the difference of two
nearly equal numbers, and the cancellation made the computed radius
slightly too large. Measured, that admitted points which broke the
invariant by a relative 8.9e-5 and cost exactly one extra evaluation
on eleven Chandrupatla problems. The clamped form below performs no
cancelling subtraction and cannot overshoot."""
allowed = self.allow
if not math.isfinite(allowed):
return x_sim
left, right = hi - allowed, lo + allowed
if left > right:
# the budget no longer permits anything but a halving
return 0.5 * (lo + hi)
return min(max(x_sim, left), right)
def bisect_before_step(self, width):
"""Called before the similarity step is even computed. Only the
bracket guard can decide at this point."""
if self.mode not in ("bracket", "both"):
return False
if width <= 0.5 * self.guard_width:
self.guard_width = width
# The similarity step that is about to be taken is itself the
# pending one. Clearing the flag here instead granted TWO
# consecutive similarity steps after every halving, which makes
# the guarantee one bisection in three evaluations (3x bisection)
# rather than one in two (2x) - measured at 139 evaluations
# against a 2x bound of 114 on Chandrupatla fun4.3.
self.pending = True
return False
if self.pending:
self.pending = False
return True
self.pending = True
return False
def reject_step(self, step):
"""Called with the proposed displacement. Only the step guard can
decide at this point."""
if self.mode not in ("step", "both", "safe"):
return False
return abs(step) > 0.5 * self.step_2
def note_step(self, step):
self.step_2 = self.step_1
self.step_1 = abs(step)
def _slope3(x2, f2, x1, f1, x0, f0):
"""Derivative at x2 of the parabola through the three most recent points,
as Newton divided differences:
f'(x2) ~ f[x1,x2] + f[x0,x1,x2] * (x2 - x1)
Falls back to the two-point slope when a point is repeated or missing, so
the first iteration of a run behaves exactly like scale="derived"."""
if x2 == x1:
return 0.0
d12 = (f2 - f1) / (x2 - x1)
# An overflowing difference must report "no usable slope" (0.0), which
# sends pure_root to its bisection fallback. Returning the infinity
# instead would make the scale infinite, hence z = 0, hence "perfectly
# similar to the target", hence a zero step - a stall dressed up as
# success.
if not math.isfinite(d12):
return 0.0
if x0 is None or x0 == x1 or x0 == x2:
return d12
d01 = (f1 - f0) / (x1 - x0)
d012 = (d12 - d01) / (x2 - x0)
s = d12 + d012 * (x2 - x1)
return s if math.isfinite(s) else d12
def _local_order(x2, f2, x1, f1, x0, f0):
"""The local order m of f, from three evaluations and nothing else.
Redo the contraction law WITHOUT assuming |f| ~ |f'(r)|.|x-r|. Take
instead |f| ~ K.e^m near the current point, m = 1 being a simple root and
m > 1 a locally flat approach. Then for a power similarity S = 1 - z^p,
z = |f| / (|f'|.D) = e / (m.D) (K cancels)
step = z^p . D = D^(1-p) . (e/m)^p
and the step lands on the root when (e/D)^(p-1) = m^p, i.e.
p = L / (L - ln m), L = ln(e/D) < 0.
m = 1 gives p = 1, the earlier law. m > 1 drives p below 1 on its own -
at e/D = 0.1, m = 10 it gives p = 0.50, and at e/D = 0.5 it gives 0.23,
which is the neighbourhood of the cube-root similarity that stage 7 found
winning on the wide-bracket families. Substituting that p back collapses
the whole expression to step = e = m.|f|/|f'|, so the exponent is a
reparameterisation and the substance is the multiplier m.
m is measurable without knowing the root: with theta = f.f''/(f')^2,
f ~ K e^m, f' ~ K m e^(m-1), f'' ~ K m(m-1) e^(m-2)
=> theta = m(m-1)/m^2 = 1 - 1/m => m = 1/(1 - theta).
Both derivatives are divided differences of the three most recent points,
so nothing is chosen or fitted.
Returns 1.0 whenever the power-law model does not apply - theta outside
(0,1) means the data does not describe a flat approach, and the honest
response is to decline the correction rather than to clamp it into range.
"""
if x0 is None or len({x0, x1, x2}) < 3:
return 1.0
d01 = (f1 - f0) / (x1 - x0)
d12 = (f2 - f1) / (x2 - x1)
d012 = (d12 - d01) / (x2 - x0)
fp = d12 + d012 * (x2 - x1) # f'(x2) on the parabola
fpp = 2.0 * d012 # f''(x2) on the parabola
if fp == 0.0 or not math.isfinite(fp) or not math.isfinite(fpp):
return 1.0
theta = f2 * fpp / (fp * fp)
if not math.isfinite(theta) or not (0.0 < theta < 1.0):
return 1.0
m = 1.0 / (1.0 - theta)
return m if math.isfinite(m) and m >= 1.0 else 1.0
def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
guard="step", stats=None, budget_factor=2.0,
budget_extra=None, endowment=1.0):
"""The pure similarity root finder.
similarity : key into PURE_SIMS
scale : "derived" - local slope of f from the two most recent
evaluations (parameter free, order ~1.618)
"derived3_m" - REFUTED as an improvement. The three-point
scale divided by the local order m of f, which
is what the contraction law gives once the
linearity assumption is dropped. The derivation
is sound and the order is preserved; the
estimator of m is not sharp enough to pay for
itself. See the note at the use site.
"derived_rho", "derived3_rho" - REFUTED. The same, with the
scale multiplied by the fraction of its own
promise the previous step kept. Retained for
reproducibility only; see the note at the use
site. Do not adopt.
"derived3" - local slope from the three most recent
evaluations, i.e. the derivative at x of the
parabola through them. Still parameter free -
a divided difference is a computation, not a
fitted coefficient - but the slope error drops
from O(h) to O(h^2), which lifts the order from
1.618 towards 1.84. This is the direct
consequence of the stage-4 finding that the
whole deficit against Brent is the endgame
order, and nothing else.
"start" - |f| at the starting point (a chosen reference)
"bracket" - largest |f| at the current bracket ends
guard : one of GUARDS
endowment : ABLATION KNOB for guard="credit", not a setting. The method
is defined at 1 bit, and 1 is not a chosen value: the
guarantee is bisection + ceil(C0) steps, so every C0 in (0,1]
promises the same thing - one step - and among those 1 is the
most permissive and therefore the cheapest. Measured over
the 199 problems: 0.1 -> 8395, 0.5 -> 7657, 1 -> 6588, all
with the same one-step guarantee; 2 -> 6404 but two steps,
5 -> 5621 but five. Nothing is free to choose once the
promise is fixed, and the promise is the specification.
stats : optional dict; on return it carries 'forced' = how many steps
the safeguard turned into bisections. Comparable with the
same key from baselines.brent, so the two methods' fallback
rates can be put side by side.
Returns (x, nfev, converged).
"""
if similarity not in PURE_SIMS:
raise ValueError(f"unknown similarity {similarity!r}")
if scale not in ("derived", "derived3", "derived_rho", "derived3_rho",
"derived3_m", "start", "bracket"):
raise ValueError(f"unknown scale {scale!r}")
S = PURE_SIMS[similarity]
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
if fa * fb > 0.0:
raise ValueError("f(a) and f(b) must straddle zero")
A, FA, B, FB = a, fa, b, fb # the proven bracket
if abs(fa) <= abs(fb):
x, fx, x_prev, f_prev = a, fa, b, fb
else:
x, fx, x_prev, f_prev = b, fb, a, fa
# the third point does not exist yet; _slope3 falls back to two points
x_prev2, f_prev2 = None, None
start_scale = abs(fx)
sg = _Safeguard(guard, abs(B - A), xtol, budget_factor, budget_extra)
sg.credit = endowment
forced = 0
if stats is not None:
stats['forced'] = 0
while abs(B - A) > xtol:
if sg.bisect_before_step(abs(B - A)):
x_new = 0.5 * (A + B)
forced += 1
# keep the step history honest under guard="both": a bisection
# forced by the bracket backstop is still a step that was taken
sg.note_step(x_new - x)
else:
far = B if abs(B - x) >= abs(A - x) else A
if scale in ("derived", "derived_rho"):
slope = ((fx - f_prev) / (x - x_prev)) if x != x_prev else 0.0
sc = abs(slope) * abs(far - x)
elif scale in ("derived3", "derived3_rho", "derived3_m"):
slope = _slope3(x, fx, x_prev, f_prev, x_prev2, f_prev2)
sc = abs(slope) * abs(far - x)
if scale == "derived3_m":
# REFUTED as an improvement - kept for reproducibility.
# The DERIVATION holds: dropping the linearity
# assumption from the contraction law does produce the
# march exponent, and it collapses to the multiplier m.
# The ESTIMATOR does not: theta from three points
# cannot tell a genuinely flat approach from curvature
# noise. Measured, it reports m > 1 on 27.9% of steps
# across Alefeld-Potra-Shi, where almost every root is
# simple and the truth is m = 1, with excursions to
# m = 34.8; on the flat fun3/4/5 families it fires on
# 35.8%. A separation of 27.9 against 35.8 is no
# separation, and the false positives cost 273
# evaluations: 3079 against 2806 on APS, 6304 against
# 6026 over both sets.
#
# It does pass the two tests rho failed - the order
# stays at 2.001 and the eight standard problems
# improve to 82 - which is exactly why it must be
# labelled: a small hand-picked set makes it look like
# a win.
#
# step = m.|f|/|f'| is step = e for a locally flat
# approach; dividing the scale by m is the same thing
# written in the similarity's own terms. m >= 1, so
# like rho this can only lengthen a step - but unlike
# rho it is read from the CURVATURE, which stays
# meaningful as |f| goes to zero, and it returns to 1
# at a simple root, leaving the endgame untouched.
sc /= _local_order(x, fx, x_prev, f_prev,
x_prev2, f_prev2)
elif scale == "start":
sc = start_scale
else:
sc = max(abs(FA), abs(FB))
if scale.endswith("_rho"):
# REFUTED - kept only so the experiment is reproducible.
# Measured (stage 8): the fun3/4/5 families it was built
# for do not improve (1364 against 1367, where cbrt gets
# 677); the eight standard problems get twice as
# expensive (84 -> 161); and the observed order collapses
# from 2.000 to 0.904.
#
# Why the derivation was wrong: rho equates "how much
# value the step removed" with "how trustworthy the local
# model is". Near the root |f_n| and |f_{n-1}| are both
# negligible and their ratio is noise, so rho thrashes in
# exactly the place where the correction has to be inert.
# The alternative credibility - the ratio of the assumed
# slope to the realised one - is closed too: the realised
# slope is what the derived scale already computes, so
# correcting by it would double-count.
#
# The phase detector. A step of |f|/|f'| is a promise to
# land on the root; rho is the fraction of that promise
# the previous step actually kept:
#
# rho = 1 - |f_n| / |f_{n-1}|
#
# Near the root the local model is right and rho -> 1, so
# the correction vanishes and the order is untouched. Far
# from it - the approach phase on a very wide bracket -
# the model under-delivers, rho is small, and the step
# must be 1/rho longer to remove what is left. Dividing
# the scale by rho does exactly that.
#
# rho <= 1 always, so the correction can only LENGTHEN the
# step, never shorten it below the Newton step. rho <= 0
# means the value did not improve at all: there is no
# credibility to read, so the correction is dropped rather
# than guessed, and the bracket projection keeps the step
# safe either way.
if f_prev != 0.0:
rho = 1.0 - abs(fx) / abs(f_prev)
if 0.0 < rho <= 1.0:
sc *= rho
if sc == 0.0 or not math.isfinite(sc):
x_new = 0.5 * (A + B)
else:
step = (1.0 - S(abs(fx) / sc)) * (far - x)
if sg.reject_step(step):
x_new = 0.5 * (A + B)
step = x_new - x
forced += 1
else:
x_new = x + step
if guard in ("project", "safe", "credit"):
projected = (sg.project_credit if guard == "credit"
else sg.project)(x_new, min(A, B), max(A, B))
if projected != x_new:
forced += 1
x_new = projected
step = x_new - x
sg.note_step(step)
if not math.isfinite(x_new) or not (min(A, B) < x_new < max(A, B)):
x_new = 0.5 * (A + B)
if x_new == x or x_new == x_prev:
x_new = 0.5 * (A + B)
if x_new == x or x_new == x_prev:
# the bracket can no longer be split in floating point
if stats is not None:
stats['forced'] = forced
return x_new, g.n, abs(B - A) <= xtol
w_before = abs(B - A)
fnew = g(x_new)
sg.j += 1 # one step of the bisection budget is spent
sg.allow *= 0.5
x_prev2, f_prev2 = x_prev, f_prev
x_prev, f_prev = x, fx
x, fx = x_new, fnew
if fnew == 0.0:
if stats is not None:
stats['forced'] = forced
return x_new, g.n, True
if FA * fnew < 0.0:
B, FB = x_new, fnew
else:
A, FA = x_new, fnew
if guard == "credit":
sg.note_shrink(w_before, abs(B - A))
if stats is not None:
stats['forced'] = forced
return 0.5 * (A + B), g.n, True
except _Budget:
if stats is not None:
stats['forced'] = forced
return x, g.n, False
+237
View File
@@ -0,0 +1,237 @@
"""SIM root finding - the similarity-induced step, in the formulation of
"Parameter-Free Similarity Control" (Sitnikov).
Mapping of the article's machinery onto root finding
----------------------------------------------------
article here
------- ----
input x the target value y* = 0 ("the expectation")
prototype c_k an evaluated value f(x_k) (value space)
prototype memory y_k the abscissa x_k that produced it
similarity s(x, c_k) similarity of f(x_k) to zero
softmax weights w_k eq. (2)
prediction sum w_k y_k the root estimate, eq. (3)
So a root finder IS the article's controller run backwards: it is asked "which
x is associated with the outcome 0?" and answers by normalized similarity
aggregation over its memory of evaluated points.
Two levels of prototype are implemented:
POINT prototypes x_hat = sum_k w_k x_k
Direct transcription of eq. (3). Exactness note: on a linear f this
recovers the root exactly only for the inverse-distance similarity;
every other similarity function biases it. Measured, not assumed.
PAIR prototypes x_hat = sum_ij w_ij r_ij , r_ij = secant root of (i,j)
Each bracketing PAIR of memory points is a local rule ("if the outcome
changes sign between x_i and x_j then the root is near r_ij"), exactly
parallel to a fuzzy rule table entry. This is the level at which the
method departs from anything classical: regula falsi is the special
case of a single pair, and the stale-endpoint failure of regula falsi
disappears because a pair containing a large-|f| stale endpoint is by
construction dissimilar to the target and is down-weighted.
Parameter freedom
-----------------
Similarity needs a scale in value space. It is taken from the data in play -
the largest |f| currently in memory - never from a chosen constant. There is
no sigma, no temperature, no threshold, no tuned coefficient anywhere below.
"""
from __future__ import annotations
import math
from .baselines import Counter, _Budget
EPS = 2.220446049250313e-16
# --------------------------------------------------------------------------
# similarity functions on the value axis, all taking already-normalised
# residuals z = f / scale and returning a similarity in a comparable range
# --------------------------------------------------------------------------
def sim_neg_sq(z):
"""Negative squared Euclidean distance to the target, then softmax.
This is the article's default (eq. 6) and its benchmark choice."""
return -z * z
def sim_neg_abs(z):
"""Negative distance to the target, then softmax."""
return -abs(z)
def sim_inv(z):
"""Inverse distance. Returned as a log-similarity so the caller's softmax
reproduces plain inverse-distance weights. This is the unique member of
the family that is exact on a linear f (see module docstring)."""
return -math.log(abs(z) + EPS)
def sim_inv_sq(z):
return -2.0 * math.log(abs(z) + EPS)
def sim_cosine(z):
"""Cosine similarity between the 2-vectors (1, f_k) and (1, 0), i.e. the
article's eq. (4) applied to the value axis lifted to 2-D."""
return 1.0 / math.sqrt(1.0 + z * z)
SIMILARITIES = {
"neg_sq": sim_neg_sq,
"neg_abs": sim_neg_abs,
"inv": sim_inv,
"inv_sq": sim_inv_sq,
"cosine": sim_cosine,
}
def _softmax(scores):
m = max(scores)
ex = [math.exp(s - m) for s in scores]
tot = sum(ex)
if tot == 0.0 or not math.isfinite(tot):
n = len(ex)
return [1.0 / n] * n
return [e / tot for e in ex]
# --------------------------------------------------------------------------
# SIM root finder
# --------------------------------------------------------------------------
def sim_root(f, a, b, xtol, maxfev, similarity="neg_sq", prototypes="pair",
memory=None, guard=False):
"""Similarity-induced bracketed root finder.
similarity : key into SIMILARITIES
prototypes : "point" - eq. (3) verbatim, prototypes are evaluated points
"pair" - rule-level prototypes, similarity on the value axis
"pair2d" - rule-level prototypes, similarity on the value axis
AND the abscissa axis (support tightness)
memory : None = unbounded memory of every evaluated point;
an int k = keep only the k most recent points.
Unbounded is the default; the truncation is an ablation knob,
not a tuned parameter.
guard : enable the parameter-free safeguard. A similarity step is
taken only while the bracket keeps halving; the moment a
whole halving has failed to happen, one bisection step is
forced. The only constant involved is 1/2, which is the
definition of bisection rather than a tuned value, and it
bounds the worst case at twice bisection. Compare: Brent,
Ridders, TOMS748 and ITP all need a safeguard here too, and
ITP's carries three tuning constants (kappa1, kappa2, n0).
"""
sfun = SIMILARITIES[similarity]
two_d = prototypes == "pair2d"
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
# memory of every evaluated point
mem = [(a, fa), (b, fb)]
guard_width = abs(b - a) # width at the last acknowledged halving
pending = False # a similarity step has been tried since then
while abs(b - a) > xtol:
lo, hi = (a, b) if a < b else (b, a)
width = hi - lo
if guard:
if width <= 0.5 * guard_width:
guard_width = width # the halving happened; nothing owed
pending = False
elif pending:
# a similarity step was already spent without halving the
# bracket -> pay it back with one bisection step
x = 0.5 * (a + b)
fx = g(x)
mem.append((x, fx))
pending = False
if fx == 0.0:
return x, g.n, True
if fa * fx < 0.0:
b, fb = x, fx
else:
a, fa = x, fx
continue
pending = True
# the memory is the WHOLE evaluation history, including points that
# have fallen outside the current bracket: a stale point is not
# deleted, it is merely dissimilar to the target and therefore
# carries little weight. (Restricting the memory to the bracket
# leaves exactly the two endpoints and collapses the method onto
# regula falsi - verified in stage 1.)
live = mem if memory is None else mem[-memory:]
scale = max(abs(fi) for (_, fi) in live)
if scale == 0.0:
return live[0][0], g.n, True
if prototypes == "point":
scores = [sfun(fi / scale) for (_, fi) in live]
w = _softmax(scores)
x_new = sum(wi * xi for wi, (xi, _) in zip(w, live))
else: # pair prototypes
cand, scores = [], []
for i in range(len(live)):
xi, fi = live[i]
for j in range(i + 1, len(live)):
xj, fj = live[j]
if fj == fi:
continue
r = (xi * fj - xj * fi) / (fj - fi)
if not math.isfinite(r) or not (lo < r < hi):
# a rule whose conclusion falls outside the proven
# bracket is inadmissible, not merely unlikely
continue
cand.append(r)
# a pair is similar to the target when BOTH of its
# endpoints produced outcomes similar to the target
sc = sfun(fi / scale) + sfun(fj / scale)
if two_d:
# ...and when its support is tight. Without this
# term a flat tail (|f| ~ 1e-16 at 39 units from
# the root) looks maximally similar to the target
# and the method stalls there - measured, not
# assumed. Both axes are normalised by the data
# in play (largest |f| in memory; current bracket
# width), exactly as the article normalises its
# input space so that each axis contributes
# equally.
sc += sfun(abs(xi - xj) / width)
scores.append(sc)
if not cand:
x_new = 0.5 * (a + b)
else:
w = _softmax(scores)
x_new = sum(wi * ri for wi, ri in zip(w, cand))
if not math.isfinite(x_new) or not (lo < x_new < hi):
x_new = 0.5 * (a + b)
# never propose a point already in memory (would waste the budget)
if any(x_new == xi for (xi, _) in mem):
x_new = 0.5 * (a + b)
if any(x_new == xi for (xi, _) in mem):
# the bracket can no longer be split in floating point.
# Report success only if it is actually inside the
# tolerance; otherwise this is stagnation and must be
# reported as a failure, not counted as a win.
return x_new, g.n, abs(b - a) <= xtol
x = x_new
fx = g(x)
mem.append((x, fx))
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 x, g.n, False