feat: search-agnostic prior-support enforcement via a Clipper class - #1477
Merged
Conversation
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
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>
This was referenced Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1476.
Why
MultiStartGradientsteps 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. AUniformPrioris-infoutside 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 = -infis constant outside the box, so its derivative is zero and the total gradient is the finite likelihood gradient.optax.apply_if_finitetherefore 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 referenceimaging/mgecell 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
-infproposals so the walker stays put. Rejection is the restoring mechanism the gradient methods lack, and aClippersupplies it.What this adds
AbstractClipper/ClipperNone/ClipperPriorBoxin a newautofit/non_linear/clipper.py, modelled oninitializer.py, wired opt-in into both exposed searches:AbstractMultiStartGradientenforces the constraint itself — imperativeprojectapplied afteroptax.apply_updates.AbstractBFGSdeclares the box to scipy and lets scipy enforce it —L-BFGS-Bsupports box bounds natively; they were simply never passed.projectreturns 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
ClipperNoneand 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:Uniform,LogUniform,TruncatedGaussianmargin * widtharctan2/sqrtat 0) — the same reason the start band defaults to the interior(0.15, 0.85).Gaussian-inf + (inf - -inf) * misNaN, and clipping againstNaNbounds turns the coordinate intoNaNand the objective with it.LogGaussianlower0strict_epsilon0.0, where the support is strict andlog_prioris-inf.Three related findings, each measured against a running install rather than assumed:
LogGaussianPriormisreports its own support. Its message is aTransformedMessage, which defaults limits to±infand is never passed any — yetlog_prior_from_valueis-infforvalue <= 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.bounds_from_modelreturning two arrays is not what scipy accepts.optimize.minimizereads 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 explicitoptimize.Bounds.Plain
BFGSdoes not reject bounds — it ignores them behind aUserWarningand 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-ClipperNoneclipper on a non-bound-supporting method raises instead.Also corrects the
AbstractMultiStartGradientclass docstring, which claimed the rule steps "on the unconstrained (unit-cube) parameterization". It does not —_broad_startsmaps 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 (
unittest3.12,unittest3.13,docs-build). 22 new tests intest_autofit/non_linear/test_clipper.py.A randomised differential stress run over models mixing every prior type:
no clipper argumentvs explicitClipperNone, identical final parameters.ClipperPriorBox, finalsum(log_prior)finite every time, and lane deaths 0 in every case against 62–96 without.search_internalwritten before this change (non_clipped_lane_steps) resumes withoutKeyErrorand continues accumulating.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
centreat 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
ClipperPriorBoxback to the naive width form makes 5 tests fail, including both named regression guards.Reviewer notes
optimizeinLBFGS._fitthat 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 realLBFGS.fit()is now included and was verified to fail with exactly thatNameErrorif the import is removed again.blackwould reformatbfgs/search.pyandmulti_start_gradient/search.py, but both were already unformatted onmain; deliberately not reformatted, to keep the diff free of unrelated churn.scipyis imported lazily, matching every other module inautofit/.test_multi_start_gradient.pyabout keeping JAX out of the library unit suite. The JAX end-to-end checks above were run locally.imaging/mgecell, 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.test_nautilus.py::test__single_core_builds_no_pooldescribed 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)
float32is not JSON serializable in result output (paths/directory.py:80).JSONDecodeErroron resume.LogGaussianPrior's(0, ∞)support on the prior itself, retiring the clipper's special case.🤖 Generated with Claude Code