Skip to main content

bixverse_methods/methods/nmf_hals/
mod.rs

1//! Implementation of NMF, dense and sparse paths. This version implements
2//! HALS with random or NNDSVD initialisation.
3
4use bixverse_core::utils::gemm::gram;
5use faer::linalg::matmul::triangular::BlockStructure;
6use faer::{Accum, Mat, MatRef};
7use num_traits::Float;
8use rand::SeedableRng;
9use rand::rngs::StdRng;
10use rand_distr::{Distribution, Normal};
11use rayon::prelude::*;
12
13use crate::prelude::*;
14use bixverse_core::core::math::pca_svd::SvdResults;
15use bixverse_core::utils::faer_parallelism;
16
17pub mod consensus;
18pub mod dense;
19pub mod nmf_preprocessing;
20pub mod refit;
21pub mod sparse;
22
23/// Columns of H per task in the row sweep.
24const HALS_COL_CHUNK: usize = 64;
25
26/// Rows of W per task in the column sweep.
27const HALS_ROW_TILE: usize = 256;
28
29////////////////////
30// Initialisation //
31////////////////////
32
33#[derive(Clone, Debug, Copy, Default)]
34/// Initialisation strategy for HALS.
35pub enum NmfInit {
36    /// Boutsidis-Gallopoulos NNDSVD from the top-k truncated SVD.
37    /// Deterministic.
38    #[default]
39    Nndsvd,
40    /// Random non-negative draws. Used for restart diversity in consensus.
41    Random {
42        /// Random seed
43        seed: u64,
44    },
45}
46
47/// Parse the NMF initialisation
48///
49/// ### Params
50///
51/// * `s` - String to parse. One of `"nndsvd"`, `"svd"` or `"random"`.
52/// * `seed` - Seed for the random initialisation
53///
54/// ### Returns
55///
56/// The option of [NmfInit]
57pub fn parse_nmf_init(s: &str, seed: usize) -> Option<NmfInit> {
58    match s.to_lowercase().as_str() {
59        "nndsvd" | "svd" => Some(NmfInit::Nndsvd),
60        "random" => Some(NmfInit::Random { seed: seed as u64 }),
61        _ => None,
62    }
63}
64
65/// Random initialisation
66///
67/// ### Params
68///
69/// * `m` - Number of features
70/// * `n` - Number of samples
71/// * `k` - Number of components to return
72/// * `sq_frob` - The squared Frobenius norm for scaling
73/// * `seed` - Seed for reproducibility
74///
75/// ### Returns
76///
77/// Tuple of `(w, h)` matrix
78pub fn random_init<F: BixverseFloat>(
79    m: usize,
80    n: usize,
81    k: usize,
82    sq_frob: F,
83    seed: u64,
84) -> (Mat<F>, Mat<F>) {
85    let mut rng = StdRng::seed_from_u64(seed);
86    let normal = Normal::new(0.0, 1.0).unwrap();
87
88    let mn_k = F::from_usize(m * n * k).unwrap();
89    let denom = if mn_k > F::zero() { mn_k } else { F::one() };
90    let scale = (sq_frob / denom).sqrt();
91
92    let w = Mat::<F>::from_fn(m, k, |_, _| {
93        let v: f64 = normal.sample(&mut rng).abs();
94        F::from_f64(v).unwrap() * scale
95    });
96    let h = Mat::<F>::from_fn(k, n, |_, _| {
97        let v: f64 = normal.sample(&mut rng).abs();
98        F::from_f64(v).unwrap() * scale
99    });
100
101    (w, h)
102}
103
104/// Boutsidis-Gallopoulos NNDSVD from a top-k truncated SVD.
105///
106/// ### Params
107///
108/// * `svd` - The Singular Value Decomposition results to use for
109///   initialisation.
110/// * `k` - Number of components to return
111/// * `m` - Number of features
112/// * `n` - Number of samples
113///
114/// ### Returns
115///
116/// Tuple of `(w, h)` matrix
117pub fn nndsvd_from_svd<F: BixverseFloat>(
118    svd: &SvdResults<F>,
119    k: usize,
120    m: usize,
121    n: usize,
122) -> Result<(Mat<F>, Mat<F>), MethodsErrors> {
123    let avail = svd.s.len();
124    if avail < k {
125        return Err(MethodsErrors::NmfRankTooLarge {
126            requested: k,
127            available: avail,
128        });
129    }
130
131    let mut w = Mat::<F>::zeros(m, k);
132    let mut h = Mat::<F>::zeros(k, n);
133
134    let s0_sqrt = svd.s[0].sqrt();
135    for i in 0..m {
136        w[(i, 0)] = svd.u[(i, 0)].abs() * s0_sqrt;
137    }
138    for j in 0..n {
139        h[(0, j)] = svd.v[(j, 0)].abs() * s0_sqrt;
140    }
141
142    for r in 1..k {
143        let mut up = vec![F::zero(); m];
144        let mut un = vec![F::zero(); m];
145        let mut vp = vec![F::zero(); n];
146        let mut vn = vec![F::zero(); n];
147        // f64 accumulators, for the same reason as in `normalise_w_cols`: these
148        // run over the sample and feature counts, and they set the scale of the
149        // whole initialisation.
150        let mut up_sq = 0f64;
151        let mut un_sq = 0f64;
152        let mut vp_sq = 0f64;
153        let mut vn_sq = 0f64;
154
155        for i in 0..m {
156            let x = svd.u[(i, r)];
157            let d = x.to_f64().unwrap();
158            if x > F::zero() {
159                up[i] = x;
160                up_sq += d * d;
161            } else if x < F::zero() {
162                un[i] = -x;
163                un_sq += d * d;
164            }
165        }
166        for j in 0..n {
167            let y = svd.v[(j, r)];
168            let d = y.to_f64().unwrap();
169            if y > F::zero() {
170                vp[j] = y;
171                vp_sq += d * d;
172            } else if y < F::zero() {
173                vn[j] = -y;
174                vn_sq += d * d;
175            }
176        }
177
178        let up_norm = F::from_f64(up_sq.sqrt()).unwrap();
179        let un_norm = F::from_f64(un_sq.sqrt()).unwrap();
180        let vp_norm = F::from_f64(vp_sq.sqrt()).unwrap();
181        let vn_norm = F::from_f64(vn_sq.sqrt()).unwrap();
182        let m_pos = up_norm * vp_norm;
183        let m_neg = un_norm * vn_norm;
184
185        let (chosen_u, chosen_v, u_norm, v_norm, m_chosen) = if m_pos >= m_neg {
186            (&up, &vp, up_norm, vp_norm, m_pos)
187        } else {
188            (&un, &vn, un_norm, vn_norm, m_neg)
189        };
190
191        if u_norm <= F::zero() || v_norm <= F::zero() {
192            continue;
193        }
194        let factor = (svd.s[r] * m_chosen).sqrt();
195        let scale_u = factor / u_norm;
196        let scale_v = factor / v_norm;
197        for i in 0..m {
198            w[(i, r)] = chosen_u[i] * scale_u;
199        }
200        for j in 0..n {
201            h[(r, j)] = chosen_v[j] * scale_v;
202        }
203    }
204
205    Ok((w, h))
206}
207
208////////////
209// Params //
210////////////
211
212/// HALS solver options.
213#[derive(Clone, Debug)]
214pub struct HalsOpts<F: BixverseFloat> {
215    /// Maximum number of outer iterations.
216    pub max_iter: usize,
217    /// Relative objective decrease below which the solver stops.
218    pub tol: F,
219    /// Floor applied to W and H entries on every sweep.
220    pub eps: F,
221    /// Cadence (in iterations) for evaluating the Frobenius objective.
222    pub check_every: usize,
223    /// How W and H are initialised.
224    pub init: NmfInit,
225}
226
227impl<F: BixverseFloat> HalsOpts<F> {
228    /// Generate a new instance of [HalsOpts]
229    ///
230    /// ### Params
231    ///
232    /// * `max_iter` - Maximum iterations to run the algorithm for
233    /// * `tol` - Tolerance for early stopping
234    /// * `eps` - Floor applied to W and H entries on every sweep.
235    /// * `check_every` - Cadence (in iters) for evaluating the Frobenius
236    ///   objective.
237    /// * `init` - How the W and H matrices are initialised, see [NmfInit].
238    ///
239    /// ### Returns
240    ///
241    /// The initialised [HalsOpts].
242    pub fn new(max_iter: usize, tol: F, eps: F, check_every: usize, init: NmfInit) -> Self {
243        Self {
244            max_iter,
245            tol,
246            eps,
247            check_every,
248            init,
249        }
250    }
251}
252
253impl<T> Default for HalsOpts<T>
254where
255    T: BixverseFloat,
256{
257    fn default() -> Self {
258        Self {
259            max_iter: 250,
260            tol: T::from_f64(1e-4).unwrap(),
261            eps: T::from_f64(1e-10).unwrap(),
262            check_every: 10,
263            init: NmfInit::Nndsvd,
264        }
265    }
266}
267
268/////////////
269// Results //
270/////////////
271
272/// Result of a single NMF run.
273pub struct NmfResult<F: BixverseFloat> {
274    /// Left factor (m x k).
275    pub w: Mat<F>,
276    /// Right factor (k x n).
277    pub h: Mat<F>,
278    /// Frobenius squared error at the last evaluated point.
279    pub final_loss: F,
280    /// Number of outer iterations actually performed.
281    pub n_iter: usize,
282    /// True if the relative tolerance was met before `max_iter`.
283    pub converged: bool,
284}
285
286//////////////
287// NmfInput //
288//////////////
289
290/// Backend-agnostic view of V for HALS NMF. Implementations write the two
291/// data-touching products into caller-owned buffers.
292pub trait NmfInput<F: BixverseFloat> {
293    /// Returns the shape
294    ///
295    /// ### Returns
296    ///
297    /// `(m, n)`.
298    fn shape(&self) -> (usize, usize);
299
300    /// The squared Frobenius norm `||V||_F^2`,
301    ///
302    /// Computed once at construction.
303    ///
304    /// ### Returns
305    ///
306    /// The squared Frobenius norm.
307    fn sq_frob(&self) -> F;
308
309    /// Compute W^T V.
310    ///
311    /// Writes the product `W^T V` into a caller-owned buffer.
312    ///
313    /// ### Params
314    ///
315    /// * `w` - Left factor, shape `m x k`.
316    /// * `out` - Output buffer `k x n`. Overwritten on return.
317    fn wt_v(&self, w: MatRef<F>, out: &mut Mat<F>);
318
319    /// Gram matrix W^T W.
320    ///
321    /// Computes the symmetric Gram matrix using a triangular matmul, then
322    /// mirrors the lower triangle into the upper half.
323    ///
324    /// ### Params
325    ///
326    /// * `w` - Left factor, shape `m x k`.
327    /// * `out` - Output buffer `k x k`. Overwritten on return.
328    fn v_ht(&self, h: MatRef<F>, out: &mut Mat<F>);
329
330    /// Top-k truncated SVD of V.
331    ///
332    /// Used to seed NNDSVD initialisation.
333    ///
334    /// ### Params
335    ///
336    /// * `k` - Number of singular triplets to compute.
337    ///
338    /// ### Returns
339    ///
340    /// The top-k [`SvdResults`], or a [`MethodsErrors`] if the decomposition
341    /// fails.
342    fn top_k_svd(&self, k: usize) -> Result<SvdResults<F>, MethodsErrors>;
343}
344
345/////////////
346// Helpers //
347/////////////
348
349/// Gram matrix W^T W.
350///
351/// Computes the lower triangle via `gram`, then mirrors it into the upper half.
352///
353/// ### Params
354///
355/// * `w` - Left factor, shape `m x k`.
356/// * `out` - Output buffer `k x k`. Overwritten on return.
357pub fn gram_wt_w<F: BixverseFloat>(w: MatRef<F>, out: &mut Mat<F>) {
358    let k = w.ncols();
359    gram(
360        out.as_mut(),
361        BlockStructure::TriangularLower,
362        Accum::Replace,
363        w.transpose(),
364        w,
365        F::one(),
366        faer_parallelism(),
367    );
368    for j in 0..k {
369        for i in 0..j {
370            out[(i, j)] = out[(j, i)];
371        }
372    }
373}
374
375/// Gram matrix H H^T.
376///
377/// Computes the lower triangle via `gram`, then mirrors it into the upper half.
378///
379/// ### Params
380///
381/// * `h` - Right factor, shape `k x n`.
382/// * `out` - Output buffer `k x k`. Overwritten on return.
383pub fn gram_h_ht<F: BixverseFloat>(h: MatRef<F>, out: &mut Mat<F>) {
384    let k = h.nrows();
385    gram(
386        out.as_mut(),
387        BlockStructure::TriangularLower,
388        Accum::Replace,
389        h,
390        h.transpose(),
391        F::one(),
392        faer_parallelism(),
393    );
394    for j in 0..k {
395        for i in 0..j {
396            out[(i, j)] = out[(j, i)];
397        }
398    }
399}
400
401/// HALS row sweep for H.
402///
403/// For each rank component `r`, updates row `r` of `H` in-place:
404///
405/// `H[r,j] = max(eps, H[r,j] + (A[r,j] - (BH)[r,j]) / B[r,r])`.
406///
407/// ### Params
408///
409/// * `h` - Right factor `k x n`, updated in-place.
410/// * `b` - Gram matrix `W^T W`, shape `k x k`.
411/// * `a` - Product `W^T V`, shape `k x n`.
412/// * `eps` - Non-negativity floor.
413pub fn hals_sweep_rows<F>(h: &mut Mat<F>, b: MatRef<F>, a: MatRef<F>, eps: F)
414where
415    F: BixverseFloat + Send + Sync,
416{
417    let k = h.nrows();
418
419    // Column j of H only reads and writes column j, so the whole r sweep runs
420    // inside one pass over the columns.
421    let b_rows: Vec<F> = (0..k)
422        .flat_map(|r| (0..k).map(move |s| (r, s)))
423        .map(|(r, s)| b[(r, s)])
424        .collect();
425
426    h.par_col_iter_mut()
427        .with_min_len(HALS_COL_CHUNK)
428        .enumerate()
429        .for_each(|(j, col)| {
430            let col = col.try_as_col_major_mut().unwrap().as_slice_mut();
431            let a_col = a.col(j);
432            for r in 0..k {
433                let brr = b_rows[r * k + r];
434                if brr <= F::zero() {
435                    continue;
436                }
437                let inv_brr = F::one() / brr;
438                let mut bh_rj = F::zero();
439                for (&b_rs, &h_sj) in b_rows[r * k..(r + 1) * k].iter().zip(col.iter()) {
440                    bh_rj += b_rs * h_sj;
441                }
442                let new_val = col[r] + (a_col[r] - bh_rj) * inv_brr;
443                col[r] = if new_val > eps { new_val } else { eps };
444            }
445        });
446}
447
448/// HALS column sweep for W.
449///
450/// For each rank component `c`, updates column `c` of `W` in-place:
451///
452/// `W[i,c] = max(eps, W[i,c] + (C[i,c] - (WD)[i,c]) / D[c,c])`.
453///
454/// ### Params
455///
456/// * `w` - Left factor `m x k`, updated in-place.
457/// * `d` - Gram matrix `H H^T`, shape `k x k`.
458/// * `c_mat` - Product `V H^T`, shape `m x k`.
459/// * `eps` - Non-negativity floor.
460pub fn hals_sweep_cols<F>(w: &mut Mat<F>, d: MatRef<F>, c_mat: MatRef<F>, eps: F)
461where
462    F: BixverseFloat + Send + Sync,
463{
464    let m = w.nrows();
465    let k = w.ncols();
466
467    let w_ptr = w.as_ptr_mut() as usize;
468    let w_row_stride = w.row_stride();
469    let w_col_stride = w.col_stride();
470
471    let d_cols: Vec<F> = (0..k)
472        .flat_map(|c| (0..k).map(move |s| (s, c)))
473        .map(|(s, c)| d[(s, c)])
474        .collect();
475
476    // Row i of W only reads and writes row i, so tiles of rows are processed
477    // independently, each staged row-major so the k-length dots are contiguous.
478    (0..m.div_ceil(HALS_ROW_TILE))
479        .into_par_iter()
480        .for_each_init(
481            || vec![F::zero(); HALS_ROW_TILE * k],
482            |scratch, tile| {
483                let i0 = tile * HALS_ROW_TILE;
484                let rows = HALS_ROW_TILE.min(m - i0);
485                let base = w_ptr as *mut F;
486                let offset =
487                    |i: usize, s: usize| i as isize * w_row_stride + s as isize * w_col_stride;
488
489                for s in 0..k {
490                    for ii in 0..rows {
491                        // SAFETY: i0 + ii < m and s < k, and tiles cover disjoint rows.
492                        scratch[ii * k + s] = unsafe { *base.offset(offset(i0 + ii, s)) };
493                    }
494                }
495
496                for c in 0..k {
497                    let dcc = d_cols[c * k + c];
498                    if dcc <= F::zero() {
499                        continue;
500                    }
501                    let inv_dcc = F::one() / dcc;
502                    let d_col_c = &d_cols[c * k..(c + 1) * k];
503                    for ii in 0..rows {
504                        let row = &mut scratch[ii * k..(ii + 1) * k];
505                        let mut wd_ic = F::zero();
506                        for (&w_is, &d_sc) in row.iter().zip(d_col_c) {
507                            wd_ic += w_is * d_sc;
508                        }
509                        let new_val = row[c] + (c_mat[(i0 + ii, c)] - wd_ic) * inv_dcc;
510                        row[c] = if new_val > eps { new_val } else { eps };
511                    }
512                }
513
514                for s in 0..k {
515                    for ii in 0..rows {
516                        // SAFETY: as above; this tile is the only writer of its rows.
517                        unsafe {
518                            *base.offset(offset(i0 + ii, s)) = scratch[ii * k + s];
519                        }
520                    }
521                }
522            },
523        );
524}
525
526/// Normalise columns of W to unit L2 norm.
527///
528/// Absorbs each column's norm into the corresponding row of H, keeping
529/// the product W H invariant.
530///
531/// ### Params
532///
533/// * `w` - Left factor `m x k`, columns normalised in-place.
534/// * `h` - Right factor `k x n`, rows rescaled in-place.
535pub fn normalise_w_cols<F: BixverseFloat>(w: &mut Mat<F>, h: &mut Mat<F>) {
536    let m = w.nrows();
537    let k = w.ncols();
538    let n = h.ncols();
539    for c in 0..k {
540        // f64 accumulator: `m` is the sample count, so on single-cell inputs this
541        // is a sum over hundreds of thousands of positive terms. In `F = f32` the
542        // norm drifts, and since it is the scale pushed into H it decides whether
543        // W's columns really are unit length, which the consensus clustering
544        // assumes.
545        let mut sq_norm = 0f64;
546        for i in 0..m {
547            let x = w[(i, c)].to_f64().unwrap();
548            sq_norm += x * x;
549        }
550        let norm = F::from_f64(sq_norm.sqrt()).unwrap();
551        if norm <= F::zero() {
552            continue;
553        }
554        let inv_norm = F::one() / norm;
555        for i in 0..m {
556            w[(i, c)] *= inv_norm;
557        }
558        for j in 0..n {
559            h[(c, j)] *= norm;
560        }
561    }
562}
563
564/// Frobenius squared reconstruction error.
565///
566/// Evaluates `||V - WH||_F^2` via the expansion
567/// `||V||_F^2 - 2<W^T V, H> + <W^T W, H H^T>`,
568/// avoiding materialising W H. Always in `f64` to avoid issues with
569/// catastrophic cancellation.
570///
571/// ### Params
572///
573/// * `sq_frob_v` - Precomputed `||V||_F^2`.
574/// * `h` - Right factor `k x n`.
575/// * `a` - Product `W^T V`, shape `k x n`.
576/// * `b` - Gram matrix `W^T W`, shape `k x k`.
577/// * `d` - Gram matrix `H H^T`, shape `k x k`.
578///
579/// ### Returns
580///
581/// The scalar reconstruction error.
582pub fn compute_objective<F: BixverseFloat + Send + Sync>(
583    sq_frob_v: F,
584    h: MatRef<F>,
585    a: MatRef<F>,
586    b: MatRef<F>,
587    d: MatRef<F>,
588) -> F {
589    let k = h.nrows();
590    let n = h.ncols();
591
592    let inner_ha: f64 = (0..n)
593        .into_par_iter()
594        .with_min_len(64)
595        .map(|j| {
596            let mut acc = 0f64;
597            for r in 0..k {
598                acc += h[(r, j)].to_f64().unwrap() * a[(r, j)].to_f64().unwrap();
599            }
600            acc
601        })
602        .reduce(|| 0f64, |x, y| x + y);
603
604    let mut inner_bd = 0f64;
605    for j in 0..k {
606        for i in 0..k {
607            inner_bd += b[(i, j)].to_f64().unwrap() * d[(i, j)].to_f64().unwrap();
608        }
609    }
610
611    let result = sq_frob_v.to_f64().unwrap() - 2.0 * inner_ha + inner_bd;
612    F::from_f64(result.max(0.0)).unwrap()
613}
614
615//////////
616// Main //
617//////////
618
619/// Frobenius HALS NMF.
620///
621/// Runs HALS on any backend implementing [`NmfInput`]. Initialises W and H via
622/// the strategy in `opts.init`, then alternates row and column HALS sweeps
623/// until convergence or `max_iter` is reached. W columns are normalised after
624/// each sweep.
625///
626/// ### Params
627///
628/// * `v` - Input matrix backend.
629/// * `k` - Number of components.
630/// * `opts` - Solver options; see [`HalsOpts`].
631/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
632///   verbosity.
633///
634/// ### Returns
635///
636/// An [`NmfResult`] containing W, H, the final reconstruction loss, the iter
637/// count, and whether the relative tolerance was met.
638pub fn nmf_hals<F, In>(
639    v: &In,
640    k: usize,
641    opts: &HalsOpts<F>,
642    verbose: usize,
643) -> Result<NmfResult<F>, MethodsErrors>
644where
645    F: BixverseFloat + Send + Sync,
646    In: NmfInput<F> + Sync,
647{
648    let (m, n) = v.shape();
649    let sq_frob = v.sq_frob();
650
651    let verbosity = parse_verbosity_level(verbose);
652
653    let (mut w, mut h) = match &opts.init {
654        NmfInit::Nndsvd => {
655            let svd = v.top_k_svd(k)?;
656            nndsvd_from_svd(&svd, k, m, n)?
657        }
658        NmfInit::Random { seed } => random_init(m, n, k, sq_frob, *seed),
659    };
660
661    let mut wtv = Mat::<F>::zeros(k, n);
662    let mut vht = Mat::<F>::zeros(m, k);
663    let mut wtw = Mat::<F>::zeros(k, k);
664    let mut hht = Mat::<F>::zeros(k, k);
665
666    let mut final_loss = F::infinity();
667    let mut last_loss = F::infinity();
668    let mut converged = false;
669    let mut n_iter = 0usize;
670
671    // the convergence check leaves wtw and wtv current for the unchanged w
672    let mut grams_current = false;
673
674    for iter in 0..opts.max_iter {
675        n_iter = iter + 1;
676
677        if !grams_current {
678            gram_wt_w(w.as_ref(), &mut wtw);
679            v.wt_v(w.as_ref(), &mut wtv);
680        }
681        grams_current = false;
682        hals_sweep_rows(&mut h, wtw.as_ref(), wtv.as_ref(), opts.eps);
683
684        gram_h_ht(h.as_ref(), &mut hht);
685        v.v_ht(h.as_ref(), &mut vht);
686        hals_sweep_cols(&mut w, hht.as_ref(), vht.as_ref(), opts.eps);
687
688        normalise_w_cols(&mut w, &mut h);
689
690        if n_iter.is_multiple_of(opts.check_every) {
691            gram_wt_w(w.as_ref(), &mut wtw);
692            v.wt_v(w.as_ref(), &mut wtv);
693            gram_h_ht(h.as_ref(), &mut hht);
694            grams_current = true;
695
696            let loss = compute_objective(
697                sq_frob,
698                h.as_ref(),
699                wtv.as_ref(),
700                wtw.as_ref(),
701                hht.as_ref(),
702            );
703            final_loss = loss;
704
705            if n_iter > opts.check_every {
706                if verbosity.normal_verbosity() {
707                    println!(
708                        "  NMF: Iteration {} out of {} - current loss: {:.2?}",
709                        iter + 1,
710                        opts.max_iter,
711                        loss
712                    );
713                }
714
715                let denom = if sq_frob > F::one() {
716                    sq_frob
717                } else {
718                    F::one()
719                };
720                let rel = (last_loss - loss).abs() / denom;
721                if rel < opts.tol {
722                    converged = true;
723                    if verbosity.normal_verbosity() {
724                        println!("  NMF converged successfully after {} iters", iter + 1)
725                    };
726
727                    break;
728                }
729            }
730            last_loss = loss;
731        }
732    }
733
734    if !n_iter.is_multiple_of(opts.check_every) {
735        gram_wt_w(w.as_ref(), &mut wtw);
736        v.wt_v(w.as_ref(), &mut wtv);
737        gram_h_ht(h.as_ref(), &mut hht);
738        final_loss = compute_objective(
739            sq_frob,
740            h.as_ref(),
741            wtv.as_ref(),
742            wtw.as_ref(),
743            hht.as_ref(),
744        );
745    }
746
747    Ok(NmfResult {
748        w,
749        h,
750        final_loss,
751        n_iter,
752        converged,
753    })
754}
755
756//////////////////////////
757// Multiple run version //
758//////////////////////////
759
760/// Result of stabilised NMF across random restarts.
761pub struct StabilisedNmfResult<F: BixverseFloat> {
762    /// Column-bound W matrices across all runs, shape `m x (k * n_runs)`.
763    /// Columns `i*k..(i+1)*k` are run `i`'s components.
764    pub w_all: Mat<F>,
765    /// Per-run H matrices, each `k x n`.
766    pub h_per_run: Vec<Mat<F>>,
767    /// Final reconstruction loss for each run.
768    pub losses: Vec<F>,
769    /// Convergence flag for each run.
770    pub converged: Vec<bool>,
771    /// Index of the run with the lowest final loss.
772    pub best_idx: usize,
773}
774
775/// Stabilised NMF via random restarts.
776///
777/// Runs `nmf_hals` `n_runs` times with random initialisations seeded by
778/// `base_seed + i` and column-binds the resulting `W` matrices for downstream
779/// consensus clustering. Outer parallelism is capped at
780/// `min(n_runs, max(cores / 2, 1))` to leave headroom for the inner
781/// parallelism inside HALS. The `init` field of `opts` is ignored; random
782/// init is always used.
783///
784/// ### Params
785///
786/// * `v` - Input matrix backend.
787/// * `k` - Number of components per run.
788/// * `n_runs` - Number of random restarts. Must be >= 1.
789/// * `base_seed` - Seed offset; run `i` uses `base_seed + i`.
790/// * `opts` - HALS options (init field ignored).
791/// * `verbose` - If `0` -> silent or `1` for normal verbosity, `2` for detailed
792///   verbosity.
793///
794/// ### Returns
795///
796/// A [`StabilisedNmfResult`] with column-bound `W`, per-run `H`, per-run
797/// losses and convergence flags, and the index of the best run.
798pub fn stabilised_nmf<F, In>(
799    v: &In,
800    k: usize,
801    n_runs: usize,
802    base_seed: u64,
803    opts: &HalsOpts<F>,
804    verbose: usize,
805) -> Result<StabilisedNmfResult<F>, MethodsErrors>
806where
807    F: BixverseFloat + Send + Sync,
808    In: NmfInput<F> + Sync,
809{
810    assert!(n_runs >= 1, "n_runs must be >= 1");
811
812    let verbosity = parse_verbosity_level(verbose);
813    let cores = rayon::current_num_threads();
814    let n_outer = n_runs.min((cores / 2).max(1));
815
816    let pool = rayon::ThreadPoolBuilder::new()
817        .num_threads(n_outer)
818        .build()
819        .expect("failed to build thread pool for stabilised NMF");
820
821    let runs: Vec<NmfResult<F>> = pool.install(|| {
822        (0..n_runs)
823            .into_par_iter()
824            .map(|i| {
825                let opts_i = HalsOpts {
826                    max_iter: opts.max_iter,
827                    tol: opts.tol,
828                    eps: opts.eps,
829                    check_every: opts.check_every,
830                    init: NmfInit::Random {
831                        seed: base_seed + i as u64,
832                    },
833                };
834                let inner_verbose = verbose.saturating_sub(1);
835
836                let res = nmf_hals(v, k, &opts_i, inner_verbose);
837
838                if verbosity.normal_verbosity() {
839                    println!(" Finished stabilised NMF run: {}", i + 1);
840                }
841
842                res
843            })
844            .collect::<Result<Vec<_>, _>>()
845    })?;
846
847    let (m, _) = v.shape();
848
849    let w_all = Mat::<F>::from_fn(m, k * n_runs, |row, col| {
850        let run_idx = col / k;
851        let comp_idx = col % k;
852        runs[run_idx].w[(row, comp_idx)]
853    });
854
855    let losses: Vec<F> = runs.iter().map(|r| r.final_loss).collect();
856    let converged: Vec<bool> = runs.iter().map(|r| r.converged).collect();
857    let h_per_run: Vec<Mat<F>> = runs.into_iter().map(|r| r.h).collect();
858
859    let best_idx = losses
860        .iter()
861        .enumerate()
862        .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
863        .map(|(i, _)| i)
864        .unwrap_or(0);
865
866    Ok(StabilisedNmfResult {
867        w_all,
868        h_per_run,
869        losses,
870        converged,
871        best_idx,
872    })
873}
874
875///////////
876// Tests //
877///////////
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::methods::nmf_hals::dense::DenseInput;
883    use crate::methods::nmf_hals::sparse::SparseInput;
884    use faer::Mat;
885
886    /// The triangular matmul fills only the lower half, so the mirror step must complete W^T W.
887    #[test]
888    fn gram_wt_w_full_symmetric() {
889        let (m, k) = (5, 3);
890        let w: Mat<f64> = Mat::from_fn(m, k, |i, j| (i + j + 1) as f64);
891        let mut out = Mat::<f64>::zeros(k, k);
892        gram_wt_w(w.as_ref(), &mut out);
893        for r in 0..k {
894            for c in 0..k {
895                let expected: f64 = (0..m).map(|i| w[(i, r)] * w[(i, c)]).sum();
896                assert!((out[(r, c)] - expected).abs() < 1e-9);
897            }
898        }
899    }
900
901    /// Same mirror step for H H^T: both triangles must match the direct dot products.
902    #[test]
903    fn gram_h_ht_full_symmetric() {
904        let (k, n) = (3, 5);
905        let h: Mat<f64> = Mat::from_fn(k, n, |i, j| (i + j + 1) as f64);
906        let mut out = Mat::<f64>::zeros(k, k);
907        gram_h_ht(h.as_ref(), &mut out);
908        for r in 0..k {
909            for c in 0..k {
910                let expected: f64 = (0..n).map(|j| h[(r, j)] * h[(c, j)]).sum();
911                assert!((out[(r, c)] - expected).abs() < 1e-9);
912            }
913        }
914    }
915
916    /// The expanded Frobenius objective equals a direct `||V - WH||_F^2` without materialising WH.
917    #[test]
918    fn objective_matches_direct() {
919        let (m, n, k) = (4, 3, 2);
920        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| (i + j + 1) as f64);
921        let w: Mat<f64> = Mat::from_fn(m, k, |i, j| (i + j + 1) as f64 * 0.1);
922        let h: Mat<f64> = Mat::from_fn(k, n, |i, j| (i + j + 1) as f64 * 0.2);
923
924        let mut sq_frob = 0.0;
925        for i in 0..m {
926            for j in 0..n {
927                sq_frob += v_mat[(i, j)].powi(2);
928            }
929        }
930
931        let mut direct = 0.0;
932        for i in 0..m {
933            for j in 0..n {
934                let mut wh = 0.0;
935                for r in 0..k {
936                    wh += w[(i, r)] * h[(r, j)];
937                }
938                direct += (v_mat[(i, j)] - wh).powi(2);
939            }
940        }
941
942        let input = DenseInput::new(v_mat.as_ref()).unwrap();
943        let mut wtv = Mat::<f64>::zeros(k, n);
944        let mut wtw = Mat::<f64>::zeros(k, k);
945        let mut hht = Mat::<f64>::zeros(k, k);
946        input.wt_v(w.as_ref(), &mut wtv);
947        gram_wt_w(w.as_ref(), &mut wtw);
948        gram_h_ht(h.as_ref(), &mut hht);
949
950        let via = compute_objective(
951            sq_frob,
952            h.as_ref(),
953            wtv.as_ref(),
954            wtw.as_ref(),
955            hht.as_ref(),
956        );
957        assert!((direct - via).abs() < 1e-8);
958    }
959
960    /// Column normalisation leaves W H invariant while driving every W column to unit L2 norm.
961    #[test]
962    fn normalise_preserves_product_and_unit_norms() {
963        let (m, n, k) = (4, 3, 2);
964        let mut w: Mat<f64> = Mat::from_fn(m, k, |i, j| (i + j + 1) as f64);
965        let mut h: Mat<f64> = Mat::from_fn(k, n, |i, j| (i + j + 1) as f64);
966
967        let mut before = Vec::with_capacity(m * n);
968        for i in 0..m {
969            for j in 0..n {
970                let mut prod = 0.0;
971                for r in 0..k {
972                    prod += w[(i, r)] * h[(r, j)];
973                }
974                before.push(prod);
975            }
976        }
977
978        normalise_w_cols(&mut w, &mut h);
979
980        for c in 0..k {
981            let mut sq = 0.0;
982            for i in 0..m {
983                sq += w[(i, c)].powi(2);
984            }
985            assert!((sq.sqrt() - 1.0).abs() < 1e-9);
986        }
987
988        let mut after = Vec::with_capacity(m * n);
989        for i in 0..m {
990            for j in 0..n {
991                let mut prod = 0.0;
992                for r in 0..k {
993                    prod += w[(i, r)] * h[(r, j)];
994                }
995                after.push(prod);
996            }
997        }
998        for (a, b) in after.iter().zip(before.iter()) {
999            assert!((a - b).abs() < 1e-9);
1000        }
1001    }
1002
1003    /// HALS recovers an exactly rank-k dense product with near-zero loss and non-negative factors.
1004    #[test]
1005    fn hals_recovers_rank_k_dense() {
1006        let (m, n, k) = (30, 20, 3);
1007        let w_true: Mat<f64> = Mat::from_fn(m, k, |i, j| ((i * 7 + j * 3) % 5 + 1) as f64);
1008        let h_true: Mat<f64> = Mat::from_fn(k, n, |i, j| ((i * 5 + j * 2) % 4 + 1) as f64);
1009        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| {
1010            let mut s = 0.0;
1011            for r in 0..k {
1012                s += w_true[(i, r)] * h_true[(r, j)];
1013            }
1014            s
1015        });
1016
1017        let mut sq_frob_v = 0.0;
1018        for i in 0..m {
1019            for j in 0..n {
1020                sq_frob_v += v_mat[(i, j)].powi(2);
1021            }
1022        }
1023
1024        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1025        let opts = HalsOpts::<f64>::new(400, 1e-9, 1e-12, 5, NmfInit::Nndsvd);
1026        let res = nmf_hals(&input, k, &opts, 0).unwrap();
1027
1028        let rel = res.final_loss / sq_frob_v;
1029        assert!(rel < 1e-3, "rel loss {rel}");
1030
1031        for i in 0..m {
1032            for r in 0..k {
1033                assert!(res.w[(i, r)] >= 0.0);
1034            }
1035        }
1036        for r in 0..k {
1037            for j in 0..n {
1038                assert!(res.h[(r, j)] >= 0.0);
1039            }
1040        }
1041    }
1042
1043    /// The sparse backend reaches the same low relative loss as the dense path on a rank-k product.
1044    #[test]
1045    fn hals_sparse_recovers_rank_k() {
1046        let (m, n, k) = (20, 15, 2);
1047        let w_true: Mat<f64> = Mat::from_fn(m, k, |i, j| {
1048            if (i + j) % 3 == 0 {
1049                (i + 1) as f64
1050            } else {
1051                0.0
1052            }
1053        });
1054        let h_true: Mat<f64> = Mat::from_fn(k, n, |i, j| ((i * 3 + j) % 4 + 1) as f64);
1055        let v_dense: Mat<f64> = Mat::from_fn(m, n, |i, j| {
1056            let mut s = 0.0;
1057            for r in 0..k {
1058                s += w_true[(i, r)] * h_true[(r, j)];
1059            }
1060            s
1061        });
1062
1063        let mut sq_frob_v = 0.0;
1064        for i in 0..m {
1065            for j in 0..n {
1066                sq_frob_v += v_dense[(i, j)].powi(2);
1067            }
1068        }
1069
1070        let csr = CompressedSparseData2::<f64, f64>::from_dense_matrix(
1071            v_dense.as_ref(),
1072            CompressedSparseFormat::Csr,
1073        );
1074        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csr).unwrap();
1075
1076        let opts = HalsOpts::<f64>::new(400, 1e-9, 1e-12, 10, NmfInit::Nndsvd);
1077        let res = nmf_hals(&sparse_in, k, &opts, 0).unwrap();
1078
1079        let rel = res.final_loss / sq_frob_v;
1080        assert!(rel < 1e-2, "rel loss {rel}");
1081    }
1082
1083    /// The converged flag is set, and iteration stops early, once the relative tolerance is met.
1084    #[test]
1085    fn hals_converged_flag_set_when_tol_met() {
1086        let (m, n, k) = (15, 10, 2);
1087        let w_true: Mat<f64> = Mat::from_fn(m, k, |i, j| ((i + 2 * j) % 4 + 1) as f64);
1088        let h_true: Mat<f64> = Mat::from_fn(k, n, |i, j| ((2 * i + j) % 3 + 1) as f64);
1089        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| {
1090            (0..k).map(|r| w_true[(i, r)] * h_true[(r, j)]).sum()
1091        });
1092        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1093        let opts = HalsOpts::<f64>::new(1000, 1e-6, 1e-12, 5, NmfInit::Nndsvd);
1094        let res = nmf_hals(&input, k, &opts, 0).unwrap();
1095        assert!(res.converged);
1096        assert!(res.n_iter < 1000);
1097    }
1098
1099    /// `best_idx` points at the run with the lowest final loss, not merely the first run.
1100    #[test]
1101    fn stabilised_nmf_best_idx_is_min_loss() {
1102        let (m, n, k) = (10, 8, 2);
1103        let n_runs = 5;
1104        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| (i * 2 + j + 1) as f64);
1105        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1106        let opts = HalsOpts::<f64>::new(100, 1e-8, 1e-12, 10, NmfInit::Nndsvd);
1107
1108        let res = stabilised_nmf(&input, k, n_runs, 0, &opts, 0).unwrap();
1109
1110        let best_loss = res.losses[res.best_idx];
1111        for &loss in &res.losses {
1112            assert!(loss >= best_loss - 1e-12);
1113        }
1114    }
1115
1116    /// The same base seed reproduces identical losses and W blocks, despite the outer thread pool.
1117    #[test]
1118    fn stabilised_nmf_deterministic_with_same_seed() {
1119        let (m, n, k) = (10, 8, 2);
1120        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| (i + j + 1) as f64);
1121        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1122        let opts = HalsOpts::<f64>::new(50, 1e-6, 1e-12, 10, NmfInit::Nndsvd);
1123
1124        let res_a = stabilised_nmf(&input, k, 3, 123, &opts, 0).unwrap();
1125        let res_b = stabilised_nmf(&input, k, 3, 123, &opts, 0).unwrap();
1126
1127        assert_eq!(res_a.losses.len(), res_b.losses.len());
1128        for run in 0..res_a.losses.len() {
1129            assert!((res_a.losses[run] - res_b.losses[run]).abs() < 1e-9);
1130        }
1131        for col in 0..res_a.w_all.ncols() {
1132            for row in 0..res_a.w_all.nrows() {
1133                assert!((res_a.w_all[(row, col)] - res_b.w_all[(row, col)]).abs() < 1e-9);
1134            }
1135        }
1136    }
1137
1138    /// The best of several random restarts still recovers an exactly rank-k product.
1139    #[test]
1140    fn stabilised_nmf_recovers_rank_k() {
1141        let (m, n, k) = (25, 18, 2);
1142        let w_true: Mat<f64> = Mat::from_fn(m, k, |i, j| ((i * 3 + j) % 4 + 1) as f64);
1143        let h_true: Mat<f64> = Mat::from_fn(k, n, |i, j| ((i * 2 + j) % 3 + 1) as f64);
1144        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| {
1145            let mut s = 0.0;
1146            for r in 0..k {
1147                s += w_true[(i, r)] * h_true[(r, j)];
1148            }
1149            s
1150        });
1151
1152        let mut sq_frob_v = 0.0;
1153        for i in 0..m {
1154            for j in 0..n {
1155                sq_frob_v += v_mat[(i, j)].powi(2);
1156            }
1157        }
1158
1159        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1160        let opts = HalsOpts::<f64>::new(300, 1e-8, 1e-12, 10, NmfInit::Nndsvd);
1161
1162        let res = stabilised_nmf(&input, k, 5, 0, &opts, 0).unwrap();
1163
1164        let rel = res.losses[res.best_idx] / sq_frob_v;
1165        assert!(rel < 1e-2, "best rel loss {rel}");
1166    }
1167
1168    /// The `init` field is ignored: every restart uses random init, so the per-run W blocks differ.
1169    #[test]
1170    fn stabilised_nmf_ignores_init_field() {
1171        // Passing NmfInit::Nndsvd in opts should not produce identical W columns
1172        // across runs: random init must override.
1173        let (m, n, k) = (10, 8, 2);
1174        let v_mat: Mat<f64> = Mat::from_fn(m, n, |i, j| (i + j + 1) as f64);
1175        let input = DenseInput::new(v_mat.as_ref()).unwrap();
1176        let opts = HalsOpts::<f64>::new(20, 1e-3, 1e-12, 10, NmfInit::Nndsvd);
1177
1178        let res = stabilised_nmf(&input, k, 3, 0, &opts, 0).unwrap();
1179
1180        // Runs 0 and 1 use different seeds, so their W blocks should differ.
1181        let mut max_diff = 0.0_f64;
1182        for col in 0..k {
1183            for row in 0..m {
1184                let a = res.w_all[(row, col)];
1185                let b = res.w_all[(row, col + k)];
1186                let d = (a - b).abs();
1187                if d > max_diff {
1188                    max_diff = d;
1189                }
1190            }
1191        }
1192        assert!(max_diff > 1e-6, "init field was not overridden to Random");
1193    }
1194}