use faer::MatRef;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use statrs::distribution::{ChiSquared, ContinuousCDF};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use thousands::*;
use super::shared::{
LisiResult, SilhouetteResult, dense_labels, lisi, lisi_weighted, silhouette_width,
};
use crate::prelude::*;
fn as_batch_error(err: ScErrors) -> ScErrors {
match err {
ScErrors::NeedAtLeastTwoLabels { n_labels } => ScErrors::NeedAtLeastTwoBatches {
n_batches: n_labels,
},
other => other,
}
}
pub struct KbetResult {
pub p_values: Vec<f64>,
pub chi_square_stats: Vec<f64>,
pub mean_chi_square: f64,
pub median_chi_square: f64,
}
pub fn kbet(
knn_data: &[Vec<usize>],
batches: &[usize],
verbose: bool,
) -> Result<KbetResult, ScErrors> {
let mut batch_counts = FxHashMap::default();
for &batch in batches {
*batch_counts.entry(batch).or_insert(0usize) += 1;
}
let total = batches.len() as f64;
let batch_ids: Vec<usize> = batch_counts.keys().copied().collect();
let n_batches = batch_ids.len();
if n_batches == 1 {
return Err(ScErrors::NeedAtLeastTwoBatches { n_batches });
}
let dof = (n_batches - 1) as f64;
let use_yates = n_batches == 2;
let n = knn_data.len();
if verbose {
println!("Running kBET on {} samples", n.separate_with_underscores())
}
let chi_sq_dist = ChiSquared::new(dof).unwrap();
let counter = Arc::new(AtomicUsize::new(0));
let batch_index: FxHashMap<usize, usize> = batch_ids
.iter()
.enumerate()
.map(|(slot, &id)| (id, slot))
.collect();
let dense_batches: Vec<usize> = batches.iter().map(|b| batch_index[b]).collect();
let batch_fractions: Vec<f64> = batch_ids
.iter()
.map(|id| batch_counts[id] as f64 / total)
.collect();
let results: Vec<(f64, f64)> = knn_data
.par_iter()
.map_init(
|| vec![0usize; n_batches],
|neighbours_count, neighbours| {
let k = neighbours.len() as f64;
neighbours_count.fill(0);
for &neighbour_idx in neighbours {
neighbours_count[dense_batches[neighbour_idx]] += 1;
}
let mut chi_square = 0.0;
for (slot, &fraction) in batch_fractions.iter().enumerate() {
let expected = k * fraction;
let observed = neighbours_count[slot] as f64;
let diff = if use_yates {
(observed - expected).abs() - 0.5
} else {
observed - expected
};
chi_square += diff * diff / expected;
}
if verbose {
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
if count.is_multiple_of(100_000) {
println!(
" kBET: processed {} / {} cells.",
count.separate_with_underscores(),
n.separate_with_underscores()
);
}
}
let p_value = 1.0 - chi_sq_dist.cdf(chi_square);
(chi_square, p_value)
},
)
.collect();
let chi_square_stats: Vec<f64> = results.iter().map(|(c, _)| *c).collect();
let p_values: Vec<f64> = results.iter().map(|(_, p)| *p).collect();
let mean_chi_square = chi_square_stats.iter().sum::<f64>() / chi_square_stats.len() as f64;
let mut sorted_chi = chi_square_stats.clone();
sorted_chi.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let median_chi_square = if sorted_chi.len().is_multiple_of(2) {
(sorted_chi[sorted_chi.len() / 2 - 1] + sorted_chi[sorted_chi.len() / 2]) / 2.0
} else {
sorted_chi[sorted_chi.len() / 2]
};
Ok(KbetResult {
p_values,
chi_square_stats,
mean_chi_square,
median_chi_square,
})
}
pub struct BatchSilhouetteResult {
pub per_cell: Vec<f32>,
pub mean_asw: f32,
pub median_asw: f32,
}
pub fn batch_silhouette_width(
embedding: MatRef<f32>,
batch_labels: &[usize],
subsample: Option<usize>,
seed: usize,
verbose: bool,
) -> Result<BatchSilhouetteResult, ScErrors> {
let SilhouetteResult {
per_cell,
mean,
median,
} = silhouette_width(embedding, batch_labels, subsample, seed, verbose)
.map_err(as_batch_error)?;
Ok(BatchSilhouetteResult {
per_cell,
mean_asw: mean,
median_asw: median,
})
}
pub fn batch_lisi(
knn_indices: &[Vec<usize>],
batch_labels: &[usize],
verbose: bool,
) -> Result<LisiResult, ScErrors> {
lisi(knn_indices, batch_labels, verbose).map_err(as_batch_error)
}
pub fn batch_lisi_weighted(
knn_indices: &[Vec<usize>],
knn_dists: &[Vec<f32>],
batch_labels: &[usize],
perplexity: Option<f64>,
verbose: bool,
) -> Result<LisiResult, ScErrors> {
lisi_weighted(knn_indices, knn_dists, batch_labels, perplexity, verbose).map_err(as_batch_error)
}
pub struct PcrResult {
pub var_explained: Vec<f64>,
pub r_squared: Vec<f64>,
pub pcr: f64,
}
pub fn pcr<T>(embedding: MatRef<T>, batches: &[usize]) -> Result<PcrResult, ScErrors>
where
T: BixverseFloat + Send + Sync,
{
let n = embedding.nrows();
let d = embedding.ncols();
let (batches, n_batches) = dense_labels(n, batches).map_err(as_batch_error)?;
let sums: Vec<(f64, f64)> = (0..d)
.into_par_iter()
.map(|j| {
let mut group_sum = vec![0.0f64; n_batches];
let mut group_count = vec![0usize; n_batches];
let mut total_sum = 0.0f64;
for i in 0..n {
let x = embedding[(i, j)].to_f64().unwrap_or(0.0);
group_sum[batches[i]] += x;
group_count[batches[i]] += 1;
total_sum += x;
}
let grand_mean = total_sum / n as f64;
let ss_between: f64 = group_sum
.iter()
.zip(group_count.iter())
.filter(|&(_, &c)| c > 0)
.map(|(&s, &c)| {
let diff = s / c as f64 - grand_mean;
c as f64 * diff * diff
})
.sum();
let ss_total: f64 = (0..n)
.map(|i| {
let diff = embedding[(i, j)].to_f64().unwrap_or(0.0) - grand_mean;
diff * diff
})
.sum();
(ss_between, ss_total)
})
.collect();
let ss_grand: f64 = sums.iter().map(|(_, t)| *t).sum();
let var_explained: Vec<f64> = if ss_grand > 0.0 {
sums.iter().map(|(_, t)| t / ss_grand).collect()
} else {
vec![0.0; d]
};
let r_squared: Vec<f64> = sums
.iter()
.map(|&(b, t)| if t > 0.0 { (b / t).min(1.0) } else { 0.0 })
.collect();
let pcr = var_explained
.iter()
.zip(r_squared.iter())
.map(|(w, r)| w * r)
.sum();
Ok(PcrResult {
var_explained,
r_squared,
pcr,
})
}
pub fn pcr_comparison(pre: &PcrResult, post: &PcrResult) -> f64 {
if pre.pcr <= 0.0 {
return 0.0;
}
(pre.pcr - post.pcr) / pre.pcr
}
#[cfg(test)]
mod batch_tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_pcr_pure_batch_signal_is_one() {
let data: Vec<f32> = vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0];
let embd = MatRef::<f32>::from_row_major_slice(&data, 6, 1);
let res = pcr(embd, &[0, 0, 0, 1, 1, 1]).unwrap();
assert_relative_eq!(res.pcr, 1.0, epsilon = 1e-6);
assert_relative_eq!(res.r_squared[0], 1.0, epsilon = 1e-6);
}
#[test]
fn test_pcr_no_batch_signal_is_zero() {
let data: Vec<f32> = vec![1.0, -1.0, 0.0, 0.0, 1.0, -1.0];
let embd = MatRef::<f32>::from_row_major_slice(&data, 6, 1);
let res = pcr(embd, &[0, 0, 0, 1, 1, 1]).unwrap();
assert_relative_eq!(res.pcr, 0.0, epsilon = 1e-6);
}
#[test]
fn test_pcr_weights_by_variance_share() {
let data: Vec<f32> = vec![
1.0, 1.0, 1.0, -1.0, 1.0, 0.0, -1.0, 0.0, -1.0, 1.0, -1.0, -1.0,
];
let embd = MatRef::<f32>::from_row_major_slice(&data, 6, 2);
let res = pcr(embd, &[0, 0, 0, 1, 1, 1]).unwrap();
assert_relative_eq!(res.r_squared[0], 1.0, epsilon = 1e-6);
assert_relative_eq!(res.r_squared[1], 0.0, epsilon = 1e-6);
assert_relative_eq!(res.var_explained[0], 0.6, epsilon = 1e-6);
assert_relative_eq!(res.pcr, 0.6, epsilon = 1e-6);
}
#[test]
fn test_pcr_comparison_reports_relative_reduction() {
let pre = PcrResult {
var_explained: vec![1.0],
r_squared: vec![0.8],
pcr: 0.8,
};
let post = PcrResult {
var_explained: vec![1.0],
r_squared: vec![0.2],
pcr: 0.2,
};
assert_relative_eq!(pcr_comparison(&pre, &post), 0.75, epsilon = 1e-12);
assert_relative_eq!(pcr_comparison(&pre, &pre), 0.0, epsilon = 1e-12);
assert!(pcr_comparison(&post, &pre) < 0.0);
}
#[test]
fn test_batch_entry_points_report_batch_error() {
let knn = vec![vec![1], vec![0]];
assert!(matches!(
batch_lisi(&knn, &[7, 7], false),
Err(ScErrors::NeedAtLeastTwoBatches { n_batches: 1 })
));
let data: Vec<f32> = vec![0.0, 0.0, 1.0, 1.0];
let embd = MatRef::<f32>::from_row_major_slice(&data, 2, 2);
assert!(matches!(
batch_silhouette_width(embd, &[7, 7], None, 1, false),
Err(ScErrors::NeedAtLeastTwoBatches { n_batches: 1 })
));
}
#[test]
fn test_kbet_separates_mixed_from_unmixed() {
let batches: Vec<usize> = (0..8).map(|i| i % 2).collect();
let mixed: Vec<Vec<usize>> = (0..8)
.map(|i| (1..=4).map(|o| (i + o) % 8).collect())
.collect();
let split: Vec<Vec<usize>> = (0..8)
.map(|i| (0..4).map(|o| (i % 2) + 2 * o).collect())
.collect();
let a = kbet(&mixed, &batches, false).unwrap();
let b = kbet(&split, &batches, false).unwrap();
assert_relative_eq!(a.mean_chi_square, 0.25, epsilon = 1e-9);
assert_relative_eq!(b.mean_chi_square, 2.25, epsilon = 1e-9);
assert_eq!(a.p_values.len(), 8);
}
}