Skip to main content

data_beans/alg/collapse_data/
stats.rs

1//! Per-pseudobulk statistics + Gamma posterior optimization.
2//!
3//! Three layers:
4//! - **Visitors** (`collect_basic_stat_visitor`,
5//!   `collect_batch_stat_visitor`, `collect_matched_stat_visitor`):
6//!   accumulate per-group sufficient statistics across the sparse data
7//!   in parallel.
8//! - **`CollapsedStat` / `CollapsedOut`**: the sufficient-statistic
9//!   buffer and the Gamma posteriors derived from it.
10//! - **`optimize`** and **`resample_and_optimize`**: fit the per-PB
11//!   Gamma posteriors from a populated `CollapsedStat`.
12//!
13//! Also houses the cross-batch matched-stat helper used by the
14//! multi-level refinement path
15//! (`collect_matched_stat_coarse`) and the coarse-level descent
16//! (`merge_stat`).
17
18use super::*;
19use nalgebra::DVector;
20
21pub(super) struct KnnParams<'a> {
22    pub(super) knn_batches: usize,
23    pub(super) knn_cells: usize,
24    pub(super) reference_indices: Option<&'a [usize]>,
25}
26
27pub(super) fn collect_matched_stat_visitor(
28    sample: usize,
29    cells: &[usize],
30    data_vec: &SparseIoVec,
31    knn_params: &KnnParams,
32    arc_stat: Arc<Mutex<&mut CollapsedStat>>,
33) -> anyhow::Result<()> {
34    let knn_batches = knn_params.knn_batches;
35    let knn_cells = knn_params.knn_cells;
36
37    let (y0_matched, source_columns, matched_columns, euclidean_distances) =
38        match knn_params.reference_indices {
39            Some(target_indices) => data_vec.read_matched_columns_with_targets_csc(
40                cells.iter().cloned(),
41                target_indices,
42                knn_cells,
43                true,
44            )?,
45            None => data_vec.read_neighbouring_columns_csc(
46                cells.iter().cloned(),
47                knn_batches,
48                knn_cells,
49                true,
50                None,
51            )?,
52        };
53
54    let y1_pos: HashMap<_, _> = cells
55        .iter()
56        .cloned()
57        .enumerate()
58        .map(|(i, p)| (p, i))
59        .collect();
60
61    let neg_distance_triplets = source_columns
62        .iter()
63        .zip(euclidean_distances.iter())
64        .enumerate()
65        .map(|(t, (&s, &d))| (t, y1_pos[&s], -d))
66        .collect::<Vec<_>>();
67
68    ////////////////////////////////////////////////////////
69    // zhat[g,j]  =  sum_k w[j,k] * z[g,k] / sum_k w[j,k] //
70    // zsum[g,s]  =  sum_j zhat[g,j]                      //
71    ////////////////////////////////////////////////////////
72
73    // Normalize distance for each source cell and take a
74    // weighted average of the matched vectors using this
75    // weight vector
76    let ww = CscMat::from_nonzero_triplets(
77        y0_matched.ncols(),
78        cells.len(),
79        neg_distance_triplets.as_ref(),
80    )?
81    .normalize_exp_logits_columns();
82
83    let y1_hat = &y0_matched * &ww;
84    let matched_batches = data_vec.get_batch_membership(matched_columns.iter().cloned());
85
86    let mut stat = arc_stat.lock().expect("lock stat");
87
88    // Source mass per batch: each cell's weights sum to 1 over its matches,
89    // and row `k` of `ww` is the `k`-th matched column, whose batch the
90    // counterfactual was drawn from.
91    for w_j in ww.col_iter() {
92        for (&k, &w) in w_j.row_indices().iter().zip(w_j.values().iter()) {
93            stat.matched_bs[(matched_batches[k], sample)] += w;
94        }
95    }
96
97    for y_j in y1_hat.col_iter() {
98        let rows = y_j.row_indices();
99        let vals = y_j.values();
100        for (&gene, &y) in rows.iter().zip(vals.iter()) {
101            stat.imputed_sum_ds[(gene, sample)] += y;
102        }
103    }
104
105    Ok(())
106}
107
108pub(super) fn collect_basic_stat_visitor(
109    sample: usize,
110    cells: &[usize],
111    data_vec: &SparseIoVec,
112    _: &EmptyArg,
113    arc_stat: Arc<Mutex<&mut CollapsedStat>>,
114) -> anyhow::Result<()> {
115    let yy = data_vec.read_columns_csc(cells.iter().cloned())?;
116
117    let mut stat = arc_stat.lock().expect("lock stat");
118
119    // `w` is how many observations the column stands for — 1 for a cell, `m`
120    // for a column carrying the mean profile of `m` cells. Scaling both the
121    // counts and the size keeps `μ = Σy / n` the per-cell rate either way.
122    for (y_j, &col) in yy.col_iter().zip(cells.iter()) {
123        let w = data_vec.column_multiplicity(col);
124        let rows = y_j.row_indices();
125        let vals = y_j.values();
126        for (&gene, &y) in rows.iter().zip(vals.iter()) {
127            stat.observed_sum_ds[(gene, sample)] += y * w;
128        }
129        stat.size_s[sample] += w;
130    }
131    Ok(())
132}
133
134pub(super) fn collect_batch_stat_visitor(
135    sample: usize,
136    cells_in_sample: &[usize],
137    data_vec: &SparseIoVec,
138    _: &EmptyArg,
139    arc_stat: Arc<Mutex<&mut CollapsedStat>>,
140) -> anyhow::Result<()> {
141    let yy = data_vec.read_columns_csc(cells_in_sample.iter().cloned())?;
142
143    // cells_in_sample: sample s -> cell j
144    // batches: cell j -> batch b
145    let batches = data_vec.get_batch_membership(cells_in_sample.iter().cloned());
146
147    let mut stat = arc_stat.lock().expect("lock stat");
148
149    yy.col_iter()
150        .zip(batches.iter())
151        .zip(cells_in_sample.iter())
152        .for_each(|((y_j, &b), &col)| {
153            let w = data_vec.column_multiplicity(col);
154            let rows = y_j.row_indices();
155            let vals = y_j.values();
156            for (&gene, &y) in rows.iter().zip(vals.iter()) {
157                stat.observed_sum_db[(gene, b)] += y * w;
158            }
159            stat.n_bs[(b, sample)] += w;
160        });
161    Ok(())
162}
163
164/// Per-feature-block kernel for [`optimize`]. Runs the DC-Poisson
165/// coordinate descent on a (sub)stat and returns a `CollapsedOut` whose row
166/// count matches `stat.num_genes()`. When `prog` is `Some`, ticks it once per
167/// descent iteration (batched path only) so the driver's bar keeps moving
168/// while a block is mid-fit; `inc` is atomic, so concurrent blocks may share
169/// one bar.
170/// `denom += effective size`: per-(gene, sample) when observability is
171/// attached, the historical per-sample scalar otherwise. The `None` arm is
172/// the exact old code — full observability is bitwise-identical by
173/// construction, not by tolerance.
174fn add_effective_size(denom_ds: &mut nalgebra::DMatrix<f32>, stat: &CollapsedStat) {
175    match stat.size_ds.as_ref() {
176        Some(size_ds) => {
177            debug_assert_eq!(denom_ds.shape(), size_ds.shape());
178            *denom_ds += size_ds;
179        }
180        None => {
181            for s in 0..denom_ds.ncols() {
182                denom_ds.column_mut(s).add_scalar_mut(stat.size_s[s]);
183            }
184        }
185    }
186}
187
188/// Rescale δ's Gamma denominators so that, per gene, the frame batches'
189/// weighted geometric mean of the posterior mean `(a0 + num) / (b0 + den)` is
190/// exactly 1. A batch masked out for a gene (`obs_mask_db == 0`) sits on the
191/// prior and does not vote. Returns the adjusted denominators.
192fn pin_delta_scale(
193    num_db: &DMatrix<f32>,
194    den_db: &DMatrix<f32>,
195    (a0, b0): (f32, f32),
196    frame_w: &DVector<f32>,
197    mask_db: Option<&DMatrix<f32>>,
198) -> DMatrix<f32> {
199    let (ng, nb) = num_db.shape();
200    let mut out = den_db.clone();
201    for g in 0..ng {
202        let (mut lsum, mut wsum) = (0f64, 0f64);
203        for b in 0..nb {
204            let w = f64::from(frame_w[b]) * mask_db.map_or(1.0, |m| f64::from(m[(g, b)]));
205            if w > 0.0 {
206                let m = f64::from(a0 + num_db[(g, b)]) / f64::from(b0 + den_db[(g, b)]);
207                lsum += w * m.max(f64::MIN_POSITIVE).ln();
208                wsum += w;
209            }
210        }
211        if wsum > 0.0 {
212            let gm = (lsum / wsum).exp() as f32;
213            for b in 0..nb {
214                // A masked entry carries no evidence and stays on the prior.
215                if mask_db.is_some_and(|m| m[(g, b)] == 0.0) {
216                    continue;
217                }
218                // (a0 + num) / (b0 + den') = mean / gm  ⇔  den' = (b0 + den)·gm − b0.
219                // The clamp only binds when gm < 1 on an entry with less than a
220                // cell's worth of evidence, where the prior dominates anyway.
221                out[(g, b)] = ((b0 + den_db[(g, b)]) * gm - b0).max(0.0);
222            }
223        }
224    }
225    out
226}
227
228fn optimize_block(
229    stat: &CollapsedStat,
230    hyper: (f32, f32),
231    num_iter: usize,
232    out_target: CalibrateTarget,
233    prog: Option<&indicatif::ProgressBar>,
234) -> anyhow::Result<CollapsedOut> {
235    let (a0, b0) = hyper;
236    let num_genes = stat.num_genes();
237    let num_samples = stat.num_samples();
238    let num_batches = stat.num_batches();
239    let mut mu_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
240
241    if num_batches > 1 {
242        //////////////////////////////////////////////////////////////////
243        // One frame for every batch                                    //
244        //                                                              //
245        //   E[observed_gs] = μ_gs · Σ_b δ_gb · n_bs        (own cells) //
246        //   E[imputed_gs]  = μ_gs · Σ_b δ_gb · w_bs   (counterfactual) //
247        //                                                              //
248        // μ is the batch-free rate, δ_gb the per-batch fold, n_bs the  //
249        // own mass and w_bs the counterfactual's source mass. Both     //
250        // sides of a pseudobulk are Poisson at the SAME μ, so μ cannot //
251        // slide into another batch's frame; δ is identified by the     //
252        // counterfactual side and pinned per gene to geometric mean 1  //
253        // over the frame batches (all of them, or the anchors).        //
254        //////////////////////////////////////////////////////////////////
255        let n_bs = &stat.n_bs;
256        let w_bs = &stat.matched_bs;
257        let own_plus_src = n_bs + w_bs; // [b × s] — full mass for update_mu
258                                        // δ's observed side: optionally drop unmatched (no counterfactual)
259                                        // samples so private-clone mass cannot pull the batch fold.
260                                        // `n_bs_d` is the matched-own mass that also votes in `pin_delta_scale`
261                                        // (same basis as unstratified `frame_weights()` — own mass only).
262        let (obs_db_for_delta, own_plus_src_for_delta, n_bs_d) = if stat
263            .exclude_unmatched_from_delta
264        {
265            let mut obs = stat.observed_sum_db.clone();
266            let mut n_bs_d = n_bs.clone();
267            for s in 0..num_samples {
268                if w_bs.column(s).sum() > 0.0 {
269                    continue;
270                }
271                let size = stat.size_s[s];
272                if size <= 0.0 {
273                    continue;
274                }
275                for b in 0..num_batches {
276                    let n = n_bs[(b, s)];
277                    if n <= 0.0 {
278                        continue;
279                    }
280                    // The split of the sample's sum by cell mass is exact
281                    // for a single-batch sample only; the floor keeps a
282                    // batch's observed count from going negative where
283                    // the split charges it more than it holds.
284                    let frac = n / size;
285                    for g in 0..num_genes {
286                        obs[(g, b)] = (obs[(g, b)] - stat.observed_sum_ds[(g, s)] * frac).max(0.0);
287                    }
288                    n_bs_d[(b, s)] = 0.0;
289                }
290            }
291            let own_plus = &n_bs_d + w_bs;
292            (obs, own_plus, n_bs_d)
293        } else {
294            (
295                stat.observed_sum_db.clone(),
296                own_plus_src.clone(),
297                n_bs.clone(),
298            )
299        };
300        let obs_plus_imp = &stat.observed_sum_ds + &stat.imputed_sum_ds;
301        // Fraction of each (gene, sample)'s mass whose source measures the gene;
302        // `None` = fully observed.
303        let obs_frac: Option<DMatrix<f32>> = stat.size_ds.as_ref().map(|size_ds| {
304            DMatrix::from_fn(num_genes, num_samples, |g, s| {
305                let n = stat.size_s[s];
306                if n > 0.0 {
307                    size_ds[(g, s)] / n
308                } else {
309                    0.0
310                }
311            })
312        });
313        let frame_w = if stat.exclude_unmatched_from_delta && stat.anchor_batches.is_empty() {
314            // Own matched mass only — do not add counterfactual source mass
315            // (that would double-count vs unstratified `frame_weights()`).
316            DVector::from_fn(num_batches, |b, _| n_bs_d.row(b).sum())
317        } else {
318            stat.frame_weights()
319        };
320        for b in 0..num_batches {
321            if frame_w[b] <= 0.0 {
322                warn!(
323                    "batch {b} has no matched mass for δ (frame weight 0); \
324                     δ_·{b} stays on prior ≈ 1 (e.g. blast-only / clone-only donor)"
325                );
326            }
327        }
328        let own_plus_src_delta_t = own_plus_src_for_delta.transpose();
329        let w_bs_t = w_bs.transpose();
330
331        let mut mu_adj_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
332        let mut delta_param = GammaMatrix::new((num_genes, num_batches), a0, b0);
333        let mut delta_gb = DMatrix::<f32>::from_element(num_genes, num_batches, 1.0);
334
335        // μ given δ: (obs + imp) / (frac · Σ_b δ_gb (n_bs + w_bs))
336        let update_mu = |mu_adj_param: &mut GammaMatrix, delta_gb: &DMatrix<f32>| {
337            let mut denom_ds = delta_gb * &own_plus_src;
338            if let Some(f) = obs_frac.as_ref() {
339                denom_ds.component_mul_assign(f);
340            }
341            mu_adj_param.update_stat(&obs_plus_imp, &denom_ds);
342            mu_adj_param.calibrate_with(CalibrateTarget::MeanOnly);
343        };
344
345        // Scratch planes, allocated once: every [g × s] temporary below is
346        // rewritten in place each iteration rather than reallocated.
347        let mut imp_share = DMatrix::<f32>::zeros(num_genes, num_samples);
348        let mut mu_frac = DMatrix::<f32>::zeros(num_genes, num_samples);
349        for _opt_iter in 0..num_iter {
350            #[cfg(debug_assertions)]
351            {
352                debug!("iteration: {}", _opt_iter);
353            }
354
355            update_mu(&mut mu_adj_param, &delta_gb);
356            let mu_ds = mu_adj_param.posterior_mean();
357
358            // δ given μ (one EM step): the observed side is exact per batch
359            // (`observed_sum_db`); the counterfactual side splits each
360            // sample's imputed sum over its source batches in proportion to
361            // δ_gb · w_bs.
362            //   num_gb = obs_db + Σ_s imp_gs · δ_gb w_bs / Σ_b' δ_gb' w_b's
363            //   den_gb = Σ_s μ_gs · frac_gs · (n_bs + w_bs)
364            imp_share.gemm(1.0, &delta_gb, w_bs, 0.0); // Σ_b δ_gb w_bs
365            imp_share.zip_apply(&stat.imputed_sum_ds, |z, x| {
366                *z = if *z > 0.0 { x / *z } else { 0.0 };
367            });
368            let mut num_db = &obs_db_for_delta + (&imp_share * &w_bs_t).component_mul(&delta_gb);
369            let mu_frac_ref: &DMatrix<f32> = match obs_frac.as_ref() {
370                Some(f) => {
371                    mu_frac.copy_from(mu_ds);
372                    mu_frac.component_mul_assign(f);
373                    &mu_frac
374                }
375                None => mu_ds,
376            };
377            let mut den_db = mu_frac_ref * &own_plus_src_delta_t; // [g × b]
378            if let Some(mask) = stat.obs_mask_db.as_ref() {
379                // Zeroing a (gene, batch) entry means "this batch carries no δ
380                // evidence for this gene"; masking BOTH sides lands the
381                // posterior on the prior exactly (≈ 1, "no adjustment").
382                num_db.component_mul_assign(mask);
383                den_db.component_mul_assign(mask);
384            }
385            // Pin the per-gene scale: the frame batches' weighted geometric
386            // mean of δ is 1. Folded into the denominators so the Gamma
387            // posterior itself carries the normalised value.
388            let den_db = pin_delta_scale(
389                &num_db,
390                &den_db,
391                (a0, b0),
392                &frame_w,
393                stat.obs_mask_db.as_ref(),
394            );
395            delta_param.update_stat(&num_db, &den_db);
396            delta_param.calibrate_with(CalibrateTarget::MeanOnly);
397            delta_gb.copy_from(delta_param.posterior_mean());
398
399            if let Some(p) = prog {
400                p.inc(1);
401            }
402        }
403        // μ consistent with the final δ.
404        update_mu(&mut mu_adj_param, &delta_gb);
405        mu_adj_param.calibrate_with(out_target);
406        delta_param.calibrate_with(out_target);
407
408        // Per-pseudobulk readouts against the batch-free μ, on the documented
409        // scale: E[y] = μ_resid · μ (own fold) and E[ŷ] = γ · μ (source fold).
410        // Each denominator is μ times a per-sample mass (own cells, or the
411        // counterfactual's matched cells), scaled by the observed fraction when
412        // a panel gap makes it less than full.
413        let mu_ds = mu_adj_param.posterior_mean();
414        let mass_times_mu = |per_sample: &dyn Fn(usize) -> f32| {
415            let mut m = mu_ds.clone();
416            for s in 0..num_samples {
417                m.column_mut(s).scale_mut(per_sample(s));
418            }
419            if let Some(f) = obs_frac.as_ref() {
420                m.component_mul_assign(f);
421            }
422            m
423        };
424        let resid_denom = mass_times_mu(&|s| stat.size_s[s]);
425        let mut mu_resid_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
426        mu_resid_param.update_stat(&stat.observed_sum_ds, &resid_denom);
427        mu_resid_param.calibrate_with(out_target);
428
429        let src_mass_s: Vec<f32> = (0..num_samples).map(|s| w_bs.column(s).sum()).collect();
430        let gamma_denom = mass_times_mu(&|s| src_mass_s[s]);
431        let mut gamma_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
432        gamma_param.update_stat(&stat.imputed_sum_ds, &gamma_denom);
433        gamma_param.calibrate_with(out_target);
434
435        // Take the observed mean over the own mass.
436        {
437            let mut own_mass = DMatrix::<f32>::zeros(num_genes, num_samples);
438            add_effective_size(&mut own_mass, stat);
439            mu_param.update_stat(&stat.observed_sum_ds, &own_mass);
440            mu_param.calibrate_with(out_target);
441        };
442
443        // Sparse output (bge / MeanOnly): drop each mean's per-column prior
444        // baseline so its support is exactly the observed∪imputed cells.
445        // Downstream `from_pseudobulks` then yields sparse pb_blobs (held
446        // across all training epochs). `All` consumers keep the dense mean.
447        if matches!(out_target, CalibrateTarget::MeanOnly) {
448            mu_param.sparsify_mean_to_support(&stat.observed_sum_ds);
449            mu_adj_param.sparsify_mean_to_support(&obs_plus_imp);
450            gamma_param.sparsify_mean_to_support(&stat.imputed_sum_ds);
451            mu_resid_param.sparsify_mean_to_support(&stat.observed_sum_ds);
452        }
453
454        Ok(CollapsedOut {
455            mu_observed: mu_param,
456            mu_adjusted: Some(mu_adj_param),
457            mu_residual: Some(mu_resid_param),
458            gamma: Some(gamma_param),
459            delta: Some(delta_param),
460            stats_kept: true,
461        })
462    } else {
463        let mut denom_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
464        add_effective_size(&mut denom_ds, stat);
465        mu_param.update_stat(&stat.observed_sum_ds, &denom_ds);
466        mu_param.calibrate_with(out_target);
467        if matches!(out_target, CalibrateTarget::MeanOnly) {
468            mu_param.sparsify_mean_to_support(&stat.observed_sum_ds);
469        }
470        Ok(CollapsedOut {
471            mu_observed: mu_param,
472            mu_adjusted: None,
473            mu_residual: None,
474            gamma: None,
475            delta: None,
476            stats_kept: true,
477        })
478    }
479}
480
481/// Optimize the mean parameters for the DC-Poisson collapse, **blocked over
482/// feature rows** so peak working memory scales with a block, not the full
483/// feature axis. Every update is elementwise per `(gene, sample)` and δ is
484/// per-gene given the shared per-sample / per-batch sizes, so the fit is
485/// separable across features — block-independent descent is numerically
486/// identical to the joint fit. For `MeanOnly` output the heavy
487/// `a_stat`/`b_stat` planes are dropped per block, so the assembled result
488/// carries only posterior estimates (bge never calls `posterior_sample`).
489pub(super) fn optimize(
490    stat: &CollapsedStat,
491    hyper: (f32, f32),
492    num_iter: usize,
493    label: &str,
494    out_target: CalibrateTarget,
495    // Retain a_stat/b_stat even under MeanOnly — the finest level of an
496    // `--emit-pb-reference` run serializes `evidence_mean`, which reads them.
497    keep_stats: bool,
498) -> anyhow::Result<CollapsedOut> {
499    let num_genes = stat.num_genes();
500    let num_samples = stat.num_samples();
501    let num_batches = stat.num_batches();
502
503    // Block width bounds a single working plane, not the whole fit. Each block
504    // holds ~19 `block_rows × num_samples` planes live (4 Gamma params × 3
505    // planes + sufficient stats + descent scratch), so a 2M-element plane
506    // (~8 MB in f32) caps a block at ~150 MB regardless of how many features
507    // the panel carries. `block_rows ≈ 2M/num_samples` keeps the plane fixed
508    // at every refinement level (few samples → wider blocks, same plane).
509    //
510    // The old 32M target barely blocked at all: at `k=1024` it gave
511    // `block_rows ≈ 31k`, so a ~41k-gene panel split into just 2 uneven blocks
512    // and peak sat at ~76% of the un-blocked cost. 2M gives ~21 even blocks.
513    const BLOCK_ELEMS: usize = 2_000_000;
514    let block_rows = (BLOCK_ELEMS / num_samples.max(1)).clamp(1, num_genes.max(1));
515    // Same crate helper the sparse block-visitors use; returns half-open
516    // `(lb, ub)` gene ranges of `block_rows` each (last one short).
517    let jobs = create_jobs(num_genes, num_samples, Some(block_rows));
518    let n_blocks = jobs.len();
519
520    let dims = format!("{num_genes} genes × {num_samples} samples");
521
522    // `posterior_sample` (topic path) reads a_stat/b_stat; bge (MeanOnly)
523    // does not, so those planes can be discarded per block — that's what
524    // keeps the assembled output from holding the full sufficient stats.
525    let keep_stats = keep_stats || matches!(out_target, CalibrateTarget::All);
526
527    // The moving unit is one descent iteration whenever the batch-correction
528    // loop runs: each block ticks `num_iter` times (`optimize_block` handed the
529    // bar), so the bar is `n_blocks × num_iter` ticks and keeps advancing even
530    // while a single block is mid-fit. Without batches a block is one
531    // closed-form pass with no inner loop, so there the unit is the block and
532    // the loop below ticks once per finished block instead.
533    let batched = num_batches > 1;
534    let total = if batched {
535        n_blocks * num_iter
536    } else {
537        n_blocks
538    };
539    let msg = if batched {
540        format!("{label} opt-iters · {dims} · {n_blocks} blocks")
541    } else {
542        format!("{label} gene-blocks · {dims}")
543    };
544
545    // One block covers the whole panel: `select_rows` would clone the full
546    // stat for nothing, and there is no job to parallelize. A moving
547    // iteration bar (batched) still beats a spinner; without batches there is
548    // nothing to count, so a spinner avoids a `0/1` bar frozen at zero.
549    if n_blocks <= 1 {
550        if batched {
551            let prog = styled_progress_bar(total as u64, &msg);
552            let mut out = optimize_block(stat, hyper, num_iter, out_target, Some(&prog))?;
553            prog.finish_and_clear();
554            if !keep_stats {
555                out.release_stats();
556            }
557            return Ok(out);
558        }
559        let spin =
560            legume_numeric::matrix::progress::new_spinner("{spinner} [{elapsed_precise}] {msg}")
561                .with_message(format!("{label} single gene-block · {dims}"));
562        let mut out = optimize_block(stat, hyper, num_iter, out_target, None)?;
563        spin.finish_and_clear();
564        if !keep_stats {
565            out.release_stats();
566        }
567        return Ok(out);
568    }
569
570    // The gene axis is separable — a block's fit depends only on its own rows
571    // and the shared per-sample sizes, never on other genes — so the blocks
572    // are independent jobs run concurrently. Peak memory is now
573    // (rayon width × ~150 MB), bounded by the plane cap above rather than by
574    // the feature count.
575    let prog = styled_progress_bar(total as u64, &msg);
576
577    // `.map(..).collect::<Result<Vec>>()` preserves input order, so blocks
578    // reassemble in gene order. `prog.inc` is atomic: batched blocks tick per
579    // iteration inside `optimize_block`; otherwise tick once per finished block.
580    let outs = jobs
581        .par_iter()
582        .map(|&(lb, ub)| -> anyhow::Result<CollapsedOut> {
583            let sub = stat.select_rows(lb, ub - lb);
584            let mut out_b =
585                optimize_block(&sub, hyper, num_iter, out_target, batched.then_some(&prog))?;
586            if !keep_stats {
587                // Free a_stat/b_stat now so the accumulated blocks never add
588                // up to the full sufficient-stat planes.
589                out_b.release_stats();
590            }
591            if !batched {
592                prog.inc(1);
593            }
594            Ok(out_b)
595        })
596        .collect::<anyhow::Result<Vec<_>>>()?;
597    prog.finish_and_clear();
598
599    let mut mu_obs: Vec<GammaMatrix> = Vec::with_capacity(n_blocks);
600    let mut mu_adj: Vec<GammaMatrix> = Vec::new();
601    let mut mu_res: Vec<GammaMatrix> = Vec::new();
602    let mut gam: Vec<GammaMatrix> = Vec::new();
603    let mut del: Vec<GammaMatrix> = Vec::new();
604    for out_b in outs {
605        mu_obs.push(out_b.mu_observed);
606        if let Some(x) = out_b.mu_adjusted {
607            mu_adj.push(x);
608        }
609        if let Some(x) = out_b.mu_residual {
610            mu_res.push(x);
611        }
612        if let Some(x) = out_b.gamma {
613            gam.push(x);
614        }
615        if let Some(x) = out_b.delta {
616            del.push(x);
617        }
618    }
619
620    let join = |v: Vec<GammaMatrix>| -> Option<GammaMatrix> {
621        (!v.is_empty()).then(|| GammaMatrix::vconcat(v, keep_stats))
622    };
623    Ok(CollapsedOut {
624        mu_observed: GammaMatrix::vconcat(mu_obs, keep_stats),
625        mu_adjusted: join(mu_adj),
626        mu_residual: join(mu_res),
627        gamma: join(gam),
628        delta: join(del),
629        stats_kept: keep_stats,
630    })
631}
632
633/// output struct to make the model parameters more accessible
634#[derive(Debug, Clone)]
635pub struct CollapsedOut {
636    pub mu_observed: GammaMatrix,
637    pub mu_adjusted: Option<GammaMatrix>,
638    pub mu_residual: Option<GammaMatrix>,
639    pub gamma: Option<GammaMatrix>,
640    pub delta: Option<GammaMatrix>,
641    /// Whether `a_stat`/`b_stat` remain on the parameters. False after
642    /// [`Self::release_stats`] or a `MeanOnly` assemble that dropped them.
643    /// [`Self::observed_counts`] requires this to be true.
644    pub stats_kept: bool,
645}
646
647impl CollapsedOut {
648    /// Drop `a_stat`/`b_stat` on every contained parameter. Safe when the
649    /// consumer reads posterior means/log-means but never `posterior_sample`
650    /// (bge). Used by the gene-blocked `optimize` to keep accumulated blocks
651    /// from summing to the full sufficient-stat planes.
652    fn release_stats(&mut self) {
653        self.mu_observed.release_stats();
654        for p in [
655            &mut self.mu_adjusted,
656            &mut self.mu_residual,
657            &mut self.gamma,
658            &mut self.delta,
659        ] {
660            if let Some(g) = p.as_mut() {
661                g.release_stats();
662            }
663        }
664        self.stats_kept = false;
665    }
666
667    /// The observed counts `[D × S]` and pseudobulk sizes `[S]` of this level:
668    /// each entry is the prior-free rate `evidence_mean` (data sum over data
669    /// denominator) times the pseudobulk's cell count, and a size is the number
670    /// of cells `cell_to_pb` puts in it. Reads the observed (not batch-adjusted)
671    /// posterior's sufficient statistics, so the collapse must have kept them
672    /// (`MultilevelParams::keep_finest_stats` for the finest level).
673    ///
674    /// # Errors
675    ///
676    /// - sufficient statistics were released (`keep_finest_stats` was false
677    ///   under `MeanOnly` calibration)
678    /// - a `cell_to_pb` entry is out of range for this level's pseudobulk count
679    pub fn observed_counts(
680        &self,
681        cell_to_pb: &[usize],
682    ) -> anyhow::Result<(DMatrix<f32>, Vec<f32>)> {
683        anyhow::ensure!(
684            self.stats_kept,
685            "CollapsedOut::observed_counts needs kept sufficient statistics; \
686             set MultilevelParams::keep_finest_stats"
687        );
688        let param = &self.mu_observed;
689        let (d, n_pb) = (param.nrows(), param.ncols());
690        let mut sizes = vec![0f32; n_pb];
691        let mut n_oob = 0usize;
692        for &pb in cell_to_pb {
693            if pb < n_pb {
694                sizes[pb] += 1.0;
695            } else {
696                n_oob += 1;
697            }
698        }
699        anyhow::ensure!(
700            n_oob == 0,
701            "CollapsedOut::observed_counts: {n_oob} cell→pseudobulk id(s) \
702             are out of range for {n_pb} pseudobulk(s)"
703        );
704        let counts = DMatrix::<f32>::from_fn(d, n_pb, |g, s| param.evidence_mean(g, s) * sizes[s]);
705        Ok((counts, sizes))
706    }
707}
708
709/// a struct to hold the sufficient statistics for the model
710#[derive(Debug, Clone)]
711pub struct CollapsedStat {
712    pub observed_sum_ds: nalgebra::DMatrix<f32>, // observed sum within each sample
713    pub imputed_sum_ds: nalgebra::DMatrix<f32>,  // counterfactual sum within each sample
714    pub size_s: nalgebra::DVector<f32>,          // sample s size
715    pub observed_sum_db: nalgebra::DMatrix<f32>, // divergence numerator
716    pub n_bs: nalgebra::DMatrix<f32>,            // batch-specific sample size
717    /// Counterfactual SOURCE mass per (batch, sample): how much of sample `s`'s
718    /// imputed profile was drawn from batch `b`'s cells (each matched cell's
719    /// weights sum to 1, so a column sums to the matched cell count). With
720    /// `n_bs` this is what identifies `δ`: `E[imputed_gs] = μ_gs Σ_b δ_gb w_bs`
721    /// against `E[observed_gs] = μ_gs Σ_b δ_gb n_bs`.
722    pub matched_bs: nalgebra::DMatrix<f32>,
723    /// Batches whose frame IS the frame: `δ` is normalised so their per-gene
724    /// geometric mean is 1. Empty = pooled, where every batch's mass weighs in.
725    pub anchor_batches: Vec<usize>,
726    /// Per-(gene, sample) effective size: the mass of sample `s` whose SOURCE
727    /// actually measures gene `g`. `None` means fully observed — every gene
728    /// sees `size_s[s]`, and `optimize` takes the exact historical code path.
729    ///
730    /// This is what distinguishes "unmeasured" from "measured as zero": a
731    /// gene absent from one cohort's panel reads 0 in all of its cells, and
732    /// without this denominator the estimate is silently dragged toward zero
733    /// by cells that never looked. Set by [`attach_observability`]; see the
734    /// plan note there for the factorized construction.
735    pub size_ds: Option<nalgebra::DMatrix<f32>>,
736    /// Per-(gene, batch) observability of δ's evidence: 1.0 when any source
737    /// in batch `b` measures gene `g`, else 0.0. Zeroing δ's denominator
738    /// there sends δ to its prior (≈ 1, "no adjustment") instead of to an
739    /// extreme driven by structurally-absent counts. `None` = all observed.
740    pub obs_mask_db: Option<nalgebra::DMatrix<f32>>,
741    /// When true (CNV strata present), samples with no counterfactual mass
742    /// (`matched_bs` column sum 0) are excluded from the δ numerator and
743    /// denominator so private-clone observed mass cannot pull δ. `update_mu`
744    /// still uses the full masses. Default false — bit-identical to before.
745    pub exclude_unmatched_from_delta: bool,
746}
747
748impl CollapsedStat {
749    pub fn new(ngene: usize, nsample: usize, nbatch: usize) -> Self {
750        Self {
751            observed_sum_ds: nalgebra::DMatrix::<f32>::zeros(ngene, nsample),
752            imputed_sum_ds: nalgebra::DMatrix::<f32>::zeros(ngene, nsample),
753            size_s: nalgebra::DVector::<f32>::zeros(nsample),
754            observed_sum_db: nalgebra::DMatrix::<f32>::zeros(ngene, nbatch),
755            n_bs: nalgebra::DMatrix::<f32>::zeros(nbatch, nsample),
756            matched_bs: nalgebra::DMatrix::<f32>::zeros(nbatch, nsample),
757            anchor_batches: Vec::new(),
758            size_ds: None,
759            obs_mask_db: None,
760            exclude_unmatched_from_delta: false,
761        }
762    }
763
764    pub fn num_genes(&self) -> usize {
765        self.observed_sum_ds.nrows()
766    }
767
768    pub fn num_samples(&self) -> usize {
769        self.observed_sum_ds.ncols()
770    }
771
772    pub fn num_batches(&self) -> usize {
773        self.observed_sum_db.ncols()
774    }
775
776    /// Per-batch weights that define the frame `δ` is pinned to: the anchors
777    /// when there are any (each weighted 1), else every batch by its own cell
778    /// mass.
779    pub fn frame_weights(&self) -> DVector<f32> {
780        let nb = self.num_batches();
781        if self.anchor_batches.is_empty() {
782            DVector::from_fn(nb, |b, _| self.n_bs.row(b).sum())
783        } else {
784            let mut w = DVector::<f32>::zeros(nb);
785            for &b in &self.anchor_batches {
786                w[b] = 1.0;
787            }
788            w
789        }
790    }
791
792    pub fn clear(&mut self) {
793        self.observed_sum_ds.fill(0_f32);
794        self.imputed_sum_ds.fill(0_f32);
795        self.observed_sum_db.fill(0_f32);
796        self.size_s.fill(0_f32);
797        self.n_bs.fill(0_f32);
798        self.matched_bs.fill(0_f32);
799        self.anchor_batches.clear();
800        self.size_ds = None;
801        self.obs_mask_db = None;
802    }
803
804    /// Select a subset of sample columns (groups) by index.
805    pub fn select_columns(&self, indices: &[usize]) -> Self {
806        let n_new = indices.len();
807        let ng = self.num_genes();
808        let nb = self.num_batches();
809        let mut out = Self::new(ng, n_new, nb);
810        for (new_col, &old_col) in indices.iter().enumerate() {
811            out.observed_sum_ds
812                .column_mut(new_col)
813                .copy_from(&self.observed_sum_ds.column(old_col));
814            out.imputed_sum_ds
815                .column_mut(new_col)
816                .copy_from(&self.imputed_sum_ds.column(old_col));
817            out.size_s[new_col] = self.size_s[old_col];
818            for b in 0..nb {
819                out.n_bs[(b, new_col)] = self.n_bs[(b, old_col)];
820                out.matched_bs[(b, new_col)] = self.matched_bs[(b, old_col)];
821            }
822        }
823        if let Some(size_ds) = self.size_ds.as_ref() {
824            out.size_ds = Some(nalgebra::DMatrix::from_fn(ng, n_new, |g, new_col| {
825                size_ds[(g, indices[new_col])]
826            }));
827        }
828        out.obs_mask_db = self.obs_mask_db.clone();
829        out.observed_sum_db.copy_from(&self.observed_sum_db);
830        out.anchor_batches = self.anchor_batches.clone();
831        out.exclude_unmatched_from_delta = self.exclude_unmatched_from_delta;
832        out
833    }
834
835    /// Select a contiguous block of feature rows (`r0..r0+nrows`). Per-gene
836    /// stats (`observed`/`imputed`/`observed_db`) are sliced; the per-sample
837    /// `size_s` and per-batch `n_bs`/`matched_bs` are shared, so they're copied
838    /// whole. Used by the gene-blocked `optimize`.
839    pub fn select_rows(&self, r0: usize, nrows: usize) -> Self {
840        Self {
841            observed_sum_ds: self.observed_sum_ds.rows(r0, nrows).into_owned(),
842            imputed_sum_ds: self.imputed_sum_ds.rows(r0, nrows).into_owned(),
843            size_s: self.size_s.clone(),
844            observed_sum_db: self.observed_sum_db.rows(r0, nrows).into_owned(),
845            n_bs: self.n_bs.clone(),
846            matched_bs: self.matched_bs.clone(),
847            anchor_batches: self.anchor_batches.clone(),
848            size_ds: self
849                .size_ds
850                .as_ref()
851                .map(|m| m.rows(r0, nrows).into_owned()),
852            obs_mask_db: self
853                .obs_mask_db
854                .as_ref()
855                .map(|m| m.rows(r0, nrows).into_owned()),
856            exclude_unmatched_from_delta: self.exclude_unmatched_from_delta,
857        }
858    }
859}
860
861/// Resample from over-resolved sufficient statistics: randomly select
862/// ~half the groups, then optimise to produce a fresh `CollapsedOut`.
863pub fn resample_and_optimize(
864    stat: &CollapsedStat,
865    rng: &mut impl rand::Rng,
866    opt_iter: usize,
867) -> anyhow::Result<CollapsedOut> {
868    use rand::seq::SliceRandom;
869    let n = stat.num_samples();
870    let target = n / 2;
871    let mut indices: Vec<usize> = (0..n).collect();
872    indices.shuffle(rng);
873    indices.truncate(target);
874    indices.sort_unstable();
875    let sub_stat = stat.select_columns(&indices);
876    optimize(
877        &sub_stat,
878        (1.0, 1.0),
879        opt_iter,
880        "Optimizing",
881        CalibrateTarget::All,
882        false,
883    )
884}
885
886///////////////////////////////////////////////////////////////
887// Multi-level (METIS-inspired) collapsing for batch effects //
888///////////////////////////////////////////////////////////////
889
890pub(super) const DEFAULT_NUM_LEVELS: usize = 2;
891pub(super) const DEFAULT_COARSEST_SORT_DIM: usize = 7;
892
893/// pb-samples of each group, in pb-sample order: with these one worker owns
894/// one output column of a `[· × group]` plane.
895fn pbsamps_by_group(pbsamp_to_group: &[usize], num_groups: usize) -> Vec<Vec<usize>> {
896    let mut out: Vec<Vec<usize>> = vec![Vec::new(); num_groups];
897    for (pbsamp, &g) in pbsamp_to_group.iter().enumerate() {
898        out[g].push(pbsamp);
899    }
900    out
901}
902
903/// The observed side of the finest stat — `observed_sum_ds`, `size_s` and,
904/// with two or more batches, `observed_sum_db` and `n_bs` — from the pb-sample
905/// aggregates instead of another pass over the data: a finest group is a union
906/// of pb-samples, each within one batch, so its sums are sums of
907/// `gene_sums`. `gene_sums` must be `data_vec`'s
908/// ([`collect_pb_sample_gene_sums`], multiplicity-weighted like the sizes
909/// here). Adds to whatever the stat already holds.
910pub(super) fn collect_observed_stat_from_pb_samples(
911    data_vec: &SparseIoVec,
912    layout: &PbSampleLayout,
913    gene_sums: &[Vec<(usize, f32)>],
914    pbsamp_to_group: &[usize],
915    stat: &mut CollapsedStat,
916) -> anyhow::Result<()> {
917    let num_pb = layout.cell_counts.len();
918    let num_genes = stat.num_genes();
919    let num_groups = stat.num_samples();
920    let num_batches = stat.num_batches();
921    anyhow::ensure!(
922        gene_sums.len() == num_pb && pbsamp_to_group.len() == num_pb,
923        "{} gene sums and {} group labels for {num_pb} pb-samples",
924        gene_sums.len(),
925        pbsamp_to_group.len(),
926    );
927    let with_batches = num_batches >= 2;
928
929    // Sizes: one multiplicity per column, as the columns are assigned.
930    for (c, &pbsamp) in layout.cell_to_pbsamp.iter().enumerate() {
931        if pbsamp == usize::MAX {
932            continue;
933        }
934        let w = data_vec.column_multiplicity(c);
935        let g = pbsamp_to_group[pbsamp];
936        stat.size_s[g] += w;
937        if with_batches {
938            stat.n_bs[(layout.pb_sample_to_batch[pbsamp], g)] += w;
939        }
940    }
941    if num_genes == 0 {
942        return Ok(());
943    }
944
945    let add_gene_sums = |column: &mut [f32], pbsamps: &Vec<usize>| {
946        for &pbsamp in pbsamps {
947            for &(gene, val) in &gene_sums[pbsamp] {
948                column[gene] += val;
949            }
950        }
951    };
952    // nalgebra stores column-major, so a `num_genes` chunk is one column.
953    stat.observed_sum_ds
954        .as_mut_slice()
955        .par_chunks_mut(num_genes)
956        .zip(pbsamps_by_group(pbsamp_to_group, num_groups).par_iter())
957        .for_each(|(column, pbsamps)| add_gene_sums(column, pbsamps));
958    if with_batches {
959        stat.observed_sum_db
960            .as_mut_slice()
961            .par_chunks_mut(num_genes)
962            .zip(pbsamps_by_group(&layout.pb_sample_to_batch, num_batches).par_iter())
963            .for_each(|(column, pbsamps)| add_gene_sums(column, pbsamps));
964    }
965    Ok(())
966}
967
968/// Cross-batch matched-stat accumulation on top of the pb-sample
969/// layout. `matches[pbsamp]` are the pb-samples matched to `pbsamp` with
970/// their distances ([`per_batch_sc_neighbors`] under the same
971/// `anchor_batches`); their distance-weighted mean profile is the
972/// counterfactual, emitted as gene sums into `stat.imputed_sum_ds` and as
973/// per-batch source mass into `stat.matched_bs`.
974pub(super) fn collect_matched_stat_coarse(
975    layout: &PbSampleLayout,
976    gene_sums: &[Vec<(usize, f32)>],
977    pbsamp_to_group: &[usize],
978    matches: &[Vec<(usize, f32)>],
979    anchor_batches: Option<&[usize]>,
980    stat: &mut CollapsedStat,
981) -> anyhow::Result<()> {
982    let num_pb = layout.cell_counts.len();
983    debug_assert_eq!(pbsamp_to_group.len(), num_pb);
984    anyhow::ensure!(
985        matches.len() == num_pb,
986        "{} match lists for {num_pb} pb-samples",
987        matches.len(),
988    );
989
990    // The batches that source the counterfactual are the batches that pin δ:
991    // recorded here, where that mass is written, so no caller can forget.
992    stat.anchor_batches = anchor_batches.map(<[usize]>::to_vec).unwrap_or_default();
993
994    let num_genes = stat.num_genes();
995    let num_batches = stat.num_batches();
996    if num_genes == 0 || num_batches == 0 {
997        return Ok(());
998    }
999    let group_to_pbsamps = pbsamps_by_group(pbsamp_to_group, stat.num_samples());
1000
1001    let prog_bar = styled_progress_bar(num_pb as u64, "pb-samples (matched stats)");
1002
1003    // One group's pb-samples write one column of each plane (column-major
1004    // storage: a `num_genes` / `num_batches` chunk), so the workers share
1005    // nothing. Each holds a dense counterfactual, cleared through the list of
1006    // genes a pb-sample touched.
1007    stat.imputed_sum_ds
1008        .as_mut_slice()
1009        .par_chunks_mut(num_genes)
1010        .zip(stat.matched_bs.as_mut_slice().par_chunks_mut(num_batches))
1011        .zip(group_to_pbsamps.par_iter())
1012        .for_each_init(
1013            || (vec![0f32; num_genes], Vec::<usize>::new()),
1014            |(y_hat, touched), ((imputed_s, matched_s), pbsamps)| {
1015                for &pbsamp_idx in pbsamps {
1016                    let sc_count = layout.cell_counts[pbsamp_idx];
1017                    let filtered = &matches[pbsamp_idx];
1018                    if sc_count < 1.0 || filtered.is_empty() {
1019                        continue;
1020                    }
1021
1022                    // Softmax weights from negative distances
1023                    let max_neg_d = filtered
1024                        .iter()
1025                        .map(|(_, d)| -d)
1026                        .fold(f32::NEG_INFINITY, f32::max);
1027                    let mut weights: Vec<f32> = filtered
1028                        .iter()
1029                        .map(|(_, d)| (-d - max_neg_d).exp())
1030                        .collect();
1031                    let w_sum: f32 = weights.iter().sum();
1032                    if w_sum > 0.0 {
1033                        weights.iter_mut().for_each(|w| *w /= w_sum);
1034                    }
1035
1036                    // Counterfactual: weighted average of matched pb-samples'
1037                    // per-cell gene expression
1038                    // y_hat[g] = sum_k w[k] * gene_sums[k][g] / cell_counts[k]
1039                    for ((matched_sc, _), &w) in filtered.iter().zip(weights.iter()) {
1040                        let matched_count = layout.cell_counts[*matched_sc];
1041                        if matched_count < 1.0 {
1042                            continue;
1043                        }
1044                        let inv_count = 1.0 / matched_count;
1045                        for &(gene, val) in &gene_sums[*matched_sc] {
1046                            // A gene whose sum is still zero may be listed
1047                            // again; its second visit below adds zero.
1048                            if y_hat[gene] == 0.0 {
1049                                touched.push(gene);
1050                            }
1051                            y_hat[gene] += w * val * inv_count;
1052                        }
1053                    }
1054
1055                    // imputed_sum_ds[g, s] += cell_counts[pbsamp] * y_hat[g]
1056                    for gene in touched.drain(..) {
1057                        imputed_s[gene] += sc_count * std::mem::take(&mut y_hat[gene]);
1058                    }
1059
1060                    // Source mass per batch: this pb-sample's `sc_count` cells
1061                    // split their unit weight over the matched pb-samples'
1062                    // batches.
1063                    for ((matched_sc, _), &w) in filtered.iter().zip(weights.iter()) {
1064                        if layout.cell_counts[*matched_sc] < 1.0 {
1065                            continue;
1066                        }
1067                        matched_s[layout.pb_sample_to_batch[*matched_sc]] += sc_count * w;
1068                    }
1069                }
1070                prog_bar.inc(pbsamps.len() as u64);
1071            },
1072        );
1073
1074    prog_bar.finish_and_clear();
1075    Ok(())
1076}
1077
1078/// Format a per-cell group-index vector as fixed-width zero-padded strings
1079/// so that `SparseIoVec::assign_groups`' lexicographic key sort agrees with
1080/// numeric order. `k` is the number of distinct groups (`group ∈ 0..k`).
1081/// (batch-marginal).
1082pub(super) fn merge_stat(
1083    fine_stat: &CollapsedStat,
1084    fine_to_coarse: &[usize],
1085    num_coarse_groups: usize,
1086) -> CollapsedStat {
1087    let num_genes = fine_stat.num_genes();
1088    let num_batches = fine_stat.num_batches();
1089    let mut coarse = CollapsedStat::new(num_genes, num_coarse_groups, num_batches);
1090
1091    for (fine_g, &coarse_g) in fine_to_coarse.iter().enumerate() {
1092        coarse
1093            .observed_sum_ds
1094            .column_mut(coarse_g)
1095            .add_assign(&fine_stat.observed_sum_ds.column(fine_g));
1096        coarse
1097            .imputed_sum_ds
1098            .column_mut(coarse_g)
1099            .add_assign(&fine_stat.imputed_sum_ds.column(fine_g));
1100        coarse.size_s[coarse_g] += fine_stat.size_s[fine_g];
1101        for b in 0..num_batches {
1102            coarse.n_bs[(b, coarse_g)] += fine_stat.n_bs[(b, fine_g)];
1103            coarse.matched_bs[(b, coarse_g)] += fine_stat.matched_bs[(b, fine_g)];
1104        }
1105    }
1106
1107    // Observability descends with the merge: effective sizes add like any
1108    // other per-sample mass, and the per-batch mask is sample-free.
1109    if let Some(fine_size_ds) = fine_stat.size_ds.as_ref() {
1110        let mut size_ds = nalgebra::DMatrix::<f32>::zeros(num_genes, num_coarse_groups);
1111        for (fine_g, &coarse_g) in fine_to_coarse.iter().enumerate() {
1112            size_ds
1113                .column_mut(coarse_g)
1114                .add_assign(&fine_size_ds.column(fine_g));
1115        }
1116        coarse.size_ds = Some(size_ds);
1117    }
1118    coarse.obs_mask_db = fine_stat.obs_mask_db.clone();
1119    coarse.anchor_batches = fine_stat.anchor_batches.clone();
1120    coarse.exclude_unmatched_from_delta = fine_stat.exclude_unmatched_from_delta;
1121
1122    coarse.observed_sum_db.copy_from(&fine_stat.observed_sum_db);
1123    coarse
1124}
1125
1126#[cfg(test)]
1127#[path = "stats_tests.rs"]
1128mod gene_block_tests;