Skip to main content

zsfm_tabfm/ensemble/
scalers.rs

1//! Per-column feature scalers, ported from `tabfm/src/classifier_and_regressor.py`'s
2//! `PreprocessingPipeline`, applied in this exact order: `CustomStandardScaler` -> one of 5
3//! optional normalizers (if not `"none"`) -> `OutlierRemover` (last, not second — verified
4//! against `PreprocessingPipeline.fit`). Each scaler exposes `fit`/`transform` mirroring
5//! sklearn's split so the same fitted state (from training columns) can be applied to held-out
6//! columns.
7
8/// `CustomStandardScaler`: `clip((x - mean) / (std + eps), -100, 100)`.
9pub struct CustomStandardScaler {
10    mean: f64,
11    scale: f64,
12}
13
14impl CustomStandardScaler {
15    const EPSILON: f64 = 1e-6;
16    const CLIP: f64 = 100.0;
17
18    pub fn fit(x: &[f64]) -> Self {
19        let mean = mean(x);
20        let scale = std_dev(x, mean, 0) + Self::EPSILON;
21        CustomStandardScaler { mean, scale }
22    }
23
24    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
25        x.iter()
26            .map(|&v| ((v - self.mean) / self.scale).clamp(-Self::CLIP, Self::CLIP))
27            .collect()
28    }
29}
30
31/// `OutlierRemover` (`threshold=4.0` default): two-pass mean/std (outliers masked before the
32/// second pass), then a smooth log-based soft clip (NOT a hard clip) at the recomputed bounds.
33pub struct OutlierRemover {
34    lower_bound: f64,
35    upper_bound: f64,
36}
37
38impl OutlierRemover {
39    pub fn fit(x: &[f64], threshold: f64) -> Self {
40        let mean1 = mean(x);
41        let std1 = std_dev(x, mean1, 1).max(1e-6);
42        let lower1 = mean1 - threshold * std1;
43        let upper1 = mean1 + threshold * std1;
44
45        let clean: Vec<f64> = x
46            .iter()
47            .copied()
48            .filter(|&v| v >= lower1 && v <= upper1)
49            .collect();
50        let (mean2, std2) = if clean.is_empty() {
51            (mean1, std1)
52        } else {
53            let m = mean(&clean);
54            (m, std_dev(&clean, m, 1).max(1e-6))
55        };
56
57        OutlierRemover {
58            lower_bound: mean2 - threshold * std2,
59            upper_bound: mean2 + threshold * std2,
60        }
61    }
62
63    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
64        x.iter()
65            .map(|&v| {
66                let v = (-((v.abs()).ln_1p()) + self.lower_bound).max(v);
67                (v.abs().ln_1p() + self.upper_bound).min(v)
68            })
69            .collect()
70    }
71}
72
73/// Plain `sklearn.preprocessing.StandardScaler` (ddof=0, no epsilon/clip) — used once, globally,
74/// on the raw regression target (`y_scaler_` in `TabFMRegressor`), separate from the per-member
75/// `CustomStandardScaler` applied to features.
76pub struct StandardScaler {
77    mean: f64,
78    scale: f64,
79}
80
81impl StandardScaler {
82    pub fn fit(x: &[f64]) -> Self {
83        let mean = mean(x);
84        let std = std_dev(x, mean, 0);
85        StandardScaler {
86            mean,
87            scale: if std == 0.0 { 1.0 } else { std },
88        }
89    }
90
91    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
92        x.iter().map(|&v| (v - self.mean) / self.scale).collect()
93    }
94
95    pub fn inverse_transform(&self, x: &[f64]) -> Vec<f64> {
96        x.iter().map(|&v| v * self.scale + self.mean).collect()
97    }
98
99    pub fn inverse_transform_scalar(&self, v: f64) -> f64 {
100        v * self.scale + self.mean
101    }
102}
103
104/// `RobustScaler(unit_variance=True)`: `(x - median) / (IQR / 1.349...)`, where the divisor
105/// makes the scale consistent with a standard-normal's std (`1.349... = Φ⁻¹(0.75) - Φ⁻¹(0.25)`).
106pub struct RobustScaler {
107    median: f64,
108    scale: f64,
109}
110
111impl RobustScaler {
112    pub fn fit(x: &[f64]) -> Self {
113        let mut sorted = x.to_vec();
114        sorted.sort_by(|a, b| a.total_cmp(b));
115        let median = percentile_linear(&sorted, 50.0);
116        let q25 = percentile_linear(&sorted, 25.0);
117        let q75 = percentile_linear(&sorted, 75.0);
118        let iqr = q75 - q25;
119        let norm_factor = norm_ppf(0.75) - norm_ppf(0.25); // ~1.3489795...
120        let scale = if iqr == 0.0 { 1.0 } else { iqr / norm_factor };
121        RobustScaler { median, scale }
122    }
123
124    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
125        x.iter().map(|&v| (v - self.median) / self.scale).collect()
126    }
127}
128
129/// `QuantileTransformer(output_distribution="normal")`: empirical CDF (via linear-interpolated
130/// percentiles, sklearn's default `n_quantiles=1000` capped at the sample count) mapped through
131/// the inverse standard-normal CDF.
132pub struct QuantileTransformer {
133    references: Vec<f64>, // uniform grid in [0,1], length n_quantiles
134    quantiles: Vec<f64>,  // data values at each reference quantile, monotonic non-decreasing
135}
136
137impl QuantileTransformer {
138    const BOUNDS_THRESHOLD: f64 = 1e-7;
139
140    pub fn fit(x: &[f64]) -> Self {
141        let n_quantiles = 1000usize.min(x.len().max(1));
142        let mut sorted = x.to_vec();
143        sorted.sort_by(|a, b| a.total_cmp(b));
144
145        let references: Vec<f64> = (0..n_quantiles)
146            .map(|i| i as f64 / (n_quantiles - 1).max(1) as f64)
147            .collect();
148        let mut quantiles: Vec<f64> = references
149            .iter()
150            .map(|&r| percentile_linear(&sorted, r * 100.0))
151            .collect();
152        // Enforce monotonicity (sklearn does this to guard against numerical noise).
153        for i in 1..quantiles.len() {
154            if quantiles[i] < quantiles[i - 1] {
155                quantiles[i] = quantiles[i - 1];
156            }
157        }
158        QuantileTransformer {
159            references,
160            quantiles,
161        }
162    }
163
164    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
165        x.iter()
166            .map(|&v| {
167                let u = interp_monotonic(v, &self.quantiles, &self.references)
168                    .clamp(Self::BOUNDS_THRESHOLD, 1.0 - Self::BOUNDS_THRESHOLD);
169                norm_ppf(u)
170            })
171            .collect()
172    }
173}
174
175/// `PowerTransformer(method="yeo-johnson", standardize=True)`: per-feature MLE-fit `lambda`
176/// (via a ported `scipy.optimize.brent`), then standardize the transformed values.
177pub struct PowerTransformer {
178    lambda: f64,
179    post_mean: f64,
180    post_std: f64,
181}
182
183impl PowerTransformer {
184    pub fn fit(x: &[f64]) -> Self {
185        let lambda = yeo_johnson_optimize(x);
186        let transformed: Vec<f64> = x
187            .iter()
188            .map(|&v| yeo_johnson_transform(v, lambda))
189            .collect();
190        let post_mean = mean(&transformed);
191        let post_std = std_dev(&transformed, post_mean, 0).max(1e-300);
192        PowerTransformer {
193            lambda,
194            post_mean,
195            post_std,
196        }
197    }
198
199    pub fn transform(&self, x: &[f64]) -> Vec<f64> {
200        x.iter()
201            .map(|&v| (yeo_johnson_transform(v, self.lambda) - self.post_mean) / self.post_std)
202            .collect()
203    }
204}
205
206/// One member's full `PreprocessingPipeline` for a single column: fit on `train_col`, apply to
207/// both `train_col` and `test_col`. Order: `CustomStandardScaler` -> normalizer (if not
208/// `NormMethod::None`) -> `OutlierRemover`.
209pub fn apply_pipeline(
210    train_col: &[f64],
211    test_col: &[f64],
212    norm_method: super::config_gen::NormMethod,
213    outlier_threshold: f64,
214) -> (Vec<f64>, Vec<f64>) {
215    use super::config_gen::NormMethod;
216
217    let scaler = CustomStandardScaler::fit(train_col);
218    let mut train = scaler.transform(train_col);
219    let mut test = scaler.transform(test_col);
220
221    match norm_method {
222        NormMethod::None => {}
223        NormMethod::Power => {
224            let n = PowerTransformer::fit(&train);
225            train = n.transform(&train);
226            test = n.transform(&test);
227        }
228        NormMethod::Quantile => {
229            let n = QuantileTransformer::fit(&train);
230            train = n.transform(&train);
231            test = n.transform(&test);
232        }
233        NormMethod::QuantileRtdl => {
234            // Noise injection uses our own seeded RNG (not bit-identical to NumPy's
235            // default_rng/PCG64) — a documented gap, see the plan's scoping note; the
236            // downstream QuantileTransformer + StandardScaler math is otherwise exact.
237            let noisy = inject_rtdl_noise(&train);
238            let n = QuantileTransformer::fit(&noisy);
239            let train_q = n.transform(&noisy);
240            let test_q = n.transform(&test);
241            let std = StandardScaler::fit(&train_q);
242            train = std.transform(&train_q);
243            test = std.transform(&test_q);
244        }
245        NormMethod::Robust => {
246            let n = RobustScaler::fit(&train);
247            train = n.transform(&train);
248            test = n.transform(&test);
249        }
250    }
251
252    let outlier = OutlierRemover::fit(&train, outlier_threshold);
253    (outlier.transform(&train), outlier.transform(&test))
254}
255
256/// `RTDLQuantileTransformer`'s noise-injection step (`noise=1e-3` default): `x + (noise /
257/// max(std(x), noise)) * standard_normal(shape)`. Uses a simple seeded LCG-derived Gaussian
258/// (Box-Muller), not NumPy's PCG64 — see `apply_pipeline`'s doc comment.
259fn inject_rtdl_noise(x: &[f64]) -> Vec<f64> {
260    let std = std_dev(x, mean(x), 0);
261    let noise = 1e-3;
262    let noise_std = noise / std.max(noise);
263    let mut state = 0x2545F4914F6CDD1Du64;
264    let mut next_u64 = move || {
265        state ^= state << 13;
266        state ^= state >> 7;
267        state ^= state << 17;
268        state
269    };
270    x.iter()
271        .map(|&v| {
272            let u1 = (next_u64() >> 11) as f64 / (1u64 << 53) as f64;
273            let u2 = (next_u64() >> 11) as f64 / (1u64 << 53) as f64;
274            let z = (-2.0 * u1.max(1e-300).ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
275            v + noise_std * z
276        })
277        .collect()
278}
279
280fn yeo_johnson_transform(x: f64, lambda: f64) -> f64 {
281    const EPS: f64 = 1e-6;
282    if x >= 0.0 {
283        if lambda.abs() > EPS {
284            ((x + 1.0).powf(lambda) - 1.0) / lambda
285        } else {
286            (x + 1.0).ln()
287        }
288    } else if (lambda - 2.0).abs() > EPS {
289        -((-x + 1.0).powf(2.0 - lambda) - 1.0) / (2.0 - lambda)
290    } else {
291        -(-x + 1.0).ln()
292    }
293}
294
295/// Negative log-likelihood of the Yeo-Johnson transform at `lambda`, matching sklearn's
296/// `PowerTransformer._yeo_johnson_optimize`'s objective (maximized there; minimized here).
297fn yeo_johnson_neg_log_likelihood(x: &[f64], lambda: f64) -> f64 {
298    let n = x.len() as f64;
299    let transformed: Vec<f64> = x
300        .iter()
301        .map(|&v| yeo_johnson_transform(v, lambda))
302        .collect();
303    let m = mean(&transformed);
304    let var = variance(&transformed, m, 0);
305    if var < f64::MIN_POSITIVE {
306        return f64::INFINITY;
307    }
308    let mut loglike = -n / 2.0 * var.ln();
309    let sum_term: f64 = x.iter().map(|&v| v.signum() * v.abs().ln_1p()).sum();
310    loglike += (lambda - 1.0) * sum_term;
311    -loglike
312}
313
314fn yeo_johnson_optimize(x: &[f64]) -> f64 {
315    brent_minimize(
316        |lambda| yeo_johnson_neg_log_likelihood(x, lambda),
317        -2.0,
318        2.0,
319    )
320}
321
322/// A 1-D bracket-then-Brent minimizer, matching the shape of `scipy.optimize.brent`: first grows
323/// an `(a, b, c)` triplet bracketing a minimum from the two starting points (golden-ratio
324/// expansion), then refines with Brent's method (golden section + parabolic interpolation).
325fn brent_minimize(f: impl Fn(f64) -> f64, xa0: f64, xb0: f64) -> f64 {
326    const GOLD: f64 = 1.618_034;
327    const GLIMIT: f64 = 100.0;
328    const TINY: f64 = 1e-21;
329
330    let (mut ax, mut bx) = (xa0, xb0);
331    let (mut fa, mut fb) = (f(ax), f(bx));
332    if fa < fb {
333        std::mem::swap(&mut ax, &mut bx);
334        std::mem::swap(&mut fa, &mut fb);
335    }
336    let mut cx = bx + GOLD * (bx - ax);
337    let mut fc = f(cx);
338
339    while fc < fb {
340        let r = (bx - ax) * (fb - fc);
341        let q = (bx - cx) * (fb - fa);
342        let denom = 2.0 * (q - r).abs().max(TINY) * (q - r).signum();
343        let mut u = bx - ((bx - cx) * q - (bx - ax) * r) / denom;
344        let ulim = bx + GLIMIT * (cx - bx);
345
346        let fu;
347        if (bx - u) * (u - cx) > 0.0 {
348            fu = f(u);
349            if fu < fc {
350                ax = bx;
351                bx = u;
352                fa = fb;
353                fb = fu;
354                break;
355            } else if fu > fb {
356                cx = u;
357                fc = fu;
358                break;
359            }
360            u = cx + GOLD * (cx - bx);
361            let fu2 = f(u);
362            ax = bx;
363            bx = cx;
364            cx = u;
365            fa = fb;
366            fb = fc;
367            fc = fu2;
368        } else if (cx - u) * (u - ulim) > 0.0 {
369            fu = f(u);
370            if fu < fc {
371                bx = cx;
372                cx = u;
373                let u2 = cx + GOLD * (cx - bx);
374                fb = fc;
375                fc = fu;
376                let fu2 = f(u2);
377                ax = bx;
378                bx = cx;
379                cx = u2;
380                fa = fb;
381                fb = fc;
382                fc = fu2;
383            } else {
384                ax = bx;
385                bx = cx;
386                cx = u;
387                fa = fb;
388                fb = fc;
389                fc = fu;
390            }
391        } else if (u - ulim) * (ulim - cx) >= 0.0 {
392            u = ulim;
393            fu = f(u);
394            ax = bx;
395            bx = cx;
396            cx = u;
397            fa = fb;
398            fb = fc;
399            fc = fu;
400        } else {
401            u = cx + GOLD * (cx - bx);
402            fu = f(u);
403            ax = bx;
404            bx = cx;
405            cx = u;
406            fa = fb;
407            fb = fc;
408            fc = fu;
409        }
410        if (cx - ax).abs() > 1e6 {
411            break; // safety valve against runaway brackets on pathological inputs
412        }
413    }
414
415    // Ensure ax < cx for Brent's method proper.
416    let (mut a, mut c) = if ax < cx { (ax, cx) } else { (cx, ax) };
417    let mut x = bx;
418    let mut w = bx;
419    let mut v = bx;
420    let mut fx = f(x);
421    let mut fw = fx;
422    let mut fv = fx;
423    let mut d = 0.0f64;
424    let mut e = 0.0f64;
425    const CGOLD: f64 = 0.381_966;
426    const ZEPS: f64 = 1e-12;
427    const TOL: f64 = 1e-8;
428
429    for _ in 0..100 {
430        let xm = 0.5 * (a + c);
431        let tol1 = TOL * x.abs() + ZEPS;
432        let tol2 = 2.0 * tol1;
433        if (x - xm).abs() <= tol2 - 0.5 * (c - a) {
434            break;
435        }
436        let mut use_golden = true;
437        if e.abs() > tol1 {
438            let r = (x - w) * (fx - fv);
439            let mut q = (x - v) * (fx - fw);
440            let mut p = (x - v) * q - (x - w) * r;
441            q = 2.0 * (q - r);
442            if q > 0.0 {
443                p = -p;
444            }
445            q = q.abs();
446            let etemp = e;
447            e = d;
448            if p.abs() < (0.5 * q * etemp).abs() && p > q * (a - x) && p < q * (c - x) {
449                d = p / q;
450                let u = x + d;
451                if u - a < tol2 || c - u < tol2 {
452                    d = if xm - x >= 0.0 { tol1 } else { -tol1 };
453                }
454                use_golden = false;
455            }
456        }
457        if use_golden {
458            e = if x >= xm { a - x } else { c - x };
459            d = CGOLD * e;
460        }
461        let u = if d.abs() >= tol1 {
462            x + d
463        } else {
464            x + if d >= 0.0 { tol1 } else { -tol1 }
465        };
466        let fu = f(u);
467        if fu <= fx {
468            if u >= x {
469                a = x;
470            } else {
471                c = x;
472            }
473            v = w;
474            fv = fw;
475            w = x;
476            fw = fx;
477            x = u;
478            fx = fu;
479        } else {
480            if u < x {
481                a = u;
482            } else {
483                c = u;
484            }
485            if fu <= fw || w == x {
486                v = w;
487                fv = fw;
488                w = u;
489                fw = fu;
490            } else if fu <= fv || v == x || v == w {
491                v = u;
492                fv = fu;
493            }
494        }
495    }
496    x
497}
498
499// ---------------------------------------------------------------------------
500// Shared numeric helpers
501// ---------------------------------------------------------------------------
502
503fn mean(x: &[f64]) -> f64 {
504    if x.is_empty() {
505        0.0
506    } else {
507        x.iter().sum::<f64>() / x.len() as f64
508    }
509}
510
511fn variance(x: &[f64], mean: f64, ddof: usize) -> f64 {
512    let n = x.len();
513    if n <= ddof {
514        return 0.0;
515    }
516    let sum_sq: f64 = x.iter().map(|&v| (v - mean).powi(2)).sum();
517    sum_sq / (n - ddof) as f64
518}
519
520fn std_dev(x: &[f64], mean: f64, ddof: usize) -> f64 {
521    variance(x, mean, ddof).sqrt()
522}
523
524/// NumPy's default ("linear") percentile interpolation on an already-sorted slice.
525pub fn percentile_linear(sorted: &[f64], p: f64) -> f64 {
526    let n = sorted.len();
527    if n == 0 {
528        return f64::NAN;
529    }
530    if n == 1 {
531        return sorted[0];
532    }
533    let rank = (p / 100.0) * (n - 1) as f64;
534    let lo = rank.floor() as usize;
535    let hi = rank.ceil() as usize;
536    if lo == hi {
537        sorted[lo]
538    } else {
539        let frac = rank - lo as f64;
540        sorted[lo] * (1.0 - frac) + sorted[hi] * frac
541    }
542}
543
544/// `np.interp(x, xp, fp)`-equivalent: linear interpolation with clamping outside the domain,
545/// for a monotonic non-decreasing `xp`.
546fn interp_monotonic(x: f64, xp: &[f64], fp: &[f64]) -> f64 {
547    let n = xp.len();
548    if x <= xp[0] {
549        return fp[0];
550    }
551    if x >= xp[n - 1] {
552        return fp[n - 1];
553    }
554    // Binary search for the interval containing x.
555    let idx = xp.partition_point(|&v| v <= x);
556    let (x0, x1) = (xp[idx - 1], xp[idx]);
557    let (y0, y1) = (fp[idx - 1], fp[idx]);
558    if x1 == x0 {
559        y0
560    } else {
561        y0 + (y1 - y0) * (x - x0) / (x1 - x0)
562    }
563}
564
565/// Inverse standard-normal CDF (`scipy.stats.norm.ppf`), via Acklam's rational approximation
566/// with one Halley's-method refinement step (accurate to ~1e-9).
567pub fn norm_ppf(p: f64) -> f64 {
568    if p <= 0.0 {
569        return f64::NEG_INFINITY;
570    }
571    if p >= 1.0 {
572        return f64::INFINITY;
573    }
574    const A: [f64; 6] = [
575        -3.969_683_028_665_376e+01,
576        2.209_460_984_245_205e+02,
577        -2.759_285_104_469_687e+02,
578        1.383_577_518_672_690e+02,
579        -3.066_479_806_614_716e+01,
580        2.506_628_277_459_239e+00,
581    ];
582    const B: [f64; 5] = [
583        -5.447_609_879_822_406e+01,
584        1.615_858_368_580_409e+02,
585        -1.556_989_798_598_866e+02,
586        6.680_131_188_771_972e+01,
587        -1.328_068_155_288_572e+01,
588    ];
589    const C: [f64; 6] = [
590        -7.784_894_002_430_293e-03,
591        -3.223_964_580_411_365e-01,
592        -2.400_758_277_161_838e+00,
593        -2.549_732_539_343_734e+00,
594        4.374_664_141_464_968e+00,
595        2.938_163_982_698_783e+00,
596    ];
597    const D: [f64; 4] = [
598        7.784_695_709_041_462e-03,
599        3.224_671_290_700_398e-01,
600        2.445_134_137_142_996e+00,
601        3.754_408_661_907_416e+00,
602    ];
603    const P_LOW: f64 = 0.02425;
604    let p_high = 1.0 - P_LOW;
605
606    let mut x = if p < P_LOW {
607        let q = (-2.0 * p.ln()).sqrt();
608        (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
609            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
610    } else if p <= p_high {
611        let q = p - 0.5;
612        let r = q * q;
613        (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
614            / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
615    } else {
616        let q = (-2.0 * (1.0 - p).ln()).sqrt();
617        -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
618            / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
619    };
620
621    // One Halley's-method refinement using the standard normal CDF/PDF.
622    let e = 0.5 * erfc(-x / std::f64::consts::SQRT_2) - p;
623    let u = e * (2.0 * std::f64::consts::PI).sqrt() * (x * x / 2.0).exp();
624    x -= u / (1.0 + x * u / 2.0);
625    x
626}
627
628fn erfc(x: f64) -> f64 {
629    1.0 - erf(x)
630}
631
632/// Abramowitz-Stegun 7.1.26 rational approximation to `erf` (max error ~1.5e-7) — sufficient
633/// precision for one Halley refinement step above.
634fn erf(x: f64) -> f64 {
635    let sign = if x < 0.0 { -1.0 } else { 1.0 };
636    let x = x.abs();
637    const A1: f64 = 0.254_829_592;
638    const A2: f64 = -0.284_496_736;
639    const A3: f64 = 1.421_413_741;
640    const A4: f64 = -1.453_152_027;
641    const A5: f64 = 1.061_405_429;
642    const P: f64 = 0.327_591_1;
643    let t = 1.0 / (1.0 + P * x);
644    let y = 1.0 - (((((A5 * t + A4) * t) + A3) * t + A2) * t + A1) * t * (-x * x).exp();
645    sign * y
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn test_custom_standard_scaler() {
654        let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
655        let s = CustomStandardScaler::fit(&x);
656        let t = s.transform(&x);
657        // mean=3, std (ddof=0) = sqrt(2) ~ 1.41421356
658        assert!((t[0] - (1.0 - 3.0) / (2f64.sqrt() + 1e-6)).abs() < 1e-9);
659        assert!((t[2]).abs() < 1e-9); // middle value maps to ~0
660    }
661
662    #[test]
663    fn test_robust_scaler_matches_scipy_norm_factor() {
664        let x: Vec<f64> = (0..101).map(|i| i as f64).collect();
665        let s = RobustScaler::fit(&x);
666        // median of 0..100 is 50; q25=25, q75=75, iqr=50
667        assert!((s.median - 50.0).abs() < 1e-9);
668    }
669
670    #[test]
671    fn test_norm_ppf_known_values() {
672        assert!((norm_ppf(0.5)).abs() < 1e-6);
673        assert!((norm_ppf(0.975) - 1.959_963_985).abs() < 1e-4);
674        assert!((norm_ppf(0.025) + 1.959_963_985).abs() < 1e-4);
675    }
676
677    #[test]
678    fn test_yeo_johnson_identity_at_lambda_1() {
679        assert!((yeo_johnson_transform(5.0, 1.0) - 5.0).abs() < 1e-9);
680        assert!((yeo_johnson_transform(-3.0, 1.0) - (-3.0)).abs() < 1e-9);
681    }
682
683    #[test]
684    fn test_power_transformer_matches_sklearn() {
685        // Generated via: sklearn.preprocessing.PowerTransformer(method="yeo-johnson",
686        // standardize=True) fit on np.random.default_rng(0).uniform(-2, 2, 20).
687        let x = vec![
688            0.5478467492858172,
689            -0.9208531449445188,
690            -1.8361059042552212,
691            -1.9338894578858836,
692            1.2530809568010897,
693            1.6510223091108869,
694            0.42654310306871945,
695            0.9179862439359936,
696            0.17449996586169148,
697            1.740289695151073,
698            1.2634142164861286,
699            -1.9890459993194076,
700            1.4296171063502774,
701            -1.8656576987781426,
702            0.9186217857197763,
703            -1.297377517589764,
704            1.4527156893995463,
705            0.16584488099636685,
706            -0.8011524378504609,
707            -0.3092511152093662,
708        ];
709        let expected_lambda = 1.345222766603304;
710        let expected_transformed = [
711            0.2719479783598148,
712            -0.8318522439585222,
713            -1.3650247006836644,
714            -1.4181615227275621,
715            0.9606112117567647,
716            1.3853857310641091,
717            0.16297351213481462,
718            0.6223628894658091,
719            -0.05314385725391679,
720            1.483863071047962,
721            0.9713347980371447,
722            -1.4478643497510573,
723            1.1461058305187737,
724            -1.3811492793940139,
725            0.6229863176690302,
726            -1.0599639970952783,
727            1.1707302052556954,
728            -0.06030208166470946,
729            -0.7561721820272899,
730            -0.42466733075390517,
731        ];
732        let pt = PowerTransformer::fit(&x);
733        assert!(
734            (pt.lambda - expected_lambda).abs() < 1e-4,
735            "lambda {} vs expected {}",
736            pt.lambda,
737            expected_lambda
738        );
739        let got = pt.transform(&x);
740        for (g, e) in got.iter().zip(expected_transformed.iter()) {
741            assert!((g - e).abs() < 1e-3, "got {g} vs expected {e}");
742        }
743    }
744
745    #[test]
746    fn test_percentile_linear() {
747        let sorted = vec![1.0, 2.0, 3.0, 4.0, 5.0];
748        assert!((percentile_linear(&sorted, 50.0) - 3.0).abs() < 1e-9);
749        assert!((percentile_linear(&sorted, 0.0) - 1.0).abs() < 1e-9);
750        assert!((percentile_linear(&sorted, 100.0) - 5.0).abs() < 1e-9);
751    }
752
753    #[test]
754    fn test_quantile_transformer_matches_sklearn() {
755        // Generated via: sklearn.preprocessing.QuantileTransformer(output_distribution="normal",
756        // random_state=0) fit on np.random.default_rng(0).uniform(-2, 2, 20). n_quantiles is
757        // capped to n_samples=20 by sklearn since 1000 > 20.
758        let x = vec![
759            0.5478467492858172,
760            -0.9208531449445188,
761            -1.8361059042552212,
762            -1.9338894578858836,
763            1.2530809568010897,
764            1.6510223091108869,
765            0.42654310306871945,
766            0.9179862439359936,
767            0.17449996586169148,
768            1.740289695151073,
769            1.2634142164861286,
770            -1.9890459993194076,
771            1.4296171063502774,
772            -1.8656576987781426,
773            0.9186217857197763,
774            -1.297377517589764,
775            1.4527156893995463,
776            0.16584488099636685,
777            -0.8011524378504609,
778            -0.3092511152093662,
779        ];
780        let expected = [
781            0.199201324789267,
782            -0.6336400007797011,
783            -1.003147967662534,
784            -1.6198562586382699,
785            0.633640000779701,
786            1.6198562586382697,
787            0.0660118123758406,
788            0.33603814037182306,
789            -0.06601181237584074,
790            5.19933758270342,
791            0.8045963803603002,
792            -5.199337582605575,
793            1.0031479676625337,
794            -1.2521195202652193,
795            0.47950565333094985,
796            -0.8045963803603002,
797            1.2521195202652189,
798            -0.199201324789267,
799            -0.47950565333095013,
800            -0.33603814037182317,
801        ];
802        let qt = QuantileTransformer::fit(&x);
803        let got = qt.transform(&x);
804        for (g, e) in got.iter().zip(expected.iter()) {
805            assert!((g - e).abs() < 1e-3, "got {g} vs expected {e}");
806        }
807    }
808}