The brief
The retention team of a telecom company has budget for a retention offer and two questions:
- How long do our customers last?
- Which of our current customers should get the offer?
A colleague has already answered the first one on a slide: "churned customers stayed 18 months on average". You suspect that number is badly wrong, because three quarters of the customers in the file have not churned at all - and they are not missing data, they are the customers who last longest.
How this notebook works
- Each task states what to deliver, not how. Write your code in the
YOUR CODE HEREcells. - Stuck?
h.hint("task2")reveals hints one level at a time: nudge → approach → code skeleton. Try to get by on nudges. h.check("task2", k_shared=...)compares your numbers with the reference solution.- A full worked solution lives in
notebooks/solutions/. Open it only when you are done (or truly stuck). - Work through example E06 first: this challenge assumes you know what
pm.Censoreddoes.
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
from scipy import stats
from pymc_challenges import Hints, data
RANDOM_SEED = 7043
rng = np.random.default_rng(RANDOM_SEED)
az.style.use("arviz-variat")
h = Hints("C04")
h.tasks()
C04 - Customer churn: most of your customers have not left yet
task0- Frame churn as a time-to-event problem (3 hints, 2 check(s))task1- Put a number on the colleague's mistake (3 hints, 3 check(s))task2- A censored Weibull regression (3 hints, 2 check(s))task3- Design a check the model can fail (3 hints, 1 check(s))task4- Diagnose, fix, compare (3 hints, 3 check(s))task5- Conditional churn risk for active customers (3 hints, 2 check(s))task6- Who gets the offer? (3 hints, 2 check(s))
The data
One row per customer, taken at a single snapshot date. tenure is the number of months the
customer has been (or was) with the company, Churn says whether they have left. The other
columns describe the contract, the services booked and how the customer pays.
data.describe("telco")
telco = data.load("telco")
telco[["customerID", "tenure", "Churn", "Contract", "InternetService", "PaymentMethod",
"MonthlyCharges", "TotalCharges"]].head()
telco: Tenure (months), churn flag and contract details for 7043 customers. source: IBM sample data, Telco customer churn. url: https://raw.githubusercontent.com/IBM/telco-customer-churn-on-icp4d/master/data/Telco-Customer-Churn.csv
| customerID | tenure | Churn | Contract | InternetService | PaymentMethod | MonthlyCharges | TotalCharges | |
|---|---|---|---|---|---|---|---|---|
| 0 | 7590-VHVEG | 1 | No | Month-to-month | DSL | Electronic check | 29.85 | 29.85 |
| 1 | 5575-GNVDE | 34 | No | One year | DSL | Mailed check | 56.95 | 1889.5 |
| 2 | 3668-QPYBK | 2 | Yes | Month-to-month | DSL | Mailed check | 53.85 | 108.15 |
| 3 | 7795-CFOCW | 45 | No | One year | DSL | Bank transfer (automatic) | 42.30 | 1840.75 |
| 4 | 9237-HQITU | 2 | Yes | Month-to-month | Fiber optic | Electronic check | 70.70 | 151.65 |
Task 0 · Frame churn as a time-to-event problem
Deliver
- A clean analysis table. Two things need a decision you can defend in one sentence each:
TotalChargesdoes not parse as a number, and some customers havetenure == 0. - The framing: what is the "event", what is the "time", which rows are censored and at what value? What share of customers is censored - overall and per contract type?
- Kaplan-Meier survival curves per contract type in one figure. What do they tell you beyond "customers on long contracts churn less"? How far out can each curve be trusted?
telco["TotalCharges"] = pd.to_numeric(telco.TotalCharges, errors="coerce")
print("rows with unparseable TotalCharges:", int(telco.TotalCharges.isna().sum()))
telco.loc[telco.tenure == 0, ["tenure", "Churn", "Contract", "MonthlyCharges", "TotalCharges"]]
rows with unparseable TotalCharges: 11
| tenure | Churn | Contract | MonthlyCharges | TotalCharges | |
|---|---|---|---|---|---|
| 488 | 0 | No | Two year | 52.55 | NaN |
| 753 | 0 | No | Two year | 20.25 | NaN |
| 936 | 0 | No | Two year | 80.85 | NaN |
| 1082 | 0 | No | Two year | 25.75 | NaN |
| 1340 | 0 | No | Two year | 56.05 | NaN |
| 3331 | 0 | No | Two year | 19.85 | NaN |
| 3826 | 0 | No | Two year | 25.35 | NaN |
| 4380 | 0 | No | Two year | 20.00 | NaN |
| 5218 | 0 | No | One year | 19.70 | NaN |
| 6670 | 0 | No | Two year | 73.35 | NaN |
| 6754 | 0 | No | Two year | 61.90 | NaN |
The blank TotalCharges are the tenure == 0 rows: customers who signed up in the
snapshot month and have not been billed yet. None of them has churned. A customer censored
at $t = 0$ contributes $\log S(0) = \log 1 = 0$ to a survival likelihood - exactly no
information - so dropping these 11 rows costs nothing (and spares us $\log 0$ in a Weibull).
TotalCharges itself stays out of every model: it is roughly tenure x MonthlyCharges,
i.e. the outcome in disguise.
Framing. Time = tenure in months. Event = churn. A customer with Churn == "No" is
right-censored at the current tenure: all we know is that the lifetime exceeds it.
df = telco[telco.tenure > 0].reset_index(drop=True)
df["churned"] = df.Churn == "Yes"
tenure = df.tenure.to_numpy(dtype=float)
churned = df.churned.to_numpy()
contract_idx, contracts = pd.factorize(df.Contract, sort=True)
CONTRACT_COLORS = dict(zip(contracts, ["C0", "C1", "C2"]))
framing = df.groupby("Contract").agg(
customers=("churned", "size"), churned=("churned", "sum"), mean_tenure=("tenure", "mean")
)
framing["censored_share"] = 1 - framing.churned / framing.customers
print(f"censored share overall: {1 - churned.mean():.3f}")
framing.round(3)
censored share overall: 0.734
| customers | churned | mean_tenure | censored_share | |
|---|---|---|---|---|
| Contract | ||||
| Month-to-month | 3875 | 1655 | 18.037 | 0.573 |
| One year | 1472 | 166 | 42.073 | 0.887 |
| Two year | 1685 | 48 | 57.072 | 0.972 |
def kaplan_meier(time, event):
"""Kaplan-Meier estimate as step-function knots (t, S), starting at (0, 1)."""
time, event = np.asarray(time, dtype=float), np.asarray(event, dtype=bool)
t_event = np.unique(time[event])
at_risk = np.array([(time >= t).sum() for t in t_event])
events = np.array([((time == t) & event).sum() for t in t_event])
return np.r_[0, t_event], np.r_[1, np.cumprod(1 - events / at_risk)]
def km_at(time, event, t):
"""Kaplan-Meier survival evaluated at the time(s) t."""
knots, surv = kaplan_meier(time, event)
return surv[np.searchsorted(knots, t, side="right") - 1]
def plot_km(ax, lw=2, ls="-", label_prefix="KM, "):
for name in contracts:
grp = df[df.Contract == name]
ax.step(*kaplan_meier(grp.tenure, grp.churned), where="post", color=CONTRACT_COLORS[name],
lw=lw, ls=ls, label=f"{label_prefix}{name}")
ax.set(xlabel="tenure (months)", ylabel="S(t) = P(still a customer)", ylim=(0, 1.02))
fig, ax = plt.subplots()
plot_km(ax)
ax.legend(loc="lower left");

