Skip to main content

data_beans/alg/
refine_multilevel.rs

1//! BBKNN-based Poisson refinement over multi-level pb-sample partitions.
2//!
3//! Given a hash-initialized hierarchy of pb-sample → group mappings and a
4//! `PbSampleLayout` (HNSW over centroids, batch assignments), refine each
5//! level from coarsest to finest by proposing moves that keep each pb-sample
6//! under the same parent group (sibling-constrained) and are drawn from the
7//! batch-balanced KNN neighborhood. Moves are scored by a DC-Poisson
8//! log-likelihood with NB Fisher-info feature weighting (see
9//! [`crate::alg::dc_poisson::FeatureWeighting`]), on pb-sample profiles
10//! divided by their batch's per-gene factor ([`Profiles::rescale_batches`]):
11//! a group pools the pb-samples of several batches, and on raw profiles a
12//! pb-sample would fit the group that holds most of its own batch. The
13//! factors are fitted within the groups of the coarsest level, so that
14//! batches of different cell-type composition are not corrected for their
15//! composition; what differs between batches within one such group is
16//! taken for a batch factor (see [`refine_assignments`]).
17//!
18//! Candidate-set construction is BBKNN-specific and lives here ([`BbknnProposer`]).
19//! The generic scoring core lives in [`crate::alg::dc_poisson`] and is shared with
20//! other front-ends (e.g. pinto's spatial-graph proposer).
21//!
22//! Entry point: [`refine_assignments`].
23
24#![allow(dead_code)]
25
26use crate::alg::collapse_data::{per_batch_sc_neighbors, PbSampleLayout};
27use crate::alg::dc_poisson::{
28    compute_sibling_sets, intersect_with_siblings_fallback, refine_with_proposer,
29    CandidateProposer, ProfileSource, Profiles, RefineContext,
30};
31use log::{debug, info};
32use rand::rngs::SmallRng;
33use rand::SeedableRng;
34use rustc_hash::FxHashMap;
35
36pub use crate::alg::collapse_data::GeneSums;
37// Re-export the DC-Poisson core items so existing callers / integration
38// tests that expect `refine_multilevel::{RefineParams, compact_labels, ...}`
39// keep working after the core moved.
40pub use crate::alg::dc_poisson::{compact_labels, RefineParams};
41
42//////////////////////////
43// Public configuration //
44//////////////////////////
45
46/// Per-level refined pb-sample → group mapping.
47///
48/// `pbsamp_to_group[level][pbsamp_idx] = refined group id at that level`. Level order
49/// matches the existing collapse convention (`level_dims[0]` = finest).
50#[derive(Debug, Clone)]
51pub struct RefinedAssignment {
52    pub pbsamp_to_group: Vec<Vec<usize>>,
53    pub num_groups_per_level: Vec<usize>,
54}
55
56/////////////////////////////////////////////////////////////////////////
57// Candidate set construction (siblings ∩ BBKNN with sibling fallback) //
58/////////////////////////////////////////////////////////////////////////
59
60/// The candidate pb-sample ids of a match table — distances are unused for
61/// refinement.
62fn candidate_ids(matches: &[Vec<(usize, f32)>]) -> Vec<Vec<usize>> {
63    matches
64        .iter()
65        .map(|hits| hits.iter().map(|&(p, _)| p).collect())
66        .collect()
67}
68
69/// Candidate set per pb-sample = siblings ∩ (groups of BBKNN neighbors),
70/// via the shared [`intersect_with_siblings_fallback`] helper.
71///
72/// With `pb_sample_to_stratum`, a pb-sample's siblings are first cut down to
73/// its own group and the groups made of its stratum only, so neither the
74/// intersection nor the sibling fallback (a pb-sample without a same-stratum
75/// match in another batch has no BBKNN neighbors) can move it across strata.
76fn build_candidate_sets(
77    siblings: &[Vec<usize>],
78    bbknn: &[Vec<usize>],
79    pbsamp_to_group_at_level: &[usize],
80    pb_sample_to_stratum: Option<&[usize]>,
81) -> Vec<Vec<usize>> {
82    let strata = pb_sample_to_stratum
83        .map(|stratum| (stratum, group_strata(pbsamp_to_group_at_level, stratum)));
84    siblings
85        .iter()
86        .enumerate()
87        .map(|(pbsamp, sib)| {
88            let own = pbsamp_to_group_at_level[pbsamp];
89            let mut neighbor_groups: Vec<usize> = bbknn[pbsamp]
90                .iter()
91                .map(|&j| pbsamp_to_group_at_level[j])
92                .collect();
93            neighbor_groups.sort_unstable();
94            neighbor_groups.dedup();
95            match strata.as_ref() {
96                Some((stratum, of_group)) => {
97                    let same_stratum: Vec<usize> = sib
98                        .iter()
99                        .copied()
100                        .filter(|&g| g == own || of_group.get(g) == Some(&Some(stratum[pbsamp])))
101                        .collect();
102                    intersect_with_siblings_fallback(&same_stratum, &neighbor_groups, own)
103                }
104                None => intersect_with_siblings_fallback(sib, &neighbor_groups, own),
105            }
106        })
107        .collect()
108}
109
110/// The one stratum of each group's members, for the groups up to the largest
111/// label in use; `None` for an empty group and for a group that mixes strata.
112fn group_strata(pbsamp_to_group: &[usize], stratum: &[usize]) -> Vec<Option<usize>> {
113    let k = pbsamp_to_group.iter().max().map_or(0, |&m| m + 1);
114    let mut of_group: Vec<Option<usize>> = vec![None; k];
115    let mut mixed = vec![false; k];
116    for (&g, &s) in pbsamp_to_group.iter().zip(stratum) {
117        match of_group[g] {
118            None => of_group[g] = Some(s),
119            Some(seen) if seen != s => mixed[g] = true,
120            Some(_) => {}
121        }
122    }
123    for (slot, &is_mixed) in of_group.iter_mut().zip(&mixed) {
124        if is_mixed {
125            *slot = None;
126        }
127    }
128    of_group
129}
130
131///////////////////////////////////
132// BBKNN-based CandidateProposer //
133///////////////////////////////////
134
135/// Proposes move candidates for pb-samples via cross-batch BBKNN.
136///
137/// For each pb-sample, the candidate set is
138/// `siblings ∩ (groups of BBKNN neighbors)`, with sibling fallback when
139/// the intersection is empty. Siblings are the other pb-samples sharing
140/// the same parent group at the next-coarser level (passed in at
141/// construction). With strata ([`Self::with_strata`]) the candidates are
142/// further limited to groups of the pb-sample's own stratum.
143pub struct BbknnProposer<'a> {
144    siblings: Vec<Vec<usize>>,
145    bbknn: &'a [Vec<usize>],
146    pb_sample_to_stratum: Option<&'a [usize]>,
147}
148
149impl<'a> BbknnProposer<'a> {
150    pub fn new(siblings: Vec<Vec<usize>>, bbknn: &'a [Vec<usize>]) -> Self {
151        Self {
152            siblings,
153            bbknn,
154            pb_sample_to_stratum: None,
155        }
156    }
157
158    /// Keep every pb-sample within groups of its own stratum
159    /// (`PbSampleLayout::pb_sample_to_stratum`); `None` leaves the proposer
160    /// unstratified.
161    #[must_use]
162    pub fn with_strata(mut self, pb_sample_to_stratum: Option<&'a [usize]>) -> Self {
163        self.pb_sample_to_stratum = pb_sample_to_stratum;
164        self
165    }
166}
167
168impl<'a> CandidateProposer for BbknnProposer<'a> {
169    fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>> {
170        build_candidate_sets(
171            &self.siblings,
172            self.bbknn,
173            labels,
174            self.pb_sample_to_stratum,
175        )
176    }
177}
178
179///////////////////////
180// Refinement driver //
181///////////////////////
182
183/// Fixed inputs describing *what* to refine. `batch_knn_lookup` is the
184/// per-batch HNSW over cells, typically obtained from
185/// `SparseIoVec::batch_knn_lookup()` after `build_hnsw_per_batch`.
186/// `k_per_batch` is the BBKNN fan-out — up to this many distinct
187/// pb-samples are drawn from **each** non-own batch as move candidates.
188#[derive(Clone, Copy)]
189pub struct RefineInputs<'a> {
190    pub layout: &'a PbSampleLayout,
191    pub gene_sums: &'a GeneSums,
192    pub num_genes: usize,
193    pub pb_sample_to_cells: &'a [Vec<usize>],
194    pub batch_knn_lookup: &'a [legume_numeric::matrix::knn_match::ColumnDict<usize>],
195    pub k_per_batch: usize,
196    pub initial_sc_to_group_per_level: &'a [Vec<usize>],
197    /// Per-level reprojection offsets used to re-anchor each finer level in its
198    /// refined parent (the child hash *relative to its parent* — the "extra
199    /// bits"). `reproject_offsets_per_level[level]` is the per-pb-sample offset
200    /// for subdividing `level` against `level+1`; the coarsest level's entry is
201    /// unused (no parent). An empty outer slice (or empty per-level entry)
202    /// falls back to the positional [`child_offset_within_parent`], which is
203    /// also bounded but not hash-semantic. See `collapse_data::refine`.
204    pub reproject_offsets_per_level: &'a [Vec<usize>],
205}
206
207/// Top-down BBKNN + Poisson refinement.
208///
209/// Levels in `inputs.initial_sc_to_group_per_level` follow the existing
210/// finest → coarsest convention. The returned `RefinedAssignment` uses the
211/// same ordering.
212///
213/// With [`ProfileSource::Raw`], the pb-sample profiles are first divided by
214/// a per-gene factor of their batch ([`Profiles::rescale_batches`]), fitted
215/// within the starting groups of the coarsest level. This assumes that
216/// pb-samples of one such group have the same expected profile in every
217/// batch up to that factor. Where a coarse group mixes cell types in
218/// proportions that differ between batches, or where the coarsest level is
219/// too fine to compare batches within a group (then the batches are
220/// compared with the pooled profile, which assumes batches of one
221/// composition), part of that difference is removed as if it were
222/// technical, and pb-samples of one type are matched less well across
223/// those batches.
224pub fn refine_assignments(
225    inputs: &RefineInputs<'_>,
226    params: &RefineParams,
227) -> anyhow::Result<RefinedAssignment> {
228    refine_assignments_with_matches(inputs, params, None)
229}
230
231/// [`refine_assignments`] with the pooled cross-batch match table already at
232/// hand: `pooled_matches` is `per_batch_sc_neighbors(layout, batch_knn_lookup,
233/// k_per_batch, None)`, which the caller also needs for the matched
234/// statistics. `None` computes it here.
235pub(crate) fn refine_assignments_with_matches(
236    inputs: &RefineInputs<'_>,
237    params: &RefineParams,
238    pooled_matches: Option<&[Vec<(usize, f32)>]>,
239) -> anyhow::Result<RefinedAssignment> {
240    let RefineInputs {
241        layout,
242        gene_sums,
243        num_genes,
244        pb_sample_to_cells,
245        batch_knn_lookup,
246        k_per_batch,
247        initial_sc_to_group_per_level,
248        reproject_offsets_per_level,
249    } = *inputs;
250    let num_levels = initial_sc_to_group_per_level.len();
251    if num_levels == 0 {
252        return Err(anyhow::anyhow!("no levels"));
253    }
254    let num_pb = layout.cell_counts.len();
255    for (i, lvl) in initial_sc_to_group_per_level.iter().enumerate() {
256        if lvl.len() != num_pb {
257            return Err(anyhow::anyhow!(
258                "level {} has {} entries, expected {}",
259                i,
260                lvl.len(),
261                num_pb
262            ));
263        }
264    }
265
266    // Compact each level's initial labels to a dense 0..K range.
267    let mut refined: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
268    let mut ks: Vec<usize> = Vec::with_capacity(num_levels);
269    for lvl in initial_sc_to_group_per_level {
270        let (compact, k) = compact_labels(lvl);
271        refined.push(compact);
272        ks.push(k);
273    }
274
275    // Both sweep counts zero ⇒ no DC-Poisson moves can happen; skip the
276    // (expensive) profile build, NB-Fisher weighting, and BBKNN construction
277    // and return the compacted initial labels.
278    if params.num_gibbs == 0 && params.num_greedy == 0 {
279        info!("Skipping DC-Poisson refinement: --pb-refine-gibbs=0 and --pb-refine-greedy=0");
280        return Ok(RefinedAssignment {
281            pbsamp_to_group: refined,
282            num_groups_per_level: ks,
283        });
284    }
285
286    // Build profiles once.
287    info!(
288        "Building DC-Poisson profiles ({} pb-samples × {} genes) ...",
289        num_pb, num_genes
290    );
291    let mut profiles = match &params.profile_source {
292        ProfileSource::Raw => Profiles::from_gene_sums(gene_sums, num_genes),
293        ProfileSource::Projected { basis } => Profiles::from_projection(basis, pb_sample_to_cells),
294    };
295    if matches!(params.profile_source, ProfileSource::Raw) {
296        // Batch factors first: the NB trend behind the feature weights is
297        // then fitted to profiles that no longer carry them as extra
298        // dispersion.
299        let coarsest = num_levels - 1;
300        profiles.rescale_batches(
301            &layout.pb_sample_to_batch,
302            batch_strata(&refined[coarsest], ks[coarsest]),
303        );
304        if !matches!(
305            params.feature_weighting,
306            crate::alg::dc_poisson::FeatureWeighting::None
307        ) {
308            info!("Computing NB Fisher-info feature weights ...");
309            profiles.apply_feature_weighting(params.feature_weighting);
310        }
311    }
312
313    // BBKNN proposals via the shared per-batch cell HNSW: for every non-own
314    // batch, that batch's cell-level index is queried with the pb-sample's
315    // centroid and the cells returned are deduplicated to their owning
316    // pb-samples, up to `k_per_batch` per other batch.
317    info!(
318        "Building BBKNN candidate sets (knn={} per non-own batch) ...",
319        k_per_batch
320    );
321    let bbknn = match pooled_matches {
322        Some(matches) => candidate_ids(matches),
323        // Pooled (None): the DC-SBM refinement graph should connect across
324        // every batch pair regardless of anchoring — anchoring shapes the
325        // counterfactual for δ, not the clustering topology.
326        None => candidate_ids(&per_batch_sc_neighbors(
327            layout,
328            batch_knn_lookup,
329            k_per_batch,
330            None,
331        )?),
332    };
333
334    let mut rng = SmallRng::seed_from_u64(params.seed);
335
336    // Walk coarsest → finest (highest level index down to 0).
337    for level in (0..num_levels).rev() {
338        // Refining the coarser level moves pb-samples across parents, so this
339        // finer level must be re-anchored to nest strictly in its *refined*
340        // parent before sibling sets are computed (else a straddling group
341        // lands in two parents' sibling sets and the hierarchy breaks,
342        // violating `fine_to_coarse_from_refined`'s invariant).
343        //
344        // We subdivide the refined parent by the child hash *relative to its
345        // parent* (`child_offset_within_parent`), not by the full child code.
346        // Both give a strict refinement, but the relative form is bounded by
347        // `2^child_dim` — crossing the scrambled parent with the full child
348        // code instead inflates the count ~20× (e.g. 1024 leaf codes →
349        // 19098 finest groups), which is the level-graduation pathology.
350        if level + 1 < num_levels {
351            // Prefer the hash-semantic extra-bit offsets the caller precomputed;
352            // fall back to the positional offset (still bounded) when absent.
353            let offset: Vec<usize> = match reproject_offsets_per_level.get(level) {
354                Some(o) if !o.is_empty() => o.clone(),
355                _ => child_offset_within_parent(
356                    &initial_sc_to_group_per_level[level],
357                    &initial_sc_to_group_per_level[level + 1],
358                ),
359            };
360            let (reprojected, new_k) = project_to_refinement(&offset, &refined[level + 1]);
361            refined[level] = reprojected;
362            ks[level] = new_k;
363        }
364        let k = ks[level];
365        debug!("refining level {} (k={}, num_pb={})", level, k, num_pb);
366        let siblings = compute_sibling_sets(&refined, level, k);
367
368        let proposer = BbknnProposer::new(siblings, &bbknn)
369            .with_strata(layout.pb_sample_to_stratum.as_deref());
370
371        let pbsamp_to_group = &mut refined[level];
372        let label = format!("Refine L{}/{}", num_levels - level, num_levels);
373        let moves = refine_with_proposer(
374            pbsamp_to_group,
375            &proposer,
376            &mut rng,
377            &RefineContext {
378                profiles: &profiles,
379                k,
380                params,
381                level_label: &label,
382            },
383        );
384        // Compact in case a group was emptied (keeps K monotone without gaps).
385        let (compact, new_k) = compact_labels(pbsamp_to_group);
386        *pbsamp_to_group = compact;
387        ks[level] = new_k;
388        info!(
389            "  level {} refined: {} moves; {} of {} groups remain",
390            level, moves, new_k, k
391        );
392    }
393
394    Ok(RefinedAssignment {
395        pbsamp_to_group: refined,
396        num_groups_per_level: ks,
397    })
398}
399
400/// Fewest pb-samples a group of the coarsest level holds on average for
401/// those groups to serve as the strata of the batch-factor fit.
402const MIN_PB_PER_STRATUM: usize = 8;
403
404/// The strata within which batches are compared for their factors
405/// ([`Profiles::rescale_batches`]): the `k` groups of the coarsest level as
406/// they start, which follow the cell types closely enough to keep a
407/// difference in composition between batches from being read as a batch
408/// factor. Groups of fewer than [`MIN_PB_PER_STRATUM`] pb-samples on
409/// average leave too little of each batch in a group to compare, and the
410/// batches are then compared with the pooled profile.
411fn batch_strata(coarsest: &[usize], k: usize) -> Option<&[usize]> {
412    (coarsest.len() >= MIN_PB_PER_STRATUM * k).then_some(coarsest)
413}
414
415/// Re-label `child` so it is a strict refinement of `parent`: two entities
416/// share an output label iff they agree on **both** their current child
417/// label and their parent label. Any child group that straddles multiple
418/// parents is split. Returns the relabeled vector and its group count
419/// (already compact `0..k` — `compact_labels` over the `(child, parent)`
420/// key does the first-appearance dense relabel).
421fn project_to_refinement(child: &[usize], parent: &[usize]) -> (Vec<usize>, usize) {
422    debug_assert_eq!(child.len(), parent.len());
423    let pairs: Vec<(usize, usize)> = child.iter().copied().zip(parent.iter().copied()).collect();
424    compact_labels(&pairs)
425}
426
427/// Local index of each entity's `child` label within its `parent` label, in
428/// first-seen order.
429///
430/// Used to subdivide a refined parent by the child hash *relative to its
431/// parent* (the "extra bits") rather than by the full child code. Because the
432/// child hash nests in the parent hash, each parent holds at most
433/// `2^(child_dim − parent_dim)` distinct child codes, so the offset — and any
434/// `(refined_parent, offset)` subdivision built from it — stays bounded by
435/// `2^child_dim` even after DC-SBM has scrambled the parent. Crossing the
436/// refined parent with the *full* child code instead (the old
437/// `project_to_refinement` against `refined[level]`) cross-products the
438/// scrambled parent with every child code and inflates the count ~20×.
439fn child_offset_within_parent(child: &[usize], parent: &[usize]) -> Vec<usize> {
440    debug_assert_eq!(child.len(), parent.len());
441    let mut per_parent: FxHashMap<usize, FxHashMap<usize, usize>> = FxHashMap::default();
442    let mut offsets = vec![0usize; child.len()];
443    for (i, (&c, &p)) in child.iter().zip(parent.iter()).enumerate() {
444        let local = per_parent.entry(p).or_default();
445        let next = local.len();
446        offsets[i] = *local.entry(c).or_insert(next);
447    }
448    offsets
449}
450
451////////////////////////////
452// Tests (BBKNN-specific) //
453////////////////////////////
454
455#[cfg(test)]
456#[path = "refine_multilevel_tests.rs"]
457mod tests;