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,
40 collect_pb_sample_gene_sums, per_batch_sc_neighbors,
41};
42pub use pb_samples::{PbSampleCollection, PbSampleLayout};
43pub(crate) use pb_samples::bbknn_match_one_pbsamp;
45mod reassign_cells;
46pub use reassign_cells::ReassignCellsParams;
47mod pb_tree;
48pub use pb_tree::{BelowEdge, ContrastGene, PbTree, PbTreeParams, RootRecord, SplitRecord};
49mod refine;
50pub use refine::StackCollapseOut;
51use refine::{
52 compute_level_sort_dims, fine_to_coarse_from_refined, pad_numeric_labels,
53 refine_and_collect_single_layer, refine_and_collect_stack, split_anchored_finest_groups,
54 RefineCollectCtx,
55};
56mod stats;
57use stats::{
58 collect_basic_stat_visitor, collect_batch_stat_visitor, collect_matched_stat_coarse,
59 collect_matched_stat_visitor, merge_stat, optimize, 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 size_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
467 for (src, cov) in coverage.iter().enumerate() {
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 for (g, &covered) in cov.iter().enumerate() {
474 if covered {
475 for s in 0..num_samples {
476 size_ds[(g, s)] += count_bs[(src, s)];
477 }
478 }
479 }
480 }
481
482 let mut mask_db = DMatrix::<f32>::zeros(num_genes, num_batches);
483 for (src, cov) in coverage.iter().enumerate() {
484 for (b, &used) in source_in_batch[src].iter().enumerate() {
485 if used {
486 for (g, &covered) in cov.iter().enumerate() {
487 if covered {
488 mask_db[(g, b)] = 1.0;
489 }
490 }
491 }
492 }
493 }
494
495 info!(
496 "panel observability attached: {} of {} (gene, sample) entries below full size",
497 size_ds
498 .column_iter()
499 .enumerate()
500 .map(|(s, col)| col.iter().filter(|&&v| v < stat.size_s[s]).count())
501 .sum::<usize>(),
502 num_genes * num_samples,
503 );
504 stat.size_ds = Some(size_ds);
505 stat.obs_mask_db = (mask_db.iter().any(|&v| v == 0.0)).then_some(mask_db);
506 Ok(())
507}
508
509pub struct EmptyArg {}
510
511#[cfg(debug_assertions)]
512use log::debug;
513
514pub trait CollapsingOps {
520 fn collapse_columns(
532 &self,
533 knn_batches: Option<usize>,
534 knn_cells: Option<usize>,
535 reference_batch_names: Option<&[Box<str>]>,
536 num_opt_iter: Option<usize>,
537 ) -> anyhow::Result<CollapsedOut>;
538
539 fn build_hnsw_per_batch<T>(
547 &mut self,
548 proj_kn: &nalgebra::DMatrix<f32>,
549 col_to_batch: &[T],
550 ) -> anyhow::Result<()>
551 where
552 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
553
554 fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
555
556 fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()>;
557
558 fn collect_matched_stat(
559 &self,
560 knn_batches: usize,
561 knn_cols: usize,
562 reference_indices: Option<&[usize]>,
563 stat: &mut CollapsedStat,
564 ) -> anyhow::Result<()>;
565}
566
567impl CollapsingOps for SparseIoVec {
568 fn build_hnsw_per_batch<T>(
569 &mut self,
570 proj_kn: &nalgebra::DMatrix<f32>,
571 col_to_batch: &[T],
572 ) -> anyhow::Result<()>
573 where
574 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
575 {
576 info!("creating batch-specific HNSW maps ...");
577 self.register_batches_dmatrix(proj_kn, col_to_batch)?;
578
579 info!(
580 "found {} columns across {} batches",
581 self.num_columns(),
582 self.num_batches()
583 );
584
585 Ok(())
586 }
587
588 fn collapse_columns(
589 &self,
590 knn_batches: Option<usize>,
591 knn_cells: Option<usize>,
592 reference_batch_names: Option<&[Box<str>]>,
593 num_opt_iter: Option<usize>,
594 ) -> anyhow::Result<CollapsedOut> {
595 let group_to_cols = self.take_grouped_columns().ok_or(anyhow::anyhow!(
596 "The columns were not assigned before. Call `assign_columns_to_groups`"
597 ))?;
598
599 let num_features = self.num_rows();
600 let num_groups = group_to_cols.len();
601 let num_batches = self.num_batches();
602
603 let mut stat = CollapsedStat::new(num_features, num_groups, num_batches);
604 info!("basic statistics across {} groups", num_groups);
605 self.collect_basic_stat(&mut stat)?;
606
607 if num_batches > 1 {
608 info!(
609 "batch-specific statistics across {} batches over {} samples",
610 num_batches, num_groups
611 );
612
613 let batch_name_map = self
614 .batch_name_map()
615 .ok_or(anyhow::anyhow!("unable to read batch names"))?;
616
617 let reference_indices = reference_batch_names.map(|x| {
618 x.iter()
619 .filter_map(|b| batch_name_map.get(b))
620 .copied()
621 .collect::<Vec<_>>()
622 });
623
624 if let Some(r) = reference_indices.as_ref() {
625 if r.is_empty() {
626 let ref_names = reference_batch_names
627 .unwrap()
628 .iter()
629 .map(|x| x.to_string())
630 .collect::<Vec<_>>()
631 .join(",");
632
633 let bat_names = self
634 .batch_names()
635 .unwrap()
636 .iter()
637 .map(|x| x.to_string())
638 .collect::<Vec<_>>()
639 .join(",");
640
641 warn!("{} vs. {}", ref_names, bat_names);
642
643 return Err(anyhow::anyhow!("no reference batch names matched!"));
644 }
645 }
646
647 self.collect_batch_stat(&mut stat)?;
648
649 info!(
650 "counterfactual inference across {} batches over {} samples",
651 num_batches, num_groups,
652 );
653
654 let knn_batches = knn_batches.unwrap_or(2);
655 let knn_cells = knn_cells.unwrap_or(DEFAULT_KNN);
656
657 self.collect_matched_stat(
658 knn_batches,
659 knn_cells,
660 reference_indices.as_deref(),
661 &mut stat,
662 )?;
663 } info!("optimizing the collapsed parameters...");
670 let (a0, b0) = (1_f32, 1_f32);
671 optimize(
672 &stat,
673 (a0, b0),
674 num_opt_iter.unwrap_or(DEFAULT_OPT_ITER),
675 "Optimizing",
676 CalibrateTarget::All,
677 false,
678 )
679 }
680
681 fn collect_basic_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
682 self.visit_columns_by_group(&collect_basic_stat_visitor, &EmptyArg {}, stat)
683 }
684
685 fn collect_batch_stat(&self, stat: &mut CollapsedStat) -> anyhow::Result<()> {
686 self.visit_columns_by_group(&collect_batch_stat_visitor, &EmptyArg {}, stat)
687 }
688
689 fn collect_matched_stat(
690 &self,
691 knn_batches: usize,
692 knn_cells: usize,
693 reference_indices: Option<&[usize]>,
694 stat: &mut CollapsedStat,
695 ) -> anyhow::Result<()> {
696 stat.anchor_batches = reference_indices.map(<[usize]>::to_vec).unwrap_or_default();
698 self.visit_columns_by_group(
699 &collect_matched_stat_visitor,
700 &KnnParams {
701 knn_batches,
702 knn_cells,
703 reference_indices,
704 },
705 stat,
706 )
707 }
708}
709
710pub trait MultilevelCollapsingOps {
711 type LevelOutput;
712
713 fn collapse_columns_multilevel<T>(
714 &mut self,
715 proj_kn: &DMatrix<f32>,
716 batch_membership: &[T],
717 params: &MultilevelParams,
718 ) -> anyhow::Result<Self::LevelOutput>
719 where
720 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
721
722 fn collapse_columns_multilevel_vec<T>(
723 &mut self,
724 proj_kn: &DMatrix<f32>,
725 batch_membership: &[T],
726 params: &MultilevelParams,
727 ) -> anyhow::Result<Vec<Self::LevelOutput>>
728 where
729 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString;
730}
731
732pub fn collapse_columns_multilevel_with_hierarchy<T>(
737 data_vec: &mut SparseIoVec,
738 proj_kn: &DMatrix<f32>,
739 batch_membership: &[T],
740 params: &MultilevelParams,
741) -> anyhow::Result<MultilevelCollapseOut>
742where
743 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
744{
745 let sort_dim = params.sort_dim;
746 let knn = params.knn_pb_samples;
747 let opt_iter = params.num_opt_iter;
748
749 data_vec.register_batch_membership(batch_membership);
750 let num_features = data_vec.num_rows();
751 let num_batches = data_vec.num_batches();
752 if num_batches >= 2 {
753 data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
754 }
755
756 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
757 let FinestCodes {
758 codes: fine_codes,
759 widths: level_dims,
760 tree: pb_tree,
761 strata_bits,
762 } = finest_codes(data_vec, proj_kn, &level_dims, params)?;
763 data_vec.assign_groups(&fine_codes, None);
764
765 let group_to_cols = data_vec
766 .take_grouped_columns()
767 .ok_or_else(|| anyhow::anyhow!("columns not assigned"))?
768 .clone();
769
770 let refine_params = ¶ms.refine;
771
772 let anchor_batches =
773 resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
774 let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
775 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
776 let summary_batches = anchor_batches.clone();
782 let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
783 let ctx = RefineCollectCtx {
784 fine_codes: &fine_codes,
785 group_to_cols_finest: &group_to_cols,
786 level_dims: &level_dims,
787 num_features,
788 num_batches,
789 knn,
790 opt_iter,
791 refine_params,
792 output_calibration: params.output_calibration,
793 anchor_batches: anchor_batches.as_deref(),
794 summary_batches: summary_batches.as_deref(),
795 bulk_batches: bulk_batches.as_deref(),
796 observe_panels: params.observe_panels,
797 keep_finest_stats: params.keep_finest_stats,
798 pb_tree: pb_tree.as_ref(),
799 cell_to_stratum: params.strata.as_deref(),
800 exclude_unmatched_from_delta: params.strata.is_some(),
801 strata_bits,
802 };
803 refine_and_collect_single_layer(data_vec, proj_kn, &ctx)
804}
805
806pub fn collapse_columns_multilevel_with_partition<T>(
820 data_vec: &mut SparseIoVec,
821 proj_kn: &DMatrix<f32>,
822 batch_membership: &[T],
823 params: &MultilevelParams,
824 cell_to_pb_per_level: &[Vec<usize>],
825) -> anyhow::Result<MultilevelCollapseOut>
826where
827 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
828{
829 let knn = params.knn_pb_samples;
830 let opt_iter = params.num_opt_iter;
831
832 data_vec.register_batch_membership(batch_membership);
833 let num_features = data_vec.num_rows();
834 let num_batches = data_vec.num_batches();
835 if num_batches >= 2 {
836 data_vec.build_hnsw_per_batch(proj_kn, batch_membership)?;
837 }
838
839 let level_dims = compute_level_sort_dims(params.sort_dim, params.num_levels);
840 anyhow::ensure!(
841 cell_to_pb_per_level.len() == level_dims.len(),
842 "inherited cell_to_pb has {} levels but --num-levels is {}; \
843 pass --num-levels to match the source run",
844 cell_to_pb_per_level.len(),
845 level_dims.len(),
846 );
847 let inherited_finest = &cell_to_pb_per_level[0];
855 anyhow::ensure!(
856 inherited_finest.len() == proj_kn.ncols(),
857 "inherited cell_to_pb finest level has {} cells, data has {}",
858 inherited_finest.len(),
859 proj_kn.ncols()
860 );
861 let k_inherited = inherited_finest.iter().copied().max().map_or(0, |m| m + 1);
862 data_vec.assign_groups(&pad_numeric_labels(inherited_finest, k_inherited), None);
863
864 let anchor_batches =
865 resolve_named_batches(data_vec, "anchor", params.anchor_batches.as_deref())?;
866 let bulk_batches = resolve_named_batches(data_vec, "bulk", params.bulk_batches.as_deref())?;
867 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
868 let summary_batches = anchor_batches.clone();
874 let anchor_batches = greedy_anchor_for_bulk(data_vec, anchor_batches, bulk_batches.as_deref());
875 let pb_samples = build_pb_samples(
876 data_vec,
877 proj_kn,
878 num_features,
879 summary_batches.as_deref().unwrap_or(&[]),
880 bulk_batches.as_deref().unwrap_or(&[]),
881 params.strata.as_deref(),
882 )?;
883 let num_pb = pb_samples.layout.cell_counts.len();
884 let ncols = proj_kn.ncols();
885 let pb_sample_to_cells = build_pb_sample_to_cells(&pb_samples.layout);
886
887 let num_levels = level_dims.len();
892 let mut pbsamp_to_group: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
893 let mut num_groups_per_level: Vec<usize> = Vec::with_capacity(num_levels);
894 for (lvl_idx, lvl) in cell_to_pb_per_level.iter().enumerate() {
895 anyhow::ensure!(
896 lvl.len() == ncols,
897 "inherited cell_to_pb level {} has {} cells, data has {}",
898 lvl_idx,
899 lvl.len(),
900 ncols
901 );
902 let mut p2g: Vec<usize> = Vec::with_capacity(num_pb);
903 for cells in &pb_sample_to_cells {
904 p2g.push(modal_group(cells, lvl));
905 }
906 let (compact, k) = crate::alg::refine_multilevel::compact_labels(&p2g);
907 num_groups_per_level.push(k);
908 pbsamp_to_group.push(compact);
909 }
910 let refined = split_anchored_finest_groups(
911 crate::alg::refine_multilevel::RefinedAssignment {
912 pbsamp_to_group,
913 num_groups_per_level,
914 },
915 &pb_samples.layout,
916 );
917
918 info!(
919 "Inherited partition: {} cells, {} pb-samples, finest k={} (skipped BBKNN + DC-SBM refinement)",
920 ncols, num_pb, refined.num_groups_per_level[0]
921 );
922
923 let k_finest = refined.num_groups_per_level[0];
927 let mut cell_to_group_finest = vec![0usize; ncols];
928 for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
929 let g = refined.pbsamp_to_group[0][pbsamp];
930 for &c in cells {
931 cell_to_group_finest[c] = g;
932 }
933 }
934 let finest_str = pad_numeric_labels(&cell_to_group_finest, k_finest);
935 data_vec.assign_groups(&finest_str, None);
936 debug_assert_eq!(data_vec.num_groups(), k_finest);
937
938 let mut fine_stat = CollapsedStat::new(num_features, k_finest, num_batches);
939 fine_stat.exclude_unmatched_from_delta = params.strata.is_some();
940 info!("Collecting basic stats over {} groups ...", k_finest);
941 data_vec.collect_basic_stat(&mut fine_stat)?;
942 if num_batches >= 2 {
943 info!(
944 "Collecting per-batch stats over {} groups × {} batches ...",
945 k_finest, num_batches
946 );
947 data_vec.collect_batch_stat(&mut fine_stat)?;
948 let batch_knn = data_vec
949 .batch_knn_lookup()
950 .ok_or_else(|| anyhow::anyhow!("batch_knn_lookup not built"))?;
951 info!(
952 "Collecting cross-batch matched stats (knn={}) over {} pb-samples ...",
953 knn, num_pb
954 );
955 collect_matched_stat_coarse(
956 &pb_samples.layout,
957 &pb_samples.gene_sums,
958 &refined.pbsamp_to_group[0],
959 batch_knn.as_slice(),
960 knn,
961 anchor_batches.as_deref(),
962 &mut fine_stat,
963 )?;
964 }
965 if params.observe_panels {
966 attach_observability(&mut fine_stat, data_vec)?;
967 }
968
969 let mut results: Vec<CollapsedOut> = Vec::with_capacity(num_levels);
970 info!(
971 "Level 1/{}: inherited k={} (finest; {} cells)",
972 num_levels, k_finest, ncols
973 );
974 let finest_out = optimize(
975 &fine_stat,
976 (1.0, 1.0),
977 opt_iter,
978 &format!("Inherit L1/{}", num_levels),
979 CalibrateTarget::All,
980 false,
981 )?;
982 results.push(finest_out);
983
984 let mut prev_stat = fine_stat;
985 for level in 1..num_levels {
986 let k_prev = refined.num_groups_per_level[level - 1];
987 let k_level = refined.num_groups_per_level[level];
988 let fine_to_coarse = fine_to_coarse_from_refined(
989 &refined.pbsamp_to_group[level - 1],
990 &refined.pbsamp_to_group[level],
991 k_prev,
992 );
993 let coarse_stat = merge_stat(&prev_stat, &fine_to_coarse, k_level);
994 info!(
995 "Level {}/{}: inherited k={} (merged from {})",
996 level + 1,
997 num_levels,
998 k_level,
999 k_prev
1000 );
1001 let level_opt_iter = (opt_iter / 2).max(10);
1002 let out = optimize(
1003 &coarse_stat,
1004 (1.0, 1.0),
1005 level_opt_iter,
1006 &format!("Inherit L{}/{}", level + 1, num_levels),
1007 CalibrateTarget::All,
1008 false,
1009 )?;
1010 results.push(out);
1011 prev_stat = coarse_stat;
1012 }
1013 info!(
1014 "Fitted pseudobulk posteriors for {} inherited levels: k = {:?} (finest first)",
1015 num_levels, refined.num_groups_per_level
1016 );
1017
1018 let mut cell_to_pb_per_level_out: Vec<Vec<usize>> = Vec::with_capacity(num_levels);
1023 for level in 0..num_levels {
1024 let mut c2g = vec![0usize; ncols];
1025 for (pbsamp, cells) in pb_sample_to_cells.iter().enumerate() {
1026 let g = refined.pbsamp_to_group[level][pbsamp];
1027 for &c in cells {
1028 c2g[c] = g;
1029 }
1030 }
1031 cell_to_pb_per_level_out.push(c2g);
1032 }
1033
1034 Ok(MultilevelCollapseOut {
1035 levels: results,
1036 cell_to_pb_per_level: cell_to_pb_per_level_out,
1037 pb_tree: None,
1038 })
1039}
1040
1041fn modal_group(cells: &[usize], lvl: &[usize]) -> usize {
1045 match cells {
1046 [] => 0,
1047 [c] => lvl[*c],
1048 _ => {
1049 use rustc_hash::FxHashMap;
1050 let mut counts: FxHashMap<usize, usize> = FxHashMap::default();
1051 for &c in cells {
1052 *counts.entry(lvl[c]).or_insert(0) += 1;
1053 }
1054 counts
1055 .into_iter()
1056 .max_by_key(|&(_, n)| n)
1057 .map(|(g, _)| g)
1058 .unwrap_or(0)
1059 }
1060 }
1061}
1062
1063impl MultilevelCollapsingOps for SparseIoVec {
1064 type LevelOutput = CollapsedOut;
1065
1066 fn collapse_columns_multilevel<T>(
1067 &mut self,
1068 proj_kn: &DMatrix<f32>,
1069 batch_membership: &[T],
1070 params: &MultilevelParams,
1071 ) -> anyhow::Result<CollapsedOut>
1072 where
1073 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1074 {
1075 let mut results =
1076 self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1077 if results.is_empty() {
1078 return Err(anyhow::anyhow!("no levels processed"));
1079 }
1080 Ok(results.remove(0))
1081 }
1082
1083 fn collapse_columns_multilevel_vec<T>(
1084 &mut self,
1085 proj_kn: &DMatrix<f32>,
1086 batch_membership: &[T],
1087 params: &MultilevelParams,
1088 ) -> anyhow::Result<Vec<CollapsedOut>>
1089 where
1090 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1091 {
1092 let sort_dim = params.sort_dim;
1093 let knn = params.knn_pb_samples;
1094 let opt_iter = params.num_opt_iter;
1095
1096 self.register_batch_membership(batch_membership);
1097 let num_features = self.num_rows();
1098 let num_batches = self.num_batches();
1099 if num_batches >= 2 {
1100 self.build_hnsw_per_batch(proj_kn, batch_membership)?;
1101 }
1102
1103 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1105
1106 info!(
1107 "Multi-level collapsing (fine→coarse): {} levels, sort_dims={:?}, {} batches",
1108 level_dims.len(),
1109 level_dims,
1110 num_batches,
1111 );
1112
1113 let FinestCodes {
1115 codes: fine_codes,
1116 widths: level_dims,
1117 tree: pb_tree,
1118 strata_bits,
1119 } = finest_codes(self, proj_kn, &level_dims, params)?;
1120
1121 self.assign_groups(&fine_codes, None);
1123
1124 let group_to_cols = self
1125 .take_grouped_columns()
1126 .ok_or(anyhow::anyhow!("columns not assigned"))?
1127 .clone();
1128
1129 let refine_params = ¶ms.refine;
1134 let anchor_batches =
1135 resolve_named_batches(self, "anchor", params.anchor_batches.as_deref())?;
1136 let bulk_batches = resolve_named_batches(self, "bulk", params.bulk_batches.as_deref())?;
1137 ensure_disjoint_roles(anchor_batches.as_deref(), bulk_batches.as_deref())?;
1138 let summary_batches = anchor_batches.clone();
1139 let anchor_batches = greedy_anchor_for_bulk(self, anchor_batches, bulk_batches.as_deref());
1140 let ctx = RefineCollectCtx {
1141 fine_codes: &fine_codes,
1142 group_to_cols_finest: &group_to_cols,
1143 level_dims: &level_dims,
1144 num_features,
1145 num_batches,
1146 knn,
1147 opt_iter,
1148 refine_params,
1149 output_calibration: params.output_calibration,
1150 anchor_batches: anchor_batches.as_deref(),
1151 summary_batches: summary_batches.as_deref(),
1152 bulk_batches: bulk_batches.as_deref(),
1153 observe_panels: params.observe_panels,
1154 keep_finest_stats: params.keep_finest_stats,
1155 pb_tree: pb_tree.as_ref(),
1156 cell_to_stratum: params.strata.as_deref(),
1157 exclude_unmatched_from_delta: params.strata.is_some(),
1158 strata_bits,
1159 };
1160 refine_and_collect_single_layer(self, proj_kn, &ctx).map(|out| out.levels)
1161 }
1162}
1163
1164impl MultilevelCollapsingOps for SparseIoStack {
1165 type LevelOutput = Vec<CollapsedOut>;
1166
1167 fn collapse_columns_multilevel<T>(
1168 &mut self,
1169 proj_kn: &DMatrix<f32>,
1170 batch_membership: &[T],
1171 params: &MultilevelParams,
1172 ) -> anyhow::Result<Vec<CollapsedOut>>
1173 where
1174 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1175 {
1176 let mut results =
1177 self.collapse_columns_multilevel_vec(proj_kn, batch_membership, params)?;
1178 if results.is_empty() {
1179 return Err(anyhow::anyhow!("no levels processed"));
1180 }
1181 Ok(results.remove(0))
1182 }
1183
1184 fn collapse_columns_multilevel_vec<T>(
1185 &mut self,
1186 proj_kn: &DMatrix<f32>,
1187 batch_membership: &[T],
1188 params: &MultilevelParams,
1189 ) -> anyhow::Result<Vec<Vec<CollapsedOut>>>
1190 where
1191 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1192 {
1193 Ok(
1194 collapse_stack_multilevel_with_hierarchy(self, proj_kn, batch_membership, params)?
1195 .levels,
1196 )
1197 }
1198}
1199
1200pub fn collapse_stack_multilevel_with_hierarchy<T>(
1206 stack: &mut SparseIoStack,
1207 proj_kn: &DMatrix<f32>,
1208 batch_membership: &[T],
1209 params: &MultilevelParams,
1210) -> anyhow::Result<StackCollapseOut>
1211where
1212 T: Sync + Send + std::hash::Hash + Eq + Clone + ToString,
1213{
1214 let num_layers = stack.num_types();
1215 if num_layers == 0 {
1216 return Err(anyhow::anyhow!("empty SparseIoStack"));
1217 }
1218
1219 let sort_dim = params.sort_dim;
1220 let knn = params.knn_pb_samples;
1221 let opt_iter = params.num_opt_iter;
1222
1223 stack.register_batch_membership(batch_membership);
1224
1225 let num_batches = stack.stack[0].num_batches();
1227 if num_batches >= 2 {
1228 for layer in stack.stack.iter_mut() {
1229 layer.build_hnsw_per_batch(proj_kn, batch_membership)?;
1230 }
1231 }
1232
1233 let ncols = proj_kn.ncols();
1234 let refine_params = ¶ms.refine;
1235 let level_dims = compute_level_sort_dims(sort_dim, params.num_levels);
1236 let finest_dim = level_dims[0];
1237 let kk = proj_kn.nrows().min(finest_dim).min(ncols);
1238 let codes = binary_sort_columns(proj_kn, kk)?;
1239 let (fine_codes, level_dims, strata_bits) = match params.strata.as_deref() {
1240 Some(strata) => {
1241 let (c, d, s) = apply_strata_to_codes(&codes, &level_dims, strata)?;
1242 (c, d, s)
1243 }
1244 None => (codes, level_dims, 0),
1245 };
1246 for layer in stack.stack.iter_mut() {
1247 layer.assign_groups(&fine_codes, None);
1248 }
1249 let group_to_cols = stack.stack[0]
1250 .take_grouped_columns()
1251 .ok_or(anyhow::anyhow!("columns not assigned"))?
1252 .clone();
1253 let num_features = stack.stack[0].num_rows();
1254 anyhow::ensure!(
1255 params.anchor_batches.is_none() && params.bulk_batches.is_none(),
1256 "anchor_batches / bulk_batches are not supported on the stack path — nothing \
1257 produces a carried reference or bulk input for stacked modalities"
1258 );
1259 let ctx = RefineCollectCtx {
1260 fine_codes: &fine_codes,
1261 group_to_cols_finest: &group_to_cols,
1262 level_dims: &level_dims,
1263 num_features,
1264 num_batches,
1265 knn,
1266 opt_iter,
1267 refine_params,
1268 output_calibration: params.output_calibration,
1269 anchor_batches: None,
1270 summary_batches: None,
1271 bulk_batches: None,
1272 observe_panels: false,
1273 keep_finest_stats: params.keep_finest_stats,
1274 pb_tree: None,
1275 cell_to_stratum: params.strata.as_deref(),
1276 exclude_unmatched_from_delta: params.strata.is_some(),
1277 strata_bits,
1278 };
1279 refine_and_collect_stack(stack, proj_kn, &ctx)
1280}