Skip to main content

data_beans/alg/
batch_mixing.rs

1//! Spread batches across cell states.
2//!
3//! A batch (a donor, a sample, a run) shifts every one of its cells, and it
4//! also has its own cell-state composition. Batch correction must remove the
5//! first and keep the second. Subtracting each batch's plain mean removes
6//! both, so a batch rich in one state has that state pulled to the origin,
7//! and cells of one state stop lining up across batches. Here the
8//! projection is fit as
9//!
10//! ```text
11//!   proj_j = state(bin_j) + shift(batch_j)
12//! ```
13//!
14//! by alternating the bins (binary codes of the projection) with the batch
15//! shifts, so the shift is what is left after each cell's state is removed.
16//!
17//! The same codes form a binary tree over cell states (bit `k` is the sign
18//! of the `k`-th component, so dropping the top bit gives the parent).
19//! [`merge_poorly_mixed_bins`] cuts that tree so every bin holds enough
20//! batches.
21
22use crate::alg::random_projection::binary_sort_columns;
23use nalgebra::DMatrix;
24use std::collections::{HashMap, HashSet};
25
26/// Rounds of the state/shift alternation; it stops early once no column
27/// changes bin.
28pub const CENTRING_ROUNDS: usize = 10;
29
30/// Most state bits used while centring.
31pub const MAX_STATE_BITS: usize = 10;
32
33/// Levels a poorly mixed bin may merge up by default.
34pub const DEFAULT_MERGE_LEVELS: usize = 8;
35
36/// Batches a bin needs by default (capped by the number of batches).
37pub const DEFAULT_MIN_BATCHES_PER_GROUP: usize = 3;
38
39/// State bits for centring: about two columns per batch per bin, at least
40/// one, at most the projection dimension and [`MAX_STATE_BITS`].
41pub fn default_state_bits(n_cols: usize, n_batches: usize, dim: usize) -> usize {
42    let per_bin = (2 * n_batches.max(1)) as f64;
43    let bits = (n_cols as f64 / per_bin).log2().floor().max(1.0) as usize;
44    bits.min(dim.max(1)).min(MAX_STATE_BITS)
45}
46
47/// Per-group column means of `x`; empty groups stay zero.
48fn group_means(x: &DMatrix<f32>, group: &[usize], n_groups: usize) -> DMatrix<f32> {
49    let mut sum = DMatrix::<f32>::zeros(x.nrows(), n_groups);
50    let mut count = vec![0f32; n_groups];
51    for (j, &g) in group.iter().enumerate() {
52        let mut col = sum.column_mut(g);
53        col += x.column(j);
54        count[g] += 1.0;
55    }
56    for (g, &c) in count.iter().enumerate() {
57        if c > 0.0 {
58            sum.column_mut(g).unscale_mut(c);
59        }
60    }
61    sum
62}
63
64/// `x_j - offsets[group_j]` for every column.
65fn subtract(x: &DMatrix<f32>, offsets: &DMatrix<f32>, group: &[usize]) -> DMatrix<f32> {
66    let mut out = x.clone();
67    for (j, &g) in group.iter().enumerate() {
68        let mut col = out.column_mut(j);
69        col -= offsets.column(g);
70    }
71    out
72}
73
74/// Centre each batch within cell state (see the module docs). Starts from
75/// the plain per-batch mean; the result has mean zero over all columns.
76///
77/// * `proj` - projection (feature x column)
78/// * `batch` - column to batch index (`0..n_batches`)
79/// * `bits` - state bits for the bins
80pub fn centre_batches_within_state(
81    proj: &DMatrix<f32>,
82    batch: &[usize],
83    bits: usize,
84) -> anyhow::Result<DMatrix<f32>> {
85    anyhow::ensure!(
86        batch.len() == proj.ncols(),
87        "batch membership size mismatch"
88    );
89    let n_batches = batch.iter().max().map_or(0, |&m| m + 1);
90    let bits = bits.min(proj.nrows()).min(proj.ncols()).max(1);
91
92    let mut shift = group_means(proj, batch, n_batches);
93    let mut codes: Vec<usize> = Vec::new();
94    for _ in 0..CENTRING_ROUNDS {
95        let centred = subtract(proj, &shift, batch);
96        let next = binary_sort_columns(&centred, bits)?;
97        if next == codes {
98            break;
99        }
100        codes = next;
101        let n_bins = codes.iter().max().map_or(0, |&m| m + 1);
102        let states = group_means(&centred, &codes, n_bins);
103        shift = group_means(&subtract(proj, &states, &codes), batch, n_batches);
104    }
105    let mut centred = subtract(proj, &shift, batch);
106    let mean = centred.column_mean();
107    for mut col in centred.column_iter_mut() {
108        col -= &mean;
109    }
110    Ok(centred)
111}
112
113/// Label of a node of the code tree: the code with a sentinel bit above its
114/// `level` bits, so labels of different levels never collide.
115fn node_label(level: usize, code: usize) -> usize {
116    (1 << level) | code
117}
118
119/// Cut the binary code tree so that bins mix batches. Bottom-up, a node
120/// holding fewer than `min_batches` batches collapses its whole parent
121/// subtree into the parent, for at most `levels` levels or up to the root.
122/// A bin still short after that is left as is. Returns one label per
123/// column.
124///
125/// * `codes` - binary codes of `bits` bits, bit `k` from component `k`
126/// * `batch` - column to batch index
127pub fn merge_poorly_mixed_bins(
128    codes: &[usize],
129    batch: &[usize],
130    bits: usize,
131    levels: usize,
132    min_batches: usize,
133) -> Vec<usize> {
134    let mut node: Vec<(usize, usize)> = codes.iter().map(|&c| (bits, c)).collect();
135    let lowest = bits.saturating_sub(levels);
136    for b in (lowest + 1..=bits).rev() {
137        let mut members: HashMap<usize, HashSet<usize>> = HashMap::default();
138        for (&(level, code), &k) in node.iter().zip(batch) {
139            if level == b {
140                members.entry(code).or_default().insert(k);
141            }
142        }
143        let parent_mask = (1 << (b - 1)) - 1;
144        let poor_parents: HashSet<usize> = members
145            .iter()
146            .filter(|(_, ks)| ks.len() < min_batches)
147            .map(|(&code, _)| code & parent_mask)
148            .collect();
149        if poor_parents.is_empty() {
150            continue;
151        }
152        for (level, code) in node.iter_mut() {
153            let parent = *code & parent_mask;
154            if *level >= b && poor_parents.contains(&parent) {
155                *level = b - 1;
156                *code = parent;
157            }
158        }
159    }
160    node.into_iter().map(|(l, c)| node_label(l, c)).collect()
161}
162
163/// Map arbitrary batch keys to `0..n_batches` in order of first appearance.
164pub fn batch_indices<T>(batch_membership: &[T]) -> Vec<usize>
165where
166    T: std::hash::Hash + Eq + Clone,
167{
168    let mut index: HashMap<T, usize> = HashMap::default();
169    batch_membership
170        .iter()
171        .map(|t| {
172            let next = index.len();
173            *index.entry(t.clone()).or_insert(next)
174        })
175        .collect()
176}