Skip to content

feat: per-parameter step scaling for the multi-start gradient searches - #1485

Merged
Jammy2211 merged 2 commits into
mainfrom
feature/per-parameter-step-scaling
Aug 17, 2026
Merged

feat: per-parameter step scaling for the multi-start gradient searches#1485
Jammy2211 merged 2 commits into
mainfrom
feature/per-parameter-step-scaling

Conversation

@Jammy2211

Copy link
Copy Markdown
Collaborator

Closes #1483. Stacked on feature/clipper-validation-campaign (#1482) — merge that first.

Adds ScalerPriorWidth: a per-parameter step scale derived from each prior,
applied as a linear change of variables phi = theta / s with the objective still
evaluated at the physical theta = s * phi. Default off and bit-identical when
off
, exactly as seed, alive_history and reset_momentum_on_clip shipped.

Read the measurement before the code: it is a NEGATIVE result

This was built to fix the prior-exit failure mode. It does not. Three of the
four pre-registered falsification conditions fired (autolens_profiling#133,
results/notes/clipper_campaign/RESULTS.md):

arm seed max_log_L gap to bar value-NaN clip rate
prior_box 0 31787.929 -1.146 2 31.55%
prior_box_scaled 0 31785.186 +1.597 3215 31.45%
prior_box 1 -120880.568 152667.350 0 32.80%
prior_box_scaled 1 -145975.777 177762.560 5884 38.51%
  • Primary readout failed. Clip rate 31.55% -> 31.45% on seed 0 — 15142 clips
    against 15097, a 0.3% change across 48,000 lane-steps. Seed 1 went the wrong way.
  • Seed 0 regressed past the bar, the only arm in the campaign that fails to
    exceed the Nautilus reference.
  • Seed 1 moved 25,095 nats away from plain clipping.
  • It undoes clipping's one clear win: value-NaN lane-steps 2 -> 3215 and
    0 -> 5884, with lanes inside their boxes — the likelihood going non-finite.

It ships default-off so that row stays reproducible, which is the same reason
reset_momentum_on_clip shipped after measuring badly. The artefact worth keeping
is the measurement, not the feature. Nothing in this PR changes any existing fit.

Why it fails, and what the real failure turned out to be

The scale is normalised to a geometric mean of 1 so only the ratios change — but
the aggregate propensity to reach a wall is set by the global step magnitude
relative to the box widths, which is exactly what that normalisation holds fixed.
Scaling redistributes wall contact rather than reducing it (pinned_coords
19 -> 6 on seed 0: same six pinned lanes, a third as many coordinates each).

Inspecting the winning parameters showed the actual failure is not wall contact at
all. Seed 1 converged to a degenerate "no lens" solution — einstein_radius
driven to 0.0000, the U(0, 8) lower bound, with the source displaced ~1.6" to
absorb the image. Once theta_E = 0 there is no deflection and nothing pulls the
mass back. Scaling made this worse because einstein_radius drew the largest
scale in the vector by far (23.87 against a 0.298 floor), so it took the
biggest steps straight into that attractor.

What the code does

  • autofit/non_linear/scaler.pyAbstractScaler / ScalerNone (default) /
    ScalerPriorWidth, modelled on Clipper. Rule: Uniform -> width; LogUniform ->
    sqrt(lo*hi)*log(hi/lo) at the median (the physical width is wrong by 543x for
    LogUniform(1e-4, 1e4)); Gaussian and TruncatedGaussian -> sigma, not the
    truncation width (ell_comps is sigma 0.3 in a [-1,1] box); LogGaussian ->
    exp(mu)*sigma. Degenerate priors fall back to 1.0 and log — the vector is a
    divisor, so a zero would send the whole population to infinity.
  • Change of variables, not a post-hoc multiply. Prodigy estimates d from the
    distance actually travelled, so rescaling its update externally would desync its
    own estimate from the trajectory it believes it took. Composed as
    fitness.call(phi * scale), so JAX's chain rule yields the phi-gradient and
    there is no manual gradient rescaling anywhere.
  • search_internal stays in PHYSICAL units. The scaler does not enter the
    search identifier, so scaled and unscaled arms share an output directory; a file
    whose units depended on an invisible knob would resume as a silently wrong
    population rather than as an error.
  • model.info gains the derived scale block; search.summary gains
    Scaler = .... Both emit nothing when scaling is off, so existing artefacts are
    unchanged byte for byte.
  • Not offered on LBFGS, which takes a clipper but does not own its step
    loop — an inert knob is worse than no knob. A test pins that.

