"""Time-aware and stratified data splitters. Out-of-time validation is essential for credit / fraud models - random splits hugely overestimate generalisation because borrower behaviour drifts and macro conditions change. We default to time-based splits and use stratified random only for unit tests. """ from __future__ import annotations from typing import Tuple import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from ..utils.logging import get_logger log = get_logger(__name__) def time_based_split( df: pd.DataFrame, date_col: str = "issue_d", val_size: float = 0.15, test_size: float = 0.15, ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """Sort by date, then chronologically slice into train/val/test. Older loans go to train, the next chunk to val, the latest to test. This mirrors how a credit model actually gets deployed. """ if date_col not in df.columns: raise ValueError(f"Date column '{date_col}' not in DataFrame") df_sorted = df.sort_values(date_col).reset_index(drop=True) n = len(df_sorted) test_start = int(n * (1 - test_size)) val_start = int(n * (1 - test_size - val_size)) train = df_sorted.iloc[:val_start].reset_index(drop=True) val = df_sorted.iloc[val_start:test_start].reset_index(drop=True) test = df_sorted.iloc[test_start:].reset_index(drop=True) def _span(d: pd.DataFrame) -> str: if d.empty or d[date_col].isna().all(): return "empty" return f"{d[date_col].min().date()} → {d[date_col].max().date()}" log.info( f"Time-split: train={len(train):,} ({_span(train)}), " f"val={len(val):,} ({_span(val)}), test={len(test):,} ({_span(test)})" ) return train, val, test def stratified_split( df: pd.DataFrame, target_col: str = "is_fraud", val_size: float = 0.15, test_size: float = 0.15, random_seed: int = 42, ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """Stratified random split - fallback when timestamps aren't available.""" train_val, test = train_test_split( df, test_size=test_size, stratify=df[target_col], random_state=random_seed ) relative_val = val_size / (1 - test_size) train, val = train_test_split( train_val, test_size=relative_val, stratify=train_val[target_col], random_state=random_seed, ) log.info( f"Stratified split: train={len(train):,}, val={len(val):,}, test={len(test):,} | " f"fraud rate train={train[target_col].mean():.2%}, " f"val={val[target_col].mean():.2%}, test={test[target_col].mean():.2%}" ) return ( train.reset_index(drop=True), val.reset_index(drop=True), test.reset_index(drop=True), )