1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Tail {
37 Lower,
39 Upper,
41 Both,
43}
44
45#[derive(Clone, Debug)]
48pub struct QcConfig {
49 pub n_mads: f32,
51 pub min_cell_nnz: usize,
55 pub min_counts_per_cell: f32,
57 pub mito_pattern: Option<String>,
60 pub mito_max_frac: Option<f32>,
62 pub ribo_pattern: Option<String>,
64 pub ribo_max_frac: Option<f32>,
66 pub mad_on_n_genes: bool,
68 pub mad_on_counts: bool,
69 pub mad_on_mito: bool,
70 pub feature_min_cells: usize,
73 pub drop_outliers: bool,
77 pub auto_cell_cutoff: bool,
87 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
113fn 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#[derive(Clone, Copy, Debug)]
123pub struct RobustBand {
124 pub median: f32,
125 pub lo: f32,
126 pub hi: f32,
127}
128
129impl RobustBand {
130 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
141fn band_scale(v: f32, log1p: bool) -> f32 {
143 if log1p {
144 v.max(0.0).ln_1p()
145 } else {
146 v
147 }
148}
149
150pub 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 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 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 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
190pub 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
227pub 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
240pub struct QcReport {
242 pub train_keep: Vec<bool>,
245 pub near_empty: Vec<bool>,
248 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 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; }
271 if !self.near_empty[c] {
272 idx.push(new_pos); }
274 new_pos += 1;
275 }
276 idx
277 }
278
279 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
290pub 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
300pub 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
313pub 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 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 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 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
384struct QcStat {
386 columns: SparseColumnRunningStatistics<f32>,
388 subsets: Vec<SparseColumnRunningStatistics<f32>>,
390 rows: Option<SparseRunningStatistics<f32>>,
392}
393
394fn 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
439pub 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, },
472 cfg,
473 None,
474 ))
475}
476
477#[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 pub feature_n_cells: Option<Vec<f32>>,
487 pub n_rows: usize,
488}
489
490#[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 pub guarded: bool,
502}
503
504impl Verdicts {
505 pub fn emitted(&self, c: usize) -> bool {
508 self.train_keep[c] && !self.near_empty[c]
509 }
510}
511
512fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
523 if cfg.drop_outliers && (cfg.auto_cell_cutoff || cfg.qc_histogram) {
525 let cells = cell_values(&m.n_genes, exempt);
527 let suggested = crate::qc::suggest_nnz_cutoff(&cells);
528 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
554fn 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
568pub fn qc_report(m: QcMetrics, cfg: &QcConfig) -> QcReport {
571 qc_from_metrics(m, cfg, None)
572}
573
574pub fn cell_verdicts(m: &QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> Verdicts {
579 verdicts(m, cfg, exempt, true)
580}
581
582pub(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 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 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; }
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 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 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 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 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
749pub 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
760pub 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#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
823#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
824pub struct QcArgs {
825 #[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 #[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 #[arg(long = "qc-mito-max-frac", hide = true)]
883 pub qc_mito_max_frac: Option<f32>,
884
885 #[arg(long = "qc-ribo-pattern", hide = true)]
887 pub qc_ribo_pattern: Option<String>,
888
889 #[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 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 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 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 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 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_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 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 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 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 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 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 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 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 assert_eq!(report.output_keep_idx(), vec![0, 2]);
1143 }
1144}
1145
1146#[cfg(test)]
1147#[path = "qc_lib_tests.rs"]
1148mod tests;