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