feat: project ell_comps onto its disk with an opt-in joint clipper (#1537) - #1538
Merged
Conversation
…1537) `ell_comps = (e1, e2)` is physical only inside the unit disk, but its priors are two independent boxes -- a square whose corners describe no ellipse. 21.5% of the declared prior area is non-physical, and 20.1% of recorded MultiStart lane best points end there (autolens_profiling#182); 0 of the 246 lanes that reach the target basin do. No per-coordinate bound can see it: (0.8, 0.8) is inside both boxes and outside the disk. Adds the structural half of the constraint machinery, beside the existing `__model_constraint__` violation *measure*: - `__model_ball_constraints__`, a class-declared `((path, radius), ...)` naming the tuple prior confined to a disk. Duck-typed like `__model_constraint__`, so a profile library states its own geometry without inheriting from PyAutoFit. - `AbstractPriorModel.ball_constraint_index_pairs()` resolves declarations to `(index_0, index_1, radius)` triples into the physical parameter vector. Static geometry, so it is cached under an underscore-prefixed `__dict__` key (the `parameterization` convention the pytree/ModelInstance paths skip). - `af.ClipperPriorBoxJoint`, an OPT-IN `ClipperPriorBox` subclass that clips the box and then radially shrinks each declared pair onto its ball. Jittable: the factor is a `where`, the radius is compared squared, and the `sqrt` argument is substituted before the `sqrt` (the double-`where` idiom) so `grad` stays finite at the origin. Both members of a moved pair are masked, which is what lets MultiStartGradient zero the outward momentum in both coordinates. The default clipper is untouched: `ClipperPriorBox.__identifier_fields__` is pinned to `("margin", "strict_epsilon")` -- exactly what the identifier's argspec fallback inferred -- so existing search identifiers and output directories are byte-identical, and a subclass adding a constructor argument cannot silently re-key stored results. Unsupported combinations raise rather than degrading silently: `AbstractBFGS` refuses a joint clipper on a model that declares a ball (a ball is not a `scipy.optimize.Bounds`), and `AbstractMultiStartGradient` refuses one alongside a non-default scaler or bijector at construction (neither change of variables maps a disk to a disk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011joies4k5TdRqezPUK8YET
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the searchability defect in #1537:
ell_comps = (e1, e2)is physical onlyinside the unit disk, but every profile's priors are two independent boxes — a
square whose corners describe no ellipse at all.
1 - pi/4= 21.5% of thedeclared prior area is non-physical, and 20.1% of recorded MultiStart lane
best points end at
|e| >= 1(1,252 of 6,240 across theautolens_profilingcampaign, issue #182), while 0 of the 246 lanes that reach the target basin
do. That makes it wasted budget rather than a wasted answer. No box clipper can
see it:
(0.8, 0.8)is inside both prior boxes and outside the disk.This is option 1 of the two the prompt weighed — a joint constraint the
clipper honours — chosen over reparameterisation because it keeps every parameter
name, prior config and recorded identifier stable, and is the only option that
helps a search already in flight.
Three pieces:
__model_ball_constraints__— a class-declared((path, radius), ...)naming the
TuplePriorconfined to a disk. It sits beside the existing__model_constraint__, which measures a violation and therefore cannot sayhow to fix one; this states the geometry's structure, which is what a
search needs to put a lane back inside it. Duck-typed exactly like
__model_constraint__, so PyAutoGalaxy states its own geometry withoutinheriting from PyAutoFit (companion PR below).
AbstractPriorModel.ball_constraint_index_pairs()— resolves declarationsto
(index_0, index_1, radius)triples indexed into the physical parametervector, so a consumer needs nothing but the vector and this list. Static
geometry, resolved once outside the step loop and cached under an
underscore-prefixed
__dict__key (theparameterizationconvention that thepytree-flattening and
ModelInstancepaths both skip). A declared ball isskipped, not raised on, when the component pinned its coordinates to an
instance (spherical profiles) or fixed one of them.
af.ClipperPriorBoxJoint— an opt-inClipperPriorBoxsubclass thatclips the box first and then radially shrinks each declared pair onto its
ball. A projection rather than a penalty deliberately: past the ellipticity
clamp the conversion to an axis ratio saturates, so the objective is flat and
a gradient has nothing to say — the projection does not need one, and moves
the lane in one step to the nearest point where one exists.
Jittability. The shrink survives
jit/vmap/grad: the factor is anxp.where(never a Python branch), the radius is compared squared, thesqrtargument is substituted before the
sqrt(the double-whereidiom —gradevaluates both branches,
sqrt(0)has infinite derivative, and0 * infisNaN), the divisor is floored attiny, and the factors are assembled as onemultiplicative vector applied with a single multiply, so the traced program is a
fixed handful of ops under both
numpyand JAX.Both members of a moved pair are masked. The mask's consumer is
MultiStartGradient's momentum reset: a lane pushed out of the disk carriesoutward momentum in both coordinates, and zeroing one leaves the pair
spiralling straight back out.
Unsupported combinations raise, they do not degrade.
AbstractBFGS._bounds_fromrefuses a joint clipper on a model that declares a ball (a ball is not expressible
as a
scipy.optimize.Bounds; keyed on the model so the clipper can still beconfigured once for a whole pipeline), and
AbstractMultiStartGradientrefuses onealongside a non-default
scaler/bijectorat construction, not at the first step— neither change of variables maps a disk to a disk.
API Changes
Additive and opt-in.
af.ClipperPriorBoxJointis a new clipper; nothing selectsit by default and no existing search or model behaviour changes.
EllProfilegains a declaration in the companion PyAutoGalaxy PR. Two previously-silent
misconfigurations now raise (the joint clipper under LBFGS with a ball-declaring
model, and the joint clipper with a scaler/bijector), which is new behaviour only
for code using the new clipper.
The default clipper's identifier is byte-identical.
ClipperPriorBox.__identifier_fields__is pinned to("margin", "strict_epsilon")— exactly what the identifier's
__init__-argspec fallback already inferred — soexisting search identifiers and output directories are unchanged, verified below.
The pin is what keeps it that way: a subclass taking a further constructor
argument would otherwise silently re-key every stored
ClipperPriorBoxresult.See full details below.
Test Plan
test_autofit/full suite: 2288 passed, 3 skipped (97s)test_autofit/mapper+test_autofit/non_linear: 1597 passed, 2 skippedtest_clipper.py(projection, anglepreservation, origin-is-not-NaN, batched lanes, box-before-ball ordering,
mask on both members and only them, scaler/bijector refusal, identifier
stability, search wiring),
test_model_constraint.py(declarationnormalisation, malformed-declaration failure at composition, index
resolution through nested Collections, linked-prior dedup, fixed-coordinate
skips, caching), and
test_multi_start_gradient.py(a lane seeded outsidethe disk lands inside after one clipper call; the momentum reset zeroes
both components; the default clippers demonstrably cannot move it).
import jaxanywhere in the new tests.The traced
jit/grad/vmapbehaviour was verified out-of-band (below).test_autogalaxy/1144 passed andtest_autolens/553 passedagainst this branch.
blackparity: the set of filesblack --checkwould reformat isidentical before and after this branch (pre-existing, untouched).
Identifier stability, measured. The same script run against
main(
cbd30065) and this branch produces identical hashes:MultiStartAdam+ClipperPriorBox45e493b60b1b5bf12bda38f2ac31f45045e493b60b1b5bf12bda38f2ac31f450MultiStartAdam+ClipperNonee1e937f9ca1a3e770b88f03ba9304313e1e937f9ca1a3e770b88f03ba9304313LBFGS+ClipperPriorBox1caa475d9e84b8e1f78797b2a5182f751caa475d9e84b8e1f78797b2a5182f75ClipperPriorBox()alone5f8556851fb663e4a076182214c75e115f8556851fb663e4a076182214c75e11ClipperPriorBox.projectandbounds_from_modelreturn bit-identical values onboth.
ClipperPriorBoxJoint()hashes differently (af9832ec…), as it must — itchanges where a lane can sit.
Effect on the measured 20.1%, on one cell. 200,000 uniform draws from the
ell_compsprior box of anIsothermallens model:1 - pi/4= 21.46%)and the canonical failing point
ell_comps = (0.9, 0.9),|e| = 1.2728, isprojected to
|e| = 0.999000with the angle preserved(
e0 == e1 == 0.7063996744), the mask set on both members and on nothing else.The default
ClipperPriorBoxleaves the same point at|e| = 1.2728with anall-false mask.
Traced behaviour (out-of-band, since unit tests are numpy-only). Under
jax.jitthe same point projects to|e| = 0.999000; the origin is finite; andjax.gradis finite at the origin, at the projected point and at an interiorpoint — the case a naive radial shrink returns a correct value and a
NaNgradient for.
jax.vmapover both is finite.Full API Changes (for automation & release notes)
Added
af.ClipperPriorBoxJoint(margin=1.0e-6, strict_epsilon=1.0e-12)—ClipperPriorBoxsubclass that clips the prior box and then radially projects each pair declared
by
__model_ball_constraints__onto its ball. Opt-in; a strict no-op on a modelthat declares no geometry. Raises
ValueErrorwhen handed ascaleorbijector.autofit.mapper.prior_model.constraint.MODEL_BALL_CONSTRAINT— the"__model_ball_constraints__"attribute name.autofit.mapper.prior_model.constraint.declares_ball_constraints(cls) -> boolautofit.mapper.prior_model.constraint.ball_constraints_for(cls) -> tuple—normalises the declaration to
((path_tuple, float_radius), ...); raisesAssertionErroron a malformed entry or a bare-string path.AbstractPriorModel.ball_constraint_index_pairs() -> tuple— sorted,de-duplicated
(index_0, index_1, radius)triples into the physical parametervector. Cached in
__dict__["_ball_constraint_index_pairs_cache"].Model.has_ball_constraints -> boolClipperPriorBox.__identifier_fields__ = ("margin", "strict_epsilon")—pinned; reproduces the previous argspec-inferred identifier exactly.
Changed Behaviour
AbstractBFGS._bounds_from(model)(private) — raisesexc.SearchExceptionwhenthe configured clipper is a
ClipperPriorBoxJointandmodeldeclares aball. Unchanged for every other clipper, and for a joint clipper on a model
with no declared geometry.
AbstractMultiStartGradient.__init__— raisesValueErrorwhen aClipperPriorBoxJointis passed together with a non-defaultscalerorbijector. Raised at construction, so a multi-hour fit does not die a minute in.__model_ball_constraints__; a malformed declarationraises
AssertionErrorataf.Model(cls)composition time rather than when asearch first asks for index pairs.
Migration
search = af.MultiStartAdam(clipper=af.ClipperPriorBoxJoint())— with thecompanion PyAutoGalaxy PR merged, every elliptical profile's
ell_compsis thenprojected onto
|e| <= 0.999after each step, and its momentum reset in bothcomponents.
scaler/bijector, or use it withaf.LBFGSon aball-declaring model; both now raise with a message naming the alternative.
Companion (depends on this PR): PyAutoLabs/PyAutoGalaxy#589 — declares the
disk on
EllProfile.Generated by the PyAutoLabs agent workflow.
🤖 Generated with Claude Code
https://claude.ai/code/session_011joies4k5TdRqezPUK8YET