Skip to main content

tensorlogic_quantrs_hooks/vmp/
mixture.rs

1//! Variational Bayes Gaussian Mixture Model (VBEM).
2//!
3//! Implements the variational EM (Variational Bayes EM) algorithm for a
4//! univariate Gaussian mixture model with conjugate Dirichlet / Gaussian
5//! priors. The derivation follows Bishop (2006), Chapter 10 and the original
6//! Attias (1999) / Ghahramani & Beal (2000) formulations.
7//!
8//! The generative model is:
9//!
10//! ```text
11//!   π ~ Dirichlet(α₀, …, α₀)           (mixing proportions)
12//!   μ_k ~ N(m₀, 1/β₀)  k = 1, …, K     (component means, iid)
13//!   z_n | π ~ Categorical(π)             (latent assignments)
14//!   x_n | z_n = k, μ_k ~ N(μ_k, 1/τ_k) (observed data, precision τ_k known)
15//! ```
16//!
17//! The mean-field variational family factorises as:
18//!
19//! ```text
20//!   q(π, μ₁, …, μ_K, z) = q(π) · Π_k q(μ_k) · Π_n q(z_n)
21//! ```
22//!
23//! The algorithm is self-contained (does not wire into the generic
24//! [`super::engine::VariationalMessagePassing`] engine) and follows the same
25//! standalone pattern as [`super::gamma`] and [`super::beta`].
26//!
27//! # References
28//!
29//! - Bishop, C. M. (2006). *Pattern Recognition and Machine Learning*,
30//!   §10.2 "Variational mixture of Gaussians".
31//! - Attias, H. (1999). Inferring parameters and structure of latent variable
32//!   models by variational Bayes. UAI-15.
33//! - Ghahramani, Z. & Beal, M. J. (2000). Variational inference for Bayesian
34//!   mixtures of factor analysers. NIPS 12.
35
36use crate::error::{PgmError, Result};
37use scirs2_core::random::{RngExt, SeedableRng, StdRng};
38
39use super::distributions::{dirichlet_kl, gaussian_kl, DirichletNP, GaussianNP};
40use super::exponential_family::ExponentialFamily;
41
42// ---------------------------------------------------------------------------
43// Configuration
44// ---------------------------------------------------------------------------
45
46/// Configuration for [`VariationalGaussianMixture`].
47///
48/// All fields are validated on [`VariationalGaussianMixture::new`] via the
49/// private `VgmmConfig::validate` method. Use the builder helpers to
50/// construct a config; the [`VgmmConfig::new`] constructor provides sensible
51/// research-preview defaults.
52#[derive(Clone, Debug)]
53pub struct VgmmConfig {
54    /// Number of mixture components K (must be ≥ 1).
55    pub n_components: usize,
56    /// Symmetric Dirichlet prior concentration α₀ > 0 for each component.
57    pub prior_concentration: f64,
58    /// Prior mean m₀ for each component's Gaussian prior.
59    pub prior_mean: f64,
60    /// Prior precision β₀ > 0 for each component's Gaussian prior.
61    pub prior_precision: f64,
62    /// Shared observation precision τ, used when `component_precisions` is
63    /// `None`. All `τ_k` are set to this value.
64    pub observation_precision: f64,
65    /// Per-component observation precisions `τ_k`. When `Some`, the vector
66    /// length must equal `n_components` and every entry must be positive.
67    /// When `None`, `observation_precision` is replicated K times.
68    pub component_precisions: Option<Vec<f64>>,
69    /// Maximum number of VBEM iterations.
70    pub max_iterations: usize,
71    /// ELBO absolute-change convergence tolerance.
72    pub tolerance: f64,
73    /// Maximum permissible ELBO decrease (used for divergence detection).
74    pub divergence_tolerance: f64,
75    /// Seed for the seeded RNG used during mean initialisation.
76    pub seed: u64,
77}
78
79impl VgmmConfig {
80    /// Construct a config for `n_components` mixture components with
81    /// sensible defaults:
82    ///
83    /// | Parameter               | Default |
84    /// |-------------------------|---------|
85    /// | `prior_concentration`   | 1.0     |
86    /// | `prior_mean`            | 0.0     |
87    /// | `prior_precision`       | 1e-3    |
88    /// | `observation_precision` | 1.0     |
89    /// | `max_iterations`        | 200     |
90    /// | `tolerance`             | 1e-6    |
91    /// | `divergence_tolerance`  | 1e-4    |
92    /// | `seed`                  | 0       |
93    pub fn new(n_components: usize) -> Self {
94        Self {
95            n_components,
96            prior_concentration: 1.0,
97            prior_mean: 0.0,
98            prior_precision: 1e-3,
99            observation_precision: 1.0,
100            component_precisions: None,
101            max_iterations: 200,
102            tolerance: 1e-6,
103            divergence_tolerance: 1e-4,
104            seed: 0,
105        }
106    }
107
108    /// Set the Gaussian prior hyperparameters for all component means.
109    ///
110    /// - `prior_mean` — prior mean m₀
111    /// - `prior_precision` — prior precision β₀ (must be > 0)
112    /// - `prior_concentration` — symmetric Dirichlet concentration α₀ (must
113    ///   be > 0)
114    pub fn with_prior(
115        mut self,
116        prior_mean: f64,
117        prior_precision: f64,
118        prior_concentration: f64,
119    ) -> Self {
120        self.prior_mean = prior_mean;
121        self.prior_precision = prior_precision;
122        self.prior_concentration = prior_concentration;
123        self
124    }
125
126    /// Set the shared observation precision τ applied to all components.
127    pub fn with_observation_precision(mut self, tau: f64) -> Self {
128        self.observation_precision = tau;
129        self
130    }
131
132    /// Set per-component observation precisions `τ_k`. The vector length must
133    /// equal `n_components` and every entry must be strictly positive; this is
134    /// validated immediately.
135    pub fn with_component_precisions(mut self, taus: Vec<f64>) -> Result<Self> {
136        if taus.len() != self.n_components {
137            return Err(PgmError::DimensionMismatch {
138                expected: vec![self.n_components],
139                got: vec![taus.len()],
140            });
141        }
142        for (k, &t) in taus.iter().enumerate() {
143            if !t.is_finite() || t <= 0.0 {
144                return Err(PgmError::InvalidDistribution(format!(
145                    "component_precisions[{}] = {} must be positive and finite",
146                    k, t
147                )));
148            }
149        }
150        self.component_precisions = Some(taus);
151        Ok(self)
152    }
153
154    /// Set iteration budget and ELBO tolerance.
155    pub fn with_limits(mut self, max_iterations: usize, tolerance: f64) -> Self {
156        self.max_iterations = max_iterations;
157        self.tolerance = tolerance;
158        self
159    }
160
161    /// Set the seed for the seeded RNG used during initialisation.
162    pub fn with_seed(mut self, seed: u64) -> Self {
163        self.seed = seed;
164        self
165    }
166
167    /// Validate all config fields. Called from
168    /// [`VariationalGaussianMixture::new`].
169    fn validate(&self) -> Result<()> {
170        if self.n_components < 1 {
171            return Err(PgmError::InvalidDistribution(
172                "VgmmConfig: n_components must be >= 1".to_string(),
173            ));
174        }
175        if !self.prior_concentration.is_finite() || self.prior_concentration <= 0.0 {
176            return Err(PgmError::InvalidDistribution(format!(
177                "VgmmConfig: prior_concentration = {} must be positive and finite",
178                self.prior_concentration
179            )));
180        }
181        if !self.prior_mean.is_finite() {
182            return Err(PgmError::InvalidDistribution(format!(
183                "VgmmConfig: prior_mean = {} must be finite",
184                self.prior_mean
185            )));
186        }
187        if !self.prior_precision.is_finite() || self.prior_precision <= 0.0 {
188            return Err(PgmError::InvalidDistribution(format!(
189                "VgmmConfig: prior_precision = {} must be positive and finite",
190                self.prior_precision
191            )));
192        }
193        if !self.observation_precision.is_finite() || self.observation_precision <= 0.0 {
194            return Err(PgmError::InvalidDistribution(format!(
195                "VgmmConfig: observation_precision = {} must be positive and finite",
196                self.observation_precision
197            )));
198        }
199        if let Some(ref taus) = self.component_precisions {
200            if taus.len() != self.n_components {
201                return Err(PgmError::DimensionMismatch {
202                    expected: vec![self.n_components],
203                    got: vec![taus.len()],
204                });
205            }
206            for (k, &t) in taus.iter().enumerate() {
207                if !t.is_finite() || t <= 0.0 {
208                    return Err(PgmError::InvalidDistribution(format!(
209                        "VgmmConfig: component_precisions[{}] = {} must be positive and finite",
210                        k, t
211                    )));
212                }
213            }
214        }
215        Ok(())
216    }
217
218    /// Return a `K`-vector of per-component observation precisions.
219    fn taus(&self) -> Vec<f64> {
220        match &self.component_precisions {
221            Some(v) => v.clone(),
222            None => vec![self.observation_precision; self.n_components],
223        }
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Result type
229// ---------------------------------------------------------------------------
230
231/// Output of a completed [`VariationalGaussianMixture::fit`] call.
232#[derive(Clone, Debug)]
233pub struct VgmmResult {
234    /// Soft assignment matrix of shape `N x K`.
235    ///
236    /// `responsibilities[n][k]` is `r_{nk} = q(z_n = k)`, the posterior
237    /// probability that data point `n` belongs to component `k`.
238    pub responsibilities: Vec<Vec<f64>>,
239    /// Posterior Gaussian distributions for the K component means.
240    pub components: Vec<GaussianNP>,
241    /// Posterior Dirichlet distribution over mixing weights.
242    pub weights: DirichletNP,
243    /// ELBO evaluated at initialisation, then after each complete iteration.
244    pub elbo_history: Vec<f64>,
245    /// Number of VBEM iterations executed (not counting the initialisation pass).
246    pub iterations: usize,
247    /// `true` if the ELBO converged within `tolerance` before exhausting the
248    /// iteration budget.
249    pub converged: bool,
250}
251
252impl VgmmResult {
253    /// Normalised mixing weights `alpha_k / sum(alpha)` derived from the Dirichlet
254    /// posterior concentrations.
255    pub fn mixing_weights(&self) -> Vec<f64> {
256        let total = self.weights.total_concentration();
257        self.weights
258            .concentration
259            .iter()
260            .map(|&a| a / total)
261            .collect()
262    }
263
264    /// Hard cluster assignments `argmax_k r_{nk}` for each data point.
265    pub fn hard_assignments(&self) -> Vec<usize> {
266        self.responsibilities
267            .iter()
268            .map(|row| {
269                row.iter()
270                    .enumerate()
271                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
272                    .map(|(k, _)| k)
273                    .unwrap_or(0)
274            })
275            .collect()
276    }
277
278    /// Effective component sizes `N_k = sum_n r_{nk}`.
279    pub fn component_counts(&self) -> Vec<f64> {
280        let k = self.components.len();
281        let mut counts = vec![0.0_f64; k];
282        for row in &self.responsibilities {
283            for (j, &r) in row.iter().enumerate() {
284                counts[j] += r;
285            }
286        }
287        counts
288    }
289}
290
291// ---------------------------------------------------------------------------
292// VariationalGaussianMixture
293// ---------------------------------------------------------------------------
294
295/// Variational Bayes Gaussian Mixture Model fitter (VBEM / VBGMM).
296///
297/// The algorithm performs coordinate-ascent variational inference on the
298/// mean-field factorisation
299///
300/// ```text
301///   q(pi, mu, z) = q(pi) * prod_k q(mu_k) * prod_n q(z_n)
302/// ```
303///
304/// updating the Dirichlet posterior `q(pi)`, Gaussian posteriors `q(mu_k)`, and
305/// categorical posteriors `q(z_n)` until the ELBO converges.
306///
307/// # Example
308///
309/// ```no_run
310/// use tensorlogic_quantrs_hooks::vmp::{VariationalGaussianMixture, VgmmConfig};
311///
312/// let config = VgmmConfig::new(2)
313///     .with_prior(0.0, 1e-3, 1.0)
314///     .with_observation_precision(1.0)
315///     .with_limits(100, 1e-6)
316///     .with_seed(42);
317///
318/// let vgmm = VariationalGaussianMixture::new(config).unwrap();
319/// let data = vec![0.0, 0.1, 0.2, 10.0, 10.1, 10.2];
320/// let result = vgmm.fit(&data).unwrap();
321/// assert!(result.converged);
322/// ```
323#[derive(Clone, Debug)]
324pub struct VariationalGaussianMixture {
325    config: VgmmConfig,
326}
327
328impl VariationalGaussianMixture {
329    /// Construct a new fitter, validating the config immediately.
330    pub fn new(config: VgmmConfig) -> Result<Self> {
331        config.validate()?;
332        Ok(Self { config })
333    }
334
335    /// Run VBEM on `data` and return the variational posterior.
336    ///
337    /// # Errors
338    ///
339    /// - [`PgmError::InvalidDistribution`] if `data` is empty or contains
340    ///   non-finite values.
341    /// - [`PgmError::ConvergenceFailure`] if the ELBO decreases by more than
342    ///   `config.divergence_tolerance` in a single iteration (numerical
343    ///   breakdown).
344    pub fn fit(&self, data: &[f64]) -> Result<VgmmResult> {
345        // ----------------------------------------------------------------
346        // Input validation
347        // ----------------------------------------------------------------
348        if data.is_empty() {
349            return Err(PgmError::InvalidDistribution(
350                "VariationalGaussianMixture::fit: data must not be empty".to_string(),
351            ));
352        }
353        for &x in data {
354            if !x.is_finite() {
355                return Err(PgmError::InvalidDistribution(format!(
356                    "VariationalGaussianMixture::fit: non-finite data value {}",
357                    x
358                )));
359            }
360        }
361
362        let k = self.config.n_components;
363        let taus = self.config.taus();
364        let m0 = self.config.prior_mean;
365        let beta0 = self.config.prior_precision;
366        let alpha0 = self.config.prior_concentration;
367
368        // ----------------------------------------------------------------
369        // Priors (fixed throughout)
370        // ----------------------------------------------------------------
371        let weights_prior = DirichletNP::new(vec![alpha0; k])?;
372        let comp_prior = GaussianNP::new(m0, beta0)?;
373
374        // ----------------------------------------------------------------
375        // Initialisation
376        // ----------------------------------------------------------------
377        let init_m = init_means(data, k, self.config.seed);
378
379        // Initial component posteriors: prior precision, initial mean from data
380        let mut components: Vec<GaussianNP> = init_m
381            .iter()
382            .map(|&m| GaussianNP::new(m, beta0))
383            .collect::<Result<Vec<_>>>()?;
384
385        // Initial weight posterior: copy of prior
386        let mut weights = weights_prior.clone();
387
388        // Compute initial responsibilities via E-step
389        let (mut resp, lse_sum_init) = e_step(data, &components, &weights, &taus);
390
391        // Compute initial ELBO before any M-step
392        let elbo0 = assemble_elbo(
393            lse_sum_init,
394            &weights,
395            &weights_prior,
396            &components,
397            &comp_prior,
398        )?;
399        let mut elbo_history = vec![elbo0];
400
401        // ----------------------------------------------------------------
402        // Main VBEM loop (mirrors engine.rs::run() structure exactly)
403        // ----------------------------------------------------------------
404        let mut converged = false;
405        let mut iterations = 0;
406
407        for iter in 0..self.config.max_iterations {
408            // M-step: update posteriors from responsibilities
409            let (new_components, new_weights) = m_step(data, &resp, &self.config, &taus)?;
410
411            // E-step: update responsibilities from posteriors
412            let (new_resp, lse_sum) = e_step(data, &new_components, &new_weights, &taus);
413
414            // ELBO for this iteration
415            let elbo_new = assemble_elbo(
416                lse_sum,
417                &new_weights,
418                &weights_prior,
419                &new_components,
420                &comp_prior,
421            )?;
422
423            let prev = *elbo_history
424                .last()
425                .ok_or_else(|| PgmError::ConvergenceFailure("VBEM elbo history is empty".into()))?;
426
427            // Divergence check: ELBO is guaranteed non-decreasing for conjugate
428            // VBEM; a drop beyond tolerance indicates numerical breakdown.
429            if elbo_new < prev - self.config.divergence_tolerance {
430                return Err(PgmError::ConvergenceFailure(format!(
431                    "VBEM ELBO decreased from {} to {} at iteration {}",
432                    prev, elbo_new, iter
433                )));
434            }
435
436            // Accept the update
437            resp = new_resp;
438            components = new_components;
439            weights = new_weights;
440            elbo_history.push(elbo_new);
441            iterations = iter + 1;
442
443            // Convergence check
444            if (elbo_new - prev).abs() < self.config.tolerance {
445                converged = true;
446                break;
447            }
448        }
449
450        Ok(VgmmResult {
451            responsibilities: resp,
452            components,
453            weights,
454            elbo_history,
455            iterations,
456            converged,
457        })
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Private algorithmic helpers
463// ---------------------------------------------------------------------------
464
465/// Initialise K component means by drawing K distinct indices (with cycling
466/// fallback when `k > data.len()`) from `data` using a seeded RNG.
467fn init_means(data: &[f64], k: usize, seed: u64) -> Vec<f64> {
468    if k == 0 {
469        return Vec::new();
470    }
471    let n = data.len();
472    let mut rng = StdRng::seed_from_u64(seed);
473    let mut result = Vec::with_capacity(k);
474    // Build a shuffled pool of indices; cycle when k > n.
475    let mut pool: Vec<usize> = (0..n).collect();
476    let mut pool_pos = 0;
477
478    // Fisher-Yates full shuffle of the initial pool
479    for i in 0..n {
480        let j = i + (rng.random::<f64>() * (n - i) as f64) as usize % (n - i).max(1);
481        pool.swap(i, j);
482    }
483
484    while result.len() < k {
485        if pool_pos >= pool.len() {
486            // Reshuffle and cycle
487            pool_pos = 0;
488            for i in 0..n {
489                let j = i + (rng.random::<f64>() * (n - i) as f64) as usize % (n - i).max(1);
490                pool.swap(i, j);
491            }
492        }
493        result.push(data[pool[pool_pos]]);
494        pool_pos += 1;
495    }
496    result
497}
498
499/// Numerically stable log-sum-exp.
500fn log_sum_exp(xs: &[f64]) -> f64 {
501    if xs.is_empty() {
502        return f64::NEG_INFINITY;
503    }
504    let max = xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
505    if !max.is_finite() {
506        return max;
507    }
508    let sum: f64 = xs.iter().map(|&x| (x - max).exp()).sum();
509    max + sum.ln()
510}
511
512/// VBEM E-step: compute normalised responsibilities `r_{nk}` for all N data
513/// points and all K components.
514///
515/// Returns `(responsibilities, sum_of_lse_over_n)` where the second element
516/// contributes to the ELBO as described in Bishop (2006) eq. (10.65).
517///
518/// The unnormalised log-responsibility for point n and component k is:
519///
520/// ```text
521///   ln rho_{nk} = E[ln pi_k]
522///                 - 0.5 * tau_k * ( m_k^2 + 1/beta_k - 2 x_n m_k + x_n^2 )
523/// ```
524///
525/// where `E[ln pi_k] = psi(alpha_k) - psi(sum alpha)` from
526/// `DirichletNP::expected_sufficient_statistics`.
527fn e_step(
528    data: &[f64],
529    components: &[GaussianNP],
530    weights: &DirichletNP,
531    taus: &[f64],
532) -> (Vec<Vec<f64>>, f64) {
533    let k = components.len();
534    // E[ln pi_k] for each k — DirichletNP::expected_sufficient_statistics
535    // returns (psi(alpha_k) - psi(alpha_0)) for each k.
536    let e_ln_pi: Vec<f64> = weights.expected_sufficient_statistics();
537
538    let mut lse_sum = 0.0;
539    let responsibilities: Vec<Vec<f64>> = data
540        .iter()
541        .map(|&x| {
542            // Compute unnormalised log-responsibilities
543            let ln_rho: Vec<f64> = (0..k)
544                .map(|j| {
545                    let comp = &components[j];
546                    let tau_k = taus[j];
547                    // E[(x - mu_k)^2] under q(mu_k) = N(m_k, 1/beta_k):
548                    //   = (x - m_k)^2 + 1/beta_k
549                    //   = x^2 - 2 x m_k + m_k^2 + 1/beta_k
550                    let quad =
551                        comp.mean * comp.mean + 1.0 / comp.precision - 2.0 * x * comp.mean + x * x;
552                    e_ln_pi[j] - 0.5 * tau_k * quad
553                })
554                .collect();
555
556            let lse = log_sum_exp(&ln_rho);
557            lse_sum += lse;
558
559            // Normalised responsibilities
560            ln_rho.iter().map(|&l| (l - lse).exp()).collect()
561        })
562        .collect();
563
564    (responsibilities, lse_sum)
565}
566
567/// VBEM M-step: update posteriors `q(pi)` and `q(mu_k)` from the current
568/// responsibilities.
569///
570/// For each component k the sufficient statistics accumulated from the data are:
571///
572/// ```text
573///   N_k = sum_n r_{nk}
574///   S_k = sum_n r_{nk} * x_n
575/// ```
576///
577/// The conjugate posterior updates are (Bishop 2006, eq. 10.58-10.63):
578///
579/// ```text
580///   alpha_k = alpha_0 + N_k
581///   beta_k  = beta_0 + tau_k * N_k
582///   m_k     = (beta_0 * m_0 + tau_k * S_k) / beta_k
583/// ```
584///
585/// Empty components (`N_k < 1e-10`) fall back to the prior to avoid
586/// degenerate precisions.
587fn m_step(
588    data: &[f64],
589    resp: &[Vec<f64>],
590    config: &VgmmConfig,
591    taus: &[f64],
592) -> Result<(Vec<GaussianNP>, DirichletNP)> {
593    let k = config.n_components;
594    let m0 = config.prior_mean;
595    let beta0 = config.prior_precision;
596    let alpha0 = config.prior_concentration;
597
598    let mut alphas = vec![0.0_f64; k];
599    let mut new_components = Vec::with_capacity(k);
600
601    for j in 0..k {
602        // Accumulate N_k and S_k
603        let n_k: f64 = resp.iter().map(|row| row[j]).sum();
604        let s_k: f64 = resp
605            .iter()
606            .zip(data.iter())
607            .map(|(row, &x)| row[j] * x)
608            .sum();
609
610        alphas[j] = alpha0 + n_k;
611
612        let comp = if n_k < 1e-10 {
613            // Empty component: revert to prior
614            GaussianNP::new(m0, beta0)?
615        } else {
616            let tau_k = taus[j];
617            let beta_k = beta0 + tau_k * n_k;
618            let m_k = (beta0 * m0 + tau_k * s_k) / beta_k;
619            GaussianNP::new(m_k, beta_k)?
620        };
621
622        new_components.push(comp);
623    }
624
625    let new_weights = DirichletNP::new(alphas)?;
626    Ok((new_components, new_weights))
627}
628
629/// Compute the VBEM evidence lower bound (ELBO).
630///
631/// The ELBO decomposes as:
632///
633/// ```text
634///   ELBO = sum_n LSE_n  -  KL[q(pi) || p(pi)]  -  sum_k KL[q(mu_k) || p(mu_k)]
635/// ```
636///
637/// where `sum_n LSE_n` is the accumulated log-normaliser from the E-step,
638/// capturing `E[ln p(X, Z | pi, mu)] - E[ln q(Z)]` by the VBEM identity
639/// (Bishop 2006, eq. 10.70).
640///
641/// The KL divergences are closed form for conjugate families:
642/// - `KL[Dir(alpha) || Dir(beta)]` from [`super::distributions::dirichlet_kl`]
643/// - `KL[N(m_q, 1/beta_q) || N(m_p, 1/beta_p)]` from [`super::distributions::gaussian_kl`]
644fn assemble_elbo(
645    lse_sum: f64,
646    weights: &DirichletNP,
647    weights_prior: &DirichletNP,
648    components: &[GaussianNP],
649    comp_prior: &GaussianNP,
650) -> Result<f64> {
651    let kl_weights = dirichlet_kl(weights, weights_prior)?;
652    let kl_comps: f64 = components
653        .iter()
654        .map(|c| gaussian_kl(c, comp_prior))
655        .collect::<Result<Vec<f64>>>()?
656        .iter()
657        .sum();
658    Ok(lse_sum - kl_weights - kl_comps)
659}
660
661// ---------------------------------------------------------------------------
662// Tests
663// ---------------------------------------------------------------------------
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    fn two_cluster_data() -> Vec<f64> {
670        vec![0.0, 0.1, 0.2, 10.0, 10.1, 10.2]
671    }
672
673    fn default_two_cluster_vgmm() -> VariationalGaussianMixture {
674        let config = VgmmConfig::new(2)
675            .with_prior(0.0, 1e-3, 1.0)
676            .with_observation_precision(1.0)
677            .with_limits(200, 1e-6)
678            .with_seed(1);
679        VariationalGaussianMixture::new(config).expect("config valid")
680    }
681
682    #[test]
683    fn responsibilities_sum_to_one() {
684        let data = two_cluster_data();
685        let vgmm = default_two_cluster_vgmm();
686        let result = vgmm.fit(&data).expect("fit");
687        for (n, row) in result.responsibilities.iter().enumerate() {
688            let sum: f64 = row.iter().sum();
689            assert!(
690                (sum - 1.0).abs() < 1e-10,
691                "row {} sums to {} (expected 1.0)",
692                n,
693                sum
694            );
695        }
696    }
697
698    #[test]
699    fn elbo_is_monotone() {
700        let data = two_cluster_data();
701        let vgmm = default_two_cluster_vgmm();
702        let result = vgmm.fit(&data).expect("fit");
703        for w in result.elbo_history.windows(2) {
704            assert!(w[1] + 1e-7 >= w[0], "ELBO decreased: {} -> {}", w[0], w[1]);
705        }
706    }
707
708    #[test]
709    fn two_cluster_recovery() {
710        let data = two_cluster_data();
711        let vgmm = default_two_cluster_vgmm();
712        let result = vgmm.fit(&data).expect("fit");
713        let mut means: Vec<f64> = result.components.iter().map(|c| c.mean).collect();
714        means.sort_by(|a, b| a.partial_cmp(b).unwrap());
715        let truth = [0.1_f64, 10.1];
716        for (recovered, &t) in means.iter().zip(truth.iter()) {
717            assert!(
718                (recovered - t).abs() < 0.5,
719                "recovered mean {} too far from truth {}",
720                recovered,
721                t
722            );
723        }
724    }
725
726    #[test]
727    fn single_component_posterior() {
728        // K=1, data=[1,2,3], essentially uninformative prior (beta_0=1e-6, tau=1).
729        // After convergence: N_1 = 3, S_1 = 6
730        //   beta_1 = 1e-6 + 1.0 * 3.0 ~= 3.0
731        //   m_1 = (1e-6 * 0 + 1.0 * 6.0) / 3.0 ~= 2.0
732        let config = VgmmConfig::new(1)
733            .with_prior(0.0, 1e-6, 1.0)
734            .with_observation_precision(1.0)
735            .with_limits(200, 1e-9)
736            .with_seed(0);
737        let vgmm = VariationalGaussianMixture::new(config).expect("config valid");
738        let result = vgmm.fit(&[1.0, 2.0, 3.0]).expect("fit");
739        let m1 = result.components[0].mean;
740        assert!(
741            (m1 - 2.0).abs() < 0.01,
742            "single-component posterior mean = {} (expected ~2.0)",
743            m1
744        );
745    }
746
747    #[test]
748    fn empty_data_errors() {
749        let vgmm = default_two_cluster_vgmm();
750        let err = vgmm.fit(&[]);
751        assert!(err.is_err(), "empty data should error");
752    }
753
754    #[test]
755    fn nan_data_errors() {
756        let vgmm = default_two_cluster_vgmm();
757        let err = vgmm.fit(&[1.0, f64::NAN]);
758        assert!(err.is_err(), "NaN data should error");
759    }
760
761    #[test]
762    fn zero_components_errors() {
763        let config = VgmmConfig::new(0);
764        let err = VariationalGaussianMixture::new(config);
765        assert!(err.is_err(), "K=0 should be rejected by validate()");
766    }
767
768    #[test]
769    fn mismatched_component_precisions() {
770        // n_components = 3 but providing only 1 precision
771        let result = VgmmConfig::new(3).with_component_precisions(vec![1.0]);
772        assert!(
773            result.is_err(),
774            "mismatched component_precisions should error"
775        );
776    }
777
778    #[test]
779    fn mixing_weights_sum() {
780        let data = two_cluster_data();
781        let vgmm = default_two_cluster_vgmm();
782        let result = vgmm.fit(&data).expect("fit");
783        let sum: f64 = result.mixing_weights().iter().sum();
784        assert!(
785            (sum - 1.0).abs() < 1e-12,
786            "mixing weights sum = {} (expected 1.0)",
787            sum
788        );
789    }
790
791    #[test]
792    fn hard_assignments_range() {
793        let data = two_cluster_data();
794        let config = VgmmConfig::new(2)
795            .with_prior(0.0, 1e-3, 1.0)
796            .with_observation_precision(1.0)
797            .with_limits(200, 1e-6)
798            .with_seed(0);
799        let vgmm = VariationalGaussianMixture::new(config).expect("config valid");
800        let result = vgmm.fit(&data).expect("fit");
801        let k = result.components.len();
802        for &a in &result.hard_assignments() {
803            assert!(a < k, "hard assignment {} out of range [0, {})", a, k);
804        }
805    }
806}