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