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/// Per-`group` column means of `x_j - offsets[offset_group_j]`, the residual
65/// taken entry by entry as it is summed; empty groups stay zero.
66fn group_means_of_residuals(
67    x: &DMatrix<f32>,
68    offsets: &DMatrix<f32>,
69    offset_group: &[usize],
70    group: &[usize],
71    n_groups: usize,
72) -> DMatrix<f32> {
73    let mut sum = DMatrix::<f32>::zeros(x.nrows(), n_groups);
74    let mut count = vec![0f32; n_groups];
75    for (j, (&g, &o)) in group.iter().zip(offset_group).enumerate() {
76        let (x_j, o_j) = (x.column(j), offsets.column(o));
77        for ((acc, &v), &off) in sum.column_mut(g).iter_mut().zip(x_j.iter()).zip(o_j.iter()) {
78            *acc += v - off;
79        }
80        count[g] += 1.0;
81    }
82    for (g, &c) in count.iter().enumerate() {
83        if c > 0.0 {
84            sum.column_mut(g).unscale_mut(c);
85        }
86    }
87    sum
88}
89
90/// Subtract `offsets[group_j]` from every column `x_j`, in place.
91fn subtract_offsets(x: &mut DMatrix<f32>, offsets: &DMatrix<f32>, group: &[usize]) {
92    for (j, &g) in group.iter().enumerate() {
93        let mut col = x.column_mut(j);
94        col -= offsets.column(g);
95    }
96}
97
98/// Centre each batch within cell state (see the module docs). Starts from
99/// the plain per-batch mean; the result has mean zero over all columns.
100///
101/// * `proj` - projection (feature x column)
102/// * `batch` - column to batch index (`0..n_batches`)
103/// * `bits` - state bits for the bins
104pub fn centre_batches_within_state(
105    proj: &DMatrix<f32>,
106    batch: &[usize],
107    bits: usize,
108) -> anyhow::Result<DMatrix<f32>> {
109    anyhow::ensure!(
110        batch.len() == proj.ncols(),
111        "batch membership size mismatch"
112    );
113    let n_batches = batch.iter().max().map_or(0, |&m| m + 1);
114    let bits = bits.min(proj.nrows()).min(proj.ncols()).max(1);
115
116    // One buffer holds the projection centred by the current shifts; it is
117    // rewritten in place whenever the shifts change.
118    let mut shift = group_means(proj, batch, n_batches);
119    let mut centred = proj.clone();
120    subtract_offsets(&mut centred, &shift, batch);
121    let mut codes: Vec<usize> = Vec::new();
122    for _ in 0..CENTRING_ROUNDS {
123        let next = binary_sort_columns(&centred, bits)?;
124        if next == codes {
125            break;
126        }
127        codes = next;
128        let n_bins = codes.iter().max().map_or(0, |&m| m + 1);
129        let states = group_means(&centred, &codes, n_bins);
130        shift = group_means_of_residuals(proj, &states, &codes, batch, n_batches);
131        centred.copy_from(proj);
132        subtract_offsets(&mut centred, &shift, batch);
133    }
134    let mean = centred.column_mean();
135    for mut col in centred.column_iter_mut() {
136        col -= &mean;
137    }
138    Ok(centred)
139}
140
141/// Label of a node of the code tree: the code with a sentinel bit above its
142/// `level` bits, so labels of different levels never collide.
143fn node_label(level: usize, code: usize) -> usize {
144    (1 << level) | code
145}
146
147/// Cut the binary code tree so that bins mix batches. Bottom-up, a node
148/// holding fewer than `min_batches` batches collapses its whole parent
149/// subtree into the parent, for at most `levels` levels or up to the root.
150/// A bin still short after that is left as is. Returns one label per
151/// column.
152///
153/// * `codes` - binary codes of `bits` bits, bit `k` from component `k`
154/// * `batch` - column to batch index
155pub fn merge_poorly_mixed_bins(
156    codes: &[usize],
157    batch: &[usize],
158    bits: usize,
159    levels: usize,
160    min_batches: usize,
161) -> Vec<usize> {
162    let mut node: Vec<(usize, usize)> = codes.iter().map(|&c| (bits, c)).collect();
163    let lowest = bits.saturating_sub(levels);
164    for b in (lowest + 1..=bits).rev() {
165        let mut members: HashMap<usize, HashSet<usize>> = HashMap::default();
166        for (&(level, code), &k) in node.iter().zip(batch) {
167            if level == b {
168                members.entry(code).or_default().insert(k);
169            }
170        }
171        let parent_mask = (1 << (b - 1)) - 1;
172        let poor_parents: HashSet<usize> = members
173            .iter()
174            .filter(|(_, ks)| ks.len() < min_batches)
175            .map(|(&code, _)| code & parent_mask)
176            .collect();
177        if poor_parents.is_empty() {
178            continue;
179        }
180        for (level, code) in node.iter_mut() {
181            let parent = *code & parent_mask;
182            if *level >= b && poor_parents.contains(&parent) {
183                *level = b - 1;
184                *code = parent;
185            }
186        }
187    }
188    node.into_iter().map(|(l, c)| node_label(l, c)).collect()
189}
190
191/// Map arbitrary batch keys to `0..n_batches` in order of first appearance.
192pub fn batch_indices<T>(batch_membership: &[T]) -> Vec<usize>
193where
194    T: std::hash::Hash + Eq + Clone,
195{
196    let mut index: HashMap<T, usize> = HashMap::default();
197    batch_membership
198        .iter()
199        .map(|t| {
200            let next = index.len();
201            *index.entry(t.clone()).or_insert(next)
202        })
203        .collect()
204}