data_beans/alg/nb_dispersion.rs
1//! Mean-variance trend fit for Negative-Binomial count data.
2//!
3//! Fits a smooth trend `σ²(μ) = μ + φ(μ) · μ²` across features (genes) and
4//! exposes two derived quantities from the same fit:
5//!
6//! - [`DispersionTrend::excess`] — HVG score: how far a feature's empirical
7//! `φ̂_g` sits above the global trend at its mean. Replaces Scanpy/Seurat
8//! binned residual variance with a continuous, self-reference-free score.
9//! - [`DispersionTrend::fisher_weight`] — DC-Poisson gene weight:
10//! `w_g = 1 / (1 + π_g · s̄ · φ(μ_g))`. Bounded in `(0, 1]`, monotone
11//! decreasing in baseline abundance, recovers `w_g = 1` when `φ → 0`.
12//!
13//! ## Trend fit
14//!
15//! MVP: weighted log-linear regression `log φ = a + b · log μ` over features
16//! with positive per-gene MoM dispersion `φ̂_g = (σ²_g − μ_g) / μ²_g`.
17//! Weights are `μ_g` (down-weights noisy low-mean features whose `φ̂`
18//! estimates are dominated by variance in the variance). For features with
19//! `φ̂_g ≤ 0` (underdispersed — Poisson or sub-Poisson), we still evaluate
20//! the fitted trend at their mean; they just don't contribute to the fit.
21//!
22//! Accuracy is sufficient for HVG scoring and DC-Poisson weighting. A
23//! smoother (LOESS / Gaussian-kernel) fit is a follow-up if the log-linear
24//! shape proves inadequate.
25
26use legume_numeric::matrix::sparse_stat::SparseRunningStatistics;
27use legume_numeric::matrix::traits::RunningStatOps;
28
29/// Minimum absolute mean to include a feature in the trend fit. Below this
30/// threshold, `μ²` is too small to yield a usable MoM `φ̂` and we would be
31/// regressing mostly on noise.
32const MIN_MEAN_FOR_FIT: f32 = 1e-4;
33
34/// Floor for `φ(μ)` evaluation. Prevents negative dispersion from the
35/// fitted line when extrapolating to regions not represented in the fit.
36const PHI_FLOOR: f32 = 0.0;
37
38/// Ceiling for `φ(μ)` evaluation. Keeps `fisher_weight` from collapsing to
39/// zero (and numerical underflow downstream) when `μ` is very large and the
40/// fitted `b` slope is positive.
41const PHI_CEIL: f32 = 100.0;
42
43/// Smooth trend of Negative-Binomial dispersion against feature mean.
44///
45/// Evaluates as `φ(μ) = exp(a + b · log μ)` clamped to `[PHI_FLOOR, PHI_CEIL]`.
46#[derive(Clone, Debug)]
47pub struct DispersionTrend {
48 a: f32,
49 b: f32,
50 /// Number of features that contributed to the fit (i.e. had
51 /// `μ_g > MIN_MEAN_FOR_FIT` and `φ̂_g > 0`). Exposed for diagnostics.
52 pub num_fit: usize,
53}
54
55impl DispersionTrend {
56 /// Fit the trend from per-feature mean and variance vectors (parallel,
57 /// equal-length).
58 pub fn fit(means: &[f32], vars: &[f32]) -> Self {
59 assert_eq!(means.len(), vars.len(), "means and vars length mismatch");
60
61 // Collect fit points: (log_mu, log_phi_hat, weight = mu).
62 let mut x: Vec<f64> = Vec::with_capacity(means.len());
63 let mut y: Vec<f64> = Vec::with_capacity(means.len());
64 let mut w: Vec<f64> = Vec::with_capacity(means.len());
65 for (&mu, &var) in means.iter().zip(vars.iter()) {
66 if !mu.is_finite() || !var.is_finite() || mu < MIN_MEAN_FOR_FIT {
67 continue;
68 }
69 let phi_hat = ((var - mu) / (mu * mu)) as f64;
70 if phi_hat <= 0.0 {
71 continue;
72 }
73 x.push((mu as f64).ln());
74 y.push(phi_hat.ln());
75 w.push(mu as f64);
76 }
77 let num_fit = x.len();
78
79 // Degenerate fallbacks: not enough points, or zero variance in x.
80 // Fall back to the Poisson limit (φ → 0), implemented as a = -∞
81 // so `exp(a + b·log μ) = 0` for any finite `μ`.
82 if num_fit < 2 {
83 return Self {
84 a: f32::NEG_INFINITY,
85 b: 0.0,
86 num_fit,
87 };
88 }
89 let w_sum: f64 = w.iter().sum();
90 let x_mean: f64 = x.iter().zip(&w).map(|(xi, wi)| xi * wi).sum::<f64>() / w_sum;
91 let y_mean: f64 = y.iter().zip(&w).map(|(yi, wi)| yi * wi).sum::<f64>() / w_sum;
92 let mut sxx = 0.0f64;
93 let mut sxy = 0.0f64;
94 for ((xi, yi), wi) in x.iter().zip(&y).zip(&w) {
95 let dx = xi - x_mean;
96 sxx += wi * dx * dx;
97 sxy += wi * dx * (yi - y_mean);
98 }
99 if sxx <= 0.0 {
100 // All log-means identical — trend collapses to a constant.
101 return Self {
102 a: y_mean as f32,
103 b: 0.0,
104 num_fit,
105 };
106 }
107 let b = sxy / sxx;
108 let a = y_mean - b * x_mean;
109 Self {
110 a: a as f32,
111 b: b as f32,
112 num_fit,
113 }
114 }
115
116 /// Convenience: fit directly from a feature-row `SparseRunningStatistics`.
117 pub fn from_sparse_stats(stats: &SparseRunningStatistics<f32>) -> Self {
118 let means = stats.mean();
119 let vars = stats.variance();
120 Self::fit(&means, &vars)
121 }
122
123 /// Smooth φ at the given mean. Clamped to `[PHI_FLOOR, PHI_CEIL]`.
124 pub fn phi_at(&self, mu: f32) -> f32 {
125 if !mu.is_finite() || mu <= 0.0 {
126 return PHI_FLOOR;
127 }
128 let log_mu = mu.ln();
129 let log_phi = self.a + self.b * log_mu;
130 log_phi.exp().clamp(PHI_FLOOR, PHI_CEIL)
131 }
132
133 /// HVG score: `(σ² − μ)/μ² − φ(μ)`. Positive = more biological variance
134 /// than the NB trend predicts; rank features by descending value.
135 pub fn excess(&self, mu: f32, var: f32) -> f32 {
136 if !mu.is_finite() || mu <= 0.0 || !var.is_finite() {
137 return f32::NEG_INFINITY;
138 }
139 let phi_hat = (var - mu) / (mu * mu);
140 phi_hat - self.phi_at(mu)
141 }
142
143 /// DC-Poisson gene weight: `1 / (1 + π · s̄ · φ(μ))`.
144 ///
145 /// `pi` is the feature's empirical marginal probability, `avg_s` the
146 /// mean entity size factor. Bounded in `(0, 1]`.
147 pub fn fisher_weight(&self, pi: f32, avg_s: f32, mu: f32) -> f32 {
148 let phi = self.phi_at(mu);
149 1.0 / (1.0 + pi * avg_s * phi)
150 }
151}
152
153#[cfg(test)]
154#[path = "nb_dispersion_tests.rs"]
155mod tests;