Skip to content

Capstone 2 - Churn with Point-in-Time Features

The decision

A subscription business wants to spend a retention budget well. Every month, for each customer, it can send a retention offer or do nothing.

Cost of an offerEUR 4 to EUR 32, depending on what marketing chooses
Probability the offer retains a would-be churner0.30
Value of a retained customerEUR 120
Churn rate41.75% of customers in the following month

Contacting a customer is worth it when the expected saving exceeds the cost:

pchurn0.30120  >  offer    pchurn  >  offer0.30×120p_{\text{churn}} \cdot 0.30 \cdot 120 \;>\; \text{offer} \;\Longleftrightarrow\; p_{\text{churn}} \;>\; \frac{\text{offer}}{0.30 \times 120}

which is the same threshold derivation in a different costume. At an EUR 8 offer, contact anyone above 0.2222.

This capstone composes the point-in-time features, that threshold rule, and the honest-evaluation discipline from Phase 11.

Step 1 — the features could have existed

4,000 customers, 51,965 events in the 120 days before the cutoff, three features aggregated over the final 30 days: event count, mean amount, and days since the last event. Every one computed by a function that takes asofasof and never looks past it.

ModelTest AUCTest APBrier
point-in-time features0.70600.60100.2131
the same window, ending 30 days later0.9564

The second row is the number this project exists to avoid. Moving the window to cover the month the label describes gives +0.2504 of AUC, and every part of the pipeline still runs. The defence is that window_features(events, 30, asof, ids)window_features(events, 30, asof, ids) is called with asof = cutoffasof = cutoff at training time and asof = todayasof = today at serving time — one function, one argument, and a parity test that proves the serving path agrees to floating-point tolerance.

Drawn on a timeline, the legal and illegal windows are one shift apart:

diagram Diagram mermaid

The dotted path is the only difference between the two rows of that table: one argument to one function. This is why asofasof is a parameter rather than an implicit “now” — a function that reads the clock cannot be tested for point-in-time correctness, because there is no way to ask it what it would have known.

An AUC of 0.7060 is not impressive, and that is the point of the next step: the question is not whether 0.7060 is good, it is whether 0.7060 is worth anything.

Step 2 — price the campaign

figureSame model (AUC 0.7060), five sets of economicsmatplotlib
Left: grouped bars of campaign value for five offer costs, comparing contact-everybody, random targeting at the same volume, and model targeting. At EUR 4 all three are near 13,000; at EUR 16 contact-everybody is minus 1,164 while the model earns 2,676; at EUR 24 and EUR 32 the model contacts nobody and earns zero while contact-everybody loses 10,764 and 20,364. Right: value of the model over random targeting, rising from 576 at EUR 4 to 3,060 at EUR 16 and falling to zero at EUR 24.Left: grouped bars of campaign value for five offer costs, comparing contact-everybody, random targeting at the same volume, and model targeting. At EUR 4 all three are near 13,000; at EUR 16 contact-everybody is minus 1,164 while the model earns 2,676; at EUR 24 and EUR 32 the model contacts nobody and earns zero while contact-everybody loses 10,764 and 20,364. Right: value of the model over random targeting, rising from 576 at EUR 4 to 3,060 at EUR 16 and falling to zero at EUR 24.
At an EUR 4 offer the campaign is profitable for almost everyone, so targeting adds only EUR 576 — the model is nearly worthless because the decision is easy. At EUR 16 an untargeted campaign LOSES EUR 1,164 while the targeted one earns EUR 2,676, and the model is worth EUR 3,060. Above EUR 24 the threshold exceeds every predicted probability, so the correct action is to run no campaign at all.
OfferThreshold tt^*ContactedContact everybodyRandom, same volumeModelModel − random
EUR 40.11111,158EUR 13,236EUR 12,720EUR 13,296+576
EUR 80.22221,037EUR 8,436EUR 7,112EUR 8,876+1,764
EUR 160.4444546−EUR 1,164−EUR 384EUR 2,676+3,060
EUR 240.66670−EUR 10,764EUR 0EUR 00
EUR 320.88890−EUR 20,364EUR 0EUR 00

Four conclusions, none of which are visible in an AUC.

A mediocre model can be very valuable — or worthless — depending on the economics. The same 0.7060 is worth EUR 576 at a cheap offer and EUR 3,060 at an expensive one. Report the value, not just the metric.

