use ferrolearn_core::error::FerroError;
use ferrolearn_core::pipeline::{FittedPipelineTransformer, PipelineTransformer};
use ferrolearn_core::traits::Transform;
use ndarray::{Array1, Array2};
use num_traits::Float;
#[derive(Debug, Clone)]
pub struct PolynomialFeatures<F> {
pub(crate) degree: usize,
pub(crate) interaction_only: bool,
pub(crate) include_bias: bool,
_marker: std::marker::PhantomData<F>,
}
impl<F: Float + Send + Sync + 'static> PolynomialFeatures<F> {
pub fn new(
degree: usize,
interaction_only: bool,
include_bias: bool,
) -> Result<Self, FerroError> {
if degree == 0 {
return Err(FerroError::InvalidParameter {
name: "degree".into(),
reason: "degree must be at least 1".into(),
});
}
Ok(Self {
degree,
interaction_only,
include_bias,
_marker: std::marker::PhantomData,
})
}
#[must_use]
pub fn default_config() -> Self {
Self {
degree: 2,
interaction_only: false,
include_bias: true,
_marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn degree(&self) -> usize {
self.degree
}
#[must_use]
pub fn interaction_only(&self) -> bool {
self.interaction_only
}
#[must_use]
pub fn include_bias(&self) -> bool {
self.include_bias
}
fn feature_combinations(&self, n_features: usize) -> Vec<Vec<usize>> {
let mut combos: Vec<Vec<usize>> = Vec::new();
if self.include_bias {
combos.push(vec![]);
}
let mut stack: Vec<(Vec<usize>, usize)> = Vec::new();
for i in 0..n_features {
stack.push((vec![i], i));
}
while let Some((combo, last_idx)) = stack.pop() {
combos.push(combo.clone());
if combo.len() < self.degree {
let start = if self.interaction_only {
last_idx + 1
} else {
last_idx
};
for i in start..n_features {
let mut new_combo = combo.clone();
new_combo.push(i);
stack.push((new_combo, i));
}
}
}
combos.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
combos
}
}
impl<F: Float + Send + Sync + 'static> Default for PolynomialFeatures<F> {
fn default() -> Self {
Self::default_config()
}
}
impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for PolynomialFeatures<F> {
type Output = Array2<F>;
type Error = FerroError;
fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
let n_samples = x.nrows();
let n_features = x.ncols();
if n_features == 0 {
return Err(FerroError::InvalidParameter {
name: "x".into(),
reason: "input must have at least one column".into(),
});
}
let combos = self.feature_combinations(n_features);
let n_out = combos.len();
let mut out = Array2::zeros((n_samples, n_out));
for (k, combo) in combos.iter().enumerate() {
for i in 0..n_samples {
let val = combo.iter().fold(F::one(), |acc, &j| acc * x[[i, j]]);
out[[i, k]] = val;
}
}
Ok(out)
}
}
impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for PolynomialFeatures<F> {
fn fit_pipeline(
&self,
_x: &Array2<F>,
_y: &Array1<F>,
) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
Ok(Box::new(self.clone()))
}
}
impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for PolynomialFeatures<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_degree2_two_features_with_bias() {
let poly = PolynomialFeatures::<f64>::new(2, false, true).unwrap();
let x = array![[2.0, 3.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[0], 1);
assert_eq!(out.shape()[1], 6); assert_abs_diff_eq!(out[[0, 0]], 1.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 1]], 2.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 2]], 3.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 3]], 4.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 4]], 6.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 5]], 9.0, epsilon = 1e-10); }
#[test]
fn test_degree2_interaction_only() {
let poly = PolynomialFeatures::<f64>::new(2, true, true).unwrap();
let x = array![[2.0, 3.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[1], 4);
assert_abs_diff_eq!(out[[0, 0]], 1.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 1]], 2.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 2]], 3.0, epsilon = 1e-10); assert_abs_diff_eq!(out[[0, 3]], 6.0, epsilon = 1e-10); }
#[test]
fn test_no_bias() {
let poly = PolynomialFeatures::<f64>::new(2, false, false).unwrap();
let x = array![[2.0, 3.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[1], 5);
assert_abs_diff_eq!(out[[0, 0]], 2.0, epsilon = 1e-10); }
#[test]
fn test_degree1_only_linear() {
let poly = PolynomialFeatures::<f64>::new(1, false, true).unwrap();
let x = array![[2.0, 3.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[1], 3);
assert_abs_diff_eq!(out[[0, 0]], 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 1]], 2.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 2]], 3.0, epsilon = 1e-10);
}
#[test]
fn test_multiple_rows() {
let poly = PolynomialFeatures::<f64>::new(2, false, false).unwrap();
let x = array![[1.0, 2.0], [3.0, 4.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape(), &[2, 5]);
assert_abs_diff_eq!(out[[0, 0]], 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 1]], 2.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 2]], 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 3]], 2.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 4]], 4.0, epsilon = 1e-10);
}
#[test]
fn test_single_feature_degree2() {
let poly = PolynomialFeatures::<f64>::new(2, false, true).unwrap();
let x = array![[3.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[1], 3);
assert_abs_diff_eq!(out[[0, 0]], 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 1]], 3.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 2]], 9.0, epsilon = 1e-10);
}
#[test]
fn test_invalid_degree_zero() {
assert!(PolynomialFeatures::<f64>::new(0, false, true).is_err());
}
#[test]
fn test_default_config() {
let poly = PolynomialFeatures::<f64>::default();
assert_eq!(poly.degree(), 2);
assert!(!poly.interaction_only());
assert!(poly.include_bias());
}
#[test]
fn test_pipeline_integration() {
use ferrolearn_core::pipeline::PipelineTransformer;
let poly = PolynomialFeatures::<f64>::new(2, false, true).unwrap();
let x = array![[1.0, 2.0], [3.0, 4.0]];
let y = Array1::zeros(2);
let fitted = poly.fit_pipeline(&x, &y).unwrap();
let result = fitted.transform_pipeline(&x).unwrap();
assert_eq!(result.shape(), &[2, 6]);
}
#[test]
fn test_degree3_single_feature() {
let poly = PolynomialFeatures::<f64>::new(3, false, false).unwrap();
let x = array![[2.0]];
let out = poly.transform(&x).unwrap();
assert_eq!(out.shape()[1], 3);
assert_abs_diff_eq!(out[[0, 0]], 2.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 1]], 4.0, epsilon = 1e-10);
assert_abs_diff_eq!(out[[0, 2]], 8.0, epsilon = 1e-10);
}
}