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