m2m = df[df.Contract == "Month-to-month"]
km_m2m_12 = km_at(m2m.tenure, m2m.churned, 12)
print(f"KM survival of month-to-month customers at 12 months: {km_m2m_12:.3f}")
print(f"... of whom lost in the very first month: {1 - km_at(m2m.tenure, m2m.churned, 1):.3f}")
at_risk = pd.DataFrame(
{name: [(df.tenure[df.Contract == name] >= t).sum() for t in (1, 24, 48, 60, 67, 70)] for name in contracts},
index=pd.Index([1, 24, 48, 60, 67, 70], name="still under observation at month"),
)
at_risk
KM survival of month-to-month customers at 12 months: 0.703 ... of whom lost in the very first month: 0.098
| Month-to-month | One year | Two year | |
|---|---|---|---|
| still under observation at month | |||
| 1 | 3875 | 1472 | 1685 |
| 24 | 1202 | 1172 | 1553 |
| 48 | 357 | 662 | 1284 |
| 60 | 127 | 337 | 1019 |
| 67 | 35 | 154 | 755 |
| 70 | 11 | 72 | 568 |
assert h.check("task0", censored_share=1 - churned.mean(), km_m2m_12=km_m2m_12)
- PASS
censored_share= 0.7342 is consistent with the reference solution. - PASS
km_m2m_12= 0.7031 is consistent with the reference solution.
- 73% of all customers are censored - 57% of month-to-month customers and 97% of two-year customers. Any method that cannot use censored rows throws away most of the data, and not at random.
- The curves differ in shape, not just level. Month-to-month customers leave early: one in ten is gone after the first month, 30% within a year, and then the curve flattens. Contract customers do the opposite: hardly anybody leaves in the first two years, then the curve bends downwards. Remember this picture.
- The right-hand end of each curve rests on few customers (the at-risk table): only 35 month-to-month customers are still under observation at month 67, so the cliff at the end of that curve is a handful of events.
Task 1 · Put a number on the colleague's mistake
"Churned customers stayed 18 months on average, so a customer lasts about a year and a half."
Deliver
- Reproduce the number. Then give the approach its best shot: fit an intercept-only Weibull model to the churned customers alone and report the median customer lifetime and the probability that a new customer is still there after 24 months.
- The same model and the same two numbers, fitted to all customers with the censoring handled properly.
- Both survival curves on top of the overall Kaplan-Meier estimate. By what factor is the naive median off? And of your two "correct" numbers, which one rests on data and which is an extrapolation?
Solution
One function, two datasets. The scale is parameterised on the log scale; Normal(4, 1.5)
puts the characteristic lifetime around $e^4 \approx 55$ months with a 94% range from 3
months to 75 years. LogNormal(0, 0.75) centres the shape on a constant hazard ($k = 1$)
and covers roughly 0.25-4.
upper = np.where(churned, np.inf, tenure) # censored rows sit ON their bound, events have none
def fit_intercept_only(time, upper):
with pm.Model() as model:
a = pm.Normal("a", 4.0, 1.5)
k = pm.LogNormal("k", 0, 0.75)
pm.Censored("tenure", pm.Weibull.dist(alpha=k, beta=pm.math.exp(a)), lower=None, upper=upper,
observed=time)
idata = pm.sample(random_seed=RANDOM_SEED)
return idata
naive_idata = fit_intercept_only(tenure[churned], np.full(churned.sum(), np.inf))
censored_idata = fit_intercept_only(tenure, upper)
def weibull_summaries(idata):
post = az.extract(idata)
scale, k = np.exp(post["a"].values), post["k"].values
return {"k": k, "median": scale * np.log(2) ** (1 / k), "S(24)": np.exp(-((24 / scale) ** k)),
"scale": scale}
naive, correct = weibull_summaries(naive_idata), weibull_summaries(censored_idata)
print(f"mean tenure of churned customers: {tenure[churned].mean():.1f} months")
table = pd.DataFrame(
{label: {f"{key} ({stat})": fn(s[key]) for key in ["k", "median", "S(24)"]
for stat, fn in [("mean", np.mean), ("3%", lambda x: np.quantile(x, 0.03)),
("97%", lambda x: np.quantile(x, 0.97))]}
for label, s in [("churned only", naive), ("all customers, censored", correct)]}
).T.round(3)
print(f"Kaplan-Meier S(24) = {km_at(tenure, churned, 24):.3f}, S(72) = {km_at(tenure, churned, 72):.3f}")
print(f"naive median is off by a factor of {correct['median'].mean() / naive['median'].mean():.0f}")
table
mean tenure of churned customers: 18.0 months Kaplan-Meier S(24) = 0.789, S(72) = 0.593 naive median is off by a factor of 12
| k (mean) | k (3%) | k (97%) | median (mean) | median (3%) | median (97%) | S(24) (mean) | S(24) (3%) | S(24) (97%) | |
|---|---|---|---|---|---|---|---|---|---|
| churned only | 0.821 | 0.793 | 0.850 | 10.357 | 9.707 | 11.007 | 0.251 | 0.236 | 0.267 |
| all customers, censored | 0.645 | 0.621 | 0.669 | 124.830 | 115.057 | 135.422 | 0.787 | 0.778 | 0.796 |
grid = np.linspace(0, 72, 145)
fig, ax = plt.subplots()
ax.step(*kaplan_meier(tenure, churned), where="post", color="k", lw=2, label="Kaplan-Meier, all customers")
for label, s, color in [("Weibull, churned customers only", naive, "C3"),
("Weibull, censoring handled", correct, "C0")]:
curves = np.exp(-((grid[:, None] / s["scale"]) ** s["k"]))
ax.fill_between(grid, *np.quantile(curves, [0.03, 0.97], axis=1), color=color, alpha=0.3)
ax.plot(grid, curves.mean(axis=1), color=color, label=label)
ax.set(xlabel="tenure (months)", ylabel="S(t)", ylim=(0, 1.02))
ax.legend(loc="lower left");

