Skip to main content

bixverse_methods/methods/nmf_hals/
sparse.rs

1//! Sparse backend for NMF. Holds the chosen layer of V as CSC and CSR with
2//! storage type `S`; widens to compute type `F` per element inside two
3//! hand-rolled spMM kernels.
4
5use faer::{Mat, MatRef};
6use num_traits::{Float, ToPrimitive};
7use rayon::prelude::*;
8
9use super::NmfInput;
10use crate::prelude::*;
11use bixverse_core::core::math::pca_svd::SvdResults;
12use bixverse_core::core::math::sparse::sparse_svd_lanczos;
13use bixverse_core::core::math::vector_helpers::sum_sq_f64;
14
15/////////////
16// Helpers //
17/////////////
18
19/// Cast and validate a slice of values for NMF input.
20///
21/// Casts each element from `T` to `S`, then checks finiteness and
22/// non-negativity.
23///
24/// ### Params
25///
26/// * `src` - Source slice to cast and validate.
27///
28/// ### Returns
29///
30/// A validated `Vec<S>`, or [`MethodsErrors::NmfNonFinite`] if any cast
31/// value is non-finite, or [`MethodsErrors::NmfNonNegativeViolated`] if
32/// any cast value is negative.
33fn cast_and_validate<T, S>(src: &[T]) -> Result<Vec<S>, MethodsErrors>
34where
35    T: Copy + Into<S>,
36    S: Float,
37{
38    let mut out = Vec::with_capacity(src.len());
39    for &v in src {
40        let s: S = v.into();
41        if !s.is_finite() {
42            return Err(MethodsErrors::NmfNonFinite);
43        }
44        if s < S::zero() {
45            return Err(MethodsErrors::NmfNonNegativeViolated);
46        }
47        out.push(s);
48    }
49    Ok(out)
50}
51
52/// Squared Frobenius norm of a flat data buffer.
53///
54/// Accumulates in `f64` via [`sum_sq_f64`] and casts once, rather than summing in
55/// `F`. Over 3e7 non-zeros the `F = f32` version measured 4.4% low, and that error
56/// lands directly in every relative loss the solver reports.
57///
58/// ### Params
59///
60/// * `data` - Flat non-zero value buffer.
61///
62/// ### Returns
63///
64/// The scalar `||V||_F^2`.
65fn compute_sq_frob<S, F>(data: &[S]) -> F
66where
67    S: BixverseNumeric + ToPrimitive,
68    F: BixverseFloat,
69{
70    F::from_f64(sum_sq_f64(data)).unwrap()
71}
72
73/// Build a matched (CSR, CSC) pair from a source matrix and its transpose.
74///
75/// Branches on the source orientation so the returned tuple is always
76/// `(csr, csc)` regardless of which was passed as `source`.
77///
78/// ### Params
79///
80/// * `source` - The primary sparse matrix.
81/// * `other` - Its transpose in the opposite format.
82/// * `source_cast` - Cast non-zero values for `source`.
83/// * `other_cast` - Cast non-zero values for `other`.
84/// * `shape` - Logical shape `(m, n)` of the matrix.
85///
86/// ### Returns
87///
88/// A `(csr, csc)` pair with indices and indptr borrowed from the inputs.
89fn build_pair<S, T, U>(
90    source: &CompressedSparseData2<T, U>,
91    other: CompressedSparseData2<T, U>,
92    source_cast: Vec<S>,
93    other_cast: Vec<S>,
94    shape: (usize, usize),
95) -> (CompressedSparseData2<S>, CompressedSparseData2<S>)
96where
97    S: Clone,
98    T: Clone,
99    U: Clone,
100{
101    match source.cs_type {
102        CompressedSparseFormat::Csr => {
103            let csr = CompressedSparseData2 {
104                data: source_cast,
105                indices: source.indices.clone(),
106                indptr: source.indptr.clone(),
107                cs_type: CompressedSparseFormat::Csr,
108                data_2: None,
109                shape,
110            };
111            let csc = CompressedSparseData2 {
112                data: other_cast,
113                indices: other.indices,
114                indptr: other.indptr,
115                cs_type: CompressedSparseFormat::Csc,
116                data_2: None,
117                shape,
118            };
119            (csr, csc)
120        }
121        CompressedSparseFormat::Csc => {
122            let csc = CompressedSparseData2 {
123                data: source_cast,
124                indices: source.indices.clone(),
125                indptr: source.indptr.clone(),
126                cs_type: CompressedSparseFormat::Csc,
127                data_2: None,
128                shape,
129            };
130            let csr = CompressedSparseData2 {
131                data: other_cast,
132                indices: other.indices,
133                indptr: other.indptr,
134                cs_type: CompressedSparseFormat::Csr,
135                data_2: None,
136                shape,
137            };
138            (csr, csc)
139        }
140    }
141}
142
143/// Sparse W^T V via CSC.
144///
145/// Computes `out = W^T V` using the CSC layout of V, parallelising over output
146/// columns.
147///
148/// ### Params
149///
150/// * `csc` - CSC representation of V, shape `m x n`.
151/// * `w` - Left factor, shape `m x k`.
152/// * `out` - Output buffer `k x n`. Overwritten on return.
153fn spmm_csc_wt_v<S, F>(csc: &CompressedSparseData2<S>, w: MatRef<F>, out: &mut Mat<F>)
154where
155    S: BixverseNumeric + Into<F> + Sync,
156    F: BixverseFloat + Send + Sync,
157{
158    let n = csc.shape.1;
159    let k = w.ncols();
160
161    // W^T, so the k entries of row i of W are contiguous per non-zero
162    let mut wt = Mat::<F>::zeros(k, w.nrows());
163    wt.par_col_iter_mut().enumerate().for_each(|(i, mut col)| {
164        for r in 0..k {
165            col[r] = w[(i, r)];
166        }
167    });
168
169    let out_ptr = out.as_ptr_mut() as usize;
170    let out_row_stride = out.row_stride();
171    let out_col_stride = out.col_stride();
172
173    (0..n).into_par_iter().for_each_init(
174        || vec![F::zero(); k],
175        |acc, j| {
176            acc.fill(F::zero());
177
178            let start = csc.indptr[j] as usize;
179            let end = csc.indptr[j + 1] as usize;
180            for idx in start..end {
181                let i = csc.indices[idx] as usize;
182                let val_f: F = csc.data[idx].into();
183                for (a, &w_ir) in acc.iter_mut().zip(wt.col_as_slice(i)) {
184                    *a += val_f * w_ir;
185                }
186            }
187
188            let base = out_ptr as *mut F;
189            for r in 0..k {
190                unsafe {
191                    let ptr =
192                        base.offset(r as isize * out_row_stride + j as isize * out_col_stride);
193                    *ptr = acc[r];
194                }
195            }
196        },
197    );
198}
199
200/// Sparse V H^T via CSR.
201///
202/// Computes `out = V H^T` using the CSR layout of V, parallelising over output
203/// rows.
204///
205/// ### Params
206///
207/// * `csr` - CSR representation of V, shape `m x n`.
208/// * `h` - Right factor, shape `k x n`.
209/// * `out` - Output buffer `m x k`. Overwritten on return.
210fn spmm_csr_v_ht<S, F>(csr: &CompressedSparseData2<S>, h: MatRef<F>, out: &mut Mat<F>)
211where
212    S: BixverseNumeric + Into<F> + Sync,
213    F: BixverseFloat + Send + Sync,
214{
215    let m = csr.shape.0;
216    let k = h.nrows();
217
218    let out_ptr = out.as_ptr_mut() as usize;
219    let out_row_stride = out.row_stride();
220    let out_col_stride = out.col_stride();
221
222    (0..m).into_par_iter().for_each_init(
223        || vec![F::zero(); k],
224        |acc, i| {
225            acc.fill(F::zero());
226
227            let start = csr.indptr[i] as usize;
228            let end = csr.indptr[i + 1] as usize;
229            for idx in start..end {
230                let j = csr.indices[idx] as usize;
231                let val_f: F = csr.data[idx].into();
232                for r in 0..k {
233                    acc[r] += val_f * h[(r, j)];
234                }
235            }
236
237            let base = out_ptr as *mut F;
238            for r in 0..k {
239                unsafe {
240                    let ptr =
241                        base.offset(i as isize * out_row_stride + r as isize * out_col_stride);
242                    *ptr = acc[r];
243                }
244            }
245        },
246    );
247}
248
249//////////
250// Main //
251//////////
252
253/// Sparse NMF input backed by a matched (CSR, CSC) pair.
254///
255/// Validates non-negativity and finiteness once at construction, then computes
256/// and caches `||V||_F^2`.
257#[derive(Debug)]
258pub struct SparseInput<S, F>
259where
260    S: BixverseNumeric + Into<F> + Float,
261    F: BixverseFloat,
262{
263    /// CSR layout of V, used for `V H^T`.
264    csr: CompressedSparseData2<S>,
265    /// CSC layout of V, used for `W^T V`.
266    csc: CompressedSparseData2<S>,
267    /// Cached `||V||_F^2`.
268    sq_frob: F,
269    /// Logical shape `(m, n)` of V.
270    shape: (usize, usize),
271}
272
273impl<S, F> SparseInput<S, F>
274where
275    S: BixverseNumeric + BixverseSimd + Into<F> + Float + Clone,
276    F: BixverseFloat + BixverseSimd + std::iter::Sum,
277{
278    /// Construct a [`SparseInput`] from the primary data layer of `matrix`.
279    ///
280    /// Casts and validates `data`, derives the transpose, and builds the
281    /// matched (CSR, CSC) pair.
282    ///
283    /// ### Params
284    ///
285    /// * `matrix` - Source sparse matrix whose `data` field is used.
286    ///
287    /// ### Returns
288    ///
289    /// A [`SparseInput`], or [`MethodsErrors::NmfNonFinite`] /
290    /// [`MethodsErrors::NmfNonNegativeViolated`] if validation fails.
291    pub fn from_primary<T, U>(matrix: &CompressedSparseData2<T, U>) -> Result<Self, MethodsErrors>
292    where
293        T: BixverseNumeric + Into<S>,
294        U: BixverseNumeric,
295    {
296        let shape = matrix.shape;
297        let other = matrix.transform_single_layer(false)?;
298
299        let primary: Vec<S> = cast_and_validate::<T, S>(&matrix.data)?;
300        let secondary: Vec<S> = other.data.iter().map(|&v| v.into()).collect();
301
302        let (csr, csc) = build_pair(matrix, other, primary, secondary, shape);
303        let sq_frob = compute_sq_frob::<S, F>(&csr.data);
304
305        Ok(Self {
306            csr,
307            csc,
308            sq_frob,
309            shape,
310        })
311    }
312
313    /// Construct a [`SparseInput`] from the secondary data layer of `matrix`.
314    ///
315    /// Casts and validates `data_2`, derives the transpose, and builds the
316    /// matched (CSR, CSC) pair. Intended for the log1p-normalised layer in the
317    /// single-cell case.
318    ///
319    /// ### Params
320    ///
321    /// * `matrix` - Source sparse matrix whose `data_2` field is used.
322    ///
323    /// ### Returns
324    ///
325    /// A [`SparseInput`], or [`CoreErrors::Data2NotAvailable`] if
326    /// `data_2` is absent, or [`MethodsErrors::NmfNonFinite`] /
327    /// [`MethodsErrors::NmfNonNegativeViolated`] if validation fails.
328    pub fn from_secondary<T, U>(matrix: &CompressedSparseData2<T, U>) -> Result<Self, MethodsErrors>
329    where
330        T: BixverseNumeric,
331        U: BixverseNumeric + Into<S>,
332    {
333        let shape = matrix.shape;
334        let other = matrix.transform_single_layer(true)?;
335
336        let m_data2 = matrix
337            .data_2
338            .as_ref()
339            .ok_or(CoreErrors::Data2NotAvailable)?;
340        let o_data2 = other.data_2.as_ref().ok_or(CoreErrors::Data2NotAvailable)?;
341
342        let primary: Vec<S> = cast_and_validate::<U, S>(m_data2)?;
343        let secondary: Vec<S> = o_data2.iter().map(|&v| v.into()).collect();
344
345        let (csr, csc) = build_pair(matrix, other, primary, secondary, shape);
346        let sq_frob = compute_sq_frob::<S, F>(&csr.data);
347
348        Ok(Self {
349            csr,
350            csc,
351            sq_frob,
352            shape,
353        })
354    }
355
356    /// The CSR layout of V, used for `V H^T`.
357    ///
358    /// Exposed so a GPU backend can upload the already-validated matched pair
359    /// rather than rebuilding and revalidating it.
360    ///
361    /// ### Returns
362    ///
363    /// A reference to the CSR layer.
364    pub fn csr(&self) -> &CompressedSparseData2<S> {
365        &self.csr
366    }
367
368    /// The CSC layout of V, used for `W^T V`.
369    ///
370    /// Exposed for the same reason as [`Self::csr`].
371    ///
372    /// ### Returns
373    ///
374    /// A reference to the CSC layer.
375    pub fn csc(&self) -> &CompressedSparseData2<S> {
376        &self.csc
377    }
378}
379
380/// NmfInput trait implementation for [SparseInput]
381impl<S, F> NmfInput<F> for SparseInput<S, F>
382where
383    S: BixverseNumeric + BixverseSimd + Into<F> + Float + Clone + Sync,
384    F: BixverseFloat + BixverseSimd + Send + Sync + std::iter::Sum,
385{
386    fn shape(&self) -> (usize, usize) {
387        self.shape
388    }
389
390    fn sq_frob(&self) -> F {
391        self.sq_frob
392    }
393
394    fn wt_v(&self, w: MatRef<F>, out: &mut Mat<F>) {
395        spmm_csc_wt_v::<S, F>(&self.csc, w, out);
396    }
397
398    fn v_ht(&self, h: MatRef<F>, out: &mut Mat<F>) {
399        spmm_csr_v_ht::<S, F>(&self.csr, h, out);
400    }
401
402    fn top_k_svd(&self, k: usize) -> Result<SvdResults<F>, MethodsErrors> {
403        sparse_svd_lanczos::<S, S, F>(&self.csr, k, 42, false, None, None, None).map_err(Into::into)
404    }
405}
406
407///////////
408// Tests //
409///////////
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::methods::nmf_hals::dense::DenseInput;
415    use faer::Mat;
416
417    /// One 3x4 matrix in both CSR and dense form, for sparse against dense parity checks.
418    fn fixture_csr() -> (CompressedSparseData2<f64, f64>, Mat<f64>) {
419        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
420        let indices: Vec<u32> = vec![0, 2, 1, 3, 0, 3];
421        let indptr: Vec<u32> = vec![0, 2, 4, 6];
422        let csr =
423            CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, (3, 4));
424        let dense = Mat::from_fn(3, 4, |i, j| match (i, j) {
425            (0, 0) => 1.0,
426            (0, 2) => 2.0,
427            (1, 1) => 3.0,
428            (1, 3) => 4.0,
429            (2, 0) => 5.0,
430            (2, 3) => 6.0,
431            _ => 0.0,
432        });
433        (csr, dense)
434    }
435
436    /// `||V||_F^2` over the non-zeros has to be accurate on data where summing in
437    /// `F` visibly is not. Sparse is the path where this bit hardest, because the
438    /// term count is the non-zero count: measured 4.4% low over 3e7 non-zeros
439    /// before the accumulator moved to f64.
440    #[test]
441    fn sq_frob_survives_a_swamped_accumulator() {
442        // One leading non-zero squaring to 1e8, then a million ones. At 1e8 the
443        // f32 spacing is 8, so a running f32 total swallows every one of them.
444        let n_ones = 1_000_000usize;
445        let mut data = vec![1e4f32];
446        data.extend(std::iter::repeat_n(1.0f32, n_ones));
447        let nnz = data.len();
448        let indices: Vec<u32> = (0..nnz as u32).collect();
449        let indptr: Vec<u32> = vec![0, nnz as u32];
450        let csr =
451            CompressedSparseData2::<f32, f32>::new_csr(&data, &indices, &indptr, None, (1, nnz));
452
453        let sparse_in: SparseInput<f32, f32> = SparseInput::from_primary(&csr).unwrap();
454        let exact = 1e8 + n_ones as f64;
455        let got = sparse_in.sq_frob() as f64;
456        assert!(
457            (got - exact).abs() <= 1e-5 * exact,
458            "sq_frob is off: got {got}, exact {exact}"
459        );
460
461        let naive = data.iter().fold(0f32, |acc, &v| acc + v * v) as f64;
462        assert!(
463            (naive - exact).abs() > 0.005 * exact,
464            "the f32 reference was supposed to be visibly wrong, so this test proves nothing"
465        );
466    }
467
468    /// The sparse backend reports the same shape and squared Frobenius norm as the dense one.
469    #[test]
470    fn shape_and_sq_frob_match_dense() {
471        let (csr, dense) = fixture_csr();
472        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csr).unwrap();
473        let dense_in = DenseInput::new(dense.as_ref()).unwrap();
474        assert_eq!(sparse_in.shape(), dense_in.shape());
475        assert!((sparse_in.sq_frob() - dense_in.sq_frob()).abs() < 1e-12);
476    }
477
478    /// A negative non-zero is refused at construction rather than silently factorised.
479    #[test]
480    fn rejects_negative() {
481        let data = vec![1.0, -2.0];
482        let indices: Vec<u32> = vec![0, 1];
483        let indptr: Vec<u32> = vec![0, 2];
484        let csr =
485            CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, (1, 2));
486        let err = SparseInput::<f64, f64>::from_primary(&csr).unwrap_err();
487        assert!(matches!(err, MethodsErrors::NmfNonNegativeViolated));
488    }
489
490    /// A NaN non-zero is refused at construction.
491    #[test]
492    fn rejects_non_finite() {
493        let data = vec![1.0, f64::NAN];
494        let indices: Vec<u32> = vec![0, 1];
495        let indptr: Vec<u32> = vec![0, 2];
496        let csr =
497            CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, (1, 2));
498        let err = SparseInput::<f64, f64>::from_primary(&csr).unwrap_err();
499        assert!(matches!(err, MethodsErrors::NmfNonFinite));
500    }
501
502    /// The hand-rolled CSC `W^T V` kernel agrees with the dense faer matmul.
503    #[test]
504    fn wt_v_matches_dense() {
505        let (csr, dense) = fixture_csr();
506        let k = 2;
507        let w: Mat<f64> = Mat::from_fn(3, k, |i, j| (i + j + 1) as f64);
508        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csr).unwrap();
509        let dense_in = DenseInput::new(dense.as_ref()).unwrap();
510        let mut out_s = Mat::<f64>::zeros(k, 4);
511        let mut out_d = Mat::<f64>::zeros(k, 4);
512        sparse_in.wt_v(w.as_ref(), &mut out_s);
513        dense_in.wt_v(w.as_ref(), &mut out_d);
514        for r in 0..k {
515            for j in 0..4 {
516                assert!((out_s[(r, j)] - out_d[(r, j)]).abs() < 1e-9);
517            }
518        }
519    }
520
521    /// The hand-rolled CSR `V H^T` kernel agrees with the dense faer matmul.
522    #[test]
523    fn v_ht_matches_dense() {
524        let (csr, dense) = fixture_csr();
525        let k = 2;
526        let h: Mat<f64> = Mat::from_fn(k, 4, |i, j| (i + j + 1) as f64);
527        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csr).unwrap();
528        let dense_in = DenseInput::new(dense.as_ref()).unwrap();
529        let mut out_s = Mat::<f64>::zeros(3, k);
530        let mut out_d = Mat::<f64>::zeros(3, k);
531        sparse_in.v_ht(h.as_ref(), &mut out_s);
532        dense_in.v_ht(h.as_ref(), &mut out_d);
533        for i in 0..3 {
534            for r in 0..k {
535                assert!((out_s[(i, r)] - out_d[(i, r)]).abs() < 1e-9);
536            }
537        }
538    }
539
540    /// A CSC source takes the other arm of `build_pair`, and must still yield the same products.
541    #[test]
542    fn csc_input_produces_same_products() {
543        // Same logical matrix but constructed as CSC.
544        let data = vec![1.0, 5.0, 3.0, 2.0, 4.0, 6.0];
545        let indices: Vec<u32> = vec![0, 2, 1, 0, 1, 2];
546        let indptr: Vec<u32> = vec![0, 2, 3, 4, 6];
547        let csc =
548            CompressedSparseData2::<f64, f64>::new_csc(&data, &indices, &indptr, None, (3, 4));
549        let dense = Mat::from_fn(3, 4, |i, j| match (i, j) {
550            (0, 0) => 1.0,
551            (0, 2) => 2.0,
552            (1, 1) => 3.0,
553            (1, 3) => 4.0,
554            (2, 0) => 5.0,
555            (2, 3) => 6.0,
556            _ => 0.0,
557        });
558
559        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csc).unwrap();
560        let dense_in = DenseInput::new(dense.as_ref()).unwrap();
561
562        let k = 2;
563        let w: Mat<f64> = Mat::from_fn(3, k, |i, j| (i + j + 1) as f64);
564        let mut out_s = Mat::<f64>::zeros(k, 4);
565        let mut out_d = Mat::<f64>::zeros(k, 4);
566        sparse_in.wt_v(w.as_ref(), &mut out_s);
567        dense_in.wt_v(w.as_ref(), &mut out_d);
568        for r in 0..k {
569            for j in 0..4 {
570                assert!((out_s[(r, j)] - out_d[(r, j)]).abs() < 1e-9);
571            }
572        }
573    }
574
575    /// `from_secondary` reads the `data_2` layer, so the cached norm comes from it, not `data`.
576    #[test]
577    fn from_secondary_uses_data_2() {
578        let data = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0];
579        let data_2 = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
580        let indices: Vec<u32> = vec![0, 2, 1, 3, 0, 3];
581        let indptr: Vec<u32> = vec![0, 2, 4, 6];
582        let csr = CompressedSparseData2::<f64, f64>::new_csr(
583            &data,
584            &indices,
585            &indptr,
586            Some(&data_2),
587            (3, 4),
588        );
589
590        let sparse_in: SparseInput<f64, f64> = SparseInput::from_secondary(&csr).unwrap();
591        // sq_frob is from data_2, not data
592        let expected: f64 = data_2.iter().map(|v| v * v).sum();
593        assert!((sparse_in.sq_frob() - expected).abs() < 1e-12);
594    }
595
596    /// The Lanczos SVD returns k triplets of the right shape, in descending singular value order.
597    #[test]
598    fn top_k_svd_shapes() {
599        let (csr, _) = fixture_csr();
600        let sparse_in: SparseInput<f64, f64> = SparseInput::from_primary(&csr).unwrap();
601        let svd = sparse_in.top_k_svd(2).unwrap();
602        assert_eq!(svd.u.shape(), (3, 2));
603        assert_eq!(svd.v.shape(), (4, 2));
604        assert_eq!(svd.s.len(), 2);
605        assert!(svd.s[0] >= svd.s[1]);
606    }
607}