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 row_names = data.row_names()?;
307
308    // Per-cell totals across all rows (nnz = n_genes, tot = total_counts).
309    // Use the running-stat accessors directly to skip the unused mean/std.
310    let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
311    let n_genes = col_stat.count_positives();
312    let total_counts = col_stat.sum();
313
314    // Optional mito / ribo fractions via row-name regex subsets.
315    let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
316        let Some(pat) = pattern else {
317            return Ok(None);
318        };
319        let rows = resolve_rows_by_regex(&row_names, pat)?;
320        if rows.is_empty() {
321            warn!(
322                "QC: pattern `{}` matched no features — metric disabled",
323                pat
324            );
325            return Ok(None);
326        }
327        let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
328        let frac = sub_tot
329            .iter()
330            .zip(total_counts.iter())
331            .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
332            .collect::<Vec<f32>>();
333        Ok(Some(frac))
334    };
335    let mito_frac = frac_for(&cfg.mito_pattern)?;
336    let ribo_frac = frac_for(&cfg.ribo_pattern)?;
337
338    // Feature axis stat (only when feature QC is enabled).
339    let feature_n_cells = if cfg.feature_min_cells > 0 {
340        Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
341    } else {
342        None
343    };
344
345    Ok(qc_from_metrics(
346        QcMetrics {
347            n_genes,
348            total_counts,
349            mito_frac,
350            ribo_frac,
351            feature_n_cells,
352            n_rows: data.num_rows(),
353        },
354        cfg,
355        exempt,
356    ))
357}
358
359/// Modality-agnostic cell QC for a [`SparseIoStack`]: per-cell `n_genes` /
360/// `total_counts` are **summed across all member modalities** (a cell rich
361/// in any one modality is kept). Mito/ribo and
362/// feature-axis QC are skipped on stacks (row names are per-modality).
363pub fn compute_qc_stack(
364    stack: &SparseIoStack,
365    cfg: &QcConfig,
366    block_size: Option<usize>,
367) -> anyhow::Result<QcReport> {
368    let n_cols = stack.num_columns()?;
369    let mut n_genes = vec![0f32; n_cols];
370    let mut total_counts = vec![0f32; n_cols];
371    for member in stack.stack.iter() {
372        let cs = collect_column_stat_across_vec(member, None, block_size)?;
373        let ng = cs.count_positives();
374        let ct = cs.sum();
375        for c in 0..n_cols {
376            n_genes[c] += ng[c];
377            total_counts[c] += ct[c];
378        }
379    }
380    if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
381        warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
382    }
383    Ok(qc_from_metrics(
384        QcMetrics {
385            n_genes,
386            total_counts,
387            mito_frac: None,
388            ribo_frac: None,
389            feature_n_cells: None,
390            n_rows: 0, // feature axis not masked on stacks
391        },
392        cfg,
393        None,
394    ))
395}
396
397/// Pre-computed per-cell / per-feature metrics fed to [`qc_from_metrics`].
398/// Lets the single-modality and stacked QC paths share one decision rule.
399struct QcMetrics {
400    n_genes: Vec<f32>,
401    total_counts: Vec<f32>,
402    mito_frac: Option<Vec<f32>>,
403    ribo_frac: Option<Vec<f32>>,
404    /// `None` when feature-axis QC is disabled.
405    feature_n_cells: Option<Vec<f32>>,
406    n_rows: usize,
407}
408
409/// Apply the two-tier QC decision (near-empty floor + MAD outliers) to
410/// pre-computed metrics. Shared by [`compute_qc`] and [`compute_qc_stack`].
411///
412/// `exempt`, when given, marks columns that are **not cells** — a prior run's
413/// carried pseudobulks. They are excluded from every band statistic (a few
414/// hundred smooth averages otherwise drag the MAD center and can guillotine
415/// the real cells wholesale — measured: 400 of 400 real cells dropped as
416/// "outliers" of the carried columns' band) and they receive no verdict:
417/// never near-empty, never dropped. Their exclusion from OUTPUT is a separate
418/// concern handled by the caller.
419fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
420    let QcMetrics {
421        n_genes,
422        total_counts,
423        mito_frac,
424        ribo_frac,
425        feature_n_cells,
426        n_rows,
427    } = m;
428    let n_cols = n_genes.len();
429
430    let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
431
432    // Tier 1: near-empty floor. Exempt columns are never near-empty.
433    let near_empty: Vec<bool> = n_genes
434        .iter()
435        .enumerate()
436        .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
437        .collect();
438
439    // Tier 2: MAD outliers among non-near-empty cells. The band statistics
440    // are fit over the non-near-empty, non-exempt cells only, so neither
441    // near-empty cells nor carried pseudobulks contaminate the robust center.
442    let not_near_empty: Vec<bool> = near_empty
443        .iter()
444        .enumerate()
445        .map(|(c, &e)| !e && !is_exempt(c))
446        .collect();
447    let consider = Some(not_near_empty.as_slice());
448    let mut outlier = vec![false; n_cols];
449    if cfg.drop_outliers {
450        let mut bands: Vec<Vec<bool>> = Vec::new();
451        if cfg.mad_on_n_genes {
452            bands.push(robust_outlier_keep(
453                &n_genes,
454                cfg.n_mads,
455                Tail::Lower,
456                true,
457                consider,
458            ));
459        }
460        if cfg.mad_on_counts {
461            bands.push(robust_outlier_keep(
462                &total_counts,
463                cfg.n_mads,
464                Tail::Lower,
465                true,
466                consider,
467            ));
468        }
469        if cfg.mad_on_mito {
470            if let Some(mf) = mito_frac.as_ref() {
471                bands.push(robust_outlier_keep(
472                    mf,
473                    cfg.n_mads,
474                    Tail::Upper,
475                    false,
476                    consider,
477                ));
478            }
479        }
480        for c in 0..n_cols {
481            if near_empty[c] || is_exempt(c) {
482                continue; // floor takes precedence; exempt columns get no verdict
483            }
484            let mut fail = total_counts[c] < cfg.min_counts_per_cell;
485            if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
486                fail |= mf[c] > cap;
487            }
488            if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
489                fail |= rf[c] > cap;
490            }
491            for band in bands.iter() {
492                if !band[c] {
493                    fail = true;
494                    break;
495                }
496            }
497            outlier[c] = fail;
498        }
499
500        // Automatic cell calling: the trough of n_genes picks the ambient↔real
501        // boundary and train-drops every cell below it, so the caller's
502        // `mask_columns(train_keep)` removes ambient up front. The cutoff
503        // is authoritative — there is NO redundant `min_cell_nnz` floor on it.
504        // When the data is unimodal the trough search returns `None`, so no auto
505        // cutoff is applied and the near-empty floor / MAD tiers stand alone.
506        // The histogram + cutoff are optionally printed.
507        if cfg.auto_cell_cutoff || cfg.qc_histogram {
508            let suggested = crate::qc::suggest_nnz_cutoff(&n_genes);
509            // Display cutoff: the trough suggestion if found, else the
510            // near-empty floor (what the non-auto tier would use).
511            let shown = suggested.unwrap_or(cfg.min_cell_nnz);
512            crate::qc::print_nnz_summary("Cell", "nnz", &n_genes, shown, suggested);
513            if cfg.auto_cell_cutoff {
514                if let Some(cut) = suggested {
515                    for (c, &g) in n_genes.iter().enumerate() {
516                        if (g as usize) < cut {
517                            outlier[c] = true;
518                        }
519                    }
520                }
521            }
522        }
523    }
524
525    let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
526    let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
527
528    // Guardrail: never produce an empty matrix.
529    if n_cols > 0 && n_cells_dropped >= n_cols {
530        warn!(
531            "QC would drop all {} cells — keeping all (check thresholds)",
532            n_cols
533        );
534        train_keep = vec![true; n_cols];
535        n_cells_dropped = 0;
536    }
537
538    // Feature axis (only when feature_n_cells was computed).
539    let (feature_keep, n_features_dropped) = match feature_n_cells {
540        Some(n_cells_expr) => {
541            let keep: Vec<bool> = n_cells_expr
542                .iter()
543                .map(|&c| (c as usize) >= cfg.feature_min_cells)
544                .collect();
545            let dropped = keep.iter().filter(|&&k| !k).count();
546            if !keep.is_empty() && dropped >= keep.len() {
547                warn!("QC would drop all features — keeping all");
548                (vec![true; keep.len()], 0)
549            } else {
550                (keep, dropped)
551            }
552        }
553        None => (vec![true; n_rows], 0),
554    };
555
556    QcReport {
557        train_keep,
558        near_empty,
559        feature_keep,
560        n_genes,
561        total_counts,
562        mito_frac,
563        ribo_frac,
564        n_cells_dropped,
565        n_features_dropped,
566    }
567}
568
569/// Filter a per-cell `Vec<T>` in lockstep with a cell keep-mask. Used to
570/// keep batch labels / coordinates aligned after `mask_columns`.
571pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
572    items
573        .iter()
574        .zip(keep.iter())
575        .filter(|&(_, &k)| k)
576        .map(|(x, _)| x.clone())
577        .collect()
578}
579
580/// Write a per-cell QC table (TSV): name, n_genes, total_counts,
581/// [mito_frac], [ribo_frac], near_empty (0/1), train_keep (0/1).
582pub fn write_qc_report(
583    path: &str,
584    cell_names: &[Box<str>],
585    report: &QcReport,
586) -> anyhow::Result<()> {
587    use std::fmt::Write as _;
588    let n = report.train_keep.len();
589    anyhow::ensure!(
590        cell_names.len() == n,
591        "write_qc_report: {} names != {} cells",
592        cell_names.len(),
593        n
594    );
595
596    let mut header = String::from("#cell\tn_genes\ttotal_counts");
597    if report.mito_frac.is_some() {
598        header.push_str("\tmito_frac");
599    }
600    if report.ribo_frac.is_some() {
601        header.push_str("\tribo_frac");
602    }
603    header.push_str("\tnear_empty\ttrain_keep");
604
605    let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
606    lines.push(header.into_boxed_str());
607    for c in 0..n {
608        let mut line = String::new();
609        let _ = write!(
610            line,
611            "{}\t{}\t{}",
612            cell_names[c], report.n_genes[c], report.total_counts[c]
613        );
614        if let Some(mf) = report.mito_frac.as_ref() {
615            let _ = write!(line, "\t{}", mf[c]);
616        }
617        if let Some(rf) = report.ribo_frac.as_ref() {
618            let _ = write!(line, "\t{}", rf[c]);
619        }
620        let _ = write!(
621            line,
622            "\t{}\t{}",
623            report.near_empty[c] as u8, report.train_keep[c] as u8
624        );
625        lines.push(line.into_boxed_str());
626    }
627    write_lines(&lines, path)
628}
629
630/// Clap surface for cell QC, shared by senna and pinto subcommands.
631///
632/// **Cell QC is ON by default and CAN DROP CELLS (columns).** Low-quality
633/// cells are removed: near-empty cells are omitted from the per-cell outputs,
634/// and robust (MAD) outlier cells are excluded from training entirely. As a
635/// result, the per-cell output files (`*.latent.parquet`, `*.cell_proj.parquet`,
636/// `*.cell_to_pb.parquet`, `*.cell_embedding.parquet`, pinto propensity, etc.)
637/// **may contain fewer rows (cells) than the input** — do not assume a 1:1,
638/// positional correspondence with the input barcodes; always join by the cell
639/// name/barcode column. Pass `--no-qc` to disable and keep every cell, or
640/// `--qc-report <path>` to dump the per-cell keep/drop flags. (Feature/row QC
641/// is OFF unless `--qc-feature-min-cells` is set.)
642#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
643#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
644pub struct QcArgs {
645    /// Disable cell QC entirely (keep every input cell).
646    #[arg(
647        long = "no-qc",
648        default_value_t = false,
649        long_help = "Disable cell quality control entirely and keep every input cell.\n\
650                     \n\
651                     By DEFAULT (without this flag) cell QC is:\n  \
652                     - a near-empty nnz floor (--qc-min-cell-nnz), and\n  \
653                     - MAD-outlier drops on detected features and total counts\n    \
654                     (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
655                     \n\
656                     Empty barcodes are dropped earlier, per input file, by the loader.\n\
657                     A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
658                     \n\
659                     Outputs may therefore have FEWER ROWS than the input.\n\
660                     Join by the cell/barcode name column, never by position.\n\
661                     \n\
662                     Use --qc-report to see exactly what was dropped.\n\
663                     For the older near-empty-floor-only gate,\n\
664                     pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
665    )]
666    pub no_qc: bool,
667
668    /// MAD multiplier for the robust outlier band; smaller = drops more cells.
669    #[arg(long = "qc-mads", default_value_t = 5.0)]
670    pub qc_mads: f32,
671
672    #[arg(
673        long = "qc-min-cell-nnz",
674        default_value_t = 2,
675        help = "Near-empty floor on a cell's detected-feature count",
676        long_help = "Near-empty floor on the detected-feature count.\n\
677                     Cells below it are dropped from the per-cell outputs.\n\
678                     They are still kept in training."
679    )]
680    pub qc_min_cell_nnz: usize,
681
682    #[arg(
683        long = "qc-min-counts",
684        hide = true,
685        default_value_t = 0.0,
686        help = "Hard floor on total counts per cell",
687        long_help = "Hard floor on total counts per cell.\n\
688                     Cells below it are dropped from training. 0 disables the floor."
689    )]
690    pub qc_min_counts: f32,
691
692    #[arg(
693        long = "qc-mito-pattern",
694        hide = true,
695        help = "Regex over feature names selecting mitochondrial genes",
696        long_help = "Regex over feature names selecting mitochondrial genes.\n\
697                     It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
698    )]
699    pub qc_mito_pattern: Option<String>,
700
701    /// Hard max mitochondrial fraction (0..1).
702    #[arg(long = "qc-mito-max-frac", hide = true)]
703    pub qc_mito_max_frac: Option<f32>,
704
705    /// Regex over feature names selecting ribosomal genes.
706    #[arg(long = "qc-ribo-pattern", hide = true)]
707    pub qc_ribo_pattern: Option<String>,
708
709    /// Hard max ribosomal fraction (0..1).
710    #[arg(long = "qc-ribo-max-frac", hide = true)]
711    pub qc_ribo_max_frac: Option<f32>,
712
713    #[arg(
714        long = "qc-feature-min-cells",
715        hide = true,
716        default_value_t = 0,
717        help = "Feature/row QC: drop genes expressed in too few cells",
718        long_help = "Feature/row QC; off by default.\n\
719                     It DROPS gene rows expressed in fewer than this many cells.\n\
720                     \n\
721                     Not every consumer applies it.\n\
722                     `bge` does not, since QC there is cell-only.\n\
723                     `pinto` does not either: it reads only the cell verdict,\n\
724                     so setting this costs a stats pass and changes nothing."
725    )]
726    pub qc_feature_min_cells: usize,
727
728    #[arg(
729        long = "qc-report",
730        help = "Write a per-cell QC table (.tsv)",
731        long_help = "Write a per-cell QC table, as .tsv.\n\
732                     It carries the metrics plus near_empty and train_keep flags.\n\
733                     You can then see exactly which cells were dropped."
734    )]
735    pub qc_report: Option<Box<str>>,
736
737    #[arg(
738        long = "qc-histogram",
739        hide = true,
740        default_value_t = false,
741        help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
742        long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
743                     The suggested trough cutoff is marked.\n\
744                     It is the same summary as `data-beans squeeze --show-histogram`.\n\
745                     \n\
746                     This is purely diagnostic. The cutoff is shown, not applied.\n\
747                     The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
748                     Use the histogram to pick --qc-min-cell-nnz by hand."
749    )]
750    pub qc_histogram: bool,
751
752    #[arg(
753        long = "qc-mad-on-genes",
754        hide = true,
755        // `--qc-mad-on-genes=false` turns it off; a bare flag leaves it on.
756        action = clap::ArgAction::Set,
757        num_args = 0..=1,
758        require_equals = true,
759        default_missing_value = "true",
760        default_value_t = true,
761        help = "MAD-outlier drop on the per-cell detected-feature count",
762        long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
763                     \n\
764                     ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
765                     with no way to enable them. That also made --qc-mads inert.\n\
766                     Only a set --qc-mito-pattern revived it.\n\
767                     \n\
768                     Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
769                     That is the conservative near-empty nnz gate alone."
770    )]
771    pub qc_mad_on_genes: bool,
772
773    #[arg(
774        long = "qc-mad-on-counts",
775        hide = true,
776        // `--qc-mad-on-counts=false` turns it off; a bare flag leaves it on.
777        action = clap::ArgAction::Set,
778        num_args = 0..=1,
779        require_equals = true,
780        default_missing_value = "true",
781        default_value_t = true,
782        help = "MAD-outlier drop on per-cell total counts",
783        long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
784                     ON by default; see --qc-mad-on-genes."
785    )]
786    pub qc_mad_on_counts: bool,
787
788    #[arg(
789        long = "qc-mad-on-mito",
790        hide = true,
791        // `--qc-mad-on-mito=false` turns it off; a bare flag leaves it on.
792        action = clap::ArgAction::Set,
793        num_args = 0..=1,
794        require_equals = true,
795        default_missing_value = "true",
796        default_value_t = true,
797        help = "MAD-outlier drop on the mito fraction, given --qc-mito-pattern",
798        long_help = "Drop cells whose mito fraction is above `median + --qc-mads * MAD * 1.4826`.\n\
799                     On whenever --qc-mito-pattern is given. `--qc-mad-on-mito=false` leaves\n\
800                     only the hard cap, --qc-mito-max-frac, when one is given."
801    )]
802    pub qc_mad_on_mito: bool,
803
804    #[arg(
805        long = "qc-auto-cutoff",
806        hide = true,
807        default_value_t = false,
808        help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
809        long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
810                     It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
811                     Without this flag it is only reported, via --qc-histogram.\n\
812                     \n\
813                     OFF by default. The intended gate is the conservative near-empty floor,\n\
814                     plus the model's own empty-call.\n\
815                     This flag was referenced in the docs before it existed."
816    )]
817    pub qc_auto_cutoff: bool,
818}
819
820impl QcArgs {
821    /// QC config, or `None` under `--no-qc`.
822    pub fn to_config(&self) -> Option<QcConfig> {
823        (!self.no_qc).then(|| QcConfig {
824            n_mads: self.qc_mads,
825            min_cell_nnz: self.qc_min_cell_nnz,
826            min_counts_per_cell: self.qc_min_counts,
827            mito_pattern: self.qc_mito_pattern.clone(),
828            mito_max_frac: self.qc_mito_max_frac,
829            ribo_pattern: self.qc_ribo_pattern.clone(),
830            ribo_max_frac: self.qc_ribo_max_frac,
831            // MAD gates are ON by default and settable. They were hardcoded
832            // `false` here with no CLI path, which also made `--qc-mads` a dead
833            // flag unless `--qc-mito-pattern` was set (nothing else reads
834            // `n_mads`). Mito stays implicit: it can only run when a pattern
835            // selects the rows to measure.
836            mad_on_n_genes: self.qc_mad_on_genes,
837            mad_on_counts: self.qc_mad_on_counts,
838            mad_on_mito: self.qc_mito_pattern.is_some() && self.qc_mad_on_mito,
839            feature_min_cells: self.qc_feature_min_cells,
840            drop_outliers: true,
841            // Off by default: the near-empty floor plus the model's own
842            // empty-call (bge's embedding-norm two-step; topic/masked-topic's
843            // flag-don't-drop) is the intended gate. `--qc-auto-cutoff` opts in.
844            auto_cell_cutoff: self.qc_auto_cutoff,
845            qc_histogram: self.qc_histogram,
846        })
847    }
848}
849
850#[cfg(test)]
851mod qc_tests {
852    use super::*;
853
854    #[test]
855    fn robust_lower_flags_low_outlier() {
856        let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
857        let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
858        assert!(!keep[5], "the value 1.0 should be a lower outlier");
859        assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
860        // upper tail must not flag a low value
861        let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
862        assert!(keep_up[5], "lower outlier kept under Tail::Upper");
863    }
864
865    #[test]
866    fn no_spread_flags_no_one() {
867        // Most cells read no mito at all: the MAD is 0, and a cell with one
868        // mito read is no outlier for it.
869        let mut v = vec![0.0f32; 60];
870        v.extend([0.01, 0.02, 0.05, 0.3]);
871        assert!(robust_band(&v, 5.0, false, None).is_none());
872        let keep = robust_outlier_keep(&v, 5.0, Tail::Upper, false, None);
873        assert!(keep.iter().all(|&k| k));
874    }
875
876    #[test]
877    fn robust_uniform_keeps_all() {
878        let v = vec![7.0; 20];
879        let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
880        assert!(keep.iter().all(|&k| k));
881    }
882
883    #[test]
884    fn auto_cutoff_train_drops_ambient() {
885        // Bimodal nnz: 30 ambient (~2) + 30 real (~100). With auto cell calling on
886        // (MAD tiers off so the auto floor is the only decider), the trough
887        // cutoff lands between the modes and the ambient cells are train-dropped.
888        let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
889        let cfg = QcConfig {
890            auto_cell_cutoff: true,
891            qc_histogram: false,
892            drop_outliers: true,
893            mad_on_n_genes: false,
894            mad_on_counts: false,
895            mad_on_mito: false,
896            min_cell_nnz: 0,
897            ..QcConfig::default()
898        };
899        let report = qc_from_metrics(
900            QcMetrics {
901                n_genes: n_genes.clone(),
902                total_counts: n_genes,
903                mito_frac: None,
904                ribo_frac: None,
905                feature_n_cells: None,
906                n_rows: 0,
907            },
908            &cfg,
909            None,
910        );
911        assert_eq!(
912            report.train_keep,
913            [vec![false; 30], vec![true; 30]].concat()
914        );
915        assert_eq!(report.n_cells_dropped, 30);
916    }
917
918    #[test]
919    fn robust_consider_excludes_contaminants_from_band() {
920        // A real cluster around 100 plus a large block of near-empty 0s that
921        // would drag a naive median/MAD down. With `consider` masking the
922        // zeros out of the band, a genuine low real cell (40) is still flagged.
923        let mut v = vec![0.0; 12];
924        v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
925        let mut consider = vec![false; 12];
926        consider.extend([true; 6]);
927        let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
928        assert!(
929            !keep[17],
930            "40 is a lower outlier of the real cluster (~100)"
931        );
932        // Without `consider`, the zeros dominate the median and 40 survives.
933        let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
934        assert!(
935            keep_naive[17],
936            "naive band (contaminated by zeros) keeps 40"
937        );
938    }
939
940    #[test]
941    fn output_keep_idx_skips_dropped_and_near_empty() {
942        // cells: 0 keep, 1 near-empty (kept in training), 2 MAD-drop, 3 keep
943        let report = QcReport {
944            train_keep: vec![true, true, false, true],
945            near_empty: vec![false, true, false, false],
946            feature_keep: vec![],
947            n_genes: vec![],
948            total_counts: vec![],
949            mito_frac: None,
950            ribo_frac: None,
951            n_cells_dropped: 1,
952            n_features_dropped: 0,
953        };
954        // post-mask order: cell0->0, cell1->1, cell3->2 (cell2 dropped)
955        // emit non-near-empty survivors: 0 and 2
956        assert_eq!(report.output_keep_idx(), vec![0, 2]);
957    }
958}
959
960#[cfg(test)]
961#[path = "qc_lib_tests.rs"]
962mod tests;