1use ann_search_rs::prelude::AnnSearchFloat;
16use ann_search_rs::{build_exhaustive_index, query_exhaustive_self};
17use faer::{Mat, MatRef};
18use rayon::prelude::*;
19
20use super::refit::{nmf_refit_h, nmf_refit_w};
21use super::{HalsOpts, NmfInput, StabilisedNmfResult, stabilised_nmf};
22use crate::prelude::*;
23use bixverse_core::ml::clustering::clustering_metrics::silhouette_cosine_unit;
24use bixverse_core::ml::clustering::k_means::{KMeansParamsWrappers, k_means_clusters};
25
26const NORM_COL_CHUNK: usize = 1024;
32
33const INERTIA_ROW_TILE: usize = 256;
35
36const LOCAL_NEIGHBOURHOOD_FRACTION: f64 = 0.3;
39
40const DEFAULT_DENSITY_THRESHOLD: f64 = 0.5;
44
45const DEFAULT_KMEANS_N_INIT: usize = 3;
48
49const DEFAULT_KMEANS_ITERS: usize = 100;
51
52const DEGENERATE_NORM_TOLERANCE: f64 = 1e-12;
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum ConsensusTarget {
64 #[default]
69 HRows,
70 WColumns,
75}
76
77pub fn parse_consensus_target(s: &str) -> Option<ConsensusTarget> {
89 match s.to_lowercase().as_str() {
90 "h" | "h_rows" | "spectra" | "programs" => Some(ConsensusTarget::HRows),
91 "w" | "w_cols" | "w_columns" | "usage" => Some(ConsensusTarget::WColumns),
92 _ => None,
93 }
94}
95
96#[derive(Clone, Copy, Debug)]
98pub struct ConsensusParams<F: BixverseFloat> {
99 pub target: ConsensusTarget,
101 pub n_neighbours: Option<usize>,
105 pub density_threshold: Option<F>,
108 pub kmeans_iters: usize,
110 pub kmeans_n_init: usize,
112 pub seed: u64,
114}
115
116impl<F: BixverseFloat> ConsensusParams<F> {
117 pub fn new(
134 target: ConsensusTarget,
135 n_neighbours: Option<usize>,
136 density_threshold: Option<F>,
137 kmeans_iters: usize,
138 kmeans_n_init: usize,
139 seed: u64,
140 ) -> Self {
141 Self {
142 target,
143 n_neighbours,
144 density_threshold,
145 kmeans_iters,
146 kmeans_n_init,
147 seed,
148 }
149 }
150}
151
152impl<F: BixverseFloat> Default for ConsensusParams<F> {
153 fn default() -> Self {
154 Self {
155 target: ConsensusTarget::default(),
156 n_neighbours: None,
157 density_threshold: Some(F::from_f64(DEFAULT_DENSITY_THRESHOLD).unwrap()),
158 kmeans_iters: DEFAULT_KMEANS_ITERS,
159 kmeans_n_init: DEFAULT_KMEANS_N_INIT,
160 seed: 42,
161 }
162 }
163}
164
165fn resolve_n_neighbours(n_neighbours: Option<usize>, n_runs: usize, n_components: usize) -> usize {
184 let requested = match n_neighbours {
187 Some(l) if l > 0 => l,
188 _ => (LOCAL_NEIGHBOURHOOD_FRACTION * n_runs as f64).ceil() as usize,
189 };
190 requested.clamp(1, n_components.saturating_sub(1).max(1))
191}
192
193#[derive(Clone, Debug)]
199pub struct ConsensusClusters<F: BixverseFloat> {
200 pub labels: Vec<Option<usize>>,
203 pub local_density: Vec<F>,
207 pub kept: Vec<usize>,
209 pub silhouette: Vec<F>,
211 pub stability: F,
213 pub sizes: Vec<usize>,
215 pub consensus: Mat<F>,
219 pub n_dropped: usize,
221 pub n_empty_clusters: usize,
223}
224
225#[derive(Clone, Debug)]
227pub struct ConsensusNmfResult<F: BixverseFloat> {
228 pub w: Mat<F>,
230 pub h: Mat<F>,
232 pub error: F,
234 pub clusters: ConsensusClusters<F>,
236 pub run_errors: Vec<F>,
238}
239
240#[derive(Clone, Debug)]
242pub struct KSweepEntry<F: BixverseFloat> {
243 pub k: usize,
245 pub stability: F,
249 pub best_error: F,
252 pub median_error: F,
254 pub consensus_failed: bool,
258 pub n_dropped: usize,
261 pub n_empty_clusters: usize,
263 pub n_converged: usize,
265}
266
267fn pool_components<F: BixverseFloat + Send + Sync>(
289 res: &StabilisedNmfResult<F>,
290 k: usize,
291 target: ConsensusTarget,
292) -> Mat<F> {
293 let n_runs = res.h_per_run.len();
294 let n_pooled = k * n_runs;
295
296 let mut pooled = match target {
297 ConsensusTarget::HRows => {
298 let n = res.h_per_run[0].ncols();
299 Mat::<F>::from_fn(n_pooled, n, |row, col| {
300 res.h_per_run[row / k][(row % k, col)]
301 })
302 }
303 ConsensusTarget::WColumns => {
304 let m = res.w_all.nrows();
305 let mut pooled = Mat::<F>::zeros(n_pooled, m);
307 pooled
308 .par_col_iter_mut()
309 .enumerate()
310 .for_each(|(col, mut out)| {
311 for row in 0..n_pooled {
312 out[row] = res.w_all[(col, row)];
313 }
314 });
315 pooled
316 }
317 };
318
319 normalise_rows_to_unit(&mut pooled);
320
321 pooled
322}
323
324fn row_norms<F: BixverseFloat + Send + Sync>(mat: MatRef<F>) -> Vec<F> {
339 let n_rows = mat.nrows();
340 let sq_sums: Vec<f64> = (0..mat.ncols())
341 .into_par_iter()
342 .with_min_len(NORM_COL_CHUNK)
343 .fold(
344 || vec![0f64; n_rows],
345 |mut acc, j| {
346 for (a, &x) in acc.iter_mut().zip(mat.col(j).iter()) {
347 let x = x.to_f64().unwrap();
348 *a += x * x;
349 }
350 acc
351 },
352 )
353 .reduce(
354 || vec![0f64; n_rows],
355 |mut a, b| {
356 a.iter_mut().zip(&b).for_each(|(x, y)| *x += y);
357 a
358 },
359 );
360
361 sq_sums
362 .into_iter()
363 .map(|s| F::from_f64(s.sqrt()).unwrap())
364 .collect()
365}
366
367fn normalise_rows_to_unit<F: BixverseFloat + Send + Sync>(mat: &mut Mat<F>) {
373 let tol = F::from_f64(DEGENERATE_NORM_TOLERANCE).unwrap();
374 let inv: Vec<F> = row_norms(mat.as_ref())
375 .into_iter()
376 .map(|norm| {
377 if norm <= tol {
378 F::one()
379 } else {
380 F::one() / norm
381 }
382 })
383 .collect();
384
385 mat.par_col_iter_mut().for_each(|mut col| {
386 for (i, &scale) in inv.iter().enumerate() {
387 col[i] *= scale;
388 }
389 });
390}
391
392fn subset_rows<F: BixverseFloat>(mat: MatRef<F>, rows: &[usize]) -> Mat<F> {
406 Mat::<F>::from_fn(rows.len(), mat.ncols(), |i, j| mat[(rows[i], j)])
407}
408
409fn local_densities<F>(data: MatRef<F>, l: usize, verbose: usize) -> Result<Vec<F>, MethodsErrors>
430where
431 F: BixverseFloat + AnnSearchFloat,
432{
433 let index = build_exhaustive_index(data, "cosine");
434 let (indices, distances) = query_exhaustive_self(&index, l + 1, true, verbose > 1)?;
436 let distances = distances.expect("distances requested from the exhaustive index");
437
438 Ok(indices
439 .into_par_iter()
440 .zip(distances)
441 .enumerate()
442 .map(|(i, (idx_row, dist_row))| {
443 let mut sum = F::zero();
448 let mut count = 0usize;
449 for (&idx, &d) in idx_row.iter().zip(dist_row.iter()) {
450 if idx == i {
451 continue;
452 }
453 sum += d;
454 count += 1;
455 if count == l {
456 break;
457 }
458 }
459
460 if count == 0 {
461 return F::zero();
462 }
463 sum / F::from_usize(count).unwrap()
464 })
465 .collect())
466}
467
468fn cluster_best_of_n<F>(
481 data: MatRef<F>,
482 k: usize,
483 params: &ConsensusParams<F>,
484) -> Result<Vec<usize>, MethodsErrors>
485where
486 F: BixverseFloat + AnnSearchFloat,
487{
488 let kmeans_params = KMeansParamsWrappers::new(params.kmeans_iters.max(1), None, None);
491 let n_init = params.kmeans_n_init.max(1);
492
493 let mut best: Option<(F, Vec<usize>)> = None;
494 for i in 0..n_init {
495 let (centroids, assignments) = k_means_clusters(
496 data,
497 "euclidean",
498 k,
499 Some(kmeans_params),
500 (params.seed + i as u64) as usize,
501 false,
502 )?;
503 let inertia = inertia(data, centroids.as_ref(), &assignments);
504 if best.as_ref().is_none_or(|(b, _)| inertia < *b) {
505 best = Some((inertia, assignments));
506 }
507 }
508
509 Ok(best.expect("at least one k-means restart runs").1)
510}
511
512fn inertia<F: BixverseFloat + Send + Sync>(
527 data: MatRef<F>,
528 centroids: MatRef<F>,
529 assignments: &[usize],
530) -> F {
531 let n = data.nrows();
532 let dim = data.ncols();
533
534 let total: f64 = (0..n.div_ceil(INERTIA_ROW_TILE))
537 .into_par_iter()
538 .map(|tile| {
539 let i0 = tile * INERTIA_ROW_TILE;
540 let rows = INERTIA_ROW_TILE.min(n - i0);
541 let labels = &assignments[i0..i0 + rows];
542 let mut acc = [0f64; INERTIA_ROW_TILE];
543 for j in 0..dim {
544 let col = &data.col(j).try_as_col_major().unwrap().as_slice()[i0..i0 + rows];
545 for ((a, &x), &label) in acc.iter_mut().zip(col).zip(labels) {
546 let d = (x - centroids[(label, j)]).to_f64().unwrap();
547 *a += d * d;
548 }
549 }
550 acc[..rows].iter().sum::<f64>()
551 })
552 .sum();
553
554 F::from_f64(total).unwrap()
555}
556
557fn median_consensus<F: BixverseFloat + Send + Sync>(
574 data: MatRef<F>,
575 assignments: &[usize],
576 k: usize,
577) -> Mat<F> {
578 let dim = data.ncols();
579 let mut members: Vec<Vec<usize>> = vec![Vec::new(); k];
580 for (i, &label) in assignments.iter().enumerate() {
581 members[label].push(i);
582 }
583
584 let mut consensus = Mat::<F>::zeros(k, dim);
585 for (c, rows) in members.iter().enumerate() {
586 if rows.is_empty() {
587 continue;
588 }
589 let column: Vec<F> = (0..dim)
590 .into_par_iter()
591 .map(|j| {
592 let mut values: Vec<F> = rows.iter().map(|&i| data[(i, j)]).collect();
593 median_in_place(&mut values)
594 })
595 .collect();
596 for (j, value) in column.into_iter().enumerate() {
597 consensus[(c, j)] = value;
598 }
599 }
600
601 normalise_rows_to_unit(&mut consensus);
604
605 consensus
606}
607
608fn median_in_place<F: BixverseFloat>(values: &mut [F]) -> F {
618 let n = values.len();
619 let mid = n / 2;
620 values.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
621
622 if n.is_multiple_of(2) {
623 let upper = values[mid];
624 let lower = values[..mid]
627 .iter()
628 .copied()
629 .fold(F::neg_infinity(), |acc, x| if x > acc { x } else { acc });
630 (lower + upper) / F::from_f64(2.0).unwrap()
631 } else {
632 values[mid]
633 }
634}
635
636pub fn median<F: BixverseFloat>(values: &[F]) -> F {
646 let mut copy = values.to_vec();
647 median_in_place(&mut copy)
648}
649
650pub fn consensus_from_restarts<F>(
686 res: &StabilisedNmfResult<F>,
687 k: usize,
688 params: &ConsensusParams<F>,
689 verbose: usize,
690) -> Result<ConsensusClusters<F>, MethodsErrors>
691where
692 F: BixverseFloat + AnnSearchFloat,
693{
694 let n_runs = res.h_per_run.len();
695 if k < 2 {
696 return Err(MethodsErrors::NmfConsensusInvalidK { k });
697 }
698 if n_runs < 2 {
699 return Err(MethodsErrors::NmfConsensusTooFewRuns { n_runs });
700 }
701 let restart_k = res.h_per_run[0].nrows();
707 if restart_k != k || res.w_all.ncols() != k * n_runs {
708 return Err(MethodsErrors::NmfConsensusKMismatch {
709 requested: k,
710 restarts: restart_k,
711 });
712 }
713
714 let verbosity = parse_verbosity_level(verbose);
715 let pooled = pool_components(res, k, params.target);
716 let n_pooled = pooled.nrows();
717
718 let tol = F::from_f64(DEGENERATE_NORM_TOLERANCE).unwrap();
720 let mut local_density = vec![F::infinity(); n_pooled];
721 let pooled_norms = row_norms(pooled.as_ref());
722 let alive: Vec<usize> = (0..n_pooled).filter(|&i| pooled_norms[i] > tol).collect();
723
724 if alive.len() < k {
725 return Err(MethodsErrors::NmfConsensusTooFewComponents {
726 k,
727 n_surviving: alive.len(),
728 });
729 }
730
731 let alive_mat = subset_rows(pooled.as_ref(), &alive);
733 let l = resolve_n_neighbours(params.n_neighbours, n_runs, alive.len());
734 let densities = local_densities(alive_mat.as_ref(), l, verbose)?;
735 for (&idx, &d) in alive.iter().zip(densities.iter()) {
736 local_density[idx] = d;
737 }
738
739 let kept: Vec<usize> = match params.density_threshold {
740 Some(threshold) => alive
741 .iter()
742 .copied()
743 .filter(|&i| local_density[i] <= threshold)
744 .collect(),
745 None => alive.clone(),
746 };
747
748 if kept.len() < k {
749 return Err(MethodsErrors::NmfConsensusTooFewComponents {
750 k,
751 n_surviving: kept.len(),
752 });
753 }
754
755 if verbosity.normal_verbosity() {
756 println!(
757 " Consensus NMF (k = {}): {} of {} components kept, {} neighbours per density estimate",
758 k,
759 kept.len(),
760 n_pooled,
761 l
762 );
763 }
764
765 let survivors = subset_rows(pooled.as_ref(), &kept);
766 let assignments = cluster_best_of_n(survivors.as_ref(), k, params)?;
767
768 let mut sizes = vec![0usize; k];
769 for &label in &assignments {
770 sizes[label] += 1;
771 }
772 let n_empty_clusters = sizes.iter().filter(|&&s| s == 0).count();
773
774 let (silhouette, stability) = silhouette_cosine_unit(survivors.as_ref(), &assignments, k);
775 let consensus = median_consensus(survivors.as_ref(), &assignments, k);
776
777 let mut labels = vec![None; n_pooled];
778 for (&idx, &label) in kept.iter().zip(assignments.iter()) {
779 labels[idx] = Some(label);
780 }
781
782 Ok(ConsensusClusters {
783 labels,
784 local_density,
785 n_dropped: n_pooled - kept.len(),
786 kept,
787 silhouette,
788 stability,
789 sizes,
790 consensus,
791 n_empty_clusters,
792 })
793}
794
795pub fn nmf_consensus<F, In>(
823 v: &In,
824 k: usize,
825 n_runs: usize,
826 base_seed: u64,
827 opts: &HalsOpts<F>,
828 params: Option<ConsensusParams<F>>,
829 verbose: usize,
830) -> Result<ConsensusNmfResult<F>, MethodsErrors>
831where
832 F: BixverseFloat + AnnSearchFloat + Send + Sync,
833 In: NmfInput<F> + Sync,
834{
835 let params = params.unwrap_or_default();
836 if k < 2 {
837 return Err(MethodsErrors::NmfConsensusInvalidK { k });
838 }
839 if n_runs < 2 {
840 return Err(MethodsErrors::NmfConsensusTooFewRuns { n_runs });
841 }
842
843 let restarts = stabilised_nmf(v, k, n_runs, base_seed, opts, verbose)?;
844 let clusters = consensus_from_restarts(&restarts, k, ¶ms, verbose)?;
845
846 if let Some(cluster) = clusters.sizes.iter().position(|&s| s == 0) {
847 return Err(MethodsErrors::NmfConsensusEmptyCluster { cluster, k });
848 }
849
850 let sq_frob = v.sq_frob();
851 let (w, h, loss) = match params.target {
852 ConsensusTarget::HRows => {
853 let h = clusters.consensus.clone();
855 let (w, loss) = nmf_refit_w(v, h.as_ref(), opts)?;
856 (w, h, loss)
857 }
858 ConsensusTarget::WColumns => {
859 let w = clusters.consensus.transpose().to_owned();
861 let (h, loss) = nmf_refit_h(v, w.as_ref(), opts)?;
862 (w, h, loss)
863 }
864 };
865
866 let denom = if sq_frob > F::zero() {
867 sq_frob
868 } else {
869 F::one()
870 };
871 let run_errors: Vec<F> = restarts.losses.iter().map(|&l| l / denom).collect();
872
873 Ok(ConsensusNmfResult {
874 w,
875 h,
876 error: loss / denom,
877 clusters,
878 run_errors,
879 })
880}
881
882pub fn nmf_k_sweep<F, In>(
913 v: &In,
914 k_range: &[usize],
915 n_runs: usize,
916 base_seed: u64,
917 opts: &HalsOpts<F>,
918 params: Option<ConsensusParams<F>>,
919 verbose: usize,
920) -> Result<Vec<KSweepEntry<F>>, MethodsErrors>
921where
922 F: BixverseFloat + AnnSearchFloat + Send + Sync,
923 In: NmfInput<F> + Sync,
924{
925 if k_range.is_empty() {
926 return Err(MethodsErrors::NmfKSweepEmptyRange);
927 }
928 if n_runs < 2 {
929 return Err(MethodsErrors::NmfConsensusTooFewRuns { n_runs });
930 }
931 if let Some(&k) = k_range.iter().find(|&&k| k < 2) {
932 return Err(MethodsErrors::NmfConsensusInvalidK { k });
933 }
934
935 let params = params.unwrap_or_default();
936 let verbosity = parse_verbosity_level(verbose);
937 let sq_frob = v.sq_frob();
938 let denom = if sq_frob > F::zero() {
939 sq_frob
940 } else {
941 F::one()
942 };
943
944 let mut out = Vec::with_capacity(k_range.len());
945 for (i, &k) in k_range.iter().enumerate() {
946 if verbosity.normal_verbosity() {
947 println!(
948 "Running NMF k sweep: k = {} ({} of {})",
949 k,
950 i + 1,
951 k_range.len()
952 );
953 }
954
955 let seed = base_seed + (i * n_runs) as u64;
956 let restarts = stabilised_nmf(v, k, n_runs, seed, opts, verbose.saturating_sub(1))?;
957
958 let clusters = match consensus_from_restarts(
959 &restarts,
960 k,
961 ¶ms,
962 verbose.saturating_sub(1),
963 ) {
964 Ok(clusters) => Some(clusters),
965 Err(MethodsErrors::NmfConsensusTooFewComponents { n_surviving, .. }) => {
966 if verbosity.normal_verbosity() {
967 println!(
968 " k = {}: only {} components survived the density filter (need {}); stability not scored",
969 k, n_surviving, k
970 );
971 }
972 None
973 }
974 Err(e) => return Err(e),
975 };
976
977 out.push(KSweepEntry {
978 k,
979 stability: clusters
980 .as_ref()
981 .map(|c| c.stability)
982 .unwrap_or_else(F::nan),
983 best_error: restarts.losses[restarts.best_idx] / denom,
984 median_error: median(&restarts.losses) / denom,
985 consensus_failed: clusters.is_none(),
986 n_dropped: clusters.as_ref().map(|c| c.n_dropped).unwrap_or(k * n_runs),
987 n_empty_clusters: clusters.as_ref().map(|c| c.n_empty_clusters).unwrap_or(0),
988 n_converged: restarts.converged.iter().filter(|&&c| c).count(),
989 });
990 }
991
992 Ok(out)
993}
994
995#[cfg(test)]
1000mod tests {
1001 use super::*;
1002 use crate::methods::nmf_hals::NmfInit;
1003 use crate::methods::nmf_hals::dense::DenseInput;
1004 use approx::assert_relative_eq;
1005
1006 fn rank_k_input(m: usize, n: usize, k: usize) -> Mat<f64> {
1009 let w: Mat<f64> = Mat::from_fn(m, k, |i, j| {
1010 if i % k == j { 5.0 } else { 0.3 }
1012 });
1013 let h: Mat<f64> = Mat::from_fn(k, n, |i, j| if j % k == i { 4.0 } else { 0.2 });
1014 Mat::from_fn(m, n, |i, j| (0..k).map(|r| w[(i, r)] * h[(r, j)]).sum())
1015 }
1016
1017 fn test_opts() -> HalsOpts<f64> {
1018 HalsOpts::<f64>::new(200, 1e-8, 1e-12, 10, NmfInit::Nndsvd)
1019 }
1020
1021 #[test]
1024 fn consensus_labels_cover_every_pooled_component() {
1025 let (m, n, k, n_runs) = (25, 18, 2, 4);
1026 let v = rank_k_input(m, n, k);
1027 let input = DenseInput::new(v.as_ref()).unwrap();
1028
1029 let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1030 let params = ConsensusParams::<f64>::default();
1031 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1032
1033 assert_eq!(clusters.labels.len(), k * n_runs);
1034 assert_eq!(clusters.local_density.len(), k * n_runs);
1035 assert_eq!(clusters.kept.len(), clusters.silhouette.len());
1036 assert_eq!(clusters.sizes.iter().sum::<usize>(), clusters.kept.len());
1037 assert_eq!(clusters.n_dropped, k * n_runs - clusters.kept.len());
1038
1039 for (i, label) in clusters.labels.iter().enumerate() {
1040 assert_eq!(label.is_some(), clusters.kept.contains(&i));
1041 }
1042 }
1043
1044 #[test]
1047 fn consensus_rows_are_unit_norm() {
1048 let (m, n, k, n_runs) = (25, 18, 2, 4);
1049 let v = rank_k_input(m, n, k);
1050 let input = DenseInput::new(v.as_ref()).unwrap();
1051
1052 let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1053 let clusters =
1054 consensus_from_restarts(&restarts, k, &ConsensusParams::<f64>::default(), 0).unwrap();
1055
1056 assert_eq!(clusters.consensus.nrows(), k);
1057 assert_eq!(clusters.consensus.ncols(), n);
1058 for c in 0..k {
1059 assert_relative_eq!(
1060 row_norms(clusters.consensus.as_ref())[c],
1061 1.0,
1062 epsilon = 1e-10
1063 );
1064 }
1065 }
1066
1067 #[test]
1070 fn consensus_stability_high_on_exact_rank_k() {
1071 let (m, n, k, n_runs) = (30, 24, 3, 5);
1072 let v = rank_k_input(m, n, k);
1073 let input = DenseInput::new(v.as_ref()).unwrap();
1074
1075 let restarts = stabilised_nmf(&input, k, n_runs, 7, &test_opts(), 0).unwrap();
1076 let clusters =
1077 consensus_from_restarts(&restarts, k, &ConsensusParams::<f64>::default(), 0).unwrap();
1078
1079 assert!(
1080 clusters.stability > 0.95,
1081 "stability {}",
1082 clusters.stability
1083 );
1084 }
1085
1086 #[test]
1089 fn consensus_is_deterministic() {
1090 let (m, n, k, n_runs) = (25, 18, 2, 4);
1091 let v = rank_k_input(m, n, k);
1092 let input = DenseInput::new(v.as_ref()).unwrap();
1093 let params = ConsensusParams::<f64>::default();
1094
1095 let first = stabilised_nmf(&input, k, n_runs, 3, &test_opts(), 0).unwrap();
1096 let a = consensus_from_restarts(&first, k, ¶ms, 0).unwrap();
1097 let second = stabilised_nmf(&input, k, n_runs, 3, &test_opts(), 0).unwrap();
1098 let b = consensus_from_restarts(&second, k, ¶ms, 0).unwrap();
1099
1100 assert_eq!(a.labels, b.labels);
1101 assert_relative_eq!(a.stability, b.stability, epsilon = 1e-12);
1102 }
1103
1104 #[test]
1108 fn density_filter_drops_an_isolated_component() {
1109 let (m, n, k, n_runs) = (25, 18, 2, 4);
1110 let v = rank_k_input(m, n, k);
1111 let input = DenseInput::new(v.as_ref()).unwrap();
1112 let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1113
1114 let last = restarts.h_per_run.last_mut().unwrap();
1117 for j in 0..n {
1118 last[(0, j)] = if j == n - 1 { 1.0 } else { 0.0 };
1119 }
1120 let outlier = (n_runs - 1) * k;
1121
1122 let unfiltered =
1123 ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
1124 let all = consensus_from_restarts(&restarts, k, &unfiltered, 0).unwrap();
1125 assert_eq!(all.n_dropped, 0);
1126
1127 let worst = all
1129 .local_density
1130 .iter()
1131 .enumerate()
1132 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1133 .map(|(i, _)| i)
1134 .unwrap();
1135 assert_eq!(worst, outlier, "densities {:?}", all.local_density);
1136
1137 let filtered =
1138 consensus_from_restarts(&restarts, k, &ConsensusParams::default(), 0).unwrap();
1139 assert!(filtered.labels[outlier].is_none());
1140 assert!(filtered.n_dropped >= 1);
1141 }
1142
1143 #[test]
1146 fn collapsed_components_are_dropped() {
1147 let (m, n, k, n_runs) = (25, 18, 2, 5);
1148 let v = rank_k_input(m, n, k);
1149 let input = DenseInput::new(v.as_ref()).unwrap();
1150 let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1151
1152 for j in 0..n {
1153 restarts.h_per_run[1][(0, j)] = 0.0;
1154 }
1155 let collapsed = k;
1156
1157 let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
1158 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1159
1160 assert!(clusters.labels[collapsed].is_none());
1161 assert!(clusters.local_density[collapsed].is_infinite());
1162 assert_eq!(clusters.n_dropped, 1);
1163 }
1164
1165 #[test]
1167 fn both_targets_return_documented_shapes() {
1168 let (m, n, k, n_runs) = (25, 18, 2, 4);
1169 let v = rank_k_input(m, n, k);
1170 let input = DenseInput::new(v.as_ref()).unwrap();
1171 let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1172
1173 let h_params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
1174 let h_res = consensus_from_restarts(&restarts, k, &h_params, 0).unwrap();
1175 assert_eq!(h_res.consensus.ncols(), n);
1176
1177 let w_params =
1178 ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
1179 let w_res = consensus_from_restarts(&restarts, k, &w_params, 0).unwrap();
1180 assert_eq!(w_res.consensus.ncols(), m);
1181 }
1182
1183 #[test]
1186 fn nmf_consensus_returns_usable_factors() {
1187 let (m, n, k) = (30, 24, 3);
1188 let v = rank_k_input(m, n, k);
1189 let input = DenseInput::new(v.as_ref()).unwrap();
1190
1191 let res = nmf_consensus(&input, k, 4, 0, &test_opts(), None, 0).unwrap();
1192
1193 assert_eq!(res.w.nrows(), m);
1194 assert_eq!(res.w.ncols(), k);
1195 assert_eq!(res.h.nrows(), k);
1196 assert_eq!(res.h.ncols(), n);
1197 assert_eq!(res.run_errors.len(), 4);
1198 assert!(res.error < 1e-6, "relative error {}", res.error);
1202
1203 for r in 0..k {
1205 let norm = (0..n).map(|j| res.h[(r, j)].powi(2)).sum::<f64>().sqrt();
1206 assert_relative_eq!(norm, 1.0, epsilon = 1e-10);
1207 }
1208 }
1209
1210 #[test]
1212 fn nmf_consensus_w_target_normalises_w() {
1213 let (m, n, k) = (30, 24, 3);
1214 let v = rank_k_input(m, n, k);
1215 let input = DenseInput::new(v.as_ref()).unwrap();
1216 let params = ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
1217
1218 let res = nmf_consensus(&input, k, 4, 0, &test_opts(), Some(params), 0).unwrap();
1219
1220 assert_eq!(res.w.nrows(), m);
1221 assert_eq!(res.h.ncols(), n);
1222 assert!(res.error < 1e-6, "relative error {}", res.error);
1223 for c in 0..k {
1224 let norm = (0..m).map(|i| res.w[(i, c)].powi(2)).sum::<f64>().sqrt();
1225 assert_relative_eq!(norm, 1.0, epsilon = 1e-10);
1226 }
1227 }
1228
1229 #[test]
1231 fn k_sweep_returns_one_row_per_k() {
1232 let (m, n) = (30, 24);
1233 let v = rank_k_input(m, n, 3);
1234 let input = DenseInput::new(v.as_ref()).unwrap();
1235
1236 let sweep = nmf_k_sweep(&input, &[2, 3], 3, 0, &test_opts(), None, 0).unwrap();
1237
1238 assert_eq!(sweep.len(), 2);
1239 assert_eq!(sweep[0].k, 2);
1240 assert_eq!(sweep[1].k, 3);
1241 for entry in &sweep {
1242 assert!(entry.n_converged <= 3);
1243 assert!(entry.best_error <= entry.median_error + 1e-12);
1244 }
1245 }
1246
1247 #[test]
1250 fn invalid_arguments_error() {
1251 let v = rank_k_input(20, 15, 2);
1252 let input = DenseInput::new(v.as_ref()).unwrap();
1253 let opts = test_opts();
1254
1255 assert!(matches!(
1256 nmf_consensus(&input, 1, 4, 0, &opts, None, 0),
1257 Err(MethodsErrors::NmfConsensusInvalidK { k: 1 })
1258 ));
1259 assert!(matches!(
1260 nmf_consensus(&input, 2, 1, 0, &opts, None, 0),
1261 Err(MethodsErrors::NmfConsensusTooFewRuns { n_runs: 1 })
1262 ));
1263 assert!(matches!(
1264 nmf_k_sweep(&input, &[], 4, 0, &opts, None, 0),
1265 Err(MethodsErrors::NmfKSweepEmptyRange)
1266 ));
1267 assert!(matches!(
1268 nmf_k_sweep(&input, &[2, 1], 4, 0, &opts, None, 0),
1269 Err(MethodsErrors::NmfConsensusInvalidK { k: 1 })
1270 ));
1271 }
1272
1273 #[test]
1276 fn consensus_rejects_mismatched_k() {
1277 let v = rank_k_input(25, 18, 2);
1278 let input = DenseInput::new(v.as_ref()).unwrap();
1279 let restarts = stabilised_nmf(&input, 2, 4, 0, &test_opts(), 0).unwrap();
1280
1281 assert!(matches!(
1282 consensus_from_restarts(&restarts, 3, &ConsensusParams::<f64>::default(), 0),
1283 Err(MethodsErrors::NmfConsensusKMismatch {
1284 requested: 3,
1285 restarts: 2
1286 })
1287 ));
1288 }
1289
1290 #[test]
1295 fn local_density_matches_hand_computed_values() {
1296 let data: Mat<f64> = Mat::from_fn(4, 2, |i, j| match (i, j) {
1298 (3, 0) => 0.0,
1299 (3, 1) => 1.0,
1300 (_, 0) => 1.0,
1301 _ => 0.0,
1302 });
1303
1304 let densities = local_densities(data.as_ref(), 2, 0).unwrap();
1306 for d in densities.iter().take(3) {
1307 assert_relative_eq!(d, &0.0, epsilon = 1e-12);
1308 }
1309 assert_relative_eq!(densities[3], 1.0, epsilon = 1e-12);
1311 }
1312
1313 #[test]
1315 fn neighbourhood_resolution_is_clamped() {
1316 assert_eq!(resolve_n_neighbours(None, 20, 100), 6);
1318 assert_eq!(resolve_n_neighbours(None, 2, 100), 1);
1320 assert_eq!(resolve_n_neighbours(Some(50), 20, 10), 9);
1322 assert_eq!(resolve_n_neighbours(Some(0), 20, 1), 1);
1324 }
1325
1326 #[test]
1329 fn median_handles_both_parities() {
1330 assert_relative_eq!(median(&[3.0, 1.0, 2.0]), 2.0, epsilon = 1e-12);
1331 assert_relative_eq!(median(&[4.0, 1.0, 3.0, 2.0]), 2.5, epsilon = 1e-12);
1332 assert_relative_eq!(median(&[5.0]), 5.0, epsilon = 1e-12);
1333 assert_relative_eq!(median(&[2.0, 2.0, 2.0, 8.0]), 2.0, epsilon = 1e-12);
1334 }
1335
1336 #[test]
1339 fn median_consensus_resists_an_outlier() {
1340 let data: Mat<f64> = Mat::from_fn(4, 2, |i, j| match (i, j) {
1342 (3, 0) => 0.0,
1343 (3, 1) => 1.0,
1344 (_, 0) => 1.0,
1345 _ => 0.0,
1346 });
1347 let consensus = median_consensus(data.as_ref(), &[0, 0, 0, 0], 1);
1348
1349 assert_relative_eq!(consensus[(0, 0)], 1.0, epsilon = 1e-10);
1350 assert_relative_eq!(consensus[(0, 1)], 0.0, epsilon = 1e-10);
1351 }
1352
1353 #[test]
1356 fn median_consensus_leaves_empty_clusters_zero() {
1357 let data: Mat<f64> = Mat::from_fn(2, 3, |_, j| if j == 0 { 1.0 } else { 0.0 });
1358 let consensus = median_consensus(data.as_ref(), &[0, 0], 2);
1359
1360 assert_relative_eq!(row_norms(consensus.as_ref())[0], 1.0, epsilon = 1e-10);
1361 assert_relative_eq!(row_norms(consensus.as_ref())[1], 0.0, epsilon = 1e-10);
1362 }
1363
1364 #[test]
1367 #[cfg(feature = "large-test")]
1368 fn k_sweep_peaks_at_the_true_rank() {
1370 let (m, n, k_true) = (200, 120, 5);
1371
1372 let w: Mat<f64> = Mat::from_fn(
1374 m,
1375 k_true,
1376 |i, j| {
1377 if i * k_true / m == j { 6.0 } else { 0.2 }
1378 },
1379 );
1380 let h: Mat<f64> = Mat::from_fn(
1381 k_true,
1382 n,
1383 |i, j| {
1384 if j * k_true / n == i { 5.0 } else { 0.15 }
1385 },
1386 );
1387
1388 let mut state = 987654321u64;
1390 let mut v = Mat::<f64>::zeros(m, n);
1391 for i in 0..m {
1392 for j in 0..n {
1393 state = state
1394 .wrapping_mul(6364136223846793005)
1395 .wrapping_add(1442695040888963407);
1396 let jitter = ((state >> 33) as f64) / ((1u64 << 31) as f64) - 1.0;
1397 let clean: f64 = (0..k_true).map(|r| w[(i, r)] * h[(r, j)]).sum();
1398 v[(i, j)] = (clean * (1.0 + 0.05 * jitter)).max(0.0);
1399 }
1400 }
1401
1402 let input = DenseInput::new(v.as_ref()).unwrap();
1403 let opts = HalsOpts::<f64>::new(400, 1e-7, 1e-12, 10, NmfInit::Nndsvd);
1404 let k_range: Vec<usize> = (2..=8).collect();
1405
1406 let sweep = nmf_k_sweep(&input, &k_range, 20, 11, &opts, None, 0).unwrap();
1407
1408 let best = sweep
1409 .iter()
1410 .max_by(|a, b| a.stability.partial_cmp(&b.stability).unwrap())
1411 .unwrap();
1412 assert_eq!(
1413 best.k,
1414 k_true,
1415 "stability curve: {:?}",
1416 sweep.iter().map(|e| (e.k, e.stability)).collect::<Vec<_>>()
1417 );
1418
1419 for pair in sweep.windows(2) {
1421 assert!(
1422 pair[1].best_error <= pair[0].best_error + 1e-3,
1423 "error rose from k = {} ({}) to k = {} ({})",
1424 pair[0].k,
1425 pair[0].best_error,
1426 pair[1].k,
1427 pair[1].best_error
1428 );
1429 }
1430 }
1431
1432 #[test]
1437 fn w_columns_pooled_ordering_matches_w_all() {
1438 let (m, n, k, n_runs) = (25, 18, 2, 4);
1439 let v = rank_k_input(m, n, k);
1440 let input = DenseInput::new(v.as_ref()).unwrap();
1441 let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1442
1443 let (run, comp) = (2usize, 1usize);
1445 let target_col = run * k + comp;
1446 for row in 0..m {
1447 restarts.w_all[(row, target_col)] = if row == m - 1 { 1.0 } else { 0.0 };
1448 }
1449
1450 let params = ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
1451 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1452
1453 let worst = clusters
1454 .local_density
1455 .iter()
1456 .enumerate()
1457 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1458 .map(|(i, _)| i)
1459 .unwrap();
1460 assert_eq!(worst, target_col, "densities {:?}", clusters.local_density);
1461 }
1462
1463 #[test]
1465 fn h_rows_pooled_ordering_matches_run_blocks() {
1466 let (m, n, k, n_runs) = (25, 18, 2, 4);
1467 let v = rank_k_input(m, n, k);
1468 let input = DenseInput::new(v.as_ref()).unwrap();
1469 let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1470
1471 let (run, comp) = (2usize, 1usize);
1472 for j in 0..n {
1473 restarts.h_per_run[run][(comp, j)] = if j == n - 1 { 1.0 } else { 0.0 };
1474 }
1475
1476 let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
1477 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1478
1479 let worst = clusters
1480 .local_density
1481 .iter()
1482 .enumerate()
1483 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
1484 .map(|(i, _)| i)
1485 .unwrap();
1486 assert_eq!(worst, run * k + comp);
1487 }
1488
1489 #[test]
1493 fn density_filter_partitions_on_the_threshold() {
1494 let (m, n, k, n_runs) = (25, 18, 2, 5);
1495 let v = rank_k_input(m, n, k);
1496 let input = DenseInput::new(v.as_ref()).unwrap();
1497 let restarts = stabilised_nmf(&input, k, n_runs, 4, &test_opts(), 0).unwrap();
1498
1499 let unfiltered = consensus_from_restarts(
1501 &restarts,
1502 k,
1503 &ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42),
1504 0,
1505 )
1506 .unwrap();
1507 let mut densities: Vec<f64> = unfiltered
1508 .local_density
1509 .iter()
1510 .copied()
1511 .filter(|d| d.is_finite())
1512 .collect();
1513 densities.sort_by(|a, b| a.partial_cmp(b).unwrap());
1514 let threshold = densities[densities.len() / 2];
1515
1516 let params =
1517 ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(threshold), 100, 3, 42);
1518 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1519
1520 for &i in &clusters.kept {
1521 assert!(
1522 clusters.local_density[i] <= threshold,
1523 "kept component {i} has density {} above {threshold}",
1524 clusters.local_density[i]
1525 );
1526 }
1527 for i in 0..clusters.labels.len() {
1528 if clusters.labels[i].is_none() && clusters.local_density[i].is_finite() {
1529 assert!(
1530 clusters.local_density[i] > threshold,
1531 "dropped component {i} has density {} at or below {threshold}",
1532 clusters.local_density[i]
1533 );
1534 }
1535 }
1536 }
1537
1538 #[test]
1542 fn degenerate_pool_leaves_an_empty_cluster() {
1543 let (m, n, k, n_runs) = (25, 18, 3, 4);
1544 let v = rank_k_input(m, n, 2);
1545 let input = DenseInput::new(v.as_ref()).unwrap();
1546 let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
1547
1548 for r in 0..n_runs {
1551 for c in 0..k {
1552 for j in 0..n {
1553 restarts.h_per_run[r][(c, j)] = if j % 2 == c % 2 { 1.0 } else { 0.0 };
1554 }
1555 }
1556 }
1557
1558 let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 1, 42);
1559 let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
1560 assert!(clusters.n_empty_clusters > 0, "sizes {:?}", clusters.sizes);
1561 }
1562
1563 #[test]
1567 fn k_sweep_records_an_over_pruned_k() {
1568 let (m, n) = (25, 18);
1569 let v = rank_k_input(m, n, 2);
1570 let input = DenseInput::new(v.as_ref()).unwrap();
1571 let params =
1573 ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(-1.0), 100, 3, 42);
1574
1575 let sweep = nmf_k_sweep(&input, &[2, 3], 3, 0, &test_opts(), Some(params), 0).unwrap();
1576
1577 assert_eq!(sweep.len(), 2);
1578 for entry in &sweep {
1579 assert!(entry.consensus_failed);
1580 assert!(entry.stability.is_nan());
1581 assert!(entry.best_error.is_finite());
1583 assert!(entry.median_error.is_finite());
1584 }
1585 }
1586
1587 #[test]
1590 fn over_pruning_errors_for_a_single_k() {
1591 let v = rank_k_input(25, 18, 2);
1592 let input = DenseInput::new(v.as_ref()).unwrap();
1593 let params =
1594 ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(-1.0), 100, 3, 42);
1595
1596 assert!(matches!(
1597 nmf_consensus(&input, 2, 4, 0, &test_opts(), Some(params), 0),
1598 Err(MethodsErrors::NmfConsensusTooFewComponents { k: 2, .. })
1599 ));
1600 }
1601
1602 #[test]
1605 fn inconsistent_w_all_errors_rather_than_panicking() {
1606 let v = rank_k_input(25, 18, 2);
1607 let input = DenseInput::new(v.as_ref()).unwrap();
1608 let mut restarts = stabilised_nmf(&input, 2, 4, 0, &test_opts(), 0).unwrap();
1609
1610 restarts.w_all = Mat::<f64>::from_fn(25, 2, |i, j| restarts.w_all[(i, j)]);
1612
1613 assert!(matches!(
1614 consensus_from_restarts(&restarts, 2, &ConsensusParams::<f64>::default(), 0),
1615 Err(MethodsErrors::NmfConsensusKMismatch { .. })
1616 ));
1617 }
1618
1619 #[test]
1623 fn consensus_runs_in_f32() {
1624 let (m, n, k, n_runs) = (30, 24, 3, 4);
1625 let v: Mat<f32> = {
1626 let w: Mat<f32> = Mat::from_fn(m, k, |i, j| if i % k == j { 5.0 } else { 0.3 });
1627 let h: Mat<f32> = Mat::from_fn(k, n, |i, j| if j % k == i { 4.0 } else { 0.2 });
1628 Mat::from_fn(m, n, |i, j| (0..k).map(|r| w[(i, r)] * h[(r, j)]).sum())
1629 };
1630 let input = DenseInput::new(v.as_ref()).unwrap();
1631 let opts = HalsOpts::<f32>::new(200, 1e-6, 1e-10, 10, NmfInit::Nndsvd);
1632
1633 let restarts = stabilised_nmf(&input, k, n_runs, 0, &opts, 0).unwrap();
1634 let clusters =
1635 consensus_from_restarts(&restarts, k, &ConsensusParams::<f32>::default(), 0).unwrap();
1636
1637 for s in &clusters.silhouette {
1638 assert!(
1639 *s <= 1.0 + 1e-5 && *s >= -1.0 - 1e-5,
1640 "f32 silhouette {s} outside [-1, 1]"
1641 );
1642 }
1643 assert!(
1644 clusters.stability > 0.9,
1645 "f32 stability {}",
1646 clusters.stability
1647 );
1648
1649 let fit = nmf_consensus(&input, k, n_runs, 0, &opts, None, 0).unwrap();
1650 assert!(
1651 fit.error.is_finite() && fit.error < 1e-2,
1652 "error {}",
1653 fit.error
1654 );
1655 }
1656
1657 #[test]
1660 fn zero_neighbours_means_the_heuristic() {
1661 assert_eq!(resolve_n_neighbours(Some(0), 20, 100), 6);
1662 assert_eq!(resolve_n_neighbours(None, 20, 100), 6);
1663 }
1664
1665 #[test]
1668 fn target_strings_parse() {
1669 assert_eq!(parse_consensus_target("h"), Some(ConsensusTarget::HRows));
1670 assert_eq!(
1671 parse_consensus_target("SPECTRA"),
1672 Some(ConsensusTarget::HRows)
1673 );
1674 assert_eq!(
1675 parse_consensus_target("w_cols"),
1676 Some(ConsensusTarget::WColumns)
1677 );
1678 assert_eq!(parse_consensus_target("nonsense"), None);
1679 }
1680}