use ann_search_rs::prelude::AnnSearchFloat;
use ann_search_rs::{build_exhaustive_index, query_exhaustive_self};
use faer::{Mat, MatRef};
use rayon::prelude::*;
use super::refit::{nmf_refit_h, nmf_refit_w};
use super::{HalsOpts, NmfInput, StabilisedNmfResult, stabilised_nmf};
use crate::ml::clustering::clustering_metrics::silhouette_cosine_unit;
use crate::ml::clustering::k_means::{KMeansParamsWrappers, k_means_clusters};
use crate::prelude::*;
const LOCAL_NEIGHBOURHOOD_FRACTION: f64 = 0.3;
const DEFAULT_DENSITY_THRESHOLD: f64 = 0.5;
const DEFAULT_KMEANS_N_INIT: usize = 3;
const DEFAULT_KMEANS_ITERS: usize = 100;
const DEGENERATE_NORM_TOLERANCE: f64 = 1e-12;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ConsensusTarget {
#[default]
HRows,
WColumns,
}
pub fn parse_consensus_target(s: &str) -> Option<ConsensusTarget> {
match s.to_lowercase().as_str() {
"h" | "h_rows" | "spectra" | "programs" => Some(ConsensusTarget::HRows),
"w" | "w_cols" | "w_columns" | "usage" => Some(ConsensusTarget::WColumns),
_ => None,
}
}
#[derive(Clone, Copy, Debug)]
pub struct ConsensusParams<F: BixverseFloat> {
pub target: ConsensusTarget,
pub n_neighbours: Option<usize>,
pub density_threshold: Option<F>,
pub kmeans_iters: usize,
pub kmeans_n_init: usize,
pub seed: u64,
}
impl<F: BixverseFloat> ConsensusParams<F> {
pub fn new(
target: ConsensusTarget,
n_neighbours: Option<usize>,
density_threshold: Option<F>,
kmeans_iters: usize,
kmeans_n_init: usize,
seed: u64,
) -> Self {
Self {
target,
n_neighbours,
density_threshold,
kmeans_iters,
kmeans_n_init,
seed,
}
}
}
impl<F: BixverseFloat> Default for ConsensusParams<F> {
fn default() -> Self {
Self {
target: ConsensusTarget::default(),
n_neighbours: None,
density_threshold: Some(F::from_f64(DEFAULT_DENSITY_THRESHOLD).unwrap()),
kmeans_iters: DEFAULT_KMEANS_ITERS,
kmeans_n_init: DEFAULT_KMEANS_N_INIT,
seed: 42,
}
}
}
fn resolve_n_neighbours(n_neighbours: Option<usize>, n_runs: usize, n_components: usize) -> usize {
let requested = match n_neighbours {
Some(l) if l > 0 => l,
_ => (LOCAL_NEIGHBOURHOOD_FRACTION * n_runs as f64).ceil() as usize,
};
requested.clamp(1, n_components.saturating_sub(1).max(1))
}
#[derive(Clone, Debug)]
pub struct ConsensusClusters<F: BixverseFloat> {
pub labels: Vec<Option<usize>>,
pub local_density: Vec<F>,
pub kept: Vec<usize>,
pub silhouette: Vec<F>,
pub stability: F,
pub sizes: Vec<usize>,
pub consensus: Mat<F>,
pub n_dropped: usize,
pub n_empty_clusters: usize,
}
#[derive(Clone, Debug)]
pub struct ConsensusNmfResult<F: BixverseFloat> {
pub w: Mat<F>,
pub h: Mat<F>,
pub error: F,
pub clusters: ConsensusClusters<F>,
pub run_errors: Vec<F>,
}
#[derive(Clone, Debug)]
pub struct KSweepEntry<F: BixverseFloat> {
pub k: usize,
pub stability: F,
pub best_error: F,
pub median_error: F,
pub consensus_failed: bool,
pub n_dropped: usize,
pub n_empty_clusters: usize,
pub n_converged: usize,
}
fn pool_components<F: BixverseFloat>(
res: &StabilisedNmfResult<F>,
k: usize,
target: ConsensusTarget,
) -> Mat<F> {
let n_runs = res.h_per_run.len();
let n_pooled = k * n_runs;
let mut pooled = match target {
ConsensusTarget::HRows => {
let n = res.h_per_run[0].ncols();
Mat::<F>::from_fn(n_pooled, n, |row, col| {
res.h_per_run[row / k][(row % k, col)]
})
}
ConsensusTarget::WColumns => {
let m = res.w_all.nrows();
Mat::<F>::from_fn(n_pooled, m, |row, col| res.w_all[(col, row)])
}
};
let tol = F::from_f64(DEGENERATE_NORM_TOLERANCE).unwrap();
let dim = pooled.ncols();
for i in 0..n_pooled {
let norm = row_norm(pooled.as_ref(), i);
if norm <= tol {
continue;
}
let inv = F::one() / norm;
for j in 0..dim {
pooled[(i, j)] *= inv;
}
}
pooled
}
#[inline]
fn row_norm<F: BixverseFloat>(mat: MatRef<F>, row: usize) -> F {
let acc = (0..mat.ncols()).fold(0f64, |acc, j| {
let x = mat[(row, j)].to_f64().unwrap();
acc + x * x
});
F::from_f64(acc.sqrt()).unwrap()
}
fn subset_rows<F: BixverseFloat>(mat: MatRef<F>, rows: &[usize]) -> Mat<F> {
Mat::<F>::from_fn(rows.len(), mat.ncols(), |i, j| mat[(rows[i], j)])
}
fn local_densities<F>(data: MatRef<F>, l: usize, verbose: usize) -> Result<Vec<F>, BixverseErrors>
where
F: BixverseFloat + AnnSearchFloat,
{
let index = build_exhaustive_index(data, "cosine");
let (indices, distances) = query_exhaustive_self(&index, l + 1, true, verbose > 1)?;
let distances = distances.expect("distances requested from the exhaustive index");
Ok(indices
.into_par_iter()
.zip(distances)
.enumerate()
.map(|(i, (idx_row, dist_row))| {
let mut sum = F::zero();
let mut count = 0usize;
for (&idx, &d) in idx_row.iter().zip(dist_row.iter()) {
if idx == i {
continue;
}
sum += d;
count += 1;
if count == l {
break;
}
}
if count == 0 {
return F::zero();
}
sum / F::from_usize(count).unwrap()
})
.collect())
}
fn cluster_best_of_n<F>(
data: MatRef<F>,
k: usize,
params: &ConsensusParams<F>,
) -> Result<Vec<usize>, BixverseErrors>
where
F: BixverseFloat + AnnSearchFloat,
{
let kmeans_params = KMeansParamsWrappers::new(params.kmeans_iters.max(1), None, None);
let n_init = params.kmeans_n_init.max(1);
let mut best: Option<(F, Vec<usize>)> = None;
for i in 0..n_init {
let (centroids, assignments) = k_means_clusters(
data,
"euclidean",
k,
Some(kmeans_params),
(params.seed + i as u64) as usize,
false,
)?;
let inertia = inertia(data, centroids.as_ref(), &assignments);
if best.as_ref().is_none_or(|(b, _)| inertia < *b) {
best = Some((inertia, assignments));
}
}
Ok(best.expect("at least one k-means restart runs").1)
}
fn inertia<F: BixverseFloat + Send + Sync>(
data: MatRef<F>,
centroids: MatRef<F>,
assignments: &[usize],
) -> F {
let dim = data.ncols();
let total: f64 = assignments
.par_iter()
.enumerate()
.map(|(i, &label)| {
let mut acc = 0f64;
for j in 0..dim {
let d = (data[(i, j)] - centroids[(label, j)]).to_f64().unwrap();
acc += d * d;
}
acc
})
.sum();
F::from_f64(total).unwrap()
}
fn median_consensus<F: BixverseFloat + Send + Sync>(
data: MatRef<F>,
assignments: &[usize],
k: usize,
) -> Mat<F> {
let dim = data.ncols();
let mut members: Vec<Vec<usize>> = vec![Vec::new(); k];
for (i, &label) in assignments.iter().enumerate() {
members[label].push(i);
}
let mut consensus = Mat::<F>::zeros(k, dim);
for (c, rows) in members.iter().enumerate() {
if rows.is_empty() {
continue;
}
let column: Vec<F> = (0..dim)
.into_par_iter()
.map(|j| {
let mut values: Vec<F> = rows.iter().map(|&i| data[(i, j)]).collect();
median_in_place(&mut values)
})
.collect();
for (j, value) in column.into_iter().enumerate() {
consensus[(c, j)] = value;
}
}
let tol = F::from_f64(DEGENERATE_NORM_TOLERANCE).unwrap();
for c in 0..k {
let norm = row_norm(consensus.as_ref(), c);
if norm <= tol {
continue;
}
let inv = F::one() / norm;
for j in 0..dim {
consensus[(c, j)] *= inv;
}
}
consensus
}
fn median_in_place<F: BixverseFloat>(values: &mut [F]) -> F {
let n = values.len();
let mid = n / 2;
values.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
if n.is_multiple_of(2) {
let upper = values[mid];
let lower = values[..mid]
.iter()
.copied()
.fold(F::neg_infinity(), |acc, x| if x > acc { x } else { acc });
(lower + upper) / F::from_f64(2.0).unwrap()
} else {
values[mid]
}
}
pub(crate) fn median<F: BixverseFloat>(values: &[F]) -> F {
let mut copy = values.to_vec();
median_in_place(&mut copy)
}
pub fn consensus_from_restarts<F>(
res: &StabilisedNmfResult<F>,
k: usize,
params: &ConsensusParams<F>,
verbose: usize,
) -> Result<ConsensusClusters<F>, BixverseErrors>
where
F: BixverseFloat + AnnSearchFloat,
{
let n_runs = res.h_per_run.len();
if k < 2 {
return Err(BixverseErrors::NmfConsensusInvalidK { k });
}
if n_runs < 2 {
return Err(BixverseErrors::NmfConsensusTooFewRuns { n_runs });
}
let restart_k = res.h_per_run[0].nrows();
if restart_k != k || res.w_all.ncols() != k * n_runs {
return Err(BixverseErrors::NmfConsensusKMismatch {
requested: k,
restarts: restart_k,
});
}
let verbosity = parse_verbosity_level(verbose);
let pooled = pool_components(res, k, params.target);
let n_pooled = pooled.nrows();
let tol = F::from_f64(DEGENERATE_NORM_TOLERANCE).unwrap();
let mut local_density = vec![F::infinity(); n_pooled];
let alive: Vec<usize> = (0..n_pooled)
.filter(|&i| row_norm(pooled.as_ref(), i) > tol)
.collect();
if alive.len() < k {
return Err(BixverseErrors::NmfConsensusTooFewComponents {
k,
n_surviving: alive.len(),
});
}
let alive_mat = subset_rows(pooled.as_ref(), &alive);
let l = resolve_n_neighbours(params.n_neighbours, n_runs, alive.len());
let densities = local_densities(alive_mat.as_ref(), l, verbose)?;
for (&idx, &d) in alive.iter().zip(densities.iter()) {
local_density[idx] = d;
}
let kept: Vec<usize> = match params.density_threshold {
Some(threshold) => alive
.iter()
.copied()
.filter(|&i| local_density[i] <= threshold)
.collect(),
None => alive.clone(),
};
if kept.len() < k {
return Err(BixverseErrors::NmfConsensusTooFewComponents {
k,
n_surviving: kept.len(),
});
}
if verbosity.normal_verbosity() {
println!(
" Consensus NMF (k = {}): {} of {} components kept, {} neighbours per density estimate",
k,
kept.len(),
n_pooled,
l
);
}
let survivors = subset_rows(pooled.as_ref(), &kept);
let assignments = cluster_best_of_n(survivors.as_ref(), k, params)?;
let mut sizes = vec![0usize; k];
for &label in &assignments {
sizes[label] += 1;
}
let n_empty_clusters = sizes.iter().filter(|&&s| s == 0).count();
let (silhouette, stability) = silhouette_cosine_unit(survivors.as_ref(), &assignments, k);
let consensus = median_consensus(survivors.as_ref(), &assignments, k);
let mut labels = vec![None; n_pooled];
for (&idx, &label) in kept.iter().zip(assignments.iter()) {
labels[idx] = Some(label);
}
Ok(ConsensusClusters {
labels,
local_density,
n_dropped: n_pooled - kept.len(),
kept,
silhouette,
stability,
sizes,
consensus,
n_empty_clusters,
})
}
pub fn nmf_consensus<F, In>(
v: &In,
k: usize,
n_runs: usize,
base_seed: u64,
opts: &HalsOpts<F>,
params: Option<ConsensusParams<F>>,
verbose: usize,
) -> Result<ConsensusNmfResult<F>, BixverseErrors>
where
F: BixverseFloat + AnnSearchFloat + Send + Sync,
In: NmfInput<F> + Sync,
{
let params = params.unwrap_or_default();
if k < 2 {
return Err(BixverseErrors::NmfConsensusInvalidK { k });
}
if n_runs < 2 {
return Err(BixverseErrors::NmfConsensusTooFewRuns { n_runs });
}
let restarts = stabilised_nmf(v, k, n_runs, base_seed, opts, verbose)?;
let clusters = consensus_from_restarts(&restarts, k, ¶ms, verbose)?;
if let Some(cluster) = clusters.sizes.iter().position(|&s| s == 0) {
return Err(BixverseErrors::NmfConsensusEmptyCluster { cluster, k });
}
let sq_frob = v.sq_frob();
let (w, h, loss) = match params.target {
ConsensusTarget::HRows => {
let h = clusters.consensus.clone();
let (w, loss) = nmf_refit_w(v, h.as_ref(), opts)?;
(w, h, loss)
}
ConsensusTarget::WColumns => {
let w = clusters.consensus.transpose().to_owned();
let (h, loss) = nmf_refit_h(v, w.as_ref(), opts)?;
(w, h, loss)
}
};
let denom = if sq_frob > F::zero() {
sq_frob
} else {
F::one()
};
let run_errors: Vec<F> = restarts.losses.iter().map(|&l| l / denom).collect();
Ok(ConsensusNmfResult {
w,
h,
error: loss / denom,
clusters,
run_errors,
})
}
pub fn nmf_k_sweep<F, In>(
v: &In,
k_range: &[usize],
n_runs: usize,
base_seed: u64,
opts: &HalsOpts<F>,
params: Option<ConsensusParams<F>>,
verbose: usize,
) -> Result<Vec<KSweepEntry<F>>, BixverseErrors>
where
F: BixverseFloat + AnnSearchFloat + Send + Sync,
In: NmfInput<F> + Sync,
{
if k_range.is_empty() {
return Err(BixverseErrors::NmfKSweepEmptyRange);
}
if n_runs < 2 {
return Err(BixverseErrors::NmfConsensusTooFewRuns { n_runs });
}
if let Some(&k) = k_range.iter().find(|&&k| k < 2) {
return Err(BixverseErrors::NmfConsensusInvalidK { k });
}
let params = params.unwrap_or_default();
let verbosity = parse_verbosity_level(verbose);
let sq_frob = v.sq_frob();
let denom = if sq_frob > F::zero() {
sq_frob
} else {
F::one()
};
let mut out = Vec::with_capacity(k_range.len());
for (i, &k) in k_range.iter().enumerate() {
if verbosity.normal_verbosity() {
println!(
"Running NMF k sweep: k = {} ({} of {})",
k,
i + 1,
k_range.len()
);
}
let seed = base_seed + (i * n_runs) as u64;
let restarts = stabilised_nmf(v, k, n_runs, seed, opts, verbose.saturating_sub(1))?;
let clusters = match consensus_from_restarts(
&restarts,
k,
¶ms,
verbose.saturating_sub(1),
) {
Ok(clusters) => Some(clusters),
Err(BixverseErrors::NmfConsensusTooFewComponents { n_surviving, .. }) => {
if verbosity.normal_verbosity() {
println!(
" k = {}: only {} components survived the density filter (need {}); stability not scored",
k, n_surviving, k
);
}
None
}
Err(e) => return Err(e),
};
out.push(KSweepEntry {
k,
stability: clusters
.as_ref()
.map(|c| c.stability)
.unwrap_or_else(F::nan),
best_error: restarts.losses[restarts.best_idx] / denom,
median_error: median(&restarts.losses) / denom,
consensus_failed: clusters.is_none(),
n_dropped: clusters.as_ref().map(|c| c.n_dropped).unwrap_or(k * n_runs),
n_empty_clusters: clusters.as_ref().map(|c| c.n_empty_clusters).unwrap_or(0),
n_converged: restarts.converged.iter().filter(|&&c| c).count(),
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::methods::nmf_hals::NmfInit;
use crate::methods::nmf_hals::dense::DenseInput;
use approx::assert_relative_eq;
fn rank_k_input(m: usize, n: usize, k: usize) -> Mat<f64> {
let w: Mat<f64> = Mat::from_fn(m, k, |i, j| {
if i % k == j { 5.0 } else { 0.3 }
});
let h: Mat<f64> = Mat::from_fn(k, n, |i, j| if j % k == i { 4.0 } else { 0.2 });
Mat::from_fn(m, n, |i, j| (0..k).map(|r| w[(i, r)] * h[(r, j)]).sum())
}
fn test_opts() -> HalsOpts<f64> {
HalsOpts::<f64>::new(200, 1e-8, 1e-12, 10, NmfInit::Nndsvd)
}
#[test]
fn consensus_labels_cover_every_pooled_component() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let params = ConsensusParams::<f64>::default();
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
assert_eq!(clusters.labels.len(), k * n_runs);
assert_eq!(clusters.local_density.len(), k * n_runs);
assert_eq!(clusters.kept.len(), clusters.silhouette.len());
assert_eq!(clusters.sizes.iter().sum::<usize>(), clusters.kept.len());
assert_eq!(clusters.n_dropped, k * n_runs - clusters.kept.len());
for (i, label) in clusters.labels.iter().enumerate() {
assert_eq!(label.is_some(), clusters.kept.contains(&i));
}
}
#[test]
fn consensus_rows_are_unit_norm() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let clusters =
consensus_from_restarts(&restarts, k, &ConsensusParams::<f64>::default(), 0).unwrap();
assert_eq!(clusters.consensus.nrows(), k);
assert_eq!(clusters.consensus.ncols(), n);
for c in 0..k {
assert_relative_eq!(
row_norm(clusters.consensus.as_ref(), c),
1.0,
epsilon = 1e-10
);
}
}
#[test]
fn consensus_stability_high_on_exact_rank_k() {
let (m, n, k, n_runs) = (30, 24, 3, 5);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, k, n_runs, 7, &test_opts(), 0).unwrap();
let clusters =
consensus_from_restarts(&restarts, k, &ConsensusParams::<f64>::default(), 0).unwrap();
assert!(
clusters.stability > 0.95,
"stability {}",
clusters.stability
);
}
#[test]
fn consensus_is_deterministic() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let params = ConsensusParams::<f64>::default();
let first = stabilised_nmf(&input, k, n_runs, 3, &test_opts(), 0).unwrap();
let a = consensus_from_restarts(&first, k, ¶ms, 0).unwrap();
let second = stabilised_nmf(&input, k, n_runs, 3, &test_opts(), 0).unwrap();
let b = consensus_from_restarts(&second, k, ¶ms, 0).unwrap();
assert_eq!(a.labels, b.labels);
assert_relative_eq!(a.stability, b.stability, epsilon = 1e-12);
}
#[test]
fn density_filter_drops_an_isolated_component() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let last = restarts.h_per_run.last_mut().unwrap();
for j in 0..n {
last[(0, j)] = if j == n - 1 { 1.0 } else { 0.0 };
}
let outlier = (n_runs - 1) * k;
let unfiltered =
ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
let all = consensus_from_restarts(&restarts, k, &unfiltered, 0).unwrap();
assert_eq!(all.n_dropped, 0);
let worst = all
.local_density
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap();
assert_eq!(worst, outlier, "densities {:?}", all.local_density);
let filtered =
consensus_from_restarts(&restarts, k, &ConsensusParams::default(), 0).unwrap();
assert!(filtered.labels[outlier].is_none());
assert!(filtered.n_dropped >= 1);
}
#[test]
fn collapsed_components_are_dropped() {
let (m, n, k, n_runs) = (25, 18, 2, 5);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
for j in 0..n {
restarts.h_per_run[1][(0, j)] = 0.0;
}
let collapsed = k;
let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
assert!(clusters.labels[collapsed].is_none());
assert!(clusters.local_density[collapsed].is_infinite());
assert_eq!(clusters.n_dropped, 1);
}
#[test]
fn both_targets_return_documented_shapes() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let h_params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
let h_res = consensus_from_restarts(&restarts, k, &h_params, 0).unwrap();
assert_eq!(h_res.consensus.ncols(), n);
let w_params =
ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
let w_res = consensus_from_restarts(&restarts, k, &w_params, 0).unwrap();
assert_eq!(w_res.consensus.ncols(), m);
}
#[test]
fn nmf_consensus_returns_usable_factors() {
let (m, n, k) = (30, 24, 3);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let res = nmf_consensus(&input, k, 4, 0, &test_opts(), None, 0).unwrap();
assert_eq!(res.w.nrows(), m);
assert_eq!(res.w.ncols(), k);
assert_eq!(res.h.nrows(), k);
assert_eq!(res.h.ncols(), n);
assert_eq!(res.run_errors.len(), 4);
assert!(res.error < 1e-6, "relative error {}", res.error);
for r in 0..k {
let norm = (0..n).map(|j| res.h[(r, j)].powi(2)).sum::<f64>().sqrt();
assert_relative_eq!(norm, 1.0, epsilon = 1e-10);
}
}
#[test]
fn nmf_consensus_w_target_normalises_w() {
let (m, n, k) = (30, 24, 3);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let params = ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
let res = nmf_consensus(&input, k, 4, 0, &test_opts(), Some(params), 0).unwrap();
assert_eq!(res.w.nrows(), m);
assert_eq!(res.h.ncols(), n);
assert!(res.error < 1e-6, "relative error {}", res.error);
for c in 0..k {
let norm = (0..m).map(|i| res.w[(i, c)].powi(2)).sum::<f64>().sqrt();
assert_relative_eq!(norm, 1.0, epsilon = 1e-10);
}
}
#[test]
fn k_sweep_returns_one_row_per_k() {
let (m, n) = (30, 24);
let v = rank_k_input(m, n, 3);
let input = DenseInput::new(v.as_ref()).unwrap();
let sweep = nmf_k_sweep(&input, &[2, 3], 3, 0, &test_opts(), None, 0).unwrap();
assert_eq!(sweep.len(), 2);
assert_eq!(sweep[0].k, 2);
assert_eq!(sweep[1].k, 3);
for entry in &sweep {
assert!(entry.n_converged <= 3);
assert!(entry.best_error <= entry.median_error + 1e-12);
}
}
#[test]
fn invalid_arguments_error() {
let v = rank_k_input(20, 15, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let opts = test_opts();
assert!(matches!(
nmf_consensus(&input, 1, 4, 0, &opts, None, 0),
Err(BixverseErrors::NmfConsensusInvalidK { k: 1 })
));
assert!(matches!(
nmf_consensus(&input, 2, 1, 0, &opts, None, 0),
Err(BixverseErrors::NmfConsensusTooFewRuns { n_runs: 1 })
));
assert!(matches!(
nmf_k_sweep(&input, &[], 4, 0, &opts, None, 0),
Err(BixverseErrors::NmfKSweepEmptyRange)
));
assert!(matches!(
nmf_k_sweep(&input, &[2, 1], 4, 0, &opts, None, 0),
Err(BixverseErrors::NmfConsensusInvalidK { k: 1 })
));
}
#[test]
fn consensus_rejects_mismatched_k() {
let v = rank_k_input(25, 18, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, 2, 4, 0, &test_opts(), 0).unwrap();
assert!(matches!(
consensus_from_restarts(&restarts, 3, &ConsensusParams::<f64>::default(), 0),
Err(BixverseErrors::NmfConsensusKMismatch {
requested: 3,
restarts: 2
})
));
}
#[test]
fn local_density_matches_hand_computed_values() {
let data: Mat<f64> = Mat::from_fn(4, 2, |i, j| match (i, j) {
(3, 0) => 0.0,
(3, 1) => 1.0,
(_, 0) => 1.0,
_ => 0.0,
});
let densities = local_densities(data.as_ref(), 2, 0).unwrap();
for d in densities.iter().take(3) {
assert_relative_eq!(d, &0.0, epsilon = 1e-12);
}
assert_relative_eq!(densities[3], 1.0, epsilon = 1e-12);
}
#[test]
fn neighbourhood_resolution_is_clamped() {
assert_eq!(resolve_n_neighbours(None, 20, 100), 6);
assert_eq!(resolve_n_neighbours(None, 2, 100), 1);
assert_eq!(resolve_n_neighbours(Some(50), 20, 10), 9);
assert_eq!(resolve_n_neighbours(Some(0), 20, 1), 1);
}
#[test]
fn median_handles_both_parities() {
assert_relative_eq!(median(&[3.0, 1.0, 2.0]), 2.0, epsilon = 1e-12);
assert_relative_eq!(median(&[4.0, 1.0, 3.0, 2.0]), 2.5, epsilon = 1e-12);
assert_relative_eq!(median(&[5.0]), 5.0, epsilon = 1e-12);
assert_relative_eq!(median(&[2.0, 2.0, 2.0, 8.0]), 2.0, epsilon = 1e-12);
}
#[test]
fn median_consensus_resists_an_outlier() {
let data: Mat<f64> = Mat::from_fn(4, 2, |i, j| match (i, j) {
(3, 0) => 0.0,
(3, 1) => 1.0,
(_, 0) => 1.0,
_ => 0.0,
});
let consensus = median_consensus(data.as_ref(), &[0, 0, 0, 0], 1);
assert_relative_eq!(consensus[(0, 0)], 1.0, epsilon = 1e-10);
assert_relative_eq!(consensus[(0, 1)], 0.0, epsilon = 1e-10);
}
#[test]
fn median_consensus_leaves_empty_clusters_zero() {
let data: Mat<f64> = Mat::from_fn(2, 3, |_, j| if j == 0 { 1.0 } else { 0.0 });
let consensus = median_consensus(data.as_ref(), &[0, 0], 2);
assert_relative_eq!(row_norm(consensus.as_ref(), 0), 1.0, epsilon = 1e-10);
assert_relative_eq!(row_norm(consensus.as_ref(), 1), 0.0, epsilon = 1e-10);
}
#[test]
#[cfg(feature = "large-test")]
fn k_sweep_peaks_at_the_true_rank() {
let (m, n, k_true) = (200, 120, 5);
let w: Mat<f64> = Mat::from_fn(
m,
k_true,
|i, j| {
if i * k_true / m == j { 6.0 } else { 0.2 }
},
);
let h: Mat<f64> = Mat::from_fn(
k_true,
n,
|i, j| {
if j * k_true / n == i { 5.0 } else { 0.15 }
},
);
let mut state = 987654321u64;
let mut v = Mat::<f64>::zeros(m, n);
for i in 0..m {
for j in 0..n {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let jitter = ((state >> 33) as f64) / ((1u64 << 31) as f64) - 1.0;
let clean: f64 = (0..k_true).map(|r| w[(i, r)] * h[(r, j)]).sum();
v[(i, j)] = (clean * (1.0 + 0.05 * jitter)).max(0.0);
}
}
let input = DenseInput::new(v.as_ref()).unwrap();
let opts = HalsOpts::<f64>::new(400, 1e-7, 1e-12, 10, NmfInit::Nndsvd);
let k_range: Vec<usize> = (2..=8).collect();
let sweep = nmf_k_sweep(&input, &k_range, 20, 11, &opts, None, 0).unwrap();
let best = sweep
.iter()
.max_by(|a, b| a.stability.partial_cmp(&b.stability).unwrap())
.unwrap();
assert_eq!(
best.k,
k_true,
"stability curve: {:?}",
sweep.iter().map(|e| (e.k, e.stability)).collect::<Vec<_>>()
);
for pair in sweep.windows(2) {
assert!(
pair[1].best_error <= pair[0].best_error + 1e-3,
"error rose from k = {} ({}) to k = {} ({})",
pair[0].k,
pair[0].best_error,
pair[1].k,
pair[1].best_error
);
}
}
#[test]
fn w_columns_pooled_ordering_matches_w_all() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let (run, comp) = (2usize, 1usize);
let target_col = run * k + comp;
for row in 0..m {
restarts.w_all[(row, target_col)] = if row == m - 1 { 1.0 } else { 0.0 };
}
let params = ConsensusParams::<f64>::new(ConsensusTarget::WColumns, None, None, 100, 3, 42);
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
let worst = clusters
.local_density
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap();
assert_eq!(worst, target_col, "densities {:?}", clusters.local_density);
}
#[test]
fn h_rows_pooled_ordering_matches_run_blocks() {
let (m, n, k, n_runs) = (25, 18, 2, 4);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
let (run, comp) = (2usize, 1usize);
for j in 0..n {
restarts.h_per_run[run][(comp, j)] = if j == n - 1 { 1.0 } else { 0.0 };
}
let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42);
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
let worst = clusters
.local_density
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap();
assert_eq!(worst, run * k + comp);
}
#[test]
fn density_filter_partitions_on_the_threshold() {
let (m, n, k, n_runs) = (25, 18, 2, 5);
let v = rank_k_input(m, n, k);
let input = DenseInput::new(v.as_ref()).unwrap();
let restarts = stabilised_nmf(&input, k, n_runs, 4, &test_opts(), 0).unwrap();
let unfiltered = consensus_from_restarts(
&restarts,
k,
&ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 3, 42),
0,
)
.unwrap();
let mut densities: Vec<f64> = unfiltered
.local_density
.iter()
.copied()
.filter(|d| d.is_finite())
.collect();
densities.sort_by(|a, b| a.partial_cmp(b).unwrap());
let threshold = densities[densities.len() / 2];
let params =
ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(threshold), 100, 3, 42);
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
for &i in &clusters.kept {
assert!(
clusters.local_density[i] <= threshold,
"kept component {i} has density {} above {threshold}",
clusters.local_density[i]
);
}
for i in 0..clusters.labels.len() {
if clusters.labels[i].is_none() && clusters.local_density[i].is_finite() {
assert!(
clusters.local_density[i] > threshold,
"dropped component {i} has density {} at or below {threshold}",
clusters.local_density[i]
);
}
}
}
#[test]
fn degenerate_pool_leaves_an_empty_cluster() {
let (m, n, k, n_runs) = (25, 18, 3, 4);
let v = rank_k_input(m, n, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, k, n_runs, 0, &test_opts(), 0).unwrap();
for r in 0..n_runs {
for c in 0..k {
for j in 0..n {
restarts.h_per_run[r][(c, j)] = if j % 2 == c % 2 { 1.0 } else { 0.0 };
}
}
}
let params = ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, None, 100, 1, 42);
let clusters = consensus_from_restarts(&restarts, k, ¶ms, 0).unwrap();
assert!(clusters.n_empty_clusters > 0, "sizes {:?}", clusters.sizes);
}
#[test]
fn k_sweep_records_an_over_pruned_k() {
let (m, n) = (25, 18);
let v = rank_k_input(m, n, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let params =
ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(-1.0), 100, 3, 42);
let sweep = nmf_k_sweep(&input, &[2, 3], 3, 0, &test_opts(), Some(params), 0).unwrap();
assert_eq!(sweep.len(), 2);
for entry in &sweep {
assert!(entry.consensus_failed);
assert!(entry.stability.is_nan());
assert!(entry.best_error.is_finite());
assert!(entry.median_error.is_finite());
}
}
#[test]
fn over_pruning_errors_for_a_single_k() {
let v = rank_k_input(25, 18, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let params =
ConsensusParams::<f64>::new(ConsensusTarget::HRows, None, Some(-1.0), 100, 3, 42);
assert!(matches!(
nmf_consensus(&input, 2, 4, 0, &test_opts(), Some(params), 0),
Err(BixverseErrors::NmfConsensusTooFewComponents { k: 2, .. })
));
}
#[test]
fn inconsistent_w_all_errors_rather_than_panicking() {
let v = rank_k_input(25, 18, 2);
let input = DenseInput::new(v.as_ref()).unwrap();
let mut restarts = stabilised_nmf(&input, 2, 4, 0, &test_opts(), 0).unwrap();
restarts.w_all = Mat::<f64>::from_fn(25, 2, |i, j| restarts.w_all[(i, j)]);
assert!(matches!(
consensus_from_restarts(&restarts, 2, &ConsensusParams::<f64>::default(), 0),
Err(BixverseErrors::NmfConsensusKMismatch { .. })
));
}
#[test]
fn consensus_runs_in_f32() {
let (m, n, k, n_runs) = (30, 24, 3, 4);
let v: Mat<f32> = {
let w: Mat<f32> = Mat::from_fn(m, k, |i, j| if i % k == j { 5.0 } else { 0.3 });
let h: Mat<f32> = Mat::from_fn(k, n, |i, j| if j % k == i { 4.0 } else { 0.2 });
Mat::from_fn(m, n, |i, j| (0..k).map(|r| w[(i, r)] * h[(r, j)]).sum())
};
let input = DenseInput::new(v.as_ref()).unwrap();
let opts = HalsOpts::<f32>::new(200, 1e-6, 1e-10, 10, NmfInit::Nndsvd);
let restarts = stabilised_nmf(&input, k, n_runs, 0, &opts, 0).unwrap();
let clusters =
consensus_from_restarts(&restarts, k, &ConsensusParams::<f32>::default(), 0).unwrap();
for s in &clusters.silhouette {
assert!(
*s <= 1.0 + 1e-5 && *s >= -1.0 - 1e-5,
"f32 silhouette {s} outside [-1, 1]"
);
}
assert!(
clusters.stability > 0.9,
"f32 stability {}",
clusters.stability
);
let fit = nmf_consensus(&input, k, n_runs, 0, &opts, None, 0).unwrap();
assert!(
fit.error.is_finite() && fit.error < 1e-2,
"error {}",
fit.error
);
}
#[test]
fn zero_neighbours_means_the_heuristic() {
assert_eq!(resolve_n_neighbours(Some(0), 20, 100), 6);
assert_eq!(resolve_n_neighbours(None, 20, 100), 6);
}
#[test]
fn target_strings_parse() {
assert_eq!(parse_consensus_target("h"), Some(ConsensusTarget::HRows));
assert_eq!(
parse_consensus_target("SPECTRA"),
Some(ConsensusTarget::HRows)
);
assert_eq!(
parse_consensus_target("w_cols"),
Some(ConsensusTarget::WColumns)
);
assert_eq!(parse_consensus_target("nonsense"), None);
}
}