data_beans/alg/dc_poisson.rs
1//! Degree-corrected Poisson refinement core.
2//!
3//! Shared Poisson-scoring machinery for top-down multi-level refinement.
4//! Given per-entity sparse profiles (gene sums or a dense projection) and
5//! current group labels, refinement reassigns entities between groups by
6//! scoring moves under a Poisson likelihood with size-factor offsets.
7//! Candidate-set construction is delegated to [`CandidateProposer`] so
8//! different front-ends (cross-batch BBKNN, spatial KNN graph, ...) can
9//! plug in their own neighborhood definition.
10//!
11//! The name "DC-Poisson" is deliberate: we are *not* inferring a stochastic
12//! block model — the blocks are given externally (by a prior coarsening
13//! step) and we only score membership moves. Compare to the full DC-SBM
14//! where blocks themselves are latent variables.
15//!
16//! Entry points:
17//! - [`refine_with_candidates`] — lowest-level sweep driver given pre-built candidates.
18//! - [`refine_with_proposer`] — generic driver that first asks a [`CandidateProposer`].
19//!
20//! See the `BbknnProposer` in `refine_multilevel` for the data_beans::alg
21//! front-end, and pinto's `GraphProposer` for the spatial-graph front-end.
22
23use log::info;
24use nalgebra::DMatrix;
25use rand::rngs::SmallRng;
26use rand::seq::SliceRandom;
27use rand::{RngExt, SeedableRng};
28use rayon::prelude::*;
29use rustc_hash::FxHashMap as HashMap;
30
31/// Additive floor to keep `ln(.)` finite when a block or feature has zero
32/// mass.
33pub const LOG_EPS: f64 = 1e-9;
34
35/// `ln(x)` in `f32`: libm's single-precision log, near half the cost of
36/// the `f64` one. Its error is `f32` rounding (about 1e-7 relative), the
37/// precision the cached block logs are stored at anyway. NaN, infinite and
38/// non-positive inputs keep their usual `ln` values.
39#[inline]
40fn fast_ln(x: f64) -> f64 {
41 f64::from((x as f32).ln())
42}
43
44//////////////////////////
45// Public configuration //
46//////////////////////////
47
48/// Feature representation used to score entity → block moves.
49#[derive(Clone, Debug)]
50pub enum ProfileSource {
51 /// Sparse entity × feature counts (the existing `gene_sums` format —
52 /// the field name is historical; the axis is generic).
53 /// [`FeatureWeighting`] is applied per-feature on the sparse profile.
54 Raw,
55 /// Dense projection: `basis * indicator(entity)` per entity. `basis` is
56 /// `proj_dim × num_cells`-shaped; the per-entity profile is the sum over
57 /// its member cells' projection columns. [`FeatureWeighting`] is skipped
58 /// because the feature axis is no longer count-distributed.
59 Projected { basis: DMatrix<f32> },
60}
61
62/// Per-feature weighting applied to the sparse profile before DC-Poisson
63/// scoring. All variants multiply each nonzero entry `y_{e,f}` by a scalar
64/// `w_f` and recompute per-row size factors; only the formula for `w_f`
65/// differs.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FeatureWeighting {
68 /// No reweighting: `w_f = 1`. Recovers proper DC-Poisson with entity-level
69 /// degree correction (Karrer–Newman MAP under `Gamma(α, 0)` on `θ`).
70 None,
71 /// Fisher-information weight under the fitted NB trend (genes-as-features
72 /// case): `w_f = 1 / (1 + π_f · s̄ · φ(μ_f))`. Bounded in `(0, 1]`,
73 /// attenuates high-mean / high-dispersion features, recovers
74 /// `w_f = 1` in the Poisson limit (`φ → 0`).
75 FisherInfoNb,
76}
77
78/// Parameters controlling the refinement pass.
79#[derive(Clone, Debug)]
80pub struct RefineParams {
81 /// Gibbs sweeps per level (0 disables Gibbs; greedy still runs).
82 pub num_gibbs: usize,
83 /// Greedy sweeps per level (early-exits on zero moves).
84 pub num_greedy: usize,
85 /// Per-feature weighting scheme (only meaningful for `Raw` profile source).
86 pub feature_weighting: FeatureWeighting,
87 /// Seed for Gibbs RNG.
88 pub seed: u64,
89 /// Gibbs stagnation threshold (fraction of entities moving per sweep).
90 /// Breaks early when three consecutive sweeps are below this bound.
91 /// `0.0` disables early exit.
92 pub gibbs_stagnation: f64,
93 /// Feature source. Defaults to `Raw`.
94 pub profile_source: ProfileSource,
95 /// Jacobi-style parallel sweeps (default `true`).
96 ///
97 /// When `true`, every entity proposes its move against a frozen pre-sweep
98 /// snapshot of the sufficient stats (rayon `par_iter`); proposals are
99 /// then applied sequentially with the move guard. Per-entity RNG seeds
100 /// are derived from `(sweep_seed, entity_idx)`, so the result is
101 /// deterministic given `seed` and independent of thread count (per
102 /// `rand` release — `SmallRng`'s stream is not guaranteed stable across
103 /// `rand` versions).
104 ///
105 /// When `false`, runs the classic Gauss–Seidel loop: each entity scores
106 /// against the live stats (updated by every accepted move so far in the
107 /// sweep), single-threaded.
108 ///
109 /// Jacobi is biased — within a sweep, entities can't see each other's
110 /// moves — but converges fine for the multilevel DC-Poisson use case
111 /// and is roughly P× faster on large pb-sample counts.
112 pub parallel: bool,
113}
114
115impl Default for RefineParams {
116 fn default() -> Self {
117 Self {
118 num_gibbs: 20,
119 num_greedy: 10,
120 feature_weighting: FeatureWeighting::FisherInfoNb,
121 seed: 42,
122 gibbs_stagnation: 0.005,
123 profile_source: ProfileSource::Raw,
124 parallel: true,
125 }
126 }
127}
128
129////////////////////////////////////
130// Sparse row-per-entity profiles //
131////////////////////////////////////
132
133/// Sparse row-per-entity profile, owned by the refinement pass.
134///
135/// Materialized from either a sparse gene-sum format (`from_gene_sums`) or a
136/// projected centroid matrix (`from_projection`). Values are stored sorted
137/// by feature index within each row so downstream accumulators can rely on
138/// the invariant.
139pub struct Profiles {
140 pub rows: Vec<Vec<(u32, f32)>>,
141 pub size_factor: Vec<f32>,
142 pub num_entities: usize,
143 pub num_features: usize,
144}
145
146impl Profiles {
147 pub fn from_gene_sums(gene_sums: &[Vec<(usize, f32)>], num_features: usize) -> Self {
148 let num_entities = gene_sums.len();
149 let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = gene_sums
150 .par_iter()
151 .map(|row| {
152 let mut out: Vec<(u32, f32)> = row
153 .iter()
154 .filter(|(_, v)| *v > 0.0)
155 .map(|(g, v)| (*g as u32, *v))
156 .collect();
157 out.sort_unstable_by_key(|&(g, _)| g);
158 let sf: f32 = out.iter().map(|(_, v)| *v).sum();
159 (out, sf)
160 })
161 .unzip();
162 Self {
163 rows,
164 size_factor,
165 num_entities,
166 num_features,
167 }
168 }
169
170 /// Build profiles from a dense projection.
171 ///
172 /// `basis` is `proj_dim × num_cells`; each entity profile is the sum of
173 /// basis columns over the cells constituting it. Gene weighting is not
174 /// applied — projection dims aren't gene-aligned.
175 ///
176 /// The Poisson score needs nonnegative profiles, so the accumulated
177 /// per-dimension sums must be `>= 0` — a signed basis (e.g. a raw random
178 /// projection) is rejected loudly rather than silently truncated.
179 pub fn from_projection(basis: &DMatrix<f32>, entity_to_cells: &[Vec<usize>]) -> Self {
180 let num_features = basis.nrows();
181 let num_entities = entity_to_cells.len();
182 let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = entity_to_cells
183 .par_iter()
184 .map(|cells| {
185 let mut acc = vec![0f32; num_features];
186 for &c in cells {
187 for d in 0..num_features {
188 acc[d] += basis[(d, c)];
189 }
190 }
191 let mut out = Vec::with_capacity(num_features);
192 let mut sf = 0f32;
193 for (d, &v) in acc.iter().enumerate() {
194 assert!(
195 v >= 0.0,
196 "projection profile is negative at dim {d} ({v}); \
197 DC-Poisson requires a nonnegative basis"
198 );
199 if v > 0.0 {
200 out.push((d as u32, v));
201 sf += v;
202 }
203 }
204 (out, sf)
205 })
206 .unzip();
207 Self {
208 rows,
209 size_factor,
210 num_entities,
211 num_features,
212 }
213 }
214
215 /// In-place reweighting by a caller-supplied per-feature weight vector.
216 /// Used by [`Profiles::apply_feature_weighting`] for the NB Fisher-info path.
217 pub fn weight_by_vec(&mut self, w: &[f32]) {
218 assert_eq!(
219 w.len(),
220 self.num_features,
221 "weight vector length must match num_features"
222 );
223 self.rows
224 .par_iter_mut()
225 .zip(self.size_factor.par_iter_mut())
226 .for_each(|(row, sf)| {
227 let mut new_sf = 0f32;
228 for (g, v) in row.iter_mut() {
229 *v *= w[*g as usize];
230 new_sf += *v;
231 }
232 *sf = new_sf;
233 });
234 }
235
236 /// Apply the chosen feature weighting in place.
237 ///
238 /// [`FeatureWeighting::None`] is a no-op; [`FeatureWeighting::FisherInfoNb`]
239 /// fits an NB dispersion trend from the current profiles and reweights each
240 /// feature by `1 / (1 + π_f · s̄ · φ(μ_f))`.
241 pub fn apply_feature_weighting(&mut self, method: FeatureWeighting) {
242 match method {
243 FeatureWeighting::None => {}
244 FeatureWeighting::FisherInfoNb => {
245 let w = self.nb_fisher_weights();
246 self.weight_by_vec(&w);
247 }
248 }
249 }
250
251 /// Compute per-feature Fisher-info weights under an NB trend fit from
252 /// the current profile contents. Returned vector has length `num_features`
253 /// and is suitable for [`Profiles::weight_by_vec`].
254 pub fn nb_fisher_weights(&self) -> Vec<f32> {
255 use crate::alg::nb_dispersion::DispersionTrend;
256 use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
257 use legume_numeric::matrix::traits::RunningStatOps;
258
259 // Rows are folded in parallel and merged in a fixed order, so the
260 // (non-integer) sums come out the same on any number of threads.
261 // Avoids the single-threaded `for row in &self.rows` over ~10⁴–10⁵
262 // pb-samples.
263 const ROWS_PER_LEAF: usize = 256;
264 let num_features = self.num_features;
265 let (stats, ..) = crate::qc::fold_in_fixed_order(
266 &self.rows,
267 ROWS_PER_LEAF,
268 &|| {
269 (
270 SparseRunningStatistics::<f32>::new(num_features),
271 Vec::<usize>::new(),
272 Vec::<f32>::new(),
273 )
274 },
275 &|(acc, col_rows, col_vals), row| {
276 col_rows.clear();
277 col_vals.clear();
278 for &(g, v) in row {
279 col_rows.push(g as usize);
280 col_vals.push(v);
281 }
282 acc.add_sparse_column(col_rows, col_vals);
283 Ok(())
284 },
285 &|(a, ..), (b, ..)| a.merge(&b),
286 )
287 .expect("the row fold cannot fail");
288
289 // `π_g = sum[g] / Σ sum` is derived directly from the stats without
290 // a second sparse traversal. `mean_g` uses the entity-count denominator
291 // that `SparseRunningStatistics` already provides.
292 let trend = DispersionTrend::from_sparse_stats(&stats);
293 let means = stats.mean();
294 let sums = stats.sum();
295 let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
296 let avg_s = if self.num_entities > 0 {
297 (total_mass / self.num_entities as f64) as f32
298 } else {
299 1.0
300 };
301 let inv_total = if total_mass > 0.0 {
302 1.0 / total_mass as f32
303 } else {
304 0.0
305 };
306 (0..self.num_features)
307 .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
308 .collect()
309 }
310}
311
312///////////////////////////////////
313// Poisson sufficient statistics //
314///////////////////////////////////
315
316/// Sufficient statistics with cached log quantities for O(K · nnz(row)) scoring.
317///
318/// `gene_sum[g·K + k]` = Σ_{e : z_e = k} y_eg; `size_sum[k]` = Σ_{e : z_e = k} s_e.
319/// The per-feature tables are laid out **feature-major** (`g·K + k`): scoring
320/// one entity walks its nonzero features once and, for each, reads the `K`
321/// group values side by side, instead of striding across a `K × M` table
322/// once per candidate group. Use [`DcPoissonStats::idx`] to address them.
323/// Log caches score every non-current block with no log; the current block's
324/// leave-one-out score takes one `fast_ln` per nonzero feature, and
325/// `delta_move` refreshes the logs of the columns a move touches.
326///
327/// Log caches are `f32` — values live in roughly `[-20, +25]` (dominated by
328/// `-ln(LOG_EPS)` at the floor), and scoring is noise-dominated so f32's
329/// ~7-digit precision is ample. Halves the largest allocation versus f64.
330#[derive(Clone)]
331pub struct DcPoissonStats {
332 pub k: usize,
333 pub num_features: usize,
334 pub membership: Vec<usize>,
335 /// Feature-major `M × K`: entry `g·K + k`.
336 pub gene_sum: Vec<f64>,
337 pub size_sum: Vec<f64>,
338 /// Feature-major `M × K`: entry `g·K + k`.
339 pub log_gene: Vec<f32>,
340 pub log_size_offset: Vec<f32>,
341}
342
343impl DcPoissonStats {
344 /// Index of (group `k`, feature `g`) in `gene_sum` / `log_gene`.
345 #[inline(always)]
346 pub fn idx(&self, k: usize, g: usize) -> usize {
347 g * self.k + k
348 }
349
350 pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
351 let m = profiles.num_features;
352 let mut gene_sum = vec![0f64; k * m];
353 let mut size_sum = vec![0f64; k];
354 for (e, row) in profiles.rows.iter().enumerate() {
355 let z = membership[e];
356 assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
357 for &(g, v) in row {
358 gene_sum[g as usize * k + z] += v as f64;
359 }
360 size_sum[z] += profiles.size_factor[e] as f64;
361 }
362 let log_gene: Vec<f32> = gene_sum
363 .iter()
364 .map(|&s| fast_ln(s + LOG_EPS) as f32)
365 .collect();
366 let m_eps = m as f64 * LOG_EPS;
367 let log_size_offset: Vec<f32> = size_sum
368 .iter()
369 .map(|&s| -fast_ln(s + m_eps) as f32)
370 .collect();
371 Self {
372 k,
373 num_features: m,
374 membership: membership.to_vec(),
375 gene_sum,
376 size_sum,
377 log_gene,
378 log_size_offset,
379 }
380 }
381
382 /// Apply an entity's reassignment and refresh only the affected log entries.
383 pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
384 if k_from == k_to {
385 return;
386 }
387 let kk = self.k;
388 let m = self.num_features;
389 let m_eps = m as f64 * LOG_EPS;
390
391 // Subtractions clamp at zero: the running sums accumulate rounding
392 // residues, and a block drained back to (true) zero can otherwise be
393 // left with a negative residue larger than LOG_EPS — feeding
394 // `ln(negative) = NaN` into the log caches.
395 for &(g, v) in &profiles.rows[e] {
396 let base = g as usize * kk;
397 let (i_from, i_to) = (base + k_from, base + k_to);
398 self.gene_sum[i_from] = (self.gene_sum[i_from] - v as f64).max(0.0);
399 self.gene_sum[i_to] += v as f64;
400 self.log_gene[i_from] = fast_ln(self.gene_sum[i_from] + LOG_EPS) as f32;
401 self.log_gene[i_to] = fast_ln(self.gene_sum[i_to] + LOG_EPS) as f32;
402 }
403 let sf = profiles.size_factor[e] as f64;
404 self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
405 self.size_sum[k_to] += sf;
406 self.log_size_offset[k_from] = -fast_ln(self.size_sum[k_from] + m_eps) as f32;
407 self.log_size_offset[k_to] = -fast_ln(self.size_sum[k_to] + m_eps) as f32;
408
409 self.membership[e] = k_to;
410 }
411
412 /// Full recompute from current `membership` (slow path, used for tests).
413 #[cfg(test)]
414 fn recompute(&mut self, profiles: &Profiles) {
415 let k = self.k;
416 let m = self.num_features;
417 self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
418 self.size_sum.iter_mut().for_each(|x| *x = 0.0);
419 for (e, row) in profiles.rows.iter().enumerate() {
420 let z = self.membership[e];
421 for &(g, v) in row {
422 self.gene_sum[g as usize * k + z] += v as f64;
423 }
424 self.size_sum[z] += profiles.size_factor[e] as f64;
425 }
426 for i in 0..k * m {
427 self.log_gene[i] = fast_ln(self.gene_sum[i] + LOG_EPS) as f32;
428 }
429 let m_eps = m as f64 * LOG_EPS;
430 for i in 0..k {
431 self.log_size_offset[i] = -fast_ln(self.size_sum[i] + m_eps) as f32;
432 }
433 }
434}
435
436/////////////////////
437// Scoring kernels //
438/////////////////////
439
440/// Score entity `e` for one destination `k` (Poisson plug-in MAP, up to a
441/// common constant):
442/// ` s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
443///
444/// where `⁻ᵉ` means entity `e`'s own contribution is excluded. Candidate
445/// blocks never contain `e`, so they read straight from the log caches; the
446/// entity's *current* block subtracts `e`'s row before taking logs
447/// (leave-one-out). Scoring the current block with `e` included would give
448/// "stay put" a self-inclusion bonus that grows with the entity's size
449/// factor and shrinks with block mass — anchoring large entities in place.
450///
451/// Reference for [`compute_log_probs_restricted`], which scores all allowed
452/// blocks in one pass with the same per-block summation order (so the two
453/// agree bit for bit).
454#[cfg(test)]
455fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
456 let m = stats.num_features;
457 let sf = profiles.size_factor[e] as f64;
458 let row = &profiles.rows[e];
459 if stats.membership[e] == k {
460 let m_eps = m as f64 * LOG_EPS;
461 let loo_size = (stats.size_sum[k] - sf).max(0.0);
462 let mut acc = -sf * fast_ln(loo_size + m_eps);
463 for &(g, v) in row {
464 let vg = v as f64;
465 let loo = (stats.gene_sum[stats.idx(k, g as usize)] - vg).max(0.0);
466 acc += vg * fast_ln(loo + LOG_EPS);
467 }
468 acc
469 } else {
470 let mut acc = sf * stats.log_size_offset[k] as f64;
471 for &(g, v) in row {
472 acc += v as f64 * stats.log_gene[stats.idx(k, g as usize)] as f64;
473 }
474 acc
475 }
476}
477
478/// Log-probability of placing entity `e` into each of the `allowed` blocks.
479///
480/// ` s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
481///
482/// `⁻ᵉ` means entity `e`'s own contribution is excluded: candidate blocks
483/// never contain `e` and read the log caches, while `e`'s *current* block
484/// subtracts `e`'s row before taking logs (leave-one-out) — otherwise
485/// "stay put" earns a self-inclusion bonus that anchors large entities.
486///
487/// All blocks are scored in one pass over `e`'s nonzero features: per
488/// feature, the `K` cached values sit side by side (feature-major layout),
489/// so the pass touches one short contiguous run per feature. Each block's
490/// sum is still accumulated in feature order, the same order as scoring the
491/// blocks one at a time, so the scores match that bit for bit. Partial
492/// candidate lists accumulate by *position* in `allowed`, so a block listed
493/// twice gets the same, correct score in both places.
494///
495/// Only the `allowed` slots of `log_probs` are written, unless `allowed`
496/// is as long as `K`, in which case the first `K` slots are written.
497/// The rest keep whatever stale values a previous call left (the caller
498/// reuses one buffer across sweeps). Downstream consumers
499/// ([`sample_categorical_log_restricted`], [`argmax_log_restricted`]) read
500/// the `allowed` slots exclusively.
501pub fn compute_log_probs_restricted(
502 e: usize,
503 stats: &DcPoissonStats,
504 profiles: &Profiles,
505 allowed: &[usize],
506 log_probs: &mut [f64],
507) {
508 let kk = stats.k;
509 let m = stats.num_features;
510 let sf = profiles.size_factor[e] as f64;
511 let row = &profiles.rows[e];
512 let cur = stats.membership[e];
513 // Every block 0..K is scored straight into `log_probs`, so repeats in
514 // `allowed` (which then misses some block) are harmless.
515 let dense = allowed.len() == kk;
516 let score_cur = dense || allowed.contains(&cur);
517
518 // Leave-one-out score of the current block.
519 let mut cur_acc = if score_cur {
520 let loo_size = (stats.size_sum[cur] - sf).max(0.0);
521 -sf * fast_ln(loo_size + m as f64 * LOG_EPS)
522 } else {
523 0.0
524 };
525 let mut add_cur = |base: usize, vg: f64| {
526 if score_cur {
527 let loo = (stats.gene_sum[base + cur] - vg).max(0.0);
528 cur_acc += vg * fast_ln(loo + LOG_EPS);
529 }
530 };
531
532 // Cached (non-leave-one-out) scores for every allowed block, including
533 // the current one, which is overwritten at the end.
534 if dense {
535 for (slot, &o) in log_probs[..kk].iter_mut().zip(&stats.log_size_offset) {
536 *slot = sf * o as f64;
537 }
538 for &(g, v) in row {
539 let vg = v as f64;
540 let base = g as usize * kk;
541 let logs = &stats.log_gene[base..base + kk];
542 for (slot, &l) in log_probs[..kk].iter_mut().zip(logs) {
543 *slot += vg * l as f64;
544 }
545 add_cur(base, vg);
546 }
547 } else {
548 thread_local! {
549 static SCRATCH: std::cell::RefCell<Vec<f64>> = const { std::cell::RefCell::new(Vec::new()) };
550 }
551 SCRATCH.with(|scratch| {
552 let mut scratch = scratch.borrow_mut();
553 scratch.clear();
554 scratch.extend(
555 allowed
556 .iter()
557 .map(|&k| sf * stats.log_size_offset[k] as f64),
558 );
559 for &(g, v) in row {
560 let vg = v as f64;
561 let base = g as usize * kk;
562 let logs = &stats.log_gene[base..base + kk];
563 for (acc, &k) in scratch.iter_mut().zip(allowed) {
564 *acc += vg * logs[k] as f64;
565 }
566 add_cur(base, vg);
567 }
568 for (&acc, &k) in scratch.iter().zip(allowed) {
569 log_probs[k] = acc;
570 }
571 });
572 }
573 if score_cur {
574 log_probs[cur] = cur_acc;
575 }
576}
577
578/// Unrestricted variant used only from tests.
579#[cfg(test)]
580fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
581 for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
582 *slot = score_move(e, k, stats, profiles);
583 }
584}
585
586/// Gumbel-max categorical sampling over the `allowed` slots of `log_probs`,
587/// skipping non-finite entries. Falls back to `current` (the entity's
588/// present label — always a legal "move") when no allowed slot is finite,
589/// so a degenerate score vector can never produce an out-of-range label.
590pub fn sample_categorical_log_restricted(
591 log_probs: &[f64],
592 allowed: &[usize],
593 current: usize,
594 rng: &mut SmallRng,
595) -> usize {
596 let mut best_key = f64::NEG_INFINITY;
597 let mut best_idx = current;
598 for &k in allowed {
599 let lp = log_probs[k];
600 if lp.is_finite() {
601 let u: f64 = rng.random_range(1e-12..1.0_f64);
602 let g = -(-u.ln()).ln();
603 let key = lp + g;
604 if key > best_key {
605 best_key = key;
606 best_idx = k;
607 }
608 }
609 }
610 best_idx
611}
612
613/// Argmax over `allowed` indices.
614pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
615 let mut best = allowed[0];
616 let mut best_val = log_probs[best];
617 for &k in &allowed[1..] {
618 if log_probs[k] > best_val {
619 best = k;
620 best_val = log_probs[k];
621 }
622 }
623 best
624}
625
626/// Argmax over `allowed` indices that leaves `current` only for a strictly
627/// better block: a tie with the current label (an entity with no evidence
628/// scores every block alike) is not a move. With `current` outside
629/// `allowed`, ties go to the first allowed index.
630pub fn argmax_log_restricted_from(log_probs: &[f64], allowed: &[usize], current: usize) -> usize {
631 let mut best = if allowed.contains(¤t) {
632 current
633 } else {
634 allowed[0]
635 };
636 let mut best_val = log_probs[best];
637 for &k in allowed {
638 if log_probs[k] > best_val {
639 best = k;
640 best_val = log_probs[k];
641 }
642 }
643 best
644}
645
646////////////////////////////////////
647// Hierarchy / relabeling helpers //
648////////////////////////////////////
649
650/// Compact a label vector to dense `0..K` ids, preserving relative order of
651/// first appearance. Returns the new labels and the new `K`. Generic over
652/// the key type so it serves both plain group ids (`usize`) and composite
653/// keys (e.g. `(child, parent)` pairs in `project_to_refinement`).
654pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
655where
656 K: Copy + Eq + std::hash::Hash,
657{
658 let mut map: HashMap<K, usize> = HashMap::default();
659 let mut next = 0usize;
660 let mut out = Vec::with_capacity(labels.len());
661 for &g in labels {
662 let new = *map.entry(g).or_insert_with(|| {
663 let id = next;
664 next += 1;
665 id
666 });
667 out.push(new);
668 }
669 (out, next)
670}
671
672/// For each entity, siblings at `level` = groups at `level` sharing the
673/// same parent group at `level + 1`.
674///
675/// `refined` follows the finest → coarsest convention: `refined[0]` is the
676/// finest level, `refined[refined.len() - 1]` is the coarsest. At the
677/// coarsest level (index `num_levels - 1`), the virtual root groups every
678/// entity together → siblings = all groups.
679pub fn compute_sibling_sets(
680 refined: &[Vec<usize>],
681 level: usize,
682 num_groups_at_level: usize,
683) -> Vec<Vec<usize>> {
684 let num_levels = refined.len();
685 let num_entities = refined[level].len();
686
687 if level + 1 >= num_levels {
688 let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
689 return vec![all_groups; num_entities];
690 }
691
692 let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
693 for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
694 let entry = parent_to_children.entry(*parent).or_default();
695 if !entry.contains(child) {
696 entry.push(*child);
697 }
698 }
699 for v in parent_to_children.values_mut() {
700 v.sort_unstable();
701 }
702
703 (0..num_entities)
704 .map(|e| {
705 let parent = refined[level + 1][e];
706 parent_to_children.get(&parent).cloned().unwrap_or_default()
707 })
708 .collect()
709}
710
711////////////////////////////////////////////
712// Candidate proposer + move guard traits //
713////////////////////////////////////////////
714
715/// Propose candidate group labels each entity may move into on one sweep.
716///
717/// The returned slice `candidates[e]` MUST include `labels[e]` (staying put
718/// is always legal) and MUST index into the current 0..k label space.
719pub trait CandidateProposer {
720 fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
721}
722
723/// Veto individual moves after they've been picked by the sweep.
724///
725/// Called once per *proposed* accepted move (`from != to`) before the move
726/// is applied to the sufficient statistics. The destination label is
727/// intentionally not passed — guards so far only need the source cluster
728/// (e.g. articulation tests ask "does removing this entity disconnect
729/// `from`?"). Add a destination-aware variant if a concrete use case
730/// surfaces.
731pub trait MoveGuard {
732 fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
733}
734
735/// Trivial guard that accepts every move. Zero-cost default used by the
736/// unguarded [`refine_with_candidates`] and [`refine_with_proposer`].
737pub struct NoGuard;
738
739impl MoveGuard for NoGuard {
740 #[inline(always)]
741 fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
742 true
743 }
744}
745
746/// Intersect a per-entity sibling set with a per-entity neighbor-group set,
747/// with sibling fallback when the intersection is empty and guaranteed
748/// inclusion of the entity's current label so staying put is always a
749/// legal move.
750///
751/// Shared between [`crate::alg::refine_multilevel::BbknnProposer`] and pinto's
752/// `GraphProposer` — both proposers differ only in how they gather
753/// `neighbor_groups`, not in how they combine with siblings.
754///
755/// * `siblings` — sorted, deduped sibling group list.
756/// * `neighbor_groups` — sorted, deduped group list from the proposer's
757/// spatial/structural neighborhood.
758/// * `current` — the entity's current label; appended if the intersection
759/// excluded it.
760pub fn intersect_with_siblings_fallback(
761 siblings: &[usize],
762 neighbor_groups: &[usize],
763 current: usize,
764) -> Vec<usize> {
765 if siblings.is_empty() {
766 return Vec::new();
767 }
768 if siblings.len() == 1 {
769 return siblings.to_vec();
770 }
771 let intersect: Vec<usize> = siblings
772 .iter()
773 .copied()
774 .filter(|g| neighbor_groups.binary_search(g).is_ok())
775 .collect();
776 if intersect.is_empty() {
777 return siblings.to_vec();
778 }
779 if intersect.contains(¤t) {
780 intersect
781 } else {
782 let mut c = intersect;
783 c.push(current);
784 c.sort_unstable();
785 c
786 }
787}
788
789///////////////////
790// Sweep drivers //
791///////////////////
792
793/// Refine `labels` in place for one level by running Gibbs + greedy sweeps
794/// over `profiles` with the given pre-built `candidates` and a custom
795/// [`MoveGuard`]. Returns total accepted moves across all sweeps.
796///
797/// A proposed move `from → to` is applied only when `guard.accept_move(...)`
798/// returns `true`. In Gibbs sweeps, a vetoed move leaves the entity in its
799/// current group for this sweep. Greedy sweeps likewise skip the move.
800///
801/// Shared sweep-driver context bundling the readonly inputs that every
802/// `refine_with_*` variant needs. Kept as a struct so the driver fns don't
803/// exceed clippy's argument-count threshold.
804pub struct RefineContext<'a> {
805 pub profiles: &'a Profiles,
806 pub k: usize,
807 pub params: &'a RefineParams,
808 pub level_label: &'a str,
809}
810
811/// Outcome of one sweep over all entities.
812#[derive(Default, Clone, Copy)]
813struct SweepCounts {
814 moves: usize,
815 vetoed: usize,
816}
817
818/// Apply a precomputed `proposals` vector to `stats` sequentially, consulting
819/// the move guard. Shared by both Jacobi sweeps after the parallel proposal
820/// phase. The guard sees the membership-being-updated, matching the
821/// sequential semantics.
822fn apply_proposals<G: MoveGuard>(
823 proposals: &[usize],
824 stats: &mut DcPoissonStats,
825 profiles: &Profiles,
826 guard: &G,
827) -> SweepCounts {
828 let mut sc = SweepCounts::default();
829 for (e, &new) in proposals.iter().enumerate() {
830 let old = stats.membership[e];
831 if new == old {
832 continue;
833 }
834 if guard.accept_move(e, old, &stats.membership) {
835 stats.delta_move(e, old, new, profiles);
836 sc.moves += 1;
837 } else {
838 sc.vetoed += 1;
839 }
840 }
841 sc
842}
843
844/// Gauss–Seidel sequential sweep. For each entity visited in `order`, scores
845/// the candidate destinations under the live (mutated) stats, picks one via
846/// `pick(log_probs, candidates, current_label)`, and applies the move through
847/// the guard. Gibbs callers shuffle `order` and pass a categorical-sampling
848/// closure; greedy callers pass `0..n` and an argmax closure.
849fn sweep_sequential<G, F, I>(
850 candidates: &[Vec<usize>],
851 stats: &mut DcPoissonStats,
852 profiles: &Profiles,
853 guard: &G,
854 log_probs: &mut [f64],
855 order: I,
856 mut pick: F,
857) -> SweepCounts
858where
859 G: MoveGuard,
860 F: FnMut(&[f64], &[usize], usize) -> usize,
861 I: IntoIterator<Item = usize>,
862{
863 let mut sc = SweepCounts::default();
864 for e in order {
865 let cand = &candidates[e];
866 if cand.len() < 2 {
867 continue;
868 }
869 compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
870 let old = stats.membership[e];
871 let new = pick(log_probs, cand, old);
872 if new == old {
873 continue;
874 }
875 if guard.accept_move(e, old, &stats.membership) {
876 stats.delta_move(e, old, new, profiles);
877 sc.moves += 1;
878 } else {
879 sc.vetoed += 1;
880 }
881 }
882 sc
883}
884
885/// Jacobi sweep: every entity computes its proposal against a frozen pre-sweep
886/// snapshot of `stats` (rayon `par_iter`); proposals are then applied
887/// sequentially with the move guard.
888///
889/// `pick(log_probs, cand, e, current)` chooses the destination given the
890/// restricted log-likelihoods. For Gibbs it derives a per-entity RNG from
891/// `(sweep_seed, e)` and samples categorically; for greedy it ignores `e`
892/// and argmaxes.
893/// `proposals` is a reusable scratch buffer of length `candidates.len()` —
894/// hoisted by the driver across sweeps to avoid re-allocating.
895fn sweep_jacobi<G: MoveGuard, F>(
896 candidates: &[Vec<usize>],
897 stats: &mut DcPoissonStats,
898 profiles: &Profiles,
899 guard: &G,
900 k: usize,
901 proposals: &mut [usize],
902 pick: F,
903) -> SweepCounts
904where
905 F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
906{
907 debug_assert_eq!(proposals.len(), candidates.len());
908 {
909 // Immutable reborrow so the rayon closure can capture `&stats: Sync`
910 // while the outer `&mut` is held across the propose / apply phases.
911 let stats: &DcPoissonStats = stats;
912 proposals
913 .par_iter_mut()
914 .enumerate()
915 .with_min_len(256)
916 .for_each_init(
917 || vec![f64::NEG_INFINITY; k],
918 |log_probs, (e, prop)| {
919 let cand = &candidates[e];
920 let current = stats.membership[e];
921 if cand.len() < 2 {
922 *prop = current;
923 return;
924 }
925 compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
926 *prop = pick(log_probs, cand, e, current);
927 },
928 );
929 }
930 apply_proposals(proposals, stats, profiles, guard)
931}
932
933/// This is the lowest-level driver. Pass [`NoGuard`] to get the unguarded
934/// behavior; most callers should prefer the convenience wrappers
935/// [`refine_with_candidates`] / [`refine_with_proposer`] /
936/// [`refine_with_proposer_guarded`].
937///
938/// Sweep dispatch follows `ctx.params.parallel`: Jacobi (parallel proposal,
939/// sequential apply) when true, Gauss–Seidel (single-threaded) when false.
940/// See [`RefineParams::parallel`] for trade-offs.
941pub fn refine_with_candidates_guarded<G: MoveGuard>(
942 labels: &mut [usize],
943 candidates: &[Vec<usize>],
944 guard: &G,
945 rng: &mut SmallRng,
946 ctx: &RefineContext,
947) -> usize {
948 let RefineContext {
949 profiles,
950 k,
951 params,
952 level_label,
953 } = *ctx;
954 let num_entities = labels.len();
955 let mut stats = DcPoissonStats::from_profiles(profiles, k, labels);
956 let mut log_probs = vec![f64::NEG_INFINITY; k];
957 let mut total_moves = 0usize;
958 let mut total_vetoed = 0usize;
959
960 // Reusable proposal scratch for Jacobi sweeps — hoisted so the
961 // num_sweeps × num_entities allocation isn't repeated each sweep.
962 let mut proposals: Vec<usize> = if params.parallel {
963 vec![0usize; num_entities]
964 } else {
965 Vec::new()
966 };
967
968 // Reusable shuffled-order buffer for sequential Gibbs sweeps. Greedy
969 // sequential iterates `0..num_entities` directly and ignores this.
970 let mut order: Vec<usize> = if !params.parallel {
971 (0..num_entities).collect()
972 } else {
973 Vec::new()
974 };
975
976 let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
977 let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
978 .with_message(format!("{level_label} sweeps"));
979 // Refresh on a timer so the bar visibly animates between sweep ticks
980 // (a single sweep at large D can take several seconds — without this
981 // the bar is silent until the next inc(1)).
982 prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
983
984 // Jacobi per-sweep seed base: draw a single odd u64 from `rng` so the
985 // sweep loop is reproducible given `params.seed` regardless of how many
986 // cells `shuffle` would have consumed in the sequential path.
987 let jacobi_base_seed = rng.random::<u64>() | 1;
988
989 if params.num_gibbs > 0 {
990 let mut low_sweeps = 0usize;
991 for sweep in 0..params.num_gibbs {
992 let sc = if params.parallel {
993 let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
994 sweep_jacobi(
995 candidates,
996 &mut stats,
997 profiles,
998 guard,
999 k,
1000 &mut proposals,
1001 |log_probs, cand, e, current| {
1002 let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
1003 let mut rng = SmallRng::seed_from_u64(vertex_seed);
1004 sample_categorical_log_restricted(log_probs, cand, current, &mut rng)
1005 },
1006 )
1007 } else {
1008 order.shuffle(rng);
1009 sweep_sequential(
1010 candidates,
1011 &mut stats,
1012 profiles,
1013 guard,
1014 &mut log_probs,
1015 order.iter().copied(),
1016 |log_probs, cand, current| {
1017 sample_categorical_log_restricted(log_probs, cand, current, rng)
1018 },
1019 )
1020 };
1021 total_moves += sc.moves;
1022 total_vetoed += sc.vetoed;
1023 prog_bar.inc(1);
1024 if params.gibbs_stagnation > 0.0 {
1025 if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
1026 low_sweeps += 1;
1027 if low_sweeps >= 3 {
1028 break;
1029 }
1030 } else {
1031 low_sweeps = 0;
1032 }
1033 }
1034 }
1035 }
1036
1037 for _sweep in 0..params.num_greedy {
1038 let sc = if params.parallel {
1039 sweep_jacobi(
1040 candidates,
1041 &mut stats,
1042 profiles,
1043 guard,
1044 k,
1045 &mut proposals,
1046 |log_probs, cand, _e, current| argmax_log_restricted_from(log_probs, cand, current),
1047 )
1048 } else {
1049 sweep_sequential(
1050 candidates,
1051 &mut stats,
1052 profiles,
1053 guard,
1054 &mut log_probs,
1055 0..num_entities,
1056 argmax_log_restricted_from,
1057 )
1058 };
1059 total_moves += sc.moves;
1060 total_vetoed += sc.vetoed;
1061 prog_bar.inc(1);
1062 if sc.moves == 0 {
1063 break;
1064 }
1065 }
1066 prog_bar.finish_and_clear();
1067
1068 if total_vetoed > 0 {
1069 log::debug!(
1070 "{}: {} moves, {} vetoed by MoveGuard",
1071 level_label,
1072 total_moves,
1073 total_vetoed
1074 );
1075 }
1076
1077 labels.copy_from_slice(&stats.membership);
1078 total_moves
1079}
1080
1081/// Unguarded variant of [`refine_with_candidates_guarded`].
1082pub fn refine_with_candidates(
1083 labels: &mut [usize],
1084 candidates: &[Vec<usize>],
1085 rng: &mut SmallRng,
1086 ctx: &RefineContext,
1087) -> usize {
1088 refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
1089}
1090
1091/// Generic driver: ask `proposer` for candidates, then run the guarded sweep.
1092pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
1093 labels: &mut [usize],
1094 proposer: &P,
1095 guard: &G,
1096 rng: &mut SmallRng,
1097 ctx: &RefineContext,
1098) -> usize {
1099 let candidates = proposer.propose(labels);
1100 let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
1101 info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
1102 moves
1103}
1104
1105/// Unguarded generic driver: ask `proposer` for candidates, then run the
1106/// sweep with [`NoGuard`].
1107pub fn refine_with_proposer<P: CandidateProposer>(
1108 labels: &mut [usize],
1109 proposer: &P,
1110 rng: &mut SmallRng,
1111 ctx: &RefineContext,
1112) -> usize {
1113 let candidates = proposer.propose(labels);
1114 let moves = refine_with_candidates(labels, &candidates, rng, ctx);
1115 info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
1116 moves
1117}
1118
1119////////////////////////////
1120// Tests (algorithm core) //
1121////////////////////////////
1122
1123#[cfg(test)]
1124#[path = "dc_poisson_tests.rs"]
1125mod tests;