use log::info;
use nalgebra::DMatrix;
use rand::rngs::SmallRng;
use rand::seq::SliceRandom;
use rand::{RngExt, SeedableRng};
use rayon::prelude::*;
use rustc_hash::FxHashMap as HashMap;
pub const LOG_EPS: f64 = 1e-9;
#[derive(Clone, Debug)]
pub enum ProfileSource {
Raw,
Projected { basis: DMatrix<f32> },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeatureWeighting {
None,
FisherInfoNb,
}
#[derive(Clone, Debug)]
pub struct RefineParams {
pub num_gibbs: usize,
pub num_greedy: usize,
pub feature_weighting: FeatureWeighting,
pub seed: u64,
pub gibbs_stagnation: f64,
pub profile_source: ProfileSource,
pub parallel: bool,
}
impl Default for RefineParams {
fn default() -> Self {
Self {
num_gibbs: 20,
num_greedy: 10,
feature_weighting: FeatureWeighting::FisherInfoNb,
seed: 42,
gibbs_stagnation: 0.005,
profile_source: ProfileSource::Raw,
parallel: true,
}
}
}
pub struct Profiles {
pub rows: Vec<Vec<(u32, f32)>>,
pub size_factor: Vec<f32>,
pub num_entities: usize,
pub num_features: usize,
}
impl Profiles {
pub fn from_gene_sums(gene_sums: &[Vec<(usize, f32)>], num_features: usize) -> Self {
let num_entities = gene_sums.len();
let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = gene_sums
.par_iter()
.map(|row| {
let mut out: Vec<(u32, f32)> = row
.iter()
.filter(|(_, v)| *v > 0.0)
.map(|(g, v)| (*g as u32, *v))
.collect();
out.sort_unstable_by_key(|&(g, _)| g);
let sf: f32 = out.iter().map(|(_, v)| *v).sum();
(out, sf)
})
.unzip();
Self {
rows,
size_factor,
num_entities,
num_features,
}
}
pub fn from_projection(basis: &DMatrix<f32>, entity_to_cells: &[Vec<usize>]) -> Self {
let num_features = basis.nrows();
let num_entities = entity_to_cells.len();
let (rows, size_factor): (Vec<Vec<(u32, f32)>>, Vec<f32>) = entity_to_cells
.par_iter()
.map(|cells| {
let mut acc = vec![0f32; num_features];
for &c in cells {
for d in 0..num_features {
acc[d] += basis[(d, c)];
}
}
let mut out = Vec::with_capacity(num_features);
let mut sf = 0f32;
for (d, &v) in acc.iter().enumerate() {
assert!(
v >= 0.0,
"projection profile is negative at dim {d} ({v}); \
DC-Poisson requires a nonnegative basis"
);
if v > 0.0 {
out.push((d as u32, v));
sf += v;
}
}
(out, sf)
})
.unzip();
Self {
rows,
size_factor,
num_entities,
num_features,
}
}
pub fn weight_by_vec(&mut self, w: &[f32]) {
assert_eq!(
w.len(),
self.num_features,
"weight vector length must match num_features"
);
self.rows
.par_iter_mut()
.zip(self.size_factor.par_iter_mut())
.for_each(|(row, sf)| {
let mut new_sf = 0f32;
for (g, v) in row.iter_mut() {
*v *= w[*g as usize];
new_sf += *v;
}
*sf = new_sf;
});
}
pub fn apply_feature_weighting(&mut self, method: FeatureWeighting) {
match method {
FeatureWeighting::None => {}
FeatureWeighting::FisherInfoNb => {
let w = self.nb_fisher_weights();
self.weight_by_vec(&w);
}
}
}
pub fn nb_fisher_weights(&self) -> Vec<f32> {
use crate::alg::nb_dispersion::DispersionTrend;
use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
use legume_numeric::matrix::traits::RunningStatOps;
let num_features = self.num_features;
let stats = self
.rows
.par_iter()
.fold(
|| {
(
SparseRunningStatistics::<f32>::new(num_features),
Vec::<usize>::new(),
Vec::<f32>::new(),
)
},
|(mut acc, mut col_rows, mut col_vals), row| {
col_rows.clear();
col_vals.clear();
for &(g, v) in row {
col_rows.push(g as usize);
col_vals.push(v);
}
acc.add_sparse_column(&col_rows, &col_vals);
(acc, col_rows, col_vals)
},
)
.map(|(acc, _, _)| acc)
.reduce(
|| SparseRunningStatistics::<f32>::new(num_features),
|mut a, b| {
a.merge(&b);
a
},
);
let trend = DispersionTrend::from_sparse_stats(&stats);
let means = stats.mean();
let sums = stats.sum();
let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
let avg_s = if self.num_entities > 0 {
(total_mass / self.num_entities as f64) as f32
} else {
1.0
};
let inv_total = if total_mass > 0.0 {
1.0 / total_mass as f32
} else {
0.0
};
(0..self.num_features)
.map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
.collect()
}
}
#[derive(Clone)]
pub struct DcPoissonStats {
pub k: usize,
pub num_features: usize,
pub membership: Vec<usize>,
pub gene_sum: Vec<f64>,
pub size_sum: Vec<f64>,
pub log_gene: Vec<f32>,
pub log_size_offset: Vec<f32>,
}
impl DcPoissonStats {
pub fn from_profiles(profiles: &Profiles, k: usize, membership: &[usize]) -> Self {
let m = profiles.num_features;
let mut gene_sum = vec![0f64; k * m];
let mut size_sum = vec![0f64; k];
for (e, row) in profiles.rows.iter().enumerate() {
let z = membership[e];
assert!(z < k, "membership[{}]={} out of range 0..{}", e, z, k);
let base = z * m;
for &(g, v) in row {
gene_sum[base + g as usize] += v as f64;
}
size_sum[z] += profiles.size_factor[e] as f64;
}
let mut log_gene = vec![0f32; k * m];
for i in 0..k * m {
log_gene[i] = (gene_sum[i] + LOG_EPS).ln() as f32;
}
let m_eps = m as f64 * LOG_EPS;
let log_size_offset: Vec<f32> = size_sum
.iter()
.map(|&s| -((s + m_eps).ln()) as f32)
.collect();
Self {
k,
num_features: m,
membership: membership.to_vec(),
gene_sum,
size_sum,
log_gene,
log_size_offset,
}
}
pub fn delta_move(&mut self, e: usize, k_from: usize, k_to: usize, profiles: &Profiles) {
if k_from == k_to {
return;
}
let m = self.num_features;
let m_eps = m as f64 * LOG_EPS;
let base_from = k_from * m;
let base_to = k_to * m;
for &(g, v) in &profiles.rows[e] {
let gi = g as usize;
self.gene_sum[base_from + gi] = (self.gene_sum[base_from + gi] - v as f64).max(0.0);
self.gene_sum[base_to + gi] += v as f64;
self.log_gene[base_from + gi] = (self.gene_sum[base_from + gi] + LOG_EPS).ln() as f32;
self.log_gene[base_to + gi] = (self.gene_sum[base_to + gi] + LOG_EPS).ln() as f32;
}
let sf = profiles.size_factor[e] as f64;
self.size_sum[k_from] = (self.size_sum[k_from] - sf).max(0.0);
self.size_sum[k_to] += sf;
self.log_size_offset[k_from] = -((self.size_sum[k_from] + m_eps).ln()) as f32;
self.log_size_offset[k_to] = -((self.size_sum[k_to] + m_eps).ln()) as f32;
self.membership[e] = k_to;
}
#[cfg(test)]
fn recompute(&mut self, profiles: &Profiles) {
let k = self.k;
let m = self.num_features;
self.gene_sum.iter_mut().for_each(|x| *x = 0.0);
self.size_sum.iter_mut().for_each(|x| *x = 0.0);
for (e, row) in profiles.rows.iter().enumerate() {
let z = self.membership[e];
let base = z * m;
for &(g, v) in row {
self.gene_sum[base + g as usize] += v as f64;
}
self.size_sum[z] += profiles.size_factor[e] as f64;
}
for i in 0..k * m {
self.log_gene[i] = (self.gene_sum[i] + LOG_EPS).ln() as f32;
}
let m_eps = m as f64 * LOG_EPS;
for i in 0..k {
self.log_size_offset[i] = -((self.size_sum[i] + m_eps).ln()) as f32;
}
}
}
#[inline]
fn score_move(e: usize, k: usize, stats: &DcPoissonStats, profiles: &Profiles) -> f64 {
let m = stats.num_features;
let sf = profiles.size_factor[e] as f64;
let row = &profiles.rows[e];
let base = k * m;
if stats.membership[e] == k {
let m_eps = m as f64 * LOG_EPS;
let loo_size = (stats.size_sum[k] - sf).max(0.0);
let mut acc = -sf * (loo_size + m_eps).ln();
for &(g, v) in row {
let vg = v as f64;
let loo = (stats.gene_sum[base + g as usize] - vg).max(0.0);
acc += vg * (loo + LOG_EPS).ln();
}
acc
} else {
let mut acc = sf * stats.log_size_offset[k] as f64;
for &(g, v) in row {
acc += v as f64 * stats.log_gene[base + g as usize] as f64;
}
acc
}
}
pub fn compute_log_probs_restricted(
e: usize,
stats: &DcPoissonStats,
profiles: &Profiles,
allowed: &[usize],
log_probs: &mut [f64],
) {
for &k in allowed {
log_probs[k] = score_move(e, k, stats, profiles);
}
}
#[cfg(test)]
fn compute_log_probs(e: usize, stats: &DcPoissonStats, profiles: &Profiles, log_probs: &mut [f64]) {
for (k, slot) in log_probs.iter_mut().enumerate().take(stats.k) {
*slot = score_move(e, k, stats, profiles);
}
}
pub fn sample_categorical_log_restricted(
log_probs: &[f64],
allowed: &[usize],
current: usize,
rng: &mut SmallRng,
) -> usize {
let mut best_key = f64::NEG_INFINITY;
let mut best_idx = current;
for &k in allowed {
let lp = log_probs[k];
if lp.is_finite() {
let u: f64 = rng.random_range(1e-12..1.0_f64);
let g = -(-u.ln()).ln();
let key = lp + g;
if key > best_key {
best_key = key;
best_idx = k;
}
}
}
best_idx
}
pub fn argmax_log_restricted(log_probs: &[f64], allowed: &[usize]) -> usize {
let mut best = allowed[0];
let mut best_val = log_probs[best];
for &k in &allowed[1..] {
if log_probs[k] > best_val {
best = k;
best_val = log_probs[k];
}
}
best
}
pub fn compact_labels<K>(labels: &[K]) -> (Vec<usize>, usize)
where
K: Copy + Eq + std::hash::Hash,
{
let mut map: HashMap<K, usize> = HashMap::default();
let mut next = 0usize;
let mut out = Vec::with_capacity(labels.len());
for &g in labels {
let new = *map.entry(g).or_insert_with(|| {
let id = next;
next += 1;
id
});
out.push(new);
}
(out, next)
}
pub fn compute_sibling_sets(
refined: &[Vec<usize>],
level: usize,
num_groups_at_level: usize,
) -> Vec<Vec<usize>> {
let num_levels = refined.len();
let num_entities = refined[level].len();
if level + 1 >= num_levels {
let all_groups: Vec<usize> = (0..num_groups_at_level).collect();
return vec![all_groups; num_entities];
}
let mut parent_to_children: HashMap<usize, Vec<usize>> = HashMap::default();
for (child, parent) in refined[level].iter().zip(refined[level + 1].iter()) {
let entry = parent_to_children.entry(*parent).or_default();
if !entry.contains(child) {
entry.push(*child);
}
}
for v in parent_to_children.values_mut() {
v.sort_unstable();
}
(0..num_entities)
.map(|e| {
let parent = refined[level + 1][e];
parent_to_children.get(&parent).cloned().unwrap_or_default()
})
.collect()
}
pub trait CandidateProposer {
fn propose(&self, labels: &[usize]) -> Vec<Vec<usize>>;
}
pub trait MoveGuard {
fn accept_move(&self, entity: usize, from: usize, labels: &[usize]) -> bool;
}
pub struct NoGuard;
impl MoveGuard for NoGuard {
#[inline(always)]
fn accept_move(&self, _e: usize, _from: usize, _labels: &[usize]) -> bool {
true
}
}
pub fn intersect_with_siblings_fallback(
siblings: &[usize],
neighbor_groups: &[usize],
current: usize,
) -> Vec<usize> {
if siblings.is_empty() {
return Vec::new();
}
if siblings.len() == 1 {
return siblings.to_vec();
}
let intersect: Vec<usize> = siblings
.iter()
.copied()
.filter(|g| neighbor_groups.binary_search(g).is_ok())
.collect();
if intersect.is_empty() {
return siblings.to_vec();
}
if intersect.contains(¤t) {
intersect
} else {
let mut c = intersect;
c.push(current);
c.sort_unstable();
c
}
}
pub struct RefineContext<'a> {
pub profiles: &'a Profiles,
pub k: usize,
pub params: &'a RefineParams,
pub level_label: &'a str,
}
#[derive(Default, Clone, Copy)]
struct SweepCounts {
moves: usize,
vetoed: usize,
}
fn apply_proposals<G: MoveGuard>(
proposals: &[usize],
stats: &mut DcPoissonStats,
profiles: &Profiles,
guard: &G,
) -> SweepCounts {
let mut sc = SweepCounts::default();
for (e, &new) in proposals.iter().enumerate() {
let old = stats.membership[e];
if new == old {
continue;
}
if guard.accept_move(e, old, &stats.membership) {
stats.delta_move(e, old, new, profiles);
sc.moves += 1;
} else {
sc.vetoed += 1;
}
}
sc
}
fn sweep_sequential<G, F, I>(
candidates: &[Vec<usize>],
stats: &mut DcPoissonStats,
profiles: &Profiles,
guard: &G,
log_probs: &mut [f64],
order: I,
mut pick: F,
) -> SweepCounts
where
G: MoveGuard,
F: FnMut(&[f64], &[usize], usize) -> usize,
I: IntoIterator<Item = usize>,
{
let mut sc = SweepCounts::default();
for e in order {
let cand = &candidates[e];
if cand.len() < 2 {
continue;
}
compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
let old = stats.membership[e];
let new = pick(log_probs, cand, old);
if new == old {
continue;
}
if guard.accept_move(e, old, &stats.membership) {
stats.delta_move(e, old, new, profiles);
sc.moves += 1;
} else {
sc.vetoed += 1;
}
}
sc
}
fn sweep_jacobi<G: MoveGuard, F>(
candidates: &[Vec<usize>],
stats: &mut DcPoissonStats,
profiles: &Profiles,
guard: &G,
k: usize,
proposals: &mut [usize],
pick: F,
) -> SweepCounts
where
F: Fn(&[f64], &[usize], usize, usize) -> usize + Sync,
{
debug_assert_eq!(proposals.len(), candidates.len());
{
let stats: &DcPoissonStats = stats;
proposals
.par_iter_mut()
.enumerate()
.with_min_len(256)
.for_each_init(
|| vec![f64::NEG_INFINITY; k],
|log_probs, (e, prop)| {
let cand = &candidates[e];
let current = stats.membership[e];
if cand.len() < 2 {
*prop = current;
return;
}
compute_log_probs_restricted(e, stats, profiles, cand, log_probs);
*prop = pick(log_probs, cand, e, current);
},
);
}
apply_proposals(proposals, stats, profiles, guard)
}
pub fn refine_with_candidates_guarded<G: MoveGuard>(
labels: &mut [usize],
candidates: &[Vec<usize>],
guard: &G,
rng: &mut SmallRng,
ctx: &RefineContext,
) -> usize {
let RefineContext {
profiles,
k,
params,
level_label,
} = *ctx;
let num_entities = labels.len();
let mut stats = DcPoissonStats::from_profiles(profiles, k, labels);
let mut log_probs = vec![f64::NEG_INFINITY; k];
let mut total_moves = 0usize;
let mut total_vetoed = 0usize;
let mut proposals: Vec<usize> = if params.parallel {
vec![0usize; num_entities]
} else {
Vec::new()
};
let mut order: Vec<usize> = if !params.parallel {
(0..num_entities).collect()
} else {
Vec::new()
};
let max_sweeps = (params.num_gibbs + params.num_greedy) as u64;
let prog_bar = legume_numeric::matrix::progress::new_progress_bar(max_sweeps)
.with_message(format!("{level_label} sweeps"));
prog_bar.enable_steady_tick(std::time::Duration::from_millis(100));
let jacobi_base_seed = rng.random::<u64>() | 1;
if params.num_gibbs > 0 {
let mut low_sweeps = 0usize;
for sweep in 0..params.num_gibbs {
let sc = if params.parallel {
let sweep_seed = jacobi_base_seed.wrapping_mul(sweep as u64 + 1);
sweep_jacobi(
candidates,
&mut stats,
profiles,
guard,
k,
&mut proposals,
|log_probs, cand, e, current| {
let vertex_seed = sweep_seed ^ (e as u64).wrapping_mul(2654435761);
let mut rng = SmallRng::seed_from_u64(vertex_seed);
sample_categorical_log_restricted(log_probs, cand, current, &mut rng)
},
)
} else {
order.shuffle(rng);
sweep_sequential(
candidates,
&mut stats,
profiles,
guard,
&mut log_probs,
order.iter().copied(),
|log_probs, cand, current| {
sample_categorical_log_restricted(log_probs, cand, current, rng)
},
)
};
total_moves += sc.moves;
total_vetoed += sc.vetoed;
prog_bar.inc(1);
if params.gibbs_stagnation > 0.0 {
if (sc.moves as f64) < params.gibbs_stagnation * (num_entities as f64) {
low_sweeps += 1;
if low_sweeps >= 3 {
break;
}
} else {
low_sweeps = 0;
}
}
}
}
for _sweep in 0..params.num_greedy {
let sc = if params.parallel {
sweep_jacobi(
candidates,
&mut stats,
profiles,
guard,
k,
&mut proposals,
|log_probs, cand, _e, _current| argmax_log_restricted(log_probs, cand),
)
} else {
sweep_sequential(
candidates,
&mut stats,
profiles,
guard,
&mut log_probs,
0..num_entities,
|log_probs, cand, _current| argmax_log_restricted(log_probs, cand),
)
};
total_moves += sc.moves;
total_vetoed += sc.vetoed;
prog_bar.inc(1);
if sc.moves == 0 {
break;
}
}
prog_bar.finish_and_clear();
if total_vetoed > 0 {
log::debug!(
"{}: {} moves, {} vetoed by MoveGuard",
level_label,
total_moves,
total_vetoed
);
}
labels.copy_from_slice(&stats.membership);
total_moves
}
pub fn refine_with_candidates(
labels: &mut [usize],
candidates: &[Vec<usize>],
rng: &mut SmallRng,
ctx: &RefineContext,
) -> usize {
refine_with_candidates_guarded(labels, candidates, &NoGuard, rng, ctx)
}
pub fn refine_with_proposer_guarded<P: CandidateProposer, G: MoveGuard>(
labels: &mut [usize],
proposer: &P,
guard: &G,
rng: &mut SmallRng,
ctx: &RefineContext,
) -> usize {
let candidates = proposer.propose(labels);
let moves = refine_with_candidates_guarded(labels, &candidates, guard, rng, ctx);
info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
moves
}
pub fn refine_with_proposer<P: CandidateProposer>(
labels: &mut [usize],
proposer: &P,
rng: &mut SmallRng,
ctx: &RefineContext,
) -> usize {
let candidates = proposer.propose(labels);
let moves = refine_with_candidates(labels, &candidates, rng, ctx);
info!(" {}: {} DC-Poisson moves", ctx.level_label, moves);
moves
}
#[cfg(test)]
#[path = "dc_poisson_tests.rs"]
mod tests;