Skip to content

Fit with least_squares instead of L-BFGS-B - #45

Merged
brmather merged 4 commits into
masterfrom
optimiser-least-squares
Jul 29, 2026
Merged

brmather merged 4 commits into
masterfrom
optimiser-least-squares

Conversation

@brmather

@brmather brmather commented Jul 27, 2026 •

Copy link
Copy Markdown
Owner

Every Bouligand fit now goes through one private method, _fit, which is scipy.optimize.least_squares (trust-region reflective) on the existing vector-valued residuals rather than minimize on the scalar min_func.

Why

The problem is a sum of squares and residuals already exposed the vector, so TRF can use structure L-BFGS-B cannot see. Over six synthetic 200 km windows, BLAS pinned to one thread:

stage L-BFGS-B _fit
optimise 12.5 ms 5.0 ms
profile("dz") 101.6 ms 33.0 ms
residual evaluations / vertex 2012 359

About 3x, on 5.6x fewer evaluations of the forward model.

On benchmarking this. An earlier revision of this PR claimed 240-340x. That was wrong. L-BFGS-B calls a threaded BLAS whose workers spin-wait, and time.process_time charges every spinning thread — so on a contended machine the same comparison reads anywhere from 20x to 400x depending on how many cores are busy. Pin OMP_NUM_THREADS/OPENBLAS_NUM_THREADS/MKL_NUM_THREADS and both wall and CPU clocks agree. The evaluation count is the number that cannot be distorted.

Answers

  • fitted parameters agree with L-BFGS-B to three or four figures on windows that constrain the fit, and bit-for-bit on real band-limited EMAG2 spectra
  • the four dz intervals pinned in the tests are the values L-BFGS-B produced
  • over cached EMAG2 spectra in a downstream project, 0 of 108 profile intervals across three window sizes and three targets excluded their point estimate

Where the likelihood is flat the two can disagree by a lot — dz of 310 km against 139 km for a misfit difference of 2e-6 on one 100 km synthetic. Both answers are equally good there; that is a property of the window, not of this change.

Analytic Jacobian

zt and C enter the forward model linearly, so dr/dzt = -2k/sigma and dr/dC = 1/sigma are exact; only beta and dz are differenced. A small cache lets the Jacobian reuse the residual least_squares just evaluated — the columns alone gave 1.32x where together they give 1.51x.

beta enters through the order of a Bessel function and has no closed-form derivative. dz has one, but it costs an extra kv call, exactly what the difference it would replace costs. _covariance and _jacobian stay on central differences: 0.16 ms, and the calibrated sigmas depend on them.

Behavioural notes

  • least_squares refuses lb == ub, which L-BFGS-B accepted by pinning the parameter. _fit widens a collapsed bound to the narrowest numerically distinct box. An inverted bound (lb > ub) is left to raise — it is a typo, and bounds is documented as reassignable.
  • Two tests drove the fit by monkeypatching min_func, which nothing calls now. The sensitivity one patches _fit and counts fits instead, so it still blows up inside the simulation loop where the prior is copied.
  • Downstream: anything reimplementing optimise's body with minimize will diverge. Global_CPD's fit_spectrum did, and its exact fit_spectrum == optimise assertion failed until it was routed through _fit too.

Testing

The Jacobian is tested directly, by capturing the callable _fit hands scipy and differencing the residual it hands it alongside — in the unconstrained, dz-held and dz = CPD - zt cases, with priors active. This is not optional rigour: TRF converges from a wrong Jacobian too, so deleting the Curie chain-rule term or flipping its sign passed the entire suite before this test existed.

Suite: 100 -> 111 tests, 293 s -> 21 s.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BLGk2RXegPShhDRHcH7FtL

brmather and others added 4 commits July 27, 2026 10:08
Every Bouligand fit now goes through one private method, `_fit`, which is
`scipy.optimize.least_squares` (trust-region reflective) on the existing
vector-valued `residuals` rather than `minimize` on the scalar `min_func`.

