axiolid_numeric/
optimize.rs

1//! Minimisation: a bounded scalar minimiser and damped Gauss-Newton
2//! (Levenberg-Marquardt) for nonlinear least squares.
3//!
4//! - [`minimize_scalar`]: Brent's `localmin` (golden section with
5//!   parabolic interpolation), Brent 1973 chapter 5, OCCT `math_BrentMinimum`.
6//! - [`levenberg_marquardt`]: minimises `1/2 ||r(x)||^2` with Moré's
7//!   column scaling (Moré, *The Levenberg-Marquardt algorithm:
8//!   implementation and theory*, 1978) and Nielsen's damping update.
9//!   Each step solves the damped system as an augmented least-squares
10//!   problem by QR rather than forming normal equations, so the Jacobian's
11//!   conditioning is not squared. A square system with a zero-residual
12//!   solution makes this a solver for nonlinear equations (OCCT
13//!   `math_FunctionSetRoot`).
14//!
15//! Both are local methods: they find a local minimiser near the start (or
16//! inside the bracket), and say how they stopped.
17
18use crate::error::{all_finite, finite, tolerance, NumericError, NumericResult, Status};
19use crate::linalg::{Matrix, Qr};
20
21/// A local minimum of a scalar function on an interval.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[must_use]
24pub struct ScalarMinimum {
25    /// The minimiser estimate.
26    pub x: f64,
27    /// `f(x)`.
28    pub value: f64,
29    /// Lower end of the final bracket.
30    pub lower: f64,
31    /// Upper end of the final bracket. If `f` is unimodal on the starting
32    /// interval, the minimiser lies in `[lower, upper]`.
33    pub upper: f64,
34    /// Iterations performed.
35    pub iterations: usize,
36    /// Function evaluations performed.
37    pub evaluations: usize,
38    /// How the search stopped.
39    pub status: Status,
40}
41
42impl ScalarMinimum {
43    /// A bound on `|x - minimiser|` for a unimodal function: the final
44    /// bracket width.
45    pub fn error_bound(&self) -> f64 {
46        self.upper - self.lower
47    }
48
49    /// The minimum if the tolerance was met.
50    ///
51    /// # Errors
52    ///
53    /// [`NumericError::NotConverged`] with the bracket width reached.
54    pub fn converged(self) -> NumericResult<Self> {
55        if self.status == Status::Converged {
56            Ok(self)
57        } else {
58            Err(NumericError::NotConverged {
59                name: "minimize_scalar",
60                error_estimate: self.error_bound(),
61            })
62        }
63    }
64}
65
66/// Minimise `f` on `[lower, upper]` by Brent's method.
67///
68/// Stops when the bracket around the estimate is within
69/// `2 (sqrt(eps) |x| + x_tolerance / 3)` of it on both sides. Relative
70/// accuracy is limited to `sqrt(eps)`: near a smooth minimum, `f` is flat to
71/// second order, so `f64` cannot place the minimiser more closely. The
72/// function is never evaluated at the interval ends; for a monotone `f` the
73/// estimate converges to within the tolerance of the lower end.
74///
75/// # Errors
76///
77/// Refuses non-finite bounds or tolerance, `lower >= upper`, a negative
78/// tolerance, and any non-finite value of `f`.
79pub fn minimize_scalar<F>(
80    mut f: F,
81    lower: f64,
82    upper: f64,
83    x_tolerance: f64,
84) -> NumericResult<ScalarMinimum>
85where
86    F: FnMut(f64) -> f64,
87{
88    finite(lower, "lower bound")?;
89    finite(upper, "upper bound")?;
90    tolerance(x_tolerance, "x tolerance")?;
91    if lower >= upper {
92        return Err(NumericError::InvalidArgument {
93            name: "interval",
94            reason: "must have lower < upper",
95        });
96    }
97    const MAX_ITERATIONS: usize = 1000;
98    let golden = 0.5 * (3.0 - 5.0f64.sqrt());
99    let sqrt_eps = f64::EPSILON.sqrt();
100    let mut evaluations = 0usize;
101    let mut eval = |x: f64| -> NumericResult<f64> {
102        evaluations += 1;
103        let v = f(x);
104        if v.is_finite() {
105            Ok(v)
106        } else {
107            Err(NumericError::NonFiniteEvaluation {
108                name: "function",
109                at: Some(x),
110            })
111        }
112    };
113
114    let (mut a, mut b) = (lower, upper);
115    let mut x = a + golden * (b - a);
116    let (mut w, mut v) = (x, x);
117    let mut fx = eval(x)?;
118    let (mut fw, mut fv) = (fx, fx);
119    let (mut d, mut e) = (0.0f64, 0.0f64);
120    let mut iterations = 0;
121    let status = loop {
122        let xm = 0.5 * (a + b);
123        let tol1 = sqrt_eps * x.abs() + x_tolerance / 3.0;
124        let tol2 = 2.0 * tol1;
125        if (x - xm).abs() <= tol2 - 0.5 * (b - a) {
126            break Status::Converged;
127        }
128        if iterations >= MAX_ITERATIONS {
129            break Status::BudgetExhausted;
130        }
131        iterations += 1;
132        let mut use_golden = true;
133        if e.abs() > tol1 {
134            let r = (x - w) * (fx - fv);
135            let mut q = (x - v) * (fx - fw);
136            let mut p = (x - v) * q - (x - w) * r;
137            q = 2.0 * (q - r);
138            if q > 0.0 {
139                p = -p;
140            }
141            q = q.abs();
142            let previous = e;
143            e = d;
144            if p.abs() < (0.5 * q * previous).abs() && p > q * (a - x) && p < q * (b - x) {
145                d = p / q;
146                let u = x + d;
147                if u - a < tol2 || b - u < tol2 {
148                    d = tol1.copysign(xm - x);
149                }
150                use_golden = false;
151            }
152        }
153        if use_golden {
154            e = if x >= xm { a - x } else { b - x };
155            d = golden * e;
156        }
157        let u = if d.abs() >= tol1 {
158            x + d
159        } else {
160            x + tol1.copysign(d)
161        };
162        let fu = eval(u)?;
163        if fu <= fx {
164            if u >= x {
165                a = x;
166            } else {
167                b = x;
168            }
169            v = w;
170            fv = fw;
171            w = x;
172            fw = fx;
173            x = u;
174            fx = fu;
175        } else {
176            if u < x {
177                a = u;
178            } else {
179                b = u;
180            }
181            if fu <= fw || w == x {
182                v = w;
183                fv = fw;
184                w = u;
185                fw = fu;
186            } else if fu <= fv || v == x || v == w {
187                v = u;
188                fv = fu;
189            }
190        }
191    };
192    Ok(ScalarMinimum {
193        x,
194        value: fx,
195        lower: a,
196        upper: b,
197        iterations,
198        evaluations,
199        status,
200    })
201}
202
203/// A vector-valued residual function `r: R^n -> R^m` for
204/// [`levenberg_marquardt`].
205pub trait Residuals {
206    /// `m`, the number of residuals.
207    fn residual_count(&self) -> usize;
208
209    /// Write `r(x)` into `out` (length `m`).
210    fn residuals(&mut self, x: &[f64], out: &mut [f64]);
211
212    /// Write the Jacobian `dr_i / dx_j` into `out` (`m x n`).
213    ///
214    /// The default is forward differences with step
215    /// `sqrt(eps) max(|x_j|, 1)`; supply the analytic Jacobian where it is
216    /// available, since differences cost `n` evaluations and half the
217    /// digits.
218    fn jacobian(&mut self, x: &[f64], out: &mut Matrix) {
219        let m = self.residual_count();
220        let mut base = vec![0.0; m];
221        self.residuals(x, &mut base);
222        let mut shifted = vec![0.0; m];
223        let mut probe = x.to_vec();
224        for j in 0..x.len() {
225            let h = f64::EPSILON.sqrt() * x[j].abs().max(1.0);
226            probe[j] = x[j] + h;
227            let actual = probe[j] - x[j];
228            self.residuals(&probe, &mut shifted);
229            for i in 0..m {
230                out[(i, j)] = (shifted[i] - base[i]) / actual;
231            }
232            probe[j] = x[j];
233        }
234    }
235}
236
237/// Stopping rules and budget for [`levenberg_marquardt`].
238#[derive(Debug, Clone, Copy, PartialEq)]
239pub struct LevenbergMarquardtOptions {
240    /// Stop when `||J^T r||_inf` is at most this.
241    pub gradient_tolerance: f64,
242    /// Stop when a step is at most this times `||x|| + step_tolerance`.
243    pub step_tolerance: f64,
244    /// Stop when the cost `1/2 ||r||^2` is at most this (for zero-residual
245    /// problems such as equation systems; `0` disables).
246    pub cost_tolerance: f64,
247    /// Most iterations (accepted or rejected steps).
248    pub max_iterations: usize,
249}
250
251impl LevenbergMarquardtOptions {
252    /// Gradient and step tolerance `tolerance`, no cost tolerance, at most
253    /// 500 iterations.
254    pub fn new(tolerance: f64) -> Self {
255        Self {
256            gradient_tolerance: tolerance,
257            step_tolerance: tolerance,
258            cost_tolerance: 0.0,
259            max_iterations: 500,
260        }
261    }
262}
263
264/// Why [`levenberg_marquardt`] stopped.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum Termination {
267    /// The gradient met its tolerance.
268    Gradient,
269    /// A step met the step tolerance.
270    Step,
271    /// The cost met its tolerance.
272    Cost,
273    /// The iteration budget ran out.
274    IterationLimit,
275    /// No damping makes the cost decrease: rounding dominates the model.
276    NoProgress,
277}
278
279/// A local minimiser of `1/2 ||r(x)||^2`.
280#[derive(Debug, Clone, PartialEq)]
281#[must_use]
282pub struct LeastSquaresMinimum {
283    /// The minimiser estimate.
284    pub x: Vec<f64>,
285    /// `1/2 ||r(x)||^2`.
286    pub cost: f64,
287    /// `||J^T r||_inf` at `x`.
288    pub gradient_norm: f64,
289    /// Length of the undamped Gauss-Newton step from `x`: near an isolated
290    /// minimiser with a well-conditioned Jacobian, an estimate of the
291    /// distance to it. Where `jacobian_condition` is large the iteration
292    /// converges only linearly and this can underestimate the distance by a
293    /// small factor. `None` where the Jacobian is rank-deficient at `x`, so
294    /// the minimiser is not isolated and no estimate is given.
295    pub error_estimate: Option<f64>,
296    /// Estimate of the Jacobian's condition number at `x`.
297    pub jacobian_condition: f64,
298    /// Iterations performed.
299    pub iterations: usize,
300    /// Residual evaluations performed (Jacobian evaluations not counted).
301    pub evaluations: usize,
302    /// Why it stopped.
303    pub termination: Termination,
304    /// [`Status`] view of `termination`.
305    pub status: Status,
306}
307
308impl LeastSquaresMinimum {
309    /// The minimum if a tolerance was met.
310    ///
311    /// # Errors
312    ///
313    /// [`NumericError::NotConverged`] with the gradient norm reached.
314    pub fn converged(self) -> NumericResult<Self> {
315        if self.status == Status::Converged {
316            Ok(self)
317        } else {
318            Err(NumericError::NotConverged {
319                name: "levenberg_marquardt",
320                error_estimate: self.error_estimate.unwrap_or(f64::INFINITY),
321            })
322        }
323    }
324}
325
326fn norm(v: &[f64]) -> f64 {
327    v.iter().map(|t| t * t).sum::<f64>().sqrt()
328}
329
330/// Minimise `1/2 ||r(x)||^2` from `x0` by Levenberg-Marquardt.
331///
332/// # Errors
333///
334/// Refuses non-finite `x0`, non-finite or negative tolerances, no unknowns
335/// and no residuals. A non-finite residual or Jacobian at `x0` or at an
336/// accepted point is refused by name; a non-finite residual at a trial
337/// point only rejects that step. Fewer residuals than unknowns is accepted
338/// (the damping keeps each step defined), but the minimiser is then not
339/// isolated and `error_estimate` is `None`.
340pub fn levenberg_marquardt<R: Residuals>(
341    problem: &mut R,
342    x0: &[f64],
343    options: LevenbergMarquardtOptions,
344) -> NumericResult<LeastSquaresMinimum> {
345    all_finite(x0, "initial point")?;
346    let gtol = tolerance(options.gradient_tolerance, "gradient tolerance")?;
347    let xtol = tolerance(options.step_tolerance, "step tolerance")?;
348    let ftol = tolerance(options.cost_tolerance, "cost tolerance")?;
349    let n = x0.len();
350    let m = problem.residual_count();
351    if n == 0 {
352        return Err(NumericError::InvalidArgument {
353            name: "initial point",
354            reason: "must have at least one unknown",
355        });
356    }
357    if m == 0 {
358        return Err(NumericError::InvalidArgument {
359            name: "residual count",
360            reason: "must be at least one",
361        });
362    }
363
364    let mut evaluations = 0usize;
365    let mut evaluate = |problem: &mut R, x: &[f64]| -> Vec<f64> {
366        evaluations += 1;
367        let mut r = vec![0.0; m];
368        problem.residuals(x, &mut r);
369        r
370    };
371    let jacobian = |problem: &mut R, x: &[f64]| -> NumericResult<Matrix> {
372        let mut j = Matrix::zeros(m, n);
373        problem.jacobian(x, &mut j);
374        if (0..m).all(|i| j.row(i).iter().all(|v| v.is_finite())) {
375            Ok(j)
376        } else {
377            Err(NumericError::NonFiniteEvaluation {
378                name: "jacobian",
379                at: None,
380            })
381        }
382    };
383
384    let mut x = x0.to_vec();
385    let mut r = evaluate(problem, &x);
386    if r.iter().any(|v| !v.is_finite()) {
387        return Err(NumericError::NonFiniteEvaluation {
388            name: "residuals",
389            at: None,
390        });
391    }
392    let mut cost = 0.5 * r.iter().map(|t| t * t).sum::<f64>();
393    let mut jac = jacobian(problem, &x)?;
394    let gradient = |jac: &Matrix, r: &[f64]| -> Vec<f64> {
395        (0..n)
396            .map(|j| (0..m).map(|i| jac[(i, j)] * r[i]).sum())
397            .collect()
398    };
399    let mut g = gradient(&jac, &r);
400    let column_norm =
401        |jac: &Matrix, j: usize| (0..m).map(|i| jac[(i, j)].powi(2)).sum::<f64>().sqrt();
402    let mut scale: Vec<f64> = (0..n)
403        .map(|j| {
404            let c = column_norm(&jac, j);
405            if c > 0.0 {
406                c
407            } else {
408                1.0
409            }
410        })
411        .collect();
412    let mut mu = 1e-3f64;
413    let mut nu = 2.0f64;
414    let mut iterations = 0;
415
416    let termination = loop {
417        let g_inf = g.iter().fold(0.0f64, |s, v| s.max(v.abs()));
418        if g_inf <= gtol {
419            break Termination::Gradient;
420        }
421        if cost <= ftol {
422            break Termination::Cost;
423        }
424        if iterations >= options.max_iterations {
425            break Termination::IterationLimit;
426        }
427        iterations += 1;
428
429        // [J; sqrt(mu) D] delta = [-r; 0].
430        let root_mu = mu.sqrt();
431        let augmented = Matrix::from_fn(m + n, n, |i, j| {
432            if i < m {
433                jac[(i, j)]
434            } else if i - m == j {
435                root_mu * scale[j]
436            } else {
437                0.0
438            }
439        });
440        let mut rhs: Vec<f64> = r.iter().map(|v| -v).collect();
441        rhs.extend(std::iter::repeat_n(0.0, n));
442        let step = Qr::new(&augmented).and_then(|qr| qr.solve_least_squares(&rhs));
443        let delta = match step {
444            Ok(s) => s.x,
445            Err(NumericError::RankDeficient { .. }) => {
446                mu *= nu;
447                nu *= 2.0;
448                if !mu.is_finite() || mu > 1e300 {
449                    break Termination::NoProgress;
450                }
451                continue;
452            }
453            Err(other) => return Err(other),
454        };
455        let x_norm = norm(&x);
456        if norm(&delta) <= xtol * (x_norm + xtol) {
457            break Termination::Step;
458        }
459        let trial: Vec<f64> = x.iter().zip(&delta).map(|(a, b)| a + b).collect();
460        let r_trial = evaluate(problem, &trial);
461        let cost_trial = if r_trial.iter().all(|v| v.is_finite()) {
462            0.5 * r_trial.iter().map(|t| t * t).sum::<f64>()
463        } else {
464            f64::INFINITY
465        };
466        // Predicted reduction of the linear model.
467        let jd = jac.mul_vec(&delta)?;
468        let model: f64 = r.iter().zip(&jd).map(|(a, b)| (a + b) * (a + b)).sum();
469        let predicted = cost - 0.5 * model;
470        let actual = cost - cost_trial;
471        if predicted > 0.0 && actual > 0.0 {
472            let rho = actual / predicted;
473            x = trial;
474            r = r_trial;
475            cost = cost_trial;
476            jac = jacobian(problem, &x)?;
477            g = gradient(&jac, &r);
478            for (j, s) in scale.iter_mut().enumerate() {
479                *s = s.max(column_norm(&jac, j));
480            }
481            mu *= (1.0f64 / 3.0).max(1.0 - (2.0 * rho - 1.0).powi(3));
482            nu = 2.0;
483        } else {
484            mu *= nu;
485            nu *= 2.0;
486            if !mu.is_finite() || mu > 1e300 {
487                break Termination::NoProgress;
488            }
489        }
490    };
491
492    let gradient_norm = g.iter().fold(0.0f64, |s, v| s.max(v.abs()));
493    let (error_estimate, jacobian_condition) = match Qr::new(&jac) {
494        Ok(qr) => {
495            let neg: Vec<f64> = r.iter().map(|v| -v).collect();
496            let estimate = qr.solve_least_squares(&neg).ok().map(|s| norm(&s.x));
497            let condition = if qr.rank() < n {
498                f64::INFINITY
499            } else {
500                qr.condition_estimate()
501            };
502            (estimate, condition)
503        }
504        Err(_) => (None, f64::INFINITY),
505    };
506    let status = match termination {
507        Termination::Gradient | Termination::Step | Termination::Cost => Status::Converged,
508        Termination::IterationLimit => Status::BudgetExhausted,
509        Termination::NoProgress => Status::RoundoffLimited,
510    };
511    Ok(LeastSquaresMinimum {
512        x,
513        cost,
514        gradient_norm,
515        error_estimate,
516        jacobian_condition,
517        iterations,
518        evaluations,
519        termination,
520        status,
521    })
522}