When the offer is cheap, do not build a model. At EUR 4, contacting everybody earns EUR 13,236 against the model’s EUR 13,296 — a 0.5% difference. The correct engineering decision is to send the offer to everyone and spend the modelling effort elsewhere.

The model’s value peaks where the decision is genuinely hard. At EUR 16 targeting is the difference between losing EUR 1,164 and making EUR 2,676. This is where a churn model earns its maintenance cost.

Above EUR 24 the honest answer is “no campaign”. The threshold 0.6667 exceeds every predicted probability the model produces, so it contacts nobody and returns exactly zero — which beats every alternative, including the untargeted campaign’s EUR 20,364 loss. A model that recommends inaction is doing its job.

See it move

Three numbers set the whole policy: the offer cost, the save rate marketing promised, and what a customer is worth. The sketch puts all three on sliders and recomputes the threshold t=offer/(save rate×value)t^* = \text{offer} / (\text{save rate} \times \text{value}), the contacted set, and the campaign’s value — against contacting everybody and against no campaign at all.

sketch Three business inputs, one policy p5.js
Offer cost, save rate and customer value as draggable sliders. The threshold, the contacted count, the expected value and the comparison against contacting everybody all recompute from the model's real score distribution over 1,200 held-out customers. Above an offer of about EUR 24 the threshold passes the model's maximum score and the campaign correctly contacts nobody.

Push the save-rate slider and watch what happens to “the model is worth”. At a 30% save rate and an EUR 16 offer the model earns its keep; drop the save rate to 15% and the threshold doubles to 0.8889, the campaign contacts nobody, and the model’s value becomes exactly zero — not because the model got worse but because the offer stopped working. The single most valuable experiment on this project is therefore a holdout group that measures the save rate, and it costs no data science at all. That is the finding worth taking to the campaign owner, and it is invisible in any model metric.

Step 3 — the numbers a campaign owner needs

campaign.py
CONFIG = {"offer_cost": 16.0, "save_rate": 0.30, "customer_value": 120.0}
 
 
def campaign(scores, config):
    """Everything the owner of the retention budget has to sign off."""
    t = config["offer_cost"] / (config["save_rate"] * config["customer_value"])
    contact = scores >= t
    expected_saved = float((scores[contact] * config["save_rate"]
                            * config["customer_value"]).sum())
    spend = float(contact.sum()) * config["offer_cost"]
    return {
        "threshold": round(t, 4),
        "contacted": int(contact.sum()),
        "share_of_base": round(float(contact.mean()), 4),
        "spend": round(spend, 2),
        "expected_value_saved": round(expected_saved, 2),
        "expected_net": round(expected_saved - spend, 2),
        "break_even_save_rate": round(config["offer_cost"]
                                      / (config["customer_value"]
                                         * float(scores[contact].mean())), 4),
    }
campaign.py
CONFIG = {"offer_cost": 16.0, "save_rate": 0.30, "customer_value": 120.0}
 
 
def campaign(scores, config):
    """Everything the owner of the retention budget has to sign off."""
    t = config["offer_cost"] / (config["save_rate"] * config["customer_value"])
    contact = scores >= t
    expected_saved = float((scores[contact] * config["save_rate"]
                            * config["customer_value"]).sum())
    spend = float(contact.sum()) * config["offer_cost"]
    return {
        "threshold": round(t, 4),
        "contacted": int(contact.sum()),
        "share_of_base": round(float(contact.mean()), 4),
        "spend": round(spend, 2),
        "expected_value_saved": round(expected_saved, 2),
        "expected_net": round(expected_saved - spend, 2),
        "break_even_save_rate": round(config["offer_cost"]
                                      / (config["customer_value"]
                                         * float(scores[contact].mean())), 4),
    }

break_even_save_ratebreak_even_save_rate is the field that survives contact with a sceptical stakeholder: it answers “how effective does the offer have to be for this to pay for itself?” — and unlike the 0.30 assumption, it is a number derived from the model’s own scores.

Step 4 — what to monitor, given the labels are 30 days late

Churn is only observable a month after the prediction, so nothing label-based is available on the day. What is available immediately:

