predictiva_early / src /dataset.py
nicolasIsmaelUTP
fix: strip CLI deps, clean requirements, add curl examples
38b642a
Raw
History Blame Contribute Delete
6.4 kB
"""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()