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    // `expm1(λ·ln y)` rather than `y^λ - 1`: the two agree mathematically, and
111    // the second loses the whole result to cancellation whenever `y^λ` is near
112    // 1 (a λ near zero, or a `y` near one).
113    let result = if lambda.abs() < 1e-10 {
114        y.iter().map(|&v| v.ln()).collect()
115    } else {
116        y.iter()
117            .map(|&v| (lambda * v.ln()).exp_m1() / lambda)
118            .collect::<Vec<_>>()
119    };
120    if result.iter().any(|v| !v.is_finite()) {
121        return Err(TransformError::InvalidTransform);
122    }
123    Ok(result)
124}
125
126/// Invert a Box-Cox transformation.
127///
128/// For each transformed element `y_tᵢ`:
129///
130/// ```text
131/// y = exp(y_t)                  when |λ| < 1e-10
132///     (y_t · λ + 1)^(1/λ)      otherwise
133/// ```
134///
135/// Accepts slices of any length, including empty. Unlike [`box_cox`], no
136/// minimum length is required. An empty slice returns an empty `Vec` silently.
137///
138/// # Errors
139/// - [`TransformError::InvalidInverse`] — any result is non-finite
140///
141/// # Examples
142/// ```
143/// use u_numflow::transforms::{box_cox, inverse_box_cox};
144///
145/// let y = vec![2.0, 5.0, 10.0];
146/// let y_t = box_cox(&y, 1.0).unwrap();
147/// let y_rec = inverse_box_cox(&y_t, 1.0).unwrap();
148/// for (a, b) in y.iter().zip(y_rec.iter()) {
149///     assert!((a - b).abs() < 1e-9);
150/// }
151/// ```
152pub fn inverse_box_cox(y_t: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
153    let result: Vec<f64> = if lambda.abs() < 1e-10 {
154        y_t.iter().map(|&v| v.exp()).collect()
155    } else {
156        y_t.iter()
157            .map(|&v| (v * lambda + 1.0).powf(1.0 / lambda))
158            .collect()
159    };
160    if result.iter().any(|v| !v.is_finite()) {
161        return Err(TransformError::InvalidInverse);
162    }
163    Ok(result)
164}
165
166/// A maximum-likelihood Box-Cox λ, with where it sits in its search range.
167#[derive(Debug, Clone, Copy, PartialEq)]
168pub struct LambdaEstimate {
169    /// The λ that maximises the profile log-likelihood within the range.
170    pub lambda: f64,
171    /// `true` when the maximum lies on an end of the search range (within
172    /// the search tolerance): the likelihood was still rising there, so the
173    /// unconstrained optimum is at or beyond that end and `lambda` is the
174    /// range limit, not an interior estimate.
175    pub at_bound: bool,
176}
177
178/// Estimate the optimal Box-Cox λ via maximum likelihood.
179///
180/// Maximises the profile log-likelihood of the **normalised** transform
181/// (Box & Cox 1964, §3), where `g` is the geometric mean of the sample:
182///
183/// ```text
184/// z(λ) = g·expm1(λ·ln(y/g)) / λ        (λ ≠ 0)
185///        g·ln(y/g)                      (λ = 0)
186/// ℓ(λ) = -(n/2)·ln(Var_population(z))
187/// ```
188///
189/// Normalising is what makes the likelihood computable, not merely tidier.
190/// The unnormalised `(y^λ - 1)/λ` puts every value next to `-1` when `y^λ` is
191/// small -- `y ≈ 25`, `λ = -5` gives `y^λ ≈ 1e-7` -- so the differences between
192/// samples, which are all the variance is made of, fall off the bottom of the
193/// mantissa. Dividing through by `g^(λ-1)` recentres the values on zero and
194/// keeps those differences at full precision; it also folds in the Jacobian,
195/// which is why no `(λ-1)·Σ ln(yᵢ)` term appears above.
196///
197/// The search is a **golden-section search** over `[lambda_min, lambda_max]`
198/// with up to 100 iterations (terminating early when the bracket width < 1e-6).
199///
200/// The result says whether the maximum lies on an end of the range
201/// ([`LambdaEstimate::at_bound`]), decided by **comparing the likelihood at the
202/// ends against the interior candidate** rather than by where the search
203/// happened to stop. A likelihood that is monotone across the range leaves the
204/// bracket somewhere in the middle, so asking the search where it landed
205/// answers a different question than the one a caller is asking.
206///
207/// # Errors
208/// - [`TransformError::InvalidLambdaRange`] — `lambda_min >= lambda_max`, or
209///   either bound is not finite
210/// - [`TransformError::InsufficientData`] — fewer than 2 data points
211/// - [`TransformError::NonPositiveData`]  — any element ≤ 0
212/// - [`TransformError::NonFiniteData`]    — any element is NaN or infinite
213///
214/// # Examples
215/// ```
216/// use u_numflow::transforms::estimate_lambda;
217///
218/// // Exponential data is well-linearised by log (λ ≈ 0).
219/// let y: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.2_f64).exp()).collect();
220/// let est = estimate_lambda(&y, -2.0, 2.0).unwrap();
221/// assert!(est.lambda.abs() < 0.3, "expected lambda near 0, got {}", est.lambda);
222/// assert!(!est.at_bound);
223/// ```
224pub fn estimate_lambda(
225    y: &[f64],
226    lambda_min: f64,
227    lambda_max: f64,
228) -> Result<LambdaEstimate, TransformError> {
229    if !lambda_min.is_finite() || !lambda_max.is_finite() || lambda_min >= lambda_max {
230        return Err(TransformError::InvalidLambdaRange);
231    }
232    validate_positive_slice(y)?;
233
234    let n = y.len() as f64;
235    // ln(yᵢ/g) about the geometric mean, which is where the normalised
236    // transform is evaluated. Small and well-scaled for a clustered sample --
237    // the case the unnormalised form cannot measure.
238    let log_g: f64 = y.iter().map(|&v| v.ln()).sum::<f64>() / n;
239    let g = log_g.exp();
240    let log_ratio: Vec<f64> = y.iter().map(|&v| v.ln() - log_g).collect();
241
242    // Profile log-likelihood of the normalised transform (higher is better).
243    let profile_ll = |lambda: f64| -> f64 {
244        let z: Vec<f64> = if lambda.abs() < 1e-10 {
245            log_ratio.iter().map(|&r| g * r).collect()
246        } else {
247            log_ratio
248                .iter()
249                .map(|&r| g * (lambda * r).exp_m1() / lambda)
250                .collect()
251        };
252        // A λ whose transform overflows (for large |λ| and a wide sample) has
253        // no finite variance: it is not a candidate, not a panic.
254        let var = match population_variance(&z) {
255            Some(v) if v > 0.0 && v.is_finite() => v,
256            _ => return f64::NEG_INFINITY,
257        };
258        -(n / 2.0) * var.ln()
259    };
260
261    // Golden-section search (maximisation).
262    const PHI: f64 = 0.618_033_988_749_895; // (√5 - 1) / 2
263    let mut a = lambda_min;
264    let mut b = lambda_max;
265
266    let mut x1 = b - PHI * (b - a);
267    let mut x2 = a + PHI * (b - a);
268    let mut f1 = profile_ll(x1);
269    let mut f2 = profile_ll(x2);
270
271    for _ in 0..100 {
272        if (b - a).abs() < 1e-6 {
273            break;
274        }
275        if f1 < f2 {
276            a = x1;
277            x1 = x2;
278            f1 = f2;
279            x2 = a + PHI * (b - a);
280            f2 = profile_ll(x2);
281        } else {
282            b = x2;
283            x2 = x1;
284            f2 = f1;
285            x1 = b - PHI * (b - a);
286            f1 = profile_ll(x1);
287        }
288    }
289
290    // Ask the likelihood where its maximum is, rather than the search where it
291    // stopped. A golden-section bracket narrows on the best of the points it
292    // sampled, which on a monotone likelihood is an interior point it has no
293    // reason to leave -- so "the bracket touches an end" and "the maximum is at
294    // an end" are different statements, and only the second is what a caller
295    // needs. On a bound the constrained maximiser *is* that bound, so report it
296    // exactly rather than a point a tolerance inside it.
297    let interior = (a + b) / 2.0;
298    let candidates = [
299        (lambda_min, profile_ll(lambda_min)),
300        (lambda_max, profile_ll(lambda_max)),
301        (interior, profile_ll(interior)),
302    ];
303    let &(best, _) = candidates
304        .iter()
305        .max_by(|(_, p), (_, q)| p.total_cmp(q))
306        .expect("the candidate array is never empty");
307    let at_bound = best == lambda_min || best == lambda_max;
308    Ok(LambdaEstimate {
309        lambda: best,
310        at_bound,
311    })
312}
313
314// ── Tests ─────────────────────────────────────────────────────────────────────
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn box_cox_log_transform() {
322        // lambda=0: y(0) = ln(y)
323        let y = vec![1.0, std::f64::consts::E, std::f64::consts::E.powi(2)];
324        let y_t = box_cox(&y, 0.0).unwrap();
325        assert!((y_t[0] - 0.0).abs() < 1e-10);
326        assert!((y_t[1] - 1.0).abs() < 1e-9);
327        assert!((y_t[2] - 2.0).abs() < 1e-9);
328    }
329
330    #[test]
331    fn box_cox_identity_lambda_1() {
332        // lambda=1: y(1) = (y^1 - 1)/1 = y - 1
333        let y = vec![2.0, 5.0, 10.0];
334        let y_t = box_cox(&y, 1.0).unwrap();
335        assert!((y_t[0] - 1.0).abs() < 1e-10);
336        assert!((y_t[1] - 4.0).abs() < 1e-10);
337    }
338
339    #[test]
340    fn box_cox_sqrt_lambda_half() {
341        // lambda=0.5: y(0.5) = (sqrt(y)-1)/0.5 = 2*(sqrt(y)-1)
342        let y = vec![4.0, 9.0];
343        let y_t = box_cox(&y, 0.5).unwrap();
344        assert!((y_t[0] - 2.0).abs() < 1e-10); // 2*(2-1)=2
345        assert!((y_t[1] - 4.0).abs() < 1e-10); // 2*(3-1)=4
346    }
347
348    #[test]
349    fn inverse_roundtrip_multiple_lambdas() {
350        let y = vec![1.5, 2.3, 4.7, 8.1, 15.2];
351        for &lambda in &[-2.0_f64, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0] {
352            let y_t = box_cox(&y, lambda).unwrap();
353            let y_rec = inverse_box_cox(&y_t, lambda).unwrap();
354            for (orig, rec) in y.iter().zip(y_rec.iter()) {
355                assert!(
356                    (orig - rec).abs() < 1e-9,
357                    "lambda={lambda} orig={orig} rec={rec}"
358                );
359            }
360        }
361    }
362
363    #[test]
364    fn estimate_lambda_near_zero_for_exponential() {
365        // Exponential-like data → log transform (lambda ≈ 0) is optimal
366        let y: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.2).exp()).collect();
367        let lambda = estimate_lambda(&y, -2.0, 2.0).unwrap().lambda;
368        assert!(lambda.abs() < 0.3, "Expected lambda near 0, got {lambda}");
369    }
370
371    /// Data that is exactly normal after a Box-Cox transform with `lambda0`:
372    /// normal quantiles pushed through the inverse transform.
373    fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
374        (1..=n)
375            .map(|i| {
376                let p = (i as f64 - 0.5) / n as f64;
377                let z = 10.0 + 2.0 * crate::special::inverse_normal_cdf(p);
378                (lambda0 * z + 1.0).powf(1.0 / lambda0)
379            })
380            .collect()
381    }
382
383    /// A sample whose values sit inside a 0.13 % band: the profile likelihood
384    /// is monotone across any symmetric range, so the estimate belongs on the
385    /// lower end of it.
386    ///
387    /// Both halves of this used to be wrong together, and each hid the other.
388    /// The unnormalised transform put every value next to `-1` (`25^-5` is
389    /// about `1e-7`), which left the likelihood non-monotone at the `1e-5`
390    /// level; golden-section converged on that noise at `-4.9927`, an interior
391    /// point, so the search's own "did an end of the bracket ever move" test
392    /// then reported `at_bound: false` -- a λ a caller could not tell from a
393    /// real optimum.
394    #[test]
395    fn a_narrow_sample_puts_its_estimate_on_the_end_of_the_range() {
396        let y = vec![
397            24.994, 25.007, 24.998, 25.006, 24.993, 25.002, 24.996, 25.004, 24.995, 25.005, 25.007,
398            24.995, 25.005, 24.993, 25.006, 24.996, 25.004, 24.998, 25.002, 24.994, 24.996, 25.004,
399            24.994, 25.006, 24.998, 25.002, 24.993, 25.007, 24.995, 25.005, 25.005, 24.995, 25.003,
400            24.993, 25.007, 24.996, 25.006, 24.994, 25.004, 24.997, 24.993, 25.006, 24.997, 25.005,
401            24.994, 25.007, 24.996, 25.004, 24.998, 25.0, 25.009, 24.999, 25.01, 24.998, 25.008,
402            25.002, 24.997, 25.006, 25.011, 25.0, 25.011, 25.0, 24.998, 25.006, 24.999, 25.009,
403            25.002, 24.997, 25.008, 25.01, 24.997, 25.01, 25.002, 24.999, 25.008, 25.011, 24.998,
404            25.009, 25.0, 25.006, 25.006, 24.999, 25.011, 25.002, 24.998, 25.01, 25.009, 24.997,
405            25.0, 25.008, 25.008, 25.01, 24.997, 25.006, 25.011, 24.999, 25.009, 25.002, 24.998,
406            25.0, 25.0, 24.997, 25.009, 25.011, 25.006, 24.998, 25.01, 24.999, 25.008, 25.002,
407            25.002, 25.008, 25.0, 24.999, 25.01, 25.006, 24.997, 25.011, 24.998, 25.009, 25.01,
408            25.002, 25.009, 24.997, 25.001, 25.008, 24.999, 25.006, 25.011, 24.997, 24.999, 25.01,
409            25.006, 24.998, 25.009, 25.001, 25.011, 25.008, 25.002, 24.996, 25.005, 24.994, 25.007,
410            24.996, 24.993, 25.006, 24.998, 25.004, 24.995, 25.002, 24.996, 25.003, 24.993, 25.007,
411            25.004, 24.995, 25.006, 24.994, 25.002, 25.0, 25.007, 24.998, 25.004, 24.993, 25.005,
412            24.996, 25.002, 24.994, 25.006, 24.995, 24.993, 25.006, 24.995, 25.004, 24.997, 25.007,
413            24.994, 25.002, 24.996, 25.006, 25.004, 24.997, 25.006, 24.994, 25.002, 24.993, 25.005,
414            24.996, 25.007, 24.996, 24.995, 25.005, 24.998, 25.003, 24.993, 25.007, 24.996, 25.004,
415            24.994, 25.005, 24.996, 25.003, 24.998, 25.002, 24.997, 25.004, 25.0, 25.023, 25.025,
416            25.021, 25.006, 24.994, 25.003, 24.997, 25.005, 24.998, 25.007, 24.995, 25.002, 24.993,
417            24.994, 25.007, 24.997, 25.006, 24.993, 25.005, 24.996, 25.003, 24.999, 25.0, 25.003,
418            24.996, 25.007, 24.994, 25.005, 24.997, 25.002, 24.993, 25.006, 24.997, 24.997, 25.004,
419            24.993, 25.006, 24.998, 25.005, 24.995, 25.007, 24.994, 25.001,
420        ];
421        assert_eq!(y.len(), 250);
422
423        for (min, max) in [(-5.0, 5.0), (-20.0, 20.0), (-40.0, 40.0)] {
424            let est = estimate_lambda(&y, min, max).expect("positive, finite data");
425            assert!(est.at_bound, "[{min}, {max}] -> {est:?}");
426            assert_eq!(est.lambda, min, "[{min}, {max}] -> {est:?}");
427        }
428
429        // The likelihood really is monotone here: it decreases from one end of
430        // the range to the other, which is the fact the estimate reports.
431        let ll = |lambda: f64| {
432            let z = box_cox_normalised(&y, lambda);
433            let n = y.len() as f64;
434            let mean = z.iter().sum::<f64>() / n;
435            let var = z.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
436            -(n / 2.0) * var.ln()
437        };
438        let grid = [
439            -40.0, -20.0, -10.0, -6.5, -5.0, -2.0, 0.0, 2.0, 5.0, 10.0, 20.0, 40.0,
440        ];
441        for pair in grid.windows(2) {
442            assert!(
443                ll(pair[0]) > ll(pair[1]),
444                "ll({}) = {} must exceed ll({}) = {}",
445                pair[0],
446                ll(pair[0]),
447                pair[1],
448                ll(pair[1])
449            );
450        }
451    }
452
453    /// The normalised transform, written out independently of the estimator so
454    /// the test above measures the likelihood rather than restating it.
455    fn box_cox_normalised(y: &[f64], lambda: f64) -> Vec<f64> {
456        let n = y.len() as f64;
457        let log_g = y.iter().map(|v| v.ln()).sum::<f64>() / n;
458        let g = log_g.exp();
459        y.iter()
460            .map(|v| {
461                let r = v.ln() - log_g;
462                if lambda.abs() < 1e-10 {
463                    g * r
464                } else {
465                    g * (lambda * r).exp_m1() / lambda
466                }
467            })
468            .collect()
469    }
470
471    /// An interior optimum is still reported as interior: the endpoint
472    /// comparison must not turn every estimate into a bound.
473    #[test]
474    fn an_interior_optimum_is_not_reported_as_a_bound() {
475        let y = normal_after_boxcox(1.0, 200);
476        let est = estimate_lambda(&y, -5.0, 5.0).expect("positive, finite data");
477        assert!(!est.at_bound, "{est:?}");
478        assert!((est.lambda - 1.0).abs() < 0.5, "{est:?}");
479    }
480
481    #[test]
482    fn estimate_lambda_flags_a_range_that_cuts_the_search_short() {
483        let y = normal_after_boxcox(4.0, 100);
484        // Wide enough: an interior maximum near the generating lambda.
485        let wide = estimate_lambda(&y, -5.0, 5.0).unwrap();
486        assert!(!wide.at_bound, "interior maximum: {wide:?}");
487        assert!((wide.lambda - 4.0).abs() < 0.5, "{wide:?}");
488        // Too narrow: the likelihood is still rising at 2, which must be said —
489        // and the reported lambda is the bound itself, not a midpoint near it.
490        let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
491        assert!(narrow.at_bound, "{narrow:?}");
492        assert_eq!(narrow.lambda, 2.0);
493        // The lower end, from the reciprocals: 1/y transformed at −λ is the
494        // negated transform of y at λ, so the likelihood mirrors to λ ≈ −4.
495        let recip: Vec<f64> = y.iter().map(|v| 1.0 / v).collect();
496        let low = estimate_lambda(&recip, -2.0, 2.0).unwrap();
497        assert!(low.at_bound, "{low:?}");
498        assert_eq!(low.lambda, -2.0);
499        let wide = estimate_lambda(&recip, -5.0, 5.0).unwrap();
500        assert!(
501            !wide.at_bound && (wide.lambda + 4.0).abs() < 0.5,
502            "{wide:?}"
503        );
504    }
505
506    #[test]
507    fn estimate_lambda_survives_a_transform_that_overflows() {
508        // y^λ overflows f64 for λ near the top of the range: such λ are not
509        // candidates, and the search must not panic on them.
510        let y: Vec<f64> = (1..=20).map(|i| 10f64.powi(i * 3)).collect();
511        let est = estimate_lambda(&y, -20.0, 20.0).expect("finite data, valid range");
512        assert!(est.lambda.is_finite() && est.lambda < 20.0, "{est:?}");
513    }
514
515    #[test]
516    fn non_finite_data_is_reported_not_passed_through() {
517        for bad in [f64::NAN, f64::INFINITY] {
518            let y = [1.0, bad, 3.0];
519            assert_eq!(box_cox(&y, 0.5), Err(TransformError::NonFiniteData));
520            assert_eq!(
521                estimate_lambda(&y, -2.0, 2.0),
522                Err(TransformError::NonFiniteData)
523            );
524        }
525    }
526
527    #[test]
528    fn estimate_lambda_near_half_for_quadratic() {
529        // y = i^2 data → sqrt transform (lambda ≈ 0.5)
530        let y: Vec<f64> = (1..=20).map(|i| (i as f64).powi(2)).collect();
531        let est = estimate_lambda(&y, -2.0, 2.0).unwrap();
532        assert!(!est.at_bound);
533        let lambda = est.lambda;
534        assert!(
535            lambda > 0.2 && lambda < 0.8,
536            "Expected lambda ~0.5, got {lambda}"
537        );
538    }
539
540    #[test]
541    fn non_positive_returns_error() {
542        assert!(box_cox(&[1.0, -1.0, 2.0], 0.5).is_err());
543        assert!(box_cox(&[0.0, 1.0, 2.0], 0.5).is_err());
544    }
545
546    #[test]
547    fn insufficient_data_returns_error() {
548        assert!(box_cox(&[1.0], 0.5).is_err());
549        assert_eq!(
550            estimate_lambda(&[1.0], -2.0, 2.0),
551            Err(TransformError::InsufficientData)
552        );
553    }
554
555    #[test]
556    fn forward_overflow_is_reported_like_the_inverse() {
557        assert_eq!(
558            box_cox(&[1e200, 2.0], 5.0),
559            Err(TransformError::InvalidTransform)
560        );
561        assert_eq!(
562            box_cox(&[1.0, 2.0], f64::NAN),
563            Err(TransformError::InvalidTransform)
564        );
565        assert!(box_cox(&[1e20, 2.0], 5.0).is_ok());
566    }
567
568    #[test]
569    fn inverse_invalid_returns_error() {
570        // (y_t * lambda + 1) must be positive for real result.
571        // For lambda=2: base = y_t*2+1; choose y_t=-1.0 → base=-1 → (-1)^0.5 = NaN
572        let y_t = vec![-1.0, -0.8];
573        assert!(inverse_box_cox(&y_t, 2.0).is_err());
574    }
575
576    #[test]
577    fn estimate_lambda_invalid_range() {
578        let y = vec![1.0, 2.0, 3.0, 4.0];
579        for (lo, hi) in [
580            (1.0, 0.0),
581            (0.5, 0.5),
582            (f64::NAN, 1.0),
583            (-1.0, f64::INFINITY),
584        ] {
585            assert_eq!(
586                estimate_lambda(&y, lo, hi),
587                Err(TransformError::InvalidLambdaRange),
588                "range [{lo}, {hi}]"
589            );
590        }
591    }
592
593    #[test]
594    fn box_cox_negative_lambda() {
595        // lambda=-1: y(-1) = (y^-1 - 1) / -1 = (1 - 1/y)
596        let y = vec![2.0, 4.0];
597        let y_t = box_cox(&y, -1.0).unwrap();
598        // (2^(-1) - 1) / (-1) = (0.5 - 1) / (-1) = 0.5
599        assert!((y_t[0] - 0.5).abs() < 1e-10);
600        // (4^(-1) - 1) / (-1) = (0.25 - 1) / (-1) = 0.75
601        assert!((y_t[1] - 0.75).abs() < 1e-10);
602    }
603}