Files
sim-numerics/tests/test_projection.py
T

143 lines
5.5 KiB
Python

"""Tests for the projection safeguard.
This is the guard the paper will propose, so its guarantee has to be checked
where it matters - on every problem of both benchmark sets, not on a sample -
and the order it was built to preserve has to be shown to survive.
"""
from __future__ import annotations
import math
import os
import sys
import pytest
from scipy.optimize._tstutils import (_APS_TESTS_DICTS,
_CHANDRUPATLA_TESTS_DICTS)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from simroot.pure import PURE_SIMS, pure_root
from stage5_three_point import PROBLEMS, order_of
XTOL = 1e-12
MAXFEV = 200
ACC = 1e-6
PROJECTING = ("project", "safe")
def _all_problems():
out = []
for d in _APS_TESTS_DICTS:
a, b = d["bracket"]
args = d.get("args", ())
out.append((d["ID"], (lambda ff, aa: lambda x: ff(x, *aa))(d["f"], args),
float(a), float(b), float(d["root"])))
for d in _CHANDRUPATLA_TESTS_DICTS:
a, b = d["bracket"]
out.append((d["ID"], (lambda ff: lambda x: ff(x))(d["f"]),
float(a), float(b), float(d["root"])))
return out
ALL = _all_problems()
def bound(a, b):
"""The guarantee the paper may state.
The budget gives w_{j+1} <= 2^(n_max - j) * eps with n_max = 2*half, so in
exact arithmetic the width reaches xtol = 2*eps at step j = n_max - 1 and
the run costs n_max steps plus the two endpoint evaluations.
In floating point the bracket can land exactly ON the budget boundary -
the instrumented invariant in stage6b shows the worst ratio at 1.000000 -
and the loop condition `w > xtol` then demands one further step. The
honest statement is therefore n_max + 1 steps, i.e.
2 * ceil(log2(w / xtol)) + 3 evaluations,
which is still at most twice bisection plus one step. The `+1` is stated
rather than hidden: it is a property of the termination test at the
boundary, not slack that could absorb a real violation."""
return 2 * math.ceil(math.log2(abs(b - a) / XTOL)) + 3
@pytest.mark.parametrize("guard", PROJECTING)
@pytest.mark.parametrize("scale", ["derived", "derived3"])
def test_projection_respects_the_bound_on_all_199_problems(guard, scale):
"""The claim the paper makes about the projection guard. A single
violation invalidates it, so every problem is checked."""
bad = []
for pid, f, a, b, root in ALL:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
scale=scale, guard=guard)
if conv and abs(x - root) <= ACC and n > bound(a, b):
bad.append((pid, n, bound(a, b)))
assert not bad, f"{guard}/{scale} exceeded the bound on {bad[:5]}"
@pytest.mark.parametrize("guard", PROJECTING)
def test_projection_solves_as_much_as_the_unguarded_rule(guard):
"""A safeguard that bought its guarantee by failing more often would be
no bargain."""
solved_guard = solved_none = 0
for _pid, f, a, b, root in ALL:
for g, acc in ((guard, "g"), ("none", "n")):
x, _n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
scale="derived3", guard=g)
ok = conv and abs(x - root) <= ACC
if acc == "g":
solved_guard += ok
else:
solved_none += ok
assert solved_guard >= solved_none, (solved_guard, solved_none)
def test_projection_keeps_the_order_the_other_guards_destroy():
"""The whole point: `bracket` and `both` collapse the three-point rule
from order ~1.94 to ~1.00; `project` and `safe` must not."""
med = {}
for guard in ("none", "bracket", "both", "project", "safe", "step"):
ps = [order_of((lambda g: lambda f_, a_, b_, xt, mx: pure_root(
f_, a_, b_, XTOL, MAXFEV, scale="derived3", guard=g))(guard),
f, a, b, root) for _n, f, a, b, root in PROBLEMS]
ps = [p for p in ps if math.isfinite(p) and 0.5 < p < 5.0]
med[guard] = sorted(ps)[len(ps) // 2] if ps else float("nan")
assert med["bracket"] < 1.2, med # the known collapse
assert med["both"] < 1.2, med
assert med["project"] > 1.7, med # the fix
assert med["safe"] > 1.7, med
assert med["safe"] >= med["bracket"] + 0.5, med
def test_zero_slack_would_be_degenerate():
"""Guards against someone 'tightening' the budget back to the exact
bisection bound: with no slack the projection radius on a unit bracket is
a twentieth of it from the very first step, and the method becomes
bisection. The doubling is load-bearing."""
from simroot.pure import _Safeguard
sg = _Safeguard("project", 1.0, XTOL)
r_doubled = 2.0 ** (sg.n_half - 0) * sg.eps - 0.5
half = sg.n_half // 2
r_zero_slack = 2.0 ** half * sg.eps - 0.5
assert r_zero_slack < 0.06, r_zero_slack
assert r_doubled > 1e6, r_doubled
@pytest.mark.parametrize("sim", sorted(PURE_SIMS))
def test_every_similarity_works_under_the_projection(sim):
f = lambda t: t * t - 2.0
x, _n, conv = pure_root(f, 0.0, 2.0, XTOL, MAXFEV, similarity=sim,
scale="derived3", guard="safe")
assert conv and abs(x - math.sqrt(2.0)) <= 1e-8
def test_projection_needs_a_tolerance_and_a_real_bracket():
with pytest.raises(ValueError):
from simroot.pure import _Safeguard
_Safeguard("project", 1.0, 0.0)
with pytest.raises(ValueError):
from simroot.pure import _Safeguard
_Safeguard("safe", 0.0, XTOL)