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}