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::qc::{blocks_per_leaf, fold_in_fixed_order};
12use crate::sparse_io_vector::SparseIoVec;
13use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
14use legume_numeric::matrix::utils::generate_minibatch_intervals;
15
16/// Stream cells through `data_vec` in `block_size` chunks, accumulating
17/// per-feature NB sufficient statistics in parallel and merging them in a
18/// fixed order ([`fold_in_fixed_order`]), so the result does not depend on
19/// the thread count.
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 = fold_in_fixed_order(
36        &jobs,
37        blocks_per_leaf(jobs.len()),
38        &|| SparseRunningStatistics::<f32>::new(n_features),
39        &|acc, &(lb, ub)| {
40            let chunk = data_vec.read_columns_csc(lb..ub)?;
41            acc.add_csc(&chunk);
42            prog_bar.inc(1);
43            Ok(())
44        },
45        &|a, b| a.merge(&b),
46    )?;
47    prog_bar.finish_and_clear();
48
49    Ok(stats)
50}
51
52/// The same pass, accumulating a second set of statistics on a folded axis.
53///
54/// Returns `(row_stats, folded_stats)`. `row_to_gene[r]` names the folded
55/// bucket row `r` belongs to; buckets are `0..n_folded`.
56///
57/// The fold happens inside the block read, so it costs `O(nnz)` arithmetic and
58/// no extra I/O. It exists because two of the four statistics do NOT survive a
59/// fold applied afterwards:
60///
61/// - `s2` of a sum is not the sum of `s2`. The cross term `2ab` is gone, so a
62///   variance rebuilt from per-row squares understates a bucket whose rows
63///   covary, which two splice tracks of one gene certainly do.
64/// - `npos` of a sum is not the sum of `npos`. A cell detected on both rows of
65///   a bucket is one detection, not two, and the difference is exactly the
66///   bimodality that breaks a detection-based cutoff on a channelized matrix.
67///
68/// `s1` and the mean do survive, which is why a caller that needs only those
69/// can fold a row-axis result afterwards instead of asking for this.
70pub fn streaming_sparse_running_stats_folded(
71    data_vec: &SparseIoVec,
72    block_size: Option<usize>,
73    progress_label: &str,
74    row_to_gene: &[u32],
75    n_folded: usize,
76) -> anyhow::Result<(SparseRunningStatistics<f32>, SparseRunningStatistics<f32>)> {
77    let n_features = data_vec.num_rows();
78    anyhow::ensure!(
79        row_to_gene.len() == n_features,
80        "row_to_gene has {} entries for a {}-row matrix",
81        row_to_gene.len(),
82        n_features
83    );
84    if let Some(&bad) = row_to_gene.iter().max() {
85        anyhow::ensure!(
86            (bad as usize) < n_folded,
87            "row_to_gene names bucket {} but only {} were declared",
88            bad,
89            n_folded
90        );
91    }
92
93    let n_total = data_vec.num_columns();
94    let jobs = generate_minibatch_intervals(n_total, n_features, block_size);
95
96    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(jobs.len() as u64)
97        .with_message(format!("{progress_label} blocks"));
98
99    // The dense scratch lives in the fold accumulator, so it is allocated once
100    // per leaf of the fold rather than once per column, and it is cleared in
101    // O(touched) rather than O(n_folded).
102    // The accumulator carries a `seen` stamp alongside the value buffer. Using
103    // `buf[g] == 0.0` as the "not yet touched" marker would be wrong: a stored
104    // explicit zero leaves the buffer at zero, so the bucket is pushed onto
105    // `touched` a second time when its other row arrives, and that bucket's
106    // npos, s1 and s2 are then all counted twice for that column. Sparse
107    // backends do not promise to prune explicit zeros, and a producer that
108    // emits both splice tracks whenever a gene is detected writes exactly that
109    // pattern.
110    type Acc = (
111        SparseRunningStatistics<f32>,
112        SparseRunningStatistics<f32>,
113        Vec<f32>,
114        Vec<bool>,
115        Vec<usize>,
116        Vec<f32>,
117    );
118
119    let (row_stats, folded_stats, ..) = fold_in_fixed_order(
120        &jobs,
121        blocks_per_leaf(jobs.len()),
122        &|| -> Acc {
123            (
124                SparseRunningStatistics::<f32>::new(n_features),
125                SparseRunningStatistics::<f32>::new(n_folded),
126                vec![0.0f32; n_folded],
127                vec![false; n_folded],
128                Vec::<usize>::new(),
129                Vec::<f32>::new(),
130            )
131        },
132        &|(rows, folded, buf, seen, touched, vals), &(lb, ub)| {
133            let chunk = data_vec.read_columns_csc(lb..ub)?;
134            rows.add_csc(&chunk);
135            for col in chunk.col_iter() {
136                touched.clear();
137                for (&r, &v) in col.row_indices().iter().zip(col.values().iter()) {
138                    let g = row_to_gene[r] as usize;
139                    if !seen[g] {
140                        seen[g] = true;
141                        touched.push(g);
142                    }
143                    buf[g] += v;
144                }
145                // `add_sparse_column` accumulates per row and never reads
146                // the order, so the touched list is left unsorted.
147                vals.clear();
148                vals.extend(touched.iter().map(|&g| buf[g]));
149                folded.add_sparse_column(touched, vals);
150                for &g in touched.iter() {
151                    buf[g] = 0.0;
152                    seen[g] = false;
153                }
154            }
155            prog_bar.inc(1);
156            Ok(())
157        },
158        &|(ra, fa, ..), (rb, fb, ..)| {
159            ra.merge(&rb);
160            fa.merge(&fb);
161        },
162    )?;
163    prog_bar.finish_and_clear();
164
165    Ok((row_stats, folded_stats))
166}
167
168#[cfg(test)]
169#[path = "sparse_streaming_tests.rs"]
170mod tests;