Skip to main content

ferromotion_learn/
nls.rs

1//! **Differentiable nonlinear least squares** — optimization as a layer, the theseus-class
2//! capability (upstream frozen since 2024) in pure Rust.
3//!
4//! Solve `θ* = argmin_θ ½‖r(θ, φ)‖²` by damped Gauss–Newton, then differentiate the *solution*
5//! with respect to the outer parameters `φ` by **implicit differentiation** of the optimality
6//! condition: at the optimum `g(θ*, φ) = Jᵀr = 0`, so `dθ*/dφ = −(JᵀJ)⁻¹ Jᵀ ∂r/∂φ` with the
7//! Gauss–Newton Hessian — theseus's default, and **exact whenever the optimum has zero residual**
8//! (IK to a reachable target, noise-free fitting); at nonzero-residual optima it drops the
9//! `Σ rᵢ∇²rᵢ` term and is the standard small-residual approximation (tested to stay within a
10//! small band of the true finite-difference sensitivity). No unrolled solver iterations; each
11//! outer parameter costs one already-factored solve plus one dual pass.
12//!
13//! Residuals are written ONCE, generically over [`Real`] — the same code evaluates at `f64` for
14//! solving and at [`Dual`] for every Jacobian (`∂r/∂θ`, one forward pass per inner variable) and
15//! every sensitivity seed (`∂r/∂φ`, one pass per outer parameter). Forward-mode scaling is the
16//! honest trade for the small-to-medium `θ` this layer targets (IK, fitting, model correction);
17//! it composes directly with [`ferromotion_core::gendyn`] — an IK layer through the real FK is a
18//! ten-line residual (see tests). Pure Rust, wasm-clean.
19
20use crate::dual::Dual;
21use ferromotion_core::gendyn::{cholesky_solve, Real};
22
23/// A residual family evaluated at any scalar: inner variables `theta`, outer parameters `phi`.
24pub trait Residual {
25    fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T>;
26}
27
28/// Solver options: LM damping is adapted multiplicatively; iteration stops on gradient norm.
29#[derive(Clone, Copy, Debug)]
30pub struct NlsOptions {
31    pub max_iters: usize,
32    pub grad_tol: f64,
33    pub damping0: f64,
34}
35
36impl Default for NlsOptions {
37    fn default() -> Self {
38        Self { max_iters: 60, grad_tol: 1e-10, damping0: 1e-4 }
39    }
40}
41
42/// The solved layer: the optimum, its cost, and the machinery for exact outer sensitivities.
43pub struct NlsSolution {
44    pub theta: Vec<f64>,
45    pub cost: f64,
46    pub iters: usize,
47    /// `JᵀJ` at the optimum (row-major n×n), kept for the implicit-differentiation solves.
48    jtj: Vec<f64>,
49    /// `J` at the optimum (row-major m×n).
50    jac: Vec<f64>,
51    n_res: usize,
52}
53
54fn dual_theta(theta: &[f64], seed: usize) -> Vec<Dual> {
55    theta.iter().enumerate().map(|(i, &v)| if i == seed { Dual::var(v) } else { Dual::constant(v) }).collect()
56}
57
58fn consts(v: &[f64]) -> Vec<Dual> {
59    v.iter().map(|&x| Dual::constant(x)).collect()
60}
61
62/// Jacobian `∂r/∂θ` (row-major m×n) by one dual pass per inner variable.
63fn jacobian<R: Residual>(res: &R, theta: &[f64], phi: &[f64]) -> (Vec<f64>, Vec<f64>) {
64    let n = theta.len();
65    let phic = consts(phi);
66    let r0: Vec<f64> = res.eval(&consts(theta), &phic).iter().map(|d| d.re).collect();
67    let m = r0.len();
68    let mut jac = vec![0.0; m * n];
69    for j in 0..n {
70        let rj = res.eval(&dual_theta(theta, j), &phic);
71        assert_eq!(rj.len(), m, "residual count must not depend on θ");
72        for i in 0..m {
73            jac[i * n + j] = rj[i].eps;
74        }
75    }
76    (r0, jac)
77}
78
79/// Solve the damped Gauss–Newton problem and return the differentiable solution layer.
80pub fn solve<R: Residual>(res: &R, theta0: &[f64], phi: &[f64], opts: NlsOptions) -> NlsSolution {
81    let n = theta0.len();
82    let mut theta = theta0.to_vec();
83    let mut damping = opts.damping0;
84    let cost_of = |th: &[f64]| -> f64 {
85        res.eval(&consts(th), &consts(phi)).iter().map(|d| d.re * d.re).sum::<f64>() * 0.5
86    };
87    let mut cost = cost_of(&theta);
88    let mut iters = 0;
89    for _ in 0..opts.max_iters {
90        iters += 1;
91        let (r0, jac) = jacobian(res, &theta, phi);
92        let m = r0.len();
93        // g = Jᵀ r, H = JᵀJ (+ λ diag)
94        let mut g = vec![0.0; n];
95        let mut h = vec![0.0; n * n];
96        for i in 0..m {
97            for a in 0..n {
98                g[a] += jac[i * n + a] * r0[i];
99                for b in 0..n {
100                    h[a * n + b] += jac[i * n + a] * jac[i * n + b];
101                }
102            }
103        }
104        if g.iter().map(|v| v.abs()).fold(0.0, f64::max) < opts.grad_tol {
105            break;
106        }
107        // LM: try the damped step; shrink damping on success, grow on failure
108        let mut stepped = false;
109        for _ in 0..20 {
110            let mut hd = h.clone();
111            for a in 0..n {
112                hd[a * n + a] += damping * (1.0 + h[a * n + a]);
113            }
114            let step = cholesky_solve(&hd, &g, n);
115            let cand: Vec<f64> = theta.iter().zip(&step).map(|(t, s)| t - s).collect();
116            let c_new = cost_of(&cand);
117            if c_new.is_finite() && c_new <= cost {
118                theta = cand;
119                cost = c_new;
120                damping = (damping * 0.3).max(1e-12);
121                stepped = true;
122                break;
123            }
124            damping *= 10.0;
125        }
126        if !stepped {
127            break;
128        }
129    }
130    let (r0, jac) = jacobian(res, &theta, phi);
131    let m = r0.len();
132    let mut jtj = vec![0.0; n * n];
133    for i in 0..m {
134        for a in 0..n {
135            for b in 0..n {
136                jtj[a * n + b] += jac[i * n + a] * jac[i * n + b];
137            }
138        }
139    }
140    // tiny Tikhonov so the factorization is defined even at flat directions — reported honestly
141    for a in 0..n {
142        jtj[a * n + a] += 1e-12;
143    }
144    NlsSolution { theta, cost, iters, jtj, jac, n_res: m }
145}
146
147impl NlsSolution {
148    /// Exact sensitivity `dθ*/dφ_k` by implicit differentiation: `−(JᵀJ)⁻¹ Jᵀ ∂r/∂φ_k`, with
149    /// `∂r/∂φ_k` from one dual pass seeded in the outer parameter.
150    pub fn dtheta_dphi<R: Residual>(&self, res: &R, phi: &[f64], k: usize) -> Vec<f64> {
151        let n = self.theta.len();
152        let phid: Vec<Dual> =
153            phi.iter().enumerate().map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) }).collect();
154        let rk = res.eval(&consts(&self.theta), &phid);
155        assert_eq!(rk.len(), self.n_res);
156        let mut jtr = vec![0.0; n];
157        for (i, r) in rk.iter().enumerate() {
158            for a in 0..n {
159                jtr[a] += self.jac[i * n + a] * r.eps;
160            }
161        }
162        cholesky_solve(&self.jtj, &jtr, n).iter().map(|v| -v).collect()
163    }
164
165    /// Chain an outer loss gradient through the layer: given `∂L/∂θ*`, return `∂L/∂φ` (one
166    /// factored solve plus one dual pass per outer parameter).
167    pub fn backward<R: Residual>(&self, res: &R, phi: &[f64], dl_dtheta: &[f64]) -> Vec<f64> {
168        let n = self.theta.len();
169        // adjoint: w = (JᵀJ)⁻¹ ∂L/∂θ ;  ∂L/∂φ_k = −wᵀ Jᵀ ∂r/∂φ_k
170        let w = cholesky_solve(&self.jtj, dl_dtheta, n);
171        let jt_w: Vec<f64> = (0..self.n_res).map(|i| (0..n).map(|a| self.jac[i * n + a] * w[a]).sum()).collect();
172        (0..phi.len())
173            .map(|k| {
174                let phid: Vec<Dual> = phi
175                    .iter()
176                    .enumerate()
177                    .map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) })
178                    .collect();
179                let rk = res.eval(&consts(&self.theta), &phid);
180                -rk.iter().zip(&jt_w).map(|(r, w)| r.eps * w).sum::<f64>()
181            })
182            .collect()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    /// Linear least squares has a CLOSED FORM for both the optimum and the sensitivity:
191    /// θ* = (AᵀA)⁻¹Aᵀy and dθ*/dy = (AᵀA)⁻¹Aᵀ. The layer must reproduce both exactly.
192    struct LinearFit {
193        a: Vec<f64>, // m×n row-major
194        m: usize,
195        n: usize,
196    }
197    impl Residual for LinearFit {
198        fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
199            (0..self.m)
200                .map(|i| {
201                    let mut acc = T::from_f64(0.0) - phi[i];
202                    for j in 0..self.n {
203                        acc = acc + T::from_f64(self.a[i * self.n + j]) * theta[j];
204                    }
205                    acc
206                })
207                .collect()
208        }
209    }
210
211    #[test]
212    fn linear_problem_matches_the_closed_form_solution_and_sensitivity() {
213        let (m, n) = (7usize, 3usize);
214        let a: Vec<f64> = (0..m * n).map(|i| ((i * 7 + 3) % 11) as f64 * 0.3 - 1.0).collect();
215        let y: Vec<f64> = (0..m).map(|i| (i as f64 * 0.9).sin()).collect();
216        let prob = LinearFit { a: a.clone(), m, n };
217        let sol = solve(&prob, &vec![0.0; n], &y, NlsOptions::default());
218
219        // closed form via normal equations
220        let mut ata = vec![0.0; n * n];
221        let mut aty = vec![0.0; n];
222        for i in 0..m {
223            for p in 0..n {
224                aty[p] += a[i * n + p] * y[i];
225                for q in 0..n {
226                    ata[p * n + q] += a[i * n + p] * a[i * n + q];
227                }
228            }
229        }
230        let closed = cholesky_solve(&ata, &aty, n);
231        for (got, want) in sol.theta.iter().zip(&closed) {
232            assert!((got - want).abs() < 1e-8, "θ*: {got} vs {want}");
233        }
234        // dθ*/dy_k column: (AᵀA)⁻¹ Aᵀ e_k
235        for k in [0usize, 3, 6] {
236            let col_want = {
237                let atk: Vec<f64> = (0..n).map(|p| a[k * n + p]).collect();
238                cholesky_solve(&ata, &atk, n)
239            };
240            let col_got = sol.dtheta_dphi(&prob, &y, k);
241            for (g, w) in col_got.iter().zip(&col_want) {
242                assert!((g - w).abs() < 1e-8, "dθ/dy[{k}]: {g} vs {w}");
243            }
244        }
245    }
246
247    /// Nonlinear (exponential fit): the implicit sensitivity must match finite differences of
248    /// RE-SOLVING the whole problem — the strong general oracle.
249    struct ExpFit {
250        xs: Vec<f64>,
251    }
252    impl Residual for ExpFit {
253        fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
254            // r_i = a·exp(b·x_i) − y_i  with θ = (a, b), φ = y
255            self.xs
256                .iter()
257                .enumerate()
258                .map(|(i, &x)| {
259                    let bx = theta[1] * T::from_f64(x);
260                    // exp via tanh-free identity: e^z = (1+tanh(z/2))/(1−tanh(z/2))
261                    let t = (bx * T::from_f64(0.5)).tanh();
262                    let ez = (T::from_f64(1.0) + t) / (T::from_f64(1.0) - t);
263                    theta[0] * ez - phi[i]
264                })
265                .collect()
266        }
267    }
268
269    #[test]
270    fn nonlinear_sensitivity_matches_finite_differences_of_resolving() {
271        let xs: Vec<f64> = (0..9).map(|i| i as f64 * 0.25).collect();
272        // ZERO-residual data (generated exactly by the model): at r* = 0 the Gauss–Newton
273        // sensitivity is the EXACT sensitivity, so tolerances are tight.
274        let y: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp()).collect();
275        let prob = ExpFit { xs: xs.clone() };
276        let opts = NlsOptions { max_iters: 120, ..Default::default() };
277        let sol = solve(&prob, &[1.0, 0.1], &y, opts);
278        assert!(sol.cost < 1e-16, "exact fit must converge to zero residual: cost {}", sol.cost);
279
280        let eps = 1e-6;
281        for k in [0usize, 4, 8] {
282            let got = sol.dtheta_dphi(&prob, &y, k);
283            let (mut yp, mut ym) = (y.clone(), y.clone());
284            yp[k] += eps;
285            ym[k] -= eps;
286            let sp = solve(&prob, &sol.theta, &yp, opts);
287            let sm = solve(&prob, &sol.theta, &ym, opts);
288            for j in 0..2 {
289                let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
290                assert!((got[j] - want).abs() < 1e-5 * want.abs().max(1.0), "dθ{j}/dy{k}: {} vs {want}", got[j]);
291            }
292        }
293
294        // NOISY data (nonzero residual at the optimum): the Gauss–Newton Hessian drops the
295        // Σ rᵢ∇²rᵢ term, so the implicit sensitivity is an APPROXIMATION — the same one theseus
296        // ships as default. Verify it stays within a small relative band of the true (FD) value.
297        let yn: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp() + 0.03 * (x * 5.0).sin()).collect();
298        let soln = solve(&prob, &[1.0, 0.1], &yn, opts);
299        for k in [0usize, 8] {
300            let got = soln.dtheta_dphi(&prob, &yn, k);
301            let (mut yp, mut ym) = (yn.clone(), yn.clone());
302            yp[k] += eps;
303            ym[k] -= eps;
304            let sp = solve(&prob, &soln.theta, &yp, opts);
305            let sm = solve(&prob, &soln.theta, &ym, opts);
306            for j in 0..2 {
307                let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
308                assert!((got[j] - want).abs() < 5e-3 * want.abs().max(1.0), "GN-approx band dθ{j}/dy{k}: {} vs {want}", got[j]);
309            }
310        }
311
312        // backward(): chain an outer loss L = Σ θ² and compare to FD of the composed pipeline
313        let dl_dtheta: Vec<f64> = sol.theta.iter().map(|t| 2.0 * t).collect();
314        let gphi = sol.backward(&prob, &y, &dl_dtheta);
315        for k in [2usize, 6] {
316            let (mut yp, mut ym) = (y.clone(), y.clone());
317            yp[k] += eps;
318            ym[k] -= eps;
319            let lp: f64 = solve(&prob, &sol.theta, &yp, opts).theta.iter().map(|t| t * t).sum();
320            let lm: f64 = solve(&prob, &sol.theta, &ym, opts).theta.iter().map(|t| t * t).sum();
321            let want = (lp - lm) / (2.0 * eps);
322            assert!((gphi[k] - want).abs() < 1e-4 * want.abs().max(1.0), "dL/dy{k}: {} vs {want}", gphi[k]);
323        }
324    }
325
326    /// The robotics payoff: IK as a differentiable layer THROUGH the real generic-scalar FK.
327    /// θ = joint angles, φ = the Cartesian target; dθ*/dφ answers "how do the joints move as the
328    /// target moves" — exactly, from the optimality condition, verified against FD re-solves.
329    struct IkResidual {
330        robot: ferromotion_core::Robot,
331        inertia: Vec<ferromotion_core::LinkInertia>,
332    }
333    impl Residual for IkResidual {
334        fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
335            use ferromotion_core::gendyn::V3;
336            let m = ferromotion_core::gendyn::GenModel::<T>::from_robot(&self.robot, &self.inertia, [0.0, 0.0, 0.0]);
337            // tool point 0.25 along the last link's z — gives the WRIST joint position leverage
338            let (r, p) = m.fk_frame(theta);
339            let tool = r.mul_v(V3::from_f64([0.0, 0.0, 0.25]));
340            let pt = p.add(tool);
341            vec![pt.0[0] - phi[0], pt.0[1] - phi[1], pt.0[2] - phi[2]]
342        }
343    }
344
345    #[test]
346    fn ik_as_a_differentiable_layer_through_the_real_fk() {
347        use ferromotion_core::{Iso, Joint, LinkInertia, Robot};
348        use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
349        let mk = |z: f64| Iso::from_parts(Translation3::new(0.0, 0.0, z), UnitQuaternion::identity());
350        let robot = Robot {
351            joints: vec![
352                Joint::revolute(mk(0.1), Vector3::z()),
353                Joint::revolute(mk(0.3), Vector3::y()),
354                Joint::revolute(mk(0.3), Vector3::y()),
355            ],
356            ee_offset: Iso::identity(),
357        };
358        let inertia = vec![
359            LinkInertia { mass: 1.0, com: Vector3::zeros(), inertia: Matrix3::identity() * 0.01 };
360            3
361        ];
362        let prob = IkResidual { robot, inertia };
363        let target = [0.25, 0.1, 0.45];
364        let opts = NlsOptions { max_iters: 200, ..Default::default() };
365        let sol = solve(&prob, &[0.3, -0.5, 0.8], &target, opts);
366        assert!(sol.cost < 1e-16, "IK must reach the target: cost {}", sol.cost);
367
368        let eps = 1e-6;
369        for k in 0..3 {
370            let got = sol.dtheta_dphi(&prob, &target, k);
371            let (mut tp, mut tm) = (target, target);
372            tp[k] += eps;
373            tm[k] -= eps;
374            let sp = solve(&prob, &sol.theta, &tp, opts);
375            let sm = solve(&prob, &sol.theta, &tm, opts);
376            for j in 0..3 {
377                let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
378                assert!((got[j] - want).abs() < 1e-4 * want.abs().max(1.0), "dq{j}/dtarget{k}: {} vs {want}", got[j]);
379            }
380        }
381    }
382}