data_beans/alg/bhc.rs
1//! Bayesian Hierarchical Clustering over K clusters with gene-mass sufficient stats.
2//!
3//! Post-hoc bottom-up merge of K clusters driven by a pairwise Dirichlet-Multinomial
4//! marginal-likelihood Bayes factor under an **empirical-Bayes asymmetric Dirichlet
5//! prior** centered on the pooled empirical marginal `bg[g] = (Σ_k T_k,g) / (Σ_k S_k)`.
6//! The prior expects every cluster to look like `bg`; the BF measures deviation from
7//! that baseline. Housekeeping-shaped clusters merge cheaply; clusters with distinct
8//! structure stay separate.
9//!
10//! Node log marginal under Dir(γ · bg) prior:
11//!
12//! f(T, S) = lgamma(γ) − lgamma(γ + S)
13//! + Σ_g [lgamma(γ·bg[g] + T_g) − lgamma(γ·bg[g])]
14//!
15//! Pairwise log Bayes factor:
16//!
17//! log_BF(i, j) = f(T_i + T_j, S_i + S_j) − f(T_i, S_i) − f(T_j, S_j)
18//!
19//! ## Interpreting the scores
20//!
21//! `log_BF` is the **log of the ratio of evidences**, H₁ (the two clusters came
22//! from one underlying distribution) vs H₀ (they came from two distinct ones):
23//!
24//! log_BF > 0 — data favor merging (H₁ wins)
25//! log_BF < 0 — data favor keeping separate (H₀ wins)
26//! log_BF = 0 — model is indifferent (natural cut point)
27//!
28//! Magnitude carries BIC-like meaning: at high counts,
29//! `log_BF ≈ −n · KL(p_i ‖ p_j)`, where n is the merged sample size and KL is
30//! computed on the proportion vectors `p_k = T_k / S_k`. Rough calibration
31//! (symmetric about zero):
32//!
33//! |log_BF| < 1 weak — effectively a toss-up
34//! 1 ≤ |log_BF| < 3 moderate evidence (≈ e≈2.7× to 20× odds)
35//! 3 ≤ |log_BF| < 5 strong (20× to 150× odds)
36//! |log_BF| ≥ 5 decisive (>150× odds)
37//!
38//! Compare only WITHIN ONE run: absolute magnitudes are not probabilities (the
39//! data-dependent normalizer `log Γ(y+1)` is dropped since it cancels across any
40//! partition of the same edges). Cross-run comparisons are not meaningful.
41//!
42//! In the bottom-up agglomeration `bhc_merge` picks the largest `log_BF` at each
43//! step. The scores need not decrease along the merge sequence: a merged node
44//! can score higher against a third cluster than the pair that formed it.
45//! The crossing point `log_BF = 0` is the natural "consensus cut" threshold
46//! exposed via `bhc_cut(merges, k, 0.0)`.
47//!
48//! **Effective sample size is caller-specified.** The caller passes `effective_size`
49//! per cluster and the internal `(T, S)` gets rescaled so that `S_eff = effective_size[k]`,
50//! preserving shape `T_g / S`. Without rescaling, large raw `S` values (e.g.
51//! 10⁴–10⁶ of projection mass on Visium scale, or Σ_n θ_{n,k}·depth_n at
52//! 100k-cell scale) make the DM posterior absurdly sharp and blow up log-BF
53//! magnitudes. Typical choices:
54//! - link-community: `effective_size[k] = edge_count[k]` (the natural N).
55//! - topic model: `effective_size[k] = (Σ_n θ_{n,k})² / Σ_n θ_{n,k}²` (ESS of the soft assignment).
56//!
57//! A Heller-Ghahramani DP tree prior can be slotted in later: extend with a
58//! `BhcConfig { dp_alpha, ... }` and replace the pure pairwise log-BF rule
59//! with the full posterior `r_k` recursion.
60//!
61//! Runs over K clusters (not N samples). Pairwise log BFs are cached, so only
62//! a new node's K pairs are scored after each merge: K²·M lgamma calls overall,
63//! spread over threads. Every node also keeps its best partner among the
64//! nodes before it, so picking the best pair is K lookups per merge plus a
65//! rescan of the rows whose best partner was just merged away (K² lookups
66//! overall when few rows go stale, K³ at worst, no lgamma). The cache holds
67//! up to 2K² values: meant for K up to a few thousand. No RNG; the merges
68//! match scoring every pair afresh.
69
70use crate::alg::union_find::UnionFind;
71use rayon::prelude::*;
72use special::Gamma as SpecialGamma;
73
74/// Borrowed view over the per-cluster sufficient statistics needed by `bhc_merge`.
75///
76/// Callers stash their own cluster struct (e.g. `LinkCommunityStats`, topic-model
77/// posterior stats) and construct this view without copying.
78pub struct BhcInput<'a> {
79 /// Number of clusters.
80 pub k: usize,
81 /// Feature dimension (genes).
82 pub m: usize,
83 /// Row-major `[k × m]` gene-mass per cluster. `gene_sum[c * m + g]` = T_{c,g}.
84 pub gene_sum: &'a [f64],
85 /// Per-cluster total size `S_k = Σ_g T_{k,g}` (or an equivalent scale; the
86 /// only requirement is `T_k / S_k` is the cluster's proportion vector).
87 pub size_sum: &'a [f64],
88 /// Per-cluster effective sample size used for DM posterior sharpness.
89 /// `(T_g, S)` is rescaled so `S_eff = effective_size[k]` before evaluating
90 /// the Bayes factor. A cluster with `effective_size == 0` is skipped.
91 pub effective_size: &'a [usize],
92}
93
94/// One merge event in the dendrogram.
95#[derive(Debug, Clone)]
96pub struct BhcMerge {
97 /// New node id. Starts at K and increments by 1 per merge.
98 pub id: i32,
99 /// Child id (< `id`). `left < right` by convention.
100 pub left: i32,
101 /// Child id (< `id`).
102 pub right: i32,
103 /// Pairwise log Bayes factor under the empirical-Bayes Dirichlet-Multinomial
104 /// model: `log p(D_merged | H₁) − log p(D_i | H₀) − log p(D_j | H₀)`.
105 /// Positive → data favor merging; negative → favor keeping separate.
106 /// Magnitude at high counts is ~`n · KL(p_i ‖ p_j)` (BIC-like).
107 /// Only meaningful within one run — not an absolute probability.
108 pub log_bf: f64,
109 /// Total effective sample size under this node (Σ over children).
110 pub n_samples: i32,
111}
112
113/// Sufficient stats for one node in the agglomeration arena.
114struct NodeStats {
115 t_gene: Vec<f64>,
116 s_size: f64,
117 n_samples: i32,
118 /// Cached f(T, S) for this node; re-used when this node participates in any pairwise BF.
119 f_cache: f64,
120 /// Public id (original cluster id for leaves, K .. 2K−2 for internal nodes).
121 id: i32,
122}
123
124#[inline]
125fn order_pair(a: i32, b: i32) -> (i32, i32) {
126 if a < b {
127 (a, b)
128 } else {
129 (b, a)
130 }
131}
132
133/// A node's best partner among the active nodes before it in the arena.
134#[derive(Clone, Copy)]
135struct RowBest {
136 /// Arena index of the partner.
137 lo: usize,
138 log_bf: f64,
139 /// `(min_id, max_id)` of the pair, the tiebreak.
140 key: (i32, i32),
141}
142
143/// Whether a pair scoring `log_bf` with ids `key` beats `best`: the larger
144/// log BF, ties to the ascending `(min_id, max_id)`.
145#[inline]
146fn beats(log_bf: f64, key: (i32, i32), best: Option<&RowBest>) -> bool {
147 best.is_none_or(|b| log_bf > b.log_bf || (log_bf == b.log_bf && key < b.key))
148}
149
150/// The best partner of arena node `hi` among the `active` nodes before it,
151/// from its cached row of log BFs. `active` is in ascending arena order.
152fn best_in_row(hi: usize, row: &[f64], active: &[usize], arena: &[NodeStats]) -> Option<RowBest> {
153 let mut best: Option<RowBest> = None;
154 for &lo in active.iter().take_while(|&&lo| lo < hi) {
155 let key = order_pair(arena[lo].id, arena[hi].id);
156 if beats(row[lo], key, best.as_ref()) {
157 best = Some(RowBest {
158 lo,
159 log_bf: row[lo],
160 key,
161 });
162 }
163 }
164 best
165}
166
167/// Empirical-Bayes prior constants. Precomputed once per `bhc_merge` call.
168struct Prior {
169 /// Total Dirichlet concentration γ.
170 gamma: f64,
171 /// Per-gene prior mass `γ · bg[g]`.
172 gamma_bg: Vec<f64>,
173 /// Σ_g lgamma(γ · bg[g]).
174 sum_lgamma_gamma_bg: f64,
175 /// lgamma(γ).
176 lgamma_gamma: f64,
177}
178
179/// Node log marginal under Dir(γ · bg):
180///
181/// f(T, S) = lgamma(γ) − lgamma(γ + S)
182/// + Σ_g [lgamma(γ·bg[g] + T_g) − lgamma(γ·bg[g])]
183#[inline]
184fn f_node(t_gene: &[f64], s_size: f64, prior: &Prior) -> f64 {
185 let mut sum_new = 0.0f64;
186 for (&t, &gb) in t_gene.iter().zip(prior.gamma_bg.iter()) {
187 sum_new += SpecialGamma::ln_gamma(gb + t).0;
188 }
189 prior.lgamma_gamma - SpecialGamma::ln_gamma(prior.gamma + s_size).0 + sum_new
190 - prior.sum_lgamma_gamma_bg
191}
192
193/// Pairwise log BF; evaluates `f(merged)` inline and subtracts children's cached f's.
194#[inline]
195fn pairwise_log_bf(a: &NodeStats, b: &NodeStats, prior: &Prior) -> f64 {
196 let mut merged_sum = 0.0f64;
197 for ((&ta, &tb), &gb) in a
198 .t_gene
199 .iter()
200 .zip(b.t_gene.iter())
201 .zip(prior.gamma_bg.iter())
202 {
203 merged_sum += SpecialGamma::ln_gamma(gb + ta + tb).0;
204 }
205 let s_merged = a.s_size + b.s_size;
206 let f_merged = prior.lgamma_gamma - SpecialGamma::ln_gamma(prior.gamma + s_merged).0
207 + merged_sum
208 - prior.sum_lgamma_gamma_bg;
209 f_merged - a.f_cache - b.f_cache
210}
211
212/// Bottom-up agglomerative merge by pairwise log Bayes factor.
213///
214/// Returns one `BhcMerge` per merge event (K_eff − 1 rows, where K_eff is the
215/// number of non-empty clusters — those with `effective_size > 0`). Empty
216/// clusters are silently skipped and never appear in the tree.
217pub fn bhc_merge(input: BhcInput<'_>, gamma: f64) -> Vec<BhcMerge> {
218 const BG_EPS: f64 = 1e-9;
219
220 let k = input.k;
221 let m = input.m;
222 let gamma = gamma.max(1e-6);
223 assert_eq!(input.gene_sum.len(), k * m, "gene_sum must be k × m");
224 assert_eq!(input.size_sum.len(), k);
225 assert_eq!(input.effective_size.len(), k);
226
227 // Pooled empirical marginal bg[g] = (Σ_k T_k,g) / (Σ_k S_k). Floor at BG_EPS so
228 // lgamma(γ·bg) stays finite for genes with no mass anywhere (those genes then
229 // cancel out of any Bayes factor — see module docstring).
230 let mut bg = vec![0.0f64; m];
231 let mut total_mass = 0.0f64;
232 for c in 0..k {
233 if input.effective_size[c] == 0 {
234 continue;
235 }
236 let row = &input.gene_sum[c * m..(c + 1) * m];
237 for (dst, &src) in bg.iter_mut().zip(row.iter()) {
238 *dst += src;
239 }
240 total_mass += input.size_sum[c];
241 }
242 let total_mass = total_mass.max(BG_EPS);
243 for p in bg.iter_mut() {
244 *p = (*p / total_mass).max(BG_EPS);
245 }
246
247 let gamma_bg: Vec<f64> = bg.iter().map(|&p| gamma * p).collect();
248 let sum_lgamma_gamma_bg: f64 = gamma_bg
249 .iter()
250 .map(|&gb| SpecialGamma::ln_gamma(gb).0)
251 .sum();
252 let prior = Prior {
253 gamma,
254 gamma_bg,
255 sum_lgamma_gamma_bg,
256 lgamma_gamma: SpecialGamma::ln_gamma(gamma).0,
257 };
258
259 // Rescale (T, S) so effective sample size S_eff = effective_size. Preserves
260 // proportions T_g / S; puts log BF magnitudes in an interpretable range
261 // (see module docstring).
262 let mut arena: Vec<NodeStats> = Vec::with_capacity(k);
263 let mut active: Vec<usize> = Vec::with_capacity(k);
264 for c in 0..k {
265 if input.effective_size[c] == 0 {
266 continue;
267 }
268 let s_raw = input.size_sum[c];
269 let n_eff = input.effective_size[c] as f64;
270 let scale = if s_raw > 0.0 { n_eff / s_raw } else { 0.0 };
271 let t: Vec<f64> = input.gene_sum[c * m..(c + 1) * m]
272 .iter()
273 .map(|&x| x * scale)
274 .collect();
275 let f = f_node(&t, n_eff, &prior);
276 arena.push(NodeStats {
277 t_gene: t,
278 s_size: n_eff,
279 n_samples: input.effective_size[c] as i32,
280 f_cache: f,
281 id: c as i32,
282 });
283 active.push(arena.len() - 1);
284 }
285
286 let k_eff = active.len();
287 if k_eff < 2 {
288 return Vec::new();
289 }
290 let mut merges: Vec<BhcMerge> = Vec::with_capacity(k_eff - 1);
291 let mut next_id: i32 = k as i32;
292
293 // Every active pair's log BF: `bf_rows[higher][lower]` by arena index.
294 // `active` stays in arena order (removals keep the order, a new node goes
295 // last), so a pair is always scored as `(arena[lower], arena[higher])`,
296 // the same argument order as scoring every pair afresh: a merge only
297 // needs the new node's row, K²·M overall instead of K³·M. Leaves are
298 // the arena's first entries, all active, so each leaf row is complete.
299 let mut bf_rows: Vec<Vec<f64>> = (0..arena.len())
300 .into_par_iter()
301 .map(|hi| {
302 (0..hi)
303 .map(|lo| pairwise_log_bf(&arena[lo], &arena[hi], &prior))
304 .collect()
305 })
306 .collect();
307
308 // Each node's best partner among the active nodes before it. Every
309 // active pair sits in exactly one row, so the best pair overall is the
310 // best of the row bests.
311 let mut row_best: Vec<Option<RowBest>> = (0..arena.len())
312 .into_par_iter()
313 .map(|hi| best_in_row(hi, &bf_rows[hi], &active, &arena))
314 .collect();
315
316 while active.len() >= 2 {
317 // Maximize log BF; tiebreak on ascending (min_id, max_id).
318 let mut best: Option<(usize, RowBest)> = None;
319 for &hi in &active {
320 if let Some(rb) = row_best[hi] {
321 if beats(rb.log_bf, rb.key, best.as_ref().map(|(_, b)| b)) {
322 best = Some((hi, rb));
323 }
324 }
325 }
326
327 let (aj, RowBest { lo: ai, log_bf, .. }) = best.expect("at least one pair exists");
328 let (left_id, right_id) = order_pair(arena[ai].id, arena[aj].id);
329
330 let new_t: Vec<f64> = arena[ai]
331 .t_gene
332 .iter()
333 .zip(arena[aj].t_gene.iter())
334 .map(|(x, y)| x + y)
335 .collect();
336 let new_s = arena[ai].s_size + arena[aj].s_size;
337 let new_n = arena[ai].n_samples + arena[aj].n_samples;
338 let new_f = f_node(&new_t, new_s, &prior);
339
340 arena.push(NodeStats {
341 t_gene: new_t,
342 s_size: new_s,
343 n_samples: new_n,
344 f_cache: new_f,
345 id: next_id,
346 });
347 let new_arena_idx = arena.len() - 1;
348
349 merges.push(BhcMerge {
350 id: next_id,
351 left: left_id,
352 right: right_id,
353 log_bf,
354 n_samples: new_n,
355 });
356 next_id += 1;
357
358 active.retain(|&a| a != ai && a != aj);
359 // The merged nodes never come back; their rows are freed, and the
360 // new node's row is scored against the nodes still active.
361 bf_rows[ai] = Vec::new();
362 bf_rows[aj] = Vec::new();
363 // A row whose best partner was merged away is scanned again; the
364 // others keep theirs.
365 for &hi in &active {
366 if row_best[hi].is_some_and(|rb| rb.lo == ai || rb.lo == aj) {
367 row_best[hi] = best_in_row(hi, &bf_rows[hi], &active, &arena);
368 }
369 }
370 let mut row = vec![f64::NAN; new_arena_idx];
371 let scored: Vec<f64> = active
372 .par_iter()
373 .map(|&lo| pairwise_log_bf(&arena[lo], &arena[new_arena_idx], &prior))
374 .collect();
375 for (&lo, bf) in active.iter().zip(scored) {
376 row[lo] = bf;
377 }
378 row_best.push(best_in_row(new_arena_idx, &row, &active, &arena));
379 bf_rows.push(row);
380 active.push(new_arena_idx);
381 }
382
383 merges
384}
385
386/// Walk the merge tree and collapse children whose `log_bf >= cutoff`.
387///
388/// Returns dense consensus labels indexed by original cluster id (length `k`).
389/// A cluster that appears in no merge gets label `-1`: the clusters that were
390/// empty (`effective_size == 0` during `bhc_merge`), and also the single
391/// non-empty cluster of a run with only one, which leaves no merge to find it
392/// in. [`bhc_cut_with_sizes`] tells the two apart.
393///
394/// A cutoff of `0.0` is the natural Bayesian break point: positive log BF means
395/// the data prefers merging; negative means separating is preferred.
396pub fn bhc_cut(merges: &[BhcMerge], k: usize, cutoff: f64) -> Vec<i32> {
397 // Non-empty originals appear as a direct leaf in exactly one merge, when
398 // there is a merge at all.
399 let mut referenced = vec![false; k];
400 for m in merges {
401 for child in [m.left as usize, m.right as usize] {
402 if child < k {
403 referenced[child] = true;
404 }
405 }
406 }
407 cut_labels(merges, &referenced, cutoff)
408}
409
410/// [`bhc_cut`] with the `effective_size` given to `bhc_merge`: exactly the
411/// clusters with `effective_size == 0` get label `-1`, so the only non-empty
412/// cluster of a run without merges keeps a label of its own.
413pub fn bhc_cut_with_sizes(merges: &[BhcMerge], effective_size: &[usize], cutoff: f64) -> Vec<i32> {
414 let non_empty: Vec<bool> = effective_size.iter().map(|&n| n > 0).collect();
415 cut_labels(merges, &non_empty, cutoff)
416}
417
418/// Dense labels of the clusters flagged in `labelled` (the rest get `-1`)
419/// after joining the children of every merge with `log_bf >= cutoff`.
420fn cut_labels(merges: &[BhcMerge], labelled: &[bool], cutoff: f64) -> Vec<i32> {
421 let k = labelled.len();
422 let mut uf = UnionFind::new(k);
423 // Representative leaf id per node, indexed by BhcMerge.id. Ids are contiguous
424 // K .. K + merges.len() − 1, so we size once up front.
425 let mut rep: Vec<usize> = Vec::with_capacity(k + merges.len());
426 rep.extend(0..k);
427 rep.resize(k + merges.len(), 0);
428
429 for m in merges {
430 let l_rep = rep[m.left as usize];
431 let r_rep = rep[m.right as usize];
432 if m.log_bf >= cutoff {
433 uf.union(l_rep, r_rep);
434 }
435 rep[m.id as usize] = l_rep.min(r_rep);
436 }
437
438 let mut labels = vec![-1i32; k];
439 let mut root_to_dense: Vec<Option<i32>> = vec![None; k];
440 let mut next_dense: i32 = 0;
441 for c in 0..k {
442 if !labelled[c] {
443 continue;
444 }
445 let root = uf.find(c);
446 let label = match root_to_dense[root] {
447 Some(l) => l,
448 None => {
449 let l = next_dense;
450 root_to_dense[root] = Some(l);
451 next_dense += 1;
452 l
453 }
454 };
455 labels[c] = label;
456 }
457 labels
458}
459
460#[cfg(test)]
461#[path = "bhc_tests.rs"]
462mod tests;