The problem is a sum of squares and `residuals` already exposed the vector,
so TRF can use structure L-BFGS-B cannot see. The difference is not
marginal. Measured in CPU time -- wall clock is useless on a shared box --
over synthetic windows, L-BFGS-B spent 3.1 s on the four-parameter fit and
16.5 s on a `dz` profile against 13 ms and 49 ms here. Its cost did not
track the number of function evaluations at all (34x spread at equal
counts), so the time was going into its own machinery, not the forward
model. The objective was 1-3% of a profile.

Answers are unchanged, which is the reason to do it:

  - fitted parameters agree to four figures on 8/8 synthetic seeds, and
    where they differ least_squares finds the lower misfit
  - the four `dz` intervals pinned in the new test are the values L-BFGS-B
    produced
  - over cached EMAG2 spectra in a downstream project, eight vertices at
    2000 km reproduce stock exactly, and 0 of 108 profile intervals across
    three window sizes and three targets excluded their point estimate

`_fit` also supplies the two exact Jacobian columns -- `zt` and `C` enter
the forward model linearly, so d r/d zt = -2k/sigma and d r/d C = 1/sigma --
and differences only `beta` and `dz`, with a small cache so the Jacobian
reuses the residual `least_squares` just evaluated. Both are needed: the
columns alone, recomputing the base, gave 1.32x where together they give
1.51x. `beta` enters through the *order* of a Bessel function and has no
closed-form derivative; `dz` has one, but it costs an extra `kv` call,
exactly what the difference it would replace costs.

`_covariance` and `_jacobian` are deliberately left on central differences.
They are 0.16 ms, and the calibrated sigmas depend on them.

One behavioural difference: `least_squares` refuses `lb == ub`, which
L-BFGS-B accepted by pinning the parameter. `_fit` widens a collapsed bound
to the narrowest numerically distinct box, which pins it just as effectively
and stays close enough to the edge for `_warn_on_bounds` to fire.

Two tests drove the fit by monkeypatching `min_func`, which nothing calls
now. The sensitivity one patches `_fit` and counts fits instead, so it still
blows up inside the simulation loop where the prior is copied; the
Metropolis one is unaffected, since `log_posterior` still uses `min_func`,
but its docstring explained a discrepancy that no longer exists.

Suite: 100 -> 107 tests, 293 s -> 12 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BLGk2RXegPShhDRHcH7FtL
Adversarial review found three things.

**The performance numbers were an artefact.** The 3.1 s / 16.5 s figures
came from an unpinned BLAS: L-BFGS-B calls a threaded one whose workers
spin-wait, and `time.process_time` charges every spinning thread, so on a
contended machine the same comparison reads 20x to 400x depending on load.
Pinned to one thread it is **3x** -- 12.5 ms against 5.0 ms for a fit,
102 ms against 33 ms for a `dz` profile. The durable number, which no
threading can distort, is evaluations of the forward model per vertex:
2012 against 359. CLAUDE.md said to prefer `process_time` over wall clock;
that is the advice that produced the wrong number, and it now says to pin
the BLAS instead.

The change is still worth making. It is 3x and 5.6x fewer model
evaluations, not two orders of magnitude.

**The Curie chain rule had no test.** Deleting
`transform[fixed[0], free.index(1)] = -1.0` or flipping its sign passed the
entire suite, because trust-region reflective converges from a wrong
Jacobian too -- just by a different route, for a few extra evaluations. The
old test asserted `curie.cost == min_func(...)`, which tests `expand()` and
holds for any Jacobian at all. Replaced with a test that captures the
callable `_fit` hands scipy and differences the residual it hands it
alongside, in all three configurations and with priors active. Both
mutations now fail it.

**An inverted bound was silently accepted.** The widening predicate was
`upper <= lower`, so `bounds[2] = (30.0, 10.0)` -- a typo, and `bounds` is
documented as reassignable -- was rewritten to `[30, 30+3e-11]` and pinned
`dz` at 30 with no error, where L-BFGS-B raised. Now `==` only, and an
inverted bound reaches `least_squares` and raises.

