#![cfg(feature = "alg")]
use data_beans::alg::batch_mixing::*;
use data_beans::alg::random_projection::RandProjOps;
use data_beans::sparse_io::create_sparse_from_triplets;
use data_beans::sparse_io_vector::SparseIoVec;
use nalgebra::{DMatrix, DVector};
use rand::{RngExt, SeedableRng};
use rand_distr::{Distribution, Normal, Poisson};
fn misalignment(proj: &DMatrix<f32>, batch: &[usize], state: &[usize]) -> f32 {
let n_batch = batch.iter().max().unwrap() + 1;
let n_state = state.iter().max().unwrap() + 1;
let mut worst = 0f32;
for s in 0..n_state {
for r in 0..proj.nrows() {
let means: Vec<f32> = (0..n_batch)
.filter_map(|b| {
let v: Vec<f32> = (0..proj.ncols())
.filter(|&j| batch[j] == b && state[j] == s)
.map(|j| proj[(r, j)])
.collect();
(!v.is_empty()).then(|| v.iter().sum::<f32>() / v.len() as f32)
})
.collect();
let m = means.iter().sum::<f32>() / means.len() as f32;
let sd =
(means.iter().map(|x| (x - m).powi(2)).sum::<f32>() / means.len() as f32).sqrt();
worst = worst.max(sd);
}
}
worst
}
fn two_states(seed: u64) -> (DMatrix<f32>, Vec<usize>, Vec<usize>) {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let noise = Normal::new(0f32, 0.3).unwrap();
let (n_batch, per_batch, dim) = (20, 100, 4);
let n = n_batch * per_batch;
let mut proj = DMatrix::<f32>::zeros(dim, n);
let (mut batch, mut state) = (vec![0; n], vec![0; n]);
for b in 0..n_batch {
let frac_a = 0.1 + 0.8 * b as f32 / (n_batch - 1) as f32;
let shift = if b % 2 == 0 { 2.0 } else { -2.0 };
for c in 0..per_batch {
let j = b * per_batch + c;
let s = usize::from(rng.random::<f32>() >= frac_a);
batch[j] = b;
state[j] = s;
for r in 0..dim {
proj[(r, j)] = noise.sample(&mut rng);
}
proj[(0, j)] += if s == 0 { 1.5 } else { -1.5 };
proj[(1, j)] += shift;
}
}
(proj, batch, state)
}
#[test]
fn centring_within_state_aligns_batches_of_any_composition() {
let (proj, batch, state) = two_states(7);
let centred = centre_batches_within_state(&proj, &batch, 2).unwrap();
let mut plain = proj.clone();
for b in 0..20 {
let cols: Vec<usize> = (0..plain.ncols()).filter(|&j| batch[j] == b).collect();
let mean = cols
.iter()
.map(|&j| plain.column(j).into_owned())
.sum::<DVector<f32>>()
/ cols.len() as f32;
for &j in &cols {
let mut col = plain.column_mut(j);
col -= &mean;
}
}
let within = misalignment(¢red, &batch, &state);
let naive = misalignment(&plain, &batch, &state);
assert!(within < 0.15, "within-state misalignment {within}");
assert!(
naive > 4.0 * within,
"plain {naive} vs within-state {within}"
);
}
#[test]
fn batch_corrected_projection_keeps_composition() {
let mut rng = rand::rngs::StdRng::seed_from_u64(11);
let (n_genes, per_batch) = (20usize, 400usize);
let mut triplets: Vec<(u64, u64, f32)> = Vec::new();
let (mut batch, mut state) = (Vec::new(), Vec::new());
for b in 0..2 {
let frac_a = if b == 0 { 0.9 } else { 0.1 };
for _ in 0..per_batch {
let j = batch.len();
let s = usize::from(rng.random::<f32>() >= frac_a);
for g in 0..n_genes {
let marker = (g < n_genes / 2) == (s == 0);
let mut rate = if marker { 5.0 } else { 0.5 };
if b == 1 && (10..15).contains(&g) {
rate *= 3.0;
}
let y = Poisson::new(rate).unwrap().sample(&mut rng) as f32;
if y > 0.0 {
triplets.push((g as u64, j as u64, y));
}
}
batch.push(b);
state.push(s);
}
}
let n = batch.len();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mix.zarr");
let mut backend = create_sparse_from_triplets(
&triplets,
(n_genes, n, triplets.len()),
Some(path.to_str().unwrap()),
Some(&data_beans::sparse_io::SparseIoBackend::Zarr),
)
.unwrap();
backend.register_row_names_vec(
&(0..n_genes)
.map(|g| format!("g{g}").into_boxed_str())
.collect::<Vec<_>>(),
);
backend.register_column_names_vec(
&(0..n)
.map(|c| format!("c{c}").into_boxed_str())
.collect::<Vec<_>>(),
);
let mut data = SparseIoVec::new();
data.push(std::sync::Arc::from(backend), None).unwrap();
let proj = data
.project_columns_with_batch_correction(5, None, Some(&batch))
.unwrap()
.proj;
let state_mean = |s: usize| -> DVector<f32> {
let cols: Vec<usize> = (0..n).filter(|&j| state[j] == s).collect();
cols.iter()
.map(|&j| proj.column(j).into_owned())
.sum::<DVector<f32>>()
/ cols.len() as f32
};
let gap = (state_mean(0) - state_mean(1)).norm();
let within = misalignment(&proj, &batch, &state);
assert!(
within < 0.1 * gap,
"misalignment {within} vs state gap {gap}"
);
}
#[test]
fn poorly_mixed_bins_merge_up_the_code_tree() {
let mut codes = Vec::new();
let mut batch = Vec::new();
for b in 0..4 {
for code in [0b001, 0b010, 0b110] {
codes.push(code);
batch.push(b);
}
}
codes.push(0b101);
batch.push(9);
let labels = merge_poorly_mixed_bins(&codes, &batch, 3, 8, 3);
let label_of = |code: usize| labels[codes.iter().position(|&c| c == code).unwrap()];
assert_eq!(label_of(0b101), label_of(0b001));
assert_ne!(label_of(0b010), label_of(0b110));
assert_ne!(label_of(0b010), label_of(0b001));
}
#[test]
fn merging_is_limited_by_levels() {
let codes = vec![0b111, 0b111, 0b000, 0b000];
let batch = vec![0, 0, 1, 2];
let one_level = merge_poorly_mixed_bins(&codes, &batch, 3, 1, 3);
assert_ne!(one_level[0], one_level[2]);
let to_root = merge_poorly_mixed_bins(&codes, &batch, 3, 8, 3);
assert!(to_root.iter().all(|&l| l == to_root[0]));
}
#[test]
fn mixed_partition_holds_enough_batches() {
let (proj, batch, _) = two_states(3);
let centred = centre_batches_within_state(&proj, &batch, 2).unwrap();
let labels = {
let codes = data_beans::alg::random_projection::binary_sort_columns(¢red, 4).unwrap();
merge_poorly_mixed_bins(&codes, &batch, 4, 8, 3)
};
let mut per_group: std::collections::HashMap<usize, std::collections::HashSet<usize>> =
Default::default();
for (&l, &b) in labels.iter().zip(&batch) {
per_group.entry(l).or_default().insert(b);
}
assert!(per_group.values().all(|bs| bs.len() >= 3));
}