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, 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;