Skip to main content

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;