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}