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:
@@ -0,0 +1,157 @@
|
||||
"""Step-size controllers. Everything else in the integration is SciPy's
|
||||
(the RK45 tableau, the stage combination, the embedded error estimate), so
|
||||
these are the only thing under comparison.
|
||||
|
||||
The classical controllers are made of constants somebody chose - a safety
|
||||
factor, growth clamps, PI gains. The question this module asks is what
|
||||
happens if the quantity a constant is GUESSING is measured instead.
|
||||
|
||||
root finding step-size control
|
||||
------------ -----------------
|
||||
the scale of the value axis the safety factor
|
||||
chosen -> brittle, linear chosen -> works, three constants
|
||||
measured-> superlinear measured-> works, none
|
||||
|
||||
What did NOT transfer is the credit ledger itself. A first version banked an
|
||||
accuracy margin exactly as the root-finding safeguard does; the margin turned
|
||||
out to take no part in any decision and was removed. The move that transfers
|
||||
is the more general one - replace a chosen constant by the measured value of
|
||||
what it approximates - not the particular bookkeeping that carried it there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
|
||||
class Controller:
|
||||
name = "?"
|
||||
constants = 0
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
def update(self, h, e, ok, order):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Elementary(Controller):
|
||||
"""h_new = h * (1/e)^(1/(p+1)) with no safety factor and no clamps.
|
||||
|
||||
Included because it is the controller the error model alone dictates. It
|
||||
is what every practical code refuses to use, and the reason for that
|
||||
refusal is exactly what the constants in the others are buying."""
|
||||
|
||||
name = "elementary (no constants)"
|
||||
constants = 0
|
||||
|
||||
def update(self, h, e, ok, order):
|
||||
if e == 0.0:
|
||||
return h * 10.0
|
||||
return h * (1.0 / e) ** (1.0 / (order + 1))
|
||||
|
||||
|
||||
class Classical(Controller):
|
||||
"""The textbook controller: a safety factor and two growth clamps."""
|
||||
|
||||
name = "classical (scipy: safety 0.9, clamps 0.2/10)"
|
||||
constants = 3
|
||||
# exactly the constants scipy.integrate._ivp.rk uses, so the driver can
|
||||
# be validated against solve_ivp with the same control law
|
||||
SAFETY, MINF, MAXF = 0.9, 0.2, 10.0
|
||||
|
||||
def update(self, h, e, ok, order):
|
||||
if e == 0.0:
|
||||
fac = self.MAXF
|
||||
else:
|
||||
fac = self.SAFETY * (1.0 / e) ** (1.0 / (order + 1))
|
||||
fac = min(self.MAXF, max(self.MINF, fac))
|
||||
return h * fac
|
||||
|
||||
|
||||
class GustafssonPI(Controller):
|
||||
"""The PI controller used in modern codes: two gains, a safety factor and
|
||||
two clamps."""
|
||||
|
||||
name = "PI (Gustafsson, 5 constants)"
|
||||
constants = 5
|
||||
SAFETY, MINF, MAXF = 0.9, 0.2, 10.0
|
||||
K_I, K_P = 0.3, 0.4
|
||||
|
||||
def reset(self):
|
||||
self.e_prev = None
|
||||
|
||||
def update(self, h, e, ok, order):
|
||||
if e == 0.0:
|
||||
return h * self.MAXF
|
||||
p1 = order + 1
|
||||
if self.e_prev is None or not ok:
|
||||
fac = self.SAFETY * (1.0 / e) ** (1.0 / p1)
|
||||
else:
|
||||
fac = (self.SAFETY * (1.0 / e) ** (self.K_I / p1)
|
||||
* (self.e_prev / e) ** (self.K_P / p1))
|
||||
fac = min(self.MAXF, max(self.MINF, fac))
|
||||
if ok:
|
||||
self.e_prev = e
|
||||
return h * fac
|
||||
|
||||
|
||||
class MeasuredSafety(Controller):
|
||||
"""The safety factor measured instead of chosen.
|
||||
|
||||
NOTE ON THE NAME. This began as "the credit rule transferred", and it
|
||||
banked a margin exactly as the root-finding safeguard does. The margin
|
||||
turned out to play no part in the decision - the step factor was computed
|
||||
from the measured model discrepancy alone - so the banking was vestigial
|
||||
and the name was unearned. What transfers is not the credit ledger but
|
||||
the deeper move behind it: replace a chosen constant with the quantity it
|
||||
is guessing, measured from the run. The ledger is left out because it did
|
||||
nothing here, and calling this a credit controller would be a mislabel.
|
||||
|
||||
The elementary controller aims at err = tol exactly, and is refused in
|
||||
practice because the model err ~ C h^(p+1) is only approximate: aiming at
|
||||
the boundary means missing it about half the time, and every miss costs a
|
||||
whole rejected step. The classical answer is a safety factor chosen by
|
||||
hand.
|
||||
|
||||
The answer here is to measure the thing the safety factor is guessing.
|
||||
After proposing a step, the model predicts what its error will be; the
|
||||
step is taken and the error is observed. The discrepancy
|
||||
|
||||
delta = log2(observed / predicted)
|
||||
|
||||
is exactly how wrong the model was, in bits, and the next step aims at
|
||||
2^-delta instead of at 1. When the model is right, delta -> 0 and the
|
||||
controller becomes the elementary one; when the model has just been
|
||||
optimistic, the controller backs off by precisely the observed amount.
|
||||
|
||||
The first step has nothing to have learned from, so it aims at the
|
||||
tolerance itself - the same licence the root-finding guard grants.
|
||||
"""
|
||||
|
||||
name = "measured safety (no constants)"
|
||||
constants = 0
|
||||
|
||||
def reset(self):
|
||||
self.predicted = None # error the model expected for this step
|
||||
self.delta = 0.0 # bits by which the model was last wrong
|
||||
|
||||
def update(self, h, e, ok, order):
|
||||
p1 = order + 1
|
||||
if self.predicted is not None and self.predicted > 0.0 and e > 0.0:
|
||||
# only optimism matters: the model predicting too SMALL an error
|
||||
# is what causes rejections. Pessimism costs nothing and is not
|
||||
# corrected for.
|
||||
self.delta = max(0.0, math.log2(e / self.predicted))
|
||||
if e == 0.0:
|
||||
fac = 10.0
|
||||
else:
|
||||
# aim at 2^-delta of the tolerance rather than at the tolerance
|
||||
fac = (2.0 ** (-self.delta) / e) ** (1.0 / p1)
|
||||
h_new = h * fac
|
||||
# record what the model now predicts for the step just chosen, so the
|
||||
# next call can measure how wrong it was
|
||||
self.predicted = e * (h_new / h) ** p1
|
||||
return h_new
|
||||
|
||||
|
||||
ALL = [Elementary(), Classical(), GustafssonPI(), MeasuredSafety()]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Integration driver whose only original part is the step-size controller.
|
||||
|
||||
The tableau, the stage combination and the embedded error estimate are
|
||||
SciPy's - `scipy.integrate._ivp.rk.rk_step` with RK45's A, B, C and E - so
|
||||
nothing about the method itself is a transcription of ours. An earlier
|
||||
version of this file carried a hand-typed Dormand-Prince tableau; that was an
|
||||
unnecessary risk and an unnecessary thing for a reader to have to check.
|
||||
|
||||
What remains ours is the loop that decides the next step size, which is the
|
||||
object under study. Everything else is shared between controllers, so a
|
||||
difference between two runs can only come from the controller.
|
||||
|
||||
The driver is validated against SciPy's own `solve_ivp(method="RK45")`: with
|
||||
the classical controller - SciPy's own safety factor and clamps - the two
|
||||
must agree closely on the number of right-hand-side evaluations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy.integrate._ivp.rk import RK45, rk_step
|
||||
|
||||
ORDER = RK45.error_estimator_order # 4; the step exponent is ORDER + 1
|
||||
|
||||
|
||||
class Counter:
|
||||
"""Counts evaluations of the right-hand side - the cost model."""
|
||||
|
||||
__slots__ = ("f", "n")
|
||||
|
||||
def __init__(self, f):
|
||||
self.f = f
|
||||
self.n = 0
|
||||
|
||||
def __call__(self, t, y):
|
||||
self.n += 1
|
||||
return np.asarray(self.f(t, y), dtype=float)
|
||||
|
||||
|
||||
def _norm(v, y, y_new, atol, rtol):
|
||||
scale = atol + rtol * np.maximum(np.abs(y), np.abs(y_new))
|
||||
return float(np.sqrt(np.mean((v / scale) ** 2)))
|
||||
|
||||
|
||||
def integrate(f, t0, t1, y0, controller, atol=1e-8, rtol=1e-8,
|
||||
max_steps=200000):
|
||||
"""Integrate with the given controller. Returns what the comparison
|
||||
needs. The initial step is chosen the same way for every controller, so
|
||||
none can win by guessing it better."""
|
||||
g = Counter(f)
|
||||
t = float(t0)
|
||||
y = np.asarray(y0, dtype=float)
|
||||
fv = g(t, y)
|
||||
K = np.empty((RK45.n_stages + 1, y.size), dtype=float)
|
||||
|
||||
d0 = _norm(y, y, y, atol, rtol)
|
||||
d1 = _norm(fv, y, y, atol, rtol)
|
||||
h = 1e-6 if (d0 < 1e-5 or d1 < 1e-5) else 0.01 * d0 / d1
|
||||
h = min(h, abs(t1 - t0))
|
||||
controller.reset()
|
||||
|
||||
accepted = rejected = 0
|
||||
while t < t1 and accepted + rejected < max_steps:
|
||||
h = min(h, t1 - t)
|
||||
if h <= 0.0:
|
||||
break
|
||||
y_new, f_new = rk_step(g, t, y, fv, h, RK45.A, RK45.B, RK45.C, K)
|
||||
err = np.dot(K.T, RK45.E) * h # RK45's own error estimate
|
||||
e = _norm(err, y, y_new, atol, rtol)
|
||||
ok = e <= 1.0
|
||||
h_next = controller.update(h, e, ok, ORDER)
|
||||
if ok:
|
||||
accepted += 1
|
||||
t, y, fv = t + h, y_new, f_new
|
||||
else:
|
||||
rejected += 1
|
||||
if not math.isfinite(h_next) or h_next <= 0.0:
|
||||
break
|
||||
h = h_next
|
||||
return {"t": t, "y": list(y), "nfev": g.n, "accepted": accepted,
|
||||
"rejected": rejected,
|
||||
"reached_end": abs(t - t1) < 1e-12 * max(1.0, abs(t1))}
|
||||
|
||||
|
||||
def validate_against_scipy(atol=1e-8, rtol=1e-8):
|
||||
"""The driver against SciPy's own solver, with SciPy's own controller.
|
||||
|
||||
This replaces the hand-written order check that the previous version
|
||||
needed: if the driver reproduces solve_ivp's evaluation count with the
|
||||
same control law, the tableau, the stage combination, the error estimate
|
||||
and the loop are all the library's behaviour."""
|
||||
from scipy.integrate import solve_ivp
|
||||
|
||||
from .controllers import Classical
|
||||
|
||||
out = []
|
||||
probs = [
|
||||
("decay", lambda t, y: [-y[0]], 0.0, 10.0, [1.0]),
|
||||
("forced", lambda t, y: [-y[0] + math.sin(t)], 0.0, 10.0, [0.0]),
|
||||
("lotka", lambda t, y: [1.5 * y[0] - y[0] * y[1],
|
||||
-3.0 * y[1] + y[0] * y[1]], 0.0, 15.0,
|
||||
[10.0, 5.0]),
|
||||
("vdp", lambda t, y: [y[1], 5.0 * (1 - y[0] ** 2) * y[1] - y[0]],
|
||||
0.0, 25.0, [2.0, 0.0]),
|
||||
]
|
||||
for name, f, t0, t1, y0 in probs:
|
||||
mine = integrate(f, t0, t1, y0, Classical(), atol=atol, rtol=rtol)
|
||||
ref = solve_ivp(f, (t0, t1), y0, method="RK45", atol=atol,
|
||||
rtol=rtol)
|
||||
out.append((name, mine["nfev"], int(ref.nfev),
|
||||
max(abs(a - b) for a, b in zip(mine["y"],
|
||||
ref.y[:, -1]))))
|
||||
return out
|
||||
Reference in New Issue
Block a user