Skip to main content

data_beans/
qc.rs

1use crate::sparse_data_visitors::*;
2use crate::sparse_io::*;
3use crate::sparse_io_vector::*;
4
5use indicatif::ParallelProgressIterator;
6use legume_numeric::matrix::sparse_stat::{SparseColumnRunningStatistics, SparseRunningStatistics};
7use legume_numeric::matrix::traits::RunningStatOps;
8use legume_numeric::matrix::utils::partition_by_membership;
9use log::warn;
10use rayon::prelude::*;
11use std::sync::{Arc, Mutex};
12
13use rustc_hash::FxHashMap as HashMap;
14
15#[derive(Clone)]
16pub struct SqueezeCutoffs {
17    pub row: usize,
18    pub column: usize,
19}
20
21/// squeeze out rows and columns with excessive zero values
22pub fn squeeze_by_nnz(
23    data: &dyn SparseIo<IndexIter = Vec<usize>>,
24    cutoffs: SqueezeCutoffs,
25    block_size: Option<usize>,
26    preload: bool,
27) -> anyhow::Result<()> {
28    let col_stat = collect_column_stat(data, block_size)?;
29    let row_stat = collect_row_stat(data, block_size)?;
30
31    let file = data.get_backend_file_name();
32    let backend = data.backend_type();
33
34    let mut data = open_sparse_matrix(file, &backend)?;
35    if preload {
36        data.preload_columns()?;
37    }
38
39    fn nnz_index(nnz: &[f32], cutoff: usize) -> Option<Vec<usize>> {
40        let ret: Vec<usize> = nnz
41            .iter()
42            .enumerate()
43            .filter(|&(_, &x)| (x as usize) >= cutoff)
44            .map(|(i, _)| i)
45            .collect();
46
47        (!ret.is_empty()).then_some(ret)
48    }
49
50    let row_nnz_vec = row_stat.count_positives();
51    let col_nnz_vec = col_stat.count_positives();
52    let row_idx = nnz_index(&row_nnz_vec, cutoffs.row);
53    let col_idx = nnz_index(&col_nnz_vec, cutoffs.column);
54
55    if row_idx.is_none() {
56        warn!(
57            "No rows can be kept with this cutoff {}!\n\
58	     \n\
59	     We will stop squeezing on the rows.\n\
60	     \n",
61            cutoffs.row
62        );
63    }
64
65    if col_idx.is_none() {
66        warn!(
67            "No columns can be kept with this cutoff {}!\n\
68	     \n\
69	     We will stop squeezing on the columns.\n\
70	     \n",
71            cutoffs.column
72        );
73    }
74
75    data.subset_columns_rows(col_idx.as_ref(), row_idx.as_ref())
76}
77
78/// collect row-wise sufficient statistics for Q/C
79/// * `data` - `SparseIoVec` across many data matrices
80/// * `block_size` - a block size for each parallelized job
81pub fn collect_row_stat_across_vec(
82    data: &SparseIoVec,
83    block_size: Option<usize>,
84) -> anyhow::Result<SparseRunningStatistics<f32>> {
85    let mut row_stat = SparseRunningStatistics::new(data.num_rows());
86    data.visit_columns_by_block(
87        &row_stat_vec_visitor,
88        &EmptyArgs {},
89        &mut row_stat,
90        block_size,
91    )?;
92    Ok(row_stat)
93}
94
95/// collect row statistics for each group of columns
96/// * `data` - `SparseIo`
97/// * `column_membership` - a hashmap assign columns to groups
98/// * `block_size` - a block size for each parallelized job
99#[allow(clippy::type_complexity)]
100pub fn collect_stratified_row_stat_across_vec(
101    data: &SparseIoVec,
102    column_membership: &HashMap<Box<str>, Box<str>>,
103    block_size: Option<usize>,
104) -> anyhow::Result<(Vec<Box<str>>, Vec<SparseRunningStatistics<f32>>)> {
105    let column_names = data.column_names()?;
106    let default = "".to_string().into_boxed_str();
107    let membership = column_names
108        .into_iter()
109        .map(|k| column_membership.get(&k).unwrap_or(&default).clone())
110        .collect::<Vec<_>>();
111
112    let partitions = partition_by_membership(&membership, None);
113    let mut group_names = Vec::with_capacity(partitions.len());
114    let mut group_stats = Vec::with_capacity(partitions.len());
115    let num_features = data.num_rows();
116
117    for (k, cols) in partitions {
118        let jobs = create_jobs(cols.len(), num_features, block_size);
119        let mut row_stat = SparseRunningStatistics::new(data.num_rows());
120        let arc_stat = Arc::new(Mutex::new(&mut row_stat));
121
122        jobs.par_iter()
123            .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
124            .for_each(|&(lb, ub)| {
125                let cols_sub = cols[lb..ub].iter().cloned();
126                let csc = data
127                    .read_columns_csc(cols_sub)
128                    .expect("failed to read data");
129                let mut stat = arc_stat.lock().expect("failed to lock row_stat");
130                stat.add_csc(&csc);
131            });
132
133        group_names.push(k);
134        group_stats.push(row_stat);
135    }
136
137    Ok((group_names, group_stats))
138}
139
140/// collect row-wise sufficient statistics for Q/C
141/// * `data` - `SparseIo`
142/// * `block_size` - a block size for each parallelized job
143pub fn collect_row_stat(
144    data: &dyn SparseIo<IndexIter = Vec<usize>>,
145    block_size: Option<usize>,
146) -> anyhow::Result<SparseRunningStatistics<f32>> {
147    let nrows = data.num_rows().unwrap_or(0);
148    let mut row_stat = SparseRunningStatistics::new(nrows);
149    let arc_stat = Arc::new(Mutex::new(&mut row_stat));
150
151    let jobs = create_jobs(data.num_columns().unwrap_or(0), nrows, block_size);
152
153    jobs.par_iter()
154        .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
155        .for_each(|&(lb, ub)| {
156            let csc = data
157                .read_columns_csc((lb..ub).collect())
158                .expect("failed to read data");
159            let mut stat = arc_stat.lock().expect("failed to lock row_stat");
160            stat.add_csc(&csc);
161        });
162
163    Ok(row_stat)
164}
165
166/// collect column-wise sufficient statistics for Q/C
167/// * `data` - `SparseIoVec` across many data matrices
168/// * `select_rows` - selected row indices
169/// * `block_size` - a block size for each parallelized job
170pub fn collect_column_stat_across_vec(
171    data: &SparseIoVec,
172    select_rows: Option<&[usize]>,
173    block_size: Option<usize>,
174) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
175    let ncols = data.num_columns();
176    let nrows_total = data.num_rows();
177
178    let row_mask: Option<Vec<bool>> = select_rows.map(|sel| {
179        let mut m = vec![false; nrows_total];
180        for &r in sel {
181            if r < nrows_total {
182                m[r] = true;
183            }
184        }
185        m
186    });
187    let nrows_denom = row_mask
188        .as_ref()
189        .map(|m| m.iter().filter(|x| **x).count())
190        .unwrap_or(nrows_total);
191
192    let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows_denom);
193    data.visit_columns_by_block(&col_stat_visitor, &row_mask, &mut col_stat, block_size)?;
194    Ok(col_stat)
195}
196
197/// collect column-wise sufficient statistics for Q/C
198/// * `data` - `SparseIo`
199/// * `block_size` - a block size for each parallelized job
200pub fn collect_column_stat(
201    data: &dyn SparseIo<IndexIter = Vec<usize>>,
202    block_size: Option<usize>,
203) -> anyhow::Result<SparseColumnRunningStatistics<f32>> {
204    let ncols = data.num_columns().unwrap_or(0);
205    let nrows = data.num_rows().unwrap_or(0);
206    let mut col_stat = SparseColumnRunningStatistics::<f32>::new(ncols, nrows);
207    let arc_stat = Arc::new(Mutex::new(&mut col_stat));
208
209    let jobs = create_jobs(ncols, nrows, block_size);
210
211    jobs.par_iter()
212        .progress_with(styled_progress_bar(jobs.len() as u64, "blocks"))
213        .for_each(|&(lb, ub)| {
214            let csc = data
215                .read_columns_csc((lb..ub).collect())
216                .expect("failed to read data");
217            let mut stat = arc_stat.lock().expect("failed to lock col_stat");
218            stat.add_csc(&csc, lb);
219        });
220
221    Ok(col_stat)
222}
223
224struct EmptyArgs {}
225
226fn row_stat_vec_visitor(
227    job: (usize, usize),
228    data: &SparseIoVec,
229    _: &EmptyArgs,
230    arc_stat: Arc<Mutex<&mut SparseRunningStatistics<f32>>>,
231) -> anyhow::Result<()> {
232    let (lb, ub) = job;
233    let csc = data.read_columns_csc(lb..ub)?;
234
235    let mut stat = arc_stat.lock().expect("failed to lock row_stat");
236    stat.add_csc(&csc);
237    Ok(())
238}
239
240fn col_stat_visitor(
241    job: (usize, usize),
242    data: &SparseIoVec,
243    row_mask: &Option<Vec<bool>>,
244    arc_stat: Arc<Mutex<&mut SparseColumnRunningStatistics<f32>>>,
245) -> anyhow::Result<()> {
246    let (lb, ub) = job;
247    let csc = data.read_columns_csc(lb..ub)?;
248    let mut stat = arc_stat.lock().expect("failed to lock col_stat");
249    match row_mask {
250        Some(mask) => stat.add_csc_masked(&csc, lb, mask),
251        None => stat.add_csc(&csc, lb),
252    }
253    Ok(())
254}
255
256//////////////////////////////////////////////////////////////////////////////////
257// Automatic nnz-cutoff selection + ASCII histogram (shared by `squeeze` and by //
258// callers that want cell-calling on a per-column nnz vector, e.g. `senna gem`). //
259//////////////////////////////////////////////////////////////////////////////////
260
261/// Bin width of the trough search, in natural-log units (~5% per bin).
262const TROUGH_BIN: f64 = 0.05;
263/// Gaussian smoothing of the binned density, in bins.
264const TROUGH_SMOOTH_BINS: f64 = 3.0;
265/// A cut needs the trough at most this fraction of the lower of its two peaks.
266const TROUGH_MAX_DEPTH: f64 = 0.25;
267/// Ambient and cell peaks sit at least a decade apart; closer modes (a doublet
268/// bump, a low-complexity cell type) are not an empty↔cell boundary.
269const TROUGH_MIN_PEAK_RATIO: f64 = 10.0;
270/// Each side of a cut must hold at least this many columns (or 0.1% of all).
271const TROUGH_MIN_SIDE: usize = 20;
272
273/// Suggest an nnz cutoff at the **deepest trough** of the nnz distribution,
274/// the gap between the ambient (empty barcode) peak and the cell peak.
275/// Columns with `nnz >= cutoff` are kept.
276///
277/// The density lives on the log axis, where ambient and cells form two
278/// peaks decades apart; in linear units the cell peak is spread so thin
279/// that the trough in front of it vanishes. The bins, though, come from
280/// the actual counts: each integer count `v` spreads its mass over its own
281/// interval `[v - 1/2, v + 1/2)` mapped to `ln(1 + ·)`, so small counts,
282/// whose log spacing exceeds a bin, leave no empty bins to pass for troughs.
283///
284/// A cut is proposed only when the smoothed density at the trough is at most
285/// [`TROUGH_MAX_DEPTH`] of the lower of the two peaks it separates, the
286/// peaks are at least [`TROUGH_MIN_PEAK_RATIO`] apart, and each side holds
287/// enough columns. Unimodal data — already-called cells included — gets
288/// `None`, whatever its tails look like. Deterministic, no RNG.
289pub fn suggest_nnz_cutoff(nnz: &[f32]) -> Option<usize> {
290    let n = nnz.len();
291    let min_side = TROUGH_MIN_SIDE.max(n / 1000);
292    if n < 2 * min_side {
293        return None;
294    }
295
296    let mut vals: Vec<u64> = nnz.iter().map(|&x| x.max(0.0).round() as u64).collect();
297    vals.sort_unstable();
298    let (vmin, vmax) = (vals[0], vals[n - 1]);
299    if vmin == vmax {
300        return None;
301    }
302
303    // Count `v`'s interval `[v - 1/2, v + 1/2)` on the `ln(1 + ·)` axis.
304    let edge = |v: u64, half: f64| (v as f64 + 1.0 + half).ln();
305    let lo = edge(vmin, -0.5);
306    let nbins = ((edge(vmax, 0.5) - lo) / TROUGH_BIN).ceil() as usize;
307    let mut hist = vec![0.0_f64; nbins];
308    let mut i = 0;
309    while i < n {
310        let v = vals[i];
311        let mut j = i;
312        while j < n && vals[j] == v {
313            j += 1;
314        }
315        let a = (edge(v, -0.5) - lo) / TROUGH_BIN;
316        let b = (edge(v, 0.5) - lo) / TROUGH_BIN;
317        let mass = (j - i) as f64 / (b - a);
318        for (k, h) in hist
319            .iter_mut()
320            .enumerate()
321            .take((b.ceil() as usize).min(nbins))
322            .skip(a.floor() as usize)
323        {
324            let overlap = b.min(k as f64 + 1.0) - a.max(k as f64);
325            if overlap > 0.0 {
326                *h += mass * overlap;
327            }
328        }
329        i = j;
330    }
331
332    // Gaussian smoothing (finite ±3σ kernel, so a real gap stays exactly 0).
333    let radius = (3.0 * TROUGH_SMOOTH_BINS).ceil() as isize;
334    let kernel: Vec<f64> = (-radius..=radius)
335        .map(|d| (-0.5 * (d as f64 / TROUGH_SMOOTH_BINS).powi(2)).exp())
336        .collect();
337    let smooth: Vec<f64> = (0..nbins as isize)
338        .map(|k| {
339            (-radius..=radius)
340                .filter_map(|d| {
341                    let t = k + d;
342                    (0..nbins as isize)
343                        .contains(&t)
344                        .then(|| hist[t as usize] * kernel[(d + radius) as usize])
345                })
346                .sum()
347        })
348        .collect();
349
350    // Mass left of each bin, and the running peaks from either end.
351    let mut below = vec![0.0_f64; nbins + 1];
352    for k in 0..nbins {
353        below[k + 1] = below[k] + hist[k];
354    }
355    let mut left_peak = vec![(0.0_f64, 0usize); nbins];
356    for k in 0..nbins {
357        let prev = if k > 0 { left_peak[k - 1] } else { (-1.0, 0) };
358        left_peak[k] = if smooth[k] > prev.0 {
359            (smooth[k], k)
360        } else {
361            prev
362        };
363    }
364    let mut right_peak = vec![(0.0_f64, 0usize); nbins];
365    for k in (0..nbins).rev() {
366        let next = if k + 1 < nbins {
367            right_peak[k + 1]
368        } else {
369            (-1.0, k)
370        };
371        right_peak[k] = if smooth[k] >= next.0 {
372            (smooth[k], k)
373        } else {
374            next
375        };
376    }
377
378    let min_bins_apart = TROUGH_MIN_PEAK_RATIO.ln() / TROUGH_BIN;
379    let total = below[nbins];
380    let mut best: Option<(f64, usize, usize)> = None; // (depth, first, last) of the deepest run
381    for t in 1..nbins.saturating_sub(1) {
382        let (l_mass, r_mass) = (below[t], total - below[t + 1]);
383        if l_mass < min_side as f64 || r_mass < min_side as f64 {
384            continue;
385        }
386        let ((l_h, l_k), (r_h, r_k)) = (left_peak[t - 1], right_peak[t + 1]);
387        if ((r_k - l_k) as f64) < min_bins_apart || l_h <= 0.0 || r_h <= 0.0 {
388            continue;
389        }
390        let depth = smooth[t] / l_h.min(r_h);
391        match best {
392            Some((d, first, last)) if depth == d && last + 1 == t => best = Some((d, first, t)),
393            Some((d, _, _)) if depth >= d => {}
394            _ => best = Some((depth, t, t)),
395        }
396    }
397
398    let Some((depth, first, last)) = best else {
399        log::info!("nnz cell-calling: no two peaks a decade apart → unimodal, no cutoff");
400        return None;
401    };
402    // Middle of the deepest run (an exact-zero gap is a run, not a point).
403    let x = lo + ((first + last) as f64 / 2.0 + 0.5) * TROUGH_BIN;
404    let cutoff = (x.exp() - 1.0).ceil().max(1.0) as usize;
405    let favors_cut = depth <= TROUGH_MAX_DEPTH;
406    log::info!(
407        "nnz cell-calling: deepest trough at nnz {} (depth {:.3}) → {}",
408        cutoff,
409        depth,
410        if favors_cut {
411            format!("bimodal, cutoff at nnz {cutoff}")
412        } else {
413            "unimodal, no cutoff".to_string()
414        }
415    );
416    favors_cut.then_some(cutoff)
417}
418
419/// One log10(x+1) histogram bin, carrying the real value range that fell into it
420struct HistBin {
421    val_min: f32,
422    val_max: f32,
423    log_val: f64,
424    count: usize,
425    is_cutoff: bool,
426}
427
428/// Create histogram with log10(x+1) binning, tracking the real value range per
429/// bin. Works on any non-negative statistic (nnz, sum, mean, sd); the ranges
430/// stay exact `f32` so count-like stats still print as integers.
431fn create_log_histogram(values: &[f32], cutoff: usize) -> Vec<HistBin> {
432    let cutoff_log = ((cutoff as f64 + 1.0).log10() * 10.0).round() as i32;
433
434    // Bin key represents log10(x+1)*10 as integer; value is (count, min, max)
435    let mut bins: std::collections::BTreeMap<i32, (usize, f32, f32)> =
436        std::collections::BTreeMap::new();
437
438    for &val in values {
439        let log_val = ((val as f64 + 1.0).log10() * 10.0).round() as i32;
440        let entry = bins
441            .entry(log_val)
442            .or_insert((0, f32::INFINITY, f32::NEG_INFINITY));
443        entry.0 += 1;
444        entry.1 = entry.1.min(val);
445        entry.2 = entry.2.max(val);
446    }
447
448    // Mark the first bin at or above the cutoff so the arrow always renders,
449    // even when no value's log bucket exactly matches cutoff_log. With no
450    // cutoff (cutoff == 0, e.g. the `histogram` command) nothing is marked.
451    let cutoff_bin = (cutoff > 0)
452        .then(|| bins.keys().copied().find(|&b| b >= cutoff_log))
453        .flatten();
454
455    bins.into_iter()
456        .map(|(bin, (count, val_min, val_max))| HistBin {
457            val_min,
458            val_max,
459            log_val: bin as f64 / 10.0,
460            count,
461            is_cutoff: Some(bin) == cutoff_bin,
462        })
463        .collect()
464}
465
466/// Format a statistic value compactly: whole numbers (nnz, integer counts)
467/// print without a decimal point; fractional values (mean, sd) get 2 decimals.
468fn fmt_stat(v: f32) -> String {
469    if v.fract() == 0.0 {
470        (v as i64).to_string()
471    } else {
472        format!("{:.2}", v)
473    }
474}
475
476/// Print summary statistics + an ASCII log10(x+1) histogram of a per-row or
477/// per-column statistic vector (`metric` names it, e.g. "nnz", "sum", "mean").
478/// A non-zero `cutoff` marks the cutoff bin and reports how much it removes;
479/// an optional `suggested` value reports the trough suggestion.
480///
481/// Used by `data-beans squeeze --show-histogram`, `data-beans histogram`, and
482/// `senna gem --auto-cell-cutoff`.
483pub fn print_nnz_summary(
484    label: &str,
485    metric: &str,
486    values: &[f32],
487    cutoff: usize,
488    suggested: Option<usize>,
489) {
490    const MAX_BAR_WIDTH: usize = 50; // Maximum width for histogram bars
491
492    let total = values.len();
493    let below_cutoff = values.iter().filter(|&&x| (x as usize) < cutoff).count();
494    let pct_removed = if total > 0 {
495        100.0 * below_cutoff as f64 / total as f64
496    } else {
497        0.0
498    };
499
500    // Calculate basic statistics
501    let min = values.iter().copied().fold(f32::INFINITY, f32::min);
502    let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
503    let sum: f32 = values.iter().sum();
504    let mean = if total > 0 { sum / total as f32 } else { 0.0 };
505
506    // Calculate median
507    let mut sorted = values.to_vec();
508    sorted.sort_by(|a, b| a.total_cmp(b));
509    let median = if total > 0 {
510        if total.is_multiple_of(2) {
511            (sorted[total / 2 - 1] + sorted[total / 2]) / 2.0
512        } else {
513            sorted[total / 2]
514        }
515    } else {
516        0.0
517    };
518
519    println!("{} {} distribution:", label, metric);
520    println!("  Total: {}", total);
521    println!(
522        "  Min: {}, Max: {}, Mean: {:.2}, Median: {:.2}",
523        fmt_stat(min),
524        fmt_stat(max),
525        mean,
526        median
527    );
528    if cutoff > 0 {
529        println!(
530            "  Cutoff: {} (removes {} / {} = {:.2}%)",
531            cutoff, below_cutoff, total, pct_removed
532        );
533    }
534    if let Some(s) = suggested {
535        let below_s = values.iter().filter(|&&x| (x as usize) < s).count();
536        let pct_s = if total > 0 {
537            100.0 * below_s as f64 / total as f64
538        } else {
539            0.0
540        };
541        println!(
542            "  Suggested cutoff (histogram trough of {}): {} (would remove {} / {} = {:.2}%)",
543            metric, s, below_s, total, pct_s
544        );
545    }
546
547    // Create histogram with log10(x+1) bins, tracking the real value range per bin
548    let hist = create_log_histogram(values, cutoff);
549
550    // Scale bar width on log10(count+1) so a few outlier bins don't flatten the rest
551    let max_log_count = hist
552        .iter()
553        .map(|b| ((b.count as f64) + 1.0).log10())
554        .fold(0.0_f64, f64::max)
555        .max(1e-9);
556
557    println!(
558        "  Histogram (x: actual {m} range [log10({m}+1)], bar: log10(count+1)):",
559        m = metric
560    );
561    for b in hist {
562        let marker = if b.is_cutoff { " <-- CUTOFF" } else { "" };
563        let log_count = ((b.count as f64) + 1.0).log10();
564        let bar_width = ((log_count / max_log_count) * MAX_BAR_WIDTH as f64).round() as usize;
565        let bar_width = if b.count > 0 { bar_width.max(1) } else { 0 };
566        let bar = "█".repeat(bar_width);
567        let range = if b.val_min == b.val_max {
568            fmt_stat(b.val_min)
569        } else {
570            format!("{}-{}", fmt_stat(b.val_min), fmt_stat(b.val_max))
571        };
572        println!(
573            "    {:>9} [{:>4.2}]: {:>6} {}{}",
574            range, b.log_val, b.count, bar, marker
575        );
576    }
577}
578
579#[cfg(test)]
580mod tests;