Skip to main content

ferromotion_learn/
msnn.rs

1//! **Model-structured network (Neural Compositional Module)** — an interpretable, data-efficient
2//! alternative to a black-box MLP, from the physics-informed-ML "model-structured" school. Rather than one
3//! opaque nonlinear map, it is a blend of **local linear models** gated by **membership functions** over a
4//! scheduling variable: `f(x) = Σᵢ φᵢ(x) · (aᵢ x + bᵢ)`. The `φᵢ` are triangular bumps centered on a grid,
5//! normalized to a **partition of unity** (`Σᵢ φᵢ(x) = 1` everywhere), so the output is a smooth interpolation
6//! between neighboring local lines. Two properties fall out: it is **linear in its parameters** `(aᵢ, bᵢ)`, so
7//! it fits by ordinary least squares — no gradient descent, no local minima — and it is **interpretable**: the
8//! learned slope `aᵢ` is the function's local slope near center `i`, a number an engineer can read and sanity-
9//! check. (The full model-structured toolkit adds neural FIR blocks over past inputs for dynamics and
10//! physics-based initialization; this is its interpretable spatial core.)
11//!
12//! Verified: the memberships partition unity exactly; the model recovers an affine function to machine
13//! precision; it fits a nonlinear curve with a handful of local models; and each learned local slope matches
14//! the true derivative at its center.
15
16/// A blend of local linear models with partition-of-unity triangular memberships over one scheduling input.
17pub struct Msnn {
18    centers: Vec<f64>,
19    h: f64,
20    a: Vec<f64>, // local slopes
21    b: Vec<f64>, // local intercepts
22}
23
24impl Msnn {
25    fn raw_memberships(centers: &[f64], h: f64, x: f64) -> Vec<f64> {
26        let k = centers.len();
27        let mut phi = vec![0.0; k];
28        for (i, &c) in centers.iter().enumerate() {
29            let t = 1.0 - (x - c).abs() / h;
30            let mut v = t.max(0.0);
31            // clamp the end tents so the partition of unity extends past the domain edges
32            if (i == 0 && x < c) || (i == k - 1 && x > c) {
33                v = 1.0;
34            }
35            phi[i] = v;
36        }
37        phi
38    }
39
40    /// The normalized membership weights at `x` (they sum to exactly 1 — the partition of unity).
41    pub fn membership(&self, x: f64) -> Vec<f64> {
42        let mut phi = Self::raw_memberships(&self.centers, self.h, x);
43        let s: f64 = phi.iter().sum();
44        if s > 0.0 {
45            for p in &mut phi {
46                *p /= s;
47            }
48        }
49        phi
50    }
51
52    /// Fit `centers` local linear models on `[lo, hi]`. Each local model `i` is an *independent*
53    /// membership-weighted linear regression, so its slope `aᵢ` is a well-defined, readable local slope; the
54    /// prediction blends them by the partition-of-unity memberships.
55    pub fn fit(xs: &[f64], ys: &[f64], centers: usize, lo: f64, hi: f64) -> Msnn {
56        let c: Vec<f64> = (0..centers).map(|i| lo + (hi - lo) * i as f64 / (centers - 1).max(1) as f64).collect();
57        let h = if centers > 1 { (hi - lo) / (centers - 1) as f64 } else { hi - lo };
58        let mut model = Msnn { centers: c, h, a: vec![0.0; centers], b: vec![0.0; centers] };
59
60        for i in 0..centers {
61            // weighted normal equations for a line minimizing Σ φᵢ(x)[(a x + b) − y]²
62            let (mut sw, mut swx, mut swxx, mut swy, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
63            for (&x, &y) in xs.iter().zip(ys.iter()) {
64                let w = Self::raw_memberships(&model.centers, h, x)[i];
65                sw += w;
66                swx += w * x;
67                swxx += w * x * x;
68                swy += w * y;
69                swxy += w * x * y;
70            }
71            // solve [[swxx, swx],[swx, sw]] [a;b] = [swxy; swy] (ridge only for a degenerate cell, so a
72            // well-conditioned local regression recovers an affine target exactly)
73            let det0 = swxx * sw - swx * swx;
74            let det = if det0.abs() < 1e-12 { det0 + 1e-9 } else { det0 };
75            model.a[i] = (swxy * sw - swx * swy) / det;
76            model.b[i] = (swxx * swy - swx * swxy) / det;
77        }
78        model
79    }
80
81    /// Predict `f(x)` = blend of the local linear models.
82    pub fn predict(&self, x: f64) -> f64 {
83        let phi = self.membership(x);
84        (0..self.centers.len()).map(|i| phi[i] * (self.a[i] * x + self.b[i])).sum()
85    }
86
87    pub fn n_centers(&self) -> usize {
88        self.centers.len()
89    }
90    pub fn center(&self, i: usize) -> f64 {
91        self.centers[i]
92    }
93    /// The learned local slope of model `i` — the function's derivative near `center(i)`.
94    pub fn local_slope(&self, i: usize) -> f64 {
95        self.a[i]
96    }
97    /// The local line `aᵢ x + bᵢ` evaluated at `x` (for drawing the individual pieces).
98    pub fn local_line(&self, i: usize, x: f64) -> f64 {
99        self.a[i] * x + self.b[i]
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn memberships_partition_unity() {
109        // THE STRUCTURAL PROPERTY. The membership weights sum to 1 at every point, inside and outside the grid.
110        let m = Msnn::fit(&[0.0, 1.0], &[0.0, 1.0], 6, -1.0, 1.0);
111        for &x in &[-2.0, -0.7, 0.0, 0.33, 0.9, 1.5] {
112            let s: f64 = m.membership(x).iter().sum();
113            assert!((s - 1.0).abs() < 1e-12, "Σφ should be 1 at x={x}, got {s}");
114        }
115    }
116
117    #[test]
118    fn recovers_an_affine_function_exactly() {
119        // A linear target y = 2x + 1 is represented exactly: every local slope is 2.
120        let xs: Vec<f64> = (0..40).map(|i| -1.0 + 2.0 * i as f64 / 39.0).collect();
121        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
122        let m = Msnn::fit(&xs, &ys, 6, -1.0, 1.0);
123        for &x in &[-0.8, -0.2, 0.5, 0.95] {
124            assert!((m.predict(x) - (2.0 * x + 1.0)).abs() < 1e-9, "affine recovery at {x}");
125        }
126        for i in 0..m.n_centers() {
127            assert!((m.local_slope(i) - 2.0).abs() < 1e-6, "every local slope should be 2");
128        }
129    }
130
131    #[test]
132    fn fits_a_nonlinear_curve_with_readable_local_slopes() {
133        // THE HEADLINE. Fit sin(2x) with a few local models; the fit is close AND each learned local slope
134        // matches the true derivative 2cos(2x) at its center — interpretability.
135        let xs: Vec<f64> = (0..80).map(|i| -1.5 + 3.0 * i as f64 / 79.0).collect();
136        let ys: Vec<f64> = xs.iter().map(|x| (2.0 * x).sin()).collect();
137        let m = Msnn::fit(&xs, &ys, 10, -1.5, 1.5);
138        // fit quality
139        let mse: f64 = xs.iter().zip(&ys).map(|(&x, &y)| (m.predict(x) - y).powi(2)).sum::<f64>() / xs.len() as f64;
140        assert!(mse < 1e-3, "should fit sin(2x): mse {mse}");
141        // interpretability: local slope ≈ true derivative 2cos(2c) at interior centers
142        for i in 2..m.n_centers() - 2 {
143            let c = m.center(i);
144            let truth = 2.0 * (2.0 * c).cos();
145            assert!((m.local_slope(i) - truth).abs() < 0.6, "slope at c={c}: {} vs {truth}", m.local_slope(i));
146        }
147    }
148}