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}