dima806's picture
Upload 51 files
3677ab8 verified
Raw
History Blame Contribute Delete
3.34 kB
"""Rule-based churn baseline model.
Rule (from PRD §7):
IF recency > CHURN_PERIOD_DAYS
OR orders_last_60d == 0
THEN is_churned = 1
For continuous-score metrics (ROC-AUC, PR-AUC), we derive a soft score
from normalised recency so the model is comparable with probability outputs.
"""
import pandas as pd
from src.config import CHURN_PERIOD_DAYS
from src.utils.logger import get_logger
logger = get_logger(__name__)
def predict_churn_rule_based(
features: pd.DataFrame,
churn_period_days: int = CHURN_PERIOD_DAYS,
) -> tuple[pd.Series, pd.Series]:
"""Apply the rule-based churn heuristic.
Args:
features: Feature matrix that must contain ``recency`` and
(optionally) ``orders_last_60d``.
churn_period_days: Threshold for the recency rule.
Returns:
Tuple of:
- ``y_pred`` – binary prediction Series (0 / 1).
- ``y_score`` – continuous churn score in [0, 1] suitable for
ROC-AUC / PR-AUC computation.
"""
orders_60d = features.get("orders_last_60d", pd.Series(1, index=features.index, dtype=int))
y_pred = ((features["recency"] > churn_period_days) | (orders_60d == 0)).astype(int)
# Continuous score: normalised recency, boosted when 60-day window is empty
max_recency = max(float(features["recency"].max()), 1.0)
y_score = (features["recency"] / max_recency).clip(0.0, 1.0)
y_score = y_score.where(orders_60d > 0, other=y_score.clip(lower=0.70))
return y_pred, y_score
def evaluate_baseline(
features: pd.DataFrame,
target: pd.DataFrame,
churn_period_days: int = CHURN_PERIOD_DAYS,
) -> dict:
"""Compute evaluation metrics for the rule-based baseline.
Args:
features: Feature matrix with ``customer_id`` column.
target: Target DataFrame with ``customer_id`` and ``is_churned``.
churn_period_days: Churn recency threshold.
Returns:
Dict with precision, recall, roc_auc, pr_auc.
"""
from sklearn.metrics import (
average_precision_score,
brier_score_loss,
f1_score,
precision_score,
recall_score,
roc_auc_score,
)
df = features.merge(target, on="customer_id", how="inner")
y_true = df["is_churned"]
y_pred, y_score = predict_churn_rule_based(df, churn_period_days)
metrics = {
"roc_auc": float(roc_auc_score(y_true, y_score)),
"pr_auc": float(average_precision_score(y_true, y_score)),
"brier_score": float(brier_score_loss(y_true, y_score)),
"precision": float(precision_score(y_true, y_pred, average="weighted", zero_division=0)),
"recall": float(recall_score(y_true, y_pred, average="weighted", zero_division=0)),
"f1": float(f1_score(y_true, y_pred, average="weighted", zero_division=0)),
"n_samples": int(len(df)),
"churn_rate": float(y_true.mean()),
}
logger.info(
f"Baseline ROC-AUC={metrics['roc_auc']:.4f} "
f"PR-AUC={metrics['pr_auc']:.4f} "
f"Brier={metrics['brier_score']:.4f} "
f"Precision={metrics['precision']:.4f} "
f"Recall={metrics['recall']:.4f} "
f"F1={metrics['f1']:.4f} "
f"N={metrics['n_samples']:,} "
f"churn_rate={metrics['churn_rate']:.2%}"
)
return metrics