Capstone: Churn Prediction, then a Recommender
A brief, six data sources that were never designed for this, fourteen questions whose answers constrain each other, and the things that go wrong once the team acts on the output. Then a second system, where the model changes the data it will be trained on next.
This exercise exists so that “retrain it” is never the answer. Every injected failure has a response that is plausible, is what most teams reach for, and makes the next incident worse: retraining on the same leaked column reproduces the leak with fresh weights; swapping the model does nothing about a serving normaliser that disagrees with the training one; adding data to a feedback loop adds more of the loop. A learner who reaches for a new model before naming the mechanism has not finished.
Build a churn-prediction system for a global SaaS product sold on monthly and annual plans across every time zone. The goal is to identify accounts at high risk of cancelling at least 7 days before the cancellation would happen, so that a customer-success team with the capacity for roughly 200 conversations a week can choose which accounts to talk to. You will start the way everyone starts — a CSV exported from the warehouse, a notebook, a model with a validation number — and then evolve it into something that runs every week, is fed by six data sources that were never designed for this, survives the team acting on its output, and can be traced, rolled back and audited. Along the way things will break, and none of them will be fixed by retraining. At the end you must state which assumptions the system depends on to keep working — about the label, the features, the population and the intervention — and how you would know when each one stopped being true.
The data
None of it is algorithmically hard. All of it is about time: when the label exists, when the features exist, and when the intervention happened.
A user is a person with a locale, a time zone and a role inside an account, and the account is the thing that cancels — so the unit the model predicts is not the unit the table describes, and every user-level feature has to be aggregated to the account with a choice of how (the admin's activity? the median seat's? the most active?). Time zones make "day" ambiguous: a login at 23:30 in Sydney and one at 08:30 in Berlin are on different calendar days depending on which clock you use, and a "logins per day" feature computed in UTC in the warehouse and in local time in the product disagree by a whole day at the edges. Users are also deleted on request, and a deleted user takes their events with them, which makes the history of churned accounts systematically thinner than the history of live ones.
The subscriptions table has status, plan, term, renews_at and cancelled_at, and it is the source of the label — which makes it the most dangerous table in the system. cancelled_at must never be a feature, obviously, but neither must anything derived from it: status, which reads "cancelling" for weeks before the effective date; renews_at, which is nulled at cancellation; the row's updated_at, which moves when the cancellation is recorded. The table is also mutable and snapshotless — it shows the current state, not the state as of any past date — so building honest training rows means reconstructing history from an audit log or a daily snapshot, and if neither exists the dataset cannot be built correctly from this table at all.
Events are the richest signal and the least trustworthy. They arrive late — a mobile client batches them and uploads hours or days later — so a 7-day login count computed on Monday and again on Thursday for the same week gives different answers, and the training pipeline, which runs later, always sees more complete data than serving did. They are duplicated by retries, so raw counts overstate activity unpredictably. Their schema changes with every release: an event is renamed, a property becomes optional, a platform stops firing it — and a feature that silently drops to zero for iOS users looks exactly like iOS users disengaging.
A payment is not one row; it is an attempt, a retry, a retry, a success or a final failure, spread over up to two weeks of dunning. A naive "failed payments in 90 days" counts retries as failures and makes every account with a flaky card look like it is churning. Involuntary churn — the card expires and the account lapses — is recorded as a cancellation by the billing system and is a different problem with a different fix (a dunning email, not a CSM call), so it must be separated from the label or the model learns to predict card expiry. Payments also settle in the provider's time, which is not the product's time zone or the warehouse's.
Support tickets look like the ideal early-warning signal, and some are. But a large fraction of tickets from accounts that cancel are opened *because* the account is cancelling — "how do I export my data", "please close my account", a billing dispute during offboarding — which makes "has open ticket" one of the strongest features in any naive model and one of the most leaked. Ticket timestamps are when the ticket was created, but the category and resolution fields are edited afterwards, so a point-in-time join on created_at still pulls in the future through the other columns. The honest version uses only tickets created before the snapshot date, with the category as it was at creation, and then checks whether the feature still predicts anything at a 7-day horizon.
Emails sent, offers shown, calls made. This is the intervention, and once the model is live it is the intervention the model caused: an account flagged as at-risk gets a call, which changes whether it churns, which changes the label that ends up in next quarter's training set. Used as a feature, "received a retention offer" becomes the strongest predictor of the previous model's score. Left out, the intervention's effect is silently attributed to the account's own features. It has to be recorded on every training row as the treatment it is, and a random holdout of flagged accounts that receive nothing is the only population whose labels still mean what the label definition says.
The questions
Answer them in this order. The label horizon decides the split; the split decides what the validation number means; the action's capacity decides the threshold; the threshold decides whether batch scoring is enough.
- 1
What is the exact label?
- 2
What is one training example?
- 3
Which features are available at prediction time?
- 4
How should data be split?
- 5
Where can leakage happen?
- 6
What baseline should be used?
- 7
Which metric maps to business cost?
- 8
How should the threshold be selected?
- 9
Batch or online inference?
- 10
How is preprocessing reproduced in serving?
- 11
How is the model versioned?
- 12
How is rollout performed?
- 13
What gets monitored?
- 14
When should we retrain?
Injected failures
Each arrives while the system is live and the team is acting on it. Read the trap even when you got the cause — it is the part that costs real teams the quarter.
A notebook with a random 80/20 split over a one-row-per-account-per-week dataset. The top features by importance are tenure, plan tier and a 90-day activity count. The confusion matrix looks excellent at 0.5. Everyone is pleased.
The strongest feature in the new model is days_until_renewal, which was always plausible — renewal proximity should predict churn. Second is subscription_updated_at as a recency feature. The list for next Monday is dominated by accounts the CSM lead recognises as "already gone".
The outcome monitor confirms it, a month late: precision in the top 200 has fallen from an illustrative one in three to one in six. The feature distributions are stable. The score distribution has shifted down slightly. The rule baseline's precision on the same weeks has fallen too.
The data monitor fires on active_days_7 for the iOS slice — its mean dropped to near zero over the weekend. The other platforms are normal. The model, correctly, reads "no activity for a week" as high risk and ranks every affected account near the top.
The shadow comparison flags the divergence. Spot-checking, the challenger ranks tiny new accounts very high and large old ones very low — roughly the inverse of the champion. On the validation set in the notebook the two agree far more closely.
A latency dashboard for the web tier with a step change on the deploy day, an alert on the p95 SLO, and a Slack thread proposing to "move the model to a GPU" because the neural version was benchmarked faster there in a notebook.
A drift dashboard with the score histogram shifted right. Feature monitors show a modest shift in active_days_30 and a larger one in days_since_last_ticket. Outcome precision, of course, will not be known for a month. A colleague proposes an emergency retrain "on the drifted data".
A panel labelled "precision (last 7 days)" computed over the handful of last week's scored accounts that happen to have cancelled already — because the only accounts with a positive label inside a week are the ones who cancelled within days. The number swings between 0 and 40% week to week and someone has proposed rolling back the promotion.