Skip to main content

spectrafit_trust_region/
driver.rs

1//! The generic Δ-radius trust-region control loop.
2//!
3//! Unlike Levenberg–Marquardt (which controls step size through the damping `λ`
4//! and lives in `spectrafit-levenberg-marquardt`), the dogleg and Newton-CG
5//! methods are genuine trust-region methods: they maintain an explicit radius
6//! `Δ`, solve the subproblem within it, and grow/shrink `Δ` from the gain ratio
7//! `ρ = actual/predicted`. This module owns that shared loop; each method only
8//! supplies a [`SubproblemStep`]. The classic accept/update rule is Nocedal &
9//! Wright, *Numerical Optimization*, Algorithm 4.1.
10//!
11//! Stopping criteria (`ftol`/`xtol`/`gtol`) match the LM driver and
12//! `scipy.optimize.least_squares` so methods are comparable. Bounds reflection
13//! and tied-parameter application live in the problem's
14//! [`set_params`](TrustRegionProblem::set_params); the driver only proposes
15//! steps and reads back the applied parameters.
16//!
17//! # Known divergence from scipy: the `ftol` stop
18//!
19//! The `ftol` test here is `actual ≤ ftol · cost` evaluated on an **accepted**
20//! step, and it deliberately omits the `ratio > 0.25` guard that
21//! `scipy.optimize._lsq.common.check_termination` applies alongside the same
22//! inequality. Consequence: an accepted but low-gain step — `ρ ∈ (eta, 0.25]`,
23//! i.e. accepted because it did reduce the cost, but by much less than the
24//! model predicted — with a tiny reduction stops this driver where scipy would
25//! take at least one more step. It is a stop-one-step-early difference, not a
26//! wrong answer: the reported point is still an accepted, cost-reducing
27//! iterate. Documented rather than changed so the existing accuracy gates keep
28//! measuring the solver as shipped.
29//!
30//! # Global state
31//!
32//! This driver does not touch faer's global parallelism. Callers are expected
33//! to set it (`spectrafit-solver::fit` sets `Par::Seq` for the whole fit); the
34//! driver itself does not touch global state.
35
36use faer::{Mat, MatRef};
37
38use crate::problem::TrustRegionProblem;
39use crate::report::{Report, Termination};
40use crate::step::{Subproblem, SubproblemStep};
41
42/// Tuning for a single Δ-radius trust-region solve.
43#[derive(Debug, Clone, Copy)]
44pub struct TrustRegionConfig {
45    /// Relative cost-decrease tolerance.
46    pub ftol: f64,
47    /// Relative step-size tolerance.
48    pub xtol: f64,
49    /// Gradient infinity-norm tolerance.
50    pub gtol: f64,
51    /// Maximum residual evaluations.
52    pub max_nfev: usize,
53    /// Initial trust radius `Δ` (scaled units); `≤ 0` ⇒ a problem-derived default.
54    pub delta0: f64,
55    /// Hard upper bound on `Δ`.
56    pub max_delta: f64,
57    /// Acceptance threshold: accept the step when `ρ > eta`.
58    ///
59    /// Must be `< 0.25` (debug-asserted in [`minimize_tr`]). The Δ update
60    /// shrinks the radius only when `ρ < 0.25`, so an `eta ≥ 0.25` opens a band
61    /// `ρ ∈ [0.25, eta]` in which a step is rejected while Δ is left unchanged
62    /// — the accept/reject subloop then re-solves the identical subproblem at
63    /// the identical radius until the evaluation budget runs out.
64    ///
65    /// The `debug_assert!` above does not run in release builds, so this
66    /// crate does not itself enforce the bound outside `cfg(debug_assertions)`
67    /// — callers must validate `eta` before constructing this config.
68    /// `spectrafit-solver::dispatch` does, rejecting `eta >= 0.25` (or
69    /// non-finite / negative `eta`) with `SolverError::InvalidEta` before it
70    /// reaches this driver.
71    pub eta: f64,
72}
73
74impl Default for TrustRegionConfig {
75    fn default() -> Self {
76        Self {
77            ftol: 1e-8,
78            xtol: 1e-8,
79            gtol: 1e-8,
80            max_nfev: 10_000,
81            delta0: 0.0, // derive from the first scaled gradient
82            max_delta: 1e3,
83            eta: 1e-4,
84        }
85    }
86}
87
88/// Minimise `½‖r(p)‖²` over the free parameters of `problem` with an explicit
89/// Δ-radius trust region, delegating the per-iteration subproblem to `step`.
90///
91/// On return the problem holds the best parameters found. Deterministic: no
92/// RNG, no clock — identical inputs give identical iterates — given a fixed
93/// faer parallelism policy; `spectrafit-solver::fit` sets `Par::Seq` for the
94/// whole fit, and a caller that leaves faer on its default (potentially
95/// multi-threaded) policy can see floating-point summation-order differences
96/// across runs.
97pub fn minimize_tr<P: TrustRegionProblem, S: SubproblemStep>(
98    problem: &mut P,
99    step: &S,
100    cfg: &TrustRegionConfig,
101) -> Report {
102    debug_assert!(
103        cfg.eta < 0.25,
104        "eta must be < 0.25: a rejected step only shrinks Δ when ρ < 0.25"
105    );
106    let m = problem.n_residuals();
107    let p = problem.n_params();
108
109    // Moré column scaling `D_j = max over iterations of ‖J_:,j‖`, floored at 1.
110    let mut diag = vec![1.0_f64; p];
111    let mut diag_initialized = false;
112
113    let mut r = Mat::<f64>::zeros(m, 1);
114    let mut r_trial = Mat::<f64>::zeros(m, 1);
115    let mut j = Mat::<f64>::zeros(m, p);
116
117    let mut n_residual_evals = 0usize;
118    let mut n_jacobian_evals = 0usize;
119    let mut n_iter = 0usize;
120
121    // Per-iteration convergence trajectory (observability only); see the LM driver
122    // for the recording contract (once per accepted point + the terminal point,
123    // de-duplicated).
124    let mut cost_history: Vec<f64> = Vec::new();
125    let mut gradient_norm_history: Vec<f64> = Vec::new();
126    // This driver records cost and gradient-norm history only, not the
127    // per-iteration θ trajectory (that is the faer LM driver's job). Left
128    // empty (honest) rather than partially populated.
129    let params_history: Vec<Vec<f64>> = Vec::new();
130
131    macro_rules! report {
132        ($term:expr, $cost:expr, $gnorm:expr) => {{
133            let final_cost: f64 = $cost;
134            let final_gnorm: f64 = $gnorm;
135            if cost_history.last() != Some(&final_cost) {
136                cost_history.push(final_cost);
137                gradient_norm_history.push(final_gnorm);
138            }
139            return Report {
140                termination: $term,
141                n_iter,
142                n_residual_evals,
143                n_jacobian_evals,
144                cost: final_cost,
145                gradient_norm: final_gnorm,
146                cost_history,
147                gradient_norm_history,
148                params_history,
149            };
150        }};
151    }
152
153    if problem.residuals_into(r.as_mut()).is_err() {
154        report!(Termination::NumericalError, f64::INFINITY, 0.0);
155    }
156    n_residual_evals += 1;
157    let mut cost = 0.5 * r.as_ref().squared_norm_l2();
158    if !cost.is_finite() {
159        report!(Termination::NumericalError, cost, 0.0);
160    }
161    if cost == 0.0 {
162        report!(Termination::ResidualsZero, cost, 0.0);
163    }
164
165    let mut delta = cfg.delta0; // 0 ⇒ initialise from the first scaled gradient
166    let mut delta_init = cfg.delta0 > 0.0;
167
168    loop {
169        if problem.jacobian_into(j.as_mut()).is_err() {
170            report!(Termination::NumericalError, cost, 0.0);
171        }
172        n_jacobian_evals += 1;
173
174        // Monotone Moré column scaling.
175        for (jc, d) in diag.iter_mut().enumerate() {
176            let cn = j.as_ref().col(jc).norm_l2();
177            let cn = if cn > 0.0 { cn } else { 1.0 };
178            *d = if diag_initialized { (*d).max(cn) } else { cn };
179        }
180        diag_initialized = true;
181
182        // Gradient g = Jᵀr and first-order optimality test. For bounded
183        // problems use the Coleman–Li-scaled measure ‖v·g‖_∞ (v from
184        // `trust_scaling`, → 0 at an active bound) — the raw ‖g‖_∞ never
185        // fires at a legitimate constrained optimum and the loop would stall
186        // to a NoImprovement "failure" (same fix as the LM driver).
187        // Unbounded problems (trust_scaling = None) keep the raw norm.
188        // NOTE: v is box-width-normalized (not scipy TRF's unnormalized
189        // distance), so the gate is laxer by ~1/range for a wide-box
190        // parameter — same caveat as the LM driver, and the same known
191        // follow-up to use the unnormalized distance instead.
192        let g = j.as_ref().transpose() * r.as_ref();
193        let gnorm = g.as_ref().norm_max();
194        // Record the accepted point's trajectory (index 0 = initial point).
195        cost_history.push(cost);
196        gradient_norm_history.push(gnorm);
197        let g_vec: Vec<f64> = (0..p).map(|i| g[(i, 0)]).collect();
198        let opt_norm = match problem.trust_scaling(&g_vec) {
199            Some(v) => g_vec
200                .iter()
201                .zip(v.iter())
202                .map(|(gi, vi)| (gi * vi).abs())
203                .fold(0.0_f64, f64::max),
204            None => gnorm,
205        };
206        if opt_norm <= cfg.gtol {
207            report!(Termination::Gtol, cost, gnorm);
208        }
209
210        // Scaled subproblem: J̃ = J/D, g̃ = g/D. The trust region is the
211        // Euclidean ball ‖δ̃‖ ≤ Δ; the step is mapped back via δ = δ̃/D.
212        let j_scaled = Mat::from_fn(m, p, |i, c| j[(i, c)] / diag[c]);
213        let g_scaled = Mat::from_fn(p, 1, |i, _| g[(i, 0)] / diag[i]);
214        let sub = Subproblem::new(j_scaled.as_ref(), g_scaled.as_ref());
215
216        // Initialise Δ from the scaled gradient magnitude on the first iteration.
217        if !delta_init {
218            let gs = g_scaled.as_ref().squared_norm_l2().sqrt();
219            delta = if gs > 0.0 { gs } else { 1.0 };
220            delta_init = true;
221        }
222
223        let p_cur = problem.params();
224        match run_delta_subloop(
225            problem,
226            step,
227            &sub,
228            cfg,
229            p,
230            &diag,
231            j.as_ref(),
232            g.as_ref(),
233            &p_cur,
234            &mut delta,
235            &mut r,
236            &mut r_trial,
237            &mut cost,
238            gnorm,
239            &mut n_residual_evals,
240            &mut n_iter,
241        ) {
242            DeltaLoopOutcome::Continue => {} // re-evaluate the Jacobian at the accepted point
243            DeltaLoopOutcome::Terminate(term, final_cost, final_gnorm) => {
244                report!(term, final_cost, final_gnorm);
245            }
246        }
247
248        if n_residual_evals >= cfg.max_nfev {
249            // `gnorm` — ‖Jᵀr‖∞ at the start of this outer iteration, the most
250            // recent point where the Jacobian was evaluated. This used to
251            // recompute `(Jᵀ r).norm_max()` pairing the OLD `j` with the
252            // post-step `r`, a quantity that is the gradient at neither point.
253            // See `Report::gradient_norm`.
254            report!(Termination::MaxEval, cost, gnorm);
255        }
256    }
257}
258
259/// Outcome of one call to [`run_delta_subloop`].
260enum DeltaLoopOutcome {
261    /// A step was accepted; the caller should re-evaluate the Jacobian at the
262    /// new point and proceed to the next outer iteration.
263    Continue,
264    /// The driver should terminate immediately with this termination reason,
265    /// reporting the given `(cost, gradient_norm)` pair — identical to the
266    /// arguments the inlined loop used to pass to the `report!` macro at each
267    /// of its call sites.
268    Terminate(Termination, f64, f64),
269}
270
271/// The inner Δ-radius accept/reject subloop, run at a fixed Jacobian `j`/`g`
272/// (evaluated once per outer iteration of [`minimize_tr`]). Repeatedly
273/// proposes a step via `step.solve`, shrinking `delta` on a degenerate or
274/// rejected step, until either a step is accepted
275/// ([`DeltaLoopOutcome::Continue`] — the caller re-evaluates the Jacobian) or
276/// a termination condition fires ([`DeltaLoopOutcome::Terminate`]).
277///
278/// This is a verbatim extraction of the loop body that used to live inline in
279/// `minimize_tr`: it mutates `problem`'s parameters, `*delta`, `*r`/`*r_trial`
280/// (via `mem::swap`), `*cost`, `*n_residual_evals`, and `*n_iter` in place,
281/// exactly as the inlined loop mutated the corresponding outer-scope
282/// variables — nothing here is "purely functional." `p_cur`, `j`, `g` are the
283/// fixed parameter snapshot / unscaled Jacobian / unscaled gradient taken at
284/// the start of this outer iteration (read-only across the accept/reject
285/// attempts below); `gnorm` is the unscaled gradient norm from that same
286/// snapshot, reported verbatim on termination exactly as the inlined loop
287/// did (it is never recomputed inside this subloop).
288// Allowed: this is a verbatim extraction of `minimize_tr`'s inner loop body
289// (see the doc comment above) — the config/state it mutates in place
290// (problem, delta, r/r_trial, cost, the eval/iter counters) and the
291// read-only outer-iteration snapshot (p_cur/j/g/gnorm) are exactly the
292// outer-scope variables the original inline loop closed over; bundling them
293// into a struct would just rename this same parameter list.
294#[allow(clippy::too_many_arguments)]
295fn run_delta_subloop<P: TrustRegionProblem, S: SubproblemStep>(
296    problem: &mut P,
297    step: &S,
298    sub: &Subproblem<'_>,
299    cfg: &TrustRegionConfig,
300    p: usize,
301    diag: &[f64],
302    j: MatRef<'_, f64>,
303    g: MatRef<'_, f64>,
304    p_cur: &[f64],
305    delta: &mut f64,
306    r: &mut Mat<f64>,
307    r_trial: &mut Mat<f64>,
308    cost: &mut f64,
309    gnorm: f64,
310    n_residual_evals: &mut usize,
311    n_iter: &mut usize,
312) -> DeltaLoopOutcome {
313    loop {
314        let res = step.solve(sub, *delta);
315        let s_tilde = res.step;
316        let pred = res.predicted_reduction;
317        if !(pred.is_finite() && pred > 0.0) {
318            // Degenerate subproblem (e.g. ‖g‖≈0 model): shrink and retry.
319            *delta *= 0.25;
320            if *delta <= f64::MIN_POSITIVE * 4.0 {
321                return DeltaLoopOutcome::Terminate(Termination::NoImprovement, *cost, gnorm);
322            }
323            continue;
324        }
325
326        // Unscale the step: δ = δ̃ / D.
327        let delta_step = Mat::from_fn(p, 1, |i, _| s_tilde[(i, 0)] / diag[i]);
328        let p_trial: Vec<f64> = (0..p).map(|i| p_cur[i] + delta_step[(i, 0)]).collect();
329        problem.set_params(&p_trial);
330        if problem.residuals_into(r_trial.as_mut()).is_err() {
331            return DeltaLoopOutcome::Terminate(Termination::NumericalError, *cost, gnorm);
332        }
333        *n_residual_evals += 1;
334        let cost_trial = 0.5 * r_trial.as_ref().squared_norm_l2();
335        let actual = *cost - cost_trial;
336        // Gain ratio against the APPLIED step when the problem's
337        // projection clipped the proposal — judging against the raw
338        // step's `pred` lets the clipped direction's phantom reduction
339        // collapse ρ and reject legitimate free-direction refinements
340        // (same fix as the LM driver). Interior
341        // steps keep the subproblem's `pred` unchanged.
342        let p_applied = problem.params();
343        let clipped = p_applied.iter().zip(p_trial.iter()).any(|(a, t)| a != t);
344        let pred_eff = if clipped {
345            let delta_a = Mat::from_fn(p, 1, |i, _| p_applied[i] - p_cur[i]);
346            let jda = j * delta_a.as_ref();
347            let gda: f64 = (0..p).map(|i| g[(i, 0)] * delta_a[(i, 0)]).sum();
348            -gda - 0.5 * jda.as_ref().squared_norm_l2()
349        } else {
350            pred
351        };
352        let rho = if pred_eff.is_finite() && pred_eff > 0.0 {
353            actual / pred_eff
354        } else {
355            0.0
356        };
357
358        // Δ update (NW Alg 4.1): shrink on a poor ratio, grow on a very good
359        // boundary step, otherwise leave Δ unchanged.
360        if rho < 0.25 {
361            *delta *= 0.25;
362        } else if rho > 0.75 && res.hit_boundary {
363            *delta = (2.0 * *delta).min(cfg.max_delta);
364        }
365
366        if rho > cfg.eta {
367            // Accept. Step/param norms use the applied (post-reflection)
368            // params (`p_applied`, read above for the gain ratio).
369            let mut step_norm2 = 0.0;
370            let mut param_norm2 = 0.0;
371            for i in 0..p {
372                let d = p_applied[i] - p_cur[i];
373                step_norm2 += d * d;
374                param_norm2 += p_applied[i] * p_applied[i];
375            }
376            let step_norm = step_norm2.sqrt();
377            let param_norm = param_norm2.sqrt();
378
379            std::mem::swap(r, r_trial);
380            *cost = cost_trial;
381            *n_iter += 1;
382
383            if *cost == 0.0 {
384                return DeltaLoopOutcome::Terminate(Termination::ResidualsZero, *cost, gnorm);
385            }
386            if actual <= cfg.ftol * (*cost).max(f64::MIN_POSITIVE) {
387                return DeltaLoopOutcome::Terminate(Termination::Ftol, *cost, gnorm);
388            }
389            if step_norm <= cfg.xtol * (cfg.xtol + param_norm) {
390                return DeltaLoopOutcome::Terminate(Termination::Xtol, *cost, gnorm);
391            }
392            return DeltaLoopOutcome::Continue; // re-evaluate the Jacobian at the accepted point
393        } else {
394            // Reject: restore params; Δ has already shrunk, retry the
395            // subproblem at the same point with a tighter radius.
396            problem.set_params(p_cur);
397            if *delta <= f64::MIN_POSITIVE * 4.0 {
398                return DeltaLoopOutcome::Terminate(Termination::NoImprovement, *cost, gnorm);
399            }
400        }
401
402        if *n_residual_evals >= cfg.max_nfev {
403            return DeltaLoopOutcome::Terminate(Termination::MaxEval, *cost, gnorm);
404        }
405    }
406}