use data_beans::alg::collapse_data::PbSampleLayout;
use data_beans::alg::refine_multilevel::{
compact_labels, refine_assignments, RefineInputs, RefineParams,
};
use legume_numeric::matrix::knn_match::ColumnDict;
use nalgebra::DMatrix;
use rand::rngs::SmallRng;
use rand::{RngExt, SeedableRng};
use rustc_hash::FxHashMap as HashMap;
struct ToyFixture {
layout: PbSampleLayout,
gene_sums: Vec<Vec<(usize, f32)>>,
pb_sample_to_cells: Vec<Vec<usize>>,
true_cluster: Vec<usize>,
batch_knn: Vec<ColumnDict<usize>>,
}
fn make_toy_layout_and_profiles(
num_batches: usize,
cells_per_cluster_per_batch: usize,
num_clusters: usize,
num_features: usize,
noise_seed: u64,
) -> ToyFixture {
let num_pb = num_batches * num_clusters;
let proj_dim = num_clusters.max(2);
let mut centroids = DMatrix::<f32>::zeros(proj_dim, num_pb);
let mut pbsamp_to_batch = Vec::with_capacity(num_pb);
let mut pbsamp_to_group = Vec::with_capacity(num_pb);
let mut bg_to_pbsamp: HashMap<(usize, usize), usize> = HashMap::default();
let mut true_cluster = Vec::with_capacity(num_pb);
let mut cell_counts = Vec::with_capacity(num_pb);
let mut gene_sums: Vec<Vec<(usize, f32)>> = Vec::with_capacity(num_pb);
let mut pb_sample_to_cells: Vec<Vec<usize>> = Vec::with_capacity(num_pb);
let per_block = num_features / num_clusters;
let mut rng = SmallRng::seed_from_u64(noise_seed);
let mut next_cell = 0usize;
let mut per_batch_cells: Vec<Vec<(usize, Vec<f32>)>> = vec![Vec::new(); num_batches];
let mut cell_to_pbsamp_vec: Vec<usize> = Vec::new();
for (batch, batch_cells) in per_batch_cells.iter_mut().enumerate() {
for cluster in 0..num_clusters {
let pbsamp = batch * num_clusters + cluster;
centroids[(cluster.min(proj_dim - 1), pbsamp)] = 1.0;
pbsamp_to_batch.push(batch);
let hash_group = if rng.random_range(0.0..1.0_f32) < 0.7 {
cluster
} else {
(cluster + 1) % num_clusters
};
pbsamp_to_group.push(hash_group);
bg_to_pbsamp.insert((batch, hash_group), pbsamp);
true_cluster.push(cluster);
cell_counts.push(cells_per_cluster_per_batch as f32);
let mut row: Vec<(usize, f32)> = Vec::new();
let start = cluster * per_block;
let end = ((cluster + 1) * per_block).min(num_features);
for g in start..end {
row.push((g, 10.0 + rng.random_range(0.0..1.0_f32)));
}
for _ in 0..3 {
let g: usize = rng.random_range(0..num_features);
row.push((g, rng.random_range(0.0..0.5_f32)));
}
row.sort_unstable_by_key(|&(g, _)| g);
row.dedup_by_key(|&mut (g, _)| g);
gene_sums.push(row);
let mut cells_here = Vec::with_capacity(cells_per_cluster_per_batch);
for _ in 0..cells_per_cluster_per_batch {
let c = next_cell;
next_cell += 1;
cells_here.push(c);
cell_to_pbsamp_vec.push(pbsamp);
let mut feat = vec![0.0f32; proj_dim];
feat[cluster.min(proj_dim - 1)] = 1.0 + rng.random_range(-0.02..0.02_f32);
batch_cells.push((c, feat));
}
pb_sample_to_cells.push(cells_here);
}
}
let batch_knn_lookup: Vec<ColumnDict<usize>> = per_batch_cells
.into_iter()
.map(|cells_in_b| {
let n = cells_in_b.len();
let d = proj_dim;
let mut mat = DMatrix::<f32>::zeros(d, n);
let mut names = Vec::with_capacity(n);
for (col, (cell_idx, feat)) in cells_in_b.into_iter().enumerate() {
for (row, v) in feat.into_iter().enumerate() {
mat[(row, col)] = v;
}
names.push(cell_idx);
}
ColumnDict::<usize>::from_dmatrix(mat, names)
})
.collect();
let singleton_col = vec![None; cell_counts.len()];
let layout = PbSampleLayout {
centroids,
cell_counts,
pb_sample_to_batch: pbsamp_to_batch,
pb_sample_to_group: pbsamp_to_group,
bg_to_pbsamp,
cell_to_pbsamp: cell_to_pbsamp_vec,
singleton_col,
bulk_batches: Vec::new(),
pb_sample_to_stratum: None,
};
ToyFixture {
layout,
gene_sums,
pb_sample_to_cells,
true_cluster,
batch_knn: batch_knn_lookup,
}
}
fn adjusted_rand_index(a: &[usize], b: &[usize]) -> f64 {
let n = a.len();
assert_eq!(n, b.len());
let mut contingency: HashMap<(usize, usize), u64> = HashMap::default();
let mut a_counts: HashMap<usize, u64> = HashMap::default();
let mut b_counts: HashMap<usize, u64> = HashMap::default();
for i in 0..n {
*contingency.entry((a[i], b[i])).or_insert(0) += 1;
*a_counts.entry(a[i]).or_insert(0) += 1;
*b_counts.entry(b[i]).or_insert(0) += 1;
}
let choose2 = |x: u64| -> f64 { (x as f64) * ((x as f64) - 1.0) / 2.0 };
let sum_cont_c2: f64 = contingency.values().map(|&c| choose2(c)).sum();
let sum_a_c2: f64 = a_counts.values().map(|&c| choose2(c)).sum();
let sum_b_c2: f64 = b_counts.values().map(|&c| choose2(c)).sum();
let total_c2 = choose2(n as u64);
let expected = sum_a_c2 * sum_b_c2 / total_c2;
let max = 0.5 * (sum_a_c2 + sum_b_c2);
if (max - expected).abs() < 1e-12 {
return 1.0;
}
(sum_cont_c2 - expected) / (max - expected)
}
#[test]
fn refine_assignments_is_deterministic() {
let ToyFixture {
layout,
gene_sums,
pb_sample_to_cells: pbsamp_to_cells,
batch_knn,
..
} = make_toy_layout_and_profiles(2, 5, 4, 16, 42);
let num_genes = 16;
let initial_finest = layout.pb_sample_to_group.clone();
let initial_coarse: Vec<usize> = initial_finest.iter().map(|&g| g / 2).collect();
let (initial_coarse_compact, _k) = compact_labels(&initial_coarse);
let initial_per_level = vec![initial_finest, initial_coarse_compact];
let params = RefineParams {
num_gibbs: 5,
num_greedy: 3,
seed: 42,
..Default::default()
};
let inputs = RefineInputs {
layout: &layout,
gene_sums: &gene_sums,
num_genes,
pb_sample_to_cells: &pbsamp_to_cells,
batch_knn_lookup: &batch_knn,
k_per_batch: 4,
initial_sc_to_group_per_level: &initial_per_level,
reproject_offsets_per_level: &[],
};
let a = refine_assignments(&inputs, ¶ms).unwrap();
let b = refine_assignments(&inputs, ¶ms).unwrap();
assert_eq!(a.pbsamp_to_group, b.pbsamp_to_group);
assert_eq!(a.num_groups_per_level, b.num_groups_per_level);
}
#[test]
fn refine_assignments_preserves_hierarchy() {
let ToyFixture {
layout,
gene_sums,
pb_sample_to_cells: pbsamp_to_cells,
batch_knn,
..
} = make_toy_layout_and_profiles(2, 5, 4, 16, 7);
let num_genes = 16;
let initial_finest = layout.pb_sample_to_group.clone();
let initial_coarse: Vec<usize> = initial_finest.iter().map(|&g| g / 2).collect();
let (initial_coarse_compact, _k) = compact_labels(&initial_coarse);
let initial_per_level = vec![initial_finest, initial_coarse_compact];
let inputs = RefineInputs {
layout: &layout,
gene_sums: &gene_sums,
num_genes,
pb_sample_to_cells: &pbsamp_to_cells,
batch_knn_lookup: &batch_knn,
k_per_batch: 4,
initial_sc_to_group_per_level: &initial_per_level,
reproject_offsets_per_level: &[],
};
let refined = refine_assignments(&inputs, &RefineParams::default()).unwrap();
let mut parent_of_fine: HashMap<usize, usize> = HashMap::default();
for pbsamp in 0..refined.pbsamp_to_group[0].len() {
let fine = refined.pbsamp_to_group[0][pbsamp];
let coarse = refined.pbsamp_to_group[1][pbsamp];
if let Some(&p) = parent_of_fine.get(&fine) {
assert_eq!(
p, coarse,
"fine group {} has inconsistent parents: {} and {}",
fine, p, coarse
);
} else {
parent_of_fine.insert(fine, coarse);
}
}
}
#[test]
fn refine_assignments_does_not_hurt_ari_on_planted() {
let ToyFixture {
layout,
gene_sums,
pb_sample_to_cells: pbsamp_to_cells,
true_cluster: truth,
batch_knn,
} = make_toy_layout_and_profiles(2, 6, 4, 24, 11);
let num_genes = 24;
let initial_finest = layout.pb_sample_to_group.clone();
let initial_per_level = vec![initial_finest.clone()];
let params = RefineParams {
num_gibbs: 15,
num_greedy: 10,
seed: 11,
..Default::default()
};
let inputs = RefineInputs {
layout: &layout,
gene_sums: &gene_sums,
num_genes,
pb_sample_to_cells: &pbsamp_to_cells,
batch_knn_lookup: &batch_knn,
k_per_batch: 4,
initial_sc_to_group_per_level: &initial_per_level,
reproject_offsets_per_level: &[],
};
let refined = refine_assignments(&inputs, ¶ms).unwrap();
let ari_before = adjusted_rand_index(&initial_finest, &truth);
let ari_after = adjusted_rand_index(&refined.pbsamp_to_group[0], &truth);
assert!(
ari_after >= ari_before,
"refinement should not hurt ARI: before={:.3}, after={:.3}",
ari_before,
ari_after
);
}