Skip to main content

single_svdlib/matrix/
masked.rs

1//! A submatrix view over a sparse matrix.
2//!
3//! Selects rows, columns, or both, and presents the result as a matrix in its own right
4//! — the solvers see only the selected entries. Nothing is copied, nothing is
5//! reindexed, and the underlying matrix is never modified.
6//!
7//! # Cost
8//!
9//! Row selection is free: the matrix is CSR, so a row is an outer index and skipping one
10//! means not visiting it. Masking rows makes every product *cheaper* in proportion to
11//! what was dropped.
12//!
13//! Column selection costs one lookup per non-zero visited, against a dense
14//! `original -> masked` table of `cols()` entries. That table is the only allocation the
15//! view makes beyond the index lists themselves.
16
17use super::{apply_centering, SparseMat, SparseMatDense};
18use crate::types::SvdFloat;
19use ndarray::{Array1, ArrayView1, ArrayView2, ArrayViewMut2, Axis};
20use rayon::prelude::*;
21use sprs::{CsMatI, SpIndex};
22
23/// Sentinel for "this column is not in the mask".
24///
25/// A sentinel rather than `Option<usize>` halves the lookup table and keeps the inner
26/// loop branch-light.
27const EXCLUDED: usize = usize::MAX;
28
29/// A view exposing a subset of the rows and/or columns of a CSR matrix.
30///
31/// Both selections keep ascending original order, so masked index `i` is original index
32/// `selected_rows()[i]` (resp. `selected_columns()[i]`).
33///
34/// ```
35/// use single_svdlib::{sprs::TriMatI, MaskedCsMat, SparseMat, SvdMat};
36///
37/// let mut t = TriMatI::<f64, u32>::new((6, 5));
38/// for i in 0..6 { for j in 0..5 { t.add_triplet(i, j, (i * 5 + j) as f64); } }
39/// let a: SvdMat<f64> = t.to_csr::<u64>();
40///
41/// // Cells 0, 2, 4 by genes 1, 3 — a 3x2 matrix, without touching `a`.
42/// let view = MaskedCsMat::submatrix(&a, Some(&[0, 2, 4]), Some(&[1, 3]));
43/// assert_eq!((view.rows(), view.cols()), (3, 2));
44/// ```
45pub struct MaskedCsMat<'a, T, I = u32, Iptr = u64>
46where
47    I: SpIndex,
48    Iptr: SpIndex,
49{
50    matrix: &'a CsMatI<T, I, Iptr>,
51    /// Selected original row indices, ascending. `None` means every row.
52    rows: Option<Vec<usize>>,
53    /// Selected original column indices, ascending. `None` means every column.
54    cols: Option<Vec<usize>>,
55    /// Original column -> masked column. Empty when no column mask is in force.
56    col_to_masked: Vec<usize>,
57    nnz: usize,
58}
59
60impl<'a, T, I, Iptr> MaskedCsMat<'a, T, I, Iptr>
61where
62    T: SvdFloat,
63    I: SpIndex,
64    Iptr: SpIndex,
65{
66    /// A view of the given rows and/or columns. `None` keeps that axis whole.
67    ///
68    /// Index lists may be in any order and may repeat; the view presents each selected
69    /// index once, ascending.
70    ///
71    /// # Panics
72    /// If any index is out of bounds, or the matrix is not CSR.
73    pub fn submatrix(
74        matrix: &'a CsMatI<T, I, Iptr>,
75        rows: Option<&[usize]>,
76        cols: Option<&[usize]>,
77    ) -> Self {
78        assert!(matrix.is_csr(), "MaskedCsMat requires a CSR matrix");
79
80        let rows = rows.map(|r| Self::normalise(r, matrix.rows(), "row"));
81        let cols = cols.map(|c| Self::normalise(c, matrix.cols(), "column"));
82
83        let col_to_masked = match &cols {
84            Some(c) => {
85                let mut table = vec![EXCLUDED; matrix.cols()];
86                for (masked, &orig) in c.iter().enumerate() {
87                    table[orig] = masked;
88                }
89                table
90            }
91            None => Vec::new(),
92        };
93
94        let mut view = Self {
95            matrix,
96            rows,
97            cols,
98            col_to_masked,
99            nnz: 0,
100        };
101        view.nnz = view.count_nnz();
102        view
103    }
104
105    /// A view of the given columns, every row.
106    pub fn with_columns(matrix: &'a CsMatI<T, I, Iptr>, columns: &[usize]) -> Self {
107        Self::submatrix(matrix, None, Some(columns))
108    }
109
110    /// A view of the given rows, every column.
111    pub fn with_rows(matrix: &'a CsMatI<T, I, Iptr>, rows: &[usize]) -> Self {
112        Self::submatrix(matrix, Some(rows), None)
113    }
114
115    /// A view built from boolean masks, one entry per original row/column.
116    ///
117    /// # Panics
118    /// If a mask's length does not match the corresponding dimension.
119    pub fn from_masks(
120        matrix: &'a CsMatI<T, I, Iptr>,
121        row_mask: Option<&[bool]>,
122        col_mask: Option<&[bool]>,
123    ) -> Self {
124        let to_indices = |mask: &[bool], n: usize, what: &str| {
125            assert_eq!(
126                mask.len(),
127                n,
128                "{what} mask has length {} but the matrix has {n} {what}s",
129                mask.len()
130            );
131            mask.iter()
132                .enumerate()
133                .filter_map(|(i, &keep)| keep.then_some(i))
134                .collect::<Vec<_>>()
135        };
136        let r = row_mask.map(|m| to_indices(m, matrix.rows(), "row"));
137        let c = col_mask.map(|m| to_indices(m, matrix.cols(), "column"));
138        Self::submatrix(matrix, r.as_deref(), c.as_deref())
139    }
140
141    /// Sort, deduplicate and bounds-check a selection.
142    fn normalise(indices: &[usize], limit: usize, what: &str) -> Vec<usize> {
143        let mut v = indices.to_vec();
144        v.sort_unstable();
145        v.dedup();
146        if let Some(&last) = v.last() {
147            assert!(
148                last < limit,
149                "{what} index {last} is out of bounds ({limit})"
150            );
151        }
152        v
153    }
154
155    fn count_nnz(&self) -> usize {
156        let masked = !self.col_to_masked.is_empty();
157        (0..self.rows_len())
158            .into_par_iter()
159            .map(|i| {
160                let orig = self.row_of(i);
161                self.matrix.outer_view(orig).map_or(0, |row| {
162                    if masked {
163                        row.indices()
164                            .iter()
165                            .filter(|j| self.col_to_masked[j.index()] != EXCLUDED)
166                            .count()
167                    } else {
168                        row.nnz()
169                    }
170                })
171            })
172            .sum()
173    }
174
175    #[inline]
176    fn rows_len(&self) -> usize {
177        self.rows.as_ref().map_or(self.matrix.rows(), |r| r.len())
178    }
179
180    /// Original row index for masked row `i`.
181    #[inline]
182    fn row_of(&self, i: usize) -> usize {
183        match &self.rows {
184            Some(r) => r[i],
185            None => i,
186        }
187    }
188
189    /// Masked column index for original column `j`, or [`EXCLUDED`].
190    #[inline]
191    fn masked_col(&self, j: usize) -> usize {
192        if self.col_to_masked.is_empty() {
193            j
194        } else {
195            self.col_to_masked[j]
196        }
197    }
198
199    /// Selected original row indices, ascending. Empty slice when every row is kept.
200    pub fn selected_rows(&self) -> Option<&[usize]> {
201        self.rows.as_deref()
202    }
203
204    /// Selected original column indices, ascending. `None` when every column is kept.
205    pub fn selected_columns(&self) -> Option<&[usize]> {
206        self.cols.as_deref()
207    }
208
209    /// Whether the view is the identity, in which case products delegate straight to
210    /// the underlying matrix.
211    pub fn is_identity(&self) -> bool {
212        self.rows.is_none() && self.cols.is_none()
213    }
214
215    /// Whether every column is retained.
216    pub fn uses_all_columns(&self) -> bool {
217        self.cols.is_none()
218    }
219
220    /// The matrix being viewed.
221    pub fn inner(&self) -> &'a CsMatI<T, I, Iptr> {
222        self.matrix
223    }
224
225    /// Copy the view into an owned sparse matrix. Still sparse — a subset copy, not a
226    /// densification — and the source is untouched.
227    ///
228    /// A view doesn't make products cheaper: each one still walks every source non-zero
229    /// and checks it against the column table. Since a solver issues hundreds of
230    /// products, a restrictive mask usually repays the one-off `O(nnz)` copy almost
231    /// immediately. Stay with the view if the mask keeps most columns, if the copy
232    /// wouldn't fit alongside the source, or if you only need a few products.
233    ///
234    /// # Panics
235    /// If the extracted index range would overflow the index type `I`.
236    pub fn to_sparse(&self) -> CsMatI<T, I, Iptr> {
237        let (m, n) = (self.rows(), self.cols());
238        let mut indptr: Vec<Iptr> = Vec::with_capacity(m + 1);
239        let mut indices: Vec<I> = Vec::with_capacity(self.nnz);
240        let mut data: Vec<T> = Vec::with_capacity(self.nnz);
241
242        indptr.push(Iptr::from_usize(0));
243        for i in 0..m {
244            if let Some(row) = self.matrix.outer_view(self.row_of(i)) {
245                for (j, &v) in row.indices().iter().zip(row.data().iter()) {
246                    let c = self.masked_col(j.index());
247                    if c != EXCLUDED {
248                        // Source indices ascend and the column table is order-preserving,
249                        // so the extracted indices ascend too — CSR's invariant holds
250                        // without a sort.
251                        indices.push(I::from_usize(c));
252                        data.push(v);
253                    }
254                }
255            }
256            indptr.push(Iptr::from_usize(indices.len()));
257        }
258
259        CsMatI::new((m, n), indptr, indices, data)
260    }
261}
262
263impl<T, I, Iptr> SparseMat<T> for MaskedCsMat<'_, T, I, Iptr>
264where
265    T: SvdFloat,
266    I: SpIndex,
267    Iptr: SpIndex,
268{
269    fn rows(&self) -> usize {
270        self.rows_len()
271    }
272    fn cols(&self) -> usize {
273        self.cols.as_ref().map_or(self.matrix.cols(), |c| c.len())
274    }
275    fn nnz(&self) -> usize {
276        self.nnz
277    }
278
279    /// Only the selected entries, so the norm describes the view and not the matrix it
280    /// borrows from.
281    fn squared_frobenius(&self) -> f64 {
282        if self.is_identity() {
283            return SparseMat::squared_frobenius(self.matrix);
284        }
285        let masked = !self.col_to_masked.is_empty();
286        (0..self.rows_len())
287            .into_par_iter()
288            .map(|i| {
289                let orig = self.row_of(i);
290                self.matrix.outer_view(orig).map_or(0.0, |row| {
291                    row.iter()
292                        .filter(|(j, _)| !masked || self.col_to_masked[j.index()] != EXCLUDED)
293                        .map(|(_, &v)| {
294                            let x = v.to_f64();
295                            x * x
296                        })
297                        .sum()
298                })
299            })
300            .sum()
301    }
302
303    /// Selected entries only, summed per entry so a column offset can't cancel it away.
304    fn centered_squared_frobenius(&self, means: ArrayView1<T>) -> f64 {
305        assert_eq!(
306            means.len(),
307            SparseMat::cols(self),
308            "centered_squared_frobenius: means must have length cols()"
309        );
310        if self.is_identity() {
311            return SparseMat::centered_squared_frobenius(self.matrix, means);
312        }
313        let rows = self.rows_len();
314        let ncols = SparseMat::cols(self);
315        let masked = !self.col_to_masked.is_empty();
316
317        // Sums and counts in one pass; splitting them would walk the view twice.
318        let (stored, col_nnz) = (0..rows)
319            .into_par_iter()
320            .fold(
321                || (0.0f64, vec![0usize; ncols]),
322                |(mut acc, mut cnt), i| {
323                    if let Some(row) = self.matrix.outer_view(self.row_of(i)) {
324                        for (j, &v) in row.iter() {
325                            let c = if masked {
326                                self.col_to_masked[j.index()]
327                            } else {
328                                j.index()
329                            };
330                            if c == EXCLUDED {
331                                continue;
332                            }
333                            let e = v.to_f64() - means[c].to_f64();
334                            acc += e * e;
335                            cnt[c] += 1;
336                        }
337                    }
338                    (acc, cnt)
339                },
340            )
341            .reduce(
342                || (0.0f64, vec![0usize; ncols]),
343                |(a1, mut c1), (a2, c2)| {
344                    for (x, y) in c1.iter_mut().zip(c2) {
345                        *x += y;
346                    }
347                    (a1 + a2, c1)
348                },
349            );
350
351        super::centered_from_parts(stored, &col_nnz, means, rows)
352    }
353
354    fn mul_vec(&self, x: &[T], y: &mut [T], trans: bool) {
355        // 1.x delegated to the *unmasked* matrix whenever the matrix was small,
356        // regardless of the mask, which fed a masked-width vector to a full-width
357        // product. Delegation is only ever valid when the view is the identity.
358        if self.is_identity() {
359            return SparseMat::mul_vec(self.matrix, x, y, trans);
360        }
361
362        let (m, n) = (self.rows(), self.cols());
363        if trans {
364            assert_eq!(x.len(), m, "mul_vec: x must have length rows()");
365            assert_eq!(y.len(), n, "mul_vec: y must have length cols()");
366            y.fill(T::zero());
367
368            let p = rayon::current_num_threads().max(1);
369            if p == 1 || self.nnz <= (8 << 10) {
370                for i in 0..m {
371                    let xi = x[i];
372                    if xi.is_zero() {
373                        continue;
374                    }
375                    let Some(row) = self.matrix.outer_view(self.row_of(i)) else {
376                        continue;
377                    };
378                    for (j, &v) in row.indices().iter().zip(row.data().iter()) {
379                        let c = self.masked_col(j.index());
380                        if c != EXCLUDED {
381                            y[c] += v * xi;
382                        }
383                    }
384                }
385                return;
386            }
387            // Scatter direction: one accumulator per thread over the masked width.
388            let chunk = m.div_ceil(p).max(1);
389            let partials: Vec<Vec<T>> = (0..p)
390                .into_par_iter()
391                .map(|t| {
392                    let lo = (t * chunk).min(m);
393                    let hi = ((t + 1) * chunk).min(m);
394                    let mut acc = vec![T::zero(); n];
395                    for i in lo..hi {
396                        let xi = x[i];
397                        if xi.is_zero() {
398                            continue;
399                        }
400                        let Some(row) = self.matrix.outer_view(self.row_of(i)) else {
401                            continue;
402                        };
403                        for (j, &v) in row.indices().iter().zip(row.data().iter()) {
404                            let c = self.masked_col(j.index());
405                            if c != EXCLUDED {
406                                acc[c] += v * xi;
407                            }
408                        }
409                    }
410                    acc
411                })
412                .collect();
413            for acc in &partials {
414                for (yi, &a) in y.iter_mut().zip(acc.iter()) {
415                    *yi += a;
416                }
417            }
418        } else {
419            assert_eq!(x.len(), n, "mul_vec: x must have length cols()");
420            assert_eq!(y.len(), m, "mul_vec: y must have length rows()");
421            // Gather direction: each output entry is one row's dot product, so masked
422            // rows are simply never visited.
423            let dot = |i: usize| -> T {
424                let Some(row) = self.matrix.outer_view(self.row_of(i)) else {
425                    return T::zero();
426                };
427                let mut sum = T::zero();
428                for (j, &v) in row.indices().iter().zip(row.data().iter()) {
429                    let c = self.masked_col(j.index());
430                    if c != EXCLUDED {
431                        sum += v * x[c];
432                    }
433                }
434                sum
435            };
436            let chunk = m.div_ceil(rayon::current_num_threads().max(1) * 4).max(1);
437            y.par_chunks_mut(chunk).enumerate().for_each(|(ci, blk)| {
438                let base = ci * chunk;
439                for (local, yi) in blk.iter_mut().enumerate() {
440                    *yi = dot(base + local);
441                }
442            });
443        }
444    }
445}
446
447impl<T, I, Iptr> SparseMatDense<T> for MaskedCsMat<'_, T, I, Iptr>
448where
449    T: SvdFloat,
450    I: SpIndex,
451    Iptr: SpIndex,
452{
453    fn mul_dense(&self, rhs: ArrayView2<T>, mut out: ArrayViewMut2<T>, trans: bool) {
454        if self.is_identity() {
455            return SparseMatDense::mul_dense(self.matrix, rhs, out, trans);
456        }
457
458        let (m, n, k) = (self.rows(), self.cols(), rhs.ncols());
459        if trans {
460            assert_eq!(rhs.nrows(), m, "mul_dense: rhs.rows != rows()");
461            assert_eq!(out.nrows(), n, "mul_dense: out.rows != cols()");
462        } else {
463            assert_eq!(rhs.nrows(), n, "mul_dense: rhs.rows != cols()");
464            assert_eq!(out.nrows(), m, "mul_dense: out.rows != rows()");
465        }
466        assert_eq!(out.ncols(), k, "mul_dense: out.cols != rhs.cols");
467
468        if !trans {
469            // Write-disjoint over output rows.
470            let chunk = m.div_ceil(rayon::current_num_threads().max(1) * 4).max(1);
471            out.axis_chunks_iter_mut(Axis(0), chunk)
472                .into_par_iter()
473                .enumerate()
474                .for_each(|(ci, mut block)| {
475                    let base = ci * chunk;
476                    for (local, mut orow) in block.rows_mut().into_iter().enumerate() {
477                        orow.fill(T::zero());
478                        let Some(row) = self.matrix.outer_view(self.row_of(base + local)) else {
479                            continue;
480                        };
481                        for (j, &v) in row.indices().iter().zip(row.data().iter()) {
482                            let c = self.masked_col(j.index());
483                            if c == EXCLUDED {
484                                continue;
485                            }
486                            let rrow = rhs.row(c);
487                            for (o, &r) in orow.iter_mut().zip(rrow.iter()) {
488                                *o += v * r;
489                            }
490                        }
491                    }
492                });
493        } else {
494            // Scatter direction, one accumulator per thread over the masked width.
495            out.fill(T::zero());
496            let p = rayon::current_num_threads().max(1);
497            let chunk = m.div_ceil(p).max(1);
498            let partials: Vec<ndarray::Array2<T>> = (0..p)
499                .into_par_iter()
500                .map(|t| {
501                    let lo = (t * chunk).min(m);
502                    let hi = ((t + 1) * chunk).min(m);
503                    let mut acc = ndarray::Array2::<T>::zeros((n, k));
504                    for i in lo..hi {
505                        let Some(row) = self.matrix.outer_view(self.row_of(i)) else {
506                            continue;
507                        };
508                        let rrow = rhs.row(i);
509                        for (j, &v) in row.indices().iter().zip(row.data().iter()) {
510                            let c = self.masked_col(j.index());
511                            if c == EXCLUDED {
512                                continue;
513                            }
514                            let mut arow = acc.row_mut(c);
515                            for (a, &r) in arow.iter_mut().zip(rrow.iter()) {
516                                *a += v * r;
517                            }
518                        }
519                    }
520                    acc
521                })
522                .collect();
523            for acc in &partials {
524                for (mut orow, arow) in out.rows_mut().into_iter().zip(acc.rows()) {
525                    for (o, &a) in orow.iter_mut().zip(arow.iter()) {
526                        *o += a;
527                    }
528                }
529            }
530        }
531    }
532
533    /// Column means **of the view** — averaged over the selected rows only, so PCA on a
534    /// row subset centers on that subset's means rather than the whole matrix's.
535    fn col_means(&self) -> Array1<T> {
536        let m = self.rows();
537        let ones = vec![T::one(); m];
538        let mut sums = vec![T::zero(); self.cols()];
539        self.mul_vec(&ones, &mut sums, true);
540        let scale = if m == 0 {
541            T::zero()
542        } else {
543            T::one() / T::from_f64_val(m as f64)
544        };
545        Array1::from_vec(sums) * scale
546    }
547
548    fn mul_dense_centered(
549        &self,
550        rhs: ArrayView2<T>,
551        mut out: ArrayViewMut2<T>,
552        trans: bool,
553        means: ArrayView1<T>,
554    ) {
555        assert_eq!(
556            means.len(),
557            self.cols(),
558            "mul_dense_centered: means must have length cols() (the masked width)"
559        );
560        self.mul_dense(rhs, out.view_mut(), trans);
561        apply_centering(rhs, out, trans, means);
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::testing::{dense_of, gen_sparse};
569    use ndarray::{Array2, Axis};
570    use sprs::TriMatI;
571
572    fn sample() -> CsMatI<f64, u32, u64> {
573        // 3 x 5
574        // [1 0 2 0 3]
575        // [0 4 0 5 0]
576        // [6 0 7 0 8]
577        let mut t = TriMatI::<f64, u32>::new((3, 5));
578        for &(i, j, v) in &[
579            (0, 0, 1.0),
580            (0, 2, 2.0),
581            (0, 4, 3.0),
582            (1, 1, 4.0),
583            (1, 3, 5.0),
584            (2, 0, 6.0),
585            (2, 2, 7.0),
586            (2, 4, 8.0),
587        ] {
588            t.add_triplet(i, j, v);
589        }
590        t.to_csr::<u64>()
591    }
592
593    /// The dense submatrix a view is supposed to emulate.
594    fn physical(m: &CsMatI<f64, u32, u64>, rows: &[usize], cols: &[usize]) -> Array2<f64> {
595        let full = dense_of(m);
596        let mut d = Array2::zeros((rows.len(), cols.len()));
597        for (ri, &r) in rows.iter().enumerate() {
598            for (ci, &c) in cols.iter().enumerate() {
599                d[[ri, ci]] = full[[r, c]];
600            }
601        }
602        d
603    }
604
605    fn check_against_physical(view: &MaskedCsMat<f64>, want: &Array2<f64>) {
606        assert_eq!((view.rows(), view.cols()), want.dim(), "shape");
607
608        let x: Vec<f64> = (0..view.cols()).map(|i| (i % 5) as f64 - 2.0).collect();
609        let mut y = vec![0.0; view.rows()];
610        view.mul_vec(&x, &mut y, false);
611        let expect = want.dot(&ndarray::Array1::from_vec(x.clone()));
612        for (g, w) in y.iter().zip(expect.iter()) {
613            approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12);
614        }
615
616        let xt: Vec<f64> = (0..view.rows()).map(|i| (i % 3) as f64 - 1.0).collect();
617        let mut yt = vec![0.0; view.cols()];
618        view.mul_vec(&xt, &mut yt, true);
619        let expect_t = want.t().dot(&ndarray::Array1::from_vec(xt.clone()));
620        for (g, w) in yt.iter().zip(expect_t.iter()) {
621            approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12);
622        }
623
624        // Blocked products, both directions.
625        let rhs = Array2::from_shape_fn((view.cols(), 2), |(i, j)| (i + 2 * j) as f64 - 1.0);
626        let mut out = Array2::zeros((view.rows(), 2));
627        view.mul_dense(rhs.view(), out.view_mut(), false);
628        let want_out = want.dot(&rhs);
629        for (g, w) in out.iter().zip(want_out.iter()) {
630            approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12);
631        }
632
633        let rhs_t = Array2::from_shape_fn((view.rows(), 2), |(i, j)| (2 * i + j) as f64 - 2.0);
634        let mut out_t = Array2::zeros((view.cols(), 2));
635        view.mul_dense(rhs_t.view(), out_t.view_mut(), true);
636        let want_t = want.t().dot(&rhs_t);
637        for (g, w) in out_t.iter().zip(want_t.iter()) {
638            approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12);
639        }
640    }
641
642    #[test]
643    fn column_selection_matches_physical_subset() {
644        let a = sample();
645        let cols = [0usize, 2, 4];
646        let all_rows: Vec<usize> = (0..3).collect();
647        check_against_physical(
648            &MaskedCsMat::with_columns(&a, &cols),
649            &physical(&a, &all_rows, &cols),
650        );
651    }
652
653    #[test]
654    fn row_selection_matches_physical_subset() {
655        let a = sample();
656        let rows = [0usize, 2];
657        let all_cols: Vec<usize> = (0..5).collect();
658        let view = MaskedCsMat::with_rows(&a, &rows);
659        assert_eq!(view.nnz(), 6, "only the two selected rows' non-zeros count");
660        check_against_physical(&view, &physical(&a, &rows, &all_cols));
661    }
662
663    #[test]
664    fn combined_selection_matches_physical_subset() {
665        let a = sample();
666        let rows = [0usize, 2];
667        let cols = [1usize, 2, 4];
668        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
669        assert_eq!((view.rows(), view.cols()), (2, 3));
670        check_against_physical(&view, &physical(&a, &rows, &cols));
671    }
672
673    #[test]
674    fn selections_are_sorted_and_deduplicated() {
675        let a = sample();
676        let view = MaskedCsMat::submatrix(&a, Some(&[2, 0, 2]), Some(&[4, 0, 4, 0]));
677        assert_eq!(view.selected_rows().unwrap(), &[0, 2]);
678        assert_eq!(view.selected_columns().unwrap(), &[0, 4]);
679        check_against_physical(&view, &physical(&a, &[0, 2], &[0, 4]));
680    }
681
682    #[test]
683    fn boolean_masks_agree_with_index_lists() {
684        let a = sample();
685        let by_mask = MaskedCsMat::from_masks(
686            &a,
687            Some(&[true, false, true]),
688            Some(&[false, true, false, true, false]),
689        );
690        let by_index = MaskedCsMat::submatrix(&a, Some(&[0, 2]), Some(&[1, 3]));
691        assert_eq!(by_mask.selected_rows(), by_index.selected_rows());
692        assert_eq!(by_mask.selected_columns(), by_index.selected_columns());
693        assert_eq!(by_mask.nnz(), by_index.nnz());
694    }
695
696    /// Regression for the 1.x fast path: a *small* masked matrix used to delegate to the
697    /// unmasked product and panic on the length assert.
698    #[test]
699    fn small_masked_matrix_does_not_delegate() {
700        let a = sample();
701        let cols = [0usize, 2, 4];
702        let view = MaskedCsMat::with_columns(&a, &cols);
703        let want = physical(&a, &[0, 1, 2], &cols);
704        let x = [1.0, 2.0, 3.0];
705        let mut y = vec![0.0; 3];
706        view.mul_vec(&x, &mut y, false);
707        assert_eq!(y, want.dot(&ndarray::arr1(&x)).to_vec());
708    }
709
710    #[test]
711    fn identity_view_matches_unmasked() {
712        let a = sample();
713        let view = MaskedCsMat::submatrix(&a, None, None);
714        assert!(view.is_identity());
715        assert_eq!(view.nnz(), a.nnz());
716        let x = [1.0, 2.0, 3.0, 4.0, 5.0];
717        let mut ym = vec![0.0; 3];
718        let mut yu = vec![0.0; 3];
719        view.mul_vec(&x, &mut ym, false);
720        SparseMat::mul_vec(&a, &x, &mut yu, false);
721        assert_eq!(ym, yu);
722    }
723
724    /// Means must be taken over the *selected* rows, so PCA on a row subset centers on
725    /// that subset.
726    #[test]
727    fn col_means_respect_the_row_selection() {
728        let a = sample();
729        let rows = [0usize, 2];
730        let cols = [0usize, 2, 4];
731        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
732        let want = physical(&a, &rows, &cols);
733
734        let got = view.col_means();
735        let expect = want.mean_axis(Axis(0)).unwrap();
736        for (g, w) in got.iter().zip(expect.iter()) {
737            approx::assert_relative_eq!(g, w, max_relative = 1e-12);
738        }
739
740        // And centering must match explicitly centering that submatrix.
741        let centered = &want - &expect.view().insert_axis(Axis(0));
742        let rhs = ndarray::arr2(&[[1.0], [2.0], [3.0]]);
743        let mut out = Array2::zeros((2, 1));
744        view.mul_dense_centered(rhs.view(), out.view_mut(), false, got.view());
745        let want_out = centered.dot(&rhs);
746        for (g, w) in out.iter().zip(want_out.iter()) {
747            approx::assert_relative_eq!(g, w, max_relative = 1e-12, epsilon = 1e-12);
748        }
749    }
750
751    /// The norm must describe the view, not its source — otherwise a masked PCA's
752    /// explained-variance ratios come out silently too small.
753    #[test]
754    fn squared_frobenius_counts_only_selected_entries() {
755        let a = gen_sparse(120, 40, 0.1, 91);
756        let rows: Vec<usize> = (0..120).filter(|r| r % 4 != 0).collect();
757        let cols: Vec<usize> = (0..40).filter(|c| c % 3 == 0).collect();
758        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
759
760        let want: f64 = physical(&a, &rows, &cols).iter().map(|&v| v * v).sum();
761        approx::assert_relative_eq!(view.squared_frobenius(), want, max_relative = 1e-12);
762
763        // Strictly less than the source, and equal to it once nothing is masked out.
764        assert!(view.squared_frobenius() < SparseMat::squared_frobenius(&a));
765        let identity = MaskedCsMat::submatrix(&a, None, None);
766        approx::assert_relative_eq!(
767            identity.squared_frobenius(),
768            SparseMat::squared_frobenius(&a),
769            max_relative = 1e-14
770        );
771        // The extraction is the same matrix, so it must report the same norm.
772        approx::assert_relative_eq!(
773            view.to_sparse().squared_frobenius(),
774            want,
775            max_relative = 1e-12
776        );
777    }
778
779    /// Same for the centered norm, and summed per entry so an offset can't cancel it.
780    #[test]
781    fn centered_squared_frobenius_matches_the_selected_submatrix() {
782        let a = gen_sparse(120, 40, 0.2, 77);
783        let rows: Vec<usize> = (0..120).filter(|r| r % 4 != 0).collect();
784        let cols: Vec<usize> = (0..40).filter(|c| c % 3 == 0).collect();
785        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
786
787        let sub = physical(&a, &rows, &cols);
788        let means = view.col_means();
789        let centered = &sub - &means.view().insert_axis(Axis(0));
790        let want: f64 = centered.iter().map(|&v| v * v).sum();
791
792        approx::assert_relative_eq!(
793            view.centered_squared_frobenius(means.view()),
794            want,
795            max_relative = 1e-10
796        );
797        // The extraction is the same matrix, so it must agree.
798        approx::assert_relative_eq!(
799            SparseMat::centered_squared_frobenius(&view.to_sparse(), means.view()),
800            want,
801            max_relative = 1e-10
802        );
803        // A view that selects everything must match the source exactly.
804        let identity = MaskedCsMat::submatrix(&a, None, None);
805        let full_means = identity.col_means();
806        approx::assert_relative_eq!(
807            identity.centered_squared_frobenius(full_means.view()),
808            SparseMat::centered_squared_frobenius(&a, full_means.view()),
809            max_relative = 1e-12
810        );
811    }
812
813    /// The extracted submatrix must be indistinguishable from the view.
814    #[test]
815    fn to_sparse_matches_the_view() {
816        let a = gen_sparse(120, 40, 0.1, 23);
817        let rows: Vec<usize> = (0..120).filter(|r| r % 4 != 0).collect();
818        let cols: Vec<usize> = (0..40).filter(|c| c % 3 == 0).collect();
819        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
820        let extracted = view.to_sparse();
821
822        assert_eq!(extracted.rows(), view.rows());
823        assert_eq!(extracted.cols(), view.cols());
824        assert_eq!(extracted.nnz(), view.nnz());
825        assert!(extracted.is_csr());
826        // Extraction must preserve CSR's ascending-index invariant.
827        for i in 0..extracted.rows() {
828            if let Some(row) = extracted.outer_view(i) {
829                let idx = row.indices();
830                assert!(
831                    idx.windows(2).all(|w| w[0] < w[1]),
832                    "row {i} indices not sorted"
833                );
834            }
835        }
836
837        assert_eq!(dense_of(&extracted), physical(&a, &rows, &cols));
838
839        // And every product agrees.
840        let x: Vec<f64> = (0..view.cols()).map(|i| (i % 7) as f64 - 3.0).collect();
841        let (mut yv, mut ye) = (vec![0.0; view.rows()], vec![0.0; view.rows()]);
842        view.mul_vec(&x, &mut yv, false);
843        SparseMat::mul_vec(&extracted, &x, &mut ye, false);
844        assert_eq!(yv, ye);
845    }
846
847    /// A decomposition must not care which representation it was handed.
848    #[test]
849    fn pca_agrees_between_view_and_extraction() {
850        let a = gen_sparse(300, 50, 0.12, 29);
851        let cols: Vec<usize> = (0..50).filter(|c| c % 2 == 0).collect();
852        let view = MaskedCsMat::with_columns(&a, &cols);
853        let extracted = view.to_sparse();
854
855        let by_view = crate::irlba::svd_centered(&view, 6, Some(42)).unwrap();
856        let by_copy = crate::irlba::svd_centered(&extracted, 6, Some(42)).unwrap();
857        for (x, y) in by_view.s.iter().zip(by_copy.s.iter()) {
858            approx::assert_relative_eq!(x, y, max_relative = 1e-9);
859        }
860    }
861
862    #[test]
863    fn empty_selections() {
864        let a = sample();
865        let no_cols = MaskedCsMat::submatrix(&a, None, Some(&[]));
866        assert_eq!(no_cols.cols(), 0);
867        assert_eq!(no_cols.nnz(), 0);
868
869        let no_rows = MaskedCsMat::submatrix(&a, Some(&[]), None);
870        assert_eq!(no_rows.rows(), 0);
871        assert_eq!(no_rows.nnz(), 0);
872    }
873
874    #[test]
875    #[should_panic(expected = "out of bounds")]
876    fn rejects_out_of_range_rows() {
877        let a = sample();
878        let _ = MaskedCsMat::with_rows(&a, &[0, 99]);
879    }
880
881    /// Masking rows must not change the answer relative to physically extracting them.
882    #[test]
883    fn larger_random_submatrix() {
884        let a = gen_sparse(200, 60, 0.08, 17);
885        let rows: Vec<usize> = (0..200).filter(|r| r % 3 == 0).collect();
886        let cols: Vec<usize> = (0..60).filter(|c| c % 2 == 1).collect();
887        let view = MaskedCsMat::submatrix(&a, Some(&rows), Some(&cols));
888        check_against_physical(&view, &physical(&a, &rows, &cols));
889    }
890}