use ferrolearn_core::error::FerroError;
use ferrolearn_core::pipeline::{FittedPipelineTransformer, PipelineTransformer};
use ferrolearn_core::traits::{Fit, FitTransform, Transform};
use ndarray::{Array1, Array2};
use num_traits::Float;
fn yeo_johnson<F: Float>(y: F, lambda: F) -> F {
let zero = F::zero();
let one = F::one();
let two = one + one;
let eps = F::from(1e-10_f64).unwrap_or_else(F::epsilon);
if y >= zero {
if (lambda - zero).abs() < eps {
(y + one).ln()
} else {
((y + one).powf(lambda) - one) / lambda
}
} else {
let two_minus_lambda = two - lambda;
if (two_minus_lambda).abs() < eps {
-(one - y).ln()
} else {
-((one - y).powf(two_minus_lambda) - one) / two_minus_lambda
}
}
}
fn log_likelihood_yj<F: Float>(col: &[F], lambda: F) -> F {
let n = F::from(col.len()).unwrap_or_else(F::one);
let one = F::one();
let two = one + one;
let pi2 = F::from(std::f64::consts::TAU).unwrap_or_else(F::one);
let transformed: Vec<F> = col
.iter()
.copied()
.map(|v| yeo_johnson(v, lambda))
.collect();
let mean = transformed
.iter()
.copied()
.fold(F::zero(), |acc, v| acc + v)
/ n;
let variance = transformed
.iter()
.copied()
.map(|v| (v - mean) * (v - mean))
.fold(F::zero(), |acc, v| acc + v)
/ n;
if variance <= F::zero() {
return F::neg_infinity();
}
let normal_ll = -n / two * (pi2 * variance).ln() - n / two;
let lambda_minus_1 = lambda - one;
let jacobian: F = col
.iter()
.copied()
.fold(F::zero(), |acc, y| acc + (y.abs() + one).ln());
let jacobian_ll = lambda_minus_1 * jacobian;
normal_ll + jacobian_ll
}
#[derive(Debug, Clone)]
pub struct PowerTransformer<F> {
pub(crate) standardize: bool,
_marker: std::marker::PhantomData<F>,
}
impl<F: Float + Send + Sync + 'static> PowerTransformer<F> {
#[must_use]
pub fn new() -> Self {
Self {
standardize: true,
_marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn without_standardize() -> Self {
Self {
standardize: false,
_marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn standardize(&self) -> bool {
self.standardize
}
}
impl<F: Float + Send + Sync + 'static> Default for PowerTransformer<F> {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct FittedPowerTransformer<F> {
pub(crate) lambdas: Array1<F>,
pub(crate) means: Option<Array1<F>>,
pub(crate) stds: Option<Array1<F>>,
}
impl<F: Float + Send + Sync + 'static> FittedPowerTransformer<F> {
#[must_use]
pub fn lambdas(&self) -> &Array1<F> {
&self.lambdas
}
}
impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for PowerTransformer<F> {
type Fitted = FittedPowerTransformer<F>;
type Error = FerroError;
fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedPowerTransformer<F>, FerroError> {
let n_samples = x.nrows();
if n_samples == 0 {
return Err(FerroError::InsufficientSamples {
required: 1,
actual: 0,
context: "PowerTransformer::fit".into(),
});
}
let n_features = x.ncols();
let mut lambdas = Array1::zeros(n_features);
for j in 0..n_features {
let col_f64: Vec<f64> = x
.column(j)
.iter()
.copied()
.map(|v| v.to_f64().unwrap_or(0.0))
.collect();
let result = ferrolearn_numerical::optimize::brent_bounded(
|lambda| {
let lam = F::from(lambda).unwrap_or_else(F::one);
let col_f: Vec<F> = col_f64
.iter()
.map(|&v| F::from(v).unwrap_or_else(F::zero))
.collect();
let ll = log_likelihood_yj(&col_f, lam);
-ll.to_f64().unwrap_or(f64::INFINITY)
},
-3.0,
3.0,
1e-8,
500,
);
lambdas[j] = F::from(result.x).unwrap_or_else(F::one);
}
let (means, stds) = if self.standardize {
let n = F::from(n_samples).unwrap_or_else(F::one);
let mut means_arr = Array1::zeros(n_features);
let mut stds_arr = Array1::zeros(n_features);
for j in 0..n_features {
let lambda = lambdas[j];
let transformed: Vec<F> = x
.column(j)
.iter()
.copied()
.map(|v| yeo_johnson(v, lambda))
.collect();
let mean = transformed
.iter()
.copied()
.fold(F::zero(), |acc, v| acc + v)
/ n;
let variance = transformed
.iter()
.copied()
.map(|v| (v - mean) * (v - mean))
.fold(F::zero(), |acc, v| acc + v)
/ n;
means_arr[j] = mean;
stds_arr[j] = variance.sqrt();
}
(Some(means_arr), Some(stds_arr))
} else {
(None, None)
};
Ok(FittedPowerTransformer {
lambdas,
means,
stds,
})
}
}
impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedPowerTransformer<F> {
type Output = Array2<F>;
type Error = FerroError;
fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
let n_features = self.lambdas.len();
if x.ncols() != n_features {
return Err(FerroError::ShapeMismatch {
expected: vec![x.nrows(), n_features],
actual: vec![x.nrows(), x.ncols()],
context: "FittedPowerTransformer::transform".into(),
});
}
let mut out = x.to_owned();
for (j, mut col) in out.columns_mut().into_iter().enumerate() {
let lambda = self.lambdas[j];
for v in &mut col {
*v = yeo_johnson(*v, lambda);
}
if let (Some(means), Some(stds)) = (&self.means, &self.stds) {
let m = means[j];
let s = stds[j];
if s > F::zero() {
for v in &mut col {
*v = (*v - m) / s;
}
}
}
}
Ok(out)
}
}
impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for PowerTransformer<F> {
type Output = Array2<F>;
type Error = FerroError;
fn transform(&self, _x: &Array2<F>) -> Result<Array2<F>, FerroError> {
Err(FerroError::InvalidParameter {
name: "PowerTransformer".into(),
reason: "transformer must be fitted before calling transform; use fit() first".into(),
})
}
}
impl<F: Float + Send + Sync + 'static> FitTransform<Array2<F>> for PowerTransformer<F> {
type FitError = FerroError;
fn fit_transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
let fitted = self.fit(x, &())?;
fitted.transform(x)
}
}
impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for PowerTransformer<F> {
fn fit_pipeline(
&self,
x: &Array2<F>,
_y: &Array1<F>,
) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
let fitted = self.fit(x, &())?;
Ok(Box::new(fitted))
}
}
impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for FittedPowerTransformer<F> {
fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
self.transform(x)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
use ndarray::array;
#[test]
fn test_yeo_johnson_identity_at_lambda_one() {
let one = 1.0_f64;
for v in [0.0, 0.5, 1.0, 2.0, 5.0] {
let out = yeo_johnson(v, one);
assert_abs_diff_eq!(out, v, epsilon = 1e-10);
}
}
#[test]
fn test_yeo_johnson_log_at_lambda_zero() {
let zero = 0.0_f64;
for v in [0.0, 0.5, 1.0, 2.0] {
let expected = (v + 1.0).ln();
assert_abs_diff_eq!(yeo_johnson(v, zero), expected, epsilon = 1e-10);
}
}
#[test]
fn test_yeo_johnson_negative_at_lambda_two() {
let two = 2.0_f64;
for v in [-0.5, -1.0, -2.0] {
let expected = -(1.0 - v).ln();
assert_abs_diff_eq!(yeo_johnson(v, two), expected, epsilon = 1e-10);
}
}
#[test]
fn test_power_transformer_fit_basic() {
let pt = PowerTransformer::<f64>::new();
let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
let fitted = pt.fit(&x, &()).unwrap();
let lambda = fitted.lambdas()[0];
assert!((-3.0..=3.0).contains(&lambda));
}
#[test]
fn test_power_transformer_transform_shape() {
let pt = PowerTransformer::<f64>::new();
let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let fitted = pt.fit(&x, &()).unwrap();
let out = fitted.transform(&x).unwrap();
assert_eq!(out.shape(), x.shape());
}
#[test]
fn test_standardize_produces_zero_mean() {
let pt = PowerTransformer::<f64>::new(); let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
let fitted = pt.fit(&x, &()).unwrap();
let out = fitted.transform(&x).unwrap();
let mean: f64 = out.column(0).iter().sum::<f64>() / out.nrows() as f64;
assert_abs_diff_eq!(mean, 0.0, epsilon = 1e-6);
}
#[test]
fn test_without_standardize() {
let pt = PowerTransformer::<f64>::without_standardize();
assert!(!pt.standardize());
let x = array![[1.0], [2.0], [3.0]];
let fitted = pt.fit(&x, &()).unwrap();
assert!(fitted.means.is_none());
assert!(fitted.stds.is_none());
let out = fitted.transform(&x).unwrap();
assert_eq!(out.shape(), x.shape());
}
#[test]
fn test_fit_transform_equivalence() {
let pt = PowerTransformer::<f64>::new();
let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let via_ft = pt.fit_transform(&x).unwrap();
let fitted = pt.fit(&x, &()).unwrap();
let via_sep = fitted.transform(&x).unwrap();
for (a, b) in via_ft.iter().zip(via_sep.iter()) {
assert_abs_diff_eq!(a, b, epsilon = 1e-12);
}
}
#[test]
fn test_shape_mismatch_error() {
let pt = PowerTransformer::<f64>::new();
let x_train = array![[1.0, 2.0], [3.0, 4.0]];
let fitted = pt.fit(&x_train, &()).unwrap();
let x_bad = array![[1.0, 2.0, 3.0]];
assert!(fitted.transform(&x_bad).is_err());
}
#[test]
fn test_insufficient_samples_error() {
let pt = PowerTransformer::<f64>::new();
let x: Array2<f64> = Array2::zeros((0, 2));
assert!(pt.fit(&x, &()).is_err());
}
#[test]
fn test_unfitted_transform_error() {
let pt = PowerTransformer::<f64>::new();
let x = array![[1.0, 2.0]];
assert!(pt.transform(&x).is_err());
}
#[test]
fn test_negative_values_supported() {
let pt = PowerTransformer::<f64>::without_standardize();
let x = array![[-2.0], [-1.0], [0.0], [1.0], [2.0]];
let fitted = pt.fit(&x, &()).unwrap();
let out = fitted.transform(&x).unwrap();
for v in &out {
assert!(v.is_finite(), "got non-finite value: {v}");
}
}
#[test]
fn test_pipeline_integration() {
use ferrolearn_core::pipeline::PipelineTransformer;
let pt = PowerTransformer::<f64>::new();
let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let y = Array1::zeros(3);
let fitted = pt.fit_pipeline(&x, &y).unwrap();
let result = fitted.transform_pipeline(&x).unwrap();
assert_eq!(result.shape(), x.shape());
}
}