Skip to main content

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;