Skip to content

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

Description

@Jammy2211

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

  • PyAutoFit (primary)

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.

  1. [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.

  2. [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.

  3. 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.

  4. [verified] The margin inset as specified turns every unbounded prior into
    NaN.
    The obvious implementation, lower + margin * (upper - lower),
    evaluates -inf + (inf - -inf) * mNaN 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.

  5. [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.

  6. [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.

  7. [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.

  8. [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.

  9. [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).

  10. [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

  1. 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.
  2. 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 NormalMessageAbstractMessage default ±inf, passes through untouched
    LogGaussianPrior TransformedMessage default — wrong special-cased to lower 0
  3. 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.

  4. 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.

  5. 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.

  1. 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:

  1. 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.
  2. 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)

  1. AbstractClipper + ClipperNone + ClipperPriorBox in a new
    @PyAutoFit/autofit/non_linear/clipper.py.
  2. 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).
  3. Wire into AbstractMultiStartGradient._fit, applied after
    optax.apply_updates, opt-in.
  4. 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.
  5. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions