Overview
MultiStartGradient steps in physical parameter space against an objective
fom = -2 * (log_likelihood + sum(log_prior_list)), with nothing constraining it
to the prior box. A UniformPrior is -inf outside its limits, so a lane that
oversteps a hard prior edge reads as non-finite, is marked dead, and with
resurrect=False is never redrawn. Measured on the real imaging/mge profiling
cell (16 starts x 150 steps, cloud CPU), this accounts for 60.25% of lane-steps
and 14/16 lane deaths — while the likelihood never went non-finite once in
~7200 lane-steps. Full investigation and evidence: autolens_profiling#128.
The behaviour is worse than "frozen". The overshoot is tiny (median 3% of box
width) and because log_prior = -inf is constant outside the box its derivative
is zero, so the total gradient is the finite likelihood gradient.
optax.apply_if_finite therefore never fires and the dead lane keeps stepping
forever — full cost every step, output discarded. 0/16 lanes ever revive.
This PR adds a pluggable Clipper — the prior-support analogue of Initializer —
and wires it opt-in into the two exposed searches. The default is ClipperNone
and this PR is bit-identical with it; flipping the default is PR 2, because it
shifts every stored multi-start benchmark.
Plan
- Add
autofit/non_linear/clipper.py with AbstractClipper, ClipperNone and
ClipperPriorBox, modelled on autofit/non_linear/initializer.py.
- Serve the two structurally different consumers from one source of truth: a
declarative bounds_from_model (for scipy) and an imperative project (for the
step loop), where project also returns which coordinates it clipped.
- Extract bounds for every prior type, with an audited special case for
LogGaussianPrior, whose declared limits do not match its actual support.
- Inset the bounds by bound kind (two-sided / unbounded / half-open) rather
than by box width — the unguarded width form turns every GaussianPrior
coordinate into NaN (measured), which would make the feature harmful on the
very models it targets.
- Hand scipy an
optimize.Bounds, never a (lower, upper) tuple — scipy misreads
the tuple as two (min, max) pairs and silently mis-fits two-parameter models.
- Wire opt-in into
AbstractMultiStartGradient._fit (project after
optax.apply_updates) and AbstractBFGS (pass bounds= to
optimize.minimize, guarded to bound-supporting methods).
- Resolve
clipper exactly as initializer is resolved, defaulting to
ClipperNone so behaviour is unchanged until a user opts in.
- Assert bit-identity under the default on both searches, and assert the
parameter-ordering correspondence rather than trusting it.
Detailed implementation plan
Work Classification
Library — PyAutoFit only. No workspace changes.
Affected Repositories
Branch Survey
| Repository |
Current Branch |
Dirty? |
| PyAutoFit |
main @ 004f798 |
clean |
Suggested branch: claude/autofit-clipper-prior-support-o3jotv
Worktree root: ~/Code/PyAutoLabs-wt/autofit-prior-support-clipper/ (created
later by /start_library; not used in this cloud session, which works from a
direct clone).
worktree_check_conflict autofit-prior-support-clipper PyAutoFit exits 0 — the
registry was read and nothing claims PyAutoFit. The two live claims
(mge-lane-death → autolens_profiling, pix-prodigy-gpu-compat →
autolens_workspace_developer) do not overlap.
Findings from the source read (PyAutoFit@004f798)
These were confirmed against the code before planning and shape the steps below.
Items marked [verified] were additionally executed against a running
PyAutoFit install (Python 3.12 venv, jax 0.11.0 / optax 0.2.8, CPU); the
probe scripts and their output are reproduced in the appendix at the end of this
issue. Two of them are silent-wrong-answer bugs in the contract as originally
specified, and would not have been caught by a passing test suite.
-
[verified] Bounds have a single uniform accessor. Prior.__getattr__
(autofit/mapper/prior/abstract.py:230) delegates to self.message, and
AbstractMessage defaults lower_limit/upper_limit to ±inf
(autofit/messages/abstract.py:45). So prior.lower_limit resolves for every
prior type without a type switch: UniformPrior / LogUniformPrior shadow it
with their own attributes, TruncatedGaussianPrior gets real limits from
TruncatedNormalMessage, and GaussianPrior correctly falls through to ±inf.
-
[verified] LogGaussianPrior breaks that uniform read. Its message is a
TransformedMessage, which defaults its limits to ±inf
(autofit/messages/composed_transform.py:109) and is never passed any — but
LogGaussianPrior.log_prior_from_value returns -inf for value <= 0
(autofit/mapper/prior/log_gaussian.py:167). A naive bounds read therefore
reports (-inf, +inf) for a parameter whose support is (0, ∞), and the
clipper would silently fail to protect exactly the lane-death mechanism it
exists to fix. Decision for this PR: special-case it inside
ClipperPriorBox (lower bound 0, inset by the absolute strict_epsilon
— see finding 6 and the margin design), leaving the shared
LogGaussianPrior class untouched so nothing the EP machinery or the nested
samplers read is disturbed. Declaring the support on the prior itself is the
cleaner long-term fix and is noted as a follow-up.
-
The NumPy and JAX paths disagree on support, which narrows the LBFGS claim.
UniformPrior.log_prior_from_value is if xp is np: return 0.0 — unconditional,
with no bound test at all (autofit/mapper/prior/uniform.py:186); only the JAX
branch returns -inf outside the box. LogUniformPrior documents the same
asymmetry explicitly. So LBFGS is exposed only in its analysis._use_jax
branch (bfgs/search.py:185, fun=fitness._jit); its NumPy branch (line 194)
has no hard wall to fall off in the first place. Both optimize.minimize calls
do lack bounds=, as reported. Passing bounds on both branches is still correct
and harmless — but the fix only changes behaviour on the JAX path, and the
tests should say so rather than implying a NumPy-path regression that cannot
occur.
-
[verified] The margin inset as specified turns every unbounded prior into
NaN. The obvious implementation, lower + margin * (upper - lower),
evaluates -inf + (inf - -inf) * m → NaN for a GaussianPrior. Clipping
against NaN bounds propagates: the probe drives a two-parameter model
(UniformPrior, GaussianPrior) through it and the Gaussian coordinate comes
back NaN, giving sum(log_prior) = nan. This would make the feature
actively harmful — it would kill lanes on exactly the models it is meant to
rescue (the MGE reference model carries GaussianPriors), and the symptom
would be indistinguishable from the bug being fixed. The margin must be applied
only where both bounds are finite; that form is verified NaN-free.
-
[verified] The prompt's bounds_from_model -> tuple[lower, upper] return
type is a silent-wrong-answer bug when handed to scipy. optimize.minimize
reads bounds=(lower_array, upper_array) as a sequence of (min, max) pairs,
not as separate arrays. Measured on a 2-parameter problem whose optimum is
outside the box: the tuple form returns x=[0., 1.] where the correct answer
is [1., 1.] — no error, no warning. At any other dimensionality it raises
ValueError: too many values to unpack. So it fails loudly for most models
and silently and wrongly for two-parameter ones. (lower, upper) arrays are
still the right internal representation (they broadcast for project), but the
LBFGS wiring must construct optimize.Bounds(lower, upper) explicitly and
never pass the tuple through.
-
[verified] A relative margin cannot protect a half-open bound. For
LogGaussianPrior's (0, ∞) the width is undefined, so a relative margin is
0 and the clip lands exactly on 0.0 — where log_prior is -inf, since
that support is strict (value > 0). The special case therefore needs an
absolute floor, not the relative margin. Confirmed the surrounding
behaviour: log_prior(1e-30) = -2316.8, finite, so a small absolute floor is
sufficient and safe.
-
[verified] Plain BFGS does not reject bounds — it ignores them.
optimize.minimize(method="BFGS", bounds=...) emits UserWarning: Method BFGS cannot handle bounds. and returns the unbounded optimum. So "guard or warn"
is too weak: a user who asks for a clipper on BFGS and relies on scipy's own
warning gets a silently unconstrained fit. This PR should raise instead.
-
[verified] Boundary semantics, settled by measurement. Both two-sided prior
types are inclusive at the limit on the JAX path: UniformPrior(0,2) gives
log_prior(2.0) = 0.0 and TruncatedGaussianPrior(-1,1) gives
log_prior(1.0) = -0.5, both finite, while a value just outside is -inf.
Clipping exactly onto a two-sided bound is therefore already safe and
margin=0 works. The margin's real justification is narrower than the prompt
assumed — it exists for (a) the half-open bounds of item 6, and (b) float32
drift downstream, where a clipped value at the bound fails <= upper after a
1e-7 relative nudge. Worth recording that float32 makes the check asymmetric:
2.0000001 is not representable distinctly from 2.0 and reads as in-box,
while -1e-7 against a lower bound of 0.0 is caught. A test asserting
"overshoot is detected" must therefore use a bound near zero or float64, or it
will pass vacuously.
-
[verified] The batched shape needs no vmap. params is
(n_starts, n_params) (params.shape[0] at search.py:634) and the bounds are
(n_params,); jnp.clip broadcasts them directly, and the broadcast form is
bit-identical to the vmaped one under jit. The mask is
(n_starts, n_params), so the per-lane counter is jnp.any(mask, axis=1).
-
[verified] The momentum-pinning effect is real and reproduces in isolation.
An Adam lane whose likelihood optimum sits outside the box is clipped on 7 of 8
consecutive steps and stays pinned at the bound, because the optimizer state
keeps pushing outward. This is the prototype's "5/16 lanes pinned" result and
it confirms the returned mask is load-bearing rather than decorative.
Also confirmed: params in _fit are physical vectors — _broad_starts "map[s]
them to physical parameters" via model.vector_from_unit_vector — which means the
AbstractMultiStartGradient class docstring is wrong where it says the rule
steps "on the unconstrained (unit-cube) parameterization". That is precisely the
sentence that would tell the next reader this bug cannot exist, so this PR should
correct it. The injection point is optax.apply_updates at
multi_start_gradient/search.py:817; the objective really does fold in the prior
(fitness.py:271-272, figure_of_merit = log_likelihood + sum(log_prior_array)
when fom_is_log_likelihood=False); Constant is a float/ModelObject, not a
Prior, so it never enters the parameter vector and needs no bound; and a repo
sweep found no existing clipping or bounds machinery to reuse or duplicate
(bounds= appears nowhere in autofit/, and the sole np.clip is unrelated, in
the NUTS ESS calculation).
Implementation Steps
-
autofit/non_linear/clipper.py (new).
AbstractClipper(ABC) with two abstract methods:
bounds_from_model(model) -> tuple[np.ndarray, np.ndarray] — (lower, upper) in physical parameter order, -inf/+inf where unbounded. Keep
this shape: it is what project broadcasts against. It is not what
scipy accepts (finding 5) — conversion is the caller's job, see step 5.
project(vector, model, xp=np) -> tuple[vector, mask] — the projected
vector and a boolean mask naming the coordinates that were moved. Accepts
both a single (n_params,) vector and a batched (n_starts, n_params)
array; broadcasting handles both, no vmap needed (finding 9).
ClipperNone — bounds are ±inf, project is the identity and returns an
all-False mask. The default.
ClipperPriorBox(margin: float = 1e-6, strict_epsilon: float = 1e-12) —
hard projection onto the prior support, inset by bound kind (see the
margin design below). Never derives an inset from upper - lower without
first establishing both are finite.
- Both
bounds_from_model and project read one private
_limits_from_model(model) helper so the declarative and imperative
consumers can never drift apart. project is written xp-generically
(xp.clip, xp.where) so it traces under JAX — verified to work under both
jit and vmap.
-
Bounds extraction, per prior type. Read prior.lower_limit /
prior.upper_limit over model.priors_ordered_by_id, then apply the audited
corrections:
| Prior |
Source of limits |
Result |
UniformPrior |
own attributes |
finite both sides |
LogUniformPrior |
own attributes |
finite both sides, lower > 0 |
TruncatedGaussianPrior |
TruncatedNormalMessage |
finite both sides |
GaussianPrior |
NormalMessage → AbstractMessage default |
±inf, passes through untouched |
LogGaussianPrior |
TransformedMessage default — wrong |
special-cased to lower 0 |
-
The margin design — inset by bound kind, not by width. Findings 4 and 6
share one root: both come from evaluating upper - lower unconditionally, which
is NaN for an unbounded prior and meaningless for a half-open one. The fix is
not a second margin bolted on, but to key the inset on what kind of bound it
is. Three cases, and the classification is the whole design:
| Bound kind |
Example |
Inset |
Why |
| Two-sided finite, inclusive |
Uniform, LogUniform, TruncatedGaussian |
relative, margin * (upper - lower) |
Not for prior support — measurement (finding 8) shows the limit itself is finite. It is to avoid parking a lane exactly on a prior edge, where the likelihood transforms are singular (arctan2 / sqrt at exactly 0). This is the same reason the search's own start band defaults to the interior (0.15, 0.85) rather than (0, 1) — see the start_lower_limit docstring. Safe to compute: both bounds are finite by construction of this branch. |
| Unbounded |
Gaussian |
none — and no width arithmetic at all |
-inf + (inf - -inf) * m is NaN (finding 4). The coordinate must pass through untouched, which is only guaranteed if the width is never computed for it. |
| Half-open, exclusive |
LogGaussian lower 0 |
absolute, strict_epsilon |
A relative margin is identically zero here (no finite width), so it would clip exactly onto 0.0 — where the support is strict and log_prior is -inf (finding 6). Only an absolute nudge lands strictly inside. |
This replaces the prompt's single 1e-6 guess with two constants that each have
a stated job, and answers its "decide and document whether log_prior at
exactly the limit is finite" trap with a measured answer: inclusive for the
two-sided types, exclusive for LogGaussian's zero.
Verified end-to-end on a five-parameter model carrying one of every prior type.
Every overshoot — including all five at once — is projected back to a finite
log_prior, the Gaussian coordinate is untouched at 900.0, and no NaN
reaches the bounds:
u over upper lp_before=-inf lp_after=0.6481 clipped=[1 0 0 0 0]
lg at zero lp_before=-inf lp_after=-353.5 clipped=[0 0 1 0 0]
lg negative lp_before=-inf lp_after=-353.5 clipped=[0 0 1 0 0]
lu under lower lp_before=-inf lp_after=13.08 clipped=[0 0 0 1 0]
tg over upper lp_before=-inf lp_after=0.1481 clipped=[0 0 0 0 1]
gaussian far out lp_before=-4.05e+05 lp_after=-4.05e+05 clipped=[0 0 0 0 0]
all at once lp_before=-inf lp_after=-4.053e+05 clipped=[1 0 1 1 1]
Note the LogGaussian floor is deliberately deep: 1e-12 gives
log_prior = -354, finite but very low density, with a correspondingly strong
restoring gradient pushing the lane back up. That is the intended behaviour —
the lane survives and recovers rather than dying, which is the whole point.
-
Wire into AbstractMultiStartGradient. Add clipper: Optional[AbstractClipper] = None
to __init__; apply project immediately after optax.apply_updates
(search.py:817). Return and retain the clipped mask — the prototype left 5/16
lanes pinned to a bound because parameters were projected while Prodigy's
accumulated state kept pushing outward, and the mask is what lets a caller zero
the optimiser momentum along clipped directions later. Surface a
n_clipped_lane_steps counter in search_internal (per-lane, i.e.
jnp.any(mask, axis=1)), alongside the existing n_value_nan_lane_steps /
n_grad_nan_lane_steps / n_constrained_lane_steps. Restore it on the resume
path with .get(key, 0), the precedent already set at search.py:678 so a
search_internal written before this key existed does not KeyError. Under
ClipperNone the projection must be skipped entirely (not applied as a no-op)
so the compiled step is unchanged — mirroring the has_constraint = bool( model.constrained_model_tuples()) short-circuit at search.py:613.
-
Wire into AbstractBFGS. Add the same constructor arg. Build
optimize.Bounds(lower, upper) from bounds_from_model and pass that to
both optimize.minimize calls (bfgs/search.py:185 and :194) — never the
raw (lower, upper) tuple, which scipy misreads as two (min, max) pairs and
silently mis-fits two-parameter models (finding 5). L-BFGS-B, TNC and
SLSQP accept bounds; plain BFGS silently ignores them behind a
UserWarning (finding 7), so raise a clear PyAutoFit-level error when a
non-ClipperNone clipper meets a non-bound-supporting method rather than
letting the user receive an unconstrained fit. Under ClipperNone, pass no
bounds= at all. Verified that L-BFGS-B accepts ±inf entries, so the
GaussianPrior passthrough needs no special handling here.
5b. Correct the AbstractMultiStartGradient class docstring, which currently
claims the rule steps "on the unconstrained (unit-cube) parameterization" while
_broad_starts maps draws to physical parameters. One-line fix, but it is the
sentence that would tell the next reader this class of bug cannot occur.
- Resolve the default like
initializer. AbstractMLE.__init__ uses the
initializer or InitializerBall(...) pattern (abstract_mle.py:15-20); mirror
it with clipper or ClipperNone() at the same tier so both searches inherit it.
Testing
test_autofit/non_linear/test_clipper.py (new, sits beside test_initializer.py):
- Bounds extraction per prior type, including the
±inf passthrough for
GaussianPrior and the LogGaussianPrior lower-bound special case.
- Ordering assertion. Not
priors_ordered_by_id against itself — that is
trivially true, since both it and log_prior_list_from_vector derive from
prior_tuples_ordered_by_id. Build a model whose priors have distinguishable
limits and assert that the bound at index i belongs to the parameter
instance_from_vector places at index i. A mismatch would clip the wrong
parameter with no error.
- A vector deliberately stepped across a boundary is projected back inside, and
the returned mask names exactly the crossed coordinates. Use a bound near
zero or float64 — at float32, an overshoot of 2.0000001 against an upper
bound of 2.0 is not representable and the test would pass vacuously
(finding 8).
ClipperNone is the identity and its mask is all-False.
- Regression guard for finding 4: a model mixing a
UniformPrior with a
GaussianPrior, projected, asserts the Gaussian coordinate is untouched and
finite — not NaN. This is the test that catches the harmful form of the
margin, and it must fail against the naive implementation.
- Regression guard for finding 6: a
LogGaussianPrior coordinate driven to
0.0 or negative is projected to a strictly positive value with finite
log_prior.
- Batched
project: an (n_starts, n_params) array clips per-lane against
(n_params,) bounds, and the result is identical under jit and vmap.
test_autofit/non_linear/search/mle/test_multi_start_gradient.py: clipper
round-trips through the constructor, and the default is ClipperNone.
- BFGS: bounds reach scipy as an
optimize.Bounds, and a two-parameter model
is fitted to a known answer with a clipper active — the dimensionality at which
the tuple form fails silently rather than raising, so this is the only shape of
test that catches finding 5. Plain BFGS with a real clipper raises.
- Bit-identity: same seed, same final parameters with
ClipperNone on both
searches — the gate for this PR.
- Regression: with
ClipperPriorBox on a model with a tight UniformPrior, the
value-NaN rate falls substantially. The reference numbers are CPU/float32 and
single-seed, so assert a direction and a large margin, not an exact figure.
Key Files
autofit/non_linear/clipper.py — new; the whole contract.
autofit/non_linear/search/mle/multi_start_gradient/search.py — _fit step loop
(:817), constructor, search_internal counters.
autofit/non_linear/search/mle/bfgs/search.py — both optimize.minimize calls
(:185, :194), constructor.
autofit/non_linear/search/mle/abstract_mle.py — default resolution.
autofit/non_linear/initializer.py — the shape being mirrored.
autofit/mapper/prior/abstract.py:230 — the __getattr__ delegation the bounds
read depends on.
autofit/__init__.py — export ClipperNone / ClipperPriorBox alongside the
Initializer* exports.
Deliberately out of scope
- NUTS. HMC entering a
-inf region diverges rather than freezing — a
different mechanism needing its own investigation.
- Unit-cube stepping. The more principled long-term fix, rejected for now:
a logit reparameterisation sends the optimum to infinity when it genuinely sits
on a boundary (which this cell demonstrably has), the inverse-CDF transform has
∂θ/∂u → ∞ at the cube faces, and it invalidates every stored benchmark.
- Changing
resurrect defaults. Not the fix — a redrawn lane walks out again.
- Flipping the default to
ClipperPriorBox. PR 2, with the benchmark
re-baseline and the momentum-reset-on-clip decision.
Follow-ups this PR should file, not fix
Both surfaced only because clipping let lanes survive, i.e. on a code path this
cell had apparently never taken:
float32 is not JSON serializable in result output —
autofit/non_linear/paths/directory.py:80 save_json raises TypeError at the
end of a successful clipped run.
- A crashed run poisons the next run of the same name: the half-written output
from (1) makes the next search with the same name fail with JSONDecodeError
while resuming — a 4-second no-op run that looks like a clean result. A new
form of the cached-result hazard in
complete/2026/08/multistart-nan-step-diagnostics.md.
Plus, from the source read: declaring LogGaussianPrior's (0, ∞) support on the
prior itself, so prior.lower_limit is correct for every consumer and the
ClipperPriorBox special case can be retired.
Appendix — measured output backing the [verified] findings
Environment: Python 3.12 venv, PyAutoFit 004f798 installed editable,
jax 0.11.0 / optax 0.2.8, CPU, scipy from the same install.
Bounds accessor across every prior type (finding 1, and the gap in finding 2):
UniformPrior(0,2) -> (0.0, 2.0)
LogUniformPrior(1e-6,1) -> (1e-06, 1.0)
GaussianPrior(0,1) -> (-inf, inf)
LogGaussianPrior(0,1) -> (-inf, inf) <-- support is (0, inf)
TruncatedGaussianPrior(0,1,-1,1) -> (-1.0, 1.0)
The NaN hazard, end to end on a (UniformPrior, GaussianPrior) model (finding 4):
naive margin (no finite guard) = [2.e-06 inf]
naive inset lower=[2.e-06 nan] upper=[1.999998 nan]
clip with NaN bounds -> [1.999998 nan]
*** parameter b became NaN? True ***
resulting sum(log_prior) = nan
versus the finite-guarded form, which is NaN-free and rescues the lane:
overshooting vector [2.0001 0.5] -> sum(log_prior) = -inf
margin=1e-06 -> clipped=[1.999998 0.5] sum(log_prior)=-0.125 finite=True
scipy's reading of the tuple form, by dimensionality (finding 5):
n=1: tuple-> RAISED ValueError: not enough values to unpack (expected 2, got 1)
n=2: tuple-> [0. 1.] correct-> [1. 1.] *** SILENTLY WRONG ***
n=3: tuple-> RAISED ValueError: too many values to unpack (expected 2)
n=5: tuple-> RAISED ValueError: too many values to unpack (expected 2)
Plain BFGS with bounds (finding 7) — optimum is at 5.0, box is [0,1]:
BFGS+bounds -> returned x=[4.99999997 4.99999997] (bounds silently ignored)
warnings: ['Method BFGS cannot handle bounds.']
JAX-path boundary semantics (finding 8):
Uniform(0,2) value=0.0 jax log_prior = 0.0
Uniform(0,2) value=2.0 jax log_prior = 0.0 <-- inclusive
Uniform(0,2) value=-1e-07 jax log_prior = -inf
Uniform(0,2) value=2.0000001 jax log_prior = 0.0 <-- float32 cannot resolve it
TruncGauss(-1,1) value=1.0 jax log_prior = -0.5 <-- inclusive
TruncGauss(-1,1) value=1.0001 jax log_prior = -inf
LogGauss value=0.0 jax log_prior = -inf <-- strict
LogGauss value=1e-30 jax log_prior = -2316.8
Momentum pinning in isolation — Adam, box [0,2], likelihood optimum at 5.0
(finding 10): clipped on 7 of 8 consecutive steps, pinned at 2.00000 throughout.
Limits of this verification. These probes exercise the prior/bounds/scipy/JAX
machinery directly; they do not run the real imaging/mge cell, and nothing
here was run on a GPU or in float64. The quantitative claims from
autolens_profiling#128 (60.25% → 17.71%, 14/16 → 5/16) are inherited from that
investigation, not re-measured here — which is why the regression test asserts a
direction and a large margin rather than a figure.
Original Prompt
Click to expand starting prompt
Search-agnostic prior-support enforcement: a Clipper class
Type: feature
Target: PyAutoFit
Repos:
- PyAutoFit
Difficulty: medium
Autonomy: supervised
Priority: high
Status: formalised
Why
@PyAutoFit/autofit/non_linear/search/mle/multi_start_gradient/search.py builds
its objective as
fom = -2 * (log_likelihood + sum(log_prior_list))
A UniformPrior returns log_prior = -inf outside its box, and the search steps
in physical parameter space with nothing constraining it to that box. A lane
that oversteps a hard prior edge reads as non-finite, is marked dead, and with
resurrect=False is never redrawn.
Measured on the real imaging/mge profiling cell (16 starts x 150 steps, cloud
CPU) — full investigation and evidence in autolens_profiling#128:
| arm |
value-NaN lane-steps |
lanes dead |
alive at end |
| baseline |
1446 (60.25%) |
14/16 |
2 |
| shear box widened to ±1 |
1422 (59.25%) |
15/16 |
1 |
| prior term neutered (diagnostic) |
215 (8.96%) |
3/16 |
13 |
| clip to prior box (prototype) |
425 (17.71%) |
5/16 |
11 |
The likelihood never went non-finite in ~7200 lane-steps. This is entirely a
prior-support problem.
The behaviour is worse than "frozen": the overshoot is tiny (median 3% of box
width, min 0.21%), and because log_prior = -inf is constant outside the box
its derivative is zero, so the total gradient is the finite likelihood
gradient. optax.apply_if_finite therefore never fires and the dead lane keeps
stepping forever — full likelihood-and-gradient cost every step, output
discarded, wandering far (one parameter went 0.30 -> -1.76). 0/16 lanes ever
revive.
The exposure is not MultiStart-only
This is why the fix should not live inside one search:
MultiStartGradient (MultiStartAdam / MultiStartADABelief /
MultiStartLion / MultiStartProdigy all share one _fit) — measured above.
@PyAutoFit/autofit/non_linear/search/mle/bfgs/search.py — same
Fitness(fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, convert_to_chi_squared=True), steps in physical space, and calls
optimize.minimize(fun=..., x0=..., method=self.method, options=..., tol=...)
with no bounds= argument. L-BFGS-B supports box bounds natively; they
are simply not passed. Being single-start, this presents as a failed fit rather
than a population collapse, so it is easier to misattribute.
- NUTS (
@PyAutoFit/autofit/non_linear/search/mcmc/blackjax/nuts/search.py)
also targets the log posterior from a physical initial_position. HMC entering
a -inf region diverges rather than freezing. Out of scope here — different
mechanism, needs its own investigation. See "Deliberately out of scope".
Not exposed, and correctly so: 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 that gradient methods lack.
The design
A Clipper, modelled on @PyAutoFit/autofit/non_linear/initializer.py — a
pluggable, per-search strategy object with a config-resolved default.
One place the Initializer analogy does not carry. Initializer has a single
consumption pattern (samples_from_model). Clipper has two structurally
different consumers and must serve both from one source of truth:
MultiStartGradient enforces the constraint itself, every step → wants an
imperative project(...).
LBFGS hands bounds to scipy and lets scipy enforce → wants a declarative
bounds.
Proposed contract:
class AbstractClipper(ABC):
@abstractmethod
def bounds_from_model(self, model) -> tuple[np.ndarray, np.ndarray]:
"""(lower, upper) in PHYSICAL parameter order. Unbounded -> -inf/+inf."""
@abstractmethod
def project(self, vector, model, xp=np):
"""Return (projected_vector, clipped_mask). Identity where unbounded."""
class ClipperNone(AbstractClipper):
"""No-op. Bounds are ±inf, project is the identity. THE DEFAULT (see below)."""
class ClipperPriorBox(AbstractClipper):
"""Hard projection onto the prior support, inset by a margin."""
project must return which coordinates it clipped, not just the new vector.
That mask is what lets a caller zero the optimiser momentum along clipped
directions. It is needed: the prototype left 5 of 16 lanes pinned to a bound at
the end of the run because the parameters were projected while Prodigy's
accumulated state kept pushing outward. The Clipper cannot fix that itself — it
does not own opt_state — so it must expose enough for the search to.
Later strategies (ClipperReflect, a soft-wall variant) drop in without touching
callers. A soft wall must be a Clipper (search-local), never a change to the
Prior classes — that would silently alter the objective for the nested samplers,
where the hard box currently works correctly.
Scope — PR 1 (this task)
AbstractClipper + ClipperNone + ClipperPriorBox in a new
@PyAutoFit/autofit/non_linear/clipper.py.
- Bounds extraction covering every prior type. Confirmed present in the
reference model: UniformPrior (finite both sides), TruncatedGaussianPrior
(finite both sides, e.g. (-1, 1) for ell_comps), GaussianPrior
(±inf — must pass through untouched). Audit the rest (LogUniformPrior,
LogGaussianPrior, any Constant/deterministic entries).
- Wire into
AbstractMultiStartGradient._fit, applied after
optax.apply_updates, opt-in.
- Wire into
LBFGS, passing bounds= through to optimize.minimize,
opt-in. Only valid for bound-supporting methods (L-BFGS-B, TNC,
SLSQP) — guard or warn for plain BFGS.
clipper: AbstractClipper = None constructor arg on the searches, resolved
like initializer.
Default is ClipperNone, and PR 1 must be bit-identical with it. Follow the
precedent set by PyAutoFit#1475, whose models declaring no constraint
short-circuit to bit-identical behaviour. Flipping the default is a real
behaviour change that shifts every stored multi-start benchmark, which is exactly
the comparability argument PyAutoFit#1472 made when it deferred its own policy
change.
Scope — PR 2 (separate prompt, file after PR 1 lands)
Flip MultiStartGradient's default to ClipperPriorBox, with the benchmark
re-baseline, plus the momentum-reset-on-clip decision informed by how bad the
pinning actually is at production budget.
Traps, measured
- Parameter ordering is load-bearing and silent if wrong.
model.priors_ordered_by_id was used for the prototype and lined up correctly
with model.instance_from_vector, but a mismatch would clip the wrong
parameter with no error. Assert the correspondence in a test rather than
trusting it.
- Boundary semantics. Decide and document whether
log_prior at exactly the
limit is finite. The prototype inset by 1e-6 of the box width to stay strictly
inside; that margin is a guess and should be a justified constant.
- Pinning is correct behaviour, not a bug. Where the likelihood genuinely
prefers a value outside the prior, a clipped lane sitting on the bound is the
correct MAP answer under the declared prior. It is worth surfacing (it says the
prior is fighting the data) rather than hiding. In the reference cell the shear
escapes were mixed-sign (+0.353, -0.341, +0.301, -0.312), which reads
more like a poorly-constrained parameter diffusing out than a true value sitting
outside.
- Clipping does not fix every death. 5/16 lanes still died in the prototype;
those are the NaN-gradient population (likelihood NaN in the jitted path,
which the Fitness guard maps to -inf and whose where makes the gradient
NaN). Separate mechanism, do not expect this task to remove it.
Two incidental bugs found while investigating — do not lose these
Both surfaced only because clipping let lanes survive, i.e. on a code path this
cell had apparently never taken:
float32 is not JSON serializable in result output.
@PyAutoFit/autofit/non_linear/paths/directory.py:80 save_json raises
TypeError: Object of type float32 is not JSON serializable at the end of a
successful clipped run. Did not fire on the baseline runs, where 14/16 lanes
were dead. File separately if confirmed.
- A crashed run poisons the next run of the same name. The half-written
output left by (1) caused the next search with the same name to fail with
JSONDecodeError while trying to resume — a 4-second no-op run that looked
like a clean result (zero deaths, because zero steps). This is a new form of
the cached-result hazard already recorded in
complete/2026/08/multistart-nan-step-diagnostics.md.
Deliberately out of scope
- NUTS. Divergence, not lane death; may need a transform or a soft wall rather
than projection. Its own task.
- Unit-cube stepping. The more principled long-term fix — PyAutoFit's prior
machinery is already unit-cube and the nested samplers work that way, and it
would also normalise parameter scales (einstein_radius ∈ [0,8] alongside
ell_comps ∈ [-1,1]). Rejected for now on three grounds: a logit
reparameterisation sends the optimum to infinity when it genuinely sits on a
boundary, which this cell demonstrably has; the inverse-CDF transform for
non-uniform priors has ∂θ/∂u -> ∞ at the cube faces, trading one numerical
hazard for another; and it invalidates every stored benchmark. If pursued, note
that reparameterising the search path does not move the optimum provided the
objective is still the physical-space posterior evaluated at θ(u) — optimise
the density of u instead and the Jacobian makes the MAP non-invariant, which
fails silently.
- Changing
resurrect defaults. Not the fix: a redrawn lane walks out again.
Testing
- Bounds extraction per prior type, including
±inf passthrough for GaussianPrior.
- Ordering assertion (see traps).
ClipperNone is bit-identical: same seed, same final parameters, on both
MultiStartGradient and LBFGS.
- A lane deliberately stepped across a boundary is projected back inside, and the
returned mask names exactly the crossed coordinates.
LBFGS passes bounds through and rejects/warns for non-bound-supporting methods.
- Regression: with
ClipperPriorBox on a model with a tight UniformPrior, the
value-NaN rate falls substantially. The reference numbers above are CPU/float32,
single seed — assert a direction and a large margin, not an exact figure.
Overview
MultiStartGradientsteps in physical parameter space against an objectivefom = -2 * (log_likelihood + sum(log_prior_list)), with nothing constraining itto the prior box. A
UniformPrioris-infoutside its limits, so a lane thatoversteps a hard prior edge reads as non-finite, is marked dead, and with
resurrect=Falseis never redrawn. Measured on the realimaging/mgeprofilingcell (16 starts x 150 steps, cloud CPU), this accounts for 60.25% of lane-steps
and 14/16 lane deaths — while the likelihood never went non-finite once in
~7200 lane-steps. Full investigation and evidence: autolens_profiling#128.
The behaviour is worse than "frozen". The overshoot is tiny (median 3% of box
width) and because
log_prior = -infis constant outside the box its derivativeis zero, so the total gradient is the finite likelihood gradient.
optax.apply_if_finitetherefore never fires and the dead lane keeps steppingforever — full cost every step, output discarded. 0/16 lanes ever revive.
This PR adds a pluggable
Clipper— the prior-support analogue ofInitializer—and wires it opt-in into the two exposed searches. The default is
ClipperNoneand this PR is bit-identical with it; flipping the default is PR 2, because it
shifts every stored multi-start benchmark.
Plan
autofit/non_linear/clipper.pywithAbstractClipper,ClipperNoneandClipperPriorBox, modelled onautofit/non_linear/initializer.py.declarative
bounds_from_model(for scipy) and an imperativeproject(for thestep loop), where
projectalso returns which coordinates it clipped.LogGaussianPrior, whose declared limits do not match its actual support.than by box width — the unguarded width form turns every
GaussianPriorcoordinate into
NaN(measured), which would make the feature harmful on thevery models it targets.
optimize.Bounds, never a(lower, upper)tuple — scipy misreadsthe tuple as two
(min, max)pairs and silently mis-fits two-parameter models.AbstractMultiStartGradient._fit(project afteroptax.apply_updates) andAbstractBFGS(passbounds=tooptimize.minimize, guarded to bound-supporting methods).clipperexactly asinitializeris resolved, defaulting toClipperNoneso behaviour is unchanged until a user opts in.parameter-ordering correspondence rather than trusting it.
Detailed implementation plan
Work Classification
Library — PyAutoFit only. No workspace changes.
Affected Repositories
Branch Survey
004f798Suggested branch:
claude/autofit-clipper-prior-support-o3jotvWorktree root:
~/Code/PyAutoLabs-wt/autofit-prior-support-clipper/(createdlater by
/start_library; not used in this cloud session, which works from adirect clone).
worktree_check_conflict autofit-prior-support-clipper PyAutoFitexits0— theregistry was read and nothing claims PyAutoFit. The two live claims
(
mge-lane-death→ autolens_profiling,pix-prodigy-gpu-compat→autolens_workspace_developer) do not overlap.
Findings from the source read (
PyAutoFit@004f798)These were confirmed against the code before planning and shape the steps below.
Items marked [verified] were additionally executed against a running
PyAutoFit install (Python 3.12 venv,
jax 0.11.0/optax 0.2.8, CPU); theprobe scripts and their output are reproduced in the appendix at the end of this
issue. Two of them are silent-wrong-answer bugs in the contract as originally
specified, and would not have been caught by a passing test suite.
[verified] Bounds have a single uniform accessor.
Prior.__getattr__(
autofit/mapper/prior/abstract.py:230) delegates toself.message, andAbstractMessagedefaultslower_limit/upper_limitto±inf(
autofit/messages/abstract.py:45). Soprior.lower_limitresolves for everyprior type without a type switch:
UniformPrior/LogUniformPriorshadow itwith their own attributes,
TruncatedGaussianPriorgets real limits fromTruncatedNormalMessage, andGaussianPriorcorrectly falls through to±inf.[verified]
LogGaussianPriorbreaks that uniform read. Its message is aTransformedMessage, which defaults its limits to±inf(
autofit/messages/composed_transform.py:109) and is never passed any — butLogGaussianPrior.log_prior_from_valuereturns-infforvalue <= 0(
autofit/mapper/prior/log_gaussian.py:167). A naive bounds read thereforereports
(-inf, +inf)for a parameter whose support is(0, ∞), and theclipper would silently fail to protect exactly the lane-death mechanism it
exists to fix. Decision for this PR: special-case it inside
ClipperPriorBox(lower bound0, inset by the absolutestrict_epsilon— see finding 6 and the margin design), leaving the shared
LogGaussianPriorclass untouched so nothing the EP machinery or the nestedsamplers read is disturbed. Declaring the support on the prior itself is the
cleaner long-term fix and is noted as a follow-up.
The NumPy and JAX paths disagree on support, which narrows the LBFGS claim.
UniformPrior.log_prior_from_valueisif xp is np: return 0.0— unconditional,with no bound test at all (
autofit/mapper/prior/uniform.py:186); only the JAXbranch returns
-infoutside the box.LogUniformPriordocuments the sameasymmetry explicitly. So LBFGS is exposed only in its
analysis._use_jaxbranch (
bfgs/search.py:185,fun=fitness._jit); its NumPy branch (line 194)has no hard wall to fall off in the first place. Both
optimize.minimizecallsdo lack
bounds=, as reported. Passing bounds on both branches is still correctand harmless — but the fix only changes behaviour on the JAX path, and the
tests should say so rather than implying a NumPy-path regression that cannot
occur.
[verified] The margin inset as specified turns every unbounded prior into
NaN. The obvious implementation,lower + margin * (upper - lower),evaluates
-inf + (inf - -inf) * m→NaNfor aGaussianPrior. Clippingagainst
NaNbounds propagates: the probe drives a two-parameter model(
UniformPrior,GaussianPrior) through it and the Gaussian coordinate comesback
NaN, givingsum(log_prior) = nan. This would make the featureactively harmful — it would kill lanes on exactly the models it is meant to
rescue (the MGE reference model carries
GaussianPriors), and the symptomwould be indistinguishable from the bug being fixed. The margin must be applied
only where both bounds are finite; that form is verified NaN-free.
[verified] The prompt's
bounds_from_model -> tuple[lower, upper]returntype is a silent-wrong-answer bug when handed to scipy.
optimize.minimizereads
bounds=(lower_array, upper_array)as a sequence of(min, max)pairs,not as separate arrays. Measured on a 2-parameter problem whose optimum is
outside the box: the tuple form returns
x=[0., 1.]where the correct answeris
[1., 1.]— no error, no warning. At any other dimensionality it raisesValueError: too many values to unpack. So it fails loudly for most modelsand silently and wrongly for two-parameter ones.
(lower, upper)arrays arestill the right internal representation (they broadcast for
project), but theLBFGS wiring must construct
optimize.Bounds(lower, upper)explicitly andnever pass the tuple through.
[verified] A relative margin cannot protect a half-open bound. For
LogGaussianPrior's(0, ∞)the width is undefined, so a relative margin is0and the clip lands exactly on0.0— wherelog_prioris-inf, sincethat support is strict (
value > 0). The special case therefore needs anabsolute floor, not the relative margin. Confirmed the surrounding
behaviour:
log_prior(1e-30) = -2316.8, finite, so a small absolute floor issufficient and safe.
[verified] Plain
BFGSdoes not reject bounds — it ignores them.optimize.minimize(method="BFGS", bounds=...)emitsUserWarning: Method BFGS cannot handle bounds.and returns the unbounded optimum. So "guard or warn"is too weak: a user who asks for a clipper on
BFGSand relies on scipy's ownwarning gets a silently unconstrained fit. This PR should raise instead.
[verified] Boundary semantics, settled by measurement. Both two-sided prior
types are inclusive at the limit on the JAX path:
UniformPrior(0,2)giveslog_prior(2.0) = 0.0andTruncatedGaussianPrior(-1,1)giveslog_prior(1.0) = -0.5, both finite, while a value just outside is-inf.Clipping exactly onto a two-sided bound is therefore already safe and
margin=0works. The margin's real justification is narrower than the promptassumed — it exists for (a) the half-open bounds of item 6, and (b) float32
drift downstream, where a clipped value at the bound fails
<= upperafter a1e-7 relative nudge. Worth recording that float32 makes the check asymmetric:
2.0000001is not representable distinctly from2.0and reads as in-box,while
-1e-7against a lower bound of0.0is caught. A test asserting"overshoot is detected" must therefore use a bound near zero or float64, or it
will pass vacuously.
[verified] The batched shape needs no
vmap.paramsis(n_starts, n_params)(params.shape[0]atsearch.py:634) and the bounds are(n_params,);jnp.clipbroadcasts them directly, and the broadcast form isbit-identical to the
vmaped one underjit. The mask is(n_starts, n_params), so the per-lane counter isjnp.any(mask, axis=1).[verified] The momentum-pinning effect is real and reproduces in isolation.
An Adam lane whose likelihood optimum sits outside the box is clipped on 7 of 8
consecutive steps and stays pinned at the bound, because the optimizer state
keeps pushing outward. This is the prototype's "5/16 lanes pinned" result and
it confirms the returned mask is load-bearing rather than decorative.
Also confirmed:
paramsin_fitare physical vectors —_broad_starts"map[s]them to physical parameters" via
model.vector_from_unit_vector— which means theAbstractMultiStartGradientclass docstring is wrong where it says the rulesteps "on the unconstrained (unit-cube) parameterization". That is precisely the
sentence that would tell the next reader this bug cannot exist, so this PR should
correct it. The injection point is
optax.apply_updatesatmulti_start_gradient/search.py:817; the objective really does fold in the prior(
fitness.py:271-272,figure_of_merit = log_likelihood + sum(log_prior_array)when
fom_is_log_likelihood=False);Constantis afloat/ModelObject, not aPrior, so it never enters the parameter vector and needs no bound; and a reposweep found no existing clipping or bounds machinery to reuse or duplicate
(
bounds=appears nowhere inautofit/, and the solenp.clipis unrelated, inthe NUTS ESS calculation).
Implementation Steps
autofit/non_linear/clipper.py(new).AbstractClipper(ABC)with two abstract methods:bounds_from_model(model) -> tuple[np.ndarray, np.ndarray]—(lower, upper)in physical parameter order,-inf/+infwhere unbounded. Keepthis shape: it is what
projectbroadcasts against. It is not whatscipy accepts (finding 5) — conversion is the caller's job, see step 5.
project(vector, model, xp=np) -> tuple[vector, mask]— the projectedvector and a boolean mask naming the coordinates that were moved. Accepts
both a single
(n_params,)vector and a batched(n_starts, n_params)array; broadcasting handles both, no
vmapneeded (finding 9).ClipperNone— bounds are±inf,projectis the identity and returns anall-
Falsemask. The default.ClipperPriorBox(margin: float = 1e-6, strict_epsilon: float = 1e-12)—hard projection onto the prior support, inset by bound kind (see the
margin design below). Never derives an inset from
upper - lowerwithoutfirst establishing both are finite.
bounds_from_modelandprojectread one private_limits_from_model(model)helper so the declarative and imperativeconsumers can never drift apart.
projectis writtenxp-generically(
xp.clip,xp.where) so it traces under JAX — verified to work under bothjitandvmap.Bounds extraction, per prior type. Read
prior.lower_limit/prior.upper_limitovermodel.priors_ordered_by_id, then apply the auditedcorrections:
UniformPriorLogUniformPriorTruncatedGaussianPriorTruncatedNormalMessageGaussianPriorNormalMessage→AbstractMessagedefault±inf, passes through untouchedLogGaussianPriorTransformedMessagedefault — wrong0The margin design — inset by bound kind, not by width. Findings 4 and 6
share one root: both come from evaluating
upper - lowerunconditionally, whichis
NaNfor an unbounded prior and meaningless for a half-open one. The fix isnot a second margin bolted on, but to key the inset on what kind of bound it
is. Three cases, and the classification is the whole design:
Uniform,LogUniform,TruncatedGaussianmargin * (upper - lower)arctan2/sqrtat exactly 0). This is the same reason the search's own start band defaults to the interior(0.15, 0.85)rather than(0, 1)— see thestart_lower_limitdocstring. Safe to compute: both bounds are finite by construction of this branch.Gaussian-inf + (inf - -inf) * misNaN(finding 4). The coordinate must pass through untouched, which is only guaranteed if the width is never computed for it.LogGaussianlower0strict_epsilon0.0— where the support is strict andlog_prioris-inf(finding 6). Only an absolute nudge lands strictly inside.This replaces the prompt's single
1e-6guess with two constants that each havea stated job, and answers its "decide and document whether
log_prioratexactly the limit is finite" trap with a measured answer: inclusive for the
two-sided types, exclusive for
LogGaussian's zero.Verified end-to-end on a five-parameter model carrying one of every prior type.
Every overshoot — including all five at once — is projected back to a finite
log_prior, theGaussiancoordinate is untouched at900.0, and noNaNreaches the bounds:
Note the
LogGaussianfloor is deliberately deep:1e-12giveslog_prior = -354, finite but very low density, with a correspondingly strongrestoring gradient pushing the lane back up. That is the intended behaviour —
the lane survives and recovers rather than dying, which is the whole point.
Wire into
AbstractMultiStartGradient. Addclipper: Optional[AbstractClipper] = Noneto
__init__; applyprojectimmediately afteroptax.apply_updates(
search.py:817). Return and retain the clipped mask — the prototype left 5/16lanes pinned to a bound because parameters were projected while Prodigy's
accumulated state kept pushing outward, and the mask is what lets a caller zero
the optimiser momentum along clipped directions later. Surface a
n_clipped_lane_stepscounter insearch_internal(per-lane, i.e.jnp.any(mask, axis=1)), alongside the existingn_value_nan_lane_steps/n_grad_nan_lane_steps/n_constrained_lane_steps. Restore it on the resumepath with
.get(key, 0), the precedent already set atsearch.py:678so asearch_internalwritten before this key existed does notKeyError. UnderClipperNonethe projection must be skipped entirely (not applied as a no-op)so the compiled step is unchanged — mirroring the
has_constraint = bool( model.constrained_model_tuples())short-circuit atsearch.py:613.Wire into
AbstractBFGS. Add the same constructor arg. Buildoptimize.Bounds(lower, upper)frombounds_from_modeland pass that toboth
optimize.minimizecalls (bfgs/search.py:185and:194) — never theraw
(lower, upper)tuple, which scipy misreads as two(min, max)pairs andsilently mis-fits two-parameter models (finding 5).
L-BFGS-B,TNCandSLSQPaccept bounds; plainBFGSsilently ignores them behind aUserWarning(finding 7), so raise a clear PyAutoFit-level error when anon-
ClipperNoneclipper meets a non-bound-supporting method rather thanletting the user receive an unconstrained fit. Under
ClipperNone, pass nobounds=at all. Verified thatL-BFGS-Baccepts±infentries, so theGaussianPriorpassthrough needs no special handling here.5b. Correct the
AbstractMultiStartGradientclass docstring, which currentlyclaims the rule steps "on the unconstrained (unit-cube) parameterization" while
_broad_startsmaps draws to physical parameters. One-line fix, but it is thesentence that would tell the next reader this class of bug cannot occur.
initializer.AbstractMLE.__init__uses theinitializer or InitializerBall(...)pattern (abstract_mle.py:15-20); mirrorit with
clipper or ClipperNone()at the same tier so both searches inherit it.Testing
test_autofit/non_linear/test_clipper.py(new, sits besidetest_initializer.py):±infpassthrough forGaussianPriorand theLogGaussianPriorlower-bound special case.priors_ordered_by_idagainst itself — that istrivially true, since both it and
log_prior_list_from_vectorderive fromprior_tuples_ordered_by_id. Build a model whose priors have distinguishablelimits and assert that the bound at index
ibelongs to the parameterinstance_from_vectorplaces at indexi. A mismatch would clip the wrongparameter with no error.
the returned mask names exactly the crossed coordinates. Use a bound near
zero or float64 — at float32, an overshoot of
2.0000001against an upperbound of
2.0is not representable and the test would pass vacuously(finding 8).
ClipperNoneis the identity and its mask is all-False.UniformPriorwith aGaussianPrior, projected, asserts the Gaussian coordinate is untouched andfinite — not
NaN. This is the test that catches the harmful form of themargin, and it must fail against the naive implementation.
LogGaussianPriorcoordinate driven to0.0or negative is projected to a strictly positive value with finitelog_prior.project: an(n_starts, n_params)array clips per-lane against(n_params,)bounds, and the result is identical underjitandvmap.test_autofit/non_linear/search/mle/test_multi_start_gradient.py:clipperround-trips through the constructor, and the default is
ClipperNone.optimize.Bounds, and a two-parameter modelis fitted to a known answer with a clipper active — the dimensionality at which
the tuple form fails silently rather than raising, so this is the only shape of
test that catches finding 5. Plain
BFGSwith a real clipper raises.ClipperNoneon bothsearches — the gate for this PR.
ClipperPriorBoxon a model with a tightUniformPrior, thevalue-NaN rate falls substantially. The reference numbers are CPU/float32 and
single-seed, so assert a direction and a large margin, not an exact figure.
Key Files
autofit/non_linear/clipper.py— new; the whole contract.autofit/non_linear/search/mle/multi_start_gradient/search.py—_fitstep loop(
:817), constructor,search_internalcounters.autofit/non_linear/search/mle/bfgs/search.py— bothoptimize.minimizecalls(
:185,:194), constructor.autofit/non_linear/search/mle/abstract_mle.py— default resolution.autofit/non_linear/initializer.py— the shape being mirrored.autofit/mapper/prior/abstract.py:230— the__getattr__delegation the boundsread depends on.
autofit/__init__.py— exportClipperNone/ClipperPriorBoxalongside theInitializer*exports.Deliberately out of scope
-infregion diverges rather than freezing — adifferent mechanism needing its own investigation.
a logit reparameterisation sends the optimum to infinity when it genuinely sits
on a boundary (which this cell demonstrably has), the inverse-CDF transform has
∂θ/∂u → ∞at the cube faces, and it invalidates every stored benchmark.resurrectdefaults. Not the fix — a redrawn lane walks out again.ClipperPriorBox. PR 2, with the benchmarkre-baseline and the momentum-reset-on-clip decision.
Follow-ups this PR should file, not fix
Both surfaced only because clipping let lanes survive, i.e. on a code path this
cell had apparently never taken:
float32is not JSON serializable in result output —autofit/non_linear/paths/directory.py:80save_jsonraisesTypeErrorat theend of a successful clipped run.
from (1) makes the next search with the same
namefail withJSONDecodeErrorwhile resuming — a 4-second no-op run that looks like a clean result. A new
form of the cached-result hazard in
complete/2026/08/multistart-nan-step-diagnostics.md.Plus, from the source read: declaring
LogGaussianPrior's(0, ∞)support on theprior itself, so
prior.lower_limitis correct for every consumer and theClipperPriorBoxspecial case can be retired.Appendix — measured output backing the [verified] findings
Environment: Python 3.12 venv, PyAutoFit
004f798installed editable,jax 0.11.0/optax 0.2.8, CPU,scipyfrom the same install.Bounds accessor across every prior type (finding 1, and the gap in finding 2):
The
NaNhazard, end to end on a(UniformPrior, GaussianPrior)model (finding 4):versus the finite-guarded form, which is NaN-free and rescues the lane:
scipy's reading of the tuple form, by dimensionality (finding 5):
Plain
BFGSwith bounds (finding 7) — optimum is at 5.0, box is[0,1]:JAX-path boundary semantics (finding 8):
Momentum pinning in isolation — Adam, box
[0,2], likelihood optimum at 5.0(finding 10): clipped on 7 of 8 consecutive steps, pinned at
2.00000throughout.Limits of this verification. These probes exercise the prior/bounds/scipy/JAX
machinery directly; they do not run the real
imaging/mgecell, and nothinghere was run on a GPU or in float64. The quantitative claims from
autolens_profiling#128 (60.25% → 17.71%, 14/16 → 5/16) are inherited from that
investigation, not re-measured here — which is why the regression test asserts a
direction and a large margin rather than a figure.
Original Prompt
Click to expand starting prompt
Search-agnostic prior-support enforcement: a Clipper class
Type: feature
Target: PyAutoFit
Repos:
Difficulty: medium
Autonomy: supervised
Priority: high
Status: formalised
Why
@PyAutoFit/autofit/non_linear/search/mle/multi_start_gradient/search.pybuildsits objective as
A
UniformPriorreturnslog_prior = -infoutside its box, and the search stepsin physical parameter space with nothing constraining it to that box. A lane
that oversteps a hard prior edge reads as non-finite, is marked dead, and with
resurrect=Falseis never redrawn.Measured on the real
imaging/mgeprofiling cell (16 starts x 150 steps, cloudCPU) — full investigation and evidence in autolens_profiling#128:
The likelihood never went non-finite in ~7200 lane-steps. This is entirely a
prior-support problem.
The behaviour is worse than "frozen": the overshoot is tiny (median 3% of box
width, min 0.21%), and because
log_prior = -infis constant outside the boxits derivative is zero, so the total gradient is the finite likelihood
gradient.
optax.apply_if_finitetherefore never fires and the dead lane keepsstepping forever — full likelihood-and-gradient cost every step, output
discarded, wandering far (one parameter went
0.30 -> -1.76). 0/16 lanes everrevive.
The exposure is not MultiStart-only
This is why the fix should not live inside one search:
MultiStartGradient(MultiStartAdam/MultiStartADABelief/MultiStartLion/MultiStartProdigyall share one_fit) — measured above.@PyAutoFit/autofit/non_linear/search/mle/bfgs/search.py— sameFitness(fom_is_log_likelihood=False, resample_figure_of_merit=-np.inf, convert_to_chi_squared=True), steps in physical space, and callsoptimize.minimize(fun=..., x0=..., method=self.method, options=..., tol=...)with no
bounds=argument.L-BFGS-Bsupports box bounds natively; theyare simply not passed. Being single-start, this presents as a failed fit rather
than a population collapse, so it is easier to misattribute.
@PyAutoFit/autofit/non_linear/search/mcmc/blackjax/nuts/search.py)also targets the log posterior from a physical
initial_position. HMC enteringa
-infregion diverges rather than freezing. Out of scope here — differentmechanism, needs its own investigation. See "Deliberately out of scope".
Not exposed, and correctly so: the nested samplers already work in unit-cube
coordinates, and the MCMC samplers reject
-infproposals so the walker staysput. Rejection is the restoring mechanism that gradient methods lack.
The design
A
Clipper, modelled on@PyAutoFit/autofit/non_linear/initializer.py— apluggable, per-search strategy object with a config-resolved default.
One place the
Initializeranalogy does not carry.Initializerhas a singleconsumption pattern (
samples_from_model).Clipperhas two structurallydifferent consumers and must serve both from one source of truth:
MultiStartGradientenforces the constraint itself, every step → wants animperative
project(...).LBFGShands bounds to scipy and lets scipy enforce → wants a declarativebounds.Proposed contract:
projectmust return which coordinates it clipped, not just the new vector.That mask is what lets a caller zero the optimiser momentum along clipped
directions. It is needed: the prototype left 5 of 16 lanes pinned to a bound at
the end of the run because the parameters were projected while Prodigy's
accumulated state kept pushing outward. The
Clippercannot fix that itself — itdoes not own
opt_state— so it must expose enough for the search to.Later strategies (
ClipperReflect, a soft-wall variant) drop in without touchingcallers. A soft wall must be a Clipper (search-local), never a change to the
Priorclasses — that would silently alter the objective for the nested samplers,where the hard box currently works correctly.
Scope — PR 1 (this task)
AbstractClipper+ClipperNone+ClipperPriorBoxin a new@PyAutoFit/autofit/non_linear/clipper.py.reference model:
UniformPrior(finite both sides),TruncatedGaussianPrior(finite both sides, e.g.
(-1, 1)forell_comps),GaussianPrior(
±inf— must pass through untouched). Audit the rest (LogUniformPrior,LogGaussianPrior, anyConstant/deterministic entries).AbstractMultiStartGradient._fit, applied afteroptax.apply_updates, opt-in.LBFGS, passingbounds=through tooptimize.minimize,opt-in. Only valid for bound-supporting methods (
L-BFGS-B,TNC,SLSQP) — guard or warn for plainBFGS.clipper: AbstractClipper = Noneconstructor arg on the searches, resolvedlike
initializer.Default is
ClipperNone, and PR 1 must be bit-identical with it. Follow theprecedent set by PyAutoFit#1475, whose models declaring no constraint
short-circuit to bit-identical behaviour. Flipping the default is a real
behaviour change that shifts every stored multi-start benchmark, which is exactly
the comparability argument PyAutoFit#1472 made when it deferred its own policy
change.
Scope — PR 2 (separate prompt, file after PR 1 lands)
Flip
MultiStartGradient's default toClipperPriorBox, with the benchmarkre-baseline, plus the momentum-reset-on-clip decision informed by how bad the
pinning actually is at production budget.
Traps, measured
model.priors_ordered_by_idwas used for the prototype and lined up correctlywith
model.instance_from_vector, but a mismatch would clip the wrongparameter with no error. Assert the correspondence in a test rather than
trusting it.
log_priorat exactly thelimit is finite. The prototype inset by
1e-6of the box width to stay strictlyinside; that margin is a guess and should be a justified constant.
prefers a value outside the prior, a clipped lane sitting on the bound is the
correct MAP answer under the declared prior. It is worth surfacing (it says the
prior is fighting the data) rather than hiding. In the reference cell the shear
escapes were mixed-sign (
+0.353,-0.341,+0.301,-0.312), which readsmore like a poorly-constrained parameter diffusing out than a true value sitting
outside.
those are the NaN-gradient population (likelihood NaN in the jitted path,
which the
Fitnessguard maps to-infand whosewheremakes the gradientNaN). Separate mechanism, do not expect this task to remove it.
Two incidental bugs found while investigating — do not lose these
Both surfaced only because clipping let lanes survive, i.e. on a code path this
cell had apparently never taken:
float32is not JSON serializable in result output.@PyAutoFit/autofit/non_linear/paths/directory.py:80save_jsonraisesTypeError: Object of type float32 is not JSON serializableat the end of asuccessful clipped run. Did not fire on the baseline runs, where 14/16 lanes
were dead. File separately if confirmed.
output left by (1) caused the next search with the same
nameto fail withJSONDecodeErrorwhile trying to resume — a 4-second no-op run that lookedlike a clean result (zero deaths, because zero steps). This is a new form of
the cached-result hazard already recorded in
complete/2026/08/multistart-nan-step-diagnostics.md.Deliberately out of scope
than projection. Its own task.
machinery is already unit-cube and the nested samplers work that way, and it
would also normalise parameter scales (
einstein_radius ∈ [0,8]alongsideell_comps ∈ [-1,1]). Rejected for now on three grounds: a logitreparameterisation sends the optimum to infinity when it genuinely sits on a
boundary, which this cell demonstrably has; the inverse-CDF transform for
non-uniform priors has
∂θ/∂u -> ∞at the cube faces, trading one numericalhazard for another; and it invalidates every stored benchmark. If pursued, note
that reparameterising the search path does not move the optimum provided the
objective is still the physical-space posterior evaluated at
θ(u)— optimisethe density of u instead and the Jacobian makes the MAP non-invariant, which
fails silently.
resurrectdefaults. Not the fix: a redrawn lane walks out again.Testing
±infpassthrough forGaussianPrior.ClipperNoneis bit-identical: same seed, same final parameters, on bothMultiStartGradientandLBFGS.returned mask names exactly the crossed coordinates.
LBFGSpasses bounds through and rejects/warns for non-bound-supporting methods.ClipperPriorBoxon a model with a tightUniformPrior, thevalue-NaN rate falls substantially. The reference numbers above are CPU/float32,
single seed — assert a direction and a large margin, not an exact figure.