Skip to main content

qualia_core_db/solvers/learning/clustering/
gmm.rs

1//! Gaussian Mixture Models via EM (PRML ch 9.2, ISL ch 12) — diagonal-covariance
2//! mixture, the standard robust GMM. Means are seeded by k-means (reusing
3//! [`super::kmeans`]); the EM loop alternates responsibilities (E) and weighted
4//! moment updates (M) and is guaranteed to increase the log-likelihood each step.
5//!
6//! The diagonal-covariance assumption (per-feature variance, no cross terms) is
7//! stated explicitly, not hidden — it is the common, numerically stable GMM and
8//! avoids singular full covariances on small data. A variance floor prevents
9//! component collapse. Kernel-class `Reduction` (the per-point responsibilities).
10
11use crate::solvers::learning::LearningError;
12
13/// A fitted diagonal-covariance Gaussian mixture.
14#[derive(Debug, Clone)]
15pub struct GmmModel {
16    /// Mixing weights `π_c` (sum to 1).
17    pub weights: Vec<f64>,
18    /// Component means, `k × p` row-major.
19    pub means: Vec<f64>,
20    /// Per-component diagonal variances, `k × p` row-major.
21    pub variances: Vec<f64>,
22    /// Hard assignment (argmax responsibility) per input row.
23    pub labels: Vec<usize>,
24    pub log_likelihood: f64,
25    pub k: usize,
26    pub p: usize,
27    pub n_iter: usize,
28    pub converged: bool,
29}
30
31const VAR_FLOOR: f64 = 1e-6;
32const LN_2PI: f64 = 1.837_877_066_409_345_6; // ln(2π)
33
34/// Log of a diagonal Gaussian density at `x` for mean/variance rows of length `p`.
35fn log_gauss_diag(x: &[f64], mean: &[f64], var: &[f64], p: usize) -> f64 {
36    let mut s = 0.0;
37    for j in 0..p {
38        let v = var[j].max(VAR_FLOOR);
39        let d = x[j] - mean[j];
40        s += LN_2PI + v.ln() + d * d / v;
41    }
42    -0.5 * s
43}
44
45fn log_sum_exp(values: &[f64]) -> f64 {
46    let m = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
47    if m == f64::NEG_INFINITY {
48        return m;
49    }
50    m + values.iter().map(|&v| (v - m).exp()).sum::<f64>().ln()
51}
52
53/// Fit a `k`-component diagonal GMM by EM. Fails closed: `InvalidDimension`,
54/// `InsufficientData` (`k == 0` or `k > n`), `NotConverged`.
55pub fn fit(
56    x: &[f64],
57    n: usize,
58    p: usize,
59    k: usize,
60    max_iter: usize,
61    tol: f64,
62    seed: u64,
63) -> Result<GmmModel, LearningError> {
64    if n == 0 || p == 0 || x.len() != n * p {
65        return Err(LearningError::InvalidDimension);
66    }
67    if k == 0 || k > n {
68        return Err(LearningError::InsufficientData);
69    }
70
71    // Initialise means with k-means; variances with the global per-feature
72    // variance; weights uniform.
73    let km = super::kmeans::fit(x, n, p, k, 50, seed)?;
74    let mut means = km.centroids;
75    let mut weights = vec![1.0 / k as f64; k];
76    let mut variances = vec![0.0; k * p];
77    {
78        // Global per-feature variance as the starting spread.
79        let mut gmean = vec![0.0; p];
80        for i in 0..n {
81            for j in 0..p {
82                gmean[j] += x[i * p + j];
83            }
84        }
85        for j in 0..p {
86            gmean[j] /= n as f64;
87        }
88        let mut gvar = vec![0.0; p];
89        for i in 0..n {
90            for j in 0..p {
91                let d = x[i * p + j] - gmean[j];
92                gvar[j] += d * d;
93            }
94        }
95        for j in 0..p {
96            gvar[j] = (gvar[j] / n as f64).max(VAR_FLOOR);
97        }
98        for c in 0..k {
99            variances[c * p..(c + 1) * p].copy_from_slice(&gvar);
100        }
101    }
102
103    let mut resp = vec![0.0; n * k]; // responsibilities γ_ic
104    let mut log_comp = vec![0.0; k];
105    let mut prev_ll = f64::NEG_INFINITY;
106    let mut converged = false;
107    let mut iters = 0;
108
109    for it in 1..=max_iter.max(1) {
110        iters = it;
111        // ── E-step: responsibilities + log-likelihood ──
112        let mut ll = 0.0;
113        for i in 0..n {
114            let xi = &x[i * p..(i + 1) * p];
115            for c in 0..k {
116                log_comp[c] = weights[c].max(1e-300).ln()
117                    + log_gauss_diag(
118                        xi,
119                        &means[c * p..(c + 1) * p],
120                        &variances[c * p..(c + 1) * p],
121                        p,
122                    );
123            }
124            let lse = log_sum_exp(&log_comp);
125            ll += lse;
126            for c in 0..k {
127                resp[i * k + c] = (log_comp[c] - lse).exp();
128            }
129        }
130
131        // ── M-step: weighted weights / means / variances ──
132        for c in 0..k {
133            let mut nc = 0.0;
134            for i in 0..n {
135                nc += resp[i * k + c];
136            }
137            let nc_safe = nc.max(1e-300);
138            weights[c] = nc / n as f64;
139            // Mean.
140            for j in 0..p {
141                let mut s = 0.0;
142                for i in 0..n {
143                    s += resp[i * k + c] * x[i * p + j];
144                }
145                means[c * p + j] = s / nc_safe;
146            }
147            // Diagonal variance.
148            for j in 0..p {
149                let mut s = 0.0;
150                for i in 0..n {
151                    let d = x[i * p + j] - means[c * p + j];
152                    s += resp[i * k + c] * d * d;
153                }
154                variances[c * p + j] = (s / nc_safe).max(VAR_FLOOR);
155            }
156        }
157
158        if (ll - prev_ll).abs() < tol && it > 1 {
159            prev_ll = ll;
160            converged = true;
161            break;
162        }
163        prev_ll = ll;
164    }
165
166    if !converged {
167        return Err(LearningError::NotConverged);
168    }
169
170    // Hard labels = argmax responsibility (recomputed at the final parameters).
171    let mut labels = vec![0usize; n];
172    for i in 0..n {
173        let xi = &x[i * p..(i + 1) * p];
174        for c in 0..k {
175            log_comp[c] = weights[c].max(1e-300).ln()
176                + log_gauss_diag(
177                    xi,
178                    &means[c * p..(c + 1) * p],
179                    &variances[c * p..(c + 1) * p],
180                    p,
181                );
182        }
183        let mut best = 0;
184        for c in 1..k {
185            if log_comp[c] > log_comp[best] {
186                best = c;
187            }
188        }
189        labels[i] = best;
190    }
191
192    Ok(GmmModel {
193        weights,
194        means,
195        variances,
196        labels,
197        log_likelihood: prev_ll,
198        k,
199        p,
200        n_iter: iters,
201        converged,
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn two_blobs() -> (Vec<f64>, usize) {
210        // Two separated Gaussian-ish blobs around (0,0) and (8,8).
211        let mut x = Vec::new();
212        for d in 0..8 {
213            let t = (d as f64 - 3.5) * 0.2;
214            x.push(0.0 + t);
215            x.push(0.0 - t);
216        }
217        for d in 0..8 {
218            let t = (d as f64 - 3.5) * 0.2;
219            x.push(8.0 + t);
220            x.push(8.0 + t);
221        }
222        (x, 16)
223    }
224
225    #[test]
226    fn recovers_two_components() {
227        let (x, n) = two_blobs();
228        let m = fit(&x, n, 2, 2, 200, 1e-8, 1).unwrap();
229        assert!(m.converged);
230        // Weights ~0.5 each.
231        assert!((m.weights[0] - 0.5).abs() < 0.1 && (m.weights[1] - 0.5).abs() < 0.1);
232        // The first 8 points share a label; the last 8 share the other.
233        let l0 = m.labels[0];
234        let l1 = m.labels[8];
235        assert_ne!(l0, l1);
236        assert!((0..8).all(|i| m.labels[i] == l0));
237        assert!((8..16).all(|i| m.labels[i] == l1));
238        // One mean near (0,0), the other near (8,8).
239        let near = |c: usize, tx: f64, ty: f64| {
240            (m.means[c * 2] - tx).abs() < 0.5 && (m.means[c * 2 + 1] - ty).abs() < 0.5
241        };
242        assert!(
243            (near(0, 0.0, 0.0) && near(1, 8.0, 8.0)) || (near(1, 0.0, 0.0) && near(0, 8.0, 8.0))
244        );
245    }
246
247    #[test]
248    fn log_likelihood_is_finite_and_weights_normalised() {
249        let (x, n) = two_blobs();
250        let m = fit(&x, n, 2, 2, 200, 1e-8, 3).unwrap();
251        assert!(m.log_likelihood.is_finite());
252        assert!((m.weights.iter().sum::<f64>() - 1.0).abs() < 1e-9);
253    }
254
255    #[test]
256    fn guards() {
257        assert_eq!(
258            fit(&[1.0, 2.0], 1, 2, 2, 10, 1e-6, 0).unwrap_err(),
259            LearningError::InsufficientData
260        );
261    }
262}