fix: compose ClipperPriorBoxJoint with a bijector/scaler instead of refusing it - #1540
Merged
Merged
Conversation
The joint clipper was refused outright whenever a `scaler` or `bijector` was set -- at construction in `MultiStartGradient.__init__` and again at projection time in `ClipperPriorBoxJoint.project`. The refusal was stronger than the geometry requires. A ball is a statement about physical coordinates, but it survives a COMMON linear rescale of both its members: `theta_i**2 + theta_j**2 <= R**2` is exactly `phi_i**2 + phi_j**2 <= (R / s)**2`, so a disk of radius `R` IS a disk of radius `R / s` in the stepped coordinates, and the radial shrink -- a pure multiply -- projects onto it correctly. Only a genuinely non-linear pair has no closed form: a `log`/`logit` kind, or two identity coordinates with different scales (an ellipse, whose nearest-point projection is the root of a quartic). Because the refusal was blanket, any gradient arm wanting a reparameterisation anywhere in the model had to drop the joint clipper entirely, and its `ell_comps` lanes then settled at the box corner `|e| = 1.414` -- inside every prior box, outside the disk, in the flat region the clipper exists to escape. - `AbstractBijector.identity_scales` reports the per-coordinate linear scale where the kind is `identity` and `None` where it is not. `None` rather than `1.0` for `log`/`logit`, deliberately: a log coordinate is not a coordinate scaled by one, and reporting it as such would let a caller compose with it silently and wrongly. - `ClipperPriorBoxJoint.pairs_in_stepped_coordinates` resolves each declared ball pair against the map, returning the radius divided by the pair's common scale or raising -- now naming the offending index pair and its kinds. - The construction-time refusal is deleted and the check moved into `_fit`, immediately after the bijector is resolved against the model and before `_vmapped` is built. Whether the two compose is a question about the MODEL and no model exists at construction; making it there is still before any likelihood evaluation, so a bad combination does not die a minute into a multi-hour fit. The projection is NOT round-tripped through the bijector. That would be correct for every kind but would drag the `logit` epsilon clamps across coordinates the ball has nothing to do with, breaking the bit-identity the interior-point path promises, saturating gradients at the clamped edges, and adding traced ops to every step. Dividing the radius costs nothing and is exact. No new constructor arguments, so `__identifier_fields__` is untouched and no stored result is re-keyed. Tests are NumPy-only, as the suite requires: the composition cases and the refusals in `test_clipper.py`, an F5 bit-exactness pin on a per-path round trip in `test_bijector.py` (the pin the radius-division shortcut rests on), and the search wiring in `test_multi_start_gradient.py`. 2301 passed, 3 skipped (base 2288/3). Traced behaviour verified separately on CPU: 8/8 lanes end on the disk under jit/vmap with 874 clipped lane-steps and zero NaN gradients. 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.
Closes #1539.
Summary
ClipperPriorBoxJointwas refused outright whenever ascalerorbijectorwas set — at construction inMultiStartGradient.__init__and again at projection time inClipperPriorBoxJoint.project. The refusal was stronger than the geometry requires.A ball is a statement about physical coordinates, but it survives a common linear rescale of both its members:
theta_i**2 + theta_j**2 <= R**2is exactlyphi_i**2 + phi_j**2 <= (R / s)**2, so a disk of radiusRis a disk of radiusR / sin the stepped coordinates, and the radial shrink — a pure multiply — projects onto it correctly. Only a genuinely non-linear pair has no closed form: alog/logitkind, or two identity coordinates with different scales (an ellipse, whose nearest-point projection is the root of a quartic, not a radial shrink).Because the refusal was blanket, any gradient arm wanting a reparameterisation anywhere in the model had to drop the joint clipper entirely, and its
ell_compslanes then settled at the box corner|e| = 1.414— inside every prior box, outside the disk, in the flat saturated region the clipper exists to escape (autolens_profiling#182: 20.1% of recorded lane best points, and 0 of the 246 lanes that reach the target basin).What changed
autofit/non_linear/bijector.py— newAbstractBijector.identity_scalesproperty: the per-coordinate linear scale where the kind isidentity,Nonewhere it is not.Nonerather than1.0forlog/logitdeliberately — a log coordinate is not a coordinate scaled by one, and reporting it as such would let a caller compose with it silently and wrongly.autofit/non_linear/clipper.py— newClipperPriorBoxJoint.pairs_in_stepped_coordinates(pairs, scale, bijector)resolves each declared ball pair against the map, returning(i, j, radius / s)for a pair sharing one common positive linear scale and raising the existingValueError— now naming the offending index pair and its kinds — otherwise.projectcalls it in place of the old blanket raise. A "Composition with a scaler/bijector" docstring section carries theR → R/sargument and the per-pair reasoning.autofit/non_linear/search/mle/multi_start_gradient/search.py— the construction-time refusal is deleted; the check runs once in_fit, immediately afterself.bijector.from_model(model=model)and before_vmappedis built. Whether the two compose is a question about the model — which pairs carry a ball, and how the map treats each — and no model exists at construction; making it here is still before any likelihood evaluation, so a bad combination does not die a minute into a multi-hour fit.bfgs/search.pyis untouched: LBFGS still refuses the joint clipper wholesale, because scipy has no ball.The projection is deliberately not round-tripped through the bijector. That would be correct for every kind, but it would drag the
logitepsilon clamps across coordinates the ball has nothing to do with — breaking the bit-identity the interior-point path promises, saturating gradients at the clamped edges, and adding traced ops to every step. Dividing the radius costs nothing and is exact.API Changes
Additive only. No existing call signature, default, or result changes.
autofit.AbstractBijector.identity_scales(property) —List[Optional[float]], requiresfrom_modelto have been called.ClipperPriorBoxJoint.pairs_in_stepped_coordinates(pairs, scale=None, bijector=None).af.MultiStartAdam(clipper=af.ClipperPriorBoxJoint(), bijector=...)(andscaler=...) no longer raises at construction. Combinations that previously raised there now either work (the ball pair is identity-mapped under one common scale) or raise the sameValueErrorclass at model resolution inside_fit, before the first likelihood evaluation. A previously-raising combination cannot start silently doing something different — the map is resolved and either accepted or refused, never applied approximately.ClipperPriorBox.__identifier_fields__remains("margin", "strict_epsilon"). The composition takes no new constructor argument, so no stored result is re-keyed — pinned by a test.__model_ball_constraints__and are unaffected.Validation
test_autofit/(full, worktree PyAutoFit first onPYTHONPATH)f466dce1a: 2288 passed, 3 skipped — +13 new tests)test_autofit/non_lineartest_autogalaxy/profiles(canonical checkout, worktree autofit)test_autolens/analysis(canonical checkout, worktree autofit)black(25.1.0, repo defaults)New tests (NumPy-only, as the suite requires):
test_clipper.py::TestJointBallComposesWithAMap—BijectorNonecomposes bit-identically; alogon a non-ball path composes and still projects the corner onto the disk;BijectorLogiton the ball pair raises and the message names(0, 1); a commonscale=[2,2,1]projects onto0.999/2;scale=[2,3,1]raises;BijectorDiagonal(ScalerPriorWidth())with equal pair widths is accepted; a model declaring no ball is unaffected.test_clipper.py::TestJointBallSearchWiring— restated from "refused at construction" to accepted at construction and refused at model resolution; plus the identifier pin.test_bijector.py::test__round_tripping_a_per_path_map_is_bit_exact_where_it_is_identity— the F5 pin: identity coordinates round-trip with==(bit-exact), thelogone torel=1e-12. This is what the radius-division shortcut rests on.test_multi_start_gradient.py— the combination constructs, an identity-mapped ball pair is accepted at model resolution and still pulls a corner lane in, and alogit-on-ell_compscombination raises.Traced (JAX/CPU) integration, run outside the unit suite: a
MultiStartAdam(clipper=ClipperPriorBoxJoint(), bijector=BijectorPerPath({"einstein_radius": "log"}))fit on a toyEllProfile-bearing model whose optimum sits at|e| = 2.0(outside the disk, so the gradient pushes outwards every step and only the clipper can hold a lane):The 874 clipped lane-steps matter: without them the
|e| <= 0.999assertion would be vacuous. The counterfactual was checked too — the same fit withbijector=BijectorLogit()raises before the first step, naming the pair:Campaign implication (not in this PR)
To actually use the disk, the Phase 8B
logitarm must be restated as aBijectorPerPathcarryinglogiton every path exceptell_comps.*. That is a config change inautolens_profiling, not a library change, and is deliberately out of scope here.Heart
Shipped under the human-authorised RED override: the sole
red_reasonsentry is"release validation FAILED (stage integrate)". The two YELLOW reasons (workspace validationrectangular_mge*, session-start-hook manifest drift) are unrelated to this change.🤖 Generated with Claude Code
https://claude.ai/code/session_011joies4k5TdRqezPUK8YET