Skip to main content

data_beans/
qc_lib.rs

1//! Cell-axis quality control (library-only): robust (MAD) outlier detection
2//! plus the near-empty floor. Built on the streaming stat collectors in the
3//! sibling `qc` module and consumed by senna / pinto — NOT by the data-beans
4//! CLI — so it lives in its own module that the binary never compiles (keeps
5//! `qc` fully bin-used and avoids dead-code in the bin target).
6
7use crate::qc::{collect_column_stat_across_vec, masked_column_stat};
8use crate::sparse_data_visitors::VisitColumnsOps;
9use crate::sparse_io_stack::SparseIoStack;
10use crate::sparse_io_vector::SparseIoVec;
11use legume_numeric::matrix::common_io::write_lines;
12use legume_numeric::matrix::sparse_stat::{SparseColumnRunningStatistics, SparseRunningStatistics};
13use legume_numeric::matrix::traits::RunningStatOps;
14use log::warn;
15use regex::Regex;
16use std::sync::{Arc, Mutex};
17
18///////////////////////////////////////////////////////////////////////////////////
19// Cell-axis quality control: robust (MAD) outlier detection + near-empty floor. //
20///////////////////////////////////////////////////////////////////////////////////
21//
22// This MAY DROP CELLS (columns): callers that enable QC end up with fewer
23// cells in the working set and/or in the per-cell outputs than the input had,
24// so downstream consumers must not assume a 1:1 positional mapping to the
25// input barcodes — join by cell name. (Feature/row QC, off by default, may
26// likewise drop genes/rows.)
27//
28// Shared by senna and pinto. Two-tier policy (see `compute_qc`):
29//   * near-empty cells (`nnz < min_cell_nnz`) are kept in training but
30//     dropped from the *output*;
31//   * non-near-empty MAD outliers are dropped from *training* via
32//     `SparseIoVec::mask_columns` (so they leave the outputs too).
33
34/// Which side(s) of the robust band count as outliers.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Tail {
37    /// Flag values far *below* the median (e.g. low counts / few genes).
38    Lower,
39    /// Flag values far *above* the median (e.g. high mito fraction).
40    Upper,
41    /// Flag both extremes.
42    Both,
43}
44
45/// Configuration for cell QC. Plain struct (no clap) so non-clap crates
46/// can construct it directly; the clap surface is [`QcArgs`].
47#[derive(Clone, Debug)]
48pub struct QcConfig {
49    /// MAD multiplier for the robust band (larger = more permissive).
50    pub n_mads: f32,
51    /// Near-empty floor: cells with fewer than this many detected features
52    /// (nnz across all rows) are masked at output, not dropped from
53    /// training. 0 disables the floor.
54    pub min_cell_nnz: usize,
55    /// Hard floor on total counts per cell, in addition to MAD. 0 disables.
56    pub min_counts_per_cell: f32,
57    /// Regex over row (feature) names selecting mitochondrial genes; enables
58    /// the per-cell mito-fraction metric. `None` disables it.
59    pub mito_pattern: Option<String>,
60    /// Optional hard max mitochondrial fraction (0..1).
61    pub mito_max_frac: Option<f32>,
62    /// Regex selecting ribosomal genes (enables ribo-fraction metric).
63    pub ribo_pattern: Option<String>,
64    /// Optional hard max ribosomal fraction (0..1).
65    pub ribo_max_frac: Option<f32>,
66    /// Per-cell metrics that drive MAD outlier flagging.
67    pub mad_on_n_genes: bool,
68    pub mad_on_counts: bool,
69    pub mad_on_mito: bool,
70    /// Feature-axis QC (off by default): drop genes expressed in fewer than
71    /// this many cells. 0 disables.
72    pub feature_min_cells: usize,
73    /// Master switch for the MAD train-drop tier. When `false`, only the
74    /// near-empty floor (output mask) is computed — used by inference
75    /// (predict/impute) so query cells are never silently dropped.
76    pub drop_outliers: bool,
77    /// Automatic cell calling: pick the per-cell nnz cutoff at the trough
78    /// between the ambient and the cell peak ([`crate::qc::suggest_nnz_cutoff`])
79    /// and **train-drop** every cell below it. So called-out ambient is removed
80    /// up front (via the caller's `mask_columns`), not just output-masked — it
81    /// never shapes the model. The cutoff is authoritative; `min_cell_nnz` does
82    /// NOT floor it (it only drives the separate near-empty output mask). No-op
83    /// when there is no trough (unimodal) or when `drop_outliers` is false
84    /// (inference). Unlike the loader's per-file empty-barcode gate, this runs
85    /// on the pooled cell axis.
86    pub auto_cell_cutoff: bool,
87    /// Print the per-cell nnz histogram + the suggested/applied cutoff (the
88    /// same ASCII summary as `data-beans squeeze --show-histogram`).
89    pub qc_histogram: bool,
90}
91
92impl Default for QcConfig {
93    fn default() -> Self {
94        Self {
95            n_mads: 5.0,
96            min_cell_nnz: 2,
97            min_counts_per_cell: 0.0,
98            mito_pattern: None,
99            mito_max_frac: None,
100            ribo_pattern: None,
101            ribo_max_frac: None,
102            mad_on_n_genes: true,
103            mad_on_counts: true,
104            mad_on_mito: false,
105            feature_min_cells: 0,
106            drop_outliers: true,
107            auto_cell_cutoff: false,
108            qc_histogram: false,
109        }
110    }
111}
112
113/// Median of a non-empty slice via `O(n)` quickselect (reorders in place).
114fn median_in_place(xs: &mut [f32]) -> f32 {
115    let mid = xs.len() / 2;
116    xs.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
117    xs[mid]
118}
119
120/// The robust band [`robust_outlier_keep`] tests against: `median ± n_mads ·
121/// MAD · 1.4826`, on the `log1p` scale when asked.
122#[derive(Clone, Copy, Debug)]
123pub struct RobustBand {
124    pub median: f32,
125    pub lo: f32,
126    pub hi: f32,
127}
128
129impl RobustBand {
130    /// The band on the values' own scale, undoing `log1p`.
131    pub fn raw(self, log1p: bool) -> Self {
132        let back = |v: f32| if log1p { v.exp_m1() } else { v };
133        Self {
134            median: back(self.median),
135            lo: back(self.lo),
136            hi: back(self.hi),
137        }
138    }
139}
140
141/// A value on the scale its band is fitted on: `ln(1 + v)` for counts.
142fn band_scale(v: f32, log1p: bool) -> f32 {
143    if log1p {
144        v.max(0.0).ln_1p()
145    } else {
146        v
147    }
148}
149
150/// [`RobustBand`] of `values` (the `consider`ed ones, when given); `None`
151/// when none is finite, or when they have no spread (a MAD of 0).
152pub fn robust_band(
153    values: &[f32],
154    n_mads: f32,
155    log1p: bool,
156    consider: Option<&[bool]>,
157) -> Option<RobustBand> {
158    let xform = |v: f32| band_scale(v, log1p);
159
160    // Band statistics computed over the considered subset only.
161    let mut xs: Vec<f32> = values
162        .iter()
163        .enumerate()
164        .filter(|(i, _)| consider.is_none_or(|c| c[*i]))
165        .map(|(_, &v)| xform(v))
166        .filter(|v| v.is_finite())
167        .collect();
168    if xs.is_empty() {
169        return None;
170    }
171    // O(n) median via quickselect (no full sort).
172    let median = median_in_place(&mut xs);
173
174    let mut dev: Vec<f32> = xs.iter().map(|&v| (v - median).abs()).collect();
175    let mad = median_in_place(&mut dev) * 1.4826;
176    // No spread: more than half the values are one value (a mito fraction
177    // of 0 in most nuclei, say). A band of width 0 would call every other
178    // value an outlier, so there is none.
179    if mad <= 0.0 {
180        return None;
181    }
182
183    Some(RobustBand {
184        median,
185        lo: median - n_mads * mad,
186        hi: median + n_mads * mad,
187    })
188}
189
190/// Robust outlier keep-mask: `keep[i] = false` when `values[i]` falls
191/// outside `median ± n_mads · MAD · 1.4826` on the requested `tail`.
192/// Count-like metrics should pass `log1p = true` so the band is symmetric
193/// on the multiplicative scale. Consolidates the median+MAD idiom used in
194/// `cnv::hmm`.
195/// `consider` (when `Some`) restricts the median/MAD band to the cells it
196/// marks `true` — so e.g. near-empty cells don't contaminate the robust
197/// center — while the keep decision is still returned for every cell.
198pub fn robust_outlier_keep(
199    values: &[f32],
200    n_mads: f32,
201    tail: Tail,
202    log1p: bool,
203    consider: Option<&[bool]>,
204) -> Vec<bool> {
205    let n = values.len();
206    let Some(band) = robust_band(values, n_mads, log1p, consider) else {
207        return vec![true; n];
208    };
209    let xform = |v: f32| band_scale(v, log1p);
210    let (lo, hi) = (band.lo, band.hi);
211    values
212        .iter()
213        .map(|&raw| {
214            let v = xform(raw);
215            if !v.is_finite() {
216                return false;
217            }
218            match tail {
219                Tail::Lower => v >= lo,
220                Tail::Upper => v <= hi,
221                Tail::Both => v >= lo && v <= hi,
222            }
223        })
224        .collect()
225}
226
227/// Resolve a regex over row (feature) names into matching row indices, in
228/// the same compact-row order as `SparseIoVec::row_names()`. Feeds
229/// `collect_column_stat_across_vec(.., Some(&rows), ..)` for subset sums.
230pub fn resolve_rows_by_regex(row_names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
231    let re = Regex::new(pattern)?;
232    Ok(row_names
233        .iter()
234        .enumerate()
235        .filter(|(_, name)| re.is_match(name))
236        .map(|(i, _)| i)
237        .collect())
238}
239
240/// Per-cell / per-feature QC outcome.
241pub struct QcReport {
242    /// Cells to retain in training (len = num_columns). `false` only for
243    /// non-near-empty MAD outliers — drives `SparseIoVec::mask_columns`.
244    pub train_keep: Vec<bool>,
245    /// Near-empty cells (len = num_columns): kept in training, masked at
246    /// output via [`QcReport::output_keep_idx`].
247    pub near_empty: Vec<bool>,
248    /// Features to retain (len = num_rows). All `true` unless
249    /// `feature_min_cells > 0`.
250    pub feature_keep: Vec<bool>,
251    pub n_genes: Vec<f32>,
252    pub total_counts: Vec<f32>,
253    pub mito_frac: Option<Vec<f32>>,
254    pub ribo_frac: Option<Vec<f32>>,
255    pub n_cells_dropped: usize,
256    pub n_features_dropped: usize,
257}
258
259impl QcReport {
260    /// Indices, in *post-`mask_columns`* column order, of the cells to emit
261    /// at output: surviving (training-kept) cells that are not near-empty.
262    /// Apply with `Mat::select_rows` on per-cell output matrices and filter
263    /// the barcode slice by the same indices.
264    pub fn output_keep_idx(&self) -> Vec<usize> {
265        let mut idx = Vec::new();
266        let mut new_pos = 0usize;
267        for c in 0..self.train_keep.len() {
268            if !self.train_keep[c] {
269                continue; // dropped from training entirely
270            }
271            if !self.near_empty[c] {
272                idx.push(new_pos); // c survives at compact index new_pos
273            }
274            new_pos += 1;
275        }
276        idx
277    }
278
279    /// Emit-keep indices in the **original** (unmasked) column order: cells
280    /// that are neither MAD-dropped nor near-empty. For paths that keep every
281    /// cell in training and instead `select_rows` the per-cell outputs
282    /// directly (e.g. bge's `UnifiedData`, which has no `mask_columns`).
283    pub fn emit_idx_unmasked(&self) -> Vec<usize> {
284        (0..self.train_keep.len())
285            .filter(|&c| self.train_keep[c] && !self.near_empty[c])
286            .collect()
287    }
288}
289
290/// Compute the two-tier cell-QC report. `block_size` controls the
291/// streaming stat passes (`None` = default chunking).
292pub fn compute_qc(
293    data: &SparseIoVec,
294    cfg: &QcConfig,
295    block_size: Option<usize>,
296) -> anyhow::Result<QcReport> {
297    compute_qc_exempting(data, cfg, block_size, None)
298}
299
300/// [`compute_qc`] with columns that are **not cells** exempted — a prior
301/// run's carried pseudobulks. `exempt[c] = true` keeps column `c` out of all
302/// band statistics and out of every verdict. See `qc_from_metrics`.
303pub fn compute_qc_exempting(
304    data: &SparseIoVec,
305    cfg: &QcConfig,
306    block_size: Option<usize>,
307    exempt: Option<&[bool]>,
308) -> anyhow::Result<QcReport> {
309    let m = compute_qc_metrics(data, cfg, block_size, cfg.feature_min_cells > 0)?;
310    Ok(qc_from_metrics(m, cfg, exempt))
311}
312
313/// The per-cell (and, with `feature_cells`, per-feature) metrics QC
314/// decides on, measured once: detected features and total counts per
315/// cell, the mito and ribo fractions where `cfg` names their patterns, and
316/// how many cells detect each feature. [`cell_verdicts`] decides on them
317/// under any config with those patterns, without reading the data again.
318pub fn compute_qc_metrics(
319    data: &SparseIoVec,
320    cfg: &QcConfig,
321    block_size: Option<usize>,
322    feature_cells: bool,
323) -> anyhow::Result<QcMetrics> {
324    let row_names = data.row_names()?;
325
326    // Optional mito / ribo row subsets via row-name regex.
327    let rows_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<usize>>> {
328        let Some(pat) = pattern else {
329            return Ok(None);
330        };
331        let rows = resolve_rows_by_regex(&row_names, pat)?;
332        if rows.is_empty() {
333            warn!(
334                "QC: pattern `{}` matched no features — metric disabled",
335                pat
336            );
337            return Ok(None);
338        }
339        Ok(Some(rows))
340    };
341    let mito_rows = rows_for(&cfg.mito_pattern)?;
342    let ribo_rows = rows_for(&cfg.ribo_pattern)?;
343    let subsets: Vec<&[usize]> = [&mito_rows, &ribo_rows]
344        .into_iter()
345        .filter_map(|rows| rows.as_deref())
346        .collect();
347
348    // Every metric from one pass over the data: per-cell totals across all
349    // rows (nnz = n_genes, tot = total_counts), the subset totals, and the
350    // feature axis stat (only when asked). Use the running-stat accessors
351    // directly to skip the unused mean/std.
352    let stat = collect_qc_stat(data, &subsets, feature_cells, block_size)?;
353    let n_genes = stat.columns.count_positives();
354    let total_counts = stat.columns.sum();
355
356    // Fractions of the subsets measured, in the order they were asked for.
357    let mut subset_totals = stat.subsets.iter().map(|s| s.sum());
358    let mut frac_for = |rows: &Option<Vec<usize>>| -> Option<Vec<f32>> {
359        rows.as_ref()?;
360        let sub_tot = subset_totals.next()?;
361        Some(
362            sub_tot
363                .iter()
364                .zip(total_counts.iter())
365                .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
366                .collect(),
367        )
368    };
369    let mito_frac = frac_for(&mito_rows);
370    let ribo_frac = frac_for(&ribo_rows);
371
372    let feature_n_cells = stat.rows.map(|rows| rows.count_positives());
373
374    Ok(QcMetrics {
375        n_genes,
376        total_counts,
377        mito_frac,
378        ribo_frac,
379        feature_n_cells,
380        n_rows: data.num_rows(),
381    })
382}
383
384/// What [`collect_qc_stat`] collects in its one pass.
385struct QcStat {
386    /// Column statistics over all rows.
387    columns: SparseColumnRunningStatistics<f32>,
388    /// Column statistics over each row subset asked for, in that order.
389    subsets: Vec<SparseColumnRunningStatistics<f32>>,
390    /// Row statistics, when asked for.
391    rows: Option<SparseRunningStatistics<f32>>,
392}
393
394/// Collect, reading every block once, the column statistics over all rows
395/// (`collect_column_stat_across_vec` with no selection), those over each
396/// of `row_subsets` (the same with each as its selection) and, with
397/// `with_rows`, the row statistics (`collect_row_stat_across_vec`).
398fn collect_qc_stat(
399    data: &SparseIoVec,
400    row_subsets: &[&[usize]],
401    with_rows: bool,
402    block_size: Option<usize>,
403) -> anyhow::Result<QcStat> {
404    let ncols = data.num_columns();
405    let nrows = data.num_rows();
406
407    let (masks, subsets): (Vec<Vec<bool>>, Vec<_>) = row_subsets
408        .iter()
409        .map(|sel| masked_column_stat(ncols, nrows, sel))
410        .unzip();
411    let mut stat = QcStat {
412        columns: SparseColumnRunningStatistics::new(ncols, nrows),
413        subsets,
414        rows: with_rows.then(|| SparseRunningStatistics::new(nrows)),
415    };
416    data.visit_columns_by_block(&qc_stat_visitor, &masks, &mut stat, block_size)?;
417    Ok(stat)
418}
419
420fn qc_stat_visitor(
421    job: (usize, usize),
422    data: &SparseIoVec,
423    masks: &Vec<Vec<bool>>,
424    arc_stat: Arc<Mutex<&mut QcStat>>,
425) -> anyhow::Result<()> {
426    let (lb, ub) = job;
427    let csc = data.read_columns_csc(lb..ub)?;
428    let mut stat = arc_stat.lock().expect("failed to lock qc stat");
429    stat.columns.add_csc(&csc, lb);
430    for (sub, mask) in stat.subsets.iter_mut().zip(masks) {
431        sub.add_csc_masked(&csc, lb, mask);
432    }
433    if let Some(rows) = stat.rows.as_mut() {
434        rows.add_csc(&csc);
435    }
436    Ok(())
437}
438
439/// Modality-agnostic cell QC for a [`SparseIoStack`]: per-cell `n_genes` /
440/// `total_counts` are **summed across all member modalities** (a cell rich
441/// in any one modality is kept). Mito/ribo and
442/// feature-axis QC are skipped on stacks (row names are per-modality).
443pub fn compute_qc_stack(
444    stack: &SparseIoStack,
445    cfg: &QcConfig,
446    block_size: Option<usize>,
447) -> anyhow::Result<QcReport> {
448    let n_cols = stack.num_columns()?;
449    let mut n_genes = vec![0f32; n_cols];
450    let mut total_counts = vec![0f32; n_cols];
451    for member in stack.stack.iter() {
452        let cs = collect_column_stat_across_vec(member, None, block_size)?;
453        let ng = cs.count_positives();
454        let ct = cs.sum();
455        for c in 0..n_cols {
456            n_genes[c] += ng[c];
457            total_counts[c] += ct[c];
458        }
459    }
460    if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
461        warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
462    }
463    Ok(qc_from_metrics(
464        QcMetrics {
465            n_genes,
466            total_counts,
467            mito_frac: None,
468            ribo_frac: None,
469            feature_n_cells: None,
470            n_rows: 0, // feature axis not masked on stacks
471        },
472        cfg,
473        None,
474    ))
475}
476
477/// Pre-computed per-cell / per-feature metrics fed to [`cell_verdicts`].
478/// Lets the single-modality and stacked QC paths share one decision rule.
479#[derive(Clone, Debug)]
480pub struct QcMetrics {
481    pub n_genes: Vec<f32>,
482    pub total_counts: Vec<f32>,
483    pub mito_frac: Option<Vec<f32>>,
484    pub ribo_frac: Option<Vec<f32>>,
485    /// Cells detecting each feature; `None` when not measured.
486    pub feature_n_cells: Option<Vec<f32>>,
487    pub n_rows: usize,
488}
489
490/// What [`cell_verdicts`] decides: the cells kept in training, the
491/// near-empty ones, and the features kept.
492#[derive(Clone, Debug, PartialEq, Eq)]
493pub struct Verdicts {
494    pub train_keep: Vec<bool>,
495    pub near_empty: Vec<bool>,
496    pub feature_keep: Vec<bool>,
497    pub n_cells_dropped: usize,
498    pub n_features_dropped: usize,
499    /// The rules would have dropped every cell, or every feature, and the
500    /// guardrail kept them all.
501    pub guarded: bool,
502}
503
504impl Verdicts {
505    /// Whether cell `c` reaches the output: kept in training and not
506    /// near-empty.
507    pub fn emitted(&self, c: usize) -> bool {
508        self.train_keep[c] && !self.near_empty[c]
509    }
510}
511
512/// Apply the two-tier QC decision (near-empty floor + MAD outliers) to
513/// pre-computed metrics. Shared by [`compute_qc`] and [`compute_qc_stack`].
514///
515/// `exempt`, when given, marks columns that are **not cells** — a prior run's
516/// carried pseudobulks. They are excluded from every band statistic (a few
517/// hundred smooth averages otherwise drag the MAD center and can guillotine
518/// the real cells wholesale — measured: 400 of 400 real cells dropped as
519/// "outliers" of the carried columns' band) and they receive no verdict:
520/// never near-empty, never dropped. Their exclusion from OUTPUT is a separate
521/// concern handled by the caller.
522fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
523    // The histogram + cutoff, optionally printed.
524    if cfg.drop_outliers && (cfg.auto_cell_cutoff || cfg.qc_histogram) {
525        // Of the cells alone, as the cutoff is found among them.
526        let cells = cell_values(&m.n_genes, exempt);
527        let suggested = crate::qc::suggest_nnz_cutoff(&cells);
528        // Display cutoff: the trough suggestion if found, else the
529        // near-empty floor (what the non-auto tier would use).
530        let shown = suggested.unwrap_or(cfg.min_cell_nnz);
531        crate::qc::eprint_nnz_summary("Cell", "nnz", &cells, shown, suggested);
532    }
533    let v = cell_verdicts(&m, cfg, exempt);
534    let QcMetrics {
535        n_genes,
536        total_counts,
537        mito_frac,
538        ribo_frac,
539        ..
540    } = m;
541    QcReport {
542        train_keep: v.train_keep,
543        near_empty: v.near_empty,
544        feature_keep: v.feature_keep,
545        n_genes,
546        total_counts,
547        mito_frac,
548        ribo_frac,
549        n_cells_dropped: v.n_cells_dropped,
550        n_features_dropped: v.n_features_dropped,
551    }
552}
553
554/// `values` of the columns that are cells: all of them, or those `exempt`
555/// does not mark.
556fn cell_values<'a>(values: &'a [f32], exempt: Option<&[bool]>) -> std::borrow::Cow<'a, [f32]> {
557    match exempt {
558        Some(e) => values
559            .iter()
560            .zip(e)
561            .filter(|&(_, &x)| !x)
562            .map(|(&v, _)| v)
563            .collect(),
564        None => values.into(),
565    }
566}
567
568/// The [`QcReport`] of metrics measured once ([`compute_qc_metrics`]),
569/// as [`compute_qc`] makes it: the histogram printed when `cfg` asks.
570pub fn qc_report(m: QcMetrics, cfg: &QcConfig) -> QcReport {
571    qc_from_metrics(m, cfg, None)
572}
573
574/// The two-tier QC decision (near-empty floor + MAD outliers) on `m`
575/// under `cfg`, printing nothing: what [`compute_qc`] decides, for a view
576/// that decides again as its settings change. `exempt` as in
577/// [`compute_qc_exempting`].
578pub fn cell_verdicts(m: &QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> Verdicts {
579    verdicts(m, cfg, exempt, true)
580}
581
582/// [`cell_verdicts`] without the guardrails' warnings, for a view that
583/// decides again at every key and shows [`Verdicts::guarded`] itself.
584pub(crate) fn cell_verdicts_quiet(m: &QcMetrics, cfg: &QcConfig) -> Verdicts {
585    verdicts(m, cfg, None, false)
586}
587
588fn verdicts(m: &QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>, warn: bool) -> Verdicts {
589    let QcMetrics {
590        n_genes,
591        total_counts,
592        mito_frac,
593        ribo_frac,
594        feature_n_cells,
595        n_rows,
596    } = m;
597    let n_rows = *n_rows;
598    let n_cols = n_genes.len();
599
600    let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
601
602    // Tier 1: near-empty floor. Exempt columns are never near-empty.
603    let near_empty: Vec<bool> = n_genes
604        .iter()
605        .enumerate()
606        .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
607        .collect();
608
609    // Tier 2: MAD outliers among non-near-empty cells. The band statistics
610    // are fit over the non-near-empty, non-exempt cells only, so neither
611    // near-empty cells nor carried pseudobulks contaminate the robust center.
612    let not_near_empty: Vec<bool> = near_empty
613        .iter()
614        .enumerate()
615        .map(|(c, &e)| !e && !is_exempt(c))
616        .collect();
617    let consider = Some(not_near_empty.as_slice());
618    let mut outlier = vec![false; n_cols];
619    if cfg.drop_outliers {
620        let mut bands: Vec<Vec<bool>> = Vec::new();
621        if cfg.mad_on_n_genes {
622            bands.push(robust_outlier_keep(
623                n_genes,
624                cfg.n_mads,
625                Tail::Lower,
626                true,
627                consider,
628            ));
629        }
630        if cfg.mad_on_counts {
631            bands.push(robust_outlier_keep(
632                total_counts,
633                cfg.n_mads,
634                Tail::Lower,
635                true,
636                consider,
637            ));
638        }
639        if cfg.mad_on_mito {
640            if let Some(mf) = mito_frac.as_ref() {
641                bands.push(robust_outlier_keep(
642                    mf,
643                    cfg.n_mads,
644                    Tail::Upper,
645                    false,
646                    consider,
647                ));
648            }
649        }
650        for c in 0..n_cols {
651            if near_empty[c] || is_exempt(c) {
652                continue; // floor takes precedence; exempt columns get no verdict
653            }
654            let mut fail = total_counts[c] < cfg.min_counts_per_cell;
655            if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
656                fail |= mf[c] > cap;
657            }
658            if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
659                fail |= rf[c] > cap;
660            }
661            for band in bands.iter() {
662                if !band[c] {
663                    fail = true;
664                    break;
665                }
666            }
667            outlier[c] = fail;
668        }
669
670        // Automatic cell calling: the trough of n_genes picks the ambient↔real
671        // boundary and train-drops every cell below it, so the caller's
672        // `mask_columns(train_keep)` removes ambient up front. The cutoff
673        // is authoritative — there is NO redundant `min_cell_nnz` floor on it.
674        // When the data is unimodal the trough search returns `None`, so no auto
675        // cutoff is applied and the near-empty floor / MAD tiers stand alone.
676        // The trough is sought among the cells: exempt columns neither
677        // shape it nor fall to it.
678        if cfg.auto_cell_cutoff {
679            if let Some(cut) = crate::qc::suggest_nnz_cutoff(&cell_values(n_genes, exempt)) {
680                for (c, &g) in n_genes.iter().enumerate() {
681                    if !is_exempt(c) && (g as usize) < cut {
682                        outlier[c] = true;
683                    }
684                }
685            }
686        }
687    }
688
689    let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
690    let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
691
692    // Guardrail: never produce a matrix with no cell. Exempt columns are
693    // never dropped, so the drops are held against the cells alone.
694    let n_cells = (0..n_cols).filter(|&c| !is_exempt(c)).count();
695    let mut guarded = false;
696    if n_cells > 0 && n_cells_dropped >= n_cells {
697        if warn {
698            warn!(
699                "QC would drop all {} cells — keeping all (check thresholds)",
700                n_cells
701            );
702        }
703        train_keep = vec![true; n_cols];
704        n_cells_dropped = 0;
705        guarded = true;
706    }
707    // Cells kept in training reach the output only above the near-empty
708    // floor; where none does, the verdicts stand and the output is empty.
709    let emits = |c: usize| !is_exempt(c) && train_keep[c] && !near_empty[c];
710    if warn && n_cells > 0 && !(0..n_cols).any(emits) {
711        warn!(
712            "QC leaves none of the {} cells in the output: each is near-empty \
713             (fewer than {} detected features) or dropped (check thresholds)",
714            n_cells, cfg.min_cell_nnz
715        );
716    }
717
718    // Feature axis (only when feature_n_cells was computed).
719    let (feature_keep, n_features_dropped) = match feature_n_cells {
720        Some(n_cells_expr) => {
721            let keep: Vec<bool> = n_cells_expr
722                .iter()
723                .map(|&c| (c as usize) >= cfg.feature_min_cells)
724                .collect();
725            let dropped = keep.iter().filter(|&&k| !k).count();
726            if !keep.is_empty() && dropped >= keep.len() {
727                if warn {
728                    warn!("QC would drop all features — keeping all");
729                }
730                guarded = true;
731                (vec![true; keep.len()], 0)
732            } else {
733                (keep, dropped)
734            }
735        }
736        None => (vec![true; n_rows], 0),
737    };
738
739    Verdicts {
740        train_keep,
741        near_empty,
742        feature_keep,
743        n_cells_dropped,
744        n_features_dropped,
745        guarded,
746    }
747}
748
749/// Filter a per-cell `Vec<T>` in lockstep with a cell keep-mask. Used to
750/// keep batch labels / coordinates aligned after `mask_columns`.
751pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
752    items
753        .iter()
754        .zip(keep.iter())
755        .filter(|&(_, &k)| k)
756        .map(|(x, _)| x.clone())
757        .collect()
758}
759
760/// Write a per-cell QC table (TSV): name, n_genes, total_counts,
761/// [mito_frac], [ribo_frac], near_empty (0/1), train_keep (0/1).
762pub fn write_qc_report(
763    path: &str,
764    cell_names: &[Box<str>],
765    report: &QcReport,
766) -> anyhow::Result<()> {
767    use std::fmt::Write as _;
768    let n = report.train_keep.len();
769    anyhow::ensure!(
770        cell_names.len() == n,
771        "write_qc_report: {} names != {} cells",
772        cell_names.len(),
773        n
774    );
775
776    let mut header = String::from("#cell\tn_genes\ttotal_counts");
777    if report.mito_frac.is_some() {
778        header.push_str("\tmito_frac");
779    }
780    if report.ribo_frac.is_some() {
781        header.push_str("\tribo_frac");
782    }
783    header.push_str("\tnear_empty\ttrain_keep");
784
785    let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
786    lines.push(header.into_boxed_str());
787    for c in 0..n {
788        let mut line = String::new();
789        let _ = write!(
790            line,
791            "{}\t{}\t{}",
792            cell_names[c], report.n_genes[c], report.total_counts[c]
793        );
794        if let Some(mf) = report.mito_frac.as_ref() {
795            let _ = write!(line, "\t{}", mf[c]);
796        }
797        if let Some(rf) = report.ribo_frac.as_ref() {
798            let _ = write!(line, "\t{}", rf[c]);
799        }
800        let _ = write!(
801            line,
802            "\t{}\t{}",
803            report.near_empty[c] as u8, report.train_keep[c] as u8
804        );
805        lines.push(line.into_boxed_str());
806    }
807    write_lines(&lines, path)
808}
809
810/// Clap surface for cell QC, shared by senna and pinto subcommands.
811///
812/// **Cell QC is ON by default and CAN DROP CELLS (columns).** Low-quality
813/// cells are removed: near-empty cells are omitted from the per-cell outputs,
814/// and robust (MAD) outlier cells are excluded from training entirely. As a
815/// result, the per-cell output files (`*.latent.parquet`, `*.cell_proj.parquet`,
816/// `*.cell_to_pb.parquet`, `*.cell_embedding.parquet`, pinto propensity, etc.)
817/// **may contain fewer rows (cells) than the input** — do not assume a 1:1,
818/// positional correspondence with the input barcodes; always join by the cell
819/// name/barcode column. Pass `--no-qc` to disable and keep every cell, or
820/// `--qc-report <path>` to dump the per-cell keep/drop flags. (Feature/row QC
821/// is OFF unless `--qc-feature-min-cells` is set.)
822#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
823#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
824pub struct QcArgs {
825    /// Disable cell QC entirely (keep every input cell).
826    #[arg(
827        long = "no-qc",
828        default_value_t = false,
829        long_help = "Disable cell quality control entirely and keep every input cell.\n\
830                     \n\
831                     By DEFAULT (without this flag) cell QC is:\n  \
832                     - a near-empty nnz floor (--qc-min-cell-nnz), and\n  \
833                     - MAD-outlier drops on detected features and total counts\n    \
834                     (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
835                     \n\
836                     Empty barcodes are dropped earlier, per input file, by the loader.\n\
837                     A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
838                     \n\
839                     Outputs may therefore have FEWER ROWS than the input.\n\
840                     Join by the cell/barcode name column, never by position.\n\
841                     \n\
842                     Use --qc-report to see exactly what was dropped.\n\
843                     For the older near-empty-floor-only gate,\n\
844                     pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
845    )]
846    pub no_qc: bool,
847
848    /// MAD multiplier for the robust outlier band; smaller = drops more cells.
849    #[arg(long = "qc-mads", default_value_t = 5.0)]
850    pub qc_mads: f32,
851
852    #[arg(
853        long = "qc-min-cell-nnz",
854        default_value_t = 2,
855        help = "Near-empty floor on a cell's detected-feature count",
856        long_help = "Near-empty floor on the detected-feature count.\n\
857                     Cells below it are dropped from the per-cell outputs.\n\
858                     They are still kept in training."
859    )]
860    pub qc_min_cell_nnz: usize,
861
862    #[arg(
863        long = "qc-min-counts",
864        hide = true,
865        default_value_t = 0.0,
866        help = "Hard floor on total counts per cell",
867        long_help = "Hard floor on total counts per cell.\n\
868                     Cells below it are dropped from training. 0 disables the floor."
869    )]
870    pub qc_min_counts: f32,
871
872    #[arg(
873        long = "qc-mito-pattern",
874        hide = true,
875        help = "Regex over feature names selecting mitochondrial genes",
876        long_help = "Regex over feature names selecting mitochondrial genes.\n\
877                     It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
878    )]
879    pub qc_mito_pattern: Option<String>,
880
881    /// Hard max mitochondrial fraction (0..1).
882    #[arg(long = "qc-mito-max-frac", hide = true)]
883    pub qc_mito_max_frac: Option<f32>,
884
885    /// Regex over feature names selecting ribosomal genes.
886    #[arg(long = "qc-ribo-pattern", hide = true)]
887    pub qc_ribo_pattern: Option<String>,
888
889    /// Hard max ribosomal fraction (0..1).
890    #[arg(long = "qc-ribo-max-frac", hide = true)]
891    pub qc_ribo_max_frac: Option<f32>,
892
893    #[arg(
894        long = "qc-feature-min-cells",
895        hide = true,
896        default_value_t = 0,
897        help = "Feature/row QC: drop genes expressed in too few cells",
898        long_help = "Feature/row QC; off by default.\n\
899                     It DROPS gene rows expressed in fewer than this many cells.\n\
900                     \n\
901                     Not every consumer applies it.\n\
902                     `bge` does not, since QC there is cell-only.\n\
903                     `pinto` does not either: it reads only the cell verdict,\n\
904                     so setting this costs a stats pass and changes nothing."
905    )]
906    pub qc_feature_min_cells: usize,
907
908    #[arg(
909        long = "qc-report",
910        help = "Write a per-cell QC table (.tsv)",
911        long_help = "Write a per-cell QC table, as .tsv.\n\
912                     It carries the metrics plus near_empty and train_keep flags.\n\
913                     You can then see exactly which cells were dropped."
914    )]
915    pub qc_report: Option<Box<str>>,
916
917    #[arg(
918        long = "qc-histogram",
919        hide = true,
920        default_value_t = false,
921        help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
922        long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
923                     The suggested trough cutoff is marked.\n\
924                     It is the same summary as `data-beans squeeze --show-histogram`.\n\
925                     \n\
926                     This is purely diagnostic. The cutoff is shown, not applied.\n\
927                     The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
928                     Use the histogram to pick --qc-min-cell-nnz by hand."
929    )]
930    pub qc_histogram: bool,
931
932    #[arg(
933        long = "qc-mad-on-genes",
934        hide = true,
935        // `--qc-mad-on-genes=false` turns it off; a bare flag leaves it on.
936        action = clap::ArgAction::Set,
937        num_args = 0..=1,
938        require_equals = true,
939        default_missing_value = "true",
940        default_value_t = true,
941        help = "MAD-outlier drop on the per-cell detected-feature count",
942        long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
943                     \n\
944                     ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
945                     with no way to enable them. That also made --qc-mads inert.\n\
946                     Only a set --qc-mito-pattern revived it.\n\
947                     \n\
948                     Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
949                     That is the conservative near-empty nnz gate alone."
950    )]
951    pub qc_mad_on_genes: bool,
952
953    #[arg(
954        long = "qc-mad-on-counts",
955        hide = true,
956        // `--qc-mad-on-counts=false` turns it off; a bare flag leaves it on.
957        action = clap::ArgAction::Set,
958        num_args = 0..=1,
959        require_equals = true,
960        default_missing_value = "true",
961        default_value_t = true,
962        help = "MAD-outlier drop on per-cell total counts",
963        long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
964                     ON by default; see --qc-mad-on-genes."
965    )]
966    pub qc_mad_on_counts: bool,
967
968    #[arg(
969        long = "qc-mad-on-mito",
970        hide = true,
971        // `--qc-mad-on-mito=false` turns it off; a bare flag leaves it on.
972        action = clap::ArgAction::Set,
973        num_args = 0..=1,
974        require_equals = true,
975        default_missing_value = "true",
976        default_value_t = true,
977        help = "MAD-outlier drop on the mito fraction, given --qc-mito-pattern",
978        long_help = "Drop cells whose mito fraction is above `median + --qc-mads * MAD * 1.4826`.\n\
979                     On whenever --qc-mito-pattern is given. `--qc-mad-on-mito=false` leaves\n\
980                     only the hard cap, --qc-mito-max-frac, when one is given."
981    )]
982    pub qc_mad_on_mito: bool,
983
984    #[arg(
985        long = "qc-auto-cutoff",
986        hide = true,
987        default_value_t = false,
988        help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
989        long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
990                     It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
991                     Without this flag it is only reported, via --qc-histogram.\n\
992                     \n\
993                     OFF by default. The intended gate is the conservative near-empty floor,\n\
994                     plus the model's own empty-call.\n\
995                     This flag was referenced in the docs before it existed."
996    )]
997    pub qc_auto_cutoff: bool,
998}
999
1000impl QcArgs {
1001    /// QC config, or `None` under `--no-qc`.
1002    pub fn to_config(&self) -> Option<QcConfig> {
1003        (!self.no_qc).then(|| QcConfig {
1004            n_mads: self.qc_mads,
1005            min_cell_nnz: self.qc_min_cell_nnz,
1006            min_counts_per_cell: self.qc_min_counts,
1007            // An empty pattern names no genes: the metric is off, not a
1008            // regex that finds every gene.
1009            mito_pattern: self.qc_mito_pattern.clone().filter(|p| !p.is_empty()),
1010            mito_max_frac: self.qc_mito_max_frac,
1011            ribo_pattern: self.qc_ribo_pattern.clone().filter(|p| !p.is_empty()),
1012            ribo_max_frac: self.qc_ribo_max_frac,
1013            // MAD gates are ON by default and settable. They were hardcoded
1014            // `false` here with no CLI path, which also made `--qc-mads` a dead
1015            // flag unless `--qc-mito-pattern` was set (nothing else reads
1016            // `n_mads`). Mito stays implicit: it can only run when a pattern
1017            // selects the rows to measure.
1018            mad_on_n_genes: self.qc_mad_on_genes,
1019            mad_on_counts: self.qc_mad_on_counts,
1020            mad_on_mito: self
1021                .qc_mito_pattern
1022                .as_deref()
1023                .is_some_and(|p| !p.is_empty())
1024                && self.qc_mad_on_mito,
1025            feature_min_cells: self.qc_feature_min_cells,
1026            drop_outliers: true,
1027            // Off by default: the near-empty floor plus the model's own
1028            // empty-call (bge's embedding-norm two-step; topic/masked-topic's
1029            // flag-don't-drop) is the intended gate. `--qc-auto-cutoff` opts in.
1030            auto_cell_cutoff: self.qc_auto_cutoff,
1031            qc_histogram: self.qc_histogram,
1032        })
1033    }
1034}
1035
1036#[cfg(test)]
1037mod qc_tests {
1038    use super::*;
1039
1040    #[test]
1041    fn robust_lower_flags_low_outlier() {
1042        let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
1043        let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
1044        assert!(!keep[5], "the value 1.0 should be a lower outlier");
1045        assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
1046        // upper tail must not flag a low value
1047        let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
1048        assert!(keep_up[5], "lower outlier kept under Tail::Upper");
1049    }
1050
1051    #[test]
1052    fn no_spread_flags_no_one() {
1053        // Most cells read no mito at all: the MAD is 0, and a cell with one
1054        // mito read is no outlier for it.
1055        let mut v = vec![0.0f32; 60];
1056        v.extend([0.01, 0.02, 0.05, 0.3]);
1057        assert!(robust_band(&v, 5.0, false, None).is_none());
1058        let keep = robust_outlier_keep(&v, 5.0, Tail::Upper, false, None);
1059        assert!(keep.iter().all(|&k| k));
1060    }
1061
1062    #[test]
1063    fn robust_uniform_keeps_all() {
1064        let v = vec![7.0; 20];
1065        let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
1066        assert!(keep.iter().all(|&k| k));
1067    }
1068
1069    #[test]
1070    fn auto_cutoff_train_drops_ambient() {
1071        // Bimodal nnz: 30 ambient (~2) + 30 real (~100). With auto cell calling on
1072        // (MAD tiers off so the auto floor is the only decider), the trough
1073        // cutoff lands between the modes and the ambient cells are train-dropped.
1074        let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
1075        let cfg = QcConfig {
1076            auto_cell_cutoff: true,
1077            qc_histogram: false,
1078            drop_outliers: true,
1079            mad_on_n_genes: false,
1080            mad_on_counts: false,
1081            mad_on_mito: false,
1082            min_cell_nnz: 0,
1083            ..QcConfig::default()
1084        };
1085        let report = qc_from_metrics(
1086            QcMetrics {
1087                n_genes: n_genes.clone(),
1088                total_counts: n_genes,
1089                mito_frac: None,
1090                ribo_frac: None,
1091                feature_n_cells: None,
1092                n_rows: 0,
1093            },
1094            &cfg,
1095            None,
1096        );
1097        assert_eq!(
1098            report.train_keep,
1099            [vec![false; 30], vec![true; 30]].concat()
1100        );
1101        assert_eq!(report.n_cells_dropped, 30);
1102    }
1103
1104    #[test]
1105    fn robust_consider_excludes_contaminants_from_band() {
1106        // A real cluster around 100 plus a large block of near-empty 0s that
1107        // would drag a naive median/MAD down. With `consider` masking the
1108        // zeros out of the band, a genuine low real cell (40) is still flagged.
1109        let mut v = vec![0.0; 12];
1110        v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
1111        let mut consider = vec![false; 12];
1112        consider.extend([true; 6]);
1113        let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
1114        assert!(
1115            !keep[17],
1116            "40 is a lower outlier of the real cluster (~100)"
1117        );
1118        // Without `consider`, the zeros dominate the median and 40 survives.
1119        let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
1120        assert!(
1121            keep_naive[17],
1122            "naive band (contaminated by zeros) keeps 40"
1123        );
1124    }
1125
1126    #[test]
1127    fn output_keep_idx_skips_dropped_and_near_empty() {
1128        // cells: 0 keep, 1 near-empty (kept in training), 2 MAD-drop, 3 keep
1129        let report = QcReport {
1130            train_keep: vec![true, true, false, true],
1131            near_empty: vec![false, true, false, false],
1132            feature_keep: vec![],
1133            n_genes: vec![],
1134            total_counts: vec![],
1135            mito_frac: None,
1136            ribo_frac: None,
1137            n_cells_dropped: 1,
1138            n_features_dropped: 0,
1139        };
1140        // post-mask order: cell0->0, cell1->1, cell3->2 (cell2 dropped)
1141        // emit non-near-empty survivors: 0 and 2
1142        assert_eq!(report.output_keep_idx(), vec![0, 2]);
1143    }
1144}
1145
1146#[cfg(test)]
1147#[path = "qc_lib_tests.rs"]
1148mod tests;