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, so scores are monotonically non-increasing along the merge sequence.
44//! The crossing point `log_BF = 0` is the natural "consensus cut" threshold
45//! exposed via `bhc_cut(merges, k, 0.0)`.
46//!
47//! **Effective sample size is caller-specified.** The caller passes `effective_size`
48//! per cluster and the internal `(T, S)` gets rescaled so that `S_eff = effective_size[k]`,
49//! preserving shape `T_g / S`. Without rescaling, large raw `S` values (e.g.
50//! 10⁴–10⁶ of projection mass on Visium scale, or Σ_n θ_{n,k}·depth_n at
51//! 100k-cell scale) make the DM posterior absurdly sharp and blow up log-BF
52//! magnitudes. Typical choices:
53//! - link-community: `effective_size[k] = edge_count[k]` (the natural N).
54//! - topic model: `effective_size[k] = (Σ_n θ_{n,k})² / Σ_n θ_{n,k}²` (ESS of the soft assignment).
55//!
56//! A Heller-Ghahramani DP tree prior can be slotted in later: extend with a
57//! `BhcConfig { dp_alpha, ... }` and replace the pure pairwise log-BF rule
58//! with the full posterior `r_k` recursion.
59//!
60//! Runs over K clusters (not N samples). Pairwise log BFs are cached, so only
61//! a new node's K pairs are scored after each merge: K²·M lgamma calls overall,
62//! spread over threads. Picking the best pair still scans the cached scores,
63//! K² lookups per merge (K³ overall, no lgamma), and the cache holds up to
64//! 2K² values: meant for K up to a few thousand. No RNG; the merges match
65//! scoring every pair afresh.
66
67use crate::alg::union_find::UnionFind;
68use rayon::prelude::*;
69use special::Gamma as SpecialGamma;
70
71/// Borrowed view over the per-cluster sufficient statistics needed by `bhc_merge`.
72///
73/// Callers stash their own cluster struct (e.g. `LinkCommunityStats`, topic-model
74/// posterior stats) and construct this view without copying.
75pub struct BhcInput<'a> {
76 /// Number of clusters.
77 pub k: usize,
78 /// Feature dimension (genes).
79 pub m: usize,
80 /// Row-major `[k × m]` gene-mass per cluster. `gene_sum[c * m + g]` = T_{c,g}.
81 pub gene_sum: &'a [f64],
82 /// Per-cluster total size `S_k = Σ_g T_{k,g}` (or an equivalent scale; the
83 /// only requirement is `T_k / S_k` is the cluster's proportion vector).
84 pub size_sum: &'a [f64],
85 /// Per-cluster effective sample size used for DM posterior sharpness.
86 /// `(T_g, S)` is rescaled so `S_eff = effective_size[k]` before evaluating
87 /// the Bayes factor. A cluster with `effective_size == 0` is skipped.
88 pub effective_size: &'a [usize],
89}
90
91/// One merge event in the dendrogram.
92#[derive(Debug, Clone)]
93pub struct BhcMerge {
94 /// New node id. Starts at K and increments by 1 per merge.
95 pub id: i32,
96 /// Child id (< `id`). `left < right` by convention.
97 pub left: i32,
98 /// Child id (< `id`).
99 pub right: i32,
100 /// Pairwise log Bayes factor under the empirical-Bayes Dirichlet-Multinomial
101 /// model: `log p(D_merged | H₁) − log p(D_i | H₀) − log p(D_j | H₀)`.
102 /// Positive → data favor merging; negative → favor keeping separate.
103 /// Magnitude at high counts is ~`n · KL(p_i ‖ p_j)` (BIC-like).
104 /// Only meaningful within one run — not an absolute probability.
105 pub log_bf: f64,
106 /// Total effective sample size under this node (Σ over children).
107 pub n_samples: i32,
108}
109
110/// Sufficient stats for one node in the agglomeration arena.
111struct NodeStats {
112 t_gene: Vec<f64>,
113 s_size: f64,
114 n_samples: i32,
115 /// Cached f(T, S) for this node; re-used when this node participates in any pairwise BF.
116 f_cache: f64,
117 /// Public id (original cluster id for leaves, K .. 2K−2 for internal nodes).
118 id: i32,
119}
120
121#[inline]
122fn order_pair(a: i32, b: i32) -> (i32, i32) {
123 if a < b {
124 (a, b)
125 } else {
126 (b, a)
127 }
128}
129
130/// Empirical-Bayes prior constants. Precomputed once per `bhc_merge` call.
131struct Prior {
132 /// Total Dirichlet concentration γ.
133 gamma: f64,
134 /// Per-gene prior mass `γ · bg[g]`.
135 gamma_bg: Vec<f64>,
136 /// Σ_g lgamma(γ · bg[g]).
137 sum_lgamma_gamma_bg: f64,
138 /// lgamma(γ).
139 lgamma_gamma: f64,
140}
141
142/// Node log marginal under Dir(γ · bg):
143///
144/// f(T, S) = lgamma(γ) − lgamma(γ + S)
145/// + Σ_g [lgamma(γ·bg[g] + T_g) − lgamma(γ·bg[g])]
146#[inline]
147fn f_node(t_gene: &[f64], s_size: f64, prior: &Prior) -> f64 {
148 let mut sum_new = 0.0f64;
149 for (&t, &gb) in t_gene.iter().zip(prior.gamma_bg.iter()) {
150 sum_new += SpecialGamma::ln_gamma(gb + t).0;
151 }
152 prior.lgamma_gamma - SpecialGamma::ln_gamma(prior.gamma + s_size).0 + sum_new
153 - prior.sum_lgamma_gamma_bg
154}
155
156/// Pairwise log BF; evaluates `f(merged)` inline and subtracts children's cached f's.
157#[inline]
158fn pairwise_log_bf(a: &NodeStats, b: &NodeStats, prior: &Prior) -> f64 {
159 let mut merged_sum = 0.0f64;
160 for ((&ta, &tb), &gb) in a
161 .t_gene
162 .iter()
163 .zip(b.t_gene.iter())
164 .zip(prior.gamma_bg.iter())
165 {
166 merged_sum += SpecialGamma::ln_gamma(gb + ta + tb).0;
167 }
168 let s_merged = a.s_size + b.s_size;
169 let f_merged = prior.lgamma_gamma - SpecialGamma::ln_gamma(prior.gamma + s_merged).0
170 + merged_sum
171 - prior.sum_lgamma_gamma_bg;
172 f_merged - a.f_cache - b.f_cache
173}
174
175/// Bottom-up agglomerative merge by pairwise log Bayes factor.
176///
177/// Returns one `BhcMerge` per merge event (K_eff − 1 rows, where K_eff is the
178/// number of non-empty clusters — those with `effective_size > 0`). Empty
179/// clusters are silently skipped and never appear in the tree.
180pub fn bhc_merge(input: BhcInput<'_>, gamma: f64) -> Vec<BhcMerge> {
181 const BG_EPS: f64 = 1e-9;
182
183 let k = input.k;
184 let m = input.m;
185 let gamma = gamma.max(1e-6);
186 assert_eq!(input.gene_sum.len(), k * m, "gene_sum must be k × m");
187 assert_eq!(input.size_sum.len(), k);
188 assert_eq!(input.effective_size.len(), k);
189
190 // Pooled empirical marginal bg[g] = (Σ_k T_k,g) / (Σ_k S_k). Floor at BG_EPS so
191 // lgamma(γ·bg) stays finite for genes with no mass anywhere (those genes then
192 // cancel out of any Bayes factor — see module docstring).
193 let mut bg = vec![0.0f64; m];
194 let mut total_mass = 0.0f64;
195 for c in 0..k {
196 if input.effective_size[c] == 0 {
197 continue;
198 }
199 let row = &input.gene_sum[c * m..(c + 1) * m];
200 for (dst, &src) in bg.iter_mut().zip(row.iter()) {
201 *dst += src;
202 }
203 total_mass += input.size_sum[c];
204 }
205 let total_mass = total_mass.max(BG_EPS);
206 for p in bg.iter_mut() {
207 *p = (*p / total_mass).max(BG_EPS);
208 }
209
210 let gamma_bg: Vec<f64> = bg.iter().map(|&p| gamma * p).collect();
211 let sum_lgamma_gamma_bg: f64 = gamma_bg
212 .iter()
213 .map(|&gb| SpecialGamma::ln_gamma(gb).0)
214 .sum();
215 let prior = Prior {
216 gamma,
217 gamma_bg,
218 sum_lgamma_gamma_bg,
219 lgamma_gamma: SpecialGamma::ln_gamma(gamma).0,
220 };
221
222 // Rescale (T, S) so effective sample size S_eff = effective_size. Preserves
223 // proportions T_g / S; puts log BF magnitudes in an interpretable range
224 // (see module docstring).
225 let mut arena: Vec<NodeStats> = Vec::with_capacity(k);
226 let mut active: Vec<usize> = Vec::with_capacity(k);
227 for c in 0..k {
228 if input.effective_size[c] == 0 {
229 continue;
230 }
231 let s_raw = input.size_sum[c];
232 let n_eff = input.effective_size[c] as f64;
233 let scale = if s_raw > 0.0 { n_eff / s_raw } else { 0.0 };
234 let t: Vec<f64> = input.gene_sum[c * m..(c + 1) * m]
235 .iter()
236 .map(|&x| x * scale)
237 .collect();
238 let f = f_node(&t, n_eff, &prior);
239 arena.push(NodeStats {
240 t_gene: t,
241 s_size: n_eff,
242 n_samples: input.effective_size[c] as i32,
243 f_cache: f,
244 id: c as i32,
245 });
246 active.push(arena.len() - 1);
247 }
248
249 let k_eff = active.len();
250 if k_eff < 2 {
251 return Vec::new();
252 }
253 let mut merges: Vec<BhcMerge> = Vec::with_capacity(k_eff - 1);
254 let mut next_id: i32 = k as i32;
255
256 // Every active pair's log BF: `bf_rows[higher][lower]` by arena index.
257 // `active` stays in arena order (removals keep the order, a new node goes
258 // last), so a pair is always scored as `(arena[lower], arena[higher])`,
259 // the same argument order as scoring every pair afresh: a merge only
260 // needs the new node's row, K²·M overall instead of K³·M. Leaves are
261 // the arena's first entries, all active, so each leaf row is complete.
262 let mut bf_rows: Vec<Vec<f64>> = (0..arena.len())
263 .into_par_iter()
264 .map(|hi| {
265 (0..hi)
266 .map(|lo| pairwise_log_bf(&arena[lo], &arena[hi], &prior))
267 .collect()
268 })
269 .collect();
270
271 while active.len() >= 2 {
272 // Maximize log BF; tiebreak on ascending (min_id, max_id).
273 let mut best: Option<(usize, usize, f64, (i32, i32))> = None;
274 for i in 0..active.len() {
275 for j in (i + 1)..active.len() {
276 let a = &arena[active[i]];
277 let b = &arena[active[j]];
278 let bf = bf_rows[active[j]][active[i]];
279 let key = order_pair(a.id, b.id);
280 let pick = match best {
281 None => true,
282 Some((_, _, best_bf, best_key)) => {
283 bf > best_bf || (bf == best_bf && key < best_key)
284 }
285 };
286 if pick {
287 best = Some((i, j, bf, key));
288 }
289 }
290 }
291
292 let (i, j, log_bf, _) = best.expect("at least one pair exists");
293 let ai = active[i];
294 let aj = active[j];
295 let (left_id, right_id) = order_pair(arena[ai].id, arena[aj].id);
296
297 let new_t: Vec<f64> = arena[ai]
298 .t_gene
299 .iter()
300 .zip(arena[aj].t_gene.iter())
301 .map(|(x, y)| x + y)
302 .collect();
303 let new_s = arena[ai].s_size + arena[aj].s_size;
304 let new_n = arena[ai].n_samples + arena[aj].n_samples;
305 let new_f = f_node(&new_t, new_s, &prior);
306
307 arena.push(NodeStats {
308 t_gene: new_t,
309 s_size: new_s,
310 n_samples: new_n,
311 f_cache: new_f,
312 id: next_id,
313 });
314 let new_arena_idx = arena.len() - 1;
315
316 merges.push(BhcMerge {
317 id: next_id,
318 left: left_id,
319 right: right_id,
320 log_bf,
321 n_samples: new_n,
322 });
323 next_id += 1;
324
325 // Remove j first (j > i) so the index for i stays valid.
326 active.remove(j);
327 active.remove(i);
328 // The merged nodes never come back; their rows are freed, and the
329 // new node's row is scored against the nodes still active.
330 bf_rows[ai] = Vec::new();
331 bf_rows[aj] = Vec::new();
332 let mut row = vec![f64::NAN; new_arena_idx];
333 let scored: Vec<f64> = active
334 .par_iter()
335 .map(|&lo| pairwise_log_bf(&arena[lo], &arena[new_arena_idx], &prior))
336 .collect();
337 for (&lo, bf) in active.iter().zip(scored) {
338 row[lo] = bf;
339 }
340 bf_rows.push(row);
341 active.push(new_arena_idx);
342 }
343
344 merges
345}
346
347/// Walk the merge tree and collapse children whose `log_bf >= cutoff`.
348///
349/// Returns dense consensus labels indexed by original cluster id (length `k`).
350/// Clusters that were empty (`effective_size == 0` during `bhc_merge` and
351/// therefore never appear in any merge) get label `-1`.
352///
353/// A cutoff of `0.0` is the natural Bayesian break point: positive log BF means
354/// the data prefers merging; negative means separating is preferred.
355pub fn bhc_cut(merges: &[BhcMerge], k: usize, cutoff: f64) -> Vec<i32> {
356 let mut uf = UnionFind::new(k);
357 // Representative leaf id per node, indexed by BhcMerge.id. Ids are contiguous
358 // K .. K + merges.len() − 1, so we size once up front.
359 let mut rep: Vec<usize> = Vec::with_capacity(k + merges.len());
360 rep.extend(0..k);
361 rep.resize(k + merges.len(), 0);
362 // Non-empty originals appear as a direct leaf in exactly one merge.
363 let mut referenced = vec![false; k];
364
365 for m in merges {
366 if (m.left as usize) < k {
367 referenced[m.left as usize] = true;
368 }
369 if (m.right as usize) < k {
370 referenced[m.right as usize] = true;
371 }
372 let l_rep = rep[m.left as usize];
373 let r_rep = rep[m.right as usize];
374 if m.log_bf >= cutoff {
375 uf.union(l_rep, r_rep);
376 }
377 rep[m.id as usize] = l_rep.min(r_rep);
378 }
379
380 let mut labels = vec![-1i32; k];
381 let mut root_to_dense: Vec<Option<i32>> = vec![None; k];
382 let mut next_dense: i32 = 0;
383 for c in 0..k {
384 if !referenced[c] {
385 continue;
386 }
387 let root = uf.find(c);
388 let label = match root_to_dense[root] {
389 Some(l) => l,
390 None => {
391 let l = next_dense;
392 root_to_dense[root] = Some(l);
393 next_dense += 1;
394 l
395 }
396 };
397 labels[c] = label;
398 }
399 labels
400}
401
402#[cfg(test)]
403#[path = "bhc_tests.rs"]
404mod tests;