Also replaced `test_fit_cost_is_min_func`, which was a tautology -- both
sides are half the sum of squares of the same vector, and it survived a
mutation returning `2 * res.cost` from `_profiled_misfit`, the exact failure
its docstring claimed to guard. It now checks that profiling a parameter at
its own fitted value recovers the unconstrained misfit, which pins the
scale.

Suite 107 -> 111.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BLGk2RXegPShhDRHcH7FtL
The four pinned intervals failed on macOS at rtol=2e-3, by up to 1.3%, with
identical code -- the only CI leg that failed.

An interval endpoint is where `brentq` crosses the threshold on a deviance
curve that is nearly flat there, which is the whole reason dz needs a profile
rather than a sigma. A last-ulp difference in `kv`, or in the FFT behind the
synthetic, moves the crossing far more than it moves the fit. Pinning to
0.2% was asserting on the platform's libm, not on this package.

5%, which is ~4x the observed cross-platform spread. Still load-bearing:
a sign-flipped zt column, zeroed finite-difference columns, and a
`_profiled_misfit` off by a factor of two each fail all four cases at the
new tolerance. Those move an endpoint by tens of percent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BLGk2RXegPShhDRHcH7FtL
Four parallel cleanup reviews. Applied:

**The affine map had two implementations.** `transform` said the `dz` row
differentiates through `zt`; `expand` separately hand-coded
`x[fixed[0]] = value - x[1]`. Same relation, two representations, kept in
step by review -- and an inconsistency between them is close to invisible,
because the fit converges from a wrong Jacobian anyway, just by a longer
route. `expand` is now `transform @ y + offset`, so the Jacobian is the
derivative of the expansion by construction. (The matmul stays: measured at
0.98 us against 1.55 us for column indexing, and indexing cannot express the
chain-rule term.)

**The Jacobian rebuilt its constant part on every call.** The analytic
columns depend only on `kh` and `sigma`, and the prior rows only on the prior
-- neither on `x`. Hoisting them out drops the lazy `base = None` guard and a
four-line comment about not double-counting prior rows, because the
differenced columns no longer touch those rows at all.

**`needed` probed the matrix it had just built** -- four `ndarray.any()`
calls on 3-element rows, measured at 4.4-6.2 us per fit, over half the
per-call setup. It is `free` minus the closed-form columns, plus the held one
under the Curie constraint, which is also what it means.

**The residual cache kept four entries for a one-deep pattern.** Instrumented
over six seeds: `residuals` calls are exactly `nfev + 2*njev`, so the base
lookup hits every time and nothing else ever does. The comment justified the
extra entries by rejected steps being retried, which trust-region reflective
does not do -- a shrunken region gives a different point. One entry, same
hit rate. (The cache itself earns its keep: ~0.1 ms of hashing against 9-27 ms
of forward model per profile.)

**`_bound_arrays`** -- `_fit` and `metropolis_hastings` had each written the
`None`-means-infinite unpacking, and had already drifted on dtype.

**`_ANALYTIC_COLUMNS` keyed by name** rather than by position, so
`_PARAMETERS` order is encoded once. Same for `free.index(_ZT)`.

Tests: the Jacobian spy no longer runs a fit it discards; the FD reference
step uses `_JACOBIAN_STEP` instead of a literal, which would otherwise let a
retuned constant pass a test that no longer measures the scheme; parametrised
ids; and `assert calls["n"] > 2` is gone, since `exploding` raises on exactly
that condition and the assertion could not fail.

Mutation-checked after the refactor: chain-rule dropped and sign-flipped,
`zt` column sign-flipped, prior rows zeroed, `_profiled_misfit` doubled, and
the new `differenced` list missing its Curie entry all still fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BLGk2RXegPShhDRHcH7FtL
@brmather
brmather merged commit 880e4aa into master Jul 29, 2026
14 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.

1 participant