use crate::qc::{collect_column_stat_across_vec, collect_row_stat_across_vec};
use crate::sparse_io_stack::SparseIoStack;
use crate::sparse_io_vector::SparseIoVec;
use legume_numeric::matrix::common_io::write_lines;
use legume_numeric::matrix::traits::RunningStatOps;
use log::warn;
use regex::Regex;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tail {
Lower,
Upper,
Both,
}
#[derive(Clone, Debug)]
pub struct QcConfig {
pub n_mads: f32,
pub min_cell_nnz: usize,
pub min_counts_per_cell: f32,
pub mito_pattern: Option<String>,
pub mito_max_frac: Option<f32>,
pub ribo_pattern: Option<String>,
pub ribo_max_frac: Option<f32>,
pub mad_on_n_genes: bool,
pub mad_on_counts: bool,
pub mad_on_mito: bool,
pub feature_min_cells: usize,
pub drop_outliers: bool,
pub auto_cell_cutoff: bool,
pub qc_histogram: bool,
}
impl Default for QcConfig {
fn default() -> Self {
Self {
n_mads: 5.0,
min_cell_nnz: 2,
min_counts_per_cell: 0.0,
mito_pattern: None,
mito_max_frac: None,
ribo_pattern: None,
ribo_max_frac: None,
mad_on_n_genes: true,
mad_on_counts: true,
mad_on_mito: false,
feature_min_cells: 0,
drop_outliers: true,
auto_cell_cutoff: false,
qc_histogram: false,
}
}
}
fn median_in_place(xs: &mut [f32]) -> f32 {
let mid = xs.len() / 2;
xs.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
xs[mid]
}
pub fn robust_outlier_keep(
values: &[f32],
n_mads: f32,
tail: Tail,
log1p: bool,
consider: Option<&[bool]>,
) -> Vec<bool> {
let n = values.len();
if n == 0 {
return vec![];
}
let xform = |v: f32| if log1p { v.max(0.0).ln_1p() } else { v };
let mut xs: Vec<f32> = values
.iter()
.enumerate()
.filter(|(i, _)| consider.is_none_or(|c| c[*i]))
.map(|(_, &v)| xform(v))
.filter(|v| v.is_finite())
.collect();
if xs.is_empty() {
return vec![true; n];
}
let median = median_in_place(&mut xs);
let mut dev: Vec<f32> = xs.iter().map(|&v| (v - median).abs()).collect();
let mad = (median_in_place(&mut dev) * 1.4826).max(1e-8);
let lo = median - n_mads * mad;
let hi = median + n_mads * mad;
values
.iter()
.map(|&raw| {
let v = xform(raw);
if !v.is_finite() {
return false;
}
match tail {
Tail::Lower => v >= lo,
Tail::Upper => v <= hi,
Tail::Both => v >= lo && v <= hi,
}
})
.collect()
}
pub fn resolve_rows_by_regex(row_names: &[Box<str>], pattern: &str) -> anyhow::Result<Vec<usize>> {
let re = Regex::new(pattern)?;
Ok(row_names
.iter()
.enumerate()
.filter(|(_, name)| re.is_match(name))
.map(|(i, _)| i)
.collect())
}
pub struct QcReport {
pub train_keep: Vec<bool>,
pub near_empty: Vec<bool>,
pub feature_keep: Vec<bool>,
pub n_genes: Vec<f32>,
pub total_counts: Vec<f32>,
pub mito_frac: Option<Vec<f32>>,
pub ribo_frac: Option<Vec<f32>>,
pub n_cells_dropped: usize,
pub n_features_dropped: usize,
}
impl QcReport {
pub fn output_keep_idx(&self) -> Vec<usize> {
let mut idx = Vec::new();
let mut new_pos = 0usize;
for c in 0..self.train_keep.len() {
if !self.train_keep[c] {
continue; }
if !self.near_empty[c] {
idx.push(new_pos); }
new_pos += 1;
}
idx
}
pub fn emit_idx_unmasked(&self) -> Vec<usize> {
(0..self.train_keep.len())
.filter(|&c| self.train_keep[c] && !self.near_empty[c])
.collect()
}
}
pub fn compute_qc(
data: &SparseIoVec,
cfg: &QcConfig,
block_size: Option<usize>,
) -> anyhow::Result<QcReport> {
compute_qc_exempting(data, cfg, block_size, None)
}
pub fn compute_qc_exempting(
data: &SparseIoVec,
cfg: &QcConfig,
block_size: Option<usize>,
exempt: Option<&[bool]>,
) -> anyhow::Result<QcReport> {
let row_names = data.row_names()?;
let col_stat = collect_column_stat_across_vec(data, None, block_size)?;
let n_genes = col_stat.count_positives();
let total_counts = col_stat.sum();
let frac_for = |pattern: &Option<String>| -> anyhow::Result<Option<Vec<f32>>> {
let Some(pat) = pattern else {
return Ok(None);
};
let rows = resolve_rows_by_regex(&row_names, pat)?;
if rows.is_empty() {
warn!(
"QC: pattern `{}` matched no features — metric disabled",
pat
);
return Ok(None);
}
let sub_tot = collect_column_stat_across_vec(data, Some(&rows), block_size)?.sum();
let frac = sub_tot
.iter()
.zip(total_counts.iter())
.map(|(&s, &t)| if t > 0.0 { s / t } else { 0.0 })
.collect::<Vec<f32>>();
Ok(Some(frac))
};
let mito_frac = frac_for(&cfg.mito_pattern)?;
let ribo_frac = frac_for(&cfg.ribo_pattern)?;
let feature_n_cells = if cfg.feature_min_cells > 0 {
Some(collect_row_stat_across_vec(data, block_size)?.count_positives())
} else {
None
};
Ok(qc_from_metrics(
QcMetrics {
n_genes,
total_counts,
mito_frac,
ribo_frac,
feature_n_cells,
n_rows: data.num_rows(),
},
cfg,
exempt,
))
}
pub fn compute_qc_stack(
stack: &SparseIoStack,
cfg: &QcConfig,
block_size: Option<usize>,
) -> anyhow::Result<QcReport> {
let n_cols = stack.num_columns()?;
let mut n_genes = vec![0f32; n_cols];
let mut total_counts = vec![0f32; n_cols];
for member in stack.stack.iter() {
let cs = collect_column_stat_across_vec(member, None, block_size)?;
let ng = cs.count_positives();
let ct = cs.sum();
for c in 0..n_cols {
n_genes[c] += ng[c];
total_counts[c] += ct[c];
}
}
if cfg.mito_pattern.is_some() || cfg.ribo_pattern.is_some() || cfg.feature_min_cells > 0 {
warn!("QC: mito/ribo/feature thresholds are ignored for stacked (multi-modal) data");
}
Ok(qc_from_metrics(
QcMetrics {
n_genes,
total_counts,
mito_frac: None,
ribo_frac: None,
feature_n_cells: None,
n_rows: 0, },
cfg,
None,
))
}
struct QcMetrics {
n_genes: Vec<f32>,
total_counts: Vec<f32>,
mito_frac: Option<Vec<f32>>,
ribo_frac: Option<Vec<f32>>,
feature_n_cells: Option<Vec<f32>>,
n_rows: usize,
}
fn qc_from_metrics(m: QcMetrics, cfg: &QcConfig, exempt: Option<&[bool]>) -> QcReport {
let QcMetrics {
n_genes,
total_counts,
mito_frac,
ribo_frac,
feature_n_cells,
n_rows,
} = m;
let n_cols = n_genes.len();
let is_exempt = |c: usize| exempt.is_some_and(|e| e[c]);
let near_empty: Vec<bool> = n_genes
.iter()
.enumerate()
.map(|(c, &g)| !is_exempt(c) && (g as usize) < cfg.min_cell_nnz)
.collect();
let not_near_empty: Vec<bool> = near_empty
.iter()
.enumerate()
.map(|(c, &e)| !e && !is_exempt(c))
.collect();
let consider = Some(not_near_empty.as_slice());
let mut outlier = vec![false; n_cols];
if cfg.drop_outliers {
let mut bands: Vec<Vec<bool>> = Vec::new();
if cfg.mad_on_n_genes {
bands.push(robust_outlier_keep(
&n_genes,
cfg.n_mads,
Tail::Lower,
true,
consider,
));
}
if cfg.mad_on_counts {
bands.push(robust_outlier_keep(
&total_counts,
cfg.n_mads,
Tail::Lower,
true,
consider,
));
}
if cfg.mad_on_mito {
if let Some(mf) = mito_frac.as_ref() {
bands.push(robust_outlier_keep(
mf,
cfg.n_mads,
Tail::Upper,
false,
consider,
));
}
}
for c in 0..n_cols {
if near_empty[c] || is_exempt(c) {
continue; }
let mut fail = total_counts[c] < cfg.min_counts_per_cell;
if let (Some(mf), Some(cap)) = (mito_frac.as_ref(), cfg.mito_max_frac) {
fail |= mf[c] > cap;
}
if let (Some(rf), Some(cap)) = (ribo_frac.as_ref(), cfg.ribo_max_frac) {
fail |= rf[c] > cap;
}
for band in bands.iter() {
if !band[c] {
fail = true;
break;
}
}
outlier[c] = fail;
}
if cfg.auto_cell_cutoff || cfg.qc_histogram {
let suggested = crate::qc::suggest_nnz_cutoff(&n_genes);
let shown = suggested.unwrap_or(cfg.min_cell_nnz);
crate::qc::print_nnz_summary("Cell", "nnz", &n_genes, shown, suggested);
if cfg.auto_cell_cutoff {
if let Some(cut) = suggested {
for (c, &g) in n_genes.iter().enumerate() {
if (g as usize) < cut {
outlier[c] = true;
}
}
}
}
}
}
let mut train_keep: Vec<bool> = outlier.iter().map(|&o| !o).collect();
let mut n_cells_dropped = outlier.iter().filter(|&&o| o).count();
if n_cols > 0 && n_cells_dropped >= n_cols {
warn!(
"QC would drop all {} cells — keeping all (check thresholds)",
n_cols
);
train_keep = vec![true; n_cols];
n_cells_dropped = 0;
}
let (feature_keep, n_features_dropped) = match feature_n_cells {
Some(n_cells_expr) => {
let keep: Vec<bool> = n_cells_expr
.iter()
.map(|&c| (c as usize) >= cfg.feature_min_cells)
.collect();
let dropped = keep.iter().filter(|&&k| !k).count();
if !keep.is_empty() && dropped >= keep.len() {
warn!("QC would drop all features — keeping all");
(vec![true; keep.len()], 0)
} else {
(keep, dropped)
}
}
None => (vec![true; n_rows], 0),
};
QcReport {
train_keep,
near_empty,
feature_keep,
n_genes,
total_counts,
mito_frac,
ribo_frac,
n_cells_dropped,
n_features_dropped,
}
}
pub fn filter_by_keep<T: Clone>(items: &[T], keep: &[bool]) -> Vec<T> {
items
.iter()
.zip(keep.iter())
.filter(|&(_, &k)| k)
.map(|(x, _)| x.clone())
.collect()
}
pub fn write_qc_report(
path: &str,
cell_names: &[Box<str>],
report: &QcReport,
) -> anyhow::Result<()> {
use std::fmt::Write as _;
let n = report.train_keep.len();
anyhow::ensure!(
cell_names.len() == n,
"write_qc_report: {} names != {} cells",
cell_names.len(),
n
);
let mut header = String::from("#cell\tn_genes\ttotal_counts");
if report.mito_frac.is_some() {
header.push_str("\tmito_frac");
}
if report.ribo_frac.is_some() {
header.push_str("\tribo_frac");
}
header.push_str("\tnear_empty\ttrain_keep");
let mut lines: Vec<Box<str>> = Vec::with_capacity(n + 1);
lines.push(header.into_boxed_str());
for c in 0..n {
let mut line = String::new();
let _ = write!(
line,
"{}\t{}\t{}",
cell_names[c], report.n_genes[c], report.total_counts[c]
);
if let Some(mf) = report.mito_frac.as_ref() {
let _ = write!(line, "\t{}", mf[c]);
}
if let Some(rf) = report.ribo_frac.as_ref() {
let _ = write!(line, "\t{}", rf[c]);
}
let _ = write!(
line,
"\t{}\t{}",
report.near_empty[c] as u8, report.train_keep[c] as u8
);
lines.push(line.into_boxed_str());
}
write_lines(&lines, path)
}
#[derive(clap::Args, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
pub struct QcArgs {
#[arg(
long = "no-qc",
default_value_t = false,
long_help = "Disable cell quality control entirely and keep every input cell.\n\
\n\
By DEFAULT (without this flag) cell QC is:\n \
- a near-empty nnz floor (--qc-min-cell-nnz), and\n \
- MAD-outlier drops on detected features and total counts\n \
(--qc-mad-on-genes / --qc-mad-on-counts, band --qc-mads).\n\
\n\
Empty barcodes are dropped earlier, per input file, by the loader.\n\
A pooled trough cut on top of that stays OFF unless --qc-auto-cutoff.\n\
\n\
Outputs may therefore have FEWER ROWS than the input.\n\
Join by the cell/barcode name column, never by position.\n\
\n\
Use --qc-report to see exactly what was dropped.\n\
For the older near-empty-floor-only gate,\n\
pass `--qc-mad-on-genes=false --qc-mad-on-counts=false`."
)]
pub no_qc: bool,
#[arg(long = "qc-mads", default_value_t = 5.0)]
pub qc_mads: f32,
#[arg(
long = "qc-min-cell-nnz",
default_value_t = 2,
help = "Near-empty floor on a cell's detected-feature count",
long_help = "Near-empty floor on the detected-feature count.\n\
Cells below it are dropped from the per-cell outputs.\n\
They are still kept in training."
)]
pub qc_min_cell_nnz: usize,
#[arg(
long = "qc-min-counts",
hide = true,
default_value_t = 0.0,
help = "Hard floor on total counts per cell",
long_help = "Hard floor on total counts per cell.\n\
Cells below it are dropped from training. 0 disables the floor."
)]
pub qc_min_counts: f32,
#[arg(
long = "qc-mito-pattern",
hide = true,
help = "Regex over feature names selecting mitochondrial genes",
long_help = "Regex over feature names selecting mitochondrial genes.\n\
It enables the mito-fraction outlier metric. An example is `(?i)^MT-`."
)]
pub qc_mito_pattern: Option<String>,
#[arg(long = "qc-mito-max-frac", hide = true)]
pub qc_mito_max_frac: Option<f32>,
#[arg(long = "qc-ribo-pattern", hide = true)]
pub qc_ribo_pattern: Option<String>,
#[arg(long = "qc-ribo-max-frac", hide = true)]
pub qc_ribo_max_frac: Option<f32>,
#[arg(
long = "qc-feature-min-cells",
hide = true,
default_value_t = 0,
help = "Feature/row QC: drop genes expressed in too few cells",
long_help = "Feature/row QC; off by default.\n\
It DROPS gene rows expressed in fewer than this many cells.\n\
\n\
Not every consumer applies it.\n\
`bge` does not, since QC there is cell-only.\n\
`pinto` does not either: it reads only the cell verdict,\n\
so setting this costs a stats pass and changes nothing."
)]
pub qc_feature_min_cells: usize,
#[arg(
long = "qc-report",
help = "Write a per-cell QC table (.tsv)",
long_help = "Write a per-cell QC table, as .tsv.\n\
It carries the metrics plus near_empty and train_keep flags.\n\
You can then see exactly which cells were dropped."
)]
pub qc_report: Option<Box<str>>,
#[arg(
long = "qc-histogram",
hide = true,
default_value_t = false,
help = "Print the per-cell nnz histogram + the (diagnostic) suggested trough cutoff",
long_help = "Print an ASCII histogram of the per-cell nnz distribution.\n\
The suggested trough cutoff is marked.\n\
It is the same summary as `data-beans squeeze --show-histogram`.\n\
\n\
This is purely diagnostic. The cutoff is shown, not applied.\n\
The upfront gate is the conservative --qc-min-cell-nnz floor.\n\
Use the histogram to pick --qc-min-cell-nnz by hand."
)]
pub qc_histogram: bool,
#[arg(
long = "qc-mad-on-genes",
hide = true,
default_value_t = true,
help = "MAD-outlier drop on the per-cell detected-feature count",
long_help = "Drop cells whose detected-feature count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
\n\
ON by default. This and --qc-mad-on-counts were previously hardcoded OFF,\n\
with no way to enable them. That also made --qc-mads inert.\n\
Only a set --qc-mito-pattern revived it.\n\
\n\
Pass `--qc-mad-on-genes=false` for the old behaviour.\n\
That is the conservative near-empty nnz gate alone."
)]
pub qc_mad_on_genes: bool,
#[arg(
long = "qc-mad-on-counts",
hide = true,
default_value_t = true,
help = "MAD-outlier drop on per-cell total counts",
long_help = "Drop cells whose total count falls outside `median +/- --qc-mads * MAD * 1.4826`.\n\
ON by default; see --qc-mad-on-genes."
)]
pub qc_mad_on_counts: bool,
#[arg(
long = "qc-auto-cutoff",
hide = true,
default_value_t = false,
help = "Apply the nnz trough cell-calling cutoff on the pooled cell axis",
long_help = "Apply the ambient/cell trough cutoff as a hard cell call.\n\
It runs on the pooled per-cell nnz distribution, after the loader's per-file gate.\n\
Without this flag it is only reported, via --qc-histogram.\n\
\n\
OFF by default. The intended gate is the conservative near-empty floor,\n\
plus the model's own empty-call.\n\
This flag was referenced in the docs before it existed."
)]
pub qc_auto_cutoff: bool,
}
impl QcArgs {
pub fn to_config(&self) -> Option<QcConfig> {
(!self.no_qc).then(|| QcConfig {
n_mads: self.qc_mads,
min_cell_nnz: self.qc_min_cell_nnz,
min_counts_per_cell: self.qc_min_counts,
mito_pattern: self.qc_mito_pattern.clone(),
mito_max_frac: self.qc_mito_max_frac,
ribo_pattern: self.qc_ribo_pattern.clone(),
ribo_max_frac: self.qc_ribo_max_frac,
mad_on_n_genes: self.qc_mad_on_genes,
mad_on_counts: self.qc_mad_on_counts,
mad_on_mito: self.qc_mito_pattern.is_some(),
feature_min_cells: self.qc_feature_min_cells,
drop_outliers: true,
auto_cell_cutoff: self.qc_auto_cutoff,
qc_histogram: self.qc_histogram,
})
}
}
#[cfg(test)]
mod qc_tests {
use super::*;
#[test]
fn robust_lower_flags_low_outlier() {
let v = vec![100.0, 110.0, 90.0, 105.0, 95.0, 1.0];
let keep = robust_outlier_keep(&v, 3.0, Tail::Lower, true, None);
assert!(!keep[5], "the value 1.0 should be a lower outlier");
assert!(keep[..5].iter().all(|&k| k), "the bulk should be kept");
let keep_up = robust_outlier_keep(&v, 3.0, Tail::Upper, true, None);
assert!(keep_up[5], "lower outlier kept under Tail::Upper");
}
#[test]
fn robust_uniform_keeps_all() {
let v = vec![7.0; 20];
let keep = robust_outlier_keep(&v, 5.0, Tail::Both, true, None);
assert!(keep.iter().all(|&k| k));
}
#[test]
fn auto_cutoff_train_drops_ambient() {
let n_genes: Vec<f32> = [vec![2.0; 30], vec![100.0; 30]].concat();
let cfg = QcConfig {
auto_cell_cutoff: true,
qc_histogram: false,
drop_outliers: true,
mad_on_n_genes: false,
mad_on_counts: false,
mad_on_mito: false,
min_cell_nnz: 0,
..QcConfig::default()
};
let report = qc_from_metrics(
QcMetrics {
n_genes: n_genes.clone(),
total_counts: n_genes,
mito_frac: None,
ribo_frac: None,
feature_n_cells: None,
n_rows: 0,
},
&cfg,
None,
);
assert_eq!(
report.train_keep,
[vec![false; 30], vec![true; 30]].concat()
);
assert_eq!(report.n_cells_dropped, 30);
}
#[test]
fn robust_consider_excludes_contaminants_from_band() {
let mut v = vec![0.0; 12];
v.extend([100.0, 102.0, 98.0, 101.0, 99.0, 40.0]);
let mut consider = vec![false; 12];
consider.extend([true; 6]);
let keep = robust_outlier_keep(&v, 2.0, Tail::Lower, true, Some(&consider));
assert!(
!keep[17],
"40 is a lower outlier of the real cluster (~100)"
);
let keep_naive = robust_outlier_keep(&v, 2.0, Tail::Lower, true, None);
assert!(
keep_naive[17],
"naive band (contaminated by zeros) keeps 40"
);
}
#[test]
fn output_keep_idx_skips_dropped_and_near_empty() {
let report = QcReport {
train_keep: vec![true, true, false, true],
near_empty: vec![false, true, false, false],
feature_keep: vec![],
n_genes: vec![],
total_counts: vec![],
mito_frac: None,
ribo_frac: None,
n_cells_dropped: 1,
n_features_dropped: 0,
};
assert_eq!(report.output_keep_idx(), vec![0, 2]);
}
}
#[cfg(test)]
#[path = "qc_lib_tests.rs"]
mod tests;