1#![allow(dead_code)]
2
3use crate::sparse_data_visitors::*;
4use crate::sparse_io_stack::SparseIoStack;
5use crate::sparse_io_vector::SparseIoVec;
6use legume_numeric::matrix::knn_match::ColumnDict;
7use legume_numeric::matrix::traits::*;
8use legume_numeric::param::dmatrix_gamma::*;
9use legume_numeric::param::traits::Inference;
10use legume_numeric::param::traits::*;
11use log::{info, warn};
12use nalgebra::DMatrix;
13use rayon::prelude::*;
14use std::ops::AddAssign;
15use std::sync::{Arc, Mutex};
16
17use crate::alg::random_projection::binary_sort_columns;
18
19use rustc_hash::FxHashMap as HashMap;
20type CscMat = nalgebra_sparse::CscMatrix<f32>;
21
22pub type GeneSums = Vec<Vec<(usize, f32)>>;
26
27pub const DEFAULT_KNN: usize = 10;
28pub const DEFAULT_OPT_ITER: usize = 100;
29
30mod pb_samples;
38use pb_samples::{
39 build_pb_sample_layout, build_pb_sample_to_cells, build_pb_samples, collect_pb_sample_gene_sums,
40};
41pub use pb_samples::{PbSampleCollection, PbSampleLayout};
42pub(crate) use pb_samples::per_batch_sc_neighbors;
44mod reassign_cells;
45pub use reassign_cells::ReassignCellsParams;
46mod pb_tree;
47pub use pb_tree::{BelowEdge, ContrastGene, PbTree, PbTreeParams, RootRecord, SplitRecord};
48mod refine;
49pub use refine::StackCollapseOut;
50use refine::{
51 compute_level_sort_dims, fine_to_coarse_from_refined, pad_numeric_labels,
52 refine_and_collect_single_layer, refine_and_collect_stack, split_anchored_finest_groups,
53 RefineCollectCtx,
54};
55mod stats;
56use stats::{
57 collect_basic_stat_visitor, collect_batch_stat_visitor, collect_matched_stat_coarse,
58 collect_matched_stat_visitor, collect_observed_stat_from_pb_samples, merge_stat, optimize,
59 KnnParams, DEFAULT_NUM_LEVELS,
60};
61pub use stats::{resample_and_optimize, CollapsedOut, CollapsedStat};
62mod strata;
63pub use strata::collapse_columns_multilevel_with_strata;
64
65pub struct MultilevelCollapseOut {
66 pub levels: Vec<CollapsedOut>,
67 pub cell_to_pb_per_level: Vec<Vec<usize>>,
68 pub pb_tree: Option<PbTree>,
71}
72
73#[derive(Clone)]
75pub struct MultilevelParams {
76 pub knn_pb_samples: usize,
77 pub num_levels: usize,
78 pub sort_dim: usize,
79 pub num_opt_iter: usize,
80 pub refine: crate::alg::refine_multilevel::RefineParams,
84 pub output_calibration: legume_numeric::param::traits::CalibrateTarget,
89 pub anchor_batches: Option<Vec<Box<str>>>,
100 pub bulk_batches: Option<Vec<Box<str>>>,
111 pub observe_panels: bool,
119 pub keep_finest_stats: bool,
125 pub pb_tree: Option<PbTreeParams>,
130 pub strata: Option<Vec<usize>>,
135}
136
137impl MultilevelParams {
138 pub fn new(proj_dim: usize) -> Self {
139 Self {
140 knn_pb_samples: DEFAULT_KNN,
141 num_levels: DEFAULT_NUM_LEVELS,
142 sort_dim: proj_dim.min(12),
143 num_opt_iter: DEFAULT_OPT_ITER,
144 refine: crate::alg::refine_multilevel::RefineParams::default(),
145 output_calibration: legume_numeric::param::traits::CalibrateTarget::All,
146 anchor_batches: None,
147 bulk_batches: None,
148 observe_panels: true,
149 keep_finest_stats: false,
150 pb_tree: None,
151 strata: None,
152 }
153 }
154
155 #[must_use]
157 pub fn with_strata(&self, strata: Vec<usize>) -> Self {
158 let mut p = self.clone();
159 p.strata = Some(strata);
160 p
161 }
162}
163
164fn stratum_bits(strata: &[usize]) -> usize {
168 let mut occupied: Vec<usize> = strata.to_vec();
169 occupied.sort_unstable();
170 occupied.dedup();
171 if occupied.len() <= 1 {
172 return 0;
173 }
174 let max = *occupied.last().unwrap_or(&0);
175 let n = max + 1;
176 (usize::BITS as usize - n.saturating_sub(1).leading_zeros() as usize).max(1)
177}
178
179fn apply_strata_to_codes(
184 codes: &[usize],
185 level_dims: &[usize],
186 strata: &[usize],
187) -> anyhow::Result<(Vec<usize>, Vec<usize>, usize)> {
188 anyhow::ensure!(
189 codes.len() == strata.len(),
190 "strata has {} entries, codes have {}",
191 strata.len(),
192 codes.len()
193 );
194 let s_bits = stratum_bits(strata);
195 if s_bits == 0 {
196 return Ok((codes.to_vec(), level_dims.to_vec(), 0));
197 }
198 let stratified: Vec<usize> = codes
199 .iter()
200 .zip(strata.iter())
201 .map(|(&c, &s)| (c << s_bits) | s)
202 .collect();
203 Ok((stratified, level_dims.to_vec(), s_bits))
204}
205
206struct FinestCodes {
208 codes: Vec<usize>,
210 widths: Vec<usize>,
212 tree: Option<PbTree>,
214 strata_bits: usize,
216}
217
218fn finest_codes(
231 data_vec: &SparseIoVec,
232 proj_kn: &DMatrix<f32>,
233 level_dims: &[usize],
234 params: &MultilevelParams,
235) -> anyhow::Result<FinestCodes> {
236 let finest_dim = level_dims[0];
237 let nn = proj_kn.ncols();
238 let kk = proj_kn.nrows().min(finest_dim).min(nn);
239 let codes = binary_sort_columns(proj_kn, kk)?;
240 let (codes, widths, tree) = match params.pb_tree.as_ref() {
241 None => (codes, level_dims.to_vec(), None),
242 Some(rb) => {
243 let coarse_bits = if level_dims.len() >= 2 {
244 *level_dims.last().expect("non-empty level dims")
245 } else {
246 stats::DEFAULT_COARSEST_SORT_DIM.min(kk)
247 };
248 let low_mask = (1usize << coarse_bits) - 1;
249 let n = data_vec.num_columns();
250 let active: Vec<bool> = if data_vec.has_column_multiplicity() {
253 (0..n)
254 .map(|c| (data_vec.column_multiplicity(c) - 1.0).abs() <= f32::EPSILON)
255 .collect()
256 } else {
257 vec![true; n]
258 };
259 let low: Vec<usize> = codes.iter().map(|&c| c & low_mask).collect();
260 let (mut node, _) = crate::alg::dc_poisson::compact_labels(&low);
261 let csc = data_vec.read_columns_csc(0..n)?;
264 let reassigned_cells = match rb.reassign_cells.as_ref() {
265 Some(cr) => {
266 let col_to_batch = data_vec.get_batch_membership(0..n);
267 reassign_cells::reassign_cells_to_nodes(
268 &csc,
269 &col_to_batch,
270 data_vec.num_batches().max(1),
271 &active,
272 &mut node,
273 cr,
274 )
275 }
276 None => 0,
277 };
278 for (c, nd) in node.iter_mut().enumerate() {
279 if !active[c] {
280 *nd = usize::MAX;
281 }
282 }
283 let targets: Vec<usize> = level_dims.iter().rev().map(|&d| 1usize << d).collect();
285 let (codes, widths, mut tree) =
286 pb_tree::build_tree(data_vec, &csc, &node, &codes, &targets, rb)?;
287 tree.reassigned_cells = reassigned_cells;
288 (codes, widths, Some(tree))
289 }
290 };
291 maybe_stratify_codes(codes, widths, tree, params)
292}
293
294fn maybe_stratify_codes(
296 codes: Vec<usize>,
297 widths: Vec<usize>,
298 tree: Option<PbTree>,
299 params: &MultilevelParams,
300) -> anyhow::Result<FinestCodes> {
301 let Some(strata) = params.strata.as_deref() else {
302 return Ok(FinestCodes {
303 codes,
304 widths,
305 tree,
306 strata_bits: 0,
307 });
308 };
309 anyhow::ensure!(
310 strata.len() == codes.len(),
311 "MultilevelParams.strata has {} entries, codes have {}",
312 strata.len(),
313 codes.len()
314 );
315 let (codes, widths, s_bits) = apply_strata_to_codes(&codes, &widths, strata)?;
316 let n_occ = {
317 let mut u = strata.to_vec();
318 u.sort_unstable();
319 u.dedup();
320 u.len()
321 };
322 info!(
323 "CNV strata: crossed finest codes with {} cell strata ({} occupied, {} stratum bits)",
324 strata.len(),
325 n_occ,
326 s_bits
327 );
328 Ok(FinestCodes {
329 codes,
330 widths,
331 tree,
332 strata_bits: s_bits,
333 })
334}
335
336fn resolve_named_batches(
343 data_vec: &SparseIoVec,
344 role: &str,
345 names: Option<&[Box<str>]>,
346) -> anyhow::Result<Option<Vec<usize>>> {
347 let Some(names) = names else { return Ok(None) };
348 let map = data_vec
349 .batch_name_map()
350 .ok_or_else(|| anyhow::anyhow!("{role} batches given but no batches are registered"))?;
351 let mut idx = Vec::with_capacity(names.len());
352 for n in names {
353 let Some(&b) = map.get(n) else {
354 anyhow::bail!(
355 "{role} batch `{n}` is not among the registered batches ({:?})",
356 map.keys().collect::<Vec<_>>(),
357 );
358 };
359 idx.push(b);
360 }
361 Ok(Some(idx))
362}
363
364fn greedy_anchor_for_bulk(
378 data_vec: &SparseIoVec,
379 anchors: Option<Vec<usize>>,
380 bulk: Option<&[usize]>,
381) -> Option<Vec<usize>> {
382 match (anchors, bulk) {
383 (Some(a), _) => Some(a),
384 (None, Some(b)) if !b.is_empty() => {
385 let all = data_vec.num_batches();
386 let frame: Vec<usize> = (0..all).filter(|i| !b.contains(i)).collect();
387 (!frame.is_empty()).then(|| {
388 info!(
389 "Greedy bulk correction: {} bulk batch(es) corrected toward {} cell batch(es); \
390 the cell frame is anchored and does not move",
391 b.len(),
392 frame.len()
393 );
394 frame
395 })
396 }
397 (None, _) => None,
398 }
399}
400
401fn ensure_disjoint_roles(anchors: Option<&[usize]>, bulk: Option<&[usize]>) -> anyhow::Result<()> {
402 if let (Some(a), Some(b)) = (anchors, bulk) {
403 if let Some(shared) = a.iter().find(|x| b.contains(x)) {
404 anyhow::bail!(
405 "batch index {shared} is named as both an anchor and a bulk batch; \
406 the roles are mutually exclusive"
407 );
408 }
409 }
410 Ok(())
411}
412
413fn attach_observability(stat: &mut CollapsedStat, data_vec: &SparseIoVec) -> anyhow::Result<()> {
426 let Some(coverage) = data_vec.row_coverage_by_backend() else {
427 return Ok(());
428 };
429 let ncols = data_vec.num_columns();
430 let mut sources = Vec::with_capacity(ncols);
431 for c in 0..ncols {
432 match data_vec.column_source(c) {
433 Some(b) => sources.push(b),
434 None => {
435 warn!(
436 "panel observability skipped: column {c} merges several backends \
437 (column-union alignment)"
438 );
439 return Ok(());
440 }
441 }
442 }
443
444 let num_genes = stat.num_genes();
445 let num_samples = stat.num_samples();
446 let num_sources = data_vec.len();
447 let cell_to_group = data_vec.get_group_membership(0..ncols)?;
448
449 let mut count_bs = DMatrix::<f32>::zeros(num_sources, num_samples);
451 let batch_of = data_vec.get_batch_membership(0..ncols);
452 let num_batches = stat.num_batches();
453 let mut source_in_batch = vec![vec![false; num_batches]; num_sources];
454 for c in 0..ncols {
455 let s = cell_to_group[c];
456 if s < num_samples {
457 count_bs[(sources[c], s)] += data_vec.column_multiplicity(c);
458 }
459 if let Some(&b) = batch_of.get(c) {
460 if b < num_batches {
461 source_in_batch[sources[c]][b] = true;
462 }
463 }
464 }
465
466 let mut covered_rows: Vec<Vec<usize>> = Vec::with_capacity(coverage.len());
467 for cov in coverage.iter() {
468 anyhow::ensure!(
469 cov.len() == num_genes,
470 "row coverage has {} rows but the stat has {num_genes} genes",
471 cov.len(),
472 );
473 covered_rows.push((0..num_genes).filter(|&g| cov[g]).collect());
474 }
475 let mut size_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
478 if num_genes > 0 {
479 size_ds
480 .as_mut_slice()
481 .par_chunks_mut(num_genes)
482 .enumerate()
483 .for_each(|(s, size_s)| {
484 for (src, rows) in covered_rows.iter().enumerate() {
485 let mass = count_bs[(src, s)];
486 if mass == 0.0 {
487 continue;
488 }
489 for &g in rows {
490 size_s[g] += mass;
491 }
492 }
493 });
494 }
495
496 let mut mask_db = DMatrix::<f32>::zeros(num_genes, num_batches);
497 for (src, rows) in covered_rows.iter().enumerate() {
498 for (b, &used) in source_in_batch[src].iter().enumerate() {
499 if used {
500 for &g in rows {
501 mask_db[(g, b)] = 1.0;
502 }
503 }
504 }
505 }
506
507 info!(
508 "panel observability attached: {} of {} (gene, sample) entries below full size",
509 size_ds
510 .column_iter()
511 .enumerate()
512 .map(|(s, col)| col.iter().filter(|&&v| v < stat.size_s[s]).count())
513 .sum::<usize>(),
514 num_genes * num_samples,
515 );
516 stat.size_ds = Some(size_ds);
517 stat.obs_mask_db = (mask_db.iter().any(|&v| v == 0.0)).then_some(mask_db);
518 Ok(())
519}
520
521pub struct EmptyArg {}
522
523#[cfg(debug_assertions)]
524use log::debug;
525
526pub trait CollapsingOps {
532 fn collapse_columns(
544 &self,
545 knn_batches: Option<usize>,
546 knn_cells: Option<usize>,
547 reference_batch_names: Option<&[Box<str>]>,
548 num_opt_iter: Option<usize>,
549 ) -> anyhow::Result<CollapsedOut>;
550
551 fn build_hnsw_per_batch<T>(
559 &mut self,
560 proj_kn: &nalgebra::DMatrix<f32>,
561 col_to_batch: &[T],
562 ) -> anyhow::Result<()>
563 where
564 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
565
566 fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
567
568 fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
569
570 fn collect_matched_stat(
571 &self,
572 knn_batches: usize,
573 knn_cols: usize,
574 reference_indices: Option<&[usize]>,
575 stat: &mut CollapsedStat,
576 ) -> anyhow::Result<()>;
577}
578
579impl CollapsingOps for SparseIoVec {
580 fn build_hnsw_per_batch<T>(
581 &mut self,
582 proj_kn: &nalgebra::DMatrix<f32>,
583 col_to_batch: &[T],
584 ) -> anyhow::Result<()>
585 where
586 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
587 {
588 info!("creating batch-specific HNSW maps ...");
589 self.register_batches_dmatrix(proj_kn, col_to_batch)?;
590
591 info!(
592 "found {} columns across {} batches",
593 self.num_columns(),
594 self.num_batches()
595 );
596
597 Ok(())
598 }
599
600 fn collapse_columns(
601 &self,
602 knn_batches: Option<usize>,
603 knn_cells: Option<usize>,
604 reference_batch_names: Option<&[Box<str>]>,
605 num_opt_iter: Option<usize>,
606 ) -> anyhow::Result<CollapsedOut> {
607 let group_to_cols = self.take_grouped_columns().ok_or(anyhow::anyhow!(
608 "The columns were not assigned before. Call `assign_columns_to_groups`"
609 ))?;
610
611 let num_features = self.num_rows();
612 let num_groups = group_to_cols.len();
613 let num_batches = self.num_batches();
614
615 let mut stat = CollapsedStat::new(num_features, num_groups, num_batches);
616 info!("basic statistics across {} groups", num_groups);
617 self.collect_basic_stat(&mut stat)?;
618
619 if num_batches > 1 {
620 info!(
621 "batch-specific statistics across {} batches over {} samples",
622 num_batches, num_groups
623 );
624
625 let batch_name_map = self
626 .batch_name_map()
627 .ok_or(anyhow::anyhow!("unable to read batch names"))?;
628
629 let reference_indices = reference_batch_names.map(|x| {
630 x.iter()
631 .filter_map(|b| batch_name_map.get(b))
632 .copied()
633 .collect::<Vec<_>>()
634 });
635
636 if let Some(r) = reference_indices.as_ref() {
637 if r.is_empty() {
638 let ref_names = reference_batch_names
639 .unwrap()
640 .iter()
641 .map(|x| x.to_string())
642 .collect::<Vec<_>>()
643 .join(",");
644
645 let bat_names = self
646 .batch_names()
647 .unwrap()
648 .iter()
649 .map(|x| x.to_string())
650 .collect::<Vec<_>>()
651 .join(",");
652
653 warn!("{} vs. {}", ref_names, bat_names);
654
655 return Err(anyhow::anyhow!("no reference batch names matched!"));
656 }
657 }
658
659 self.collect_batch_stat(&mut stat)?;
660
661 info!(
662 "counterfactual inference across {} batches over {} samples",
663 num_batches, num_groups,
664 );
665
666 let knn_batches = knn_batches.unwrap_or(2);
667 let knn_cells = knn_cells.unwrap_or(DEFAULT_KNN);
668
669 self.collect_matched_stat(
670 knn_batches,
671 knn_cells,
672 reference_indices.as_deref(),
673 &mut stat,
674 )?;
675 } info!("optimizing the collapsed parameters...");
682 let (a0, b0) = (1_f32, 1_f32);
683 optimize(
684 &stat,
685 (a0, b0),
686 num_opt_iter.unwrap_or(DEFAULT_OPT_ITER),
687 "Optimizing",
688 CalibrateTarget::All,
689 false,
690 )
691 }
692
693 fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
694 self.visit_columns_by_group(&collect_basic_stat_visitor, &EmptyArg {}, stat)
695 }
696
697 fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
698 self.visit_columns_by_group(&collect_batch_stat_visitor, &EmptyArg {}, stat)
699 }
700
701 fn collect_matched_stat(
702 &self,
703 knn_batches: usize,
704 knn_cells: usize,
705 reference_indices: Option<&[usize]>,
706 stat: &mut CollapsedStat,
707 ) -> anyhow::Result<()> {
708 stat.anchor_batches = reference_indices.map(<[usize]>::to_vec).unwrap_or_default();
710 self.visit_columns_by_group(
711 &collect_matched_stat_visitor,
712 &KnnParams {
713 knn_batches,
714 knn_cells,
715 reference_indices,
716 },
717 stat,
718 )
719 }
720}
721
722pub trait MultilevelCollapsingOps {
723 type LevelOutput;
724
725 fn collapse_columns_multilevel<T>(
726 &mut self,
727 proj_kn: &DMatrix<f32>,
728 batch_membership: &[T],
729 params: &MultilevelParams,
730 ) -> anyhow::Result<Self::LevelOutput>
731 where
732 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
733
734 fn collapse_columns_multilevel_vec<T>(
735 &mut self,
736 proj_kn: &DMatrix<f32>,
737 batch_membership: &[T],
738 params: &MultilevelParams,
739 ) -> anyhow::Result<Vec<Self::LevelOutput>>
740 where
741 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
742}
743
744pub fn collapse_columns_multilevel_with_hierarchy<T>(
749 data_vec: &mut SparseIoVec,
750 proj_kn: &DMatrix<f32>,
751 batch_membership: &[T],
752 params: &MultilevelParams,
753) -> anyhow::Result<MultilevelCollapseOut>
754where
755 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
756{
757 let sort_dim = params.sort_dim;
758 let knn = params.knn_pb_samples;
759 let opt_iter = params.num_opt_iter;
760
761 data_vec.register_batch_membership(batch_membership);
762 let num_features = data_vec.num_rows();
763 let num_batches = data_vec.num_batches();
764 if num_batches >= 2 {
765 data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
766 }
767
768 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
769 let FinestCodes {
770 codes: fine_codes,
771 widths: level_dims,
772 tree: pb_tree,
773 strata_bits,
774 } = finest_codes(data_vec, proj_kn, &level_dims, params)?;
775 data_vec.assign_groups(&fine_codes, None);
776
777 let group_to_cols = data_vec
778 .take_grouped_columns()
779 .ok_or_else(|| anyhow::anyhow!("columns not assigned"))?
780 .clone();
781
782 let refine_params = ¶ms.refine;
783
784 let anchor_batches =
785 resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
786 let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
787 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
788 let summary_batches = anchor_batches.clone();
794 let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
795 let ctx = RefineCollectCtx {
796 fine_codes: &fine_codes,
797 group_to_cols_finest: &group_to_cols,
798 level_dims: &level_dims,
799 num_features,
800 num_batches,
801 knn,
802 opt_iter,
803 refine_params,
804 output_calibration: params.output_calibration,
805 anchor_batches: anchor_batches.as_deref(),
806 summary_batches: summary_batches.as_deref(),
807 bulk_batches: bulk_batches.as_deref(),
808 observe_panels: params.observe_panels,
809 keep_finest_stats: params.keep_finest_stats,
810 pb_tree: pb_tree.as_ref(),
811 cell_to_stratum: params.strata.as_deref(),
812 exclude_unmatched_from_delta: params.strata.is_some(),
813 strata_bits,
814 };
815 refine_and_collect_single_layer(data_vec, proj_kn, &ctx)
816}
817
818pub fn collapse_columns_multilevel_with_partition<T>(
834 data_vec: &mut SparseIoVec,
835 proj_kn: &DMatrix<f32>,
836 batch_membership: &[T],
837 params: &MultilevelParams,
838 cell_to_pb_per_level: &[Vec<usize>],
839) -> anyhow::Result<MultilevelCollapseOut>
840where
841 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
842{
843 let knn = params.knn_pb_samples;
844 let opt_iter = params.num_opt_iter;
845
846 data_vec.register_batch_membership(batch_membership);
847 let num_features = data_vec.num_rows();
848 let num_batches = data_vec.num_batches();
849 if num_batches >= 2 {
850 data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
851 }
852
853 let level_dims = compute_level_sort_dims(params.sort_dim, params.num_levels);
854 anyhow::ensure!(
855 cell_to_pb_per_level.len() == level_dims.len(),
856 "inherited cell_to_pb has {} levels but --num-levels is {}; \
857 pass --num-levels to match the source run",
858 cell_to_pb_per_level.len(),
859 level_dims.len(),
860 );
861 let inherited_finest = &cell_to_pb_per_level[0];
869 anyhow::ensure!(
870 inherited_finest.len() == proj_kn.ncols(),
871 "inherited cell_to_pb finest level has {} cells, data has {}",
872 inherited_finest.len(),
873 proj_kn.ncols()
874 );
875 let k_inherited = inherited_finest.iter().copied().max().map_or(0, |m| m + 1);
876 data_vec.assign_groups(&pad_numeric_labels(inherited_finest, k_inherited), None);
877
878 let anchor_batches =
879 resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
880 let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
881 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
882 let summary_batches = anchor_batches.clone();
888 let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
889 let pb_samples = build_pb_samples(
890 data_vec,
891 proj_kn,
892 num_features,
893 summary_batches.as_deref().unwrap_or(&[]),
894 bulk_batches.as_deref().unwrap_or(&[]),
895 params.strata.as_deref(),
896 )?;
897 let num_pb = pb_samples.layout.cell_counts.len();
898 let ncols = proj_kn.ncols();
899 let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
900
901 let num_levels = level_dims.len();
906 for (lvl_idx, lvl) in cell_to_pb_per_level.iter().enumerate() {
907 anyhow::ensure!(
908 lvl.len() == ncols,
909 "inherited cell_to_pb level {} has {} cells, data has {}",
910 lvl_idx,
911 lvl.len(),
912 ncols
913 );
914 }
915 let (pbsamp_to_group, num_groups_per_level) =
916 nested_levels_by_vote(&pb_sample_to_cells, cell_to_pb_per_level);
917 let refined = split_anchored_finest_groups(
918 crate::alg::refine_multilevel::RefinedAssignment {
919 pbsamp_to_group,
920 num_groups_per_level,
921 },
922 &pb_samples.layout,
923 );
924
925 info!(
926 "Inherited partition: {} cells, {} pb-samples, finest k={} (skipped BBKNN + DC-SBM refinement)",
927 ncols, num_pb, refined.num_groups_per_level[0]
928 );
929
930 let k_finest = refined.num_groups_per_level[0];
934 let mut cell_to_group_finest = vec![0usize; ncols];
935 for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
936 let g = refined.pbsamp_to_group[0][pbsamp];
937 for &c in cells {
938 cell_to_group_finest[c] = g;
939 }
940 }
941 let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
942 data_vec.assign_groups(&finest_str, None);
943 debug_assert_eq!(data_vec.num_groups(), k_finest);
944
945 let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
946 fine_stat.exclude_unmatched_from_delta = params.strata.is_some();
947 info!(
948 "Collecting observed stats over {} groups × {} batches from {} pb-samples ...",
949 k_finest, num_batches, num_pb
950 );
951 collect_observed_stat_from_pb_samples(
952 data_vec,
953 &pb_samples.layout,
954 &pb_samples.gene_sums,
955 &refined.pbsamp_to_group[0],
956 &mut fine_stat,
957 )?;
958 if num_batches >= 2 {
959 let batch_knn = data_vec
960 .batch_knn_lookup()
961 .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
962 info!(
963 "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
964 knn, num_pb
965 );
966 let matches = per_batch_sc_neighbors(
967 &pb_samples.layout,
968 batch_knn.as_slice(),
969 knn,
970 anchor_batches.as_deref(),
971 )?;
972 collect_matched_stat_coarse(
973 &pb_samples.layout,
974 &pb_samples.gene_sums,
975 &refined.pbsamp_to_group[0],
976 &matches,
977 anchor_batches.as_deref(),
978 &mut fine_stat,
979 )?;
980 }
981 if params.observe_panels {
982 attach_observability(&mut fine_stat, data_vec)?;
983 }
984
985 let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
986 info!(
987 "Level 1/{}: inherited k={} (finest; {} cells)",
988 num_levels, k_finest, ncols
989 );
990 let finest_out = optimize(
991 &fine_stat,
992 (1.0, 1.0),
993 opt_iter,
994 &format!("Inherit L1/{}", num_levels),
995 CalibrateTarget::All,
996 false,
997 )?;
998 results.push(finest_out);
999
1000 let mut prev_stat = fine_stat;
1001 for level in 1..num_levels {
1002 let k_prev = refined.num_groups_per_level[level - 1];
1003 let k_level = refined.num_groups_per_level[level];
1004 let fine_to_coarse = fine_to_coarse_from_refined(
1005 &refined.pbsamp_to_group[level - 1],
1006 &refined.pbsamp_to_group[level],
1007 k_prev,
1008 );
1009 let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
1010 info!(
1011 "Level {}/{}: inherited k={} (merged from {})",
1012 level + 1,
1013 num_levels,
1014 k_level,
1015 k_prev
1016 );
1017 let level_opt_iter = (opt_iter / 2).max(10);
1018 let out = optimize(
1019 &coarse_stat,
1020 (1.0, 1.0),
1021 level_opt_iter,
1022 &format!("Inherit L{}/{}", level + 1, num_levels),
1023 CalibrateTarget::All,
1024 false,
1025 )?;
1026 results.push(out);
1027 prev_stat = coarse_stat;
1028 }
1029 info!(
1030 "Fitted pseudobulk posteriors for {} inherited levels: k = {:?} (finest first)",
1031 num_levels, refined.num_groups_per_level
1032 );
1033
1034 let mut cell_to_pb_per_level_out: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
1039 for level in 0..num_levels {
1040 let mut c2g = vec![0usize; ncols];
1041 for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
1042 let g = refined.pbsamp_to_group[level][pbsamp];
1043 for &c in cells {
1044 c2g[c] = g;
1045 }
1046 }
1047 cell_to_pb_per_level_out.push(c2g);
1048 }
1049
1050 Ok(MultilevelCollapseOut {
1051 levels: results,
1052 cell_to_pb_per_level: cell_to_pb_per_level_out,
1053 pb_tree: None,
1054 })
1055}
1056
1057fn nested_levels_by_vote(
1066 pb_sample_to_cells: &[Vec<usize>],
1067 cell_to_group_per_level: &[Vec<usize>],
1068) -> (Vec<Vec<usize>>, Vec<usize>) {
1069 use rustc_hash::FxHashMap;
1070 let mut pbsamp_to_group: Vec<Vec<usize>> = Vec::with_capacity(cell_to_group_per_level.len());
1071 let mut num_groups_per_level: Vec<usize> = Vec::with_capacity(cell_to_group_per_level.len());
1072 for lvl in cell_to_group_per_level {
1073 let voted: Vec<usize> = match pbsamp_to_group.last() {
1074 None => pb_sample_to_cells
1075 .iter()
1076 .map(|cells| modal_group(cells, lvl))
1077 .collect(),
1078 Some(finer) => {
1079 let k_finer = num_groups_per_level.last().copied().unwrap_or(0);
1080 let mut votes: Vec<FxHashMap<usize, usize>> = vec![FxHashMap::default(); k_finer];
1081 for (cells, &g) in pb_sample_to_cells.iter().zip(finer) {
1082 for &c in cells {
1083 *votes[g].entry(lvl[c]).or_insert(0) += 1;
1084 }
1085 }
1086 let winner: Vec<usize> = votes
1087 .iter()
1088 .map(|v| {
1089 v.iter()
1090 .max_by_key(|&(&label, &n)| (n, std::cmp::Reverse(label)))
1091 .map_or(0, |(&label, _)| label)
1092 })
1093 .collect();
1094 finer.iter().map(|&g| winner[g]).collect()
1095 }
1096 };
1097 let (compact, k) = crate::alg::refine_multilevel::compact_labels(&voted);
1098 num_groups_per_level.push(k);
1099 pbsamp_to_group.push(compact);
1100 }
1101 (pbsamp_to_group, num_groups_per_level)
1102}
1103
1104fn modal_group(cells: &[usize], lvl: &[usize]) -> usize {
1108 match cells {
1109 [] => 0,
1110 [c] => lvl[*c],
1111 _ => {
1112 use rustc_hash::FxHashMap;
1113 let mut counts: FxHashMap<usize, usize> = FxHashMap::default();
1114 for &c in cells {
1115 *counts.entry(lvl[c]).or_insert(0) += 1;
1116 }
1117 counts
1118 .into_iter()
1119 .max_by_key(|&(_, n)| n)
1120 .map(|(g, _)| g)
1121 .unwrap_or(0)
1122 }
1123 }
1124}
1125
1126impl MultilevelCollapsingOps for SparseIoVec {
1127 type LevelOutput = CollapsedOut;
1128
1129 fn collapse_columns_multilevel<T>(
1130 &mut self,
1131 proj_kn: &DMatrix<f32>,
1132 batch_membership: &[T],
1133 params: &MultilevelParams,
1134 ) -> anyhow::Result<CollapsedOut>
1135 where
1136 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1137 {
1138 let mut results =
1139 self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1140 if results.is_empty() {
1141 return Err(anyhow::anyhow!("no levels processed"));
1142 }
1143 Ok(results.remove(0))
1144 }
1145
1146 fn collapse_columns_multilevel_vec<T>(
1147 &mut self,
1148 proj_kn: &DMatrix<f32>,
1149 batch_membership: &[T],
1150 params: &MultilevelParams,
1151 ) -> anyhow::Result<Vec<CollapsedOut>>
1152 where
1153 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1154 {
1155 let sort_dim = params.sort_dim;
1156 let knn = params.knn_pb_samples;
1157 let opt_iter = params.num_opt_iter;
1158
1159 self.register_batch_membership(batch_membership);
1160 let num_features = self.num_rows();
1161 let num_batches = self.num_batches();
1162 if num_batches >= 2 {
1163 self.build_hnsw_per_batch(proj_kn, batch_membership)?;
1164 }
1165
1166 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1168
1169 info!(
1170 "Multi-level collapsing (fine→coarse): {} levels, sort_dims={:?}, {} batches",
1171 level_dims.len(),
1172 level_dims,
1173 num_batches,
1174 );
1175
1176 let FinestCodes {
1178 codes: fine_codes,
1179 widths: level_dims,
1180 tree: pb_tree,
1181 strata_bits,
1182 } = finest_codes(self, proj_kn, &level_dims, params)?;
1183
1184 self.assign_groups(&fine_codes, None);
1186
1187 let group_to_cols = self
1188 .take_grouped_columns()
1189 .ok_or(anyhow::anyhow!("columns not assigned"))?
1190 .clone();
1191
1192 let refine_params = ¶ms.refine;
1197 let anchor_batches =
1198 resolve_named_batches(self, "anchor", params.anchor_batches.as_deref())?;
1199 let bulk_batches = resolve_named_batches(self, "bulk", params.bulk_batches.as_deref())?;
1200 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
1201 let summary_batches = anchor_batches.clone();
1202 let anchor_batches = greedy_anchor_for_bulk(self, anchor_batches, bulk_batches.as_deref());
1203 let ctx = RefineCollectCtx {
1204 fine_codes: &fine_codes,
1205 group_to_cols_finest: &group_to_cols,
1206 level_dims: &level_dims,
1207 num_features,
1208 num_batches,
1209 knn,
1210 opt_iter,
1211 refine_params,
1212 output_calibration: params.output_calibration,
1213 anchor_batches: anchor_batches.as_deref(),
1214 summary_batches: summary_batches.as_deref(),
1215 bulk_batches: bulk_batches.as_deref(),
1216 observe_panels: params.observe_panels,
1217 keep_finest_stats: params.keep_finest_stats,
1218 pb_tree: pb_tree.as_ref(),
1219 cell_to_stratum: params.strata.as_deref(),
1220 exclude_unmatched_from_delta: params.strata.is_some(),
1221 strata_bits,
1222 };
1223 refine_and_collect_single_layer(self, proj_kn, &ctx).map(|out| out.levels)
1224 }
1225}
1226
1227impl MultilevelCollapsingOps for SparseIoStack {
1228 type LevelOutput = Vec<CollapsedOut>;
1229
1230 fn collapse_columns_multilevel<T>(
1231 &mut self,
1232 proj_kn: &DMatrix<f32>,
1233 batch_membership: &[T],
1234 params: &MultilevelParams,
1235 ) -> anyhow::Result<Vec<CollapsedOut>>
1236 where
1237 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1238 {
1239 let mut results =
1240 self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1241 if results.is_empty() {
1242 return Err(anyhow::anyhow!("no levels processed"));
1243 }
1244 Ok(results.remove(0))
1245 }
1246
1247 fn collapse_columns_multilevel_vec<T>(
1248 &mut self,
1249 proj_kn: &DMatrix<f32>,
1250 batch_membership: &[T],
1251 params: &MultilevelParams,
1252 ) -> anyhow::Result<Vec<Vec<CollapsedOut>>>
1253 where
1254 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1255 {
1256 Ok(
1257 collapse_stack_multilevel_with_hierarchy(self, proj_kn, batch_membership, params)?
1258 .levels,
1259 )
1260 }
1261}
1262
1263pub fn collapse_stack_multilevel_with_hierarchy<T>(
1269 stack: &mut SparseIoStack,
1270 proj_kn: &DMatrix<f32>,
1271 batch_membership: &[T],
1272 params: &MultilevelParams,
1273) -> anyhow::Result<StackCollapseOut>
1274where
1275 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1276{
1277 let num_layers = stack.num_types();
1278 if num_layers == 0 {
1279 return Err(anyhow::anyhow!("empty SparseIoStack"));
1280 }
1281
1282 let sort_dim = params.sort_dim;
1283 let knn = params.knn_pb_samples;
1284 let opt_iter = params.num_opt_iter;
1285
1286 stack.register_batch_membership(batch_membership);
1287
1288 let num_batches = stack.stack[0].num_batches();
1290 if num_batches >= 2 {
1294 for layer in stack.stack.iter_mut() {
1295 layer.build_hnsw_per_batch(proj_kn, batch_membership)?;
1296 }
1297 }
1298
1299 let ncols = proj_kn.ncols();
1300 let refine_params = ¶ms.refine;
1301 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1302 let finest_dim = level_dims[0];
1303 let kk = proj_kn.nrows().min(finest_dim).min(ncols);
1304 let codes = binary_sort_columns(proj_kn, kk)?;
1305 let (fine_codes, level_dims, strata_bits) = match params.strata.as_deref() {
1306 Some(strata) => {
1307 let (c, d, s) = apply_strata_to_codes(&codes, &level_dims, strata)?;
1308 (c, d, s)
1309 }
1310 None => (codes, level_dims, 0),
1311 };
1312 for layer in stack.stack.iter_mut() {
1313 layer.assign_groups(&fine_codes, None);
1314 }
1315 let group_to_cols = stack.stack[0]
1316 .take_grouped_columns()
1317 .ok_or(anyhow::anyhow!("columns not assigned"))?
1318 .clone();
1319 let num_features = stack.stack[0].num_rows();
1320 anyhow::ensure!(
1321 params.anchor_batches.is_none() && params.bulk_batches.is_none(),
1322 "anchor_batches / bulk_batches are not supported on the stack path — nothing \
1323 produces a carried reference or bulk input for stacked modalities"
1324 );
1325 let ctx = RefineCollectCtx {
1326 fine_codes: &fine_codes,
1327 group_to_cols_finest: &group_to_cols,
1328 level_dims: &level_dims,
1329 num_features,
1330 num_batches,
1331 knn,
1332 opt_iter,
1333 refine_params,
1334 output_calibration: params.output_calibration,
1335 anchor_batches: None,
1336 summary_batches: None,
1337 bulk_batches: None,
1338 observe_panels: false,
1339 keep_finest_stats: params.keep_finest_stats,
1340 pb_tree: None,
1341 cell_to_stratum: params.strata.as_deref(),
1342 exclude_unmatched_from_delta: params.strata.is_some(),
1343 strata_bits,
1344 };
1345 refine_and_collect_stack(stack, proj_kn, &ctx)
1346}
1347
1348#[cfg(test)]
1349#[path = "tests.rs"]
1350mod tests;