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::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/// Per-pb-sample BBKNN proposals: for every non-own batch, query that
54/// batch's cell-level HNSW (`SparseIoVec::batch_knn_lookup`) with the
55/// pb-sample's centroid, dedup returned cells to their owning pb-samples
56/// via `layout.cell_to_pbsamp`, and keep up to `knn` distinct pb-samples per
57/// other batch.
58///
59/// Total candidate set per pb-sample is up to `knn · (num_batches − 1)`.
60fn build_bbknn_neighbors(
61 layout: &PbSampleLayout,
62 batch_knn_lookup: &[legume_numeric::matrix::knn_match::ColumnDict<usize>],
63 knn: usize,
64) -> anyhow::Result<Vec<Vec<usize>>> {
65 use crate::alg::collapse_data::bbknn_match_one_pbsamp;
66 use rayon::prelude::*;
67 let num_pb = layout.cell_counts.len();
68
69 // Same cross-batch matching as `per_batch_sc_neighbors`, but we keep only
70 // the candidate pb-sample ids — distances are unused for refinement.
71 (0..num_pb)
72 .into_par_iter()
73 .map(|pbsamp| -> anyhow::Result<Vec<usize>> {
74 // Pooled (None): the DC-SBM refinement graph should connect across
75 // every batch pair regardless of anchoring — anchoring shapes the
76 // counterfactual for δ, not the clustering topology.
77 let hits = bbknn_match_one_pbsamp(layout, batch_knn_lookup, knn, pbsamp, None)?;
78 Ok(hits.into_iter().map(|(p, _)| p).collect())
79 })
80 .collect()
81}
82
83/// Candidate set per pb-sample = siblings ∩ (groups of BBKNN neighbors),
84/// via the shared [`intersect_with_siblings_fallback`] helper.
85fn build_candidate_sets(
86 siblings: &[Vec<usize>],
87 bbknn: &[Vec<usize>],
88 pbsamp_to_group_at_level: &[usize],
89) -> Vec<Vec<usize>> {
90 siblings
91 .iter()
92 .enumerate()
93 .map(|(pbsamp, sib)| {
94 let mut neighbor_groups: Vec<usize> = bbknn[pbsamp]
95 .iter()
96 .map(|&j| pbsamp_to_group_at_level[j])
97 .collect();
98 neighbor_groups.sort_unstable();
99 neighbor_groups.dedup();
100 intersect_with_siblings_fallback(
101 sib,
102 &neighbor_groups,
103 pbsamp_to_group_at_level[pbsamp],
104 )
105 })
106 .collect()
107}
108
109///////////////////////////////////
110// BBKNN-based CandidateProposer //
111///////////////////////////////////
112
113/// Proposes move candidates for pb-samples via cross-batch BBKNN.
114///
115/// For each pb-sample, the candidate set is
116/// `siblings ∩ (groups of BBKNN neighbors)`, with sibling fallback when
117/// the intersection is empty. Siblings are the other pb-samples sharing
118/// the same parent group at the next-coarser level (passed in at
119/// construction).
120pub struct BbknnProposer<'a> {
121 siblings: Vec<Vec<usize>>,
122 bbknn: &'a [Vec<usize>],
123}
124
125impl<'a> BbknnProposer<'a> {
126 pub fn new(siblings: Vec<Vec<usize>>, bbknn: &'a [Vec<usize>]) -> Self {
127 Self { siblings, bbknn }
128 }
129}
130
131impl<'a> CandidateProposer for BbknnProposer<'a> {
132 fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>> {
133 build_candidate_sets(&self.siblings, self.bbknn, labels)
134 }
135}
136
137///////////////////////
138// Refinement driver //
139///////////////////////
140
141/// Fixed inputs describing *what* to refine. `batch_knn_lookup` is the
142/// per-batch HNSW over cells, typically obtained from
143/// `SparseIoVec::batch_knn_lookup()` after `build_hnsw_per_batch`.
144/// `k_per_batch` is the BBKNN fan-out — up to this many distinct
145/// pb-samples are drawn from **each** non-own batch as move candidates.
146#[derive(Clone, Copy)]
147pub struct RefineInputs<'a> {
148 pub layout: &'a PbSampleLayout,
149 pub gene_sums: &'a GeneSums,
150 pub num_genes: usize,
151 pub pb_sample_to_cells: &'a [Vec<usize>],
152 pub batch_knn_lookup: &'a [legume_numeric::matrix::knn_match::ColumnDict<usize>],
153 pub k_per_batch: usize,
154 pub initial_sc_to_group_per_level: &'a [Vec<usize>],
155 /// Per-level reprojection offsets used to re-anchor each finer level in its
156 /// refined parent (the child hash *relative to its parent* — the "extra
157 /// bits"). `reproject_offsets_per_level[level]` is the per-pb-sample offset
158 /// for subdividing `level` against `level+1`; the coarsest level's entry is
159 /// unused (no parent). An empty outer slice (or empty per-level entry)
160 /// falls back to the positional [`child_offset_within_parent`], which is
161 /// also bounded but not hash-semantic. See `collapse_data::refine`.
162 pub reproject_offsets_per_level: &'a [Vec<usize>],
163}
164
165/// Top-down BBKNN + Poisson refinement.
166///
167/// Levels in `inputs.initial_sc_to_group_per_level` follow the existing
168/// finest → coarsest convention. The returned `RefinedAssignment` uses the
169/// same ordering.
170pub fn refine_assignments(
171 inputs: &RefineInputs<'_>,
172 params: &RefineParams,
173) -> anyhow::Result<RefinedAssignment> {
174 let RefineInputs {
175 layout,
176 gene_sums,
177 num_genes,
178 pb_sample_to_cells,
179 batch_knn_lookup,
180 k_per_batch,
181 initial_sc_to_group_per_level,
182 reproject_offsets_per_level,
183 } = *inputs;
184 let num_levels = initial_sc_to_group_per_level.len();
185 if num_levels == 0 {
186 return Err(anyhow::anyhow!("no levels"));
187 }
188 let num_pb = layout.cell_counts.len();
189 for (i, lvl) in initial_sc_to_group_per_level.iter().enumerate() {
190 if lvl.len() != num_pb {
191 return Err(anyhow::anyhow!(
192 "level {} has {} entries, expected {}",
193 i,
194 lvl.len(),
195 num_pb
196 ));
197 }
198 }
199
200 // Compact each level's initial labels to a dense 0..K range.
201 let mut refined: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
202 let mut ks: Vec<usize> = Vec::with_capacity(num_levels);
203 for lvl in initial_sc_to_group_per_level {
204 let (compact, k) = compact_labels(lvl);
205 refined.push(compact);
206 ks.push(k);
207 }
208
209 // Both sweep counts zero ⇒ no DC-Poisson moves can happen; skip the
210 // (expensive) profile build, NB-Fisher weighting, and BBKNN construction
211 // and return the compacted initial labels.
212 if params.num_gibbs == 0 && params.num_greedy == 0 {
213 info!("Skipping DC-Poisson refinement: --pb-refine-gibbs=0 and --pb-refine-greedy=0");
214 return Ok(RefinedAssignment {
215 pbsamp_to_group: refined,
216 num_groups_per_level: ks,
217 });
218 }
219
220 // Build profiles once.
221 info!(
222 "Building DC-Poisson profiles ({} pb-samples × {} genes) ...",
223 num_pb, num_genes
224 );
225 let mut profiles = match ¶ms.profile_source {
226 ProfileSource::Raw => Profiles::from_gene_sums(gene_sums, num_genes),
227 ProfileSource::Projected { basis } => Profiles::from_projection(basis, pb_sample_to_cells),
228 };
229 if matches!(params.profile_source, ProfileSource::Raw)
230 && !matches!(
231 params.feature_weighting,
232 crate::alg::dc_poisson::FeatureWeighting::None
233 )
234 {
235 info!("Computing NB Fisher-info feature weights ...");
236 profiles.apply_feature_weighting(params.feature_weighting);
237 }
238
239 // BBKNN proposals via the shared per-batch cell HNSW.
240 info!(
241 "Building BBKNN candidate sets (knn={} per non-own batch) ...",
242 k_per_batch
243 );
244 let bbknn = build_bbknn_neighbors(layout, batch_knn_lookup, k_per_batch)?;
245
246 let mut rng = SmallRng::seed_from_u64(params.seed);
247
248 // Walk coarsest → finest (highest level index down to 0).
249 for level in (0..num_levels).rev() {
250 // Refining the coarser level moves pb-samples across parents, so this
251 // finer level must be re-anchored to nest strictly in its *refined*
252 // parent before sibling sets are computed (else a straddling group
253 // lands in two parents' sibling sets and the hierarchy breaks,
254 // violating `fine_to_coarse_from_refined`'s invariant).
255 //
256 // We subdivide the refined parent by the child hash *relative to its
257 // parent* (`child_offset_within_parent`), not by the full child code.
258 // Both give a strict refinement, but the relative form is bounded by
259 // `2^child_dim` — crossing the scrambled parent with the full child
260 // code instead inflates the count ~20× (e.g. 1024 leaf codes →
261 // 19098 finest groups), which is the level-graduation pathology.
262 if level + 1 < num_levels {
263 // Prefer the hash-semantic extra-bit offsets the caller precomputed;
264 // fall back to the positional offset (still bounded) when absent.
265 let offset: Vec<usize> = match reproject_offsets_per_level.get(level) {
266 Some(o) if !o.is_empty() => o.clone(),
267 _ => child_offset_within_parent(
268 &initial_sc_to_group_per_level[level],
269 &initial_sc_to_group_per_level[level + 1],
270 ),
271 };
272 let (reprojected, new_k) = project_to_refinement(&offset, &refined[level + 1]);
273 refined[level] = reprojected;
274 ks[level] = new_k;
275 }
276 let k = ks[level];
277 debug!("refining level {} (k={}, num_pb={})", level, k, num_pb);
278 let siblings = compute_sibling_sets(&refined, level, k);
279
280 let proposer = BbknnProposer::new(siblings, &bbknn);
281
282 let pbsamp_to_group = &mut refined[level];
283 let label = format!("Refine L{}/{}", num_levels - level, num_levels);
284 let moves = refine_with_proposer(
285 pbsamp_to_group,
286 &proposer,
287 &mut rng,
288 &RefineContext {
289 profiles: &profiles,
290 k,
291 params,
292 level_label: &label,
293 },
294 );
295 info!(" level {} refined: {} moves; k={} groups", level, moves, k);
296
297 // Compact in case greedy emptied groups (keeps K monotone without gaps).
298 let (compact, new_k) = compact_labels(pbsamp_to_group);
299 *pbsamp_to_group = compact;
300 ks[level] = new_k;
301 }
302
303 Ok(RefinedAssignment {
304 pbsamp_to_group: refined,
305 num_groups_per_level: ks,
306 })
307}
308
309/// Re-label `child` so it is a strict refinement of `parent`: two entities
310/// share an output label iff they agree on **both** their current child
311/// label and their parent label. Any child group that straddles multiple
312/// parents is split. Returns the relabeled vector and its group count
313/// (already compact `0..k` — `compact_labels` over the `(child, parent)`
314/// key does the first-appearance dense relabel).
315fn project_to_refinement(child: &[usize], parent: &[usize]) -> (Vec<usize>, usize) {
316 debug_assert_eq!(child.len(), parent.len());
317 let pairs: Vec<(usize, usize)> = child.iter().copied().zip(parent.iter().copied()).collect();
318 compact_labels(&pairs)
319}
320
321/// Local index of each entity's `child` label within its `parent` label, in
322/// first-seen order.
323///
324/// Used to subdivide a refined parent by the child hash *relative to its
325/// parent* (the "extra bits") rather than by the full child code. Because the
326/// child hash nests in the parent hash, each parent holds at most
327/// `2^(child_dim − parent_dim)` distinct child codes, so the offset — and any
328/// `(refined_parent, offset)` subdivision built from it — stays bounded by
329/// `2^child_dim` even after DC-SBM has scrambled the parent. Crossing the
330/// refined parent with the *full* child code instead (the old
331/// `project_to_refinement` against `refined[level]`) cross-products the
332/// scrambled parent with every child code and inflates the count ~20×.
333fn child_offset_within_parent(child: &[usize], parent: &[usize]) -> Vec<usize> {
334 debug_assert_eq!(child.len(), parent.len());
335 let mut per_parent: FxHashMap<usize, FxHashMap<usize, usize>> = FxHashMap::default();
336 let mut offsets = vec![0usize; child.len()];
337 for (i, (&c, &p)) in child.iter().zip(parent.iter()).enumerate() {
338 let local = per_parent.entry(p).or_default();
339 let next = local.len();
340 offsets[i] = *local.entry(c).or_insert(next);
341 }
342 offsets
343}
344
345////////////////////////////
346// Tests (BBKNN-specific) //
347////////////////////////////
348
349#[cfg(test)]
350#[path = "refine_multilevel_tests.rs"]
351mod tests;