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, euclidean_distances) = match knn_params.reference_indices {
38        Some(target_indices) => data_vec.read_matched_columns_csc(
39            cells.iter().cloned(),
40            target_indices,
41            knn_cells,
42            true,
43        )?,
44        None => {
45            let (mat, src, _matched, dist) = data_vec.read_neighbouring_columns_csc(
46                cells.iter().cloned(),
47                knn_batches,
48                knn_cells,
49                true,
50                None,
51            )?;
52            (mat, src, dist)
53        }
54    };
55
56    let y1_pos: HashMap<_, _> = cells
57        .iter()
58        .cloned()
59        .enumerate()
60        .map(|(i, p)| (p, i))
61        .collect();
62
63    let neg_distance_triplets = source_columns
64        .iter()
65        .zip(euclidean_distances.iter())
66        .enumerate()
67        .map(|(t, (&s, &d))| (t, y1_pos[&s], -d))
68        .collect::<Vec<_>>();
69
70    ////////////////////////////////////////////////////////
71    // zhat[g,j]  =  sum_k w[j,k] * z[g,k] / sum_k w[j,k] //
72    // zsum[g,s]  =  sum_j zhat[g,j]                      //
73    ////////////////////////////////////////////////////////
74
75    // Normalize distance for each source cell and take a
76    // weighted average of the matched vectors using this
77    // weight vector
78    let ww = CscMat::from_nonzero_triplets(
79        y0_matched.ncols(),
80        cells.len(),
81        neg_distance_triplets.as_ref(),
82    )?
83    .normalize_exp_logits_columns();
84
85    let y1_hat = &y0_matched * &ww;
86    let source_batches = data_vec.get_batch_membership(source_columns.iter().cloned());
87
88    let mut stat = arc_stat.lock().expect("lock stat");
89
90    // Source mass per batch: each cell's weights sum to 1 over its matches.
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[(source_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) =
263            if stat.exclude_unmatched_from_delta {
264                let mut obs = stat.observed_sum_db.clone();
265                let mut n_bs_d = n_bs.clone();
266                for s in 0..num_samples {
267                    if w_bs.column(s).sum() > 0.0 {
268                        continue;
269                    }
270                    let size = stat.size_s[s];
271                    if size <= 0.0 {
272                        continue;
273                    }
274                    for b in 0..num_batches {
275                        let n = n_bs[(b, s)];
276                        if n <= 0.0 {
277                            continue;
278                        }
279                        let frac = n / size;
280                        for g in 0..num_genes {
281                            obs[(g, b)] -= stat.observed_sum_ds[(g, s)] * frac;
282                        }
283                        n_bs_d[(b, s)] = 0.0;
284                    }
285                }
286                let own_plus = &n_bs_d + w_bs;
287                (obs, own_plus, n_bs_d)
288            } else {
289                (
290                    stat.observed_sum_db.clone(),
291                    own_plus_src.clone(),
292                    n_bs.clone(),
293                )
294            };
295        let obs_plus_imp = &stat.observed_sum_ds + &stat.imputed_sum_ds;
296        // Fraction of each (gene, sample)'s mass whose source measures the gene;
297        // `None` = fully observed.
298        let obs_frac: Option<DMatrix<f32>> = stat.size_ds.as_ref().map(|size_ds| {
299            DMatrix::from_fn(num_genes, num_samples, |g, s| {
300                let n = stat.size_s[s];
301                if n > 0.0 {
302                    size_ds[(g, s)] / n
303                } else {
304                    0.0
305                }
306            })
307        });
308        let frame_w = if stat.exclude_unmatched_from_delta && stat.anchor_batches.is_empty() {
309            // Own matched mass only — do not add counterfactual source mass
310            // (that would double-count vs unstratified `frame_weights()`).
311            DVector::from_fn(num_batches, |b, _| n_bs_d.row(b).sum())
312        } else {
313            stat.frame_weights()
314        };
315        for b in 0..num_batches {
316            if frame_w[b] <= 0.0 {
317                warn!(
318                    "batch {b} has no matched mass for δ (frame weight 0); \
319                     δ_·{b} stays on prior ≈ 1 (e.g. blast-only / clone-only donor)"
320                );
321            }
322        }
323        let own_plus_src_delta_t = own_plus_src_for_delta.transpose();
324        let w_bs_t = w_bs.transpose();
325
326        let mut mu_adj_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
327        let mut delta_param = GammaMatrix::new((num_genes, num_batches), a0, b0);
328        let mut delta_gb = DMatrix::<f32>::from_element(num_genes, num_batches, 1.0);
329
330        // μ given δ: (obs + imp) / (frac · Σ_b δ_gb (n_bs + w_bs))
331        let update_mu = |mu_adj_param: &mut GammaMatrix, delta_gb: &DMatrix<f32>| {
332            let mut denom_ds = delta_gb * &own_plus_src;
333            if let Some(f) = obs_frac.as_ref() {
334                denom_ds.component_mul_assign(f);
335            }
336            mu_adj_param.update_stat(&obs_plus_imp, &denom_ds);
337            mu_adj_param.calibrate_with(CalibrateTarget::MeanOnly);
338        };
339
340        // Scratch planes, allocated once: every [g × s] temporary below is
341        // rewritten in place each iteration rather than reallocated.
342        let mut imp_share = DMatrix::<f32>::zeros(num_genes, num_samples);
343        let mut mu_frac = DMatrix::<f32>::zeros(num_genes, num_samples);
344        for _opt_iter in 0..num_iter {
345            #[cfg(debug_assertions)]
346            {
347                debug!("iteration: {}", _opt_iter);
348            }
349
350            update_mu(&mut mu_adj_param, &delta_gb);
351            let mu_ds = mu_adj_param.posterior_mean();
352
353            // δ given μ (one EM step): the observed side is exact per batch
354            // (`observed_sum_db`); the counterfactual side splits each
355            // sample's imputed sum over its source batches in proportion to
356            // δ_gb · w_bs.
357            //   num_gb = obs_db + Σ_s imp_gs · δ_gb w_bs / Σ_b' δ_gb' w_b's
358            //   den_gb = Σ_s μ_gs · frac_gs · (n_bs + w_bs)
359            imp_share.gemm(1.0, &delta_gb, w_bs, 0.0); // Σ_b δ_gb w_bs
360            imp_share.zip_apply(&stat.imputed_sum_ds, |z, x| {
361                *z = if *z > 0.0 { x / *z } else { 0.0 };
362            });
363            let mut num_db = &obs_db_for_delta + (&imp_share * &w_bs_t).component_mul(&delta_gb);
364            let mu_frac_ref: &DMatrix<f32> = match obs_frac.as_ref() {
365                Some(f) => {
366                    mu_frac.copy_from(mu_ds);
367                    mu_frac.component_mul_assign(f);
368                    &mu_frac
369                }
370                None => mu_ds,
371            };
372            let mut den_db = mu_frac_ref * &own_plus_src_delta_t; // [g × b]
373            if let Some(mask) = stat.obs_mask_db.as_ref() {
374                // Zeroing a (gene, batch) entry means "this batch carries no δ
375                // evidence for this gene"; masking BOTH sides lands the
376                // posterior on the prior exactly (≈ 1, "no adjustment").
377                num_db.component_mul_assign(mask);
378                den_db.component_mul_assign(mask);
379            }
380            // Pin the per-gene scale: the frame batches' weighted geometric
381            // mean of δ is 1. Folded into the denominators so the Gamma
382            // posterior itself carries the normalised value.
383            let den_db = pin_delta_scale(
384                &num_db,
385                &den_db,
386                (a0, b0),
387                &frame_w,
388                stat.obs_mask_db.as_ref(),
389            );
390            delta_param.update_stat(&num_db, &den_db);
391            delta_param.calibrate_with(CalibrateTarget::MeanOnly);
392            delta_gb.copy_from(delta_param.posterior_mean());
393
394            if let Some(p) = prog {
395                p.inc(1);
396            }
397        }
398        // μ consistent with the final δ.
399        update_mu(&mut mu_adj_param, &delta_gb);
400        mu_adj_param.calibrate_with(out_target);
401        delta_param.calibrate_with(out_target);
402
403        // Per-pseudobulk readouts against the batch-free μ, on the documented
404        // scale: E[y] = μ_resid · μ (own fold) and E[ŷ] = γ · μ (source fold).
405        // Each denominator is μ times a per-sample mass (own cells, or the
406        // counterfactual's matched cells), scaled by the observed fraction when
407        // a panel gap makes it less than full.
408        let mu_ds = mu_adj_param.posterior_mean();
409        let mass_times_mu = |per_sample: &dyn Fn(usize) -> f32| {
410            let mut m = mu_ds.clone();
411            for s in 0..num_samples {
412                m.column_mut(s).scale_mut(per_sample(s));
413            }
414            if let Some(f) = obs_frac.as_ref() {
415                m.component_mul_assign(f);
416            }
417            m
418        };
419        let resid_denom = mass_times_mu(&|s| stat.size_s[s]);
420        let mut mu_resid_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
421        mu_resid_param.update_stat(&stat.observed_sum_ds, &resid_denom);
422        mu_resid_param.calibrate_with(out_target);
423
424        let src_mass_s: Vec<f32> = (0..num_samples).map(|s| w_bs.column(s).sum()).collect();
425        let gamma_denom = mass_times_mu(&|s| src_mass_s[s]);
426        let mut gamma_param = GammaMatrix::new((num_genes, num_samples), a0, b0);
427        gamma_param.update_stat(&stat.imputed_sum_ds, &gamma_denom);
428        gamma_param.calibrate_with(out_target);
429
430        // Take the observed mean over the own mass.
431        {
432            let mut own_mass = DMatrix::<f32>::zeros(num_genes, num_samples);
433            add_effective_size(&mut own_mass, stat);
434            mu_param.update_stat(&stat.observed_sum_ds, &own_mass);
435            mu_param.calibrate_with(out_target);
436        };
437
438        // Sparse output (bge / MeanOnly): drop each mean's per-column prior
439        // baseline so its support is exactly the observed∪imputed cells.
440        // Downstream `from_pseudobulks` then yields sparse pb_blobs (held
441        // across all training epochs). `All` consumers keep the dense mean.
442        if matches!(out_target, CalibrateTarget::MeanOnly) {
443            mu_param.sparsify_mean_to_support(&stat.observed_sum_ds);
444            mu_adj_param.sparsify_mean_to_support(&obs_plus_imp);
445            gamma_param.sparsify_mean_to_support(&stat.imputed_sum_ds);
446            mu_resid_param.sparsify_mean_to_support(&stat.observed_sum_ds);
447        }
448
449        Ok(CollapsedOut {
450            mu_observed: mu_param,
451            mu_adjusted: Some(mu_adj_param),
452            mu_residual: Some(mu_resid_param),
453            gamma: Some(gamma_param),
454            delta: Some(delta_param),
455            stats_kept: true,
456        })
457    } else {
458        let mut denom_ds = DMatrix::<f32>::zeros(num_genes, num_samples);
459        add_effective_size(&mut denom_ds, stat);
460        mu_param.update_stat(&stat.observed_sum_ds, &denom_ds);
461        mu_param.calibrate_with(out_target);
462        if matches!(out_target, CalibrateTarget::MeanOnly) {
463            mu_param.sparsify_mean_to_support(&stat.observed_sum_ds);
464        }
465        Ok(CollapsedOut {
466            mu_observed: mu_param,
467            mu_adjusted: None,
468            mu_residual: None,
469            gamma: None,
470            delta: None,
471            stats_kept: true,
472        })
473    }
474}
475
476/// Optimize the mean parameters for the DC-Poisson collapse, **blocked over
477/// feature rows** so peak working memory scales with a block, not the full
478/// feature axis. Every update is elementwise per `(gene, sample)` and δ is
479/// per-gene given the shared per-sample / per-batch sizes, so the fit is
480/// separable across features — block-independent descent is numerically
481/// identical to the joint fit. For `MeanOnly` output the heavy
482/// `a_stat`/`b_stat` planes are dropped per block, so the assembled result
483/// carries only posterior estimates (bge never calls `posterior_sample`).
484pub(super) fn optimize(
485    stat: &CollapsedStat,
486    hyper: (f32, f32),
487    num_iter: usize,
488    label: &str,
489    out_target: CalibrateTarget,
490    // Retain a_stat/b_stat even under MeanOnly — the finest level of an
491    // `--emit-pb-reference` run serializes `evidence_mean`, which reads them.
492    keep_stats: bool,
493) -> anyhow::Result<CollapsedOut> {
494    let num_genes = stat.num_genes();
495    let num_samples = stat.num_samples();
496    let num_batches = stat.num_batches();
497
498    // Block width bounds a single working plane, not the whole fit. Each block
499    // holds ~19 `block_rows × num_samples` planes live (4 Gamma params × 3
500    // planes + sufficient stats + descent scratch), so a 2M-element plane
501    // (~8 MB in f32) caps a block at ~150 MB regardless of how many features
502    // the panel carries. `block_rows ≈ 2M/num_samples` keeps the plane fixed
503    // at every refinement level (few samples → wider blocks, same plane).
504    //
505    // The old 32M target barely blocked at all: at `k=1024` it gave
506    // `block_rows ≈ 31k`, so a ~41k-gene panel split into just 2 uneven blocks
507    // and peak sat at ~76% of the un-blocked cost. 2M gives ~21 even blocks.
508    const BLOCK_ELEMS: usize = 2_000_000;
509    let block_rows = (BLOCK_ELEMS / num_samples.max(1)).clamp(1, num_genes.max(1));
510    // Same crate helper the sparse block-visitors use; returns half-open
511    // `(lb, ub)` gene ranges of `block_rows` each (last one short).
512    let jobs = create_jobs(num_genes, num_samples, Some(block_rows));
513    let n_blocks = jobs.len();
514
515    let dims = format!("{num_genes} genes × {num_samples} samples");
516
517    // `posterior_sample` (topic path) reads a_stat/b_stat; bge (MeanOnly)
518    // does not, so those planes can be discarded per block — that's what
519    // keeps the assembled output from holding the full sufficient stats.
520    let keep_stats = keep_stats || matches!(out_target, CalibrateTarget::All);
521
522    // The moving unit is one descent iteration whenever the batch-correction
523    // loop runs: each block ticks `num_iter` times (`optimize_block` handed the
524    // bar), so the bar is `n_blocks × num_iter` ticks and keeps advancing even
525    // while a single block is mid-fit. Without batches a block is one
526    // closed-form pass with no inner loop, so there the unit is the block and
527    // the loop below ticks once per finished block instead.
528    let batched = num_batches > 1;
529    let total = if batched {
530        n_blocks * num_iter
531    } else {
532        n_blocks
533    };
534    let msg = if batched {
535        format!("{label} opt-iters · {dims} · {n_blocks} blocks")
536    } else {
537        format!("{label} gene-blocks · {dims}")
538    };
539
540    // One block covers the whole panel: `select_rows` would clone the full
541    // stat for nothing, and there is no job to parallelize. A moving
542    // iteration bar (batched) still beats a spinner; without batches there is
543    // nothing to count, so a spinner avoids a `0/1` bar frozen at zero.
544    if n_blocks <= 1 {
545        if batched {
546            let prog = styled_progress_bar(total as u64, &msg);
547            let mut out = optimize_block(stat, hyper, num_iter, out_target, Some(&prog))?;
548            prog.finish_and_clear();
549            if !keep_stats {
550                out.release_stats();
551            }
552            return Ok(out);
553        }
554        let spin =
555            legume_numeric::matrix::progress::new_spinner("{spinner} [{elapsed_precise}] {msg}")
556                .with_message(format!("{label} single gene-block · {dims}"));
557        let mut out = optimize_block(stat, hyper, num_iter, out_target, None)?;
558        spin.finish_and_clear();
559        if !keep_stats {
560            out.release_stats();
561        }
562        return Ok(out);
563    }
564
565    // The gene axis is separable — a block's fit depends only on its own rows
566    // and the shared per-sample sizes, never on other genes — so the blocks
567    // are independent jobs run concurrently. Peak memory is now
568    // (rayon width × ~150 MB), bounded by the plane cap above rather than by
569    // the feature count.
570    let prog = styled_progress_bar(total as u64, &msg);
571
572    // `.map(..).collect::<Result<Vec>>()` preserves input order, so blocks
573    // reassemble in gene order. `prog.inc` is atomic: batched blocks tick per
574    // iteration inside `optimize_block`; otherwise tick once per finished block.
575    let outs = jobs
576        .par_iter()
577        .map(|&(lb, ub)| -> anyhow::Result<CollapsedOut> {
578            let sub = stat.select_rows(lb, ub - lb);
579            let mut out_b =
580                optimize_block(&sub, hyper, num_iter, out_target, batched.then_some(&prog))?;
581            if !keep_stats {
582                // Free a_stat/b_stat now so the accumulated blocks never add
583                // up to the full sufficient-stat planes.
584                out_b.release_stats();
585            }
586            if !batched {
587                prog.inc(1);
588            }
589            Ok(out_b)
590        })
591        .collect::<anyhow::Result<Vec<_>>>()?;
592    prog.finish_and_clear();
593
594    let mut mu_obs: Vec<GammaMatrix> = Vec::with_capacity(n_blocks);
595    let mut mu_adj: Vec<GammaMatrix> = Vec::new();
596    let mut mu_res: Vec<GammaMatrix> = Vec::new();
597    let mut gam: Vec<GammaMatrix> = Vec::new();
598    let mut del: Vec<GammaMatrix> = Vec::new();
599    for out_b in outs {
600        mu_obs.push(out_b.mu_observed);
601        if let Some(x) = out_b.mu_adjusted {
602            mu_adj.push(x);
603        }
604        if let Some(x) = out_b.mu_residual {
605            mu_res.push(x);
606        }
607        if let Some(x) = out_b.gamma {
608            gam.push(x);
609        }
610        if let Some(x) = out_b.delta {
611            del.push(x);
612        }
613    }
614
615    let join = |v: Vec<GammaMatrix>| -> Option<GammaMatrix> {
616        (!v.is_empty()).then(|| GammaMatrix::vconcat(v, keep_stats))
617    };
618    Ok(CollapsedOut {
619        mu_observed: GammaMatrix::vconcat(mu_obs, keep_stats),
620        mu_adjusted: join(mu_adj),
621        mu_residual: join(mu_res),
622        gamma: join(gam),
623        delta: join(del),
624        stats_kept: keep_stats,
625    })
626}
627
628/// output struct to make the model parameters more accessible
629#[derive(Debug, Clone)]
630pub struct CollapsedOut {
631    pub mu_observed: GammaMatrix,
632    pub mu_adjusted: Option<GammaMatrix>,
633    pub mu_residual: Option<GammaMatrix>,
634    pub gamma: Option<GammaMatrix>,
635    pub delta: Option<GammaMatrix>,
636    /// Whether `a_stat`/`b_stat` remain on the parameters. False after
637    /// [`Self::release_stats`] or a `MeanOnly` assemble that dropped them.
638    /// [`Self::observed_counts`] requires this to be true.
639    pub stats_kept: bool,
640}
641
642impl CollapsedOut {
643    /// Drop `a_stat`/`b_stat` on every contained parameter. Safe when the
644    /// consumer reads posterior means/log-means but never `posterior_sample`
645    /// (bge). Used by the gene-blocked `optimize` to keep accumulated blocks
646    /// from summing to the full sufficient-stat planes.
647    fn release_stats(&mut self) {
648        self.mu_observed.release_stats();
649        for p in [
650            &mut self.mu_adjusted,
651            &mut self.mu_residual,
652            &mut self.gamma,
653            &mut self.delta,
654        ] {
655            if let Some(g) = p.as_mut() {
656                g.release_stats();
657            }
658        }
659        self.stats_kept = false;
660    }
661
662    /// The observed counts `[D × S]` and pseudobulk sizes `[S]` of this level:
663    /// each entry is the prior-free rate `evidence_mean` (data sum over data
664    /// denominator) times the pseudobulk's cell count, and a size is the number
665    /// of cells `cell_to_pb` puts in it. Reads the observed (not batch-adjusted)
666    /// posterior's sufficient statistics, so the collapse must have kept them
667    /// (`MultilevelParams::keep_finest_stats` for the finest level).
668    ///
669    /// # Errors
670    ///
671    /// - sufficient statistics were released (`keep_finest_stats` was false
672    ///   under `MeanOnly` calibration)
673    /// - a `cell_to_pb` entry is out of range for this level's pseudobulk count
674    pub fn observed_counts(
675        &self,
676        cell_to_pb: &[usize],
677    ) -> anyhow::Result<(DMatrix<f32>, Vec<f32>)> {
678        anyhow::ensure!(
679            self.stats_kept,
680            "CollapsedOut::observed_counts needs kept sufficient statistics; \
681             set MultilevelParams::keep_finest_stats"
682        );
683        let param = &self.mu_observed;
684        let (d, n_pb) = (param.nrows(), param.ncols());
685        let mut sizes = vec![0f32; n_pb];
686        let mut n_oob = 0usize;
687        for &pb in cell_to_pb {
688            if pb < n_pb {
689                sizes[pb] += 1.0;
690            } else {
691                n_oob += 1;
692            }
693        }
694        anyhow::ensure!(
695            n_oob == 0,
696            "CollapsedOut::observed_counts: {n_oob} cell→pseudobulk id(s) \
697             are out of range for {n_pb} pseudobulk(s)"
698        );
699        let counts = DMatrix::<f32>::from_fn(d, n_pb, |g, s| param.evidence_mean(g, s) * sizes[s]);
700        Ok((counts, sizes))
701    }
702}
703
704/// a struct to hold the sufficient statistics for the model
705#[derive(Debug, Clone)]
706pub struct CollapsedStat {
707    pub observed_sum_ds: nalgebra::DMatrix<f32>, // observed sum within each sample
708    pub imputed_sum_ds: nalgebra::DMatrix<f32>,  // counterfactual sum within each sample
709    pub size_s: nalgebra::DVector<f32>,          // sample s size
710    pub observed_sum_db: nalgebra::DMatrix<f32>, // divergence numerator
711    pub n_bs: nalgebra::DMatrix<f32>,            // batch-specific sample size
712    /// Counterfactual SOURCE mass per (batch, sample): how much of sample `s`'s
713    /// imputed profile was drawn from batch `b`'s cells (each matched cell's
714    /// weights sum to 1, so a column sums to the matched cell count). With
715    /// `n_bs` this is what identifies `δ`: `E[imputed_gs] = μ_gs Σ_b δ_gb w_bs`
716    /// against `E[observed_gs] = μ_gs Σ_b δ_gb n_bs`.
717    pub matched_bs: nalgebra::DMatrix<f32>,
718    /// Batches whose frame IS the frame: `δ` is normalised so their per-gene
719    /// geometric mean is 1. Empty = pooled, where every batch's mass weighs in.
720    pub anchor_batches: Vec<usize>,
721    /// Per-(gene, sample) effective size: the mass of sample `s` whose SOURCE
722    /// actually measures gene `g`. `None` means fully observed — every gene
723    /// sees `size_s[s]`, and `optimize` takes the exact historical code path.
724    ///
725    /// This is what distinguishes "unmeasured" from "measured as zero": a
726    /// gene absent from one cohort's panel reads 0 in all of its cells, and
727    /// without this denominator the estimate is silently dragged toward zero
728    /// by cells that never looked. Set by [`attach_observability`]; see the
729    /// plan note there for the factorized construction.
730    pub size_ds: Option<nalgebra::DMatrix<f32>>,
731    /// Per-(gene, batch) observability of δ's evidence: 1.0 when any source
732    /// in batch `b` measures gene `g`, else 0.0. Zeroing δ's denominator
733    /// there sends δ to its prior (≈ 1, "no adjustment") instead of to an
734    /// extreme driven by structurally-absent counts. `None` = all observed.
735    pub obs_mask_db: Option<nalgebra::DMatrix<f32>>,
736    /// When true (CNV strata present), samples with no counterfactual mass
737    /// (`matched_bs` column sum 0) are excluded from the δ numerator and
738    /// denominator so private-clone observed mass cannot pull δ. `update_mu`
739    /// still uses the full masses. Default false — bit-identical to before.
740    pub exclude_unmatched_from_delta: bool,
741}
742
743impl CollapsedStat {
744    pub fn new(ngene: usize, nsample: usize, nbatch: usize) -> Self {
745        Self {
746            observed_sum_ds: nalgebra::DMatrix::<f32>::zeros(ngene, nsample),
747            imputed_sum_ds: nalgebra::DMatrix::<f32>::zeros(ngene, nsample),
748            size_s: nalgebra::DVector::<f32>::zeros(nsample),
749            observed_sum_db: nalgebra::DMatrix::<f32>::zeros(ngene, nbatch),
750            n_bs: nalgebra::DMatrix::<f32>::zeros(nbatch, nsample),
751            matched_bs: nalgebra::DMatrix::<f32>::zeros(nbatch, nsample),
752            anchor_batches: Vec::new(),
753            size_ds: None,
754            obs_mask_db: None,
755            exclude_unmatched_from_delta: false,
756        }
757    }
758
759    pub fn num_genes(&self) -> usize {
760        self.observed_sum_ds.nrows()
761    }
762
763    pub fn num_samples(&self) -> usize {
764        self.observed_sum_ds.ncols()
765    }
766
767    pub fn num_batches(&self) -> usize {
768        self.observed_sum_db.ncols()
769    }
770
771    /// Per-batch weights that define the frame `δ` is pinned to: the anchors
772    /// when there are any (each weighted 1), else every batch by its own cell
773    /// mass.
774    pub fn frame_weights(&self) -> DVector<f32> {
775        let nb = self.num_batches();
776        if self.anchor_batches.is_empty() {
777            DVector::from_fn(nb, |b, _| self.n_bs.row(b).sum())
778        } else {
779            let mut w = DVector::<f32>::zeros(nb);
780            for &b in &self.anchor_batches {
781                w[b] = 1.0;
782            }
783            w
784        }
785    }
786
787    pub fn clear(&mut self) {
788        self.observed_sum_ds.fill(0_f32);
789        self.imputed_sum_ds.fill(0_f32);
790        self.observed_sum_db.fill(0_f32);
791        self.size_s.fill(0_f32);
792        self.n_bs.fill(0_f32);
793        self.matched_bs.fill(0_f32);
794        self.anchor_batches.clear();
795        self.size_ds = None;
796        self.obs_mask_db = None;
797    }
798
799    /// Select a subset of sample columns (groups) by index.
800    pub fn select_columns(&self, indices: &[usize]) -> Self {
801        let n_new = indices.len();
802        let ng = self.num_genes();
803        let nb = self.num_batches();
804        let mut out = Self::new(ng, n_new, nb);
805        for (new_col, &old_col) in indices.iter().enumerate() {
806            out.observed_sum_ds
807                .column_mut(new_col)
808                .copy_from(&self.observed_sum_ds.column(old_col));
809            out.imputed_sum_ds
810                .column_mut(new_col)
811                .copy_from(&self.imputed_sum_ds.column(old_col));
812            out.size_s[new_col] = self.size_s[old_col];
813            for b in 0..nb {
814                out.n_bs[(b, new_col)] = self.n_bs[(b, old_col)];
815                out.matched_bs[(b, new_col)] = self.matched_bs[(b, old_col)];
816            }
817        }
818        if let Some(size_ds) = self.size_ds.as_ref() {
819            out.size_ds = Some(nalgebra::DMatrix::from_fn(ng, n_new, |g, new_col| {
820                size_ds[(g, indices[new_col])]
821            }));
822        }
823        out.obs_mask_db = self.obs_mask_db.clone();
824        out.observed_sum_db.copy_from(&self.observed_sum_db);
825        out.anchor_batches = self.anchor_batches.clone();
826        out.exclude_unmatched_from_delta = self.exclude_unmatched_from_delta;
827        out
828    }
829
830    /// Select a contiguous block of feature rows (`r0..r0+nrows`). Per-gene
831    /// stats (`observed`/`imputed`/`observed_db`) are sliced; the per-sample
832    /// `size_s` and per-batch `n_bs`/`matched_bs` are shared, so they're copied
833    /// whole. Used by the gene-blocked `optimize`.
834    pub fn select_rows(&self, r0: usize, nrows: usize) -> Self {
835        Self {
836            observed_sum_ds: self.observed_sum_ds.rows(r0, nrows).into_owned(),
837            imputed_sum_ds: self.imputed_sum_ds.rows(r0, nrows).into_owned(),
838            size_s: self.size_s.clone(),
839            observed_sum_db: self.observed_sum_db.rows(r0, nrows).into_owned(),
840            n_bs: self.n_bs.clone(),
841            matched_bs: self.matched_bs.clone(),
842            anchor_batches: self.anchor_batches.clone(),
843            size_ds: self
844                .size_ds
845                .as_ref()
846                .map(|m| m.rows(r0, nrows).into_owned()),
847            obs_mask_db: self
848                .obs_mask_db
849                .as_ref()
850                .map(|m| m.rows(r0, nrows).into_owned()),
851            exclude_unmatched_from_delta: self.exclude_unmatched_from_delta,
852        }
853    }
854}
855
856/// Resample from over-resolved sufficient statistics: randomly select
857/// ~half the groups, then optimise to produce a fresh `CollapsedOut`.
858pub fn resample_and_optimize(
859    stat: &CollapsedStat,
860    rng: &mut impl rand::Rng,
861    opt_iter: usize,
862) -> anyhow::Result<CollapsedOut> {
863    use rand::seq::SliceRandom;
864    let n = stat.num_samples();
865    let target = n / 2;
866    let mut indices: Vec<usize> = (0..n).collect();
867    indices.shuffle(rng);
868    indices.truncate(target);
869    indices.sort_unstable();
870    let sub_stat = stat.select_columns(&indices);
871    optimize(
872        &sub_stat,
873        (1.0, 1.0),
874        opt_iter,
875        "Optimizing",
876        CalibrateTarget::All,
877        false,
878    )
879}
880
881///////////////////////////////////////////////////////////////
882// Multi-level (METIS-inspired) collapsing for batch effects //
883///////////////////////////////////////////////////////////////
884
885pub(super) const DEFAULT_NUM_LEVELS: usize = 2;
886pub(super) const DEFAULT_COARSEST_SORT_DIM: usize = 7;
887
888/// Cross-batch matched-stat accumulation on top of the pb-sample
889/// layout. For each fine-group, fetches `knn` matched cells from each
890/// non-own batch via `batch_knn_lookup`, dedupes them through their
891/// pb-samples, and emits the counterfactual gene sums into
892/// `stat.imputed_sum_ds` and the per-batch source mass into `stat.matched_bs`.
893pub(super) fn collect_matched_stat_coarse(
894    layout: &PbSampleLayout,
895    gene_sums: &[Vec<(usize, f32)>],
896    pbsamp_to_group: &[usize],
897    batch_knn_lookup: &[ColumnDict<usize>],
898    knn: usize,
899    anchor_batches: Option<&[usize]>,
900    stat: &mut CollapsedStat,
901) -> anyhow::Result<()> {
902    let num_pb = layout.cell_counts.len();
903    debug_assert_eq!(pbsamp_to_group.len(), num_pb);
904
905    // The batches that source the counterfactual are the batches that pin δ:
906    // recorded here, where that mass is written, so no caller can forget.
907    stat.anchor_batches = anchor_batches.map(<[usize]>::to_vec).unwrap_or_default();
908    let neighbors_per_sc = per_batch_sc_neighbors(layout, batch_knn_lookup, knn, anchor_batches)?;
909
910    use indicatif::ParallelProgressIterator;
911    let prog_bar = styled_progress_bar(num_pb as u64, "pb-samples (matched stats)");
912    let arc_stat = Arc::new(Mutex::new(stat));
913
914    (0..num_pb)
915        .into_par_iter()
916        .progress_with(prog_bar.clone())
917        .for_each(|pbsamp_idx| {
918            let pbsamp_group = pbsamp_to_group[pbsamp_idx];
919            let sc_count = layout.cell_counts[pbsamp_idx];
920
921            if sc_count < 1.0 {
922                return;
923            }
924
925            let filtered = &neighbors_per_sc[pbsamp_idx];
926            if filtered.is_empty() {
927                return;
928            }
929
930            // Softmax weights from negative distances
931            let max_neg_d = filtered
932                .iter()
933                .map(|(_, d)| -d)
934                .fold(f32::NEG_INFINITY, f32::max);
935            let mut weights: Vec<f32> = filtered
936                .iter()
937                .map(|(_, d)| (-d - max_neg_d).exp())
938                .collect();
939            let w_sum: f32 = weights.iter().sum();
940            if w_sum > 0.0 {
941                weights.iter_mut().for_each(|w| *w /= w_sum);
942            }
943
944            // Counterfactual: weighted average of matched pb-samples'
945            // per-cell gene expression
946            // y_hat[g] = sum_k w[k] * gene_sums[k][g] / cell_counts[k]
947            let mut y_hat: HashMap<usize, f32> = HashMap::default();
948            for ((matched_sc, _), &w) in filtered.iter().zip(weights.iter()) {
949                let matched_count = layout.cell_counts[*matched_sc];
950                if matched_count < 1.0 {
951                    continue;
952                }
953                let inv_count = 1.0 / matched_count;
954                for &(gene, val) in &gene_sums[*matched_sc] {
955                    *y_hat.entry(gene).or_default() += w * val * inv_count;
956                }
957            }
958
959            let mut stat = arc_stat.lock().expect("lock stat");
960
961            // Accumulate imputed_sum_ds[g, s] += cell_counts[pbsamp] * y_hat[g]
962            for (&gene, &y) in &y_hat {
963                stat.imputed_sum_ds[(gene, pbsamp_group)] += sc_count * y;
964            }
965
966            // Source mass per batch: this pb-sample's `sc_count` cells split their
967            // unit weight over the matched pb-samples' batches.
968            for ((matched_sc, _), &w) in filtered.iter().zip(weights.iter()) {
969                if layout.cell_counts[*matched_sc] < 1.0 {
970                    continue;
971                }
972                stat.matched_bs[(layout.pb_sample_to_batch[*matched_sc], pbsamp_group)] +=
973                    sc_count * w;
974            }
975        });
976
977    prog_bar.finish_and_clear();
978    Ok(())
979}
980
981/// Format a per-cell group-index vector as fixed-width zero-padded strings
982/// so that `SparseIoVec::assign_groups`' lexicographic key sort agrees with
983/// numeric order. `k` is the number of distinct groups (`group ∈ 0..k`).
984/// (batch-marginal).
985pub(super) fn merge_stat(
986    fine_stat: &CollapsedStat,
987    fine_to_coarse: &[usize],
988    num_coarse_groups: usize,
989) -> CollapsedStat {
990    let num_genes = fine_stat.num_genes();
991    let num_batches = fine_stat.num_batches();
992    let mut coarse = CollapsedStat::new(num_genes, num_coarse_groups, num_batches);
993
994    for (fine_g, &coarse_g) in fine_to_coarse.iter().enumerate() {
995        coarse
996            .observed_sum_ds
997            .column_mut(coarse_g)
998            .add_assign(&fine_stat.observed_sum_ds.column(fine_g));
999        coarse
1000            .imputed_sum_ds
1001            .column_mut(coarse_g)
1002            .add_assign(&fine_stat.imputed_sum_ds.column(fine_g));
1003        coarse.size_s[coarse_g] += fine_stat.size_s[fine_g];
1004        for b in 0..num_batches {
1005            coarse.n_bs[(b, coarse_g)] += fine_stat.n_bs[(b, fine_g)];
1006            coarse.matched_bs[(b, coarse_g)] += fine_stat.matched_bs[(b, fine_g)];
1007        }
1008    }
1009
1010    // Observability descends with the merge: effective sizes add like any
1011    // other per-sample mass, and the per-batch mask is sample-free.
1012    if let Some(fine_size_ds) = fine_stat.size_ds.as_ref() {
1013        let mut size_ds = nalgebra::DMatrix::<f32>::zeros(num_genes, num_coarse_groups);
1014        for (fine_g, &coarse_g) in fine_to_coarse.iter().enumerate() {
1015            size_ds
1016                .column_mut(coarse_g)
1017                .add_assign(&fine_size_ds.column(fine_g));
1018        }
1019        coarse.size_ds = Some(size_ds);
1020    }
1021    coarse.obs_mask_db = fine_stat.obs_mask_db.clone();
1022    coarse.anchor_batches = fine_stat.anchor_batches.clone();
1023    coarse.exclude_unmatched_from_delta = fine_stat.exclude_unmatched_from_delta;
1024
1025    coarse.observed_sum_db.copy_from(&fine_stat.observed_sum_db);
1026    coarse
1027}
1028
1029#[cfg(test)]
1030#[path = "stats_tests.rs"]
1031mod gene_block_tests;