SignalWhy it mattersWhere it comes from
contacted share (0.4550 at EUR 16)budget consumption, todaythe policy itself
mean predicted probabilitydrifts when the population or the pipeline changesthe scores
feature parity against the offline pathcatches skew before performance movesthe parity test
schema violations per batcha broken join changes features silentlythe schema contract
realised save rate, when it arrivesthe 0.30 assumption is the biggest lever in the modelthe campaign’s own A/B test

The last row deserves emphasis: the save rate is not a model parameter and nobody measured it. It came from marketing’s estimate, it appears in the threshold, and the whole business case is proportional to it. A holdout group that receives no offer is worth more than any modelling improvement on this page, because it turns 0.30 from an assumption into a measurement.

What this project does not solve

  • The offer’s effect is assumed, not estimated. Predicting who will churn is not the same as predicting who will be retained by an offer — that is uplift modelling, and it requires randomised holdouts to train on.
  • A single cutoff. Real churn scoring runs monthly, so a customer appears many times; rows are not independent and a random split would leak across months.
  • Censoring. Customers who churn after the 30-day window are labelled as staying, which biases the target towards imminent churn.
  • The 41.75% base rate is unrealistic. Real monthly churn is a few percent, which makes the problem much more like the fraud capstone: average precision rather than AUC, and a threshold far from 0.5.

Recap

  • Point-in-time features gave test AUC 0.7060, AP 0.6010, Brier 0.2131; the same window ending 30 days later reported 0.9564.
  • Contact when p>offer/(save rate×customer value)p > \text{offer} / (\text{save rate} \times \text{customer value}) — 0.2222 at an EUR 8 offer.
  • At EUR 4 the model is worth EUR 576 over random and 0.5% over contacting everybody.
  • At EUR 16 an untargeted campaign loses EUR 1,164; the targeted one earns EUR 2,676, and the model is worth EUR 3,060.
  • Above EUR 24 the correct action is no campaign, and the model says so by contacting nobody.
  • The campaign’s value is proportional to an unmeasured save rate of 0.30; a holdout group is worth more than any modelling change here.
quizCheck yourself
  1. Your churn model has AUC 0.7060. Is it good enough to deploy?

    Show answer

    B — The question is incomplete: the same model is worth EUR 576 at a cheap offer and EUR 3,060 at an expensive one, so value depends on the economics rather than the metric — AUC is a property of the ranking; value is a property of the decision. At an EUR 4 offer, contacting everybody earns 99.5% of what the model earns, and the honest recommendation is to skip the model entirely.

  2. The retention offer costs EUR 16, and the untargeted campaign loses EUR 1,164. What does the model contribute?

    Show answer

    B — It turns a loss into a profit: EUR 2,676 against minus EUR 1,164, and EUR 3,060 more than random targeting at the same volume — This is where targeting earns its keep — the decision is genuinely hard, and the model's ranking is what separates the customers worth EUR 16 from the ones who are not.

  3. At an EUR 24 offer the model contacts nobody. Is that a failure?

    Show answer

    B — No: t* = 0.6667 exceeds every probability the model produces, so no customer's expected saving covers the offer, and zero beats the untargeted campaign's EUR 10,764 loss — A model that recommends inaction is doing its job. It also gives you a concrete improvement target: to keep the campaign viable at EUR 24 you need calibrated probabilities above 0.67, not a higher AUC.

  4. Which single investment would most improve this project's business case?

    Show answer

    B — A randomised holdout that receives no offer, turning the assumed 0.30 save rate into a measurement — The save rate multiplies the entire value calculation and nobody measured it. It is also the gateway to uplift modelling — predicting who responds to the offer rather than who churns.

  5. Why is a random train/test split wrong for a monthly churn scoring job?

    Show answer

    B — Because each customer appears at many cutoffs, so a random split puts the same customer's adjacent months on both sides and leaks — The version on this page uses one cutoff and splits by customer, which is defensible. As soon as you score monthly, you need grouped, time-ordered splits — customers grouped, months ordered.

🧪 Try It Yourself

Exercise 1 – Build point-in-time features and the model

Exercise 2 – Derive the contact threshold

Exercise 3 – Price the model against two baselines

Exercise 4 – Reproduce the leakage you are avoiding

Exercise 5 – Report what the campaign owner signs off

Next

Capstone 3 - Demand Forecasting for Ordering — from a contact decision to a quantity decision, where the asymmetry moves from the threshold into the loss function.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did