Skip to content

feat: search-agnostic prior-support enforcement via a Clipper class - #1477

Merged
Jammy2211 merged 3 commits into
mainfrom
claude/autofit-clipper-prior-support-o3jotv
Aug 16, 2026
Merged

feat: search-agnostic prior-support enforcement via a Clipper class#1477
Jammy2211 merged 3 commits into
mainfrom
claude/autofit-clipper-prior-support-o3jotv

Conversation

@Jammy2211

@Jammy2211 Jammy2211 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #1476.

Why

MultiStartGradient steps in physical parameter space against an objective that folds in the prior (fom = -2 * (log_likelihood + sum(log_prior_list)), fitness.py:271-272), with nothing constraining a step to the prior box. A UniformPrior is -inf outside its limits, so a lane that oversteps a hard prior edge reads as non-finite and is marked dead.

It is worse than frozen: log_prior = -inf is constant outside the box, so its derivative is zero and the total gradient is the finite likelihood gradient. optax.apply_if_finite therefore never fires, and the dead lane keeps stepping for the rest of the run at full likelihood-and-gradient cost with its output discarded. On the reference imaging/mge cell this was 60.25% of lane-steps and 14 of 16 lane deaths, while the likelihood never went non-finite once in ~7200 lane-steps (autolens_profiling#128).

The searches that are not exposed are instructive: the nested samplers already work in unit-cube coordinates, and the MCMC samplers reject -inf proposals so the walker stays put. Rejection is the restoring mechanism the gradient methods lack, and a Clipper supplies it.

What this adds

AbstractClipper / ClipperNone / ClipperPriorBox in a new autofit/non_linear/clipper.py, modelled on initializer.py, wired opt-in into both exposed searches:

  • AbstractMultiStartGradient enforces the constraint itself — imperative project applied after optax.apply_updates.
  • AbstractBFGS declares the box to scipy and lets scipy enforce it — L-BFGS-B supports box bounds natively; they were simply never passed.

project returns the clipped mask as well as the vector, so a caller can zero optimiser momentum along clipped directions. That is needed rather than decorative: projecting parameters while the accumulated optimiser state keeps pushing outward is what leaves lanes pinned to a bound.

The default is ClipperNone and this PR is bit-identical with it. Flipping the default moves where a search converges and would shift every stored multi-start benchmark, so it is deliberately left to a separate, re-baselined change.

Design: inset by bound kind, not by box width

The single most important detail. The obvious implementation — one relative margin * (upper - lower) — is wrong in two separate and silent ways, so the inset is keyed on what kind of bound it is:

Bound kind Example Inset Why
Two-sided finite Uniform, LogUniform, TruncatedGaussian relative margin * width Not for prior support — those priors are inclusive at the limit. It avoids parking a lane exactly on a prior edge, where the model's transforms are prone to singularities (arctan2/sqrt at 0) — the same reason the start band defaults to the interior (0.15, 0.85).
Unbounded Gaussian none, and no width arithmetic at all -inf + (inf - -inf) * m is NaN, and clipping against NaN bounds turns the coordinate into NaN and the objective with it.
Half-open, exclusive LogGaussian lower 0 absolute strict_epsilon A relative margin is identically zero with no finite width, so it would clip exactly onto 0.0, where the support is strict and log_prior is -inf.

Three related findings, each measured against a running install rather than assumed:

  1. LogGaussianPrior misreports its own support. Its message is a TransformedMessage, which defaults limits to ±inf and is never passed any — yet log_prior_from_value is -inf for value <= 0. Its real (0, inf) support is declared in the clipper, leaving the shared prior class untouched so nothing the EP machinery or the nested samplers read is disturbed. Declaring it on the prior itself is the cleaner long-term fix and is noted as a follow-up.

  2. bounds_from_model returning two arrays is not what scipy accepts. optimize.minimize reads a (lower, upper) tuple as a sequence of (min, max) pairs: on a two-parameter model that returns a silently wrong fit with no error or warning, and at any other dimensionality it raises. The BFGS wiring builds an explicit optimize.Bounds.

  3. Plain BFGS does not reject bounds — it ignores them behind a UserWarning and returns the unconstrained optimum. Handing an unbounded fit to someone who asked for prior-support enforcement is the exact failure being prevented, so a non-ClipperNone clipper on a non-bound-supporting method raises instead.

Also corrects the AbstractMultiStartGradient class docstring, which claimed the rule steps "on the unconstrained (unit-cube) parameterization". It does not — _broad_starts maps draws to physical parameters, and that sentence is what would tell the next reader this class of bug cannot occur.

Validation

CI is green on all three jobs (unittest 3.12, unittest 3.13, docs-build). 22 new tests in test_autofit/non_linear/test_clipper.py.

A randomised differential stress run over models mixing every prior type:

  • Bit-identity 10/10 on both searchesno clipper argument vs explicit ClipperNone, identical final parameters.
  • Core promise 8/8 — with ClipperPriorBox, final sum(log_prior) finite every time, and lane deaths 0 in every case against 62–96 without.
  • Does no harm — when the optimum is inside the box the clipper does not fire and the answer is unchanged.
  • Resume — a search_internal written before this change (no n_clipped_lane_steps) resumes without KeyError and continues accumulating.
  • Identifiers unchanged — real fits produce a single identifier directory shared by no clipper / ClipperNone / ClipperPriorBox, so existing on-disk results are not orphaned.

End-to-end on a Gaussian fit whose truth sits outside the prior box: lane deaths 249 → 0 with 252 clips, and the clipped run pins centre at the upper bound — the correct MAP answer under a prior that excludes the truth, and an independent reproduction of the momentum pinning.

The guards were verified by inversion: patching ClipperPriorBox back to the naive width form makes 5 tests fail, including both named regression guards.

Reviewer notes

  • The second commit fixes an undefined optimize in LBFGS._fit that the first commit introduced. Worth knowing why it survived: the entire library suite passed against it, because nothing in the suite ever executed an LBFGS fit. A smoke test running a real LBFGS.fit() is now included and was verified to fail with exactly that NameError if the import is removed again.
  • black would reformat bfgs/search.py and multi_start_gradient/search.py, but both were already unformatted on main; deliberately not reformatted, to keep the diff free of unrelated churn.
  • scipy is imported lazily, matching every other module in autofit/.
  • Committed tests are NumPy-only, per the note atop test_multi_start_gradient.py about keeping JAX out of the library unit suite. The JAX end-to-end checks above were run locally.
  • Not verified here: the real imaging/mge cell, GPU, and float64. The 60.25% → 17.71% figures are inherited from autolens_profiling#128, which is why the regression assertions are directional with wide margins.
  • Correction to an earlier revision of this description, which reported "1791 passed, 1 failed" with test_nautilus.py::test__single_core_builds_no_pool described as pre-existing. That test passes in CI; the failure was specific to the local environment these checks were run in, not a condition of the repo.

Follow-ups (filed, not fixed)

  1. float32 is not JSON serializable in result output (paths/directory.py:80).
  2. A crashed run poisons the next run of the same name via JSONDecodeError on resume.
  3. Declaring LogGaussianPrior's (0, ∞) support on the prior itself, retiring the clipper's special case.
  4. Two runs differing only in clipper currently share an output directory — relevant to PR 2's benchmark re-baseline.

🤖 Generated with Claude Code

claude added 3 commits August 16, 2026 00:36
The gradient searches step in physical parameter space against an objective
that folds in the prior (`fom = -2 * (log_likelihood + sum(log_prior_list))`),
with nothing constraining a step to the prior box. A `UniformPrior` is `-inf`
outside its limits, so a lane that oversteps a hard prior edge reads as
non-finite and is marked dead. It is worse than frozen: `log_prior = -inf` is
*constant* outside the box, so its derivative is zero and the total gradient is
the finite likelihood gradient. `optax.apply_if_finite` never fires, and the
dead lane keeps stepping for the rest of the run at full cost with its output
discarded. On the reference `imaging/mge` cell this was 60.25% of lane-steps
and 14 of 16 lane deaths, while the likelihood never went non-finite once
(autolens_profiling#128).

Adds `AbstractClipper` / `ClipperNone` / `ClipperPriorBox` in a new
`autofit/non_linear/clipper.py`, modelled on `initializer.py`, and wires them
opt-in into `AbstractMultiStartGradient` (imperative `project` after
`optax.apply_updates`) and `AbstractBFGS` (declarative bounds handed to scipy).
`project` returns the clipped mask as well as the vector, so a caller can zero
optimiser momentum along clipped directions -- projecting parameters while the
accumulated state keeps pushing outward is what leaves lanes pinned.

The default is `ClipperNone` and this change is bit-identical with it, verified
by running both searches with and without the argument. Flipping the default
moves where a search converges and would shift every stored multi-start
benchmark, so it is deliberately left to a separate, re-baselined change.

Three things the implementation is shaped around, each measured rather than
assumed, and each a silent failure if got wrong:

- The inset is keyed on **bound kind**, never on unguarded `upper - lower`.
  The obvious `lower + margin * (upper - lower)` is `-inf + inf` = NaN for a
  `GaussianPrior`, and clipping against NaN bounds turns the coordinate into
  NaN and the objective with it -- killing lanes on exactly the models this
  rescues, with a symptom indistinguishable from the bug being fixed.
- `bounds_from_model` returns two arrays, which is the shape `project`
  broadcasts against, but *not* what scipy accepts: `minimize` reads a
  `(lower, upper)` tuple as a sequence of `(min, max)` pairs, silently
  mis-fitting two-parameter models and raising at any other dimensionality.
  The BFGS wiring builds an explicit `optimize.Bounds`.
- `LogGaussianPrior` reports `(-inf, inf)` because its `TransformedMessage` is
  never given limits, yet its `log_prior_from_value` is `-inf` for `value <= 0`.
  Its real `(0, inf)` support is declared in the clipper, with an *absolute*
  epsilon: a relative margin is identically zero for a half-open bound and
  would clip onto exactly `0.0`.

Also corrects the `AbstractMultiStartGradient` class docstring, which claimed
the rule steps "on the unconstrained (unit-cube) parameterization" -- it does
not; `_broad_starts` maps draws to physical parameters, and that sentence is
what would tell the next reader this class of bug cannot occur.

End-to-end on a Gaussian fit whose truth sits outside the prior box: lane
deaths go 249 -> 0, with 252 clips, and the clipped run pins `centre` at the
upper bound, which is the correct MAP answer under a prior excluding the truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt89ELdo9fb8gEoU1bP2yH
The earlier commit's insertion of the `bounds` resolution replaced `_fit`'s
local `from scipy import optimize`, and a module-level import added at the same
time masked it. Removing that module-level import (no other module in autofit
pulls scipy in at import time) left `optimize` undefined, so any real
`LBFGS.fit()` raised `NameError`.

The whole 1790-test suite passed against that, because nothing in the library
suite ever executes an LBFGS fit -- it was only caught by a randomised
end-to-end stress run. So this restores the local import and adds the cheap
smoke test that closes the gap: a real `search.fit()` on the NumPy path, with
and without a clipper, asserting the clipped run respects the prior box. That
test fails with exactly this `NameError` if the import is removed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt89ELdo9fb8gEoU1bP2yH
`ClipperNone` and `ClipperPriorBox` are exported from `autofit` but were absent
from `docs/api/searches.rst`, where their `Initializer*` siblings are listed.
Adds them to the same autosummary block.

This is the first time the clipper docstrings are processed by Sphinx, and the
docs job fails on a warning-count regression against
`docs/sphinx_warning_baseline.txt`, so it is pushed as its own commit to keep
that signal attributable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt89ELdo9fb8gEoU1bP2yH
@Jammy2211
Jammy2211 merged commit 1f4b66a into main Aug 16, 2026
3 checks passed
Jammy2211 added a commit that referenced this pull request Aug 16, 2026
The Clipper (#1477) counts how often it fires -- n_clipped_lane_steps is
accumulated per-lane in AbstractMultiStartGradient and written to
search_internal -- but the count never reached the artefact a user actually
reads to find out what a search did. search.summary said nothing about
clipping at all.

The channel already existed: search_summary_from_samples reads
samples.samples_info and emits the NaN counters and their rates, guarded on
the key so searches without them are unaffected. Two things were missing from
it rather than a mechanism:

- n_clipped_lane_steps never reached samples_info. It stopped at
  search_internal, so nothing downstream could see it.
- n_constrained_lane_steps did reach samples_info but was never emitted, so
  the trapped-lane counter from #1475 has been invisible in search.summary
  since it shipped. Now reported.

Clipping is reported in three cases, and the distinction between the last two
is the point:

- No clipper (ClipperNone, or a search predating the Clipper): emits nothing.
  The default path's summary is unchanged, which matters because this file is
  read by tooling and sits in every archived run's output.
- Clipped and counted (MultiStartGradient): it enforces the constraint itself
  every step via Clipper.project, so it knows how often it fired and reports
  the count and the rate, denominated by n_starts * total_steps like the NaN
  rates beside it.
- Clipped but not observable (LBFGS and the bound-supporting scipy methods):
  declarative, handing optimize.Bounds to scipy and letting scipy enforce, so
  project is never called and no mask exists. Reporting 0 there would read as
  "the clipper never fired" when it means "this search cannot know", so it
  says "not measured (bounds enforced by scipy)" instead.

The clipper is published as its class NAME rather than a bool, so the summary
can say which strategy ran and a later strategy needs no schema change. The
count is per-LANE, not per-coordinate -- a lane clipped in three parameters on
one step is one clipped lane-step -- matching how the counters beside it read,
so all four stay directly comparable. It is restored from search_internal as a
lifetime total, so a resumed run reports the whole run's clipping rather than
the current process's share.

Verified end-to-end against the search.summary files four real searches wrote,
not just the formatting helper in isolation: LBFGS default (no clipping lines),
LBFGS clipped ("not measured"), MultiStart default (no clipping lines),
MultiStart clipped (Clipped Lane-Steps = 414, rate 0.958).

One behaviour change to note: multi-start summaries gain a Constrained
Lane-Steps line they did not have before. Everything else is additive and
gated.


Claude-Session: https://claude.ai/code/session_01FzF2XmKQaqZRWZfZMxvTNR

Co-authored-by: Claude <noreply@anthropic.com>
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.

feat: search-agnostic prior-support enforcement via a Clipper class

2 participants