diff --git a/book/src/getting-started/quick-start.md b/book/src/getting-started/quick-start.md index da206732..e0b751f9 100644 --- a/book/src/getting-started/quick-start.md +++ b/book/src/getting-started/quick-start.md @@ -49,7 +49,10 @@ For more complex scenarios, you can use the `Forecaster` API which supports data # extern crate augurs; use augurs::{ ets::AutoETS, - forecaster::{transforms::MinMaxScaleParams, Forecaster, Transform}, + forecaster::{ + transforms::{LinearInterpolator, Log, MinMaxScaler}, + Forecaster, Transform, + }, mstl::MSTLModel, }; @@ -61,9 +64,9 @@ fn main() { let mstl = MSTLModel::new(vec![2], ets); let transforms = vec![ - Transform::linear_interpolator(), - Transform::min_max_scaler(MinMaxScaleParams::from_data(data.iter().copied())), - Transform::log(), + LinearInterpolator::new().boxed(), + MinMaxScaler::new().boxed(), + Log::new().boxed(), ]; // Create and fit forecaster diff --git a/crates/augurs-core/src/lib.rs b/crates/augurs-core/src/lib.rs index 63e74988..c3090d2a 100644 --- a/crates/augurs-core/src/lib.rs +++ b/crates/augurs-core/src/lib.rs @@ -8,7 +8,6 @@ pub mod prelude { mod distance; mod forecast; -pub mod interpolate; mod traits; use std::convert::Infallible; diff --git a/crates/augurs-forecaster/src/error.rs b/crates/augurs-forecaster/src/error.rs index 27ffa463..538523a6 100644 --- a/crates/augurs-forecaster/src/error.rs +++ b/crates/augurs-forecaster/src/error.rs @@ -1,5 +1,7 @@ use augurs_core::ModelError; +use crate::transforms; + /// Errors returned by this crate. #[derive(Debug, thiserror::Error)] pub enum Error { @@ -18,4 +20,12 @@ pub enum Error { /// The original error. source: Box, }, + + /// An error occurred while running a transformation. + #[error("Transform error: {source}")] + Transform { + /// The original error. + #[from] + source: transforms::Error, + }, } diff --git a/crates/augurs-forecaster/src/forecaster.rs b/crates/augurs-forecaster/src/forecaster.rs index 5ad62957..1cecfaeb 100644 --- a/crates/augurs-forecaster/src/forecaster.rs +++ b/crates/augurs-forecaster/src/forecaster.rs @@ -1,6 +1,6 @@ use augurs_core::{Fit, Forecast, Predict}; -use crate::{Data, Error, Result, Transform, Transforms}; +use crate::{Data, Error, Pipeline, Result, Transform}; /// A high-level API to fit and predict time series forecasting models. /// @@ -13,7 +13,7 @@ pub struct Forecaster { model: M, fitted: Option, - transforms: Transforms, + pipeline: Pipeline, } impl Forecaster @@ -26,23 +26,21 @@ where Self { model, fitted: None, - transforms: Transforms::default(), + pipeline: Pipeline::default(), } } /// Set the transformations to be applied to the input data. - pub fn with_transforms(mut self, transforms: Vec) -> Self { - self.transforms = Transforms::new(transforms); + pub fn with_transforms(mut self, transforms: Vec>) -> Self { + self.pipeline = Pipeline::new(transforms); self } /// Fit the model to the given time series. pub fn fit(&mut self, y: D) -> Result<()> { - let data: Vec<_> = self - .transforms - .transform(y.as_slice().iter().copied()) - .collect(); - self.fitted = Some(self.model.fit(&data).map_err(|e| Error::Fit { + let mut y = y.as_slice().to_vec(); + self.pipeline.transform(&mut y)?; + self.fitted = Some(self.model.fit(&y).map_err(|e| Error::Fit { source: Box::new(e) as _, })?); Ok(()) @@ -55,87 +53,66 @@ where /// Predict the next `horizon` values, optionally including prediction /// intervals at the given level. pub fn predict(&self, horizon: usize, level: impl Into>) -> Result { - self.fitted()? - .predict(horizon, level.into()) - .map_err(|e| Error::Predict { - source: Box::new(e) as _, - }) - .map(|f| self.transforms.inverse_transform(f)) + let mut untransformed = + self.fitted()? + .predict(horizon, level.into()) + .map_err(|e| Error::Predict { + source: Box::new(e) as _, + })?; + self.pipeline + .inverse_transform_forecast(&mut untransformed)?; + Ok(untransformed) } /// Produce in-sample forecasts, optionally including prediction intervals /// at the given level. pub fn predict_in_sample(&self, level: impl Into>) -> Result { - self.fitted()? + let mut untransformed = self + .fitted()? .predict_in_sample(level.into()) .map_err(|e| Error::Predict { source: Box::new(e) as _, - }) - .map(|f| self.transforms.inverse_transform(f)) + })?; + self.pipeline + .inverse_transform_forecast(&mut untransformed)?; + Ok(untransformed) } } #[cfg(test)] mod test { - use itertools::{Itertools, MinMaxResult}; use augurs::mstl::{MSTLModel, NaiveTrend}; + use augurs_testing::assert_all_close; - use crate::transforms::MinMaxScaleParams; + use crate::transforms::{BoxCox, LinearInterpolator, Logit, MinMaxScaler, YeoJohnson}; use super::*; - fn assert_approx_eq(a: f64, b: f64) -> bool { - if a.is_nan() && b.is_nan() { - return true; - } - (a - b).abs() < 0.001 - } - - fn assert_all_approx_eq(a: &[f64], b: &[f64]) { - if a.len() != b.len() { - assert_eq!(a, b); - } - for (ai, bi) in a.iter().zip(b) { - if !assert_approx_eq(*ai, *bi) { - assert_eq!(a, b); - } - } - } - #[test] fn test_forecaster() { let data = &[1.0_f64, 2.0, 3.0, 4.0, 5.0]; - let MinMaxResult::MinMax(min, max) = data - .iter() - .copied() - .minmax_by(|a, b| a.partial_cmp(b).unwrap()) - else { - unreachable!(); - }; let transforms = vec![ - Transform::linear_interpolator(), - Transform::min_max_scaler(MinMaxScaleParams::new(min - 1e-3, max + 1e-3)), - Transform::logit(), + LinearInterpolator::new().boxed(), + MinMaxScaler::new().boxed(), + Logit::new().boxed(), ]; let model = MSTLModel::new(vec![2], NaiveTrend::new()); let mut forecaster = Forecaster::new(model).with_transforms(transforms); forecaster.fit(data).unwrap(); let forecasts = forecaster.predict(4, None).unwrap(); - assert_all_approx_eq(&forecasts.point, &[5.0, 5.0, 5.0, 5.0]); + assert_all_close(&forecasts.point, &[5.0, 5.0, 5.0, 5.0]); } #[test] fn test_forecaster_power_positive() { let data = &[1.0_f64, 2.0, 3.0, 4.0, 5.0]; - let got = Transform::power_transform(data); - assert!(got.is_ok()); - let transforms = vec![got.unwrap()]; + let transforms = vec![BoxCox::new().boxed()]; let model = MSTLModel::new(vec![2], NaiveTrend::new()); let mut forecaster = Forecaster::new(model).with_transforms(transforms); forecaster.fit(data).unwrap(); let forecasts = forecaster.predict(4, None).unwrap(); - assert_all_approx_eq( + assert_all_close( &forecasts.point, &[ 5.084499064884572, @@ -149,14 +126,12 @@ mod test { #[test] fn test_forecaster_power_non_positive() { let data = &[0.0, 2.0, 3.0, 4.0, 5.0]; - let got = Transform::power_transform(data); - assert!(got.is_ok()); - let transforms = vec![got.unwrap()]; + let transforms = vec![YeoJohnson::new().boxed()]; let model = MSTLModel::new(vec![2], NaiveTrend::new()); let mut forecaster = Forecaster::new(model).with_transforms(transforms); forecaster.fit(data).unwrap(); let forecasts = forecaster.predict(4, None).unwrap(); - assert_all_approx_eq( + assert_all_close( &forecasts.point, &[ 5.205557727170964, diff --git a/crates/augurs-forecaster/src/lib.rs b/crates/augurs-forecaster/src/lib.rs index d8c2c689..6c9f6a8f 100644 --- a/crates/augurs-forecaster/src/lib.rs +++ b/crates/augurs-forecaster/src/lib.rs @@ -8,7 +8,6 @@ pub mod transforms; pub use data::Data; pub use error::Error; pub use forecaster::Forecaster; -pub use transforms::Transform; -pub(crate) use transforms::Transforms; +pub use transforms::{Pipeline, Transform}; type Result = std::result::Result; diff --git a/crates/augurs-forecaster/src/transforms.rs b/crates/augurs-forecaster/src/transforms.rs index 1d15a550..e598e546 100644 --- a/crates/augurs-forecaster/src/transforms.rs +++ b/crates/augurs-forecaster/src/transforms.rs @@ -11,282 +11,80 @@ apply a transformation to a time series and its inverse, respectively. // Note: implementations of the various transforms are in the // various submodules of this module (e.g. `power` and `scale`). +mod error; mod exp; +mod interpolate; mod power; mod scale; -use argmin::core::Error; -use augurs_core::{ - interpolate::{InterpolateExt, LinearInterpolator}, - Forecast, -}; +use std::fmt; -use exp::{ExpExt, LogExt, LogisticExt, LogitExt}; -use power::{ - optimize_box_cox_lambda, optimize_yeo_johnson_lambda, BoxCoxExt, IntoBoxCoxLambda, - IntoYeoJohnsonLambda, InverseBoxCoxExt, InverseYeoJohnsonExt, YeoJohnsonExt, -}; -use scale::{InverseMinMaxScaleExt, InverseStandardScaleExt, MinMaxScaleExt, StandardScaleExt}; -pub use scale::{MinMaxScaleParams, StandardScaleParams}; +use augurs_core::Forecast; + +pub use error::Error; +pub use exp::{Log, Logit}; +pub use interpolate::{InterpolateExt, LinearInterpolator}; +pub use power::{BoxCox, YeoJohnson}; +pub use scale::{MinMaxScaler, StandardScaleParams, StandardScaler}; /// Transforms and Transform implementations. /// /// The `Transforms` struct is a collection of `Transform` instances that can be applied to a time series. /// The `Transform` enum represents a single transformation that can be applied to a time series. #[derive(Debug, Default)] -pub(crate) struct Transforms(Vec); +pub struct Pipeline(Vec>); -impl Transforms { - /// create a new `Transforms` instance with the given transforms. - pub(crate) fn new(transforms: Vec) -> Self { +impl Pipeline { + /// Create a new `Pipeline` with the given transforms. + pub fn new(transforms: Vec>) -> Self { Self(transforms) } /// Apply the transformations to the given time series. - pub(crate) fn transform<'a, T>(&'a self, input: T) -> Box + 'a> - where - T: Iterator + 'a, - { - self.0 - .iter() - .fold(Box::new(input) as _, |y, t| t.transform(y)) - } - - /// Apply the inverse transformations to the given forecast. - pub(crate) fn inverse_transform(&self, forecast: Forecast) -> Forecast { - self.0 - .iter() - .rev() - .fold(forecast, |f, t| t.inverse_transform_forecast(f)) - } -} - -// Note: ideally this would be a trait, but that makes it quite difficult to -// compose transformations, since we need to work with trait objects and -// dynamic dispatch and lifetimes tend to get a bit tricky. It might be worth -// revisiting this in the future. - -/// A transformation that can be applied to a time series. -#[derive(Debug)] -#[non_exhaustive] -pub enum Transform { - /// Linear interpolation. - /// - /// This can be used to fill in missing values in a time series - /// by interpolating between the nearest non-missing values. - LinearInterpolator, - /// Min-max scaling. - MinMaxScaler(MinMaxScaleParams), - /// Standard scaling. - StandardScaler(StandardScaleParams), - /// Logit transform. - Logit, - /// Log transform. - Log, - /// Box-Cox transform. - BoxCox { - /// The lambda parameter for the Box-Cox transformation. - /// If lambda == 0, the transformation is equivalent to the natural logarithm. - /// Otherwise, the transformation is (x^lambda - 1) / lambda. - lambda: f64, - }, - /// Yeo-Johnson transform. - YeoJohnson { - /// The lambda parameter for the Yeo-Johnson transformation. - /// If lambda == 0, the transformation is equivalent to the natural logarithm. - /// Otherwise, the transformation is ((x + 1)^lambda - 1) / lambda. - lambda: f64, - }, -} - -impl Transform { - /// Create a new linear interpolator. - /// - /// This interpolator uses linear interpolation to fill in missing values. - pub fn linear_interpolator() -> Self { - Self::LinearInterpolator - } - - /// Create a new min-max scaler. - /// - /// This scaler scales each item to the range [0, 1]. - /// - /// Because transforms operate on iterators, the data min and max must be passed for now. - /// This also allows for the possibility of using different min and max values; for example, - /// if you know that the true possible min and max of your data differ from the sample. - pub fn min_max_scaler(min_max_params: MinMaxScaleParams) -> Self { - Self::MinMaxScaler(min_max_params) - } - - /// Create a new standard scaler. - /// - /// This scaler standardizes features by removing the mean and scaling to unit variance. - /// - /// The standard score of a sample x is calculated as: - /// - /// ```text - /// z = (x - u) / s - /// ``` - /// - /// where u is the mean and s is the standard deviation in the provided - /// `StandardScaleParams`. - pub fn standard_scaler(scale_params: StandardScaleParams) -> Self { - Self::StandardScaler(scale_params) - } - - /// Create a new logit transform. - /// - /// This transform applies the logit function to each item. - pub fn logit() -> Self { - Self::Logit - } - - /// Create a new log transform. - /// - /// This transform applies the natural logarithm to each item. - pub fn log() -> Self { - Self::Log - } - - /// Create a new Box-Cox transform. - /// - /// This transform applies the Box-Cox transformation to each item. - /// - /// The Box-Cox transformation is defined as: - /// - /// - if lambda == 0: x.ln() - /// - otherwise: (x^lambda - 1) / lambda - /// - /// # Parameters - /// - /// The `lambda` parameter can be a `f64` or a slice of `f64`s. In the latter case, - /// the optimal lambda parameter will be found using maximum likelihood estimation - /// to minimise skewness. - /// - /// # Errors - /// - /// This function returns an error if the optimal lambda parameter cannot be found. - pub fn box_cox(lambda: T) -> Result { - let lambda = lambda.into_box_cox_lambda()?; - Ok(Self::BoxCox { lambda }) + pub fn transform(&mut self, input: &mut [f64]) -> Result<(), Error> { + for t in self.0.iter_mut() { + t.transform(input)?; + } + Ok(()) } - /// Create a new Yeo-Johnson transform. - /// - /// This transform applies the Yeo-Johnson transformation to each item. - /// - /// The Yeo-Johnson transformation is a generalization of the Box-Cox transformation that - /// supports negative values. It is defined as: - /// - /// - if lambda != 0 and x >= 0: ((x + 1)^lambda - 1) / lambda - /// - if lambda == 0 and x >= 0: (x + 1).ln() - /// - if lambda != 2 and x < 0: ((-x + 1)^2 - 1) / 2 - /// - if lambda == 2 and x < 0: (-x + 1).ln() - /// - /// # Parameters - /// - /// The `lambda` parameter can be a `f64` or a slice of `f64`s. In the latter case, - /// the optimal lambda parameter will be found using maximum likelihood estimation - /// to minimise skewness. - /// - /// # Errors - /// - /// This function returns an error if the optimal lambda parameter cannot be found. - pub fn yeo_johnson(lambda: T) -> Result { - let lambda = lambda.into_yeo_johnson_lambda()?; - Ok(Self::YeoJohnson { lambda }) + /// Apply the inverse transformations to the given time series. + pub fn inverse_transform(&self, input: &mut [f64]) -> Result<(), Error> { + for t in self.0.iter().rev() { + t.inverse_transform(input)?; + } + Ok(()) } - /// Create a power transform that optimizes the lambda parameter. - /// - /// # Algorithm Selection - /// - /// - If all values are positive: Uses Box-Cox transformation - /// - If any values are negative or zero: Uses Yeo-Johnson transformation - /// - /// # Returns - /// - /// Returns `Result` to handle optimization failures gracefully - pub fn power_transform(data: &[f64]) -> Result { - if data.iter().all(|&x| x > 0.0) { - optimize_box_cox_lambda(data).map(|lambda| Self::BoxCox { lambda }) - } else { - optimize_yeo_johnson_lambda(data).map(|lambda| Self::YeoJohnson { lambda }) + /// Apply the inverse transformations to the given forecast. + pub(crate) fn inverse_transform_forecast(&self, forecast: &mut Forecast) -> Result<(), Error> { + for t in self.0.iter().rev() { + t.inverse_transform(&mut forecast.point)?; + if let Some(intervals) = forecast.intervals.as_mut() { + t.inverse_transform(&mut intervals.lower)?; + t.inverse_transform(&mut intervals.upper)?; + } } + Ok(()) } +} +/// A transformation that can be applied to a time series. +pub trait Transform: fmt::Debug + Sync + Send { /// Apply the transformation to the given time series. - /// - /// # Returns - /// - /// A boxed iterator over the transformed values. - /// - /// # Example - /// - /// ``` - /// use augurs_forecaster::transforms::Transform; - /// - /// let data = vec![1.0, 2.0, 3.0]; - /// let transform = Transform::log(); - /// let transformed: Vec<_> = transform.transform(data.into_iter()).collect(); - /// ``` - pub fn transform<'a, T>(&'a self, input: T) -> Box + 'a> - where - T: Iterator + 'a, - { - match self { - Self::LinearInterpolator => Box::new(input.interpolate(LinearInterpolator::default())), - Self::MinMaxScaler(params) => Box::new(input.min_max_scale(params)), - Self::StandardScaler(params) => Box::new(input.standard_scale(params)), - Self::Logit => Box::new(input.logit()), - Self::Log => Box::new(input.log()), - Self::BoxCox { lambda } => Box::new(input.box_cox(*lambda)), - Self::YeoJohnson { lambda } => Box::new(input.yeo_johnson(*lambda)), - } - } + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error>; /// Apply the inverse transformation to the given time series. + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error>; + + /// Create a boxed version of the transformation. /// - /// # Returns - /// - /// A boxed iterator over the inverse transformed values. - /// - /// # Example - /// - /// ``` - /// use augurs_forecaster::transforms::Transform; - /// - /// let data = vec![1.0, 2.0, 3.0]; - /// let transform = Transform::log(); - /// let transformed: Vec<_> = transform.inverse_transform(data.into_iter()).collect(); - /// ``` - pub fn inverse_transform<'a, T>(&'a self, input: T) -> Box + 'a> + /// This is useful for creating a `Transform` instance that can be used as + /// part of a [`Pipeline`]. + fn boxed(self) -> Box where - T: Iterator + 'a, + Self: Sized + 'static, { - match self { - Self::LinearInterpolator => Box::new(input), - Self::MinMaxScaler(params) => Box::new(input.inverse_min_max_scale(params)), - Self::StandardScaler(params) => Box::new(input.inverse_standard_scale(params)), - Self::Logit => Box::new(input.logistic()), - Self::Log => Box::new(input.exp()), - Self::BoxCox { lambda } => Box::new(input.inverse_box_cox(*lambda)), - Self::YeoJohnson { lambda } => Box::new(input.inverse_yeo_johnson(*lambda)), - } - } - - /// Apply the inverse transformations to the given forecast. - pub fn inverse_transform_forecast(&self, mut f: Forecast) -> Forecast { - f.point = self.inverse_transform(f.point.into_iter()).collect(); - if let Some(mut intervals) = f.intervals.take() { - intervals.lower = self - .inverse_transform(intervals.lower.into_iter()) - .collect(); - intervals.upper = self - .inverse_transform(intervals.upper.into_iter()) - .collect(); - f.intervals = Some(intervals); - } - f + Box::new(self) } } diff --git a/crates/augurs-forecaster/src/transforms/error.rs b/crates/augurs-forecaster/src/transforms/error.rs new file mode 100644 index 00000000..f320b7b0 --- /dev/null +++ b/crates/augurs-forecaster/src/transforms/error.rs @@ -0,0 +1,43 @@ +/// An error that can occur during the transformation process. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// An error occurred during optimization. + #[error("error in optimization: {0}")] + Optimize(#[from] argmin::core::Error), + /// No best parameter was found during optimization. + #[error("no best parameter found")] + NoBestParameter, + /// The input data did not have a distinct minimum and maximum value. + #[error("no min-max found: {0:?}")] + MinMaxNotFound(itertools::MinMaxResult), + /// The transform has not been fitted yet. + #[error("transform has not been fitted yet")] + NotFitted, + /// The input data is empty. + #[error("data must not be empty")] + EmptyData, + /// The input data contains non-positive values. + #[error("data contains non-positive values")] + NonPositiveData, + /// The input values contain NaN. + #[error("input values must not be NaN")] + NaNValue, + /// The input lambda must be finite. + #[error("input lambda must be finite")] + InvalidLambda, + /// The variance must be positive. + #[error("variance must be positive")] + VarianceNotPositive, + /// All data must be greater than 0. + #[error("all data must be greater than 0")] + AllDataNotPositive, + /// The input data is not in the valid domain. + #[error("invalid domain")] + InvalidDomain, +} + +impl From> for Error { + fn from(e: itertools::MinMaxResult) -> Self { + Self::MinMaxNotFound(e) + } +} diff --git a/crates/augurs-forecaster/src/transforms/exp.rs b/crates/augurs-forecaster/src/transforms/exp.rs index c8067481..e1c280ba 100644 --- a/crates/augurs-forecaster/src/transforms/exp.rs +++ b/crates/augurs-forecaster/src/transforms/exp.rs @@ -1,5 +1,9 @@ //! Exponential transformations, including log and logit. +use std::fmt; + +use super::{Error, Transform}; + // Logit and logistic functions. /// Returns the logistic function of the given value. @@ -12,117 +16,71 @@ fn logit(x: f64) -> f64 { (x / (1.0 - x)).ln() } -/// An iterator adapter that applies the logit function to each item. -#[derive(Clone, Debug)] -pub(crate) struct Logit { - inner: T, +/// The logit transform. +#[derive(Clone, Default)] +pub struct Logit { + _priv: (), } -impl Iterator for Logit -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(logit) +impl Logit { + /// Create a new logit transform. + pub fn new() -> Self { + Self::default() } } -pub(crate) trait LogitExt: Iterator { - fn logit(self) -> Logit - where - Self: Sized, - { - Logit { inner: self } +impl fmt::Debug for Logit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Logit").finish() } } -impl LogitExt for T where T: Iterator {} - -/// An iterator adapter that applies the logistic function to each item. -#[derive(Clone, Debug)] -pub(crate) struct Logistic { - inner: T, -} - -impl Iterator for Logistic -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(logistic) +impl Transform for Logit { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + data.iter_mut().for_each(|x| *x = logit(*x)); + Ok(()) } -} -pub(crate) trait LogisticExt: Iterator { - fn logistic(self) -> Logistic - where - Self: Sized, - { - Logistic { inner: self } + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + data.iter_mut().for_each(|x| *x = logistic(*x)); + Ok(()) } } -impl LogisticExt for T where T: Iterator {} - -/// An iterator adapter that applies the log function to each item. -#[derive(Clone, Debug)] -pub(crate) struct Log { - inner: T, +/// The log transform. +#[derive(Clone, Default)] +pub struct Log { + _priv: (), } -impl Iterator for Log -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(f64::ln) +impl Log { + /// Create a new log transform. + pub fn new() -> Self { + Self::default() } } -pub(crate) trait LogExt: Iterator { - fn log(self) -> Log - where - Self: Sized, - { - Log { inner: self } +impl fmt::Debug for Log { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Log").finish() } } -impl LogExt for T where T: Iterator {} - -/// An iterator adapter that applies the exponential function to each item. -#[derive(Clone, Debug)] -pub(crate) struct Exp { - inner: T, -} - -impl Iterator for Exp -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(f64::exp) +impl Transform for Log { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + data.iter_mut().for_each(|x| *x = f64::ln(*x)); + Ok(()) } -} -pub(crate) trait ExpExt: Iterator { - fn exp(self) -> Exp - where - Self: Sized, - { - Exp { inner: self } + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + data.iter_mut().for_each(|x| *x = f64::exp(*x)); + Ok(()) } } -impl ExpExt for T where T: Iterator {} - #[cfg(test)] mod test { - use augurs_testing::assert_approx_eq; + use augurs_testing::{assert_all_close, assert_approx_eq}; use super::*; @@ -159,34 +117,50 @@ mod test { } #[test] - fn logistic_transform() { - let data = vec![0.0, 1.0, -1.0]; + fn logit_transform() { + let mut data = vec![0.5, 0.75, 0.25]; let expected = vec![ - 0.5_f64, - 1.0 / (1.0 + (-1.0_f64).exp()), - 1.0 / (1.0 + 1.0_f64.exp()), + 0.0_f64, + (0.75_f64 / (1.0 - 0.75)).ln(), + (0.25_f64 / (1.0 - 0.25)).ln(), ]; - let actual: Vec<_> = data.into_iter().logistic().collect(); - assert_eq!(expected, actual); + Logit::new() + .transform(&mut data) + .expect("failed to logit transform"); + assert_all_close(&expected, &data); } #[test] - fn logit_transform() { - let data = vec![0.5, 0.75, 0.25]; + fn logit_inverse_transform() { + let mut data = vec![0.0, 1.0, -1.0]; let expected = vec![ - 0.0_f64, - (0.75_f64 / (1.0 - 0.75)).ln(), - (0.25_f64 / (1.0 - 0.25)).ln(), + 0.5_f64, + 1.0 / (1.0 + (-1.0_f64).exp()), + 1.0 / (1.0 + 1.0_f64.exp()), ]; - let actual: Vec<_> = data.into_iter().logit().collect(); - assert_eq!(expected, actual); + Logit::new() + .inverse_transform(&mut data) + .expect("failed to inverse logit transform"); + assert_all_close(&expected, &data); } #[test] fn log_transform() { - let data = vec![1.0, 2.0, 3.0]; + let mut data = vec![1.0, 2.0, 3.0]; let expected = vec![0.0_f64, 2.0_f64.ln(), 3.0_f64.ln()]; - let actual: Vec<_> = data.into_iter().log().collect(); - assert_eq!(expected, actual); + Log::new() + .transform(&mut data) + .expect("failed to log transform"); + assert_all_close(&expected, &data); + } + + #[test] + fn log_inverse_transform() { + let mut data = vec![0.0, 2.0_f64.ln(), 3.0_f64.ln()]; + let expected = vec![1.0, 2.0, 3.0]; + Log::new() + .inverse_transform(&mut data) + .expect("failed to inverse log transform"); + assert_all_close(&expected, &data); } } diff --git a/crates/augurs-core/src/interpolate.rs b/crates/augurs-forecaster/src/transforms/interpolate.rs similarity index 96% rename from crates/augurs-core/src/interpolate.rs rename to crates/augurs-forecaster/src/transforms/interpolate.rs index 39c9ccff..29d67060 100644 --- a/crates/augurs-core/src/interpolate.rs +++ b/crates/augurs-forecaster/src/transforms/interpolate.rs @@ -12,6 +12,8 @@ use std::{ ops::{Add, Div, Mul, Sub}, }; +use super::{Error, Transform}; + /// A type that can be used to interpolate between values. pub trait Interpolater { /// Interpolate between two values. @@ -42,6 +44,13 @@ pub struct LinearInterpolator { _priv: (), } +impl LinearInterpolator { + /// Create a new `LinearInterpolator`. + pub fn new() -> Self { + Self::default() + } +} + impl Interpolater for LinearInterpolator { fn interpolate(&self, low: T, high: T, n: usize) -> impl Iterator { let diff = high - low; @@ -50,6 +59,18 @@ impl Interpolater for LinearInterpolator { } } +impl Transform for LinearInterpolator { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + let interpolated: Vec<_> = data.iter().copied().interpolate(*self).collect(); + data.copy_from_slice(&interpolated); + Ok(()) + } + + fn inverse_transform(&self, _data: &mut [f64]) -> Result<(), Error> { + Ok(()) + } +} + /// An iterator that interpolates between NaN values in the input. /// /// This iterator is used to fill in missing values in a time series by diff --git a/crates/augurs-forecaster/src/transforms/power.rs b/crates/augurs-forecaster/src/transforms/power.rs index ee0739f2..310c8578 100644 --- a/crates/augurs-forecaster/src/transforms/power.rs +++ b/crates/augurs-forecaster/src/transforms/power.rs @@ -1,13 +1,15 @@ //! Power transformations, including Box-Cox and Yeo-Johnson. -use argmin::core::{CostFunction, Error, Executor}; +use argmin::core::{CostFunction, Executor}; use argmin::solver::brent::BrentOpt; +use super::{Error, Transform}; + /// Returns the Box-Cox transformation of the given value. /// Assumes x > 0. -pub(crate) fn box_cox(x: f64, lambda: f64) -> Result { +fn box_cox(x: f64, lambda: f64) -> Result { if x <= 0.0 { - return Err("x must be greater than 0"); + return Err(Error::NonPositiveData); } if lambda == 0.0 { Ok(x.ln()) @@ -16,40 +18,34 @@ pub(crate) fn box_cox(x: f64, lambda: f64) -> Result { } } -/// Returns the Yeo-Johnson transformation of the given value. -pub(crate) fn yeo_johnson(x: f64, lambda: f64) -> Result { - if x.is_nan() || lambda.is_nan() { - return Err("Input values must be valid numbers."); - } - - if x >= 0.0 { - if lambda == 0.0 { - Ok((x + 1.0).ln()) +/// Returns the inverse Box-Cox transformation of the given value. +fn inverse_box_cox(y: f64, lambda: f64) -> Result { + if lambda == 0.0 { + Ok(y.exp()) + } else { + let value = y * lambda + 1.0; + if value <= 0.0 { + Err(Error::InvalidDomain) } else { - Ok(((x + 1.0).powf(lambda) - 1.0) / lambda) + Ok(value.powf(1.0 / lambda)) } - } else if lambda == 2.0 { - Ok(-(-x + 1.0).ln()) - } else { - Ok(-((-x + 1.0).powf(2.0 - lambda) - 1.0) / (2.0 - lambda)) } } fn box_cox_log_likelihood(data: &[f64], lambda: f64) -> Result { let n = data.len() as f64; if n == 0.0 { - return Err(Error::msg("Data must not be empty")); + return Err(Error::EmptyData); } if data.iter().any(|&x| x <= 0.0) { - return Err(Error::msg("All data must be greater than 0")); + return Err(Error::NonPositiveData); } - let transformed_data: Result, _> = data.iter().map(|&x| box_cox(x, lambda)).collect(); + let transformed_data = data + .iter() + .map(|&x| box_cox(x, lambda)) + .collect::, _>>()?; - let transformed_data = match transformed_data { - Ok(values) => values, - Err(e) => return Err(Error::msg(e)), - }; - let mean_transformed: f64 = transformed_data.iter().copied().sum::() / n; + let mean_transformed: f64 = transformed_data.iter().sum::() / n; let variance: f64 = transformed_data .iter() .map(|&x| (x - mean_transformed).powi(2)) @@ -58,52 +54,13 @@ fn box_cox_log_likelihood(data: &[f64], lambda: f64) -> Result { // Avoid log(0) by ensuring variance is positive if variance <= 0.0 { - return Err(Error::msg("Variance must be positive")); + return Err(Error::VarianceNotPositive); } let log_likelihood = -0.5 * n * variance.ln() + (lambda - 1.0) * data.iter().map(|&x| x.ln()).sum::(); Ok(log_likelihood) } -fn yeo_johnson_log_likelihood(data: &[f64], lambda: f64) -> Result { - let n = data.len() as f64; - - if n == 0.0 { - return Err(Error::msg("Data array is empty")); - } - - let transformed_data: Result, _> = - data.iter().map(|&x| yeo_johnson(x, lambda)).collect(); - - let transformed_data = match transformed_data { - Ok(values) => values, - Err(e) => return Err(Error::msg(e)), - }; - - let mean = transformed_data.iter().sum::() / n; - - let variance = transformed_data - .iter() - .map(|&x| (x - mean).powi(2)) - .sum::() - / n; - - if variance <= 0.0 { - return Err(Error::msg("Variance is non-positive")); - } - - let log_sigma_squared = variance.ln(); - let log_likelihood = -n / 2.0 * log_sigma_squared; - - let additional_term: f64 = data - .iter() - .map(|&x| (x.signum() * (x.abs() + 1.0).ln())) - .sum::() - * (lambda - 1.0); - - Ok(log_likelihood + additional_term) -} - #[derive(Clone)] struct BoxCoxProblem<'a> { data: &'a [f64], @@ -114,23 +71,8 @@ impl CostFunction for BoxCoxProblem<'_> { type Output = f64; // The goal is to minimize the negative log-likelihood - fn cost(&self, lambda: &Self::Param) -> Result { - box_cox_log_likelihood(self.data, *lambda).map(|ll| -ll) - } -} - -#[derive(Clone)] -struct YeoJohnsonProblem<'a> { - data: &'a [f64], -} - -impl CostFunction for YeoJohnsonProblem<'_> { - type Param = f64; - type Output = f64; - - // The goal is to minimize the negative log-likelihood - fn cost(&self, lambda: &Self::Param) -> Result { - yeo_johnson_log_likelihood(self.data, *lambda).map(|ll| -ll) + fn cost(&self, lambda: &Self::Param) -> Result { + Ok(box_cox_log_likelihood(self.data, *lambda).map(|ll| -ll)?) } } @@ -165,11 +107,9 @@ fn optimize_lambda>( }) .run(); - result.and_then(|res| { - res.state() - .best_param - .ok_or_else(|| Error::msg("No best parameter found")) - }) + result + .map_err(Error::Optimize) + .and_then(|res| res.state().best_param.ok_or_else(|| Error::NoBestParameter)) } /// Optimize the lambda parameter for the Box-Cox or Yeo-Johnson transformation @@ -187,119 +127,88 @@ pub(crate) fn optimize_yeo_johnson_lambda(data: &[f64]) -> Result { optimize_lambda(cost, optimization_params) } -/// An iterator adapter that applies the Box-Cox transformation to each item. +/// A transformer that applies the Box-Cox transformation to each item. +/// +/// The Box-Cox transformation is defined as: +/// +/// - if lambda == 0: x.ln() +/// - otherwise: (x^lambda - 1) / lambda +/// +/// By default the optimal `lambda` parameter is found from the data in +/// `transform` using maximum likelihood estimation. If you want to use a +/// specific `lambda` value, you can use the `with_lambda` method. #[derive(Clone, Debug)] -pub(crate) struct BoxCox { - inner: T, +pub struct BoxCox { lambda: f64, } -impl Iterator for BoxCox -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner - .next() - .map(|x| box_cox(x, self.lambda).unwrap_or(f64::NAN)) +impl BoxCox { + /// Create a new `BoxCox` transformer. + pub fn new() -> Self { + Self { lambda: f64::NAN } } -} -pub(crate) trait BoxCoxExt: Iterator { - fn box_cox(self, lambda: f64) -> BoxCox - where - Self: Sized, - { - BoxCox { - inner: self, - lambda, + /// Set the `lambda` parameter for the Box-Cox transformation. + /// + /// # Errors + /// + /// This function returns an error if the `lambda` parameter is NaN. + pub fn with_lambda(mut self, lambda: f64) -> Result { + if !lambda.is_finite() { + return Err(Error::InvalidLambda); } + self.lambda = lambda; + Ok(self) } } -impl BoxCoxExt for T where T: Iterator {} - -/// Returns the inverse Box-Cox transformation of the given value. -fn inverse_box_cox(y: f64, lambda: f64) -> Result { - if lambda == 0.0 { - Ok(y.exp()) - } else { - let value = y * lambda + 1.0; - if value <= 0.0 { - Err("Invalid domain for inverse Box-Cox transformation") - } else { - Ok(value.powf(1.0 / lambda)) - } +impl Default for BoxCox { + fn default() -> Self { + Self::new() } } -/// An iterator adapter that applies the inverse Box-Cox transformation to each item. -#[derive(Clone, Debug)] -pub(crate) struct InverseBoxCox { - inner: T, - lambda: f64, -} - -impl Iterator for InverseBoxCox -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner - .next() - .map(|y| inverse_box_cox(y, self.lambda).unwrap_or(f64::NAN)) +impl Transform for BoxCox { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + if self.lambda.is_nan() { + self.lambda = optimize_box_cox_lambda(data)?; + } + for x in data.iter_mut() { + *x = box_cox(*x, self.lambda)?; + } + Ok(()) } -} -pub(crate) trait InverseBoxCoxExt: Iterator { - fn inverse_box_cox(self, lambda: f64) -> InverseBoxCox - where - Self: Sized, - { - InverseBoxCox { - inner: self, - lambda, + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + for x in data.iter_mut() { + *x = inverse_box_cox(*x, self.lambda)?; } + Ok(()) } } -impl InverseBoxCoxExt for T where T: Iterator {} - -/// An iterator adapter that applies the Yeo-Johnson transformation to each item. -#[derive(Clone, Debug)] -pub(crate) struct YeoJohnson { - inner: T, - lambda: f64, -} - -impl Iterator for YeoJohnson -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner - .next() - .map(|x| yeo_johnson(x, self.lambda).unwrap_or(f64::NAN)) +/// Returns the Yeo-Johnson transformation of the given value. +fn yeo_johnson(x: f64, lambda: f64) -> Result { + if x.is_nan() { + return Err(Error::NaNValue); + } + if !lambda.is_finite() { + return Err(Error::InvalidLambda); } -} -pub(crate) trait YeoJohnsonExt: Iterator { - fn yeo_johnson(self, lambda: f64) -> YeoJohnson - where - Self: Sized, - { - YeoJohnson { - inner: self, - lambda, + if x >= 0.0 { + if lambda == 0.0 { + Ok((x + 1.0).ln()) + } else { + Ok(((x + 1.0).powf(lambda) - 1.0) / lambda) } + } else if lambda == 2.0 { + Ok(-(-x + 1.0).ln()) + } else { + Ok(-((-x + 1.0).powf(2.0 - lambda) - 1.0) / (2.0 - lambda)) } } -impl YeoJohnsonExt for T where T: Iterator {} - /// Returns the inverse Yeo-Johnson transformation of the given value. fn inverse_yeo_johnson(y: f64, lambda: f64) -> f64 { const EPSILON: f64 = 1e-6; @@ -319,76 +228,117 @@ fn inverse_yeo_johnson(y: f64, lambda: f64) -> f64 { } } -/// An iterator adapter that applies the inverse Yeo-Johnson transformation to each item. -#[derive(Clone, Debug)] -pub(crate) struct InverseYeoJohnson { - inner: T, - lambda: f64, -} +fn yeo_johnson_log_likelihood(data: &[f64], lambda: f64) -> Result { + let n = data.len() as f64; -impl Iterator for InverseYeoJohnson -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner - .next() - .map(|y| inverse_yeo_johnson(y, self.lambda)) + if n == 0.0 { + return Err(Error::EmptyData); } -} -pub(crate) trait InverseYeoJohnsonExt: Iterator { - fn inverse_yeo_johnson(self, lambda: f64) -> InverseYeoJohnson - where - Self: Sized, - { - InverseYeoJohnson { - inner: self, - lambda, - } + let transformed_data = data + .iter() + .map(|&x| yeo_johnson(x, lambda)) + .collect::, _>>()?; + + let mean = transformed_data.iter().sum::() / n; + + let variance = transformed_data + .iter() + .map(|&x| (x - mean).powi(2)) + .sum::() + / n; + + if variance <= 0.0 { + return Err(Error::VarianceNotPositive); } + + let log_sigma_squared = variance.ln(); + let log_likelihood = -n / 2.0 * log_sigma_squared; + + let additional_term: f64 = data + .iter() + .map(|&x| (x.signum() * (x.abs() + 1.0).ln())) + .sum::() + * (lambda - 1.0); + + Ok(log_likelihood + additional_term) +} + +#[derive(Clone)] +struct YeoJohnsonProblem<'a> { + data: &'a [f64], } -impl InverseYeoJohnsonExt for T where T: Iterator {} +impl CostFunction for YeoJohnsonProblem<'_> { + type Param = f64; + type Output = f64; -/// A trait for types that can be used as the `lambda` parameter for the -/// `Transform::box_cox` method. -pub trait IntoBoxCoxLambda { - fn into_box_cox_lambda(self) -> Result; + // The goal is to minimize the negative log-likelihood + fn cost(&self, lambda: &Self::Param) -> Result { + Ok(yeo_johnson_log_likelihood(self.data, *lambda).map(|ll| -ll)?) + } +} + +/// A transformer that applies the Yeo-Johnson transformation to each item. +/// +/// The Yeo-Johnson transformation is a generalization of the Box-Cox transformation that +/// supports negative values. It is defined as: +/// +/// - if lambda != 0 and x >= 0: ((x + 1)^lambda - 1) / lambda +/// - if lambda == 0 and x >= 0: (x + 1).ln() +/// - if lambda != 2 and x < 0: ((-x + 1)^2 - 1) / 2 +/// - if lambda == 2 and x < 0: (-x + 1).ln() +/// +/// By default the optimal `lambda` parameter is found from the data in +/// `transform` using maximum likelihood estimation. If you want to use a +/// specific `lambda` value, you can use the `with_lambda` method. +#[derive(Clone, Debug)] +pub struct YeoJohnson { + lambda: f64, } -impl IntoBoxCoxLambda for f64 { - /// Use the given lambda parameter. - fn into_box_cox_lambda(self) -> Result { - Ok(self) +impl YeoJohnson { + /// Create a new `YeoJohnson` transformer. + pub fn new() -> Self { + Self { lambda: f64::NAN } } -} -impl IntoBoxCoxLambda for &[f64] { - /// Find the optimal Box-Cox lambda parameter using maximum likelihood estimation. - fn into_box_cox_lambda(self) -> Result { - optimize_box_cox_lambda(self) + /// Set the `lambda` parameter for the Yeo-Johnson transformation. + /// + /// # Errors + /// + /// This function returns an error if the `lambda` parameter is NaN. + pub fn with_lambda(mut self, lambda: f64) -> Result { + if !lambda.is_finite() { + return Err(Error::InvalidLambda); + } + self.lambda = lambda; + Ok(self) } } -/// A trait for types that can be used as the `lambda` parameter for the -/// `Transform::box_cox` method. -pub trait IntoYeoJohnsonLambda { - fn into_yeo_johnson_lambda(self) -> Result; +impl Default for YeoJohnson { + fn default() -> Self { + Self::new() + } } -impl IntoYeoJohnsonLambda for f64 { - /// Use the given lambda parameter. - fn into_yeo_johnson_lambda(self) -> Result { - Ok(self) +impl Transform for YeoJohnson { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + if self.lambda.is_nan() { + self.lambda = optimize_yeo_johnson_lambda(data)?; + } + for x in data.iter_mut() { + *x = yeo_johnson(*x, self.lambda)?; + } + Ok(()) } -} -impl IntoYeoJohnsonLambda for &[f64] { - /// Find the optimal Yeo-Johnson lambda parameter using maximum likelihood estimation. - fn into_yeo_johnson_lambda(self) -> Result { - optimize_yeo_johnson_lambda(self) + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + for x in data.iter_mut() { + *x = inverse_yeo_johnson(*x, self.lambda); + } + Ok(()) } } @@ -459,37 +409,41 @@ mod test { #[test] fn box_cox_test() { - let data = vec![1.0, 2.0, 3.0]; + let mut data = vec![1.0, 2.0, 3.0]; let lambda = 0.5; + let mut box_cox = BoxCox::new().with_lambda(lambda).unwrap(); let expected = vec![0.0, 0.8284271247461903, 1.4641016151377544]; - let actual: Vec<_> = data.into_iter().box_cox(lambda).collect(); - assert_all_close(&expected, &actual); + box_cox.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn inverse_box_cox_test() { - let data = vec![0.0, 0.5_f64.ln(), 1.0_f64.ln()]; + let mut data = vec![0.0, 0.5_f64.ln(), 1.0_f64.ln()]; let lambda = 0.5; + let box_cox = BoxCox::new().with_lambda(lambda).unwrap(); let expected = vec![1.0, 0.426966072919605, 1.0]; - let actual: Vec<_> = data.into_iter().inverse_box_cox(lambda).collect(); - assert_all_close(&expected, &actual); + box_cox.inverse_transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn yeo_johnson_test() { - let data = vec![-1.0, 0.0, 1.0]; + let mut data = vec![-1.0, 0.0, 1.0]; let lambda = 0.5; + let mut yeo_johnson = YeoJohnson::new().with_lambda(lambda).unwrap(); let expected = vec![-1.2189514164974602, 0.0, 0.8284271247461903]; - let actual: Vec<_> = data.into_iter().yeo_johnson(lambda).collect(); - assert_all_close(&expected, &actual); + yeo_johnson.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn inverse_yeo_johnson_test() { - let data = vec![-1.2189514164974602, 0.0, 0.8284271247461903]; + let mut data = vec![-1.2189514164974602, 0.0, 0.8284271247461903]; let lambda = 0.5; + let yeo_johnson = YeoJohnson::new().with_lambda(lambda).unwrap(); let expected = vec![-1.0, 0.0, 1.0]; - let actual: Vec<_> = data.into_iter().inverse_yeo_johnson(lambda).collect(); - assert_all_close(&expected, &actual); + yeo_johnson.inverse_transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } } diff --git a/crates/augurs-forecaster/src/transforms/scale.rs b/crates/augurs-forecaster/src/transforms/scale.rs index 6681f991..6c697735 100644 --- a/crates/augurs-forecaster/src/transforms/scale.rs +++ b/crates/augurs-forecaster/src/transforms/scale.rs @@ -1,130 +1,136 @@ //! Scalers, including min-max and standard scalers. -/// Parameters for the min-max scaler. -/// -/// The target range is [0, 1] by default. Use [`MinMaxScaleParams::with_scaled_range`] -/// to set a custom range. -#[derive(Debug, Clone)] -pub struct MinMaxScaleParams { - data_min: f64, - data_max: f64, - scaled_min: f64, - scaled_max: f64, +use core::f64; + +use itertools::{Itertools, MinMaxResult}; + +use super::{Error, Transform}; + +/// Helper struct holding the min and max for use in a `MinMaxScaler`. +#[derive(Debug, Clone, Copy)] +struct MinMax { + min: f64, + max: f64, } -impl MinMaxScaleParams { - /// Create a new `MinMaxScaleParams` with the given data min and max. - /// - /// The scaled range is set to [0, 1] by default. - pub fn new(data_min: f64, data_max: f64) -> Self { +impl MinMax { + fn zero_one() -> Self { Self { - data_min, - data_max, - scaled_min: 0.0 + f64::EPSILON, - scaled_max: 1.0 - f64::EPSILON, + min: 0.0 + f64::EPSILON, + max: 1.0 - f64::EPSILON, } } - - /// Set the scaled range for the transformation. - pub fn with_scaled_range(mut self, min: f64, max: f64) -> Self { - self.scaled_min = min; - self.scaled_max = max; - self - } - - /// Create a new `MinMaxScaleParams` from the given data. - pub fn from_data(data: T) -> Self - where - T: Iterator, - { - let (min, max) = data.fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), x| { - (min.min(x), max.max(x)) - }); - Self::new(min, max) - } } -/// Iterator adapter that scales each item to the range [0, 1]. +/// Parameters for the min-max scaler. +/// +/// Will be created by the `MinMaxScaler` when it is fit to the data, +/// or when it is supplied with a custom data range. +/// +/// We store the scale factor and offset to avoid having to +/// recalculating them every time the transform is applied. +/// +/// We store the input scale as well so we can recalculate the +/// scale factor and offset if the user changes the output scale. #[derive(Debug, Clone)] -pub(crate) struct MinMaxScale { - inner: T, +struct FittedMinMaxScalerParams { + input_scale: MinMax, scale_factor: f64, offset: f64, } -impl Iterator for MinMaxScale -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - let Self { - scale_factor, - offset, - inner, - .. - } = self; - inner.next().map(|x| *offset + (x * *scale_factor)) - } -} - -pub(crate) trait MinMaxScaleExt: Iterator { - fn min_max_scale(self, params: &MinMaxScaleParams) -> MinMaxScale - where - Self: Sized, - { +impl FittedMinMaxScalerParams { + fn new(input_scale: MinMax, output_scale: MinMax) -> Self { let scale_factor = - (params.scaled_max - params.scaled_min) / (params.data_max - params.data_min); - let offset = params.scaled_min - (params.data_min * scale_factor); - MinMaxScale { - inner: self, + (output_scale.max - output_scale.min) / (input_scale.max - input_scale.min); + Self { + input_scale, scale_factor, - offset, + offset: output_scale.min - (input_scale.min * scale_factor), } } } -impl MinMaxScaleExt for T where T: Iterator {} - -pub(crate) struct InverseMinMaxScale { - inner: T, - scale_factor: f64, - offset: f64, +/// A transformer that scales each item to a custom range, defaulting to [0, 1]. +#[derive(Debug, Clone)] +pub struct MinMaxScaler { + output_scale: MinMax, + // The parameters learned from the data and used to transform it. + // Not known until the transform method is called. + params: Option, } -impl Iterator for InverseMinMaxScale -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - let Self { - inner, - scale_factor, - offset, - .. - } = self; - inner.next().map(|x| *offset + (x * *scale_factor)) +impl Default for MinMaxScaler { + fn default() -> Self { + Self::new() } } -pub(crate) trait InverseMinMaxScaleExt: Iterator { - fn inverse_min_max_scale(self, params: &MinMaxScaleParams) -> InverseMinMaxScale - where - Self: Sized, - { - let scale_factor = - (params.data_max - params.data_min) / (params.scaled_max - params.scaled_min); - let offset = params.data_min - (params.scaled_min * scale_factor); - InverseMinMaxScale { - inner: self, - scale_factor, - offset, +impl MinMaxScaler { + /// Create a new `MinMaxScaler` with the default output range of [0, 1]. + pub fn new() -> Self { + Self { + output_scale: MinMax::zero_one(), + params: None, + } + } + + /// Set the output range for the transformation. + pub fn with_scaled_range(mut self, min: f64, max: f64) -> Self { + self.output_scale = MinMax { min, max }; + self.params.iter_mut().for_each(|p| { + let input_scale = p.input_scale; + *p = FittedMinMaxScalerParams::new(input_scale, self.output_scale); + }); + self + } + + /// Manually set the input range for the transformation. + /// + /// This is useful if you know the input range in advance and want to avoid + /// the overhead of fitting the scaler to the data during the initial transform, + /// and instead want to set the input range manually. + /// + /// Note that this will override any previously set (or learned) parameters. + pub fn with_data_range(mut self, min: f64, max: f64) -> Self { + let data_range = MinMax { min, max }; + self.params = Some(FittedMinMaxScalerParams::new(data_range, self.output_scale)); + self + } + + fn fit(&self, data: &[f64]) -> Result { + match data + .iter() + .copied() + .minmax_by(|a, b| a.partial_cmp(b).unwrap()) + { + e @ MinMaxResult::NoElements | e @ MinMaxResult::OneElement(_) => Err(e.into()), + MinMaxResult::MinMax(min, max) => Ok(FittedMinMaxScalerParams::new( + MinMax { min, max }, + self.output_scale, + )), } } } -impl InverseMinMaxScaleExt for T where T: Iterator {} +impl Transform for MinMaxScaler { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + let params = match &mut self.params { + Some(p) => p, + None => self.params.get_or_insert(self.fit(data)?), + }; + data.iter_mut() + .for_each(|x| *x = *x * params.scale_factor + params.offset); + Ok(()) + } + + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + let params = self.params.as_ref().ok_or(Error::NotFitted)?; + data.iter_mut() + .for_each(|x| *x = (*x - params.offset) / params.scale_factor); + Ok(()) + } +} /// Parameters for the standard scaler. #[derive(Debug, Clone)] @@ -178,74 +184,84 @@ impl StandardScaleParams { } } -/// Iterator adapter that scales each item using the given mean and standard deviation, -/// so that (assuming the adapter was created using the same data), the output items -/// have zero mean and unit standard deviation. -#[derive(Debug, Clone)] -pub(crate) struct StandardScale { - inner: T, - mean: f64, - std_dev: f64, +/// A transformer that scales items to have zero mean and unit standard deviation. +/// +/// The standard score of a sample `x` is calculated as: +/// +/// ```text +/// z = (x - mean) / std_dev +/// ``` +/// +/// where `mean` is the mean and s is the standard deviation of the data first passed to +/// `transform` (or provided via `with_parameters`). +/// +/// # Implementation +/// +/// This transformer uses Welford's online algorithm to compute mean and variance in +/// one pass over the data. The standard deviation is calculated using the biased +/// estimator, for parity with the [scikit-learn implementation][sklearn]. +/// +/// [sklearn]: https://github.com/scikit-learn/scikit-learn/blob/main/sklearn/preprocessing/_data.py#L128 +/// +/// # Example +/// +/// ## Using the default constructor +/// +/// ``` +/// use augurs_forecaster::transforms::{StandardScaler, Transform}; +/// +/// let mut data = vec![1.0, 2.0, 3.0]; +/// let mut scaler = StandardScaler::new(); +/// scaler.transform(&mut data); +/// +/// assert_eq!(data, vec![-1.224744871391589, 0.0, 1.224744871391589]); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct StandardScaler { + params: Option, } -impl Iterator for StandardScale -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(|x| (x - self.mean) / self.std_dev) +impl StandardScaler { + /// Create a new `StandardScaler`. + pub fn new() -> Self { + Self::default() } -} -pub(crate) trait StandardScaleExt: Iterator { - fn standard_scale(self, params: &StandardScaleParams) -> StandardScale - where - Self: Sized, - { - StandardScale { - inner: self, - mean: params.mean, - std_dev: params.std_dev, - } + /// Set the parameters for the scaler. + /// + /// This is useful if you know the mean and standard deviation in advance + /// and want to avoid the overhead of fitting the scaler to the data + /// during the initial transform, and instead want to set the parameters + /// manually. + pub fn with_parameters(mut self, params: StandardScaleParams) -> Self { + self.params = Some(params); + self } -} -impl StandardScaleExt for T where T: Iterator {} - -/// Iterator adapter that applies the inverse standard scaling transformation. -#[derive(Debug, Clone)] -pub(crate) struct InverseStandardScale { - inner: T, - mean: f64, - std_dev: f64, + fn fit(&self, data: &[f64]) -> StandardScaleParams { + StandardScaleParams::from_data(data.iter().copied()) + } } -impl Iterator for InverseStandardScale -where - T: Iterator, -{ - type Item = f64; - fn next(&mut self) -> Option { - self.inner.next().map(|x| (x * self.std_dev) + self.mean) +impl Transform for StandardScaler { + fn transform(&mut self, data: &mut [f64]) -> Result<(), Error> { + let params = match &mut self.params { + Some(p) => p, + None => self.params.get_or_insert(self.fit(data)), + }; + data.iter_mut() + .for_each(|x| *x = (*x - params.mean) / params.std_dev); + Ok(()) } -} -pub(crate) trait InverseStandardScaleExt: Iterator { - fn inverse_standard_scale(self, params: &StandardScaleParams) -> InverseStandardScale - where - Self: Sized, - { - InverseStandardScale { - inner: self, - mean: params.mean, - std_dev: params.std_dev, - } + fn inverse_transform(&self, data: &mut [f64]) -> Result<(), Error> { + let params = self.params.as_ref().ok_or(Error::NotFitted)?; + data.iter_mut() + .for_each(|x| *x = (*x * params.std_dev) + params.mean); + Ok(()) } } -impl InverseStandardScaleExt for T where T: Iterator {} - #[cfg(test)] mod test { use augurs_testing::{assert_all_close, assert_approx_eq}; @@ -254,72 +270,71 @@ mod test { #[test] fn min_max_scale() { - let data = vec![1.0, 2.0, 3.0]; - let min = 1.0; - let max = 3.0; + let mut data = vec![1.0, 2.0, 3.0]; let expected = vec![0.0, 0.5, 1.0]; - let actual: Vec<_> = data - .into_iter() - .min_max_scale(&MinMaxScaleParams::new(min, max)) - .collect(); - assert_all_close(&expected, &actual); + let mut scaler = MinMaxScaler::new(); + scaler.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn min_max_scale_custom() { - let data = vec![1.0, 2.0, 3.0]; - let min = 1.0; - let max = 3.0; + let mut data = vec![1.0, 2.0, 3.0]; let expected = vec![0.0, 5.0, 10.0]; - let actual: Vec<_> = data - .into_iter() - .min_max_scale(&MinMaxScaleParams::new(min, max).with_scaled_range(0.0, 10.0)) - .collect(); - assert_all_close(&expected, &actual); + let mut scaler = MinMaxScaler::new().with_scaled_range(0.0, 10.0); + scaler.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn inverse_min_max_scale() { - let data = vec![0.0, 0.5, 1.0]; - let min = 1.0; - let max = 3.0; + let mut data = vec![0.0, 0.5, 1.0]; let expected = vec![1.0, 2.0, 3.0]; - let actual: Vec<_> = data - .into_iter() - .inverse_min_max_scale(&MinMaxScaleParams::new(min, max)) - .collect(); - assert_all_close(&expected, &actual); + let scaler = MinMaxScaler::new().with_data_range(1.0, 3.0); + scaler.inverse_transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn inverse_min_max_scale_custom() { - let data = vec![0.0, 5.0, 10.0]; - let min = 1.0; - let max = 3.0; + let mut data = vec![0.0, 5.0, 10.0]; let expected = vec![1.0, 2.0, 3.0]; - let actual: Vec<_> = data - .into_iter() - .inverse_min_max_scale(&MinMaxScaleParams::new(min, max).with_scaled_range(0.0, 10.0)) - .collect(); - assert_all_close(&expected, &actual); + let scaler = MinMaxScaler::new() + .with_scaled_range(0.0, 10.0) + .with_data_range(1.0, 3.0); + scaler.inverse_transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn standard_scale() { - let data = vec![1.0, 2.0, 3.0]; - let params = StandardScaleParams::new(2.0, 1.0); // mean=2, std=1 + let mut data = vec![1.0, 2.0, 3.0]; + // We use the biased estimator for standard deviation so the result is + // not necessarily obvious. + let expected = vec![-1.224744871391589, 0.0, 1.224744871391589]; + let mut scaler = StandardScaler::new(); // 2.0, 1.0); // mean=2, std=1 + scaler.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); + } + + #[test] + fn standard_scale_custom() { + let mut data = vec![1.0, 2.0, 3.0]; let expected = vec![-1.0, 0.0, 1.0]; - let actual: Vec<_> = data.into_iter().standard_scale(¶ms).collect(); - assert_all_close(&expected, &actual); + let params = StandardScaleParams::new(2.0, 1.0); // mean=2, std=1 + let mut scaler = StandardScaler::new().with_parameters(params); + scaler.transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] fn inverse_standard_scale() { - let data = vec![-1.0, 0.0, 1.0]; - let params = StandardScaleParams::new(2.0, 1.0); // mean=2, std=1 + let mut data = vec![-1.0, 0.0, 1.0]; let expected = vec![1.0, 2.0, 3.0]; - let actual: Vec<_> = data.into_iter().inverse_standard_scale(¶ms).collect(); - assert_all_close(&expected, &actual); + let params = StandardScaleParams::new(2.0, 1.0); // mean=2, std=1 + let scaler = StandardScaler::new().with_parameters(params); + scaler.inverse_transform(&mut data).unwrap(); + assert_all_close(&expected, &data); } #[test] @@ -348,14 +363,4 @@ mod test { assert_approx_eq!(params.mean, 42.0); assert_approx_eq!(params.std_dev, 0.0); // technically undefined, but we return 0 } - - #[test] - fn min_max_scale_params_from_data() { - let data = [1.0, 2.0, f64::NAN, 3.0]; - let params = MinMaxScaleParams::from_data(data.iter().copied()); - assert_approx_eq!(params.data_min, 1.0); - assert_approx_eq!(params.data_max, 3.0); - assert_approx_eq!(params.scaled_min, 0.0); - assert_approx_eq!(params.scaled_max, 1.0); - } } diff --git a/crates/augurs/tests/integration.rs b/crates/augurs/tests/integration.rs index e77f6541..c17237fc 100644 --- a/crates/augurs/tests/integration.rs +++ b/crates/augurs/tests/integration.rs @@ -102,23 +102,16 @@ fn test_ets() { #[test] fn test_forecaster() { use augurs::{ - forecaster::{transforms::MinMaxScaleParams, Forecaster, Transform}, + forecaster::{transforms::MinMaxScaler, Forecaster, Transform}, mstl::{MSTLModel, NaiveTrend}, }; + use augurs_forecaster::transforms::{LinearInterpolator, Logit}; use augurs_testing::{assert_all_close, data::AIR_PASSENGERS}; - use itertools::{Itertools, MinMaxResult}; - let MinMaxResult::MinMax(min, max) = AIR_PASSENGERS - .iter() - .copied() - .minmax_by(|a, b| a.partial_cmp(b).unwrap()) - else { - unreachable!() - }; let transforms = vec![ - Transform::linear_interpolator(), - Transform::min_max_scaler(MinMaxScaleParams::new(min - 1e-3, max + 1e-3)), - Transform::logit(), + LinearInterpolator::new().boxed(), + MinMaxScaler::new().boxed(), + Logit::new().boxed(), ]; let model = MSTLModel::new(vec![2], NaiveTrend::new()); let mut forecaster = Forecaster::new(model).with_transforms(transforms); diff --git a/examples/forecasting/examples/forecaster.rs b/examples/forecasting/examples/forecaster.rs index 70f3efb4..33891b47 100644 --- a/examples/forecasting/examples/forecaster.rs +++ b/examples/forecasting/examples/forecaster.rs @@ -7,7 +7,10 @@ use augurs::{ ets::AutoETS, - forecaster::{transforms::MinMaxScaleParams, Forecaster, Transform}, + forecaster::{ + transforms::{LinearInterpolator, Log, MinMaxScaler}, + Forecaster, Transform, + }, mstl::MSTLModel, }; @@ -40,9 +43,9 @@ fn main() { // These are just illustrative examples; you can use whatever transforms // you want. let transforms = vec![ - Transform::linear_interpolator(), - Transform::min_max_scaler(MinMaxScaleParams::from_data(DATA.iter().copied())), - Transform::log(), + LinearInterpolator::new().boxed(), + MinMaxScaler::new().boxed(), + Log::new().boxed(), ]; // Create a forecaster using the transforms. diff --git a/examples/forecasting/examples/prophet_forecaster.rs b/examples/forecasting/examples/prophet_forecaster.rs index 5d0b826b..5fca5bd3 100644 --- a/examples/forecasting/examples/prophet_forecaster.rs +++ b/examples/forecasting/examples/prophet_forecaster.rs @@ -1,7 +1,7 @@ //! Example of using the Prophet model with the wasmstan optimizer. use augurs::{ - forecaster::{transforms::MinMaxScaleParams, Forecaster, Transform}, + forecaster::{transforms::MinMaxScaler, Forecaster, Transform}, prophet::{wasmstan::WasmstanOptimizer, Prophet, TrainingData}, }; @@ -21,9 +21,7 @@ fn main() -> Result<(), Box> { // Set up the transforms. // These are just illustrative examples; you can use whatever transforms // you want. - let transforms = vec![Transform::min_max_scaler(MinMaxScaleParams::from_data( - y.iter().copied(), - ))]; + let transforms = vec![MinMaxScaler::new().boxed()]; // Set up the model. Create the Prophet model as normal, then convert it to a // `ProphetForecaster`. diff --git a/js/augurs-mstl-js/src/lib.rs b/js/augurs-mstl-js/src/lib.rs index 59590f6f..b865e7fe 100644 --- a/js/augurs-mstl-js/src/lib.rs +++ b/js/augurs-mstl-js/src/lib.rs @@ -5,7 +5,10 @@ use tsify_next::Tsify; use wasm_bindgen::prelude::*; use augurs_ets::{trend::AutoETSTrendModel, AutoETS}; -use augurs_forecaster::{Forecaster, Transform}; +use augurs_forecaster::{ + transforms::{LinearInterpolator, Logit}, + Forecaster, Transform, +}; use augurs_mstl::{MSTLModel, TrendModel}; use augurs_core_js::{Forecast, VecF64, VecUsize}; @@ -101,13 +104,13 @@ pub struct ETSOptions { } impl ETSOptions { - fn into_transforms(self) -> Vec { + fn into_transforms(self) -> Vec> { let mut transforms = vec![]; if self.impute.unwrap_or_default() { - transforms.push(Transform::linear_interpolator()); + transforms.push(LinearInterpolator::new().boxed()) } if self.logit_transform.unwrap_or_default() { - transforms.push(Transform::logit()); + transforms.push(Logit::new().boxed()); } transforms } diff --git a/js/augurs-transforms-js/src/lib.rs b/js/augurs-transforms-js/src/lib.rs index 1dde91ba..5cc58e2d 100644 --- a/js/augurs-transforms-js/src/lib.rs +++ b/js/augurs-transforms-js/src/lib.rs @@ -1,143 +1,104 @@ //! JavaScript bindings for augurs transformations, such as power transforms, scaling, etc. -use std::cell::RefCell; +// TODO: rewrite all of this. We can just expose a simple enum of available transforms +// and a `Pipeline` struct which is a simpler wrapper of `augurs_forecaster::Pipeline`. -use serde::{Deserialize, Serialize}; -use tsify_next::Tsify; -use wasm_bindgen::prelude::*; +// use std::cell::RefCell; -use augurs_core_js::VecF64; -use augurs_forecaster::transforms::{StandardScaleParams, Transform}; +// use serde::{Deserialize, Serialize}; +// use tsify_next::Tsify; +// use wasm_bindgen::prelude::*; -/// The algorithm used by a power transform. -#[derive(Debug, Serialize, Tsify)] -#[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] -pub enum PowerTransformAlgorithm { - /// The Box-Cox transform. - BoxCox, - /// The Yeo-Johnson transform. - YeoJohnson, -} +// use augurs_core_js::VecF64; +// use augurs_forecaster::transforms::{self, Transform}; -/// A power transform. -/// -/// This transform applies the power function to each item. -/// -/// If all values are positive, it will use the Box-Cox transform. -/// If any values are negative or zero, it will use the Yeo-Johnson transform. -/// -/// The optimal value of the `lambda` parameter is calculated from the data -/// using maximum likelihood estimation. -/// -/// @experimental -#[derive(Debug)] -#[wasm_bindgen] -pub struct PowerTransform { - inner: Transform, - standardize: bool, - scale_params: RefCell>, -} +// /// The Yeo-Johnson transform. +// /// +// /// This transform applies the Yeo-Johnson transformation to each item. +// /// +// /// The optimal value of the `lambda` parameter is calculated from the data +// /// using maximum likelihood estimation. +// /// +// /// @experimental +// #[derive(Debug)] +// #[wasm_bindgen] +// pub struct YeoJohnson { +// inner: transforms::YeoJohnson, +// } -#[wasm_bindgen] -impl PowerTransform { - /// Create a new power transform for the given data. - /// - /// @experimental - #[wasm_bindgen(constructor)] - pub fn new(opts: PowerTransformOptions) -> Result { - Ok(PowerTransform { - inner: Transform::power_transform(&opts.data) - .map_err(|e| JsError::new(&e.to_string()))?, - standardize: opts.standardize, - scale_params: RefCell::new(None), - }) - } +// #[wasm_bindgen] +// impl YeoJohnson { +// /// Create a new power transform for the given data. +// /// +// /// @experimental +// #[wasm_bindgen(constructor)] +// pub fn new() -> Self { +// Self { +// inner: transforms::YeoJohnson::new(), +// } +// } - /// Transform the given data. - /// - /// The transformed data is then scaled using a standard scaler (unless - /// `standardize` was set to `false` in the constructor). - /// - /// @experimental - #[wasm_bindgen] - pub fn transform(&self, data: VecF64) -> Result, JsError> { - let transformed: Vec<_> = self - .inner - .transform(data.convert()?.iter().copied()) - .collect(); - if !self.standardize { - Ok(transformed) - } else { - let scale_params = StandardScaleParams::from_data(transformed.iter().copied()); - let scaler = Transform::standard_scaler(scale_params.clone()); - self.scale_params.replace(Some(scale_params)); - Ok(scaler.transform(transformed.iter().copied()).collect()) - } - } +// /// Transform the given data. +// /// +// /// The transformed data is then scaled using a standard scaler (unless +// /// `standardize` was set to `false` in the constructor). +// /// +// /// @experimental +// #[wasm_bindgen] +// pub fn transform(&self, data: VecF64) -> Result, JsError> { +// let data = data.convert()?; +// self.inner.transform(&mut data)?; +// if !self.standardize { +// Ok(data) +// } else { +// let scale_params = StandardScaleParams::from_data(transformed.iter().copied()); +// let scaler = Transform::standard_scaler(scale_params.clone()); +// self.scale_params.replace(Some(scale_params)); +// Ok(scaler.transform(transformed.iter().copied()).collect()) +// } +// } - /// Inverse transform the given data. - /// - /// The data is first scaled back to the original scale using the standard scaler - /// (unless `standardize` was set to `false` in the constructor), then the - /// inverse power transform is applied. - /// - /// @experimental - #[wasm_bindgen(js_name = "inverseTransform")] - pub fn inverse_transform(&self, data: VecF64) -> Result, JsError> { - match (self.standardize, self.scale_params.borrow().as_ref()) { - (true, Some(scale_params)) => { - let inverse_scaler = Transform::standard_scaler(scale_params.clone()); - let data = data.convert()?; - let scaled = inverse_scaler.inverse_transform(data.iter().copied()); - Ok(self.inner.inverse_transform(scaled).collect()) - } - _ => Ok(self - .inner - .inverse_transform(data.convert()?.iter().copied()) - .collect()), - } - } +// /// Inverse transform the given data. +// /// +// /// The data is first scaled back to the original scale using the standard scaler +// /// (unless `standardize` was set to `false` in the constructor), then the +// /// inverse power transform is applied. +// /// +// /// @experimental +// #[wasm_bindgen(js_name = "inverseTransform")] +// pub fn inverse_transform(&self, data: VecF64) -> Result, JsError> { +// match (self.standardize, self.scale_params.borrow().as_ref()) { +// (true, Some(scale_params)) => { +// let inverse_scaler = Transform::standard_scaler(scale_params.clone()); +// let data = data.convert()?; +// let scaled = inverse_scaler.inverse_transform(data.iter().copied()); +// Ok(self.inner.inverse_transform(scaled).collect()) +// } +// _ => Ok(self +// .inner +// .inverse_transform(data.convert()?.iter().copied()) +// .collect()), +// } +// } - /// Get the algorithm used by the power transform. - /// - /// @experimental - pub fn algorithm(&self) -> PowerTransformAlgorithm { - match self.inner { - Transform::BoxCox { .. } => PowerTransformAlgorithm::BoxCox, - Transform::YeoJohnson { .. } => PowerTransformAlgorithm::YeoJohnson, - _ => unreachable!(), - } - } +// /// Get the algorithm used by the power transform. +// /// +// /// @experimental +// pub fn algorithm(&self) -> PowerTransformAlgorithm { +// match self.inner { +// Transform::BoxCox { .. } => PowerTransformAlgorithm::BoxCox, +// Transform::YeoJohnson { .. } => PowerTransformAlgorithm::YeoJohnson, +// _ => unreachable!(), +// } +// } - /// Retrieve the `lambda` parameter used to transform the data. - /// - /// @experimental - pub fn lambda(&self) -> f64 { - match self.inner { - Transform::BoxCox { lambda, .. } | Transform::YeoJohnson { lambda, .. } => lambda, - _ => unreachable!(), - } - } -} - -fn default_standardize() -> bool { - true -} - -/// Options for the power transform. -#[derive(Debug, Default, Deserialize, Tsify)] -#[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] -pub struct PowerTransformOptions { - /// The data to transform. This is used to calculate the optimal value of 'lambda'. - #[tsify(type = "number[] | Float64Array")] - pub data: Vec, - - /// Whether to standardize the data after applying the power transform. - /// - /// This is generally recommended, and defaults to `true`. - #[serde(default = "default_standardize")] - #[tsify(optional)] - pub standardize: bool, -} +// /// Retrieve the `lambda` parameter used to transform the data. +// /// +// /// @experimental +// pub fn lambda(&self) -> f64 { +// match self.inner { +// Transform::BoxCox { lambda, .. } | Transform::YeoJohnson { lambda, .. } => lambda, +// _ => unreachable!(), +// } +// } +// }