Skip to main content

bixverse_methods/methods/nmf_hals/
consensus.rs

1//! Consensus NMF: turns the random restarts from [`super::stabilised_nmf`] into
2//! a stability score and a consensus factor, plus a driver loop over k so the
3//! rank can be chosen from the stability and error curves.
4//!
5//! The pipeline follows cNMF: pool the components across restarts, L2-normalise
6//! them, drop the ones sitting in a sparse neighbourhood, cluster the survivors
7//! into k groups, score stability as the mean silhouette, and take the
8//! per-cluster median as the consensus factor. The partner factor is then refit
9//! against it with a frozen-factor NNLS pass.
10//!
11//! ### References
12//!
13//! Kotliar et al., eLife, 2019
14
15use 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
26////////////
27// Consts //
28////////////
29
30/// Columns per task when accumulating row norms.
31const NORM_COL_CHUNK: usize = 1024;
32
33/// Rows per task when accumulating k-means inertia.
34const INERTIA_ROW_TILE: usize = 256;
35
36/// Fraction of `n_runs` used as the local neighbourhood size for the density
37/// filter. cNMF's `local_neighborhood_size` default.
38const LOCAL_NEIGHBOURHOOD_FRACTION: f64 = 0.3;
39
40/// Default cutoff for the density filter, as a mean COSINE distance. Cosine
41/// distance is bounded by 2, so any value at or above 2 leaves every component
42/// in place.
43const DEFAULT_DENSITY_THRESHOLD: f64 = 0.5;
44
45/// k-means restarts kept per consensus step, lowest inertia winning. cNMF leans
46/// on scikit-learn's `n_init = 10` for the same purpose.
47const DEFAULT_KMEANS_N_INIT: usize = 3;
48
49/// Default Lloyd iterations per k-means restart.
50const DEFAULT_KMEANS_ITERS: usize = 100;
51
52/// Norm below which a pooled component counts as collapsed and is dropped
53/// before anything else runs. A zero-norm row has no direction, so cosine
54/// distance against it is meaningless.
55const DEGENERATE_NORM_TOLERANCE: f64 = 1e-12;
56
57////////////
58// Params //
59////////////
60
61/// Which factor the consensus clustering operates on.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum ConsensusTarget {
64    /// Rows of the per-run H matrices stacked vertically, L2-normalised. These
65    /// are the programs over features (the spectra), and what cNMF clusters. H
66    /// rows carry the magnitudes out of [`super::nmf_hals`], so normalisation
67    /// is required rather than cosmetic.
68    #[default]
69    HRows,
70    /// Columns of `w_all`. Already unit L2 by construction, but these are
71    /// sample-side activity patterns rather than the interpretable programs.
72    /// The clustering then runs in sample space, which is cheap for bulk and
73    /// expensive on the single-cell path, where that is the cell count.
74    WColumns,
75}
76
77/// Parse the consensus target
78///
79/// ### Params
80///
81/// * `s` - String to parse. `"h"`, `"h_rows"`, `"spectra"` or `"programs"` for
82///   [`ConsensusTarget::HRows`]; `"w"`, `"w_cols"`, `"w_columns"` or `"usage"`
83///   for [`ConsensusTarget::WColumns`]. Case-insensitive.
84///
85/// ### Returns
86///
87/// The option of [ConsensusTarget].
88pub 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/// Options for the consensus step.
97#[derive(Clone, Copy, Debug)]
98pub struct ConsensusParams<F: BixverseFloat> {
99    /// Which factor to cluster.
100    pub target: ConsensusTarget,
101    /// Neighbours used by the local-density filter. `None` or `Some(0)` resolve
102    /// to `LOCAL_NEIGHBOURHOOD_FRACTION * n_runs`; both spellings mean "pick for
103    /// me", so that a zero arriving from R behaves the same way.
104    pub n_neighbours: Option<usize>,
105    /// Mean cosine distance to those neighbours above which a component is
106    /// dropped. `None` disables filtering entirely.
107    pub density_threshold: Option<F>,
108    /// Lloyd iterations per k-means restart.
109    pub kmeans_iters: usize,
110    /// Number of k-means restarts; the lowest-inertia labelling wins.
111    pub kmeans_n_init: usize,
112    /// Seed for the k-means restarts.
113    pub seed: u64,
114}
115
116impl<F: BixverseFloat> ConsensusParams<F> {
117    /// Generate a new instance of [ConsensusParams]
118    ///
119    /// ### Params
120    ///
121    /// * `target` - Which factor to cluster, see [ConsensusTarget].
122    /// * `n_neighbours` - Neighbours for the density filter. `None` or `Some(0)`
123    ///   for the `0.3 * n_runs` heuristic.
124    /// * `density_threshold` - Mean cosine distance cutoff. `None` disables the
125    ///   filter.
126    /// * `kmeans_iters` - Lloyd iterations per k-means restart.
127    /// * `kmeans_n_init` - Number of k-means restarts.
128    /// * `seed` - Seed for the k-means restarts.
129    ///
130    /// ### Returns
131    ///
132    /// The initialised [ConsensusParams].
133    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
165/// Resolve the local neighbourhood size for the density filter.
166///
167/// Defaults to `LOCAL_NEIGHBOURHOOD_FRACTION * n_runs` rounded up, which keeps
168/// the neighbourhood proportional to how many chances a real program had to
169/// reappear across restarts, and `Some(0)` is taken to mean the same as `None`.
170/// Clamped to at least one and to at most `n_components - 1`, since a point cannot
171/// be its own neighbour; a pool of one therefore still reports one, which the
172/// caller's guards make unreachable.
173///
174/// ### Params
175///
176/// * `n_neighbours` - Caller override, or `None` for the heuristic.
177/// * `n_runs` - Number of restarts pooled.
178/// * `n_components` - Components available to search over.
179///
180/// ### Returns
181///
182/// The neighbourhood size, at least one.
183fn resolve_n_neighbours(n_neighbours: Option<usize>, n_runs: usize, n_components: usize) -> usize {
184    // `Some(0)` is treated as `None` rather than clamped to one neighbour, so a
185    // zero coming across the R boundary means the same thing as an absent value.
186    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/////////////
194// Results //
195/////////////
196
197/// Diagnostics from clustering the pooled components at one k.
198#[derive(Clone, Debug)]
199pub struct ConsensusClusters<F: BixverseFloat> {
200    /// Cluster label per pooled component, `None` if the component was dropped.
201    /// Length `k * n_runs`, indexed the same way as `w_all`'s columns.
202    pub labels: Vec<Option<usize>>,
203    /// Mean cosine distance to the nearest neighbours, per pooled component.
204    /// `infinity` for components dropped as collapsed. Plot this to pick a
205    /// `density_threshold`.
206    pub local_density: Vec<F>,
207    /// Pooled indices of the surviving components, ascending.
208    pub kept: Vec<usize>,
209    /// Silhouette per survivor, aligned with `kept`.
210    pub silhouette: Vec<F>,
211    /// Mean silhouette over the survivors. The stability score.
212    pub stability: F,
213    /// Number of survivors per cluster. Length k.
214    pub sizes: Vec<usize>,
215    /// Per-cluster median component, shape `k x dim`, rows renormalised to unit
216    /// L2. `dim` is the feature count for [`ConsensusTarget::HRows`] and the
217    /// sample count for [`ConsensusTarget::WColumns`].
218    pub consensus: Mat<F>,
219    /// Components removed, whether collapsed or filtered out.
220    pub n_dropped: usize,
221    /// Clusters that ended up with no members.
222    pub n_empty_clusters: usize,
223}
224
225/// A consensus NMF fit at one k.
226#[derive(Clone, Debug)]
227pub struct ConsensusNmfResult<F: BixverseFloat> {
228    /// Left factor, shape `m x k`.
229    pub w: Mat<F>,
230    /// Right factor, shape `k x n`.
231    pub h: Mat<F>,
232    /// `||V - WH||_F^2` of the consensus pair, divided by `||V||_F^2`.
233    pub error: F,
234    /// The clustering diagnostics behind the fit.
235    pub clusters: ConsensusClusters<F>,
236    /// Per-restart losses, each divided by `||V||_F^2`.
237    pub run_errors: Vec<F>,
238}
239
240/// One row of the k sweep.
241#[derive(Clone, Debug)]
242pub struct KSweepEntry<F: BixverseFloat> {
243    /// The rank this row describes.
244    pub k: usize,
245    /// Mean silhouette of the surviving components. Higher is more stable. `NaN`
246    /// if the consensus step could not run at this k, which R reads as `NA`; see
247    /// `consensus_failed`.
248    pub stability: F,
249    /// Loss of the best restart, divided by `||V||_F^2`. Always populated, since
250    /// it comes from the restarts rather than the consensus.
251    pub best_error: F,
252    /// Median restart loss, divided by `||V||_F^2`.
253    pub median_error: F,
254    /// True if the density filter left fewer than `k` components, so the
255    /// clustering never ran. The error columns are still meaningful; the
256    /// stability one is not. Usually means the threshold is too tight for this k.
257    pub consensus_failed: bool,
258    /// Components dropped, whether collapsed or filtered out. A rising drop rate
259    /// is itself a sign that k has gone past useful.
260    pub n_dropped: usize,
261    /// Clusters that ended up empty.
262    pub n_empty_clusters: usize,
263    /// Restarts that met the HALS tolerance.
264    pub n_converged: usize,
265}
266
267/////////////
268// Helpers //
269/////////////
270
271/// Pool the chosen factor across restarts into unit-L2 rows.
272///
273/// Pooled row `r * k + j` is component `j` of restart `r`, matching the column
274/// blocks of `w_all`. Rows are normalised in place; `w_all` columns already have
275/// unit norm out of HALS, but they are renormalised anyway so both targets carry
276/// the same invariant.
277///
278/// ### Params
279///
280/// * `res` - Restarts from [`super::stabilised_nmf`].
281/// * `k` - Components per restart.
282/// * `target` - Which factor to pool.
283///
284/// ### Returns
285///
286/// A `(k * n_runs) x dim` matrix of unit-L2 rows. Collapsed components stay as
287/// zero rows for the caller to drop.
288fn 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            // consecutive columns reuse the same cache lines of `w_all`
306            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
324/// L2 norm of every row.
325///
326/// One pass over the columns, so the reads stay contiguous. Accumulates in
327/// `f64`: the pooled dimension runs to hundreds of thousands on single-cell
328/// inputs, and these norms drive the collapsed-component test and the unit-norm
329/// invariant the cosine kNN and silhouette assume.
330///
331/// ### Params
332///
333/// * `mat` - The matrix to read from.
334///
335/// ### Returns
336///
337/// One norm per row.
338fn 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
367/// Scale every row to unit L2 norm, leaving collapsed rows untouched.
368///
369/// ### Params
370///
371/// * `mat` - Matrix whose rows are rescaled in place.
372fn 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
392/// Copy a subset of rows into a fresh matrix.
393///
394/// The clustering and kNN backends both want a contiguous matrix, so the subset
395/// is materialised rather than viewed.
396///
397/// ### Params
398///
399/// * `mat` - Source matrix.
400/// * `rows` - Row indices to keep, in the order wanted.
401///
402/// ### Returns
403///
404/// A `rows.len() x mat.ncols()` matrix.
405fn 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
409/// Mean cosine distance from each row to its `l` nearest neighbours.
410///
411/// The cNMF outlier criterion. A component that reappeared across restarts sits
412/// in a tight neighbourhood of its near-duplicates and scores low; a one-off
413/// artefact from a bad initialisation has nothing nearby and scores high.
414/// Without this filter a handful of bad restarts smear the cluster medians and
415/// depress the silhouette at every k, so the stability curve loses its shape.
416///
417/// Runs through the exhaustive index, which is exact and needs no recall tuning.
418/// That makes it the one `O(n^2 * dim)` step in the pipeline.
419///
420/// ### Params
421///
422/// * `data` - Unit-L2 rows, shape `n x dim`.
423/// * `l` - Neighbours per row, excluding self.
424/// * `verbose` - Passed through to the kNN query.
425///
426/// ### Returns
427///
428/// One mean distance per row, or a [`MethodsErrors`] if the query fails.
429fn 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    // Self comes back in the results, so ask for one extra and drop it below.
435    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            // Self is dropped by identity rather than by assuming it sits at
444            // position 0. Equivalent in practice, since anything tied with self is
445            // also at distance zero, but it does not depend on the backend's
446            // ordering of ties.
447            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
468/// k-means over the survivors, keeping the lowest-inertia restart.
469///
470/// ### Params
471///
472/// * `data` - Unit-L2 rows to cluster, shape `n x dim`.
473/// * `k` - Number of clusters.
474/// * `params` - Consensus options supplying the iteration count, restart count
475///   and seed.
476///
477/// ### Returns
478///
479/// One cluster label per row, or a [`MethodsErrors`] if k-means fails.
480fn 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    // Both floored at one: a zero arriving from R would otherwise give an
489    // init-only clustering, or no clustering at all, rather than an error.
490    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
512/// Within-cluster sum of squared distances to the assigned centroid.
513///
514/// The k-means wrapper does not report inertia, so it is recomputed here to pick
515/// between restarts. One pass, `O(n * dim)`.
516///
517/// ### Params
518///
519/// * `data` - The clustered rows, shape `n x dim`.
520/// * `centroids` - Centroids, shape `k x dim`.
521/// * `assignments` - Cluster label per row.
522///
523/// ### Returns
524///
525/// The total inertia.
526fn 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    // tiles of rows, columns outermost: reads stay contiguous and each row's
535    // squared distances still accumulate in column order
536    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
557/// Per-cluster median component, rows renormalised to unit L2.
558///
559/// The median rather than the mean, following cNMF: a straggler that k-means put
560/// in the wrong cluster shifts a mean but not a median. Parallelised over the
561/// `dim` coordinates, since each is an independent selection problem.
562///
563/// ### Params
564///
565/// * `data` - Unit-L2 survivor rows, shape `n x dim`.
566/// * `assignments` - Cluster label per row.
567/// * `k` - Number of clusters.
568///
569/// ### Returns
570///
571/// A `k x dim` matrix. Empty clusters give zero rows; the caller decides whether
572/// that is fatal.
573fn 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    // Renormalise so the consensus carries the same unit-norm invariant as the
602    // components it was built from.
603    normalise_rows_to_unit(&mut consensus);
604
605    consensus
606}
607
608/// Median of a slice, reordering it in the process.
609///
610/// ### Params
611///
612/// * `values` - The values, reordered in place. Must be non-empty.
613///
614/// ### Returns
615///
616/// The median; the mean of the two central values for an even count.
617fn 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        // The lower half is unordered but does sit below the pivot, so its max is
625        // the other central value.
626        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
636/// Median of a slice without touching it.
637///
638/// ### Params
639///
640/// * `values` - The values. Must be non-empty.
641///
642/// ### Returns
643///
644/// The median.
645pub fn median<F: BixverseFloat>(values: &[F]) -> F {
646    let mut copy = values.to_vec();
647    median_in_place(&mut copy)
648}
649
650///////////////
651// Workhorse //
652///////////////
653
654/// Cluster the pooled components of an existing set of restarts.
655///
656/// Split out from [`nmf_consensus`] so a density threshold can be re-picked
657/// from the `local_density` histogram without paying for the NMF restarts
658/// again, which is how cNMF is meant to be driven.
659///
660/// Steps: pool and L2-normalise the chosen factor, drop collapsed components,
661/// compute each component's mean cosine distance to its nearest neighbours and
662/// drop those above the threshold, k-means the survivors into k groups, score
663/// the silhouette, and take the per-cluster median.
664///
665/// The k-means runs on unit-norm rows under squared Euclidean rather than
666/// cosine distance. On unit-norm rows the two agree up to
667/// `||a - b||^2 = 2(1 - cos)`.
668///
669/// ### Params
670///
671/// * `res` - Restarts from [`super::stabilised_nmf`].
672/// * `k` - Rank the restarts were run at. Must be at least 2.
673/// * `params` - Consensus options; see [`ConsensusParams`].
674/// * `verbose` - If `0` -> silent, `1` or higher prints progress.
675///
676/// ### Returns
677///
678/// The [`ConsensusClusters`] diagnostics, or
679/// [`MethodsErrors::NmfConsensusInvalidK`] /
680/// [`MethodsErrors::NmfConsensusTooFewRuns`] for bad arguments,
681/// [`MethodsErrors::NmfConsensusKMismatch`] if `k` does not match the restarts,
682/// or [`MethodsErrors::NmfConsensusTooFewComponents`] if the density filter left
683/// fewer than `k` survivors. An empty cluster is reported in the result rather
684/// than raised.
685pub 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    // k must be the rank the restarts were actually run at, or the pooling
702    // would slice the per-run H matrices along the wrong stride. `w_all` is
703    // checked separately: the fields of `StabilisedNmfResult` are public, so a
704    // spliced value can disagree with `h_per_run` and would otherwise index out
705    // of bounds inside faer rather than erroring.
706    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    // Collapsed components are dropped before anything touches cosine distance.
719    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    // Local density over the non-collapsed components only.
732    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
795//////////////////
796// Entry points //
797//////////////////
798
799/// Consensus NMF at a single k.
800///
801/// Runs `n_runs` random restarts, clusters the pooled components, and refits the
802/// partner factor against the consensus one. For
803/// [`ConsensusTarget::HRows`] the consensus is H and W is refit against it, so
804/// the returned H rows have unit L2 norm and W carries the magnitudes. For
805/// [`ConsensusTarget::WColumns`] it is the other way round.
806///
807/// ### Params
808///
809/// * `v` - Input matrix backend.
810/// * `k` - Number of components. Must be at least 2.
811/// * `n_runs` - Number of random restarts. Must be at least 2.
812/// * `base_seed` - Seed offset; restart `i` uses `base_seed + i`.
813/// * `opts` - HALS options. The `init` field is ignored by the restarts.
814/// * `params` - Optional [`ConsensusParams`], defaulted if `None`.
815/// * `verbose` - If `0` -> silent, `1` for normal, `2` for detailed verbosity.
816///
817/// ### Returns
818///
819/// A [`ConsensusNmfResult`], or a [`MethodsErrors`]. Errors on an empty cluster,
820/// since the consensus factor would carry a zero row and the refit would be
821/// degenerate.
822pub 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, &params, 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            // The consensus is already k x n, so it is H directly.
854            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            // The consensus is k x m, so transpose it into an m x k W.
860            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
882/// Sweep k and report stability against error.
883///
884/// One row per k, with no refit and no factors retained, so sweeping a wide
885/// range stays cheap in memory. Pick the k where stability is high and the
886/// error curve has not yet flattened, then call [`nmf_consensus`] there.
887///
888/// k is swept sequentially: [`super::stabilised_nmf`] already parallelises
889/// across its restarts and sizes its inner pool against the core count, so
890/// nesting another layer here would only oversubscribe.
891///
892/// Two conditions that are properties of a particular k rather than of the
893/// input are recorded rather than raised, so one awkward rank does not take the
894/// whole sweep down: an empty cluster (see `n_empty_clusters`) and a density
895/// filter that left fewer than k components (see `consensus_failed`, with
896/// `stability` set to `NaN`). Anything else aborts.
897///
898/// ### Params
899///
900/// * `v` - Input matrix backend.
901/// * `k_range` - Ranks to evaluate. Must be non-empty, every entry at least 2.
902/// * `n_runs` - Restarts per k. Must be at least 2.
903/// * `base_seed` - Seed offset. Each k gets a disjoint block of seeds, so two
904///   ranks never share a restart.
905/// * `opts` - HALS options. The `init` field is ignored by the restarts.
906/// * `params` - Optional [`ConsensusParams`], defaulted if `None`.
907/// * `verbose` - If `0` -> silent, `1` for normal, `2` for detailed verbosity.
908///
909/// ### Returns
910///
911/// One [`KSweepEntry`] per entry of `k_range`, in the order given.
912pub 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            &params,
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///////////
996// Tests //
997///////////
998
999#[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    /// Exact rank-k non-negative product, built so the components are clearly
1007    /// distinguishable rather than near-collinear.
1008    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            // Each component owns a contiguous block of rows.
1011            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    /// The pooled labels must cover every restart's components, with dropped
1022    /// entries reported as `None` rather than silently reindexed.
1023    #[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, &params, 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    /// The consensus rows must carry the unit-norm invariant the clustering
1045    /// assumes, or a downstream silhouette against them is meaningless.
1046    #[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    /// Restarts that recover the same rank-k structure should cluster cleanly, so
1068    /// stability sits near the top of the scale.
1069    #[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    /// Same seeds must give the same labels and the same stability, or a sweep
1087    /// cannot be compared against a rerun.
1088    #[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, &params, 0).unwrap();
1097        let second = stabilised_nmf(&input, k, n_runs, 3, &test_opts(), 0).unwrap();
1098        let b = consensus_from_restarts(&second, k, &params, 0).unwrap();
1099
1100        assert_eq!(a.labels, b.labels);
1101        assert_relative_eq!(a.stability, b.stability, epsilon = 1e-12);
1102    }
1103
1104    /// A component with nothing near it must land above the threshold. This is the
1105    /// whole point of the filter, so it gets an explicit outlier rather than
1106    /// trusting a real restart to misbehave.
1107    #[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        // Overwrite the last restart's first component with a direction nothing
1115        // else points in: a single non-zero feature.
1116        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        // Its local density must be the worst of the pool.
1128        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    /// A collapsed (zero-norm) component has no direction, so it must be dropped
1144    /// before it reaches the cosine kNN rather than poisoning it.
1145    #[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, &params, 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    /// Both targets must run and return the documented orientation.
1166    #[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    /// The full fit must return factors of the right shape, in the scale
1184    /// convention its target documents, and reconstruct the input.
1185    #[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        // The input is an exact rank-k product, so the consensus must reconstruct
1199        // it. A loose bound here is worthless: refitting against a random H of the
1200        // right shape lands around 0.5 and would sail through.
1201        assert!(res.error < 1e-6, "relative error {}", res.error);
1202
1203        // HRows is the default, so H carries the unit-norm rows.
1204        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    /// WColumns must put the unit norms on W instead.
1211    #[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    /// The sweep must return one row per requested k, in order.
1230    #[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    /// Guard rails: k below 2 has no silhouette, one restart has nothing to pool,
1248    /// and an empty range has nothing to sweep.
1249    #[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    /// Applying a k the restarts were not run at would slice the per-run H
1274    /// matrices along the wrong stride, so it must be rejected.
1275    #[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    /// Pins the density values themselves on a hand-computable case. It does not
1291    /// discriminate the identity-based self-exclusion from a positional one: since
1292    /// anything tied with self is also at distance zero, the two agree for every
1293    /// reachable input.
1294    #[test]
1295    fn local_density_matches_hand_computed_values() {
1296        // Three identical rows plus one orthogonal to them.
1297        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        // Each duplicate has two other duplicates at distance 0.
1305        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        // The odd one out only has the duplicates, all at distance 1.
1310        assert_relative_eq!(densities[3], 1.0, epsilon = 1e-12);
1311    }
1312
1313    /// The neighbourhood heuristic must stay inside the pool and never reach zero.
1314    #[test]
1315    fn neighbourhood_resolution_is_clamped() {
1316        // 0.3 * 20 = 6.
1317        assert_eq!(resolve_n_neighbours(None, 20, 100), 6);
1318        // Rounds up rather than down to zero.
1319        assert_eq!(resolve_n_neighbours(None, 2, 100), 1);
1320        // Cannot exceed the pool minus self.
1321        assert_eq!(resolve_n_neighbours(Some(50), 20, 10), 9);
1322        // Never zero, even for a pool of one.
1323        assert_eq!(resolve_n_neighbours(Some(0), 20, 1), 1);
1324    }
1325
1326    /// Even and odd counts, since the even case has to average two central values
1327    /// out of a partially ordered slice.
1328    #[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    /// The median must be the per-coordinate median of the cluster members, and a
1337    /// single outlier in a cluster must not move it the way a mean would.
1338    #[test]
1339    fn median_consensus_resists_an_outlier() {
1340        // Three near-identical rows plus one wild one, all in cluster 0.
1341        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    /// An empty cluster gives a zero consensus row, which `nmf_consensus` treats
1354    /// as fatal but the sweep only records.
1355    #[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    /// The whole point of the sweep: stability must peak at the rank the data was
1365    /// actually generated at, and the error must keep falling as k grows.
1366    #[test]
1367    #[cfg(feature = "large-test")]
1368    // 200 x 120 rank-5 product with 5% noise, k in 2..=8, 20 restarts per k.
1369    fn k_sweep_peaks_at_the_true_rank() {
1370        let (m, n, k_true) = (200, 120, 5);
1371
1372        // Block-structured non-negative factors, so the true rank is unambiguous.
1373        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        // Deterministic multiplicative noise at roughly 5%.
1389        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        // More components can only fit the data better, up to solver slack.
1420        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    /// The pooled index ordering for `WColumns` must line up with `w_all`'s column
1433    /// blocks, or `labels` is silently misaligned with `local_density`. Planting an
1434    /// isolated direction at a known column and asserting the density peak lands at
1435    /// the matching pooled index is the only thing that pins it.
1436    #[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        // Restart 2, component 1 -> pooled index 2 * k + 1.
1444        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, &params, 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    /// The same ordering claim for `HRows`, which indexes `h_per_run[r]` row `j`.
1464    #[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, &params, 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    /// Every kept component must be at or below the threshold and every dropped
1490    /// live one above it. Without this the filter could be applied with the
1491    /// comparison inverted and the other tests would not notice.
1492    #[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        // Tight enough to actually bite, loose enough to leave k survivors.
1500        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, &params, 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    /// A pool collapsed onto fewer directions than k cannot fill every cluster, so
1539    /// k-means leaves one empty and the count must report it. `nmf_consensus`
1540    /// refuses on that condition; the sweep only records it.
1541    #[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        // Collapse every pooled component onto two directions, so a third cluster
1549        // has nothing to claim.
1550        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, &params, 0).unwrap();
1560        assert!(clusters.n_empty_clusters > 0, "sizes {:?}", clusters.sizes);
1561    }
1562
1563    /// A threshold that prunes everything is a property of that k, not a broken
1564    /// input, so the sweep must record it and carry on rather than discarding the
1565    /// ranks it already computed.
1566    #[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        // Negative threshold: nothing can survive.
1572        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            // The error columns come from the restarts, so they stay meaningful.
1582            assert!(entry.best_error.is_finite());
1583            assert!(entry.median_error.is_finite());
1584        }
1585    }
1586
1587    /// Over-pruning is recorded by the sweep but is still an error for a single
1588    /// consensus call, which has no way to return a factor.
1589    #[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    /// A spliced `StabilisedNmfResult` whose `w_all` disagrees with `h_per_run`
1603    /// must error rather than panicking inside faer, since the fields are public.
1604    #[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        // Truncate w_all so it no longer holds k * n_runs columns.
1611        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    /// The single-cell path is `f32` only, so the whole pipeline gets exercised at
1620    /// that precision. The silhouette's cancellation is severe enough that an
1621    /// `f32` accumulation put scores outside `[-1, 1]` here.
1622    #[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    /// `Some(0)` neighbours must mean the same as `None`, so a zero crossing the R
1658    /// boundary does not silently become a one-neighbour density estimate.
1659    #[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    /// Target strings are the R-facing surface, so unknown input must be `None`
1666    /// rather than silently defaulting.
1667    #[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}