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 // Parallel fold over rows: each worker accumulates into its own
260 // `SparseRunningStatistics`, then we reduce via `merge`. Avoids the
261 // single-threaded `for row in &self.rows` over ~10⁴–10⁵ pb-samples.
262 let num_features = self.num_features;
263 let stats = self
264 .rows
265 .par_iter()
266 .fold(
267 || {
268 (
269 SparseRunningStatistics::<f32>::new(num_features),
270 Vec::<usize>::new(),
271 Vec::<f32>::new(),
272 )
273 },
274 |(mut acc, mut col_rows, mut col_vals), row| {
275 col_rows.clear();
276 col_vals.clear();
277 for &(g, v) in row {
278 col_rows.push(g as usize);
279 col_vals.push(v);
280 }
281 acc.add_sparse_column(&col_rows, &col_vals);
282 (acc, col_rows, col_vals)
283 },
284 )
285 .map(|(acc, _, _)| acc)
286 .reduce(
287 || SparseRunningStatistics::<f32>::new(num_features),
288 |mut a, b| {
289 a.merge(&b);
290 a
291 },
292 );
293
294 // `π_g = sum[g] / Σ sum` is derived directly from the stats without
295 // a second sparse traversal. `mean_g` uses the entity-count denominator
296 // that `SparseRunningStatistics` already provides.
297 let trend = DispersionTrend::from_sparse_stats(&stats);
298 let means = stats.mean();
299 let sums = stats.sum();
300 let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
301 let avg_s = if self.num_entities > 0 {
302 (total_mass / self.num_entities as f64) as f32
303 } else {
304 1.0
305 };
306 let inv_total = if total_mass > 0.0 {
307 1.0 / total_mass as f32
308 } else {
309 0.0
310 };
311 (0..self.num_features)
312 .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
313 .collect()
314 }
315}
316
317///////////////////////////////////
318// Poisson sufficient statistics //
319///////////////////////////////////
320
321/// Sufficient statistics with cached log quantities for O(K · nnz(row)) scoring.
322///
323/// `gene_sum[g·K + k]` = Σ_{e : z_e = k} y_eg; `size_sum[k]` = Σ_{e : z_e = k} s_e.
324/// The per-feature tables are laid out **feature-major** (`g·K + k`): scoring
325/// one entity walks its nonzero features once and, for each, reads the `K`
326/// group values side by side, instead of striding across a `K × M` table
327/// once per candidate group. Use [`DcPoissonStats::idx`] to address them.
328/// Log caches score every non-current block with no log; the current block's
329/// leave-one-out score takes one `fast_ln` per nonzero feature, and
330/// `delta_move` refreshes the logs of the columns a move touches.
331///
332/// Log caches are `f32` — values live in roughly `[-20, +25]` (dominated by
333/// `-ln(LOG_EPS)` at the floor), and scoring is noise-dominated so f32's
334/// ~7-digit precision is ample. Halves the largest allocation versus f64.
335#[derive(Clone)]
336pub struct DcPoissonStats {
337 pub k: usize,
338 pub num_features: usize,
339 pub membership: Vec<usize>,
340 /// Feature-major `M × K`: entry `g·K + k`.
341 pub gene_sum: Vec<f64>,
342 pub size_sum: Vec<f64>,
343 /// Feature-major `M × K`: entry `g·K + k`.
344 pub log_gene: Vec<f32>,
345 pub log_size_offset: Vec<f32>,
346}
347
348impl DcPoissonStats {
349 /// Index of (group `k`, feature `g`) in `gene_sum` / `log_gene`.
350 #[inline(always)]
351 pub fn idx(&self, k: usize, g: usize) -> usize {
352 g * self.k + k
353 }
354
355 pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
356 let m = profiles.num_features;
357 let mut gene_sum = vec![0f64; k * m];
358 let mut size_sum = vec![0f64; k];
359 for (e, row) in profiles.rows.iter().enumerate() {
360 let z = membership[e];
361 assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
362 for &(g, v) in row {
363 gene_sum[g as usize * k + z] += v as f64;
364 }
365 size_sum[z] += profiles.size_factor[e] as f64;
366 }
367 let log_gene: Vec<f32> = gene_sum
368 .iter()
369 .map(|&s| fast_ln(s + LOG_EPS) as f32)
370 .collect();
371 let m_eps = m as f64 * LOG_EPS;
372 let log_size_offset: Vec<f32> = size_sum
373 .iter()
374 .map(|&s| -fast_ln(s + m_eps) as f32)
375 .collect();
376 Self {
377 k,
378 num_features: m,
379 membership: membership.to_vec(),
380 gene_sum,
381 size_sum,
382 log_gene,
383 log_size_offset,
384 }
385 }
386
387 /// Apply an entity's reassignment and refresh only the affected log entries.
388 pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
389 if k_from == k_to {
390 return;
391 }
392 let kk = self.k;
393 let m = self.num_features;
394 let m_eps = m as f64 * LOG_EPS;
395
396 // Subtractions clamp at zero: the running sums accumulate rounding
397 // residues, and a block drained back to (true) zero can otherwise be
398 // left with a negative residue larger than LOG_EPS — feeding
399 // `ln(negative) = NaN` into the log caches.
400 for &(g, v) in &profiles.rows[e] {
401 let base = g as usize * kk;
402 let (i_from, i_to) = (base + k_from, base + k_to);
403 self.gene_sum[i_from] = (self.gene_sum[i_from] - v as f64).max(0.0);
404 self.gene_sum[i_to] += v as f64;
405 self.log_gene[i_from] = fast_ln(self.gene_sum[i_from] + LOG_EPS) as f32;
406 self.log_gene[i_to] = fast_ln(self.gene_sum[i_to] + LOG_EPS) as f32;
407 }
408 let sf = profiles.size_factor[e] as f64;
409 self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
410 self.size_sum[k_to] += sf;
411 self.log_size_offset[k_from] = -fast_ln(self.size_sum[k_from] + m_eps) as f32;
412 self.log_size_offset[k_to] = -fast_ln(self.size_sum[k_to] + m_eps) as f32;
413
414 self.membership[e] = k_to;
415 }
416
417 /// Full recompute from current `membership` (slow path, used for tests).
418 #[cfg(test)]
419 fn recompute(&mut self, profiles: &Profiles) {
420 let k = self.k;
421 let m = self.num_features;
422 self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
423 self.size_sum.iter_mut().for_each(|x| *x = 0.0);
424 for (e, row) in profiles.rows.iter().enumerate() {
425 let z = self.membership[e];
426 for &(g, v) in row {
427 self.gene_sum[g as usize * k + z] += v as f64;
428 }
429 self.size_sum[z] += profiles.size_factor[e] as f64;
430 }
431 for i in 0..k * m {
432 self.log_gene[i] = fast_ln(self.gene_sum[i] + LOG_EPS) as f32;
433 }
434 let m_eps = m as f64 * LOG_EPS;
435 for i in 0..k {
436 self.log_size_offset[i] = -fast_ln(self.size_sum[i] + m_eps) as f32;
437 }
438 }
439}
440
441/////////////////////
442// Scoring kernels //
443/////////////////////
444
445/// Score entity `e` for one destination `k` (Poisson plug-in MAP, up to a
446/// common constant):
447/// ` s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
448///
449/// where `⁻ᵉ` means entity `e`'s own contribution is excluded. Candidate
450/// blocks never contain `e`, so they read straight from the log caches; the
451/// entity's *current* block subtracts `e`'s row before taking logs
452/// (leave-one-out). Scoring the current block with `e` included would give
453/// "stay put" a self-inclusion bonus that grows with the entity's size
454/// factor and shrinks with block mass — anchoring large entities in place.
455///
456/// Reference for [`compute_log_probs_restricted`], which scores all allowed
457/// blocks in one pass with the same per-block summation order (so the two
458/// agree bit for bit).
459#[cfg(test)]
460fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
461 let m = stats.num_features;
462 let sf = profiles.size_factor[e] as f64;
463 let row = &profiles.rows[e];
464 if stats.membership[e] == k {
465 let m_eps = m as f64 * LOG_EPS;
466 let loo_size = (stats.size_sum[k] - sf).max(0.0);
467 let mut acc = -sf * fast_ln(loo_size + m_eps);
468 for &(g, v) in row {
469 let vg = v as f64;
470 let loo = (stats.gene_sum[stats.idx(k, g as usize)] - vg).max(0.0);
471 acc += vg * fast_ln(loo + LOG_EPS);
472 }
473 acc
474 } else {
475 let mut acc = sf * stats.log_size_offset[k] as f64;
476 for &(g, v) in row {
477 acc += v as f64 * stats.log_gene[stats.idx(k, g as usize)] as f64;
478 }
479 acc
480 }
481}
482
483/// Log-probability of placing entity `e` into each of the `allowed` blocks.
484///
485/// ` s(e, k) = Σ_{g : y_eg > 0} y_eg · ln(gene_sum⁻ᵉ[k, g] + ε) − size_factor[e] · ln(size_sum⁻ᵉ[k] + Mε)`
486///
487/// `⁻ᵉ` means entity `e`'s own contribution is excluded: candidate blocks
488/// never contain `e` and read the log caches, while `e`'s *current* block
489/// subtracts `e`'s row before taking logs (leave-one-out) — otherwise
490/// "stay put" earns a self-inclusion bonus that anchors large entities.
491///
492/// All blocks are scored in one pass over `e`'s nonzero features: per
493/// feature, the `K` cached values sit side by side (feature-major layout),
494/// so the pass touches one short contiguous run per feature. Each block's
495/// sum is still accumulated in feature order, the same order as scoring the
496/// blocks one at a time, so the scores match that bit for bit. Partial
497/// candidate lists accumulate by *position* in `allowed`, so a block listed
498/// twice gets the same, correct score in both places.
499///
500/// Only the `allowed` slots of `log_probs` are written, unless `allowed`
501/// is as long as `K`, in which case the first `K` slots are written.
502/// The rest keep whatever stale values a previous call left (the caller
503/// reuses one buffer across sweeps). Downstream consumers
504/// ([`sample_categorical_log_restricted`], [`argmax_log_restricted`]) read
505/// the `allowed` slots exclusively.
506pub fn compute_log_probs_restricted(
507 e: usize,
508 stats: &DcPoissonStats,
509 profiles: &Profiles,
510 allowed: &[usize],
511 log_probs: &mut [f64],
512) {
513 let kk = stats.k;
514 let m = stats.num_features;
515 let sf = profiles.size_factor[e] as f64;
516 let row = &profiles.rows[e];
517 let cur = stats.membership[e];
518 // Every block 0..K is scored straight into `log_probs`, so repeats in
519 // `allowed` (which then misses some block) are harmless.
520 let dense = allowed.len() == kk;
521 let score_cur = dense || allowed.contains(&cur);
522
523 // Leave-one-out score of the current block.
524 let mut cur_acc = if score_cur {
525 let loo_size = (stats.size_sum[cur] - sf).max(0.0);
526 -sf * fast_ln(loo_size + m as f64 * LOG_EPS)
527 } else {
528 0.0
529 };
530 let mut add_cur = |base: usize, vg: f64| {
531 if score_cur {
532 let loo = (stats.gene_sum[base + cur] - vg).max(0.0);
533 cur_acc += vg * fast_ln(loo + LOG_EPS);
534 }
535 };
536
537 // Cached (non-leave-one-out) scores for every allowed block, including
538 // the current one, which is overwritten at the end.
539 if dense {
540 for (slot, &o) in log_probs[..kk].iter_mut().zip(&stats.log_size_offset) {
541 *slot = sf * o as f64;
542 }
543 for &(g, v) in row {
544 let vg = v as f64;
545 let base = g as usize * kk;
546 let logs = &stats.log_gene[base..base + kk];
547 for (slot, &l) in log_probs[..kk].iter_mut().zip(logs) {
548 *slot += vg * l as f64;
549 }
550 add_cur(base, vg);
551 }
552 } else {
553 thread_local! {
554 static SCRATCH: std::cell::RefCell<Vec<f64>> = const { std::cell::RefCell::new(Vec::new()) };
555 }
556 SCRATCH.with(|scratch| {
557 let mut scratch = scratch.borrow_mut();
558 scratch.clear();
559 scratch.extend(
560 allowed
561 .iter()
562 .map(|&k| sf * stats.log_size_offset[k] as f64),
563 );
564 for &(g, v) in row {
565 let vg = v as f64;
566 let base = g as usize * kk;
567 let logs = &stats.log_gene[base..base + kk];
568 for (acc, &k) in scratch.iter_mut().zip(allowed) {
569 *acc += vg * logs[k] as f64;
570 }
571 add_cur(base, vg);
572 }
573 for (&acc, &k) in scratch.iter().zip(allowed) {
574 log_probs[k] = acc;
575 }
576 });
577 }
578 if score_cur {
579 log_probs[cur] = cur_acc;
580 }
581}
582
583/// Unrestricted variant used only from tests.
584#[cfg(test)]
585fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
586 for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
587 *slot = score_move(e, k, stats, profiles);
588 }
589}
590
591/// Gumbel-max categorical sampling over the `allowed` slots of `log_probs`,
592/// skipping non-finite entries. Falls back to `current` (the entity's
593/// present label — always a legal "move") when no allowed slot is finite,
594/// so a degenerate score vector can never produce an out-of-range label.
595pub fn sample_categorical_log_restricted(
596 log_probs: &[f64],
597 allowed: &[usize],
598 current: usize,
599 rng: &mut SmallRng,
600) -> usize {
601 let mut best_key = f64::NEG_INFINITY;
602 let mut best_idx = current;
603 for &k in allowed {
604 let lp = log_probs[k];
605 if lp.is_finite() {
606 let u: f64 = rng.random_range(1e-12..1.0_f64);
607 let g = -(-u.ln()).ln();
608 let key = lp + g;
609 if key > best_key {
610 best_key = key;
611 best_idx = k;
612 }
613 }
614 }
615 best_idx
616}
617
618/// Argmax over `allowed` indices.
619pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
620 let mut best = allowed[0];
621 let mut best_val = log_probs[best];
622 for &k in &allowed[1..] {
623 if log_probs[k] > best_val {
624 best = k;
625 best_val = log_probs[k];
626 }
627 }
628 best
629}
630
631////////////////////////////////////
632// Hierarchy / relabeling helpers //
633////////////////////////////////////
634
635/// Compact a label vector to dense `0..K` ids, preserving relative order of
636/// first appearance. Returns the new labels and the new `K`. Generic over
637/// the key type so it serves both plain group ids (`usize`) and composite
638/// keys (e.g. `(child, parent)` pairs in `project_to_refinement`).
639pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
640where
641 K: Copy + Eq + std::hash::Hash,
642{
643 let mut map: HashMap<K, usize> = HashMap::default();
644 let mut next = 0usize;
645 let mut out = Vec::with_capacity(labels.len());
646 for &g in labels {
647 let new = *map.entry(g).or_insert_with(|| {
648 let id = next;
649 next += 1;
650 id
651 });
652 out.push(new);
653 }
654 (out, next)
655}
656
657/// For each entity, siblings at `level` = groups at `level` sharing the
658/// same parent group at `level + 1`.
659///
660/// `refined` follows the finest → coarsest convention: `refined[0]` is the
661/// finest level, `refined[refined.len() - 1]` is the coarsest. At the
662/// coarsest level (index `num_levels - 1`), the virtual root groups every
663/// entity together → siblings = all groups.
664pub fn compute_sibling_sets(
665 refined: &[Vec<usize>],
666 level: usize,
667 num_groups_at_level: usize,
668) -> Vec<Vec<usize>> {
669 let num_levels = refined.len();
670 let num_entities = refined[level].len();
671
672 if level + 1 >= num_levels {
673 let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
674 return vec![all_groups; num_entities];
675 }
676
677 let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
678 for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
679 let entry = parent_to_children.entry(*parent).or_default();
680 if !entry.contains(child) {
681 entry.push(*child);
682 }
683 }
684 for v in parent_to_children.values_mut() {
685 v.sort_unstable();
686 }
687
688 (0..num_entities)
689 .map(|e| {
690 let parent = refined[level + 1][e];
691 parent_to_children.get(&parent).cloned().unwrap_or_default()
692 })
693 .collect()
694}
695
696////////////////////////////////////////////
697// Candidate proposer + move guard traits //
698////////////////////////////////////////////
699
700/// Propose candidate group labels each entity may move into on one sweep.
701///
702/// The returned slice `candidates[e]` MUST include `labels[e]` (staying put
703/// is always legal) and MUST index into the current 0..k label space.
704pub trait CandidateProposer {
705 fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
706}
707
708/// Veto individual moves after they've been picked by the sweep.
709///
710/// Called once per *proposed* accepted move (`from != to`) before the move
711/// is applied to the sufficient statistics. The destination label is
712/// intentionally not passed — guards so far only need the source cluster
713/// (e.g. articulation tests ask "does removing this entity disconnect
714/// `from`?"). Add a destination-aware variant if a concrete use case
715/// surfaces.
716pub trait MoveGuard {
717 fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
718}
719
720/// Trivial guard that accepts every move. Zero-cost default used by the
721/// unguarded [`refine_with_candidates`] and [`refine_with_proposer`].
722pub struct NoGuard;
723
724impl MoveGuard for NoGuard {
725 #[inline(always)]
726 fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
727 true
728 }
729}
730
731/// Intersect a per-entity sibling set with a per-entity neighbor-group set,
732/// with sibling fallback when the intersection is empty and guaranteed
733/// inclusion of the entity's current label so staying put is always a
734/// legal move.
735///
736/// Shared between [`crate::alg::refine_multilevel::BbknnProposer`] and pinto's
737/// `GraphProposer` — both proposers differ only in how they gather
738/// `neighbor_groups`, not in how they combine with siblings.
739///
740/// * `siblings` — sorted, deduped sibling group list.
741/// * `neighbor_groups` — sorted, deduped group list from the proposer's
742/// spatial/structural neighborhood.
743/// * `current` — the entity's current label; appended if the intersection
744/// excluded it.
745pub fn intersect_with_siblings_fallback(
746 siblings: &[usize],
747 neighbor_groups: &[usize],
748 current: usize,
749) -> Vec<usize> {
750 if siblings.is_empty() {
751 return Vec::new();
752 }
753 if siblings.len() == 1 {
754 return siblings.to_vec();
755 }
756 let intersect: Vec<usize> = siblings
757 .iter()
758 .copied()
759 .filter(|g| neighbor_groups.binary_search(g).is_ok())
760 .collect();
761 if intersect.is_empty() {
762 return siblings.to_vec();
763 }
764 if intersect.contains(¤t) {
765 intersect
766 } else {
767 let mut c = intersect;
768 c.push(current);
769 c.sort_unstable();
770 c
771 }
772}
773
774///////////////////
775// Sweep drivers //
776///////////////////
777
778/// Refine `labels` in place for one level by running Gibbs + greedy sweeps
779/// over `profiles` with the given pre-built `candidates` and a custom
780/// [`MoveGuard`]. Returns total accepted moves across all sweeps.
781///
782/// A proposed move `from → to` is applied only when `guard.accept_move(...)`
783/// returns `true`. In Gibbs sweeps, a vetoed move leaves the entity in its
784/// current group for this sweep. Greedy sweeps likewise skip the move.
785///
786/// Shared sweep-driver context bundling the readonly inputs that every
787/// `refine_with_*` variant needs. Kept as a struct so the driver fns don't
788/// exceed clippy's argument-count threshold.
789pub struct RefineContext<'a> {
790 pub profiles: &'a Profiles,
791 pub k: usize,
792 pub params: &'a RefineParams,
793 pub level_label: &'a str,
794}
795
796/// Outcome of one sweep over all entities.
797#[derive(Default, Clone, Copy)]
798struct SweepCounts {
799 moves: usize,
800 vetoed: usize,
801}
802
803/// Apply a precomputed `proposals` vector to `stats` sequentially, consulting
804/// the move guard. Shared by both Jacobi sweeps after the parallel proposal
805/// phase. The guard sees the membership-being-updated, matching the
806/// sequential semantics.
807fn apply_proposals<G: MoveGuard>(
808 proposals: &[usize],
809 stats: &mut DcPoissonStats,
810 profiles: &Profiles,
811 guard: &G,
812) -> SweepCounts {
813 let mut sc = SweepCounts::default();
814 for (e, &new) in proposals.iter().enumerate() {
815 let old = stats.membership[e];
816 if new == old {
817 continue;
818 }
819 if guard.accept_move(e, old, &stats.membership) {
820 stats.delta_move(e, old, new, profiles);
821 sc.moves += 1;
822 } else {
823 sc.vetoed += 1;
824 }
825 }
826 sc
827}
828
829/// Gauss–Seidel sequential sweep. For each entity visited in `order`, scores
830/// the candidate destinations under the live (mutated) stats, picks one via
831/// `pick(log_probs, candidates, current_label)`, and applies the move through
832/// the guard. Gibbs callers shuffle `order` and pass a categorical-sampling
833/// closure; greedy callers pass `0..n` and an argmax closure.
834fn sweep_sequential<G, F, I>(
835 candidates: &[Vec<usize>],
836 stats: &mut DcPoissonStats,
837 profiles: &Profiles,
838 guard: &G,
839 log_probs: &mut [f64],
840 order: I,
841 mut pick: F,
842) -> SweepCounts
843where
844 G: MoveGuard,
845 F: FnMut(&[f64], &[usize], usize) -> usize,
846 I: IntoIterator<Item = usize>,
847{
848 let mut sc = SweepCounts::default();
849 for e in order {
850 let cand = &candidates[e];
851 if cand.len() < 2 {
852 continue;
853 }
854 compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
855 let old = stats.membership[e];
856 let new = pick(log_probs, cand, old);
857 if new == old {
858 continue;
859 }
860 if guard.accept_move(e, old, &stats.membership) {
861 stats.delta_move(e, old, new, profiles);
862 sc.moves += 1;
863 } else {
864 sc.vetoed += 1;
865 }
866 }
867 sc
868}
869
870/// Jacobi sweep: every entity computes its proposal against a frozen pre-sweep
871/// snapshot of `stats` (rayon `par_iter`); proposals are then applied
872/// sequentially with the move guard.
873///
874/// `pick(log_probs, cand, e, current)` chooses the destination given the
875/// restricted log-likelihoods. For Gibbs it derives a per-entity RNG from
876/// `(sweep_seed, e)` and samples categorically; for greedy it ignores `e`
877/// and argmaxes.
878/// `proposals` is a reusable scratch buffer of length `candidates.len()` —
879/// hoisted by the driver across sweeps to avoid re-allocating.
880fn sweep_jacobi<G: MoveGuard, F>(
881 candidates: &[Vec<usize>],
882 stats: &mut DcPoissonStats,
883 profiles: &Profiles,
884 guard: &G,
885 k: usize,
886 proposals: &mut [usize],
887 pick: F,
888) -> SweepCounts
889where
890 F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
891{
892 debug_assert_eq!(proposals.len(), candidates.len());
893 {
894 // Immutable reborrow so the rayon closure can capture `&stats: Sync`
895 // while the outer `&mut` is held across the propose / apply phases.
896 let stats: &DcPoissonStats = stats;
897 proposals
898 .par_iter_mut()
899 .enumerate()
900 .with_min_len(256)
901 .for_each_init(
902 || vec![f64::NEG_INFINITY; k],
903 |log_probs, (e, prop)| {
904 let cand = &candidates[e];
905 let current = stats.membership[e];
906 if cand.len() < 2 {
907 *prop = current;
908 return;
909 }
910 compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
911 *prop = pick(log_probs, cand, e, current);
912 },
913 );
914 }
915 apply_proposals(proposals, stats, profiles, guard)
916}
917
918/// This is the lowest-level driver. Pass [`NoGuard`] to get the unguarded
919/// behavior; most callers should prefer the convenience wrappers
920/// [`refine_with_candidates`] / [`refine_with_proposer`] /
921/// [`refine_with_proposer_guarded`].
922///
923/// Sweep dispatch follows `ctx.params.parallel`: Jacobi (parallel proposal,
924/// sequential apply) when true, Gauss–Seidel (single-threaded) when false.
925/// See [`RefineParams::parallel`] for trade-offs.
926pub fn refine_with_candidates_guarded<G: MoveGuard>(
927 labels: &mut [usize],
928 candidates: &[Vec<usize>],
929 guard: &G,
930 rng: &mut SmallRng,
931 ctx: &RefineContext,
932) -> usize {
933 let RefineContext {
934 profiles,
935 k,
936 params,
937 level_label,
938 } = *ctx;
939 let num_entities = labels.len();
940 let mut stats = DcPoissonStats::from_profiles(profiles, k, labels);
941 let mut log_probs = vec![f64::NEG_INFINITY; k];
942 let mut total_moves = 0usize;
943 let mut total_vetoed = 0usize;
944
945 // Reusable proposal scratch for Jacobi sweeps — hoisted so the
946 // num_sweeps × num_entities allocation isn't repeated each sweep.
947 let mut proposals: Vec<usize> = if params.parallel {
948 vec![0usize; num_entities]
949 } else {
950 Vec::new()
951 };
952
953 // Reusable shuffled-order buffer for sequential Gibbs sweeps. Greedy
954 // sequential iterates `0..num_entities` directly and ignores this.
955 let mut order: Vec<usize> = if !params.parallel {
956 (0..num_entities).collect()
957 } else {
958 Vec::new()
959 };
960
961 let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
962 let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
963 .with_message(format!("{level_label} sweeps"));
964 // Refresh on a timer so the bar visibly animates between sweep ticks
965 // (a single sweep at large D can take several seconds — without this
966 // the bar is silent until the next inc(1)).
967 prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
968
969 // Jacobi per-sweep seed base: draw a single odd u64 from `rng` so the
970 // sweep loop is reproducible given `params.seed` regardless of how many
971 // cells `shuffle` would have consumed in the sequential path.
972 let jacobi_base_seed = rng.random::<u64>() | 1;
973
974 if params.num_gibbs > 0 {
975 let mut low_sweeps = 0usize;
976 for sweep in 0..params.num_gibbs {
977 let sc = if params.parallel {
978 let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
979 sweep_jacobi(
980 candidates,
981 &mut stats,
982 profiles,
983 guard,
984 k,
985 &mut proposals,
986 |log_probs, cand, e, current| {
987 let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
988 let mut rng = SmallRng::seed_from_u64(vertex_seed);
989 sample_categorical_log_restricted(log_probs, cand, current, &mut rng)
990 },
991 )
992 } else {
993 order.shuffle(rng);
994 sweep_sequential(
995 candidates,
996 &mut stats,
997 profiles,
998 guard,
999 &mut log_probs,
1000 order.iter().copied(),
1001 |log_probs, cand, current| {
1002 sample_categorical_log_restricted(log_probs, cand, current, rng)
1003 },
1004 )
1005 };
1006 total_moves += sc.moves;
1007 total_vetoed += sc.vetoed;
1008 prog_bar.inc(1);
1009 if params.gibbs_stagnation > 0.0 {
1010 if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
1011 low_sweeps += 1;
1012 if low_sweeps >= 3 {
1013 break;
1014 }
1015 } else {
1016 low_sweeps = 0;
1017 }
1018 }
1019 }
1020 }
1021
1022 for _sweep in 0..params.num_greedy {
1023 let sc = if params.parallel {
1024 sweep_jacobi(
1025 candidates,
1026 &mut stats,
1027 profiles,
1028 guard,
1029 k,
1030 &mut proposals,
1031 |log_probs, cand, _e, _current| argmax_log_restricted(log_probs, cand),
1032 )
1033 } else {
1034 sweep_sequential(
1035 candidates,
1036 &mut stats,
1037 profiles,
1038 guard,
1039 &mut log_probs,
1040 0..num_entities,
1041 |log_probs, cand, _current| argmax_log_restricted(log_probs, cand),
1042 )
1043 };
1044 total_moves += sc.moves;
1045 total_vetoed += sc.vetoed;
1046 prog_bar.inc(1);
1047 if sc.moves == 0 {
1048 break;
1049 }
1050 }
1051 prog_bar.finish_and_clear();
1052
1053 if total_vetoed > 0 {
1054 log::debug!(
1055 "{}: {} moves, {} vetoed by MoveGuard",
1056 level_label,
1057 total_moves,
1058 total_vetoed
1059 );
1060 }
1061
1062 labels.copy_from_slice(&stats.membership);
1063 total_moves
1064}
1065
1066/// Unguarded variant of [`refine_with_candidates_guarded`].
1067pub fn refine_with_candidates(
1068 labels: &mut [usize],
1069 candidates: &[Vec<usize>],
1070 rng: &mut SmallRng,
1071 ctx: &RefineContext,
1072) -> usize {
1073 refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
1074}
1075
1076/// Generic driver: ask `proposer` for candidates, then run the guarded sweep.
1077pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
1078 labels: &mut [usize],
1079 proposer: &P,
1080 guard: &G,
1081 rng: &mut SmallRng,
1082 ctx: &RefineContext,
1083) -> usize {
1084 let candidates = proposer.propose(labels);
1085 let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
1086 info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
1087 moves
1088}
1089
1090/// Unguarded generic driver: ask `proposer` for candidates, then run the
1091/// sweep with [`NoGuard`].
1092pub fn refine_with_proposer<P: CandidateProposer>(
1093 labels: &mut [usize],
1094 proposer: &P,
1095 rng: &mut SmallRng,
1096 ctx: &RefineContext,
1097) -> usize {
1098 let candidates = proposer.propose(labels);
1099 let moves = refine_with_candidates(labels, &candidates, rng, ctx);
1100 info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
1101 moves
1102}
1103
1104////////////////////////////
1105// Tests (algorithm core) //
1106////////////////////////////
1107
1108#[cfg(test)]
1109#[path = "dc_poisson_tests.rs"]
1110mod tests;