Overview
The cause-side follow-up to the prior-support clipper. Phase 2
(autolens_profiling#131, PR #132; library PR PyAutoFit#1482) established that
ClipperPriorBox does its job perfectly and does not improve the answer, and
diagnosed why lanes reach the prior walls at all: on imaging/mge hst the prior
box widths span 40x (einstein_radius U(0,8) → 8.0; bulge.centre
U(-0.1,0.1) → 0.2) while MultiStartGradient steps in physical space with a
single global step scale. Lanes do not start at the edges — _broad_starts draws
in the unit cube at [0.15, 0.85] — they walk there, and clipping currently runs
at 31.5% of lane-steps.
This task gives each parameter its own prior-derived step scale, so a unit-sized
optimizer step means the same fraction of the box in every coordinate. It ends
with the side-by-side comparison against the six recorded baseline rows — that
comparison is the deliverable, not the code.
Plan
- Add a
Scaler strategy to PyAutoFit, modelled one-for-one on the existing
Clipper: AbstractScaler / ScalerNone (the default) / ScalerPriorWidth.
- Derive a static per-parameter scale vector from the priors once at fit start —
Uniform → width, Gaussian/TruncatedGaussian → sigma (not the truncation
width), LogUniform → the median-anchored sqrt(lo*hi)*ln(hi/lo) — then
normalise so the geometric mean is exactly 1, so only the ratios change
and the global step magnitude is untouched.
- Apply it as a linear change of variables
phi = theta / s, evaluating the
objective at theta = s * phi. Prodigy then needs no changes and its own d
estimate stays self-consistent; a post-hoc params += s * updates would not.
- Surface the derived scale to users as a block appended to the written
model.info file, guarded so nothing is written when scaling is off.
- Default off and bit-identical when off, with a test pinning that a constant
diagonal Jacobian cannot move the MAP.
- Keep
ClipperPriorBox on in every arm — the two features are
complementary, and the clip rate becomes the primary diagnostic.
- Re-run
multi_start_prodigy / imaging/mge / hst at 16x3000, seeds 0 and 1,
fp64, and put the numbers beside the six baseline rows in RESULTS.md.
Detailed implementation plan
Work Classification
Both — library (PyAutoFit) first, then the measurement workspace
(autolens_profiling) once the API is known.
Affected Repositories
- PyAutoFit (primary)
- autolens_profiling
Branch Survey
| Repository |
Current Branch |
Dirty? |
| ./PyAutoFit |
main |
clean |
| ./autolens_profiling |
main |
untracked results/dataset dirs only |
| ~/Code/PyAutoLabs-wt/clipper-validation-campaign/PyAutoFit |
feature/clipper-validation-campaign |
clean |
| ~/Code/PyAutoLabs-wt/clipper-validation-campaign/autolens_profiling |
feature/clipper-validation-campaign |
clean |
Suggested branch: feature/per-parameter-step-scaling
Base: cut from feature/clipper-validation-campaign in both repos, not
from main. This work needs seed, alive_history and reset_momentum_on_clip,
which are unreleased and live only on that branch (PyAutoFit#1482 /
autolens_profiling#132, both open and unmerged). The PRs target that branch and
retarget to main once it merges.
Worktree root: ~/Code/PyAutoLabs-wt/per-parameter-step-scaling/
Implementation Steps
-
autofit/non_linear/scaler.py (new) — AbstractScaler with
scale_from_model(model) -> np.ndarray in model.priors_ordered_by_id order
(the same physical order the clipper uses) and info_from_model(model) -> str;
ScalerNone returning ones; ScalerPriorWidth implementing the rule table
below. Non-finite or zero scales fall back to 1.0 and log once — a zero
scale would divide the whole population to inf.
| prior |
scale |
why |
UniformPrior(lo, hi) |
hi - lo |
dtheta/du is constant and equals the width |
LogUniformPrior(lo, hi) |
sqrt(lo*hi) * ln(hi/lo) |
dtheta/du = theta*ln(hi/lo) at the median. The raw physical width is wrong by orders of magnitude — for LogUniform(1e-4, 1e4) it is 1e4 against a true local scale of 18.4 |
GaussianPrior(mu, sigma) |
sigma |
unbounded, no width exists |
TruncatedGaussianPrior |
sigma |
not the truncation width: ell_comps is sigma=0.3 inside a [-1,1] box, so the width overstates it 6x |
LogGaussianPrior |
sigma of the underlying normal, via the median |
half-open support; Gaussian in log space |
| anything else |
1.0, logged |
never silently 0 / inf / NaN |
Then s /= exp(mean(log(s))) so the geometric mean is exactly 1.
-
autofit/non_linear/search/mle/multi_start_gradient/search.py — add
scaler: Optional[AbstractScaler] = None (defaulting to ScalerNone()),
carried through to_dict/from_dict. In _fit, beside the existing
has_clipper short-circuit, add has_scaler = not isinstance(self.scaler, ScalerNone) and compute scale once. Then:
_value_and_grad = jax.value_and_grad(lambda phi: fitness.call(phi * scale))
— JAX's chain rule produces gradients w.r.t. phi for free, so there is no
manual gradient scaling anywhere;
- the declared-constraint term inside
_value_and_grad_finite evaluates at
phi * scale, staying a function of the physical vector;
_broad_starts / _reinit_dead_starts keep drawing physical vectors and
are divided by scale at their call sites — the draw logic, the
finite-gradient filter and the RNG streams are untouched;
best_params is recorded physical (params[best_index] * scale) so
samples_via_internal_from is unchanged;
samples_info gains "scaler": type(self.scaler).__name__ beside the
existing "clipper" key.
-
search_internal stays entirely in physical units — params is written as
params * scale and divided by scale on the resume path, so a file written
by an unscaled run resumes correctly under a scaled one and vice versa, and its
meaning never depends on a search knob. The scale vector is also recorded
under "scale" for diagnostics.
-
autofit/non_linear/clipper.py — project gains an optional scale=None
which divides the bounds once (lower/scale, upper/scale) before clipping,
so clipping composes with the change of variables. Scales are strictly positive
so bound ordering is preserved; ClipperNone ignores it.
-
autofit/non_linear/paths/directory.py::_save_model_info — append the
scaler's info_from_model block to the written model.info, guarded by the
same try/except (NotImplementedError, AttributeError) that
_save_model_start_point already uses. The printed Model.info property
is untouched — it has no access to the search, which is precisely why this
lands in the file. Format mirrors the initializer's model.start block:
Per-Parameter Step Scaling (ScalerPriorWidth)
galaxies.lens.mass.einstein_radius UniformPrior width 8.000e+00 scale 8.062e+00
galaxies.lens.shear.gamma_1 UniformPrior width 6.000e-01 scale 6.047e-01
galaxies.lens.bulge.centre_0 UniformPrior width 2.000e-01 scale 2.016e-01
galaxies.source.bulge.ell_comps_0 TruncatedGaussianPrior sigma 3.000e-01 scale 3.023e-01
geometric mean of scale = 1.000
-
Tests — pure NumPy. test_multi_start_gradient.py states the house rule
explicitly ("keeping JAX out of the library unit suite"), so the end-to-end JAX
fit is validated downstream and the library pins the algebra:
- MAP invariance (the test the prompt demands): minimise a toy quadratic
with a deliberately 40x-spread prior set with and without scaling, asserting
the recovered argmin agrees to ~1e-12 in physical coordinates; plus the
algebraic pin that f(s*phi) == f(theta) exactly at phi = theta/s, so a
constant diagonal Jacobian adds a constant to the log-density and cannot move
the MAP. Optimising the density of phi instead is the silent failure
phase 1 warned about;
- the scale-rule table, one assertion per row, including TruncatedGaussian →
sigma (not truncation width) and LogUniform → median-anchored (not physical
width);
- geometric mean exactly 1, ratios preserved;
- degenerate priors fall back to 1.0;
ScalerNone returns ones and the dict round-trip preserves the knob;
project(..., scale=s) clips at bounds/s with a matching clipped_mask.
-
autolens_profiling — SEARCHES_SCALER=none|prior_width in
scripts/misc/searches/_samplers.py, recorded as a string in the sampler
config and always recorded (including the default) so no result file is
ambiguous about its arm; the prior_box_scaled arm in
scripts/misc/searches/clipper_campaign.py.
The measurement — this is the deliverable
Matched configuration, not varied: multi_start_prodigy, imaging/mge, hst,
16 starts x 3000 steps, seeds 0 and 1, JAX float64,
check_for_convergence=False, via scripts/misc/searches/clipper_campaign.py.
Two new arms — prior_box_scaled at seeds 0 and 1, clipper on in both.
The six baseline rows are already recorded in
results/notes/clipper_campaign/multi_start_prodigy_imaging_mge_hst.json;
re-running them would only re-prove reproducibility at ~2 hours of GPU.
Baseline to beat (truth bar: Nautilus 31786.782462488976; a negative gap means
the MAP optimizer exceeded it, which is expected):
| arm |
seed |
max_log_likelihood |
gap to bar |
value-NaN |
clips |
alive fraction |
pinned |
wall (s) |
none |
0 |
31787.929 |
-1.146 |
31655 |
0 |
0.341 |
— |
1126 |
none |
1 |
-139485.799 |
171272.581 |
27870 |
0 |
0.419 |
— |
2203 |
prior_box |
0 |
31787.929 |
-1.146 |
2 |
15142 |
1.000 |
6/16 |
1255 |
prior_box |
1 |
-120880.568 |
152667.350 |
0 |
15744 |
1.000 |
4/16 |
1251 |
prior_box_reset |
0 |
31787.929 |
-1.146 |
2523 |
18611 |
0.947 |
7/16 |
1744 |
prior_box_reset |
1 |
-137783.609 |
169570.391 |
0 |
8798 |
1.000 |
4/16 |
1123 |
A100s are unavailable — checked 2026-08-17: all 8 across euclid-ral-gpu-1/2
are held by another user's array 334968 (%8 throttle, longest job 2d22h left),
with 335049 queued behind it. Running on the laptop RTX 2060, ~20-35 min per
arm at 16x3000 fp64.
Environment: ~/venv/PyAutoGPU, JAX_PLATFORM_NAME=cuda JAX_PLATFORMS=cuda,cpu XLA_PYTHON_CLIENT_MEM_FRACTION=0.5 JAX_ENABLE_X64=True SEARCHES_DISABLE_VIZ=1.
Verify autofit.__file__ resolves to the worktree checkout before running.
Pre-registered readouts
- Clip rate collapses — the primary readout. Today 31.5% of lane-steps
(15142 / 16x3000). It is the direct measure of "steps are now commensurate with
prior widths".
- Seed 1 is the real test — 171,272 nats from the bar, and neither clipping
nor momentum reset rescued it.
- Seed 0 must not regress past the bar-beating
31787.929.
- Pinned lanes fall from 6/16 — or, if they do not, that is evidence those
lanes are pinned because the likelihood genuinely prefers the boundary: a
science finding about the shear prior, not a bug.
- Wall time does not rise materially — a diagonal multiply should be free.
Falsification, written down before running
- Clip rate falls but the answer moves on neither seed → scaling is as cosmetic as
clipping was, and the prior-exit line of work is closed, not advanced.
- Seed 0 regresses → the preconditioner is fighting Prodigy's own
d estimate.
- Clip rate does not fall → the diagnosis is wrong and wall contact is not a
step-scale effect. Say so; do not re-scope to save the idea.
- Results become seed-more-dependent → preconditioning has narrowed the basin of
attraction.
Traps carried forward (already paid for)
- Give every arm a unique search
name, clear its directory, and assert
total_steps == n_steps — the scaler does not enter the search identifier, so
arms differing only in a search knob collide on one output directory and a
short-circuited run reports the previous run's counters, not zeros.
search.summary is Key = Value, not colon-separated. A colon parser
returns an empty dict and every counter reads None — indistinguishable from a
feature that never fired.
- Grade the alive-versus-step curve (
alive_history), not the percentage —
the lane counters are survival integrals.
- Never state a result without its step budget. At 105 steps clipping was
worth 114 nats; by 3000 it was worth zero.
--out must actually reach the search (the driver routes it through conf).
- A
ClipperPriorBox arm reporting zero clips has not exercised the clipper —
a broken arm, not a null result.
Key Files
autofit/non_linear/scaler.py — new; the strategy, the rule table, the info block
autofit/non_linear/search/mle/multi_start_gradient/search.py — the knob and the change of variables in _fit
autofit/non_linear/clipper.py — optional scale on project
autofit/non_linear/paths/directory.py — _save_model_info appends the block
test_autofit/non_linear/test_scaler.py — new; MAP invariance + the rule table
scripts/misc/searches/clipper_campaign.py / _samplers.py — the arm
results/notes/clipper_campaign/RESULTS.md — the side-by-side verdict
Out of scope
Flipping the clipper default (phase 3, which this evidence should finally settle);
removing or weakening the clipper; unit-cube / logit reparameterisation; NUTS; the
seed-dependence investigation (filed separately).
Original Prompt
Click to expand starting prompt
Per-parameter step scaling for the gradient searches — treat the cause of prior exits
Type: feature
Target: autofit
Repos:
- PyAutoFit
- autolens_profiling
Difficulty: medium
Autonomy: supervised
Priority: high
Status: formalised
What this is
The cause-side follow-up to the prior-support clipper. Phase 2 of that work
(autolens_profiling#131, record results/notes/clipper_campaign/RESULTS.md)
established that clipping does its job perfectly and does not improve the answer,
and diagnosed why lanes reach the prior walls in the first place.
The diagnosis. On the imaging/mge hst model, prior box widths span 8.0
down to 0.2 — a 40x range — while MultiStartGradient steps in physical
parameter space with a single global step scale:
| parameter |
prior |
box width |
start range (unit [0.15, 0.85]) |
nearest wall |
mass.einstein_radius |
U(0, 8) |
8.00 |
[+1.20, +6.80] |
1.20 |
shear.gamma_1/2 |
U(-0.3, 0.3) |
0.60 |
[-0.21, +0.21] |
0.09 |
bulge.centre_0/1 |
U(-0.1, 0.1) |
0.20 |
[-0.07, +0.07] |
0.03 |
Prodigy's step scale d grows from 1.00e-06 at step 1 to
2.26e-02 / 5.49e-02 / 8.29e-01 by step 3000. A step that is sensible for
einstein_radius is a wall-crossing for bulge.centre.
Lanes are not initialised at the edges — _broad_starts draws in the unit
cube at [0.15, 0.85], the middle 70% of every prior. They walk to the walls.
The prediction that escapes concentrate in the narrow-box parameters is confirmed
by autolens_profiling#128's autopsy: 10 of 11 exits were the shear, landing at
±0.30-0.35 just outside its ±0.3 box.
Do per-parameter scaling, NOT unit-cube reparameterisation
Phase 1 rejected unit-cube stepping on three grounds
(complete/2026/08/prior-support-clipper.md, "Deliberately out of scope"), and
those objections still stand:
- A logit reparameterisation sends the optimum to infinity when it genuinely
sits on a boundary — and this cell demonstrably has such optima (6/16 lanes end
pinned).
- The inverse-CDF transform for non-uniform priors has
∂θ/∂u -> ∞ at the cube
faces, trading one numerical hazard for another.
- It invalidates every stored benchmark.
Diagonal preconditioning — a per-parameter step scale derived from each prior's
width — buys the same normalisation and dodges all three: no logit, so boundary
optima stay finite; no inverse-CDF, so no face singularity; and the objective
stays the physical-space posterior, so the Jacobian trap below cannot arise.
Carry this warning forward regardless. 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 — and it fails silently. Any implementation must
have a test that pins this.
Proposed design
1. A static per-parameter scale vector s, derived from the priors
Computed once at fit start, reusing the clipper's bounds machinery
(AbstractClipper.bounds_from_model returns (lower, upper) in physical order,
±inf when unbounded). Share the extraction helper; do not couple the two
features (see "Both features stay").
| prior |
scale |
note |
UniformPrior(lo, hi) |
hi - lo |
— |
GaussianPrior(mu, sigma) |
sigma |
unbounded, no width exists |
TruncatedGaussianPrior(mu, sigma, lo, hi) |
sigma |
NOT the truncation width: ell_comps is sigma=0.3 inside a [-1,1] box, so the width overstates it 6x |
LogUniformPrior |
needs a decision |
log-spaced, so the natural step is multiplicative; do not silently use the physical width |
Then normalise so the geometric mean of s is 1. This keeps the global step
magnitude unchanged and alters only the ratios, so the A/B measures rescaling
alone rather than being confounded with an effective learning-rate change.
2. Apply as a constant diagonal change of variables, not a post-hoc multiply
The tempting implementation is params += s * updates after the optimizer.
Reject it: Prodigy estimates d from the distance actually travelled
(params0, grad_sum), so rescaling its update externally leaves its own estimate
inconsistent with the trajectory it thinks it took.
Instead run the optimizer in scaled coordinates phi = theta / s, evaluating the
objective at theta = s * phi:
- Prodigy needs no changes — it sees
phi, steps in phi, and its state stays
self-consistent. Same for every other optax rule.
- The objective remains the physical-space posterior at
theta(phi), satisfying
phase 1's silent-failure warning: the Jacobian is a constant diagonal, so it adds
a constant to the log-density and cannot move the MAP. Write the test that pins
this.
- The map is linear, so boundary optima stay finite — the exact failure that
killed the logit reparameterisation.
- Clipping still composes: scale the bounds once (
phi_bounds = theta_bounds / s)
and clip in phi.
3. Default off, opt-in, bit-identical when off
The same discipline seed, alive_history and reset_momentum_on_clip shipped
under.
4. Why this repairs Prodigy's premise rather than merely helping it
Prodigy estimates a single global step scale d, which implicitly assumes every
coordinate has comparable scale. A 40x spread violates that assumption at the root.
5. The fixed-rate optimizers are MORE exposed, not less
Adam's update is lr * m_hat / (sqrt(v_hat) + eps), and since m_hat/sqrt(v_hat) ~ 1
for a consistent gradient, Adam steps by ~lr in PHYSICAL units in every
coordinate. With lr=0.01:
bulge.centre (box 0.2, wall 0.03 from the start band) -> 5% of the box per
step, crossing in ~3 steps;
einstein_radius (box 8.0) -> 0.125% of the box per step.
Adam's per-coordinate normalisation therefore guarantees the pathology rather
than mitigating it, and Lion is worse still (sign-based, so literally ±lr). Expect
the effect to be larger for MultiStartAdam / Lion / ADABelief than for the
Prodigy baseline measured here — see PyAutoFit#1481.
Both features stay — they are not substitutes
Do not treat this as a replacement for the clipper. They solve different halves,
and the case that breaks a reparameterisation is exactly the case clipping is for:
- Scaling treats the cause: it reduces how often a lane reaches a wall.
- Clipping guarantees the invariant: scaling makes overshoot rarer, never
impossible (a large gradient, bad curvature, or a grown d can still cross), and
without clipping that failure is silent — the lane goes -inf, is discarded,
and nothing is raised.
- Where the likelihood genuinely prefers a value outside the prior, the clipped
lane sitting on the bound is the correct MAP answer under the declared prior.
This cell has 6/16 such lanes. Only clipping can express that.
Keep ClipperPriorBox ON in every arm of this work, so the clip rate is
measurable as a diagnostic rather than being confounded with the scaling change.
The comparison this task must end with
This is the deliverable. Re-run the exact arms phase 2 ran and put the numbers
side by side, so "steps are now linked to priors sensibly" is demonstrated rather
than asserted.
Matched configuration (do not vary it — the point is comparability):
multi_start_prodigy, imaging/mge, hst, 16 starts x 3000 steps,
seeds 0 and 1, JAX float64, check_for_convergence=False, via
scripts/misc/searches/clipper_campaign.py.
Baseline to beat, from results/notes/clipper_campaign/multi_start_prodigy_imaging_mge_hst.json:
| arm |
seed |
max_log_likelihood |
gap to bar |
value-NaN |
clips |
alive fraction |
pinned |
wall (s) |
none |
0 |
31787.929 |
-1.146 |
31655 |
0 |
0.341 |
— |
1126 |
none |
1 |
-139485.799 |
171272.581 |
27870 |
0 |
0.419 |
— |
2203 |
prior_box |
0 |
31787.929 |
-1.146 |
2 |
15142 |
1.000 |
6/16 |
1255 |
prior_box |
1 |
-120880.568 |
152667.350 |
0 |
15744 |
1.000 |
4/16 |
1251 |
prior_box_reset |
0 |
31787.929 |
-1.146 |
2523 |
18611 |
0.947 |
7/16 |
1744 |
prior_box_reset |
1 |
-137783.609 |
169570.391 |
0 |
8798 |
1.000 |
4/16 |
1123 |
Truth bar: Nautilus max_log_likelihood = 31786.782462488976
(results/searches/nautilus/imaging/mge/hst/hpc_a100_fp64.json). Negative gap means
the MAP optimizer exceeded it, which is expected.
What would count as success
- Clip rate collapses. Today clipping runs at 31.5% of lane-steps
(15142 / (16 x 3000)). Well-scaled steps should drive this toward zero. This is
the primary readout: it is the direct measure of "steps are now commensurate with
prior widths".
- Seed 1 is the real test. Seed 0 already reaches the bar under every arm, so it
can only show a no-regression. Seed 1 sits 171,272 nats away and neither
clipping nor momentum reset rescued it. If per-parameter scaling moves seed 1
materially toward the bar, that is the result this whole line of work has been
looking for.
- Seed 0 must not regress past the bar-beating
31787.929.
- Pinned lanes fall. 6/16 pinned should drop if the pinning is a step-scale
artefact. If it does not fall, that is evidence those lanes are pinned because
the likelihood genuinely prefers the boundary — a science finding about the shear
prior, not a bug (see "Pinning is a result" in the phase-2 prompt).
- Wall time does not rise materially. A diagonal multiply should be free.
What would falsify it
Write these down before running and report them honestly:
- Clip rate falls but the answer does not move on either seed -> scaling is as
cosmetic as clipping was, and the prior-exit line of work is closed rather than
advanced.
- Seed 0 regresses -> the preconditioner is fighting Prodigy's own
d estimate.
- Clip rate does not fall -> the diagnosis in this prompt is wrong, and the wall
contact is not a step-scale effect. Say so; do not re-scope to save the idea.
- Results become seed-more-dependent -> preconditioning has narrowed the basin of
attraction.
Traps carried forward (all already paid for)
- Grade on the alive-versus-step CURVE, not the percentage. The lane counters
are survival integrals — a dead lane keeps counting every later step, so the same
death curve reads ~60% at 150 steps and ~75% at 300. alive_history is now in
search_internal and alive_fraction is the budget-independent scalar.
- At least two seeds, always. Identical settings swing the outcome 171,000
nats between seeds 0 and 1. Use the search's seed argument — seeding
random/numpy reaches only the initializer, and before that argument existed
_broad_starts hardcoded default_rng(0) so every run drew the same population.
- The clipper does not enter the search identifier, so arms differing only in a
search knob collide on one output directory. Give every arm a unique name, clear
its directory, and assert total_steps == n_steps — a short-circuited run reports
the previous run's counters, not zeros.
search.summary is Key = Value, not colon-separated. A colon parser returns
an empty dict and every counter reads None — indistinguishable from a feature
that never fired.
- Budget decides the answer. At 105 steps clipping was worth 114 nats; by 3000
it was worth zero. Never state a result without its step budget.
--out must actually reach the search. The campaign driver sets the output
root through conf; before that fix a stale .completed short-circuited fit()
and returned a cached result in 2.9 s with every counter None.
- A GPU run is ~20-35 min per arm on the laptop RTX 2060 at 16x3000 fp64
(3.3 GB of 6 GB, XLA_PYTHON_CLIENT_MEM_FRACTION=0.5). Six arms is a long
evening. Check A100 availability first — during the phase-2 session all 8 were
allocated to another user's array.
Deliverables
- The library change in
@PyAutoFit, default off and bit-identical when off, with
the MAP-invariance test described above.
- Results JSON under
results/searches/ / results/notes/clipper_campaign/
following the existing conventions.
- A note extending
results/notes/clipper_campaign/RESULTS.md with the side-by-side
table against the six baseline rows, and an explicit verdict on whether steps are
now linked to prior widths sensibly.
- A recommendation on the clipper default (phase 3 of the prior-support work), which
this evidence should finally be able to settle: if the clip rate collapses, the
cost of defaulting it on approaches zero and its value becomes diagnostic.
Environment
- Python 3.12+,
~/venv/PyAutoGPU (Python 3.12.10, jax 0.10.2, CUDA working).
JAX_PLATFORM_NAME=cuda JAX_PLATFORMS=cuda,cpu XLA_PYTHON_CLIENT_MEM_FRACTION=0.5 JAX_ENABLE_X64=True SEARCHES_DISABLE_VIZ=1.
- Set
jax_enable_x64 explicitly under sbatch — it is not inherited.
autofit.__file__ must resolve to the checkout; the clipper, seed,
alive_history and reset_momentum_on_clip are all unreleased.
Out of scope
- Flipping the clipper default (that is phase 3, and this task feeds it).
- Removing or weakening the clipper — see "Both features stay".
- Unit-cube / logit reparameterisation, for the three reasons above.
- NUTS — it diverges rather than dying, a different mechanism.
- The seed-dependence investigation, which is filed separately and is arguably
more important than this task.
Overview
The cause-side follow-up to the prior-support clipper. Phase 2
(autolens_profiling#131, PR #132; library PR PyAutoFit#1482) established that
ClipperPriorBoxdoes its job perfectly and does not improve the answer, anddiagnosed why lanes reach the prior walls at all: on
imaging/mgehst the priorbox widths span 40x (
einstein_radiusU(0,8) → 8.0;bulge.centreU(-0.1,0.1) → 0.2) while
MultiStartGradientsteps in physical space with asingle global step scale. Lanes do not start at the edges —
_broad_startsdrawsin the unit cube at
[0.15, 0.85]— they walk there, and clipping currently runsat 31.5% of lane-steps.
This task gives each parameter its own prior-derived step scale, so a unit-sized
optimizer step means the same fraction of the box in every coordinate. It ends
with the side-by-side comparison against the six recorded baseline rows — that
comparison is the deliverable, not the code.
Plan
Scalerstrategy to PyAutoFit, modelled one-for-one on the existingClipper:AbstractScaler/ScalerNone(the default) /ScalerPriorWidth.Uniform → width, Gaussian/TruncatedGaussian → sigma (not the truncation
width), LogUniform → the median-anchored
sqrt(lo*hi)*ln(hi/lo)— thennormalise so the geometric mean is exactly 1, so only the ratios change
and the global step magnitude is untouched.
phi = theta / s, evaluating theobjective at
theta = s * phi. Prodigy then needs no changes and its owndestimate stays self-consistent; a post-hoc
params += s * updateswould not.model.infofile, guarded so nothing is written when scaling is off.diagonal Jacobian cannot move the MAP.
ClipperPriorBoxon in every arm — the two features arecomplementary, and the clip rate becomes the primary diagnostic.
multi_start_prodigy/imaging/mge/ hst at 16x3000, seeds 0 and 1,fp64, and put the numbers beside the six baseline rows in
RESULTS.md.Detailed implementation plan
Work Classification
Both — library (PyAutoFit) first, then the measurement workspace
(autolens_profiling) once the API is known.
Affected Repositories
Branch Survey
Suggested branch:
feature/per-parameter-step-scalingBase: cut from
feature/clipper-validation-campaignin both repos, notfrom
main. This work needsseed,alive_historyandreset_momentum_on_clip,which are unreleased and live only on that branch (PyAutoFit#1482 /
autolens_profiling#132, both open and unmerged). The PRs target that branch and
retarget to
mainonce it merges.Worktree root:
~/Code/PyAutoLabs-wt/per-parameter-step-scaling/Implementation Steps
autofit/non_linear/scaler.py(new) —AbstractScalerwithscale_from_model(model) -> np.ndarrayinmodel.priors_ordered_by_idorder(the same physical order the clipper uses) and
info_from_model(model) -> str;ScalerNonereturningones;ScalerPriorWidthimplementing the rule tablebelow. Non-finite or zero scales fall back to
1.0and log once — a zeroscale would divide the whole population to
inf.UniformPrior(lo, hi)hi - lodtheta/duis constant and equals the widthLogUniformPrior(lo, hi)sqrt(lo*hi) * ln(hi/lo)dtheta/du = theta*ln(hi/lo)at the median. The raw physical width is wrong by orders of magnitude — forLogUniform(1e-4, 1e4)it is 1e4 against a true local scale of 18.4GaussianPrior(mu, sigma)sigmaTruncatedGaussianPriorsigmaell_compsis sigma=0.3 inside a [-1,1] box, so the width overstates it 6xLogGaussianPrior1.0, logged0/inf/NaNThen
s /= exp(mean(log(s)))so the geometric mean is exactly 1.autofit/non_linear/search/mle/multi_start_gradient/search.py— addscaler: Optional[AbstractScaler] = None(defaulting toScalerNone()),carried through
to_dict/from_dict. In_fit, beside the existinghas_clippershort-circuit, addhas_scaler = not isinstance(self.scaler, ScalerNone)and computescaleonce. Then:_value_and_grad = jax.value_and_grad(lambda phi: fitness.call(phi * scale))— JAX's chain rule produces gradients w.r.t.
phifor free, so there is nomanual gradient scaling anywhere;
_value_and_grad_finiteevaluates atphi * scale, staying a function of the physical vector;_broad_starts/_reinit_dead_startskeep drawing physical vectors andare divided by
scaleat their call sites — the draw logic, thefinite-gradient filter and the RNG streams are untouched;
best_paramsis recorded physical (params[best_index] * scale) sosamples_via_internal_fromis unchanged;samples_infogains"scaler": type(self.scaler).__name__beside theexisting
"clipper"key.search_internalstays entirely in physical units —paramsis written asparams * scaleand divided byscaleon the resume path, so a file writtenby an unscaled run resumes correctly under a scaled one and vice versa, and its
meaning never depends on a search knob. The scale vector is also recorded
under
"scale"for diagnostics.autofit/non_linear/clipper.py—projectgains an optionalscale=Nonewhich divides the bounds once (
lower/scale,upper/scale) before clipping,so clipping composes with the change of variables. Scales are strictly positive
so bound ordering is preserved;
ClipperNoneignores it.autofit/non_linear/paths/directory.py::_save_model_info— append thescaler's
info_from_modelblock to the writtenmodel.info, guarded by thesame
try/except (NotImplementedError, AttributeError)that_save_model_start_pointalready uses. The printedModel.infopropertyis untouched — it has no access to the search, which is precisely why this
lands in the file. Format mirrors the initializer's
model.startblock:Tests — pure NumPy.
test_multi_start_gradient.pystates the house ruleexplicitly ("keeping JAX out of the library unit suite"), so the end-to-end JAX
fit is validated downstream and the library pins the algebra:
with a deliberately 40x-spread prior set with and without scaling, asserting
the recovered argmin agrees to ~1e-12 in physical coordinates; plus the
algebraic pin that
f(s*phi) == f(theta)exactly atphi = theta/s, so aconstant diagonal Jacobian adds a constant to the log-density and cannot move
the MAP. Optimising the density of
phiinstead is the silent failurephase 1 warned about;
sigma (not truncation width) and LogUniform → median-anchored (not physical
width);
ScalerNonereturnsonesand the dict round-trip preserves the knob;project(..., scale=s)clips atbounds/swith a matchingclipped_mask.autolens_profiling —
SEARCHES_SCALER=none|prior_widthinscripts/misc/searches/_samplers.py, recorded as a string in the samplerconfig and always recorded (including the default) so no result file is
ambiguous about its arm; the
prior_box_scaledarm inscripts/misc/searches/clipper_campaign.py.The measurement — this is the deliverable
Matched configuration, not varied:
multi_start_prodigy,imaging/mge,hst,16 starts x 3000 steps, seeds 0 and 1, JAX float64,
check_for_convergence=False, viascripts/misc/searches/clipper_campaign.py.Two new arms —
prior_box_scaledat seeds 0 and 1, clipper on in both.The six baseline rows are already recorded in
results/notes/clipper_campaign/multi_start_prodigy_imaging_mge_hst.json;re-running them would only re-prove reproducibility at ~2 hours of GPU.
Baseline to beat (truth bar: Nautilus
31786.782462488976; a negative gap meansthe MAP optimizer exceeded it, which is expected):
nonenoneprior_boxprior_boxprior_box_resetprior_box_resetA100s are unavailable — checked 2026-08-17: all 8 across
euclid-ral-gpu-1/2are held by another user's array
334968(%8throttle, longest job 2d22h left),with
335049queued behind it. Running on the laptop RTX 2060, ~20-35 min perarm at 16x3000 fp64.
Environment:
~/venv/PyAutoGPU,JAX_PLATFORM_NAME=cuda JAX_PLATFORMS=cuda,cpu XLA_PYTHON_CLIENT_MEM_FRACTION=0.5 JAX_ENABLE_X64=True SEARCHES_DISABLE_VIZ=1.Verify
autofit.__file__resolves to the worktree checkout before running.Pre-registered readouts
(15142 / 16x3000). It is the direct measure of "steps are now commensurate with
prior widths".
nor momentum reset rescued it.
31787.929.lanes are pinned because the likelihood genuinely prefers the boundary: a
science finding about the shear prior, not a bug.
Falsification, written down before running
clipping was, and the prior-exit line of work is closed, not advanced.
destimate.step-scale effect. Say so; do not re-scope to save the idea.
attraction.
Traps carried forward (already paid for)
name, clear its directory, and asserttotal_steps == n_steps— the scaler does not enter the search identifier, soarms differing only in a search knob collide on one output directory and a
short-circuited run reports the previous run's counters, not zeros.
search.summaryisKey = Value, not colon-separated. A colon parserreturns an empty dict and every counter reads
None— indistinguishable from afeature that never fired.
alive_history), not the percentage —the lane counters are survival integrals.
worth 114 nats; by 3000 it was worth zero.
--outmust actually reach the search (the driver routes it throughconf).ClipperPriorBoxarm reporting zero clips has not exercised the clipper —a broken arm, not a null result.
Key Files
autofit/non_linear/scaler.py— new; the strategy, the rule table, the info blockautofit/non_linear/search/mle/multi_start_gradient/search.py— the knob and the change of variables in_fitautofit/non_linear/clipper.py— optionalscaleonprojectautofit/non_linear/paths/directory.py—_save_model_infoappends the blocktest_autofit/non_linear/test_scaler.py— new; MAP invariance + the rule tablescripts/misc/searches/clipper_campaign.py/_samplers.py— the armresults/notes/clipper_campaign/RESULTS.md— the side-by-side verdictOut of scope
Flipping the clipper default (phase 3, which this evidence should finally settle);
removing or weakening the clipper; unit-cube / logit reparameterisation; NUTS; the
seed-dependence investigation (filed separately).
Original Prompt
Click to expand starting prompt
Per-parameter step scaling for the gradient searches — treat the cause of prior exits
Type: feature
Target: autofit
Repos:
Difficulty: medium
Autonomy: supervised
Priority: high
Status: formalised
What this is
The cause-side follow-up to the prior-support clipper. Phase 2 of that work
(autolens_profiling#131, record
results/notes/clipper_campaign/RESULTS.md)established that clipping does its job perfectly and does not improve the answer,
and diagnosed why lanes reach the prior walls in the first place.
The diagnosis. On the
imaging/mgehst model, prior box widths span 8.0down to 0.2 — a 40x range — while
MultiStartGradientsteps in physicalparameter space with a single global step scale:
mass.einstein_radiusshear.gamma_1/2bulge.centre_0/1Prodigy's step scale
dgrows from1.00e-06at step 1 to2.26e-02 / 5.49e-02 / 8.29e-01by step 3000. A step that is sensible foreinstein_radiusis a wall-crossing forbulge.centre.Lanes are not initialised at the edges —
_broad_startsdraws in the unitcube at
[0.15, 0.85], the middle 70% of every prior. They walk to the walls.The prediction that escapes concentrate in the narrow-box parameters is confirmed
by autolens_profiling#128's autopsy: 10 of 11 exits were the shear, landing at
±0.30-0.35 just outside its ±0.3 box.
Do per-parameter scaling, NOT unit-cube reparameterisation
Phase 1 rejected unit-cube stepping on three grounds
(
complete/2026/08/prior-support-clipper.md, "Deliberately out of scope"), andthose objections still stand:
sits on a boundary — and this cell demonstrably has such optima (6/16 lanes end
pinned).
∂θ/∂u -> ∞at the cubefaces, trading one numerical hazard for another.
Diagonal preconditioning — a per-parameter step scale derived from each prior's
width — buys the same normalisation and dodges all three: no logit, so boundary
optima stay finite; no inverse-CDF, so no face singularity; and the objective
stays the physical-space posterior, so the Jacobian trap below cannot arise.
Carry this warning forward regardless. Reparameterising the search path does
not move the optimum provided the objective is still the physical-space posterior
evaluated at
θ(u). Optimise the density ofuinstead and the Jacobianmakes the MAP non-invariant — and it fails silently. Any implementation must
have a test that pins this.
Proposed design
1. A static per-parameter scale vector
s, derived from the priorsComputed once at fit start, reusing the clipper's bounds machinery
(
AbstractClipper.bounds_from_modelreturns(lower, upper)in physical order,±infwhen unbounded). Share the extraction helper; do not couple the twofeatures (see "Both features stay").
UniformPrior(lo, hi)hi - loGaussianPrior(mu, sigma)sigmaTruncatedGaussianPrior(mu, sigma, lo, hi)sigmaell_compsis sigma=0.3 inside a [-1,1] box, so the width overstates it 6xLogUniformPriorThen normalise so the geometric mean of
sis 1. This keeps the global stepmagnitude unchanged and alters only the ratios, so the A/B measures rescaling
alone rather than being confounded with an effective learning-rate change.
2. Apply as a constant diagonal change of variables, not a post-hoc multiply
The tempting implementation is
params += s * updatesafter the optimizer.Reject it: Prodigy estimates
dfrom the distance actually travelled(
params0,grad_sum), so rescaling its update externally leaves its own estimateinconsistent with the trajectory it thinks it took.
Instead run the optimizer in scaled coordinates
phi = theta / s, evaluating theobjective at
theta = s * phi:phi, steps inphi, and its state staysself-consistent. Same for every other optax rule.
theta(phi), satisfyingphase 1's silent-failure warning: the Jacobian is a constant diagonal, so it adds
a constant to the log-density and cannot move the MAP. Write the test that pins
this.
killed the logit reparameterisation.
phi_bounds = theta_bounds / s)and clip in
phi.3. Default off, opt-in, bit-identical when off
The same discipline
seed,alive_historyandreset_momentum_on_clipshippedunder.
4. Why this repairs Prodigy's premise rather than merely helping it
Prodigy estimates a single global step scale
d, which implicitly assumes everycoordinate has comparable scale. A 40x spread violates that assumption at the root.
5. The fixed-rate optimizers are MORE exposed, not less
Adam's update is
lr * m_hat / (sqrt(v_hat) + eps), and sincem_hat/sqrt(v_hat) ~ 1for a consistent gradient, Adam steps by ~
lrin PHYSICAL units in everycoordinate. With
lr=0.01:bulge.centre(box 0.2, wall 0.03 from the start band) -> 5% of the box perstep, crossing in ~3 steps;
einstein_radius(box 8.0) -> 0.125% of the box per step.Adam's per-coordinate normalisation therefore guarantees the pathology rather
than mitigating it, and Lion is worse still (sign-based, so literally
±lr). Expectthe effect to be larger for
MultiStartAdam/Lion/ADABeliefthan for theProdigy baseline measured here — see PyAutoFit#1481.
Both features stay — they are not substitutes
Do not treat this as a replacement for the clipper. They solve different halves,
and the case that breaks a reparameterisation is exactly the case clipping is for:
impossible (a large gradient, bad curvature, or a grown
dcan still cross), andwithout clipping that failure is silent — the lane goes
-inf, is discarded,and nothing is raised.
lane sitting on the bound is the correct MAP answer under the declared prior.
This cell has 6/16 such lanes. Only clipping can express that.
Keep
ClipperPriorBoxON in every arm of this work, so the clip rate ismeasurable as a diagnostic rather than being confounded with the scaling change.
The comparison this task must end with
This is the deliverable. Re-run the exact arms phase 2 ran and put the numbers
side by side, so "steps are now linked to priors sensibly" is demonstrated rather
than asserted.
Matched configuration (do not vary it — the point is comparability):
multi_start_prodigy,imaging/mge,hst, 16 starts x 3000 steps,seeds 0 and 1, JAX float64,
check_for_convergence=False, viascripts/misc/searches/clipper_campaign.py.Baseline to beat, from
results/notes/clipper_campaign/multi_start_prodigy_imaging_mge_hst.json:nonenoneprior_boxprior_boxprior_box_resetprior_box_resetTruth bar: Nautilus
max_log_likelihood = 31786.782462488976(
results/searches/nautilus/imaging/mge/hst/hpc_a100_fp64.json). Negative gap meansthe MAP optimizer exceeded it, which is expected.
What would count as success
(15142 / (16 x 3000)). Well-scaled steps should drive this toward zero. This is
the primary readout: it is the direct measure of "steps are now commensurate with
prior widths".
can only show a no-regression. Seed 1 sits 171,272 nats away and neither
clipping nor momentum reset rescued it. If per-parameter scaling moves seed 1
materially toward the bar, that is the result this whole line of work has been
looking for.
31787.929.artefact. If it does not fall, that is evidence those lanes are pinned because
the likelihood genuinely prefers the boundary — a science finding about the shear
prior, not a bug (see "Pinning is a result" in the phase-2 prompt).
What would falsify it
Write these down before running and report them honestly:
cosmetic as clipping was, and the prior-exit line of work is closed rather than
advanced.
destimate.contact is not a step-scale effect. Say so; do not re-scope to save the idea.
attraction.
Traps carried forward (all already paid for)
are survival integrals — a dead lane keeps counting every later step, so the same
death curve reads ~60% at 150 steps and ~75% at 300.
alive_historyis now insearch_internalandalive_fractionis the budget-independent scalar.nats between seeds 0 and 1. Use the search's
seedargument — seedingrandom/numpyreaches only the initializer, and before that argument existed_broad_startshardcodeddefault_rng(0)so every run drew the same population.search knob collide on one output directory. Give every arm a unique
name, clearits directory, and assert
total_steps == n_steps— a short-circuited run reportsthe previous run's counters, not zeros.
search.summaryisKey = Value, not colon-separated. A colon parser returnsan empty dict and every counter reads
None— indistinguishable from a featurethat never fired.
it was worth zero. Never state a result without its step budget.
--outmust actually reach the search. The campaign driver sets the outputroot through
conf; before that fix a stale.completedshort-circuitedfit()and returned a cached result in 2.9 s with every counter
None.(3.3 GB of 6 GB,
XLA_PYTHON_CLIENT_MEM_FRACTION=0.5). Six arms is a longevening. Check A100 availability first — during the phase-2 session all 8 were
allocated to another user's array.
Deliverables
@PyAutoFit, default off and bit-identical when off, withthe MAP-invariance test described above.
results/searches//results/notes/clipper_campaign/following the existing conventions.
results/notes/clipper_campaign/RESULTS.mdwith the side-by-sidetable against the six baseline rows, and an explicit verdict on whether steps are
now linked to prior widths sensibly.
this evidence should finally be able to settle: if the clip rate collapses, the
cost of defaulting it on approaches zero and its value becomes diagnostic.
Environment
~/venv/PyAutoGPU(Python 3.12.10, jax 0.10.2, CUDA working).JAX_PLATFORM_NAME=cuda JAX_PLATFORMS=cuda,cpu XLA_PYTHON_CLIENT_MEM_FRACTION=0.5 JAX_ENABLE_X64=True SEARCHES_DISABLE_VIZ=1.jax_enable_x64explicitly undersbatch— it is not inherited.autofit.__file__must resolve to the checkout; the clipper,seed,alive_historyandreset_momentum_on_clipare all unreleased.Out of scope
more important than this task.