Skip to main content

data_beans/alg/
sparse_streaming.rs

1//! Lock-free parallel streaming pass over a `SparseIoVec` that yields a
2//! merged `SparseRunningStatistics<f32>`.
3//!
4//! Both HVG selection (`hvg::select_hvg_streaming`) and NB-Fisher
5//! weighting (`gene_weighting::compute_nb_fisher_weights`) need the same
6//! per-feature `(npos, sum, sum_sq, n_cells)` aggregates from a sparse
7//! backend. Sharing the skeleton keeps the parallel / lock-free
8//! invariants in one place and gives every caller a labelled progress
9//! bar with consistent styling.
10
11use crate::sparse_io_vector::SparseIoVec;
12use indicatif::ParallelProgressIterator;
13use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
14use legume_numeric::matrix::utils::generate_minibatch_intervals;
15use rayon::prelude::*;
16
17/// Stream cells through `data_vec` in `block_size` chunks, accumulating
18/// per-feature NB sufficient statistics into one `SparseRunningStatistics`
19/// per rayon worker, then merge to one result.
20///
21/// `progress_label` is shown in the indicatif progress bar (e.g.
22/// `"HVG"`, `"NB-Fisher"`).
23pub fn streaming_sparse_running_stats(
24    data_vec: &SparseIoVec,
25    block_size: Option<usize>,
26    progress_label: &str,
27) -> anyhow::Result<SparseRunningStatistics<f32>> {
28    let n_features = data_vec.num_rows();
29    let n_total = data_vec.num_columns();
30    let jobs = generate_minibatch_intervals(n_total, n_features, block_size);
31
32    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(jobs.len() as u64)
33        .with_message(format!("{progress_label} blocks"));
34
35    let stats: SparseRunningStatistics<f32> = jobs
36        .par_iter()
37        .progress_with(prog_bar.clone())
38        .try_fold(
39            || SparseRunningStatistics::<f32>::new(n_features),
40            |mut acc, &(lb, ub)| -> anyhow::Result<SparseRunningStatistics<f32>> {
41                let chunk = data_vec.read_columns_csc(lb..ub)?;
42                acc.add_csc(&chunk);
43                Ok(acc)
44            },
45        )
46        .try_reduce(
47            || SparseRunningStatistics::<f32>::new(n_features),
48            |mut a, b| {
49                a.merge(&b);
50                Ok(a)
51            },
52        )?;
53    prog_bar.finish_and_clear();
54
55    Ok(stats)
56}
57
58/// The same pass, accumulating a second set of statistics on a folded axis.
59///
60/// Returns `(row_stats, folded_stats)`. `row_to_gene[r]` names the folded
61/// bucket row `r` belongs to; buckets are `0..n_folded`.
62///
63/// The fold happens inside the block read, so it costs `O(nnz)` arithmetic and
64/// no extra I/O. It exists because two of the four statistics do NOT survive a
65/// fold applied afterwards:
66///
67/// - `s2` of a sum is not the sum of `s2`. The cross term `2ab` is gone, so a
68///   variance rebuilt from per-row squares understates a bucket whose rows
69///   covary, which two splice tracks of one gene certainly do.
70/// - `npos` of a sum is not the sum of `npos`. A cell detected on both rows of
71///   a bucket is one detection, not two, and the difference is exactly the
72///   bimodality that breaks a detection-based cutoff on a channelized matrix.
73///
74/// `s1` and the mean do survive, which is why a caller that needs only those
75/// can fold a row-axis result afterwards instead of asking for this.
76pub fn streaming_sparse_running_stats_folded(
77    data_vec: &SparseIoVec,
78    block_size: Option<usize>,
79    progress_label: &str,
80    row_to_gene: &[u32],
81    n_folded: usize,
82) -> anyhow::Result<(SparseRunningStatistics<f32>, SparseRunningStatistics<f32>)> {
83    let n_features = data_vec.num_rows();
84    anyhow::ensure!(
85        row_to_gene.len() == n_features,
86        "row_to_gene has {} entries for a {}-row matrix",
87        row_to_gene.len(),
88        n_features
89    );
90    if let Some(&bad) = row_to_gene.iter().max() {
91        anyhow::ensure!(
92            (bad as usize) < n_folded,
93            "row_to_gene names bucket {} but only {} were declared",
94            bad,
95            n_folded
96        );
97    }
98
99    let n_total = data_vec.num_columns();
100    let jobs = generate_minibatch_intervals(n_total, n_features, block_size);
101
102    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(jobs.len() as u64)
103        .with_message(format!("{progress_label} blocks"));
104
105    // The dense scratch lives in the fold accumulator, so it is allocated once
106    // per rayon worker rather than once per block, and it is cleared in
107    // O(touched) rather than O(n_folded).
108    // The accumulator carries a `seen` stamp alongside the value buffer. Using
109    // `buf[g] == 0.0` as the "not yet touched" marker would be wrong: a stored
110    // explicit zero leaves the buffer at zero, so the bucket is pushed onto
111    // `touched` a second time when its other row arrives, and that bucket's
112    // npos, s1 and s2 are then all counted twice for that column. Sparse
113    // backends do not promise to prune explicit zeros, and a producer that
114    // emits both splice tracks whenever a gene is detected writes exactly that
115    // pattern.
116    type Acc = (
117        SparseRunningStatistics<f32>,
118        SparseRunningStatistics<f32>,
119        Vec<f32>,
120        Vec<bool>,
121        Vec<usize>,
122        Vec<f32>,
123    );
124
125    let (row_stats, folded_stats) = jobs
126        .par_iter()
127        .progress_with(prog_bar.clone())
128        .try_fold(
129            || {
130                (
131                    SparseRunningStatistics::<f32>::new(n_features),
132                    SparseRunningStatistics::<f32>::new(n_folded),
133                    vec![0.0f32; n_folded],
134                    vec![false; n_folded],
135                    Vec::<usize>::new(),
136                    Vec::<f32>::new(),
137                )
138            },
139            |(mut rows, mut folded, mut buf, mut seen, mut touched, mut vals),
140             &(lb, ub)|
141             -> anyhow::Result<Acc> {
142                let chunk = data_vec.read_columns_csc(lb..ub)?;
143                rows.add_csc(&chunk);
144                for col in chunk.col_iter() {
145                    touched.clear();
146                    for (&r, &v) in col.row_indices().iter().zip(col.values().iter()) {
147                        let g = row_to_gene[r] as usize;
148                        if !seen[g] {
149                            seen[g] = true;
150                            touched.push(g);
151                        }
152                        buf[g] += v;
153                    }
154                    // `add_sparse_column` accumulates per row and never reads
155                    // the order, so the touched list is left unsorted.
156                    vals.clear();
157                    vals.extend(touched.iter().map(|&g| buf[g]));
158                    folded.add_sparse_column(&touched, &vals);
159                    for &g in touched.iter() {
160                        buf[g] = 0.0;
161                        seen[g] = false;
162                    }
163                }
164                Ok((rows, folded, buf, seen, touched, vals))
165            },
166        )
167        .map(|acc| acc.map(|(rows, folded, ..)| (rows, folded)))
168        .try_reduce(
169            || {
170                (
171                    SparseRunningStatistics::<f32>::new(n_features),
172                    SparseRunningStatistics::<f32>::new(n_folded),
173                )
174            },
175            |(mut ra, mut fa), (rb, fb)| {
176                ra.merge(&rb);
177                fa.merge(&fb);
178                Ok((ra, fa))
179            },
180        )?;
181    prog_bar.finish_and_clear();
182
183    Ok((row_stats, folded_stats))
184}