| """Preprocessing pipeline builder.""" |
|
|
| import logging |
| from dataclasses import dataclass |
|
|
| import numpy as np |
| import pandas as pd |
| from sklearn.compose import ColumnTransformer |
| from sklearn.impute import SimpleImputer |
| from sklearn.pipeline import Pipeline |
| from sklearn.preprocessing import OrdinalEncoder, RobustScaler, OneHotEncoder |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _OHE_MAX_CARDINALITY = 15 |
|
|
|
|
| @dataclass |
| class PreprocessorResult: |
| """Holds the fitted ColumnTransformer and the resulting feature names.""" |
|
|
| pipeline: ColumnTransformer |
| feature_names: list[str] |
|
|
|
|
| def _extract_datetime_features(df: pd.DataFrame, datetime_cols: list[str]) -> pd.DataFrame: |
| """Replace each datetime column with numeric calendar features. |
| |
| Extracts: year, month, day, dayofweek, hour, is_weekend. |
| The original datetime column is dropped. |
| |
| Args: |
| df: Input DataFrame (will be modified in-place via copy). |
| datetime_cols: List of datetime column names. |
| |
| Returns: |
| DataFrame with datetime columns replaced by extracted features. |
| """ |
| df = df.copy() |
| for col in datetime_cols: |
| try: |
| dt = pd.to_datetime(df[col]) |
| df[f"{col}_year"] = dt.dt.year |
| df[f"{col}_month"] = dt.dt.month |
| df[f"{col}_day"] = dt.dt.day |
| df[f"{col}_dayofweek"] = dt.dt.dayofweek |
| df[f"{col}_hour"] = dt.dt.hour |
| df[f"{col}_is_weekend"] = dt.dt.dayofweek.isin([5, 6]).astype(int) |
| df.drop(columns=[col], inplace=True) |
| logger.info("Extracted datetime features from column '%s'.", col) |
| except Exception as exc: |
| logger.warning("Could not extract datetime features from '%s': %s", col, exc) |
| return df |
|
|
|
|
| def build_preprocessor( |
| df: pd.DataFrame, |
| numerical_cols: list[str], |
| categorical_cols: list[str], |
| datetime_cols: list[str], |
| cardinality: dict[str, int], |
| ) -> tuple[ColumnTransformer, pd.DataFrame]: |
| """Build and return an unfitted ColumnTransformer plus datetime-expanded DataFrame. |
| |
| Args: |
| df: The raw DataFrame (target column should already be removed by the caller). |
| numerical_cols: List of numerical feature column names. |
| categorical_cols: List of categorical feature column names. |
| datetime_cols: List of datetime column names. |
| cardinality: Mapping of column name → number of unique values. |
| |
| Returns: |
| A tuple of: |
| - Unfitted ColumnTransformer (call .fit_transform(df_expanded) externally). |
| - DataFrame with datetime columns already replaced by numeric features. |
| """ |
| |
| df_expanded = _extract_datetime_features(df, datetime_cols) |
|
|
| |
| datetime_derived: list[str] = [] |
| for col in datetime_cols: |
| for suffix in ("year", "month", "day", "dayofweek", "hour", "is_weekend"): |
| derived = f"{col}_{suffix}" |
| if derived in df_expanded.columns: |
| datetime_derived.append(derived) |
|
|
| all_numerical = numerical_cols + datetime_derived |
|
|
| |
| low_card_cats = [c for c in categorical_cols if cardinality.get(c, 0) <= _OHE_MAX_CARDINALITY] |
| high_card_cats = [c for c in categorical_cols if cardinality.get(c, 0) > _OHE_MAX_CARDINALITY] |
|
|
| transformers = [] |
|
|
| if all_numerical: |
| num_pipeline = Pipeline([ |
| ("imputer", SimpleImputer(strategy="median")), |
| ("scaler", RobustScaler()), |
| ]) |
| transformers.append(("numerical", num_pipeline, all_numerical)) |
|
|
| if low_card_cats: |
| low_cat_pipeline = Pipeline([ |
| ("imputer", SimpleImputer(strategy="constant", fill_value="MISSING")), |
| ("encoder", OneHotEncoder(handle_unknown="ignore", sparse_output=False)), |
| ]) |
| transformers.append(("low_card_cat", low_cat_pipeline, low_card_cats)) |
|
|
| if high_card_cats: |
| high_cat_pipeline = Pipeline([ |
| ("imputer", SimpleImputer(strategy="constant", fill_value="MISSING")), |
| ( |
| "encoder", |
| OrdinalEncoder( |
| handle_unknown="use_encoded_value", |
| unknown_value=-1, |
| ), |
| ), |
| ]) |
| transformers.append(("high_card_cat", high_cat_pipeline, high_card_cats)) |
|
|
| preprocessor = ColumnTransformer( |
| transformers=transformers, |
| remainder="drop", |
| verbose_feature_names_out=True, |
| ) |
|
|
| logger.info( |
| "Preprocessor built: %d numerical, %d low-card cat (OHE), %d high-card cat (ordinal), " |
| "%d datetime cols expanded.", |
| len(all_numerical), |
| len(low_card_cats), |
| len(high_card_cats), |
| len(datetime_cols), |
| ) |
|
|
| return preprocessor, df_expanded |
|
|
|
|
| def fit_preprocessor( |
| df: pd.DataFrame, |
| numerical_cols: list[str], |
| categorical_cols: list[str], |
| datetime_cols: list[str], |
| cardinality: dict[str, int], |
| ) -> PreprocessorResult: |
| """Build, fit, and return the preprocessor plus final feature names. |
| |
| Args: |
| df: Feature DataFrame (target column removed). |
| numerical_cols: Numerical column names. |
| categorical_cols: Categorical column names. |
| datetime_cols: Datetime column names. |
| cardinality: Column → unique count mapping. |
| |
| Returns: |
| PreprocessorResult with fitted pipeline and feature name list. |
| """ |
| preprocessor, df_expanded = build_preprocessor( |
| df, numerical_cols, categorical_cols, datetime_cols, cardinality |
| ) |
| preprocessor.fit(df_expanded) |
|
|
| try: |
| feature_names = list(preprocessor.get_feature_names_out()) |
| except Exception: |
| feature_names = [] |
|
|
| logger.info("Preprocessor fitted. Output features: %d", len(feature_names)) |
| return PreprocessorResult(pipeline=preprocessor, feature_names=feature_names) |
|
|