Churn Prediction — Leakage-Free Retention Model preview
Situation
A churn model for a subscription product: a random split leaks future activity into training
Task
Score the model so its offline quality is not an artifact of leakage
Action
As-of features, a future inactivity label, a chronological split, decisions driven by recall@top-decile and lift
Result
The top 10% riskiest catches 54.4% of churners at 5.44x lift, but only 1.75x on the at-risk subgroup: 69.7% of the cohort is a near-guaranteed negative by construction.
Stack
PythonLightGBMscikit-learnpandas / NumPymatplotlibpytestuv
Source View on GitHub → Updated: Nov 1, 2026
On this page
  1. Situation
  2. Task
  3. Actions
  4. Features and label
  5. Split and metric
  6. Model and tests
  7. Run
  8. Result
  9. Limitations
  10. Documentation
  11. Frequently asked

Churn Prediction — Leakage-Free Retention Model

▶ Data-flow diagram — self-contained HTML, opens in a browser — no install, no build

Situation

The difficulty of a churn model for a subscription product is not the algorithm but the discipline: features must be computed as-of a snapshot date, the target must be a future inactivity window, and the split must be chronological.

A random train/test split puts a user’s future activity into training and their past into test: the model shows a pretty number that production never delivers. The project exists to eliminate that leakage — the same principle as A/B analysis: no future in the moment being modelled.

Task

I needed a churn model whose offline score would survive contact with production, so I owned the as-of feature build, the future inactivity target, and the chronological split that keep the future out of the training moment.

Actions

The data is synthetic and deterministic (seed = 42): 12,000 users sign up between Jun 2023 and Jan 2024, a channel-driven tenure (exponential lifetime) and a plan-driven daily activity probability; activity decays slightly approaching churn; the snapshot base rate base_rate = 0.174 is a mixture artifact, not a churn share.

Features and label

  • As-of features — recency, activity over prior 7/14/30 days, tenure, recent trend, average sessions, plus categorical channel / device / country / plan.
  • Leakage-free label — churn = no activity in [snapshot, snapshot+30d] for a user who was active in the prior 30 days. Long-dead users are excluded rather than labelled: predicting on ghosts is not a real task.

Split and metric

  • Chronological split — train (2024-01-15) -> val (14.02.2024) -> test (2024-03-15). A random split would put a user’s future activity into training and their past into test — silent leakage, and eliminating it is the whole reason the project exists.
  • Business metric first — ROC-AUC and PR-AUC are reported, but the decision metric is recall@top-decile and lift@top-decile: if retention acts on the top 10% riskiest users, how many actual churners do we catch.

Model and tests

  • SHAP — LightGBM’s native predict_proba(pred_contrib=True) (TreeSHAP without the shap package). recency_days dominates.
  • Baseline — a fairly-fitted logistic regression (no class_weight="balanced", which distorted its probabilities).
  • Tests — 39 pytest: leakage properties (deleting post-snapshot activity leaves features unchanged), config integrity, metrics and plots.
Metric (test snapshot) LightGBM LogReg
ROC-AUC 0.916 0.917
PR-AUC 0.824 0.828
Recall@top-10% 0.544 0.537
Lift@top-10% 5.44x 5.37x
Brier (calibration) 0.063 0.062

Run

# Python >=3.10. Deps: pandas, numpy, scikit-learn, lightgbm, matplotlib.
uv run --with pandas --with numpy python data/generate_data.py
uv run --with pandas --with numpy --with scikit-learn --with lightgbm --with matplotlib python run.py
uv run --with pandas --with numpy --with scikit-learn --with lightgbm --with matplotlib --with pytest pytest -q

Outputs land in reports/: metrics.json + evaluation.png (ROC, PR, SHAP bar).

Result

LightGBM and logistic regression run even: the synthetic features are nearly linear, and the linear model is no worse — LogReg is marginally ahead on AUC, PR-AUC and Brier (0.062 vs 0.063). The honest finding: boosting earns no trophy here. The project demonstrates a leakage-free evaluation setup, not that GBM beats logistic regression. recency_days is so strong that the lift ceiling is low for any model: within the top 10% riskiest users the model catches 54.4% of real churners at 5.44x lift.

  • Money threshold: the score becomes a spending decision — offer cost and retained-customer value set the minimum probability below which no contact pays back.
  • Honest scale: on the at-risk subgroup (n = 1,692 of 5,581) lift falls to 1.75x and Brier rises to 0.163 / 0.157 — the headline metrics are earned mostly on users who could not have churned inside the label window.

Limitations

The synthetic features are nearly linear, so the boosting edge in ranking is not informative here. The project demonstrates a leakage-free evaluation setup and score calibration, not that GBM beats logistic regression. The lift ceiling is set by the strength of recency_days, not model quality — on other data the gap could be larger.

Documentation

Frequently asked

Why a chronological split instead of a random one?

A random split leaks future activity into training: offline quality becomes an artifact of the leak rather than of signal.

How is the churn label defined?

As a future 30-day inactivity window, applied only to recently active users.

How good is the model?

ROC-AUC 0.916: in the top 10% riskiest the model catches 54.4% of real churners at 5.44x lift.

Why is the decision metric recall and not AUC alone?

A retention campaign works the top of the risk list, so the decision follows recall@top-10% and lift.

Why look at Brier?

Brier is sensitive to probability calibration: 0.063 for LightGBM versus 0.062 for a fairly-fitted logistic baseline — a tie. The earlier 0.099 Brier was an artifact of an unbalanced baseline.

Need this reasoning applied to your data?

Tell me the task — I'll come back with a plan: what data it needs, what we test, and how long it takes. I reply within a day.

Discuss a task →