Skip to content

Redesign the FD engine around empirical noise floors and extrapolation - #80

Open
dweindl wants to merge 20 commits into
mainfrom
redesign-fd-engine
Open

Redesign the FD engine around empirical noise floors and extrapolation#80
dweindl wants to merge 20 commits into
mainfrom
redesign-fd-engine

Conversation

@dweindl

@dweindl dweindl commented Sep 5, 2026

Copy link
Copy Markdown
Member

Replaces the Computer/DirectionalDerivative/Consistency architecture with a layered engine that needs no user-supplied step sizes or tolerances: ECNoise-style noise-floor estimation -> geometric step ladder -> Neville extrapolation with two independently-corroborating chains -> forward/backward/central discontinuity cross-check -> sequential/parallel batch execution, composed into check_gradient/check_jacobian (multi-output/Jacobian support included).

  • fiddy/output.py, function.py: bundle/unbundle named multi-output results; joblib caching hardened against a closure cache-key collision
  • fiddy/noise.py, step_size.py, extrapolation.py, discontinuity.py, executor.py, estimate.py, check.py: the new engine itself
  • Every non-trivial algorithm cites its source; doc/references.bib adds proper BibTeX entries for all of them
  • doc/examples/derivative.ipynb rewritten as a problems-then-solutions walkthrough; tests/ reorganized one file per fiddy/*.py module

Old modules (derivative.py, directional_derivative.py, success.py, analysis.py, step.py, numpy.py) removed; nothing in fiddy/* references AMICI or any other specific framework.

Requirements for this redesign were worked out jointly with @dilpath.

🤖 This PR was written by an AI agent (Claude).

Replaces the Computer/DirectionalDerivative/Consistency architecture
with a layered engine that needs no user-supplied step sizes or
tolerances: ECNoise-style noise-floor estimation -> geometric step
ladder -> Neville extrapolation with two independently-corroborating
chains -> forward/backward/central discontinuity cross-check ->
sequential/parallel batch execution, composed into check_gradient/
check_jacobian (multi-output/Jacobian support included).

- fiddy/output.py, function.py: bundle/unbundle named multi-output
  results; joblib caching hardened against a closure cache-key collision
- fiddy/noise.py, step_size.py, extrapolation.py, discontinuity.py,
  executor.py, estimate.py, check.py: the new engine itself
- Every non-trivial algorithm cites its source; doc/references.bib adds
  proper BibTeX entries for all of them
- doc/examples/derivative.ipynb rewritten as a problems-then-solutions
  walkthrough; tests/ reorganized one file per fiddy/*.py module

Old modules (derivative.py, directional_derivative.py, success.py,
analysis.py, step.py, numpy.py) removed; nothing in fiddy/* references
AMICI or any other specific framework.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dweindl and others added 7 commits September 6, 2026 08:47
check_gradient/check_jacobian (and the estimate_* layer underneath) now
accept an optional `bounds` (a per-parameter valid domain, e.g. a
model's declared parameter bounds) so a probe or ladder step never
evaluates the function outside it -- fiddy.step_size.clamp_step_to_bounds
does the clamping; a starting point that already violates bounds raises
a clear error instead of silently corrupting every result.

The shared, all-ones noise-floor probe turns out to be fragile once
bounds-clamped: a single parameter close to its own bound can crush the
probe for every direction at once, not just its own. New
noise_floor_strategy="auto" (default) catches this -- it tries the cheap
shared probe first, and only escalates to an independent, bounds-aware
probe per direction (one combined batch dispatch, not N separate ones)
when the shared probe comes back unconfident or degenerate. Cost is
~2x, not Nx, since the per-direction step ladder already dominates
total evaluations.

17 new tests covering the clamping helper, the escalation logic
(including a deterministic reproduction of the real failure this was
built to fix), and the new bounds-validation error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A step-size ladder can legitimately collapse to an all-zero step when
clamp_step_to_bounds finds zero room to move in a direction because the
point sits exactly on its declared bound. The resulting 0/0 divisions in
estimate.py, extrapolation.py, and discontinuity.py already produce NaN
values that downstream logic correctly classifies as "noise_dominated"
(NaN comparisons are always False), but the raw RuntimeWarning this
raises becomes a hard failure under warnings-as-errors configs -- as
surfaced by AMICI's own PEtab benchmark CI, which doesn't override
warning filters the way our own manual verification runs did all
session, masking this. Wrap the three known-degenerate divisions in
np.errstate to suppress the expected warning without changing any
computed value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
check_gradient/check_jacobian's auto-derived tolerance was based purely
on fiddy's own re-evaluation noise, which can be measured as spuriously
tiny for a direction whose parameter barely perturbs the underlying
computation (e.g. a pure output-scaling factor). That understates the
real achievable agreement with an independently-computed `expected`
value, whose own floating-point-accumulation floor scales with the
value's magnitude, not with fiddy's self-consistency -- found on two
real models where FD and analytic gradients agreed to ~1e-10 relative
error yet failed a tolerance ~900x tighter than that gap.

Add rtol * max(|value|, |expected|) as a third floor term in
_check_direction, exposed as rtol=1e-8 on check_gradient/check_jacobian.
Calibrated ~30x above the worst measured real-model relative error,
mirroring the engine's own noise-floor tolerance's "50x margin" story,
while staying far enough below round numbers like 1e-6 to still catch a
genuine small-percentage sensitivity bug. New tests confirm both that
the floor is needed (fails at rtol=0) and that it doesn't mask a real
~1e-4 relative error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…topping

_best_diagonal_estimate picked the Neville-diagonal entry minimizing
successive-difference disagreement via a global argmin over the whole
table. Since Neville's algorithm is an exact polynomial interpolant, its
deepest entries fit every point exactly regardless of whether the
underlying data is real curvature or pure noise -- so two deep,
heavily-noise-contaminated entries can coincidentally agree with each
other, fooling the search into reporting a confidently wrong value even
though a far more accurate, shallower entry was sitting in the same
table. Confirmed on real ODE-model directions (found via AMICI's SBML
semantic test suite): the old selection was off by up to ~3.8 million
times the error of the best available entry.

Replace the global search with Ridders' method's early-stopping rule
(Ridders 1978; Numerical Recipes' dfridr): scan the diagonal shallow to
deep, tracking the best error seen so far, and stop considering deeper
entries once a new one is worse than the best-so-far by more than a
safety factor. This is a pure post-hoc analysis of the already-computed
diagonal -- no new function evaluations, no change to the batch-then-
analyze evaluation model.

Verified extensively given how central this function is: fiddy's own
suite (104 passed, 4 new regression tests), the existing notebook
example's output is byte-identical (confirms no behavior change for
well-behaved cases), all 8 originally-reported SBML suite cases now pass
with zero check_gradient tolerance changes, and the ~24-model broad
PEtab benchmark suite shows zero regressions. Adds a notebook cell
demonstrating the failure mode directly from the real captured data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fiddy.step_size.build_step_ladder sizes its ladder purely from the
measured noise floor, with no awareness of where the function stops
being smooth. When a parameter-space discontinuity (e.g. an SBML event
trigger) has a crossing perturbation smaller than the ladder's finest
rung, every rung samples a mix of pre-/post-event trajectory that is
locally smooth on the wrong branch -- neither the extrapolation's
corroboration nor check_discontinuity's adjacent-rung comparison can
see anything wrong, since neither rung being compared is itself
internally inconsistent. Confirmed via 13 distinct real AMICI/SBML
models: a ladder spanning 54% to 0.4% relative reported a confidently
"converged" value 5.2x off the true one, for a crossing point only
~0.1% relative away.

Add a second, small "far" ladder per direction, built via the same,
unmodified build_step_ladder(..., noise_floor=numpy.finfo(float).eps)
-- the classical central-difference optimum at the theoretical minimum
possible noise floor, not an arbitrary constant. Extrapolated
independently, then cross-checked against the main ladder's value (not
gap-vs-predicted-gap, since the two regimes are separated by many
orders of magnitude with no reason to expect a smooth trend connects
them) via new fiddy.discontinuity.check_cross_regime_disagreement.
Always dispatched in the same batch, at a real ~47% extra evaluation
cost per direction -- there is no cheap signal in the main ladder's own
data that could safely gate this, since the wrong branch looks
perfectly smooth on its own.

The budget deliberately excludes the main ladder's own error estimate,
even though it looks like a natural candidate (mirroring how the
existing two-chain corroboration folds its own disagreement). Real-
model validation found this actively wrong: the main ladder's error
estimate, when it is itself sitting on the wrong side of a hidden
discontinuity, is partially informative about the same problem but
underestimates it -- close enough to mask the disagreement with a
naively-reused safety factor. Uses only the far ladder's own error
estimate instead, with a smaller safety_factor=3.0 (mirroring check.py's
own k convention) instead of check_discontinuity's differently-scoped
10.0.

Also fixes an independent, partial bug in estimate_jacobian's
discontinuity check: it used one shared, maxed-across-outputs noise
sigma for every output's own check, which could mask a kink for a quiet
output bundled with a noisier sibling. Now uses each output's own noise
sigma for the discontinuity check; curvature_rtol's relative budget
term, not sigma-sharing, is what guards against the original false-
positive concern that motivated sharing it.

Verified: fiddy's own suite (111 passed, up from 104), ruff/sphinx
clean, all 13 originally-reported SBML semantic suite cases now pass
(previously all 13 failed), and the ~24-model broad PEtab benchmark
suite shows zero regressions (24/24 passed). Adds notebook cells
demonstrating the failure mode from real captured data.

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fiddy's own test suite covers the engine's own logic, but validating
against real AMICI models has repeatedly caught bugs the synthetic
tests missed. Add a "Validating against AMICI's real-model test
suites" section to doc/development.rst covering both: AMICI's PEtab
benchmark-collection gradient-check suite (dozens of real ODE models)
and its SBML Test Suite semantic-case suite (~1780 small, targeted
models, checking full bundled sensitivities via check_jacobian) -- the
latter has repeatedly surfaced discontinuity/event-triggered edge
cases the former's larger models don't happen to exercise.

Shares the AMICI clone and editable install (combined extras covering
both suites' needs) between the two, branching only into each suite's
own extra setup (benchmark_models_petab; cloning the SBML Test Suite
itself) and run commands.

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Generic, in-repo test functions (smooth / noisy / kink / step
discontinuity / near-zero-gradient / ill-conditioned) with known
analytic derivatives, and a harness comparing fiddy against
numdifftools and scipy.differentiate on accuracy per function
evaluation.

Informational only, not a competitive benchmark (see benchmarks/README.md).
Both dependencies are optional (imported lazily, new `benchmark` extra
in pyproject.toml) and not part of fiddy's own runtime dependencies.

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dweindl
dweindl marked this pull request as ready for review September 8, 2026 05:40
Purely cosmetic, shown in reports alongside (never instead of) the
existing positional index -- lets a caller with domain knowledge (e.g.
an AMICI/PEtab adapter, which knows parameter/state/observable IDs)
make failures actually diagnosable without an external side-lookup.

🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@dilpath dilpath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall fine to merge. The comments could be made more concise I think.

Comment thread fiddy/check.py Outdated
def check_gradient(
function: Type.FUNCTION,
point: Type.POINT,
expected,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typehint?

Comment thread fiddy/check.py Outdated
Comment on lines +212 to +213
tol: float | None = None,
k: float = 3.0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is tol atol?

Rename k to be more informative

Comment thread fiddy/check.py Outdated
k: float = 3.0,
rtol: float = 1e-8,
noise_floor: float | None = None,
nondet_tol: float = 0.0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collect estimate_gradient args in **kwargs or some dictionary instead of making them args here

Comment thread fiddy/check.py
return flat


def check_jacobian(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comments as for check_gradient

Comment thread README.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be simplified I think but fine

Fixes signature rendering (autodoc_type_aliases keeps fiddy's own
Type.* aliases readable instead of expanding into numpy internals).
Drops recommonmark/mock (unused) and the sphinx<8/docutils<0.19 caps
they required; verified sphinx 9 + sphinx-autodoc-typehints>=3 builds
clean with -W.
Worked around an old RTD build image's OpenSSL incompatibility with
urllib3>=2; RTD now builds on ubuntu-24.04, and the doc build already
works fine with urllib3 2.7.0 here.
Both were only needed by the removed AMICI/PEtab interface; nothing
in the current codebase references swig, atlas, or blas.
Leads with estimate_gradient/estimate_jacobian (the core engine) and
moves check_gradient/check_jacobian to the end, condensed into one
section instead of two. Also notes the API isn't stable yet.
index.rst was a bare toctree; now a proper landing page (tagline,
quickstart, install) with User Guide/API Reference/Development as
distinct sidebar sections. about.rst gained the architecture/design-
principles overview. fiddy.rst's modules now follow the engine's own
layering instead of alphabetical order; modules.rst (a redundant
wrapper) is gone. Added viewcode links and RTD theme nav options.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants