Skip to main content

spectrafit_levenberg_marquardt/
step.rs

1//! Regime-adaptive Levenberg–Marquardt step solvers.
2//!
3//! Both strategies solve the damped Gauss–Newton (Levenberg–Marquardt) system
4//! ```text
5//!   (JᵀJ + λ·D²) δ = −g ,   g = Jᵀr
6//! ```
7//! where `D = diag(diag)` is the column-scaling diagonal. They differ only in
8//! how the linear algebra is carried out:
9//!
10//! * [`StepKind::NormalEqLlt`] — form the `p×p` normal-equations matrix and
11//!   Cholesky-solve. One streaming `JᵀJ` reduction collapses all `O(m)` work,
12//!   so it dominates when `m ≫ p`. The predicted reduction is then computed
13//!   from the `p×p` Hessian `H` — no second `O(m)` pass per `λ` trial.
14//! * [`StepKind::SvdSecular`] — one thin SVD of the (column-scaled) Jacobian,
15//!   then a closed-form damped solution. Avoids forming `JᵀJ`, so it does not
16//!   square the condition number — preferred when `p` is large / `J` is
17//!   ill-conditioned.
18
19use faer::prelude::*;
20use faer::{Mat, MatRef, Side};
21
22use crate::error::StepError;
23
24/// Which linear-algebra path the step uses. Chosen per-fit by [`select_regime`].
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StepKind {
27    /// Normal equations + Cholesky (`m ≫ p`).
28    NormalEqLlt,
29    /// SVD + secular/closed-form damped solve (large `p` / ill-conditioned).
30    SvdSecular,
31}
32
33/// Choose the step factorization path from the problem shape.
34///
35/// * Many parameters (`p > 40`) ⇒ [`StepKind::SvdSecular`] to avoid squaring the
36///   condition number on an ill-conditioned `J`.
37/// * Tall and thin (`m ≥ 8·p`) ⇒ [`StepKind::NormalEqLlt`]: the `JᵀJ` reduction
38///   collapses the `O(m)` work and the `p×p` Cholesky is trivially cheap.
39/// * Otherwise default to [`StepKind::NormalEqLlt`]; a cheap conditioning probe
40///   (column-norm ratio) for escalating borderline cases is a known follow-up.
41///
42/// The `p > 40` and `m ≥ 8·p` constants are documented heuristics, not tuned
43/// thresholds — revisit alongside the benchmark.
44pub fn select_regime(n_residuals: usize, n_params: usize) -> StepKind {
45    // `m ≥ 8·p` is the clean normal-equations regime. Borderline shapes
46    // (`p ≤ 40` but `m < 8·p`) also default to NE today; a cheap conditioning
47    // probe (column-norm ratio) to escalate them to SVD when `J` is
48    // ill-conditioned is a known follow-up. `n_residuals` is named for that future use.
49    let _ = n_residuals;
50    if n_params > 40 {
51        StepKind::SvdSecular
52    } else {
53        StepKind::NormalEqLlt
54    }
55}
56
57/// A computed trial step and the cost reduction the linear model predicts for it.
58pub struct StepOutput {
59    /// Trial step `δ` (shape `p × 1`).
60    pub delta: Mat<f64>,
61    /// Predicted decrease of `½‖r‖²`: `−gᵀδ − ½δᵀJᵀJδ` (positive for descent).
62    pub predicted_reduction: f64,
63}
64
65#[inline]
66fn col_dot(a: MatRef<'_, f64>, b: MatRef<'_, f64>) -> f64 {
67    let n = a.nrows();
68    let mut s = 0.0;
69    for i in 0..n {
70        s += a[(i, 0)] * b[(i, 0)];
71    }
72    s
73}
74
75/// A once-per-outer-iteration factorization of the (column-scaled) Jacobian,
76/// reused across every `λ` trial *and* by geodesic acceleration. This is the key
77/// to keeping the inner `λ` search cheap: the `O(m·p²)` work (forming `JᵀJ`, or
78/// the thin SVD of `J`) happens once; each `λ` trial is then only `O(p³)`
79/// (Cholesky) or `O(p²)` (closed form).
80///
81/// Both `J` and the column scaling `D = diag(diag)` are fixed across the inner
82/// loop (only `λ` changes), so this is sound.
83pub enum StepFactor {
84    /// Normal equations: stores `H = JᵀJ` (`p×p`).
85    Ne {
86        /// The Gauss–Newton Hessian `H = JᵀJ` (`p×p`).
87        h: Mat<f64>,
88    },
89    /// Thin SVD of the column-scaled `J̃ = J·diag(1/D)`: `U, s, V`.
90    Svd {
91        /// Left singular vectors `U` (`m×k`).
92        u: Mat<f64>,
93        /// Singular values `s` (length `k`), `k = min(m, p)`.
94        s: Vec<f64>,
95        /// Right singular vectors `V` (`p×k`).
96        v: Mat<f64>,
97    },
98}
99
100/// Factor the step operator once for the current Jacobian and column scaling.
101/// `diag` (`D`) is the per-iteration damping scale; for the SVD path it is baked
102/// into the factorization (`J̃ = J/D`), for the NE path it is applied per `λ`.
103///
104/// # Errors
105/// Returns [`StepError::Factorization`] when the [`StepKind::SvdSecular`]
106/// thin SVD of the column-scaled Jacobian fails to converge. The
107/// [`StepKind::NormalEqLlt`] path forms `H = JᵀJ` directly and cannot fail
108/// here (its own Cholesky factorization happens later, per-`λ`, in
109/// [`StepFactor::solve`]/[`StepFactor::solve_rhs`]).
110pub fn factorize(
111    kind: StepKind,
112    j: MatRef<'_, f64>,
113    diag: &[f64],
114) -> Result<StepFactor, StepError> {
115    match kind {
116        StepKind::NormalEqLlt => Ok(StepFactor::Ne {
117            h: j.transpose() * j,
118        }),
119        StepKind::SvdSecular => {
120            let m = j.nrows();
121            let p = j.ncols();
122            let j_scaled = Mat::from_fn(m, p, |i, c| j[(i, c)] / diag[c]);
123            let svd = j_scaled
124                .as_ref()
125                .thin_svd()
126                // faer::SvdError does not implement Display; use Debug format.
127                .map_err(|e| StepError::Factorization(format!("{e:?}")))?;
128            let uu = svd.U();
129            let vv = svd.V();
130            let sv = svd.S().column_vector();
131            let k = sv.nrows();
132            Ok(StepFactor::Svd {
133                u: Mat::from_fn(uu.nrows(), uu.ncols(), |i, jx| uu[(i, jx)]),
134                v: Mat::from_fn(vv.nrows(), vv.ncols(), |i, jx| vv[(i, jx)]),
135                s: (0..k).map(|i| sv[i]).collect(),
136            })
137        }
138    }
139}
140
141impl StepFactor {
142    /// Solve `(JᵀJ + λD²) δ = −g` for the trial step `δ` and its predicted
143    /// reduction `−gᵀδ − ½‖Jδ‖²`. `r` is the residual (used by the SVD path).
144    ///
145    /// # Errors
146    /// Returns [`StepError::NotPositiveDefinite`] on the [`StepKind::NormalEqLlt`]
147    /// path when `H + λD²` fails its Cholesky factorization (not positive
148    /// definite for the current damping `λ`). The [`StepKind::SvdSecular`] path
149    /// cannot fail here — its factorization risk was already taken in
150    /// [`factorize`].
151    pub fn solve(
152        &self,
153        g: MatRef<'_, f64>,
154        r: MatRef<'_, f64>,
155        diag: &[f64],
156        lambda: f64,
157    ) -> Result<StepOutput, StepError> {
158        match self {
159            StepFactor::Ne { h } => {
160                let p = h.ncols();
161                let mut a = h.clone();
162                for i in 0..p {
163                    a[(i, i)] += lambda * diag[i] * diag[i];
164                }
165                let llt = a
166                    .as_ref()
167                    .llt(Side::Lower)
168                    .map_err(|_| StepError::NotPositiveDefinite)?;
169                let neg_g = Mat::from_fn(p, 1, |i, _| -g[(i, 0)]);
170                let delta = llt.solve(neg_g.as_ref());
171                // pred = −gᵀδ − ½ δᵀHδ, both O(p²).
172                let hd = h.as_ref() * delta.as_ref();
173                let predicted_reduction =
174                    -col_dot(g, delta.as_ref()) - 0.5 * col_dot(delta.as_ref(), hd.as_ref());
175                Ok(StepOutput {
176                    delta,
177                    predicted_reduction,
178                })
179            }
180            StepFactor::Svd { u, s, v } => {
181                let p = v.nrows();
182                let k = s.len();
183                // δ = D⁻¹ V diag(s/(s²+λ)) Uᵀr  (SVD of J̃ = J/D).
184                let utr = u.as_ref().transpose() * r; // k×1
185                let coef = Mat::from_fn(k, 1, |i, _| {
186                    let si = s[i];
187                    -(si / (si * si + lambda)) * utr[(i, 0)]
188                });
189                let y = v.as_ref() * coef.as_ref(); // p×1
190                let delta = Mat::from_fn(p, 1, |i, _| y[(i, 0)] / diag[i]);
191                // ‖Jδ‖² = ‖J̃(D∘δ)‖² = Σ_i (s_i·(Vᵀ(D∘δ))_i)².
192                let z = Mat::from_fn(p, 1, |i, _| diag[i] * delta[(i, 0)]);
193                let vtz = v.as_ref().transpose() * z.as_ref(); // k×1
194                let mut jd2 = 0.0;
195                for i in 0..k {
196                    let x = s[i] * vtz[(i, 0)];
197                    jd2 += x * x;
198                }
199                let predicted_reduction = -col_dot(g, delta.as_ref()) - 0.5 * jd2;
200                Ok(StepOutput {
201                    delta,
202                    predicted_reduction,
203                })
204            }
205        }
206    }
207
208    /// Solve `(JᵀJ + λD²) x = rhs` for an arbitrary right-hand side (geodesic
209    /// acceleration: `rhs = −Jᵀr_vv`). Reuses this factorization — no re-forming.
210    ///
211    /// # Errors
212    /// Returns [`StepError::NotPositiveDefinite`] on the [`StepKind::NormalEqLlt`]
213    /// path when `H + λD²` fails its Cholesky factorization, exactly as in
214    /// [`StepFactor::solve`]. The [`StepKind::SvdSecular`] path cannot fail here.
215    pub fn solve_rhs(
216        &self,
217        diag: &[f64],
218        lambda: f64,
219        rhs: MatRef<'_, f64>,
220    ) -> Result<Mat<f64>, StepError> {
221        match self {
222            StepFactor::Ne { h } => {
223                let p = h.ncols();
224                let mut a = h.clone();
225                for i in 0..p {
226                    a[(i, i)] += lambda * diag[i] * diag[i];
227                }
228                let llt = a
229                    .as_ref()
230                    .llt(Side::Lower)
231                    .map_err(|_| StepError::NotPositiveDefinite)?;
232                Ok(llt.solve(rhs))
233            }
234            StepFactor::Svd { s, v, .. } => {
235                let p = v.nrows();
236                let k = s.len();
237                // x = D⁻¹ V diag(1/(s²+λ)) Vᵀ D⁻¹ rhs.
238                let dinv_rhs = Mat::from_fn(p, 1, |i, _| rhs[(i, 0)] / diag[i]);
239                let w = v.as_ref().transpose() * dinv_rhs.as_ref(); // k×1
240                let w_scaled = Mat::from_fn(k, 1, |i, _| {
241                    let si = s[i];
242                    w[(i, 0)] / (si * si + lambda)
243                });
244                let y = v.as_ref() * w_scaled.as_ref(); // p×1
245                Ok(Mat::from_fn(p, 1, |i, _| y[(i, 0)] / diag[i]))
246            }
247        }
248    }
249}