assert h.check("task1", naive_median=naive["median"].mean(), naive_s24=naive["S(24)"].mean(),
censored_s24=correct["S(24)"].mean())
- PASS
naive_median= 10.36 is consistent with the reference solution. - PASS
naive_s24= 0.2511 is consistent with the reference solution. - PASS
censored_s24= 0.787 is consistent with the reference solution.
The colleague's analysis conditions on the outcome: it asks "how long did the customers who left stay?", which by construction excludes everybody who stays long.
- Churned only: median lifetime about 10 months, and only a quarter of new customers are predicted to survive two years. The interval is tight - 1,869 churners make the model very sure of the wrong answer.
- Censoring handled: 79% survive two years, matching Kaplan-Meier, and the median lifetime is about ten years - an order of magnitude more.
But look at what the second median is: the data end at 72 months, where 59% of customers are
still there. S(24) rests on data; the median is the Weibull shape extrapolated far beyond
the observation window. Report the first with confidence and the second with a health warning.
Even the correct curve only follows the KM steps approximately: it smooths over the drop in the first month and is too flat at the far end. One Weibull for all customers averages over very different populations - the next tasks take them apart.
Task 2 · A censored Weibull regression
Build an accelerated-failure-time model for tenure: a censored Weibull whose log-scale is
linear in contract type, internet service, payment method and monthly charges, with a
single shape parameter.
MonthlyCharges is largely determined by the internet service (check this). Enter it as
the deviation from the average charge for the customer's internet service type, in units
of $20, so that its coefficient reads "paying more than others with the same service".
Deliver
- The model with named dimensions and index variables (no dummy matrices). Think about how to keep three categorical predictors identified.
- Priors you can defend, with a prior predictive check on the scale that matters: survival curves.
- Clean diagnostics and a table of time ratios with 94% intervals: one-year and two-year contracts relative to month-to-month, plus the other covariates.
- One sentence: do you believe the two-year time ratio?
Solution
$$ T_i \sim \text{Weibull}(k,\ \lambda_i), \qquad \log \lambda_i = a_{\text{contract}[i]} + b^{\text{int}}_{\text{internet}[i]} + b^{\text{pay}}_{\text{payment}[i]} + b^{\text{chg}}\, x_i $$
agets one free level per contract type; the other two factors areZeroSumNormal, so their effects read "relative to the average category" andastays identified without choosing reference levels by hand.- Coefficients live on the log-time scale: $e^{b}$ is a time ratio.
sigma = 1for the zero-sum effects allows factors of about 7 either way, which is already a lot. - The function below takes the family and whether the shape varies by contract as arguments. Only the default is needed now.
print(df.groupby("InternetService").MonthlyCharges.agg(["mean", "std"]).round(1))
internet_idx, internets = pd.factorize(df.InternetService, sort=True)
payment_idx, payments = pd.factorize(df.PaymentMethod, sort=True)
charges_dev = ((df.MonthlyCharges - df.groupby("InternetService").MonthlyCharges.transform("mean")) / 20).to_numpy()
coords = {"contract": contracts, "internet": internets, "payment": payments, "customer": df.customerID}
def build_aft(family="weibull", shape_by_contract=False):
shape_dims = "contract" if shape_by_contract else None
with pm.Model(coords=coords) as model:
a = pm.Normal("a", 4.0, 1.5, dims="contract")
b_internet = pm.ZeroSumNormal("b_internet", 1.0, dims="internet")
b_payment = pm.ZeroSumNormal("b_payment", 1.0, dims="payment")
b_charges = pm.Normal("b_charges", 0, 0.5)
mu = a[contract_idx] + b_internet[internet_idx] + b_payment[payment_idx] + b_charges * charges_dev
if family == "weibull":
k = pm.LogNormal("k", 0, 0.75, dims=shape_dims)
dist = pm.Weibull.dist(alpha=k[contract_idx] if shape_by_contract else k, beta=pm.math.exp(mu))
elif family == "lognormal":
sigma = pm.HalfNormal("sigma", 2.0, dims=shape_dims)
dist = pm.LogNormal.dist(mu=mu, sigma=sigma[contract_idx] if shape_by_contract else sigma)
pm.Censored("tenure", dist, lower=None, upper=upper, observed=tenure, dims="customer")
return model
shared_model = build_aft()
with shared_model:
shared_prior = pm.sample_prior_predictive(200, random_seed=RANDOM_SEED)
prior = az.extract(shared_prior, group="prior", var_names=["a", "k"])
prior_scale = np.exp(prior["a"].sel(contract="Month-to-month").values)
fig, ax = plt.subplots()
ax.plot(grid, np.exp(-((grid[:, None] / prior_scale[:60]) ** prior["k"].values[:60])), color="C0", alpha=0.3)
ax.set(xlabel="tenure (months)", ylabel="S(t)", title="Prior survival curves, average month-to-month customer");
mean std InternetService DSL 58.1 16.3 Fiber optic 91.5 12.7 No 21.1 2.2

