Skip to main content

rsomics_quantile_transform/
transform.rs

1//! Per-column forward transform — sklearn `QuantileTransformer._transform_col`.
2//!
3//! Forward map:
4//!   1. Double-interp averaging (handles ties):
5//!      `0.5 * (interp(x, quantiles, refs) - interp(-x, -quantiles[::-1], -refs[::-1]))`
6//!   2. Force boundary values to 0/1. Uniform detects boundaries by exact equality
7//!      (`x == quantiles[0]` / `x == quantiles[-1]`); normal detects them by a
8//!      `±BOUNDS_THRESHOLD` band (`x - t < lo` / `x + t > hi`), matching sklearn's
9//!      distribution-dependent bound masks.
10//!   3. For normal output: apply `ndtri` then clip to `[CLIP_MIN, CLIP_MAX]`.
11//!
12//! Uniform output is purely linear interpolation → BIT-EXACT vs sklearn (0 ULP).
13//! Normal output adds the Cephes `ndtri` transcendental; cross-arch last bits can
14//! differ ≤1 ULP, so compat tolerance is ≤1e-12 relative.
15
16use crate::ndtri::{CLIP_MAX, CLIP_MIN, ndtri};
17
18/// sklearn `BOUNDS_THRESHOLD` — the band width for normal-output boundary masks.
19const BOUNDS_THRESHOLD: f64 = 1e-7;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum OutputDistribution {
23    Uniform,
24    Normal,
25}
26
27/// Linear interpolation matching `np.interp(x, xp, fp)`.
28///
29/// numpy's C implementation computes `slope = (fp[j+1]-fp[j])/(xp[j+1]-xp[j])` then
30/// returns `fp[j] + slope * (x - xp[j])` — which is `fp[j].mul_add(slope, dx)` in FP.
31/// Using the same `fp[i].mul_add(slope, dx)` → `fp[i] + slope*(x-xp[i])` order
32/// via `f64::mul_add` (FMA) matches numpy bit-for-bit including in the subsample regime.
33fn np_interp(x: f64, xp: &[f64], fp: &[f64]) -> f64 {
34    debug_assert_eq!(xp.len(), fp.len());
35    let n = xp.len();
36    if x <= xp[0] {
37        return fp[0];
38    }
39    if x >= xp[n - 1] {
40        return fp[n - 1];
41    }
42    let idx = xp.partition_point(|&v| v <= x);
43    let i = idx - 1;
44    let slope = (fp[i + 1] - fp[i]) / (xp[i + 1] - xp[i]);
45    // FMA: slope * (x - xp[i]) + fp[i]
46    slope.mul_add(x - xp[i], fp[i])
47}
48
49/// Transform a single column in place, matching `_transform_col(inverse=False)`.
50pub fn transform_col(
51    col: &mut [f64],
52    quantiles: &[f64],
53    references: &[f64],
54    dist: OutputDistribution,
55) {
56    let lower_bound_x = quantiles[0];
57    let upper_bound_x = quantiles[quantiles.len() - 1];
58
59    // Build reversed views for the second interp.
60    let q_rev: Vec<f64> = quantiles.iter().rev().map(|&v| -v).collect();
61    let r_rev: Vec<f64> = references.iter().rev().map(|&v| -v).collect();
62
63    for v in col.iter_mut() {
64        if v.is_nan() {
65            continue;
66        }
67        let x = *v;
68
69        let (at_lower, at_upper) = match dist {
70            OutputDistribution::Uniform => (x == lower_bound_x, x == upper_bound_x),
71            OutputDistribution::Normal => (
72                x - BOUNDS_THRESHOLD < lower_bound_x,
73                x + BOUNDS_THRESHOLD > upper_bound_x,
74            ),
75        };
76
77        // Double-interp averaging (ties handling, per sklearn comment).
78        let fwd = np_interp(x, quantiles, references);
79        let rev = -np_interp(-x, &q_rev, &r_rev);
80        let mut y = 0.5 * (fwd + rev);
81
82        // sklearn sets upper then lower after interp, so lower wins on a tie.
83        if at_upper {
84            y = 1.0;
85        }
86        if at_lower {
87            y = 0.0;
88        }
89
90        if dist == OutputDistribution::Normal {
91            y = ndtri(y).clamp(CLIP_MIN, CLIP_MAX);
92        }
93
94        *v = y;
95    }
96}
97
98/// Transform every column of the matrix (row-major `data`, `n_rows × n_cols`).
99pub fn transform_matrix(
100    data: &mut [f64],
101    n_rows: usize,
102    n_cols: usize,
103    quantiles_per_col: &[Vec<f64>],
104    references: &[f64],
105    dist: OutputDistribution,
106) {
107    // Work column by column; extract → transform → scatter back.
108    for j in 0..n_cols {
109        let mut col: Vec<f64> = (0..n_rows).map(|i| data[i * n_cols + j]).collect();
110        transform_col(&mut col, &quantiles_per_col[j], references, dist);
111        for (i, v) in col.into_iter().enumerate() {
112            data[i * n_cols + j] = v;
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn close(a: f64, b: f64) {
122        assert!(
123            (a - b).abs() < 1e-12,
124            "got={a} want={b} diff={}",
125            (a - b).abs()
126        );
127    }
128
129    #[test]
130    fn np_interp_basic() {
131        let xp = [0.0, 1.0, 2.0];
132        let fp = [0.0, 0.5, 1.0];
133        close(np_interp(0.5, &xp, &fp), 0.25);
134        close(np_interp(0.0, &xp, &fp), 0.0);
135        close(np_interp(2.0, &xp, &fp), 1.0);
136        close(np_interp(-1.0, &xp, &fp), 0.0); // below → fp[0]
137        close(np_interp(3.0, &xp, &fp), 1.0); // above → fp[-1]
138    }
139
140    #[test]
141    fn uniform_ties_average() {
142        // quantiles = [1,2,2,2,3], refs = [0,.25,.5,.75,1]
143        // x=2 → fwd=interp(2,[1,2,2,2,3],[0,.25,.5,.75,1])=0.25
144        //        rev=-interp(-2,[-3,-2,-2,-2,-1],[−1,−.75,−.5,−.25,0])
145        //        fwd for rev interp: x=-2 in [-3,-2,-2,-2,-1] → 0.75
146        //        rev term = -(−0.75) but wait — refs_rev = [−1,−.75,−.5,−.25,0]
147        //        -interp(-2,[-3,-2,-2,-2,-1],[-1,-.75,-.5,-.25,0]) = -(-0.75) = 0.75
148        //        average = 0.5*(0.25+0.75) = 0.5
149        let quantiles = [1.0, 2.0, 2.0, 2.0, 3.0];
150        let refs = [0.0, 0.25, 0.5, 0.75, 1.0];
151        let mut col = [2.0];
152        transform_col(&mut col, &quantiles, &refs, OutputDistribution::Uniform);
153        close(col[0], 0.5);
154    }
155
156    #[test]
157    fn boundary_forced_to_exact() {
158        let quantiles = [1.0, 2.0, 3.0];
159        let refs = [0.0, 0.5, 1.0];
160        let mut col = [1.0, 3.0];
161        transform_col(&mut col, &quantiles, &refs, OutputDistribution::Uniform);
162        assert_eq!(col[0], 0.0);
163        assert_eq!(col[1], 1.0);
164    }
165}