featrs 0.2.0

Feature engineering library for Rust, inspired by scikit-learn
Documentation
//! Missing value imputation.
//!
//! [`SimpleImputer`] replaces missing (`None` / `null`) values with
//! a statistic computed from the non-missing values in each column.

use crate::traits::{Error, Fit, Result, Transform};
use polars::prelude::*;
use std::collections::HashMap;

/// Strategy for computing imputation values.
#[derive(Clone, Copy)]
pub enum Strategy {
    /// Replace missing values with the column mean.
    Mean,
    /// Replace missing values with the column median.
    Median,
    /// Replace missing values with the most frequent value in the column.
    MostFrequent,
    /// Replace missing values with a constant value.
    Constant(f64),
}

/// Impute missing values using basic statistics.
///
/// Only operates on [`Float64`](DataType::Float64) columns.
///
/// # Example
///
/// ```rust
/// use featrs::preprocessing::imputer::SimpleImputer;
/// use featrs::preprocessing::imputer::Strategy;
/// use featrs::traits::{Fit, Transform};
///
/// let mut imp = SimpleImputer::mean();
/// # let df = polars::prelude::DataFrame::new(0usize, vec![]).unwrap();
/// // imp.fit(df.clone(), target)?;
/// // let filled = imp.transform(df)?;
/// ```
pub struct SimpleImputer {
    fitted: bool,
    strategy: Strategy,
    fill_values: Option<HashMap<String, f64>>,
}

impl SimpleImputer {
    /// Create a new `SimpleImputer` with the given strategy.
    pub fn new(strategy: Strategy) -> Self {
        Self {
            fitted: false,
            strategy,
            fill_values: None,
        }
    }

    /// Impute using the column mean.
    pub fn mean() -> Self {
        Self::new(Strategy::Mean)
    }

    /// Impute using the column median.
    pub fn median() -> Self {
        Self::new(Strategy::Median)
    }

    /// Impute using the most frequent (mode) value.
    pub fn most_frequent() -> Self {
        Self::new(Strategy::MostFrequent)
    }

    /// Impute with a constant value.
    pub fn constant(value: f64) -> Self {
        Self::new(Strategy::Constant(value))
    }
}

impl Fit<DataFrame, DataFrame> for SimpleImputer {
    type Output = ();

    fn fit(&mut self, x: DataFrame, _y: DataFrame) -> Result<()> {
        if x.height() == 0 {
            return Err(Error::InvalidInput(
                "SimpleImputer.fit received a DataFrame with 0 rows. \
                 Provide data with at least 1 row."
                    .into(),
            ));
        }
        let mut fill_values = HashMap::new();

        for col in x.columns() {
            let name = col.name().to_string();
            if col.dtype() != &DataType::Float64 {
                continue;
            }
            let ca = col.f64().unwrap();
            let all_vals: Vec<f64> = ca.iter().flatten().collect();
            let has_missing = ca.iter().any(|v| v.is_none());

            if !has_missing {
                continue;
            }

            let fill = match self.strategy {
                Strategy::Mean => {
                    if all_vals.is_empty() {
                        return Err(Error::Computation(format!(
                            "SimpleImputer(Mean): column '{}' has no non-null values. \
                             Cannot compute the mean of an all-null column. \
                             Use Strategy::Constant instead.",
                            name
                        )));
                    }
                    all_vals.iter().sum::<f64>() / all_vals.len() as f64
                }
                Strategy::Median => {
                    let mut sorted = all_vals.clone();
                    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
                    if sorted.is_empty() {
                        return Err(Error::Computation(format!(
                            "SimpleImputer(Median): column '{}' has no non-null values. \
                             Cannot compute the median of an all-null column. \
                             Use Strategy::Constant instead.",
                            name
                        )));
                    }
                    let mid = sorted.len() / 2;
                    if sorted.len().is_multiple_of(2) {
                        (sorted[mid - 1] + sorted[mid]) / 2.0
                    } else {
                        sorted[mid]
                    }
                }
                Strategy::MostFrequent => {
                    if all_vals.is_empty() {
                        return Err(Error::Computation(format!(
                            "SimpleImputer(MostFrequent): column '{}' has no non-null values. \
                             Cannot compute the mode of an all-null column. \
                             Use Strategy::Constant instead.",
                            name
                        )));
                    }
                    let mut freq: HashMap<u64, usize> = HashMap::new();
                    for &v in &all_vals {
                        *freq.entry(v.to_bits()).or_default() += 1;
                    }
                    let (max_key, _) = freq.into_iter().max_by_key(|&(_, c)| c).unwrap();
                    f64::from_bits(max_key)
                }
                Strategy::Constant(v) => v,
            };

            fill_values.insert(name, fill);
        }

        self.fill_values = Some(fill_values);
        self.fitted = true;
        Ok(())
    }
}

