Skip to main content

ferromotion_learn/
delan.rs

1//! **Deep Lagrangian Network (DeLaN)** — the middle ground between a black box and hand-derived dynamics.
2//! Instead of learning the map `(q, q̇, q̈) → τ` directly, it keeps the *structure* of the manipulator equation
3//! `M(q) q̈ + C(q,q̇) q̇ + G(q) = τ` and learns only the physically-meaningful pieces: the mass matrix `M(q)`
4//! and the potential `V(q)`. Two structural guarantees come for free:
5//!
6//! - **`M(q)` is symmetric positive-definite by construction.** The network outputs the entries of a
7//!   lower-triangular `L(q)` (diagonal made positive with softplus) and `M = L Lᵀ` — a valid inertia matrix
8//!   for any weights, never singular or indefinite.
9//! - **The Coriolis/centrifugal term is not learned separately.** `C(q,q̇) q̇` is *computed* from `∂M/∂q` via
10//!   the Christoffel symbols — and `∂M/∂q` is obtained exactly by autodiff (a first-order jet in `q` on the
11//!   tape), the DeLaN trick. Gravity is `G(q) = ∂V/∂q`, likewise exact.
12//!
13//! The whole predicted torque is assembled from these and trained to match measured `τ`; because every
14//! component is a tape [`Var`], one backward pass gives the exact parameter gradient. Verified against
15//! ferromotion-core's own recursive-Newton–Euler dynamics for a 2-link pendulum: the learned inverse dynamics
16//! match, and the recovered `M(q)` matches the true mass matrix.
17
18use crate::autodiff::{Tape, Var};
19
20/// A Deep Lagrangian Network for a 2-DOF system: learns `L(q)` (→ `M = LLᵀ`) and `V(q)`.
21pub struct Delan {
22    sizes: Vec<usize>, // [2, H, H, 4] → outputs (L11_raw, L21, L22_raw, V)
23    params: Vec<f64>,
24    m: Vec<f64>,
25    v: Vec<f64>,
26    t: u64,
27}
28
29fn splitmix64(state: &mut u64) -> u64 {
30    *state = state.wrapping_add(0x9E3779B97F4A7C15);
31    let mut z = *state;
32    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
33    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
34    z ^ (z >> 31)
35}
36
37// First-order jet in (q0, q1): (value, ∂/∂q0, ∂/∂q1), each a tape Var.
38type J<'t> = (Var<'t>, Var<'t>, Var<'t>);
39
40fn j_add<'t>(a: J<'t>, b: J<'t>) -> J<'t> {
41    (a.0 + b.0, a.1 + b.1, a.2 + b.2)
42}
43fn j_scale<'t>(w: Var<'t>, a: J<'t>) -> J<'t> {
44    (w * a.0, w * a.1, w * a.2)
45}
46fn j_mul<'t>(a: J<'t>, b: J<'t>) -> J<'t> {
47    (a.0 * b.0, a.0 * b.1 + a.1 * b.0, a.0 * b.2 + a.2 * b.0)
48}
49fn j_tanh<'t>(g: J<'t>) -> J<'t> {
50    let f0 = g.0.tanh();
51    let fp = f0 * f0 * (-1.0) + 1.0;
52    (f0, fp * g.1, fp * g.2)
53}
54fn j_softplus<'t>(g: J<'t>) -> J<'t> {
55    // softplus(x) = ln(1+eˣ); softplus'(x) = σ(x)
56    let val = (g.0.exp() + 1.0).ln();
57    let s = g.0.sigmoid();
58    (val, s * g.1, s * g.2)
59}
60// ∂(entry)/∂q_k from an entry jet (k = 0 or 1)
61fn dqk<'t>(ent: J<'t>, k: usize) -> Var<'t> {
62    if k == 0 { ent.1 } else { ent.2 }
63}
64
65impl Delan {
66    /// A DeLaN with the given hidden width.
67    pub fn new(hidden: usize, seed: u64) -> Self {
68        let sizes = vec![2, hidden, hidden, 4];
69        let mut state = seed ^ 0x2468_ACE0_1357_9BDF;
70        let mut params = Vec::new();
71        for l in 0..sizes.len() - 1 {
72            let (ind, outd) = (sizes[l], sizes[l + 1]);
73            let r = (6.0 / (ind + outd) as f64).sqrt();
74            for _ in 0..ind * outd {
75                let u = (splitmix64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0;
76                params.push(u * r);
77            }
78            params.extend(std::iter::repeat_n(0.0, outd));
79        }
80        let n = params.len();
81        Delan { sizes, params, m: vec![0.0; n], v: vec![0.0; n], t: 0 }
82    }
83
84    // Forward as jets in (q0,q1); returns the 4 raw output jets.
85    fn forward_jet<'t>(&self, tape: &'t Tape, pv: &[Var<'t>], q0: f64, q1: f64, zero: Var<'t>, one: Var<'t>) -> Vec<J<'t>> {
86        let mut a: Vec<J<'t>> = vec![(tape.constant(q0), one, zero), (tape.constant(q1), zero, one)];
87        let mut off = 0;
88        let layers = self.sizes.len() - 1;
89        for l in 0..layers {
90            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
91            let mut z: Vec<J<'t>> = Vec::with_capacity(outd);
92            for o in 0..outd {
93                let mut s: J<'t> = (pv[off + ind * outd + o], zero, zero);
94                for (i, &ai) in a.iter().enumerate() {
95                    s = j_add(s, j_scale(pv[off + o * ind + i], ai));
96                }
97                z.push(if l + 1 < layers { j_tanh(s) } else { s });
98            }
99            off += ind * outd + outd;
100            a = z;
101        }
102        a
103    }
104
105    // Build the mass-matrix entry jets (m00, m01, m11) and the potential jet from raw outputs.
106    fn assemble<'t>(raw: &[J<'t>]) -> (J<'t>, J<'t>, J<'t>, J<'t>) {
107        let l11 = j_softplus(raw[0]); // positive diagonal
108        let l21 = raw[1];
109        let l22 = j_softplus(raw[2]);
110        // M = L Lᵀ with L = [[l11,0],[l21,l22]]
111        let m00 = j_mul(l11, l11);
112        let m01 = j_mul(l11, l21);
113        let m11 = j_add(j_mul(l21, l21), j_mul(l22, l22));
114        let vpot = raw[3];
115        (m00, m01, m11, vpot)
116    }
117
118    /// One Adam step matching the structured predicted torque to measured `τ`. Returns the loss.
119    pub fn train_step(&mut self, q: &[[f64; 2]], qd: &[[f64; 2]], qdd: &[[f64; 2]], tau: &[[f64; 2]], lr: f64) -> f64 {
120        let tape = Tape::new();
121        let pv: Vec<Var> = self.params.iter().map(|&x| tape.var(x)).collect();
122        let zero = tape.constant(0.0);
123        let one = tape.constant(1.0);
124        let mut loss = tape.constant(0.0);
125
126        for s in 0..q.len() {
127            let raw = self.forward_jet(&tape, &pv, q[s][0], q[s][1], zero, one);
128            let (m00, m01, m11, vpot) = Self::assemble(&raw);
129            // dm[i][j][k] = ∂M_ij/∂q_k  (symmetric in i,j)
130            let mm = [[m00, m01], [m01, m11]]; // value-jets, symmetric
131            let qdv = [qd[s][0], qd[s][1]];
132            // Christoffel: Cq̇_k = Σ_ij ½(∂M_ki/∂q_j + ∂M_kj/∂q_i − ∂M_ij/∂q_k) q̇_i q̇_j
133            for k in 0..2 {
134                let mut cq = tape.constant(0.0);
135                for i in 0..2 {
136                    for j in 0..2 {
137                        let term = dqk(mm[k][i], j) + dqk(mm[k][j], i) - dqk(mm[i][j], k);
138                        cq = cq + term * (0.5 * qdv[i] * qdv[j]);
139                    }
140                }
141                // G_k = ∂V/∂q_k
142                let g = if k == 0 { vpot.1 } else { vpot.2 };
143                // τ_pred_k = Σ_j M_kj q̈_j + Cq̇_k + G_k
144                let tau_pred = mm[k][0].0 * qdd[s][0] + mm[k][1].0 * qdd[s][1] + cq + g;
145                let e = tau_pred - tau[s][k];
146                loss = loss + e * e;
147            }
148        }
149        loss = loss * (1.0 / (q.len() * 2) as f64);
150        let grad_g = loss.backward();
151        let grad: Vec<f64> = pv.iter().map(|&x| grad_g.wrt(x)).collect();
152
153        self.t += 1;
154        let (b1, b2, eps) = (0.9_f64, 0.999_f64, 1e-8);
155        let bc1 = 1.0 - b1.powi(self.t as i32);
156        let bc2 = 1.0 - b2.powi(self.t as i32);
157        for (i, &gi) in grad.iter().enumerate() {
158            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi;
159            self.v[i] = b2 * self.v[i] + (1.0 - b2) * gi * gi;
160            self.params[i] -= lr * (self.m[i] / bc1) / ((self.v[i] / bc2).sqrt() + eps);
161        }
162        loss.value()
163    }
164
165    /// Train for `epochs` steps; returns the final loss.
166    pub fn train(&mut self, q: &[[f64; 2]], qd: &[[f64; 2]], qdd: &[[f64; 2]], tau: &[[f64; 2]], epochs: usize, lr: f64) -> f64 {
167        let mut l = f64::INFINITY;
168        for _ in 0..epochs {
169            l = self.train_step(q, qd, qdd, tau, lr);
170        }
171        l
172    }
173
174    // Plain-f64 forward with q-derivatives: returns for each of the 4 outputs (value, ∂/∂q0, ∂/∂q1).
175    fn forward_plain(&self, q0: f64, q1: f64) -> Vec<(f64, f64, f64)> {
176        let mut a: Vec<(f64, f64, f64)> = vec![(q0, 1.0, 0.0), (q1, 0.0, 1.0)];
177        let mut off = 0;
178        let layers = self.sizes.len() - 1;
179        for l in 0..layers {
180            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
181            let mut z = Vec::with_capacity(outd);
182            for o in 0..outd {
183                let (mut v0, mut v1, mut v2) = (self.params[off + ind * outd + o], 0.0, 0.0);
184                for (i, ai) in a.iter().enumerate() {
185                    let w = self.params[off + o * ind + i];
186                    v0 += w * ai.0;
187                    v1 += w * ai.1;
188                    v2 += w * ai.2;
189                }
190                if l + 1 < layers {
191                    let t = v0.tanh();
192                    let fp = 1.0 - t * t;
193                    z.push((t, fp * v1, fp * v2));
194                } else {
195                    z.push((v0, v1, v2));
196                }
197            }
198            off += ind * outd + outd;
199            a = z;
200        }
201        a
202    }
203
204    /// The learned mass matrix at `q`, returned as `[m00, m01, m11]` (symmetric).
205    pub fn mass(&self, q: &[f64]) -> [f64; 3] {
206        let raw = self.forward_plain(q[0], q[1]);
207        let l11 = softplus(raw[0].0);
208        let l21 = raw[1].0;
209        let l22 = softplus(raw[2].0);
210        [l11 * l11, l11 * l21, l21 * l21 + l22 * l22]
211    }
212
213    /// The learned predicted torque `τ` for a full `(q, q̇, q̈)`.
214    pub fn predict_tau(&self, q: &[f64], qd: &[f64], qdd: &[f64]) -> [f64; 2] {
215        let raw = self.forward_plain(q[0], q[1]);
216        let l11 = softplus(raw[0].0);
217        let sp0 = sigmoid(raw[0].0);
218        let l21 = raw[1].0;
219        let l22 = softplus(raw[2].0);
220        let sp2 = sigmoid(raw[2].0);
221        // M entries and their q-derivatives
222        let m = [l11 * l11, l11 * l21, l21 * l21 + l22 * l22]; // m00,m01,m11
223        // ∂l/∂qk
224        let dl11 = [sp0 * raw[0].1, sp0 * raw[0].2];
225        let dl21 = [raw[1].1, raw[1].2];
226        let dl22 = [sp2 * raw[2].1, sp2 * raw[2].2];
227        // ∂M/∂qk
228        let dm00 = [2.0 * l11 * dl11[0], 2.0 * l11 * dl11[1]];
229        let dm01 = [dl11[0] * l21 + l11 * dl21[0], dl11[1] * l21 + l11 * dl21[1]];
230        let dm11 = [2.0 * l21 * dl21[0] + 2.0 * l22 * dl22[0], 2.0 * l21 * dl21[1] + 2.0 * l22 * dl22[1]];
231        let mm = [[m[0], m[1]], [m[1], m[2]]];
232        let dmm = [[dm00, dm01], [dm01, dm11]];
233        let g = [raw[3].1, raw[3].2]; // ∂V/∂q
234        let mut tau = [0.0f64; 2];
235        for k in 0..2 {
236            let mut cq = 0.0;
237            for i in 0..2 {
238                for j in 0..2 {
239                    cq += 0.5 * (dmm[k][i][j] + dmm[k][j][i] - dmm[i][j][k]) * qd[i] * qd[j];
240                }
241            }
242            tau[k] = mm[k][0] * qdd[0] + mm[k][1] * qdd[1] + cq + g[k];
243        }
244        tau
245    }
246}
247
248fn softplus(x: f64) -> f64 {
249    (x.exp() + 1.0).ln()
250}
251fn sigmoid(x: f64) -> f64 {
252    1.0 / (1.0 + (-x).exp())
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use ferromotion_core::{from_urdf_full, inverse_dynamics, mass_matrix, LinkInertia, Robot};
259    use nalgebra::Vector3;
260
261    const URDF: &str = r#"<robot name="dpend">
262      <link name="base"/>
263      <link name="l1"><inertial><origin xyz="0.5 0 0" rpy="0 0 0"/><mass value="1.0"/>
264        <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.083" iyz="0" izz="0.083"/></inertial></link>
265      <link name="l2"><inertial><origin xyz="0.5 0 0" rpy="0 0 0"/><mass value="1.0"/>
266        <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.083" iyz="0" izz="0.083"/></inertial></link>
267      <link name="tool"/>
268      <joint name="j1" type="revolute"><parent link="base"/><child link="l1"/><origin xyz="0 0 0" rpy="0 0 0"/>
269        <axis xyz="0 1 0"/><limit lower="-6.3" upper="6.3" effort="100" velocity="100"/></joint>
270      <joint name="j2" type="revolute"><parent link="l1"/><child link="l2"/><origin xyz="1 0 0" rpy="0 0 0"/>
271        <axis xyz="0 1 0"/><limit lower="-6.3" upper="6.3" effort="100" velocity="100"/></joint>
272      <joint name="jt" type="fixed"><parent link="l2"/><child link="tool"/><origin xyz="1 0 0" rpy="0 0 0"/></joint>
273    </robot>"#;
274
275    fn gen_data(robot: &Robot, inertia: &[LinkInertia], n: usize) -> (Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[f64; 2]>) {
276        let mut s = 987u64;
277        let mut rnd = |lo: f64, hi: f64| {
278            let u = splitmix64(&mut s) as f64 / u64::MAX as f64;
279            lo + u * (hi - lo)
280        };
281        let g = Vector3::new(0.0, 0.0, -9.81);
282        let (mut q, mut qd, mut qdd, mut tau) = (vec![], vec![], vec![], vec![]);
283        for _ in 0..n {
284            let qi = [rnd(-2.0, 2.0), rnd(-2.0, 2.0)];
285            let qdi = [rnd(-1.5, 1.5), rnd(-1.5, 1.5)];
286            let qddi = [rnd(-2.0, 2.0), rnd(-2.0, 2.0)];
287            let t = inverse_dynamics(robot, inertia, &qi, &qdi, &qddi, g);
288            q.push(qi);
289            qd.push(qdi);
290            qdd.push(qddi);
291            tau.push([t[0], t[1]]);
292        }
293        (q, qd, qdd, tau)
294    }
295
296    fn rmse(delan: &Delan, tq: &[[f64; 2]], tqd: &[[f64; 2]], tqdd: &[[f64; 2]], ttau: &[[f64; 2]]) -> f64 {
297        let mut err = 0.0;
298        for i in 0..tq.len() {
299            let p = delan.predict_tau(&tq[i], &tqd[i], &tqdd[i]);
300            err += (p[0] - ttau[i][0]).powi(2) + (p[1] - ttau[i][1]).powi(2);
301        }
302        (err / (tq.len() * 2) as f64).sqrt()
303    }
304
305    #[test]
306    fn delan_learns_structured_dynamics_and_keeps_mass_matrix_spd() {
307        // Fast default test: the structured model trains (RMSE falls far below its untrained value) and the
308        // learned M(q) is symmetric-positive-definite at every configuration — the Cholesky guarantee, which
309        // holds for ANY weights, trained or not.
310        let (robot, inertia) = from_urdf_full(URDF, "base", "tool").unwrap();
311        let (q, qd, qdd, tau) = gen_data(&robot, &inertia, 80);
312        let (tq, tqd, tqdd, ttau) = gen_data(&robot, &inertia, 30);
313        let mut delan = Delan::new(20, 5);
314        let before = rmse(&delan, &tq, &tqd, &tqdd, &ttau);
315        delan.train(&q, &qd, &qdd, &tau, 1500, 4e-3);
316        let after = rmse(&delan, &tq, &tqd, &tqdd, &ttau);
317        assert!(after < before * 0.3, "structured dynamics should train: rmse {before} → {after}");
318
319        // SPD at several configurations (the structural guarantee)
320        for &qc in &[[0.0, 0.0], [0.3, 0.7], [-1.2, 0.9], [2.0, -1.5]] {
321            let m = delan.mass(&qc);
322            let det = m[0] * m[2] - m[1] * m[1];
323            assert!(m[0] > 0.0 && m[2] > 0.0 && det > 0.0, "M must be SPD at {qc:?}: {m:?}");
324        }
325    }
326
327    #[test]
328    #[ignore = "slow (~30s): recovers M(q) to within ~0.05 of ferromotion's true mass matrix; run explicitly"]
329    fn delan_recovers_the_true_mass_matrix() {
330        // The strong claim, verified with enough data + training: the learned M(q) matches ferromotion's own
331        // recursive-Newton–Euler mass matrix.
332        let (robot, inertia) = from_urdf_full(URDF, "base", "tool").unwrap();
333        let (q, qd, qdd, tau) = gen_data(&robot, &inertia, 200);
334        let mut delan = Delan::new(24, 5);
335        delan.train(&q, &qd, &qdd, &tau, 8000, 3e-3);
336        let qc = [0.3, 0.7];
337        let m_true = mass_matrix(&robot, &inertia, &qc);
338        let m_hat = delan.mass(&qc);
339        assert!((m_hat[0] - m_true[(0, 0)]).abs() < 0.15, "M00: {} vs {}", m_hat[0], m_true[(0, 0)]);
340        assert!((m_hat[1] - m_true[(0, 1)]).abs() < 0.15, "M01: {} vs {}", m_hat[1], m_true[(0, 1)]);
341        assert!((m_hat[2] - m_true[(1, 1)]).abs() < 0.15, "M11: {} vs {}", m_hat[2], m_true[(1, 1)]);
342    }
343}