use std::fmt;
use crate::stats::population_variance;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum TransformError {
NonPositiveData { index: usize, value: f64 },
NonFiniteData { index: usize },
InsufficientData { min: usize, got: usize },
InvalidTransform,
InvalidInverse,
InvalidLambdaRange { min: f64, max: f64 },
}
impl fmt::Display for TransformError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TransformError::NonPositiveData { index, value } => {
write!(
f,
"Box-Cox requires all y > 0, got {value} at index {index}"
)
}
TransformError::NonFiniteData { index } => {
write!(f, "data[{index}] is not a finite number")
}
TransformError::InsufficientData { min, got } => {
write!(f, "need at least {min} data points, got {got}")
}
TransformError::InvalidTransform => {
write!(f, "transformation produced non-finite values")
}
TransformError::InvalidInverse => {
write!(f, "inverse transformation produced non-finite values")
}
TransformError::InvalidLambdaRange { min, max } => {
write!(
f,
"lambda search range must be finite with lambda_min < lambda_max, \
got [{min}, {max}]"
)
}
}
}
}
impl std::error::Error for TransformError {}
fn validate_positive_slice(y: &[f64]) -> Result<(), TransformError> {
if y.len() < 2 {
return Err(TransformError::InsufficientData {
min: 2,
got: y.len(),
});
}
if let Some(index) = y.iter().position(|v| !v.is_finite()) {
return Err(TransformError::NonFiniteData { index });
}
if let Some(index) = y.iter().position(|&v| v <= 0.0) {
return Err(TransformError::NonPositiveData {
index,
value: y[index],
});
}
Ok(())
}
pub fn box_cox(y: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
validate_positive_slice(y)?;
let result = if lambda.abs() < 1e-10 {
y.iter().map(|&v| v.ln()).collect()
} else {
y.iter()
.map(|&v| (lambda * v.ln()).exp_m1() / lambda)
.collect::<Vec<_>>()
};
if result.iter().any(|v| !v.is_finite()) {
return Err(TransformError::InvalidTransform);
}
Ok(result)
}
pub fn inverse_box_cox(y_t: &[f64], lambda: f64) -> Result<Vec<f64>, TransformError> {
let result: Vec<f64> = if lambda.abs() < 1e-10 {
y_t.iter().map(|&v| v.exp()).collect()
} else {
y_t.iter()
.map(|&v| (v * lambda + 1.0).powf(1.0 / lambda))
.collect()
};
if result.iter().any(|v| !v.is_finite()) {
return Err(TransformError::InvalidInverse);
}
Ok(result)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LambdaEstimate {
pub lambda: f64,
pub at_bound: bool,
}
pub fn estimate_lambda(
y: &[f64],
lambda_min: f64,
lambda_max: f64,
) -> Result<LambdaEstimate, TransformError> {
if !lambda_min.is_finite() || !lambda_max.is_finite() || lambda_min >= lambda_max {
return Err(TransformError::InvalidLambdaRange {
min: lambda_min,
max: lambda_max,
});
}
validate_positive_slice(y)?;
let n = y.len() as f64;
let log_g: f64 = y.iter().map(|&v| v.ln()).sum::<f64>() / n;
let g = log_g.exp();
let log_ratio: Vec<f64> = y.iter().map(|&v| v.ln() - log_g).collect();
let profile_ll = |lambda: f64| -> f64 {
let z: Vec<f64> = if lambda.abs() < 1e-10 {
log_ratio.iter().map(|&r| g * r).collect()
} else {
log_ratio
.iter()
.map(|&r| g * (lambda * r).exp_m1() / lambda)
.collect()
};
let var = match population_variance(&z) {
Some(v) if v > 0.0 && v.is_finite() => v,
_ => return f64::NEG_INFINITY,
};
-(n / 2.0) * var.ln()
};
const PHI: f64 = 0.618_033_988_749_895; let mut a = lambda_min;
let mut b = lambda_max;
let mut x1 = b - PHI * (b - a);
let mut x2 = a + PHI * (b - a);
let mut f1 = profile_ll(x1);
let mut f2 = profile_ll(x2);
for _ in 0..100 {
if (b - a).abs() < 1e-6 {
break;
}
if f1 < f2 {
a = x1;
x1 = x2;
f1 = f2;
x2 = a + PHI * (b - a);
f2 = profile_ll(x2);
} else {
b = x2;
x2 = x1;
f2 = f1;
x1 = b - PHI * (b - a);
f1 = profile_ll(x1);
}
}
let interior = (a + b) / 2.0;
let candidates = [
(lambda_min, profile_ll(lambda_min)),
(lambda_max, profile_ll(lambda_max)),
(interior, profile_ll(interior)),
];
let &(best, _) = candidates
.iter()
.max_by(|(_, p), (_, q)| p.total_cmp(q))
.expect("the candidate array is never empty");
let at_bound = best == lambda_min || best == lambda_max;
Ok(LambdaEstimate {
lambda: best,
at_bound,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn box_cox_log_transform() {
let y = vec![1.0, std::f64::consts::E, std::f64::consts::E.powi(2)];
let y_t = box_cox(&y, 0.0).unwrap();
assert!((y_t[0] - 0.0).abs() < 1e-10);
assert!((y_t[1] - 1.0).abs() < 1e-9);
assert!((y_t[2] - 2.0).abs() < 1e-9);
}
#[test]
fn box_cox_identity_lambda_1() {
let y = vec![2.0, 5.0, 10.0];
let y_t = box_cox(&y, 1.0).unwrap();
assert!((y_t[0] - 1.0).abs() < 1e-10);
assert!((y_t[1] - 4.0).abs() < 1e-10);
}
#[test]
fn box_cox_sqrt_lambda_half() {
let y = vec![4.0, 9.0];
let y_t = box_cox(&y, 0.5).unwrap();
assert!((y_t[0] - 2.0).abs() < 1e-10); assert!((y_t[1] - 4.0).abs() < 1e-10); }
#[test]
fn inverse_roundtrip_multiple_lambdas() {
let y = vec![1.5, 2.3, 4.7, 8.1, 15.2];
for &lambda in &[-2.0_f64, -1.0, -0.5, 0.0, 0.5, 1.0, 2.0] {
let y_t = box_cox(&y, lambda).unwrap();
let y_rec = inverse_box_cox(&y_t, lambda).unwrap();
for (orig, rec) in y.iter().zip(y_rec.iter()) {
assert!(
(orig - rec).abs() < 1e-9,
"lambda={lambda} orig={orig} rec={rec}"
);
}
}
}
#[test]
fn estimate_lambda_near_zero_for_exponential() {
let y: Vec<f64> = (1..=30).map(|i| (i as f64 * 0.2).exp()).collect();
let lambda = estimate_lambda(&y, -2.0, 2.0).unwrap().lambda;
assert!(lambda.abs() < 0.3, "Expected lambda near 0, got {lambda}");
}
fn normal_after_boxcox(lambda0: f64, n: usize) -> Vec<f64> {
(1..=n)
.map(|i| {
let p = (i as f64 - 0.5) / n as f64;
let z = 10.0 + 2.0 * crate::special::inverse_normal_cdf(p);
(lambda0 * z + 1.0).powf(1.0 / lambda0)
})
.collect()
}
#[test]
fn a_narrow_sample_puts_its_estimate_on_the_end_of_the_range() {
let y = vec![
24.994, 25.007, 24.998, 25.006, 24.993, 25.002, 24.996, 25.004, 24.995, 25.005, 25.007,
24.995, 25.005, 24.993, 25.006, 24.996, 25.004, 24.998, 25.002, 24.994, 24.996, 25.004,
24.994, 25.006, 24.998, 25.002, 24.993, 25.007, 24.995, 25.005, 25.005, 24.995, 25.003,
24.993, 25.007, 24.996, 25.006, 24.994, 25.004, 24.997, 24.993, 25.006, 24.997, 25.005,
24.994, 25.007, 24.996, 25.004, 24.998, 25.0, 25.009, 24.999, 25.01, 24.998, 25.008,
25.002, 24.997, 25.006, 25.011, 25.0, 25.011, 25.0, 24.998, 25.006, 24.999, 25.009,
25.002, 24.997, 25.008, 25.01, 24.997, 25.01, 25.002, 24.999, 25.008, 25.011, 24.998,
25.009, 25.0, 25.006, 25.006, 24.999, 25.011, 25.002, 24.998, 25.01, 25.009, 24.997,
25.0, 25.008, 25.008, 25.01, 24.997, 25.006, 25.011, 24.999, 25.009, 25.002, 24.998,
25.0, 25.0, 24.997, 25.009, 25.011, 25.006, 24.998, 25.01, 24.999, 25.008, 25.002,
25.002, 25.008, 25.0, 24.999, 25.01, 25.006, 24.997, 25.011, 24.998, 25.009, 25.01,
25.002, 25.009, 24.997, 25.001, 25.008, 24.999, 25.006, 25.011, 24.997, 24.999, 25.01,
25.006, 24.998, 25.009, 25.001, 25.011, 25.008, 25.002, 24.996, 25.005, 24.994, 25.007,
24.996, 24.993, 25.006, 24.998, 25.004, 24.995, 25.002, 24.996, 25.003, 24.993, 25.007,
25.004, 24.995, 25.006, 24.994, 25.002, 25.0, 25.007, 24.998, 25.004, 24.993, 25.005,
24.996, 25.002, 24.994, 25.006, 24.995, 24.993, 25.006, 24.995, 25.004, 24.997, 25.007,
24.994, 25.002, 24.996, 25.006, 25.004, 24.997, 25.006, 24.994, 25.002, 24.993, 25.005,
24.996, 25.007, 24.996, 24.995, 25.005, 24.998, 25.003, 24.993, 25.007, 24.996, 25.004,
24.994, 25.005, 24.996, 25.003, 24.998, 25.002, 24.997, 25.004, 25.0, 25.023, 25.025,
25.021, 25.006, 24.994, 25.003, 24.997, 25.005, 24.998, 25.007, 24.995, 25.002, 24.993,
24.994, 25.007, 24.997, 25.006, 24.993, 25.005, 24.996, 25.003, 24.999, 25.0, 25.003,
24.996, 25.007, 24.994, 25.005, 24.997, 25.002, 24.993, 25.006, 24.997, 24.997, 25.004,
24.993, 25.006, 24.998, 25.005, 24.995, 25.007, 24.994, 25.001,
];
assert_eq!(y.len(), 250);
for (min, max) in [(-5.0, 5.0), (-20.0, 20.0), (-40.0, 40.0)] {
let est = estimate_lambda(&y, min, max).expect("positive, finite data");
assert!(est.at_bound, "[{min}, {max}] -> {est:?}");
assert_eq!(est.lambda, min, "[{min}, {max}] -> {est:?}");
}
let ll = |lambda: f64| {
let z = box_cox_normalised(&y, lambda);
let n = y.len() as f64;
let mean = z.iter().sum::<f64>() / n;
let var = z.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
-(n / 2.0) * var.ln()
};
let grid = [
-40.0, -20.0, -10.0, -6.5, -5.0, -2.0, 0.0, 2.0, 5.0, 10.0, 20.0, 40.0,
];
for pair in grid.windows(2) {
assert!(
ll(pair[0]) > ll(pair[1]),
"ll({}) = {} must exceed ll({}) = {}",
pair[0],
ll(pair[0]),
pair[1],
ll(pair[1])
);
}
}
fn box_cox_normalised(y: &[f64], lambda: f64) -> Vec<f64> {
let n = y.len() as f64;
let log_g = y.iter().map(|v| v.ln()).sum::<f64>() / n;
let g = log_g.exp();
y.iter()
.map(|v| {
let r = v.ln() - log_g;
if lambda.abs() < 1e-10 {
g * r
} else {
g * (lambda * r).exp_m1() / lambda
}
})
.collect()
}
#[test]
fn an_interior_optimum_is_not_reported_as_a_bound() {
let y = normal_after_boxcox(1.0, 200);
let est = estimate_lambda(&y, -5.0, 5.0).expect("positive, finite data");
assert!(!est.at_bound, "{est:?}");
assert!((est.lambda - 1.0).abs() < 0.5, "{est:?}");
}
#[test]
fn estimate_lambda_flags_a_range_that_cuts_the_search_short() {
let y = normal_after_boxcox(4.0, 100);
let wide = estimate_lambda(&y, -5.0, 5.0).unwrap();
assert!(!wide.at_bound, "interior maximum: {wide:?}");
assert!((wide.lambda - 4.0).abs() < 0.5, "{wide:?}");
let narrow = estimate_lambda(&y, -2.0, 2.0).unwrap();
assert!(narrow.at_bound, "{narrow:?}");
assert_eq!(narrow.lambda, 2.0);
let recip: Vec<f64> = y.iter().map(|v| 1.0 / v).collect();
let low = estimate_lambda(&recip, -2.0, 2.0).unwrap();
assert!(low.at_bound, "{low:?}");
assert_eq!(low.lambda, -2.0);
let wide = estimate_lambda(&recip, -5.0, 5.0).unwrap();
assert!(
!wide.at_bound && (wide.lambda + 4.0).abs() < 0.5,
"{wide:?}"
);
}
#[test]
fn estimate_lambda_survives_a_transform_that_overflows() {
let y: Vec<f64> = (1..=20).map(|i| 10f64.powi(i * 3)).collect();
let est = estimate_lambda(&y, -20.0, 20.0).expect("finite data, valid range");
assert!(est.lambda.is_finite() && est.lambda < 20.0, "{est:?}");
}
#[test]
fn non_finite_data_is_reported_not_passed_through() {
for bad in [f64::NAN, f64::INFINITY] {
let y = [1.0, bad, 3.0];
assert_eq!(
box_cox(&y, 0.5),
Err(TransformError::NonFiniteData { index: 1 })
);
assert_eq!(
estimate_lambda(&y, -2.0, 2.0),
Err(TransformError::NonFiniteData { index: 1 })
);
}
}
#[test]
fn estimate_lambda_near_half_for_quadratic() {
let y: Vec<f64> = (1..=20).map(|i| (i as f64).powi(2)).collect();
let est = estimate_lambda(&y, -2.0, 2.0).unwrap();
assert!(!est.at_bound);
let lambda = est.lambda;
assert!(
lambda > 0.2 && lambda < 0.8,
"Expected lambda ~0.5, got {lambda}"
);
}
#[test]
fn a_non_positive_value_is_refused_where_it_sits() {
assert_eq!(
box_cox(&[1.0, -1.0, 2.0], 0.5),
Err(TransformError::NonPositiveData {
index: 1,
value: -1.0
})
);
assert_eq!(
box_cox(&[0.0, 1.0, 2.0], 0.5),
Err(TransformError::NonPositiveData {
index: 0,
value: 0.0
})
);
}
#[test]
fn insufficient_data_returns_error() {
assert!(box_cox(&[1.0], 0.5).is_err());
assert_eq!(
estimate_lambda(&[1.0], -2.0, 2.0),
Err(TransformError::InsufficientData { min: 2, got: 1 })
);
}
#[test]
fn forward_overflow_is_reported_like_the_inverse() {
assert_eq!(
box_cox(&[1e200, 2.0], 5.0),
Err(TransformError::InvalidTransform)
);
assert_eq!(
box_cox(&[1.0, 2.0], f64::NAN),
Err(TransformError::InvalidTransform)
);
assert!(box_cox(&[1e20, 2.0], 5.0).is_ok());
}
#[test]
fn inverse_invalid_returns_error() {
let y_t = vec![-1.0, -0.8];
assert!(inverse_box_cox(&y_t, 2.0).is_err());
}
#[test]
fn estimate_lambda_invalid_range() {
let y = vec![1.0, 2.0, 3.0, 4.0];
for (lo, hi) in [
(1.0, 0.0),
(0.5, 0.5),
(f64::NAN, 1.0),
(-1.0, f64::INFINITY),
] {
assert!(
matches!(
estimate_lambda(&y, lo, hi),
Err(TransformError::InvalidLambdaRange { .. })
),
"range [{lo}, {hi}]"
);
}
}
#[test]
fn box_cox_negative_lambda() {
let y = vec![2.0, 4.0];
let y_t = box_cox(&y, -1.0).unwrap();
assert!((y_t[0] - 0.5).abs() < 1e-10);
assert!((y_t[1] - 0.75).abs() < 1e-10);
}
}