Skip to main content

secs_dsp/optimize/
normal.rs

1//! Normal-equation form of SciPy 1.18.1 optimize/_lsq/trf.py (BSD-3-Clause,
2//! Copyright SciPy Developers; see docs/THIRD_PARTY.md).
3//! The residual is retained, but no m-by-n Jacobian is allocated here.
4
5use super::{
6    DspError, LeastSquaresResult, Mat, QuadraticModel, SelectTrace, dot, feasible, grad, norm,
7    scaling, select, termination, trust,
8};
9use faer::{
10    Par,
11    diag::Diag,
12    dyn_stack::{MemBuffer, MemStack},
13    linalg::evd::{self, ComputeEigenvectors},
14};
15
16/// Supplies the residual and the normal equations of a least-squares problem.
17pub trait NormalEquations {
18    /// Residual f(x), counted in nfev like SciPy's fun.
19    fn residual(&mut self, x: &[f64]) -> Vec<f64>;
20    /// JᵀJ (n-by-n, both triangles filled) and Jᵀf at x, using the residual
21    /// just returned for the same x. Counted in njev like SciPy's jac.
22    fn normal(&mut self, x: &[f64], f: &[f64]) -> Result<(Mat<f64>, Vec<f64>), DspError>;
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum XScale {
27    Unit,
28    Jac,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub struct NormalOptions {
33    pub max_nfev: usize,
34    pub x_scale: XScale,
35    pub ftol: f64,
36    pub xtol: f64,
37    pub gtol: f64,
38}
39impl Default for NormalOptions {
40    fn default() -> Self {
41        Self {
42            max_nfev: 100,
43            x_scale: XScale::Unit,
44            ftol: 1e-8,
45            xtol: 1e-8,
46            gtol: 1e-8,
47        }
48    }
49}
50
51/// Trial diagnostics without storing the large residual or a Jacobian.
52#[derive(Debug, Default)]
53pub struct NormalTrial {
54    pub x: Vec<f64>,
55    pub cost: f64,
56    pub scale_inv: Vec<f64>,
57    pub v: Vec<f64>,
58    pub delta: f64,
59    pub alpha_in: f64,
60    /// Alpha returned by solve_lsq_trust_region, before the radius rescaling.
61    pub alpha_out: f64,
62    pub s: Vec<f64>,
63    pub full_rank: bool,
64    pub step: Vec<f64>,
65    pub x_new: Vec<f64>,
66    pub predicted_reduction: f64,
67    pub actual_reduction: f64,
68    pub delta_new: f64,
69    pub ratio: f64,
70    pub accepted: bool,
71    pub select: SelectTrace,
72}
73#[derive(Debug, Default)]
74pub struct NormalTrace {
75    pub trials: Vec<NormalTrial>,
76}
77
78/// Bounded exact TRF from JᵀJ and Jᵀf. Nonfinite or malformed normal equations
79/// and failed eigendecompositions return Err, allowing the caller to retry
80/// with Unit scaling. Nonfinite trial residuals shrink the trust radius.
81pub fn least_squares_normal<P: NormalEquations>(
82    problem: &mut P,
83    x0: &[f64],
84    lb: &[f64],
85    ub: &[f64],
86    options: &NormalOptions,
87) -> Result<LeastSquaresResult, DspError> {
88    least_squares_normal_with_trace(problem, x0, lb, ub, options, None)
89}
90
91pub fn least_squares_normal_with_trace<P: NormalEquations>(
92    problem: &mut P,
93    x0: &[f64],
94    lb: &[f64],
95    ub: &[f64],
96    options: &NormalOptions,
97    trace: Option<&mut NormalTrace>,
98) -> Result<LeastSquaresResult, DspError> {
99    solve(problem, x0, lb, ub, options, trace, None)
100}
101
102/// Replay a recorded trial, including a rejected-trial retry. The supplied
103/// scale_inv is the historical maximum at that base point, not a fresh scale.
104#[doc(hidden)]
105#[allow(clippy::too_many_arguments)]
106pub fn trf_normal_one_step<P: NormalEquations>(
107    problem: &mut P,
108    x: &[f64],
109    lb: &[f64],
110    ub: &[f64],
111    delta: f64,
112    alpha: f64,
113    scale_inv: &[f64],
114) -> Result<NormalTrial, DspError> {
115    if !delta.is_finite()
116        || delta <= 0.
117        || !alpha.is_finite()
118        || alpha < 0.
119        || scale_inv.len() != x.len()
120        || scale_inv.iter().any(|s| !s.is_finite() || *s <= 0.)
121    {
122        return Err(crate::invalid("invalid normal TRF replay state"));
123    }
124    let mut trace = NormalTrace::default();
125    solve(
126        problem,
127        x,
128        lb,
129        ub,
130        &NormalOptions {
131            max_nfev: 2,
132            gtol: 0.,
133            ..NormalOptions::default()
134        },
135        Some(&mut trace),
136        Some((delta, alpha, scale_inv)),
137    )?;
138    trace
139        .trials
140        .pop()
141        .ok_or_else(|| crate::invalid("no normal TRF replay trial"))
142}
143
144fn checked_normal<P: NormalEquations>(
145    problem: &mut P,
146    x: &[f64],
147    f: &[f64],
148) -> Result<(Mat<f64>, Vec<f64>), DspError> {
149    let (h, g) = problem.normal(x, f)?;
150    let n = x.len();
151    if h.nrows() != n
152        || h.ncols() != n
153        || g.len() != n
154        || g.iter().any(|v| !v.is_finite())
155        || (0..n).any(|i| h[(i, i)] < 0. || (0..n).any(|j| !h[(i, j)].is_finite()))
156    {
157        return Err(crate::invalid("invalid/nonfinite TRF normal equations"));
158    }
159    Ok((h, g))
160}
161
162fn jac_scale(h: &Mat<f64>, old: Option<&[f64]>) -> Vec<f64> {
163    // common.py:compute_jac_scale lines 603–610. Only the initial call
164    // substitutes one for zero; later calls take the historical maximum.
165    (0..h.ncols())
166        .map(|i| {
167            let s = h[(i, i)].sqrt();
168            match old {
169                Some(old) => s.max(old[i]),
170                None if s == 0. => 1.,
171                None => s,
172            }
173        })
174        .collect()
175}
176
177struct Model {
178    h: Mat<f64>,
179    diag: Vec<f64>,
180    gh: Vec<f64>,
181    d: Vec<f64>,
182    s: Vec<f64>,
183    vectors: Mat<f64>,
184    uf: Vec<f64>,
185}
186impl Model {
187    fn new(
188        h: &Mat<f64>,
189        g: &[f64],
190        v: &[f64],
191        dv: &[f64],
192        scale_inv: &[f64],
193    ) -> Result<Self, DspError> {
194        let n = g.len();
195        // trf.py:288–296: d=sqrt(v)*scale, diag_h=g*dv*scale, g_h=d*g.
196        let scale: Vec<_> = scale_inv.iter().map(|s| 1. / s).collect();
197        let d: Vec<_> = (0..n).map(|i| v[i].sqrt() * scale[i]).collect();
198        let diag: Vec<_> = (0..n).map(|i| g[i] * dv[i] * scale[i]).collect();
199        let gh: Vec<_> = (0..n).map(|i| d[i] * g[i]).collect();
200        // trf.py:300–305: augmented SVD replaced by M=diag(d) JᵀJ diag(d)+diag_h.
201        let h = Mat::from_fn(n, n, |i, j| d[i] * h[(i, j)] * d[j]);
202        let augmented = Mat::from_fn(n, n, |i, j| h[(i, j)] + if i == j { diag[i] } else { 0. });
203        if (0..n).any(|i| (0..n).any(|j| !augmented[(i, j)].is_finite())) {
204            return Err(crate::invalid("nonfinite TRF eigenproblem"));
205        }
206        let mut eigenvalues = Diag::zeros(n);
207        let mut vectors = Mat::zeros(n, n);
208        let mut scratch = MemBuffer::new(evd::self_adjoint_evd_scratch::<f64>(
209            n,
210            ComputeEigenvectors::Yes,
211            Par::Seq,
212            Default::default(),
213        ));
214        evd::self_adjoint_evd(
215            augmented.as_ref(),
216            eigenvalues.as_mut(),
217            Some(vectors.as_mut()),
218            Par::Seq,
219            MemStack::new(&mut scratch),
220            Default::default(),
221        )
222        .map_err(|_| crate::invalid("TRF eigendecomposition did not converge"))?;
223        if (0..n).any(|i| {
224            !eigenvalues.column_vector()[i].is_finite()
225                || (0..n).any(|j| !vectors[(i, j)].is_finite())
226        }) {
227            return Err(crate::invalid("nonfinite TRF eigendecomposition"));
228        }
229        // faer returns ascending eigenvalues; SciPy's SVD is descending.
230        let s: Vec<_> = (0..n)
231            .rev()
232            .map(|i| eigenvalues.column_vector()[i].max(0.).sqrt())
233            .collect();
234        let vectors = Mat::from_fn(n, n, |i, j| vectors[(i, n - 1 - j)]);
235        let suf = grad(&vectors, &gh);
236        let uf = suf
237            .iter()
238            .zip(&s)
239            .map(|(v, s)| if *s > 0. { v / s } else { 0. })
240            .collect();
241        // common.py:118–123: trust reconstructs s*uf and applies the original
242        // EPS*m*s[0] full-rank test, using the residual row count (not m+n).
243        Ok(Self {
244            h,
245            diag,
246            gh,
247            d,
248            s,
249            vectors,
250            uf,
251        })
252    }
253}
254
255#[allow(clippy::too_many_arguments)]
256fn solve<P: NormalEquations>(
257    problem: &mut P,
258    x0: &[f64],
259    lb: &[f64],
260    ub: &[f64],
261    options: &NormalOptions,
262    mut trace: Option<&mut NormalTrace>,
263    replay: Option<(f64, f64, &[f64])>,
264) -> Result<LeastSquaresResult, DspError> {
265    let n = x0.len();
266    let tols = [options.ftol, options.xtol, options.gtol];
267    if n == 0
268        || lb.len() != n
269        || ub.len() != n
270        || options.max_nfev == 0
271        || tols.iter().any(|t| !t.is_finite() || *t < 0.)
272        || tols.iter().all(|t| *t <= f64::EPSILON)
273        || !lb.iter().chain(ub).any(|b| b.is_finite())
274        || (0..n).any(|i| {
275            !x0[i].is_finite()
276                || lb[i].is_nan()
277                || ub[i].is_nan()
278                || lb[i] >= ub[i]
279                || x0[i] < lb[i]
280                || x0[i] > ub[i]
281        })
282    {
283        return Err(crate::invalid("invalid normal TRF start/bounds/options"));
284    }
285    if let Some(t) = trace.as_deref_mut() {
286        t.trials.clear();
287    }
288    let mut x = x0.to_vec();
289    if replay.is_none() {
290        feasible(&mut x, lb, ub, true);
291    }
292    let mut f = problem.residual(&x);
293    let m = f.len();
294    if m == 0 || f.iter().any(|v| !v.is_finite()) {
295        return Err(crate::invalid("invalid initial residual"));
296    }
297    let (mut h, mut g) = checked_normal(problem, &x, &f)?;
298    let mut scale_inv = match options.x_scale {
299        XScale::Unit => vec![1.; n],
300        XScale::Jac => jac_scale(&h, None),
301    };
302    let (mut v, dv) = scaling(&x, &g, lb, ub);
303    for i in 0..n {
304        if dv[i] != 0. {
305            v[i] *= scale_inv[i];
306        }
307    }
308    // trf.py:234–238, including both x scaling and Coleman–Li scaling.
309    let mut delta = norm(
310        &(0..n)
311            .map(|i| x[i] * scale_inv[i] / v[i].sqrt())
312            .collect::<Vec<_>>(),
313    );
314    if delta == 0. {
315        delta = 1.;
316    }
317    let mut alpha = 0.;
318    if let Some((dd, aa, ss)) = replay {
319        delta = dd;
320        alpha = aa;
321        scale_inv = ss.to_vec();
322    }
323    let mut cost = 0.5 * dot(&f, &f);
324    let (mut nfev, mut njev, mut status) = (1, 1, 0);
325    let optimality = loop {
326        let (mut v, dv) = scaling(&x, &g, lb, ub);
327        let gn = g
328            .iter()
329            .zip(&v)
330            .map(|(g, v)| (g * v).abs())
331            .fold(0., f64::max);
332        if gn < options.gtol {
333            status = 1;
334        }
335        if status != 0 || nfev == options.max_nfev {
336            break gn;
337        }
338        // trf.py:285: scale v only after measuring the unscaled optimality.
339        for i in 0..n {
340            if dv[i] != 0. {
341                v[i] *= scale_inv[i];
342            }
343        }
344        let model = Model::new(&h, &g, &v, &dv, &scale_inv)?;
345        let theta = 0.995_f64.max(1. - gn);
346        let mut actual = -1.;
347        let mut xn = x.clone();
348        let mut fnn = f.clone();
349        let mut cn = cost;
350        while actual <= 0. && nfev < options.max_nfev {
351            let (ph, a, _, full_rank) =
352                trust(n, m, &model.uf, &model.s, &model.vectors, delta, alpha);
353            let mut trial = trace.as_ref().map(|_| NormalTrial {
354                x: x.clone(),
355                cost,
356                scale_inv: scale_inv.clone(),
357                v: v.clone(),
358                delta,
359                alpha_in: alpha,
360                alpha_out: a,
361                s: model.s.clone(),
362                full_rank,
363                ..NormalTrial::default()
364            });
365            alpha = a;
366            let p = ph.iter().zip(&model.d).map(|(p, d)| p * d).collect();
367            let (step, sh, pred) = select(
368                &x,
369                QuadraticModel::Normal(&model.h),
370                &model.diag,
371                &model.gh,
372                p,
373                ph,
374                &model.d,
375                delta,
376                lb,
377                ub,
378                theta,
379                trial.as_mut().map(|t| &mut t.select),
380            );
381            xn = x.iter().zip(&step).map(|(x, s)| x + s).collect();
382            feasible(&mut xn, lb, ub, false);
383            fnn = problem.residual(&xn);
384            nfev += 1;
385            let shn = norm(&sh);
386            if fnn.len() != m {
387                return Err(crate::invalid("residual length changed"));
388            }
389            if fnn.iter().any(|f| !f.is_finite()) {
390                if let (Some(t), Some(mut trial)) = (trace.as_deref_mut(), trial) {
391                    trial.step = step;
392                    trial.x_new = xn.clone();
393                    trial.predicted_reduction = pred;
394                    trial.actual_reduction = f64::NAN;
395                    trial.ratio = f64::NAN;
396                    trial.delta_new = 0.25 * shn;
397                    t.trials.push(trial);
398                }
399                delta = 0.25 * shn;
400                continue;
401            }
402            cn = 0.5 * dot(&fnn, &fnn);
403            actual = cost - cn;
404            let ratio = if pred > 0. {
405                actual / pred
406            } else if pred == 0. && actual == 0. {
407                1.
408            } else {
409                0.
410            };
411            let dn = if ratio < 0.25 {
412                0.25 * shn
413            } else if ratio > 0.75 && shn > 0.95 * delta {
414                2. * delta
415            } else {
416                delta
417            };
418            if let (Some(t), Some(mut trial)) = (trace.as_deref_mut(), trial) {
419                trial.step = step.clone();
420                trial.x_new = xn.clone();
421                trial.predicted_reduction = pred;
422                trial.actual_reduction = actual;
423                trial.ratio = ratio;
424                trial.delta_new = dn;
425                trial.accepted = actual > 0.;
426                t.trials.push(trial);
427            }
428            status = termination(actual, cost, &step, &x, ratio, options.ftol, options.xtol);
429            if status != 0 {
430                break;
431            }
432            alpha *= delta / dn;
433            delta = dn;
434        }
435        if actual > 0. {
436            x = xn;
437            f = fnn;
438            cost = cn;
439            // trf.py:376–386: accepted steps always update J/g/scale, including
440            // the final accepted step that satisfied ftol or xtol.
441            (h, g) = checked_normal(problem, &x, &f)?;
442            njev += 1;
443            if options.x_scale == XScale::Jac {
444                scale_inv = jac_scale(&h, Some(&scale_inv));
445            }
446        }
447    };
448    Ok(LeastSquaresResult {
449        x,
450        cost,
451        nfev,
452        status,
453        njev,
454        optimality,
455    })
456}