Skip to main content

ferromotion_learn/
autodiff.rs

1//! **Reverse-mode automatic differentiation** — the keystone the whole crate stands on. Every learned
2//! physical model in `ferromotion-learn` (PINNs, Lagrangian/Hamiltonian nets, Neural ODEs, model-structured
3//! nets) is trained by gradient descent, and every gradient here is computed *exactly* — not by finite
4//! differences — by recording the forward computation on a **tape** (a Wengert list) and walking it backward,
5//! accumulating `∂output/∂·` via the chain rule. One backward pass yields the gradient w.r.t. *all* inputs at
6//! once, which is why reverse mode (a.k.a. backpropagation) is the right tool when there are many parameters
7//! and one scalar loss.
8//!
9//! The design is a shared [`Tape`] that owns the nodes and lightweight [`Var`] handles (`Copy`, carrying a
10//! value + a tape index) that record operations as they execute. Each node stores up to two parents and the
11//! local partial derivative w.r.t. each; leaves point to themselves with weight 0 so propagation stops. This
12//! is pure `std` + `f64` — no BLAS, no `ndarray` — so it compiles to WASM and runs on-device, the whole point
13//! of doing this in Rust rather than importing PyTorch.
14//!
15//! Verified against **finite differences**: for a battery of nonlinear scalar functions of several variables,
16//! the tape's exact gradient matches a central-difference gradient to ~1e-6, and the classic identities
17//! (`∂(x·y)/∂x = y`, `∂tanh/∂x = 1−tanh²`, product/quotient/chain rules) hold. For higher-order input
18//! derivatives (needed by PINNs and Lagrangian nets), see the forward-mode [`crate::dual`] module.
19
20use core::cell::RefCell;
21use core::ops::{Add, Div, Mul, Neg, Sub};
22
23/// One entry of the tape: up to two parents and the local partial derivative of this node w.r.t. each.
24#[derive(Clone, Copy)]
25struct Node {
26    dep: [usize; 2],
27    weight: [f64; 2],
28}
29
30/// The tape (Wengert list) recording the forward computation. Create [`Var`]s from it, compute, then call
31/// [`Var::backward`]. Interior mutability lets `Copy` `Var` handles append nodes during ordinary arithmetic.
32#[derive(Default)]
33pub struct Tape {
34    nodes: RefCell<Vec<Node>>,
35}
36
37impl Tape {
38    /// A fresh, empty tape.
39    pub fn new() -> Self {
40        Tape { nodes: RefCell::new(Vec::new()) }
41    }
42
43    /// Number of recorded nodes (operations + leaves).
44    pub fn len(&self) -> usize {
45        self.nodes.borrow().len()
46    }
47
48    /// Whether the tape is empty.
49    pub fn is_empty(&self) -> bool {
50        self.nodes.borrow().is_empty()
51    }
52
53    /// Introduce an independent variable (a leaf) with the given value.
54    pub fn var(&self, value: f64) -> Var<'_> {
55        let index = self.push([usize::MAX, usize::MAX], [0.0, 0.0], true);
56        Var { tape: self, index, value }
57    }
58
59    /// A constant on this tape (a leaf whose gradient never flows). Same as `var` but semantically read-only.
60    pub fn constant(&self, value: f64) -> Var<'_> {
61        self.var(value)
62    }
63
64    fn push(&self, dep: [usize; 2], weight: [f64; 2], leaf: bool) -> usize {
65        let mut nodes = self.nodes.borrow_mut();
66        let index = nodes.len();
67        // A leaf refers to itself with weight 0 so reverse propagation is a harmless no-op there.
68        let dep = if leaf { [index, index] } else { dep };
69        nodes.push(Node { dep, weight });
70        index
71    }
72}
73
74/// A differentiable value: a `Copy` handle carrying its numeric value and its position on the [`Tape`].
75#[derive(Clone, Copy)]
76pub struct Var<'t> {
77    tape: &'t Tape,
78    index: usize,
79    value: f64,
80}
81
82/// The result of a backward pass: the gradient of the seed output w.r.t. every node on the tape.
83pub struct Grad(Vec<f64>);
84
85impl Grad {
86    /// The partial derivative of the backward-seed output w.r.t. the given variable.
87    pub fn wrt(&self, v: Var<'_>) -> f64 {
88        self.0[v.index]
89    }
90}
91
92impl<'t> Var<'t> {
93    /// The current numeric value.
94    pub fn value(&self) -> f64 {
95        self.value
96    }
97
98    fn unary(self, value: f64, d: f64) -> Var<'t> {
99        let index = self.tape.push([self.index, self.index], [d, 0.0], false);
100        Var { tape: self.tape, index, value }
101    }
102
103    fn binary(self, rhs: Var<'t>, value: f64, da: f64, db: f64) -> Var<'t> {
104        let index = self.tape.push([self.index, rhs.index], [da, db], false);
105        Var { tape: self.tape, index, value }
106    }
107
108    /// Reverse pass seeded at `self`: returns `∂self/∂·` for every node. Cost is O(#nodes), one linear sweep.
109    pub fn backward(&self) -> Grad {
110        let nodes = self.tape.nodes.borrow();
111        let mut grad = vec![0.0; nodes.len()];
112        grad[self.index] = 1.0;
113        for i in (0..nodes.len()).rev() {
114            let g = grad[i];
115            if g != 0.0 {
116                let n = nodes[i];
117                grad[n.dep[0]] += n.weight[0] * g;
118                grad[n.dep[1]] += n.weight[1] * g;
119            }
120        }
121        Grad(grad)
122    }
123
124    // --- elementary functions, each recording its exact local derivative ---
125
126    /// Sine (`d/dx sin x = cos x`).
127    pub fn sin(self) -> Var<'t> {
128        self.unary(self.value.sin(), self.value.cos())
129    }
130    /// Cosine (`d/dx cos x = −sin x`).
131    pub fn cos(self) -> Var<'t> {
132        self.unary(self.value.cos(), -self.value.sin())
133    }
134    /// Exponential (`d/dx eˣ = eˣ`).
135    pub fn exp(self) -> Var<'t> {
136        let e = self.value.exp();
137        self.unary(e, e)
138    }
139    /// Natural log (`d/dx ln x = 1/x`).
140    pub fn ln(self) -> Var<'t> {
141        self.unary(self.value.ln(), 1.0 / self.value)
142    }
143    /// Hyperbolic tangent (`d/dx tanh x = 1 − tanh²x`) — the workhorse network activation.
144    pub fn tanh(self) -> Var<'t> {
145        let t = self.value.tanh();
146        self.unary(t, 1.0 - t * t)
147    }
148    /// Square root (`d/dx √x = 1/(2√x)`).
149    pub fn sqrt(self) -> Var<'t> {
150        let s = self.value.sqrt();
151        self.unary(s, 0.5 / s)
152    }
153    /// Integer/real power (`d/dx xⁿ = n·xⁿ⁻¹`).
154    pub fn powf(self, n: f64) -> Var<'t> {
155        self.unary(self.value.powf(n), n * self.value.powf(n - 1.0))
156    }
157    /// Logistic sigmoid `σ(x) = 1/(1+e⁻ˣ)` (`σ' = σ(1−σ)`).
158    pub fn sigmoid(self) -> Var<'t> {
159        let s = 1.0 / (1.0 + (-self.value).exp());
160        self.unary(s, s * (1.0 - s))
161    }
162    /// Rectified linear unit (`ReLU' = [x>0]`).
163    pub fn relu(self) -> Var<'t> {
164        let d = if self.value > 0.0 { 1.0 } else { 0.0 };
165        self.unary(self.value.max(0.0), d)
166    }
167}
168
169impl<'t> Add for Var<'t> {
170    type Output = Var<'t>;
171    fn add(self, rhs: Var<'t>) -> Var<'t> {
172        self.binary(rhs, self.value + rhs.value, 1.0, 1.0)
173    }
174}
175impl<'t> Sub for Var<'t> {
176    type Output = Var<'t>;
177    fn sub(self, rhs: Var<'t>) -> Var<'t> {
178        self.binary(rhs, self.value - rhs.value, 1.0, -1.0)
179    }
180}
181impl<'t> Mul for Var<'t> {
182    type Output = Var<'t>;
183    fn mul(self, rhs: Var<'t>) -> Var<'t> {
184        self.binary(rhs, self.value * rhs.value, rhs.value, self.value)
185    }
186}
187impl<'t> Div for Var<'t> {
188    type Output = Var<'t>;
189    fn div(self, rhs: Var<'t>) -> Var<'t> {
190        let v = self.value / rhs.value;
191        self.binary(rhs, v, 1.0 / rhs.value, -self.value / (rhs.value * rhs.value))
192    }
193}
194impl<'t> Neg for Var<'t> {
195    type Output = Var<'t>;
196    fn neg(self) -> Var<'t> {
197        self.unary(-self.value, -1.0)
198    }
199}
200
201// scalar convenience: Var ∘ f64
202impl<'t> Add<f64> for Var<'t> {
203    type Output = Var<'t>;
204    fn add(self, rhs: f64) -> Var<'t> {
205        self.unary(self.value + rhs, 1.0)
206    }
207}
208impl<'t> Mul<f64> for Var<'t> {
209    type Output = Var<'t>;
210    fn mul(self, rhs: f64) -> Var<'t> {
211        self.unary(self.value * rhs, rhs)
212    }
213}
214impl<'t> Sub<f64> for Var<'t> {
215    type Output = Var<'t>;
216    fn sub(self, rhs: f64) -> Var<'t> {
217        self.unary(self.value - rhs, 1.0)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// Central-difference gradient of a scalar function of `n` inputs — the oracle.
226    fn fd_grad(f: impl Fn(&[f64]) -> f64, x: &[f64]) -> Vec<f64> {
227        let h = 1e-6;
228        (0..x.len())
229            .map(|i| {
230                let mut xp = x.to_vec();
231                let mut xm = x.to_vec();
232                xp[i] += h;
233                xm[i] -= h;
234                (f(&xp) - f(&xm)) / (2.0 * h)
235            })
236            .collect()
237    }
238
239    #[test]
240    fn product_and_quotient_rules_are_exact() {
241        // THE ORACLE. ∂(x·y)/∂x = y, ∂(x/y)/∂y = −x/y².
242        let t = Tape::new();
243        let x = t.var(3.0);
244        let y = t.var(4.0);
245        let z = x * y;
246        let g = z.backward();
247        assert!((g.wrt(x) - 4.0).abs() < 1e-12 && (g.wrt(y) - 3.0).abs() < 1e-12);
248
249        let t2 = Tape::new();
250        let a = t2.var(3.0);
251        let b = t2.var(4.0);
252        let q = a / b;
253        let gq = q.backward();
254        assert!((gq.wrt(a) - 0.25).abs() < 1e-12, "∂(a/b)/∂a = 1/b");
255        assert!((gq.wrt(b) - (-3.0 / 16.0)).abs() < 1e-12, "∂(a/b)/∂b = −a/b²");
256    }
257
258    #[test]
259    fn tanh_derivative_matches_one_minus_tanh_squared() {
260        let t = Tape::new();
261        let x = t.var(0.7);
262        let y = x.tanh();
263        let g = y.backward();
264        let expect = 1.0 - 0.7_f64.tanh().powi(2);
265        assert!((g.wrt(x) - expect).abs() < 1e-12, "tanh' = 1 − tanh²");
266    }
267
268    #[test]
269    fn gradient_of_a_nonlinear_multivariable_function_matches_finite_differences() {
270        // THE HEADLINE. f(x,y,z) = sin(x·y) + exp(z) / (1 + x²) − tanh(y + z). Exact reverse-mode gradient vs
271        // central differences at a nontrivial point.
272        let f = |v: &[f64]| {
273            let (x, y, z) = (v[0], v[1], v[2]);
274            (x * y).sin() + z.exp() / (1.0 + x * x) - (y + z).tanh()
275        };
276        let x0 = [0.6, -1.3, 0.4];
277        let t = Tape::new();
278        let x = t.var(x0[0]);
279        let y = t.var(x0[1]);
280        let z = t.var(x0[2]);
281        let one = t.constant(1.0);
282        let out = (x * y).sin() + z.exp() / (one + x * x) - (y + z).tanh();
283        let g = out.backward();
284        let fd = fd_grad(f, &x0);
285        for (i, &v) in [g.wrt(x), g.wrt(y), g.wrt(z)].iter().enumerate() {
286            assert!((v - fd[i]).abs() < 1e-6, "grad[{i}]: autodiff {v} vs fd {}", fd[i]);
287        }
288    }
289
290    #[test]
291    fn a_shared_subexpression_accumulates_both_paths() {
292        // THE DISCRIMINATOR. When a variable feeds an output through two paths, reverse mode sums both
293        // contributions in ONE sweep. f = x·x + x → ∂f/∂x = 2x + 1.
294        let t = Tape::new();
295        let x = t.var(5.0);
296        let f = x * x + x;
297        let g = f.backward();
298        assert!((g.wrt(x) - 11.0).abs() < 1e-12, "∂(x²+x)/∂x = 2x+1 = 11");
299    }
300
301    #[test]
302    fn sigmoid_and_relu_gradients_are_correct() {
303        let t = Tape::new();
304        let x = t.var(0.5);
305        let s = x.sigmoid();
306        let gs = s.backward();
307        let sv = 1.0 / (1.0 + (-0.5_f64).exp());
308        assert!((gs.wrt(x) - sv * (1.0 - sv)).abs() < 1e-12, "σ' = σ(1−σ)");
309
310        let t2 = Tape::new();
311        let xp = t2.var(2.0);
312        let xn = t2.var(-2.0);
313        assert!((xp.relu().backward().wrt(xp) - 1.0).abs() < 1e-12, "ReLU'(+) = 1");
314        assert!(xn.relu().backward().wrt(xn).abs() < 1e-12, "ReLU'(−) = 0");
315    }
316}