use rayon::prelude::*;
use std::time::Instant;
use thousands::Separable;
use crate::core::base::info::{BinningStrategy, parse_bin_strategy_type};
use crate::core::base::loess::*;
use crate::prelude::*;
const GENE_BATCH_SIZE: usize = 1000;
const MAX_HISTOGRAM_SPAN: usize = 1024;
const MIN_EXPECTED_VAR: f32 = f32::MIN_POSITIVE;
#[derive(Clone, Debug)]
pub struct HvgRes {
pub mean: Vec<f64>,
pub var: Vec<f64>,
pub var_exp: Vec<f64>,
pub var_std: Vec<f64>,
}
#[derive(Clone, Debug)]
pub struct HvgDispersionRes {
pub mean: Vec<f64>,
pub dispersion: Vec<f64>,
pub dispersion_scaled: Vec<f64>,
pub bin: Vec<i32>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum HvgMethod {
#[default]
Vst,
MeanVarBin,
Dispersion,
}
pub fn parse_hvg_method(s: &str) -> Option<HvgMethod> {
match s.to_lowercase().as_str() {
"vst" => Some(HvgMethod::Vst),
"meanvarbin" => Some(HvgMethod::MeanVarBin),
"dispersion" => Some(HvgMethod::Dispersion),
_ => None,
}
}
#[derive(Clone, Copy, Debug)]
pub struct HvgRunOpts {
pub gene_batch_size: Option<usize>,
pub verbose: usize,
}
#[derive(Clone, Debug)]
pub struct CellBatchIndex {
lookup: Vec<u32>,
batch_sizes: Vec<usize>,
}
impl CellBatchIndex {
pub fn new(
total_cells: usize,
cell_indices: &[usize],
batch_labels: Option<&[usize]>,
) -> Result<Self, BixverseErrors> {
if cell_indices.is_empty() {
return Err(BixverseErrors::HvgEmptySelection);
}
if let Some(labels) = batch_labels
&& labels.len() != cell_indices.len()
{
return Err(BixverseErrors::HvgBatchLabelLengthMismatch {
n_labels: labels.len(),
n_cells: cell_indices.len(),
});
}
let n_batches = match batch_labels {
Some(labels) => labels.iter().copied().max().map_or(1, |m| m + 1),
None => 1,
};
let tally_len = n_batches.min(cell_indices.len() + 1);
let mut batch_sizes = vec![0usize; tally_len];
match batch_labels {
Some(labels) => {
for &batch in labels {
if batch < tally_len {
batch_sizes[batch] += 1;
}
}
}
None => batch_sizes[0] = cell_indices.len(),
}
if let Some(batch) = batch_sizes.iter().position(|&size| size == 0) {
return Err(BixverseErrors::HvgEmptyBatch { batch, n_batches });
}
let mut lookup = vec![0u32; total_cells];
for (i, &cell) in cell_indices.iter().enumerate() {
if cell >= total_cells {
return Err(BixverseErrors::HvgCellIndexOutOfRange {
index: cell,
total_cells,
});
}
if lookup[cell] != 0 {
return Err(BixverseErrors::HvgDuplicateCellIndex { index: cell });
}
let batch = batch_labels.map_or(0, |labels| labels[i]);
lookup[cell] = batch as u32 + 1;
}
Ok(Self {
lookup,
batch_sizes,
})
}
#[inline]
pub fn n_batches(&self) -> usize {
self.batch_sizes.len()
}
#[inline]
pub fn n_slots(&self) -> usize {
self.batch_sizes.len() + 1
}
#[inline]
pub fn batch_sizes(&self) -> &[usize] {
&self.batch_sizes
}
#[inline(always)]
pub fn slot(&self, cell_id: u32) -> usize {
self.lookup.get(cell_id as usize).copied().unwrap_or(0) as usize
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct GeneStats {
pub mean: f32,
pub var: f32,
pub max_count: u32,
pub min_count: u32,
}
#[derive(Clone, Copy, Debug)]
struct RawAccumulator {
sum: f64,
sum_sq: f64,
max: u32,
min: u32,
nnz: usize,
}
impl Default for RawAccumulator {
fn default() -> Self {
Self {
sum: 0.0,
sum_sq: 0.0,
max: 0,
min: u32::MAX,
nnz: 0,
}
}
}
impl RawAccumulator {
#[inline]
fn finish(&self, no_cells: usize) -> GeneStats {
if no_cells == 0 || self.nnz == 0 {
return GeneStats::default();
}
let n = no_cells as f64;
let mean = self.sum / n;
let var = ((self.sum_sq - n * mean * mean) / n).max(0.0);
GeneStats {
mean: mean as f32,
var: var as f32,
max_count: self.max,
min_count: if self.nnz < no_cells { 0 } else { self.min },
}
}
}
pub struct HistogramScratch {
counters: Vec<u32>,
accumulators: Vec<RawAccumulator>,
}
impl HistogramScratch {
pub fn new(n_slots: usize) -> Self {
Self {
counters: Vec::new(),
accumulators: vec![RawAccumulator::default(); n_slots],
}
}
}
#[inline]
fn tally_counts<V: Copy + Into<u32>>(
values: &[V],
indices: &[u32],
index: &CellBatchIndex,
counters: &mut [u32],
stride: usize,
) {
for (&cell_id, &value) in indices.iter().zip(values.iter()) {
let slot = index.slot(cell_id);
counters[slot * stride + Into::<u32>::into(value) as usize] += 1;
}
}
#[inline]
fn accumulate_counts<V: Copy + Into<u32>>(
values: &[V],
indices: &[u32],
index: &CellBatchIndex,
accumulators: &mut [RawAccumulator],
) {
for (&cell_id, &value) in indices.iter().zip(values.iter()) {
let slot = index.slot(cell_id);
if slot == 0 {
continue;
}
let value = Into::<u32>::into(value);
let as_f64 = value as f64;
let acc = &mut accumulators[slot];
acc.sum += as_f64;
acc.sum_sq += as_f64 * as_f64;
acc.max = acc.max.max(value);
acc.min = acc.min.min(value);
acc.nnz += 1;
}
}
#[inline]
fn max_raw_count(gene: &CscGeneChunk) -> u32 {
match &gene.data_raw {
RawCounts::U16(values) => values.iter().copied().max().unwrap_or(0) as u32,
RawCounts::U32(values) => values.iter().copied().max().unwrap_or(0),
}
}
pub fn gene_stats(
gene: &CscGeneChunk,
index: &CellBatchIndex,
scratch: &mut HistogramScratch,
out: &mut [GeneStats],
) {
if gene.indices.is_empty() {
out.fill(GeneStats::default());
return;
}
let n_slots = index.n_slots();
let stride = max_raw_count(gene) as usize + 1;
if stride <= MAX_HISTOGRAM_SPAN {
scratch.counters.clear();
scratch.counters.resize(n_slots * stride, 0);
match &gene.data_raw {
RawCounts::U16(values) => {
tally_counts(values, &gene.indices, index, &mut scratch.counters, stride)
}
RawCounts::U32(values) => {
tally_counts(values, &gene.indices, index, &mut scratch.counters, stride)
}
}
for (batch, stats) in out.iter_mut().enumerate() {
let base = (batch + 1) * stride;
let mut acc = RawAccumulator::default();
for (value, &freq) in scratch.counters[base..base + stride].iter().enumerate() {
if freq == 0 {
continue;
}
if acc.nnz == 0 {
acc.min = value as u32;
}
acc.max = value as u32;
let value = value as f64;
let freq = freq as f64;
acc.sum += freq * value;
acc.sum_sq += freq * value * value;
acc.nnz += freq as usize;
}
*stats = acc.finish(index.batch_sizes()[batch]);
}
} else {
scratch.accumulators.fill(RawAccumulator::default());
match &gene.data_raw {
RawCounts::U16(values) => {
accumulate_counts(values, &gene.indices, index, &mut scratch.accumulators)
}
RawCounts::U32(values) => {
accumulate_counts(values, &gene.indices, index, &mut scratch.accumulators)
}
}
for (batch, stats) in out.iter_mut().enumerate() {
*stats = scratch.accumulators[batch + 1].finish(index.batch_sizes()[batch]);
}
}
}
#[inline]
pub fn clip_is_reachable(mean: f32, min: f32, max: f32, expected_var: f32, clip_max: f32) -> bool {
if !expected_var.is_finite() || expected_var < MIN_EXPECTED_VAR {
return true;
}
let reach = clip_max * expected_var.sqrt();
(max - mean) > reach || (mean - min) > reach
}
pub fn std_variance_exact(
gene: &CscGeneChunk,
index: &CellBatchIndex,
params: &[(f32, f32, f32)],
scratch: &mut [(f64, f64, usize)],
out: &mut [f32],
) {
for slot in scratch.iter_mut() {
*slot = (0.0, 0.0, 0);
}
for (i, &cell_id) in gene.indices.iter().enumerate() {
let slot = index.slot(cell_id);
if slot == 0 {
continue;
}
let (mean, expected_sd, clip_max) = params[slot - 1];
let norm = ((gene.data_raw.get(i) as f32 - mean) / expected_sd)
.min(clip_max)
.max(-clip_max) as f64;
scratch[slot].0 += norm;
scratch[slot].1 += norm * norm;
scratch[slot].2 += 1;
}
for (batch, value) in out.iter_mut().enumerate() {
let no_cells = index.batch_sizes()[batch];
if no_cells == 0 {
*value = 0.0;
continue;
}
let (mean, expected_sd, clip_max) = params[batch];
let (mut sum, mut sum_sq, nnz) = scratch[batch + 1];
let n_zeros = no_cells.saturating_sub(nnz);
if n_zeros > 0 {
let norm = ((-mean) / expected_sd).min(clip_max).max(-clip_max) as f64;
let n_zeros = n_zeros as f64;
sum += n_zeros * norm;
sum_sq += n_zeros * norm * norm;
}
let n = no_cells as f64;
let standardised_mean = sum / n;
*value = ((sum_sq / n) - standardised_mean * standardised_mean) as f32;
}
}
pub fn accumulate_disp_stats(gene: &CscGeneChunk, index: &CellBatchIndex, out: &mut [(f64, f64)]) {
for (i, &cell_id) in gene.indices.iter().enumerate() {
let slot = index.slot(cell_id);
if slot == 0 {
continue;
}
let value = (gene.data_norm[i].to_f32() as f64).exp_m1();
out[slot].0 += value;
out[slot].1 += value * value;
}
}
#[inline]
pub fn disp_stats_from_sums(sum: f64, sum_sq: f64, no_cells: usize) -> (f32, f32) {
let n = no_cells as f64;
let mean = if n > 0.0 { sum / n } else { 0.0 };
let var = if n > 1.0 {
((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)
} else {
0.0
};
let exp_mean = mean.ln_1p() as f32;
let log_vmr = if mean > 0.0 && var > 0.0 {
(var / mean).ln() as f32
} else {
f32::NAN
};
(exp_mean, log_vmr)
}
fn bin_features(means: &[f32], method: BinningStrategy, n_bins: usize) -> Vec<i32> {
let n = means.len();
let valid: Vec<bool> = means.iter().map(|v| v.is_finite()).collect();
match method {
BinningStrategy::EqualWidth => {
let (mut vmin, mut vmax) = (f32::INFINITY, f32::NEG_INFINITY);
for i in 0..n {
if valid[i] {
if means[i] < vmin {
vmin = means[i];
}
if means[i] > vmax {
vmax = means[i];
}
}
}
if !vmin.is_finite() || !vmax.is_finite() || vmax <= vmin {
return vec![-1; n];
}
let width = (vmax - vmin) / n_bins as f32;
(0..n)
.map(|i| {
if !valid[i] {
-1
} else {
let idx = ((means[i] - vmin) / width) as i32;
idx.clamp(0, n_bins as i32 - 1)
}
})
.collect()
}
BinningStrategy::EqualFrequency => {
let mut nonzero: Vec<f32> = means
.iter()
.zip(valid.iter())
.filter_map(|(&v, &ok)| if ok && v > 0.0 { Some(v) } else { None })
.collect();
if nonzero.len() < 2 {
return vec![-1; n];
}
nonzero.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let last = nonzero.len() - 1;
let breaks: Vec<f32> = (0..=n_bins)
.map(|i| {
let p = i as f32 / n_bins as f32;
let idx = (p * last as f32).round() as usize;
nonzero[idx.min(last)]
})
.collect();
(0..n)
.map(|i| {
if !valid[i] {
return -1;
}
let v = means[i];
if v < breaks[0] {
return 0;
}
for b in 0..n_bins {
if v <= breaks[b + 1] {
return b as i32;
}
}
(n_bins - 1) as i32
})
.collect()
}
}
}
fn scale_within_bins(dispersion: &[f32], bins: &[i32], n_bins: usize) -> Vec<f32> {
let mut sums = vec![0f64; n_bins];
let mut sums_sq = vec![0f64; n_bins];
let mut counts = vec![0usize; n_bins];
for (&d, &b) in dispersion.iter().zip(bins.iter()) {
if b >= 0 && d.is_finite() {
let bi = b as usize;
let df = d as f64;
sums[bi] += df;
sums_sq[bi] += df * df;
counts[bi] += 1;
}
}
let stats: Vec<(f64, f64)> = (0..n_bins)
.map(|i| {
if counts[i] < 2 {
(0.0, 0.0)
} else {
let c = counts[i] as f64;
let m = sums[i] / c;
let var = ((sums_sq[i] - c * m * m) / (c - 1.0)).max(0.0);
(m, var.sqrt())
}
})
.collect();
dispersion
.iter()
.zip(bins.iter())
.map(|(&d, &b)| {
if b < 0 || !d.is_finite() {
return 0.0;
}
let (m, sd) = stats[b as usize];
if sd > 0.0 {
((d as f64 - m) / sd) as f32
} else {
0.0
}
})
.collect()
}
pub fn build_disp_result(
means: Vec<f32>,
dispersions: Vec<f32>,
binning: BinningStrategy,
n_bins: usize,
) -> HvgDispersionRes {
let bins = bin_features(&means, binning, n_bins);
let scaled = scale_within_bins(&dispersions, &bins, n_bins);
let means_clean: Vec<f32> = means
.iter()
.map(|&v| if v.is_finite() { v } else { 0.0 })
.collect();
let disp_clean: Vec<f32> = dispersions
.iter()
.map(|&v| if v.is_finite() { v } else { 0.0 })
.collect();
HvgDispersionRes {
mean: means_clean.r_float_convert(),
dispersion: disp_clean.r_float_convert(),
dispersion_scaled: scaled.r_float_convert(),
bin: bins,
}
}
fn gene_blocks(no_genes: usize, gene_batch_size: Option<usize>) -> Vec<(usize, usize)> {
let step = gene_batch_size.unwrap_or(no_genes).max(1);
(0..no_genes.div_ceil(step))
.map(|i| (i * step, ((i + 1) * step).min(no_genes)))
.collect()
}
fn sweep_gene_block<S: SingleCellReading>(
reader: &S,
gene_indices: &[usize],
index: &CellBatchIndex,
out: &mut [GeneStats],
) -> Result<(), BixverseErrors> {
let genes = reader.read_gene_parallel(gene_indices)?;
let n_batches = index.n_batches();
let n_slots = index.n_slots();
genes
.par_iter()
.zip(out.par_chunks_mut(n_batches))
.for_each_init(
|| HistogramScratch::new(n_slots),
|scratch, (gene, stats)| gene_stats(gene, index, scratch, stats),
);
Ok(())
}
pub fn run_hvg_vst<S: SingleCellReading>(
reader: &S,
index: &CellBatchIndex,
loess_span: f32,
clip_max: Option<f32>,
opts: HvgRunOpts,
) -> Result<Vec<HvgRes>, BixverseErrors> {
if !(loess_span > 0.0 && loess_span <= 1.0) {
return Err(BixverseErrors::HvgInvalidLoessSpan { span: loess_span });
}
let verbosity = parse_verbosity_level(opts.verbose);
let start_total = Instant::now();
let no_genes = reader.get_header().total_genes;
let n_batches = index.n_batches();
let n_slots = index.n_slots();
let blocks = gene_blocks(no_genes, opts.gene_batch_size);
if verbosity.normal_verbosity() {
println!(
"HVG (VST): {} genes, {} batches, {} disk block(s)",
no_genes.separate_with_underscores(),
n_batches,
blocks.len()
);
}
let start_pass1 = Instant::now();
let mut stats: Vec<GeneStats> = vec![GeneStats::default(); no_genes * n_batches];
for &(start_gene, end_gene) in blocks.iter() {
let gene_indices: Vec<usize> = (start_gene..end_gene).collect();
sweep_gene_block(
reader,
&gene_indices,
index,
&mut stats[start_gene * n_batches..end_gene * n_batches],
)?;
}
if verbosity.normal_verbosity() {
println!(
"HVG (VST): Calculated gene statistics in {:.2?}",
start_pass1.elapsed()
);
}
let start_loess = Instant::now();
let mut loess_results = Vec::with_capacity(n_batches);
let mut clips = Vec::with_capacity(n_batches);
for batch in 0..n_batches {
clips.push(clip_max.unwrap_or((index.batch_sizes()[batch] as f32).sqrt()));
let means_log10: Vec<f32> = stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.mean.log10())
.collect();
let vars_log10: Vec<f32> = stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.var.log10())
.collect();
let loess = LoessRegression::new(loess_span, 2);
loess_results.push(loess.fit(&means_log10, &vars_log10));
}
if verbosity.normal_verbosity() {
println!("HVG (VST): Fitted Loess in {:.2?}", start_loess.elapsed());
}
let start_pass2 = Instant::now();
let mut var_std: Vec<f32> = vec![0.0; no_genes * n_batches];
let needs_exact: Vec<usize> = stats
.par_chunks(n_batches)
.zip(var_std.par_chunks_mut(n_batches))
.enumerate()
.filter_map(|(gene, (gene_stats, out))| {
let mut reachable = false;
for (batch, value) in out.iter_mut().enumerate() {
let stats = &gene_stats[batch];
let expected_var = 10_f32.powf(loess_results[batch].fitted_vals[gene]);
if clip_is_reachable(
stats.mean,
stats.min_count as f32,
stats.max_count as f32,
expected_var,
clips[batch],
) {
reachable = true;
} else {
*value = stats.var / expected_var;
}
}
if reachable { Some(gene) } else { None }
})
.collect();
if !needs_exact.is_empty() {
let genes = reader.read_gene_parallel(&needs_exact)?;
let mut exact = vec![0f32; needs_exact.len() * n_batches];
genes
.par_iter()
.zip(needs_exact.par_iter())
.zip(exact.par_chunks_mut(n_batches))
.for_each_init(
|| vec![(0f64, 0f64, 0usize); n_slots],
|scratch, ((gene, &gene_idx), out)| {
let params: Vec<(f32, f32, f32)> = (0..n_batches)
.map(|batch| {
let expected_var =
10_f32.powf(loess_results[batch].fitted_vals[gene_idx]);
(
stats[gene_idx * n_batches + batch].mean,
expected_var.sqrt(),
clips[batch],
)
})
.collect();
std_variance_exact(gene, index, ¶ms, scratch, out);
},
);
for (&gene, values) in needs_exact.iter().zip(exact.chunks(n_batches)) {
var_std[gene * n_batches..(gene + 1) * n_batches].copy_from_slice(values);
}
}
if verbosity.normal_verbosity() {
println!(
"HVG (VST): Standardised variance in {:.2?} ({} gene(s) re-read for the clip)",
start_pass2.elapsed(),
needs_exact.len().separate_with_underscores()
);
println!("HVG (VST): Total run time -> {:.2?}", start_total.elapsed());
}
Ok(loess_results
.into_iter()
.enumerate()
.map(|(batch, loess_res)| HvgRes {
mean: stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.mean as f64)
.collect(),
var: stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.var as f64)
.collect(),
var_exp: loess_res.fitted_vals.r_float_convert(),
var_std: var_std[batch..]
.iter()
.step_by(n_batches)
.map(|&v| v as f64)
.collect(),
})
.collect())
}
pub fn run_hvg_dispersion<S: SingleCellReading>(
reader: &S,
index: &CellBatchIndex,
binning: BinningStrategy,
n_bins: usize,
opts: HvgRunOpts,
) -> Result<Vec<HvgDispersionRes>, BixverseErrors> {
if n_bins == 0 {
return Err(BixverseErrors::HvgInvalidBinCount);
}
let verbosity = parse_verbosity_level(opts.verbose);
let start_total = Instant::now();
let no_genes = reader.get_header().total_genes;
let n_batches = index.n_batches();
let n_slots = index.n_slots();
let blocks = gene_blocks(no_genes, opts.gene_batch_size);
if verbosity.normal_verbosity() {
println!(
"HVG (dispersion): {} genes, {} batches, {} disk block(s)",
no_genes.separate_with_underscores(),
n_batches,
blocks.len()
);
}
let start_stats = Instant::now();
let mut stats: Vec<(f32, f32)> = vec![(0.0, 0.0); no_genes * n_batches];
for &(start_gene, end_gene) in blocks.iter() {
let gene_indices: Vec<usize> = (start_gene..end_gene).collect();
let genes = reader.read_gene_parallel(&gene_indices)?;
genes
.par_iter()
.zip(stats[start_gene * n_batches..end_gene * n_batches].par_chunks_mut(n_batches))
.for_each_init(
|| vec![(0f64, 0f64); n_slots],
|acc, (gene, out)| {
acc.fill((0.0, 0.0));
accumulate_disp_stats(gene, index, acc);
for (batch, slot) in out.iter_mut().enumerate() {
let (sum, sum_sq) = acc[batch + 1];
*slot = disp_stats_from_sums(sum, sum_sq, index.batch_sizes()[batch]);
}
},
);
}
if verbosity.normal_verbosity() {
println!(
"HVG (dispersion): Calculated gene statistics in {:.2?}",
start_stats.elapsed()
);
}
let start_bin = Instant::now();
let out: Vec<HvgDispersionRes> = (0..n_batches)
.map(|batch| {
let mean: Vec<f32> = stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.0)
.collect();
let dispersion: Vec<f32> = stats[batch..]
.iter()
.step_by(n_batches)
.map(|s| s.1)
.collect();
build_disp_result(mean, dispersion, binning, n_bins)
})
.collect();
if verbosity.normal_verbosity() {
println!(
"HVG (dispersion): Binning and scaling in {:.2?}",
start_bin.elapsed()
);
println!(
"HVG (dispersion): Total run time -> {:.2?}",
start_total.elapsed()
);
}
Ok(out)
}
fn single_batch<T>(results: Vec<T>) -> Result<T, BixverseErrors> {
results
.into_iter()
.next()
.ok_or(BixverseErrors::HvgEmptySelection)
}
pub fn get_hvg_vst<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
loess_span: f32,
clip_max: Option<f32>,
verbose: usize,
) -> Result<HvgRes, BixverseErrors> {
let index = CellBatchIndex::new(reader.get_header().total_cells, cell_indices, None)?;
let opts = HvgRunOpts {
gene_batch_size: None,
verbose,
};
single_batch(run_hvg_vst(reader, &index, loess_span, clip_max, opts)?)
}
pub fn get_hvg_vst_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
loess_span: f32,
clip_max: Option<f32>,
verbose: usize,
) -> Result<HvgRes, BixverseErrors> {
let index = CellBatchIndex::new(reader.get_header().total_cells, cell_indices, None)?;
let opts = HvgRunOpts {
gene_batch_size: Some(GENE_BATCH_SIZE),
verbose,
};
single_batch(run_hvg_vst(reader, &index, loess_span, clip_max, opts)?)
}
pub fn get_hvg_dispersion<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<HvgDispersionRes, BixverseErrors> {
let index = CellBatchIndex::new(reader.get_header().total_cells, cell_indices, None)?;
let opts = HvgRunOpts {
gene_batch_size: None,
verbose,
};
let binning = parse_bin_strategy_type(binning).unwrap_or_default();
single_batch(run_hvg_dispersion(reader, &index, binning, n_bins, opts)?)
}
pub fn get_hvg_dispersion_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<HvgDispersionRes, BixverseErrors> {
let index = CellBatchIndex::new(reader.get_header().total_cells, cell_indices, None)?;
let opts = HvgRunOpts {
gene_batch_size: Some(GENE_BATCH_SIZE),
verbose,
};
let binning = parse_bin_strategy_type(binning).unwrap_or_default();
single_batch(run_hvg_dispersion(reader, &index, binning, n_bins, opts)?)
}
pub fn get_hvg_mvb<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<HvgDispersionRes, BixverseErrors> {
get_hvg_dispersion(reader, cell_indices, binning, n_bins, verbose)
}
pub fn get_hvg_mvb_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<HvgDispersionRes, BixverseErrors> {
get_hvg_dispersion_streaming(reader, cell_indices, binning, n_bins, verbose)
}
pub fn get_hvg_vst_batch_aware<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
loess_span: f32,
clip_max: Option<f32>,
verbose: usize,
) -> Result<Vec<HvgRes>, BixverseErrors> {
let index = CellBatchIndex::new(
reader.get_header().total_cells,
cell_indices,
Some(batch_labels),
)?;
let opts = HvgRunOpts {
gene_batch_size: None,
verbose,
};
run_hvg_vst(reader, &index, loess_span, clip_max, opts)
}
pub fn get_hvg_vst_batch_aware_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
loess_span: f32,
clip_max: Option<f32>,
verbose: usize,
) -> Result<Vec<HvgRes>, BixverseErrors> {
let index = CellBatchIndex::new(
reader.get_header().total_cells,
cell_indices,
Some(batch_labels),
)?;
let opts = HvgRunOpts {
gene_batch_size: Some(GENE_BATCH_SIZE),
verbose,
};
run_hvg_vst(reader, &index, loess_span, clip_max, opts)
}
pub fn get_hvg_dispersion_batch_aware<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<Vec<HvgDispersionRes>, BixverseErrors> {
let index = CellBatchIndex::new(
reader.get_header().total_cells,
cell_indices,
Some(batch_labels),
)?;
let opts = HvgRunOpts {
gene_batch_size: None,
verbose,
};
let binning = parse_bin_strategy_type(binning).unwrap_or_default();
run_hvg_dispersion(reader, &index, binning, n_bins, opts)
}
pub fn get_hvg_dispersion_batch_aware_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<Vec<HvgDispersionRes>, BixverseErrors> {
let index = CellBatchIndex::new(
reader.get_header().total_cells,
cell_indices,
Some(batch_labels),
)?;
let opts = HvgRunOpts {
gene_batch_size: Some(GENE_BATCH_SIZE),
verbose,
};
let binning = parse_bin_strategy_type(binning).unwrap_or_default();
run_hvg_dispersion(reader, &index, binning, n_bins, opts)
}
pub fn get_hvg_mvb_batch_aware<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<Vec<HvgDispersionRes>, BixverseErrors> {
get_hvg_dispersion_batch_aware(reader, cell_indices, batch_labels, binning, n_bins, verbose)
}
pub fn get_hvg_mvb_batch_aware_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
batch_labels: &[usize],
binning: &str,
n_bins: usize,
verbose: usize,
) -> Result<Vec<HvgDispersionRes>, BixverseErrors> {
get_hvg_dispersion_batch_aware_streaming(
reader,
cell_indices,
batch_labels,
binning,
n_bins,
verbose,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::single_cell::sc_data::data_io::CellGeneSparseWriter;
use approx::assert_relative_eq;
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_hvg_{name}.bin")))
}
fn path(&self) -> &str {
self.0.to_str().expect("temp path is valid UTF-8")
}
}
fn norm_of(count: u32) -> F16 {
F16::from_f32((count as f32).ln_1p())
}
fn write_store(path: &str, dense: &[Vec<u32>], n_cells: usize) {
let mut writer = CellGeneSparseWriter::new(path, false, n_cells, dense.len(), 1e4)
.expect("writer opens");
for (gene_idx, gene) in dense.iter().enumerate() {
let mut raw = Vec::new();
let mut indices = Vec::new();
for (cell, &value) in gene.iter().enumerate() {
if value > 0 {
raw.push(value);
indices.push(cell);
}
}
let norms: Vec<F16> = raw.iter().map(|&v| norm_of(v)).collect();
writer
.write_gene_chunk(CscGeneChunk::from_conversion(
RawCounts::from_u32_auto(&raw),
&norms,
&indices,
gene_idx,
true,
))
.expect("write gene chunk");
}
writer.finalise().expect("finalise");
}
fn synthetic_counts(n_genes: usize, n_cells: usize) -> Vec<Vec<u32>> {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut dense = vec![vec![0u32; n_cells]; n_genes];
for (gene_idx, gene) in dense.iter_mut().enumerate() {
for value in gene.iter_mut() {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let draw = (state >> 33) as u32;
if draw % 100 >= 60 {
*value = (draw % 17) + (gene_idx as u32 % 4) + 1;
}
}
}
dense
}
fn reference_vst(
dense: &[Vec<u32>],
cells: &[usize],
loess_span: f32,
clip_max: Option<f32>,
) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
let n = cells.len() as f64;
let mut means = Vec::with_capacity(dense.len());
let mut vars = Vec::with_capacity(dense.len());
for gene in dense {
let mut sum = 0f64;
let mut sum_sq = 0f64;
for &cell in cells {
let value = gene[cell] as f64;
sum += value;
sum_sq += value * value;
}
let mean = sum / n;
means.push(mean as f32);
vars.push(((sum_sq - n * mean * mean) / n).max(0.0) as f32);
}
let clip = clip_max.unwrap_or((cells.len() as f32).sqrt());
let means_log10: Vec<f32> = means.iter().map(|x| x.log10()).collect();
let vars_log10: Vec<f32> = vars.iter().map(|x| x.log10()).collect();
let loess = LoessRegression::new(loess_span, 2).fit(&means_log10, &vars_log10);
let mut var_std = Vec::with_capacity(dense.len());
for (gene_idx, gene) in dense.iter().enumerate() {
let expected_sd = 10_f32.powf(loess.fitted_vals[gene_idx]).sqrt();
let mut sum = 0f64;
let mut sum_sq = 0f64;
for &cell in cells {
let norm = ((gene[cell] as f32 - means[gene_idx]) / expected_sd)
.min(clip)
.max(-clip) as f64;
sum += norm;
sum_sq += norm * norm;
}
let standardised_mean = sum / n;
var_std.push(((sum_sq / n) - standardised_mean * standardised_mean) as f32);
}
(means, vars, loess.fitted_vals, var_std)
}
fn reference_dispersion(dense: &[Vec<u32>], cells: &[usize]) -> (Vec<f32>, Vec<f32>) {
let n = cells.len() as f64;
let mut means = Vec::with_capacity(dense.len());
let mut dispersions = Vec::with_capacity(dense.len());
for gene in dense {
let mut sum = 0f64;
let mut sum_sq = 0f64;
for &cell in cells {
if gene[cell] == 0 {
continue;
}
let value = (norm_of(gene[cell]).to_f32() as f64).exp_m1();
sum += value;
sum_sq += value * value;
}
let mean = sum / n;
let var = if n > 1.0 {
((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)
} else {
0.0
};
let exp_mean = mean.ln_1p() as f32;
let log_vmr = if mean > 0.0 && var > 0.0 {
(var / mean).ln() as f32
} else {
0.0
};
means.push(if exp_mean.is_finite() { exp_mean } else { 0.0 });
dispersions.push(log_vmr);
}
(means, dispersions)
}
fn reader_for(temp: &TempStore, dense: &[Vec<u32>], n_cells: usize) -> ParallelSparseReader {
write_store(temp.path(), dense, n_cells);
ParallelSparseReader::new(temp.path()).expect("reader opens")
}
#[test]
fn test_vst_matches_dense_reference() {
let (n_genes, n_cells) = (40, 120);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("vst_reference");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).rev().filter(|c| c % 3 != 0).collect();
let res = get_hvg_vst(&reader, &cells, 0.3, None, 0).expect("vst runs");
let (means, vars, var_exp, var_std) = reference_vst(&dense, &cells, 0.3, None);
for gene in 0..n_genes {
assert_relative_eq!(res.mean[gene], means[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(res.var[gene], vars[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(res.var_exp[gene], var_exp[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(res.var_std[gene], var_std[gene] as f64, epsilon = 1e-4);
}
}
#[test]
fn test_dispersion_matches_dense_reference() {
let (n_genes, n_cells) = (30, 90);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("disp_reference");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).filter(|c| c % 2 == 0).collect();
let res =
get_hvg_dispersion(&reader, &cells, "equal_width", 10, 0).expect("dispersion runs");
let (means, dispersions) = reference_dispersion(&dense, &cells);
for gene in 0..n_genes {
assert_relative_eq!(res.mean[gene], means[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(
res.dispersion[gene],
dispersions[gene] as f64,
epsilon = 1e-6
);
}
}
#[test]
fn test_batch_aware_single_batch_matches_plain() {
let (n_genes, n_cells) = (25, 80);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("single_batch");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let labels = vec![0usize; n_cells];
let plain = get_hvg_vst(&reader, &cells, 0.3, None, 0).expect("plain vst");
let batched =
get_hvg_vst_batch_aware(&reader, &cells, &labels, 0.3, None, 0).expect("batch-aware");
assert_eq!(batched.len(), 1);
assert_eq!(plain.mean, batched[0].mean);
assert_eq!(plain.var, batched[0].var);
assert_eq!(plain.var_exp, batched[0].var_exp);
assert_eq!(plain.var_std, batched[0].var_std);
}
#[test]
fn test_batch_aware_matches_per_batch_runs() {
let (n_genes, n_cells) = (25, 90);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("batch_split");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let labels: Vec<usize> = (0..n_cells).map(|c| c % 3).collect();
let batched =
get_hvg_vst_batch_aware(&reader, &cells, &labels, 0.4, None, 0).expect("batch-aware");
assert_eq!(batched.len(), 3);
for (batch, res) in batched.iter().enumerate() {
let subset: Vec<usize> = cells
.iter()
.zip(labels.iter())
.filter_map(|(&c, &b)| if b == batch { Some(c) } else { None })
.collect();
let solo = get_hvg_vst(&reader, &subset, 0.4, None, 0).expect("solo vst");
assert_eq!(solo.mean, res.mean);
assert_eq!(solo.var, res.var);
assert_eq!(solo.var_std, res.var_std);
}
}
#[test]
fn test_gene_batch_size_invariant() {
let (n_genes, n_cells) = (37, 60);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("batch_size");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let index = CellBatchIndex::new(n_cells, &cells, None).expect("index builds");
let single = run_hvg_vst(
&reader,
&index,
0.3,
None,
HvgRunOpts {
gene_batch_size: None,
verbose: 0,
},
)
.expect("single block");
let chunked = run_hvg_vst(
&reader,
&index,
0.3,
None,
HvgRunOpts {
gene_batch_size: Some(7),
verbose: 0,
},
)
.expect("chunked");
assert_eq!(single[0].mean, chunked[0].mean);
assert_eq!(single[0].var, chunked[0].var);
assert_eq!(single[0].var_std, chunked[0].var_std);
}
#[test]
fn test_gene_without_selected_counts_gives_zero_var_std() {
let dense = vec![vec![3, 1, 4, 2], vec![0, 5, 0, 7], vec![2, 2, 2, 2]];
let temp = TempStore::new("empty_gene");
let reader = reader_for(&temp, &dense, 4);
let cells = vec![0usize, 2];
let res = get_hvg_vst(&reader, &cells, 1.0, None, 0).expect("vst runs");
assert_eq!(res.mean[1], 0.0);
assert_eq!(res.var[1], 0.0);
assert!(res.var_std[1].is_finite(), "var_std must not be NaN");
assert_eq!(res.var_std[1], 0.0);
}
#[test]
fn test_wide_count_range_takes_exact_path() {
let n_cells = 32;
let wide: Vec<u32> = (0..n_cells as u32).map(|c| 5_000 + c * 137).collect();
let dense = vec![
wide,
vec![4u32; n_cells],
(1..=n_cells as u32).collect::<Vec<u32>>(),
];
let temp = TempStore::new("wide_range");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let res = get_hvg_vst(&reader, &cells, 1.0, None, 0).expect("vst runs");
let (means, vars, _, var_std) = reference_vst(&dense, &cells, 1.0, None);
for gene in 0..dense.len() {
assert_relative_eq!(res.mean[gene], means[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(res.var[gene], vars[gene] as f64, max_relative = 1e-5);
assert_relative_eq!(res.var_std[gene], var_std[gene] as f64, max_relative = 1e-4);
}
}
#[test]
fn test_counts_above_u16_max() {
let n_cells = 8;
let dense = vec![
vec![70_000, 1, 0, 90_000, 2, 0, 3, 100_000],
vec![1, 2, 3, 4, 5, 6, 7, 8],
];
let temp = TempStore::new("u32_counts");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let res = get_hvg_vst(&reader, &cells, 1.0, None, 0).expect("vst runs");
let (means, vars, _, var_std) = reference_vst(&dense, &cells, 1.0, None);
for gene in 0..dense.len() {
assert_relative_eq!(res.mean[gene], means[gene] as f64, epsilon = 1e-6);
assert_relative_eq!(res.var[gene], vars[gene] as f64, max_relative = 1e-5);
assert_relative_eq!(res.var_std[gene], var_std[gene] as f64, max_relative = 1e-4);
}
}
#[test]
fn test_clip_max_bites() {
let n_cells = 16;
let mut gene = vec![1u32; n_cells];
gene[0] = 50_000;
let dense = vec![gene, vec![2u32; n_cells]];
let temp = TempStore::new("clip_max");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let tight = get_hvg_vst(&reader, &cells, 1.0, Some(1.0), 0).expect("tight clip");
let loose = get_hvg_vst(&reader, &cells, 1.0, Some(1e6), 0).expect("loose clip");
assert!(
tight.var_std[0] < loose.var_std[0],
"clipping must shrink var_std: {} vs {}",
tight.var_std[0],
loose.var_std[0]
);
let (_, _, _, reference) = reference_vst(&dense, &cells, 1.0, Some(1.0));
assert_relative_eq!(tight.var_std[0], reference[0] as f64, epsilon = 1e-4);
}
#[test]
fn test_gene_stats_per_batch() {
let index = CellBatchIndex::new(6, &[0, 1, 2, 3], Some(&[0, 0, 1, 1])).expect("index");
let gene = CscGeneChunk::from_conversion(
RawCounts::from_u32_auto(&[5, 5, 5, 7, 9]),
&[norm_of(5), norm_of(5), norm_of(5), norm_of(7), norm_of(9)],
&[0, 1, 2, 3, 4],
0,
true,
);
let mut scratch = HistogramScratch::new(index.n_slots());
let mut out = vec![GeneStats::default(); index.n_batches()];
gene_stats(&gene, &index, &mut scratch, &mut out);
assert_eq!(out[0].mean, 5.0);
assert_eq!(out[0].var, 0.0);
assert_eq!(out[0].max_count, 5);
assert_eq!(out[0].min_count, 5);
assert_eq!(out[1].mean, 6.0);
assert_eq!(out[1].var, 1.0);
assert_eq!(out[1].max_count, 7);
assert_eq!(out[1].min_count, 5);
}
#[test]
fn test_gene_stats_marks_the_implicit_zero() {
let index = CellBatchIndex::new(4, &[0, 1, 2], None).expect("index");
let gene = CscGeneChunk::from_conversion(
RawCounts::from_u32_auto(&[4, 6]),
&[norm_of(4), norm_of(6)],
&[0, 1],
0,
true,
);
let mut scratch = HistogramScratch::new(index.n_slots());
let mut out = vec![GeneStats::default(); index.n_batches()];
gene_stats(&gene, &index, &mut scratch, &mut out);
assert_eq!(out[0].max_count, 6);
assert_eq!(out[0].min_count, 0);
assert_relative_eq!(out[0].mean, 10.0 / 3.0, epsilon = 1e-6);
}
#[test]
fn test_clip_reachability_gates_the_exact_path() {
let (mean, min, max) = (2.0, 0.0, 50.0);
assert!(clip_is_reachable(mean, min, max, 1.0, 1.0));
assert!(!clip_is_reachable(mean, min, max, 1.0, 100.0));
assert!(clip_is_reachable(mean, min, max, 0.0, 1e9));
assert!(clip_is_reachable(mean, min, max, f32::NAN, 1e9));
}
#[test]
fn test_index_rejects_bad_input() {
assert!(matches!(
CellBatchIndex::new(10, &[], None),
Err(BixverseErrors::HvgEmptySelection)
));
assert!(matches!(
CellBatchIndex::new(10, &[0, 11], None),
Err(BixverseErrors::HvgCellIndexOutOfRange { index: 11, .. })
));
assert!(matches!(
CellBatchIndex::new(10, &[0, 3, 0], None),
Err(BixverseErrors::HvgDuplicateCellIndex { index: 0 })
));
assert!(matches!(
CellBatchIndex::new(10, &[0, 1, 2], Some(&[0, 1])),
Err(BixverseErrors::HvgBatchLabelLengthMismatch {
n_labels: 2,
n_cells: 3
})
));
assert!(matches!(
CellBatchIndex::new(10, &[0, 1, 2], Some(&[0, 2, 2])),
Err(BixverseErrors::HvgEmptyBatch { batch: 1, .. })
));
assert!(matches!(
CellBatchIndex::new(10, &[0, 1], Some(&[0, usize::MAX / 2])),
Err(BixverseErrors::HvgEmptyBatch { .. })
));
}
#[test]
fn test_drivers_reject_bad_parameters() {
let n_cells = 8;
let dense = synthetic_counts(4, n_cells);
let temp = TempStore::new("bad_params");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let index = CellBatchIndex::new(n_cells, &cells, None).expect("index builds");
let opts = HvgRunOpts {
gene_batch_size: None,
verbose: 0,
};
assert!(matches!(
run_hvg_vst(&reader, &index, 0.0, None, opts),
Err(BixverseErrors::HvgInvalidLoessSpan { .. })
));
assert!(matches!(
run_hvg_vst(&reader, &index, 1.5, None, opts),
Err(BixverseErrors::HvgInvalidLoessSpan { .. })
));
assert!(matches!(
run_hvg_dispersion(&reader, &index, BinningStrategy::EqualWidth, 0, opts),
Err(BixverseErrors::HvgInvalidBinCount)
));
}
#[test]
#[cfg(feature = "large_scale_diagnostics")]
fn diagnostic_hvg_scaling_sweep() {
let (n_genes, n_cells) = (3_000, 6_000);
let dense = synthetic_counts(n_genes, n_cells);
let temp = TempStore::new("scaling_sweep");
let reader = reader_for(&temp, &dense, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
for &n_batches in &[1usize, 8] {
let labels: Vec<usize> = (0..n_cells).map(|c| c % n_batches).collect();
for &gene_batch_size in &[None, Some(GENE_BATCH_SIZE)] {
let index = CellBatchIndex::new(n_cells, &cells, Some(&labels)).expect("index");
let opts = HvgRunOpts {
gene_batch_size,
verbose: 0,
};
let start = Instant::now();
let res = run_hvg_vst(&reader, &index, 0.3, None, opts).expect("vst");
let vst = start.elapsed();
let start = Instant::now();
run_hvg_dispersion(&reader, &index, BinningStrategy::EqualWidth, 20, opts)
.expect("dispersion");
let dispersion = start.elapsed();
println!(
"batches {n_batches}, gene_batch_size {gene_batch_size:?} -> \
vst {vst:.2?}, dispersion {dispersion:.2?}"
);
assert_eq!(res.len(), n_batches);
}
}
}
}