Everything from "all gone within months" to "hardly anyone ever leaves", convex and concave shapes alike. With 7,000 customers the priors only need to be sane, and they are.
with shared_model:
shared_idata = pm.sample(random_seed=RANDOM_SEED)
print("divergences:", int(shared_idata.sample_stats["diverging"].sum()))
az.summary(shared_idata, ci_kind="hdi", ci_prob=0.94, round_to=2)
divergences: 0
| mean | sd | hdi94_lb | hdi94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| a[Month-to-month] | 4.34 | 0.06 | 4.24 | 4.45 | 1908.32 | 2528.72 | 1.0 | 0.0 | 0.0 |
| a[One year] | 6.25 | 0.11 | 6.06 | 6.46 | 3735.70 | 2933.97 | 1.0 | 0.0 | 0.0 |
| a[Two year] | 7.76 | 0.18 | 7.44 | 8.10 | 5102.56 | 3493.17 | 1.0 | 0.0 | 0.0 |
| b_charges | 0.72 | 0.05 | 0.62 | 0.81 | 5564.72 | 3117.72 | 1.0 | 0.0 | 0.0 |
| b_internet[DSL] | 0.11 | 0.05 | 0.00 | 0.20 | 3759.08 | 3605.45 | 1.0 | 0.0 | 0.0 |
| b_internet[Fiber optic] | -0.41 | 0.05 | -0.51 | -0.32 | 2065.20 | 2595.48 | 1.0 | 0.0 | 0.0 |
| b_internet[No] | 0.30 | 0.08 | 0.16 | 0.46 | 1969.12 | 2308.38 | 1.0 | 0.0 | 0.0 |
| b_payment[Bank transfer (automatic)] | 0.40 | 0.06 | 0.29 | 0.51 | 6430.94 | 3347.09 | 1.0 | 0.0 | 0.0 |
| b_payment[Credit card (automatic)] | 0.42 | 0.06 | 0.31 | 0.54 | 6158.55 | 3088.82 | 1.0 | 0.0 | 0.0 |
| b_payment[Electronic check] | -0.41 | 0.04 | -0.49 | -0.32 | 5702.23 | 3237.85 | 1.0 | 0.0 | 0.0 |
| b_payment[Mailed check] | -0.41 | 0.06 | -0.53 | -0.30 | 5238.89 | 2847.05 | 1.0 | 0.0 | 0.0 |
| k | 0.86 | 0.02 | 0.83 | 0.89 | 3114.78 | 3152.87 | 1.0 | 0.0 | 0.0 |
def time_ratio_table(idata):
post = idata.posterior
log_ratios = {
"One year vs month-to-month": post["a"].sel(contract="One year") - post["a"].sel(contract="Month-to-month"),
"Two year vs month-to-month": post["a"].sel(contract="Two year") - post["a"].sel(contract="Month-to-month"),
"Fiber optic vs DSL": post["b_internet"].sel(internet="Fiber optic") - post["b_internet"].sel(internet="DSL"),
"Electronic check vs credit card (automatic)": post["b_payment"].sel(payment="Electronic check")
- post["b_payment"].sel(payment="Credit card (automatic)"),
"+$20 monthly charges within service type": post["b_charges"],
}
rows = {}
for label, log_ratio in log_ratios.items():
ratio = np.exp(log_ratio.values.ravel())
rows[label] = {"time ratio": ratio.mean(), "3%": np.quantile(ratio, 0.03), "97%": np.quantile(ratio, 0.97)}
return pd.DataFrame(rows).T.round(2)
shared_ratios = time_ratio_table(shared_idata)
post = az.extract(shared_idata)
for name in ["Month-to-month", "Two year"]:
median = np.exp(post["a"].sel(contract=name).values) * np.log(2) ** (1 / post["k"].values)
print(f"implied median lifetime of an average {name} customer: {median.mean() / 12:.0f} years")
shared_ratios
implied median lifetime of an average Month-to-month customer: 4 years implied median lifetime of an average Two year customer: 129 years
| time ratio | 3% | 97% | |
|---|---|---|---|
| One year vs month-to-month | 6.79 | 5.56 | 8.22 |
| Two year vs month-to-month | 31.01 | 22.21 | 43.45 |
| Fiber optic vs DSL | 0.59 | 0.52 | 0.67 |
| Electronic check vs credit card (automatic) | 0.44 | 0.37 | 0.51 |
| +$20 monthly charges within service type | 2.06 | 1.87 | 2.26 |
assert h.check(
"task2",
k_shared=shared_idata.posterior["k"].mean(),
time_ratio_two_year=shared_ratios.loc["Two year vs month-to-month", "time ratio"],
)
- PASS
k_shared= 0.8591 is consistent with the reference solution. - PASS
time_ratio_two_year= 31.01 is consistent with the reference solution.
The sampler is happy: no divergences, r_hat of 1.00, ESS in the thousands. The signs make
business sense - fiber customers and customers paying by electronic check leave sooner,
customers who book more than the basics for their service type stay longer.
But a two-year contract multiplying lifetime by a factor of about 30? The implied median lifetime of an average two-year customer is printed above the table, and it is far longer than a human life. A number like that is a model straining to say something it has no other way to say. Clean diagnostics mean the sampler explored this posterior faithfully. They say nothing about whether the model resembles the data.
Task 3 · Design a check the model can fail
az.plot_ppc_dist on tenure is close to meaningless here (why?). Design a posterior
predictive check that respects the censoring and looks at the model by contract type.
Deliver
- The figure, with posterior uncertainty.
- A verdict per contract type: where, in which direction and by how much does the model miss?
- The model's predicted 12-month survival for the customers on one-year contracts, next to the model-free estimate.
Solution
The observed tenure column mixes two different things - lifetimes and "time on the books
so far" - so a density overlay compares replicated lifetimes with a quantity that depends on
when people happened to sign up. Replicating the censoring as well would need every
customer's potential censoring time, which we do not know for the churners.
What we can compare is survival. Kaplan-Meier estimates the survival function of the customer mix within a contract type. The model's counterpart is the average of the individual survival curves $S_i(t)$ over those same customers, one curve per posterior draw. (Averaging curves is not the same as the curve of an "average customer".) The comparison assumes that, within a contract type, how long ago a customer signed up is unrelated to their covariates - good enough for a check of this kind.
def customer_survival(idata, family, rows, times, n_draws=400):
"""S_i(times) for the customers in `rows` -> array (len(rows), n_draws).
`times` is a scalar or an array with one time per customer.
"""
post = az.extract(idata, num_samples=n_draws, random_seed=RANDOM_SEED)
mu = (
post["a"].values[contract_idx[rows]]
+ post["b_internet"].values[internet_idx[rows]]
+ post["b_payment"].values[payment_idx[rows]]
+ charges_dev[rows, None] * post["b_charges"].values
)
times = np.asarray(times, dtype=float)
times = times[:, None] if times.ndim == 1 else times
shape = post["k" if family == "weibull" else "sigma"].values
shape = shape[contract_idx[rows]] if shape.ndim == 2 else shape
if family == "weibull":
return np.exp(-((times / np.exp(mu)) ** shape))
return stats.norm.sf((np.log(times) - mu) / shape)
cal_grid = np.arange(1, 73, 2.0)
def group_survival_curves(idata, family, name):
"""Average survival curve of all customers on contract `name`: array (n_draws, len(cal_grid))."""
rows = np.flatnonzero(df.Contract == name)
return np.stack([customer_survival(idata, family, rows, t).mean(axis=0) for t in cal_grid], axis=1)
def plot_calibration(fits, axes):
"""fits: list of (label, idata, family, linestyle); one panel per contract type."""
for ax, name in zip(axes, contracts):
grp = df[df.Contract == name]
ax.step(*kaplan_meier(grp.tenure, grp.churned), where="post", color="k", lw=1.5, label="Kaplan-Meier")
for (label, idata, family, ls), color in zip(fits, ["C3", "C0", "C2"]):
curves = group_survival_curves(idata, family, name)
ax.fill_between(cal_grid, *np.quantile(curves, [0.03, 0.97], axis=0), color=color, alpha=0.3)
ax.plot(cal_grid, curves.mean(axis=0), color=color, ls=ls, label=label)
ax.set(title=name, xlabel="tenure (months)")
axes[0].set(ylabel="S(t)", ylim=(0, 1.02))
axes[1].set(ylim=(0.5, 1.01))
axes[2].set(ylim=(0.9, 1.002))
axes[0].legend(loc="lower left", fontsize=9)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
plot_calibration([("Weibull, shared shape", shared_idata, "weibull", "-")], axes)

