Precondition the fit parameters, and restart a stalled minimizer - #150
Merged
Conversation
Many unconstrained, strongly correlated parameters -- the coefficients of a smooth parameterisation whose basis is not orthogonal under the data's own weight -- make the Hessian badly conditioned. The Krylov inner solve then needs a number of Hessian-vector products growing like sqrt(kappa), and outer steps get rejected. Observed on a 2112-coefficient in-situ muon efficiency fit: 19 of 77 iterations returned a bit-identical loss while consuming 29 of 145 minutes, the worst single iteration taking 25 minutes to make no progress. Add an opt-in reparameterisation theta = theta_ref + T y with T = L^-T from the Cholesky of a reference Hessian restricted to a selected block, so T^T H0 T = I there. This is preconditioning of the trust-region subproblem obtained as a change of variables, which matters because scipy's trust-krylov (GLTR/_trlib) accepts no user preconditioner: the minimizer is left completely untouched and the spherical trust region in y becomes an H0-aligned ellipsoid in theta. The transform is confined to the three scipy callbacks in fit(); self.x holds physical parameters everywhere else, so the postfit Hessian, covariance, impacts and pulls need no mapping back. Preconditioner.identity() is an exact no-op, keeping a single code path. Default scope is the unconstrained parameters, where this pays off; constrained nuisances are already normalised by their unit Gaussian prior. Frozen parameters are always excluded, since a dense transform would otherwise mix them back in. T is applied as a dense matvec against a cached L^-1 rather than a triangular solve: same flops, but the solve is inherently sequential while the matvec is a parallel GEMV, measured 15x faster at m=2112 (0.19 ms vs 2.9 ms per HVP, 0.09% of a 215 ms HVP). The triangular solve is kept as a fallback. A block that cannot be factorised, or a reference Hessian that cannot be formed, falls back to running unpreconditioned with a warning: a preconditioner must never break a fit. Off by default and a pure reparameterisation, so existing fits are unchanged. Tests cover the algebra (whitening, chain rule, hessp vs dense, fast path vs fallback), scope selection, the degenerate-block fallbacks, and fit invariance for both trust-krylov and trust-exact on a deliberately ill-conditioned model (correlation condition number 1.3e3 -> 1.0, identical results). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tall Two follow-ups to the preconditioner, both driven by what the 2112-coefficient in-situ efficiency fit actually did. --preconditionFrom gaussnewton takes the Fisher information instead of the exact Hessian, computed by evaluating the Hessian with the data replaced by the current prediction: there (1 - nobs/nexp) vanishes, the second-derivative term drops out and J^T W J + diag(cw) remains, positive semi-definite by construction. It is not the better default, and the docstrings say so with the measurement behind it. Being PSD, the Fisher matrix cannot represent negative curvature; the exact Hessian of that fit has 33 negative eigenvalues at the starting point, whitening with Gauss-Newton left all 33 negative and the true Hessian at kappa ~1e4 in the new coordinates, and the fit froze after 13 iterations. Raising the ridge did not help, so it is the missing curvature and not the scaling. The ridge on the exact Hessian turns out to be load-bearing for exactly that reason: it regularises the indefinite directions. "hessian" stays the default; "gaussnewton" is for models that are positive definite where the fit starts. The second change is unrelated to preconditioning and worth having on its own. scipy's trust-region loop shrinks the trust radius 4x on every rejected step with no lower bound, and keeps it in a local variable, so a run of rejections leaves the minimizer taking infinitesimal steps far from any minimum. The loss stops moving while the gradient stays large, which is what --earlyStopping was added to catch. But a fresh minimize() call resets the radius, which is why restarting from a stalled point resumes the descent -- the trick of chaining --externalPostfit by hand. Do it in the fitter instead: on an early-stopping stall, restart from the stalled point, and keep restarting for as long as the loss keeps coming down, stopping only once a restart no longer reduces it (--maxRestarts, -1 by default, 0 to disable). Measured on a fit that froze at loss 91596: four automatic restarts took it to 11252, matching the 145-minute unpreconditioned run's 11249. Also: a minimizer exception that is not a stall is now logged at warning level. The bare except previously swallowed everything, so a broken callback -- for instance one whose argument is not named intermediate_result, which makes scipy pass a bare array -- looked exactly like a converged fit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transform whitens the Hessian at the point it was built. A restart happens precisely because the fit has stalled somewhere else, where that Hessian is no longer the same one -- so reusing the original transform resets the trust radius but carries a stale change of variables that no longer conditions anything. Rebuild it at the current point instead, which is where the next round will actually be working. The transform now lives in a one-element cell so the three scipy callbacks pick up the new one; the physical parameters are re-expressed in the new coordinates before restarting. Costs one Hessian evaluation per restart, and is a no-op when preconditioning is off, since the builder returns the identity without computing anything. The accompanying test asserts the rebuild happens *and* that it happens at a different point than the previous build; it was verified to fail when the rebuild is removed, so it cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FitterCallback, merge_callbacks and the restart improvement threshold touch no Fitter state, so they do not need to live in the largest module in the package. fitter.py goes from 3105 to 3039 lines; the names are re-exported by importing them, so fitter.FitterCallback still resolves. The underscores are dropped now that the two helpers are a cross-module API. The restart loop itself stays in fit(): it reads six pieces of Fitter state and closes over the three scipy callbacks, so moving it would trade a long function for indirection through a module boundary. Also make the rebuild-before-restart test deterministic. It relied on the model tripping --earlyStopping on its own, which turns on differences far below the scale of the fit; TF's multithreaded CPU reductions are not bitwise reproducible, and the test was observed to pass and fail on the same code. It now forces the stall from a callback subclass and asserts the exact build count, one up front plus one per restart, and that each refresh happens somewhere new. Verified to fail when the rebuild is removed, so it still cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scipy will not stop on its own. Its trust-region loop has no termination test for a collapsed trust radius, and maxiter defaults to 200*nparams, so a fit whose radius has collapsed keeps calling the subproblem solver on a microscopic region: measured on a 3603-parameter fit, the loss froze after 63 seconds and the next 720 iterations changed nothing, at ~10 iterations per second, which projects to twenty hours before maxiter would end it. Now that a stall triggers a restart rather than a give-up, detecting one is what lets the fit continue, so there is no longer a reason to leave it off. Default 20 iterations without improvement; -1 still disables it, which now means "let a stalled fit spin". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One Cholesky over everything selected was both the most expensive option and
the most fragile. Cholesky is O(m^3), so a single 2112-parameter block costs
~4e4 times more than the 240 blocks the parameterisation actually has; worse, a
union of individually well-behaved groups can be singular, and then the whole
transform is lost. Measured on the combined W+Z fit: the 1921 ABCD fake
parameters were not factorisable, which took the 2112 in-situ parameters down
with them and the fit ran unpreconditioned.
The transform is now block diagonal, one factorisation per block, and a block
that cannot be factorised is skipped while the others still apply.
--preconditionBlocks chooses how the blocks are formed:
auto (default) threshold the reference matrix's correlations and take
connected components. On the in-situ fit this recovers 233-240
components where the parameterisation has 240 (step, eta,
charge) blocks, with no knowledge of parameter names, and every
block factorises at the default ridge where the single block
needed 1e-2. The median block is 12 parameters throughout.
expressions one block per --preconditionParams entry.
none no grouping, the whole scope as one block (the previous
behaviour).
auto is the default because it needs nothing from the user: --precondition alone
now selects the unconstrained parameters and discovers their clusters, which is
the right thing without knowing in advance which parameters are badly
correlated, and the tuning knobs stay for when that is known.
--preconditionBlockThreshold sets the correlation cut (0.1); below the
percolation point everything joins one component, which is warned about.
The blocks are re-derived at each restart along with the factorisations, since
the correlation structure moves with the fit: over five builds of one 4D fit the
block count went 233, 240, 230, 229, 213 with the median size pinned at 12 while
the largest cluster grew 73 -> 204, i.e. clusters merge as the minimum is
approached.
Grouping cannot change the answer, and the invariance test is parametrised over
all three modes to keep it that way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ridge escalated by powers of a hundred, 1e-8 -> 1e-6 -> 1e-4 -> 1e-2, and gave up after four tries. That steps straight over the values a mildly indefinite block actually needs. On the combined W+Z fit two blocks holding 168 parameters were skipped as "not factorisable" when their smallest eigenvalues were only 2-3% of max|diag| and nothing was near zero: 1e-4 was too small and 1e-2 was the next rung up. The ridge required to restore definiteness is set by the most negative eigenvalue, so compute it instead of guessing. The caller's value is still tried first, since a positive definite block needs nothing more and the Cholesky is cheap; only if that fails is the extra O(m^3) eigendecomposition worth it. The powers-of-a-hundred escalation stays as a fallback. Measured on that fit: 69 of 71 blocks factorised before, 71 of 71 after, i.e. all 4033 selected parameters are now preconditioned. Splitting the failing blocks further was the alternative and a worse one -- they only became factorisable at a correlation threshold of 0.5, which would have discarded every correlation between 0.1 and 0.5, the bulk of what the transform is for. Also fix the message for a block that really cannot be used: it reported the next ridge it would have tried rather than the largest one actually tried. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-block line was written when a run had one block; auto-blocking routinely finds hundreds, so it is now debug and one summary line reports how many blocks were used, over how many parameters, and the condition numbers they started from. Drop the claim that no --preconditionParams means "a single block of the unconstrained parameters": those expressions only set the scope, and how it is split is --preconditionBlocks' business. The corrected line repeats identically on every restart's rebuild and says nothing the summary does not, so it moves to debug as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HrsrmERvJF2Vafn7wE1B3v
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.
Problem
A Z dilepton in-situ muon efficiency fit with 2112 unconstrained Chebyshev
coefficients took 145 minutes, and most of that time bought nothing: 20 of
78 iterations returned a bit-identical loss while consuming 57 minutes, and the
worst single iteration spent 27 minutes to arrive back where it started.
The cause is conditioning.
trust-krylovsolves its subproblem with a Krylovmethod whose inner iteration count grows like
sqrt(kappa), and thecorrelation condition number of that parameter block is 6.1e4. Nothing in
rabbit currently scales or preconditions parameters.
This PR adds two independent things: a preconditioner, and recovery from a
stalled minimizer.
1.
--precondition(off by default)Reparameterise
theta = theta_ref + T ywithT = L^-Tfrom the Cholesky of areference Hessian restricted to a selected block, so
T^T H0 T = Ithere.The important design point: this is preconditioning obtained as a change of
variables, so the minimizer is left completely untouched. scipy's
trust-krylov(GLTR/_trlib) accepts no user preconditioner and does not needto — a spherical trust region in
yis anH0-aligned ellipsoid intheta,which is exactly the desired effect.
It is confined to the three scipy callbacks in
fit();self.xholds physicalparameters everywhere else, so the postfit Hessian, covariance, impacts and
pulls are untouched and need no mapping back.
The transform is block diagonal, and by default the blocks are found from
the reference matrix rather than named by hand: threshold its correlations and
take connected components. On the in-situ fit that recovers 233-240 components
where the parameterisation has 240 (step, eta, charge) blocks, knowing nothing
about parameter names. Two reasons that matters more than tidiness:
fit the 1921 ABCD fake parameters were not factorisable and took the 2112
in-situ ones down with them, leaving the fit unpreconditioned. With blocks, an
unusable block is skipped and the rest still apply.
New options:
--precondition,--preconditionParams(names, regexes orsystematic groups; defaults to the unconstrained parameters, where it pays off —
constrained nuisances are already normalised by their prior),
--preconditionBlocks {auto,expressions,none}with--preconditionBlockThreshold,--preconditionFrom {hessian,gaussnewton},--preconditionRidge.autois the default so that--preconditionon its own does something sensiblewithout the user having to know which of their parameters are badly correlated;
expressions(one block per--preconditionParamsentry) andnone(one jointblock, the original behaviour) are there when that is known.
The ridge comes from the block's spectrum
A block that is not positive definite gets a ridge before the Cholesky. That
ridge used to escalate by powers of a hundred (1e-8 -> 1e-6 -> 1e-4 -> 1e-2),
which steps straight over what a mildly indefinite block needs: on the combined
W+Z fit two blocks holding 168 parameters were skipped as "not factorisable"
when their smallest eigenvalues were only 2-3% of
max|diag|and nothing wasnear zero — 1e-4 was too small and 1e-2 was the next rung up.
The ridge required to restore definiteness is set by the most negative
eigenvalue, so it is computed rather than guessed. The caller's value is still
tried first, since a positive definite block needs nothing more and the Cholesky
is cheap; only if that fails is the extra O(m^3) eigendecomposition worth it.
Measured on that fit: 69 of 71 blocks factorised before, 71 of 71 after, so
all 4033 selected parameters are now preconditioned. Splitting the failing
blocks further was the alternative and a worse one — they only became
factorisable at a correlation threshold of 0.5, which would have discarded every
correlation between 0.1 and 0.5, the bulk of what the transform is for.
The blocks are re-derived at every restart along with the factorisations, since
the correlation structure moves with the fit: over five builds of one 4D fit the
count went 233, 240, 230, 229, 213 with the median block pinned at 12 while the
largest grew 73 -> 204, i.e. clusters merge as the minimum is approached.
Tis applied as a dense matvec against a cachedL^-1rather than atriangular solve: same flops, but the solve is inherently sequential while the
matvec is a parallel GEMV, measured 15x faster at m=2112 (0.19 ms vs 2.9 ms per
HVP, 0.09% of a 215 ms HVP). The triangular solve is kept as a fallback.
2.
--maxRestarts(on by default)Independent of preconditioning, and useful for any trust-region fit.
scipy's
_minimize_trust_regionshrinks the trust radius 4x on every rejectedstep with no lower bound, and holds it in a local variable. Once it has
collapsed the minimizer takes infinitesimal steps far from any minimum: the loss
freezes while the gradient is still large. A fresh
minimize()call resets theradius, which is why restarting from a stalled point resumes the descent — the
effect of chaining
--externalPostfitby hand.fit()now does this itself: on an early-stopping stall, restart from thestalled point, keep restarting for as long as the loss keeps coming down, and
stop only once a restart no longer reduces it. The preconditioner is rebuilt at
each restart point, since the transform whitens the Hessian where it was built
and by the time the fit has stalled elsewhere that Hessian has changed
(measured: 3x worse conditioning at the restart point than at the start).
Measured: a fit frozen at loss 91596 reached 11252 through four automatic
restarts, matching the minimum the 145-minute run found.
Results
The cleanest measurement is the 4-dimensional fit (74864 bins, the distribution
the analysis actually intends to fit). Same input, same
-t 0 --noHessian --noEDM:The same minimum, 17.7x faster for the single-block transform: it and the
unpreconditioned fit agree to 13 significant figures on the loss and exactly on
2*deltaNLL, with the preconditioned one converged an order of magnitude more
tightly (25 iterations instead of 93; the unpreconditioned run's last iterations
cost 3294 s each).
The third row is the current default (
--preconditionwithautoblocks) and itis not the same comparison: it walks a different path and ends in a different,
slightly deeper minimum (2deltaNLL lower by 1.2), taking 2.6x longer to get
there. So no speed ratio should be quoted for it. That auto-blocking finds a
deeper minimum than the joint transform is consistent with the 2D picture below,
where this likelihood is shown to have several minima a few units apart in
2deltaNLL and a reparameterisation changes which one is reached. Use
--preconditionBlocks noneto reproduce the 19.1 min row.The 2-dimensional projection of the same analysis, for reference:
--precondition--precondition, no restartThere the runs land on different minima — the likelihood has several separated
by ~3 in 2*deltaNLL, and a reparameterisation changes which one the optimiser
walks to, though it provably cannot move any of them. All are genuine minima:
note the preconditioned fits reach EDM ~1e-16 where the unpreconditioned one
stops at 7e-11, i.e. the speedup does not come at the cost of convergence. The
4D comparison above has no such ambiguity.
The same input also has a pre-existing offline basis-orthogonalisation applied
in the histmaker; that took 179 minutes to reach 22513.30, so preconditioning in
rabbit is ~12x faster than the analysis's previous best in 2D (~18x in 4D) and
removes the need to regenerate a basis file whenever the parameterisation
changes.
Things reviewers may want to push on
gaussnewtonis deliberately not the default. The Fisher matrix is PSDby construction and so cannot represent negative curvature. The exact Hessian
of this fit has 33 negative eigenvalues at the starting point; whitening with
Gauss-Newton left all 33 negative and the fit froze after 13 iterations, and
raising the ridge did not help. The ridge on the exact Hessian turns out to be
load-bearing for exactly that reason. Both docstrings record this.
It does not generalise either way: with restarts it was the fastest option
in 2D (5.7 min, EDM 6.5e-17) and clearly the worst in 4D (52 min and a higher
NLL than
hessian's 19.1 min), where the starting kappa is 4e8 against 1.3e5for the exact Hessian. Situational, hence not the default.
reference (22504.78 vs 22499.28), and the ordering across runs tracks how
many restarts were taken. A pure reparameterisation cannot move the minimum,
so this is about where each run stops, not where the minimum is. Note the
obvious check — rerun with
--earlyStopping -1and let scipy converge — doesnot work: see below.
gtolapplies to||grad||,which is not invariant under the transform, whereas EDM is.
scipy does not terminate on its own
Worth stating plainly, because it motivates the defaults.
_minimize_trust_regionhas no termination test for a collapsed trust radius, and
maxiterdefaults to200*nparams. Measured on this 3603-parameter fit with--earlyStopping -1:the loss froze after 63 seconds at 11427.90, and the following 720 iterations
changed nothing, running at ~10 iterations/second — which projects to twenty
hours before
maxiterwould end it. It was killed at 3 minutes.So a stalled fit does not eventually converge, and it does not error out either:
it spins. That is why
--earlyStoppingis now on by default (20) — with restartsit means "restart here", not "give up here" — and it is why the honest reading of
the table above is that every one of these numbers is a stopping point rather
than a converged minimum.
Backwards compatibility
--earlyStoppingmoves from-1(off) to20, and--maxRestartsdefaultsto
-1(restart while the loss keeps improving). Together a stall now means"restart and continue" where it previously meant "keep spinning" (see above).
--earlyStopping -1 --maxRestarts 0restores the old behaviour exactly.Happy to make either opt-in instead if the team prefers.
block, with the per-block detail moved to debug: with auto-blocking a run has
hundreds of blocks, not the one it had when that line was written.
Previously the bare
except Exceptionswallowed everything, so a brokencallback looked exactly like a converged fit.
Testing
44 tests across
tests/test_preconditioner.pyandtests/test_restart.py;65 pass across the suite. The load-bearing ones:
uncertainties and NLL to an unpreconditioned one, for both
trust-krylov(hessp path) and
trust-exact(dense hess path), on a deliberatelyill-conditioned model (correlation condition number 1.3e3 -> 1.0).
hesspagainst thedense transform, cached-inverse fast path against the triangular fallback.
back to running unpreconditioned rather than failing the fit.
model tripping a threshold, since TF's multithreaded CPU reductions are not
bitwise reproducible and an earlier version of it was observed to both pass
and fail on identical code.
smallest eigenvalues at -3% of
max|diag|, i.e. between the old ladder'srungs, which the previous schedule skipped; and on a rank-deficient block with
an exact linear dependence.
a strong correlation is injected between them, isolates a parameter with no
curvature, and percolates into one block below the threshold — the documented
failure mode is tested rather than assumed away.
Not included
Absolute-eigenvalue whitening (
V |Lambda|^-1/2) for indefinite Hessians, whichwould subsume the ridge and handle the negative-curvature directions explicitly;
and passing the same factor as
M=to thescipy.sparse.linalg.cgused in theHessian-free covariance path.
🤖 Generated with Claude Code