Skip to main content

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}