Testing

test_autofit/non_linear/test_scaler.py, pure NumPy per the house rule. Pins the
MAP invariance directly (a constant diagonal Jacobian cannot move the optimum —
optimising the density of phi instead would move it silently), the scale-rule
table including the two "not the width" cases, geometric-mean normalisation, the
degenerate-prior fallback, and clipper composition.

Full suite: 1852 passed, 2 skipped.

The suite is NumPy-only by house rule, so it never executes _fit — a green suite
proves nothing about this feature. Verified separately end-to-end on the real JAX
path: same optimum to ~1e-9 with scaling on and off, Prodigy's d estimates
genuinely differ (so it is not an accidental no-op), stored per-start parameters
come back physical, and the clip count fell 2 -> 0 on a toy model.

Known limitation, stated in the module

Mixing Uniform -> width with Gaussian -> sigma means one scaled unit carries no
single probabilistic interpretation
. That is acceptable for an engineering goal
(commensurable steps) but is not prior standardisation. The unified alternatives —
prior standard deviation, robust central quantile width, local inverse-CDF
derivative — are named and deliberately not implemented: measure the simple
version first, and it did not work.

🤖 Generated with Claude Code

Jammy2211 and others added 2 commits August 17, 2026 09:15
`ScalerPriorWidth` gives each parameter its own step scale, derived from its
prior, applied as a change of variables so the rule steps in `phi = theta / s`
while the objective is still evaluated at the physical `theta = s * phi`.
Default `ScalerNone`, skipped rather than applied as a multiply by ones, so the
default path and its compiled step are both unchanged.

