Skip to main content

sim_lib_numbers_optimize/
least_squares.rs

1//! Nonlinear and linear least-squares solvers.
2
3use super::*;
4
5/// Damped LM for unconstrained fits and trust-region reflective active-set steps for boxes.
6pub fn least_squares<R, J>(
7    residual: R,
8    jacobian: J,
9    mut x: Vec<f64>,
10    plan: &LeastSquaresPlan,
11) -> Result<LeastSquaresResult, Error>
12where
13    R: Fn(&[f64]) -> Vec<f64>,
14    J: Fn(&[f64]) -> Vec<Vec<f64>>,
15{
16    let n = x.len();
17    validate_scale(&plan.variable_scale, n)?;
18    if plan.initial_damping <= 0.0 || !plan.initial_damping.is_finite() {
19        return Err(Error::InvalidPlan(
20            "initial damping must be finite and positive",
21        ));
22    }
23    if let Some(bounds) = &plan.bounds {
24        if bounds.lower.len() != n {
25            return Err(Error::Dimension("bounds and point differ"));
26        }
27        bounds.project(&mut x);
28        if plan.policy != StepPolicy::TrustRegionReflective {
29            return Err(Error::InvalidPlan(
30                "bounded least squares requires trust-region reflective policy",
31            ));
32        }
33    } else if plan.policy != StepPolicy::LevenbergMarquardt {
34        return Err(Error::InvalidPlan(
35            "unconstrained least squares requires LM",
36        ));
37    }
38    let mut lambda = plan.initial_damping.max(1e-12);
39    let mut evals = 0;
40    for iter in 0..plan.limits.iterations {
41        if evals >= plan.limits.evaluations {
42            return Ok(ls_result(
43                x,
44                &residual,
45                &jacobian,
46                plan.bounds.as_ref(),
47                Termination::WorkLimit,
48                evals,
49                iter,
50                false,
51            ));
52        }
53        let r = residual(&x);
54        let j = jacobian(&x);
55        evals += 2;
56        if plan.residual_scale.len() != r.len()
57            || plan
58                .residual_scale
59                .iter()
60                .any(|v| !v.is_finite() || *v <= 0.0)
61            || j.len() != r.len()
62            || j.iter().any(|v| v.len() != n)
63        {
64            return Err(Error::Dimension(
65                "residual scaling or Jacobian dimensions differ",
66            ));
67        }
68        let memory = (j.len() * n + n * n) * 8;
69        if memory > plan.limits.memory_bytes {
70            return Ok(ls_result(
71                x,
72                &residual,
73                &jacobian,
74                plan.bounds.as_ref(),
75                Termination::WorkLimit,
76                evals,
77                iter,
78                false,
79            ));
80        }
81        if !finite(&r) || j.iter().any(|v| !finite(v)) {
82            return Ok(ls_result(
83                x,
84                &residual,
85                &jacobian,
86                plan.bounds.as_ref(),
87                Termination::NonFinite,
88                evals,
89                iter,
90                false,
91            ));
92        }
93        let mut step = if let Some(bounds) = &plan.bounds {
94            reflective_step(
95                &j,
96                &r,
97                &x,
98                bounds,
99                lambda,
100                (1.0 / lambda).sqrt(),
101                plan.tolerances.gradient,
102            )
103        } else {
104            let (a, b) = normal(&j, &r, lambda);
105            solve(a, b, plan.tolerances.gradient).0
106        };
107        if norm(&step) <= plan.tolerances.argument {
108            return Ok(ls_result(
109                x,
110                &residual,
111                &jacobian,
112                plan.bounds.as_ref(),
113                Termination::Converged,
114                evals,
115                iter,
116                false,
117            ));
118        }
119        if let Some(bounds) = &plan.bounds {
120            // Fraction-to-the-boundary keeps the trial strictly feasible. The
121            // diagonal reflective metric above, rather than projection, defines
122            // the step direction and trust region.
123            for i in 0..n {
124                let room = if step[i] > 0.0 {
125                    bounds.upper[i] - x[i]
126                } else {
127                    x[i] - bounds.lower[i]
128                };
129                if step[i].abs() > room {
130                    step[i] = (0.995 * room).copysign(step[i])
131                }
132            }
133        }
134        let y = x.iter().zip(&step).map(|(a, b)| a + b).collect::<Vec<_>>();
135        let nr = residual(&y);
136        evals += 1;
137        if !finite(&nr) {
138            lambda *= 10.0;
139            continue;
140        }
141        if norm(&nr) < norm(&r) {
142            x = y;
143            lambda *= 0.3;
144            if (norm(&r) - norm(&nr)).abs() <= plan.tolerances.objective {
145                return Ok(ls_result(
146                    x,
147                    &residual,
148                    &jacobian,
149                    plan.bounds.as_ref(),
150                    Termination::Converged,
151                    evals,
152                    iter + 1,
153                    false,
154                ));
155            }
156        } else {
157            lambda *= 10.0
158        }
159    }
160    Ok(ls_result(
161        x,
162        &residual,
163        &jacobian,
164        plan.bounds.as_ref(),
165        Termination::WorkLimit,
166        evals,
167        plan.limits.iterations,
168        false,
169    ))
170}
171
172/// Active-set bounded linear least squares over the shared, pivot-free Jacobi SVD.
173///
174/// Each iteration solves the reduced problem for the currently free variables,
175/// fixes the first bound encountered, and releases a bound only when its KKT
176/// multiplier has the wrong sign. This is a bounded-variable least-squares
177/// path, not an unconstrained solve followed by clipping.
178pub fn linear_least_squares(
179    a: &[Vec<f64>],
180    b: &[f64],
181    bounds: Bounds,
182    tol: f64,
183    limits: Limits,
184    statistical_assumptions: bool,
185) -> Result<LeastSquaresResult, Error> {
186    let n = a.first().map_or(0, Vec::len);
187    if a.len() != b.len() || bounds.lower.len() != n || a.iter().any(|r| r.len() != n) {
188        return Err(Error::Dimension("linear system dimensions differ"));
189    }
190    if !tol.is_finite() || tol <= 0.0 || !finite(b) || a.iter().any(|r| !finite(r)) {
191        return Err(Error::InvalidPlan(
192            "linear least-squares data and tolerance must be finite",
193        ));
194    }
195    let memory = a.len().saturating_mul(n).saturating_mul(24);
196    if memory > limits.memory_bytes {
197        return Ok(linear_result(
198            a,
199            b,
200            vec![0.0; n],
201            0,
202            Vec::new(),
203            0,
204            statistical_assumptions,
205            Termination::WorkLimit,
206        ));
207    }
208    let mut x = bounds
209        .lower
210        .iter()
211        .zip(&bounds.upper)
212        .map(|(l, u)| 0.0_f64.clamp(*l, *u))
213        .collect::<Vec<_>>();
214    let mut active = vec![None; n]; // Some(false) lower, Some(true) upper.
215    let mut iterations = 0;
216    for k in 0..limits.iterations {
217        iterations = k + 1;
218        let free = (0..n).filter(|&i| active[i].is_none()).collect::<Vec<_>>();
219        let adjusted = a
220            .iter()
221            .zip(b)
222            .map(|(row, rhs)| {
223                rhs - (0..n)
224                    .filter(|&i| active[i].is_some())
225                    .map(|i| row[i] * x[i])
226                    .sum::<f64>()
227            })
228            .collect::<Vec<_>>();
229        let reduced = a
230            .iter()
231            .flat_map(|row| free.iter().map(|&i| row[i]))
232            .collect::<Vec<_>>();
233        let (candidate, rank) = if free.is_empty() {
234            (Vec::new(), 0)
235        } else {
236            let svd = svd_f64(
237                &reduced,
238                a.len(),
239                free.len(),
240                SvdPlan {
241                    max_dimension: a.len().max(free.len()),
242                    max_work: u64::try_from(limits.evaluations)
243                        .unwrap_or(u64::MAX)
244                        .saturating_mul(1_000),
245                    max_iterations: limits.iterations.max(1),
246                    tolerance: tol,
247                    vectors: VectorForm::Thin,
248                    reconstruction_tolerance: tol.sqrt().max(1e-10),
249                    return_partial: false,
250                },
251            )
252            .map_err(|_| {
253                Error::InvalidPlan("SVD could not certify the reduced least-squares system")
254            })?;
255            let rank = numerical_rank(&svd, SingularCutoff(tol))
256                .map_err(|_| Error::InvalidPlan("invalid SVD cutoff"))?;
257            let z = svd_least_squares(&svd, &adjusted, SingularCutoff(tol))
258                .map_err(|_| Error::InvalidPlan("SVD least-squares solve failed"))?;
259            (z, rank)
260        };
261        let mut target = x.clone();
262        for (&i, &z) in free.iter().zip(&candidate) {
263            target[i] = z;
264        }
265        let mut alpha = 1.0_f64;
266        let mut hit = None;
267        for &i in &free {
268            let step = target[i] - x[i];
269            let (bound, upper) = if step > 0.0 {
270                (bounds.upper[i], true)
271            } else {
272                (bounds.lower[i], false)
273            };
274            if step != 0.0 {
275                let q = (bound - x[i]) / step;
276                if q >= 0.0 && q < alpha {
277                    alpha = q;
278                    hit = Some((i, upper));
279                }
280            }
281        }
282        for &i in &free {
283            x[i] += alpha * (target[i] - x[i]);
284        }
285        if let Some((i, upper)) = hit {
286            x[i] = if upper {
287                bounds.upper[i]
288            } else {
289                bounds.lower[i]
290            };
291            active[i] = Some(upper);
292            continue;
293        }
294
295        let residual = linear_residual(a, b, &x);
296        let gradient = (0..n)
297            .map(|j| {
298                a.iter()
299                    .zip(&residual)
300                    .map(|(row, r)| row[j] * r)
301                    .sum::<f64>()
302            })
303            .collect::<Vec<_>>();
304        let release = (0..n)
305            .filter(|&i| match active[i] {
306                Some(false) => gradient[i] < -tol,
307                Some(true) => gradient[i] > tol,
308                None => false,
309            })
310            .max_by(|&i, &j| gradient[i].abs().total_cmp(&gradient[j].abs()));
311        if let Some(i) = release {
312            active[i] = None;
313        } else {
314            let indices = (0..n).filter(|&i| active[i].is_some()).collect::<Vec<_>>();
315            let termination = if indices.is_empty() {
316                Termination::Converged
317            } else {
318                Termination::BoundaryConverged
319            };
320            return Ok(linear_result(
321                a,
322                b,
323                x,
324                rank + indices.len(),
325                indices,
326                iterations,
327                statistical_assumptions,
328                termination,
329            ));
330        }
331        if iterations >= limits.evaluations {
332            let indices = (0..n).filter(|&i| active[i].is_some()).collect::<Vec<_>>();
333            return Ok(linear_result(
334                a,
335                b,
336                x,
337                rank,
338                indices,
339                iterations,
340                statistical_assumptions,
341                Termination::WorkLimit,
342            ));
343        }
344    }
345    let active = (0..n)
346        .filter(|&i| (x[i] - bounds.lower[i]).abs() <= tol || (x[i] - bounds.upper[i]).abs() <= tol)
347        .collect();
348    Ok(linear_result(
349        a,
350        b,
351        x,
352        n,
353        active,
354        iterations,
355        statistical_assumptions,
356        Termination::WorkLimit,
357    ))
358}
359pub(crate) fn linear_residual(a: &[Vec<f64>], b: &[f64], x: &[f64]) -> Vec<f64> {
360    a.iter()
361        .zip(b)
362        .map(|(row, y)| row.iter().zip(x).map(|(v, z)| v * z).sum::<f64>() - y)
363        .collect()
364}
365#[allow(clippy::too_many_arguments)]
366pub(crate) fn linear_result(
367    a: &[Vec<f64>],
368    b: &[f64],
369    x: Vec<f64>,
370    rank: usize,
371    active: Vec<usize>,
372    iterations: usize,
373    stats: bool,
374    termination: Termination,
375) -> LeastSquaresResult {
376    let r = linear_residual(a, b, &x);
377    let n = x.len();
378    let covariance = if rank < n {
379        Covariance::Unavailable(CovarianceUnavailable::RankDeficient)
380    } else if !stats {
381        Covariance::Unavailable(CovarianceUnavailable::StatisticalAssumptionsNotDeclared)
382    } else if a.len() <= n {
383        Covariance::Unavailable(CovarianceUnavailable::InsufficientDegreesOfFreedom)
384    } else {
385        let mut gram = vec![vec![0.0; n]; n];
386        for row in a {
387            for i in 0..n {
388                for j in 0..n {
389                    gram[i][j] += row[i] * row[j];
390                }
391            }
392        }
393        match inverse(gram, 1e-12) {
394            Some(mut inv) => {
395                let variance = r.iter().map(|v| v * v).sum::<f64>() / (a.len() - n) as f64;
396                for row in &mut inv {
397                    for v in row {
398                        *v *= variance;
399                    }
400                }
401                Covariance::Available(inv)
402            }
403            None => Covariance::Unavailable(CovarianceUnavailable::RankDeficient),
404        }
405    };
406    LeastSquaresResult {
407        point: x,
408        residual_norm: norm(&r),
409        residuals: r,
410        rank,
411        active,
412        covariance,
413        termination,
414        work: Work {
415            evaluations: iterations,
416            iterations,
417            memory_bytes: (a.len() * n + n * n) * 8,
418        },
419    }
420}
421
422pub(crate) fn inverse(mut a: Vec<Vec<f64>>, tol: f64) -> Option<Vec<Vec<f64>>> {
423    let n = a.len();
424    let mut inv = vec![vec![0.0; n]; n];
425    for (i, row) in inv.iter_mut().enumerate() {
426        row[i] = 1.0
427    }
428    for k in 0..n {
429        let p = (k..n).max_by(|&i, &j| a[i][k].abs().total_cmp(&a[j][k].abs()))?;
430        if a[p][k].abs() <= tol {
431            return None;
432        }
433        a.swap(k, p);
434        inv.swap(k, p);
435        let d = a[k][k];
436        for j in 0..n {
437            a[k][j] /= d;
438            inv[k][j] /= d
439        }
440        for i in 0..n {
441            if i != k {
442                let q = a[i][k];
443                for j in 0..n {
444                    a[i][j] -= q * a[k][j];
445                    inv[i][j] -= q * inv[k][j]
446                }
447            }
448        }
449    }
450    Some(inv)
451}
452#[allow(clippy::too_many_arguments)]
453pub(crate) fn ls_result<R: Fn(&[f64]) -> Vec<f64>, J: Fn(&[f64]) -> Vec<Vec<f64>>>(
454    x: Vec<f64>,
455    r: &R,
456    j: &J,
457    bounds: Option<&Bounds>,
458    t: Termination,
459    e: usize,
460    i: usize,
461    stats: bool,
462) -> LeastSquaresResult {
463    let rv = r(&x);
464    let jj = j(&x);
465    let (_, rank) = solve(normal(&jj, &rv, 0.0).0, vec![0.0; x.len()], 1e-10);
466    let n = x.len();
467    let active = bounds.map_or_else(Vec::new, |bounds| {
468        (0..n)
469            .filter(|&i| {
470                (x[i] - bounds.lower[i]).abs() <= 10.0 * f64::EPSILON.sqrt()
471                    || (x[i] - bounds.upper[i]).abs() <= 10.0 * f64::EPSILON.sqrt()
472            })
473            .collect()
474    });
475    LeastSquaresResult {
476        point: x,
477        residual_norm: norm(&rv),
478        residuals: rv,
479        rank,
480        active,
481        covariance: if rank < n {
482            Covariance::Unavailable(CovarianceUnavailable::RankDeficient)
483        } else if stats {
484            Covariance::Available(vec![vec![0.0; n]; n])
485        } else {
486            Covariance::Unavailable(CovarianceUnavailable::StatisticalAssumptionsNotDeclared)
487        },
488        termination: t,
489        work: Work {
490            evaluations: e,
491            iterations: i,
492            memory_bytes: jj.len() * n * 8,
493        },
494    }
495}