impl Transform<DataFrame> for SimpleImputer {
    type Output = DataFrame;

    fn transform(&self, x: DataFrame) -> Result<DataFrame> {
        if !self.fitted {
            return Err(Error::NotFitted(
                "SimpleImputer has not been fitted. \
                 Call .fit(dataframe, target) before .transform()."
                    .into(),
            ));
        }
        let fill_values = self.fill_values.as_ref().unwrap();
        let mut out = x.clone();

        for (name, fill) in fill_values {
            let s = out.column(name.as_str()).map_err(|e| {
                Error::InvalidInput(format!(
                    "SimpleImputer.transform: column '{}' not found. \
                     The imputer was fitted on columns: {:?}. {}",
                    name,
                    fill_values.keys().collect::<Vec<_>>(),
                    e
                ))
            })?;
            let ca = s.f64().map_err(|e| {
                Error::InvalidInput(format!(
                    "SimpleImputer.transform: column '{}' has dtype {}; expected Float64. {}",
                    name,
                    s.dtype(),
                    e
                ))
            })?;
            let filled: ChunkedArray<Float64Type> =
                ca.iter().map(|opt| opt.or(Some(*fill))).collect();
            out.replace(name.as_str(), filled.into_series().into())
                .unwrap();
        }

        Ok(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use approx::assert_relative_eq;

    fn make_test_df() -> DataFrame {
        let a = Column::from(Series::new("x".into(), &[Some(1.0f64), None, Some(3.0)]));
        let b = Column::from(Series::new("y".into(), &[Some(10.0f64), Some(20.0), None]));
        DataFrame::new(3, vec![a, b]).unwrap()
    }

    #[test]
    fn test_imputer_mean() {
        let mut imp = SimpleImputer::mean();
        let df = make_test_df();
        let y = df.clone();

        imp.fit(df.clone(), y).unwrap();
        let result = imp.transform(df).unwrap();

        let x_vals: Vec<f64> = result
            .column("x")
            .unwrap()
            .f64()
            .unwrap()
            .iter()
            .flatten()
            .collect();
        assert_relative_eq!(x_vals[1], 2.0, epsilon = 1e-6);

        let y_vals: Vec<f64> = result
            .column("y")
            .unwrap()
            .f64()
            .unwrap()
            .iter()
            .flatten()
            .collect();
        assert_relative_eq!(y_vals[2], 15.0, epsilon = 1e-6);
    }

    #[test]
    fn test_imputer_constant() {
        let mut imp = SimpleImputer::constant(0.0);
        let df = make_test_df();
        let y = df.clone();

        imp.fit(df.clone(), y).unwrap();
        let result = imp.transform(df).unwrap();

        let x_vals: Vec<f64> = result
            .column("x")
            .unwrap()
            .f64()
            .unwrap()
            .iter()
            .flatten()
            .collect();
        assert_relative_eq!(x_vals[1], 0.0, epsilon = 1e-6);
    }
}