"""Dataset loading and preprocessing for Banco Ripley credit-scoring. Handles data ingestion, dtype casting, null imputation and ordinal encoding of categorical variables. Produces a single cleaned CSV ready for feature engineering and modelling. """ from pathlib import Path import pandas as pd from loguru import logger from tqdm import tqdm # ── Column-type registry ───────────────────────────────────────────────────── IDENTIFIER_COLS: list[str] = ["serie"] TEMPORAL_COLS: list[str] = ["PERIODO"] TARGET_COL: str = "predictiva_early" BINARY_FLAG_COLS: list[str] = [ "FLAG_ENTIDAD_PRINCIPAL", "FLAG_TC_MODELOS", "FLAG_MES", "MARCA_HP", "MARCA_SEG_VIDA", "MARCA_DIF", "MARCA_CONV", "MARCA_DIF3", "MARCA_SEG_VIDA3", "MARCA_GAR3", "MARCA_DIF6", "MARCA_SEG_VIDA6", "MARCA_GAR6", "MARCA_HIP6", "MARCA_DIF12", "MARCA_SEG_VIDA12", "MARCA_GAR12", "MARCA_HIP12", "MARCA_CONV3", "MARCA_HIP3", "MARCA_CONV6", "MARCA_CONV12", "MARCA_GAR", "FLAG_TENENCIA_VEHICULAR", ] CATEGORICAL_COLS: list[str] = [ "GENERO", "GRADO_INSTRUCCION", "DEPARTAMENTO", "PROVINCIA", "DISTRITO", "SITUACION_LABORAL", "ESTADO_CIVIL", ] DISCRETE_COLS: list[str] = [ "EDAD", "MESES_ANT_RCC", "CONTAR_COMP", "MAX_ATRASO3", "MAX_ATRASO6", "MAX_ATRASO12", "NMES_UMORA", "Max_AumKP", "Max_AumMORA", "Max_AumKT", "Max_DismDTOTAL", "Max_AumMES", "Max_DismMES", "Max_DismCONS", "Max_AumCONS", "Max_AumDTOTAL", "Max_DismKP", "Max_DismKT", ] # ── Public helpers ──────────────────────────────────────────────────────────── def load_raw(input_path: Path) -> pd.DataFrame: """Load raw CSV from disk. Args: input_path: Absolute path to the raw CSV file. Returns: Raw ``DataFrame`` with all original columns preserved. """ logger.info(f"Loading raw data from {input_path}") df = pd.read_csv(input_path, low_memory=False, encoding="latin1") # Normalize whitespace-only strings (e.g. " ", " ") to proper NaN so # that type casting, null detection and numeric operations work correctly. df = df.replace(r"^\s*$", pd.NA, regex=True) logger.info(f"Loaded {len(df):,} rows × {df.shape[1]} columns") return df def cast_column_types(df: pd.DataFrame) -> pd.DataFrame: """Cast columns to correct dtypes based on the data dictionary. Conversion rules: * Binary flags → ``int8`` (0 / 1). * Categoricals → ``category``. * Discrete integers → nullable ``Int16``. * ``PERIODO`` → ``str`` (year-month identifier). * Target → ``int8``. Args: df: Raw ``DataFrame``. Returns: ``DataFrame`` with corrected dtypes. """ df = df.copy() for col in BINARY_FLAG_COLS: if col in df.columns: # -1 sentinel: distinguishes unknown from confirmed absence (0) df[col] = pd.to_numeric(df[col], errors="coerce").fillna(-1).astype("int8") for col in CATEGORICAL_COLS: if col in df.columns: df[col] = df[col].astype("category") for col in DISCRETE_COLS: if col in df.columns: df[col] = pd.to_numeric(df[col], errors="coerce").astype("Int16") if "PERIODO" in df.columns: df["PERIODO"] = df["PERIODO"].astype(str) if TARGET_COL in df.columns: # Invert encoding: original 1=Bueno → 0, 0=Malo/moroso → 1 # Convention: positive class (1) = risk event (morosidad) df[TARGET_COL] = (1 - pd.to_numeric(df[TARGET_COL], errors="coerce")).astype("int8") return df def impute_nulls(df: pd.DataFrame) -> pd.DataFrame: """Impute missing values with sentinel-based strategies for tree models. Sentinel values keep missingness as a learnable signal rather than conflating it with observed zero/mean values. Strategy by column type: * Binary flags → ``-1`` (distinguishes unknown from confirmed 0/1). * Categoricals → ``"Otros"`` (new sentinel category). * Discrete ints → ``-999`` (clearly out-of-range, fits ``Int16``). * Continuous → ``-999999`` (clearly out-of-range sentinel). Args: df: ``DataFrame`` after :func:`cast_column_types`. Returns: ``DataFrame`` with no remaining null values in feature columns. """ df = df.copy() null_counts = df.isnull().sum() cols_with_nulls = null_counts[null_counts > 0].index.tolist() logger.info(f"Columns with nulls before imputation: {len(cols_with_nulls)}") for col in tqdm(cols_with_nulls, desc="Imputing nulls"): if col in BINARY_FLAG_COLS: df[col] = df[col].fillna(-1) elif col in CATEGORICAL_COLS: # Sentinel category keeps missingness as a learnable signal for tree models df[col] = df[col].cat.add_categories("Otros").fillna("Otros") elif col in DISCRETE_COLS: # -999 fits within Int16 range and acts as a clear sentinel df[col] = pd.to_numeric(df[col], errors="coerce").fillna(-999).astype("Int16") else: # -999999 as sentinel for continuous features df[col] = pd.to_numeric(df[col], errors="coerce").fillna(-999999) return df def encode_categoricals(df: pd.DataFrame) -> pd.DataFrame: """Ordinal-encode categorical columns for tree-based models. Each category is mapped to its integer code (pandas ``cat.codes``). Unknown / unseen categories receive code ``-1`` automatically. Args: df: ``DataFrame`` with imputed values. Returns: ``DataFrame`` where categorical columns are stored as ``int16``. """ df = df.copy() for col in CATEGORICAL_COLS: if col in df.columns: df[col] = df[col].cat.codes.astype("int16") return df def get_feature_cols(df: pd.DataFrame) -> list[str]: """Return feature column names, excluding IDs, temporal cols and target. Args: df: Processed ``DataFrame``. Returns: Ordered list of column names suitable for model input. """ exclude = set(IDENTIFIER_COLS + TEMPORAL_COLS + [TARGET_COL]) return [c for c in df.columns if c not in exclude] if __name__ == "__main__": app()