Nkosiii's picture
Upload 44 files
5863f1d verified
Raw
History Blame Contribute Delete
6.44 kB
"""
src/preprocess.py
Enterprise-grade preprocessing utilities used by app.py and ModelPredictor.
Responsibilities:
- Safe date parsing and time-feature extraction
- Missing-value handling with flexible strategies
- Lightweight feature alignment to model feature list (preserving order)
- Optional basic scaling (mean/std)
- Validation helpers for incoming data
"""
from typing import List, Optional, Dict, Any
import pandas as pd
import numpy as np
import logging
import json
from pathlib import Path
logger = logging.getLogger(__name__)
class DataPrep:
def __init__(
self,
fillna_strategy: str = "median",
custom_fill: Optional[Dict[str, Any]] = None,
apply_scaling: bool = False,
scaling_stats: Optional[Dict[str, Dict[str, float]]] = None,
):
"""
Parameters
----------
fillna_strategy : 'median' or 'zero'
custom_fill : dict {col: value} for overrides on specific columns
apply_scaling : if True, apply standard scaling using scaling_stats
scaling_stats : dict {col: {"mean": .., "std": ..}}
"""
if fillna_strategy not in ("median", "zero"):
raise ValueError("fillna_strategy must be 'median' or 'zero'")
self.fillna_strategy = fillna_strategy
self.custom_fill = custom_fill or {}
self.apply_scaling = apply_scaling
self.scaling_stats = scaling_stats or {}
# ---------------------------------------------------------
# INTERNAL HELPERS
# ---------------------------------------------------------
def _parse_dates(self, df: pd.DataFrame) -> pd.DataFrame:
"""If a date column exists, generate useful time-based features."""
df = df.copy()
date_candidates = [
c for c in df.columns
if c.lower() in ("date", "timestamp", "datetime")
]
if not date_candidates:
return df
date_col = date_candidates[0]
try:
df[date_col] = pd.to_datetime(df[date_col], errors="coerce")
df["dayofweek"] = df[date_col].dt.dayofweek
df["month"] = df[date_col].dt.month
# Only add hour if it varies
if "hour" not in df.columns:
if df[date_col].dt.hour.nunique(dropna=True) > 1:
df["hour"] = df[date_col].dt.hour
except Exception as e:
logger.debug(f"Date parsing failed for column {date_col}: {e}")
return df
def _fill_missing(self, df: pd.DataFrame) -> pd.DataFrame:
"""Fill missing values with strategy + custom overrides."""
df = df.copy()
# apply custom fills first
for col, val in self.custom_fill.items():
if col in df.columns:
try:
df[col] = df[col].fillna(val)
except Exception:
df[col] = df[col].astype(object).fillna(val)
# numeric fill
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
if numeric_cols:
if self.fillna_strategy == "median":
medians = df[numeric_cols].median()
df[numeric_cols] = df[numeric_cols].fillna(medians)
else: # zero fill
df[numeric_cols] = df[numeric_cols].fillna(0)
# object/categorical fill
obj_cols = df.select_dtypes(include=["object", "category"]).columns.tolist()
if obj_cols:
df[obj_cols] = df[obj_cols].fillna("")
return df
def _apply_scaling(self, X: pd.DataFrame) -> pd.DataFrame:
"""Standard scaling (X - mean) / std."""
if not self.apply_scaling or not self.scaling_stats:
return X
X = X.copy()
for col, stats in self.scaling_stats.items():
if col in X.columns:
mean = stats.get("mean", 0.0)
std = stats.get("std", 1.0) or 1.0
try:
X[col] = (X[col] - mean) / std
except Exception as e:
logger.debug(f"Scaling failed for {col}: {e}")
return X
def _align_columns(self, df: pd.DataFrame, feature_columns: List[str]) -> pd.DataFrame:
"""
Ensures the output has exactly the feature_columns, in order.
Missing columns are created as zero; extra columns are dropped.
"""
aligned = pd.DataFrame(index=df.index)
for col in feature_columns:
if col in df.columns:
aligned[col] = df[col]
else:
aligned[col] = 0
return aligned
# ---------------------------------------------------------
# PUBLIC API
# ---------------------------------------------------------
def validate(self, df: pd.DataFrame) -> None:
"""Lightweight data checks."""
if df is None or len(df) == 0:
raise ValueError("Input DataFrame is empty.")
if not isinstance(df, pd.DataFrame):
raise ValueError("Input must be a pandas DataFrame.")
def prepare(self, df: pd.DataFrame, feature_columns: List[str]) -> pd.DataFrame:
"""
Main preprocessing entrypoint.
Steps:
1. Validate
2. Date parsing
3. Missing-value handling
4. Optional scaling
5. Alignment with feature list
"""
self.validate(df)
df = df.copy()
df = self._parse_dates(df)
df = self._fill_missing(df)
X = self._align_columns(df, feature_columns)
if self.apply_scaling:
X = self._apply_scaling(X)
return X[feature_columns] # ensure strict order
def save_scaling_stats(self, out_path: str, feature_cols: List[str], df_reference: pd.DataFrame) -> None:
"""
Computes & saves scaling means/stds for future runs.
"""
stats = {}
numeric = df_reference[feature_cols].select_dtypes(include=[np.number]).columns.tolist()
for col in numeric:
stats[col] = {
"mean": float(df_reference[col].mean()),
"std": float(df_reference[col].std() or 1.0)
}
Path(out_path).write_text(json.dumps(stats, indent=2))