WHY THIS FAMILY. Every other search in the library already adapts per-parameter
scale: the nested samplers propose in the unit cube, Emcee's stretch move is
affine-invariant, Zeus self-tunes per direction, BlackJAXNUTS adapts a MASS
MATRIX via window_adaptation, and L-BFGS accumulates curvature. MultiStart
{Adam,Lion,ADABelief,Prodigy} is the sole exception, and not by oversight: the
Adam rules normalise per-coordinate by GRADIENT magnitude rather than PARAMETER
scale, so with m/sqrt(v) ~ 1 every coordinate moves ~lr in PHYSICAL units, and
Prodigy adds only a single GLOBAL `d`. On the reference imaging/mge cell prior
widths span 40x, so a step sensible for einstein_radius (width 8.0) is a
wall-crossing for bulge.centre (width 0.2) — measured at 31.5% of lane-steps
clipped (autolens_profiling#131). This is therefore the diagonal preconditioner
the family lacks, the analogue of the NUTS mass matrix, not a novel hack.

WHAT IT COSTS. NUTS LEARNS its preconditioner from the geometry during warmup;
this one is static and prior-derived — cheap, no warmup, no new hazard, but a
prior width is a PROXY for the posterior scale and is poor where the likelihood
is far tighter than the prior. Documented in the module rather than hidden.

THE RULE is the physical extent of a unit-cube-sized step: Uniform -> width;
LogUniform -> sqrt(lo*hi)*log(hi/lo) at the median, NOT the physical width,
which for LogUniform(1e-4, 1e4) is wrong by a factor of 543; Gaussian and
TruncatedGaussian -> sigma, NOT the truncation width, which for ell_comps
(sigma 0.3 in a [-1,1] box) overstates the scale six-fold; LogGaussian ->
exp(mean)*sigma. Anything degenerate falls back to 1.0 and logs — the vector is
a DIVISOR, so a zero would send the whole population to infinity. Normalised to
a geometric mean of exactly 1, so only the RATIOS change and an A/B is not
confounded with an effective learning-rate change.

APPLIED AS A CHANGE OF VARIABLES, not `params += s * updates`. Prodigy estimates
`d` from the distance actually travelled (params0, grad_sum), so rescaling its
update externally would leave its own estimate inconsistent with the trajectory
it believes it took. Composed as `fitness.call(phi * scale)`, JAX's chain rule
yields the gradient w.r.t. phi and there is no manual gradient rescaling
anywhere. Optimising the density OF phi instead would fold a Jacobian in and
move the MAP — silently, with every counter looking healthy — so
`test_scaler.py` pins the invariance directly. The map is linear, so an optimum
that genuinely sits on a boundary stays finite; that is what killed the logit
reparameterisation this replaces.

`search_internal` stays entirely in PHYSICAL units (`params` multiplied on write,
divided on resume). The scaler does not enter the search identifier, so a scaled
and an unscaled arm share an output directory; a file whose units depended on an
invisible knob would resume as a silently wrong population rather than as an
error. `samples_via_internal_from` reads that array directly and has no scaler
to consult.

COMPLEMENTARY TO THE CLIPPER, not a replacement. Scaling makes reaching a wall
rarer, never impossible, and where the likelihood genuinely prefers a value
outside the prior the clipped lane on the bound is the correct MAP under the
declared prior. `Clipper.project` takes an optional `scale` and clips against
`bounds / s` in the caller's own coordinates, rather than round-tripping the
whole batch through physical space every step.

VISIBILITY. The derived vector is appended to the `model.info` FILE — not to the
`Model.info` property, which has no access to the search and so cannot render a
search-dependent block. `search.summary` gains a `Scaler = ...` line under the
same discipline as the clipper's: `ScalerNone` emits nothing, so an existing
run's summary is unchanged byte for byte. There is no per-step count to report;
scaling's effect is read in the clipped lane-step rate.

NOT offered on LBFGS, which takes a `clipper` but does not own its step loop — a
knob that accepts a value and ignores it is worse than no knob. A test pins that.

Verified end-to-end on the real JAX path (the library suite is NumPy-only by
house rule, so it does not execute `_fit`): MultiStartProdigy on a 40x-spread
model reaches the same optimum to ~1e-9 with scaling on and off, Prodigy's own
`d` estimates differ (so the scaler is not an accidental no-op), the stored
per-start parameters come back physical, and the clip count falls 2 -> 0.

Note when reading logs: under a scaler the reported `d` is in SCALED units,
because that is the space the rule steps in. Do not compare it across a scaled
and an unscaled arm; compare the clip rate.

Issue #1483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…per-default policy

Two clarifications from review, both about honesty rather than behaviour.

SCALE DEFINITION. Mixing Uniform -> width with Gaussian -> sigma means one
scaled unit does NOT carry a single consistent probabilistic interpretation
across coordinates. That is fine for the ENGINEERING goal here (stop a step
sized for a wide coordinate crossing a narrow one's wall, for which
order-of-magnitude commensurability is enough) but it is not a claim to
prior-standardised coordinates, and the module now says so. The unified
definitions to reach for if that is ever wanted are named: the prior's actual
standard deviation, a robust central quantile width, or the local inverse-CDF
derivative dtheta/du at a reference point — the last being the continuous
generalisation of what the uniform and log-uniform rules already do. None is
implemented, deliberately: if the simple width/sigma version does not move the
clip rate, a more principled one will not either, and the diagnosis is wrong.

CLIPPER DEFAULT. Reframed from hygiene to constrained-optimizer semantics. A
search advertising a posterior with hard prior support is solving a CONSTRAINED
problem, so a state outside that support is infeasible, not merely poor.
Projection does not alter the constrained optimum, makes the invariant explicit,
prevents silent invalid trajectories, stops prior exits masking later
pathologies, and is what makes the diagnostics interpretable. Hard-support
enforcement is therefore the INTENDED default for the gradient/MLE searches; the
reason it is not yet is empirical breadth, not mathematics — everything measured
is MultiStartProdigy on one lens cell, and Adam/Lion/ADABelief are unmeasured
while being MORE exposed (Adam steps ~lr in physical units in every coordinate,
Lion exactly it). When it flips it must not be sold as a long-budget accuracy
gain: at 16x3000 the answer does not move at all.

The scaler and the clipper stay separate features regardless. Even at a clip rate
driven from 31% to 0.1%, projection is retained as the last-line invariant.

Issue #1483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Jammy2211
Jammy2211 changed the base branch from feature/clipper-validation-campaign to main August 17, 2026 17:17
@Jammy2211
Jammy2211 merged commit d6fef74 into main Aug 17, 2026
3 checks passed
@Jammy2211
Jammy2211 deleted the feature/per-parameter-step-scaling branch August 17, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: per-parameter step scaling for the multi-start gradient searches

1 participant