Skip to main content

rsomics_quantile_transform/
quantile.rs

1//! Type-7 `np.nanpercentile` and quantile fitting — sklearn `QuantileTransformer._dense_fit`.
2//!
3//! `np.nanpercentile` (linear = type-7) places the virtual index `h = (n−1)·q/100` over
4//! the sorted non-NaN survivors and linearly interpolates the bracketing order statistics.
5//! sklearn builds `references_ = linspace(0, 1, n_quantiles_)`, calls
6//! `np.nanpercentile(X, references * 100, axis=0)`, then `np.maximum.accumulate`
7//! along axis=0 to force monotonicity (guards against floating-point reversals at
8//! repeated values).
9//!
10//! When n_samples > subsample, sklearn resamples the **entire matrix** (shared row
11//! indices for all columns) via one `resample(X, replace=False)` call. We replicate
12//! that by drawing indices once then extracting per-column subsets.
13
14use crate::rng::{Mt19937, shuffle_arange};
15
16/// Type-7 quantile of `xs` at probability `p ∈ [0,1]`. `xs` is reordered in place.
17#[cfg(test)]
18fn quantile_type7(xs: &mut [f64], p: f64) -> f64 {
19    let n = xs.len();
20    if n == 1 {
21        return xs[0];
22    }
23    let h = (n as f64 - 1.0) * p;
24    let lo = h.floor() as usize;
25    let frac = h - lo as f64;
26    if lo + 1 >= n {
27        return *xs.select_nth_unstable_by(n - 1, f64::total_cmp).1;
28    }
29    let (_, &mut kth, right) = xs.select_nth_unstable_by(lo, f64::total_cmp);
30    let next = right.iter().copied().fold(f64::INFINITY, f64::min);
31    kth + frac * (next - kth)
32}
33
34/// `np.nanpercentile(col, q)` with `q` in percent. All-NaN → NaN.
35/// Used for single-quantile queries (unit tests / internal use).
36#[cfg(test)]
37fn nanpercentile(col: &[f64], q: f64) -> f64 {
38    let mut v: Vec<f64> = col.iter().copied().filter(|x| !x.is_nan()).collect();
39    if v.is_empty() {
40        return f64::NAN;
41    }
42    quantile_type7(&mut v, q / 100.0)
43}
44
45/// `np.nanpercentile(col, references * 100)` for all references in one pass.
46///
47/// Sorts the finite values ONCE then evaluates every quantile via the type-7 formula
48/// on the sorted array in O(n_quantiles) lookups — vs O(n × n_quantiles) if we called
49/// quickselect for each quantile individually. This is the critical performance path.
50///
51/// The virtual index `h` is computed as `(n-1) * q / 100` where `q = r * 100`,
52/// matching numpy's internal FP evaluation order so quantile values are bit-identical.
53fn nanpercentile_all(col: &[f64], references: &[f64]) -> Vec<f64> {
54    let mut v: Vec<f64> = col.iter().copied().filter(|x| !x.is_nan()).collect();
55    if v.is_empty() {
56        return vec![f64::NAN; references.len()];
57    }
58    v.sort_unstable_by(f64::total_cmp);
59    let n = v.len();
60    let nm1 = (n - 1) as f64;
61    references
62        .iter()
63        .map(|&r| {
64            // sklearn calls np.nanpercentile(col, references*100), so q = r*100.
65            // numpy computes h = (n-1) * (q/100), with parentheses — NOT (n-1)*q/100.
66            // The round-trip r→r*100→÷100 can differ by up to 1 ULP, which changes lo.
67            let q = r * 100.0;
68            let h = nm1 * (q / 100.0);
69            let lo = h.floor() as usize;
70            let frac = h - lo as f64;
71            if lo + 1 >= n {
72                return v[n - 1];
73            }
74            v[lo] + frac * (v[lo + 1] - v[lo])
75        })
76        .collect()
77}
78
79/// Fit `quantiles_` for one (already-subsampled) column.
80/// Returns a `Vec` of length `references.len()` after `np.maximum.accumulate`.
81fn fit_column(col: &[f64], references: &[f64]) -> Vec<f64> {
82    let mut q = nanpercentile_all(col, references);
83
84    // np.maximum.accumulate along quantile axis — enforces monotonicity.
85    let mut running_max = f64::NEG_INFINITY;
86    for v in &mut q {
87        if !v.is_nan() {
88            if *v < running_max {
89                *v = running_max;
90            } else {
91                running_max = *v;
92            }
93        }
94    }
95
96    q
97}
98
99/// Fit quantile tables for every column. Returns `(references, quantiles)` where
100/// `quantiles[j]` is the `n_quantiles`-length vector for column `j`.
101///
102/// `n_quantiles` is clamped to `n_samples` per sklearn's `fit()` logic.
103pub fn fit_quantiles(
104    data: &[f64],
105    n_rows: usize,
106    n_cols: usize,
107    n_quantiles_req: usize,
108    subsample: Option<usize>,
109    seed: u64,
110) -> (Vec<f64>, Vec<Vec<f64>>) {
111    let n_quantiles = n_quantiles_req.min(n_rows).max(1);
112    // np.linspace(0, 1, n_quantiles). For num==1 numpy returns [start] == [0.0],
113    // not the endpoint. For num>1 multiply i by step (not divide i by n-1) to
114    // match numpy's bit pattern (7.0/9.0 != 7.0*(1.0/9.0) at i=7, n=10) and pin
115    // the last landmark to exactly 1.0.
116    let references: Vec<f64> = if n_quantiles == 1 {
117        vec![0.0]
118    } else {
119        let step = 1.0 / (n_quantiles - 1) as f64;
120        (0..n_quantiles)
121            .map(|i| {
122                if i == n_quantiles - 1 {
123                    1.0
124                } else {
125                    i as f64 * step
126                }
127            })
128            .collect()
129    };
130
131    // Pre-compute shared subsample row indices when n_rows > k (one shuffle, all columns).
132    let shared_indices: Option<Vec<usize>> = subsample.and_then(|k| {
133        if n_rows > k {
134            let mut rng = Mt19937::seed(seed as u32);
135            let all = shuffle_arange(&mut rng, n_rows);
136            Some(all[..k].to_vec())
137        } else {
138            None
139        }
140    });
141
142    let quantiles: Vec<Vec<f64>> = (0..n_cols)
143        .map(|j| {
144            let col: Vec<f64> = match &shared_indices {
145                Some(idxs) => idxs.iter().map(|&i| data[i * n_cols + j]).collect(),
146                None => (0..n_rows).map(|i| data[i * n_cols + j]).collect(),
147            };
148            fit_column(&col, &references)
149        })
150        .collect();
151
152    (references, quantiles)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn close(a: f64, b: f64) {
160        assert!((a - b).abs() < 1e-12, "{a} != {b}");
161    }
162
163    #[test]
164    fn nanpercentile_type7() {
165        let c = [1.0, 2.0, 3.0, 4.0, 5.0];
166        close(nanpercentile(&c, 25.0), 2.0);
167        close(nanpercentile(&c, 50.0), 3.0);
168        close(nanpercentile(&c, 75.0), 4.0);
169        close(nanpercentile(&c, 10.0), 1.4);
170        close(nanpercentile(&c, 0.0), 1.0);
171        close(nanpercentile(&c, 100.0), 5.0);
172    }
173
174    #[test]
175    fn nanpercentile_unsorted() {
176        close(nanpercentile(&[7.0, 3.0, 9.0, 1.0, 5.0], 10.0), 1.8);
177        close(nanpercentile(&[7.0, 3.0, 9.0, 1.0, 5.0], 75.0), 7.0);
178    }
179
180    #[test]
181    fn fit_column_basic() {
182        // 5 samples, 5 quantiles → references=[0,0.25,0.5,0.75,1] (step=1/4 exact)
183        // np.nanpercentile([1,2,3,4,5], [0,25,50,75,100]) = [1,2,3,4,5]
184        let refs: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect();
185        let q = fit_column(&[1.0, 2.0, 3.0, 4.0, 5.0], &refs);
186        for (got, want) in q.iter().zip(&[1.0, 2.0, 3.0, 4.0, 5.0]) {
187            close(*got, *want);
188        }
189    }
190
191    #[test]
192    fn monotonic_accumulate() {
193        let refs = vec![0.0, 0.5, 1.0];
194        let q = fit_column(&[5.0, 5.0, 5.0], &refs);
195        assert!(q.windows(2).all(|w| w[0] <= w[1]));
196    }
197}