Spaces:
Running
Running
| """Top-level FeatureBuilder - orchestrates the whole pipeline. | |
| Designed to be: | |
| - Stateful: fit/transform separation (no leakage) | |
| - Serialisable: pickled with the model | |
| - Inspectable: every step logs counts and feature names | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.base import BaseEstimator, TransformerMixin | |
| from sklearn.impute import SimpleImputer | |
| from sklearn.preprocessing import StandardScaler | |
| from ..utils.logging import get_logger | |
| from .behavioral import build_behavioral_features | |
| from .velocity import build_velocity_features | |
| from .graph_features import build_graph_features | |
| from .encoders import WoEEncoder, TargetEncoder | |
| log = get_logger(__name__) | |
| # Feature groups | |
| NUMERIC_BASE = [ | |
| "loan_amnt", "int_rate", "installment", "annual_inc", "dti", | |
| "delinq_2yrs", "inq_last_6mths", "open_acc", "pub_rec", | |
| "revol_bal", "revol_util", "total_acc", "mort_acc", | |
| "pub_rec_bankruptcies", "emp_length", "term", | |
| ] | |
| NUMERIC_DERIVED = [ | |
| "loan_to_income", "installment_to_income", "revol_bal_to_income", | |
| "credit_history_years", "pct_open_acc", "n_negative_events", | |
| "flag_high_dti", "flag_no_employment", | |
| "app_month", "app_quarter", "app_year", | |
| "title_len", "title_digit_ratio", | |
| "emp_title_len", "emp_title_digit_ratio", | |
| "graph_component_size", "graph_degree", "graph_isolated", | |
| ] | |
| CATEGORICAL_LOW = ["grade", "home_ownership", "verification_status", "purpose", "addr_state"] | |
| CATEGORICAL_HIGH = ["sub_grade", "emp_title", "zip_code"] | |
| class FeatureBuilder(BaseEstimator, TransformerMixin): | |
| """End-to-end feature pipeline.""" | |
| winsorize_quantiles: tuple[float, float] = (0.005, 0.995) | |
| use_velocity: bool = True | |
| use_graph: bool = True | |
| fitted_: bool = False | |
| # Fitted state | |
| winsor_bounds_: dict = field(default_factory=dict) | |
| imputer_: Optional[SimpleImputer] = None | |
| scaler_: Optional[StandardScaler] = None | |
| woe_encoder_: Optional[WoEEncoder] = None | |
| target_encoder_: Optional[TargetEncoder] = None | |
| feature_names_: list[str] = field(default_factory=list) | |
| numeric_cols_: list[str] = field(default_factory=list) | |
| categorical_low_cols_: list[str] = field(default_factory=list) | |
| categorical_high_cols_: list[str] = field(default_factory=list) | |
| # ---------------------------------------------------------------- # | |
| # Public API | |
| # ---------------------------------------------------------------- # | |
| def fit(self, X: pd.DataFrame, y: pd.Series) -> "FeatureBuilder": | |
| log.info(f"FeatureBuilder.fit on {len(X):,} rows") | |
| X_eng = self._engineer(X) | |
| # Determine which columns are actually present | |
| self.numeric_cols_ = [c for c in NUMERIC_BASE + NUMERIC_DERIVED if c in X_eng.columns] | |
| self.categorical_low_cols_ = [c for c in CATEGORICAL_LOW if c in X_eng.columns] | |
| self.categorical_high_cols_ = [c for c in CATEGORICAL_HIGH if c in X_eng.columns] | |
| # Winsorise numeric on training only, then store bounds | |
| X_num = X_eng[self.numeric_cols_].apply(pd.to_numeric, errors="coerce") | |
| lo_q, hi_q = self.winsorize_quantiles | |
| self.winsor_bounds_ = { | |
| col: (X_num[col].quantile(lo_q), X_num[col].quantile(hi_q)) | |
| for col in self.numeric_cols_ | |
| } | |
| X_num = self._apply_winsor(X_num) | |
| # Impute + scale numeric | |
| self.imputer_ = SimpleImputer(strategy="median") | |
| X_num_imp = pd.DataFrame( | |
| self.imputer_.fit_transform(X_num), columns=self.numeric_cols_, index=X.index | |
| ) | |
| self.scaler_ = StandardScaler() | |
| self.scaler_.fit(X_num_imp) | |
| # WoE for low-cardinality cats (also great for tree models) | |
| if self.categorical_low_cols_: | |
| self.woe_encoder_ = WoEEncoder() | |
| self.woe_encoder_.fit(X_eng[self.categorical_low_cols_], y) | |
| log.info( | |
| f"WoE IV (low-card): " | |
| + ", ".join(f"{k}={v:.3f}" for k, v in self.woe_encoder_.iv_.items()) | |
| ) | |
| # Target encoding for high-cardinality cats | |
| if self.categorical_high_cols_: | |
| self.target_encoder_ = TargetEncoder() | |
| self.target_encoder_.fit(X_eng[self.categorical_high_cols_], y) | |
| # Final feature names | |
| feat_names = list(self.numeric_cols_) | |
| feat_names += [f"{c}_woe" for c in self.categorical_low_cols_] | |
| feat_names += [f"{c}_te" for c in self.categorical_high_cols_] | |
| self.feature_names_ = feat_names | |
| self.fitted_ = True | |
| log.info(f"FeatureBuilder ready - {len(feat_names)} features") | |
| return self | |
| def transform(self, X: pd.DataFrame) -> pd.DataFrame: | |
| if not self.fitted_: | |
| raise RuntimeError("FeatureBuilder not fitted") | |
| X_eng = self._engineer(X) | |
| X_num = X_eng.reindex(columns=self.numeric_cols_).apply(pd.to_numeric, errors="coerce") | |
| X_num = self._apply_winsor(X_num) | |
| X_num_imp = pd.DataFrame( | |
| self.imputer_.transform(X_num), columns=self.numeric_cols_, index=X.index | |
| ) | |
| # Note: scaler fitted but only applied for models that want it. | |
| # Tree models use raw imputed values. We keep both available. | |
| frames = [X_num_imp] | |
| if self.woe_encoder_ is not None: | |
| frames.append(self.woe_encoder_.transform(X_eng[self.categorical_low_cols_])) | |
| if self.target_encoder_ is not None: | |
| frames.append(self.target_encoder_.transform(X_eng[self.categorical_high_cols_])) | |
| out = pd.concat(frames, axis=1) | |
| out = out.reindex(columns=self.feature_names_, fill_value=0.0) | |
| out = out.replace([np.inf, -np.inf], 0.0).fillna(0.0) | |
| return out | |
| def fit_transform(self, X: pd.DataFrame, y: pd.Series = None) -> pd.DataFrame: | |
| return self.fit(X, y).transform(X) | |
| # ---------------------------------------------------------------- # | |
| # Internal helpers | |
| # ---------------------------------------------------------------- # | |
| def _engineer(self, X: pd.DataFrame) -> pd.DataFrame: | |
| """Run the engineering chain and ALWAYS return rows in the original | |
| order/index of ``X``. | |
| This is critical: ``build_velocity_features`` sorts by date and | |
| ``build_graph_features`` resets the index. Without restoring the | |
| original order here, the engineered features would come back in a | |
| different row order than the labels ``y`` - silently misaligning | |
| X and y and destroying model performance. | |
| """ | |
| original_index = X.index | |
| out = X.copy() | |
| # Inject a stable positional id that survives sorting / re-indexing | |
| out["__row_id__"] = np.arange(len(out)) | |
| out = build_behavioral_features(out) | |
| if self.use_velocity: | |
| out = build_velocity_features(out) | |
| if self.use_graph: | |
| out = build_graph_features(out) | |
| # Restore the original row order and index, then drop the helper. | |
| out = out.sort_values("__row_id__").drop(columns="__row_id__") | |
| out.index = original_index | |
| return out | |
| def _apply_winsor(self, X_num: pd.DataFrame) -> pd.DataFrame: | |
| for col, (lo, hi) in self.winsor_bounds_.items(): | |
| if col in X_num.columns and pd.notna(lo) and pd.notna(hi): | |
| X_num[col] = X_num[col].clip(lower=lo, upper=hi) | |
| return X_num | |