Skip to content

Precondition the fit parameters, and restart a stalled minimizer - #150

Merged
lucalavezzo merged 8 commits into
WMass:mainfrom
davidwalter2:260818_preconditioner
Aug 20, 2026
Merged

Precondition the fit parameters, and restart a stalled minimizer#150
lucalavezzo merged 8 commits into
WMass:mainfrom
davidwalter2:260818_preconditioner

Conversation

@davidwalter2

@davidwalter2 davidwalter2 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

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-krylov solves its subproblem with a Krylov
method whose inner iteration count grows like sqrt(kappa), and the
correlation 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 y with T = L^-T from the Cholesky of a
reference Hessian restricted to a selected block, so T^T H0 T = I there.

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 need
to — a spherical trust region in y is an H0-aligned ellipsoid in theta,
which is exactly the desired effect.

It is confined to the three scipy callbacks in fit(); self.x holds physical
parameters 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:

  • Cholesky is O(m^3), so 240 blocks of ~12 cost ~4e-5 of one block of 2112.
  • A union of individually fine groups can be singular. On the combined W+Z
    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 or
systematic 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.

auto is the default so that --precondition on its own does something sensible
without the user having to know which of their parameters are badly correlated;
expressions (one block per --preconditionParams entry) and none (one joint
block, 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 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 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.

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.

2. --maxRestarts (on by default)

Independent of preconditioning, and useful for any trust-region fit.

scipy's _minimize_trust_region shrinks the trust radius 4x on every rejected
step 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 the
radius, which is why restarting from a stalled point resumes the descent — the
effect of chaining --externalPostfit by hand.

fit() now does this itself: on an early-stopping stall, restart from the
stalled 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:

configuration wall-clock 2*deltaNLL final loss EDM
no preconditioning 338 min 73118.71 36559.35666339957 5.3e-15
preconditioned, one block 19.1 min 73118.71 36559.35666339955 3.7e-16
preconditioned, auto blocks 50 min 73117.52 36558.7587 -

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 (--precondition with auto blocks) and it
is 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
2
deltaNLL and a reparameterisation changes which one is reached. Use
--preconditionBlocks none to reproduce the 19.1 min row.

The 2-dimensional projection of the same analysis, for reference:

configuration wall-clock 2*deltaNLL EDM
no preconditioning 145.5 min 22499.28 7.0e-11
--precondition 14.3 min 22504.78 7.3e-16
--precondition, no restart 4.6 min 22509.84 -

There 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

  • gaussnewton is deliberately not the default. The Fisher matrix is PSD
    by 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.3e5
    for the exact Hessian. Situational, hence not the default.
  • The preconditioned runs still stop slightly above the unpreconditioned
    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 -1 and let scipy converge — does
    not work: see below.
  • Convergence thresholds are basis-dependent: gtol applies 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_region
has no termination test for a collapsed trust radius, and maxiter defaults to
200*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 maxiter would 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 --earlyStopping is now on by default (20) — with restarts
it 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

  • Preconditioning is off by default; existing fits are unchanged.
  • Two default changes, both behaviour-affecting:
    --earlyStopping moves from -1 (off) to 20, and --maxRestarts defaults
    to -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 0 restores the old behaviour exactly.
    Happy to make either opt-in instead if the team prefers.
  • Preconditioner logging is one summary line per build rather than one line per
    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.
  • A minimizer exception that is not a stall is now logged at warning level.
    Previously the bare except Exception swallowed everything, so a broken
    callback looked exactly like a converged fit.

Testing

44 tests across tests/test_preconditioner.py and tests/test_restart.py;
65 pass across the suite. The load-bearing ones:

  • Invariance: a preconditioned fit reaches identical parameters,
    uncertainties and NLL to an unpreconditioned one, for both trust-krylov
    (hessp path) and trust-exact (dense hess path), on a deliberately
    ill-conditioned model (correlation condition number 1.3e3 -> 1.0).
  • Algebra: whitening, chain rule vs finite differences, hessp against the
    dense transform, cached-inverse fast path against the triangular fallback.
  • Degenerate blocks, singular blocks and an unformable reference matrix all fall
    back to running unpreconditioned rather than failing the fit.
  • The rebuild-before-restart test forces the stall rather than relying on the
    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.
  • The spectrum-derived ridge is tested on a block deliberately built with its
    smallest eigenvalues at -3% of max|diag|, i.e. between the old ladder's
    rungs, which the previous schedule skipped; and on a rank-deficient block with
    an exact linear dependence.
  • Auto-blocking recovers a hidden block structure exactly, merges two groups when
    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, which
would subsume the ridge and handle the negative-curvature directions explicitly;
and passing the same factor as M= to the scipy.sparse.linalg.cg used in the
Hessian-free covariance path.

🤖 Generated with Claude Code

davidwalter2 and others added 5 commits August 18, 2026 16:09
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>
davidwalter2 and others added 3 commits August 19, 2026 10:04
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
@lucalavezzo
lucalavezzo merged commit 0ae4d3c into WMass:main Aug 20, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants