1use std::fmt;
12
13use crate::stats::population_variance;
14
15#[derive(Debug, Clone, PartialEq)]
19pub enum TransformError {
20 NonPositiveData,
22 NonFiniteData,
24 InsufficientData,
26 InvalidTransform,
29 InvalidInverse,
31 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
65fn 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
80pub 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
123pub 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#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct LambdaEstimate {
166 pub lambda: f64,
168 pub at_bound: bool,
173}
174
175pub 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 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 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 const PHI: f64 = 0.618_033_988_749_895; 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 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#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn box_cox_log_transform() {
290 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 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 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); assert!((y_t[1] - 4.0).abs() < 1e-10); }
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 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 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 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 let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
361 assert!(narrow.at_bound, "{narrow:?}");
362 assert_eq!(narrow.lambda, 2.0);
363 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 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 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 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 let y = vec![2.0, 4.0];
467 let y_t = box_cox(&y, -1.0).unwrap();
468 assert!((y_t[0] - 0.5).abs() < 1e-10);
470 assert!((y_t[1] - 0.75).abs() < 1e-10);
472 }
473}