def survival_vs_km(idata, family, months=(12, 36, 60)):
rows = {}
for name in contracts:
grp = df[df.Contract == name]
idx = np.flatnonzero(df.Contract == name)
for t in months:
s = customer_survival(idata, family, idx, t).mean(axis=0)
rows[(name, t)] = {"KM": km_at(grp.tenure, grp.churned, t), "model": s.mean(),
"3%": np.quantile(s, 0.03), "97%": np.quantile(s, 0.97)}
return pd.DataFrame(rows).T.round(3)
shared_cal = survival_vs_km(shared_idata, "weibull")
shared_cal
| KM | model | 3% | 97% | ||
|---|---|---|---|---|---|
| Month-to-month | 12 | 0.703 | 0.709 | 0.698 | 0.723 |
| 36 | 0.491 | 0.439 | 0.423 | 0.457 | |
| 60 | 0.297 | 0.300 | 0.283 | 0.317 | |
| One year | 12 | 0.991 | 0.961 | 0.955 | 0.967 |
| 36 | 0.959 | 0.904 | 0.890 | 0.917 | |
| 60 | 0.832 | 0.856 | 0.836 | 0.874 | |
| Two year | 12 | 1.000 | 0.992 | 0.990 | 0.994 |
| 36 | 0.999 | 0.979 | 0.974 | 0.985 | |
| 60 | 0.986 | 0.968 | 0.961 | 0.977 |
assert h.check("task3", s12_one_year=shared_cal.loc[("One year", 12), "model"])
- PASS
s12_one_year= 0.961 is consistent with the reference solution.
Note the different y-axes: the panels zoom in on where each curve lives.
- Month-to-month: roughly right, a little low in the middle. Not the problem.
- One year: wrong in both directions. The model predicts that 4% of these customers leave in the first year; in reality fewer than 1% did. After five years it errs the other way and is too optimistic. The model curve is convex, the data are concave.
- Two year: the same pattern in miniature: too much early churn, and a curve that flattens where the data start to fall.
The bands are narrow, so none of this is sampling noise. A PPC on the pooled data would have hidden it: month-to-month customers produce 89% of all churn events and they fit fine.
Task 4 · Diagnose, fix, compare
Deliver
- A diagnosis of the misfit in terms of the hazard: what is the model structurally unable to express? (Task 0 already showed you the answer.)
- A fix to the model - not to the sampler, and not more covariates - and the refit with clean diagnostics.
- At least one other survival family with the same fix applied.
- A PSIS-LOO comparison of all your models, and your calibration check from Task 3 for the contenders. Do the two agree about which model is best?
- What happened to the contract time ratios from Task 2, and why?
Solution
A Weibull hazard is $h(t) \propto t^{k-1}$. With a single $k$ the hazard of every customer has the same shape, and the covariates can only stretch the time axis. Month-to-month customers supply nine out of ten events and their hazard falls with tenure (the people who were going to leave, leave early), so the shared $k$ lands below 1. Contract customers have a hazard that rises with tenure: nobody walks out of a fresh two-year contract, but contracts run out. A falling-hazard curve cannot be stretched into a rising-hazard curve - hence the absurd time ratio, the model's only lever.
The fix is one shape parameter per contract type: k with dims="contract", indexed like
the intercept. As a second family we take the log-normal, whose hazard rises and then falls,
with a contract-specific sigma.
group_model = build_aft("weibull", shape_by_contract=True)
lognormal_model = build_aft("lognormal", shape_by_contract=True)
with group_model:
group_idata = pm.sample(random_seed=RANDOM_SEED)
with lognormal_model:
lognormal_idata = pm.sample(random_seed=RANDOM_SEED)
for label, idata in [("Weibull, shape by contract", group_idata), ("log-normal, sigma by contract", lognormal_idata)]:
print(f"{label}: {int(idata.sample_stats['diverging'].sum())} divergences")
az.summary(group_idata, var_names=["a", "k", "b_charges"], ci_kind="hdi", ci_prob=0.94, round_to=2)
Weibull, shape by contract: 0 divergences log-normal, sigma by contract: 0 divergences
| mean | sd | hdi94_lb | hdi94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| a[Month-to-month] | 4.22 | 0.05 | 4.12 | 4.31 | 1757.83 | 1957.84 | 1.0 | 0.00 | 0.00 |
| a[One year] | 5.05 | 0.08 | 4.90 | 5.20 | 1469.44 | 1666.87 | 1.0 | 0.00 | 0.00 |
| a[Two year] | 5.23 | 0.12 | 5.02 | 5.46 | 1362.49 | 1886.47 | 1.0 | 0.00 | 0.00 |
| k[Month-to-month] | 0.81 | 0.02 | 0.78 | 0.84 | 4837.32 | 3270.96 | 1.0 | 0.00 | 0.00 |
| k[One year] | 1.92 | 0.13 | 1.67 | 2.16 | 1651.88 | 1830.68 | 1.0 | 0.00 | 0.00 |
| k[Two year] | 2.89 | 0.32 | 2.33 | 3.51 | 1339.99 | 1697.23 | 1.0 | 0.01 | 0.01 |
| b_charges | 0.45 | 0.05 | 0.36 | 0.54 | 2243.57 | 2611.43 | 1.0 | 0.00 | 0.00 |
az.summary(lognormal_idata, var_names=["a", "sigma", "b_charges"], ci_kind="hdi", ci_prob=0.94, round_to=2)
| mean | sd | hdi94_lb | hdi94_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| a[Month-to-month] | 3.73 | 0.05 | 3.64 | 3.82 | 2572.87 | 2873.46 | 1.00 | 0.00 | 0.0 |
| a[One year] | 5.21 | 0.10 | 5.03 | 5.42 | 1760.25 | 2074.55 | 1.00 | 0.00 | 0.0 |
| a[Two year] | 5.61 | 0.19 | 5.28 | 6.00 | 1265.91 | 1677.51 | 1.00 | 0.01 | 0.0 |
| sigma[Month-to-month] | 1.76 | 0.03 | 1.70 | 1.82 | 3727.07 | 3359.65 | 1.00 | 0.00 | 0.0 |
| sigma[One year] | 1.14 | 0.07 | 1.02 | 1.28 | 1658.94 | 1895.87 | 1.00 | 0.00 | 0.0 |
| sigma[Two year] | 0.91 | 0.11 | 0.72 | 1.12 | 1229.05 | 1651.50 | 1.01 | 0.00 | 0.0 |
| b_charges | 0.60 | 0.05 | 0.50 | 0.70 | 2689.66 | 2903.67 | 1.00 | 0.00 | 0.0 |
fits = {"Weibull, shared shape": (shared_model, shared_idata), "Weibull, shape by contract": (group_model, group_idata),
"log-normal, sigma by contract": (lognormal_model, lognormal_idata)}
# Memory matters here: the pointwise log-likelihood is a 4000 x 7032 array (225 MB) per model,
# and ArviZ keeps an equally large matrix of importance weights on every LOO result. Compute
# LOO once per model, keep the small result, and free both arrays before moving on.
loos = {}
for label, (model, idata) in fits.items():
with model:
pm.compute_log_likelihood(idata, progressbar=False)
loos[label] = az.loo(idata, pointwise=True)
loos[label].log_weights = None
del idata["log_likelihood"]
comparison = az.compare(loos)
comparison
| rank | elpd_diff | dse | p_worse | diag_diff | diag_elpd | p | elpd | se | weight | |
|---|---|---|---|---|---|---|---|---|---|---|
| log-normal, sigma by contract | 0 | 0.0 | 0.0 | NaN | 13.4 | -9000.0 | 160.0 | 0.73 | ||
| Weibull, shape by contract | 1 | -30.0 | 11.0 | 0.99 | 12.7 | -9000.0 | 150.0 | 0.24 | ||
| Weibull, shared shape | 2 | -120.0 | 17.0 | 1.00 | 10.1 | -9100.0 | 160.0 | 0.03 |
elpd_gain = loos["Weibull, shape by contract"].elpd - loos["Weibull, shared shape"].elpd
print(f"elpd gain from contract-specific shapes (Weibull): {elpd_gain:.0f}")
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
plot_calibration([("Weibull, shared shape", shared_idata, "weibull", ":"),
("Weibull, shape by contract", group_idata, "weibull", "-"),
("log-normal, sigma by contract", lognormal_idata, "lognormal", "--")], axes)
elpd gain from contract-specific shapes (Weibull): 88

