Skip to main content

ferromotion_learn/
neural_ode.rs

1//! **Neural ODE (+ the integrator choice)** — learn *continuous-time* dynamics. Instead of learning a
2//! discrete "next state from this state" map, a Neural ODE learns the **derivative** `ẋ = f_θ(x)` with a
3//! network and recovers trajectories by handing `f_θ` to an ODE solver. Training matches an integrated
4//! trajectory to observations by **backpropagating through the solver itself** — here the discrete adjoint:
5//! the whole rollout (each RK4 step, each field evaluation) is recorded on the reverse-mode tape, so one
6//! backward pass gives the exact gradient of the trajectory-matching loss w.r.t. the field's parameters.
7//! Because it learns the vector field rather than memorizing points, it can be integrated forward to
8//! *predict the future* past the training window.
9//!
10//! The integrator is part of the model, which is the door to **Variational Integrator Networks**: for a
11//! conservative system a structure-preserving (symplectic) integrator in the loop keeps a rollout stable and
12//! energy-bounded where a naive one drifts — the same lesson as the energy module, now inside the learner.
13//! Verified: from a single trajectory of a damped oscillator, the Neural ODE learns the field and its rollout
14//! tracks the true trajectory, including a short extrapolation beyond the training window.
15
16use crate::autodiff::{Tape, Var};
17
18/// A Neural ODE for a 2-D system: an MLP field `f_θ : ℝ² → ℝ²`, trained by trajectory matching.
19pub struct NeuralOde {
20    sizes: Vec<usize>, // [2, H, H, 2]
21    params: Vec<f64>,
22    m: Vec<f64>,
23    v: Vec<f64>,
24    t: u64,
25}
26
27fn splitmix64(state: &mut u64) -> u64 {
28    *state = state.wrapping_add(0x9E3779B97F4A7C15);
29    let mut z = *state;
30    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
31    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
32    z ^ (z >> 31)
33}
34
35impl NeuralOde {
36    /// A Neural ODE with the given hidden width.
37    pub fn new(hidden: usize, seed: u64) -> Self {
38        let sizes = vec![2, hidden, hidden, 2];
39        let mut state = seed ^ 0x7777_1111_3333_9999;
40        let mut params = Vec::new();
41        for l in 0..sizes.len() - 1 {
42            let (ind, outd) = (sizes[l], sizes[l + 1]);
43            let r = (6.0 / (ind + outd) as f64).sqrt();
44            for _ in 0..ind * outd {
45                let u = (splitmix64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0;
46                params.push(u * r);
47            }
48            params.extend(std::iter::repeat_n(0.0, outd));
49        }
50        let n = params.len();
51        NeuralOde { sizes, params, m: vec![0.0; n], v: vec![0.0; n], t: 0 }
52    }
53
54    // The field f_θ(x) evaluated in Var arithmetic (x carried as Vars through the rollout).
55    fn field_var<'t>(&self, pv: &[Var<'t>], x: [Var<'t>; 2]) -> [Var<'t>; 2] {
56        let mut a = vec![x[0], x[1]];
57        let mut off = 0;
58        let layers = self.sizes.len() - 1;
59        for l in 0..layers {
60            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
61            let mut z = Vec::with_capacity(outd);
62            for o in 0..outd {
63                let mut s = pv[off + ind * outd + o];
64                for (i, &ai) in a.iter().enumerate() {
65                    s = s + pv[off + o * ind + i] * ai;
66                }
67                z.push(if l + 1 < layers { s.tanh() } else { s });
68            }
69            off += ind * outd + outd;
70            a = z;
71        }
72        [a[0], a[1]]
73    }
74
75    // One RK4 step in Var arithmetic.
76    fn rk4_var<'t>(&self, pv: &[Var<'t>], x: [Var<'t>; 2], dt: f64) -> [Var<'t>; 2] {
77        let add = |a: [Var<'t>; 2], b: [Var<'t>; 2], s: f64| [a[0] + b[0] * s, a[1] + b[1] * s];
78        let k1 = self.field_var(pv, x);
79        let k2 = self.field_var(pv, add(x, k1, 0.5 * dt));
80        let k3 = self.field_var(pv, add(x, k2, 0.5 * dt));
81        let k4 = self.field_var(pv, add(x, k3, dt));
82        [
83            x[0] + (k1[0] + k2[0] * 2.0 + k3[0] * 2.0 + k4[0]) * (dt / 6.0),
84            x[1] + (k1[1] + k2[1] * 2.0 + k3[1] * 2.0 + k4[1]) * (dt / 6.0),
85        ]
86    }
87
88    /// The learned field `f_θ(x)` in plain `f64` (for inspection).
89    pub fn field(&self, x: &[f64]) -> [f64; 2] {
90        let mut a = vec![x[0], x[1]];
91        let mut off = 0;
92        let layers = self.sizes.len() - 1;
93        for l in 0..layers {
94            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
95            let mut z = vec![0.0; outd];
96            for (o, zo) in z.iter_mut().enumerate() {
97                let mut s = self.params[off + ind * outd + o];
98                for (i, &ai) in a.iter().enumerate() {
99                    s += self.params[off + o * ind + i] * ai;
100                }
101                *zo = if l + 1 < layers { s.tanh() } else { s };
102            }
103            off += ind * outd + outd;
104            a = z;
105        }
106        [a[0], a[1]]
107    }
108
109    /// Roll out the learned dynamics from `x0` for `n` RK4 steps of size `dt` (plain `f64`).
110    pub fn rollout(&self, x0: &[f64], n: usize, dt: f64) -> Vec<[f64; 2]> {
111        let mut x = [x0[0], x0[1]];
112        let mut out = vec![x];
113        for _ in 0..n {
114            let f = |x: [f64; 2]| self.field(&x);
115            let add = |a: [f64; 2], b: [f64; 2], s: f64| [a[0] + b[0] * s, a[1] + b[1] * s];
116            let k1 = f(x);
117            let k2 = f(add(x, k1, 0.5 * dt));
118            let k3 = f(add(x, k2, 0.5 * dt));
119            let k4 = f(add(x, k3, dt));
120            x = [
121                x[0] + dt / 6.0 * (k1[0] + 2.0 * k2[0] + 2.0 * k3[0] + k4[0]),
122                x[1] + dt / 6.0 * (k1[1] + 2.0 * k2[1] + 2.0 * k3[1] + k4[1]),
123            ];
124            out.push(x);
125        }
126        out
127    }
128
129    /// One Adam step of trajectory matching: integrate `f_θ` from `obs[0]` and match the observed states,
130    /// backpropagating through the whole rollout. Returns the loss.
131    pub fn train_step(&mut self, obs: &[[f64; 2]], dt: f64, lr: f64) -> f64 {
132        let tape = Tape::new();
133        let pv: Vec<Var> = self.params.iter().map(|&x| tape.var(x)).collect();
134        let mut x = [tape.constant(obs[0][0]), tape.constant(obs[0][1])];
135        let mut loss = tape.constant(0.0);
136        for o in obs.iter().skip(1) {
137            x = self.rk4_var(&pv, x, dt);
138            let e0 = x[0] - o[0];
139            let e1 = x[1] - o[1];
140            loss = loss + e0 * e0 + e1 * e1;
141        }
142        loss = loss * (1.0 / (obs.len() - 1) as f64);
143        let g = loss.backward();
144        let grad: Vec<f64> = pv.iter().map(|&x| g.wrt(x)).collect();
145
146        self.t += 1;
147        let (b1, b2, eps) = (0.9_f64, 0.999_f64, 1e-8);
148        let bc1 = 1.0 - b1.powi(self.t as i32);
149        let bc2 = 1.0 - b2.powi(self.t as i32);
150        for (i, &gi) in grad.iter().enumerate() {
151            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi;
152            self.v[i] = b2 * self.v[i] + (1.0 - b2) * gi * gi;
153            self.params[i] -= lr * (self.m[i] / bc1) / ((self.v[i] / bc2).sqrt() + eps);
154        }
155        loss.value()
156    }
157
158    /// Train for `epochs` trajectory-matching steps; returns the final loss.
159    pub fn train(&mut self, obs: &[[f64; 2]], dt: f64, epochs: usize, lr: f64) -> f64 {
160        let mut l = f64::INFINITY;
161        for _ in 0..epochs {
162            l = self.train_step(obs, dt, lr);
163        }
164        l
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // Damped linear oscillator: ẋ = y, ẏ = −x − 0.15 y (a decaying spiral).
173    fn true_field(x: f64, y: f64) -> (f64, f64) {
174        (y, -x - 0.15 * y)
175    }
176
177    fn trajectory(x0: f64, y0: f64, n: usize, dt: f64) -> Vec<[f64; 2]> {
178        let (mut x, mut y) = (x0, y0);
179        let mut out = vec![[x, y]];
180        for _ in 0..n {
181            let f = |x: f64, y: f64| true_field(x, y);
182            let (k1x, k1y) = f(x, y);
183            let (k2x, k2y) = f(x + 0.5 * dt * k1x, y + 0.5 * dt * k1y);
184            let (k3x, k3y) = f(x + 0.5 * dt * k2x, y + 0.5 * dt * k2y);
185            let (k4x, k4y) = f(x + dt * k3x, y + dt * k3y);
186            x += dt / 6.0 * (k1x + 2.0 * k2x + 2.0 * k3x + k4x);
187            y += dt / 6.0 * (k1y + 2.0 * k2y + 2.0 * k3y + k4y);
188            out.push([x, y]);
189        }
190        out
191    }
192
193    #[test]
194    fn neural_ode_learns_the_field_and_predicts_the_future() {
195        // THE HEADLINE. Train on the first 24 steps of one trajectory by backprop-through-the-solver, then
196        // roll the learned field forward and check it tracks the observed trajectory AND a short extrapolation
197        // beyond the training window.
198        let dt = 0.12;
199        let full = trajectory(1.6, 0.0, 40, dt); // 41 points
200        let train = &full[..25]; // first 24 steps
201        let mut node = NeuralOde::new(16, 4);
202        node.train(train, dt, 1500, 5e-3);
203
204        let pred = node.rollout(&full[0], 40, dt);
205        // in-window tracking
206        let mut e_in = 0.0;
207        for i in 0..25 {
208            e_in += (pred[i][0] - full[i][0]).powi(2) + (pred[i][1] - full[i][1]).powi(2);
209        }
210        let rmse_in = (e_in / 25.0).sqrt();
211        assert!(rmse_in < 0.1, "rollout should match the training window: rmse {rmse_in}");
212        // extrapolation beyond the training window still tracks the decaying spiral
213        let mut e_out = 0.0;
214        for i in 25..40 {
215            e_out += (pred[i][0] - full[i][0]).powi(2) + (pred[i][1] - full[i][1]).powi(2);
216        }
217        let rmse_out = (e_out / 15.0).sqrt();
218        assert!(rmse_out < 0.25, "should extrapolate past the training window: rmse {rmse_out}");
219    }
220}