refactor: switch from argmin to basin, pin MSRV - #32
Conversation
|
@jolars is attempting to deploy a commit to the GymPiper Team on Vercel. A member of the Team first needs to authorize it. |
|
@jolars would you also migrate the |
|
I'd be happy to. Would you like it in this PR or as a separate one? |
|
Let's do it separately. One thing that can be interesting: the current crate is using |
|
Yes, you are right. Hm, I'll update Basin's implementation to either make this configurable or just use QR instead. |
dancixx
left a comment
There was a problem hiding this comment.
Box constraints are not actually enforced by basin 1.9.0's L-BFGS-B
Thanks for the migration — the benchmark numbers are a real win. Before merging, though, there is one issue I think is blocking, and it lands specifically on the fitting paths.
This PR removes the defensive clamp() from cost()/gradient() in three places (mle/fit.rs, hurst/whittle.rs, vol_surface/sabr_smile/objective.rs) on the assumption that L-BFGS-B keeps its iterates inside the box. basin 1.9.0 does not honour that assumption.
Root cause
basin/src/solver/lbfgs.rs:608-644 computes the Fortran lnsrlb feasibility cap and then throws it away:
// Maximum feasible step (Fortran lnsrlb `stpmx`).
let stpmx = if cnstnd { ... feasible_step_cap(...) } else { ... };
...
// `LineSearch` has no generic hooks for the initial step or the
// feasibility cap used by Fortran's `lnsrlb`.
let _ = (alpha_init, stpmx);There is no projection of state.param back onto the box after the line search either — only the initial iterate is projected, in Solver::init. So whenever Moré–Thuente extrapolates past stp = 1 (i.e. whenever the objective is still descending at the bound, which is exactly the active-constraint case), the iterate leaves the box.
Reproduction
Minimal program against basin = "1.9.0", minimising (x - c)^2 over the box [0, 1]^2 from [0.5, 0.5]:
c |
returned param() / best_param() |
box violation |
|---|---|---|
| 5 | [1.0, 1.0] |
0 |
| 8 | [3.0, 3.0] |
2.0 |
| 50 | [3.0, 3.0] |
2.0 |
This is not caused by the line-search settings introduced here: Lbfgsb::default() (basin's own MoreThuente::new(), stpmax = 1e10) produces the identical [3.0, 3.0]. basin's own shifted_quadratic_in_box_converges_to_clamp test passes only because there the unconstrained optimum sits just barely outside the bound.
What this breaks here
hurst/whittle.rscan return an out-of-box Hurst exponent. Measured on this branch: 4 of 24 synthetic log-RV series returnedHoutside the declared[0.005, 0.495], the worst beingH = 0.5507. Inline comment below.convergedcan be reportedtrueat a non-stationary, infeasible point, because the crate's own bound-guarded finite difference returns a zero gradient outside the box. Inline comment below.sabr_smile/objective.rshas a finite-difference scaling bug introduced by the same change. Inline comment below.
Suggested fix
The cheapest fix that keeps the migration is to restore the clamp() in cost()/gradient() at all three sites, plus the problem.clamp(&best_p) at the end of whittle.rs::run_lbfgs. It is redundant if the solver honours the box — which is precisely why the pre-existing code had it. Filing an upstream issue against basin for the discarded stpmx would be worth doing in parallel.
Minor, non-blocking
- The Rust workflow has not run on this PR —
gh pr checks 32shows only a Vercel check — sotest,lintand the newmsrvjob are all unverified. Worth a maintainer approval run:stochastic-rs-quant/src/calibration/tree_swaption/tests.rs:63assertsresult.converged, and theconvergedsemantics tightened here (previously any non-Errexecutor outcome counted as converged; now onlySimplexTolerancedoes). .claude/skills/dev-rules/SKILL.md:53and.claude/skills/calibration-pattern/SKILL.md:183,204still recommendargmin. Easy to miss, sincegrep -rskips hidden directories.SimplexStandardDeviationis now defined twice — incalibration.rsand inportfolio/optimizers/mod.rs— with slightly different validation (Optionvsdebug_assert!).
Things I checked that are fine
The .expect("… is infallible") calls are sound (Solver::Error = P::Error = Infallible, so run()'s error arm is uninhabited); basin's Nelder-Mead coefficients (α=1, β=2, γ=0.5, δ=0.5) match argmin's; the portfolio fallback is behaviour-preserving because softmax(0) = 1/n equals the old vec![1.0 / n; n]; more_thuente() faithfully reproduces argmin's line-search defaults; and CostTolerance is a cost-change test rather than a cost-level one, so it does not fire immediately on negative log-likelihoods.
| .run() | ||
| .expect("Whittle objective is infallible"); | ||
| let best = result.best_param(); | ||
| (best[0], best[1], problem.eval(best)) |
There was a problem hiding this comment.
run_lbfgs no longer clamps its result. It used to be:
let best_p = res.state.get_best_param().cloned().unwrap_or(init);
let clamped = problem.clamp(&best_p);
let cost = problem.eval(&clamped);
(clamped[0], clamped[1], cost)Since basin's L-BFGS-B does not keep iterates inside the box (details in the review comment), best can lie outside self.lower / self.upper. run_estimate at line 428 then assigns it straight into best_h, and neither FukasawaResult nor the HurstEstimator wrapper at line 111 validates the range — so estimate() can return H >= 0.5, which this parameterisation does not admit, and eta = best_v / delta.powf(best_h) is corrupted along with it.
Measured on this branch over 24 synthetic log-RV series: 4 returned an out-of-box H, the worst being
seed 7: OUT OF BOX H = 0.5506779013063017 eta = 0.27242376415994
Restoring the clamp here — and in WhittleProblem::eval / WhittleProblem::gradient — fixes it.
There was a problem hiding this comment.
Control run, to confirm this is a regression rather than something that was already possible: the identical probe, same 24 seeds, same code, built against main (b8c20fa, still on argmin):
main (argmin): out-of-box estimates: 0 / 24, max H seen: 0.495
this PR (basin): out-of-box estimates: 4 / 24, max H seen: 0.5506779013063017
On main the maximum is exactly 0.495 — the declared upper bound — because run_lbfgs clamped its result before returning it. On this branch the same series produce H > 0.5.
There was a problem hiding this comment.
Sorry about this!
I'm tracking the L-BFGS-B problem in jolars/basin#90 and will update basin after solving it.
dancixx
left a comment
There was a problem hiding this comment.
Two additions after a deeper pass. The first is more serious than anything in my earlier review: the removed clamp_params in the SABR smile objective opens a reachable panic that does not depend on the box-escape issue at all — the calibrator's own hard-coded x0 is outside the bounds it declares, and clamp_params was what made that survivable. The second is a note about the MSRV pin's effect on dependency resolution. Both inline.
dancixx
left a comment
There was a problem hiding this comment.
Swept the parts my earlier passes had not touched — the README/website doc edits and all nine Cargo.toml files. Those are clean: every workspace member picked up rust-version.workspace = true, argmin/argmin-math are correctly dropped rather than swapped in stochastic-rs-copulas and stochastic-rs-stochastic (they carried it as an unused dependency), basin is added only to the two crates that actually optimize, default-features = false is right since basin's default problems feature is only its test problem set, and the doc wording matches the new code. One small thing found, inline.
dancixx
left a comment
There was a problem hiding this comment.
Final pass — this closes out my review. Four more inline comments, then two items that fall outside the diff so they have nowhere to hang, and finally the list of things I checked and cleared, which is the more useful half if you are working through this.
Outside the diff, but caused by it
website/content/docs/quant.mdx:204-208 documents the Python return tuples for the three rate calibrators:
print(bk.calibrate(initial_guess=(0.1, 0.2))) # (a, sigma, rmse, converged)
print(hw.calibrate()) # (a, sigma, rmse, converged) via Jamshidian
print(g2.calibrate()) # (a, b, sigma, eta, rho, rmse, converged)That last element changes meaning in this PR — from "the run completed" to "the simplex SD fell below sd_tolerance". A Python user branching on converged will start getting False on grids that previously returned True. This PR already edits lines 21, 22, 28 and 56 of that same file, so it is in touched territory; it needs a note here, in the PR description, or in a CHANGELOG.
stochastic-rs-quant/src/python/calibration_basic.rs:220 is a fourth Python surface leaking the same flag — PySabrCapletCalibrator.calibrate() returns (alpha, beta, nu, rho, rmse, converged). It matters more than the other three because SabrCapletCalibrator carries the largest budget (max_iters: 600, sd_tolerance: 1e-10), so it is the most likely of the four to exhaust its iterations.
Checked and cleared — no action needed
.unbounded()is a faithful swap for the old plainLBFGS. H₀ scaling is identical:basin/src/core/state/lbfgs.rs:289isself.theta = yy_dot / sy_dot, soH₀ = (1/θ)·Iequals argmin'sgamma = sk·yk / yk·yk. History sizem = 10is unchanged, and line-search-failure handling is equivalent (SolverFailedstill yieldsbest_param(), as argmin'sSolverExitdid). One real divergence, in your favour: basin applies the Fortran curvature skip (dr > epsilon * |ddum|) where argmin pushed every(s, y)pair unconditionally. Polished estimates will shift slightly from the released version — a release note, not a defect.distfit.rs/garch.rsconvergednever comes from the basin polish.minimisereturnsconverged || converged_restartfromnelder_mead_vec(distfit.rs:97,garch.rs:318);polishreturns only(theta, iters). Sotests/doctest_stats_garch.rs:31anddoctest_stats_distfit.rs:20,26are safe, andpython/distfit.rs:72,81,90/python/garch.rs:48are unaffected by theconvergedchange.- The
iterssemantics change is unobservable. Only the deletedErr(_) => (theta, 0)arm differs, anditers_polishis summed with two simplex runs, sogarch.rs:227and:229both remain accurate. from_simplex'sassert!(len >= 2)replaces no graceful path. argmin'sNelderMead::next_iterindexesparams[num_param_vecs - 2], so a 1-vertex simplex panicked there too — basin's assert is simply the better message. All call sites build 3-6 vertices from literals, andn == 0is guarded in all five portfolio entry points.SabrSmileCalibratoris Rust-only — noSabrSmilesymbol under anysrc/python/, so the panic findings do not reach the Python surface.- The doc and manifest edits are correct and complete. Every workspace member picked up
rust-version.workspace = true;argmin/argmin-mathare rightly dropped rather than swapped instochastic-rs-copulasandstochastic-rs-stochastic, which carried them as unused dependencies;basinis added only to the two crates that optimize;default-features = falseis correct, since basin's defaultproblemsfeature is only its own test problem set. - The long-short portfolio fallback is behaviour-preserving. Deleting
let x0 = vec![0.0; n]looks like it changes the fallback, buttanh_weights(zeros)takes theabs_sum < 1e-15branch athelpers.rs:59and returnsvec![1.0/n; n]— byte-identical to the old value.
| .with_c(1e-4, 0.9) | ||
| .expect("Wolfe params (1e-4, 0.9) satisfy 0 < c1 < c2 < 1 by construction"); | ||
| let solver = LBFGS::new(linesearch, 10); | ||
| let linesearch = MoreThuente::new() |
There was a problem hiding this comment.
Two things worth reconsidering about this loop, now that basin is the dependency.
It hand-rolls what basin already exports. basin::BasinHopping, basin::RandomDisplacement and basin::Metropolis are all re-exported at the crate root (basin/src/lib.rs:417-424). Line 164's uniform jitter is RandomDisplacement, and lines 193-199 are Metropolis. .claude/skills/dev-rules/SKILL.md:53 is explicit about this: "Do not rewrite algorithms that already exist in well-maintained crates ... only write custom code when no suitable crate exists." Migrating to the crate's own basin-hopping would have been the natural shape of this PR; keeping the hand-rolled loop is a defensible choice, but it should be a stated one.
The per-hop rebuild is new cost. Lines 168-185 sit inside for _ in 0..niter, so the line search, the solver and both termination criteria are constructed once per hop — and because this PR widened bounds_lo/bounds_hi from [f64; NVARS] to Vec<f64>, problem.bounds_lo.clone() / bounds_hi.clone() now heap-allocate on every iteration. With the default short_tenor_iters that is a few thousand allocations per smile that the pre-PR fixed-size arrays never made. Hoisting the solver construction out of the loop, or using ProjectedGradientTolerance::from_problem, removes it.
Related: the registered ProjectedGradientTolerance duplicates a check Lbfgsb already runs internally each iteration (basin/src/solver/lbfgs.rs:405-420, its own tol_pg), and clones state.param() on every check. Lbfgsb::with_tol_pg(f64::EPSILON.sqrt()) expresses the same intent allocation-free. Same pattern at whittle.rs:368 and mle/fit.rs:225.
There was a problem hiding this comment.
I've kept the outer loop to preserve the RNG stream and failed-hop behavior, and documented why. The local solver is now reused through InnerExecutor, and all three fits use with_tol_pg.
|
Thanks for the thorough review. The fixes are in commit 28d34dc. I've also documented the changed All 70 affected tests pass, including the tree-swaption assertions. The Rust 1.89 check, Clippy, formatting, and docs builds pass locally too. The GitHub Actions run still needs maintainer approval. |
9c251ee to
dd720e0
Compare
|
Okay, I've solved the bounds problem in Basin and updated the dependency here. It's ready for another review! |
dancixx
left a comment
There was a problem hiding this comment.
Re-reviewed at dd720e01. Every finding from my earlier reviews is addressed, and the root cause is now fixed upstream as well. I verified the fixes rather than taking them on faith — details below, including the version bump, which I treated as new surface since my whole earlier review was against basin 1.9.0.
The root cause is gone, and the regression with it
Bumping to basin 1.10.0 fixes the discarded feasibility cap I reported. solver/lbfgs.rs now actually threads it through:
let stpmx = safeguard_step_cap(&state.param, ..., stpmx);
self.line_search.next_with_bounds(..., LineSearchBounds::new(alpha_init, stpmx))?Re-running my original standalone reproduction ((x-c)² over [0,1]² from [0.5, 0.5]) against 1.10.0 — every case that escaped before is now feasible:
c |
1.9.0 | 1.10.0 |
|---|---|---|
| 8 | [3.0, 3.0] — escaped |
[1.0, 1.0] |
| 50 | [3.0, 3.0] — escaped |
[1.0, 1.0] |
| 100000 | [3.0, 3.0] — escaped |
[1.0, 1.0] |
And the end-to-end Whittle regression I measured earlier is closed. Same probe, same 24 seeds:
main (argmin): 0 / 24 out of box, max H = 0.495
PR @1560c6cf (basin 1.9): 4 / 24 out of box, max H = 0.5507
PR @dd720e01 (fixed): 0 / 24 out of box, max H = 0.495
Exactly main's behaviour, with 0.495 — the declared bound — as the maximum.
What I checked about the version bump
Since 1.10.0 is a substantial release (new LineSearch::next_with_bounds, rewritten COBYLA, reworked math backends), I checked the surfaces this PR actually uses:
MoreThuente::searchis byte-identical between 1.9.0 and 1.10.0, andnext_with_evaluationjust calls it. So the.unbounded()polish paths ingarch.rsanddistfit.rsare unaffected by the bump.- The
Lbfgs<Unbounded>solver impl is byte-identical too (diffof the whole impl block: no output). .stpmax(f64::INFINITY)is now inert where it mattered.next_with_boundsdoeslet max = self.stpmax.min(bounds.max), so the per-iteration box cap always dominates. On the unbounded path it behaves exactly as argmin's default did. That fully retires my earlier concern about this setting.- basin's MSRV is still 1.87, so the 1.89 pin continues to cover it.
Verified rather than assumed
InnerExecutorreuse across hops is safe. This was my main suspicion about hoisting the solver out of the loop, sinceCostToleranceholds alast: Option<F>.run_loop(core/executor.rs:493) callscriterion.reset()at the start of every run, and theInnerExecutordocs make that a documented guarantee. No state bleeds between hops.- The
None => SolverFailedarm cannot misfire at iteration 0. I was concerned the criterion might seefrom_simplex's initialvec![inf; n]costs. It cannot:solver.init()runs before the loop and before any criterion check, so the simplex is populated with real costs first. - The scaling in
sample_standard_deviationsolves the right problem.SabrCapletCostandHullWhiteCostreturnf64::MAXas an out-of-domain sentinel — finite, but its squared deviation would overflow to infinity and produce exactly the NaN I flagged. Dividing byscalefirst closes that, andsample_standard_deviation(&[f64::MAX, f64::MAX]) == Some(0.0)pins it. - Dropping
rho.clamp(-0.99, 0.99)/nu.max(0.01)incalibrate.rsis correct, not a lost guard. I traced every assignment tobest_x— the initialclamp_params(&x0), andcurrent_x = paramwhereparam = clamp_params(result.param())— so it is clamped on every path includingniter = 0, andbounds_lo[4..6]are exactly0.01/-0.99. The four strikes are now in-box too, which they were not before. - Recomputing the cost at the clamped param in the hop loop, instead of reusing
result.cost(), is more correct than what I asked for —result.cost()was the cost at the unclamped iterate. - The SABR gradient fix goes past the report. I only flagged the
epsdenominator; this moves to a central difference with the realised step and astep > 0.0guard, matchingwhittle.rsandmle/fit.rs. - The extra
convergedgate infit_mle— invalidating convergence whenbest_paramsis infeasible — is a good addition I had not asked for.
The one thing rejected, correctly
Keeping the hand-rolled basin-hopping instead of basin::BasinHopping is the right call, and the new doc comment gives the reason: BasinHopping owns a ChaCha RNG, which would break the existing SimdRng stream and therefore reproducibility. That is a stronger argument than my dev-rules §7 point. The part that was a real cost — rebuilding the solver and heap-allocating both bounds vectors on every hop — is fixed by hoisting into InnerExecutor.
Test results
Run against dd720e01 on my machine:
| Command | Result |
|---|---|
cargo test -p stochastic-rs-stats --lib |
277 passed, 0 failed |
cargo test -p stochastic-rs-quant --lib |
1013 passed, 0 failed, 3 ignored |
cargo clippy -p stochastic-rs-stats -p stochastic-rs-quant --all-targets -- -D warnings |
clean — 0 warnings |
cargo test -p stochastic-rs --test doctest_quant_tree_swaption |
1 passed, 0 failed |
The 3 ignored are pre-existing #[ignore]d slow HSCM tests in files this PR does not touch. black_karasinski_recovers_synthetic_parameters — the test carrying assert!(result.converged), the tripwire I was most worried about for the tightened converged semantics — passes. I ran the umbrella-crate tree-swaption test separately because -p stochastic-rs-quant --lib does not build it; it passes too.
Two nits, neither blocking
.claude/skills/calibration-pattern/SKILL.md:211 now reads "Basin 1.9.0 can evaluate outside the box during its line search", but the workspace pulls 1.10.0, where that is fixed. Keeping the clamps is still right as defence in depth — the sentence just cites a version the project no longer uses. Something like "clamp regardless; the solver's box handling is not a guarantee you should depend on" would age better.
mle/fit.rs: the TerminationReason::ProjectedGradientTolerance arm in resolve_fit_outcome is now unreachable, since with_tol_pg reports SolverConverged instead. Harmless, and arguably worth keeping if the external criterion ever comes back.
Nothing else from my side. Nice work on the migration docs — the converged semantics change is now documented in three places a user might actually look.
dancixx
left a comment
There was a problem hiding this comment.
The two nits from my last review, as applicable suggestions. The second one grew slightly on closer inspection: it is three unreachable arms rather than one, and the convergence test is feeding resolve_fit_outcome a reason production can never produce — that last part is the only bit with any real substance. Neither is blocking; happy for you to take or leave them.
Maintainer edits are enabled on this branch, so say the word if you would rather I push these myself than have you click through them.
| under `portfolio/optimizers/`. Reuse `calibration::run_nelder_mead` | ||
| to preserve sample-standard-deviation stopping and convergence reporting. | ||
| Bounded objectives and finite differences must clamp their inputs: | ||
| Basin 1.9.0 can evaluate outside the box during its line search. |
There was a problem hiding this comment.
The clamps are the right call, but this sentence pins the reason to a version the PR no longer uses — the workspace now resolves basin 1.10.0, where next_with_bounds applies the feasibility cap and the box is respected (I verified this: my 1.9.0 escape reproduction returns feasible points on 1.10.0).
Since the advice should outlive any particular basin release, it reads better without the version claim:
| Basin 1.9.0 can evaluate outside the box during its line search. | |
| do not rely on the solver's box handling to keep iterates feasible. |
| TerminationReason::ProjectedGradientTolerance | ||
| | TerminationReason::GradientTolerance | ||
| | TerminationReason::CostTolerance | ||
| | TerminationReason::TargetCost | ||
| | TerminationReason::SolverConverged |
There was a problem hiding this comment.
Follow-up on my earlier note about the unreachable ProjectedGradientTolerance arm — having checked it properly, it is three arms, not one.
fit_mle registers exactly one criterion (CostTolerance, line 235) plus max_iter (234) and the solver's internal tol_pg (231). basin 1.10.0 reports internal projected-gradient convergence as SolverConverged (solver/lbfgs.rs:425, unchanged from 1.9.0), so the reachable set is:
| Reason | Source | Reachable |
|---|---|---|
CostTolerance |
registered criterion | yes |
SolverConverged |
solver tol_pg |
yes |
MaxIter |
max_iter(200) |
yes (not matched — correct) |
SolverFailed |
line-search failure | yes (not matched — correct) |
ProjectedGradientTolerance |
— | no criterion registered |
GradientTolerance |
— | no criterion registered |
TargetCost |
— | no criterion registered |
Narrowing to what can actually arrive also fails safe: if a criterion is added later without updating this list, the result is an under-reported converged, not a fabricated one.
| TerminationReason::ProjectedGradientTolerance | |
| | TerminationReason::GradientTolerance | |
| | TerminationReason::CostTolerance | |
| | TerminationReason::TargetCost | |
| | TerminationReason::SolverConverged | |
| TerminationReason::CostTolerance | TerminationReason::SolverConverged |
| let (params, converged, iterations) = resolve_fit_outcome( | ||
| fitted.clone(), | ||
| TerminationStatus::Terminated(TerminationReason::SolverConverged), | ||
| TerminationReason::ProjectedGradientTolerance, |
There was a problem hiding this comment.
Worth changing alongside the matches! narrowing above, and it is the part that actually matters: this is the only test of the happy path, and it feeds in a reason fit_mle can never receive. So today it asserts that a state which cannot occur maps to converged = true, and it would keep passing even if the reachable reasons stopped mapping correctly.
SolverConverged is what basin reports when the solver's own tol_pg fires, which is the ordinary way this fit converges:
| TerminationReason::ProjectedGradientTolerance, | |
| TerminationReason::SolverConverged, |
CostTolerance would work equally well if you would rather cover the registered criterion; mle_result_signals_non_convergence_without_error and ..._retains_best_point_on_solver_failure already pin MaxIter and SolverFailed, so between them the four reachable reasons would all be covered.
|
Pushed both nits to the branch rather than leaving you to click through the suggestions — maintainer edits were enabled, and neither change is one you should have to think about:
Verified locally before pushing: Revert either one freely if you disagree with the call; I have no further findings. |
|
Great, no, I have no problem with the edits. Thanks for making them! |
Summary
argminandargmin-mathwith Basin 1.9.0 across the statistics andquantitative-finance crates.
explicit box constraints and More-Thuente line searches where appropriate.
implementation while preserving the existing sample-standard-deviation
stopping rule.
iteration limits or solver failures.
Performance
I compared the existing
mle_fitCriterion benchmarks before and after theoptimizer migration. Both revisions used the same benchmark code, deterministic
inputs, ten samples, and a ten-second measurement window per case. Times are
Criterion slope point estimates; lower is better.
I also ran Basin with and without its
parallelfeature in an A/B/A order.These workloads provide crate-owned gradients and therefore do not invoke
Basin's Rayon-backed finite-difference or batch-cost paths. The measurements did
not establish a causal benefit from the feature, so this PR leaves it disabled.
I used Codex to prepare the migration and to draft this PR description.
Closes #31