Skip to main content

u_numflow/
transforms.rs

1//! Data transformations for statistical analysis.
2//!
3//! Currently provides the Box-Cox power transformation, which maps non-normal
4//! positive data to approximate normality. The optimal transformation parameter
5//! λ is estimated via maximum likelihood.
6//!
7//! # References
8//! Box, G. E. P. & Cox, D. R. (1964). "An analysis of transformations."
9//! *Journal of the Royal Statistical Society, Series B*, 26(2), 211–252.
10
11use std::fmt;
12
13use crate::stats::population_variance;
14
15// ── Error type ────────────────────────────────────────────────────────────────
16
17/// Errors that can arise from Box-Cox transformations.
18#[derive(Debug, Clone, PartialEq)]
19pub enum TransformError {
20    /// Box-Cox requires all y > 0.
21    NonPositiveData,
22    /// Data contains NaN or an infinity.
23    NonFiniteData,
24    /// Need at least 2 data points.
25    InsufficientData,
26    /// The forward transformation produced non-finite values (`y^λ` beyond
27    /// the range of `f64`, or a non-finite λ).
28    InvalidTransform,
29    /// Inverse transformation produced non-finite values.
30    InvalidInverse,
31    /// The λ search range is empty or not finite (`lambda_min >= lambda_max`).
32    InvalidLambdaRange,
33}
34
35impl fmt::Display for TransformError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            TransformError::NonPositiveData => {
39                write!(f, "Box-Cox requires all y > 0")
40            }
41            TransformError::NonFiniteData => {
42                write!(f, "data must not contain NaN or infinite values")
43            }
44            TransformError::InsufficientData => {
45                write!(f, "need at least 2 data points")
46            }
47            TransformError::InvalidTransform => {
48                write!(f, "transformation produced non-finite values")
49            }
50            TransformError::InvalidInverse => {
51                write!(f, "inverse transformation produced non-finite values")
52            }
53            TransformError::InvalidLambdaRange => {
54                write!(
55                    f,
56                    "lambda search range must be finite with lambda_min < lambda_max"
57                )
58            }
59        }
60    }
61}
62
63impl std::error::Error for TransformError {}
64
65// ── Validation helpers ────────────────────────────────────────────────────────
66
67fn validate_positive_slice(y: &[f64]) -> Result<(), TransformError> {
68    if y.len() < 2 {
69        return Err(TransformError::InsufficientData);
70    }
71    if y.iter().any(|v| !v.is_finite()) {
72        return Err(TransformError::NonFiniteData);
73    }
74    if y.iter().any(|&v| v <= 0.0) {
75        return Err(TransformError::NonPositiveData);
76    }
77    Ok(())
78}
79
80// ── Public API ────────────────────────────────────────────────────────────────
81
82/// Apply the Box-Cox power transformation to positive data.
83///
84/// For each element `yᵢ > 0`:
85///
86/// ```text
87/// y(λ) = ln(y)           when |λ| < 1e-10
88///         (y^λ - 1) / λ   otherwise
89/// ```
90///
91/// # Errors
92/// - [`TransformError::InsufficientData`] — fewer than 2 data points
93/// - [`TransformError::NonPositiveData`]  — any element ≤ 0
94/// - [`TransformError::NonFiniteData`]    — any element is NaN or infinite
95/// - [`TransformError::InvalidTransform`] — a result is not finite (`y^λ`
96///   overflows, or `lambda` is not finite); the mirror of
97///   [`inverse_box_cox`]'s `InvalidInverse`
98///
99/// # Examples
100/// ```
101/// use u_numflow::transforms::box_cox;
102///
103/// let y = vec![1.0, std::f64::consts::E];
104/// let y_t = box_cox(&y, 0.0).unwrap();
105/// assert!((y_t[0] - 0.0).abs() < 1e-10);
106/// assert!((y_t[1] - 1.0).abs() < 1e-9);
107/// ```
108pub fn box_cox(y: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
109    validate_positive_slice(y)?;
110    let result = if lambda.abs() < 1e-10 {
111        y.iter().map(|&v| v.ln()).collect()
112    } else {
113        y.iter()
114            .map(|&v| (v.powf(lambda) - 1.0) / lambda)
115            .collect::<Vec<_>>()
116    };
117    if result.iter().any(|v| !v.is_finite()) {
118        return Err(TransformError::InvalidTransform);
119    }
120    Ok(result)
121}
122
123/// Invert a Box-Cox transformation.
124///
125/// For each transformed element `y_tᵢ`:
126///
127/// ```text
128/// y = exp(y_t)                  when |λ| < 1e-10
129///     (y_t · λ + 1)^(1/λ)      otherwise
130/// ```
131///
132/// Accepts slices of any length, including empty. Unlike [`box_cox`], no
133/// minimum length is required. An empty slice returns an empty `Vec` silently.
134///
135/// # Errors
136/// - [`TransformError::InvalidInverse`] — any result is non-finite
137///
138/// # Examples
139/// ```
140/// use u_numflow::transforms::{box_cox, inverse_box_cox};
141///
142/// let y = vec![2.0, 5.0, 10.0];
143/// let y_t = box_cox(&y, 1.0).unwrap();
144/// let y_rec = inverse_box_cox(&y_t, 1.0).unwrap();
145/// for (a, b) in y.iter().zip(y_rec.iter()) {
146///     assert!((a - b).abs() < 1e-9);
147/// }
148/// ```
149pub fn inverse_box_cox(y_t: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
150    let result: Vec<f64> = if lambda.abs() < 1e-10 {
151        y_t.iter().map(|&v| v.exp()).collect()
152    } else {
153        y_t.iter()
154            .map(|&v| (v * lambda + 1.0).powf(1.0 / lambda))
155            .collect()
156    };
157    if result.iter().any(|v| !v.is_finite()) {
158        return Err(TransformError::InvalidInverse);
159    }
160    Ok(result)
161}
162
163/// A maximum-likelihood Box-Cox λ, with where it sits in its search range.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct LambdaEstimate {
166    /// The λ that maximises the profile log-likelihood within the range.
167    pub lambda: f64,
168    /// `true` when the maximum lies on an end of the search range (within
169    /// the search tolerance): the likelihood was still rising there, so the
170    /// unconstrained optimum is at or beyond that end and `lambda` is the
171    /// range limit, not an interior estimate.
172    pub at_bound: bool,
173}
174
175/// Estimate the optimal Box-Cox λ via maximum likelihood.
176///
177/// Maximises the profile log-likelihood:
178///
179/// ```text
180/// ℓ(λ) = -(n/2)·ln(Var_population(y(λ))) + (λ-1)·Σ ln(yᵢ)
181/// ```
182///
183/// using a **golden-section search** over `[lambda_min, lambda_max]` with up
184/// to 100 iterations (terminating early when the bracket width < 1e-6).
185///
186/// The result says whether the maximum was found on an end of the range
187/// ([`LambdaEstimate::at_bound`]): a λ of `lambda_max` because the likelihood
188/// peaks there is indistinguishable by value from one where the likelihood
189/// keeps rising past it, and only the latter means the range cut the search
190/// short.
191///
192/// # Errors
193/// - [`TransformError::InvalidLambdaRange`] — `lambda_min >= lambda_max`, or
194///   either bound is not finite
195/// - [`TransformError::InsufficientData`] — fewer than 2 data points
196/// - [`TransformError::NonPositiveData`]  — any element ≤ 0
197/// - [`TransformError::NonFiniteData`]    — any element is NaN or infinite
198///
199/// # Examples
200/// ```
201/// use u_numflow::transforms::estimate_lambda;
202///
203/// // Exponential data is well-linearised by log (λ ≈ 0).
204/// let y: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.2_f64).exp()).collect();
205/// let est = estimate_lambda(&y, -2.0, 2.0).unwrap();
206/// assert!(est.lambda.abs() < 0.3, "expected lambda near 0, got {}", est.lambda);
207/// assert!(!est.at_bound);
208/// ```
209pub fn estimate_lambda(
210    y: &[f64],
211    lambda_min: f64,
212    lambda_max: f64,
213) -> Result<LambdaEstimate, TransformError> {
214    if !lambda_min.is_finite() || !lambda_max.is_finite() || lambda_min >= lambda_max {
215        return Err(TransformError::InvalidLambdaRange);
216    }
217    validate_positive_slice(y)?;
218
219    let n = y.len() as f64;
220    let log_sum: f64 = y.iter().map(|&v| v.ln()).sum::<f64>();
221
222    // Profile log-likelihood (higher is better).
223    let profile_ll = |lambda: f64| -> f64 {
224        let y_t: Vec<f64> = if lambda.abs() < 1e-10 {
225            y.iter().map(|&v| v.ln()).collect()
226        } else {
227            y.iter().map(|&v| (v.powf(lambda) - 1.0) / lambda).collect()
228        };
229        // A λ whose transform overflows (`y^λ` beyond f64 for large |λ| or
230        // large y) has no finite variance: it is not a candidate, not a panic.
231        let var = match population_variance(&y_t) {
232            Some(v) if v > 0.0 && v.is_finite() => v,
233            _ => return f64::NEG_INFINITY,
234        };
235        -(n / 2.0) * var.ln() + (lambda - 1.0) * log_sum
236    };
237
238    // Golden-section search (maximisation).
239    const PHI: f64 = 0.618_033_988_749_895; // (√5 - 1) / 2
240    let mut a = lambda_min;
241    let mut b = lambda_max;
242
243    let mut x1 = b - PHI * (b - a);
244    let mut x2 = a + PHI * (b - a);
245    let mut f1 = profile_ll(x1);
246    let mut f2 = profile_ll(x2);
247
248    for _ in 0..100 {
249        if (b - a).abs() < 1e-6 {
250            break;
251        }
252        if f1 < f2 {
253            a = x1;
254            x1 = x2;
255            f1 = f2;
256            x2 = a + PHI * (b - a);
257            f2 = profile_ll(x2);
258        } else {
259            b = x2;
260            x2 = x1;
261            f2 = f1;
262            x1 = b - PHI * (b - a);
263            f1 = profile_ll(x1);
264        }
265    }
266
267    // Golden-section only ever moves the end of the bracket on the side away
268    // from the maximum. An end that never moved means every comparison pointed
269    // toward it: the maximum is on that end of the range.
270    // On a bound the constrained maximiser *is* that bound, so report it exactly
271    // rather than the bracket midpoint half a tolerance inside it.
272    let (lambda, at_bound) = if a == lambda_min {
273        (lambda_min, true)
274    } else if b == lambda_max {
275        (lambda_max, true)
276    } else {
277        ((a + b) / 2.0, false)
278    };
279    Ok(LambdaEstimate { lambda, at_bound })
280}
281
282// ── Tests ─────────────────────────────────────────────────────────────────────
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn box_cox_log_transform() {
290        // lambda=0: y(0) = ln(y)
291        let y = vec![1.0, std::f64::consts::E, std::f64::consts::E.powi(2)];
292        let y_t = box_cox(&y, 0.0).unwrap();
293        assert!((y_t[0] - 0.0).abs() < 1e-10);
294        assert!((y_t[1] - 1.0).abs() < 1e-9);
295        assert!((y_t[2] - 2.0).abs() < 1e-9);
296    }
297
298    #[test]
299    fn box_cox_identity_lambda_1() {
300        // lambda=1: y(1) = (y^1 - 1)/1 = y - 1
301        let y = vec![2.0, 5.0, 10.0];
302        let y_t = box_cox(&y, 1.0).unwrap();
303        assert!((y_t[0] - 1.0).abs() < 1e-10);
304        assert!((y_t[1] - 4.0).abs() < 1e-10);
305    }
306
307    #[test]
308    fn box_cox_sqrt_lambda_half() {
309        // lambda=0.5: y(0.5) = (sqrt(y)-1)/0.5 = 2*(sqrt(y)-1)
310        let y = vec![4.0, 9.0];
311        let y_t = box_cox(&y, 0.5).unwrap();
312        assert!((y_t[0] - 2.0).abs() < 1e-10); // 2*(2-1)=2
313        assert!((y_t[1] - 4.0).abs() < 1e-10); // 2*(3-1)=4
314    }
315
316    #[test]
317    fn inverse_roundtrip_multiple_lambdas() {
318        let y = vec![1.5, 2.3, 4.7, 8.1, 15.2];
319        for &lambda in &[-2.0_f64, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0] {
320            let y_t = box_cox(&y, lambda).unwrap();
321            let y_rec = inverse_box_cox(&y_t, lambda).unwrap();
322            for (orig, rec) in y.iter().zip(y_rec.iter()) {
323                assert!(
324                    (orig - rec).abs() < 1e-9,
325                    "lambda={lambda} orig={orig} rec={rec}"
326                );
327            }
328        }
329    }
330
331    #[test]
332    fn estimate_lambda_near_zero_for_exponential() {
333        // Exponential-like data → log transform (lambda ≈ 0) is optimal
334        let y: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.2).exp()).collect();
335        let lambda = estimate_lambda(&y, -2.0, 2.0).unwrap().lambda;
336        assert!(lambda.abs() < 0.3, "Expected lambda near 0, got {lambda}");
337    }
338
339    /// Data that is exactly normal after a Box-Cox transform with `lambda0`:
340    /// normal quantiles pushed through the inverse transform.
341    fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
342        (1..=n)
343            .map(|i| {
344                let p = (i as f64 - 0.5) / n as f64;
345                let z = 10.0 + 2.0 * crate::special::inverse_normal_cdf(p);
346                (lambda0 * z + 1.0).powf(1.0 / lambda0)
347            })
348            .collect()
349    }
350
351    #[test]
352    fn estimate_lambda_flags_a_range_that_cuts_the_search_short() {
353        let y = normal_after_boxcox(4.0, 100);
354        // Wide enough: an interior maximum near the generating lambda.
355        let wide = estimate_lambda(&y, -5.0, 5.0).unwrap();
356        assert!(!wide.at_bound, "interior maximum: {wide:?}");
357        assert!((wide.lambda - 4.0).abs() < 0.5, "{wide:?}");
358        // Too narrow: the likelihood is still rising at 2, which must be said —
359        // and the reported lambda is the bound itself, not a midpoint near it.
360        let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
361        assert!(narrow.at_bound, "{narrow:?}");
362        assert_eq!(narrow.lambda, 2.0);
363        // The lower end, from the reciprocals: 1/y transformed at −λ is the
364        // negated transform of y at λ, so the likelihood mirrors to λ ≈ −4.
365        let recip: Vec<f64> = y.iter().map(|v| 1.0 / v).collect();
366        let low = estimate_lambda(&recip, -2.0, 2.0).unwrap();
367        assert!(low.at_bound, "{low:?}");
368        assert_eq!(low.lambda, -2.0);
369        let wide = estimate_lambda(&recip, -5.0, 5.0).unwrap();
370        assert!(
371            !wide.at_bound && (wide.lambda + 4.0).abs() < 0.5,
372            "{wide:?}"
373        );
374    }
375
376    #[test]
377    fn estimate_lambda_survives_a_transform_that_overflows() {
378        // y^λ overflows f64 for λ near the top of the range: such λ are not
379        // candidates, and the search must not panic on them.
380        let y: Vec<f64> = (1..=20).map(|i| 10f64.powi(i * 3)).collect();
381        let est = estimate_lambda(&y, -20.0, 20.0).expect("finite data, valid range");
382        assert!(est.lambda.is_finite() && est.lambda < 20.0, "{est:?}");
383    }
384
385    #[test]
386    fn non_finite_data_is_reported_not_passed_through() {
387        for bad in [f64::NAN, f64::INFINITY] {
388            let y = [1.0, bad, 3.0];
389            assert_eq!(box_cox(&y, 0.5), Err(TransformError::NonFiniteData));
390            assert_eq!(
391                estimate_lambda(&y, -2.0, 2.0),
392                Err(TransformError::NonFiniteData)
393            );
394        }
395    }
396
397    #[test]
398    fn estimate_lambda_near_half_for_quadratic() {
399        // y = i^2 data → sqrt transform (lambda ≈ 0.5)
400        let y: Vec<f64> = (1..=20).map(|i| (i as f64).powi(2)).collect();
401        let est = estimate_lambda(&y, -2.0, 2.0).unwrap();
402        assert!(!est.at_bound);
403        let lambda = est.lambda;
404        assert!(
405            lambda > 0.2 && lambda < 0.8,
406            "Expected lambda ~0.5, got {lambda}"
407        );
408    }
409
410    #[test]
411    fn non_positive_returns_error() {
412        assert!(box_cox(&[1.0, -1.0, 2.0], 0.5).is_err());
413        assert!(box_cox(&[0.0, 1.0, 2.0], 0.5).is_err());
414    }
415
416    #[test]
417    fn insufficient_data_returns_error() {
418        assert!(box_cox(&[1.0], 0.5).is_err());
419        assert_eq!(
420            estimate_lambda(&[1.0], -2.0, 2.0),
421            Err(TransformError::InsufficientData)
422        );
423    }
424
425    #[test]
426    fn forward_overflow_is_reported_like_the_inverse() {
427        assert_eq!(
428            box_cox(&[1e200, 2.0], 5.0),
429            Err(TransformError::InvalidTransform)
430        );
431        assert_eq!(
432            box_cox(&[1.0, 2.0], f64::NAN),
433            Err(TransformError::InvalidTransform)
434        );
435        assert!(box_cox(&[1e20, 2.0], 5.0).is_ok());
436    }
437
438    #[test]
439    fn inverse_invalid_returns_error() {
440        // (y_t * lambda + 1) must be positive for real result.
441        // For lambda=2: base = y_t*2+1; choose y_t=-1.0 → base=-1 → (-1)^0.5 = NaN
442        let y_t = vec![-1.0, -0.8];
443        assert!(inverse_box_cox(&y_t, 2.0).is_err());
444    }
445
446    #[test]
447    fn estimate_lambda_invalid_range() {
448        let y = vec![1.0, 2.0, 3.0, 4.0];
449        for (lo, hi) in [
450            (1.0, 0.0),
451            (0.5, 0.5),
452            (f64::NAN, 1.0),
453            (-1.0, f64::INFINITY),
454        ] {
455            assert_eq!(
456                estimate_lambda(&y, lo, hi),
457                Err(TransformError::InvalidLambdaRange),
458                "range [{lo}, {hi}]"
459            );
460        }
461    }
462
463    #[test]
464    fn box_cox_negative_lambda() {
465        // lambda=-1: y(-1) = (y^-1 - 1) / -1 = (1 - 1/y)
466        let y = vec![2.0, 4.0];
467        let y_t = box_cox(&y, -1.0).unwrap();
468        // (2^(-1) - 1) / (-1) = (0.5 - 1) / (-1) = 0.5
469        assert!((y_t[0] - 0.5).abs() < 1e-10);
470        // (4^(-1) - 1) / (-1) = (0.25 - 1) / (-1) = 0.75
471        assert!((y_t[1] - 0.75).abs() < 1e-10);
472    }
473}