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