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;