Skip to main content

data_beans/alg/collapse_data/
pb_samples.rs

1//! Pseudobulk-sample (pb-sample) construction.
2//!
3//! `build_pb_samples` aggregates per-(batch, group) intersections of the
4//! finest hash partition into a layout + per-pb-sample gene sums used
5//! downstream by the refinement step (`refine_multilevel` BBKNN +
6//! DC-SBM) and by the cross-batch matched-stat path.
7
8use super::*;
9
10pub struct PbSampleLayout {
11    /// Centroid matrix: proj_dim x num_pb_samples
12    pub centroids: DMatrix<f32>,
13    /// Number of cells in each pb-sample
14    pub cell_counts: Vec<f32>,
15    /// Batch index for each pb-sample
16    pub pb_sample_to_batch: Vec<usize>,
17    /// Sample/group index for each pb-sample
18    pub pb_sample_to_group: Vec<usize>,
19    /// Maps (batch, group) → pb-sample index
20    pub bg_to_pbsamp: HashMap<(usize, usize), usize>,
21    /// Global cell index → owning pb-sample index.
22    pub cell_to_pbsamp: Vec<usize>,
23    /// For a singleton pb-sample (an anchored reference column or a bulk
24    /// sample), the one column it stands for; `None` for ordinary
25    /// (batch, group) pb-samples. The layout is the single owner of "which
26    /// pb-samples are already summaries" — downstream steps
27    /// (`split_anchored_finest_groups`) read it here instead of re-deriving
28    /// membership from batch labels.
29    pub singleton_col: Vec<Option<usize>>,
30    /// Batches whose columns are bulk samples: singletons like anchors, but
31    /// excluded from cross-batch matching in BOTH directions (see
32    /// [`bbknn_match_one_pbsamp`]).
33    pub bulk_batches: Vec<usize>,
34    /// Per-pb-sample CNV stratum when collapse is stratified. Same-stratum
35    /// BBKNN filter reads this; `None` disables the filter.
36    pub pb_sample_to_stratum: Option<Vec<usize>>,
37}
38
39impl PbSampleLayout {
40    /// Whether this pb-sample is a bulk sample (a singleton that takes no
41    /// part in matching).
42    #[must_use]
43    pub fn is_bulk(&self, pbsamp: usize) -> bool {
44        self.is_bulk_batch(self.pb_sample_to_batch[pbsamp])
45    }
46
47    /// Whether `batch` is a bulk batch — the one spelling of the predicate,
48    /// so per-pb-sample and per-batch callers cannot drift apart.
49    #[must_use]
50    pub fn is_bulk_batch(&self, batch: usize) -> bool {
51        self.bulk_batches.contains(&batch)
52    }
53}
54
55/// Pre-aggregated pb-sample data for fast cross-batch matching.
56/// Each pb-sample is the intersection of a (batch, sample) pair.
57pub struct PbSampleCollection {
58    pub layout: PbSampleLayout,
59    /// Sparse gene sums per pb-sample: Vec of (gene_index, sum)
60    pub gene_sums: Vec<Vec<(usize, f32)>>,
61    /// Number of genes
62    pub num_genes: usize,
63}
64
65/// Intermediate per-batch accumulator used during pb-sample construction.
66pub(super) struct BatchAccumulator {
67    centroid_sum: Vec<f32>,
68    gene_sum: HashMap<usize, f32>,
69    count: usize,
70}
71
72/// A single pb-sample produced from a (batch, group) intersection.
73pub(super) struct PbSampleData {
74    centroid: Vec<f32>,
75    gene_sums: Vec<(usize, f32)>,
76    cell_count: f32,
77    batch: usize,
78    group: usize,
79}
80
81/// Build the shared pb-sample layout from (batch, group) intersections.
82///
83/// For each non-empty (batch, group) block:
84/// - Centroid = mean of projection vectors
85/// - Cell count = number of cells in block
86///
87/// `anchor_batches` are batches whose columns are ALREADY pseudobulks — a
88/// prior run's carried reference. Re-averaging those through this run's
89/// partition would flatten resolution the parent already paid for (averages
90/// of averages), so each anchored column becomes its own singleton pb-sample
91/// instead, keeping the reference at its stored granularity for matching.
92/// `bulk_batches` get the same singleton treatment (a bulk sample is already
93/// a summary) but are additionally barred from matching — see
94/// [`bbknn_match_one_pbsamp`].
95///
96/// This only uses `proj_kn` (no CSC reads), so it can be shared across layers.
97pub(super) fn build_pb_sample_layout(
98    group_to_cols: &[Vec<usize>],
99    col_to_batch: &[usize],
100    proj_kn: &DMatrix<f32>,
101    col_weight: Option<&[f32]>,
102    anchor_batches: &[usize],
103    bulk_batches: &[usize],
104    cell_to_stratum: Option<&[usize]>,
105) -> anyhow::Result<PbSampleLayout> {
106    let proj_dim = proj_kn.nrows();
107
108    /// Intermediate per-batch accumulator for centroid computation.
109    struct CentroidAccum {
110        centroid_sum: Vec<f32>,
111        /// Summed multiplicity, not a raw column count: a column standing for
112        /// `m` cells must weigh `m` in both the centroid and `cell_counts`,
113        /// or a carried pseudobulk counts the same as one cell.
114        count: f32,
115    }
116    let weight_of = |c: usize| col_weight.map_or(1.0, |w| w[c]);
117
118    // Collect centroid data per group in parallel. The last tuple field is
119    // `Some(column)` for a singleton (anchored) pb-sample, `None` for an
120    // ordinary (batch, group) block.
121    type CentroidTuple = (usize, usize, Vec<f32>, f32, Option<usize>);
122    let per_group_results: Vec<Vec<CentroidTuple>> = group_to_cols
123        .par_iter()
124        .enumerate()
125        .map(|(group, cells)| {
126            let mut batch_data: HashMap<usize, CentroidAccum> = HashMap::default();
127            let mut singletons: Vec<CentroidTuple> = Vec::new();
128
129            for &glob_idx in cells {
130                let batch = col_to_batch[glob_idx];
131                let w = weight_of(glob_idx);
132                if anchor_batches.contains(&batch) || bulk_batches.contains(&batch) {
133                    let centroid: Vec<f32> =
134                        (0..proj_dim).map(|d| proj_kn[(d, glob_idx)]).collect();
135                    singletons.push((batch, group, centroid, w, Some(glob_idx)));
136                    continue;
137                }
138                let acc = batch_data.entry(batch).or_insert_with(|| CentroidAccum {
139                    centroid_sum: vec![0f32; proj_dim],
140                    count: 0.0,
141                });
142                for d in 0..proj_dim {
143                    acc.centroid_sum[d] += proj_kn[(d, glob_idx)] * w;
144                }
145                acc.count += w;
146            }
147
148            let mut out: Vec<CentroidTuple> = batch_data
149                .into_iter()
150                .filter(|(_, acc)| acc.count > 0.0)
151                .map(|(batch, acc)| {
152                    let inv_count = 1.0 / acc.count;
153                    let centroid: Vec<f32> =
154                        acc.centroid_sum.iter().map(|v| v * inv_count).collect();
155                    (batch, group, centroid, acc.count, None)
156                })
157                .collect::<Vec<_>>();
158            out.extend(singletons);
159            out
160        })
161        .collect();
162
163    // Flatten into a single list
164    let all_pbsamp: Vec<_> = per_group_results.into_iter().flatten().collect();
165    let num_pb = all_pbsamp.len();
166
167    if num_pb == 0 {
168        return Err(anyhow::anyhow!("no pb-samples built"));
169    }
170
171    // Build centroid matrix and metadata
172    let mut centroids = DMatrix::<f32>::zeros(proj_dim, num_pb);
173    let mut cell_counts = Vec::with_capacity(num_pb);
174    let mut pbsamp_to_batch = Vec::with_capacity(num_pb);
175    let mut pbsamp_to_group = Vec::with_capacity(num_pb);
176    let mut singleton_col = Vec::with_capacity(num_pb);
177    let mut bg_to_pbsamp = HashMap::default();
178
179    let ncols = col_to_batch.len();
180    let mut cell_to_pbsamp = vec![usize::MAX; ncols];
181    for (i, (batch, group, centroid, count, sc)) in all_pbsamp.into_iter().enumerate() {
182        for (d, &v) in centroid.iter().enumerate() {
183            centroids[(d, i)] = v;
184        }
185        cell_counts.push(count);
186        pbsamp_to_batch.push(batch);
187        pbsamp_to_group.push(group);
188        singleton_col.push(sc);
189        match sc {
190            // A singleton column IS its pb-sample; `(batch, group)` is not a
191            // unique key for singletons, so they are mapped directly and stay
192            // out of `bg_to_pbsamp`.
193            Some(col) => cell_to_pbsamp[col] = i,
194            None => {
195                bg_to_pbsamp.insert((batch, group), i);
196            }
197        }
198    }
199
200    // Cell → pb-sample inversion for the ordinary (batch, group) blocks;
201    // singleton columns were mapped directly above.
202    for (group, cells) in group_to_cols.iter().enumerate() {
203        for &c in cells {
204            let b = col_to_batch[c];
205            if cell_to_pbsamp[c] == usize::MAX {
206                if let Some(&pbsamp) = bg_to_pbsamp.get(&(b, group)) {
207                    cell_to_pbsamp[c] = pbsamp;
208                }
209            }
210        }
211    }
212
213    let pb_sample_to_stratum = if let Some(strata) = cell_to_stratum {
214        anyhow::ensure!(
215            strata.len() == ncols,
216            "cell_to_stratum has {} entries, layout has {} columns",
217            strata.len(),
218            ncols
219        );
220        let mut out = vec![0usize; num_pb];
221        let mut seen = vec![false; num_pb];
222        for (c, &pbsamp) in cell_to_pbsamp.iter().enumerate() {
223            if pbsamp == usize::MAX {
224                continue;
225            }
226            if !seen[pbsamp] {
227                out[pbsamp] = strata[c];
228                seen[pbsamp] = true;
229            }
230        }
231        Some(out)
232    } else {
233        None
234    };
235
236    Ok(PbSampleLayout {
237        centroids,
238        cell_counts,
239        pb_sample_to_batch: pbsamp_to_batch,
240        pb_sample_to_group: pbsamp_to_group,
241        bg_to_pbsamp,
242        cell_to_pbsamp,
243        singleton_col,
244        bulk_batches: bulk_batches.to_vec(),
245        pb_sample_to_stratum,
246    })
247}
248
249/// Collect gene sums for each pb-sample from a single `SparseIoVec` layer.
250///
251/// Keyed on the layout's `cell_to_pbsamp` so ordinary (batch, group) blocks
252/// and singleton (anchored) pb-samples accumulate through one path.
253pub(super) fn collect_pb_sample_gene_sums(
254    data_vec: &SparseIoVec,
255    group_to_cols: &[Vec<usize>],
256    cell_to_pbsamp: &[usize],
257    num_pb: usize,
258) -> anyhow::Result<Vec<Vec<(usize, f32)>>> {
259    use indicatif::ParallelProgressIterator;
260    let prog_bar = styled_progress_bar(group_to_cols.len() as u64, "groups (pb-sample gene sums)");
261    let gene_sum_maps: Vec<(usize, HashMap<usize, f32>)> = group_to_cols
262        .par_iter()
263        .progress_with(prog_bar.clone())
264        .flat_map(|cells| {
265            let yy = data_vec
266                .read_columns_csc(cells.iter().cloned())
267                .expect("read_columns_csc");
268
269            let mut per_pbsamp: HashMap<usize, HashMap<usize, f32>> = HashMap::default();
270
271            for (local_idx, y_j) in yy.col_iter().enumerate() {
272                let col = cells[local_idx];
273                let pbsamp = cell_to_pbsamp[col];
274                if pbsamp == usize::MAX {
275                    continue;
276                }
277                // Weighted to stay consistent with the weighted `cell_counts`
278                // in the layout: `collect_matched_stat_coarse` divides one by
279                // the other, and the quotient has to remain the per-cell rate.
280                let w = data_vec.column_multiplicity(col);
281                let gene_map = per_pbsamp.entry(pbsamp).or_default();
282                for (&gene, &val) in y_j.row_indices().iter().zip(y_j.values().iter()) {
283                    *gene_map.entry(gene).or_default() += val * w;
284                }
285            }
286
287            per_pbsamp.into_iter().collect::<Vec<_>>()
288        })
289        .collect();
290
291    let mut gene_sums: Vec<Vec<(usize, f32)>> = vec![vec![]; num_pb];
292    for (pbsamp_idx, gene_map) in gene_sum_maps {
293        let mut sorted: Vec<(usize, f32)> = gene_map.into_iter().collect();
294        sorted.sort_unstable_by_key(|&(g, _)| g);
295        gene_sums[pbsamp_idx] = sorted;
296    }
297
298    Ok(gene_sums)
299}
300
301/// Build pb-samples (layout + gene sums) from a single `SparseIoVec`.
302pub(super) fn build_pb_samples(
303    data_vec: &SparseIoVec,
304    proj_kn: &DMatrix<f32>,
305    num_genes: usize,
306    anchor_batches: &[usize],
307    bulk_batches: &[usize],
308    cell_to_stratum: Option<&[usize]>,
309) -> anyhow::Result<PbSampleCollection> {
310    let group_to_cols = data_vec
311        .take_grouped_columns()
312        .ok_or(anyhow::anyhow!("columns not assigned to groups"))?;
313    let col_to_batch: Vec<usize> = (0..proj_kn.ncols())
314        .map(|c| data_vec.get_batch_membership(std::iter::once(c))[0])
315        .collect();
316
317    let weights: Option<Vec<f32>> = data_vec.has_column_multiplicity().then(|| {
318        (0..proj_kn.ncols())
319            .map(|c| data_vec.column_multiplicity(c))
320            .collect()
321    });
322    let layout = build_pb_sample_layout(
323        group_to_cols,
324        &col_to_batch,
325        proj_kn,
326        weights.as_deref(),
327        anchor_batches,
328        bulk_batches,
329        cell_to_stratum,
330    )?;
331    let num_pb = layout.cell_counts.len();
332    let gene_sums =
333        collect_pb_sample_gene_sums(data_vec, group_to_cols, &layout.cell_to_pbsamp, num_pb)?;
334
335    Ok(PbSampleCollection {
336        layout,
337        gene_sums,
338        num_genes,
339    })
340}
341
342/// Recover up to `knn` *distinct* pb-samples (closest, by min cell distance)
343/// from a single batch's cell-level HNSW for the given `query` centroid.
344///
345/// Grows the HNSW query adaptively until `knn` distinct pb-samples are found or
346/// the batch's cells are exhausted (bounded by `bknn.len()`). This replaces the
347/// old fixed `4·knn+1` oversample, which under-recovered whenever a batch's
348/// nearest cells collapsed into fewer than `knn` pb-samples (small / sorting-
349/// biased batches) — starving the cross-batch δ estimate of matches.
350///
351/// `own_pbsamp` and `usize::MAX` (unassigned) cells are skipped. Returned
352/// distance per pb-sample is the minimum over its collapsed cells.
353pub(crate) fn knn_distinct_pbsamples_in_batch(
354    bknn: &ColumnDict<usize>,
355    query: &[f32],
356    knn: usize,
357    cell_to_pbsamp: &[usize],
358    own_pbsamp: usize,
359    pb_sample_to_stratum: Option<&[usize]>,
360    own_stratum: Option<usize>,
361) -> anyhow::Result<Vec<(usize, f32)>> {
362    let n = bknn.num_points();
363    if n == 0 || knn == 0 {
364        return Ok(Vec::new());
365    }
366    // Start at the historical oversample, then grow ×4 per round. `search_by_
367    // query_data` self-clamps the request to the batch size, so query_k == n
368    // returns every cell — our exhaustion signal.
369    let mut query_k = (knn * 4 + 1).min(n);
370    let mut best: HashMap<usize, f32> = HashMap::default();
371    loop {
372        let (cell_ids, dists) = bknn.search_by_query_data(query, query_k)?;
373        best.clear();
374        for (&c, &d) in cell_ids.iter().zip(dists.iter()) {
375            let other_pbsamp = cell_to_pbsamp[c];
376            if other_pbsamp == usize::MAX || other_pbsamp == own_pbsamp {
377                continue;
378            }
379            if let (Some(st), Some(own_s)) = (pb_sample_to_stratum, own_stratum) {
380                if st[other_pbsamp] != own_s {
381                    continue;
382                }
383            }
384            best.entry(other_pbsamp)
385                .and_modify(|old| {
386                    if d < *old {
387                        *old = d;
388                    }
389                })
390                .or_insert(d);
391        }
392        if best.len() >= knn || query_k >= n {
393            break;
394        }
395        query_k = query_k.saturating_mul(4).min(n);
396    }
397    let mut per_batch: Vec<(usize, f32)> = best.drain().collect();
398    per_batch.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
399    per_batch.truncate(knn);
400    Ok(per_batch)
401}
402
403/// For a single `pbsamp`, gather its cross-batch match set: up to `knn`
404/// distinct pb-samples from **each** non-own batch (closest to `pbsamp`'s
405/// centroid), flattened across batches via [`knn_distinct_pbsamples_in_batch`].
406///
407/// Shared per-pb-sample body for both [`per_batch_sc_neighbors`] (keeps
408/// distances) and `refine_multilevel::build_bbknn_neighbors` (ids only).
409pub(crate) fn bbknn_match_one_pbsamp(
410    layout: &PbSampleLayout,
411    batch_knn_lookup: &[ColumnDict<usize>],
412    knn: usize,
413    pbsamp: usize,
414    anchor_batches: Option<&[usize]>,
415) -> anyhow::Result<Vec<(usize, f32)>> {
416    // Bulk is a RECEIVER but never a SOURCE — greedy correction, the same
417    // discipline the carried pb_reference uses. A bulk column draws its
418    // counterfactual from the cell frame and is corrected toward it; the
419    // cells self-match through the anchor path, so their frame never moves.
420    // Letting bulk serve as a source is what would drag the cells, and that
421    // is the only direction excluded here (below, and by keeping bulk out of
422    // every anchor set).
423    let pbsamp_batch = layout.pb_sample_to_batch[pbsamp];
424    let centroid: Vec<f32> = layout.centroids.column(pbsamp).iter().copied().collect();
425    let pb_stratum = layout.pb_sample_to_stratum.as_deref();
426    let own_stratum = pb_stratum.map(|s| s[pbsamp]);
427    let mut all_hits: Vec<(usize, f32)> = Vec::new();
428    match anchor_batches {
429        // Pooled matching: every non-own, non-bulk batch contributes
430        // counterfactual candidates, symmetrically. A bulk batch cannot be a
431        // source: matching a cell pb-sample to a mixture is the same
432        // composition-into-δ leak in the other direction.
433        None => {
434            for (b, bknn) in batch_knn_lookup.iter().enumerate() {
435                if b == pbsamp_batch || layout.is_bulk_batch(b) {
436                    continue;
437                }
438                let per_batch = knn_distinct_pbsamples_in_batch(
439                    bknn,
440                    &centroid,
441                    knn,
442                    &layout.cell_to_pbsamp,
443                    pbsamp,
444                    pb_stratum,
445                    own_stratum,
446                )?;
447                all_hits.extend(per_batch);
448            }
449        }
450        // Greedy (anchored) matching: counterfactuals come from the anchor
451        // batches ONLY, for everyone. New batches are corrected toward the
452        // anchor frame; an anchor pb-sample's nearest anchor is itself
453        // (distance 0 → softmax self-match), so the frame stays fixed and its
454        // δ settles at the prior — the reference is never re-adjusted.
455        Some(anchors) => {
456            for &b in anchors {
457                let Some(bknn) = batch_knn_lookup.get(b) else {
458                    continue;
459                };
460                let per_batch = knn_distinct_pbsamples_in_batch(
461                    bknn,
462                    &centroid,
463                    knn,
464                    &layout.cell_to_pbsamp,
465                    usize::MAX, // self-match allowed
466                    pb_stratum,
467                    own_stratum,
468                )?;
469                all_hits.extend(per_batch);
470            }
471        }
472    }
473    Ok(all_hits)
474}
475
476/// Per-pb-sample, for each non-own batch return up to `knn` distinct
477/// pb-samples whose member cells are closest to `pbsamp`'s centroid.
478///
479/// Queries `SparseIoVec::batch_knn_lookup` (per-batch HNSW over cells) via
480/// [`knn_distinct_pbsamples_in_batch`], which grows each per-batch search until
481/// `knn` distinct pb-samples are recovered or the batch is exhausted.
482///
483/// Returns: `result[pbsamp] = Vec<(other_pbsamp, distance)>` flattened across all
484/// non-own batches.
485pub(super) fn per_batch_sc_neighbors(
486    layout: &PbSampleLayout,
487    batch_knn_lookup: &[ColumnDict<usize>],
488    knn: usize,
489    anchor_batches: Option<&[usize]>,
490) -> anyhow::Result<Vec<Vec<(usize, f32)>>> {
491    use indicatif::ParallelProgressIterator;
492    let num_pb = layout.cell_counts.len();
493
494    let prog_bar = styled_progress_bar(num_pb as u64, "pb-samples (BBKNN match)");
495    let result = (0..num_pb)
496        .into_par_iter()
497        .progress_with(prog_bar.clone())
498        .map(|pbsamp| bbknn_match_one_pbsamp(layout, batch_knn_lookup, knn, pbsamp, anchor_batches))
499        .collect();
500    prog_bar.finish_and_clear();
501    result
502}
503
504/// Match pb-samples across batches and accumulate counterfactual
505/// statistics into `stat.imputed_sum_ds` and `stat.matched_bs`.
506///
507/// `pbsamp_to_group` is the per-pb-sample group assignment to use when writing
508/// into stat columns; callers pass `&layout.pb_sample_to_group` for the
509/// hash-partition mapping, or a refined mapping from
510/// `refine_multilevel::refine_assignments`.
511///
512/// `knn` is now the per-other-batch neighbour count: each pb-sample draws
513/// up to `knn` distinct foreign pb-samples from **each** non-own batch, so
514/// the total match set is up to `knn · (num_batches − 1)`.
515pub(super) fn build_pb_sample_to_cells(layout: &PbSampleLayout) -> Vec<Vec<usize>> {
516    let num_pb = layout.cell_counts.len();
517    let mut out: Vec<Vec<usize>> = vec![vec![]; num_pb];
518    for (c, &pbsamp) in layout.cell_to_pbsamp.iter().enumerate() {
519        if pbsamp != usize::MAX {
520            out[pbsamp].push(c);
521        }
522    }
523    out
524}
525
526#[cfg(test)]
527#[path = "pb_samples_tests.rs"]
528mod tests;