"""XGBoost churn prediction model.""" from datetime import date import numpy as np import pandas as pd import xgboost as xgb from sklearn.metrics import ( average_precision_score, f1_score, precision_score, recall_score, roc_auc_score, ) CHURN_THRESHOLD_DAYS = 180 CHURN_PROBA_CUTOFF = 0.5 FEATURE_COLS = [ # NOTE: recency_days intentionally excluded — it IS the churn target (leakage) "frequency", "monetary", "avg_order_value", "avg_review_score", "avg_days_to_delivery", "late_delivery_rate", "avg_delivery_delay", "complaint_rate", "tenure_days", ] class ChurnModel: def __init__(self) -> None: self.model: xgb.XGBClassifier | None = None self.feature_cols = FEATURE_COLS def define_churn_target( self, rfm_df: pd.DataFrame, reference_date: date | None = None ) -> pd.Series: """Label a customer as churned if recency > CHURN_THRESHOLD_DAYS.""" if reference_date is None: reference_date = date.today() churned = (rfm_df["recency_days"] > CHURN_THRESHOLD_DAYS).astype(int) churned.index = rfm_df["customer_unique_id"] return churned def build_feature_matrix( self, rfm_df: pd.DataFrame, clv_df: pd.DataFrame, delivery_df: pd.DataFrame, ) -> tuple[pd.DataFrame, pd.Series]: """Merge all feature sources and return (X, y).""" df = ( rfm_df[["customer_unique_id", "recency_days", "frequency", "monetary"]] .merge( clv_df[[ "customer_unique_id", "avg_order_value", "avg_review_score", "avg_days_to_delivery", "late_delivery_rate", "tenure_days", ]], on="customer_unique_id", how="inner", ) .merge( delivery_df[["customer_unique_id", "avg_delivery_delay", "complaint_rate"]], on="customer_unique_id", how="left", ) ) df["avg_delivery_delay"] = df["avg_delivery_delay"].fillna(0) df["complaint_rate"] = df["complaint_rate"].fillna(0) df = df.dropna(subset=self.feature_cols) # Churn target: no purchase in last CHURN_THRESHOLD_DAYS days y = (df["recency_days"] > CHURN_THRESHOLD_DAYS).astype(int).values return df, y def fit(self, X: np.ndarray, y: np.ndarray) -> "ChurnModel": neg, pos = (y == 0).sum(), (y == 1).sum() scale_pos = neg / pos if pos > 0 else 1.0 self.model = xgb.XGBClassifier( n_estimators=200, max_depth=5, learning_rate=0.05, scale_pos_weight=scale_pos, random_state=42, eval_metric="auc", verbosity=0, ) self.model.fit(X, y) return self def predict_proba(self, X: np.ndarray) -> np.ndarray: if self.model is None: raise RuntimeError("Model not fitted.") return self.model.predict_proba(X)[:, 1] def predict(self, X: np.ndarray, threshold: float = CHURN_PROBA_CUTOFF) -> np.ndarray: return (self.predict_proba(X) >= threshold).astype(int) def evaluate(self, X: np.ndarray, y: np.ndarray) -> dict: proba = self.predict_proba(X) labels = (proba >= CHURN_PROBA_CUTOFF).astype(int) return { "auc_roc": float(roc_auc_score(y, proba)), "avg_precision": float(average_precision_score(y, proba)), "f1": float(f1_score(y, labels, zero_division=0)), "precision": float(precision_score(y, labels, zero_division=0)), "recall": float(recall_score(y, labels, zero_division=0)), "churn_rate": float(y.mean()), }