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 {
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
126pub 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#[derive(Debug, Clone, Copy, PartialEq)]
168pub struct LambdaEstimate {
169 pub lambda: f64,
171 pub at_bound: bool,
176}
177
178pub 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 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 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 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 const PHI: f64 = 0.618_033_988_749_895; 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 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#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn box_cox_log_transform() {
322 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 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 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); assert!((y_t[1] - 4.0).abs() < 1e-10); }
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 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 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 #[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 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 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 #[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 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 let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
491 assert!(narrow.at_bound, "{narrow:?}");
492 assert_eq!(narrow.lambda, 2.0);
493 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 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 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 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 let y = vec![2.0, 4.0];
597 let y_t = box_cox(&y, -1.0).unwrap();
598 assert!((y_t[0] - 0.5).abs() < 1e-10);
600 assert!((y_t[1] - 0.75).abs() < 1e-10);
602 }
603}