1use std::fmt;
12
13use crate::stats::population_variance;
14
15#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub enum TransformError {
21 NonPositiveData { index: usize, value: f64 },
23 NonFiniteData { index: usize },
25 InsufficientData { min: usize, got: usize },
27 InvalidTransform,
30 InvalidInverse,
32 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
70fn 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
91pub fn box_cox(y: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
120 validate_positive_slice(y)?;
121 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
137pub 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#[derive(Debug, Clone, Copy, PartialEq)]
179pub struct LambdaEstimate {
180 pub lambda: f64,
182 pub at_bound: bool,
187}
188
189pub 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 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 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 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 const PHI: f64 = 0.618_033_988_749_895; 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 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#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn box_cox_log_transform() {
336 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 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 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); assert!((y_t[1] - 4.0).abs() < 1e-10); }
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 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 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 #[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 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 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 #[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 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 let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
505 assert!(narrow.at_bound, "{narrow:?}");
506 assert_eq!(narrow.lambda, 2.0);
507 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 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 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 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 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 let y = vec![2.0, 4.0];
629 let y_t = box_cox(&y, -1.0).unwrap();
630 assert!((y_t[0] - 0.5).abs() < 1e-10);
632 assert!((y_t[1] - 0.75).abs() < 1e-10);
634 }
635}