1use faer::Side;
45use gam_runtime::warm_start::{Fingerprint, Fingerprinter};
46use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
47use serde::{Deserialize, Serialize};
48
49use crate::arrow_schur::ArrowFactorCache;
50use crate::priority_selection::{PriorityCandidate, rank_priority_candidates};
51use gam_linalg::faer_ndarray::FaerEigh;
52use gam_linalg::pairwise_reduce::{BASE_CHUNK, pairwise_sum};
53use gam_math::special::bessel_i0_log_minus_abs_and_ratio;
54
55pub const ANALYTIC_LOGDET_DENSE_DIM_THRESHOLD: usize = 1024;
56
57#[derive(Clone, Copy)]
61pub struct EvidenceHvpLogDet<'a> {
62 pub dim: usize,
63 pub apply: &'a dyn Fn(&[f64]) -> Vec<f64>,
64}
65
66#[derive(Clone, Copy)]
68pub enum EvidenceLogDetSource<'a> {
69 FactoredArrow {
72 cache: &'a ArrowFactorCache,
73 fallback_hvp: Option<EvidenceHvpLogDet<'a>>,
74 },
75 Hvp(EvidenceHvpLogDet<'a>),
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum TopologyKind {
93 Periodic,
95 Flat,
97 Sphere,
99 Torus,
101}
102
103#[derive(Debug, Clone)]
106pub struct TopologyCandidate {
107 pub kind: TopologyKind,
108 pub negative_log_evidence: f64,
111 pub effective_dim: f64,
114 pub n_obs: usize,
117 pub converged: bool,
121 pub exclusion_reason: Option<String>,
124}
125
126#[derive(Debug, Clone)]
128pub struct SelectedTopology {
129 pub winner: TopologyKind,
130 pub ranking: Vec<TopologyCandidate>,
133 pub tie: bool,
137}
138
139#[derive(Debug, Clone, Copy)]
141pub struct TopologySelectOptions {
142 pub tie_tolerance: f64,
146 pub score_scale: TopologyScoreScale,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum TopologyScoreScale {
155 PerObservation,
157 PerEffectiveDim,
159}
160
161#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
163pub struct StackingConfig {
164 pub max_iter: usize,
168 pub kkt_tol: f64,
172}
173
174impl Default for StackingConfig {
175 fn default() -> Self {
176 Self {
177 max_iter: 256,
178 kkt_tol: f64::EPSILON.sqrt(),
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
185pub struct StackingCertificate {
186 pub mean_log_score: f64,
188 pub duality_gap: f64,
191 pub simplex_residual: f64,
193 pub multiplier_residual: f64,
195 pub complementarity_residual: f64,
197}
198
199impl StackingCertificate {
200 pub fn residual(&self) -> f64 {
201 self.duality_gap
202 .max(self.simplex_residual)
203 .max(self.multiplier_residual)
204 .max(self.complementarity_residual)
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct StackingCheckpoint {
213 pub weights: Array1<f64>,
214 pub completed_iterations: usize,
215 density_fingerprint: Fingerprint,
216}
217
218#[derive(Debug, Clone)]
221pub enum StackingError {
222 InvalidInput {
223 message: String,
224 },
225 NumericalFailure {
226 message: String,
227 certificate: Option<StackingCertificate>,
228 checkpoint: Option<StackingCheckpoint>,
229 },
230 DidNotConverge {
231 max_iterations: usize,
232 tolerance: f64,
233 certificate: StackingCertificate,
234 checkpoint: StackingCheckpoint,
235 },
236}
237
238impl std::fmt::Display for StackingError {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 match self {
241 Self::InvalidInput { message } => write!(f, "invalid stacking problem: {message}"),
242 Self::NumericalFailure {
243 message,
244 certificate,
245 checkpoint,
246 } => write!(
247 f,
248 "stacking numerical failure: {message} (certificate residual {}, checkpoint iterations {})",
249 certificate.map_or(f64::NAN, |value| value.residual()),
250 checkpoint
251 .as_ref()
252 .map_or(0, |value| value.completed_iterations)
253 ),
254 Self::DidNotConverge {
255 max_iterations,
256 tolerance,
257 certificate,
258 checkpoint,
259 } => write!(
260 f,
261 "stacking did not certify after {max_iterations} additional iterations (total {}): KKT residual {:.6e} exceeds tolerance {:.3e}; resume from the carried weights checkpoint",
262 checkpoint.completed_iterations,
263 certificate.residual(),
264 tolerance
265 ),
266 }
267 }
268}
269
270impl std::error::Error for StackingError {}
271
272#[derive(Debug, Clone)]
275pub struct StackingWeights {
276 pub weights: Array1<f64>,
277 pub iterations: usize,
278 pub certificate: StackingCertificate,
279}
280
281impl StackingWeights {
282 pub fn mean_log_score(&self) -> f64 {
283 self.certificate.mean_log_score
284 }
285}
286
287struct StackingProblem {
288 scaled_density: Array2<f64>,
289 row_log_scale: Array1<f64>,
290}
291
292impl StackingProblem {
293 fn from_log_density(log_density: ArrayView2<'_, f64>) -> Result<Self, StackingError> {
294 let n_obs = log_density.nrows();
295 let n_cand = log_density.ncols();
296 if n_cand == 0 || n_obs == 0 {
297 return Err(StackingError::InvalidInput {
298 message: "at least one candidate and one held-out row are required".to_string(),
299 });
300 }
301 if let Some(((row, col), value)) = log_density
302 .indexed_iter()
303 .find(|(_, value)| value.is_nan() || **value == f64::INFINITY)
304 {
305 return Err(StackingError::InvalidInput {
306 message: format!(
307 "log density at row {row}, candidate {col} is {value}; NaN and +infinity are not predictive densities"
308 ),
309 });
310 }
311 let mut scaled_density = Array2::<f64>::zeros((n_obs, n_cand));
312 let mut row_log_scale = Array1::<f64>::zeros(n_obs);
313 for row in 0..n_obs {
314 let row_max = (0..n_cand)
315 .map(|col| log_density[[row, col]])
316 .fold(f64::NEG_INFINITY, f64::max);
317 if !row_max.is_finite() {
318 return Err(StackingError::InvalidInput {
319 message: format!(
320 "held-out row {row} has zero density under every candidate; deleting it would change the stacking target"
321 ),
322 });
323 }
324 row_log_scale[row] = row_max;
325 for col in 0..n_cand {
326 let value = log_density[[row, col]];
327 if value.is_finite() {
328 scaled_density[[row, col]] = (value - row_max).exp();
329 }
330 }
331 }
332 Ok(Self {
333 scaled_density,
334 row_log_scale,
335 })
336 }
337
338 fn evaluate(
339 &self,
340 weights: ArrayView1<'_, f64>,
341 ) -> Result<(Array1<f64>, StackingCertificate, f64), String> {
342 let n = self.scaled_density.nrows();
343 let k = self.scaled_density.ncols();
344 let mass = weights.sum();
345 if weights.len() != k
346 || weights
347 .iter()
348 .any(|value| !value.is_finite() || *value < 0.0)
349 || !(mass.is_finite() && mass > 0.0)
350 {
351 return Err(
352 "checkpoint weights are not a finite nonnegative simplex vector".to_string(),
353 );
354 }
355 let mut gradient = Array1::<f64>::zeros(k);
356 let mut centered_objective = 0.0_f64;
357 let mut mean_log_score = 0.0_f64;
358 for row in 0..n {
359 let mut mixture = 0.0_f64;
360 for col in 0..k {
361 mixture += weights[col] * self.scaled_density[[row, col]];
362 }
363 if !(mixture.is_finite() && mixture > 0.0) {
364 return Err(format!(
365 "candidate mixture lost held-out row {row} (scaled density {mixture})"
366 ));
367 }
368 let log_mixture = mixture.ln();
369 centered_objective += log_mixture / n as f64;
370 let log_score = self.row_log_scale[row] + log_mixture;
371 let count = (row + 1) as f64;
372 mean_log_score = mean_log_score * ((count - 1.0) / count) + log_score / count;
373 for col in 0..k {
374 gradient[col] += self.scaled_density[[row, col]] / mixture / n as f64;
375 }
376 }
377 if !centered_objective.is_finite()
378 || !mean_log_score.is_finite()
379 || gradient.iter().any(|value| !value.is_finite())
380 {
381 return Err("objective or analytic gradient became non-finite".to_string());
382 }
383 let multiplier = weights.dot(&gradient);
384 let max_gradient = gradient.iter().copied().fold(f64::NEG_INFINITY, f64::max);
385 let certificate = StackingCertificate {
386 mean_log_score,
387 duality_gap: (max_gradient - multiplier).max(0.0),
388 simplex_residual: (mass - 1.0).abs(),
389 multiplier_residual: (multiplier - 1.0).abs(),
390 complementarity_residual: weights
391 .iter()
392 .zip(gradient.iter())
393 .map(|(&weight, &gain)| weight * (gain - multiplier).abs())
394 .fold(0.0_f64, f64::max),
395 };
396 Ok((gradient, certificate, centered_objective))
397 }
398
399 fn centered_objective(&self, weights: ArrayView1<'_, f64>) -> Option<f64> {
400 let n = self.scaled_density.nrows();
401 let mut objective = 0.0_f64;
402 for row in 0..n {
403 let mixture = self.scaled_density.row(row).dot(&weights);
404 if !(mixture.is_finite() && mixture > 0.0) {
405 return None;
406 }
407 objective += mixture.ln() / n as f64;
408 }
409 objective.is_finite().then_some(objective)
410 }
411}
412
413pub fn solve_stacking_weights(
434 log_density: ArrayView2<'_, f64>,
435 config: StackingConfig,
436) -> Result<StackingWeights, StackingError> {
437 solve_stacking_weights_impl(log_density, config, None)
438}
439
440fn solve_stacking_weights_impl(
441 log_density: ArrayView2<'_, f64>,
442 config: StackingConfig,
443 checkpoint: Option<&StackingCheckpoint>,
444) -> Result<StackingWeights, StackingError> {
445 if config.max_iter == 0 {
446 return Err(StackingError::InvalidInput {
447 message: "max_iter must be positive".to_string(),
448 });
449 }
450 let numerical_floor = f64::EPSILON.sqrt();
451 if !config.kkt_tol.is_finite() || config.kkt_tol < numerical_floor {
452 return Err(StackingError::InvalidInput {
453 message: format!(
454 "kkt_tol must be finite and at least the floating-point resolution floor {numerical_floor:.3e}"
455 ),
456 });
457 }
458 let density_fingerprint = evidence_matrix_fingerprint("stacking-log-density-v1", log_density);
459 let problem = StackingProblem::from_log_density(log_density)?;
460 let k = problem.scaled_density.ncols();
461 let (mut weights, completed_before) = if let Some(checkpoint) = checkpoint {
462 if checkpoint.density_fingerprint != density_fingerprint {
463 return Err(StackingError::InvalidInput {
464 message: "checkpoint belongs to a different held-out density table".to_string(),
465 });
466 }
467 if checkpoint.weights.len() != k {
468 return Err(StackingError::InvalidInput {
469 message: format!(
470 "checkpoint has {} weights but the density table has {k} candidates",
471 checkpoint.weights.len()
472 ),
473 });
474 }
475 let mut weights = checkpoint.weights.clone();
476 let mass = weights.sum();
477 if weights
478 .iter()
479 .any(|value| !value.is_finite() || *value < 0.0)
480 || !mass.is_finite()
481 || (mass - 1.0).abs() > config.kkt_tol
482 {
483 return Err(StackingError::InvalidInput {
484 message: "checkpoint weights must be a finite nonnegative simplex vector"
485 .to_string(),
486 });
487 }
488 weights.mapv_inplace(|value| value / mass);
489 (weights, checkpoint.completed_iterations)
490 } else {
491 (Array1::<f64>::from_elem(k, 1.0 / k as f64), 0)
492 };
493
494 for additional_iterations in 0..=config.max_iter {
495 let completed_iterations = completed_before + additional_iterations;
496 let checkpoint = StackingCheckpoint {
497 weights: weights.clone(),
498 completed_iterations,
499 density_fingerprint,
500 };
501 let (gradient, certificate, objective) =
502 problem.evaluate(weights.view()).map_err(|message| {
503 StackingError::NumericalFailure {
504 message,
505 certificate: None,
506 checkpoint: Some(checkpoint.clone()),
507 }
508 })?;
509 if certificate.residual() <= config.kkt_tol {
510 return Ok(StackingWeights {
511 weights,
512 iterations: completed_iterations,
513 certificate,
514 });
515 }
516 if additional_iterations == config.max_iter {
517 return Err(StackingError::DidNotConverge {
518 max_iterations: config.max_iter,
519 tolerance: config.kkt_tol,
520 certificate,
521 checkpoint,
522 });
523 }
524
525 let max_gradient_col = gradient
526 .iter()
527 .enumerate()
528 .max_by(|left, right| left.1.total_cmp(right.1))
529 .map(|(index, _)| index)
530 .expect("stacking has at least one candidate");
531 let candidate = stacking_newton_step(&problem, weights.view(), gradient.view(), objective)
532 .or_else(|| {
533 stacking_vertex_step(&problem, weights.view(), max_gradient_col, objective)
534 })
535 .ok_or_else(|| StackingError::NumericalFailure {
536 message: "positive KKT gap remained but neither the analytic Newton direction nor the exact vertex line solve produced a representable ascent step".to_string(),
537 certificate: Some(certificate),
538 checkpoint: Some(checkpoint),
539 })?;
540 weights = candidate;
541 }
542 Err(StackingError::NumericalFailure {
543 message: format!(
544 "stacking solver exhausted its inclusive iteration budget ({}) without producing a \
545 terminal verdict",
546 config.max_iter
547 ),
548 certificate: None,
549 checkpoint: None,
550 })
551}
552
553fn stacking_newton_step(
554 problem: &StackingProblem,
555 weights: ArrayView1<'_, f64>,
556 gradient: ArrayView1<'_, f64>,
557 objective: f64,
558) -> Option<Array1<f64>> {
559 let active: Vec<usize> = weights
560 .iter()
561 .enumerate()
562 .filter_map(|(index, &weight)| (weight > 0.0).then_some(index))
563 .collect();
564 if active.len() < 2 {
565 return None;
566 }
567 let reference_position = active
568 .iter()
569 .enumerate()
570 .max_by(|left, right| weights[*left.1].total_cmp(&weights[*right.1]))
571 .map(|(position, _)| position)?;
572 let reference = active[reference_position];
573 let free: Vec<usize> = active
574 .iter()
575 .copied()
576 .filter(|&index| index != reference)
577 .collect();
578 let dimension = free.len();
579 let n = problem.scaled_density.nrows();
580 let mut information = Array2::<f64>::zeros((dimension, dimension));
581 for row in 0..n {
582 let mixture = problem.scaled_density.row(row).dot(&weights);
583 if !(mixture.is_finite() && mixture > 0.0) {
584 return None;
585 }
586 let reference_density = problem.scaled_density[[row, reference]];
587 let contrasts: Vec<f64> = free
588 .iter()
589 .map(|&col| (problem.scaled_density[[row, col]] - reference_density) / mixture)
590 .collect();
591 for left in 0..dimension {
592 for right in 0..=left {
593 information[[left, right]] += contrasts[left] * contrasts[right] / n as f64;
594 information[[right, left]] = information[[left, right]];
595 }
596 }
597 }
598 let reduced_gradient =
599 Array1::from_iter(free.iter().map(|&col| gradient[col] - gradient[reference]));
600 let (eigenvalues, eigenvectors) = information.eigh(Side::Lower).ok()?;
601 let spectral_scale = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
602 if !(spectral_scale.is_finite() && spectral_scale > 0.0) {
603 return None;
604 }
605 let rank_tolerance = f64::EPSILON * (dimension as f64) * spectral_scale.max(f64::MIN_POSITIVE);
606 let projected = eigenvectors.t().dot(&reduced_gradient);
607 let mut spectral_step = Array1::<f64>::zeros(dimension);
608 for index in 0..dimension {
609 if eigenvalues[index] > rank_tolerance {
610 spectral_step[index] = projected[index] / eigenvalues[index];
611 }
612 }
613 let reduced_step = eigenvectors.dot(&spectral_step);
614 let ascent = reduced_gradient.dot(&reduced_step);
615 if !(ascent.is_finite() && ascent > 0.0) {
616 return None;
617 }
618 let mut direction = Array1::<f64>::zeros(weights.len());
619 for (position, &col) in free.iter().enumerate() {
620 direction[col] = reduced_step[position];
621 }
622 direction[reference] = -reduced_step.sum();
623 let mut step = 1.0_f64;
624 let mut boundary = None;
625 for col in 0..weights.len() {
626 if direction[col] < 0.0 {
627 let candidate = -weights[col] / direction[col];
628 if candidate < step {
629 step = candidate;
630 boundary = Some(col);
631 }
632 }
633 }
634 loop {
635 let mut candidate = &weights + &(direction.mapv(|value| step * value));
636 if let Some(col) = boundary {
637 if step == -weights[col] / direction[col] {
638 candidate[col] = 0.0;
639 }
640 }
641 for value in candidate.iter_mut() {
642 if *value < 0.0 && *value >= -f64::EPSILON {
643 *value = 0.0;
644 }
645 }
646 let mass = candidate.sum();
647 if mass.is_finite() && mass > 0.0 {
648 candidate.mapv_inplace(|value| value / mass);
649 if problem
650 .centered_objective(candidate.view())
651 .is_some_and(|value| value > objective)
652 {
653 return Some(candidate);
654 }
655 }
656 let next_step = 0.5 * step;
657 if next_step == step || next_step == 0.0 {
658 return None;
659 }
660 step = next_step;
661 boundary = None;
662 }
663}
664
665fn stacking_vertex_step(
666 problem: &StackingProblem,
667 weights: ArrayView1<'_, f64>,
668 vertex: usize,
669 objective: f64,
670) -> Option<Array1<f64>> {
671 let derivative = |step: f64| -> f64 {
672 let mut value = 0.0_f64;
673 let n = problem.scaled_density.nrows();
674 for row in 0..n {
675 let current = problem.scaled_density.row(row).dot(&weights);
676 let target = problem.scaled_density[[row, vertex]];
677 let mixture = (1.0 - step) * current + step * target;
678 if mixture <= 0.0 {
679 return f64::NEG_INFINITY;
680 }
681 value += (target - current) / mixture / n as f64;
682 }
683 value
684 };
685 if derivative(0.0) <= 0.0 {
686 return None;
687 }
688 let mut step = if derivative(1.0) >= 0.0 {
689 1.0
690 } else {
691 let mut lower = 0.0_f64;
692 let mut upper = 1.0_f64;
693 while upper - lower > f64::EPSILON.sqrt() {
694 let middle = 0.5 * (lower + upper);
695 if derivative(middle) > 0.0 {
696 lower = middle;
697 } else {
698 upper = middle;
699 }
700 }
701 0.5 * (lower + upper)
702 };
703 loop {
704 let mut candidate = weights.mapv(|weight| (1.0 - step) * weight);
705 candidate[vertex] += step;
706 if problem
707 .centered_objective(candidate.view())
708 .is_some_and(|value| value > objective)
709 {
710 return Some(candidate);
711 }
712 let next_step = 0.5 * step;
713 if next_step == step || next_step == 0.0 {
714 return None;
715 }
716 step = next_step;
717 }
718}
719
720#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
745pub struct GaussianMixtureConfig {
746 pub max_iter: usize,
751 pub loglik_tol: f64,
753 pub parameter_tol: f64,
759 pub covariance_floor: f64,
763 pub kmeans_max_iter: usize,
765}
766
767impl Default for GaussianMixtureConfig {
768 fn default() -> Self {
769 Self {
770 max_iter: 1000,
771 loglik_tol: f64::EPSILON.sqrt(),
772 parameter_tol: f64::EPSILON.sqrt(),
773 covariance_floor: 1e-6,
774 kmeans_max_iter: 25,
775 }
776 }
777}
778
779#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
782pub struct GaussianMixtureCertificate {
783 pub mean_log_likelihood: f64,
785 pub mean_log_likelihood_gain: f64,
787 pub monotonicity_uncertainty: f64,
791 pub objective_residual: f64,
792 pub objective_tolerance: f64,
793 pub parameter_residual: f64,
794 pub parameter_tolerance: f64,
795 pub contraction_rate: Option<f64>,
800 pub projected_iterations_to_tolerance: Option<usize>,
806}
807
808const EM_RATE_WINDOW: usize = 64;
820
821fn em_contraction_rate(window: &std::collections::VecDeque<f64>) -> Option<f64> {
828 if window.len() < EM_RATE_WINDOW + 1 {
829 return None;
830 }
831 let first = *window.front()?;
832 let last = *window.back()?;
833 if !(first.is_finite() && last.is_finite() && first > 0.0 && last > 0.0) {
834 return None;
835 }
836 let steps = (window.len() - 1) as f64;
837 let rate = (last / first).powf(1.0 / steps);
838 rate.is_finite().then_some(rate)
839}
840
841fn em_projected_iterations(residual: f64, tolerance: f64, rate: f64) -> Option<usize> {
849 if !(residual.is_finite() && tolerance.is_finite() && rate.is_finite()) {
850 return None;
851 }
852 if !(rate > 0.0 && rate < 1.0) || !(residual > tolerance) || tolerance <= 0.0 {
853 return None;
854 }
855 let steps = (tolerance / residual).ln() / rate.ln();
856 (steps.is_finite() && steps >= 0.0).then(|| steps.ceil() as usize)
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct GaussianMixtureCheckpoint {
862 pub weights: Array1<f64>,
863 pub means: Array2<f64>,
864 pub covariances: Vec<Array2<f64>>,
865 pub mean_log_likelihood: f64,
866 pub completed_iterations: usize,
867 data_fingerprint: Fingerprint,
868 covariance_floor: f64,
869}
870
871#[derive(Debug, Clone)]
874pub enum GaussianMixtureError {
875 InvalidInput {
876 message: String,
877 },
878 NumericalFailure {
879 message: String,
880 checkpoint: Option<GaussianMixtureCheckpoint>,
881 },
882 MonotonicityViolation {
883 previous_mean_log_likelihood: f64,
884 next_mean_log_likelihood: f64,
885 numerical_uncertainty: f64,
886 checkpoint: GaussianMixtureCheckpoint,
887 },
888 DidNotConverge {
889 max_iterations: usize,
890 certificate: GaussianMixtureCertificate,
891 checkpoint: GaussianMixtureCheckpoint,
892 },
893}
894
895impl std::fmt::Display for GaussianMixtureError {
896 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897 match self {
898 Self::InvalidInput { message } => write!(f, "invalid Gaussian mixture: {message}"),
899 Self::NumericalFailure {
900 message,
901 checkpoint,
902 } => write!(
903 f,
904 "Gaussian-mixture numerical failure: {message} (checkpoint iterations {})",
905 checkpoint
906 .as_ref()
907 .map_or(0, |value| value.completed_iterations)
908 ),
909 Self::MonotonicityViolation {
910 previous_mean_log_likelihood,
911 next_mean_log_likelihood,
912 numerical_uncertainty,
913 checkpoint,
914 } => write!(
915 f,
916 "Gaussian-mixture EM violated monotone ascent at iteration {}: mean log likelihood {previous_mean_log_likelihood:.12e} -> {next_mean_log_likelihood:.12e} (comparison uncertainty {numerical_uncertainty:.3e}); resume from the carried checkpoint only after diagnosing the numerical failure",
917 checkpoint.completed_iterations
918 ),
919 Self::DidNotConverge {
920 max_iterations,
921 certificate,
922 checkpoint,
923 } => write!(
924 f,
925 "Gaussian-mixture EM did not certify after {max_iterations} additional iterations (total {}): signed mean-log-likelihood gain {:.6e} (numerical uncertainty {:.3e}), objective residual {:.6e}/{:.3e}, parameter-map residual {:.6e}/{:.3e}, contraction rate {} per iteration, projected iterations to tolerance {}; resume from the carried checkpoint, which is not comparable evidence",
926 checkpoint.completed_iterations,
927 certificate.mean_log_likelihood_gain,
928 certificate.monotonicity_uncertainty,
929 certificate.objective_residual,
930 certificate.objective_tolerance,
931 certificate.parameter_residual,
932 certificate.parameter_tolerance,
933 match certificate.contraction_rate {
934 Some(rate) => format!("{rate:.6}"),
935 None => "unmeasured".to_string(),
936 },
937 match certificate.projected_iterations_to_tolerance {
938 Some(steps) => steps.to_string(),
939 None => "none (not contracting)".to_string(),
940 }
941 ),
942 }
943 }
944}
945
946impl std::error::Error for GaussianMixtureError {}
947
948#[derive(Debug, Clone)]
950pub struct GaussianMixtureFit {
951 weights: Array1<f64>,
953 means: Array2<f64>,
955 covariances: Vec<Array2<f64>>,
957 k: usize,
959 d: usize,
961 n_obs: usize,
963 loglik: f64,
965 iterations: usize,
967 certificate: GaussianMixtureCertificate,
968}
969
970impl GaussianMixtureFit {
971 pub fn weights(&self) -> ArrayView1<'_, f64> {
972 self.weights.view()
973 }
974
975 pub fn means(&self) -> ArrayView2<'_, f64> {
976 self.means.view()
977 }
978
979 pub fn iterations(&self) -> usize {
980 self.iterations
981 }
982
983 pub fn certificate(&self) -> GaussianMixtureCertificate {
984 self.certificate
985 }
986
987 pub fn num_free_parameters(&self) -> usize {
992 let cov_per = self.d * (self.d + 1) / 2;
993 (self.k - 1) + self.k * self.d + self.k * cov_per
994 }
995
996 pub fn per_point_log_density(&self, data: ArrayView2<'_, f64>) -> Result<Array1<f64>, String> {
1000 if data.ncols() != self.d {
1001 return Err(format!(
1002 "mixture log-density expects {} columns, got {}",
1003 self.d,
1004 data.ncols()
1005 ));
1006 }
1007 let n = data.nrows();
1008 let mut comp = Vec::with_capacity(self.k);
1009 for j in 0..self.k {
1010 comp.push(GaussianComponentEval::factor(
1011 self.means.row(j),
1012 &self.covariances[j],
1013 )?);
1014 }
1015 let mut out = Array1::<f64>::zeros(n);
1016 let log_w: Vec<f64> = self.weights.iter().map(|w| w.ln()).collect();
1017 for i in 0..n {
1018 let row = data.row(i);
1019 let mut log_terms = vec![f64::NEG_INFINITY; self.k];
1020 let mut max_term = f64::NEG_INFINITY;
1021 for j in 0..self.k {
1022 let lt = log_w[j] + comp[j].log_density(row);
1023 log_terms[j] = lt;
1024 if lt > max_term {
1025 max_term = lt;
1026 }
1027 }
1028 out[i] = log_sum_exp(&log_terms, max_term);
1029 }
1030 Ok(out)
1031 }
1032
1033 pub fn bic(&self) -> f64 {
1037 -self.loglik + 0.5 * self.num_free_parameters() as f64 * (self.n_obs as f64).ln()
1038 }
1039}
1040
1041#[derive(Debug, Clone)]
1044struct GaussianComponentEval {
1045 residual_origin: Array1<f64>,
1046 residual_scale: Array1<f64>,
1047 residual_normalized_offset: Array1<f64>,
1048 precision: Array2<f64>,
1049 log_norm: f64,
1050 d: usize,
1051}
1052
1053impl GaussianComponentEval {
1054 fn factor(mean: ArrayView1<'_, f64>, cov: &Array2<f64>) -> Result<Self, String> {
1055 let d = mean.len();
1056 if mean.iter().any(|value| !value.is_finite()) {
1057 return Err("mixture component mean must be finite".to_string());
1058 }
1059 if cov.nrows() != d || cov.ncols() != d {
1060 return Err(format!(
1061 "mixture component covariance must be {d}x{d}, got {}x{}",
1062 cov.nrows(),
1063 cov.ncols()
1064 ));
1065 }
1066 let (evals, evecs) = cov
1067 .eigh(Side::Lower)
1068 .map_err(|e| format!("mixture component covariance eigendecomposition failed: {e}"))?;
1069 let mut log_det = 0.0_f64;
1070 let mut inv_evals = Array1::<f64>::zeros(d);
1071 for (idx, &ev) in evals.iter().enumerate() {
1072 if !ev.is_finite() || ev <= 0.0 {
1073 return Err(format!(
1074 "mixture component covariance is not SPD: eigenvalue {idx} is {ev:.3e}"
1075 ));
1076 }
1077 log_det += ev.ln();
1078 let inverse = ev.recip();
1079 if !inverse.is_finite() {
1080 return Err(format!(
1081 "mixture component precision is not representable: eigenvalue {idx} is {ev:.3e}"
1082 ));
1083 }
1084 inv_evals[idx] = inverse;
1085 }
1086 let mut precision = Array2::<f64>::zeros((d, d));
1088 for a in 0..d {
1089 for b in 0..d {
1090 let mut acc = 0.0_f64;
1091 for m in 0..d {
1092 acc += evecs[[a, m]] * inv_evals[m] * evecs[[b, m]];
1093 }
1094 precision[[a, b]] = acc;
1095 }
1096 }
1097 let log_norm = -0.5 * (d as f64 * (2.0 * std::f64::consts::PI).ln() + log_det);
1098 if precision.iter().any(|value| !value.is_finite()) || !log_norm.is_finite() {
1099 return Err(
1100 "mixture component factorization produced non-finite precision or log normalizer"
1101 .to_string(),
1102 );
1103 }
1104 Ok(Self {
1105 residual_origin: mean.to_owned(),
1106 residual_scale: Array1::zeros(d),
1107 residual_normalized_offset: Array1::zeros(d),
1108 precision,
1109 log_norm,
1110 d,
1111 })
1112 }
1113
1114 #[inline]
1115 fn log_density(&self, y: ArrayView1<'_, f64>) -> f64 {
1116 let residual = self.residual(y);
1117 let pv = self.precision_times_residual(&residual);
1118 let mut quad = 0.0_f64;
1119 for c in 0..self.d {
1120 quad += residual[c] * pv[c];
1121 }
1122 self.log_norm - 0.5 * quad
1123 }
1124
1125 #[inline]
1126 fn residual(&self, y: ArrayView1<'_, f64>) -> Vec<f64> {
1127 let mut residual = vec![0.0_f64; self.d];
1128 for axis in 0..self.d {
1129 residual[axis] = (-self.residual_normalized_offset[axis]).mul_add(
1130 self.residual_scale[axis],
1131 y[axis] - self.residual_origin[axis],
1132 );
1133 }
1134 residual
1135 }
1136
1137 #[inline]
1139 fn precision_times_residual(&self, residual: &[f64]) -> Vec<f64> {
1140 let mut out = vec![0.0_f64; self.d];
1141 for a in 0..self.d {
1142 let mut acc = 0.0_f64;
1143 for b in 0..self.d {
1144 acc += self.precision[[a, b]] * residual[b];
1145 }
1146 out[a] = acc;
1147 }
1148 out
1149 }
1150}
1151
1152#[inline]
1153fn log_sum_exp(terms: &[f64], max_term: f64) -> f64 {
1154 if !max_term.is_finite() {
1155 return f64::NEG_INFINITY;
1156 }
1157 let mut acc = 0.0_f64;
1158 for &t in terms {
1159 acc += (t - max_term).exp();
1160 }
1161 max_term + acc.ln()
1162}
1163
1164fn evidence_matrix_fingerprint(namespace: &str, values: ArrayView2<'_, f64>) -> Fingerprint {
1165 let mut hasher = Fingerprinter::new();
1166 hasher.write_str(namespace);
1167 hasher.write_usize(values.nrows());
1168 hasher.write_usize(values.ncols());
1169 for &value in values {
1172 hasher.write_f64(value);
1173 }
1174 hasher.finalize()
1175}
1176
1177fn mixture_data_fingerprint(data: ArrayView2<'_, f64>) -> Fingerprint {
1178 evidence_matrix_fingerprint("gaussian-mixture-em-v1", data)
1179}
1180
1181pub fn fit_gaussian_mixture(
1190 data: ArrayView2<'_, f64>,
1191 k: usize,
1192 config: GaussianMixtureConfig,
1193) -> Result<GaussianMixtureFit, GaussianMixtureError> {
1194 validate_gaussian_mixture_problem(data, k, config)?;
1195 let means = gam_terms::basis::select_centers_by_strategy(
1198 data,
1199 &gam_terms::basis::CenterStrategy::KMeans {
1200 num_centers: k,
1201 max_iter: config.kmeans_max_iter,
1202 },
1203 )
1204 .map_err(|error| GaussianMixtureError::NumericalFailure {
1205 message: format!("deterministic k-means seeding failed: {error}"),
1206 checkpoint: None,
1207 })?;
1208 if means.nrows() != k || means.ncols() != data.ncols() {
1209 return Err(GaussianMixtureError::NumericalFailure {
1210 message: format!(
1211 "seeding returned {}x{} centers, expected {k}x{}",
1212 means.nrows(),
1213 means.ncols(),
1214 data.ncols()
1215 ),
1216 checkpoint: None,
1217 });
1218 }
1219 let global_covariance =
1220 constrained_data_covariance(data, config.covariance_floor).map_err(|message| {
1221 GaussianMixtureError::NumericalFailure {
1222 message,
1223 checkpoint: None,
1224 }
1225 })?;
1226 let weights = Array1::<f64>::from_elem(k, 1.0 / k as f64);
1227 let covariances = vec![global_covariance; k];
1228 let initial_e_step =
1229 mixture_e_step(data, &weights, &means, &covariances).map_err(|message| {
1230 GaussianMixtureError::NumericalFailure {
1231 message,
1232 checkpoint: None,
1233 }
1234 })?;
1235 let data_fingerprint = mixture_data_fingerprint(data);
1236 let checkpoint = GaussianMixtureCheckpoint {
1237 weights,
1238 means,
1239 covariances,
1240 mean_log_likelihood: initial_e_step.mean_log_likelihood,
1241 completed_iterations: 0,
1242 data_fingerprint,
1243 covariance_floor: config.covariance_floor,
1244 };
1245 run_gaussian_mixture_em(data, config, checkpoint)
1246}
1247
1248fn validate_gaussian_mixture_problem(
1249 data: ArrayView2<'_, f64>,
1250 k: usize,
1251 config: GaussianMixtureConfig,
1252) -> Result<(), GaussianMixtureError> {
1253 let n = data.nrows();
1254 let d = data.ncols();
1255 if k == 0 {
1256 return Err(GaussianMixtureError::InvalidInput {
1257 message: "k must be positive".to_string(),
1258 });
1259 }
1260 if d == 0 {
1261 return Err(GaussianMixtureError::InvalidInput {
1262 message: "at least one data column is required".to_string(),
1263 });
1264 }
1265 if k > n {
1266 return Err(GaussianMixtureError::InvalidInput {
1267 message: format!("requested {k} components but data has {n} rows"),
1268 });
1269 }
1270 if data.iter().any(|value| !value.is_finite()) {
1271 return Err(GaussianMixtureError::InvalidInput {
1272 message: "data must be finite".to_string(),
1273 });
1274 }
1275 if config.max_iter == 0 || config.kmeans_max_iter == 0 {
1276 return Err(GaussianMixtureError::InvalidInput {
1277 message: "max_iter and kmeans_max_iter must be positive".to_string(),
1278 });
1279 }
1280 let numerical_floor = f64::EPSILON.sqrt();
1281 if !config.loglik_tol.is_finite()
1282 || config.loglik_tol < numerical_floor
1283 || !config.parameter_tol.is_finite()
1284 || config.parameter_tol < numerical_floor
1285 || !config.covariance_floor.is_finite()
1286 || config.covariance_floor <= 0.0
1287 {
1288 return Err(GaussianMixtureError::InvalidInput {
1289 message: format!(
1290 "loglik_tol and parameter_tol must be finite and >= {numerical_floor:.3e}, and covariance_floor must be finite and positive"
1291 ),
1292 });
1293 }
1294 Ok(())
1295}
1296
1297fn validate_gaussian_mixture_checkpoint(
1298 data: ArrayView2<'_, f64>,
1299 covariance_floor: f64,
1300 checkpoint: &GaussianMixtureCheckpoint,
1301) -> Result<(), GaussianMixtureError> {
1302 let d = data.ncols();
1303 let k = checkpoint.weights.len();
1304 let mass = checkpoint.weights.sum();
1305 if k == 0
1306 || checkpoint.data_fingerprint != mixture_data_fingerprint(data)
1307 || checkpoint.covariance_floor.to_bits() != covariance_floor.to_bits()
1308 || checkpoint.means.dim() != (k, d)
1309 || checkpoint.covariances.len() != k
1310 || checkpoint
1311 .covariances
1312 .iter()
1313 .any(|covariance| covariance.dim() != (d, d))
1314 || checkpoint
1315 .weights
1316 .iter()
1317 .chain(checkpoint.means.iter())
1318 .chain(checkpoint.covariances.iter().flat_map(|value| value.iter()))
1319 .any(|value| !value.is_finite())
1320 || checkpoint.weights.iter().any(|value| *value <= 0.0)
1321 || !mass.is_finite()
1322 || (mass - 1.0).abs() > f64::EPSILON.sqrt()
1323 || !checkpoint.mean_log_likelihood.is_finite()
1324 {
1325 return Err(GaussianMixtureError::InvalidInput {
1326 message: "checkpoint problem identity, dimensions, interior parameters, likelihood, or simplex mass are invalid".to_string(),
1327 });
1328 }
1329 Ok(())
1330}
1331
1332fn run_gaussian_mixture_em(
1333 data: ArrayView2<'_, f64>,
1334 config: GaussianMixtureConfig,
1335 mut checkpoint: GaussianMixtureCheckpoint,
1336) -> Result<GaussianMixtureFit, GaussianMixtureError> {
1337 validate_gaussian_mixture_checkpoint(data, config.covariance_floor, &checkpoint)?;
1338 let k = checkpoint.weights.len();
1339 let d = data.ncols();
1340 let data_fingerprint = mixture_data_fingerprint(data);
1341
1342 let mut budget = config.max_iter;
1353 let mut extension: Option<usize> = None;
1354 let mut residual_at_last_grant = f64::INFINITY;
1363 let mut residual_window: std::collections::VecDeque<f64> =
1364 std::collections::VecDeque::with_capacity(EM_RATE_WINDOW + 1);
1365 let mut additional_updates = 0usize;
1366 loop {
1367 let current = mixture_e_step(
1368 data,
1369 &checkpoint.weights,
1370 &checkpoint.means,
1371 &checkpoint.covariances,
1372 )
1373 .map_err(|message| GaussianMixtureError::NumericalFailure {
1374 message,
1375 checkpoint: Some(checkpoint.clone()),
1376 })?;
1377 if (checkpoint.mean_log_likelihood - current.mean_log_likelihood).abs()
1378 > current.mean_log_likelihood_roundoff
1379 {
1380 return Err(GaussianMixtureError::InvalidInput {
1381 message: format!(
1382 "checkpoint mean log likelihood {:.12e} disagrees with its parameters ({:.12e} +/- {:.3e})",
1383 checkpoint.mean_log_likelihood,
1384 current.mean_log_likelihood,
1385 current.mean_log_likelihood_roundoff
1386 ),
1387 });
1388 }
1389 checkpoint.mean_log_likelihood = current.mean_log_likelihood;
1390
1391 let (next_weights, next_means, next_covariances) = mixture_m_step(
1392 data,
1393 current.responsibilities.view(),
1394 config.covariance_floor,
1395 )
1396 .map_err(|message| GaussianMixtureError::NumericalFailure {
1397 message,
1398 checkpoint: Some(checkpoint.clone()),
1399 })?;
1400 let next = mixture_e_step(data, &next_weights, &next_means, &next_covariances).map_err(
1401 |message| GaussianMixtureError::NumericalFailure {
1402 message,
1403 checkpoint: Some(checkpoint.clone()),
1404 },
1405 )?;
1406 let objective_scale = current
1407 .mean_log_likelihood
1408 .abs()
1409 .max(next.mean_log_likelihood.abs())
1410 .max(1.0);
1411 let objective_step = next.mean_log_likelihood - current.mean_log_likelihood;
1412 let objective_residual = objective_step.abs() / objective_scale;
1413 let parameter_residual = empirical_predictive_density_residual(
1414 ¤t.row_log_likelihoods,
1415 &next.row_log_likelihoods,
1416 )
1417 .map_err(|message| GaussianMixtureError::NumericalFailure {
1418 message,
1419 checkpoint: Some(checkpoint.clone()),
1420 })?;
1421 let monotonicity_uncertainty = gaussian_mixture_monotonicity_uncertainty(
1422 objective_scale,
1423 current.mean_log_likelihood_roundoff,
1424 next.mean_log_likelihood_roundoff,
1425 );
1426 residual_window.push_back(parameter_residual);
1427 if residual_window.len() > EM_RATE_WINDOW + 1 {
1428 residual_window.pop_front();
1429 }
1430 let contraction_rate = em_contraction_rate(&residual_window);
1431 let projected_iterations_to_tolerance = contraction_rate
1432 .and_then(|rate| em_projected_iterations(parameter_residual, config.parameter_tol, rate));
1433 let certificate = GaussianMixtureCertificate {
1434 mean_log_likelihood: current.mean_log_likelihood,
1435 mean_log_likelihood_gain: objective_step,
1436 monotonicity_uncertainty,
1437 objective_residual,
1438 objective_tolerance: config.loglik_tol,
1439 parameter_residual,
1440 parameter_tolerance: config.parameter_tol,
1441 contraction_rate,
1442 projected_iterations_to_tolerance,
1443 };
1444 if objective_step < -monotonicity_uncertainty {
1445 return Err(GaussianMixtureError::MonotonicityViolation {
1446 previous_mean_log_likelihood: current.mean_log_likelihood,
1447 next_mean_log_likelihood: next.mean_log_likelihood,
1448 numerical_uncertainty: monotonicity_uncertainty,
1449 checkpoint,
1450 });
1451 }
1452 if objective_residual <= config.loglik_tol && parameter_residual <= config.parameter_tol {
1453 let loglik = current.mean_log_likelihood * data.nrows() as f64;
1454 if !loglik.is_finite() {
1455 return Err(GaussianMixtureError::NumericalFailure {
1456 message: "certified mean log likelihood overflows as a total likelihood"
1457 .to_string(),
1458 checkpoint: Some(checkpoint),
1459 });
1460 }
1461 return Ok(GaussianMixtureFit {
1462 weights: checkpoint.weights,
1463 means: checkpoint.means,
1464 covariances: checkpoint.covariances,
1465 k,
1466 d,
1467 n_obs: data.nrows(),
1468 loglik,
1469 iterations: checkpoint.completed_iterations,
1470 certificate,
1471 });
1472 }
1473 if additional_updates >= budget {
1474 let extend = match (contraction_rate, projected_iterations_to_tolerance) {
1501 (Some(rate), Some(steps))
1502 if rate < 1.0 && parameter_residual <= 0.5 * residual_at_last_grant =>
1503 {
1504 Some(steps)
1505 }
1506 _ => None,
1507 };
1508 match extend {
1509 Some(steps) => {
1510 let steps = steps.min(config.max_iter);
1521 budget = budget.saturating_add(steps);
1522 extension = Some(steps);
1523 residual_at_last_grant = parameter_residual;
1524 }
1525 None => {
1526 return Err(GaussianMixtureError::DidNotConverge {
1527 max_iterations: budget,
1528 certificate,
1529 checkpoint,
1530 });
1531 }
1532 }
1533 } else if extension.is_some() && additional_updates.is_multiple_of(EM_RATE_WINDOW) {
1534 if !matches!(contraction_rate, Some(rate) if rate < 1.0) {
1538 return Err(GaussianMixtureError::DidNotConverge {
1539 max_iterations: budget,
1540 certificate,
1541 checkpoint,
1542 });
1543 }
1544 }
1545 checkpoint = GaussianMixtureCheckpoint {
1546 weights: next_weights,
1547 means: next_means,
1548 covariances: next_covariances,
1549 mean_log_likelihood: next.mean_log_likelihood,
1550 completed_iterations: checkpoint.completed_iterations + 1,
1551 data_fingerprint,
1552 covariance_floor: config.covariance_floor,
1553 };
1554 additional_updates += 1;
1555 }
1556}
1557
1558struct GaussianMixtureEStep {
1559 responsibilities: Array2<f64>,
1560 row_log_likelihoods: Vec<f64>,
1561 mean_log_likelihood: f64,
1562 mean_log_likelihood_roundoff: f64,
1563}
1564
1565fn gaussian_mixture_monotonicity_uncertainty(
1577 objective_scale: f64,
1578 current_reduction_roundoff: f64,
1579 next_reduction_roundoff: f64,
1580) -> f64 {
1581 let reduction_roundoff = current_reduction_roundoff + next_reduction_roundoff;
1582 let composite_map_resolution = f64::EPSILON.sqrt() * objective_scale;
1583 reduction_roundoff.max(composite_map_resolution)
1584}
1585
1586fn pairwise_sum_max_depth(term_count: usize) -> usize {
1587 if term_count <= 1 {
1588 return 0;
1589 }
1590 let within_block = term_count.min(BASE_CHUNK) - 1;
1591 let blocks = term_count.div_ceil(BASE_CHUNK);
1592 let tree_levels = if blocks <= 1 {
1593 0
1594 } else {
1595 (usize::BITS - (blocks - 1).leading_zeros()) as usize
1596 };
1597 within_block.saturating_add(tree_levels)
1598}
1599
1600fn pairwise_mean_with_roundoff(values: &[f64]) -> Result<(f64, f64), String> {
1601 if values.is_empty() || values.iter().any(|value| !value.is_finite()) {
1602 return Err("mean log-likelihood terms must be nonempty and finite".to_string());
1603 }
1604 let sum = pairwise_sum(values);
1605 let magnitudes: Vec<f64> = values.iter().map(|value| value.abs()).collect();
1606 let magnitude_sum = pairwise_sum(&magnitudes);
1607 let unit_roundoff = 0.5 * f64::EPSILON;
1608 let accumulated = pairwise_sum_max_depth(values.len()) as f64 * unit_roundoff;
1609 let addition_bound = if accumulated < 1.0 {
1610 accumulated / (1.0 - accumulated) * magnitude_sum
1611 } else {
1612 f64::INFINITY
1613 };
1614 let count = values.len() as f64;
1615 let mean = sum / count;
1616 let roundoff = addition_bound / count + unit_roundoff * mean.abs();
1620 if !(mean.is_finite() && roundoff.is_finite()) {
1621 return Err("mean mixture log likelihood or its rounding bound is non-finite".to_string());
1622 }
1623 Ok((mean, roundoff))
1624}
1625
1626fn mixture_e_step(
1627 data: ArrayView2<'_, f64>,
1628 weights: &Array1<f64>,
1629 means: &Array2<f64>,
1630 covariances: &[Array2<f64>],
1631) -> Result<GaussianMixtureEStep, String> {
1632 let n = data.nrows();
1633 let k = weights.len();
1634 if weights
1635 .iter()
1636 .any(|weight| !weight.is_finite() || *weight <= 0.0)
1637 {
1638 return Err("mixture E-step requires strictly positive finite weights".to_string());
1639 }
1640 let mut components = Vec::with_capacity(k);
1641 for component in 0..k {
1642 components.push(GaussianComponentEval::factor(
1643 means.row(component),
1644 &covariances[component],
1645 )?);
1646 }
1647 let log_weights: Vec<f64> = weights.iter().map(|weight| weight.ln()).collect();
1648 let mut responsibilities = Array2::<f64>::zeros((n, k));
1649 let mut row_log_likelihoods = Vec::with_capacity(n);
1650 for row in 0..n {
1651 let observation = data.row(row);
1652 let mut log_terms = vec![f64::NEG_INFINITY; k];
1653 let mut max_term = f64::NEG_INFINITY;
1654 for component in 0..k {
1655 let term = log_weights[component] + components[component].log_density(observation);
1656 log_terms[component] = term;
1657 max_term = max_term.max(term);
1658 }
1659 let log_mixture = log_sum_exp(&log_terms, max_term);
1660 if !log_mixture.is_finite() {
1661 return Err(format!(
1662 "mixture density is non-finite at training row {row}"
1663 ));
1664 }
1665 row_log_likelihoods.push(log_mixture);
1666 for component in 0..k {
1667 responsibilities[[row, component]] = (log_terms[component] - log_mixture).exp();
1668 }
1669 }
1670 let (mean_log_likelihood, mean_log_likelihood_roundoff) =
1671 pairwise_mean_with_roundoff(&row_log_likelihoods)?;
1672 Ok(GaussianMixtureEStep {
1673 responsibilities,
1674 row_log_likelihoods,
1675 mean_log_likelihood,
1676 mean_log_likelihood_roundoff,
1677 })
1678}
1679
1680fn mixture_m_step(
1681 data: ArrayView2<'_, f64>,
1682 responsibilities: ArrayView2<'_, f64>,
1683 covariance_floor: f64,
1684) -> Result<(Array1<f64>, Array2<f64>, Vec<Array2<f64>>), String> {
1685 let n = data.nrows();
1686 let d = data.ncols();
1687 let k = responsibilities.ncols();
1688 let mut component_mass = Array1::<f64>::zeros(k);
1689 for component in 0..k {
1690 component_mass[component] = responsibilities.column(component).sum();
1691 }
1692 if component_mass
1693 .iter()
1694 .any(|mass| !mass.is_finite() || *mass <= 0.0)
1695 {
1696 return Err(
1697 "M-step reached a zero-mass component; the requested mixture order has no interior fitted density"
1698 .to_string(),
1699 );
1700 }
1701 let mut weights = component_mass.mapv(|mass| mass / n as f64);
1702 let total_weight = weights.sum();
1703 if !(total_weight.is_finite() && total_weight > 0.0) {
1704 return Err("M-step produced invalid mixture-weight mass".to_string());
1705 }
1706 weights.mapv_inplace(|weight| weight / total_weight);
1707 let mut means = Array2::<f64>::zeros((k, d));
1708 let mut covariances = Vec::with_capacity(k);
1709 for component in 0..k {
1710 let mass = component_mass[component];
1711 let mut mean = Array1::<f64>::zeros(d);
1712 for row in 0..n {
1713 let responsibility = responsibilities[[row, component]];
1714 for col in 0..d {
1715 mean[col] += responsibility * data[[row, col]];
1716 }
1717 }
1718 mean.mapv_inplace(|value| value / mass);
1719 means.row_mut(component).assign(&mean);
1720 let mut covariance = Array2::<f64>::zeros((d, d));
1721 for row in 0..n {
1722 let responsibility = responsibilities[[row, component]];
1723 for left in 0..d {
1724 let left_residual = data[[row, left]] - mean[left];
1725 for right in 0..d {
1726 covariance[[left, right]] +=
1727 responsibility * left_residual * (data[[row, right]] - mean[right]);
1728 }
1729 }
1730 }
1731 covariance.mapv_inplace(|value| value / mass);
1732 covariances.push(constrain_covariance(covariance, covariance_floor)?);
1733 }
1734 Ok((weights, means, covariances))
1735}
1736
1737fn relative_parameter_step(previous: f64, next: f64) -> f64 {
1738 (next - previous).abs() / previous.abs().max(next.abs()).max(1.0)
1739}
1740
1741fn empirical_predictive_density_residual(
1754 previous_row_log_density: &[f64],
1755 next_row_log_density: &[f64],
1756) -> Result<f64, String> {
1757 if previous_row_log_density.is_empty()
1758 || previous_row_log_density.len() != next_row_log_density.len()
1759 || previous_row_log_density
1760 .iter()
1761 .chain(next_row_log_density)
1762 .any(|value| !value.is_finite())
1763 {
1764 return Err(
1765 "predictive-density residual requires equal, nonempty, finite log-density vectors"
1766 .to_string(),
1767 );
1768 }
1769 Ok(previous_row_log_density
1770 .iter()
1771 .zip(next_row_log_density)
1772 .map(|(&previous, &next)| (next - previous).abs())
1773 .fold(0.0_f64, f64::max))
1774}
1775
1776fn constrain_covariance(covariance: Array2<f64>, floor: f64) -> Result<Array2<f64>, String> {
1777 let (eigenvalues, eigenvectors) = covariance
1778 .eigh(Side::Lower)
1779 .map_err(|error| format!("covariance eigendecomposition failed: {error}"))?;
1780 let d = covariance.nrows();
1781 let mut constrained = Array2::<f64>::zeros((d, d));
1782 for row in 0..d {
1783 for col in 0..d {
1784 let mut value = 0.0_f64;
1785 for index in 0..d {
1786 value += eigenvectors[[row, index]]
1787 * eigenvalues[index].max(floor)
1788 * eigenvectors[[col, index]];
1789 }
1790 constrained[[row, col]] = value;
1791 }
1792 }
1793 if constrained.iter().any(|value| !value.is_finite()) {
1794 return Err("constrained covariance became non-finite".to_string());
1795 }
1796 Ok(constrained)
1797}
1798
1799fn constrained_data_covariance(
1801 data: ArrayView2<'_, f64>,
1802 floor: f64,
1803) -> Result<Array2<f64>, String> {
1804 let n = data.nrows();
1805 let d = data.ncols();
1806 let mut mean = Array1::<f64>::zeros(d);
1807 for i in 0..n {
1808 for c in 0..d {
1809 mean[c] += data[[i, c]];
1810 }
1811 }
1812 mean.mapv_inplace(|v| v / n.max(1) as f64);
1813 let mut cov = Array2::<f64>::zeros((d, d));
1814 for i in 0..n {
1815 for a in 0..d {
1816 let da = data[[i, a]] - mean[a];
1817 for b in 0..d {
1818 cov[[a, b]] += da * (data[[i, b]] - mean[b]);
1819 }
1820 }
1821 }
1822 let inv = 1.0 / n as f64;
1823 cov.mapv_inplace(|v| v * inv);
1824 constrain_covariance(cov, floor)
1825}
1826
1827#[derive(Debug, Clone)]
1845pub struct RingGaussianMixtureFit {
1846 weights: Array1<f64>,
1847 center: Array1<f64>,
1848 radius: f64,
1849 directions: Array2<f64>,
1850 variance: f64,
1851 k: usize,
1852 n_obs: usize,
1853 loglik: f64,
1854 iterations: usize,
1855 certificate: GaussianMixtureCertificate,
1856}
1857
1858impl RingGaussianMixtureFit {
1859 pub fn weights(&self) -> ArrayView1<'_, f64> {
1860 self.weights.view()
1861 }
1862
1863 pub fn center(&self) -> ArrayView1<'_, f64> {
1864 self.center.view()
1865 }
1866
1867 pub fn radius(&self) -> f64 {
1868 self.radius
1869 }
1870
1871 pub fn directions(&self) -> ArrayView2<'_, f64> {
1872 self.directions.view()
1873 }
1874
1875 pub fn variance(&self) -> f64 {
1876 self.variance
1877 }
1878
1879 pub fn iterations(&self) -> usize {
1880 self.iterations
1881 }
1882
1883 pub fn certificate(&self) -> GaussianMixtureCertificate {
1884 self.certificate
1885 }
1886
1887 pub fn num_free_parameters(&self) -> usize {
1890 2 * self.k + 3
1891 }
1892
1893 pub fn per_point_log_density(&self, data: ArrayView2<'_, f64>) -> Result<Array1<f64>, String> {
1894 if data.ncols() != 2 {
1895 return Err(format!(
1896 "ring-of-clusters density expects two columns, got {}",
1897 data.ncols()
1898 ));
1899 }
1900 ring_mixture_log_density(
1901 data,
1902 &self.weights,
1903 &self.center,
1904 self.radius,
1905 &self.directions,
1906 self.variance,
1907 )
1908 }
1909
1910 pub fn bic(&self) -> f64 {
1913 -self.loglik + 0.5 * self.num_free_parameters() as f64 * (self.n_obs as f64).ln()
1914 }
1915}
1916
1917#[derive(Debug, Clone)]
1918struct RingMixtureState {
1919 weights: Array1<f64>,
1920 center: Array1<f64>,
1921 radius: f64,
1922 directions: Array2<f64>,
1923 variance: f64,
1924 mean_log_likelihood: f64,
1925 completed_iterations: usize,
1926}
1927
1928fn ring_component_means(
1929 center: &Array1<f64>,
1930 radius: f64,
1931 directions: &Array2<f64>,
1932) -> Array2<f64> {
1933 let mut means = Array2::<f64>::zeros((directions.nrows(), 2));
1934 for component in 0..directions.nrows() {
1935 means[[component, 0]] = center[0] + radius * directions[[component, 0]];
1936 means[[component, 1]] = center[1] + radius * directions[[component, 1]];
1937 }
1938 means
1939}
1940
1941fn ring_mixture_log_terms(
1942 data: ArrayView2<'_, f64>,
1943 weights: &Array1<f64>,
1944 center: &Array1<f64>,
1945 radius: f64,
1946 directions: &Array2<f64>,
1947 variance: f64,
1948) -> Result<(Array2<f64>, Vec<f64>), String> {
1949 if data.ncols() != 2
1950 || center.len() != 2
1951 || directions.ncols() != 2
1952 || directions.nrows() != weights.len()
1953 || weights
1954 .iter()
1955 .any(|weight| !weight.is_finite() || *weight <= 0.0)
1956 || !(radius.is_finite() && radius > 0.0)
1957 || !(variance.is_finite() && variance > 0.0)
1958 {
1959 return Err("invalid ring-of-clusters parameter state".to_string());
1960 }
1961 let means = ring_component_means(center, radius, directions);
1962 let log_normalizer = -(std::f64::consts::TAU).ln() - variance.ln();
1963 let mut terms = Array2::<f64>::zeros((data.nrows(), weights.len()));
1964 let mut row_log_likelihoods = Vec::with_capacity(data.nrows());
1965 for row in 0..data.nrows() {
1966 let mut max_term = f64::NEG_INFINITY;
1967 for component in 0..weights.len() {
1968 let dx = data[[row, 0]] - means[[component, 0]];
1969 let dy = data[[row, 1]] - means[[component, 1]];
1970 let term =
1971 weights[component].ln() + log_normalizer - 0.5 * (dx * dx + dy * dy) / variance;
1972 terms[[row, component]] = term;
1973 max_term = max_term.max(term);
1974 }
1975 let values = terms.row(row).to_vec();
1976 let log_likelihood = log_sum_exp(&values, max_term);
1977 if !log_likelihood.is_finite() {
1978 return Err(format!(
1979 "ring-of-clusters density is non-finite at training row {row}"
1980 ));
1981 }
1982 row_log_likelihoods.push(log_likelihood);
1983 }
1984 Ok((terms, row_log_likelihoods))
1985}
1986
1987fn ring_mixture_e_step(
1988 data: ArrayView2<'_, f64>,
1989 state: &RingMixtureState,
1990) -> Result<GaussianMixtureEStep, String> {
1991 let (terms, row_log_likelihoods) = ring_mixture_log_terms(
1992 data,
1993 &state.weights,
1994 &state.center,
1995 state.radius,
1996 &state.directions,
1997 state.variance,
1998 )?;
1999 let mut responsibilities = Array2::<f64>::zeros(terms.raw_dim());
2000 for row in 0..terms.nrows() {
2001 for component in 0..terms.ncols() {
2002 responsibilities[[row, component]] =
2003 (terms[[row, component]] - row_log_likelihoods[row]).exp();
2004 }
2005 }
2006 let (mean_log_likelihood, mean_log_likelihood_roundoff) =
2007 pairwise_mean_with_roundoff(&row_log_likelihoods)?;
2008 Ok(GaussianMixtureEStep {
2009 responsibilities,
2010 row_log_likelihoods,
2011 mean_log_likelihood,
2012 mean_log_likelihood_roundoff,
2013 })
2014}
2015
2016fn ring_mixture_log_density(
2017 data: ArrayView2<'_, f64>,
2018 weights: &Array1<f64>,
2019 center: &Array1<f64>,
2020 radius: f64,
2021 directions: &Array2<f64>,
2022 variance: f64,
2023) -> Result<Array1<f64>, String> {
2024 let (_, row_log_likelihoods) =
2025 ring_mixture_log_terms(data, weights, center, radius, directions, variance)?;
2026 Ok(Array1::from_vec(row_log_likelihoods))
2027}
2028
2029fn fit_weighted_component_circle(
2030 component_means: &Array2<f64>,
2031 component_mass: &Array1<f64>,
2032 initial_center: &Array1<f64>,
2033 initial_radius: f64,
2034 parameter_tol: f64,
2035 max_iter: usize,
2036) -> Result<(Array1<f64>, f64, Array2<f64>), String> {
2037 let k = component_means.nrows();
2038 let total_mass = component_mass.sum();
2039 if component_means.ncols() != 2
2040 || component_mass.len() != k
2041 || component_mass
2042 .iter()
2043 .any(|mass| !mass.is_finite() || *mass <= 0.0)
2044 || !(total_mass.is_finite() && total_mass > 0.0)
2045 {
2046 return Err("ring M-step requires positive component masses and 2-D means".to_string());
2047 }
2048 let mut center = initial_center.clone();
2049 let mut radius = initial_radius;
2050 let mut directions = Array2::<f64>::zeros((k, 2));
2051 for _ in 0..max_iter {
2052 for component in 0..k {
2053 let dx = component_means[[component, 0]] - center[0];
2054 let dy = component_means[[component, 1]] - center[1];
2055 let norm = dx.hypot(dy);
2056 if !(norm.is_finite() && norm > 0.0) {
2057 return Err(
2058 "ring M-step reached a component centroid at the circle center; its angle is unidentified"
2059 .to_string(),
2060 );
2061 }
2062 directions[[component, 0]] = dx / norm;
2063 directions[[component, 1]] = dy / norm;
2064 }
2065
2066 let mut mean_point = Array1::<f64>::zeros(2);
2067 let mut mean_direction = Array1::<f64>::zeros(2);
2068 for component in 0..k {
2069 let weight = component_mass[component] / total_mass;
2070 for axis in 0..2 {
2071 mean_point[axis] += weight * component_means[[component, axis]];
2072 mean_direction[axis] += weight * directions[[component, axis]];
2073 }
2074 }
2075 let mut numerator = 0.0;
2076 let mut denominator = 0.0;
2077 for component in 0..k {
2078 let mass = component_mass[component];
2079 let dux = directions[[component, 0]] - mean_direction[0];
2080 let duy = directions[[component, 1]] - mean_direction[1];
2081 numerator += mass
2082 * (dux * (component_means[[component, 0]] - mean_point[0])
2083 + duy * (component_means[[component, 1]] - mean_point[1]));
2084 denominator += mass * (dux * dux + duy * duy);
2085 }
2086 if !(denominator.is_finite() && denominator > 0.0) {
2087 return Err(
2088 "ring M-step component directions are identical; radius and center are unidentified"
2089 .to_string(),
2090 );
2091 }
2092 let mut next_radius = numerator / denominator;
2093 if !next_radius.is_finite() || next_radius == 0.0 {
2094 return Err("ring M-step produced an unidentified zero radius".to_string());
2095 }
2096 if next_radius < 0.0 {
2097 next_radius = -next_radius;
2098 directions.mapv_inplace(|value| -value);
2099 }
2100 let next_center = Array1::from_vec(vec![
2101 mean_point[0] - next_radius * mean_direction[0],
2102 mean_point[1] - next_radius * mean_direction[1],
2103 ]);
2104 let residual = center
2105 .iter()
2106 .zip(next_center.iter())
2107 .map(|(&left, &right)| relative_parameter_step(left, right))
2108 .chain(std::iter::once(relative_parameter_step(
2109 radius,
2110 next_radius,
2111 )))
2112 .fold(0.0, f64::max);
2113 center = next_center;
2114 radius = next_radius;
2115 if residual <= parameter_tol {
2116 for component in 0..k {
2119 let dx = component_means[[component, 0]] - center[0];
2120 let dy = component_means[[component, 1]] - center[1];
2121 let norm = dx.hypot(dy);
2122 if !(norm.is_finite() && norm > 0.0) {
2123 return Err("ring M-step terminal component angle is unidentified".to_string());
2124 }
2125 directions[[component, 0]] = dx / norm;
2126 directions[[component, 1]] = dy / norm;
2127 }
2128 return Ok((center, radius, directions));
2129 }
2130 }
2131 Err(format!(
2132 "ring M-step did not certify its constrained center/radius fixed point after {max_iter} iterations"
2133 ))
2134}
2135
2136fn ring_mixture_m_step(
2137 data: ArrayView2<'_, f64>,
2138 responsibilities: ArrayView2<'_, f64>,
2139 previous: &RingMixtureState,
2140 config: GaussianMixtureConfig,
2141) -> Result<RingMixtureState, String> {
2142 let n = data.nrows();
2143 let k = responsibilities.ncols();
2144 let mut component_mass = Array1::<f64>::zeros(k);
2145 let mut component_means = Array2::<f64>::zeros((k, 2));
2146 for component in 0..k {
2147 let mass = responsibilities.column(component).sum();
2148 if !(mass.is_finite() && mass > 0.0) {
2149 return Err(
2150 "ring M-step reached a zero-mass component; the requested order is singular"
2151 .to_string(),
2152 );
2153 }
2154 component_mass[component] = mass;
2155 for row in 0..n {
2156 for axis in 0..2 {
2157 component_means[[component, axis]] +=
2158 responsibilities[[row, component]] * data[[row, axis]];
2159 }
2160 }
2161 for axis in 0..2 {
2162 component_means[[component, axis]] /= mass;
2163 }
2164 }
2165 let mut weights = component_mass.mapv(|mass| mass / n as f64);
2166 let weight_sum = weights.sum();
2167 weights.mapv_inplace(|weight| weight / weight_sum);
2168 let (center, radius, directions) = fit_weighted_component_circle(
2169 &component_means,
2170 &component_mass,
2171 &previous.center,
2172 previous.radius,
2173 config.parameter_tol,
2174 config.max_iter,
2175 )?;
2176 let means = ring_component_means(¢er, radius, &directions);
2177 let mut expected_squared_error = 0.0;
2178 for row in 0..n {
2179 for component in 0..k {
2180 let dx = data[[row, 0]] - means[[component, 0]];
2181 let dy = data[[row, 1]] - means[[component, 1]];
2182 expected_squared_error += responsibilities[[row, component]] * (dx * dx + dy * dy);
2183 }
2184 }
2185 let variance = (expected_squared_error / (2 * n) as f64).max(config.covariance_floor);
2186 if !variance.is_finite() {
2187 return Err("ring M-step produced non-finite shared variance".to_string());
2188 }
2189 Ok(RingMixtureState {
2190 weights,
2191 center,
2192 radius,
2193 directions,
2194 variance,
2195 mean_log_likelihood: f64::NAN,
2196 completed_iterations: previous.completed_iterations + 1,
2197 })
2198}
2199
2200pub fn fit_ring_gaussian_mixture(
2203 data: ArrayView2<'_, f64>,
2204 k: usize,
2205 config: GaussianMixtureConfig,
2206) -> Result<RingGaussianMixtureFit, String> {
2207 validate_gaussian_mixture_problem(data, k, config).map_err(|error| error.to_string())?;
2208 if data.ncols() != 2 {
2209 return Err(format!(
2210 "ring-of-clusters fitting requires exactly two columns, got {}",
2211 data.ncols()
2212 ));
2213 }
2214 if k < 3 {
2215 return Err(format!(
2216 "ring-of-clusters fitting requires at least three component centers, got {k}"
2217 ));
2218 }
2219 let seeded_means = gam_terms::basis::select_centers_by_strategy(
2220 data,
2221 &gam_terms::basis::CenterStrategy::KMeans {
2222 num_centers: k,
2223 max_iter: config.kmeans_max_iter,
2224 },
2225 )
2226 .map_err(|error| format!("ring-of-clusters deterministic seeding failed: {error}"))?;
2227 let component_mass = Array1::<f64>::ones(k);
2228 let mut initial_center = Array1::<f64>::zeros(2);
2229 for component in 0..k {
2230 initial_center[0] += seeded_means[[component, 0]] / k as f64;
2231 initial_center[1] += seeded_means[[component, 1]] / k as f64;
2232 }
2233 let mut initial_radius = 0.0;
2234 for component in 0..k {
2235 initial_radius += (seeded_means[[component, 0]] - initial_center[0])
2236 .hypot(seeded_means[[component, 1]] - initial_center[1])
2237 / k as f64;
2238 }
2239 if !(initial_radius.is_finite() && initial_radius > 0.0) {
2240 return Err("ring-of-clusters seed has an unidentified zero radius".to_string());
2241 }
2242 let (center, radius, directions) = fit_weighted_component_circle(
2243 &seeded_means,
2244 &component_mass,
2245 &initial_center,
2246 initial_radius,
2247 config.parameter_tol,
2248 config.max_iter,
2249 )?;
2250 let means = ring_component_means(¢er, radius, &directions);
2251 let mut squared_error = 0.0;
2252 for row in 0..data.nrows() {
2253 let mut nearest = f64::INFINITY;
2254 for component in 0..k {
2255 let dx = data[[row, 0]] - means[[component, 0]];
2256 let dy = data[[row, 1]] - means[[component, 1]];
2257 nearest = nearest.min(dx * dx + dy * dy);
2258 }
2259 squared_error += nearest;
2260 }
2261 let variance = (squared_error / (2 * data.nrows()) as f64).max(config.covariance_floor);
2262 let mut state = RingMixtureState {
2263 weights: Array1::from_elem(k, 1.0 / k as f64),
2264 center,
2265 radius,
2266 directions,
2267 variance,
2268 mean_log_likelihood: f64::NAN,
2269 completed_iterations: 0,
2270 };
2271 for additional_updates in 0..=config.max_iter {
2272 let current = ring_mixture_e_step(data, &state)?;
2273 state.mean_log_likelihood = current.mean_log_likelihood;
2274 let mut next =
2275 ring_mixture_m_step(data, current.responsibilities.view(), &state, config)?;
2276 let next_e_step = ring_mixture_e_step(data, &next)?;
2277 next.mean_log_likelihood = next_e_step.mean_log_likelihood;
2278 let current_mean = current.mean_log_likelihood;
2279 let next_mean = next_e_step.mean_log_likelihood;
2280 let objective_scale = current_mean.abs().max(next_mean.abs()).max(1.0);
2281 let objective_step = next_mean - current_mean;
2282 let objective_residual = objective_step.abs() / objective_scale;
2283 let parameter_residual = empirical_predictive_density_residual(
2284 ¤t.row_log_likelihoods,
2285 &next_e_step.row_log_likelihoods,
2286 )?;
2287 let monotonicity_uncertainty = gaussian_mixture_monotonicity_uncertainty(
2288 objective_scale,
2289 current.mean_log_likelihood_roundoff,
2290 next_e_step.mean_log_likelihood_roundoff,
2291 );
2292 let certificate = GaussianMixtureCertificate {
2293 mean_log_likelihood: current_mean,
2294 mean_log_likelihood_gain: objective_step,
2295 monotonicity_uncertainty,
2296 objective_residual,
2297 objective_tolerance: config.loglik_tol,
2298 parameter_residual,
2299 parameter_tolerance: config.parameter_tol,
2300 contraction_rate: None,
2305 projected_iterations_to_tolerance: None,
2306 };
2307 if objective_step < -monotonicity_uncertainty {
2308 return Err(format!(
2309 "ring-of-clusters generalized EM violated monotone ascent at iteration {}: {current_mean:.12e} -> {next_mean:.12e} (comparison uncertainty {monotonicity_uncertainty:.3e})",
2310 state.completed_iterations
2311 ));
2312 }
2313 if objective_residual <= config.loglik_tol && parameter_residual <= config.parameter_tol {
2314 let loglik = current_mean * data.nrows() as f64;
2315 if !loglik.is_finite() {
2316 return Err("ring-of-clusters total log likelihood overflowed".to_string());
2317 }
2318 return Ok(RingGaussianMixtureFit {
2319 weights: state.weights,
2320 center: state.center,
2321 radius: state.radius,
2322 directions: state.directions,
2323 variance: state.variance,
2324 k,
2325 n_obs: data.nrows(),
2326 loglik,
2327 iterations: state.completed_iterations,
2328 certificate,
2329 });
2330 }
2331 if additional_updates == config.max_iter {
2332 return Err(format!(
2333 "ring-of-clusters generalized EM did not certify after {} iterations: objective residual {:.6e}/{:.3e}, parameter-map residual {:.6e}/{:.3e}",
2334 config.max_iter,
2335 objective_residual,
2336 config.loglik_tol,
2337 parameter_residual,
2338 config.parameter_tol,
2339 ));
2340 }
2341 state = next;
2342 }
2343 Err("ring-of-clusters generalized EM exhausted without a terminal certificate".to_string())
2344}
2345
2346#[derive(Debug, Clone, Copy)]
2367pub struct CircularGaussianFit2d {
2368 center: [f64; 2],
2369 radius: f64,
2370 noise_variance: f64,
2371}
2372
2373impl CircularGaussianFit2d {
2374 pub const NUM_FREE_PARAMETERS: usize = 4;
2376
2377 pub fn from_parameters(
2379 center: [f64; 2],
2380 radius: f64,
2381 noise_variance: f64,
2382 ) -> Result<Self, String> {
2383 if !center.iter().all(|value| value.is_finite()) {
2384 return Err("circular Gaussian center must be finite".to_string());
2385 }
2386 if !(radius.is_finite() && radius >= 0.0) {
2387 return Err("circular Gaussian radius must be finite and nonnegative".to_string());
2388 }
2389 if !(noise_variance.is_finite() && noise_variance > 0.0) {
2390 return Err("circular Gaussian noise variance must be finite and positive".to_string());
2391 }
2392 Ok(Self {
2393 center,
2394 radius,
2395 noise_variance,
2396 })
2397 }
2398
2399 pub fn fit(coords: ArrayView2<'_, f64>, rows: &[usize]) -> Result<Self, String> {
2401 if coords.ncols() != 2 {
2402 return Err(format!(
2403 "circular Gaussian requires 2-D data, got {} columns",
2404 coords.ncols()
2405 ));
2406 }
2407 if rows.is_empty() {
2408 return Err("circular Gaussian requires a nonempty training set".to_string());
2409 }
2410 if rows.iter().any(|&row| row >= coords.nrows()) {
2411 return Err("circular Gaussian row index is out of bounds".to_string());
2412 }
2413 if rows
2414 .iter()
2415 .any(|&row| !coords[[row, 0]].is_finite() || !coords[[row, 1]].is_finite())
2416 {
2417 return Err("circular Gaussian requires finite training coordinates".to_string());
2418 }
2419
2420 let anchor_row = rows[0];
2424 let anchor = [coords[[anchor_row, 0]], coords[[anchor_row, 1]]];
2425 let mut scale = 0.0_f64;
2426 for &row in rows {
2427 let dx = coords[[row, 0]] - anchor[0];
2428 let dy = coords[[row, 1]] - anchor[1];
2429 if !(dx.is_finite() && dy.is_finite()) {
2430 return Err("circular Gaussian coordinate range exceeds f64".to_string());
2431 }
2432 scale = scale.max(dx.hypot(dy));
2433 }
2434 if !(scale.is_finite() && scale > 0.0) {
2435 return Err("circular Gaussian requires nonzero spatial extent".to_string());
2436 }
2437
2438 let mut points = Vec::with_capacity(rows.len());
2439 let mut mean = [0.0_f64; 2];
2440 for &row in rows {
2441 let point = [
2442 (coords[[row, 0]] - anchor[0]) / scale,
2443 (coords[[row, 1]] - anchor[1]) / scale,
2444 ];
2445 points.push(point);
2446 mean[0] += point[0];
2447 mean[1] += point[1];
2448 }
2449 let count = rows.len() as f64;
2450 mean[0] /= count;
2451 mean[1] /= count;
2452
2453 let mut squared_radii = Vec::with_capacity(rows.len());
2458 let mut mean_squared_radius = 0.0_f64;
2459 for point in &points {
2460 let dx = point[0] - mean[0];
2461 let dy = point[1] - mean[1];
2462 let squared_radius = dx * dx + dy * dy;
2463 squared_radii.push(squared_radius);
2464 mean_squared_radius += squared_radius;
2465 }
2466 mean_squared_radius /= count;
2467 let mut squared_radius_variance = 0.0_f64;
2468 for squared_radius in squared_radii {
2469 squared_radius_variance += (squared_radius - mean_squared_radius).powi(2);
2470 }
2471 squared_radius_variance /= count;
2472
2473 let variance_floor = (64.0 * f64::EPSILON * mean_squared_radius).max(f64::MIN_POSITIVE);
2477 let radius_squared = (mean_squared_radius * mean_squared_radius - squared_radius_variance)
2478 .max(0.0)
2479 .sqrt();
2480 let mut radius = radius_squared.sqrt();
2481 let mut noise_variance = (0.5 * (mean_squared_radius - radius_squared)).max(variance_floor);
2482 let mut center = mean;
2483
2484 const MAX_EM_ITERATIONS: usize = 4096;
2489 const EM_TOLERANCE: f64 = 2.0e-12;
2490 let mut posterior_means = vec![[0.0_f64; 2]; points.len()];
2491 let mut converged = false;
2492 for _ in 0..MAX_EM_ITERATIONS {
2493 let mut posterior_mean = [0.0_f64; 2];
2494 for (point, latent_mean) in points.iter().zip(&mut posterior_means) {
2495 let dx = point[0] - center[0];
2496 let dy = point[1] - center[1];
2497 let observed_radius = dx.hypot(dy);
2498 if observed_radius == 0.0 || radius == 0.0 {
2499 *latent_mean = [0.0, 0.0];
2500 } else {
2501 let (_, bessel_ratio) =
2502 circular_gaussian_bessel_terms(radius, observed_radius, noise_variance);
2503 if !(bessel_ratio.is_finite() && (0.0..=1.0).contains(&bessel_ratio)) {
2504 return Err("circular Gaussian Bessel ratio left [0, 1]".to_string());
2505 }
2506 let multiplier = bessel_ratio / observed_radius;
2507 *latent_mean = [multiplier * dx, multiplier * dy];
2508 }
2509 posterior_mean[0] += latent_mean[0];
2510 posterior_mean[1] += latent_mean[1];
2511 }
2512 posterior_mean[0] /= count;
2513 posterior_mean[1] /= count;
2514
2515 let denominator =
2516 1.0 - posterior_mean[0] * posterior_mean[0] - posterior_mean[1] * posterior_mean[1];
2517 if !(denominator.is_finite() && denominator > 0.0) {
2518 return Err("circular Gaussian EM radius update is singular".to_string());
2519 }
2520 let mut radius_numerator = 0.0_f64;
2521 for (point, latent_mean) in points.iter().zip(&posterior_means) {
2522 radius_numerator +=
2523 latent_mean[0] * (point[0] - mean[0]) + latent_mean[1] * (point[1] - mean[1]);
2524 }
2525 let next_radius = (radius_numerator / (count * denominator)).max(0.0);
2526 let next_center = [
2527 mean[0] - next_radius * posterior_mean[0],
2528 mean[1] - next_radius * posterior_mean[1],
2529 ];
2530
2531 let mut residual_sum = 0.0_f64;
2534 for (point, latent_mean) in points.iter().zip(&posterior_means) {
2535 let dx = point[0] - next_center[0];
2536 let dy = point[1] - next_center[1];
2537 let ex = dx - next_radius * latent_mean[0];
2538 let ey = dy - next_radius * latent_mean[1];
2539 let latent_norm_squared =
2540 latent_mean[0] * latent_mean[0] + latent_mean[1] * latent_mean[1];
2541 residual_sum += ex * ex
2542 + ey * ey
2543 + next_radius * next_radius * (1.0 - latent_norm_squared).max(0.0);
2544 }
2545 let next_noise_variance = (residual_sum / (2.0 * count)).max(variance_floor);
2546
2547 let parameter_change = (next_center[0] - center[0])
2548 .hypot(next_center[1] - center[1])
2549 .max((next_radius - radius).abs())
2550 .max(
2551 (next_noise_variance - noise_variance).abs()
2552 / (next_noise_variance + noise_variance),
2553 );
2554 center = next_center;
2555 radius = next_radius;
2556 noise_variance = next_noise_variance;
2557 if parameter_change <= EM_TOLERANCE {
2558 converged = true;
2559 break;
2560 }
2561 }
2562 if !converged {
2563 return Err("circular Gaussian maximum-likelihood fit did not converge".to_string());
2564 }
2565
2566 let fitted_noise_sd = scale * noise_variance.sqrt();
2567 Self::from_parameters(
2568 [anchor[0] + scale * center[0], anchor[1] + scale * center[1]],
2569 scale * radius,
2570 fitted_noise_sd * fitted_noise_sd,
2571 )
2572 .map_err(|error| format!("circular Gaussian fit produced invalid parameters: {error}"))
2573 }
2574
2575 pub const fn center(self) -> [f64; 2] {
2577 self.center
2578 }
2579
2580 pub const fn radius(self) -> f64 {
2582 self.radius
2583 }
2584
2585 pub const fn noise_variance(self) -> f64 {
2587 self.noise_variance
2588 }
2589
2590 pub fn log_density(self, x: f64, y: f64) -> f64 {
2592 let observed_radius = (x - self.center[0]).hypot(y - self.center[1]);
2593 let (log_i0_minus_kappa, _) =
2594 circular_gaussian_bessel_terms(self.radius, observed_radius, self.noise_variance);
2595 let standardized_radial_residual =
2596 (observed_radius - self.radius) / self.noise_variance.sqrt();
2597 -std::f64::consts::TAU.ln()
2600 - self.noise_variance.ln()
2601 - 0.5 * standardized_radial_residual.powi(2)
2602 + log_i0_minus_kappa
2603 }
2604
2605 pub fn log_likelihood(
2607 self,
2608 coords: ArrayView2<'_, f64>,
2609 rows: &[usize],
2610 ) -> Result<f64, String> {
2611 if coords.ncols() != 2 || rows.iter().any(|&row| row >= coords.nrows()) {
2612 return Err(
2613 "circular Gaussian likelihood received invalid coordinates or rows".to_string(),
2614 );
2615 }
2616 let mut log_densities = Vec::with_capacity(rows.len());
2617 for &row in rows {
2618 let value = self.log_density(coords[[row, 0]], coords[[row, 1]]);
2619 if !value.is_finite() {
2620 return Err("circular Gaussian likelihood is not finite".to_string());
2621 }
2622 log_densities.push(value);
2623 }
2624 let log_likelihood = pairwise_sum(&log_densities);
2625 if !log_likelihood.is_finite() {
2626 return Err("circular Gaussian likelihood sum is not finite".to_string());
2627 }
2628 Ok(log_likelihood)
2629 }
2630
2631 pub fn fit_with_bic(
2635 coords: ArrayView2<'_, f64>,
2636 rows: &[usize],
2637 ) -> Result<(Self, f64), String> {
2638 let fit = Self::fit(coords, rows)?;
2639 let log_likelihood = fit.log_likelihood(coords, rows)?;
2640 let bic =
2641 -log_likelihood + 0.5 * Self::NUM_FREE_PARAMETERS as f64 * (rows.len() as f64).ln();
2642 if !bic.is_finite() {
2643 return Err("circular Gaussian BIC is not finite".to_string());
2644 }
2645 Ok((fit, bic))
2646 }
2647}
2648
2649fn circular_gaussian_bessel_terms(
2655 radius: f64,
2656 observed_radius: f64,
2657 noise_variance: f64,
2658) -> (f64, f64) {
2659 if radius == 0.0 || observed_radius == 0.0 {
2660 return (0.0, 0.0);
2661 }
2662 let kappa = radius * observed_radius / noise_variance;
2663 if kappa.is_finite() {
2664 return bessel_i0_log_minus_abs_and_ratio(kappa);
2665 }
2666 let log_kappa = radius.ln() + observed_radius.ln() - noise_variance.ln();
2667 if log_kappa <= f64::MAX.ln() {
2668 return bessel_i0_log_minus_abs_and_ratio(log_kappa.exp());
2671 }
2672 (-0.5 * (std::f64::consts::TAU.ln() + log_kappa), 1.0)
2673}
2674#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2695pub enum UnionStructure {
2696 CircleCircle,
2698 CirclePointCluster,
2700 LineCluster,
2702}
2703
2704pub const UNION_STRUCTURE_LADDER: &[UnionStructure] = &[
2706 UnionStructure::CircleCircle,
2707 UnionStructure::CirclePointCluster,
2708 UnionStructure::LineCluster,
2709];
2710
2711#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2719pub enum UnionComponentKind {
2720 Circle,
2721 Line,
2722 PointCluster,
2723}
2724
2725impl UnionStructure {
2726 pub const fn as_str(self) -> &'static str {
2728 match self {
2729 UnionStructure::CircleCircle => "union_circle+circle",
2730 UnionStructure::CirclePointCluster => "union_circle+cluster",
2731 UnionStructure::LineCluster => "union_line+cluster",
2732 }
2733 }
2734
2735 pub const fn components(self) -> &'static [UnionComponentKind] {
2737 match self {
2738 UnionStructure::CircleCircle => {
2739 &[UnionComponentKind::Circle, UnionComponentKind::Circle]
2740 }
2741 UnionStructure::CirclePointCluster => {
2742 &[UnionComponentKind::Circle, UnionComponentKind::PointCluster]
2743 }
2744 UnionStructure::LineCluster => {
2745 &[UnionComponentKind::Line, UnionComponentKind::PointCluster]
2746 }
2747 }
2748 }
2749
2750}
2751
2752#[derive(Debug, Clone)]
2758pub struct UnionComponentFit {
2759 pub kind: UnionComponentKind,
2760 pub row_count: usize,
2761 pub num_parameters: usize,
2762 pub mixing_weight: f64,
2763}
2764
2765#[derive(Debug, Clone)]
2769pub struct UnionStructureFit {
2770 pub structure: UnionStructure,
2771 pub components: Vec<UnionComponentFit>,
2772 pub log_likelihood: f64,
2774 pub bic: f64,
2776 pub total_parameters: usize,
2778}
2779
2780#[derive(Clone, Debug)]
2782pub struct RemlCandidate {
2783 pub index: usize,
2784 pub name: String,
2785 pub score: f64,
2788 pub edf: f64,
2791 pub log_lik: f64,
2794 pub family: Option<String>,
2800 pub n_obs: Option<usize>,
2808}
2809
2810impl RemlCandidate {
2811 pub fn ranking_score(&self) -> Result<f64, String> {
2829 if !self.score.is_finite() {
2830 return Err(format!(
2831 "compare_models: candidate '{}' has non-finite raw REML/LAML score {}",
2832 self.name, self.score
2833 ));
2834 }
2835 if !(self.edf.is_finite() && self.edf >= 0.0) {
2836 return Err(format!(
2837 "compare_models: candidate '{}' requires finite non-negative edf_total, got {}",
2838 self.name, self.edf
2839 ));
2840 }
2841 if !self.log_lik.is_finite() {
2842 return Err(format!(
2843 "compare_models: candidate '{}' requires finite log_likelihood; \
2844 raw REML/LAML is not a substitute ranking estimand",
2845 self.name
2846 ));
2847 }
2848 let score = -2.0 * self.log_lik + 2.0 * self.edf;
2849 if !score.is_finite() {
2850 return Err(format!(
2851 "compare_models: candidate '{}' conditional AIC is outside f64 range",
2852 self.name
2853 ));
2854 }
2855 Ok(score)
2856 }
2857}
2858
2859#[derive(Clone, Debug)]
2860pub struct RemlComparison {
2861 pub ranking: Vec<RankedRow>,
2862 pub winner: String,
2863 pub evidence_summary: String,
2864 pub score_table: Vec<ScoreRow>,
2865}
2866
2867#[derive(Clone, Debug)]
2868pub struct RankedRow {
2869 pub name: String,
2870 pub score: f64,
2871 pub delta: f64,
2878 pub evidence_ratio: f64,
2890 pub edf: f64,
2891}
2892
2893#[derive(Clone, Debug)]
2894pub struct ScoreRow {
2895 pub name: String,
2896 pub reml_score: f64,
2897 pub delta_reml: f64,
2898 pub bayes_factor_best_over_model: f64,
2899 pub effective_dof: f64,
2900}
2901
2902#[inline]
2904pub fn log_bayes_factor(reml_score_a: f64, reml_score_b: f64) -> f64 {
2905 reml_score_b - reml_score_a
2906}
2907
2908pub fn compare_reml_fits(mut candidates: Vec<RemlCandidate>) -> Result<RemlComparison, String> {
2912 if candidates.is_empty() {
2913 return Err("compare_models requires at least one fit".to_string());
2914 }
2915 {
2923 let mut seen_family: Option<&str> = None;
2924 for cand in &candidates {
2925 if let Some(fam) = cand.family.as_deref() {
2926 match seen_family {
2927 None => seen_family = Some(fam),
2928 Some(prev) if prev != fam => {
2929 return Err(format!(
2930 "compare_models: cannot compare fits of different response families ('{prev}' vs '{fam}'); their REML/LAML evidence scores are on incomparable base measures. Compare models fit to the same response under the same family."
2931 ));
2932 }
2933 Some(_) => {}
2934 }
2935 }
2936 }
2937 }
2938 {
2949 let mut seen_n: Option<usize> = None;
2950 for cand in &candidates {
2951 if let Some(n) = cand.n_obs {
2952 match seen_n {
2953 None => seen_n = Some(n),
2954 Some(prev) if prev != n => {
2955 return Err(format!(
2956 "compare_models: cannot compare fits made on a different number of \
2957 observations (n={prev} vs n={n}); AIC / REML-LAML evidence scales \
2958 with the sample size, so their score difference is not a Bayes \
2959 factor. Compare models fit to the same response on the same data."
2960 ));
2961 }
2962 Some(_) => {}
2963 }
2964 }
2965 }
2966 }
2967 let priority_candidates = candidates
2968 .into_iter()
2969 .enumerate()
2970 .map(|(idx, row)| {
2971 let ranking = row.ranking_score()?;
2972 Ok(PriorityCandidate::new(row, idx, ranking, 0))
2973 })
2974 .collect::<Result<Vec<_>, String>>()?;
2975 candidates = rank_priority_candidates(priority_candidates)
2976 .into_iter()
2977 .map(|row| row.item)
2978 .collect();
2979
2980 let winner = candidates[0].name.clone();
2981 let best_ranking_score = candidates[0].ranking_score()?;
2991 let best_raw_score = candidates
2995 .iter()
2996 .map(|c| c.score)
2997 .fold(f64::INFINITY, f64::min);
2998 let mut ranking = Vec::with_capacity(candidates.len());
2999 let mut score_table = Vec::with_capacity(candidates.len());
3000 for row in &candidates {
3001 let delta = log_bayes_factor(best_ranking_score, row.ranking_score()?);
3002 let evidence_ratio = (0.5 * delta).exp();
3009 let delta_reml = log_bayes_factor(best_raw_score, row.score);
3010 ranking.push(RankedRow {
3011 name: row.name.clone(),
3012 score: row.score,
3013 delta,
3014 evidence_ratio,
3015 edf: row.edf,
3016 });
3017 score_table.push(ScoreRow {
3018 name: row.name.clone(),
3019 reml_score: row.score,
3020 delta_reml,
3021 bayes_factor_best_over_model: delta_reml.exp(),
3022 effective_dof: row.edf,
3023 });
3024 }
3025 let evidence_summary = if let Some(runner_up) = candidates.get(1) {
3030 let margin = runner_up.ranking_score()? - candidates[0].ranking_score()?;
3031 format!(
3036 "{} wins by evidence ratio {} over {}",
3037 winner,
3038 format_bayes_factor(0.5 * margin),
3039 runner_up.name
3040 )
3041 } else {
3042 format!("{winner} (single fit; no comparison)")
3043 };
3044 Ok(RemlComparison {
3045 ranking,
3046 winner,
3047 evidence_summary,
3048 score_table,
3049 })
3050}
3051
3052pub fn format_bayes_factor(log_bf: f64) -> String {
3053 if !log_bf.is_finite() {
3054 return "inf".to_string();
3055 }
3056 if log_bf.abs() >= std::f64::consts::LN_10 * 3.0 {
3057 return format!("1e{:+.1}", log_bf / std::f64::consts::LN_10);
3058 }
3059 format_three_significant(log_bf.exp())
3060}
3061
3062pub fn format_three_significant(value: f64) -> String {
3063 if value == 0.0 {
3064 return "0".to_string();
3065 }
3066 if !value.is_finite() {
3067 return format!("{value}");
3068 }
3069 let exponent = value.abs().log10().floor() as i32;
3070 if exponent >= 3 {
3071 return format!("{value:.2e}");
3072 }
3073 let decimals = (2 - exponent).max(0) as usize;
3074 let scale = 10f64.powi(decimals as i32);
3075 let rounded = (value * scale).abs().round() / scale * value.signum();
3076 format!("{rounded:.decimals$}")
3077}
3078
3079impl Default for TopologySelectOptions {
3080 fn default() -> Self {
3081 Self {
3082 tie_tolerance: 1e-3,
3083 score_scale: TopologyScoreScale::PerObservation,
3084 }
3085 }
3086}
3087
3088pub fn coupling_components(hessian: ArrayView2<'_, f64>) -> Vec<usize> {
3117 let p = hessian.nrows();
3118 if p == 0 || hessian.ncols() != p {
3119 return Vec::new();
3120 }
3121 let mut parent: Vec<usize> = (0..p).collect();
3123 let mut size: Vec<usize> = vec![1; p];
3124
3125 fn find(parent: &mut [usize], mut x: usize) -> usize {
3126 while parent[x] != x {
3127 parent[x] = parent[parent[x]];
3128 x = parent[x];
3129 }
3130 x
3131 }
3132
3133 for i in 0..p {
3134 for j in (i + 1)..p {
3135 if hessian[[i, j]] != 0.0 || hessian[[j, i]] != 0.0 {
3138 let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
3139 if ri != rj {
3140 let (small, large) = if size[ri] < size[rj] {
3141 (ri, rj)
3142 } else {
3143 (rj, ri)
3144 };
3145 parent[small] = large;
3146 size[large] += size[small];
3147 }
3148 }
3149 }
3150 }
3151
3152 let mut label_of_root: Vec<Option<usize>> = vec![None; p];
3155 let mut next_label = 0usize;
3156 let mut labels = vec![0usize; p];
3157 for idx in 0..p {
3158 let root = find(&mut parent, idx);
3159 let label = match label_of_root[root] {
3160 Some(l) => l,
3161 None => {
3162 let l = next_label;
3163 label_of_root[root] = Some(l);
3164 next_label += 1;
3165 l
3166 }
3167 };
3168 labels[idx] = label;
3169 }
3170 labels
3171}
3172
3173pub fn cone_of_influence(labels: &[usize], support: &[usize]) -> Vec<usize> {
3184 if support.is_empty() {
3185 return Vec::new();
3186 }
3187 let mut in_cone_labels: Vec<usize> = support
3188 .iter()
3189 .filter_map(|&idx| labels.get(idx).copied())
3190 .collect();
3191 in_cone_labels.sort_unstable();
3192 in_cone_labels.dedup();
3193 if in_cone_labels.is_empty() {
3194 return Vec::new();
3195 }
3196 (0..labels.len())
3197 .filter(|idx| in_cone_labels.binary_search(&labels[*idx]).is_ok())
3198 .collect()
3199}
3200
3201#[derive(Clone)]
3219pub struct EvidenceIftGradientTerms<'a> {
3220 pub dbeta_drho: ArrayView2<'a, f64>,
3221 pub du_drho: ArrayView2<'a, f64>,
3222 pub value_beta: ArrayView1<'a, f64>,
3223 pub value_u: ArrayView1<'a, f64>,
3224 pub logdet_h_beta: ArrayView1<'a, f64>,
3225 pub logdet_h_u: ArrayView1<'a, f64>,
3226}
3227
3228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3289pub enum HybridAtomParam {
3290 Curved { latent_dim: usize },
3292 Linear,
3294}
3295
3296impl HybridAtomParam {
3297 pub const fn as_str(self) -> &'static str {
3299 match self {
3300 HybridAtomParam::Curved { .. } => "curved",
3301 HybridAtomParam::Linear => "linear",
3302 }
3303 }
3304
3305 pub const fn is_linear(self) -> bool {
3307 matches!(self, HybridAtomParam::Linear)
3308 }
3309}
3310
3311#[derive(Debug, Clone, Copy)]
3319pub struct HybridAtomCandidate {
3320 pub param: HybridAtomParam,
3321 pub negative_log_evidence: f64,
3323 pub num_parameters: usize,
3325 pub fitted_turning: Option<f64>,
3330}
3331
3332impl HybridAtomCandidate {
3333 pub fn linear(negative_log_evidence: f64, num_parameters: usize) -> Self {
3335 Self {
3336 param: HybridAtomParam::Linear,
3337 negative_log_evidence,
3338 num_parameters,
3339 fitted_turning: Some(0.0),
3340 }
3341 }
3342
3343 pub fn curved(
3345 latent_dim: usize,
3346 negative_log_evidence: f64,
3347 num_parameters: usize,
3348 fitted_turning: Option<f64>,
3349 ) -> Self {
3350 Self {
3351 param: HybridAtomParam::Curved { latent_dim },
3352 negative_log_evidence,
3353 num_parameters,
3354 fitted_turning,
3355 }
3356 }
3357}
3358
3359#[derive(Debug, Clone, Copy)]
3363pub struct HybridAtomChoice {
3364 pub param: HybridAtomParam,
3365 pub negative_log_evidence: f64,
3367 pub num_parameters: usize,
3369 pub curved_turning: Option<f64>,
3372 pub curved_evidence_margin: f64,
3377}
3378
3379pub const HYBRID_LINEAR_TURNING_FLOOR: f64 = 1e-9;
3387
3388pub fn select_hybrid_atom(candidates: &[HybridAtomCandidate]) -> Option<HybridAtomChoice> {
3416 if candidates.is_empty() {
3417 return None;
3418 }
3419 let linear = candidates.iter().find(|c| c.param.is_linear());
3420 let curved = candidates.iter().find(|c| !c.param.is_linear());
3421 let curved_turning = curved.and_then(|c| c.fitted_turning);
3422 let curved_evidence_margin = match (linear, curved) {
3423 (Some(l), Some(c)) => l.negative_log_evidence - c.negative_log_evidence,
3424 _ => 0.0,
3425 };
3426
3427 if let (Some(l), Some(turning)) = (linear, curved_turning)
3430 && turning <= HYBRID_LINEAR_TURNING_FLOOR
3431 {
3432 return Some(HybridAtomChoice {
3433 param: l.param,
3434 negative_log_evidence: l.negative_log_evidence,
3435 num_parameters: l.num_parameters,
3436 curved_turning,
3437 curved_evidence_margin,
3438 });
3439 }
3440
3441 let mut best = candidates[0];
3443 for cand in &candidates[1..] {
3444 let better_evidence = cand.negative_log_evidence < best.negative_log_evidence;
3445 let tied = cand.negative_log_evidence == best.negative_log_evidence;
3446 let cheaper_on_tie = tied && cand.num_parameters < best.num_parameters;
3447 if better_evidence || cheaper_on_tie {
3448 best = *cand;
3449 }
3450 }
3451 Some(HybridAtomChoice {
3452 param: best.param,
3453 negative_log_evidence: best.negative_log_evidence,
3454 num_parameters: best.num_parameters,
3455 curved_turning,
3456 curved_evidence_margin,
3457 })
3458}
3459
3460#[derive(Debug, Clone)]
3464pub struct HybridSplitSelection {
3465 pub atoms: Vec<HybridAtomChoice>,
3467 pub total_negative_log_evidence: f64,
3476 pub total_parameters: usize,
3479 pub curved_atom_count: usize,
3481}
3482
3483impl HybridSplitSelection {
3484 pub fn linear_atom_count(&self) -> usize {
3486 self.atoms.len() - self.curved_atom_count
3487 }
3488
3489 pub fn is_pure_linear(&self) -> bool {
3492 self.curved_atom_count == 0 && !self.atoms.is_empty()
3493 }
3494
3495 pub fn is_pure_curved(&self) -> bool {
3498 self.curved_atom_count == self.atoms.len() && !self.atoms.is_empty()
3499 }
3500}
3501
3502pub fn select_hybrid_split(
3516 slots: &[Vec<HybridAtomCandidate>],
3517) -> Result<HybridSplitSelection, String> {
3518 let mut atoms = Vec::with_capacity(slots.len());
3519 let mut total_nle = 0.0_f64;
3520 let mut total_parameters = 0usize;
3521 let mut curved_atom_count = 0usize;
3522 for (i, slot) in slots.iter().enumerate() {
3523 let choice = select_hybrid_atom(slot)
3524 .ok_or_else(|| format!("hybrid split slot {i} has no candidate parameterizations"))?;
3525 if !choice.negative_log_evidence.is_finite() {
3526 return Err(format!(
3527 "hybrid split slot {i} selected a non-finite evidence ({})",
3528 choice.negative_log_evidence
3529 ));
3530 }
3531 if !choice.param.is_linear() {
3532 curved_atom_count += 1;
3533 }
3534 total_nle += choice.negative_log_evidence;
3535 total_parameters += choice.num_parameters;
3536 atoms.push(choice);
3537 }
3538 Ok(HybridSplitSelection {
3539 atoms,
3540 total_negative_log_evidence: total_nle,
3541 total_parameters,
3542 curved_atom_count,
3543 })
3544}
3545
3546#[cfg(test)]
3556mod tests {
3557 use super::*;
3558 use ndarray::array;
3559
3560 fn dense_inverse(h: &Array2<f64>) -> Array2<f64> {
3562 let p = h.nrows();
3563 let mut aug = Array2::<f64>::zeros((p, 2 * p));
3564 for i in 0..p {
3565 for j in 0..p {
3566 aug[[i, j]] = h[[i, j]];
3567 }
3568 aug[[i, p + i]] = 1.0;
3569 }
3570 for col in 0..p {
3571 let mut pivot = col;
3572 for row in (col + 1)..p {
3573 if aug[[row, col]].abs() > aug[[pivot, col]].abs() {
3574 pivot = row;
3575 }
3576 }
3577 if pivot != col {
3578 for j in 0..(2 * p) {
3579 aug.swap([col, j], [pivot, j]);
3580 }
3581 }
3582 let d = aug[[col, col]];
3583 for j in 0..(2 * p) {
3584 aug[[col, j]] /= d;
3585 }
3586 for row in 0..p {
3587 if row == col {
3588 continue;
3589 }
3590 let f = aug[[row, col]];
3591 if f != 0.0 {
3592 for j in 0..(2 * p) {
3593 aug[[row, j]] -= f * aug[[col, j]];
3594 }
3595 }
3596 }
3597 }
3598 let mut inv = Array2::<f64>::zeros((p, p));
3599 for i in 0..p {
3600 for j in 0..p {
3601 inv[[i, j]] = aug[[i, p + j]];
3602 }
3603 }
3604 inv
3605 }
3606
3607 #[test]
3611 fn em_contraction_rate_recovers_a_planted_geometric_decay() {
3612 let planted = 0.98_f64;
3613 let mut window = std::collections::VecDeque::new();
3614 let mut residual = 1.0_f64;
3615 for _ in 0..EM_RATE_WINDOW {
3616 window.push_back(residual);
3617 residual *= planted;
3618 }
3619 assert_eq!(em_contraction_rate(&window), None);
3621 window.push_back(residual);
3622 let measured = em_contraction_rate(&window).expect("a full window yields a rate");
3623 assert!(
3624 (measured - planted).abs() < 1e-12,
3625 "measured {measured} should recover the planted {planted}"
3626 );
3627 }
3628
3629 #[test]
3632 fn em_contraction_rate_does_not_contract_on_a_flat_or_growing_residual() {
3633 let flat: std::collections::VecDeque<f64> =
3634 std::iter::repeat_n(1e-6, EM_RATE_WINDOW + 1).collect();
3635 let rate = em_contraction_rate(&flat).expect("a full window yields a rate");
3636 assert!(rate >= 1.0, "a flat residual must not look like contraction");
3637 let growing: std::collections::VecDeque<f64> = (0..=EM_RATE_WINDOW)
3638 .map(|i| 1e-6 * 1.01_f64.powi(i as i32))
3639 .collect();
3640 let rate = em_contraction_rate(&growing).expect("a full window yields a rate");
3641 assert!(rate > 1.0, "a growing residual must not look like contraction");
3642 }
3643
3644 #[test]
3647 fn em_projected_iterations_inverts_the_decay_and_declines_otherwise() {
3648 let steps = em_projected_iterations(1.0, 1e-8, 0.98).expect("a contracting rate projects");
3650 assert_eq!(steps, 912);
3651 assert!(0.98_f64.powi(steps as i32) <= 1e-8);
3653 assert_eq!(em_projected_iterations(1.0, 1e-8, 1.0), None);
3655 assert_eq!(em_projected_iterations(1.0, 1e-8, 1.05), None);
3656 assert_eq!(em_projected_iterations(1e-9, 1e-8, 0.98), None);
3657 }
3658
3659 #[test]
3660 fn coupling_components_block_diagonal_is_all_singletons_by_block() {
3661 let mut h = Array2::<f64>::eye(4);
3663 h[[0, 1]] = 0.3;
3664 h[[1, 0]] = 0.3;
3665 h[[2, 3]] = 0.7;
3666 h[[3, 2]] = 0.7;
3667 let labels = coupling_components(h.view());
3668 assert_eq!(labels[0], labels[1]);
3669 assert_eq!(labels[2], labels[3]);
3670 assert_ne!(labels[0], labels[2]);
3671 let mut uniq = labels.clone();
3673 uniq.sort_unstable();
3674 uniq.dedup();
3675 assert_eq!(uniq.len(), 2);
3676 }
3677
3678 #[test]
3679 fn coupling_components_fully_coupled_is_one_component() {
3680 let mut h = Array2::<f64>::eye(3);
3681 for i in 0..3 {
3682 for j in 0..3 {
3683 if i != j {
3684 h[[i, j]] = 0.1;
3685 }
3686 }
3687 }
3688 let labels = coupling_components(h.view());
3689 assert!(labels.iter().all(|&l| l == labels[0]));
3690 }
3691
3692 #[test]
3693 fn coupling_components_transitive_chain_merges() {
3694 let mut h = Array2::<f64>::eye(3);
3696 h[[0, 1]] = 0.5;
3697 h[[1, 0]] = 0.5;
3698 h[[1, 2]] = 0.5;
3699 h[[2, 1]] = 0.5;
3700 let labels = coupling_components(h.view());
3701 assert_eq!(labels[0], labels[1]);
3702 assert_eq!(labels[1], labels[2]);
3703 }
3704
3705 #[test]
3706 fn compare_reml_fits_delta_and_evidence_ratio_never_contradict_winner_gh1465() {
3707 let cand = |name: &str, score: f64, edf: f64| RemlCandidate {
3719 index: 0,
3720 name: name.to_string(),
3721 score,
3722 edf,
3723 log_lik: 0.0,
3724 family: Some("gaussian".to_string()),
3725 n_obs: Some(100),
3726 };
3727 let candidates = vec![
3730 cand("m1", 53.748, 50.0),
3731 cand("m2", 41.605, 51.0),
3732 cand("m3", 120.011, 65.0),
3733 ];
3734 let cmp = compare_reml_fits(candidates).expect("comparison");
3735
3736 assert_eq!(cmp.winner, "m1", "AIC winner");
3737 for row in &cmp.ranking {
3739 assert!(
3740 row.delta >= 0.0,
3741 "ranking delta for {} must be >= 0, got {}",
3742 row.name,
3743 row.delta
3744 );
3745 assert!(
3746 row.evidence_ratio >= 1.0 - 1e-12,
3747 "ranking evidence_ratio for {} must be >= 1, got {}",
3748 row.name,
3749 row.evidence_ratio
3750 );
3751 }
3752 let winner_row = cmp.ranking.iter().find(|r| r.name == "m1").unwrap();
3753 assert!(winner_row.delta.abs() < 1e-12, "winner delta == 0");
3754 assert!(
3755 (winner_row.evidence_ratio - 1.0).abs() < 1e-9,
3756 "winner evidence_ratio == 1"
3757 );
3758
3759 for row in &cmp.score_table {
3762 assert!(
3763 row.delta_reml >= 0.0,
3764 "score-table delta_reml for {} must be >= 0, got {}",
3765 row.name,
3766 row.delta_reml
3767 );
3768 assert!(
3769 row.bayes_factor_best_over_model >= 1.0 - 1e-12,
3770 "score-table bayes_factor for {} must be >= 1, got {}",
3771 row.name,
3772 row.bayes_factor_best_over_model
3773 );
3774 }
3775 let m2 = cmp.score_table.iter().find(|r| r.name == "m2").unwrap();
3777 assert!(
3778 m2.delta_reml.abs() < 1e-12,
3779 "the minimum-raw-REML row has delta_reml 0"
3780 );
3781 }
3782
3783 #[test]
3784 fn cone_of_influence_empty_support_is_empty() {
3785 let labels = vec![0usize, 0, 1, 1];
3786 assert!(cone_of_influence(&labels, &[]).is_empty());
3787 }
3788
3789 #[test]
3790 fn cone_of_influence_returns_full_component() {
3791 let labels = vec![0usize, 0, 1, 1];
3792 assert_eq!(cone_of_influence(&labels, &[0]), vec![0, 1]);
3794 assert_eq!(cone_of_influence(&labels, &[1, 2]), vec![0, 1, 2, 3]);
3796 }
3797
3798 #[test]
3799 fn coned_matches_full_solve_on_fully_coupled_hessian() {
3800 let h = Array2::from_shape_vec((3, 3), vec![4.0, 1.0, 0.5, 1.0, 3.0, 0.8, 0.5, 0.8, 2.5])
3803 .unwrap();
3804 let inv = dense_inverse(&h);
3805 let mut dg = Array2::<f64>::zeros((3, 2));
3807 dg[[0, 0]] = 1.3;
3808 dg[[2, 1]] = -0.7;
3809 let supports = vec![0..1usize, 2..3usize];
3810
3811 let eye: Array2<f64> = Array2::eye(3);
3812 let op = crate::sensitivity::FitSensitivity::from_projected(&eye, &inv);
3813 let full = op.mode_response(dg.view()).unwrap();
3814 let coned = op
3815 .mode_response_coned(h.view(), dg.view(), &supports)
3816 .unwrap();
3817 for i in 0..3 {
3818 for a in 0..2 {
3819 assert!(
3820 (full[[i, a]] - coned[[i, a]]).abs() < 1e-12,
3821 "fully-coupled mismatch at ({i},{a}): {} vs {}",
3822 full[[i, a]],
3823 coned[[i, a]]
3824 );
3825 }
3826 }
3827 }
3828
3829 #[test]
3830 fn coned_confines_to_component_on_decoupled_hessian() {
3831 let mut h = Array2::<f64>::zeros((4, 4));
3835 h[[0, 0]] = 4.0;
3837 h[[1, 1]] = 3.0;
3838 h[[0, 1]] = 1.0;
3839 h[[1, 0]] = 1.0;
3840 h[[2, 2]] = 2.0;
3842 h[[3, 3]] = 5.0;
3843 h[[2, 3]] = 0.6;
3844 h[[3, 2]] = 0.6;
3845 let inv = dense_inverse(&h);
3846
3847 let mut dg = Array2::<f64>::zeros((4, 1));
3848 dg[[0, 0]] = 0.9;
3849 dg[[1, 0]] = -0.4;
3850 let support_range = 0..2usize;
3851 let supports = std::slice::from_ref(&support_range);
3852
3853 let eye: Array2<f64> = Array2::eye(4);
3854 let coned = crate::sensitivity::FitSensitivity::from_projected(&eye, &inv)
3855 .mode_response_coned(h.view(), dg.view(), supports)
3856 .unwrap();
3857 let q = dg.column(0).to_owned();
3860 let exact = inv.dot(&q).mapv(|v| -v);
3861 for i in 0..4 {
3862 assert!(
3863 (coned[[i, 0]] - exact[[i]]).abs() < 1e-12,
3864 "decoupled mismatch at {i}: {} vs {}",
3865 coned[[i, 0]],
3866 exact[[i]]
3867 );
3868 }
3869 assert_eq!(coned[[2, 0]], 0.0);
3871 assert_eq!(coned[[3, 0]], 0.0);
3872 }
3873
3874 #[test]
3875 fn coned_skips_inactive_column_with_empty_support() {
3876 let h = Array2::<f64>::eye(2);
3877 let dg = Array2::<f64>::zeros((2, 1));
3878 let empty_support = 0..0usize;
3880 let supports = std::slice::from_ref(&empty_support);
3881 let eye: Array2<f64> = Array2::eye(2);
3886 let nan_inv = Array2::<f64>::from_elem((2, 2), f64::NAN);
3887 let coned = crate::sensitivity::FitSensitivity::from_projected(&eye, &nan_inv)
3888 .mode_response_coned(h.view(), dg.view(), supports)
3889 .unwrap();
3890 assert_eq!(coned[[0, 0]], 0.0);
3891 assert_eq!(coned[[1, 0]], 0.0);
3892 }
3893
3894 fn gaussian_logpdf(y: f64, mean: f64, sd: f64) -> f64 {
3895 let z = (y - mean) / sd;
3896 -0.5 * (2.0 * std::f64::consts::PI).ln() - sd.ln() - 0.5 * z * z
3897 }
3898
3899 #[test]
3900 fn stacking_single_candidate_gets_full_weight() {
3901 let log_density = Array2::from_shape_vec((3, 1), vec![-1.0, -2.0, -0.5]).unwrap();
3902 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3903 assert!((out.weights[0] - 1.0).abs() < 1e-12);
3904 assert_eq!(out.weights.len(), 1);
3905 }
3906
3907 #[test]
3908 fn stacking_dominant_candidate_attracts_nearly_all_weight() {
3909 let mut log_density = Array2::<f64>::zeros((50, 2));
3910 for i in 0..50 {
3911 log_density[[i, 0]] = -0.1;
3912 log_density[[i, 1]] = -5.0;
3913 }
3914 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3915 assert!(out.weights[0] > 0.99, "w0 = {}", out.weights[0]);
3916 assert!(out.weights[1] < 0.01, "w1 = {}", out.weights[1]);
3917 }
3918
3919 #[test]
3920 fn stacking_complementary_candidates_share_weight() {
3921 let n = 40;
3924 let mut log_density = Array2::<f64>::zeros((n, 2));
3925 for i in 0..n {
3926 if i < n / 2 {
3927 log_density[[i, 0]] = gaussian_logpdf(0.0, 0.0, 0.5);
3928 log_density[[i, 1]] = gaussian_logpdf(0.0, 1.5, 0.5);
3929 } else {
3930 log_density[[i, 0]] = gaussian_logpdf(0.0, 1.5, 0.5);
3931 log_density[[i, 1]] = gaussian_logpdf(0.0, 0.0, 0.5);
3932 }
3933 }
3934 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3935 assert!(
3936 out.weights[0] > 0.2 && out.weights[0] < 0.8,
3937 "w0 = {}",
3938 out.weights[0]
3939 );
3940 assert!((out.weights.sum() - 1.0).abs() < 1e-9);
3941 }
3942
3943 #[test]
3944 fn stacking_weights_stay_on_the_simplex() {
3945 let log_density = Array2::from_shape_vec(
3946 (3, 3),
3947 vec![-1.0, -2.0, -3.0, -2.5, -1.0, -2.0, -3.0, -2.0, -1.0],
3948 )
3949 .unwrap();
3950 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3951 assert!((out.weights.sum() - 1.0).abs() < 1e-9);
3952 assert!(out.weights.iter().all(|&w| w >= -1e-12));
3953 }
3954
3955 #[test]
3956 fn stacking_solution_satisfies_the_simplex_kkt_certificate() {
3957 let log_density = Array2::from_shape_vec(
3962 (5, 2),
3963 vec![-0.2, -3.0, -3.0, -0.2, -0.5, -1.5, -1.5, -0.5, -0.1, -2.0],
3964 )
3965 .unwrap();
3966 let config = StackingConfig::default();
3967 let out = solve_stacking_weights(log_density.view(), config).unwrap();
3968 assert!(out.certificate.residual() <= config.kkt_tol);
3969 let n = log_density.nrows();
3970 for k in 0..2 {
3971 let mut g = 0.0_f64;
3972 for i in 0..n {
3973 let mix: f64 = (0..2)
3974 .map(|c| out.weights[c] * log_density[[i, c]].exp())
3975 .sum();
3976 g += log_density[[i, k]].exp() / mix;
3977 }
3978 g /= n as f64;
3979 assert!(
3980 g <= 1.0 + config.kkt_tol,
3981 "stationarity violated for candidate {k}: g = {g}"
3982 );
3983 assert!(
3984 out.weights[k] * (g - 1.0).abs() <= config.kkt_tol * (1.0 + 1e-6),
3985 "complementary slackness violated for candidate {k}: w = {}, g = {g}",
3986 out.weights[k]
3987 );
3988 }
3989 }
3990
3991 #[test]
3992 fn stacking_near_tied_boundary_uses_newton_not_millions_of_em_steps() {
3993 let log_density =
3994 Array2::from_shape_fn(
3995 (64, 2),
3996 |(_, candidate)| {
3997 if candidate == 0 { 0.0 } else { -1.0e-6 }
3998 },
3999 );
4000 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
4001 assert!(out.weights[0] >= 1.0 - StackingConfig::default().kkt_tol);
4002 assert!(out.iterations < 8, "iterations = {}", out.iterations);
4003 }
4004
4005 #[test]
4006 fn stacking_dead_candidate_column_gets_zero_weight() {
4007 let log_density = Array2::from_shape_vec(
4008 (3, 2),
4009 vec![
4010 -1.0,
4011 f64::NEG_INFINITY,
4012 -2.0,
4013 f64::NEG_INFINITY,
4014 -0.5,
4015 f64::NEG_INFINITY,
4016 ],
4017 )
4018 .unwrap();
4019 let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
4020 assert_eq!(out.weights[1], 0.0);
4021 assert!((out.weights[0] - 1.0).abs() < 1e-12);
4022 }
4023
4024 #[test]
4025 fn stacking_rejects_invalid_and_unscorable_rows() {
4026 let log_density = Array2::from_shape_vec(
4027 (3, 2),
4028 vec![-1.0, -2.0, f64::NAN, f64::NEG_INFINITY, -2.0, -1.0],
4029 )
4030 .unwrap();
4031 assert!(matches!(
4032 solve_stacking_weights(log_density.view(), StackingConfig::default()),
4033 Err(StackingError::InvalidInput { .. })
4034 ));
4035 let unscorable = Array2::from_shape_vec(
4036 (2, 2),
4037 vec![-1.0, -2.0, f64::NEG_INFINITY, f64::NEG_INFINITY],
4038 )
4039 .unwrap();
4040 assert!(matches!(
4041 solve_stacking_weights(unscorable.view(), StackingConfig::default()),
4042 Err(StackingError::InvalidInput { .. })
4043 ));
4044 }
4045
4046 fn two_cluster_mixture_data() -> Array2<f64> {
4047 Array2::from_shape_vec(
4048 (12, 1),
4049 vec![
4050 -2.2, -2.0, -1.9, -2.1, -1.8, -2.05, 1.8, 2.0, 2.2, 1.9, 2.1, 2.05,
4051 ],
4052 )
4053 .unwrap()
4054 }
4055
4056 #[test]
4057 fn gaussian_mixture_monotonicity_resolves_composite_map_noise_2264() {
4058 let objective_scale = 1.0;
4059 let composite_resolution = f64::EPSILON.sqrt() * objective_scale;
4060 let uncertainty = gaussian_mixture_monotonicity_uncertainty(objective_scale, 0.0, 0.0);
4061 assert_eq!(uncertainty, composite_resolution);
4062
4063 let noise_scale_decrease = -0.5 * composite_resolution;
4064 assert!(noise_scale_decrease >= -uncertainty);
4065 let resolved_decrease = -2.0 * composite_resolution;
4066 assert!(resolved_decrease < -uncertainty);
4067
4068 let larger_reduction_bound = 2.0 * composite_resolution;
4069 assert_eq!(
4070 gaussian_mixture_monotonicity_uncertainty(objective_scale, larger_reduction_bound, 0.0),
4071 larger_reduction_bound,
4072 );
4073 }
4074
4075 #[test]
4076 fn gaussian_mixture_issue_scale_negative_step_is_within_computed_uncertainty_2264() {
4077 let objective_scale = 1.0;
4084 let recorded_step = -1.4e-13;
4085 let uncertainty = gaussian_mixture_monotonicity_uncertainty(objective_scale, 0.0, 0.0);
4086 let certificate = GaussianMixtureCertificate {
4087 mean_log_likelihood: -objective_scale,
4088 mean_log_likelihood_gain: recorded_step,
4089 monotonicity_uncertainty: uncertainty,
4090 objective_residual: recorded_step.abs() / objective_scale,
4091 objective_tolerance: f64::EPSILON.sqrt(),
4092 parameter_residual: 0.0,
4093 parameter_tolerance: f64::EPSILON.sqrt(),
4094 contraction_rate: None,
4095 projected_iterations_to_tolerance: None,
4096 };
4097
4098 assert_eq!(
4099 certificate.monotonicity_uncertainty,
4100 f64::EPSILON.sqrt() * objective_scale,
4101 "reported uncertainty must be the computed composite-map resolution"
4102 );
4103 assert!(
4104 certificate.mean_log_likelihood_gain >= -certificate.monotonicity_uncertainty,
4105 "the recorded noise-scale decrease must not be a monotonicity violation"
4106 );
4107 }
4108
4109 #[test]
4110 fn gaussian_mixture_below_roundoff_positive_gain_can_certify_2264() {
4111 let objective_scale = 1.0;
4116 let recorded_gain = 6.6e-15;
4117 let recorded_reduction_bound = 1.5e-14;
4118 let objective_tolerance = f64::EPSILON.sqrt();
4119 let parameter_tolerance = f64::EPSILON.sqrt();
4120 let uncertainty = gaussian_mixture_monotonicity_uncertainty(
4121 objective_scale,
4122 recorded_reduction_bound,
4123 0.0,
4124 );
4125 let certificate = GaussianMixtureCertificate {
4126 mean_log_likelihood: -objective_scale,
4127 mean_log_likelihood_gain: recorded_gain,
4128 monotonicity_uncertainty: uncertainty,
4129 objective_residual: recorded_gain / objective_scale,
4130 objective_tolerance,
4131 parameter_residual: 0.5 * parameter_tolerance,
4132 parameter_tolerance,
4133 contraction_rate: None,
4134 projected_iterations_to_tolerance: None,
4135 };
4136
4137 assert_eq!(
4138 certificate.monotonicity_uncertainty,
4139 (f64::EPSILON.sqrt() * objective_scale).max(recorded_reduction_bound),
4140 "reported uncertainty must come from the composite-map and reduction bounds"
4141 );
4142 assert!(certificate.mean_log_likelihood_gain >= -certificate.monotonicity_uncertainty);
4143 assert!(certificate.objective_residual <= certificate.objective_tolerance);
4144 assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4145 }
4146
4147 #[test]
4148 fn gaussian_mixture_certificate_quotients_duplicate_component_mass_exchange_2324() {
4149 let data = array![[-1.0], [0.0], [2.0]];
4155 let means = array![[0.0], [0.0]];
4156 let covariance = vec![array![[1.0]], array![[1.0]]];
4157 let weights = array![0.25, 0.75];
4158 let redistributed_weights = array![0.5, 0.5];
4159 let previous = mixture_e_step(data.view(), &weights, &means, &covariance).unwrap();
4160 let redistributed =
4161 mixture_e_step(data.view(), &redistributed_weights, &means, &covariance).unwrap();
4162 let residual = empirical_predictive_density_residual(
4163 &previous.row_log_likelihoods,
4164 &redistributed.row_log_likelihoods,
4165 )
4166 .unwrap();
4167 assert!(residual <= 4.0 * f64::EPSILON);
4168
4169 let shifted_means = array![[0.01], [0.0]];
4171 let shifted =
4172 mixture_e_step(data.view(), &weights, &shifted_means, &covariance).unwrap();
4173 let shifted_residual = empirical_predictive_density_residual(
4174 &previous.row_log_likelihoods,
4175 &shifted.row_log_likelihoods,
4176 )
4177 .unwrap();
4178 assert!(shifted_residual > f64::EPSILON.sqrt());
4179 }
4180
4181 #[test]
4182 fn gaussian_mixture_fit_certificate_describes_the_exact_returned_iterate() {
4183 let data = two_cluster_mixture_data();
4184 let config = GaussianMixtureConfig::default();
4185 let fit = fit_gaussian_mixture(data.view(), 2, config).unwrap();
4186 let certificate = fit.certificate();
4187 assert!(certificate.objective_residual <= certificate.objective_tolerance);
4188 assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4189
4190 let checkpoint = GaussianMixtureCheckpoint {
4191 weights: fit.weights.clone(),
4192 means: fit.means.clone(),
4193 covariances: fit.covariances.clone(),
4194 mean_log_likelihood: certificate.mean_log_likelihood,
4195 completed_iterations: fit.iterations,
4196 data_fingerprint: mixture_data_fingerprint(data.view()),
4197 covariance_floor: config.covariance_floor,
4198 };
4199 let current = mixture_e_step(
4200 data.view(),
4201 &checkpoint.weights,
4202 &checkpoint.means,
4203 &checkpoint.covariances,
4204 )
4205 .unwrap();
4206 let (weights, means, covariances) = mixture_m_step(
4207 data.view(),
4208 current.responsibilities.view(),
4209 config.covariance_floor,
4210 )
4211 .unwrap();
4212 let next = mixture_e_step(data.view(), &weights, &means, &covariances).unwrap();
4213 let residual = empirical_predictive_density_residual(
4214 ¤t.row_log_likelihoods,
4215 &next.row_log_likelihoods,
4216 )
4217 .unwrap();
4218 assert!(residual <= config.parameter_tol);
4219 assert_eq!(certificate.mean_log_likelihood, current.mean_log_likelihood);
4220 assert_eq!(
4221 certificate.mean_log_likelihood_gain,
4222 next.mean_log_likelihood - current.mean_log_likelihood
4223 );
4224 assert_eq!(
4225 certificate.monotonicity_uncertainty,
4226 gaussian_mixture_monotonicity_uncertainty(
4227 current
4228 .mean_log_likelihood
4229 .abs()
4230 .max(next.mean_log_likelihood.abs())
4231 .max(1.0),
4232 current.mean_log_likelihood_roundoff,
4233 next.mean_log_likelihood_roundoff,
4234 )
4235 );
4236 assert_eq!(certificate.parameter_residual, residual);
4237 assert!(
4238 (next.mean_log_likelihood - current.mean_log_likelihood).abs()
4239 / current
4240 .mean_log_likelihood
4241 .abs()
4242 .max(next.mean_log_likelihood.abs())
4243 .max(1.0)
4244 <= config.loglik_tol
4245 );
4246 }
4247
4248 #[test]
4249 fn gaussian_mixture_bic_is_finite_with_an_active_covariance_floor() {
4250 let per_cluster = 45usize;
4256 let mut data = Array2::<f64>::zeros((2 * per_cluster, 2));
4257 for sample in 0..per_cluster {
4258 let phase = std::f64::consts::TAU * sample as f64 / per_cluster as f64;
4259 data[[2 * sample, 0]] = -2.0;
4260 data[[2 * sample, 1]] = 0.08 * phase.sin();
4261 data[[2 * sample + 1, 0]] = 2.0 + 0.12 * phase.cos();
4262 data[[2 * sample + 1, 1]] = 0.08 * phase.sin();
4263 }
4264 let fit = fit_gaussian_mixture(data.view(), 2, GaussianMixtureConfig::default())
4265 .expect("the covariance floor defines a valid constrained mixture fit");
4266 let bic = fit.bic();
4267 assert!(bic.is_finite());
4268 assert_eq!(
4269 bic,
4270 -fit.loglik + 0.5 * fit.num_free_parameters() as f64 * (data.nrows() as f64).ln()
4271 );
4272 }
4273
4274 fn seven_clusters_on_a_circle_2262() -> Array2<f64> {
4275 let clusters = 7usize;
4276 let per_cluster = 32usize;
4277 let mut data = Array2::<f64>::zeros((clusters * per_cluster, 2));
4278 for cluster in 0..clusters {
4279 let angle = std::f64::consts::TAU * cluster as f64 / clusters as f64;
4280 let (sin_angle, cos_angle) = angle.sin_cos();
4281 for sample in 0..per_cluster {
4282 let phase = std::f64::consts::TAU * sample as f64 / per_cluster as f64;
4283 let local_radius = 0.035 * (1.0 + 0.3 * (3.0 * phase).cos());
4288 let radial_noise = local_radius * phase.cos();
4289 let tangent_noise = local_radius * phase.sin();
4290 let radius = 2.0 + radial_noise;
4291 let row = cluster * per_cluster + sample;
4292 data[[row, 0]] = 0.4 + radius * cos_angle - tangent_noise * sin_angle;
4293 data[[row, 1]] = -0.3 + radius * sin_angle + tangent_noise * cos_angle;
4294 }
4295 }
4296 data
4297 }
4298
4299 #[test]
4300 fn circular_gaussian_density_avoids_extreme_scale_intermediate_overflow() {
4301 let noise_variance = f64::MAX / 2.0;
4302 let fit =
4303 CircularGaussianFit2d::from_parameters([0.0, 0.0], 1.1e154, noise_variance).unwrap();
4304 let center_log_density = fit.log_density(0.0, 0.0);
4307 let off_center_log_density = fit.log_density(1.7e154, 0.0);
4308 assert!(center_log_density.is_finite());
4309 assert!(off_center_log_density.is_finite());
4310 let expected_center = -std::f64::consts::TAU.ln()
4311 - noise_variance.ln()
4312 - 0.5 * (fit.radius() / noise_variance.sqrt()).powi(2);
4313 assert_eq!(center_log_density, expected_center);
4314 }
4315
4316 #[test]
4317 fn ring_of_clusters_fit_is_stationary_and_complexity_priced_2262() {
4318 let data = seven_clusters_on_a_circle_2262();
4319 let config = GaussianMixtureConfig::default();
4320 let fit = fit_ring_gaussian_mixture(data.view(), 7, config).unwrap();
4321 let certificate = fit.certificate();
4322 assert!(certificate.objective_residual <= certificate.objective_tolerance);
4323 assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4324 assert_eq!(fit.num_free_parameters(), 17);
4325 assert!((fit.center()[0] - 0.4).abs() < 0.05);
4326 assert!((fit.center()[1] + 0.3).abs() < 0.05);
4327 assert!((fit.radius() - 2.0).abs() < 0.05);
4328 assert!(fit.variance().is_finite() && fit.variance() > 0.0);
4329 assert!(
4330 fit.per_point_log_density(data.view())
4331 .unwrap()
4332 .iter()
4333 .all(|value| value.is_finite())
4334 );
4335 assert!(fit.bic().is_finite());
4336
4337 let free = fit_gaussian_mixture(data.view(), 7, config).unwrap();
4338 assert_eq!(free.num_free_parameters(), 41);
4339 assert!(fit.num_free_parameters() < free.num_free_parameters());
4340 }
4341
4342 #[test]
4343 fn ring_certificate_uses_identifiable_component_means() {
4344 let y = 0.91_f64.sqrt();
4350 let weights = Array1::from_vec(vec![0.2, 0.3, 0.5]);
4351 let previous = RingMixtureState {
4352 weights: weights.clone(),
4353 center: Array1::from_vec(vec![0.0, 0.0]),
4354 radius: 1.0,
4355 directions: Array2::from_shape_vec((3, 2), vec![0.3, y, 0.3, -y, 0.3, y]).unwrap(),
4356 variance: 0.25,
4357 mean_log_likelihood: -1.0,
4358 completed_iterations: 10,
4359 };
4360 let next = RingMixtureState {
4361 weights,
4362 center: Array1::from_vec(vec![0.6, 0.0]),
4363 radius: 1.0,
4364 directions: Array2::from_shape_vec((3, 2), vec![-0.3, y, -0.3, -y, -0.3, y]).unwrap(),
4365 variance: 0.25,
4366 mean_log_likelihood: -1.0,
4367 completed_iterations: 11,
4368 };
4369 assert!(relative_parameter_step(previous.center[0], next.center[0]) > 0.5);
4370 let data = array![[0.3, y], [0.3, -y], [1.0, 0.0]];
4371 let previous_e_step = ring_mixture_e_step(data.view(), &previous).unwrap();
4372 let next_e_step = ring_mixture_e_step(data.view(), &next).unwrap();
4373 let residual = empirical_predictive_density_residual(
4374 &previous_e_step.row_log_likelihoods,
4375 &next_e_step.row_log_likelihoods,
4376 )
4377 .unwrap();
4378 assert_eq!(residual, 0.0);
4379 }
4380
4381 #[test]
4382 fn ring_certificate_quotients_duplicate_component_mass_exchange_2324() {
4383 let previous = RingMixtureState {
4384 weights: array![0.2, 0.3, 0.5],
4385 center: array![0.0, 0.0],
4386 radius: 1.0,
4387 directions: array![[1.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
4388 variance: 1.0,
4389 mean_log_likelihood: -1.0,
4390 completed_iterations: 10,
4391 };
4392 let next = RingMixtureState {
4393 weights: array![0.4, 0.1, 0.5],
4394 center: previous.center.clone(),
4395 radius: previous.radius,
4396 directions: previous.directions.clone(),
4397 variance: previous.variance,
4398 mean_log_likelihood: -1.0,
4399 completed_iterations: 11,
4400 };
4401 let data = array![[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]];
4402 let previous_e_step = ring_mixture_e_step(data.view(), &previous).unwrap();
4403 let next_e_step = ring_mixture_e_step(data.view(), &next).unwrap();
4404 let residual = empirical_predictive_density_residual(
4405 &previous_e_step.row_log_likelihoods,
4406 &next_e_step.row_log_likelihoods,
4407 )
4408 .unwrap();
4409 assert!(residual <= 4.0 * f64::EPSILON);
4410 }
4411
4412 fn hybrid_slot(
4427 linear_nle: f64,
4428 p_linear: usize,
4429 latent_dim: usize,
4430 p_curved: usize,
4431 theta: f64,
4432 curved_loglik_gain: f64,
4433 ) -> Vec<HybridAtomCandidate> {
4434 let param_price =
4435 0.5 * (p_curved as f64 - p_linear as f64) * (2.0 * std::f64::consts::PI).ln();
4436 let curved_nle = linear_nle - curved_loglik_gain + param_price;
4437 vec![
4438 HybridAtomCandidate::linear(linear_nle, p_linear),
4439 HybridAtomCandidate::curved(latent_dim, curved_nle, p_curved, Some(theta)),
4440 ]
4441 }
4442
4443 #[test]
4444 fn hybrid_dominance_floor_selects_linear_when_turning_is_zero() {
4445 let slot = hybrid_slot(100.0, 2, 1, 5, 0.0, 0.0);
4450 let choice = select_hybrid_atom(&slot).unwrap();
4451 assert!(choice.param.is_linear());
4452 assert_eq!(choice.param, HybridAtomParam::Linear);
4453 assert!(choice.curved_turning.unwrap() <= HYBRID_LINEAR_TURNING_FLOOR);
4455 }
4456
4457 #[test]
4458 fn hybrid_selects_curved_when_turning_pays_for_itself() {
4459 let slot = hybrid_slot(100.0, 2, 1, 5, 2.0 * std::f64::consts::PI, 30.0);
4463 let choice = select_hybrid_atom(&slot).unwrap();
4464 assert_eq!(choice.param, HybridAtomParam::Curved { latent_dim: 1 });
4465 assert!(choice.curved_evidence_margin > 0.0);
4467 }
4468
4469 #[test]
4470 fn hybrid_keeps_linear_when_curvature_doesnt_pay_its_price() {
4471 let slot = hybrid_slot(100.0, 2, 1, 5, 0.05, 0.1);
4475 let choice = select_hybrid_atom(&slot).unwrap();
4476 assert!(choice.param.is_linear());
4477 assert!(choice.curved_evidence_margin <= 0.0);
4478 }
4479
4480 #[test]
4481 fn hybrid_tie_breaks_to_the_cheaper_linear_atom() {
4482 let theta = 0.5; let nle = 42.0;
4487 let slot = vec![
4488 HybridAtomCandidate::linear(nle, 2),
4489 HybridAtomCandidate::curved(1, nle, 5, Some(theta)),
4490 ];
4491 let choice = select_hybrid_atom(&slot).unwrap();
4492 assert!(choice.param.is_linear());
4493 assert_eq!(choice.num_parameters, 2);
4494 }
4495
4496 #[test]
4497 fn hybrid_split_reduces_to_pure_linear_when_all_features_are_straight() {
4498 let slots: Vec<Vec<HybridAtomCandidate>> = (0..6)
4502 .map(|i| hybrid_slot(50.0 + i as f64, 2, 1, 5, 0.0, 0.0))
4503 .collect();
4504 let split = select_hybrid_split(&slots).unwrap();
4505 assert!(split.is_pure_linear());
4506 assert_eq!(split.curved_atom_count, 0);
4507 assert_eq!(split.linear_atom_count(), 6);
4508 let pure_linear: f64 = (0..6).map(|i| 50.0 + i as f64).sum();
4510 assert!((split.total_negative_log_evidence - pure_linear).abs() < 1e-12);
4511 }
4512
4513 #[test]
4514 fn hybrid_split_reduces_to_pure_curved_when_every_feature_curves() {
4515 let slots: Vec<Vec<HybridAtomCandidate>> = (0..5)
4518 .map(|i| hybrid_slot(80.0 + i as f64, 2, 1, 5, 2.0 * std::f64::consts::PI, 40.0))
4519 .collect();
4520 let split = select_hybrid_split(&slots).unwrap();
4521 assert!(split.is_pure_curved());
4522 assert_eq!(split.curved_atom_count, 5);
4523 assert_eq!(split.linear_atom_count(), 0);
4524 }
4525
4526 #[test]
4527 fn hybrid_split_on_mixed_dictionary_picks_curved_for_circles_linear_for_directions() {
4528 let mut slots: Vec<Vec<HybridAtomCandidate>> = Vec::new();
4538 let mut pure_linear_baseline = 0.0_f64;
4539 for i in 0..3 {
4542 let linear_nle = 120.0 + 3.0 * i as f64;
4543 pure_linear_baseline += linear_nle;
4544 slots.push(hybrid_slot(
4545 linear_nle,
4546 2,
4547 1,
4548 5,
4549 2.0 * std::f64::consts::PI,
4550 35.0,
4551 ));
4552 }
4553 for i in 0..4 {
4556 let linear_nle = 90.0 + 2.0 * i as f64;
4557 pure_linear_baseline += linear_nle;
4558 slots.push(hybrid_slot(linear_nle, 2, 1, 5, 0.0, 0.0));
4559 }
4560
4561 let split = select_hybrid_split(&slots).unwrap();
4562
4563 for (idx, choice) in split.atoms.iter().enumerate() {
4566 if idx < 3 {
4567 assert_eq!(
4568 choice.param,
4569 HybridAtomParam::Curved { latent_dim: 1 },
4570 "circle slot {idx} should select curved"
4571 );
4572 } else {
4573 assert!(
4574 choice.param.is_linear(),
4575 "direction slot {idx} should select linear"
4576 );
4577 }
4578 }
4579 assert_eq!(split.curved_atom_count, 3);
4580 assert_eq!(split.linear_atom_count(), 4);
4581
4582 assert!(
4588 split.total_negative_log_evidence <= pure_linear_baseline + 1e-9,
4589 "hybrid NLE {} must be <= summed linear-candidate NLE {}",
4590 split.total_negative_log_evidence,
4591 pure_linear_baseline
4592 );
4593 assert!(split.total_negative_log_evidence < pure_linear_baseline);
4595 }
4596
4597 #[test]
4598 fn hybrid_split_rejects_empty_slot() {
4599 let slots = vec![hybrid_slot(10.0, 2, 1, 5, 0.0, 0.0), Vec::new()];
4600 assert!(select_hybrid_split(&slots).is_err());
4601 }
4602
4603 fn cand(name: &str, score: f64, edf: f64, log_lik: f64) -> RemlCandidate {
4611 RemlCandidate {
4612 index: 0,
4613 name: name.to_string(),
4614 score,
4615 edf,
4616 log_lik,
4617 family: None,
4618 n_obs: None,
4619 }
4620 }
4621
4622 #[test]
4623 fn ranking_score_is_conditional_aic_when_loglik_and_edf_present() {
4624 let c = cand("m", 999.0, 6.748, -32.0866);
4626 let expected = -2.0 * -32.0866 + 2.0 * 6.748;
4627 assert!((c.ranking_score().expect("finite AIC") - expected).abs() < 1e-9);
4628 }
4629
4630 #[test]
4631 fn ranking_score_refuses_non_finite_log_likelihood_instead_of_using_reml() {
4632 let c = RemlCandidate {
4633 index: 0,
4634 name: "m".to_string(),
4635 score: 151.28,
4636 edf: 6.0,
4637 log_lik: f64::NAN,
4638 family: None,
4639 n_obs: None,
4640 };
4641 let error = c
4642 .ranking_score()
4643 .expect_err("raw REML must not replace a missing likelihood");
4644 assert!(error.contains("requires finite log_likelihood"));
4645 assert!(error.contains("not a substitute ranking estimand"));
4646 }
4647
4648 #[test]
4649 fn compare_models_rejects_pure_noise_smooth_despite_lower_evidence() {
4650 let small = cand("small", 180.526, 6.748, -32.0866);
4657 let big = cand("big", 177.404, 14.250, -32.1212);
4658
4659 assert!(big.score < small.score);
4661
4662 let cmp = compare_reml_fits(vec![small, big]).expect("compare");
4663 assert_eq!(
4664 cmp.winner, "small",
4665 "compare_models must Occam-penalise the pure-noise smooth and pick the smaller model"
4666 );
4667 let small_row = cmp
4670 .score_table
4671 .iter()
4672 .find(|r| r.name == "small")
4673 .expect("small row");
4674 let big_row = cmp
4675 .score_table
4676 .iter()
4677 .find(|r| r.name == "big")
4678 .expect("big row");
4679 assert!((small_row.reml_score - 180.526).abs() < 1e-9);
4680 assert!((big_row.reml_score - 177.404).abs() < 1e-9);
4681 }
4682
4683 #[test]
4684 fn ranking_evidence_ratio_is_akaike_evidence_ratio_not_its_square() {
4685 let delta_aic = 27.68_f64;
4695 let winner = cand("winner", 100.0, 0.0, 0.0);
4696 let loser = cand("loser", 110.0, 0.0, -delta_aic / 2.0);
4697
4698 let cmp = compare_reml_fits(vec![winner, loser]).expect("compare");
4699 assert_eq!(cmp.winner, "winner");
4700
4701 let loser_row = cmp
4702 .ranking
4703 .iter()
4704 .find(|r| r.name == "loser")
4705 .expect("loser ranking row");
4706
4707 assert!((loser_row.delta - delta_aic).abs() < 1e-9);
4709
4710 let expected = (0.5 * delta_aic).exp();
4713 assert!(
4714 (loser_row.evidence_ratio / expected - 1.0).abs() < 1e-9,
4715 "ranking evidence_ratio {} should be exp(½ΔAIC)={}, not exp(ΔAIC)={}",
4716 loser_row.evidence_ratio,
4717 expected,
4718 delta_aic.exp()
4719 );
4720 assert!(loser_row.evidence_ratio < delta_aic.exp() * 0.5);
4722
4723 let loser_score_row = cmp
4728 .score_table
4729 .iter()
4730 .find(|r| r.name == "loser")
4731 .expect("loser score row");
4732 let expected_reml_bf = 10.0_f64.exp();
4733 assert!(
4734 (loser_score_row.bayes_factor_best_over_model / expected_reml_bf - 1.0).abs() < 1e-9,
4735 "raw-REML bayes_factor_best_over_model must stay exp(Δreml)=exp(10), got {}",
4736 loser_score_row.bayes_factor_best_over_model
4737 );
4738 }
4739
4740 #[test]
4741 fn compare_models_keeps_power_for_a_relevant_smooth() {
4742 let small = cand("small", 1025.067, 6.75, -368.985);
4748 let big = cand("big", 199.509, 14.25, -33.165);
4749 let cmp = compare_reml_fits(vec![small, big]).expect("compare");
4750 assert_eq!(
4751 cmp.winner, "big",
4752 "compare_models must retain power: the relevant smooth's model must win"
4753 );
4754 }
4755
4756 #[test]
4757 fn compare_models_rejects_mismatched_observation_counts() {
4758 let with_n = |name: &str, n: usize| RemlCandidate {
4762 index: 0,
4763 name: name.to_string(),
4764 score: 100.0,
4765 edf: 5.0,
4766 log_lik: -40.0,
4767 family: Some("gaussian".to_string()),
4768 n_obs: Some(n),
4769 };
4770 let err = compare_reml_fits(vec![with_n("big", 500), with_n("small", 100)])
4771 .expect_err("cross-n comparison must be rejected");
4772 assert!(
4773 err.contains("number of observations") && err.contains("500") && err.contains("100"),
4774 "n-guard error should name the incomparable counts, got: {err}"
4775 );
4776
4777 compare_reml_fits(vec![with_n("a", 250), with_n("b", 250)])
4779 .expect("same-n comparison must succeed");
4780
4781 let without_n = RemlCandidate {
4784 index: 0,
4785 name: "legacy".to_string(),
4786 score: 90.0,
4787 edf: 4.0,
4788 log_lik: -35.0,
4789 family: Some("gaussian".to_string()),
4790 n_obs: None,
4791 };
4792 compare_reml_fits(vec![with_n("counted", 500), without_n])
4793 .expect("an unconstrained (None) count must not trip the guard");
4794 }
4795}