Skip to main content

secs_dsp/optimize/
operator.rs

1//! Opt-in matrix-free bounded trust-region least squares.
2//!
3//! Coleman–Li scaling, reflective/Cauchy candidate selection and acceptance
4//! follow the bounded solver in the parent module. The trust-region subproblem
5//! uses preconditioned Steihaug CG instead of an exact SVD/eigendecomposition.
6//! No residual-by-parameter Jacobian or parameter-by-parameter Gram matrix is
7//! formed. The different subproblem solution intentionally changes trajectories.
8//! Bound scaling/selection adapted from SciPy 1.18.1 optimize/_lsq (BSD-3-Clause,
9//! Copyright SciPy Developers); see docs/THIRD_PARTY.md.
10
11use super::{
12    DspError, LeastSquaresResult, XScale, bound_step, dot, feasible, minimize, norm, scaling,
13    termination,
14};
15
16/// A real residual with an independently retained linearization.
17///
18/// `linearize` returns the squared Euclidean norm of each Jacobian column.
19/// Subsequent products use that base point until the next `linearize` call;
20/// evaluating trial residuals must not invalidate it. Products overwrite all
21/// entries of the supplied output buffer and use real inner products, including
22/// when the implementation internally uses complex spectra.
23pub trait JacobianOperator {
24    fn residual(&mut self, x: &[f64]) -> Vec<f64>;
25    fn linearize(&mut self, x: &[f64]) -> Result<Vec<f64>, DspError>;
26    fn jvp(&self, direction: &[f64], out: &mut [f64]) -> Result<(), DspError>;
27    fn vjp(&self, residual_direction: &[f64], out: &mut [f64]) -> Result<(), DspError>;
28}
29
30#[derive(Debug, Clone, Copy)]
31pub struct OperatorOptions {
32    pub max_nfev: usize,
33    /// Maximum products with JᵀJ in one trust-region subproblem.
34    pub max_cg_iterations: usize,
35    /// Relative preconditioned residual tolerance of the CG subproblem.
36    pub cg_rtol: f64,
37    pub x_scale: XScale,
38    pub ftol: f64,
39    pub xtol: f64,
40    pub gtol: f64,
41}
42
43impl Default for OperatorOptions {
44    fn default() -> Self {
45        Self {
46            max_nfev: 100,
47            max_cg_iterations: 64,
48            cg_rtol: 1e-3,
49            x_scale: XScale::Jac,
50            ftol: 1e-8,
51            xtol: 1e-8,
52            gtol: 1e-8,
53        }
54    }
55}
56
57fn checked_linearization<P: JacobianOperator>(
58    problem: &mut P,
59    x: &[f64],
60    f: &[f64],
61) -> Result<(Vec<f64>, Vec<f64>), DspError> {
62    let diagonal = problem.linearize(x)?;
63    if diagonal.len() != x.len() || diagonal.iter().any(|v| !v.is_finite() || *v < 0.0) {
64        return Err(crate::invalid("invalid operator Jacobian diagonal"));
65    }
66    let mut gradient = vec![0.0; x.len()];
67    problem.vjp(f, &mut gradient)?;
68    if gradient.iter().any(|v| !v.is_finite()) {
69        return Err(crate::invalid(
70            "nonfinite operator Jacobian transpose product",
71        ));
72    }
73    Ok((diagonal, gradient))
74}
75
76fn jac_scale(diagonal: &[f64], old: Option<&[f64]>) -> Vec<f64> {
77    diagonal
78        .iter()
79        .enumerate()
80        .map(|(i, v)| {
81            let s = v.sqrt();
82            match old {
83                Some(old) => s.max(old[i]),
84                None if s == 0.0 => 1.0,
85                None => s,
86            }
87        })
88        .collect()
89}
90
91struct Model<'a, P> {
92    problem: &'a P,
93    d: &'a [f64],
94    diagonal: &'a [f64],
95    gradient: &'a [f64],
96    rows: usize,
97}
98
99impl<P: JacobianOperator> Model<'_, P> {
100    fn product(&self, s: &[f64]) -> Result<Vec<f64>, DspError> {
101        let physical: Vec<_> = s.iter().zip(self.d).map(|(s, d)| s * d).collect();
102        let mut jv = vec![0.0; self.rows];
103        self.problem.jvp(&physical, &mut jv)?;
104        if jv.iter().any(|v| !v.is_finite()) {
105            return Err(crate::invalid("nonfinite operator Jacobian product"));
106        }
107        let mut out = vec![0.0; s.len()];
108        self.problem.vjp(&jv, &mut out)?;
109        for i in 0..s.len() {
110            out[i] = self.d[i] * out[i] + self.diagonal[i] * s[i];
111        }
112        if out.iter().any(|v| !v.is_finite()) {
113            return Err(crate::invalid("nonfinite operator trust-region model"));
114        }
115        Ok(out)
116    }
117
118    fn value(&self, s: &[f64]) -> Result<f64, DspError> {
119        Ok(0.5 * dot(s, &self.product(s)?) + dot(s, self.gradient))
120    }
121
122    fn line(&self, s: &[f64], s0: Option<&[f64]>) -> Result<(f64, f64, f64), DspError> {
123        let hs = self.product(s)?;
124        let a = 0.5 * dot(s, &hs);
125        let mut b = dot(self.gradient, s);
126        let mut c = 0.0;
127        if let Some(s0) = s0 {
128            b += dot(s0, &hs);
129            c = self.value(s0)?;
130        }
131        Ok((a, b, c))
132    }
133}
134
135/// Positive intersection with ||s + tau p|| = delta. The alternate form when
136/// b >= 0 avoids subtracting nearly equal roots at the trust-region boundary.
137fn to_boundary(s: &[f64], p: &[f64], delta: f64) -> f64 {
138    let a = dot(p, p);
139    let b = dot(s, p);
140    let c = dot(s, s) - delta * delta;
141    let root = (b * b - a * c).max(0.0).sqrt();
142    if b >= 0.0 {
143        if root + b == 0.0 {
144            0.0
145        } else {
146            -c / (root + b)
147        }
148    } else {
149        (root - b) / a
150    }
151}
152
153fn truncated_cg<P: JacobianOperator>(
154    model: &Model<'_, P>,
155    column_norms: &[f64],
156    delta: f64,
157    options: &OperatorOptions,
158) -> Result<Vec<f64>, DspError> {
159    let n = model.gradient.len();
160    let preconditioner: Vec<_> = (0..n)
161        .map(|i| {
162            let v = model.d[i] * model.d[i] * column_norms[i] + model.diagonal[i];
163            if v > 0.0 { v } else { 1.0 }
164        })
165        .collect();
166    let mut s = vec![0.0; n];
167    let mut r: Vec<_> = model.gradient.iter().map(|g| -g).collect();
168    let mut z: Vec<_> = r.iter().zip(&preconditioner).map(|(r, d)| r / d).collect();
169    let mut direction = z.clone();
170    let mut rz = dot(&r, &z);
171    if !rz.is_finite() || rz < 0.0 {
172        return Err(crate::invalid("invalid operator CG preconditioner"));
173    }
174    let threshold = options.cg_rtol * options.cg_rtol * rz;
175    if rz == 0.0 {
176        return Ok(s);
177    }
178    for _ in 0..options.max_cg_iterations.min(n) {
179        let hd = model.product(&direction)?;
180        let curvature = dot(&direction, &hd);
181        if curvature <= 0.0 {
182            let tau = to_boundary(&s, &direction, delta);
183            for i in 0..n {
184                s[i] += tau * direction[i];
185            }
186            return Ok(s);
187        }
188        let alpha = rz / curvature;
189        let next: Vec<_> = (0..n).map(|i| s[i] + alpha * direction[i]).collect();
190        if norm(&next) >= delta {
191            let tau = to_boundary(&s, &direction, delta);
192            for i in 0..n {
193                s[i] += tau * direction[i];
194            }
195            return Ok(s);
196        }
197        s = next;
198        for i in 0..n {
199            r[i] -= alpha * hd[i];
200            z[i] = r[i] / preconditioner[i];
201        }
202        let next_rz = dot(&r, &z);
203        if !next_rz.is_finite() || next_rz < 0.0 {
204            return Err(crate::invalid("nonfinite operator CG iteration"));
205        }
206        if next_rz <= threshold {
207            return Ok(s);
208        }
209        let beta = next_rz / rz;
210        for i in 0..n {
211            direction[i] = z[i] + beta * direction[i];
212        }
213        rz = next_rz;
214    }
215    Ok(s)
216}
217
218// The same three candidates as the dense bounded solver: feasible truncated
219// step, reflection at the first hit bound, and scaled anti-gradient line.
220#[allow(clippy::too_many_arguments)]
221fn select<P: JacobianOperator>(
222    x: &[f64],
223    model: &Model<'_, P>,
224    mut ph: Vec<f64>,
225    delta: f64,
226    lb: &[f64],
227    ub: &[f64],
228    theta: f64,
229) -> Result<(Vec<f64>, Vec<f64>, f64), DspError> {
230    let mut p: Vec<_> = ph.iter().zip(model.d).map(|(p, d)| p * d).collect();
231    if (0..x.len()).all(|i| x[i] + p[i] >= lb[i] && x[i] + p[i] <= ub[i]) {
232        let value = model.value(&ph)?;
233        return Ok((p, ph, -value));
234    }
235    let (stride, hits) = bound_step(x, &p, lb, ub);
236    let mut rh: Vec<_> = ph
237        .iter()
238        .zip(hits)
239        .map(|(v, hit)| if hit { -v } else { *v })
240        .collect();
241    let r: Vec<_> = rh.iter().zip(model.d).map(|(a, b)| a * b).collect();
242    for v in &mut p {
243        *v *= stride;
244    }
245    for v in &mut ph {
246        *v *= stride;
247    }
248    let xb: Vec<_> = x.iter().zip(&p).map(|(x, p)| x + p).collect();
249    let tr = to_boundary(&ph, &rh, delta);
250    let (bound, _) = bound_step(&xb, &r, lb, ub);
251    let rs = bound.min(tr);
252    let (lo, hi) = if rs > 0.0 {
253        (
254            (1.0 - theta) * stride / rs,
255            if rs == bound { theta * bound } else { tr },
256        )
257    } else {
258        (0.0, -1.0)
259    };
260    let mut reflected_value = f64::INFINITY;
261    if lo <= hi {
262        let (t, value) = minimize(model.line(&rh, Some(&ph))?, lo, hi);
263        reflected_value = value;
264        for i in 0..rh.len() {
265            rh[i] = rh[i] * t + ph[i];
266        }
267    }
268    for v in &mut p {
269        *v *= theta;
270    }
271    for v in &mut ph {
272        *v *= theta;
273    }
274    let step_value = model.value(&ph)?;
275    let mut ah: Vec<_> = model.gradient.iter().map(|v| -v).collect();
276    let mut ag: Vec<_> = ah.iter().zip(model.d).map(|(a, b)| a * b).collect();
277    let tr = delta / norm(&ah);
278    let (bound, _) = bound_step(x, &ag, lb, ub);
279    let upper = if bound < tr { theta * bound } else { tr };
280    let (stride, gradient_value) = minimize(model.line(&ah, None)?, 0.0, upper);
281    for v in &mut ah {
282        *v *= stride;
283    }
284    for v in &mut ag {
285        *v *= stride;
286    }
287    if step_value < reflected_value && step_value < gradient_value {
288        Ok((p, ph, -step_value))
289    } else if reflected_value < step_value && reflected_value < gradient_value {
290        Ok((
291            rh.iter().zip(model.d).map(|(a, b)| a * b).collect(),
292            rh,
293            -reflected_value,
294        ))
295    } else {
296        Ok((ag, ah, -gradient_value))
297    }
298}
299
300/// Bounded trust-region least squares using only Jv and Jᵀv products.
301///
302/// This is an explicit alternative to the exact dense solvers. A failed or
303/// nonfinite linearization/product returns `Err` so callers can retain their
304/// dense fallback. Rejected nonfinite trial residuals shrink the trust radius.
305/// All arithmetic is f64 and all reductions are deterministic and sequential.
306pub fn least_squares_operator<P: JacobianOperator>(
307    problem: &mut P,
308    x0: &[f64],
309    lb: &[f64],
310    ub: &[f64],
311    options: &OperatorOptions,
312) -> Result<LeastSquaresResult, DspError> {
313    let n = x0.len();
314    let tolerances = [options.ftol, options.xtol, options.gtol];
315    if n == 0
316        || lb.len() != n
317        || ub.len() != n
318        || options.max_nfev == 0
319        || options.max_cg_iterations == 0
320        || !options.cg_rtol.is_finite()
321        || options.cg_rtol <= 0.0
322        || options.cg_rtol >= 1.0
323        || tolerances.iter().any(|v| !v.is_finite() || *v < 0.0)
324        || tolerances.iter().all(|v| *v <= f64::EPSILON)
325        || !lb.iter().chain(ub).any(|v| v.is_finite())
326        || (0..n).any(|i| {
327            !x0[i].is_finite()
328                || lb[i].is_nan()
329                || ub[i].is_nan()
330                || lb[i] >= ub[i]
331                || x0[i] < lb[i]
332                || x0[i] > ub[i]
333        })
334    {
335        return Err(crate::invalid("invalid operator TRF start/bounds/options"));
336    }
337    let mut x = x0.to_vec();
338    feasible(&mut x, lb, ub, true);
339    let mut f = problem.residual(&x);
340    let rows = f.len();
341    if rows == 0 || f.iter().any(|v| !v.is_finite()) {
342        return Err(crate::invalid("invalid initial operator residual"));
343    }
344    let (mut column_norms, mut gradient) = checked_linearization(problem, &x, &f)?;
345    let mut scale_inv = match options.x_scale {
346        XScale::Unit => vec![1.0; n],
347        XScale::Jac => jac_scale(&column_norms, None),
348    };
349    let (mut v, dv) = scaling(&x, &gradient, lb, ub);
350    for i in 0..n {
351        if dv[i] != 0.0 {
352            v[i] *= scale_inv[i];
353        }
354    }
355    let mut delta = norm(
356        &(0..n)
357            .map(|i| x[i] * scale_inv[i] / v[i].sqrt())
358            .collect::<Vec<_>>(),
359    );
360    if delta == 0.0 {
361        delta = 1.0;
362    }
363    let mut cost = 0.5 * dot(&f, &f);
364    if !cost.is_finite() || !delta.is_finite() {
365        return Err(crate::invalid("nonfinite initial operator cost/radius"));
366    }
367    let (mut nfev, mut njev, mut status) = (1, 1, 0);
368    let optimality = loop {
369        let (mut v, dv) = scaling(&x, &gradient, lb, ub);
370        let gn = gradient
371            .iter()
372            .zip(&v)
373            .map(|(g, v)| (g * v).abs())
374            .fold(0.0, f64::max);
375        if gn < options.gtol {
376            status = 1;
377        }
378        if status != 0 || nfev == options.max_nfev {
379            break gn;
380        }
381        for i in 0..n {
382            if dv[i] != 0.0 {
383                v[i] *= scale_inv[i];
384            }
385        }
386        let d: Vec<_> = (0..n).map(|i| v[i].sqrt() / scale_inv[i]).collect();
387        let diagonal: Vec<_> = (0..n).map(|i| gradient[i] * dv[i] / scale_inv[i]).collect();
388        let gh: Vec<_> = (0..n).map(|i| d[i] * gradient[i]).collect();
389        if d.iter().chain(&diagonal).chain(&gh).any(|v| !v.is_finite()) {
390            return Err(crate::invalid("nonfinite operator TRF scaling"));
391        }
392        let theta = 0.995_f64.max(1.0 - gn);
393        let mut actual = -1.0;
394        let mut candidate = x.clone();
395        let mut candidate_f = f.clone();
396        let mut candidate_cost = cost;
397        while actual <= 0.0 && nfev < options.max_nfev {
398            let model = Model {
399                problem: &*problem,
400                d: &d,
401                diagonal: &diagonal,
402                gradient: &gh,
403                rows,
404            };
405            let ph = truncated_cg(&model, &column_norms, delta, options)?;
406            let (step, sh, predicted) = select(&x, &model, ph, delta, lb, ub, theta)?;
407            if !predicted.is_finite() || step.iter().any(|v| !v.is_finite()) {
408                return Err(crate::invalid("nonfinite operator trust-region step"));
409            }
410            candidate = x.iter().zip(&step).map(|(x, s)| x + s).collect();
411            feasible(&mut candidate, lb, ub, false);
412            candidate_f = problem.residual(&candidate);
413            nfev += 1;
414            if candidate_f.len() != rows {
415                return Err(crate::invalid("residual length changed"));
416            }
417            let shn = norm(&sh);
418            if candidate_f.iter().any(|v| !v.is_finite()) {
419                delta = 0.25 * shn;
420                continue;
421            }
422            candidate_cost = 0.5 * dot(&candidate_f, &candidate_f);
423            actual = cost - candidate_cost;
424            let ratio = if predicted > 0.0 {
425                actual / predicted
426            } else if predicted == 0.0 && actual == 0.0 {
427                1.0
428            } else {
429                0.0
430            };
431            let next_delta = if ratio < 0.25 {
432                0.25 * shn
433            } else if ratio > 0.75 && shn > 0.95 * delta {
434                2.0 * delta
435            } else {
436                delta
437            };
438            status = termination(actual, cost, &step, &x, ratio, options.ftol, options.xtol);
439            if status != 0 {
440                break;
441            }
442            delta = next_delta;
443            if delta <= 0.0 || !delta.is_finite() {
444                return Err(crate::invalid("operator trust radius exhausted"));
445            }
446        }
447        if actual > 0.0 {
448            x = candidate;
449            f = candidate_f;
450            cost = candidate_cost;
451            (column_norms, gradient) = checked_linearization(problem, &x, &f)?;
452            njev += 1;
453            if options.x_scale == XScale::Jac {
454                scale_inv = jac_scale(&column_norms, Some(&scale_inv));
455            }
456        }
457    };
458    Ok(LeastSquaresResult {
459        x,
460        cost,
461        nfev,
462        status,
463        njev,
464        optimality,
465    })
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    struct Linear {
473        a: Vec<Vec<f64>>,
474        b: Vec<f64>,
475    }
476    impl JacobianOperator for Linear {
477        fn residual(&mut self, x: &[f64]) -> Vec<f64> {
478            self.a
479                .iter()
480                .zip(&self.b)
481                .map(|(a, b)| dot(a, x) - b)
482                .collect()
483        }
484        fn linearize(&mut self, x: &[f64]) -> Result<Vec<f64>, DspError> {
485            Ok((0..x.len())
486                .map(|j| self.a.iter().map(|a| a[j] * a[j]).sum())
487                .collect())
488        }
489        fn jvp(&self, v: &[f64], out: &mut [f64]) -> Result<(), DspError> {
490            for (out, a) in out.iter_mut().zip(&self.a) {
491                *out = dot(a, v);
492            }
493            Ok(())
494        }
495        fn vjp(&self, v: &[f64], out: &mut [f64]) -> Result<(), DspError> {
496            for (j, out) in out.iter_mut().enumerate() {
497                *out = self.a.iter().zip(v).map(|(a, v)| a[j] * v).sum();
498            }
499            Ok(())
500        }
501    }
502
503    #[test]
504    fn operator_matches_dense_bounded_solution() {
505        let mut problem = Linear {
506            a: vec![vec![1.0, 0.5], vec![-0.2, 2.0], vec![0.4, -0.1]],
507            b: vec![-2.0, 4.0, -1.0],
508        };
509        let (x, lb, ub) = ([0.4, 0.6], [0.0, 0.0], [1.0, 1.0]);
510        let dense =
511            super::super::least_squares(|x| problem.residual(x), &x, &lb, &ub, 100).unwrap();
512        let iterative =
513            least_squares_operator(&mut problem, &x, &lb, &ub, &OperatorOptions::default())
514                .unwrap();
515        assert!(
516            iterative
517                .x
518                .iter()
519                .zip(dense.x)
520                .all(|(a, b)| (a - b).abs() < 1e-7)
521        );
522        assert!((iterative.cost - dense.cost).abs() < 1e-8);
523        assert!(
524            iterative
525                .x
526                .iter()
527                .zip(lb)
528                .zip(ub)
529                .all(|((&x, l), u)| x >= l && x <= u)
530        );
531    }
532
533    #[test]
534    fn operator_handles_rank_deficiency_and_invalid_options() {
535        let mut problem = Linear {
536            a: vec![vec![1.0, 1.0, 0.0], vec![2.0, 2.0, 0.0]],
537            b: vec![3.0, 6.0],
538        };
539        let (x, lb, ub) = ([0.0, 0.0, 0.7], [-10.0; 3], [10.0; 3]);
540        let options = OperatorOptions::default();
541        let result = least_squares_operator(&mut problem, &x, &lb, &ub, &options).unwrap();
542        assert!((result.x[0] + result.x[1] - 3.0).abs() < 1e-7);
543        assert_eq!(result.x[2], 0.7);
544        assert!(
545            least_squares_operator(
546                &mut problem,
547                &x,
548                &lb,
549                &ub,
550                &OperatorOptions {
551                    max_cg_iterations: 0,
552                    ..options
553                }
554            )
555            .is_err()
556        );
557    }
558}