use crate::core::error::{Error, Result};
use crate::dataframe::DataFrame;
use crate::series::Series;
use std::collections::HashMap;
pub trait PipelineTransformer {
fn transform(&self, df: &DataFrame) -> Result<DataFrame>;
fn fit(&mut self, df: &DataFrame) -> Result<()>;
fn fit_transform(&mut self, df: &DataFrame) -> Result<DataFrame> {
self.fit(df)?;
self.transform(df)
}
}
#[derive(Debug)]
pub enum PipelineStage {
StandardScaler {
columns: Option<Vec<String>>,
_means: Option<HashMap<String, f64>>,
_stds: Option<HashMap<String, f64>>,
},
MinMaxScaler {
columns: Option<Vec<String>>,
feature_range: (f64, f64),
_min_values: Option<HashMap<String, f64>>,
_max_values: Option<HashMap<String, f64>>,
},
OneHotEncoder {
columns: Option<Vec<String>>,
drop_first: bool,
prefix: Option<String>,
_categories: Option<HashMap<String, Vec<String>>>,
},
Imputer {
columns: Option<Vec<String>>,
strategy: String,
fill_value: Option<f64>,
_fill_values: Option<HashMap<String, f64>>,
},
FeatureSelector {
columns: Vec<String>,
},
}
impl PipelineTransformer for PipelineStage {
fn transform(&self, df: &DataFrame) -> Result<DataFrame> {
match self {
PipelineStage::StandardScaler { _means, _stds, .. } => {
let means = _means.as_ref().ok_or_else(not_fitted("StandardScaler"))?;
let stds = _stds.as_ref().ok_or_else(not_fitted("StandardScaler"))?;
let mut result = DataFrame::new();
for col_name in df.column_names() {
if let Some(&mean) = means.get(col_name.as_str()) {
let std = stds.get(col_name.as_str()).copied().unwrap_or(1.0);
let values = df.get_column_numeric_values(col_name.as_str())?;
let scaled: Vec<f64> = values
.iter()
.map(|&v| {
if std > 1e-10 {
(v - mean) / std
} else {
v - mean
}
})
.collect();
result.add_column(
col_name.clone(),
Series::new(scaled, Some(col_name.clone()))?,
)?;
} else {
passthrough_column(df, &mut result, col_name.as_str())?;
}
}
Ok(result)
}
PipelineStage::MinMaxScaler {
feature_range,
_min_values,
_max_values,
..
} => {
let mins = _min_values
.as_ref()
.ok_or_else(not_fitted("MinMaxScaler"))?;
let maxs = _max_values
.as_ref()
.ok_or_else(not_fitted("MinMaxScaler"))?;
let (out_min, out_max) = *feature_range;
let mut result = DataFrame::new();
for col_name in df.column_names() {
if let (Some(&min_v), Some(&max_v)) =
(mins.get(col_name.as_str()), maxs.get(col_name.as_str()))
{
let values = df.get_column_numeric_values(col_name.as_str())?;
let span = max_v - min_v;
let scaled: Vec<f64> = values
.iter()
.map(|&v| {
if span.abs() < f64::EPSILON {
out_min
} else {
out_min + (out_max - out_min) * (v - min_v) / span
}
})
.collect();
result.add_column(
col_name.clone(),
Series::new(scaled, Some(col_name.clone()))?,
)?;
} else {
passthrough_column(df, &mut result, col_name.as_str())?;
}
}
Ok(result)
}
PipelineStage::OneHotEncoder {
drop_first,
prefix,
_categories,
..
} => {
let categories = _categories
.as_ref()
.ok_or_else(not_fitted("OneHotEncoder"))?;
let mut result = DataFrame::new();
for col_name in df.column_names() {
if let Some(cats) = categories.get(col_name.as_str()) {
let values = df.get_column_string_values(col_name.as_str())?;
let start = if *drop_first { 1 } else { 0 };
let base = prefix.clone().unwrap_or_else(|| col_name.clone());
for cat in cats.iter().skip(start) {
let dummy: Vec<f64> = values
.iter()
.map(|v| if v == cat { 1.0 } else { 0.0 })
.collect();
let new_name = format!("{}_{}", base, cat);
result.add_column(
new_name.clone(),
Series::new(dummy, Some(new_name))?,
)?;
}
} else {
passthrough_column(df, &mut result, col_name.as_str())?;
}
}
Ok(result)
}
PipelineStage::Imputer { _fill_values, .. } => {
let fills = _fill_values.as_ref().ok_or_else(not_fitted("Imputer"))?;
let mut result = DataFrame::new();
for col_name in df.column_names() {
if let Some(&fill) = fills.get(col_name.as_str()) {
let values = df.get_column_numeric_values(col_name.as_str())?;
let imputed: Vec<f64> = values
.iter()
.map(|&v| if v.is_nan() { fill } else { v })
.collect();
result.add_column(
col_name.clone(),
Series::new(imputed, Some(col_name.clone()))?,
)?;
} else {
passthrough_column(df, &mut result, col_name.as_str())?;
}
}
Ok(result)
}
PipelineStage::FeatureSelector { columns } => {
for col_name in columns {
if !df.contains_column(col_name) {
return Err(Error::InvalidValue(format!(
"Column '{}' not found",
col_name
)));
}
}
let refs: Vec<&str> = columns.iter().map(|s| s.as_str()).collect();
df.select_columns(&refs)
}
}
}
fn fit(&mut self, df: &DataFrame) -> Result<()> {
match self {
PipelineStage::StandardScaler {
columns,
_means,
_stds,
} => {
let targets = resolve_numeric_targets(df, columns);
let mut means = HashMap::new();
let mut stds = HashMap::new();
for col_name in &targets {
let values = df.get_column_numeric_values(col_name)?;
if values.is_empty() {
continue;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n;
means.insert(col_name.clone(), mean);
stds.insert(col_name.clone(), variance.sqrt());
}
*_means = Some(means);
*_stds = Some(stds);
Ok(())
}
PipelineStage::MinMaxScaler {
columns,
_min_values,
_max_values,
..
} => {
let targets = resolve_numeric_targets(df, columns);
let mut mins = HashMap::new();
let mut maxs = HashMap::new();
for col_name in &targets {
let values = df.get_column_numeric_values(col_name)?;
if values.is_empty() {
continue;
}
let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min);
let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
mins.insert(col_name.clone(), min_v);
maxs.insert(col_name.clone(), max_v);
}
*_min_values = Some(mins);
*_max_values = Some(maxs);
Ok(())
}
PipelineStage::OneHotEncoder {
columns,
_categories,
..
} => {
let targets = resolve_categorical_targets(df, columns);
let mut categories = HashMap::new();
for col_name in &targets {
let values = df.get_column_string_values(col_name)?;
let unique: Vec<String> = values
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
categories.insert(col_name.clone(), unique);
}
*_categories = Some(categories);
Ok(())
}
PipelineStage::Imputer {
columns,
strategy,
fill_value,
_fill_values,
} => {
let targets = resolve_numeric_targets(df, columns);
let mut fills = HashMap::new();
for col_name in &targets {
let values = df.get_column_numeric_values(col_name)?;
let present: Vec<f64> =
values.iter().cloned().filter(|v| !v.is_nan()).collect();
if present.is_empty()
&& matches!(
strategy.to_lowercase().as_str(),
"mean" | "median" | "most_frequent" | "mode"
)
{
return Err(Error::InvalidValue(format!(
"Imputer: column '{}' has no non-missing values; cannot compute \
a '{}' fill value from it",
col_name, strategy
)));
}
let fill = match strategy.to_lowercase().as_str() {
"mean" => present.iter().sum::<f64>() / present.len() as f64,
"median" => median(&present),
"most_frequent" | "mode" => mode(&present),
"constant" => fill_value.ok_or_else(|| {
Error::InvalidValue(
"Imputer: strategy 'constant' requires fill_value to be set"
.to_string(),
)
})?,
other => {
return Err(Error::InvalidValue(format!(
"Unknown imputer strategy '{}'",
other
)))
}
};
fills.insert(col_name.clone(), fill);
}
*_fill_values = Some(fills);
Ok(())
}
PipelineStage::FeatureSelector { .. } => Ok(()),
}
}
}
fn not_fitted(stage: &'static str) -> impl Fn() -> Error {
move || Error::InvalidOperation(format!("{} must be fitted before transform", stage))
}
fn is_concrete_numeric_column(df: &DataFrame, name: &str) -> bool {
df.get_column::<f64>(name).is_ok()
|| df.get_column::<f32>(name).is_ok()
|| df.get_column::<i64>(name).is_ok()
|| df.get_column::<i32>(name).is_ok()
|| df.get_column::<bool>(name).is_ok()
}
fn is_concrete_string_column(df: &DataFrame, name: &str) -> bool {
df.get_column::<String>(name).is_ok()
}
fn resolve_numeric_targets(df: &DataFrame, columns: &Option<Vec<String>>) -> Vec<String> {
match columns {
Some(cols) => cols.clone(),
None => df
.column_names()
.iter()
.filter(|name| is_concrete_numeric_column(df, name.as_str()))
.cloned()
.collect(),
}
}
fn resolve_categorical_targets(df: &DataFrame, columns: &Option<Vec<String>>) -> Vec<String> {
match columns {
Some(cols) => cols.clone(),
None => df
.column_names()
.iter()
.filter(|name| is_concrete_string_column(df, name.as_str()))
.cloned()
.collect(),
}
}
fn passthrough_column(df: &DataFrame, result: &mut DataFrame, name: &str) -> Result<()> {
if is_concrete_string_column(df, name) {
let values = df.get_column_string_values(name)?;
result.add_column(
name.to_string(),
Series::new(values, Some(name.to_string()))?,
)?;
} else if let Ok(values) = df.get_column_numeric_values(name) {
result.add_column(
name.to_string(),
Series::new(values, Some(name.to_string()))?,
)?;
} else if let Ok(values) = df.get_column_string_values(name) {
result.add_column(
name.to_string(),
Series::new(values, Some(name.to_string()))?,
)?;
} else {
return Err(Error::InvalidValue(format!(
"Unable to determine type of column '{}' for pass-through",
name
)));
}
Ok(())
}
fn median(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
}
}
fn mode(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut counts: HashMap<u64, usize> = HashMap::new();
for &v in values {
*counts.entry(v.to_bits()).or_insert(0) += 1;
}
let mut best_bits = values[0].to_bits();
let mut best_count = 0usize;
for (&bits, &count) in &counts {
let value = f64::from_bits(bits);
if count > best_count || (count == best_count && value < f64::from_bits(best_bits)) {
best_count = count;
best_bits = bits;
}
}
f64::from_bits(best_bits)
}
#[derive(Debug)]
pub struct Pipeline {
pub stages: Vec<PipelineStage>,
}
impl Pipeline {
pub fn new() -> Self {
Pipeline { stages: Vec::new() }
}
pub fn add_stage(&mut self, stage: PipelineStage) -> &mut Self {
self.stages.push(stage);
self
}
pub fn fit(&mut self, df: &DataFrame) -> Result<()> {
let mut current_df = df.clone();
for stage in &mut self.stages {
stage.fit(¤t_df)?;
current_df = stage.transform(¤t_df)?;
}
Ok(())
}
pub fn transform(&self, df: &DataFrame) -> Result<DataFrame> {
let mut current_df = df.clone();
for stage in &self.stages {
current_df = stage.transform(¤t_df)?;
}
Ok(current_df)
}
pub fn fit_transform(&mut self, df: &DataFrame) -> Result<DataFrame> {
let mut current_df = df.clone();
for stage in &mut self.stages {
stage.fit(¤t_df)?;
current_df = stage.transform(¤t_df)?;
}
Ok(current_df)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn numeric_df() -> DataFrame {
let mut df = DataFrame::new();
df.add_column(
"a".to_string(),
Series::new(vec![1.0, 2.0, 3.0, 4.0, 5.0], Some("a".to_string())).unwrap(),
)
.unwrap();
df.add_column(
"b".to_string(),
Series::new(vec![10.0, 20.0, 30.0, 40.0, 50.0], Some("b".to_string())).unwrap(),
)
.unwrap();
df
}
#[test]
fn test_standard_scaler_real() {
let df = numeric_df();
let mut stage = PipelineStage::StandardScaler {
columns: None,
_means: None,
_stds: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
let a = out.get_column_numeric_values("a").unwrap();
let mean: f64 = a.iter().sum::<f64>() / a.len() as f64;
let var: f64 = a.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / a.len() as f64;
assert!(
mean.abs() < 1e-9,
"standardized mean should be ~0, got {mean}"
);
assert!(
(var - 1.0).abs() < 1e-6,
"standardized variance should be ~1, got {var}"
);
}
#[test]
fn test_min_max_scaler_real() {
let df = numeric_df();
let mut stage = PipelineStage::MinMaxScaler {
columns: None,
feature_range: (0.0, 1.0),
_min_values: None,
_max_values: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
let a = out.get_column_numeric_values("a").unwrap();
assert!((a[0] - 0.0).abs() < 1e-9);
assert!((a[4] - 1.0).abs() < 1e-9);
assert!((a[2] - 0.5).abs() < 1e-9);
}
#[test]
fn test_one_hot_encoder_real() {
let mut df = DataFrame::new();
df.add_column(
"color".to_string(),
Series::new(
vec!["red".to_string(), "blue".to_string(), "red".to_string()],
Some("color".to_string()),
)
.unwrap(),
)
.unwrap();
let mut stage = PipelineStage::OneHotEncoder {
columns: Some(vec!["color".to_string()]),
drop_first: false,
prefix: None,
_categories: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
assert!(out.contains_column("color_blue"));
assert!(out.contains_column("color_red"));
assert_eq!(
out.get_column_numeric_values("color_red").unwrap(),
vec![1.0, 0.0, 1.0]
);
assert_eq!(
out.get_column_numeric_values("color_blue").unwrap(),
vec![0.0, 1.0, 0.0]
);
}
#[test]
fn test_one_hot_encoder_drop_first() {
let mut df = DataFrame::new();
df.add_column(
"color".to_string(),
Series::new(
vec!["red".to_string(), "blue".to_string(), "green".to_string()],
Some("color".to_string()),
)
.unwrap(),
)
.unwrap();
let mut stage = PipelineStage::OneHotEncoder {
columns: Some(vec!["color".to_string()]),
drop_first: true,
prefix: None,
_categories: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
assert!(!out.contains_column("color_blue"));
assert!(out.contains_column("color_green"));
assert!(out.contains_column("color_red"));
}
#[test]
fn test_imputer_mean_real() {
let mut df = DataFrame::new();
df.add_column(
"x".to_string(),
Series::new(vec![1.0, f64::NAN, 3.0], Some("x".to_string())).unwrap(),
)
.unwrap();
let mut stage = PipelineStage::Imputer {
columns: Some(vec!["x".to_string()]),
strategy: "mean".to_string(),
fill_value: None,
_fill_values: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
assert_eq!(
out.get_column_numeric_values("x").unwrap(),
vec![1.0, 2.0, 3.0]
);
}
#[test]
fn test_imputer_constant_real() {
let mut df = DataFrame::new();
df.add_column(
"x".to_string(),
Series::new(vec![f64::NAN, 5.0, f64::NAN], Some("x".to_string())).unwrap(),
)
.unwrap();
let mut stage = PipelineStage::Imputer {
columns: Some(vec!["x".to_string()]),
strategy: "constant".to_string(),
fill_value: Some(-1.0),
_fill_values: None,
};
stage.fit(&df).unwrap();
let out = stage.transform(&df).unwrap();
assert_eq!(
out.get_column_numeric_values("x").unwrap(),
vec![-1.0, 5.0, -1.0]
);
}
#[test]
fn test_feature_selector_numeric_columns() {
let df = numeric_df();
let stage = PipelineStage::FeatureSelector {
columns: vec!["a".to_string()],
};
let out = stage.transform(&df).unwrap();
assert_eq!(out.column_names(), vec!["a".to_string()]);
assert_eq!(
out.get_column_numeric_values("a").unwrap(),
vec![1.0, 2.0, 3.0, 4.0, 5.0]
);
}
#[test]
fn test_unfitted_transform_errors() {
let df = numeric_df();
let stage = PipelineStage::StandardScaler {
columns: None,
_means: None,
_stds: None,
};
assert!(stage.transform(&df).is_err());
}
#[test]
fn test_pipeline_fit_transform_end_to_end() {
let df = numeric_df();
let mut pipeline = Pipeline::new();
pipeline.add_stage(PipelineStage::StandardScaler {
columns: None,
_means: None,
_stds: None,
});
let out = pipeline.fit_transform(&df).unwrap();
for col in ["a", "b"] {
let values = out.get_column_numeric_values(col).unwrap();
let mean: f64 = values.iter().sum::<f64>() / values.len() as f64;
assert!(mean.abs() < 1e-9, "column {col} should be centered");
}
}
}