pd.concat({"Weibull, shape by contract": survival_vs_km(group_idata, "weibull"),
"log-normal, sigma by contract": survival_vs_km(lognormal_idata, "lognormal")}, axis=1)
| Weibull, shape by contract | log-normal, sigma by contract | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| KM | model | 3% | 97% | KM | model | 3% | 97% | ||
| Month-to-month | 12 | 0.703 | 0.708 | 0.697 | 0.719 | 0.703 | 0.676 | 0.664 | 0.688 |
| 36 | 0.491 | 0.443 | 0.426 | 0.457 | 0.491 | 0.443 | 0.425 | 0.459 | |
| 60 | 0.297 | 0.301 | 0.283 | 0.318 | 0.297 | 0.337 | 0.318 | 0.353 | |
| One year | 12 | 0.991 | 0.991 | 0.987 | 0.994 | 0.991 | 0.989 | 0.984 | 0.992 |
| 36 | 0.959 | 0.933 | 0.922 | 0.944 | 0.959 | 0.919 | 0.906 | 0.931 | |
| 60 | 0.832 | 0.838 | 0.819 | 0.858 | 0.832 | 0.838 | 0.813 | 0.857 | |
| Two year | 12 | 1.000 | 1.000 | 0.999 | 1.000 | 1.000 | 1.000 | 0.999 | 1.000 |
| 36 | 0.999 | 0.993 | 0.990 | 0.996 | 0.999 | 0.990 | 0.986 | 0.993 | |
| 60 | 0.986 | 0.971 | 0.963 | 0.978 | 0.986 | 0.967 | 0.959 | 0.975 | |
# where does the log-normal earn its LOO advantage over the Weibull?
elpd_i = {"weibull": loos["Weibull, shape by contract"].elpd_i.values,
"lognormal": loos["log-normal, sigma by contract"].elpd_i.values}
segment = pd.Series(np.select(
[churned & (tenure == 1), churned & (tenure <= 12), churned],
["churned in month 1", "churned in months 2-12", "churned after month 12"], "still active (censored)"))
pd.DataFrame({
"customers": segment.value_counts(),
"elpd, log-normal minus Weibull": pd.Series(elpd_i["lognormal"] - elpd_i["weibull"]).groupby(segment).sum(),
}).round(1)
| customers | elpd, log-normal minus Weibull | |
|---|---|---|
| churned after month 12 | 832 | -160.2 |
| churned in month 1 | 380 | 81.0 |
| churned in months 2-12 | 657 | 106.8 |
| still active (censored) | 5163 | -0.1 |
group_ratios = time_ratio_table(group_idata)
pd.concat({"shared shape": shared_ratios, "shape by contract": group_ratios}, axis=1).iloc[:2]
| shared shape | shape by contract | |||||
|---|---|---|---|---|---|---|
| time ratio | 3% | 97% | time ratio | 3% | 97% | |
| One year vs month-to-month | 6.79 | 5.56 | 8.22 | 2.31 | 2.02 | 2.67 |
| Two year vs month-to-month | 31.01 | 22.21 | 43.45 | 2.77 | 2.27 | 3.44 |
k_post = group_idata.posterior["k"].mean(("chain", "draw"))
assert h.check("task4", k_month_to_month=k_post.sel(contract="Month-to-month"),
k_one_year=k_post.sel(contract="One year"), elpd_gain=elpd_gain)
- PASS
k_month_to_month= 0.809 is consistent with the reference solution. - PASS
k_one_year= 1.919 is consistent with the reference solution. - PASS
elpd_gain= 88.35 is consistent with the reference solution.
The fix works where it was needed. k is 0.81 for month-to-month customers (falling
hazard), 1.9 for one-year and 2.9 for two-year contracts (rising hazard), and the three
intervals do not overlap. LOO improves by 88 elpd points over the shared-shape model, and in
the calibration figure the solid curves now bend the right way: predicted first-year churn
of one-year customers is 0.9%, as observed.
The time ratios drop from 6.8 and 31 to 2.3 and 2.8. The shared-shape model had only the scale to express "hardly any early churn", so it inflated the scale. (With different shapes the ratio of scales no longer stretches the whole time axis uniformly; it compares characteristic lifetimes, the time by which 63% of customers have left.)
LOO and calibration disagree about the family. LOO prefers the log-normal by 30 elpd points, just under three standard errors. The calibration table prefers the Weibull: for month-to-month customers it is within half a point of Kaplan-Meier at 12 and 60 months, where the log-normal is 3 points low and 4 points high (both are 5 points low at 36 months). The pointwise table explains the disagreement: the log-normal gains 188 elpd points on customers who churned within their first year - it puts more density on the pile-up of events in the first months - and gives 160 back on later churners. The censored customers, 73% of the data, are a wash. LOO scores the density at the observed event times, and most events are early. Whether that is the right yardstick depends on what the model is for; Task 5 checks the quantity we actually need.
Nothing fits the far end. Every curve misses the cliff after month 65. For month-to-month customers that region holds a few dozen customers; for one-year contracts the late surge in churn is better supported (154 customers at month 67) and steeper than either family allows. Predictions for customers beyond five years of tenure deserve less trust.
Task 5 · From lifetimes to "who is at risk now?"
The retention team cannot act on customers who already left. For every active customer they need the probability of churning within the next 6 months, given that the customer has stayed until today.
Deliver
- The formula, and its posterior for every active customer under your final model.
- The average 6-month churn probability per contract type.
- A figure of this risk against current tenure, by contract type, for the final model and for the shared-shape model of Task 2. What did the fix change about who looks risky?
- A sanity check of this very quantity against the data: Kaplan-Meier can estimate "churn within 6 months given survival to $t$" too. Use it to decide which of your Task 4 contenders to carry into the decision.
Solution
$$P(T \le t + 6 \mid T > t) = 1 - \frac{S(t + 6)}{S(t)}$$
A customer's past is not wasted: surviving to $t$ changes the forecast, unless the hazard is constant. Both survival probabilities are evaluated per customer and per posterior draw, so the ratio carries the full posterior uncertainty.
active = np.flatnonzero(~churned)
HORIZON = 6
def churn_risk(idata, family, n_draws=1000):
"""P(churn within HORIZON months | active today) -> array (n_active, n_draws)."""
s_now = customer_survival(idata, family, active, tenure[active], n_draws)
s_later = customer_survival(idata, family, active, tenure[active] + HORIZON, n_draws)
return 1 - s_later / s_now
risk = {"Weibull, shared shape": churn_risk(shared_idata, "weibull"),
"Weibull, shape by contract": churn_risk(group_idata, "weibull"),
"log-normal, sigma by contract": churn_risk(lognormal_idata, "lognormal")}
active_df = df.iloc[active][["customerID", "Contract", "InternetService", "PaymentMethod", "tenure",
"MonthlyCharges"]].reset_index(drop=True)
risk_by_contract = pd.DataFrame(
{label: pd.Series(r.mean(axis=1)).groupby(active_df.Contract).mean() for label, r in risk.items()}
).round(4)
risk_by_contract
| Weibull, shared shape | Weibull, shape by contract | log-normal, sigma by contract | |
|---|---|---|---|
| Contract | |||
| Month-to-month | 0.1228 | 0.1167 | 0.1184 |
| One year | 0.0144 | 0.0261 | 0.0217 |
| Two year | 0.0028 | 0.0083 | 0.0068 |
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for ax, name in zip(axes, contracts):
sel = (active_df.Contract == name).to_numpy()
for label, color in [("Weibull, shared shape", "C3"), ("Weibull, shape by contract", "C0")]:
ax.scatter(active_df.tenure[sel], risk[label].mean(axis=1)[sel], s=5, alpha=0.3, color=color, label=label)
ax.set(title=name, xlabel="current tenure (months)")
axes[0].set(ylabel=f"P(churn within {HORIZON} months | active)")
leg = axes[0].legend(markerscale=3, fontsize=9)
for handle in leg.legend_handles:
handle.set_alpha(1)

