spectrafit_levenberg_marquardt/driver.rs
1//! The Levenberg–Marquardt outer control loop (LM / TRF / geodesic).
2//!
3//! One loop drives every LM-family strategy; only the [`StepKind`] and the `λ`
4//! update differ. The loop is classic Levenberg–Marquardt with a Nielsen `λ/ν`
5//! schedule and a gain-ratio accept/reject test, with stopping criteria matched
6//! to `scipy.optimize.least_squares` / `lmfit` defaults (`ftol`, `xtol`,
7//! `gtol`). Bounds reflection and tied-parameter application live inside the
8//! problem's [`set_params`](TrustRegionProblem::set_params); the driver only
9//! proposes raw steps and reads back the applied parameters.
10//!
11//! # Known divergence from scipy: the `ftol` stop
12//!
13//! The `ftol` test here is `actual ≤ ftol · cost` evaluated on an **accepted**
14//! step, and it deliberately omits the `ratio > 0.25` guard that
15//! `scipy.optimize._lsq.common.check_termination` applies alongside the same
16//! inequality. Consequence: an accepted but low-gain step — `ρ ∈ (1e-4, 0.25]`,
17//! i.e. accepted because it did reduce the cost, but by much less than the
18//! model predicted — with a tiny reduction stops this driver where scipy would
19//! take at least one more step. It is a stop-one-step-early difference, not a
20//! wrong answer: the reported point is still an accepted, cost-reducing
21//! iterate. Documented rather than changed so the existing accuracy gates keep
22//! measuring the solver as shipped.
23//!
24//! # Global state
25//!
26//! This driver does not touch faer's global parallelism. Callers are expected
27//! to set it (`spectrafit-solver::fit` sets `Par::Seq` for the whole fit); the
28//! driver itself does not touch global state.
29
30use faer::{Mat, MatRef};
31
32use crate::error::StepError;
33use crate::step::{factorize, StepFactor, StepKind};
34use spectrafit_trust_region::{Report, Termination, TrustRegionProblem};
35
36/// Tuning for a single Levenberg–Marquardt solve (covers LM, TRF and geodesic).
37#[derive(Debug, Clone, Copy)]
38pub struct StrategyConfig {
39 /// Linear-algebra path for the step (pick via [`crate::select_regime`]).
40 pub kind: StepKind,
41 /// Relative cost-decrease tolerance.
42 pub ftol: f64,
43 /// Relative step-size tolerance.
44 pub xtol: f64,
45 /// Gradient infinity-norm tolerance.
46 pub gtol: f64,
47 /// Maximum residual evaluations.
48 pub max_nfev: usize,
49 /// Initial Marquardt damping `λ`.
50 pub initial_lambda: f64,
51 /// Enable geodesic acceleration (Transtrum): augment the LM velocity step
52 /// with a second-directional-derivative acceleration term. Faster on
53 /// sloppy/degenerate surfaces (multi-peak) at ~1 extra residual eval/iter.
54 pub geodesic: bool,
55 /// Finite-difference step for the geodesic second directional derivative.
56 pub geodesic_h: f64,
57 /// Acceptance threshold for the acceleration: accept `δ + ½a` only when
58 /// `‖Da‖ / ‖Dδ‖ ≤ α` (mild curvature).
59 pub geodesic_alpha: f64,
60 /// Enable Coleman–Li bound scaling (Trust-Region-Reflective): fold the
61 /// problem's [`trust_scaling`](TrustRegionProblem::trust_scaling) into the
62 /// per-iteration damping so steps shrink as parameters approach active bounds.
63 pub bound_scaling: bool,
64}
65
66impl Default for StrategyConfig {
67 fn default() -> Self {
68 Self {
69 kind: StepKind::NormalEqLlt,
70 ftol: 1e-8,
71 xtol: 1e-8,
72 gtol: 1e-8,
73 max_nfev: 10_000,
74 initial_lambda: 1e-3,
75 geodesic: false,
76 geodesic_h: 0.1,
77 geodesic_alpha: 0.75,
78 bound_scaling: false,
79 }
80 }
81}
82
83#[inline]
84fn col_dot(a: MatRef<'_, f64>, b: MatRef<'_, f64>) -> f64 {
85 let n = a.nrows();
86 let mut s = 0.0;
87 for i in 0..n {
88 s += a[(i, 0)] * b[(i, 0)];
89 }
90 s
91}
92
93/// `‖Dv‖` where `D = diag(diag)`.
94#[inline]
95fn scaled_norm(v: &Mat<f64>, diag: &[f64]) -> f64 {
96 let mut s = 0.0;
97 for (i, &d) in diag.iter().enumerate() {
98 let x = d * v[(i, 0)];
99 s += x * x;
100 }
101 s.sqrt()
102}
103
104/// Geodesic acceleration (Transtrum/Sethna): given the LM velocity `δ`, compute
105/// the acceleration `a` from the second directional derivative of the residual
106/// along `δ` and return the augmented step `δ + ½a` (or `δ` if the curvature is
107/// too large), plus its predicted reduction `−gᵀs − ½‖Js‖²`.
108///
109/// Probes the residual at `p + h·δ` (one extra eval), then restores the problem
110/// to `p_cur`. Falls back to the plain velocity on any numerical failure.
111// Allowed: this is the per-outer-iteration hot path — the mutable problem,
112// its cached factorization/Jacobian/residual/gradient, the current point and
113// LM velocity, the damping/probe-step/mixing scalars (diag/lambda/h/alpha),
114// and the shared eval counter are each consumed directly below; wrapping
115// them in a struct here would just rename this same parameter list without
116// reducing the coupling.
117#[allow(clippy::too_many_arguments)]
118fn geodesic_augment<P: TrustRegionProblem>(
119 problem: &mut P,
120 factor: &StepFactor,
121 j: MatRef<'_, f64>,
122 r: MatRef<'_, f64>,
123 g: MatRef<'_, f64>,
124 p_cur: &[f64],
125 velocity: Mat<f64>,
126 diag: &[f64],
127 lambda: f64,
128 h: f64,
129 alpha: f64,
130 n_residual_evals: &mut usize,
131) -> (Mat<f64>, f64) {
132 let p = velocity.nrows();
133 let m = j.nrows();
134
135 let pred_of = |step: &Mat<f64>| {
136 let js = j * step.as_ref();
137 -col_dot(g, step.as_ref()) - 0.5 * js.as_ref().squared_norm_l2()
138 };
139
140 let jv = j * velocity.as_ref(); // m×1
141
142 // Probe r(p + h·δ); restore to p_cur afterwards.
143 let p_probe: Vec<f64> = (0..p).map(|i| p_cur[i] + h * velocity[(i, 0)]).collect();
144 problem.set_params(&p_probe);
145 let mut r_plus = Mat::<f64>::zeros(m, 1);
146 let probe_ok = problem.residuals_into(r_plus.as_mut()).is_ok();
147 problem.set_params(p_cur);
148 // The probe is a real residual evaluation whether it succeeded or not — count
149 // it before any early return so the work budget is accounted accurately.
150 *n_residual_evals += 1;
151 if !probe_ok {
152 let pr = pred_of(&velocity);
153 return (velocity, pr);
154 }
155
156 // Second directional derivative: r_vv ≈ (2/h)·[ (r(p+hδ) − r)/h − Jδ ].
157 let rvv = Mat::from_fn(m, 1, |i, _| {
158 (2.0 / h) * (((r_plus[(i, 0)] - r[(i, 0)]) / h) - jv[(i, 0)])
159 });
160 let jt_rvv = j.transpose() * rvv.as_ref(); // p×1
161 let rhs2 = Mat::from_fn(p, 1, |i, _| -jt_rvv[(i, 0)]);
162
163 let accel = match factor.solve_rhs(diag, lambda, rhs2.as_ref()) {
164 Ok(a) => a,
165 Err(_) => {
166 let pr = pred_of(&velocity);
167 return (velocity, pr);
168 }
169 };
170
171 // Accept the acceleration only under mild curvature: ‖Da‖/‖Dδ‖ ≤ α.
172 let dv = scaled_norm(&velocity, diag);
173 let da = scaled_norm(&accel, diag);
174 let step = if dv > 0.0 && da / dv <= alpha {
175 Mat::from_fn(p, 1, |i, _| velocity[(i, 0)] + 0.5 * accel[(i, 0)])
176 } else {
177 velocity
178 };
179 let pr = pred_of(&step);
180 (step, pr)
181}
182
183/// Update the monotone Moré column scaling `D` in place from the current
184/// Jacobian's column norms: `D_j ← max(D_j, ‖J_:,j‖)`, floored at 1 for an
185/// all-zero column, seeded (not maxed) on the first call. Per MINPACK, `diag`
186/// never shrinks, so the trust region stays measured in units that equalise
187/// the Jacobian columns regardless of how many outer iterations have run.
188fn update_more_scaling(diag: &mut [f64], diag_initialized: &mut bool, j: MatRef<'_, f64>) {
189 // faer stores columns contiguously, so `col(jc).norm_l2()` is a fast SIMD
190 // reduction.
191 for (jc, d) in diag.iter_mut().enumerate() {
192 let cn = j.col(jc).norm_l2();
193 let cn = if cn > 0.0 { cn } else { 1.0 };
194 *d = if *diag_initialized { (*d).max(cn) } else { cn };
195 }
196 *diag_initialized = true;
197}
198
199/// Gradient `g = Jᵀr`, its raw infinity norm, and the first-order optimality
200/// measure used for the `gtol` stop.
201///
202/// For a bounded problem the raw `‖g‖_∞` is the wrong measure at an active
203/// bound: the gradient component pointing into the wall can stay large at a
204/// legitimate constrained optimum, so the raw test never fires and the loop
205/// stalls to a `NoImprovement` "failure" (repro: TI-003, `fraction` pinned
206/// at 1.0). Use a Coleman–Li-style scaled measure `‖v·g‖_∞` instead — `v_i → 0`
207/// as parameter `i` approaches the bound its descent direction points into —
208/// so an active-bound gradient no longer blocks convergence. Unbounded
209/// problems (`trust_scaling` = `None`) keep the raw norm, unchanged. The raw
210/// `gnorm` (not `opt_norm`) is what callers should record for observability.
211///
212/// NOTE: `trust_scaling` returns `v` normalized by box width (`v_i =
213/// dist_i/range_i`, in `spectrafit-solver::lm_problem`), NOT scipy TRF's
214/// unnormalized distance. So this gate is laxer than scipy's by ~1/range for
215/// a wide-box parameter sitting mid-box facing a wall; the accuracy gate
216/// (max |Δr²| vs the oracle) bounds the practical impact. Making the
217/// optimality gate use the unnormalized distance is a known follow-up, not
218/// yet done.
219///
220/// Returns `(g, gnorm, opt_norm, trust_v)` — `trust_v` is handed back (rather
221/// than recomputed) so the caller can reuse it for the Coleman–Li step-diag
222/// fold in [`compute_step_diag`] without a second `trust_scaling` call.
223fn compute_gradient_and_optimality<P: TrustRegionProblem>(
224 problem: &P,
225 j: MatRef<'_, f64>,
226 r: MatRef<'_, f64>,
227) -> (Mat<f64>, f64, f64, Option<Vec<f64>>) {
228 let g = j.transpose() * r;
229 let gnorm = g.as_ref().norm_max();
230 let p = g.nrows();
231 let g_vec: Vec<f64> = (0..p).map(|i| g[(i, 0)]).collect();
232 let trust_v = problem.trust_scaling(&g_vec);
233 let opt_norm = match &trust_v {
234 Some(v) => g_vec
235 .iter()
236 .zip(v.iter())
237 .map(|(gi, vi)| (gi * vi).abs())
238 .fold(0.0_f64, f64::max),
239 None => gnorm,
240 };
241 (g, gnorm, opt_norm, trust_v)
242}
243
244/// Per-iteration step scaling: the monotone Moré `diag`, optionally folded
245/// with Coleman–Li bound scaling (TRF) `D_i ← D_i / √v_i` so steps shrink
246/// near active bounds. `diag` itself is never modified — this returns a
247/// derived copy for the current factorization only.
248fn compute_step_diag(diag: &[f64], bound_scaling: bool, trust_v: Option<Vec<f64>>) -> Vec<f64> {
249 if bound_scaling {
250 match trust_v {
251 Some(v) => diag
252 .iter()
253 .zip(v.iter())
254 .map(|(&d, &vi)| d / vi.clamp(1e-12, 1.0).sqrt())
255 .collect(),
256 None => diag.to_vec(),
257 }
258 } else {
259 diag.to_vec()
260 }
261}
262
263/// Minimise `½‖r(p)‖²` over the free parameters of `problem` with Levenberg–Marquardt.
264///
265/// On return the problem holds the best parameters found. Deterministic: no
266/// RNG, no clock — identical inputs give identical iterates — given a fixed
267/// faer parallelism policy; `spectrafit-solver::fit` sets `Par::Seq` for the
268/// whole fit, and a caller that leaves faer on its default (potentially
269/// multi-threaded) policy can see floating-point summation-order differences
270/// across runs.
271pub fn minimize<P: TrustRegionProblem>(problem: &mut P, cfg: &StrategyConfig) -> Report {
272 let m = problem.n_residuals();
273 let p = problem.n_params();
274
275 // Moré column scaling `D`: `D_j = max over iterations of ‖J_:,j‖`, floored at
276 // 1 for all-zero columns. Monotone (never shrinks) per MINPACK, so the trust
277 // region is measured in scaled units that equalise the Jacobian columns —
278 // this is what keeps the normal-equations regime well-behaved despite its κ²
279 // sensitivity. Filled from the first Jacobian below.
280 let mut diag = vec![1.0_f64; p];
281 let mut diag_initialized = false;
282
283 let mut r = Mat::<f64>::zeros(m, 1);
284 let mut r_trial = Mat::<f64>::zeros(m, 1);
285 let mut j = Mat::<f64>::zeros(m, p);
286
287 let mut n_residual_evals = 0usize;
288 let mut n_jacobian_evals = 0usize;
289 let mut n_iter = 0usize;
290
291 // Per-iteration convergence trajectory (observability only). Recorded once at
292 // the top of each outer iteration (the accepted point's cost + gradient norm)
293 // and, via the `report!` macro, the terminal point — de-duplicated so a
294 // gtol/max-eval stop at the same point is not double-counted.
295 let mut cost_history: Vec<f64> = Vec::new();
296 let mut gradient_norm_history: Vec<f64> = Vec::new();
297 // The free-parameter vector θ recorded alongside each cost_history entry
298 // (same length). Raw material for the convergence-to-truth metric.
299 let mut params_history: Vec<Vec<f64>> = Vec::new();
300
301 macro_rules! report {
302 ($term:expr, $cost:expr, $gnorm:expr) => {{
303 let final_cost: f64 = $cost;
304 let final_gnorm: f64 = $gnorm;
305 if cost_history.last() != Some(&final_cost) {
306 cost_history.push(final_cost);
307 gradient_norm_history.push(final_gnorm);
308 params_history.push(problem.params());
309 }
310 return Report {
311 termination: $term,
312 n_iter,
313 n_residual_evals,
314 n_jacobian_evals,
315 cost: final_cost,
316 gradient_norm: final_gnorm,
317 cost_history,
318 gradient_norm_history,
319 params_history,
320 };
321 }};
322 }
323
324 // Initial residual.
325 if problem.residuals_into(r.as_mut()).is_err() {
326 report!(Termination::NumericalError, f64::INFINITY, 0.0);
327 }
328 n_residual_evals += 1;
329 let mut cost = 0.5 * r.as_ref().squared_norm_l2();
330 if !cost.is_finite() {
331 // NaN/inf in the starting residual (e.g. log of a non-positive model
332 // value) — bail immediately rather than spin to NoImprovement.
333 report!(Termination::NumericalError, cost, 0.0);
334 }
335 if cost == 0.0 {
336 report!(Termination::ResidualsZero, cost, 0.0);
337 }
338
339 let mut lambda = cfg.initial_lambda;
340 let mut nu = 2.0_f64;
341
342 loop {
343 // Jacobian at the current point.
344 if problem.jacobian_into(j.as_mut()).is_err() {
345 report!(Termination::NumericalError, cost, 0.0);
346 }
347 n_jacobian_evals += 1;
348
349 // Update the Moré column scaling from this Jacobian's column norms.
350 update_more_scaling(&mut diag, &mut diag_initialized, j.as_ref());
351
352 // Gradient g = Jᵀr and first-order optimality test — see
353 // `compute_gradient_and_optimality`'s doc comment for the Coleman–Li
354 // rationale behind the scaled measure.
355 let (g, gnorm, opt_norm, trust_v) =
356 compute_gradient_and_optimality(problem, j.as_ref(), r.as_ref());
357 // Record the accepted point's trajectory (index 0 = initial point).
358 // `problem` holds the accepted parameters here (p_cur), so `params()`
359 // is θ at this point — kept in lock-step length with cost_history.
360 cost_history.push(cost);
361 gradient_norm_history.push(gnorm);
362 params_history.push(problem.params());
363 if opt_norm <= cfg.gtol {
364 report!(Termination::Gtol, cost, gnorm);
365 }
366
367 // Per-iteration step scaling — see `compute_step_diag`'s doc comment.
368 let step_diag = compute_step_diag(&diag, cfg.bound_scaling, trust_v);
369
370 // Factor the step operator ONCE for this outer iteration (the O(m·p²)
371 // work: forming JᵀJ, or the thin SVD of J̃). `J` and `step_diag` are
372 // fixed across the inner λ search — only λ changes — so every λ trial and
373 // the geodesic acceleration solve reuse this factorization.
374 let factor: StepFactor = match factorize(cfg.kind, j.as_ref(), &step_diag) {
375 Ok(f) => f,
376 Err(_) => report!(Termination::NumericalError, cost, gnorm),
377 };
378
379 // Inner λ search: shrink the trust region (raise λ) until a step is
380 // accepted or the budget runs out. `p_cur` is invariant across trials —
381 // params only change on accept (we break) or reject (we restore to
382 // p_cur) — so capture it once instead of cloning it every trial.
383 let p_cur = problem.params();
384 let mut bumps = 0usize;
385
386 // Bump λ (shrink the trust region) after a rejected/failed trial, advance
387 // the Nielsen ν schedule, and bail out with `NoImprovement` once the
388 // per-outer-iteration λ-search budget is exhausted (200 bumps, or λ
389 // overflowing to non-finite). Identical 4-line sequence at every
390 // "this trial doesn't work" exit from the inner loop below — a failed
391 // factorization, a non-positive predicted reduction (pre- or
392 // post-geodesic), and an outright rejected step — so it is named once
393 // here instead of repeated verbatim at each call site. Local macro (same
394 // technique as `report!` above) rather than a function: it mutates
395 // `lambda`/`nu`/`bumps` and reads `cost`/`gnorm` from the enclosing
396 // scope, so a helper would need all five threaded through its signature
397 // for no benefit. Defined here (not near `report!` above) because macro
398 // hygiene resolves its free identifiers against what's in scope at the
399 // macro's *definition* point, not its call site — `bumps` (declared just
400 // above) must already exist for that to work.
401 macro_rules! bump_lambda {
402 () => {{
403 lambda *= nu;
404 nu *= 2.0;
405 bumps += 1;
406 if !lambda.is_finite() || bumps > 200 {
407 report!(Termination::NoImprovement, cost, gnorm);
408 }
409 }};
410 }
411
412 loop {
413 let step = match factor.solve(g.as_ref(), r.as_ref(), &step_diag, lambda) {
414 Ok(s) => s,
415 Err(StepError::NotPositiveDefinite) | Err(StepError::Factorization(_)) => {
416 bump_lambda!();
417 continue;
418 }
419 };
420 let delta = step.delta;
421 let pred = step.predicted_reduction;
422 if !(pred.is_finite() && pred > 0.0) {
423 bump_lambda!();
424 continue;
425 }
426
427 // Optional geodesic acceleration: augment the velocity δ with the
428 // second-directional-derivative term. Re-validate the predicted
429 // reduction since the augmented step may not be a descent direction.
430 let (delta, pred) = if cfg.geodesic {
431 geodesic_augment(
432 problem,
433 &factor,
434 j.as_ref(),
435 r.as_ref(),
436 g.as_ref(),
437 &p_cur,
438 delta,
439 &step_diag,
440 lambda,
441 cfg.geodesic_h,
442 cfg.geodesic_alpha,
443 &mut n_residual_evals,
444 )
445 } else {
446 (delta, pred)
447 };
448 if !(pred.is_finite() && pred > 0.0) {
449 bump_lambda!();
450 continue;
451 }
452
453 // Trial point: p_trial = p_applied + δ.
454 let p_trial: Vec<f64> = (0..p).map(|i| p_cur[i] + delta[(i, 0)]).collect();
455 problem.set_params(&p_trial);
456 if problem.residuals_into(r_trial.as_mut()).is_err() {
457 // Restore the last accepted point before bailing so `problem`
458 // holds the accepted θ (not the NaN-producing trial) when
459 // `report!` fires — keeps params_history lock-step with
460 // cost_history regardless of the dedup, and makes the failed
461 // result report the last good parameters.
462 problem.set_params(&p_cur);
463 report!(Termination::NumericalError, cost, gnorm);
464 }
465 n_residual_evals += 1;
466 let cost_trial = 0.5 * r_trial.as_ref().squared_norm_l2();
467
468 let actual = cost - cost_trial;
469 // Gain ratio. When the problem's projection (bounds reflection /
470 // clamping in `set_params`) clipped the proposed step, judging
471 // `actual` against the RAW step's predicted reduction is wrong:
472 // the phantom reduction from the clipped direction dominates
473 // `pred`, ρ collapses, and legitimate refinements of the still-
474 // free directions get rejected until λ inflates to NoImprovement.
475 // Recompute the model reduction for the
476 // APPLIED step δₐ = p_applied − p_cur in that case. Interior
477 // (unclipped) steps keep the factorization's `pred` unchanged.
478 let p_applied = problem.params();
479 let clipped = p_applied.iter().zip(p_trial.iter()).any(|(a, t)| a != t);
480 let pred_eff = if clipped {
481 let delta_a = Mat::from_fn(p, 1, |i, _| p_applied[i] - p_cur[i]);
482 let jda = j.as_ref() * delta_a.as_ref();
483 -col_dot(g.as_ref(), delta_a.as_ref()) - 0.5 * jda.as_ref().squared_norm_l2()
484 } else {
485 pred
486 };
487 let rho = if pred_eff.is_finite() && pred_eff > 0.0 {
488 actual / pred_eff
489 } else {
490 // Fully-clamped (δₐ = 0) or degenerate applied step: no
491 // model reduction to judge against — treat as a rejection.
492 0.0
493 };
494
495 if rho > 1e-4 {
496 // Accept. Step norm uses the parameters the problem actually
497 // applied (post-reflection), not the proposed δ.
498 let mut step_norm2 = 0.0;
499 let mut param_norm2 = 0.0;
500 for i in 0..p {
501 let d = p_applied[i] - p_cur[i];
502 step_norm2 += d * d;
503 param_norm2 += p_applied[i] * p_applied[i];
504 }
505 let step_norm = step_norm2.sqrt();
506 let param_norm = param_norm2.sqrt();
507
508 std::mem::swap(&mut r, &mut r_trial);
509 cost = cost_trial;
510 n_iter += 1;
511
512 // Nielsen λ decrease on a successful step.
513 lambda *= f64::max(1.0 / 3.0, 1.0 - (2.0 * rho - 1.0).powi(3));
514 nu = 2.0;
515
516 if cost == 0.0 {
517 report!(Termination::ResidualsZero, cost, gnorm);
518 }
519 if actual <= cfg.ftol * cost.max(f64::MIN_POSITIVE) {
520 report!(Termination::Ftol, cost, gnorm);
521 }
522 if step_norm <= cfg.xtol * (cfg.xtol + param_norm) {
523 report!(Termination::Xtol, cost, gnorm);
524 }
525 break; // re-evaluate the Jacobian at the new point
526 } else {
527 // Reject: restore parameters, shrink the trust region.
528 problem.set_params(&p_cur);
529 bump_lambda!();
530 }
531
532 if n_residual_evals >= cfg.max_nfev {
533 report!(Termination::MaxEval, cost, gnorm);
534 }
535 }
536
537 if n_residual_evals >= cfg.max_nfev {
538 // `gnorm` — ‖Jᵀr‖∞ at the start of this outer iteration, the most
539 // recent point where the Jacobian was evaluated. This used to
540 // recompute `(Jᵀ r).norm_max()` pairing the OLD `j` with the
541 // post-step `r`, a quantity that is the gradient at neither point.
542 // See `Report::gradient_norm`.
543 report!(Termination::MaxEval, cost, gnorm);
544 }
545 }
546}