Skip to main content

ferromotion_learn/
nn.rs

1//! **The multilayer perceptron (MLP) + Adam** — the plain neural network, trained through the reverse-mode
2//! [`crate::autodiff`] tape. This is the "black box" the rest of the course measures against: a stack of
3//! affine maps and `tanh` nonlinearities that, by the **universal approximation theorem**, can fit any
4//! continuous function given enough hidden units — but with *no* physics built in, so it needs a lot of data
5//! and extrapolates blindly. Later modules keep this training loop and add a physics prior to the data, the
6//! loss, or the architecture.
7//!
8//! Parameters live as a flat `Vec<f64>`; each training step builds a fresh tape, wraps the parameters as
9//! [`Var`]s, runs the batch forward accumulating a mean-squared-error loss, calls `backward` once for the
10//! exact gradient w.r.t. every weight, and takes an **Adam** step (adaptive per-parameter learning rates with
11//! bias-corrected first/second moment estimates). Weight initialization is seeded (deterministic) Xavier.
12//! Pure `f64`, wasm-clean. Verified against the universal-approximation oracle: an MLP drives its training
13//! loss near zero fitting `sin(3x)` and learns the non-linearly-separable XOR function.
14
15use crate::autodiff::{Tape, Var};
16
17/// A fully-connected network with `tanh` hidden activations and a linear output layer.
18pub struct Mlp {
19    sizes: Vec<usize>,
20    params: Vec<f64>,
21    // Adam optimizer state
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 Mlp {
36    /// A network with the given layer sizes, e.g. `&[1, 16, 16, 1]` (one input, two hidden layers of 16,
37    /// one output). Weights are seeded-Xavier initialized; biases start at zero.
38    pub fn new(sizes: &[usize], seed: u64) -> Self {
39        assert!(sizes.len() >= 2, "need at least an input and an output layer");
40        let mut state = seed ^ 0xA5A5_5A5A_1234_5678;
41        let mut params = Vec::new();
42        for l in 0..sizes.len() - 1 {
43            let (ind, outd) = (sizes[l], sizes[l + 1]);
44            let r = (6.0 / (ind + outd) as f64).sqrt(); // Xavier uniform half-range
45            for _ in 0..ind * outd {
46                let u = (splitmix64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0;
47                params.push(u * r);
48            }
49            params.extend(std::iter::repeat_n(0.0, outd)); // biases
50        }
51        let n = params.len();
52        Mlp { sizes: sizes.to_vec(), params, m: vec![0.0; n], v: vec![0.0; n], t: 0 }
53    }
54
55    /// Number of trainable parameters.
56    pub fn n_params(&self) -> usize {
57        self.params.len()
58    }
59
60    /// Forward inference in plain `f64` (no tape): map an input vector to the output vector.
61    pub fn forward(&self, x: &[f64]) -> Vec<f64> {
62        let mut a = x.to_vec();
63        let mut off = 0;
64        let layers = self.sizes.len() - 1;
65        for l in 0..layers {
66            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
67            let mut z = vec![0.0; outd];
68            for (o, zo) in z.iter_mut().enumerate() {
69                let mut s = self.params[off + ind * outd + o]; // bias
70                for (i, &ai) in a.iter().enumerate() {
71                    s += self.params[off + o * ind + i] * ai;
72                }
73                *zo = if l + 1 < layers { s.tanh() } else { s };
74            }
75            off += ind * outd + outd;
76            a = z;
77        }
78        a
79    }
80
81    /// Full-batch loss and gradient via the tape: builds one tape, wraps params as `Var`s, runs every sample
82    /// forward accumulating MSE, and backpropagates once. Returns `(mse, gradient)`.
83    fn loss_and_grad(&self, xs: &[Vec<f64>], ys: &[Vec<f64>]) -> (f64, Vec<f64>) {
84        let tape = Tape::new();
85        let pv: Vec<Var> = self.params.iter().map(|&p| tape.var(p)).collect();
86        let layers = self.sizes.len() - 1;
87        let mut loss = tape.constant(0.0);
88        for (x, y) in xs.iter().zip(ys.iter()) {
89            let mut a: Vec<Var> = x.iter().map(|&xi| tape.constant(xi)).collect();
90            let mut off = 0;
91            for l in 0..layers {
92                let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
93                let mut z = Vec::with_capacity(outd);
94                for o in 0..outd {
95                    let mut s = pv[off + ind * outd + o]; // bias var
96                    for i in 0..ind {
97                        s = s + pv[off + o * ind + i] * a[i];
98                    }
99                    z.push(if l + 1 < layers { s.tanh() } else { s });
100                }
101                off += ind * outd + outd;
102                a = z;
103            }
104            for (o, out) in a.iter().enumerate() {
105                let e = *out - y[o];
106                loss = loss + e * e;
107            }
108        }
109        let n = (xs.len() * ys[0].len()) as f64;
110        let loss = loss * (1.0 / n);
111        let g = loss.backward();
112        let grad: Vec<f64> = pv.iter().map(|&p| g.wrt(p)).collect();
113        (loss.value(), grad)
114    }
115
116    /// One Adam optimizer step over the full batch. Returns the MSE *before* the step.
117    pub fn train_step(&mut self, xs: &[Vec<f64>], ys: &[Vec<f64>], lr: f64) -> f64 {
118        let (loss, grad) = self.loss_and_grad(xs, ys);
119        self.t += 1;
120        let (b1, b2, eps) = (0.9_f64, 0.999_f64, 1e-8);
121        let bc1 = 1.0 - b1.powi(self.t as i32);
122        let bc2 = 1.0 - b2.powi(self.t as i32);
123        for (i, &gi) in grad.iter().enumerate() {
124            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi;
125            self.v[i] = b2 * self.v[i] + (1.0 - b2) * gi * gi;
126            let mhat = self.m[i] / bc1;
127            let vhat = self.v[i] / bc2;
128            self.params[i] -= lr * mhat / (vhat.sqrt() + eps);
129        }
130        loss
131    }
132
133    /// Train for `epochs` full-batch Adam steps; returns the final MSE.
134    pub fn train(&mut self, xs: &[Vec<f64>], ys: &[Vec<f64>], epochs: usize, lr: f64) -> f64 {
135        let mut mse = f64::INFINITY;
136        for _ in 0..epochs {
137            mse = self.train_step(xs, ys, lr);
138        }
139        // report the loss AFTER the last step, not before
140        self.loss_and_grad(xs, ys).0.min(mse)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn an_mlp_approximates_a_nonlinear_function() {
150        // THE HEADLINE (universal approximation). Fit sin(3x) on [-1,1] — a function with no linear structure
151        // — and drive the training MSE near zero.
152        let xs: Vec<Vec<f64>> = (0..40).map(|i| vec![-1.0 + 2.0 * i as f64 / 39.0]).collect();
153        let ys: Vec<Vec<f64>> = xs.iter().map(|x| vec![(3.0 * x[0]).sin()]).collect();
154        let mut net = Mlp::new(&[1, 24, 24, 1], 7);
155        let mse = net.train(&xs, &ys, 1500, 0.02);
156        assert!(mse < 1e-3, "MLP should fit sin(3x): final MSE {mse}");
157        // spot-check a held-out-ish point
158        let p = net.forward(&[0.5])[0];
159        assert!((p - 1.5_f64.sin()).abs() < 0.1, "prediction at x=0.5: {p} vs {}", 1.5_f64.sin());
160    }
161
162    #[test]
163    fn an_mlp_learns_xor() {
164        // THE DISCRIMINATOR. XOR is not linearly separable — a single perceptron cannot learn it, but an MLP
165        // with a hidden layer can. Targets are ±1 (tanh-friendly).
166        let xs = vec![vec![0.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0], vec![1.0, 1.0]];
167        let ys = vec![vec![-1.0], vec![1.0], vec![1.0], vec![-1.0]];
168        let mut net = Mlp::new(&[2, 8, 1], 3);
169        let mse = net.train(&xs, &ys, 2000, 0.03);
170        assert!(mse < 1e-2, "MLP should learn XOR: final MSE {mse}");
171        // signs must match the XOR truth table
172        for (x, y) in xs.iter().zip(ys.iter()) {
173            let p = net.forward(x)[0];
174            assert_eq!(p.signum(), y[0].signum(), "XOR({x:?}) predicted {p}, want sign {}", y[0]);
175        }
176    }
177
178    #[test]
179    fn the_loss_decreases_monotonically_early_on() {
180        // Sanity: Adam should reduce the loss over the first steps on a simple linear target y = 2x.
181        let xs: Vec<Vec<f64>> = (0..10).map(|i| vec![i as f64 / 10.0]).collect();
182        let ys: Vec<Vec<f64>> = xs.iter().map(|x| vec![2.0 * x[0]]).collect();
183        let mut net = Mlp::new(&[1, 8, 1], 1);
184        let first = net.train_step(&xs, &ys, 0.05);
185        let mut last = first;
186        for _ in 0..50 {
187            last = net.train_step(&xs, &ys, 0.05);
188        }
189        assert!(last < first, "loss should drop: {first} → {last}");
190    }
191}