1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Tail {
34 Lower,
36 Upper,
38 Both,
40}
41
42#[derive(Clone, Debug)]
45pub struct QcConfig {
46 pub n_mads: f32,
48 pub min_cell_nnz: usize,
52 pub min_counts_per_cell: f32,
54 pub mito_pattern: Option<String>,
57 pub mito_max_frac: Option<f32>,
59 pub ribo_pattern: Option<String>,
61 pub ribo_max_frac: Option<f32>,
63 pub mad_on_n_genes: bool,
65 pub mad_on_counts: bool,
66 pub mad_on_mito: bool,
67 pub feature_min_cells: usize,
70 pub drop_outliers: bool,
74 pub auto_cell_cutoff: bool,
84 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
110fn 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
117pub fn robust_outlier_keep(
126 values: &[f32],
127 n_mads: f32,
128 tail: Tail,
129 log1p: bool,
130 consider: Option<&[bool]>,
131) -> Vec<bool> {
132 let n = values.len();
133 if n == 0 {
134 return vec![];
135 }
136 let xform = |v: f32| if log1p { v.max(0.0).ln_1p() } else { v };
137
138 let mut xs: Vec<f32> = values
140 .iter()
141 .enumerate()
142 .filter(|(i, _)| consider.is_none_or(|c| c[*i]))
143 .map(|(_, &v)| xform(v))
144 .filter(|v| v.is_finite())
145 .collect();
146 if xs.is_empty() {
147 return vec![true; n];
148 }
149 let median = median_in_place(&mut xs);
151
152 let mut dev: Vec<f32> = xs.iter().map(|&v| (v - median).abs()).collect();
153 let mad = (median_in_place(&mut dev) * 1.4826).max(1e-8);
154
155 let lo = median - n_mads * mad;
156 let hi = median + n_mads * mad;
157 values
158 .iter()
159 .map(|&raw| {
160 let v = xform(raw);
161 if !v.is_finite() {
162 return false;
163 }
164 match tail {
165 Tail::Lower => v >= lo,
166 Tail::Upper => v <= hi,
167 Tail::Both => v >= lo && v <= hi,
168 }
169 })
170 .collect()
171}
172
173pub fn resolve_rows_by_regex(row_names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
177 let re = Regex::new(pattern)?;
178 Ok(row_names
179 .iter()
180 .enumerate()
181 .filter(|(_, name)| re.is_match(name))
182 .map(|(i, _)| i)
183 .collect())
184}
185
186pub struct QcReport {
188 pub train_keep: Vec<bool>,
191 pub near_empty: Vec<bool>,
194 pub feature_keep: Vec<bool>,
197 pub n_genes: Vec<f32>,
198 pub total_counts: Vec<f32>,
199 pub mito_frac: Option<Vec<f32>>,
200 pub ribo_frac: Option<Vec<f32>>,
201 pub n_cells_dropped: usize,
202 pub n_features_dropped: usize,
203}
204
205impl QcReport {
206 pub fn output_keep_idx(&self) -> Vec<usize> {
211 let mut idx = Vec::new();
212 let mut new_pos = 0usize;
213 for c in 0..self.train_keep.len() {
214 if !self.train_keep[c] {
215 continue; }
217 if !self.near_empty[c] {
218 idx.push(new_pos); }
220 new_pos += 1;
221 }
222 idx
223 }
224
225 pub fn emit_idx_unmasked(&self) -> Vec<usize> {
230 (0..self.train_keep.len())
231 .filter(|&c| self.train_keep[c] && !self.near_empty[c])
232 .collect()
233 }
234}
235
236pub fn compute_qc(
239 data: &SparseIoVec,
240 cfg: &QcConfig,
241 block_size: Option<usize>,
242) -> anyhow::Result<QcReport> {
243 compute_qc_exempting(data, cfg, block_size, None)
244}
245
246pub fn compute_qc_exempting(
250 data: &SparseIoVec,
251 cfg: &QcConfig,
252 block_size: Option<usize>,
253 exempt: Option<&[bool]>,
254) -> anyhow::Result<QcReport> {
255 let row_names = data.row_names()?;
256
257 let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
260 let n_genes = col_stat.count_positives();
261 let total_counts = col_stat.sum();
262
263 let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
265 let Some(pat) = pattern else {
266 return Ok(None);
267 };
268 let rows = resolve_rows_by_regex(&row_names, pat)?;
269 if rows.is_empty() {
270 warn!(
271 "QC: pattern `{}` matched no features — metric disabled",
272 pat
273 );
274 return Ok(None);
275 }
276 let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
277 let frac = sub_tot
278 .iter()
279 .zip(total_counts.iter())
280 .map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
281 .collect::<Vec<f32>>();
282 Ok(Some(frac))
283 };
284 let mito_frac = frac_for(&cfg.mito_pattern)?;
285 let ribo_frac = frac_for(&cfg.ribo_pattern)?;
286
287 let feature_n_cells = if cfg.feature_min_cells > 0 {
289 Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
290 } else {
291 None
292 };
293
294 Ok(qc_from_metrics(
295 QcMetrics {
296 n_genes,
297 total_counts,
298 mito_frac,
299 ribo_frac,
300 feature_n_cells,
301 n_rows: data.num_rows(),
302 },
303 cfg,
304 exempt,
305 ))
306}
307
308pub fn compute_qc_stack(
313 stack: &SparseIoStack,
314 cfg: &QcConfig,
315 block_size: Option<usize>,
316) -> anyhow::Result<QcReport> {
317 let n_cols = stack.num_columns()?;
318 let mut n_genes = vec![0f32; n_cols];
319 let mut total_counts = vec![0f32; n_cols];
320 for member in stack.stack.iter() {
321 let cs = collect_column_stat_across_vec(member, None, block_size)?;
322 let ng = cs.count_positives();
323 let ct = cs.sum();
324 for c in 0..n_cols {
325 n_genes[c] += ng[c];
326 total_counts[c] += ct[c];
327 }
328 }
329 if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
330 warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
331 }
332 Ok(qc_from_metrics(
333 QcMetrics {
334 n_genes,
335 total_counts,
336 mito_frac: None,
337 ribo_frac: None,
338 feature_n_cells: None,
339 n_rows: 0, },
341 cfg,
342 None,
343 ))
344}
345
346struct QcMetrics {
349 n_genes: Vec<f32>,
350 total_counts: Vec<f32>,
351 mito_frac: Option<Vec<f32>>,
352 ribo_frac: Option<Vec<f32>>,
353 feature_n_cells: Option<Vec<f32>>,
355 n_rows: usize,
356}
357
358fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
369 let QcMetrics {
370 n_genes,
371 total_counts,
372 mito_frac,
373 ribo_frac,
374 feature_n_cells,
375 n_rows,
376 } = m;
377 let n_cols = n_genes.len();
378
379 let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
380
381 let near_empty: Vec<bool> = n_genes
383 .iter()
384 .enumerate()
385 .map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
386 .collect();
387
388 let not_near_empty: Vec<bool> = near_empty
392 .iter()
393 .enumerate()
394 .map(|(c, &e)| !e && !is_exempt(c))
395 .collect();
396 let consider = Some(not_near_empty.as_slice());
397 let mut outlier = vec![false; n_cols];
398 if cfg.drop_outliers {
399 let mut bands: Vec<Vec<bool>> = Vec::new();
400 if cfg.mad_on_n_genes {
401 bands.push(robust_outlier_keep(
402 &n_genes,
403 cfg.n_mads,
404 Tail::Lower,
405 true,
406 consider,
407 ));
408 }
409 if cfg.mad_on_counts {
410 bands.push(robust_outlier_keep(
411 &total_counts,
412 cfg.n_mads,
413 Tail::Lower,
414 true,
415 consider,
416 ));
417 }
418 if cfg.mad_on_mito {
419 if let Some(mf) = mito_frac.as_ref() {
420 bands.push(robust_outlier_keep(
421 mf,
422 cfg.n_mads,
423 Tail::Upper,
424 false,
425 consider,
426 ));
427 }
428 }
429 for c in 0..n_cols {
430 if near_empty[c] || is_exempt(c) {
431 continue; }
433 let mut fail = total_counts[c] < cfg.min_counts_per_cell;
434 if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
435 fail |= mf[c] > cap;
436 }
437 if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
438 fail |= rf[c] > cap;
439 }
440 for band in bands.iter() {
441 if !band[c] {
442 fail = true;
443 break;
444 }
445 }
446 outlier[c] = fail;
447 }
448
449 if cfg.auto_cell_cutoff || cfg.qc_histogram {
457 let suggested = crate::qc::suggest_nnz_cutoff(&n_genes);
458 let shown = suggested.unwrap_or(cfg.min_cell_nnz);
461 crate::qc::print_nnz_summary("Cell", "nnz", &n_genes, shown, suggested);
462 if cfg.auto_cell_cutoff {
463 if let Some(cut) = suggested {
464 for (c, &g) in n_genes.iter().enumerate() {
465 if (g as usize) < cut {
466 outlier[c] = true;
467 }
468 }
469 }
470 }
471 }
472 }
473
474 let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
475 let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
476
477 if n_cols > 0 && n_cells_dropped >= n_cols {
479 warn!(
480 "QC would drop all {} cells — keeping all (check thresholds)",
481 n_cols
482 );
483 train_keep = vec![true; n_cols];
484 n_cells_dropped = 0;
485 }
486
487 let (feature_keep, n_features_dropped) = match feature_n_cells {
489 Some(n_cells_expr) => {
490 let keep: Vec<bool> = n_cells_expr
491 .iter()
492 .map(|&c| (c as usize) >= cfg.feature_min_cells)
493 .collect();
494 let dropped = keep.iter().filter(|&&k| !k).count();
495 if !keep.is_empty() && dropped >= keep.len() {
496 warn!("QC would drop all features — keeping all");
497 (vec![true; keep.len()], 0)
498 } else {
499 (keep, dropped)
500 }
501 }
502 None => (vec![true; n_rows], 0),
503 };
504
505 QcReport {
506 train_keep,
507 near_empty,
508 feature_keep,
509 n_genes,
510 total_counts,
511 mito_frac,
512 ribo_frac,
513 n_cells_dropped,
514 n_features_dropped,
515 }
516}
517
518pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
521 items
522 .iter()
523 .zip(keep.iter())
524 .filter(|&(_, &k)| k)
525 .map(|(x, _)| x.clone())
526 .collect()
527}
528
529pub fn write_qc_report(
532 path: &str,
533 cell_names: &[Box<str>],
534 report: &QcReport,
535) -> anyhow::Result<()> {
536 use std::fmt::Write as _;
537 let n = report.train_keep.len();
538 anyhow::ensure!(
539 cell_names.len() == n,
540 "write_qc_report: {} names != {} cells",
541 cell_names.len(),
542 n
543 );
544
545 let mut header = String::from("#cell\tn_genes\ttotal_counts");
546 if report.mito_frac.is_some() {
547 header.push_str("\tmito_frac");
548 }
549 if report.ribo_frac.is_some() {
550 header.push_str("\tribo_frac");
551 }
552 header.push_str("\tnear_empty\ttrain_keep");
553
554 let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
555 lines.push(header.into_boxed_str());
556 for c in 0..n {
557 let mut line = String::new();
558 let _ = write!(
559 line,
560 "{}\t{}\t{}",
561 cell_names[c], report.n_genes[c], report.total_counts[c]
562 );
563 if let Some(mf) = report.mito_frac.as_ref() {
564 let _ = write!(line, "\t{}", mf[c]);
565 }
566 if let Some(rf) = report.ribo_frac.as_ref() {
567 let _ = write!(line, "\t{}", rf[c]);
568 }
569 let _ = write!(
570 line,
571 "\t{}\t{}",
572 report.near_empty[c] as u8, report.train_keep[c] as u8
573 );
574 lines.push(line.into_boxed_str());
575 }
576 write_lines(&lines, path)
577}
578
579#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
592#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
593pub struct QcArgs {
594 #[arg(
596 long = "no-qc",
597 default_value_t = false,
598 long_help = "Disable cell quality control entirely and keep every input cell.\n\
599 \n\
600 By DEFAULT (without this flag) cell QC is:\n \
601 - a near-empty nnz floor (--qc-min-cell-nnz), and\n \
602 - MAD-outlier drops on detected features and total counts\n \
603 (--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
604 \n\
605 Empty barcodes are dropped earlier, per input file, by the loader.\n\
606 A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
607 \n\
608 Outputs may therefore have FEWER ROWS than the input.\n\
609 Join by the cell/barcode name column, never by position.\n\
610 \n\
611 Use --qc-report to see exactly what was dropped.\n\
612 For the older near-empty-floor-only gate,\n\
613 pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
614 )]
615 pub no_qc: bool,
616
617 #[arg(long = "qc-mads", default_value_t = 5.0)]
619 pub qc_mads: f32,
620
621 #[arg(
622 long = "qc-min-cell-nnz",
623 default_value_t = 2,
624 help = "Near-empty floor on a cell's detected-feature count",
625 long_help = "Near-empty floor on the detected-feature count.\n\
626 Cells below it are dropped from the per-cell outputs.\n\
627 They are still kept in training."
628 )]
629 pub qc_min_cell_nnz: usize,
630
631 #[arg(
632 long = "qc-min-counts",
633 hide = true,
634 default_value_t = 0.0,
635 help = "Hard floor on total counts per cell",
636 long_help = "Hard floor on total counts per cell.\n\
637 Cells below it are dropped from training. 0 disables the floor."
638 )]
639 pub qc_min_counts: f32,
640
641 #[arg(
642 long = "qc-mito-pattern",
643 hide = true,
644 help = "Regex over feature names selecting mitochondrial genes",
645 long_help = "Regex over feature names selecting mitochondrial genes.\n\
646 It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
647 )]
648 pub qc_mito_pattern: Option<String>,
649
650 #[arg(long = "qc-mito-max-frac", hide = true)]
652 pub qc_mito_max_frac: Option<f32>,
653
654 #[arg(long = "qc-ribo-pattern", hide = true)]
656 pub qc_ribo_pattern: Option<String>,
657
658 #[arg(long = "qc-ribo-max-frac", hide = true)]
660 pub qc_ribo_max_frac: Option<f32>,
661
662 #[arg(
663 long = "qc-feature-min-cells",
664 hide = true,
665 default_value_t = 0,
666 help = "Feature/row QC: drop genes expressed in too few cells",
667 long_help = "Feature/row QC; off by default.\n\
668 It DROPS gene rows expressed in fewer than this many cells.\n\
669 \n\
670 Not every consumer applies it.\n\
671 `bge` does not, since QC there is cell-only.\n\
672 `pinto` does not either: it reads only the cell verdict,\n\
673 so setting this costs a stats pass and changes nothing."
674 )]
675 pub qc_feature_min_cells: usize,
676
677 #[arg(
678 long = "qc-report",
679 help = "Write a per-cell QC table (.tsv)",
680 long_help = "Write a per-cell QC table, as .tsv.\n\
681 It carries the metrics plus near_empty and train_keep flags.\n\
682 You can then see exactly which cells were dropped."
683 )]
684 pub qc_report: Option<Box<str>>,
685
686 #[arg(
687 long = "qc-histogram",
688 hide = true,
689 default_value_t = false,
690 help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
691 long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
692 The suggested trough cutoff is marked.\n\
693 It is the same summary as `data-beans squeeze --show-histogram`.\n\
694 \n\
695 This is purely diagnostic. The cutoff is shown, not applied.\n\
696 The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
697 Use the histogram to pick --qc-min-cell-nnz by hand."
698 )]
699 pub qc_histogram: bool,
700
701 #[arg(
702 long = "qc-mad-on-genes",
703 hide = true,
704 default_value_t = true,
705 help = "MAD-outlier drop on the per-cell detected-feature count",
706 long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
707 \n\
708 ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
709 with no way to enable them. That also made --qc-mads inert.\n\
710 Only a set --qc-mito-pattern revived it.\n\
711 \n\
712 Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
713 That is the conservative near-empty nnz gate alone."
714 )]
715 pub qc_mad_on_genes: bool,
716
717 #[arg(
718 long = "qc-mad-on-counts",
719 hide = true,
720 default_value_t = true,
721 help = "MAD-outlier drop on per-cell total counts",
722 long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
723 ON by default; see --qc-mad-on-genes."
724 )]
725 pub qc_mad_on_counts: bool,
726
727 #[arg(
728 long = "qc-auto-cutoff",
729 hide = true,
730 default_value_t = false,
731 help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
732 long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
733 It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
734 Without this flag it is only reported, via --qc-histogram.\n\
735 \n\
736 OFF by default. The intended gate is the conservative near-empty floor,\n\
737 plus the model's own empty-call.\n\
738 This flag was referenced in the docs before it existed."
739 )]
740 pub qc_auto_cutoff: bool,
741}
742
743impl QcArgs {
744 pub fn to_config(&self) -> Option<QcConfig> {
746 (!self.no_qc).then(|| QcConfig {
747 n_mads: self.qc_mads,
748 min_cell_nnz: self.qc_min_cell_nnz,
749 min_counts_per_cell: self.qc_min_counts,
750 mito_pattern: self.qc_mito_pattern.clone(),
751 mito_max_frac: self.qc_mito_max_frac,
752 ribo_pattern: self.qc_ribo_pattern.clone(),
753 ribo_max_frac: self.qc_ribo_max_frac,
754 mad_on_n_genes: self.qc_mad_on_genes,
760 mad_on_counts: self.qc_mad_on_counts,
761 mad_on_mito: self.qc_mito_pattern.is_some(),
762 feature_min_cells: self.qc_feature_min_cells,
763 drop_outliers: true,
764 auto_cell_cutoff: self.qc_auto_cutoff,
768 qc_histogram: self.qc_histogram,
769 })
770 }
771}
772
773#[cfg(test)]
774mod qc_tests {
775 use super::*;
776
777 #[test]
778 fn robust_lower_flags_low_outlier() {
779 let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
780 let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
781 assert!(!keep[5], "the value 1.0 should be a lower outlier");
782 assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
783 let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
785 assert!(keep_up[5], "lower outlier kept under Tail::Upper");
786 }
787
788 #[test]
789 fn robust_uniform_keeps_all() {
790 let v = vec![7.0; 20];
791 let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
792 assert!(keep.iter().all(|&k| k));
793 }
794
795 #[test]
796 fn auto_cutoff_train_drops_ambient() {
797 let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
801 let cfg = QcConfig {
802 auto_cell_cutoff: true,
803 qc_histogram: false,
804 drop_outliers: true,
805 mad_on_n_genes: false,
806 mad_on_counts: false,
807 mad_on_mito: false,
808 min_cell_nnz: 0,
809 ..QcConfig::default()
810 };
811 let report = qc_from_metrics(
812 QcMetrics {
813 n_genes: n_genes.clone(),
814 total_counts: n_genes,
815 mito_frac: None,
816 ribo_frac: None,
817 feature_n_cells: None,
818 n_rows: 0,
819 },
820 &cfg,
821 None,
822 );
823 assert_eq!(
824 report.train_keep,
825 [vec![false; 30], vec![true; 30]].concat()
826 );
827 assert_eq!(report.n_cells_dropped, 30);
828 }
829
830 #[test]
831 fn robust_consider_excludes_contaminants_from_band() {
832 let mut v = vec![0.0; 12];
836 v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
837 let mut consider = vec![false; 12];
838 consider.extend([true; 6]);
839 let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
840 assert!(
841 !keep[17],
842 "40 is a lower outlier of the real cluster (~100)"
843 );
844 let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
846 assert!(
847 keep_naive[17],
848 "naive band (contaminated by zeros) keeps 40"
849 );
850 }
851
852 #[test]
853 fn output_keep_idx_skips_dropped_and_near_empty() {
854 let report = QcReport {
856 train_keep: vec![true, true, false, true],
857 near_empty: vec![false, true, false, false],
858 feature_keep: vec![],
859 n_genes: vec![],
860 total_counts: vec![],
861 mito_frac: None,
862 ribo_frac: None,
863 n_cells_dropped: 1,
864 n_features_dropped: 0,
865 };
866 assert_eq!(report.output_keep_idx(), vec![0, 2]);
869 }
870}
871
872#[cfg(test)]
873#[path = "qc_lib_tests.rs"]
874mod tests;