1use clap::Args;
21use legume_numeric::matrix::dmatrix_util::build_columns_par;
22use legume_numeric::matrix::kmeans::kmeans_centroids_seeded;
23use legume_numeric::matrix::rand_util::mix_seed;
24use legume_numeric::matrix::traits::SampleOps;
25use log::{debug, info};
26use nalgebra::DMatrix;
27use rayon::prelude::*;
28use serde::{Deserialize, Serialize};
29
30type CscMat = nalgebra_sparse::CscMatrix<f32>;
31
32#[derive(Clone, Serialize, Deserialize)]
34pub struct FeatureCoarsening {
35 pub fine_to_coarse: Vec<usize>,
37 pub coarse_to_fine: Vec<Vec<usize>>,
39 pub num_coarse: usize,
41}
42
43impl FeatureCoarsening {
44 pub fn from_fine_to_coarse(
50 fine_to_coarse: Vec<usize>,
51 num_coarse: usize,
52 ) -> anyhow::Result<Self> {
53 let mut coarse_to_fine = vec![Vec::new(); num_coarse];
54 for (f, &c) in fine_to_coarse.iter().enumerate() {
55 anyhow::ensure!(
56 c < num_coarse,
57 "feature coarsening: feature {f} is assigned to group {c} of {num_coarse}"
58 );
59 coarse_to_fine[c].push(f);
60 }
61 Ok(Self {
62 fine_to_coarse,
63 coarse_to_fine,
64 num_coarse,
65 })
66 }
67
68 pub fn grow_by_profile(
84 &self,
85 new_to_old: &[Option<usize>],
86 unit_profiles: &[Vec<f32>],
87 ) -> anyhow::Result<FeatureCoarsening> {
88 let d_new = new_to_old.len();
89 anyhow::ensure!(
90 unit_profiles.len() == d_new,
91 "feature coarsening growth: {} profiles for {d_new} features",
92 unit_profiles.len(),
93 );
94 let k = self.num_coarse;
95 let n_pb = unit_profiles.first().map_or(0, Vec::len);
96
97 let mut fine_to_coarse = vec![usize::MAX; d_new];
100 let mut centroid = DMatrix::<f32>::zeros(n_pb, k);
101 let mut members = vec![0usize; k];
102 for (g, old) in new_to_old.iter().enumerate() {
103 let Some(p) = old else { continue };
104 anyhow::ensure!(
105 *p < self.fine_to_coarse.len(),
106 "feature coarsening growth: feature {g} maps to {p}, beyond the {} covered",
107 self.fine_to_coarse.len(),
108 );
109 let m = self.fine_to_coarse[*p];
110 fine_to_coarse[g] = m;
111 members[m] += 1;
112 for (c, v) in centroid.column_mut(m).iter_mut().zip(&unit_profiles[g]) {
113 *c += v;
114 }
115 }
116 let live: Vec<usize> = (0..k).filter(|&m| members[m] > 0).collect();
117 anyhow::ensure!(
118 !live.is_empty(),
119 "feature coarsening growth: no feature of the new axis is covered, so the groups \
120 cannot be placed on it"
121 );
122 let fallback = live
123 .iter()
124 .copied()
125 .max_by_key(|&m| members[m])
126 .expect("a live group exists");
127 let mut centroid = centroid.select_columns(&live);
128 for mut c in centroid.column_iter_mut() {
129 let nrm = c.norm();
130 if nrm > 0.0 {
131 c /= nrm;
132 }
133 }
134
135 let new: Vec<usize> = (0..d_new).filter(|&g| new_to_old[g].is_none()).collect();
138 let u = DMatrix::from_fn(new.len(), n_pb, |i, j| unit_profiles[new[i]][j]);
139 let scores = u * centroid;
140 for (i, &g) in new.iter().enumerate() {
141 let row = scores.row(i);
142 let best = row
143 .iter()
144 .enumerate()
145 .max_by(|a, b| a.1.total_cmp(b.1))
146 .expect("a live group exists");
147 fine_to_coarse[g] = if row.iter().all(|&v| v == 0.0) {
148 fallback
149 } else {
150 live[best.0]
151 };
152 }
153 debug!(
154 "feature coarsening growth: {} of {d_new} features known, {} placed by profile into \
155 {k} groups ({} groups had no surviving member)",
156 d_new - new.len(),
157 new.len(),
158 k - live.len(),
159 );
160 Self::from_fine_to_coarse(fine_to_coarse, k)
161 }
162
163 pub fn aggregate_columns_nd(&self, data_nd: &DMatrix<f32>) -> DMatrix<f32> {
166 let n = data_nd.nrows();
167 build_columns_par(n, self.num_coarse, |c, col| {
168 for &fine in &self.coarse_to_fine[c] {
169 let src = data_nd.column(fine);
170 for (dst, src_v) in col.iter_mut().zip(src.iter()) {
171 *dst += *src_v;
172 }
173 }
174 })
175 }
176
177 pub fn aggregate_rows_ds(&self, data_ds: &DMatrix<f32>) -> DMatrix<f32> {
180 let s = data_ds.ncols();
181 build_columns_par(self.num_coarse, s, |j, col| {
182 let src_col = data_ds.column(j);
183 for (fine, &coarse) in self.fine_to_coarse.iter().enumerate() {
184 col[coarse] += src_col[fine];
185 }
186 })
187 }
188
189 pub fn expand_log_dict_dk(&self, log_dict_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
197 let k = log_dict_dk.ncols();
198 build_columns_par(d_fine, k, |kk, col| {
199 let src_col = log_dict_dk.column(kk);
200 for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
201 let val = src_col[c] - (fine_indices.len() as f32).ln();
202 for &f in fine_indices {
203 col[f] = val;
204 }
205 }
206 })
207 }
208
209 pub fn expand_rows_dk(&self, table_dk: &DMatrix<f32>, d_fine: usize) -> DMatrix<f32> {
219 let k = table_dk.ncols();
220 build_columns_par(d_fine, k, |kk, col| {
221 let src_col = table_dk.column(kk);
222 for (c, fine_indices) in self.coarse_to_fine.iter().enumerate() {
223 for &f in fine_indices {
224 col[f] = src_col[c];
225 }
226 }
227 })
228 }
229
230 pub fn aggregate_sparse_csc(&self, data_dn: &CscMat) -> DMatrix<f32> {
233 let n = data_dn.ncols();
234 build_columns_par(self.num_coarse, n, |j, col| {
235 let src = data_dn.col(j);
236 for (&row, &val) in src.row_indices().iter().zip(src.values().iter()) {
237 col[self.fine_to_coarse[row]] += val;
238 }
239 })
240 }
241}
242
243const INFORMATIVE_Z: f64 = 5.0;
250const KMEANS_ITER: usize = 30;
252const MAX_PROFILE_DIM: usize = 1024;
255const SKETCH_DIM: usize = 64;
256
257pub fn informative_features(counts: &DMatrix<f32>, sizes: &[f32]) -> Vec<bool> {
262 let total_size: f64 = sizes.iter().map(|&n| f64::from(n)).sum();
263 let live: Vec<usize> = (0..sizes.len()).filter(|&s| sizes[s] > 0.0).collect();
264 if live.len() < 2 || total_size <= 0.0 {
265 return vec![false; counts.nrows()];
266 }
267 let df = (live.len() - 1) as f64;
268 (0..counts.nrows())
269 .into_par_iter()
270 .map(|g| {
271 let t: f64 = live.iter().map(|&s| f64::from(counts[(g, s)])).sum();
272 if t <= 0.0 {
273 return false;
274 }
275 let dev: f64 = live
276 .iter()
277 .map(|&s| {
278 let n = f64::from(counts[(g, s)]);
279 let e = t * f64::from(sizes[s]) / total_size;
280 let log_term = if n > 0.0 { n * (n / e).ln() } else { 0.0 };
281 2.0 * (log_term - (n - e))
282 })
283 .sum();
284 (dev - df) / (2.0 * df).sqrt() > INFORMATIVE_Z
285 })
286 .collect()
287}
288
289fn residual_profiles(
293 counts: &DMatrix<f32>,
294 sizes: &[f32],
295 rows: &[usize],
296 seed: u64,
297) -> DMatrix<f32> {
298 let n_pb = sizes.len();
299 let total_size: f32 = sizes.iter().sum();
300 let clip = (n_pb as f32).sqrt();
301 let mut z = DMatrix::<f32>::zeros(rows.len(), n_pb);
302 for (i, &g) in rows.iter().enumerate() {
303 let t: f32 = counts.row(g).sum();
304 for s in 0..n_pb {
305 let e = t * sizes[s] / total_size.max(f32::MIN_POSITIVE);
306 if e > 0.0 {
307 z[(i, s)] = ((counts[(g, s)] - e) / e.sqrt()).clamp(-clip, clip);
308 }
309 }
310 }
311 if n_pb > MAX_PROFILE_DIM {
312 let basis = DMatrix::<f32>::rnorm_seeded(n_pb, SKETCH_DIM, mix_seed(seed, 0x5343_4854));
313 z = (&z * basis) / (SKETCH_DIM as f32).sqrt();
314 }
315 unit_rows(&mut z);
316 z
317}
318
319fn unit_rows(z: &mut DMatrix<f32>) {
320 for mut row in z.row_iter_mut() {
321 let norm = row.norm();
322 if norm > 0.0 {
323 row /= norm;
324 }
325 }
326}
327
328pub fn coarsen_features(
353 counts: &DMatrix<f32>,
354 sizes: &[f32],
355 level_targets: &[usize],
356 seed: u64,
357) -> anyhow::Result<Vec<FeatureCoarsening>> {
358 let d = counts.nrows();
359 anyhow::ensure!(
360 counts.ncols() == sizes.len(),
361 "{} count columns for {} pseudobulk sizes",
362 counts.ncols(),
363 sizes.len()
364 );
365 anyhow::ensure!(!level_targets.is_empty(), "no coarsening level requested");
366 anyhow::ensure!(
367 level_targets.windows(2).all(|w| w[0] <= w[1]),
368 "coarsening levels must run coarsest → finest: {level_targets:?}"
369 );
370 let informative = informative_features(counts, sizes);
371 let rows: Vec<usize> = (0..d).filter(|&g| informative[g]).collect();
372 let has_background = rows.len() < d;
373 let slots = |target: usize| target.max(1).saturating_sub(usize::from(has_background));
376 info!(
377 "feature coarsening: {} of {d} features informative, {} in the background group",
378 rows.len(),
379 d - rows.len()
380 );
381
382 let finest = *level_targets.last().expect("checked non-empty");
384 let k_fine = slots(finest).min(rows.len());
385 let (fine_label, centroids) = if rows.is_empty() || k_fine == 0 {
386 (Vec::new(), DMatrix::<f32>::zeros(0, 0))
387 } else {
388 let z = residual_profiles(counts, sizes, &rows, seed);
389 let (mut c, labels) = kmeans_centroids_seeded(&z, k_fine, KMEANS_ITER, seed);
390 unit_rows(&mut c);
391 (labels, c)
392 };
393 let n_fine = centroids.nrows();
394
395 let levels = level_targets
397 .iter()
398 .enumerate()
399 .map(|(l, &target)| {
400 let k = slots(target);
401 let to_level: Vec<usize> = if n_fine == 0 || k == 0 {
402 Vec::new()
403 } else if l + 1 == level_targets.len() || k >= n_fine {
404 (0..n_fine).collect()
405 } else {
406 kmeans_centroids_seeded(¢roids, k, KMEANS_ITER, mix_seed(seed, l as u64)).1
407 };
408 let level = level_from_labels(d, &rows, &fine_label, &to_level, has_background);
409 info!(
410 "feature coarsening level {l} (target {target}): {}",
411 group_sizes(&level, has_background)
412 );
413 level
414 })
415 .collect();
416 Ok(levels)
417}
418
419fn level_from_labels(
423 d: usize,
424 rows: &[usize],
425 fine_label: &[usize],
426 to_level: &[usize],
427 has_background: bool,
428) -> FeatureCoarsening {
429 let mut in_rows = vec![false; d];
430 let mut compact = std::collections::HashMap::<usize, usize>::new();
431 let mut fine_to_coarse = vec![0usize; d];
432 for (&g, &f) in rows.iter().zip(fine_label) {
433 in_rows[g] = true;
434 let next = compact.len();
435 fine_to_coarse[g] = *compact.entry(to_level[f]).or_insert(next);
436 }
437 let mut num_coarse = compact.len();
438 if has_background {
439 for (g, &inside) in in_rows.iter().enumerate() {
440 if !inside {
441 fine_to_coarse[g] = num_coarse;
442 }
443 }
444 num_coarse += 1;
445 }
446 let mut coarse_to_fine = vec![Vec::new(); num_coarse];
447 for (g, &c) in fine_to_coarse.iter().enumerate() {
448 coarse_to_fine[c].push(g);
449 }
450 FeatureCoarsening {
451 fine_to_coarse,
452 coarse_to_fine,
453 num_coarse,
454 }
455}
456
457fn group_sizes(level: &FeatureCoarsening, has_background: bool) -> String {
461 let n_inf = level.num_coarse - usize::from(has_background);
462 let mut sizes: Vec<usize> = level.coarse_to_fine[..n_inf].iter().map(Vec::len).collect();
463 sizes.sort_unstable();
464 format!(
465 "{} informative group(s), sizes min {} / median {} / max {}, {} singleton(s); \
466 background {}",
467 sizes.len(),
468 sizes.first().copied().unwrap_or(0),
469 sizes.get(sizes.len() / 2).copied().unwrap_or(0),
470 sizes.last().copied().unwrap_or(0),
471 sizes.iter().filter(|&&n| n == 1).count(),
472 if has_background {
473 level.coarse_to_fine[n_inf].len()
474 } else {
475 0
476 }
477 )
478}
479
480#[derive(Clone, Debug, Default)]
483pub struct PartitionOptions {
484 pub min_group_size: usize,
490 pub block: Option<Vec<u32>>,
495}
496
497#[derive(Clone, Debug)]
499pub struct FeaturePartition {
500 pub labels: Vec<usize>,
503 pub num_groups: usize,
504 pub background: Option<usize>,
506 pub informative: Vec<bool>,
509}
510
511const SPLIT_SEED_SALT: u64 = 0x5350_4c54;
513
514fn kmeans_groups(
518 counts: &DMatrix<f32>,
519 sizes: &[f32],
520 rows: &[usize],
521 k: usize,
522 seed: u64,
523) -> Vec<Vec<usize>> {
524 let k = k.min(rows.len());
525 if k == 0 {
526 return Vec::new();
527 }
528 let z = residual_profiles(counts, sizes, rows, seed);
529 let (_, labels) = kmeans_centroids_seeded(&z, k, KMEANS_ITER, seed);
530 let mut slot = vec![usize::MAX; k];
531 let mut groups: Vec<Vec<usize>> = Vec::new();
532 for (&r, &l) in rows.iter().zip(&labels) {
533 if slot[l] == usize::MAX {
534 slot[l] = groups.len();
535 groups.push(Vec::new());
536 }
537 groups[slot[l]].push(r);
538 }
539 groups
540}
541
542fn informative_groups(
547 counts: &DMatrix<f32>,
548 sizes: &[f32],
549 rows: &[usize],
550 informative: &[bool],
551 n_groups: usize,
552 seed: u64,
553 min_size: usize,
554) -> Vec<Vec<usize>> {
555 let inf: Vec<usize> = rows.iter().copied().filter(|&r| informative[r]).collect();
556 let has_empty = inf.len() < rows.len();
557 let slots = n_groups.max(1).saturating_sub(usize::from(has_empty));
558 let groups = kmeans_groups(counts, sizes, &inf, slots, seed);
559 if min_size <= 1 {
560 return groups;
561 }
562 let (mut groups, scattered): (Vec<Vec<usize>>, Vec<Vec<usize>>) =
563 groups.into_iter().partition(|g| g.len() >= min_size);
564 let has_bg = has_empty || !scattered.is_empty();
565 let mut free = n_groups.saturating_sub(groups.len() + usize::from(has_bg));
566 let mut heap: std::collections::BinaryHeap<(usize, usize)> = groups
567 .iter()
568 .enumerate()
569 .map(|(i, g)| (g.len(), i))
570 .collect();
571 while free > 0 {
572 let Some((len, i)) = heap.pop() else { break };
573 if len < 2 * min_size {
574 break;
575 }
576 let mut halves = kmeans_groups(
577 counts,
578 sizes,
579 &groups[i],
580 2,
581 mix_seed(seed, SPLIT_SEED_SALT + i as u64),
582 );
583 if halves.len() == 2 && halves.iter().all(|h| h.len() >= min_size) {
584 let b = halves.pop().expect("two halves");
585 groups[i] = halves.pop().expect("two halves");
586 heap.push((groups[i].len(), i));
587 heap.push((b.len(), groups.len()));
588 groups.push(b);
589 free -= 1;
590 }
591 }
592 groups
593}
594
595fn block_shares(weight: &[usize], budget: usize) -> Vec<usize> {
599 let total: usize = weight.iter().sum();
600 let mut alloc: Vec<usize> = weight
601 .iter()
602 .map(|&c| (budget * c / total.max(1)).max(1))
603 .collect();
604 while alloc.iter().sum::<usize>() > budget {
605 let i = (0..alloc.len())
606 .max_by_key(|&i| alloc[i])
607 .expect("non-empty");
608 alloc[i] -= 1;
609 }
610 while alloc.iter().sum::<usize>() < budget {
611 let i = (0..alloc.len())
612 .max_by(|&a, &b| (weight[a] * alloc[b]).cmp(&(weight[b] * alloc[a])))
613 .expect("non-empty");
614 alloc[i] += 1;
615 }
616 alloc
617}
618
619pub fn partition_features(
625 counts: &DMatrix<f32>,
626 sizes: &[f32],
627 n_groups: usize,
628 seed: u64,
629 opts: &PartitionOptions,
630) -> anyhow::Result<FeaturePartition> {
631 let d = counts.nrows();
632 anyhow::ensure!(
633 counts.ncols() == sizes.len(),
634 "{} count columns for {} pseudobulk sizes",
635 counts.ncols(),
636 sizes.len()
637 );
638 let informative = informative_features(counts, sizes);
639 let min_size = opts.min_group_size;
640 let mut rows_of = std::collections::BTreeMap::<u32, Vec<usize>>::new();
641 match &opts.block {
642 Some(block) => {
643 anyhow::ensure!(
644 block.len() == d,
645 "{} block ids for {d} features",
646 block.len()
647 );
648 for (r, &b) in block.iter().enumerate() {
649 rows_of.entry(b).or_default().push(r);
650 }
651 }
652 None => {
653 rows_of.insert(0, (0..d).collect());
654 }
655 }
656
657 let groups: Vec<Vec<usize>> = if rows_of.len() <= 1 {
658 let rows: Vec<usize> = (0..d).collect();
659 informative_groups(counts, sizes, &rows, &informative, n_groups, seed, min_size)
660 } else {
661 let n_inf = |rows: &[usize]| rows.iter().filter(|&&r| informative[r]).count();
662 let live: Vec<(u32, usize)> = rows_of
663 .iter()
664 .map(|(&b, rows)| (b, n_inf(rows)))
665 .filter(|&(_, c)| c > 0)
666 .collect();
667 let budget = n_groups.saturating_sub(1);
668 anyhow::ensure!(
669 live.len() <= budget,
670 "{} blocks hold informative features but only {budget} group slots: widen the blocks",
671 live.len()
672 );
673 let weight: Vec<usize> = live.iter().map(|&(_, c)| c).collect();
674 let alloc = block_shares(&weight, budget);
675 live.iter()
676 .zip(&alloc)
677 .flat_map(|(&(b, _), &k)| {
678 let rows = &rows_of[&b];
679 let has_empty = rows.len() > n_inf(rows);
682 informative_groups(
683 counts,
684 sizes,
685 rows,
686 &informative,
687 k + usize::from(has_empty),
688 mix_seed(seed, u64::from(b)),
689 min_size,
690 )
691 })
692 .collect()
693 };
694
695 let bg = groups.len();
697 let mut labels = vec![bg; d];
698 for (i, g) in groups.iter().enumerate() {
699 for &r in g {
700 labels[r] = i;
701 }
702 }
703 let n_bg = d - groups.iter().map(Vec::len).sum::<usize>();
704 let n_flat = informative.iter().filter(|&&i| !i).count();
705 let mut group_sizes: Vec<usize> = groups.iter().map(Vec::len).collect();
706 group_sizes.sort_unstable();
707 info!(
708 "feature partition: {bg} group(s) over {} block(s), sizes min {} / median {} / max {}; \
709 background {n_bg} ({n_flat} flat, {} scattered)",
710 rows_of.len(),
711 group_sizes.first().copied().unwrap_or(0),
712 group_sizes.get(group_sizes.len() / 2).copied().unwrap_or(0),
713 group_sizes.last().copied().unwrap_or(0),
714 n_bg - n_flat,
715 );
716 Ok(FeaturePartition {
717 labels,
718 num_groups: bg + usize::from(n_bg > 0),
719 background: (n_bg > 0).then_some(bg),
720 informative,
721 })
722}
723
724#[derive(Args, Debug, Clone, Serialize, Deserialize)]
732#[serde(default = "legume_numeric::matrix::clap_defaults::clap_defaults")]
733pub struct FeatureCoarseningArgs {
734 #[arg(
735 long,
736 default_value_t = 1000,
737 value_name = "N",
738 help = "Group co-expressed features into at most N coarse features; 0 = every feature",
739 long_help = "Group co-expressed features into at most N coarse features,\n\
740 so the model answers for a group instead of for each feature.\n\
741 Groups come from the finest pseudobulk profiles, nested per\n\
742 level with log-spaced widths. The dictionary is expanded back\n\
743 to every feature on output, so what you read is unchanged.\n\
744 \n\
745 --no-feature-coarsening trains on every feature instead. So\n\
746 does 0 here, kept because recorded runs and existing scripts\n\
747 spell it that way.\n\
748 \n\
749 WHAT THE GROUPING APPLIES TO DEPENDS ON THE COMMAND:\n\
750 \n\
751 `topic` and `vae` group both sides. The encoder reads coarse\n\
752 features and every decoder answers for them, so 0 makes each\n\
753 per-step tensor as wide as the feature axis.\n\
754 \n\
755 `masked-topic`, `masked-vae` and `masked-sbp` group the decoder\n\
756 targets only. The encoder keeps its feature-level context and\n\
757 embedding either way.\n\
758 \n\
759 `joint-topic` groups per modality, or on the reference modality\n\
760 and shares it, following --decoder-type.\n\
761 \n\
762 This is a COARSENING, not a module. senna tells the two kinds\n\
763 of grouping apart by one question: does training move the\n\
764 membership? A coarsening's does not. It is read off the data\n\
765 before the first step and fixed for the life of the model,\n\
766 which is what lets every decoder be keyed to it and what lets a\n\
767 continued fit inherit it. A module's does: --gene-modules learns\n\
768 its centroids, so which features group together changes as the\n\
769 fit proceeds.\n\
770 \n\
771 What a coarsening fixes is the assignment, not the meaning. A\n\
772 group's embedding is the mean of its members' and moves at every\n\
773 step, so grouping the targets does not freeze what the model can\n\
774 learn about a feature."
775 )]
776 pub max_coarse_features: usize,
777
778 #[arg(
779 long,
780 conflicts_with = "max_coarse_features",
781 help = "Train on every feature, with no grouping",
782 long_help = "Train on every feature. The named form of\n\
783 --max-coarse-features 0, which reads as a request for zero\n\
784 features when it means the opposite.\n\
785 \n\
786 Every per-step tensor is then as wide as the feature axis, so\n\
787 the fit costs considerably more at the same epoch count. It is\n\
788 also fixed for the life of a model: a continued fit inherits\n\
789 the groups the source run trained on, so a chain that starts\n\
790 ungrouped stays ungrouped and one that starts grouped cannot\n\
791 be switched over partway."
792 )]
793 pub no_feature_coarsening: bool,
794}
795
796impl FeatureCoarseningArgs {
797 #[must_use]
803 pub fn cap(&self) -> Option<std::num::NonZeroUsize> {
804 if self.no_feature_coarsening {
805 return None;
806 }
807 std::num::NonZeroUsize::new(self.max_coarse_features)
808 }
809}
810
811#[cfg(test)]
812#[path = "feature_coarsening_tests.rs"]
813mod tests;