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