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}