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