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}