Churn Model With 0.94 AUC and 0.04 Precision in Production
A subscription business has 1.2M customer-months of data with a 2.1% churn rate and 140 candidate features. The team runs a random 80/20 split with XGBoost and sees validation AUC 0.94 and PR-AUC 0.62. After eight weeks in production, precision at the deployed 0.5 probability threshold is 0.04. The retention team can make 500 calls per week. Two features are suspicious: account_closed_reason, which is null for active accounts, and support_tickets_last_30d. Exploratory analysis also shows days_since_last_login with a bimodal distribution and a large spike at exactly 999.
1. Explain the gap between validation AUC 0.94 and production precision 0.04, and name the changes that make the offline estimate trustworthy.
Read the worked answer
Three separate problems compound here.
- Leakage in the split. A random row split puts the same customer in train and test in different months. The model learns customer identity and next month's outcome indirectly. Split by time instead (train months 1-18, validate 19-21, hold out 22-24) so the validation set asks the real question: predict a future month from the past.
- Target leakage in a feature.
account_closed_reasonis only populated after the customer has closed, i.e. after the event being predicted. It must be dropped, not imputed. Checksupport_tickets_last_30dtoo: if the ticket extract is refreshed after the label window, it carries post-outcome information. - Metric mismatch. AUC measures ranking and is nearly insensitive to a 2.1% base rate, so 0.94 sounds better than it is. At that prevalence PR-AUC (0.62 was already the honest signal) and precision at the actual operating capacity are the metrics that govern the business outcome. A 0.5 threshold on a 2% base rate is arbitrary and produces exactly the 4% precision observed.
Do not resample or SMOTE before the split; use class weights or scale_pos_weight inside the model, evaluated on the untouched time-based holdout.
2. The team can call 500 accounts per week. Turn that into a concrete deployment decision and say how you validate it.
Read the worked answer
The constraint is capacity, not a probability threshold, so deploy a ranking with a fixed top-K, not a fixed cut-off.
- On the time-based validation period, compute the model's ordering and measure precision@500 and recall@500 - the fraction of real churners captured by the top 500 accounts that week.
- Compare against the do-nothing baseline: 500 random accounts capture about 500 x 2.1% ≈ 10.5 churners. If the model's top 500 captures, say, 60, the lift is roughly 5-6x and the expected value per call is (precision x save rate x account value) minus call cost. That calculation, not the AUC, is what justifies the project.
- Deploy by sorting scored accounts and taking the top 500 per week. Recompute the effective threshold each week, because with a drifting base rate a fixed probability silently changes the number of calls and the precision.
- Guard rails: a data quality monitor on the top features and a model quality monitor once churn labels land, plus a weekly review of precision@500. If precision@500 degrades while data quality is stable, retrain; if data quality alarms fire first, fix the pipeline.
Finally, run Clarify bias metrics (class imbalance, difference in positive proportions in predicted labels) across region and tenure segments before go-live, since a capacity-limited process makes whatever it skips invisible.
3. How should the 999 sentinel in `days_since_last_login` be handled, and what is the argument against mean imputation?
Read the worked answer
First establish what 999 means, because the treatment depends on the semantics. It almost always encodes "never logged in" or "unknown", not "999 days ago". Check its correlation with tenure and with churn - if never-logged-in users churn at a different rate, the sentinel carries real signal.
- Wrong: impute to the feature mean. That places never-logged-in users in the middle of the activity distribution, which is the opposite of the truth, and it makes the imputed rows indistinguishable from genuinely average users.
- Better: convert 999 to null, then add a binary
has_never_logged_inindicator and let the tree split on the indicator. Set the null to a value outside the observed range (or let XGBoost's native missing handling route it), so the model can separate the group without a spurious 999-day ordinal claim. - Why it matters even for trees: a split at
days_since_last_login < 30is unaffected, but every partial dependence plot, SHAP summary and any downstream linear or distance-based model is distorted by the fake magnitude. It also silently poisons any future feature that averages or bins this column.
Validate the choice on the time-based holdout: compare AUC/PR-AUC for sentinel-as-value versus sentinel-as-missing-plus-indicator, and check that the change in SHAP attribution is explainable to the business.