def km_conditional(name, t0):
grp = df[df.Contract == name]
return 1 - km_at(grp.tenure, grp.churned, t0 + HORIZON) / km_at(grp.tenure, grp.churned, t0)
def model_conditional(idata, family, name, t0):
"""Conditional churn of the contract group's average survival curve (the KM counterpart)."""
rows = np.flatnonzero(df.Contract == name)
s0 = customer_survival(idata, family, rows, t0).mean(axis=0)
s1 = customer_survival(idata, family, rows, t0 + HORIZON).mean(axis=0)
return (1 - s1 / s0).mean()
starts = [1, 3, 6, 12, 24, 36, 48]
cond = {}
for name in contracts[:2]:
cond[(name, "Kaplan-Meier")] = [km_conditional(name, t0) for t0 in starts]
cond[(name, "Weibull by contract")] = [model_conditional(group_idata, "weibull", name, t0) for t0 in starts]
cond[(name, "log-normal by contract")] = [model_conditional(lognormal_idata, "lognormal", name, t0) for t0 in starts]
cond_table = pd.DataFrame(cond, index=pd.Index(starts, name="survived to month")).round(3)
cond_table
| Month-to-month | One year | |||||
|---|---|---|---|---|---|---|
| Kaplan-Meier | Weibull by contract | log-normal by contract | Kaplan-Meier | Weibull by contract | log-normal by contract | |
| survived to month | ||||||
| 1 | 0.146 | 0.163 | 0.198 | 0.003 | 0.003 | 0.003 |
| 3 | 0.119 | 0.149 | 0.177 | 0.003 | 0.004 | 0.006 |
| 6 | 0.105 | 0.136 | 0.153 | 0.006 | 0.006 | 0.009 |
| 12 | 0.096 | 0.122 | 0.123 | 0.005 | 0.010 | 0.014 |
| 24 | 0.081 | 0.107 | 0.091 | 0.006 | 0.017 | 0.020 |
| 36 | 0.107 | 0.097 | 0.074 | 0.020 | 0.023 | 0.022 |
| 48 | 0.131 | 0.090 | 0.063 | 0.042 | 0.028 | 0.023 |
final_label, final_idata, final_family = "Weibull, shape by contract", group_idata, "weibull"
p6 = risk[final_label]
assert h.check("task5", p6_month_to_month=risk_by_contract.loc["Month-to-month", final_label],
p6_one_year=risk_by_contract.loc["One year", final_label])
- PASS
p6_month_to_month= 0.1167 is consistent with the reference solution. - PASS
p6_one_year= 0.0261 is consistent with the reference solution.
- Level. Under the Weibull model with contract-specific shapes an active month-to-month customer has a 12% chance of leaving within six months, a one-year customer 2.6% and a two-year customer 0.8%. The shared-shape model put the contract customers at about half and a third of that (1.4% and 0.3%).
- Direction. The figure is the payoff of Task 4. With one shared $k < 1$, risk falls with tenure for everyone, so the model declared the longest-serving contract customers the safest. With contract-specific shapes it is the other way round: for contract customers risk rises with tenure. For month-to-month customers the newest are the riskiest under both models, but the shared-shape points reach well above 0.30 where the fixed model stops at 0.27.
- Calibration on the decision scale. Against Kaplan-Meier, both contenders overstate the risk of month-to-month customers in their first year - the Weibull by 2-3 points, the log-normal by about 5 in the first months - and both understate it beyond three years, where the observed risk climbs again. For one-year contracts both are within a point and a half up to month 36 and too low at 48. So LOO's favourite is the worse model for this purpose exactly where customers are riskiest. We carry the Weibull with contract-specific shapes into the decision and keep the log-normal as a robustness check.
Task 6 · Who gets the offer?
The offer on the table, with the retention team's working assumptions:
OFFER_COST = 40.0 # dollars per customer who receives the offer
OFFER_EFFECT = 0.30 # assumed: the offer cuts the 6-month churn probability by 30% (relative)
VALUE_MONTHS = 12 # a customer saved from churning is worth 12 months of their MonthlyCharges
Deliver
- The posterior of the expected net value of making the offer, for every active customer.
- A ranked top-10 list with the uncertainty a manager needs to see.
- A targeting rule, the number of customers it selects, and the total expected profit with an interval. Compare it with the blanket policy "offer it to every month-to-month customer".
- Robustness: how much does the target list change under the runner-up model from Task 4? And which assumption would you most want to test before spending real money?
- Three sentences for the retention team.
Solution
For customer $i$ with 6-month churn probability $p_i$ and monthly charges $m_i$:
$$\text{net value}_i = \underbrace{0.30 \cdot p_i}_{\text{churn averted}} \times 12\, m_i - 40$$
It is linear in $p_i$, so every posterior draw of $p_i$ gives a draw of the net value. A risk-neutral company targets whoever has a positive posterior mean; the posterior probability that the value is positive tells a manager how much of the list is solid.
def net_value(p):
return OFFER_EFFECT * p * VALUE_MONTHS * active_df.MonthlyCharges.to_numpy()[:, None] - OFFER_COST
value = net_value(p6) # (n_active, n_draws)
ranking = active_df.assign(
p6=p6.mean(axis=1),
p6_lo=np.quantile(p6, 0.03, axis=1),
p6_hi=np.quantile(p6, 0.97, axis=1),
value=value.mean(axis=1),
value_lo=np.quantile(value, 0.03, axis=1),
value_hi=np.quantile(value, 0.97, axis=1),
p_positive=(value > 0).mean(axis=1),
).sort_values("value", ascending=False)
ranking.drop(columns=["InternetService", "PaymentMethod"]).head(10).round(2)
| customerID | Contract | tenure | MonthlyCharges | p6 | p6_lo | p6_hi | value | value_lo | value_hi | p_positive | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 220 | 6556-DBKZF | Two year | 71 | 76.05 | 0.26 | 0.12 | 0.44 | 30.13 | -6.27 | 80.94 | 0.92 |
| 2156 | 5542-TBBWB | Month-to-month | 1 | 69.90 | 0.27 | 0.25 | 0.29 | 27.65 | 21.77 | 33.96 | 1.00 |
| 1788 | 9603-OAIHC | Month-to-month | 1 | 70.05 | 0.27 | 0.24 | 0.29 | 27.63 | 21.77 | 33.93 | 1.00 |
| 1922 | 2262-SLNVK | Month-to-month | 1 | 70.10 | 0.27 | 0.24 | 0.29 | 27.63 | 21.77 | 33.92 | 1.00 |
| 4475 | 9605-WGJVW | Month-to-month | 1 | 70.20 | 0.27 | 0.24 | 0.29 | 27.62 | 21.77 | 33.89 | 1.00 |
| 2455 | 1640-PLFMP | Month-to-month | 1 | 70.25 | 0.27 | 0.24 | 0.29 | 27.61 | 21.77 | 33.88 | 1.00 |
| 3300 | 7465-ZZRVX | Month-to-month | 1 | 70.35 | 0.27 | 0.24 | 0.29 | 27.60 | 21.78 | 33.84 | 1.00 |
| 2676 | 3878-AVSOQ | Month-to-month | 1 | 71.25 | 0.26 | 0.24 | 0.29 | 27.50 | 21.85 | 33.54 | 1.00 |
| 256 | 0021-IKXGC | Month-to-month | 1 | 72.10 | 0.26 | 0.24 | 0.28 | 27.40 | 21.87 | 33.24 | 1.00 |
| 3007 | 8687-BAFGU | Month-to-month | 1 | 74.00 | 0.25 | 0.23 | 0.27 | 27.12 | 21.90 | 32.66 | 1.00 |
target = ranking.value > 0
profit_draws = value[ranking.index[target]].sum(axis=0)
blanket_draws = value[(active_df.Contract == "Month-to-month").to_numpy()].sum(axis=0)
print(f"active customers: {len(ranking)}, targeted: {int(target.sum())}, "
f"of which with P(value > 0) > 0.9: {int((ranking.p_positive[target] > 0.9).sum())}")
print(f"expected profit of the target list: ${profit_draws.mean():,.0f} "
f"(94% interval {np.quantile(profit_draws, 0.03):,.0f} to {np.quantile(profit_draws, 0.97):,.0f})")
print(f"blanket offer to all {int((active_df.Contract == 'Month-to-month').sum())} month-to-month customers: "
f"${blanket_draws.mean():,.0f} "
f"(94% interval {np.quantile(blanket_draws, 0.03):,.0f} to {np.quantile(blanket_draws, 0.97):,.0f})")
print("\nwho is on the list?")
for col in ["Contract", "InternetService", "PaymentMethod"]:
print(ranking[target][col].value_counts().to_string(), "\n")
print(ranking[target][["tenure", "MonthlyCharges", "p6"]].describe().loc[["min", "50%", "max"]].round(2))
active customers: 5163, targeted: 471, of which with P(value > 0) > 0.9: 395
expected profit of the target list: $5,076 (94% interval 3,439 to 6,766)
blanket offer to all 2220 month-to-month customers: $-30,807 (94% interval -33,735 to -27,525)
who is on the list?
Contract
Month-to-month 464
One year 5
Two year 2
InternetService
Fiber optic 471
PaymentMethod
Electronic check 380
Mailed check 81
Credit card (automatic) 8
Bank transfer (automatic) 2
tenure MonthlyCharges p6
min 1.0 68.65 0.10
50% 12.0 81.70 0.17
max 72.0 111.40 0.27cumulative = value[ranking.index].cumsum(axis=0) # profit if we target the top-n customers, per draw
n_show = 1500
fig, ax = plt.subplots()
n_range = np.arange(1, n_show + 1)
ax.fill_between(n_range, *np.quantile(cumulative[:n_show], [0.03, 0.97], axis=1), alpha=0.3)
ax.plot(n_range, cumulative[:n_show].mean(axis=1))
ax.axvline(target.sum(), color="k", ls=":", label=f"positive expected value: top {int(target.sum())}")
ax.axhline(0, color="k", lw=1)
ax.set(xlabel="number of customers targeted (best first)", ylabel="expected profit ($)")
ax.legend();

