Skip to main content

data_beans/alg/
gene_weighting.rs

1//! Per-gene weighting helpers used by reporting / annotation pipelines.
2//!
3//! `compute_nb_fisher_weights` derives bounded `(0, 1]` weights from the
4//! NB dispersion trend (see `nb_dispersion`) — high-mean / high-dispersion
5//! genes get attenuated, low-mean genes recover `w_g = 1` in the Poisson
6//! limit. The same formula is applied inside DC-Poisson refinement so
7//! clustering and reporting stay consistent.
8//!
9//! `apply_gene_weights` row-scales a `(num_genes × K)` matrix in place.
10//!
11//! Originally lived in `pinto/src/util/common.rs`; moved here so senna /
12//! chickpea / other consumers don't have to duplicate the implementation.
13
14use crate::alg::feature_coarsening::FeatureCoarsening;
15use crate::alg::nb_dispersion::DispersionTrend;
16use crate::alg::sparse_streaming::streaming_sparse_running_stats;
17use crate::sparse_io_vector::SparseIoVec;
18use indicatif::ParallelProgressIterator;
19use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
20use legume_numeric::matrix::traits::{IoOps, RunningStatOps};
21use legume_numeric::matrix::utils::generate_minibatch_intervals;
22use rayon::prelude::*;
23
24/// Fit a fresh NB dispersion trend on `stats` and return per-feature
25/// Fisher-info weights `w_d ∈ (0, 1]`. `n_entities` is the count divisor
26/// used to compute the mean entity size factor `s̄` (typically the
27/// number of cells or pseudobulks the stats were accumulated over).
28pub fn fisher_weights_from_stats(
29    stats: &SparseRunningStatistics<f32>,
30    n_entities: usize,
31) -> Vec<f32> {
32    let trend = DispersionTrend::from_sparse_stats(stats);
33    let means = stats.mean();
34    let sums = stats.sum();
35    let total_mass: f64 = sums.iter().map(|&s| s as f64).sum();
36    let avg_s = if n_entities > 0 {
37        (total_mass / n_entities as f64) as f32
38    } else {
39        1.0
40    };
41    let inv_total = if total_mass > 0.0 {
42        1.0 / total_mass as f32
43    } else {
44        0.0
45    };
46    (0..means.len())
47        .map(|g| trend.fisher_weight(sums[g] * inv_total, avg_s, means[g]))
48        .collect()
49}
50
51/// Fisher weights fitted on **pseudobulks** rather than on cells.
52///
53/// `mu_ds` is a `[D, S]` posterior-mean matrix of per-cell *rates* — the
54/// finest `CollapsedOut` — and `size_s[s]` is the number of cells behind
55/// column `s`. `coarsening`, when given, sums features into coarse features
56/// first, matching whatever resolution the decoder runs at.
57///
58/// **The count rescaling is the whole trick.** `phi_hat = (var - mu)/mu²`
59/// measures excess over a Poisson floor of `var = mu`, which is a statement
60/// about *counts*. A pseudobulk posterior mean is a per-cell *rate*, whose
61/// floor is `mu/size_s`, so feeding rates straight in subtracts a floor that
62/// is not there: genes with `var < mu` fall out of the fit entirely, and the
63/// survivors get `phi_hat` inflated by an arbitrary `1/mu`. In the worst case
64/// every point drops and the trend degenerates to `φ = 0`, i.e. the weighting
65/// silently switches itself off. Multiplying column `s` by `size_s[s]` puts
66/// the population back on the scale the estimator is defined for.
67///
68/// Pass the **observed** posterior mean, not a batch-adjusted one: δ-removal
69/// strips between-batch spread, which is part of the variance the trend is
70/// meant to see.
71///
72/// When to reach for this over the cell-level pass — and the measurements
73/// behind both statements — is the dispatcher's decision; see
74/// `senna::refine_weighting::fit_fisher_weights`, the one production caller.
75pub fn fisher_weights_from_pseudobulk(
76    mu_ds: &nalgebra::DMatrix<f32>,
77    size_s: &[f32],
78    coarsening: Option<&FeatureCoarsening>,
79) -> anyhow::Result<Vec<f32>> {
80    let coarse = coarsening.map(|fc| fc.aggregate_rows_ds(mu_ds));
81    let m = coarse.as_ref().unwrap_or(mu_ds);
82    // Not defaulted: a wrong-length `size_s` rescales the wrong columns and
83    // returns plausible weights with no shape mismatch to give it away.
84    anyhow::ensure!(
85        size_s.len() == m.ncols(),
86        "pseudobulk NB-Fisher: {} cell counts for {} pseudobulks",
87        size_s.len(),
88        m.ncols(),
89    );
90
91    let d = m.nrows();
92    let mut stats = SparseRunningStatistics::<f32>::new(d);
93    // `DMatrix` is column-major, so `chunks_exact(d)` walks the columns
94    // without a scaled copy — the multiply folds into the accumulation.
95    for (col, &n) in m.as_slice().chunks_exact(d).zip(size_s) {
96        stats.add_dense_column_scaled(col, n.max(1.0));
97    }
98
99    // Total cells, not pseudobulks: `fisher_weights_from_stats` divides total
100    // mass by this to get the mean library size `s̄`, and the weight belongs on
101    // the per-cell scale — the same `s̄` the cell-level pass sees.
102    let n_cells = f64::from(size_s.iter().sum::<f32>()).max(1.0) as usize;
103    Ok(fisher_weights_from_stats(&stats, n_cells))
104}
105
106/// Fit the NB mean-variance trend on `data_vec` and return per-gene
107/// Fisher-info weights in row order (same as `data_vec.row_names()`).
108pub fn compute_nb_fisher_weights(
109    data_vec: &SparseIoVec,
110    block_size: Option<usize>,
111) -> anyhow::Result<Vec<f32>> {
112    let n_cells = data_vec.num_columns();
113    let stats = streaming_sparse_running_stats(data_vec, block_size, "NB-Fisher")?;
114    Ok(fisher_weights_from_stats(&stats, n_cells))
115}
116
117/// Same as [`compute_nb_fisher_weights`], but at the *coarse* feature
118/// level after `FeatureCoarsening` aggregation. Streams cells, coarsens
119/// each block via `aggregate_sparse_csc` (sum within group), then fits
120/// a fresh NB dispersion trend at `D_coarse` and computes per-meta-gene
121/// Fisher weights.
122///
123/// Use this when the encoder/decoder operate at a coarsened feature
124/// resolution: the data the model actually sees is summed-coarsened
125/// counts, so the dispersion trend (and hence Fisher info) must be
126/// fit at that scale — not aggregated from fine-level weights.
127pub fn compute_nb_fisher_weights_coarsened(
128    data_vec: &SparseIoVec,
129    coarsening: &FeatureCoarsening,
130    block_size: Option<usize>,
131) -> anyhow::Result<Vec<f32>> {
132    let n_features_coarse = coarsening.num_coarse;
133    let n_total = data_vec.num_columns();
134    let jobs = generate_minibatch_intervals(n_total, n_features_coarse, block_size);
135
136    let prog_bar = legume_numeric::matrix::progress::new_progress_bar(jobs.len() as u64)
137        .with_message("NB-Fisher (coarse) blocks");
138
139    let stats: SparseRunningStatistics<f32> = jobs
140        .par_iter()
141        .progress_with(prog_bar.clone())
142        .try_fold(
143            || SparseRunningStatistics::<f32>::new(n_features_coarse),
144            |mut acc, &(lb, ub)| -> anyhow::Result<SparseRunningStatistics<f32>> {
145                let chunk = data_vec.read_columns_csc(lb..ub)?;
146                let coarse = coarsening.aggregate_sparse_csc(&chunk);
147                acc.add_dense_columns(&coarse);
148                Ok(acc)
149            },
150        )
151        .try_reduce(
152            || SparseRunningStatistics::<f32>::new(n_features_coarse),
153            |mut a, b| {
154                a.merge(&b);
155                Ok(a)
156            },
157        )?;
158    prog_bar.finish_and_clear();
159
160    Ok(fisher_weights_from_stats(&stats, n_total))
161}
162
163/// Save a per-gene weight vector as a single-column parquet keyed on
164/// gene name. The output path is fully specified — callers control the
165/// filename. For the standard `{out}.fisher_weights.parquet` convention
166/// used by senna and chickpea, see [`save_fisher_weights`].
167pub fn save_per_gene_weights(
168    weights: &[f32],
169    gene_names: &[Box<str>],
170    out_path: &str,
171) -> anyhow::Result<()> {
172    let mat = nalgebra::DMatrix::<f32>::from_column_slice(weights.len(), 1, weights);
173    let weight_col = vec![Box::<str>::from("weight")];
174    mat.to_parquet_with_names(
175        out_path,
176        (Some(gene_names), Some("gene")),
177        Some(&weight_col),
178    )?;
179    Ok(())
180}
181
182/// Convenience wrapper: write per-gene weights to the conventional
183/// `{out_prefix}.fisher_weights.parquet` path used by senna `topic` and
184/// chickpea `fit-topic`. Downstream consumers (e.g. `senna annotate`)
185/// look for this filename to reload weights instead of re-scanning all
186/// cells.
187pub fn save_fisher_weights(
188    out_prefix: &str,
189    weights: &[f32],
190    gene_names: &[Box<str>],
191) -> anyhow::Result<()> {
192    save_per_gene_weights(
193        weights,
194        gene_names,
195        &format!("{out_prefix}.fisher_weights.parquet"),
196    )
197}
198
199/// `(gene_names, per-gene weights)` returned by the per-gene weight loaders.
200pub type GeneWeights = (Vec<Box<str>>, Vec<f32>);
201
202/// Load a per-gene weight vector previously written by
203/// [`save_per_gene_weights`]. Returns `(gene_names, weights)`.
204pub fn load_per_gene_weights(path: &str) -> anyhow::Result<GeneWeights> {
205    let result = nalgebra::DMatrix::<f32>::from_parquet_with_row_names(path, Some(0))?;
206    anyhow::ensure!(
207        result.mat.ncols() >= 1,
208        "per-gene weights parquet at {path} has no value column",
209    );
210    let weights: Vec<f32> = result.mat.column(0).iter().copied().collect();
211    Ok((result.rows, weights))
212}
213
214/// Convenience wrapper: load `{prefix}.fisher_weights.parquet`. Returns
215/// `Ok(None)` when the file doesn't exist so callers can fall back to
216/// recomputing via [`compute_nb_fisher_weights`].
217pub fn load_fisher_weights(prefix: &str) -> anyhow::Result<Option<GeneWeights>> {
218    let path = format!("{prefix}.fisher_weights.parquet");
219    if !std::path::Path::new(&path).exists() {
220        return Ok(None);
221    }
222    Ok(Some(load_per_gene_weights(&path)?))
223}
224
225/// Save coarse-resolution NB-Fisher weights computed at the encoder/
226/// decoder's `D_coarse` (post-feature-coarsening). Stored at
227/// `{prefix}.fisher_weights_coarse.parquet` keyed on synthetic
228/// `coarse_<i>` axis ids since coarse meta-genes have no biological
229/// names. Loaded by `senna predict` / `senna eval-topic` to re-attach
230/// the same per-feature multinomial-loss weights at inference.
231pub fn save_fisher_weights_coarse(out_prefix: &str, weights: &[f32]) -> anyhow::Result<()> {
232    let axis_names: Vec<Box<str>> = (0..weights.len())
233        .map(|i| format!("coarse_{i}").into_boxed_str())
234        .collect();
235    save_per_gene_weights(
236        weights,
237        &axis_names,
238        &format!("{out_prefix}.fisher_weights_coarse.parquet"),
239    )
240}
241
242/// Convenience wrapper: load `{prefix}.fisher_weights_coarse.parquet`.
243/// Returns `Ok(None)` when the file doesn't exist (older models trained
244/// before coarse weights were saved).
245pub fn load_fisher_weights_coarse(prefix: &str) -> anyhow::Result<Option<GeneWeights>> {
246    let path = format!("{prefix}.fisher_weights_coarse.parquet");
247    if !std::path::Path::new(&path).exists() {
248        return Ok(None);
249    }
250    Ok(Some(load_per_gene_weights(&path)?))
251}
252
253/// Scale each row of `sum_gk` by its corresponding entry of `weights`.
254/// `weights.len()` must equal `sum_gk.nrows()`.
255pub fn apply_gene_weights(sum_gk: &mut nalgebra::DMatrix<f32>, weights: &[f32]) {
256    debug_assert_eq!(weights.len(), sum_gk.nrows());
257    for (g, &w) in weights.iter().enumerate() {
258        sum_gk.row_mut(g).scale_mut(w);
259    }
260}