Skip to main content

gsva/
kcdf.rs

1//! Kernel cumulative-distribution estimates for GSVA's `kcdf` step.
2//!
3//! For each gene (row) GSVA estimates a smoothed CDF of that gene's expression
4//! across samples and evaluates it at every sample. The Bioconductor C routine
5//! `matrix_density_R` (`src/kernel_estimation.c`) computes, for a Gaussian
6//! kernel,
7//!
8//! ```text
9//! left_tail[g, j] = (1/n) * Σ_l  Φ_table((x[g,j] − x[g,l]) / bw_g)
10//! r[g, j]         = logit(left_tail[g, j]) = −log((1 − left_tail) / left_tail)
11//! ```
12//!
13//! with per-gene bandwidth `bw_g = sd(row_g) / 4` (sample sd, denominator
14//! `n − 1`) and `Φ_table` a 10 001-point lookup of the standard-normal CDF.
15//! GSVA then takes per-sample ranks of `r`. Because `logit` is strictly
16//! increasing, those ranks equal the ranks of `left_tail`, so this module
17//! returns the raw `left_tail` values and lets the caller rank them — avoiding
18//! the `±∞` that `logit` produces at the saturated `0`/`1` ends.
19//!
20//! The standard-normal CDF is computed with W. J. Cody's (1969) rational
21//! approximation — the same algorithm R uses in `pnorm` — so the lookup table is
22//! bit-for-bit identical to GSVA's, and `left_tail` matches `matrix_density_R`
23//! to full `f64` precision (verified against R 4.6.0 / GSVA 2.6.2).
24
25// The Cody (1969) `pnorm` coefficients below are transcribed verbatim from R's
26// `src/nmath/pnorm.c` so they can be diffed against the original, and the unit
27// tests hold R's `%.17g` console output verbatim. Some literals carry digits
28// past `f64` resolution — kept intentionally, for provenance — so silence the
29// excessive-precision lint for the whole module.
30#![allow(clippy::excessive_precision)]
31
32use crate::matrix::ExprMatrix;
33
34/// Bandwidth divisor: `bw = sd / 4` (`SIGMA_FACTOR` in `kernel_estimation.c`).
35const SIGMA_FACTOR: f64 = 4.0;
36/// Number of intervals in the precomputed CDF table (`PRECOMPUTE_RESOLUTION`).
37const PRECOMPUTE_RESOLUTION: usize = 10_000;
38/// Largest `|z|` the table covers, in units of `bw` (`MAX_PRECOMPUTE`).
39const MAX_PRECOMPUTE: f64 = 10.0;
40
41/// Standard-normal cumulative distribution function `Φ(x)`.
42///
43/// Port of W. J. Cody's rational Chebyshev approximation (the algorithm in R's
44/// `src/nmath/pnorm.c`). On `x ≥ 0` it reproduces R's `pnorm` bit-for-bit across
45/// the whole table range `[0, 10]` (verified against R 4.6.0). For `x < 0` it
46/// reflects via `Φ(x) = 1 − Φ(−x)`; the GSVA table only ever evaluates `x ≥ 0`.
47pub fn std_normal_cdf(x: f64) -> f64 {
48    if x < 0.0 {
49        return 1.0 - std_normal_cdf(-x);
50    }
51
52    // Cody (1969) coefficients, identical to R's pnorm.c.
53    const A: [f64; 5] = [
54        2.2352520354606839287,
55        161.02823106855587881,
56        1067.6894854603709582,
57        18154.981253343561249,
58        0.065682337918207449113,
59    ];
60    const B: [f64; 4] = [
61        47.20258190468824187,
62        976.09855173777669322,
63        10260.932208618978205,
64        45507.789335026729956,
65    ];
66    const C: [f64; 9] = [
67        0.39894151208813466764,
68        8.8831497943883759412,
69        93.506656132177855979,
70        597.27027639480026226,
71        2494.5375852903726711,
72        6848.1904505362823326,
73        11602.651437647350124,
74        9842.7148383839780218,
75        1.0765576773720192317e-8,
76    ];
77    const D: [f64; 8] = [
78        22.266688044328115691,
79        235.38790178262499861,
80        1519.377599407554805,
81        6485.558298266760755,
82        18615.571640885098091,
83        34900.952721145977266,
84        38912.003286093271411,
85        19685.429676859990727,
86    ];
87    const P: [f64; 6] = [
88        0.21589853405795699,
89        0.1274011611602473639,
90        0.022235277870649807,
91        0.001421619193227893466,
92        2.9112874951168792e-5,
93        0.02307344176494017303,
94    ];
95    const Q: [f64; 5] = [
96        1.28426009614491121,
97        0.468238212480865118,
98        0.0659881378689285515,
99        0.00378239633202758244,
100        7.29751555083966205e-5,
101    ];
102    const M_1_SQRT_2PI: f64 = 0.398942280401432677939946059934;
103
104    let y = x; // x ≥ 0 here, so |x| == x.
105    if y <= 0.67448975 {
106        // Central region: rational approximation of the area around 0.
107        let xsq = if y > f64::EPSILON * 0.5 { x * x } else { 0.0 };
108        let mut xnum = A[4] * xsq;
109        let mut xden = xsq;
110        for i in 0..3 {
111            xnum = (xnum + A[i]) * xsq;
112            xden = (xden + B[i]) * xsq;
113        }
114        let temp = x * (xnum + A[3]) / (xden + B[3]);
115        0.5 + temp
116    } else if y <= 32.0_f64.sqrt() {
117        // Intermediate region: approximate the small (upper) tail, then 1 − it.
118        let mut xnum = C[8] * y;
119        let mut xden = y;
120        for i in 0..7 {
121            xnum = (xnum + C[i]) * y;
122            xden = (xden + D[i]) * y;
123        }
124        let temp = (xnum + C[7]) / (xden + D[7]);
125        let xsq = (y * 16.0).trunc() / 16.0;
126        let del = (y - xsq) * (y + xsq);
127        let small = (-xsq * xsq * 0.5).exp() * (-del * 0.5).exp() * temp;
128        1.0 - small
129    } else {
130        // Far upper tail: asymptotic series for the (tiny) complementary area.
131        let xsq = 1.0 / (x * x);
132        let mut xnum = P[5] * xsq;
133        let mut xden = xsq;
134        for i in 0..4 {
135            xnum = (xnum + P[i]) * xsq;
136            xden = (xden + Q[i]) * xsq;
137        }
138        let mut temp = xsq * (xnum + P[4]) / (xden + Q[4]);
139        temp = (M_1_SQRT_2PI - temp) / y;
140        let xsq = (y * 16.0).trunc() / 16.0;
141        let del = (y - xsq) * (y + xsq);
142        let small = (-xsq * xsq * 0.5).exp() * (-del * 0.5).exp() * temp;
143        1.0 - small
144    }
145}
146
147/// Build GSVA's precomputed standard-normal CDF table: `table[i] = Φ(10·i/10000)`
148/// for `i = 0..=10000` (`initCdfs` in `kernel_estimation.c`).
149fn build_table() -> Vec<f64> {
150    (0..=PRECOMPUTE_RESOLUTION)
151        .map(|i| std_normal_cdf(MAX_PRECOMPUTE * i as f64 / PRECOMPUTE_RESOLUTION as f64))
152        .collect()
153}
154
155/// Look up `Φ(x / sigma)` in the precomputed table, reproducing
156/// `precomputedCdf` exactly: clamp at `±10` sigma, otherwise index by the
157/// truncated `|x/sigma|` and reflect for negative arguments.
158#[inline]
159fn precomputed_cdf(table: &[f64], x: f64, sigma: f64) -> f64 {
160    let v = x / sigma;
161    if v < -MAX_PRECOMPUTE {
162        return 0.0;
163    }
164    if v > MAX_PRECOMPUTE {
165        return 1.0;
166    }
167    // Same integer truncation and operation order as the C cast.
168    let idx = ((v.abs() / MAX_PRECOMPUTE) * PRECOMPUTE_RESOLUTION as f64) as usize;
169    let base = table[idx];
170    if v < 0.0 {
171        1.0 - base
172    } else {
173        base
174    }
175}
176
177/// Per-gene bandwidth `sd(row) / 4`, where `sd` is the sample standard deviation
178/// (denominator `n − 1`). Matches GSVA's C `sd` (`src/utils.c`): a two-pass
179/// corrected mean followed by the sum of squared deviations. The `0.001`
180/// fallback mirrors the C guard for a zero/NA bandwidth (constant rows are
181/// dropped upstream, so it is essentially never hit).
182fn bandwidth(row: &[f64]) -> f64 {
183    let n = row.len();
184    let nf = n as f64;
185    let mut mean = row.iter().sum::<f64>() / nf;
186    let mut corr = 0.0f64;
187    for &v in row {
188        corr += v - mean;
189    }
190    mean += corr / nf;
191    let mut ss = 0.0f64;
192    for &v in row {
193        let d = v - mean;
194        ss += d * d;
195    }
196    let sd = (ss / (nf - 1.0)).sqrt();
197    let bw = sd / SIGMA_FACTOR;
198    if bw == 0.0 || bw.is_nan() {
199        0.001
200    } else {
201        bw
202    }
203}
204
205/// Gaussian kernel CDF (`left_tail`) for every gene × sample, row-major
206/// (`out[g * nsamp + j]`).
207///
208/// For each gene `g` with bandwidth `bw_g`, `left_tail[g, j]` is the mean over
209/// all samples `l` of `Φ((x[g,j] − x[g,l]) / bw_g)`, accumulated in the same
210/// order and plain `f64` arithmetic as `row_d` in `kernel_estimation.c`. The
211/// returned values are rank-equivalent to GSVA's logit output.
212pub(crate) fn gaussian_left_tail(expr: &ExprMatrix) -> Vec<f64> {
213    let p = expr.nrow();
214    let n = expr.ncol();
215    let table = build_table();
216    let nf = n as f64;
217    let mut out = vec![0.0f64; p * n];
218    // Each gene's row is independent (its bandwidth and pairwise CDF terms touch
219    // only that row), so parallelize over genes; each unit owns one contiguous
220    // output row and the inner accumulation order is unchanged.
221    crate::par::fill_chunks_mut(&mut out, n, |g, row_out| {
222        let row = expr.row(g);
223        let bw = bandwidth(row);
224        for (slot, &yj) in row_out.iter_mut().zip(row) {
225            let mut lt = 0.0f64;
226            for &xl in row {
227                lt += precomputed_cdf(&table, yj - xl, bw);
228            }
229            *slot = lt / nf;
230        }
231    });
232    out
233}
234
235// --- Poisson kernel (for integer count data; GSVA `kcdf = "Poisson"`) -------
236
237/// Natural log of the gamma function via the Lanczos approximation (g = 7).
238/// Accurate to ~1e-15 relative for the `a ≥ 1` arguments used by [`ppois`].
239fn ln_gamma(x: f64) -> f64 {
240    const G: f64 = 7.0;
241    const C: [f64; 9] = [
242        0.99999999999980993,
243        676.5203681218851,
244        -1259.1392167224028,
245        771.32342877765313,
246        -176.61502916214059,
247        12.507343278686905,
248        -0.13857109526572012,
249        9.9843695780195716e-6,
250        1.5056327351493116e-7,
251    ];
252    if x < 0.5 {
253        // Reflection formula, for completeness (not hit by ppois).
254        (std::f64::consts::PI / (std::f64::consts::PI * x).sin()).ln() - ln_gamma(1.0 - x)
255    } else {
256        let x = x - 1.0;
257        let mut a = C[0];
258        let t = x + G + 0.5;
259        for (i, &c) in C.iter().enumerate().skip(1) {
260            a += c / (x + i as f64);
261        }
262        0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
263    }
264}
265
266/// Regularized lower incomplete gamma `P(a, x)` via its series expansion
267/// (converges quickly for `x < a + 1`).
268fn gamma_p_series(a: f64, x: f64) -> f64 {
269    let mut ap = a;
270    let mut del = 1.0 / a;
271    let mut sum = del;
272    for _ in 0..300 {
273        ap += 1.0;
274        del *= x / ap;
275        sum += del;
276        if del.abs() < sum.abs() * 1e-16 {
277            break;
278        }
279    }
280    sum * (-x + a * x.ln() - ln_gamma(a)).exp()
281}
282
283/// Regularized upper incomplete gamma `Q(a, x)` via the Lentz continued
284/// fraction (converges quickly for `x ≥ a + 1`).
285fn gamma_q_cf(a: f64, x: f64) -> f64 {
286    const FPMIN: f64 = 1e-300;
287    let mut b = x + 1.0 - a;
288    let mut c = 1.0 / FPMIN;
289    let mut d = 1.0 / b;
290    let mut h = d;
291    for i in 1..300 {
292        let an = -(i as f64) * (i as f64 - a);
293        b += 2.0;
294        d = an * d + b;
295        if d.abs() < FPMIN {
296            d = FPMIN;
297        }
298        c = b + an / c;
299        if c.abs() < FPMIN {
300            c = FPMIN;
301        }
302        d = 1.0 / d;
303        let del = d * c;
304        h *= del;
305        if (del - 1.0).abs() < 1e-16 {
306            break;
307        }
308    }
309    (-x + a * x.ln() - ln_gamma(a)).exp() * h
310}
311
312/// Regularized upper incomplete gamma `Q(a, x) = Γ(a, x) / Γ(a)`.
313fn gamma_q(a: f64, x: f64) -> f64 {
314    if x <= 0.0 {
315        return 1.0;
316    }
317    if x < a + 1.0 {
318        1.0 - gamma_p_series(a, x)
319    } else {
320        gamma_q_cf(a, x)
321    }
322}
323
324/// Poisson CDF `P(N ≤ ⌊k⌋)` for mean `lambda`, using the identity
325/// `F(k; λ) = Q(⌊k⌋ + 1, λ)`. Equivalent to R's `ppois(k, lambda)`.
326fn ppois(k: f64, lambda: f64) -> f64 {
327    let kk = k.floor();
328    if kk < 0.0 {
329        return 0.0;
330    }
331    gamma_q(kk + 1.0, lambda)
332}
333
334/// Poisson kernel CDF (`left_tail`) for every gene × sample, row-major.
335///
336/// Mirrors the Poisson branch of `row_d` in `kernel_estimation.c`: bandwidth is
337/// the fixed `0.5` offset, and each term is `ppois(x[g,j], x[g,l] + 0.5)`,
338/// summed in sample order with plain `f64` arithmetic and divided by `n`.
339pub(crate) fn poisson_left_tail(expr: &ExprMatrix) -> Vec<f64> {
340    let p = expr.nrow();
341    let n = expr.ncol();
342    let nf = n as f64;
343    let mut out = vec![0.0f64; p * n];
344    // Per-gene rows are independent — parallelize over genes (see the Gaussian
345    // kernel above); accumulation order within a row is unchanged.
346    crate::par::fill_chunks_mut(&mut out, n, |g, row_out| {
347        let row = expr.row(g);
348        for (slot, &yj) in row_out.iter_mut().zip(row) {
349            let mut lt = 0.0f64;
350            for &xl in row {
351                lt += ppois(yj, xl + 0.5);
352            }
353            *slot = lt / nf;
354        }
355    });
356    out
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn pnorm_matches_known_values() {
365        // Reference values from R 4.6.0 `pnorm()` (%.17g).
366        let cases = [
367            (0.0, 0.5),
368            (0.5, 0.69146246127401301),
369            (1.0, 0.84134474606854293),
370            (1.6166, 0.94701767404030579),
371            (2.0, 0.97724986805182079),
372            (3.0, 0.9986501019683699),
373            (5.0, 0.99999971334842808),
374            (8.0, 0.99999999999999933),
375        ];
376        for (z, want) in cases {
377            let got = std_normal_cdf(z);
378            assert!(
379                (got - want).abs() <= 1e-15,
380                "pnorm({z}) = {got:.17e}, want {want:.17e}"
381            );
382        }
383        // Reflection for negative inputs.
384        assert!((std_normal_cdf(-1.0) - (1.0 - 0.84134474606854293)).abs() <= 1e-15);
385    }
386
387    #[test]
388    fn ppois_matches_known_values() {
389        // Reference values from R 4.6.0 `ppois(k, lambda)` (%.17g).
390        let cases = [
391            (0.0, 0.5, 0.60653065971263342),
392            (3.0, 2.5, 0.75757613313306593),
393            (5.0, 5.5, 0.52891868652586216),
394            (10.0, 4.5, 0.99333132791281809),
395            (0.0, 10.5, 2.7536449349747158e-5),
396            (20.0, 15.5, 0.89436693722434268),
397        ];
398        for (k, lambda, want) in cases {
399            let got = ppois(k, lambda);
400            assert!(
401                (got - want).abs() <= 1e-12,
402                "ppois({k}, {lambda}) = {got:.17e}, want {want:.17e}"
403            );
404        }
405    }
406
407    #[test]
408    fn left_tail_sums_to_half_n() {
409        // Each gene's kernel CDF sums to n/2 across samples (antisymmetry of the
410        // pairwise differences plus the n diagonal 0.5 terms).
411        let expr = ExprMatrix::new(
412            vec!["G1".into(), "G2".into()],
413            vec!["S1".into(), "S2".into(), "S3".into(), "S4".into()],
414            vec![1.0, 2.0, 5.0, 9.0, 3.0, 3.1, 2.9, 4.0],
415        );
416        let lt = gaussian_left_tail(&expr);
417        for g in 0..2 {
418            let s: f64 = lt[g * 4..(g + 1) * 4].iter().sum();
419            assert!((s - 2.0).abs() < 1e-12, "row {g} sums to {s}, want 2.0");
420        }
421    }
422}