Files
sim-numerics/simroot/sim.py
T

238 lines
10 KiB
Python

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