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}