runner_up = net_value(risk["log-normal, sigma by contract"]).mean(axis=1)
target_alt = runner_up > 0
target_final = value.mean(axis=1) > 0
print(f"targets under the Weibull model: {target_final.sum()}, under the log-normal model: {target_alt.sum()}, "
f"on both lists: {(target_final & target_alt).sum()}")
print(f"rank correlation of expected value between the two models: "
f"{stats.spearmanr(value.mean(axis=1), runner_up)[0]:.3f}")
print(f"expected profit when targeting only the customers on both lists: "
f"${value[target_final & target_alt].sum(axis=0).mean():,.0f}")
print("\nsensitivity to the assumed effect of the offer (final model):")
for effect in [0.2, 0.3, 0.4]:
v = value if effect == OFFER_EFFECT else (value + OFFER_COST) * effect / OFFER_EFFECT - OFFER_COST
keep = v.mean(axis=1) > 0
print(f" effect {effect:.1f}: {keep.sum():>4d} customers targeted, expected profit ${v[keep].sum(axis=0).mean():,.0f}")
targets under the Weibull model: 471, under the log-normal model: 292, on both lists: 280 rank correlation of expected value between the two models: 0.982 expected profit when targeting only the customers on both lists: $4,293 sensitivity to the assumed effect of the offer (final model): effect 0.2: 75 customers targeted, expected profit $188 effect 0.3: 471 customers targeted, expected profit $5,076 effect 0.4: 760 customers targeted, expected profit $15,114
assert h.check("task6", n_target=target.sum(), profit=profit_draws.mean())
- PASS
n_target= 471 is consistent with the reference solution. - PASS
profit= 5076 is consistent with the reference solution.
Whom to target. 471 of the 5,163 active customers have a positive expected net value; for 395 of them the posterior probability of a positive value exceeds 0.9. Expected profit is about $5,100 (94% interval $3,400 to $6,800). The blanket policy - every month-to-month customer - loses about $31,000: most of those customers are not risky enough, or do not pay enough, to justify $40.
Who they are. Every one of them has fiber, four in five pay by electronic check, all but seven are on month-to-month terms, half have been customers for a year or less and none pays less than $68 a month. Risk alone is not enough: a $20-a-month customer would need a churn probability above 0.55 to repay the offer, and the model gives nobody more than 0.27.
Read the top of the ranking with its intervals. Number one is a two-year customer in
month 71. The rising hazard gives this customer the highest expected value and by far the
widest interval (from a loss to $81), because the forecast leans on k[Two year] - 48
events - pushed beyond the edge of the data. The next nine are brand-new month-to-month
customers whose value is pinned down to within a few dollars. A ranking by posterior mean
hides that difference; the interval and p_positive show it.
Robustness. The log-normal model orders customers almost identically (rank correlation 0.98) but would target only 292, of whom 280 are on our list too. The models disagree about the level of early-tenure risk, i.e. about where the cut-off falls - and Task 5 showed that both overstate that level somewhat. The profit curve is flat around its maximum, so caution is cheap: targeting only the 280 customers both models agree on still earns an expected $4,300.
The assumption that matters most is the one nobody estimated. With an effect of 0.2 instead of 0.3 the list shrinks to 75 customers and the profit to under $200; with 0.4 it grows to 760 customers and $15,000. The effect is also assumed to be the same for everyone, while a survival model says who is likely to leave, not who is persuadable. Only a randomised pilot - the offer withheld from a random part of the list - can measure that.
For the retention team.
- "Customers last 18 months" is wrong by an order of magnitude: four in five new customers are still with us after two years. Those who leave, leave early, and nearly all of them are on month-to-month terms.
- Send the offer to the 300-470 highest-ranked active customers - mostly recent month-to-month fiber customers paying by electronic check - for an expected gain of $4,000-5,000. Offering it to every month-to-month customer would lose about $30,000.
- All of this assumes the offer prevents 30% of churn. Run the campaign as a randomised pilot, so that the next target list rests on a measured effect instead of an assumed one.
Going further
- Month-to-month churn is a bathtub: a spike in month one, a long quiet period, and a rise again after four years. Neither family here can express that. Try a piecewise-constant hazard (a piecewise exponential model is a Poisson regression in disguise) and rerun the Task 5 check.
tenureis recorded in whole months, so a "churn at 1" is really a churn somewhere in (0, 1]. Treat events as interval-censored -pm.Censoredwill not do it, but apm.Potentialorpm.CustomDiston $S(t-1) - S(t)$ will. Does the first-month spike look different?- The offer's effect was assumed. Put a prior on it (say, a Beta centred on 0.3), push it through the decision, and work out how large a randomised pilot would have to be before the target list stops changing.
- The file is a snapshot taken on one date, not a cohort followed from sign-up. What would you need to know about how customers entered the file to rule out left-truncation, and how would it enter the likelihood if you knew each customer's sign-up date?
h.progress()
C04: 0/21 hints used, 15/15 checks passed.