use rayon::prelude::*;
use std::time::Instant;
use crate::prelude::*;
use crate::sc_analysis::fast_ranking::{
GeneGroupStats, NO_GROUP, append_cell_chunks, csr_rank_sum_stats_two_groups, rank_cell_into,
};
use bixverse_core::core::math::stats::{p_adjust_fdr, z_scores_to_pval};
#[derive(Clone, Debug)]
pub struct DgeMannWhitneyRes {
pub lfc: Vec<f32>,
pub auroc: Vec<f32>,
pub prop1: Vec<f32>,
pub prop2: Vec<f32>,
pub z_scores: Vec<f64>,
pub p_vals: Vec<f64>,
pub fdr: Vec<f64>,
pub genes_to_keep: Vec<bool>,
}
#[derive(Clone, Debug)]
pub struct DgeAurocMultiRes {
pub auroc: Vec<Vec<f32>>,
pub lfc: Vec<Vec<f32>>,
pub prop_other: Vec<Vec<f32>>,
pub z_scores: Vec<Vec<f64>>,
pub p_vals: Vec<Vec<f64>>,
pub fdr: Vec<Vec<f64>>,
pub prop_ref: Vec<f32>,
pub median_auroc: Vec<f32>,
pub min_auroc: Vec<f32>,
pub mean_auroc: Vec<f32>,
pub max_auroc: Vec<f32>,
pub worst_comparison: Vec<usize>,
pub min_rank: Vec<usize>,
pub simes_p: Vec<f64>,
pub simes_fdr: Vec<f64>,
pub max_p: Vec<f64>,
pub max_p_fdr: Vec<f64>,
pub genes_to_keep: Vec<bool>,
}
fn calculate_avg_exp_prop(cells: &[CsrCellChunk], num_genes: usize) -> (Vec<f64>, Vec<f32>) {
let (sum_exp, count_exp) = cells
.par_iter()
.fold(
|| (vec![0.0f64; num_genes], vec![0usize; num_genes]),
|(mut sum, mut count), cell| {
for (&gene_idx, &norm_val) in cell.indices.iter().zip(cell.data_norm.iter()) {
sum[gene_idx as usize] += norm_val.to_f32() as f64;
count[gene_idx as usize] += 1;
}
(sum, count)
},
)
.reduce(
|| (vec![0.0f64; num_genes], vec![0usize; num_genes]),
|(mut sum_a, mut count_a), (sum_b, count_b)| {
for (a, b) in sum_a.iter_mut().zip(&sum_b) {
*a += b;
}
for (a, b) in count_a.iter_mut().zip(&count_b) {
*a += b;
}
(sum_a, count_a)
},
);
let total_cells = cells.len() as f32;
let avg_exp: Vec<f64> = sum_exp
.iter()
.map(|&sum| sum / cells.len() as f64)
.collect();
let prop_exp: Vec<f32> = count_exp
.iter()
.map(|&count| count as f32 / total_cells)
.collect();
(avg_exp, prop_exp)
}
fn mann_whitney_stats(u1: f64, tie_term: f64, n1: usize, n2: usize) -> (f32, f64) {
if n1 == 0 || n2 == 0 {
return (f32::NAN, 0.0);
}
let n1 = n1 as f64;
let n2 = n2 as f64;
let n = n1 + n2;
let auroc = (u1 / (n1 * n2)) as f32;
if n < 2.0 {
return (auroc, 0.0);
}
let variance = (n1 * n2 / 12.0) * ((n + 1.0) - tie_term / (n * (n - 1.0)));
let z = if variance <= 0.0 {
0.0
} else {
(u1 - n1 * n2 / 2.0) / variance.sqrt()
};
(auroc, z)
}
pub fn calculate_dge_grps_mann_whitney<S: SingleCellReading>(
reader: &S,
grp_1_indices: &[usize],
grp_2_indices: &[usize],
min_proportion: f32,
alternative: &str,
verbose: usize,
) -> Result<DgeMannWhitneyRes, ScErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_read = Instant::now();
let no_genes = reader.get_header().total_genes;
let mut cell_chunks_1: Vec<CsrCellChunk> = reader.read_cells_parallel(grp_1_indices)?;
let mut cell_chunks_2: Vec<CsrCellChunk> = reader.read_cells_parallel(grp_2_indices)?;
let end_read = start_read.elapsed();
if verbosity.normal_verbosity() {
println!("Loaded in data: {:.2?}", end_read);
}
let (avg_exp_1, prop_1) = calculate_avg_exp_prop(&cell_chunks_1, no_genes);
let (avg_exp_2, prop_2) = calculate_avg_exp_prop(&cell_chunks_2, no_genes);
let genes_to_keep: Vec<bool> = prop_1
.iter()
.zip(prop_2.iter())
.map(|(&p1, &p2)| p1 >= min_proportion || p2 >= min_proportion)
.collect();
let no_genes_new = genes_to_keep.iter().filter(|&&x| x).count();
cell_chunks_1
.par_iter_mut()
.for_each(|cell| cell.filter_genes(&genes_to_keep));
cell_chunks_2
.par_iter_mut()
.for_each(|cell| cell.filter_genes(&genes_to_keep));
let genes_kept: Vec<usize> = genes_to_keep
.iter()
.enumerate()
.filter_map(|(i, &keep)| if keep { Some(i) } else { None })
.collect();
let n1 = cell_chunks_1.len();
let n2 = cell_chunks_2.len();
let start_ranking = Instant::now();
let mut indptr = vec![0_usize];
let mut indices: Vec<u32> = Vec::new();
let mut data: Vec<F16> = Vec::new();
append_cell_chunks(&cell_chunks_1, &mut indptr, &mut indices, &mut data);
append_cell_chunks(&cell_chunks_2, &mut indptr, &mut indices, &mut data);
drop(cell_chunks_1);
drop(cell_chunks_2);
let rank_stats =
csr_rank_sum_stats_two_groups(&indptr, &indices, &data, n1, n1 + n2, no_genes_new);
let end_ranking = start_ranking.elapsed();
if verbosity.normal_verbosity() {
println!("Finished the ranking across cells: {:.2?}", end_ranking);
}
let start_calculations = Instant::now();
let res: Vec<(f32, f32, f32, f32, f64)> = genes_kept
.par_iter()
.enumerate()
.map(|(new_idx, &original_idx)| {
let log_fc = (avg_exp_1[original_idx] - avg_exp_2[original_idx]) as f32;
let prop1 = prop_1[original_idx];
let prop2 = prop_2[original_idx];
let (rank_sum, tie_term) = rank_stats[new_idx];
let u1 = rank_sum - (n1 * (n1 + 1)) as f64 / 2.0;
let (auroc, z) = mann_whitney_stats(u1, tie_term, n1, n2);
(log_fc, auroc, prop1, prop2, z)
})
.collect();
let mut log_fc = Vec::with_capacity(res.len());
let mut auroc = Vec::with_capacity(res.len());
let mut prop1 = Vec::with_capacity(res.len());
let mut prop2 = Vec::with_capacity(res.len());
let mut z_scores = Vec::with_capacity(res.len());
for (log_fc_i, auroc_i, prop1_i, prop2_i, z_i) in res {
log_fc.push(log_fc_i);
auroc.push(auroc_i);
prop1.push(prop1_i);
prop2.push(prop2_i);
z_scores.push(z_i);
}
let p_vals = z_scores_to_pval(&z_scores, alternative);
let fdr = p_adjust_fdr(&p_vals);
let end_calculations = start_calculations.elapsed();
if verbosity.normal_verbosity() {
println!("Finished DGE calculations: {:.2?}", end_calculations);
}
Ok(DgeMannWhitneyRes {
lfc: log_fc,
auroc,
prop1,
prop2,
z_scores,
p_vals,
fdr,
genes_to_keep,
})
}
struct GeneSummary {
median_auroc: f32,
min_auroc: f32,
mean_auroc: f32,
max_auroc: f32,
worst_comparison: usize,
min_rank: usize,
simes_p: f64,
max_p: f64,
}
fn median_in_place(values: &mut [f32]) -> f32 {
let n = values.len();
if n == 0 {
return f32::NAN;
}
let mid = n / 2;
let (lo, &mut upper, _) = values.select_nth_unstable_by(mid, f32::total_cmp);
if n % 2 == 1 {
upper
} else {
let lower = lo.iter().copied().fold(f32::NEG_INFINITY, f32::max);
(lower + upper) / 2.0
}
}
fn competition_ranks_desc(values: &[f32]) -> Vec<usize> {
let n = values.len();
let mut order: Vec<usize> = (0..n).collect();
order.sort_unstable_by(|&a, &b| values[b].total_cmp(&values[a]));
let mut ranks = vec![0_usize; n];
let mut i = 0;
while i < n {
let start = i;
while i < n && values[order[i]] == values[order[start]] {
i += 1;
}
for &idx in &order[start..i] {
ranks[idx] = start + 1;
}
}
ranks
}
fn simes_combine(p_vals: &mut [f64]) -> f64 {
let m = p_vals.len();
if m == 0 {
return f64::NAN;
}
p_vals.sort_unstable_by(f64::total_cmp);
let combined = p_vals
.iter()
.enumerate()
.map(|(k, &p)| (m as f64 / (k + 1) as f64) * p)
.fold(f64::INFINITY, f64::min);
combined.min(1.0)
}
fn build_auroc_multi_res(
auroc: Vec<Vec<f32>>,
lfc: Vec<Vec<f32>>,
z_scores: Vec<Vec<f64>>,
prop_ref: Vec<f32>,
prop_other: Vec<Vec<f32>>,
alternative: &str,
genes_to_keep: Vec<bool>,
) -> DgeAurocMultiRes {
let n_comparisons = auroc.len();
let no_genes = prop_ref.len();
if no_genes == 0 {
return empty_auroc_multi_res(n_comparisons, genes_to_keep);
}
let p_vals: Vec<Vec<f64>> = z_scores
.iter()
.map(|z| z_scores_to_pval(z, alternative))
.collect();
let fdr: Vec<Vec<f64>> = p_vals.iter().map(|p| p_adjust_fdr(p)).collect();
let per_comparison_ranks: Vec<Vec<usize>> = auroc
.par_iter()
.map(|a| competition_ranks_desc(a))
.collect();
let summaries: Vec<GeneSummary> = (0..no_genes)
.into_par_iter()
.map(|gene| {
let mut gene_auroc: Vec<f32> = auroc.iter().map(|a| a[gene]).collect();
let mut gene_p: Vec<f64> = p_vals.iter().map(|p| p[gene]).collect();
let (worst_comparison, &min_auroc) = gene_auroc
.iter()
.enumerate()
.min_by(|a, b| a.1.total_cmp(b.1))
.expect("at least one comparison group exists, checked on entry");
let max_auroc = gene_auroc.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mean_auroc =
gene_auroc.iter().map(|&a| a as f64).sum::<f64>() / n_comparisons as f64;
let min_rank = per_comparison_ranks
.iter()
.map(|r| r[gene])
.min()
.expect("at least one comparison group exists, checked on entry");
let max_p = gene_p.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let simes_p = simes_combine(&mut gene_p);
let median_auroc = median_in_place(&mut gene_auroc);
GeneSummary {
median_auroc,
min_auroc,
mean_auroc: mean_auroc as f32,
max_auroc,
worst_comparison,
min_rank,
simes_p,
max_p,
}
})
.collect();
let mut median_auroc = Vec::with_capacity(no_genes);
let mut min_auroc = Vec::with_capacity(no_genes);
let mut mean_auroc = Vec::with_capacity(no_genes);
let mut max_auroc = Vec::with_capacity(no_genes);
let mut worst_comparison = Vec::with_capacity(no_genes);
let mut min_rank = Vec::with_capacity(no_genes);
let mut simes_p = Vec::with_capacity(no_genes);
let mut max_p = Vec::with_capacity(no_genes);
for summary in summaries {
median_auroc.push(summary.median_auroc);
min_auroc.push(summary.min_auroc);
mean_auroc.push(summary.mean_auroc);
max_auroc.push(summary.max_auroc);
worst_comparison.push(summary.worst_comparison);
min_rank.push(summary.min_rank);
simes_p.push(summary.simes_p);
max_p.push(summary.max_p);
}
let simes_fdr = p_adjust_fdr(&simes_p);
let max_p_fdr = p_adjust_fdr(&max_p);
DgeAurocMultiRes {
auroc,
lfc,
prop_other,
z_scores,
p_vals,
fdr,
prop_ref,
median_auroc,
min_auroc,
mean_auroc,
max_auroc,
worst_comparison,
min_rank,
simes_p,
simes_fdr,
max_p,
max_p_fdr,
genes_to_keep,
}
}
fn empty_auroc_multi_res(n_comparisons: usize, genes_to_keep: Vec<bool>) -> DgeAurocMultiRes {
DgeAurocMultiRes {
auroc: vec![Vec::new(); n_comparisons],
lfc: vec![Vec::new(); n_comparisons],
prop_other: vec![Vec::new(); n_comparisons],
z_scores: vec![Vec::new(); n_comparisons],
p_vals: vec![Vec::new(); n_comparisons],
fdr: vec![Vec::new(); n_comparisons],
prop_ref: Vec::new(),
median_auroc: Vec::new(),
min_auroc: Vec::new(),
mean_auroc: Vec::new(),
max_auroc: Vec::new(),
worst_comparison: Vec::new(),
min_rank: Vec::new(),
simes_p: Vec::new(),
simes_fdr: Vec::new(),
max_p: Vec::new(),
max_p_fdr: Vec::new(),
genes_to_keep,
}
}
const GENE_CHUNK_SIZE: usize = 1_000;
struct GroupLayout {
lookup: Vec<u16>,
sizes: Vec<usize>,
}
fn build_group_layout(groups: &[Vec<usize>], n_cells: usize) -> Result<GroupLayout, ScErrors> {
let n_groups = groups.len();
if n_groups < 2 {
return Err(ScErrors::DgeTooFewGroups { n_groups });
}
if n_groups >= NO_GROUP as usize {
return Err(ScErrors::DgeTooManyGroups {
n_groups,
max: NO_GROUP as usize - 1,
});
}
let mut lookup = vec![NO_GROUP; n_cells];
for (group, cells) in groups.iter().enumerate() {
if cells.is_empty() {
return Err(ScErrors::DgeEmptyGroup { group });
}
for &cell in cells {
if cell >= n_cells {
return Err(ScErrors::DgeCellOutOfRange { cell, n_cells });
}
if lookup[cell] != NO_GROUP {
return Err(ScErrors::DgeOverlappingGroups {
cell,
first: lookup[cell] as usize,
second: group,
});
}
lookup[cell] = group as u16;
}
}
Ok(GroupLayout {
lookup,
sizes: groups.iter().map(|g| g.len()).collect(),
})
}
fn scan_genes_by_group<S, T, K, F, C>(
reader: &S,
layout: &GroupLayout,
keep: K,
per_gene: F,
mut consume: C,
verbosity: Verbosity,
) -> Result<Vec<bool>, ScErrors>
where
S: SingleCellReading,
T: Send,
K: Fn(&GeneGroupStats) -> bool + Sync,
F: Fn(&GeneGroupStats) -> T + Sync,
C: FnMut(usize, T),
{
let n_genes = reader.get_header().total_genes;
let n_groups = layout.sizes.len();
let all_genes: Vec<usize> = (0..n_genes).collect();
let mut genes_to_keep = Vec::with_capacity(n_genes);
let start = Instant::now();
let mut done = 0;
for block in all_genes.chunks(GENE_CHUNK_SIZE) {
let chunks = reader.read_gene_parallel(block)?;
let reduced: Vec<Option<T>> = chunks
.par_iter()
.map_init(
|| GeneGroupStats::new(n_groups),
|stats, chunk| {
stats.gather(&chunk.data_norm, &chunk.indices, &layout.lookup);
if !keep(stats) {
return None;
}
stats.sweep(&layout.sizes);
Some(per_gene(stats))
},
)
.collect();
for (&gene, res) in block.iter().zip(reduced) {
genes_to_keep.push(res.is_some());
if let Some(res) = res {
consume(gene, res);
}
}
let prev = done;
done += block.len();
if verbosity.normal_verbosity() {
report_decile_progress(done, prev, n_genes, "genes", start.elapsed());
}
}
Ok(genes_to_keep)
}
type RestRow = (f32, f32, f32, f32, f64);
pub fn calculate_dge_one_vs_rest_mann_whitney<S: SingleCellReading>(
reader: &S,
groups: &[Vec<usize>],
min_proportion: f32,
alternative: &str,
verbose: usize,
) -> Result<Vec<DgeMannWhitneyRes>, ScErrors> {
let verbosity = parse_verbosity_level(verbose);
let header = reader.get_header();
let layout = build_group_layout(groups, header.total_cells)?;
let n_groups = groups.len();
let sizes = &layout.sizes;
let n_total: usize = sizes.iter().sum();
let start = Instant::now();
let arm_props = |stats: &GeneGroupStats, g: usize| -> (f32, f32) {
let nnz_total: u64 = stats.nnz.iter().sum();
(
stats.nnz[g] as f32 / sizes[g] as f32,
(nnz_total - stats.nnz[g]) as f32 / (n_total - sizes[g]) as f32,
)
};
let arm_kept = |(p1, p2): (f32, f32)| p1 >= min_proportion || p2 >= min_proportion;
let keep = |stats: &GeneGroupStats| (0..n_groups).any(|g| arm_kept(arm_props(stats, g)));
let per_gene = |stats: &GeneGroupStats| -> Vec<Option<RestRow>> {
let sum_total: f64 = stats.sum.iter().sum();
let tie = stats.tie_pooled();
(0..n_groups)
.map(|g| {
let props = arm_props(stats, g);
if !arm_kept(props) {
return None;
}
let n_rest = n_total - sizes[g];
let mean_grp = stats.sum[g] / sizes[g] as f64;
let mean_rest = (sum_total - stats.sum[g]) / n_rest as f64;
let (auroc, z) = mann_whitney_stats(stats.u_rest(g), tie, sizes[g], n_rest);
Some(((mean_grp - mean_rest) as f32, auroc, props.0, props.1, z))
})
.collect()
};
let mut arms: Vec<DgeMannWhitneyRes> = (0..n_groups)
.map(|_| DgeMannWhitneyRes {
lfc: Vec::new(),
auroc: Vec::new(),
prop1: Vec::new(),
prop2: Vec::new(),
z_scores: Vec::new(),
p_vals: Vec::new(),
fdr: Vec::new(),
genes_to_keep: vec![false; header.total_genes],
})
.collect();
scan_genes_by_group(
reader,
&layout,
keep,
per_gene,
|gene, rows| {
for (arm, row) in arms.iter_mut().zip(rows) {
if let Some((lfc, auroc, prop1, prop2, z)) = row {
arm.lfc.push(lfc);
arm.auroc.push(auroc);
arm.prop1.push(prop1);
arm.prop2.push(prop2);
arm.z_scores.push(z);
arm.genes_to_keep[gene] = true;
}
}
},
verbosity,
)?;
for arm in arms.iter_mut() {
arm.p_vals = z_scores_to_pval(&arm.z_scores, alternative);
arm.fdr = p_adjust_fdr(&arm.p_vals);
}
if verbosity.normal_verbosity() {
println!(
"One-vs-rest across {} groups: {:.2?}",
n_groups,
start.elapsed()
);
}
Ok(arms)
}
pub fn calculate_dge_one_vs_many_auroc<S: SingleCellReading>(
reader: &S,
groups: &[Vec<usize>],
references: &[usize],
min_proportion: f32,
alternative: &str,
verbose: usize,
) -> Result<Vec<DgeAurocMultiRes>, ScErrors> {
let verbosity = parse_verbosity_level(verbose);
let layout = build_group_layout(groups, reader.get_header().total_cells)?;
let n_groups = groups.len();
let sizes = &layout.sizes;
if let Some(&reference) = references.iter().find(|&&r| r >= n_groups) {
return Err(ScErrors::DgeReferenceOutOfRange {
reference,
n_groups,
});
}
let start = Instant::now();
let keep = |stats: &GeneGroupStats| {
(0..n_groups).any(|g| stats.nnz[g] as f32 / sizes[g] as f32 >= min_proportion)
};
let per_gene = |stats: &GeneGroupStats| -> (Vec<f32>, Vec<(f32, f32, f64)>) {
let props: Vec<f32> = (0..n_groups)
.map(|g| stats.nnz[g] as f32 / sizes[g] as f32)
.collect();
let means: Vec<f64> = (0..n_groups)
.map(|g| stats.sum[g] / sizes[g] as f64)
.collect();
let mut rows = Vec::with_capacity(references.len() * (n_groups - 1));
for &r in references {
for k in (0..n_groups).filter(|&k| k != r) {
let (auroc, z) = mann_whitney_stats(
stats.u_pair(r, k),
stats.tie_pair(r, k),
sizes[r],
sizes[k],
);
rows.push((auroc, (means[r] - means[k]) as f32, z));
}
}
(props, rows)
};
let n_rivals = n_groups - 1;
let mut props: Vec<Vec<f32>> = vec![Vec::new(); n_groups];
let mut auroc: Vec<Vec<Vec<f32>>> = vec![vec![Vec::new(); n_rivals]; references.len()];
let mut lfc: Vec<Vec<Vec<f32>>> = vec![vec![Vec::new(); n_rivals]; references.len()];
let mut z_scores: Vec<Vec<Vec<f64>>> = vec![vec![Vec::new(); n_rivals]; references.len()];
let genes_to_keep = scan_genes_by_group(
reader,
&layout,
keep,
per_gene,
|_, (p, rows)| {
for (col, value) in props.iter_mut().zip(p) {
col.push(value);
}
for (i, (a, l, z)) in rows.into_iter().enumerate() {
let (arm, comparison) = (i / n_rivals, i % n_rivals);
auroc[arm][comparison].push(a);
lfc[arm][comparison].push(l);
z_scores[arm][comparison].push(z);
}
},
verbosity,
)?;
if verbosity.normal_verbosity() {
println!(
"Scanned {} groups, {} reference arms: {:.2?}",
n_groups,
references.len(),
start.elapsed()
);
}
let res = references
.iter()
.zip(auroc.into_iter().zip(lfc).zip(z_scores))
.map(|(&r, ((auroc_r, lfc_r), z_r))| {
let prop_other = (0..n_groups)
.filter(|&k| k != r)
.map(|k| props[k].clone())
.collect();
build_auroc_multi_res(
auroc_r,
lfc_r,
z_r,
props[r].clone(),
prop_other,
alternative,
genes_to_keep.clone(),
)
})
.collect();
Ok(res)
}
#[derive(Clone, Copy, Debug, Default)]
pub enum AucType {
#[default]
Recovery,
MannWhitney,
AveragePrecision,
}
pub fn parse_auc_type(s: &str) -> Option<AucType> {
match s.to_lowercase().as_str() {
"auroc" | "aucell" | "recovery" => Some(AucType::Recovery),
"wilcox" | "mannwhitney" => Some(AucType::MannWhitney),
"aupr" | "ap" => Some(AucType::AveragePrecision),
_ => None,
}
}
const AUC_MAX_RANK_FRAC: f64 = 0.05;
#[derive(Clone, Copy, Debug, Default)]
pub struct AucellParams {
pub auc_type: AucType,
pub max_rank: Option<usize>,
pub standardise: bool,
}
impl AucellParams {
pub fn new(auc_type: AucType, max_rank: Option<usize>, standardise: bool) -> Self {
Self {
auc_type,
max_rank,
standardise,
}
}
}
pub(crate) fn resolve_max_rank(max_rank: Option<usize>, n_genes: usize) -> usize {
max_rank
.unwrap_or_else(|| (AUC_MAX_RANK_FRAC * n_genes as f64).ceil() as usize)
.clamp(1, n_genes.max(1))
}
pub fn calculate_auc_per_cell_mw(ranks: &[f32], gene_set: &[usize]) -> f32 {
let n_genes = ranks.len();
let n_in_set = gene_set.len();
let n_not_in_set = n_genes - n_in_set;
let rank_sum: f64 = gene_set.iter().map(|&idx| ranks[idx] as f64).sum();
let n_in_set = n_in_set as f64;
let u = rank_sum - n_in_set * (n_in_set + 1.0) / 2.0;
(u / (n_in_set * n_not_in_set as f64)) as f32
}
fn descending_ranks_into(ranks: &[f32], gene_set: &[usize], out: &mut Vec<f32>) {
let n_genes = ranks.len() as f32;
out.clear();
out.extend(gene_set.iter().map(|&idx| n_genes + 1.0 - ranks[idx]));
out.sort_unstable_by(|a, b| a.total_cmp(b));
}
pub fn calculate_auc_recovery(ranks: &[f32], gene_set: &[usize], max_rank: usize) -> f32 {
auc_recovery_with(ranks, gene_set, max_rank, &mut Vec::new())
}
fn auc_recovery_with(
ranks: &[f32],
gene_set: &[usize],
max_rank: usize,
hits: &mut Vec<f32>,
) -> f32 {
if gene_set.is_empty() || max_rank == 0 {
return 0.0;
}
let cutoff = max_rank as f32;
descending_ranks_into(ranks, gene_set, hits);
hits.truncate(hits.partition_point(|&d| d < cutoff));
if hits.is_empty() {
return 0.0;
}
hits.push(cutoff);
let area: f64 = hits
.windows(2)
.enumerate()
.map(|(i, w)| (w[1] - w[0]) as f64 * (i + 1) as f64)
.sum();
(area / (cutoff as f64 * gene_set.len() as f64)) as f32
}
pub fn calculate_ap_per_cell(ranks: &[f32], gene_set: &[usize]) -> f32 {
ap_with(ranks, gene_set, &mut Vec::new())
}
fn ap_with(ranks: &[f32], gene_set: &[usize], hits: &mut Vec<f32>) -> f32 {
if gene_set.is_empty() {
return 0.0;
}
descending_ranks_into(ranks, gene_set, hits);
let sum: f64 = hits
.iter()
.enumerate()
.map(|(k, &d)| (k + 1) as f64 / d as f64)
.sum();
(sum / hits.len() as f64) as f32
}
pub(crate) fn standardise_rows(rows: &mut [Vec<f32>]) {
rows.par_iter_mut().for_each(|row| {
let n = row.len();
if n < 2 {
row.iter_mut().for_each(|v| *v = 0.0);
return;
}
let mean = row.iter().map(|&v| v as f64).sum::<f64>() / n as f64;
let var = row
.iter()
.map(|&v| {
let d = v as f64 - mean;
d * d
})
.sum::<f64>()
/ (n - 1) as f64;
let sd = var.sqrt();
if sd < 1e-12 {
row.iter_mut().for_each(|v| *v = 0.0);
} else {
row.iter_mut()
.for_each(|v| *v = ((*v as f64 - mean) / sd) as f32);
}
});
}
pub(crate) fn score_cell(
cell_ranks: &[f32],
gene_sets: &[Vec<usize>],
auc_type: AucType,
max_rank: usize,
) -> Vec<f32> {
gene_sets
.par_iter()
.map(|gene_set| match auc_type {
AucType::Recovery => calculate_auc_recovery(cell_ranks, gene_set, max_rank),
AucType::MannWhitney => calculate_auc_per_cell_mw(cell_ranks, gene_set),
AucType::AveragePrecision => calculate_ap_per_cell(cell_ranks, gene_set),
})
.collect()
}
fn score_cells_fused(
cells: &[CsrCellChunk],
gene_sets: &[Vec<usize>],
auc_type: AucType,
max_rank: usize,
n_genes: usize,
) -> Vec<Vec<f32>> {
cells
.par_iter()
.map_init(
|| {
(
Vec::<f32>::new(),
Vec::<(u16, usize)>::new(),
Vec::<f32>::new(),
)
},
|(ranks, sort_buf, hits), cell| {
rank_cell_into(&cell.indices, &cell.data_norm, n_genes, ranks, sort_buf);
gene_sets
.iter()
.map(|gene_set| match auc_type {
AucType::Recovery => auc_recovery_with(ranks, gene_set, max_rank, hits),
AucType::MannWhitney => calculate_auc_per_cell_mw(ranks, gene_set),
AucType::AveragePrecision => ap_with(ranks, gene_set, hits),
})
.collect()
},
)
.collect()
}
pub fn calculate_aucell<S: SingleCellReading>(
reader: &S,
gene_sets: &[Vec<usize>],
cells_to_keep: &[usize],
params: Option<AucellParams>,
verbose: usize,
) -> Result<Vec<Vec<f32>>, ScErrors> {
let verbosity = parse_verbosity_level(verbose);
let params = params.unwrap_or_default();
let start_read = Instant::now();
let no_genes = reader.get_header().total_genes;
let cell_chunks: Vec<CsrCellChunk> = reader.read_cells_parallel(cells_to_keep)?;
let total_cells = cell_chunks.len();
let end_read = start_read.elapsed();
if verbosity.normal_verbosity() {
println!("Loaded in data: {:.2?}", end_read);
}
let max_rank = resolve_max_rank(params.max_rank, no_genes);
let start_auc = Instant::now();
let per_cell = score_cells_fused(&cell_chunks, gene_sets, params.auc_type, max_rank, no_genes);
drop(cell_chunks);
let mut all_results: Vec<Vec<f32>> = vec![Vec::with_capacity(total_cells); gene_sets.len()];
for aucs in per_cell {
for (gene_set_idx, auc) in aucs.into_iter().enumerate() {
all_results[gene_set_idx].push(auc);
}
}
if params.standardise {
standardise_rows(&mut all_results);
}
let end_auc = start_auc.elapsed();
if verbosity.normal_verbosity() {
println!("Ranked within cells and calculated AUCs {:.2?}", end_auc);
}
Ok(all_results)
}
pub fn calculate_aucell_streaming<S: SingleCellReading>(
reader: &S,
gene_sets: &[Vec<usize>],
cells_to_keep: &[usize],
params: Option<AucellParams>,
verbose: usize,
) -> Result<Vec<Vec<f32>>, ScErrors> {
let verbosity = parse_verbosity_level(verbose);
let params = params.unwrap_or_default();
const CHUNK_SIZE: usize = 50000;
let no_genes = reader.get_header().total_genes;
let total_chunks = cells_to_keep.len().div_ceil(CHUNK_SIZE);
let max_rank = resolve_max_rank(params.max_rank, no_genes);
let mut all_results: Vec<Vec<f32>> =
vec![Vec::with_capacity(cells_to_keep.len()); gene_sets.len()];
for (chunk_idx, cell_indices_chunk) in cells_to_keep.chunks(CHUNK_SIZE).enumerate() {
let start_chunk = Instant::now();
let cell_chunks = reader.read_cells_parallel(cell_indices_chunk)?;
let per_cell =
score_cells_fused(&cell_chunks, gene_sets, params.auc_type, max_rank, no_genes);
for aucs in per_cell {
for (gene_set_idx, auc) in aucs.into_iter().enumerate() {
all_results[gene_set_idx].push(auc);
}
}
if verbosity.normal_verbosity() {
let elapsed = start_chunk.elapsed();
let pct_complete = ((chunk_idx + 1) as f32 / total_chunks as f32) * 100.0;
println!(
"Processing chunk {} out of {} (took {:.2?}, completed {:.1}%)",
chunk_idx + 1,
total_chunks,
elapsed,
pct_complete
);
}
}
if params.standardise {
standardise_rows(&mut all_results);
}
Ok(all_results)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use bixverse_sc_data::data_io::CellGeneSparseWriter;
use bixverse_sc_data::gene_file_io::write_gene_file;
struct TempStore(std::path::PathBuf);
impl Drop for TempStore {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
impl TempStore {
fn new(name: &str) -> Self {
Self(std::env::temp_dir().join(format!("bixverse_dge_{name}.bin")))
}
fn path(&self) -> &str {
self.0.to_str().expect("temp path is valid UTF-8")
}
}
fn reader_for(temp: &TempStore, dense: &[Vec<u32>]) -> ParallelSparseReader {
let n_cells = dense.len();
let n_genes = dense[0].len();
let mut writer = CellGeneSparseWriter::new(temp.path(), true, n_cells, n_genes, 1e4)
.expect("writer opens");
for (cell_idx, cell) in dense.iter().enumerate() {
let mut raw = Vec::new();
let mut indices: Vec<u32> = Vec::new();
for (gene, &value) in cell.iter().enumerate() {
if value > 0 {
raw.push(value);
indices.push(gene as u32);
}
}
writer
.write_cell_chunk(CsrCellChunk::from_data(&raw, &indices, cell_idx, 1e4, true))
.expect("write cell chunk");
}
writer.finalise().expect("finalise");
ParallelSparseReader::new(temp.path()).expect("reader opens")
}
fn identity_ranks(n: usize) -> Vec<f32> {
(1..=n).map(|r| r as f32).collect()
}
fn genes_with_asc_ranks(asc: &[usize]) -> Vec<usize> {
asc.iter().map(|&r| r - 1).collect()
}
#[test]
fn test_mw_auc_perfect_separation() {
let ranks = identity_ranks(10);
let top = vec![7, 8, 9];
assert_relative_eq!(calculate_auc_per_cell_mw(&ranks, &top), 1.0);
let bottom = vec![0, 1, 2];
assert_relative_eq!(calculate_auc_per_cell_mw(&ranks, &bottom), 0.0);
}
#[test]
fn test_recovery_auc_known_value() {
let ranks = identity_ranks(10);
let gene_set = genes_with_asc_ranks(&[10, 8]);
assert_relative_eq!(calculate_auc_recovery(&ranks, &gene_set, 4), 0.5);
let deep = genes_with_asc_ranks(&[3, 2]);
assert_relative_eq!(calculate_auc_recovery(&ranks, &deep, 4), 0.0);
}
#[test]
fn test_recovery_auc_distinguishes_top_heavy() {
let ranks = identity_ranks(20);
let spread = genes_with_asc_ranks(&[16, 14]);
let concentrated = genes_with_asc_ranks(&[20, 10]);
let mw_spread = calculate_auc_per_cell_mw(&ranks, &spread);
let mw_concentrated = calculate_auc_per_cell_mw(&ranks, &concentrated);
assert_relative_eq!(mw_spread, 0.75);
assert_relative_eq!(mw_concentrated, 0.75);
assert_relative_eq!(calculate_auc_recovery(&ranks, &spread, 8), 4.0 / 16.0);
assert_relative_eq!(calculate_auc_recovery(&ranks, &concentrated, 8), 7.0 / 16.0);
}
#[test]
fn test_ap_distinguishes_top_heavy() {
let ranks = identity_ranks(20);
let spread = genes_with_asc_ranks(&[16, 14]);
let concentrated = genes_with_asc_ranks(&[20, 10]);
assert_relative_eq!(
calculate_ap_per_cell(&ranks, &spread),
0.242_857_15,
epsilon = 1e-6
);
assert_relative_eq!(
calculate_ap_per_cell(&ranks, &concentrated),
0.590_909_1,
epsilon = 1e-6
);
}
#[test]
fn test_resolve_max_rank() {
assert_eq!(resolve_max_rank(None, 20_000), 1_000);
assert_eq!(resolve_max_rank(Some(50), 20_000), 50);
assert_eq!(resolve_max_rank(Some(0), 20_000), 1);
assert_eq!(resolve_max_rank(Some(99_999), 20_000), 20_000);
assert_eq!(resolve_max_rank(None, 3), 1);
}
#[test]
fn test_standardise_rows() {
let mut rows = vec![vec![1.0, 2.0, 3.0], vec![5.0, 5.0, 5.0], vec![7.0]];
standardise_rows(&mut rows);
assert_relative_eq!(rows[0][0], -1.0);
assert_relative_eq!(rows[0][1], 0.0);
assert_relative_eq!(rows[0][2], 1.0);
assert_eq!(rows[1], vec![0.0, 0.0, 0.0]);
assert_eq!(rows[2], vec![0.0]);
}
#[test]
fn test_auroc_perfect_separation() {
let (auroc, z) = mann_whitney_stats(9.0, 0.0, 3, 3);
assert_relative_eq!(auroc, 1.0);
assert!(z > 0.0);
let (auroc, z) = mann_whitney_stats(0.0, 0.0, 3, 3);
assert_relative_eq!(auroc, 0.0);
assert!(z < 0.0);
}
#[test]
fn test_auroc_no_separation() {
let (auroc, _) = mann_whitney_stats(2.0, 0.0, 2, 2);
assert_relative_eq!(auroc, 0.5);
}
#[test]
fn test_auroc_z_orientation() {
let (n1, n2) = (3_usize, 3_usize);
let (auroc, z) = mann_whitney_stats(9.0, 0.0, n1, n2);
let n = (n1 + n2) as f64;
let expected = (auroc as f64 - 0.5) * (12.0 * n1 as f64 * n2 as f64 / (n + 1.0)).sqrt();
assert_relative_eq!(z, expected, epsilon = 1e-12);
assert!(z > 0.0 && auroc > 0.5);
}
#[test]
fn test_tie_correction_known_value() {
let (auroc, z) = mann_whitney_stats(6.0, 30.0, 3, 3);
assert_relative_eq!(auroc, 6.0 / 9.0);
assert_relative_eq!(z, 1.5 / 4.5_f64.sqrt(), epsilon = 1e-12);
}
#[test]
fn test_tie_correction_no_ties() {
let (_, z) = mann_whitney_stats(6.0, 0.0, 3, 3);
assert_relative_eq!(z, 1.5 / 5.25_f64.sqrt(), epsilon = 1e-12);
}
#[test]
fn test_constant_gene_degenerate() {
let (auroc, z) = mann_whitney_stats(4.5, 210.0, 3, 3);
assert_relative_eq!(auroc, 0.5);
assert_eq!(z, 0.0);
}
#[test]
fn test_mann_whitney_stats_empty_group() {
let (auroc, z) = mann_whitney_stats(0.0, 0.0, 0, 5);
assert!(auroc.is_nan());
assert_eq!(z, 0.0);
}
#[test]
fn test_min_rank_competition_ties() {
let ranks = competition_ranks_desc(&[0.9, 0.7, 0.7, 0.5]);
assert_eq!(ranks, vec![1, 2, 2, 4]);
let ranks = competition_ranks_desc(&[0.5, 0.9, 0.7]);
assert_eq!(ranks, vec![3, 1, 2]);
}
#[test]
fn test_simes_and_max_p() {
let mut p = vec![0.01, 0.30, 0.04];
let max_p = p.iter().copied().fold(f64::NEG_INFINITY, f64::max);
assert_relative_eq!(simes_combine(&mut p), 0.03, epsilon = 1e-12);
assert_relative_eq!(max_p, 0.30);
let mut p = vec![0.9, 0.95];
assert_relative_eq!(simes_combine(&mut p), 0.95, epsilon = 1e-12);
}
#[test]
fn test_median_in_place() {
let mut odd = [3.0, 1.0, 2.0];
assert_relative_eq!(median_in_place(&mut odd), 2.0);
let mut even = [4.0, 1.0, 3.0, 2.0];
assert_relative_eq!(median_in_place(&mut even), 2.5);
let mut single = [7.0];
assert_relative_eq!(median_in_place(&mut single), 7.0);
}
#[test]
fn test_build_group_layout() {
assert!(matches!(
build_group_layout(&[vec![0]], 4),
Err(ScErrors::DgeTooFewGroups { n_groups: 1 })
));
assert!(matches!(
build_group_layout(&[vec![0], vec![]], 4),
Err(ScErrors::DgeEmptyGroup { group: 1 })
));
assert!(matches!(
build_group_layout(&[vec![0], vec![4]], 4),
Err(ScErrors::DgeCellOutOfRange {
cell: 4,
n_cells: 4
})
));
assert!(matches!(
build_group_layout(&[vec![0, 1], vec![2, 1]], 4),
Err(ScErrors::DgeOverlappingGroups {
cell: 1,
first: 0,
second: 1
})
));
assert!(matches!(
build_group_layout(&[vec![0, 0], vec![1]], 4),
Err(ScErrors::DgeOverlappingGroups {
cell: 0,
first: 0,
second: 0
})
));
let layout = build_group_layout(&[vec![3], vec![0, 2]], 4).expect("valid layout");
assert_eq!(layout.lookup, vec![1, NO_GROUP, 1, 0]);
assert_eq!(layout.sizes, vec![1, 2]);
}
#[test]
fn test_auc_per_cell_mw_precision() {
let n_genes = 20_000;
let ranks = identity_ranks(n_genes);
let gene_set: Vec<usize> = (n_genes - 1_000..n_genes).collect();
assert_relative_eq!(calculate_auc_per_cell_mw(&ranks, &gene_set), 1.0);
}
fn one_vs_many_counts() -> Vec<Vec<u32>> {
let mut dense = Vec::with_capacity(12);
for &g0 in &[10_u32, 12, 11, 13] {
dense.push(vec![g0, 20, 0, 5, 0, 75 - g0]);
}
for &g2 in &[10_u32, 12, 11, 13] {
dense.push(vec![0, 20, g2, 5, 0, 75 - g2]);
}
for _ in 0..4 {
dense.push(vec![0, 0, 0, 5, 0, 95]);
}
dense
}
#[test]
fn test_one_vs_many_auroc_end_to_end() {
let cells = TempStore::new("one_vs_many_cells");
let genes = TempStore::new("one_vs_many_genes");
let (_, reader) = readers_for(&cells, &genes, &one_vs_many_counts());
let res =
calculate_dge_one_vs_many_auroc(&reader, &three_groups(), &[0], 0.5, "greater", 0)
.expect("one-vs-many runs")
.remove(0);
assert_eq!(res.genes_to_keep, vec![true, true, true, true, false, true]);
assert_eq!(res.auroc[0], vec![1.0, 0.5, 0.0, 0.5, 0.5]);
assert_eq!(res.auroc[1], vec![1.0, 1.0, 0.5, 0.5, 0.0]);
assert_eq!(res.median_auroc, vec![1.0, 0.75, 0.25, 0.5, 0.25]);
assert_eq!(res.min_auroc, vec![1.0, 0.5, 0.0, 0.5, 0.0]);
assert_eq!(res.max_auroc, vec![1.0, 1.0, 0.5, 0.5, 0.5]);
assert_eq!(res.worst_comparison[1], 0);
assert_eq!(res.worst_comparison[4], 1);
assert_eq!(res.min_rank, vec![1, 1, 3, 2, 2]);
assert_eq!(res.z_scores[0][1], 0.0);
assert_eq!(res.z_scores[0][3], 0.0);
assert_eq!(res.z_scores[1][2], 0.0);
assert!(res.z_scores.iter().flatten().all(|z| z.is_finite()));
assert!(res.median_auroc[0] > res.median_auroc[1]);
assert!(res.simes_p[0] < res.simes_p[2]);
assert!(res.max_p[0] < res.max_p[1]);
assert_eq!(res.prop_ref, vec![1.0, 1.0, 0.0, 1.0, 1.0]);
assert_eq!(res.prop_other[0], vec![0.0, 1.0, 1.0, 1.0, 1.0]);
}
fn three_groups() -> Vec<Vec<usize>> {
vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7], vec![8, 9, 10, 11]]
}
fn readers_for(
cells: &TempStore,
genes: &TempStore,
dense: &[Vec<u32>],
) -> (ParallelSparseReader, ParallelSparseReader) {
let cell_reader = reader_for(cells, dense);
write_gene_file(cells.path(), genes.path(), None, false).expect("gene file");
let gene_reader = ParallelSparseReader::new(genes.path()).expect("reader opens");
(cell_reader, gene_reader)
}
#[test]
fn test_one_vs_many_matches_pairwise() {
let cells = TempStore::new("vs_pairwise_cells");
let genes = TempStore::new("vs_pairwise_genes");
let (cell_reader, gene_reader) = readers_for(&cells, &genes, &one_vs_many_counts());
let groups = three_groups();
let multi =
calculate_dge_one_vs_many_auroc(&gene_reader, &groups[..2], &[0], 0.5, "greater", 0)
.expect("one-vs-many runs")
.remove(0);
let pairwise = calculate_dge_grps_mann_whitney(
&cell_reader,
&groups[0],
&groups[1],
0.5,
"greater",
0,
)
.expect("pairwise runs");
assert_eq!(multi.genes_to_keep, pairwise.genes_to_keep);
assert_eq!(multi.auroc[0], pairwise.auroc);
assert_eq!(multi.z_scores[0], pairwise.z_scores);
assert_eq!(multi.lfc[0], pairwise.lfc);
assert_eq!(multi.prop_ref, pairwise.prop1);
assert_eq!(multi.prop_other[0], pairwise.prop2);
}
#[test]
fn test_one_vs_many_all_references() {
let cells = TempStore::new("all_refs_cells");
let genes = TempStore::new("all_refs_genes");
let (cell_reader, gene_reader) = readers_for(&cells, &genes, &one_vs_many_counts());
let groups = three_groups();
let res =
calculate_dge_one_vs_many_auroc(&gene_reader, &groups, &[0, 1, 2], 0.0, "twosided", 0)
.expect("one-vs-many runs");
assert_eq!(res.len(), 3);
for (r, arm) in res.iter().enumerate() {
let rivals: Vec<usize> = (0..3).filter(|&k| k != r).collect();
for (c, &k) in rivals.iter().enumerate() {
let pairwise = calculate_dge_grps_mann_whitney(
&cell_reader,
&groups[r],
&groups[k],
0.0,
"twosided",
0,
)
.expect("pairwise runs");
assert_eq!(arm.auroc[c], pairwise.auroc, "ref {r} vs {k}");
assert_eq!(arm.z_scores[c], pairwise.z_scores, "ref {r} vs {k}");
assert_eq!(arm.lfc[c], pairwise.lfc, "ref {r} vs {k}");
let mirror = &res[k];
let c_back = (0..3).filter(|&j| j != k).position(|j| j == r).unwrap();
for (a, b) in arm.auroc[c].iter().zip(&mirror.auroc[c_back]) {
assert_relative_eq!(*a, 1.0 - *b, epsilon = 1e-6);
}
}
}
}
#[test]
fn test_one_vs_many_all_genes_filtered() {
let cells = TempStore::new("all_filtered_cells");
let genes = TempStore::new("all_filtered_genes");
let (_, reader) = readers_for(&cells, &genes, &one_vs_many_counts());
let res =
calculate_dge_one_vs_many_auroc(&reader, &three_groups(), &[0], 1.5, "greater", 0)
.expect("one-vs-many runs")
.remove(0);
assert!(res.genes_to_keep.iter().all(|&keep| !keep));
assert_eq!(res.auroc.len(), 2);
assert!(res.auroc.iter().all(|a| a.is_empty()));
assert!(res.median_auroc.is_empty());
}
#[test]
fn test_one_vs_many_reference_out_of_range() {
let cells = TempStore::new("bad_ref_cells");
let genes = TempStore::new("bad_ref_genes");
let (_, reader) = readers_for(&cells, &genes, &one_vs_many_counts());
assert!(matches!(
calculate_dge_one_vs_many_auroc(&reader, &three_groups(), &[3], 0.5, "greater", 0),
Err(ScErrors::DgeReferenceOutOfRange {
reference: 3,
n_groups: 3
})
));
}
#[test]
fn test_one_vs_rest_matches_pairwise() {
let cells = TempStore::new("vs_rest_cells");
let genes = TempStore::new("vs_rest_genes");
let (cell_reader, gene_reader) = readers_for(&cells, &genes, &one_vs_many_counts());
let groups = vec![vec![0, 1, 2], vec![4, 5, 6, 7], vec![8, 9, 10]];
let res = calculate_dge_one_vs_rest_mann_whitney(&gene_reader, &groups, 0.6, "greater", 0)
.expect("one-vs-rest runs");
assert_eq!(res.len(), 3);
for (g, arm) in res.iter().enumerate() {
let rest: Vec<usize> = groups
.iter()
.enumerate()
.filter(|&(h, _)| h != g)
.flat_map(|(_, cells)| cells.iter().copied())
.collect();
let pairwise =
calculate_dge_grps_mann_whitney(&cell_reader, &groups[g], &rest, 0.6, "greater", 0)
.expect("pairwise runs");
assert_eq!(arm.genes_to_keep, pairwise.genes_to_keep, "group {g}");
assert_eq!(arm.auroc, pairwise.auroc, "group {g}");
assert_eq!(arm.z_scores, pairwise.z_scores, "group {g}");
assert_eq!(arm.prop1, pairwise.prop1, "group {g}");
assert_eq!(arm.prop2, pairwise.prop2, "group {g}");
assert_eq!(arm.fdr, pairwise.fdr, "group {g}");
for (a, b) in arm.lfc.iter().zip(&pairwise.lfc) {
assert_relative_eq!(*a, *b, epsilon = 1e-6);
}
}
}
#[test]
fn test_one_vs_rest_rejects_cell_store() {
let cells = TempStore::new("wrong_mode_cells");
let reader = reader_for(&cells, &one_vs_many_counts());
assert!(matches!(
calculate_dge_one_vs_rest_mann_whitney(&reader, &three_groups(), 0.5, "greater", 0),
Err(ScErrors::ScData(ScDataErrors::ReaderModeMismatch { .. }))
));
}
#[test]
fn test_parse_auc_type() {
assert!(matches!(parse_auc_type("AUROC"), Some(AucType::Recovery)));
assert!(matches!(parse_auc_type("aucell"), Some(AucType::Recovery)));
assert!(matches!(
parse_auc_type("wilcox"),
Some(AucType::MannWhitney)
));
assert!(matches!(
parse_auc_type("aupr"),
Some(AucType::AveragePrecision)
));
assert!(parse_auc_type("nonsense").is_none());
}
}