feat(customer-analytics): case 02 — churn without leakage - #2
Merged
Conversation
…be demonstrated The five cases sit on one data model, and case 02 (churn without leakage) needs something the model could not give it: a way to train on the past and score the future. With a single observation cutoff, "temporal validation" can only be asserted — the train/test split runs across customers, and the model is never asked to generalise forward, which is the only thing production asks of it. Emits `churn_labels_prior`: the same 90-day label observed `prior_cutoff_offset` months earlier (default 6, so the two outcome windows are disjoint). The risk proxy is re-derived from only the events that had happened by that cutoff — payment problems, unresolved escalations and campaign responses are now time-stamped internally — while latent traits stay time-invariant. Additive by construction: the new table is emitted *after* every other one, so the generator's random stream is untouched and the twelve existing tables are byte-for-byte identical (verified against sha256 of a pre-change seed-42 run). Two consequences documented rather than hidden: customers who signed up after the earlier cutoff are absent from the prior label, and a customer labelled churned at the prior cutoff still appears at the final one. The second is a deliberate simplification — defining the scoreable population is the case's job, and getting it wrong is one of the commonest real errors in churn work. Refs #64
Trains at one observation cutoff and scores six months later, then deliberately reruns the same model two dishonest ways to show what each shortcut reports. out of time AUC 0.690 +/- 0.014 in-time random split AUC 0.709 with one post-outcome feature AUC 1.000 The third line is the point: one field derived from the label, nothing errors, no test fails, and the model is worthless. The first two are the point in a quieter way — the gap is inside two standard errors, so the report says plainly that this dataset does not establish the random split is optimistic. The reason to evaluate out of time is methodological, not empirical. What the case actually finds: - The ranking survives the six-month gap; the calibration does not (slope 0.77). Recalibrating on the training period cannot fix a base rate that moved after the calibrator was fitted. So the answer is not a better model — it is recalibration against the most recent closed window plus a drift alarm. - Re-sorting the same probabilities by expected value instead of risk changes 44% of the contact list and +66% of the profit at the same budget, and peaks at fewer calls. Accuracy is not reported anywhere: at an 11.8% base rate, "nobody churns" scores 88.2%. - Of 9 designed drivers, 5 recovered with the correct sign and 0 contradicted; 4 are too weak to read individually, reported as such rather than as contradictions, since their marginal effect is smaller than its own s.e. - `retention_offer_taken` is inert marginally (-0.045) and strongly protective conditionally (-0.290) — the targeting confound the data model was built to contain. Flagged as a warning, not read as campaign ROI; that is case 05. Leakage is prevented in one place (`features._before`) and tested by poisoning the input tables with facts dated 2099 and asserting no feature moves. The standardiser, collinearity filter and calibrator are bundled with the model because re-deriving any of them from scoring data is leakage nothing would flag. Three near-duplicate features are pruned before the coefficients are read (arpu_last3 ~ monthly_fee, r=0.997). Not for accuracy — unpruned, the model reports that an unresolved escalation *reduces* churn, the opposite of how the data was built. An explanation that is wrong is worse than none, because someone will act on it. Standard library only, so CI validates it with nothing installed and the result stays reproducible byte-for-byte. Two real defects were caught by the new tests and fixed: KS stepped through tied scores row by row (a constant model could score 1.0 on sorted input), and equal-count bins split tied predictions across bin boundaries (a perfectly calibrated constant model could report ECE 0.36). 40 tests, ruff clean. Report and charts committed under outputs/ so the result is visible on GitHub without running anything. Refs #64
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs ronaldmego/site-ronaldmego#64 · builds on data-intelligence-lab#1 (the data model)
The second deliverable of the Customer Intelligence Lab: the first of the five cases, on the data model merged in #1.
What it does
Trains at one observation cutoff and scores six months later — an out-of-time backtest, the way a deployed model is judged. Then it deliberately reruns the same model two dishonest ways.
The third row is the point: one field derived from the label, nothing errors, no test fails, and the model is worthless. Leakage does not announce itself — it shows up as a result good enough that nobody questions it.
The first two rows are the point in a quieter way, and this is the bit I want reviewed. The gap is +0.020 against a standard error of ±0.014 — inside two standard errors. The report says plainly that this dataset does not establish the random split is optimistic, and argues the case for out-of-time evaluation on methodological grounds instead. It would have been easy to bank the number; it isn't real at this sample size.
What it finds
retention_offer_takenis inert marginally (−0.045) and strongly protective conditionally (−0.290) — the targeting confound the data model was built to contain. Flagged as a warning, explicitly not read as campaign ROI. That is case 05.Two commits
1 · data model — a second labelled cutoff. Case 02 needed something the model could not give it.
churn_labels_prioremits the same 90-day label 6 months earlier, with the risk proxy re-derived from only the events that had happened by then. Emitted after every other table so the random stream is untouched — the twelve existing tables are byte-for-byte identical, verified against sha256 of a pre-change seed-42 run.2 · the case itself. Pipeline, tests, report, charts.
How leakage is prevented, not just avoided
2099-01-01and asserts no feature moves.arpu_last3~monthly_fee, r=0.997). Not for accuracy — unpruned, the model reports that an unresolved escalation reduces churn, the opposite of how the data was built.Two real defects the new tests caught
Both were in code I had just written, and both produced better-looking numbers:
ks_statisticstepped through tied scores row by row, so a constant model could score KS 1.0 purely because the input arrived sorted by outcome.Checks
uvx pytestwith nothing installed ·uvx ruff check .cleanoutputs/committed, so the result is visible on GitHub without running anythingScope note
Standard library only — no pandas/scikit-learn. The estimator is a ridge-penalised logistic regression fitted by IRLS (~60 lines): the right default for a decision that has to be defended to a retention team, it keeps every number auditable, and it means no library version can silently change the result in three years. The hard part of this case was never the estimator.
Distribution (post/video) is Hermes' layer — ronaldmego/site-ronaldmego#65 — with this as the base material.