use half::f16;
use indexmap::IndexSet;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use std::path::Path;
use std::time::Instant;
use thousands::Separable;
use crate::errors::ScErrors;
use crate::prelude::*;
use bixverse_core::core::base::kernel_smooth::bw_nrd;
use bixverse_core::core::math::vector_helpers::{interp_linear_at, median};
use bixverse_sc_data::data_io::{CellGeneSparseWriter, CscGeneChunk, RawCounts, SingleCellReading};
use crate::sc_processing::residuals::{
ResidualSource, distinct_cell_set, intersect_gene_sets, residual_variance, validate_groups,
};
use super::model::{
SctCellContext, SctCovariates, SctGeneStats, SctModel, SctParams, min_variance_from_umi_median,
regularise_sct_model,
};
use super::nb_fit::{NbOffsetFit, fit_nb_offset_gene};
use super::residuals::SctResiduals;
const SCT_GENE_BATCH_SIZE: usize = 1000;
const DENSITY_GRID_POINTS: usize = 512;
const DENSITY_CUTOFF: f64 = 3.0;
#[derive(Clone, Copy, Debug)]
pub struct SctStreamOpts {
pub gene_batch_size: Option<usize>,
pub seed: u64,
pub verbose: usize,
}
impl Default for SctStreamOpts {
fn default() -> Self {
Self {
gene_batch_size: Some(SCT_GENE_BATCH_SIZE),
seed: 42,
verbose: 0,
}
}
}
fn correct_one_gene(
chunk: &CscGeneChunk,
gene_pos: usize,
n_cells: usize,
source: &SctResiduals<'_>,
median_log10_umi: f64,
median_covariates: &[f64],
) -> CscGeneChunk {
let cells = source.cells();
let simple = cells.covariates.is_empty();
let per_group: Vec<CorrectionParams<'_>> = source
.models()
.iter()
.enumerate()
.map(|(group, model)| {
let pos = source.model_gene_position(group, gene_pos);
let beta = model.coefficients_for(pos);
let theta = model.theta[pos];
let mut eta_target = beta[0] + model.log_umi_coef * median_log10_umi;
for (b, x) in beta[1..].iter().zip(median_covariates) {
eta_target += b * x;
}
let mu_target = eta_target.exp();
CorrectionParams {
beta,
theta,
log_umi_coef: model.log_umi_coef,
mu_target,
sd_target: (mu_target + mu_target * mu_target / theta).sqrt(),
}
})
.collect();
let mut counts = vec![0.0_f64; n_cells];
match &chunk.data_raw {
RawCounts::U16(v) => {
for (&i, &x) in chunk.indices.iter().zip(v.iter()) {
counts[i as usize] = x as f64;
}
}
RawCounts::U32(v) => {
for (&i, &x) in chunk.indices.iter().zip(v.iter()) {
counts[i as usize] = x as f64;
}
}
}
let groups = source.group_of_cell_slice();
let mut raw = Vec::new();
let mut indices = Vec::new();
let mut norms = Vec::new();
for (c, &y) in counts.iter().enumerate() {
let p = &per_group[groups[c] as usize];
let mut eta = p.beta[0] + p.log_umi_coef * cells.log10_umi[c];
if !simple {
for (b, x) in p.beta[1..].iter().zip(cells.covariates.row(c)) {
eta += b * x;
}
}
let mu = eta.exp();
let residual = (y - mu) / (mu + mu * mu / p.theta).sqrt();
let corrected = (p.mu_target + residual * p.sd_target).round_ties_even();
if corrected >= 1.0 {
let value = corrected as u32;
raw.push(value);
indices.push(c);
norms.push(F16::from(f16::from_f32((value as f32).ln_1p())));
}
}
CscGeneChunk::from_conversion(
RawCounts::from_u32_auto(&raw),
&norms,
&indices,
gene_pos,
true,
)
}
struct CorrectionParams<'a> {
beta: &'a [f64],
theta: f64,
log_umi_coef: f64,
mu_target: f64,
sd_target: f64,
}
fn flag_poisson_genes(
fits: Vec<NbOffsetFit>,
fit_idx: &[usize],
stats: &SctGeneStats,
threshold: f64,
) -> Vec<NbOffsetFit> {
fits.into_iter()
.zip(fit_idx.iter())
.map(|(fit, &g)| {
let amean = stats.amean[g];
let moment_theta = amean * amean / (stats.var[g] - amean);
if moment_theta / fit.theta < threshold {
NbOffsetFit {
theta: f64::INFINITY,
..fit
}
} else {
fit
}
})
.collect()
}
fn count_detected<S: SingleCellReading>(
reader: &S,
genes: &[usize],
cell_set: &IndexSet<u32>,
opts: SctStreamOpts,
) -> Result<Vec<usize>, ScErrors> {
let n_genes = reader.get_header().total_genes;
let mut out = vec![0_usize; n_genes];
let step = opts.gene_batch_size.unwrap_or(genes.len()).max(1);
for block in genes.chunks(step) {
for chunk in reader.read_gene_parallel_filtered(block, cell_set)? {
out[chunk.original_index] = chunk.indices.len();
}
}
Ok(out)
}
fn densify(chunk: &CscGeneChunk, n_cells: usize) -> Vec<f64> {
let mut out = vec![0.0_f64; n_cells];
match &chunk.data_raw {
RawCounts::U16(v) => {
for (&i, &x) in chunk.indices.iter().zip(v.iter()) {
out[i as usize] = x as f64;
}
}
RawCounts::U32(v) => {
for (&i, &x) in chunk.indices.iter().zip(v.iter()) {
out[i as usize] = x as f64;
}
}
}
out
}
#[derive(Clone, Debug)]
pub struct SctGenePass {
pub stats: SctGeneStats,
pub detected: Vec<usize>,
pub modelled: Vec<usize>,
pub median_nonzero: f64,
pub mean_cell_sum: f64,
}
#[derive(Clone, Debug, Default)]
struct GeneAcc {
sum: f64,
ss: f64,
sum_log: f64,
nnz: usize,
}
const COUNT_TABLE_LEN: usize = 4096;
const LN_SUM_LANES: usize = 4;
fn sweep_values<V: Copy + Into<u32>>(
values: &[V],
n_cells: usize,
gmean_eps: f64,
ln_table: &[f64],
) -> GeneAcc {
let nnz = values.len();
let sum: f64 = values.iter().map(|&x| Into::<u32>::into(x) as f64).sum();
let mean = sum / n_cells as f64;
let ss_nonzero: f64 = values
.iter()
.map(|&x| {
let x = Into::<u32>::into(x) as f64;
(x - mean) * (x - mean)
})
.sum();
let ss = ss_nonzero + mean * mean * (n_cells - nnz) as f64;
let ln_of = |x: u32| -> f64 {
ln_table
.get(x as usize)
.copied()
.unwrap_or_else(|| (x as f64 + gmean_eps).ln())
};
let mut lanes = [0.0_f64; LN_SUM_LANES];
let mut chunks = values.chunks_exact(LN_SUM_LANES);
for c in &mut chunks {
for (lane, &x) in lanes.iter_mut().zip(c) {
*lane += ln_of(x.into());
}
}
for &x in chunks.remainder() {
lanes[0] += ln_of(x.into());
}
let sum_log: f64 = lanes.iter().sum();
GeneAcc {
sum,
ss,
sum_log,
nnz,
}
}
fn sweep_gene(gene: &CscGeneChunk, n_cells: usize, gmean_eps: f64, ln_table: &[f64]) -> GeneAcc {
match &gene.data_raw {
RawCounts::U16(v) => sweep_values(v, n_cells, gmean_eps, ln_table),
RawCounts::U32(v) => sweep_values(v, n_cells, gmean_eps, ln_table),
}
}
struct CountHistogram {
dense: Vec<u64>,
overflow: FxHashMap<u32, u64>,
}
impl CountHistogram {
fn new() -> Self {
Self {
dense: vec![0; COUNT_TABLE_LEN],
overflow: FxHashMap::default(),
}
}
#[inline]
fn add(&mut self, x: u32) {
match self.dense.get_mut(x as usize) {
Some(slot) => *slot += 1,
None => *self.overflow.entry(x).or_insert(0) += 1,
}
}
fn add_gene(&mut self, gene: &CscGeneChunk) {
match &gene.data_raw {
RawCounts::U16(v) => v.iter().for_each(|&x| self.add(x as u32)),
RawCounts::U32(v) => v.iter().for_each(|&x| self.add(x)),
}
}
fn merge(mut self, other: Self) -> Self {
self.dense
.iter_mut()
.zip(&other.dense)
.for_each(|(a, b)| *a += b);
for (k, v) in other.overflow {
*self.overflow.entry(k).or_insert(0) += v;
}
self
}
fn drain_into(self, target: &mut FxHashMap<u32, u64>) {
for (value, freq) in self.dense.into_iter().enumerate() {
if freq > 0 {
*target.entry(value as u32).or_insert(0) += freq;
}
}
for (value, freq) in self.overflow {
*target.entry(value).or_insert(0) += freq;
}
}
}
pub fn sct_gene_pass<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
params: &SctParams,
opts: SctStreamOpts,
) -> Result<SctGenePass, ScErrors> {
if !reader.is_gene_based() {
return Err(ScDataErrors::ReaderModeMismatch {
actual: "cell-based",
requested: "gene-based",
}
.into());
}
if cell_indices.is_empty() {
return Err(CoreErrors::LengthMismatch {
name: "cell_indices",
expected: 1,
found: 0,
}
.into());
}
let verbosity = parse_verbosity_level(opts.verbose);
let start = Instant::now();
let n_cells = cell_indices.len();
let n_genes = reader.get_header().total_genes;
let cell_set = distinct_cell_set(cell_indices)?;
let step = opts.gene_batch_size.unwrap_or(n_genes).max(1);
let mut log_gmean = vec![0.0_f64; n_genes];
let mut amean = vec![0.0_f64; n_genes];
let mut var = vec![0.0_f64; n_genes];
let mut detected = vec![0_usize; n_genes];
let mut histogram: FxHashMap<u32, u64> = FxHashMap::default();
let mut total_counts = 0.0_f64;
let ln_table: Vec<f64> = (0..COUNT_TABLE_LEN)
.map(|x| (x as f64 + params.gmean_eps).ln())
.collect();
for block_start in (0..n_genes).step_by(step) {
let block_end = (block_start + step).min(n_genes);
let block: Vec<usize> = (block_start..block_end).collect();
let chunks = reader.read_gene_parallel_filtered(&block, &cell_set)?;
let accs: Vec<GeneAcc> = chunks
.par_iter()
.map(|c| sweep_gene(c, n_cells, params.gmean_eps, &ln_table))
.collect();
chunks
.par_iter()
.zip(accs.par_iter())
.filter(|(_, acc)| acc.nnz >= params.min_cells)
.fold(CountHistogram::new, |mut hist, (chunk, _)| {
hist.add_gene(chunk);
hist
})
.reduce(CountHistogram::new, CountHistogram::merge)
.drain_into(&mut histogram);
for (chunk, acc) in chunks.iter().zip(accs.iter()) {
let g = chunk.original_index;
let n_f = n_cells as f64;
amean[g] = acc.sum / n_f;
var[g] = if n_cells > 1 {
acc.ss / (n_f - 1.0)
} else {
0.0
};
let gmean = ((acc.sum_log + params.gmean_eps.ln() * (n_cells - acc.nnz) as f64) / n_f)
.exp()
- params.gmean_eps;
log_gmean[g] = gmean.log10();
detected[g] = acc.nnz;
if acc.nnz >= params.min_cells {
total_counts += acc.sum;
}
}
if verbosity.detailed_verbosity() {
report_decile_progress(block_end, block_start, n_genes, "genes", start.elapsed());
}
}
let modelled: Vec<usize> = (0..n_genes)
.filter(|&g| detected[g] >= params.min_cells)
.collect();
if modelled.is_empty() {
return Err(ScErrors::SctNoGenesPassFilter {
min_cells: params.min_cells,
n_genes,
});
}
let median_nonzero = histogram_median(&histogram);
let mean_cell_sum = total_counts / n_cells as f64;
if verbosity.normal_verbosity() {
println!(
"scTransform: swept {} genes in {:.2?}, {} pass the min_cells filter",
n_genes.separate_with_underscores(),
start.elapsed(),
modelled.len()
);
}
Ok(SctGenePass {
stats: SctGeneStats {
log_gmean,
amean,
var,
},
detected,
modelled,
median_nonzero,
mean_cell_sum,
})
}
fn histogram_median(histogram: &FxHashMap<u32, u64>) -> f64 {
let total: u64 = histogram.values().sum();
if total == 0 {
return 0.0;
}
let mut values: Vec<(u32, u64)> = histogram.iter().map(|(&v, &f)| (v, f)).collect();
values.sort_unstable_by_key(|&(v, _)| v);
let want = if total.is_multiple_of(2) {
vec![total / 2 - 1, total / 2]
} else {
vec![total / 2]
};
let mut found = Vec::with_capacity(want.len());
let mut seen = 0_u64;
let mut wi = 0_usize;
for &(value, freq) in &values {
seen += freq;
while wi < want.len() && want[wi] < seen {
found.push(value as f64);
wi += 1;
}
if wi == want.len() {
break;
}
}
found.iter().sum::<f64>() / found.len() as f64
}
fn select_step1_genes(
pass: &SctGenePass,
detected_step1: &[usize],
params: &SctParams,
rng: &mut StdRng,
) -> Result<Vec<usize>, ScErrors> {
let eligible: Vec<usize> = pass
.modelled
.iter()
.copied()
.filter(|&g| {
detected_step1[g] >= params.min_cells
&& pass.stats.var[g] - pass.stats.amean[g] > 0.0
&& pass.stats.log_gmean[g].is_finite()
})
.collect();
if eligible.len() < 2 {
return Err(ScErrors::SctTooFewGenesToRegularise {
kept: eligible.len(),
total: pass.modelled.len(),
});
}
if eligible.len() <= params.n_genes {
return Ok(eligible);
}
let x: Vec<f64> = eligible.iter().map(|&g| pass.stats.log_gmean[g]).collect();
let weights = inverse_density_weights(&x)?;
let mut keyed: Vec<(f64, usize)> = eligible
.iter()
.zip(weights.iter())
.map(|(&g, &w)| {
let u: f64 = rng.random_range(f64::MIN_POSITIVE..1.0);
(u.powf(1.0 / w), g)
})
.collect();
keyed.sort_unstable_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut picked: Vec<usize> = keyed
.into_iter()
.take(params.n_genes)
.map(|(_, g)| g)
.collect();
picked.sort_unstable();
Ok(picked)
}
fn density_grid_span(lo: f64, hi: f64, bw: f64) -> (f64, f64) {
(lo - DENSITY_CUTOFF * bw, hi + DENSITY_CUTOFF * bw)
}
fn inverse_density_weights(x: &[f64]) -> Result<Vec<f64>, ScErrors> {
let bw = bw_nrd(x)?;
let (lo, hi) = x
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), &v| {
(l.min(v), h.max(v))
});
let (from, to) = density_grid_span(lo, hi, bw);
let step = (to - from) / (DENSITY_GRID_POINTS - 1) as f64;
let grid: Vec<f64> = (0..DENSITY_GRID_POINTS)
.map(|i| from + i as f64 * step)
.collect();
let norm = 1.0 / (x.len() as f64 * bw * (2.0 * std::f64::consts::PI).sqrt());
let dens: Vec<f64> = grid
.par_iter()
.map(|&g| {
norm * x
.iter()
.map(|&v| {
let u = (v - g) / bw;
(-0.5 * u * u).exp()
})
.sum::<f64>()
})
.collect();
Ok(x.iter()
.map(|&v| 1.0 / (interp_linear_at(&grid, &dens, v) + f64::EPSILON))
.collect())
}
#[allow(clippy::too_many_arguments)]
pub fn fit_sctransform<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
library_sizes: &[f64],
covariates: &SctCovariates,
params: &SctParams,
step1_genes: Option<&[usize]>,
step1_cells: Option<&[usize]>,
opts: SctStreamOpts,
) -> Result<(SctModel, SctGenePass), ScErrors> {
if library_sizes.len() != cell_indices.len() {
return Err(CoreErrors::LengthMismatch {
name: "library_sizes",
expected: cell_indices.len(),
found: library_sizes.len(),
}
.into());
}
covariates.validate(cell_indices.len())?;
let verbosity = parse_verbosity_level(opts.verbose);
let pass = sct_gene_pass(reader, cell_indices, params, opts)?;
let mut rng = StdRng::seed_from_u64(opts.seed);
let n_cells = cell_indices.len();
let cells: Vec<usize> = match step1_cells {
Some(c) => c.to_vec(),
None if params.n_cells >= n_cells => (0..n_cells).collect(),
None => {
let mut idx: Vec<usize> = (0..n_cells).collect();
for i in 0..params.n_cells {
let j = rng.random_range(i..n_cells);
idx.swap(i, j);
}
let mut c = idx[..params.n_cells].to_vec();
c.sort_unstable();
c
}
};
if let Some(&bad) = cells.iter().find(|&&c| c >= n_cells) {
return Err(ScErrors::SctCellIndexOutOfRange {
index: bad,
n_cells,
});
}
let step1_cells_global: Vec<usize> = cells.iter().map(|&c| cell_indices[c]).collect();
let step1_cell_set = distinct_cell_set(&step1_cells_global)?;
let genes = match step1_genes {
Some(g) => g.to_vec(),
None => {
let detected_step1 = count_detected(reader, &pass.modelled, &step1_cell_set, opts)?;
select_step1_genes(&pass, &detected_step1, params, &mut rng)?
}
};
if verbosity.normal_verbosity() {
println!(
"scTransform: step-1 fit on {} genes by {} cells",
genes.len().separate_with_underscores(),
cells.len().separate_with_underscores()
);
}
let start_fit = Instant::now();
let log_offset: Vec<f64> = cells.iter().map(|&c| library_sizes[c].ln()).collect();
let n_coef = covariates.n_covariates() + 1;
let mut design = Vec::with_capacity(cells.len() * n_coef);
for &c in &cells {
design.push(1.0);
design.extend_from_slice(covariates.row(c));
}
let chunks = reader.read_gene_parallel_filtered(&genes, &step1_cell_set)?;
let fits: Vec<NbOffsetFit> = chunks
.par_iter()
.map(|chunk| {
let dense = densify(chunk, cells.len());
fit_nb_offset_gene(&dense, &log_offset, &design, n_coef)
})
.collect::<Result<Vec<_>, _>>()?;
let fit_idx: Vec<usize> = chunks.iter().map(|c| c.original_index).collect();
let fits = flag_poisson_genes(fits, &fit_idx, &pass.stats, params.poisson_diff_theta);
if verbosity.normal_verbosity() {
println!(
"scTransform: fitted {} genes with {} coefficient(s) in {:.2?}",
fits.len().separate_with_underscores(),
n_coef,
start_fit.elapsed()
);
}
let position: FxHashMap<usize, usize> = pass
.modelled
.iter()
.enumerate()
.map(|(pos, &g)| (g, pos))
.collect();
let mut kept_fits = Vec::with_capacity(fits.len());
let mut kept_idx = Vec::with_capacity(fits.len());
for (fit, g) in fits.into_iter().zip(fit_idx.iter()) {
if let Some(&pos) = position.get(g) {
kept_fits.push(fit);
kept_idx.push(pos);
}
}
let sub_stats = SctGeneStats {
log_gmean: pass
.modelled
.iter()
.map(|&g| pass.stats.log_gmean[g])
.collect(),
amean: pass.modelled.iter().map(|&g| pass.stats.amean[g]).collect(),
var: pass.modelled.iter().map(|&g| pass.stats.var[g]).collect(),
};
let mut model = regularise_sct_model(
&kept_fits,
&kept_idx,
&sub_stats,
&covariates.names,
pass.mean_cell_sum,
min_variance_from_umi_median(pass.median_nonzero),
n_cells,
params,
)?;
model.genes = pass.modelled.clone();
Ok((model, pass))
}
pub fn sct_residual_variance<S: SingleCellReading>(
reader: &S,
model: &SctModel,
cell_indices: &[usize],
cells: &SctCellContext<'_>,
opts: SctStreamOpts,
) -> Result<Vec<f64>, ScErrors> {
let source = SctResiduals::single(model, *cells)?;
let mut per_group = residual_variance(reader, &source, cell_indices, opts)?;
Ok(per_group.remove(0))
}
pub fn sct_corrected_counts<S: SingleCellReading, P: AsRef<Path>>(
reader: &S,
source: &SctResiduals<'_>,
cell_indices: &[usize],
out_path: P,
opts: SctStreamOpts,
) -> Result<(), ScErrors> {
if source.n_cells() != cell_indices.len() {
return Err(CoreErrors::LengthMismatch {
name: "cell_indices",
expected: source.n_cells(),
found: cell_indices.len(),
}
.into());
}
let verbosity = parse_verbosity_level(opts.verbose);
let start = Instant::now();
let cells = source.cells();
let genes = source.genes();
let n_genes = genes.len();
let n_cells = cell_indices.len();
let cell_set = distinct_cell_set(cell_indices)?;
let median_log10_umi = median(cells.log10_umi).unwrap_or(0.0);
let median_covariates = cells.covariates.medians(n_cells);
let mut writer = CellGeneSparseWriter::new(out_path, false, n_cells, n_genes, 0.0)?;
let step = opts.gene_batch_size.unwrap_or(n_genes).max(1);
let mut done = 0_usize;
for block in genes.chunks(step) {
let chunks = reader.read_gene_parallel_filtered(block, &cell_set)?;
let corrected: Vec<CscGeneChunk> = chunks
.par_iter()
.map(|chunk| {
let pos =
source
.position(chunk.original_index)
.ok_or(ScErrors::SctGeneNotModelled {
gene: chunk.original_index,
})?;
Ok(correct_one_gene(
chunk,
pos,
n_cells,
source,
median_log10_umi,
&median_covariates,
))
})
.collect::<Result<Vec<_>, ScErrors>>()?;
for chunk in corrected {
writer.write_gene_chunk(chunk)?;
}
let prev = done;
done += block.len();
if verbosity.detailed_verbosity() {
report_decile_progress(done, prev, n_genes, "genes", start.elapsed());
}
}
writer.finalise()?;
if verbosity.normal_verbosity() {
println!(
"scTransform: wrote {} corrected genes in {:.2?}",
n_genes.separate_with_underscores(),
start.elapsed()
);
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct SctGroupedFit {
pub models: Vec<SctModel>,
pub genes: Vec<usize>,
pub passes: Vec<SctGenePass>,
pub group_of_cell: Vec<u32>,
}
pub fn fit_sctransform_grouped<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
group_of_cell: &[u32],
library_sizes: &[f64],
covariates: &SctCovariates,
params: &SctParams,
opts: SctStreamOpts,
) -> Result<SctGroupedFit, ScErrors> {
let n_groups = validate_groups(group_of_cell, cell_indices.len())?;
if library_sizes.len() != cell_indices.len() {
return Err(CoreErrors::LengthMismatch {
name: "library_sizes",
expected: cell_indices.len(),
found: library_sizes.len(),
}
.into());
}
covariates.validate(cell_indices.len())?;
let verbosity = parse_verbosity_level(opts.verbose);
let start = Instant::now();
let shared_params = SctParams {
clip_range: Some(params.resolve_clip_range(cell_indices.len())),
..*params
};
let mut models = Vec::with_capacity(n_groups);
let mut passes = Vec::with_capacity(n_groups);
for group in 0..n_groups {
let slots: Vec<usize> = group_of_cell
.iter()
.enumerate()
.filter(|&(_, &g)| g as usize == group)
.map(|(slot, _)| slot)
.collect();
let group_cells: Vec<usize> = slots.iter().map(|&s| cell_indices[s]).collect();
let group_sizes: Vec<f64> = slots.iter().map(|&s| library_sizes[s]).collect();
let group_covariates = covariates.subset(&slots)?;
let (model, pass) = fit_sctransform(
reader,
&group_cells,
&group_sizes,
&group_covariates,
&shared_params,
None,
None,
opts,
)
.map_err(|e| ScErrors::ResidualGroupFitFailed {
group,
reason: e.to_string(),
})?;
if verbosity.normal_verbosity() {
println!(
"scTransform: group {group} fitted over {} cells, {} genes ({:.2?})",
group_cells.len().separate_with_underscores(),
model.len().separate_with_underscores(),
start.elapsed()
);
}
models.push(model);
passes.push(pass);
}
let sets: Vec<&[usize]> = models.iter().map(|m| m.genes.as_slice()).collect();
let genes = intersect_gene_sets(&sets)?;
if verbosity.normal_verbosity() {
println!(
"scTransform: {} gene(s) modelled in all {n_groups} group(s)",
genes.len().separate_with_underscores()
);
}
Ok(SctGroupedFit {
models,
genes,
passes,
group_of_cell: group_of_cell.to_vec(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn stats(amean: Vec<f64>, var: Vec<f64>) -> SctGeneStats {
SctGeneStats {
log_gmean: amean.iter().map(|v| v.log10()).collect(),
amean,
var,
}
}
#[test]
fn test_flag_poisson_genes_overrides_a_confident_fit() {
let s = stats(vec![10.0, 10.0], vec![110.0, 110.0]);
let fits = vec![
NbOffsetFit {
theta: 1.0,
coefficients: vec![-5.0],
converged: true,
},
NbOffsetFit {
theta: 10_000.0,
coefficients: vec![-5.0],
converged: true,
},
];
let out = flag_poisson_genes(fits, &[0, 1], &s, 1e-3);
assert_relative_eq!(out[0].theta, 1.0, max_relative = 1e-12);
assert!(out[1].theta.is_infinite());
assert_relative_eq!(out[1].intercept(), -5.0, max_relative = 1e-12);
}
#[test]
fn test_flag_poisson_genes_handles_underdispersion() {
let s = stats(vec![10.0], vec![5.0]);
let fits = vec![NbOffsetFit {
theta: 2.0,
coefficients: vec![-5.0],
converged: true,
}];
let out = flag_poisson_genes(fits, &[0], &s, 1e-3);
assert!(out[0].theta.is_infinite());
}
#[test]
fn test_flag_poisson_genes_leaves_infinite_theta_alone() {
let s = stats(vec![10.0], vec![110.0]);
let fits = vec![NbOffsetFit {
theta: f64::INFINITY,
coefficients: vec![-5.0],
converged: true,
}];
let out = flag_poisson_genes(fits, &[0], &s, 1e-3);
assert!(out[0].theta.is_infinite());
}
#[test]
fn test_histogram_median_matches_r_on_an_even_count() {
let mut h: FxHashMap<u32, u64> = FxHashMap::default();
h.insert(1, 2);
h.insert(2, 1);
h.insert(3, 1);
assert_relative_eq!(histogram_median(&h), 1.5, max_relative = 1e-12);
}
#[test]
fn test_histogram_median_on_an_odd_count() {
let mut h: FxHashMap<u32, u64> = FxHashMap::default();
h.insert(1, 1);
h.insert(2, 3);
h.insert(9, 1);
assert_relative_eq!(histogram_median(&h), 2.0, max_relative = 1e-12);
}
#[test]
fn test_histogram_median_of_nothing_is_zero() {
assert_relative_eq!(
histogram_median(&FxHashMap::default()),
0.0,
max_relative = 1e-12
);
}
#[allow(clippy::excessive_precision)]
#[test]
fn test_inverse_density_weights_match_r_density() {
let x = [
-2.5, -2.1, -1.9, -1.85, -1.8, -0.4, -0.35, -0.3, 0.0, 0.05, 0.1, 0.12, 0.9, 1.4, 2.2,
3.1, 3.15, 4.0,
];
let want = [
1.03458082384436933e-01,
1.25931894605516459e-01,
1.36044862074708311e-01,
1.38444272035506244e-01,
1.40792693177528960e-01,
1.82506541618378482e-01,
1.82751321578175946e-01,
1.82867864041406952e-01,
1.80786853199413428e-01,
1.79965686232037181e-01,
1.79011125770661916e-01,
1.78587221821085107e-01,
1.48949556824229640e-01,
1.24884348041357951e-01,
9.70968112444874215e-02,
7.94907425133158096e-02,
7.84809737058110718e-02,
5.60556936261453892e-02,
];
let bw = bw_nrd(&x).unwrap();
assert_relative_eq!(bw, 1.15072852580446972e+00, epsilon = 1e-12);
let (from, to) = density_grid_span(-2.5, 4.0, bw);
assert_relative_eq!(from, -5.95218557741340959e+00, epsilon = 1e-12);
assert_relative_eq!(to, 7.45218557741340959e+00, epsilon = 1e-12);
let w = inverse_density_weights(&x).unwrap();
for (i, &want_i) in want.iter().enumerate() {
let got = 1.0 / w[i];
assert_relative_eq!(got, want_i, max_relative = 1e-3);
}
}
#[test]
fn test_inverse_density_weights_favour_sparse_regions() {
let mut x: Vec<f64> = (0..200).map(|i| i as f64 * 0.001).collect();
x.push(5.0);
let w = inverse_density_weights(&x).unwrap();
let isolated = w[w.len() - 1];
let crowded = w[100];
assert!(
isolated > crowded * 10.0,
"isolated weight {isolated} should dwarf the crowded one {crowded}"
);
assert!(w.iter().all(|v| v.is_finite()));
}
}