Spaces:
Sleeping
Sleeping
| import pandas as pd | |
| import numpy as np | |
| from pathlib import Path | |
| from sklearn.preprocessing import OrdinalEncoder | |
| from typing import Tuple | |
| import sys | |
| sys.path.append(str(Path(__file__).resolve().parents[3])) | |
| from src.logger import get_logger | |
| logger = get_logger("ieee_cis.preprocessor") | |
| HIGH_MISSING_THRESHOLD = 0.90 | |
| DROP_HIGH_MISSING_COLS = [ | |
| "id_24", "id_25", "id_07", "id_08", "id_21", | |
| "id_26", "id_27", "id_23", "id_22", | |
| "dist2", "D7", "id_18", | |
| ] | |
| REDUNDANT_COLS = ["C1", "C4", "C8", "C12", "V242", "V244", "V49", "V90"] | |
| NAN_FLAG_COLS = [ | |
| "dist1", | |
| "D1", "D2", "D3", "D4", "D5", | |
| "D6", "D8", "D9", "D10", "D11", | |
| "D12", "D13", "D14", "D15", | |
| ] | |
| M_COLS = ["M1", "M2", "M3", "M5", "M6", "M7", "M8", "M9"] | |
| M4_MAP = {"M0": 0, "M1": 1, "M2": 2} | |
| BOOL_MAP = {"T": 1, "F": 0} | |
| # FE numerical features that must NOT be OrdinalEncoded. | |
| # These are float aggregation features created in feature_engineer.py. | |
| # Without this exclusion, pandas may detect them as object dtype | |
| # due to __group_col__ / __global__ sentinel keys in agg_maps dict, | |
| # causing OrdinalEncoder to incorrectly treat them as categorical. | |
| FE_NUMERICAL_COLS = [ | |
| "FE_card1_amt_mean", | |
| "FE_card1_amt_std", | |
| "FE_card1_amt_count", | |
| "FE_card1a1_amt_mean", | |
| "FE_card1a1_amt_std", | |
| "FE_card1a1_amt_count", | |
| ] | |
| # Columns that are never passed to OrdinalEncoder | |
| ENCODE_EXCLUDE = ["TransactionID"] + FE_NUMERICAL_COLS | |
| def drop_high_missing( | |
| df: pd.DataFrame, | |
| threshold: float = HIGH_MISSING_THRESHOLD, | |
| drop_cols_fitted: list = None, | |
| ) -> Tuple[pd.DataFrame, list]: | |
| """ | |
| Drop columns exceeding missing value threshold. | |
| Train: compute and fit drop list. | |
| Test : apply fitted drop list from train. | |
| """ | |
| if drop_cols_fitted is not None: | |
| cols = [c for c in drop_cols_fitted if c in df.columns] | |
| df = df.drop(columns=cols) | |
| logger.info(f"[TEST] Dropped {len(cols)} high-missing columns") | |
| logger.info(f" Columns: {cols}") | |
| return df, drop_cols_fitted | |
| exclude = ["isFraud", "TransactionID", "TransactionDT"] | |
| feature_df = df.drop( | |
| columns=[c for c in exclude if c in df.columns] | |
| ) | |
| missing_pct = feature_df.isnull().mean() | |
| drop_cols = missing_pct[missing_pct > threshold].index.tolist() | |
| df = df.drop(columns=drop_cols) | |
| logger.info( | |
| f"[TRAIN] Dropped {len(drop_cols)} columns " | |
| f"with >{threshold*100:.0f}% missing" | |
| ) | |
| logger.info(f" Dropped: {drop_cols}") | |
| logger.info(f" EDA reference list : {DROP_HIGH_MISSING_COLS}") | |
| return df, drop_cols | |
| def drop_redundant(df: pd.DataFrame) -> pd.DataFrame: | |
| """Drop manually identified redundant columns (high collinearity).""" | |
| cols = [c for c in REDUNDANT_COLS if c in df.columns] | |
| df = df.drop(columns=cols) | |
| logger.info(f"Dropped {len(cols)} redundant columns: {cols}") | |
| return df | |
| def add_nan_flags( | |
| df: pd.DataFrame, | |
| nan_flag_cols_fitted: list = None, | |
| ) -> Tuple[pd.DataFrame, list]: | |
| """ | |
| Add binary NaN indicator columns for informative missing features. | |
| NaN in D columns signals absence of transaction history. | |
| Train: detect which NAN_FLAG_COLS have missing values. | |
| Test : apply same flag columns as train. | |
| """ | |
| if nan_flag_cols_fitted is not None: | |
| new_cols = {} | |
| for flag_col in nan_flag_cols_fitted: | |
| src = flag_col.replace("_isnan", "") | |
| if src in df.columns: | |
| new_cols[flag_col] = ( | |
| df[src].isnull().astype(np.int8).values | |
| ) | |
| else: | |
| new_cols[flag_col] = np.zeros(len(df), dtype=np.int8) | |
| logger.info( | |
| f" WARNING: {src} not found in test β " | |
| f"{flag_col} filled with 0" | |
| ) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info( | |
| f"[TEST] Applied {len(new_cols)} NaN flag columns from train" | |
| ) | |
| return df, nan_flag_cols_fitted | |
| new_cols = {} | |
| fitted_cols = [] | |
| for col in NAN_FLAG_COLS: | |
| if col in df.columns and df[col].isnull().any(): | |
| flag = f"{col}_isnan" | |
| new_cols[flag] = df[col].isnull().astype(np.int8).values | |
| fitted_cols.append(flag) | |
| if new_cols: | |
| df = pd.concat( | |
| [df, pd.DataFrame(new_cols, index=df.index)], axis=1 | |
| ) | |
| logger.info(f"[TRAIN] Added {len(new_cols)} NaN flag columns") | |
| logger.info(f" Flags: {fitted_cols}") | |
| return df, fitted_cols | |
| def encode_m_columns(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Encode M columns from string T/F to binary 0/1. | |
| NaN encoded as -1 β preserves NaN signal. | |
| M4 encoded ordinally: M0=0, M1=1, M2=2. | |
| """ | |
| for col in M_COLS: | |
| if col in df.columns: | |
| df[col] = ( | |
| df[col].map(BOOL_MAP).fillna(-1).astype(np.int8) | |
| ) | |
| if "M4" in df.columns: | |
| df["M4"] = ( | |
| df["M4"].map(M4_MAP).fillna(-1).astype(np.int8) | |
| ) | |
| logger.info( | |
| "Encoded M columns: T=1, F=0, NaN=-1 | " | |
| "M4 ordinal: M0=0, M1=1, M2=2" | |
| ) | |
| return df | |
| def impute_d_columns( | |
| df: pd.DataFrame, | |
| d_medians: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| """ | |
| Impute D columns using card1 group median. | |
| D columns represent time deltas relative to card activity. | |
| Same card1 group shares similar temporal patterns. | |
| Global median used as fallback for unseen card1 values. | |
| """ | |
| d_cols = [ | |
| c for c in df.columns | |
| if c.startswith("D") | |
| and c[1:].isdigit() | |
| and pd.api.types.is_numeric_dtype(df[c]) | |
| ] | |
| if d_medians is not None: | |
| for col, fill_map in d_medians.items(): | |
| if col not in df.columns: | |
| continue | |
| global_med = fill_map.get("__global__", 0) | |
| df[col] = df.groupby("card1")[col].transform( | |
| lambda x: x.fillna(x.median()) | |
| ) | |
| df[col] = df[col].fillna(global_med) | |
| logger.info( | |
| f"[TEST] Applied card1 group imputation " | |
| f"to {len(d_medians)} D columns" | |
| ) | |
| return df, d_medians | |
| d_medians = {} | |
| for col in d_cols: | |
| if df[col].isnull().sum() == 0: | |
| continue | |
| global_med = df[col].median() | |
| d_medians[col] = {"__global__": global_med} | |
| df[col] = df.groupby("card1")[col].transform( | |
| lambda x: x.fillna(x.median()) | |
| ) | |
| df[col] = df[col].fillna(global_med) | |
| logger.info( | |
| f"[TRAIN] Imputed {len(d_medians)} D columns " | |
| f"by card1 group median" | |
| ) | |
| return df, d_medians | |
| def impute_numerical( | |
| df: pd.DataFrame, | |
| fill_values: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| """ | |
| Impute numerical columns with median. | |
| Median chosen over mean β robust to outliers. | |
| """ | |
| exclude = ["isFraud", "TransactionID", "TransactionDT"] | |
| num_cols = [ | |
| c for c in df.select_dtypes( | |
| include=["float64", "float32", "int64", "int32"] | |
| ).columns | |
| if c not in exclude | |
| ] | |
| if fill_values is not None: | |
| updates = { | |
| col: df[col].fillna(val) | |
| for col, val in fill_values.items() | |
| if col in df.columns | |
| } | |
| if updates: | |
| df = df.assign(**updates) | |
| logger.info( | |
| f"[TEST] Applied median imputation " | |
| f"to {len(fill_values)} numerical columns" | |
| ) | |
| return df, fill_values | |
| fill_values = {} | |
| updates = {} | |
| for col in num_cols: | |
| if df[col].isnull().any(): | |
| med = df[col].median() | |
| fill_values[col] = med | |
| updates[col] = df[col].fillna(med) | |
| if updates: | |
| df = df.assign(**updates) | |
| logger.info( | |
| f"[TRAIN] Imputed {len(fill_values)} numerical columns " | |
| f"with median" | |
| ) | |
| return df, fill_values | |
| def impute_categorical( | |
| df: pd.DataFrame, | |
| cat_fill_values: dict = None, | |
| ) -> Tuple[pd.DataFrame, dict]: | |
| """ | |
| Impute categorical (object/str) columns with mode. | |
| Applied before OrdinalEncoder to avoid unknown value issues. | |
| Excludes FE_NUMERICAL_COLS β those are float, not categorical. | |
| """ | |
| cat_cols = [ | |
| c for c in df.select_dtypes(include=["object"]).columns | |
| if c not in ENCODE_EXCLUDE | |
| ] | |
| if cat_fill_values is not None: | |
| updates = { | |
| col: df[col].fillna(val) | |
| for col, val in cat_fill_values.items() | |
| if col in df.columns | |
| } | |
| if updates: | |
| df = df.assign(**updates) | |
| logger.info( | |
| f"[TEST] Applied mode imputation " | |
| f"to {len(cat_fill_values)} categorical columns" | |
| ) | |
| return df, cat_fill_values | |
| cat_fill_values = {} | |
| updates = {} | |
| for col in cat_cols: | |
| if df[col].isnull().any(): | |
| mode_val = df[col].mode()[0] | |
| cat_fill_values[col] = mode_val | |
| updates[col] = df[col].fillna(mode_val) | |
| if updates: | |
| df = df.assign(**updates) | |
| logger.info( | |
| f"[TRAIN] Imputed {len(cat_fill_values)} categorical columns " | |
| f"with mode" | |
| ) | |
| return df, cat_fill_values | |
| def fix_fe_numerical_dtypes(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Ensure FE numerical aggregation columns have correct float64 dtype. | |
| These columns may be detected as object dtype due to agg_maps | |
| dict sentinel keys (__group_col__, __global__) in feature_engineer. | |
| Converting to float64 before encode_categoricals prevents them | |
| from being incorrectly passed to OrdinalEncoder. | |
| """ | |
| fixed = [] | |
| for col in FE_NUMERICAL_COLS: | |
| if col in df.columns: | |
| try: | |
| df[col] = pd.to_numeric(df[col], errors="coerce").astype( | |
| np.float64 | |
| ) | |
| fixed.append(col) | |
| except Exception: | |
| pass | |
| if fixed: | |
| logger.info( | |
| f"[FIX] Converted {len(fixed)} FE numerical cols to float64: " | |
| f"{fixed}" | |
| ) | |
| return df | |
| def encode_categoricals( | |
| df: pd.DataFrame, | |
| encoder: OrdinalEncoder = None, | |
| cat_cols: list = None, | |
| ) -> Tuple[pd.DataFrame, OrdinalEncoder, list]: | |
| """ | |
| Ordinal encode categorical (string) columns. | |
| handle_unknown='use_encoded_value' with unknown_value=-1 | |
| ensures unseen categories in test do not cause errors. | |
| FE_NUMERICAL_COLS are explicitly excluded β they are float | |
| aggregation features, not categorical, and must never be encoded. | |
| Must run AFTER feature_engineer.py β encoding destroys raw strings | |
| needed for browser/device/email feature engineering. | |
| """ | |
| if encoder is not None: | |
| train_cat_cols = encoder.feature_names_in_.tolist() | |
| missing_cols = [ | |
| c for c in train_cat_cols if c not in df.columns | |
| ] | |
| if missing_cols: | |
| missing_df = pd.DataFrame( | |
| "missing", index=df.index, columns=missing_cols | |
| ) | |
| df = pd.concat([df, missing_df], axis=1) | |
| logger.info( | |
| f"[TEST] Added {len(missing_cols)} missing columns " | |
| f"with placeholder: {missing_cols}" | |
| ) | |
| df = df.copy() | |
| df[train_cat_cols] = encoder.transform( | |
| df[train_cat_cols].astype(str) | |
| ) | |
| logger.info( | |
| f"[TEST] OrdinalEncoder applied " | |
| f"to {len(train_cat_cols)} columns" | |
| ) | |
| return df, encoder, train_cat_cols | |
| # Train mode β exclude FE_NUMERICAL_COLS explicitly | |
| if cat_cols is None: | |
| cat_cols = [ | |
| c for c in df.select_dtypes(include=["object"]).columns | |
| if c not in ENCODE_EXCLUDE | |
| ] | |
| cat_cols = [c for c in cat_cols if c in df.columns] | |
| encoder = OrdinalEncoder( | |
| handle_unknown="use_encoded_value", | |
| unknown_value=-1, | |
| encoded_missing_value=-2, | |
| ) | |
| df = df.copy() | |
| df[cat_cols] = encoder.fit_transform(df[cat_cols].astype(str)) | |
| logger.info( | |
| f"[TRAIN] OrdinalEncoder fitted " | |
| f"on {len(cat_cols)} columns: {cat_cols}" | |
| ) | |
| return df, encoder, cat_cols | |
| def preprocess_train(df: pd.DataFrame) -> Tuple[pd.DataFrame, dict]: | |
| """ | |
| Full preprocessing pipeline for training data. | |
| Order: | |
| 1. Drop high-missing columns (>90%) | |
| 2. Drop redundant columns | |
| 3. Fix FE numerical dtypes (prevent OrdinalEncoder bug) | |
| 4. Add NaN flag columns | |
| 5. Encode M columns | |
| 6. Impute D columns (card1 group median) | |
| 7. Impute numerical (global median) | |
| 8. Impute categorical (mode) | |
| 9. Encode categoricals (OrdinalEncoder) | |
| Must run AFTER feature_engineer_train(). | |
| """ | |
| logger.info("=" * 50) | |
| logger.info("PREPROCESSING β TRAIN") | |
| logger.info(f"Input shape: {df.shape}") | |
| df, drop_cols = drop_high_missing(df) | |
| df = drop_redundant(df) | |
| df = fix_fe_numerical_dtypes(df) | |
| df, nan_flag_cols = add_nan_flags(df) | |
| df = encode_m_columns(df) | |
| df, d_medians = impute_d_columns(df) | |
| df, num_fills = impute_numerical(df) | |
| df, cat_fills = impute_categorical(df) | |
| df, encoder, cat_cols = encode_categoricals(df) | |
| artifacts = { | |
| "drop_cols" : drop_cols, | |
| "nan_flag_cols": nan_flag_cols, | |
| "d_medians" : d_medians, | |
| "num_fills" : num_fills, | |
| "cat_fills" : cat_fills, | |
| "encoder" : encoder, | |
| "cat_cols" : cat_cols, | |
| } | |
| logger.info(f"Output shape: {df.shape}") | |
| logger.info("PREPROCESSING TRAIN COMPLETE") | |
| logger.info("=" * 50) | |
| return df, artifacts | |
| def preprocess_test( | |
| df: pd.DataFrame, | |
| artifacts: dict, | |
| ) -> pd.DataFrame: | |
| """ | |
| Apply preprocessing to validation or test data. | |
| Uses fitted artifacts from training only β no leakage. | |
| """ | |
| logger.info("=" * 50) | |
| logger.info("PREPROCESSING β TEST/VAL") | |
| logger.info(f"Input shape: {df.shape}") | |
| df, _ = drop_high_missing( | |
| df, drop_cols_fitted=artifacts["drop_cols"] | |
| ) | |
| df = drop_redundant(df) | |
| df = fix_fe_numerical_dtypes(df) | |
| df, _ = add_nan_flags( | |
| df, nan_flag_cols_fitted=artifacts["nan_flag_cols"] | |
| ) | |
| df = encode_m_columns(df) | |
| df, _ = impute_d_columns( | |
| df, d_medians=artifacts["d_medians"] | |
| ) | |
| df, _ = impute_numerical( | |
| df, fill_values=artifacts["num_fills"] | |
| ) | |
| df, _ = impute_categorical( | |
| df, cat_fill_values=artifacts["cat_fills"] | |
| ) | |
| df, _, _ = encode_categoricals( | |
| df, encoder=artifacts["encoder"] | |
| ) | |
| logger.info(f"Output shape: {df.shape}") | |
| logger.info("PREPROCESSING TEST/VAL COMPLETE") | |
| logger.info("=" * 50) | |
| return df |