Skip to main content

ferromotion_learn/
pinn.rs

1//! **Physics-informed neural network (PINN)** — the first way to inject physics: in the **loss**. Instead of
2//! fitting data, a PINN fits the *governing equation*. A network `u_θ(t)` is trained so that its own
3//! derivatives satisfy a differential equation — the loss is the equation's residual driven to zero at a cloud
4//! of collocation points, plus the initial/boundary conditions. No solution data is needed; the physics is the
5//! supervision (Raissi et al.).
6//!
7//! The interesting part is that the loss contains derivatives of the network w.r.t. its **input** (`u'(t)`,
8//! `u''(t)`), and we still need the gradient of that loss w.r.t. the **parameters**. This module composes the
9//! two exactly: it propagates a second-order **jet** `(u, u', u'')` through the network where every component
10//! is a reverse-mode [`Var`] on the tape. The jet gives the exact input-derivatives (forward-mode chain rule),
11//! and one `backward` pass then gives the exact parameter gradient of the physics loss (reverse mode) — no
12//! finite differences anywhere. This is the "use the network's gradient as an output" idea that defines PINNs.
13//!
14//! Verified against the closed form: trained to solve the harmonic oscillator `u'' + ω²u = 0`, `u(0)=1`,
15//! `u'(0)=0`, the network reproduces `cos(ωt)` across the domain.
16
17use crate::autodiff::{Tape, Var};
18
19/// A trained PINN for the harmonic oscillator `u'' + ω² u = 0` on `[0, t_max]` with `u(0)=1, u'(0)=0`.
20pub struct Pinn {
21    sizes: Vec<usize>,
22    params: Vec<f64>,
23    m: Vec<f64>,
24    v: Vec<f64>,
25    t: u64,
26    omega: f64,
27    colloc: Vec<f64>,
28    ic_weight: f64,
29}
30
31fn splitmix64(state: &mut u64) -> u64 {
32    *state = state.wrapping_add(0x9E3779B97F4A7C15);
33    let mut z = *state;
34    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
35    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
36    z ^ (z >> 31)
37}
38
39// A second-order jet in the input t: (value, d/dt, d²/dt²), each a tape Var.
40type Jet<'t> = (Var<'t>, Var<'t>, Var<'t>);
41
42fn jet_add<'t>(a: Jet<'t>, b: Jet<'t>) -> Jet<'t> {
43    (a.0 + b.0, a.1 + b.1, a.2 + b.2)
44}
45// scale a jet by a parameter Var w that does not depend on t (dt = dtt = 0)
46fn jet_scale<'t>(w: Var<'t>, a: Jet<'t>) -> Jet<'t> {
47    (w * a.0, w * a.1, w * a.2)
48}
49// tanh of a jet: f(g(t)) with f=tanh, f'=1−tanh², f''=−2·tanh·(1−tanh²)
50fn jet_tanh<'t>(g: Jet<'t>) -> Jet<'t> {
51    let f0 = g.0.tanh();
52    let fp = f0 * f0 * (-1.0) + 1.0; // 1 − tanh²
53    let fpp = (f0 * fp) * (-2.0); // −2·tanh·(1−tanh²)
54    (f0, fp * g.1, fpp * (g.1 * g.1) + fp * g.2)
55}
56
57impl Pinn {
58    /// A PINN with the given hidden width, angular frequency `omega`, domain `[0, t_max]`, and number of
59    /// interior collocation points.
60    pub fn new(hidden: usize, omega: f64, t_max: f64, n_colloc: usize, seed: u64) -> Self {
61        let sizes = vec![1, hidden, hidden, 1];
62        let mut state = seed ^ 0x1234_5678_9ABC_DEF0;
63        let mut params = Vec::new();
64        for l in 0..sizes.len() - 1 {
65            let (ind, outd) = (sizes[l], sizes[l + 1]);
66            let r = (6.0 / (ind + outd) as f64).sqrt();
67            for _ in 0..ind * outd {
68                let u = (splitmix64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0;
69                params.push(u * r);
70            }
71            params.extend(std::iter::repeat_n(0.0, outd));
72        }
73        let n = params.len();
74        let colloc: Vec<f64> = (0..n_colloc).map(|i| t_max * (i as f64 + 0.5) / n_colloc as f64).collect();
75        Pinn { sizes, params, m: vec![0.0; n], v: vec![0.0; n], t: 0, omega, colloc, ic_weight: 12.0 }
76    }
77
78    /// The closed-form solution `cos(ω t)` (for verification / plotting the truth).
79    pub fn exact(&self, t: f64) -> f64 {
80        (self.omega * t).cos()
81    }
82
83    /// Plain-`f64` inference: the network's `u(t)`.
84    pub fn forward(&self, t: f64) -> f64 {
85        let mut a = vec![t];
86        let mut off = 0;
87        let layers = self.sizes.len() - 1;
88        for l in 0..layers {
89            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
90            let mut z = vec![0.0; outd];
91            for (o, zo) in z.iter_mut().enumerate() {
92                let mut s = self.params[off + ind * outd + o];
93                for (i, &ai) in a.iter().enumerate() {
94                    s += self.params[off + o * ind + i] * ai;
95                }
96                *zo = if l + 1 < layers { s.tanh() } else { s };
97            }
98            off += ind * outd + outd;
99            a = z;
100        }
101        a[0]
102    }
103
104    // Forward the network as a second-order jet in t; returns (u, u', u'') as tape Vars.
105    fn forward_jet<'t>(&self, tape: &'t Tape, pv: &[Var<'t>], t: f64, zero: Var<'t>, one: Var<'t>) -> Jet<'t> {
106        let mut a: Vec<Jet<'t>> = vec![(tape.constant(t), one, zero)];
107        let mut off = 0;
108        let layers = self.sizes.len() - 1;
109        for l in 0..layers {
110            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
111            let mut z: Vec<Jet<'t>> = Vec::with_capacity(outd);
112            for o in 0..outd {
113                let mut s: Jet<'t> = (pv[off + ind * outd + o], zero, zero); // bias (const in t)
114                for (i, &ai) in a.iter().enumerate() {
115                    s = jet_add(s, jet_scale(pv[off + o * ind + i], ai));
116                }
117                z.push(if l + 1 < layers { jet_tanh(s) } else { s });
118            }
119            off += ind * outd + outd;
120            a = z;
121        }
122        a[0]
123    }
124
125    fn loss_and_grad(&self) -> (f64, Vec<f64>) {
126        let tape = Tape::new();
127        let pv: Vec<Var> = self.params.iter().map(|&p| tape.var(p)).collect();
128        let zero = tape.constant(0.0);
129        let one = tape.constant(1.0);
130        let w2 = self.omega * self.omega;
131
132        // physics residual: u'' + ω² u = 0 at each collocation point
133        let mut loss = tape.constant(0.0);
134        for &t in &self.colloc {
135            let (u, _ut, utt) = self.forward_jet(&tape, &pv, t, zero, one);
136            let r = utt + u * w2;
137            loss = loss + r * r;
138        }
139        loss = loss * (1.0 / self.colloc.len() as f64);
140
141        // initial conditions: u(0) = 1, u'(0) = 0
142        let (u0, ut0, _) = self.forward_jet(&tape, &pv, 0.0, zero, one);
143        let e1 = u0 - 1.0;
144        loss = loss + (e1 * e1 + ut0 * ut0) * self.ic_weight;
145
146        let g = loss.backward();
147        (loss.value(), pv.iter().map(|&p| g.wrt(p)).collect())
148    }
149
150    /// One Adam step; returns the loss before the step.
151    pub fn train_step(&mut self, lr: f64) -> f64 {
152        let (loss, grad) = self.loss_and_grad();
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
163    }
164
165    /// Train for `epochs` steps; returns the final loss.
166    pub fn train(&mut self, epochs: usize, lr: f64) -> f64 {
167        let mut l = f64::INFINITY;
168        for _ in 0..epochs {
169            l = self.train_step(lr);
170        }
171        l
172    }
173
174    /// Max absolute error against the closed form on a fine grid — the verification metric.
175    pub fn max_error(&self) -> f64 {
176        let n = 100;
177        (0..=n)
178            .map(|i| {
179                let t = self.colloc.last().copied().unwrap_or(1.0) * i as f64 / n as f64;
180                (self.forward(t) - self.exact(t)).abs()
181            })
182            .fold(0.0, f64::max)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn a_pinn_solves_the_harmonic_oscillator_from_physics_alone() {
192        // THE HEADLINE. With NO solution data — only the ODE residual and the initial conditions in the loss —
193        // the network reconstructs cos(ω t). ω=2 on [0,2] (past the first turning point).
194        let mut pinn = Pinn::new(16, 2.0, 2.0, 30, 7);
195        pinn.train(3000, 6e-3);
196        let err = pinn.max_error();
197        assert!(err < 0.08, "PINN should match cos(2t) from physics alone: max error {err}");
198    }
199
200    #[test]
201    fn an_untrained_pinn_does_not_satisfy_the_equation() {
202        // Control: before training, the network is nowhere near the solution.
203        let pinn = Pinn::new(24, 2.0, 2.0, 48, 7);
204        assert!(pinn.max_error() > 0.1, "an untrained net should not solve the ODE");
205    }
206}