learn-customer-retention-with-phoebe / Analyst session 5 of 8
Learn Customer Retention with Phoebe · Analyst track · Session 5 of 8

Survival analysis and the censoring problem

Your best customers share an annoying statistical property: they have not churned yet. Average the lifetimes of only the customers who quit and you get a number biased low - you measured the quitters and ignored the loyal. Today you meet censoring, build Kaplan-Meier curves that count everyone honestly, test segment gaps with log-rank, and read Cox hazard ratios - all in lifelines, all feeding the retention report you ship in a8.

🟠 Analyst track Data analysts · Python Build-along: lifelines 45 min
0-3 · Welcome 3-18 · Censoring & KM 18-40 · Cox hazards 40-45 · Q&A
Part 0

Where this session sits

The cohort triangle from a2 describes what happened. Starting today, we model it. Survival analysis is the bridge: it keeps a2's honest, forward-looking spirit but adds two things the triangle cannot give you - correct handling of still-active customers, and covariate effects ("does contract type change churn risk?"). The RFM personas you built in a4 become the segments we compare here, and the P(alive) models in a6 pick up where censoring math leaves off. Work along in the companion notebook notebooks/a5_survival.ipynb.

Live - presented in session Self-study - read after class ★ Build-along - run the code yourself Sources covered
★ What you walk out with today A working definition of right-censoring and why "average lifetime of churned customers" is biased low, a Kaplan-Meier fit in lifelines with a defensible median lifetime, a log-rank test that says whether a segment gap is real, and a Cox model whose exp(coef) column you can read out loud - plus a clear rule for when a plain cohort table is all you need.
Part 1 · covers right-censoring + the Kaplan-Meier estimator

The censoring problem 15 min live

A customer who is still active when your data ends has not churned - their lifetime is at least what you observed, and that is all you know. They are right-censored. The naive "average customer lifetime" - mean tenure of the customers who churned - silently throws these people away, so it only counts the quitters and comes out biased low. Kaplan-Meier counts everyone: S(t) is the probability of surviving past t, drawn as a step curve that steps down only at observed churn events. Censored customers simply exit the risk set when their data runs out - they thin the denominator of later steps without stepping the curve themselves.

Six customers, one honest curve: censoring thins the risk set without stepping S(t) ● churn observed - steps the curve ○ censored - still active at data end, exits the risk set, no step A B C D E F Kaplan-Meier S(t) from the same six customers 1.0 0.5 0.0 x 5/6 x 3/4 x 1/2 median = 7 months 0 2 4 6 8 10 Naive mean of the 3 churned = 4.3 months; KM median = 7 months. Ignoring censoring understates lifetime.
🔍 Click to zoom - three churn events step the curve, three censored exits only thin the risk set
Live★ Build-along: the six-customer worked example8 min▶

Six customers, observed for up to ten months. A churned at month 2, C at month 4, E at month 7. B, D and F were still active when the data ended, at months 3, 5 and 9 - censored. Walk the estimator by hand once, then let lifelines do it forever:

  • Month 2: 6 customers at risk, 1 churns. S(2) = 5/6 = 0.83. B is censored at month 3 - the curve does not move, but B leaves the risk set.
  • Month 4: 4 at risk (C, D, E, F), 1 churns. S(4) = 0.83 x 3/4 = 0.63. D exits censored at month 5.
  • Month 7: 2 at risk (E, F), 1 churns. S(7) = 0.63 x 1/2 = 0.31. F exits censored at month 9. Median survival - where S(t) crosses 0.5 - is 7 months.
  • The naive number: mean tenure of the churned three is (2 + 4 + 7) / 3 = 4.3 months. The honest median is 7. Same data, and the naive figure understated lifetime by counting only quitters.
★ The same example in lifelines - fit, plot, medianfrom lifelines import KaplanMeierFitter durations = [2, 3, 4, 5, 7, 9] # months each customer was observed event_observed = [1, 0, 1, 0, 1, 0] # 1 = churn seen, 0 = censored (still active) kmf = KaplanMeierFitter() kmf.fit(durations, event_observed=event_observed, label="All customers") kmf.plot_survival_function() # the step curve, with censor ticks print(kmf.median_survival_time_) # 7.0 - not the naive 4.3
But what is a "churn event" for a shop? Contract businesses observe churn: the cancellation lands in a table with a timestamp. Ecommerce and games do not - a quiet customer might be gone or just slow. To run survival on non-subscription data you must define churn as an inactivity window: no purchase for 60 / 90 / 120 days, or a multiple of that customer's own inter-purchase gap. Write the definition down; the homework makes you feel how much the answer moves when it changes.

Here is that churn-definition choice applied to the repo's own orders: call 90 days of silence churn, then plot how long each churned customer lasted from first order to last. The shape explains why this session exists.

Time from first to last order: 2,514 churned retail customers (90-day window) 0 500 1,000 customers 1,241 185 427 231 141 183 90 16 49% never placed a second order 1,241 of 2,514 bought once, went quiet, and were gone at day 90 day 0 1-29 30-59 60-89 90-119 120-179 180-269 270-399 days between first and last order The day-0 cliff is why the second purchase is the single biggest lever - and why survival curves front-load. Churned customers only: the still-active are censored and are not in this histogram.
🔍 Click to zoom - 49% of churned customers never placed a second order: the day-0 cliff that front-loads retention work
Part 2 · covers per-segment KM + the log-rank test

Comparing segments honestly 8 min live

One KM curve per segment - contract type, acquisition channel, or the RFM personas from a4 - and the gaps become visible. But visible is not the same as real: two curves from the same population will drift apart by luck. The log-rank test compares whole curves, not one cherry-picked time point, and answers "how surprising is a gap this size if the segments actually churn identically?" This is the censoring-correct cousin of reading a2's triangle rows: same question, but every still-active customer is counted instead of dropped.

Two segments, one question: is the gap real or luck? Two-year contract Month-to-month 1.0 0.5 0.0 0 6 12 18 24 mo Log-rank verdict p = 0.003 (illustrative) A gap this size is very unlikely if both segments churn identically. It tests whole curves, not one cherry-picked month. KM per segment is the censoring-correct cousin of a2's retention rows; log-rank replaces eyeballing the gap.
🔍 Click to zoom - the shaded gap is the claim, the log-rank p-value is the evidence
Live★ Build-along: two KM fits overlaid + log-rank6 min▶

Split the frame by segment, fit one KaplanMeierFitter per side, plot on a shared axis, then let the test speak. Swap in any a4 persona pair - Champions vs Hibernating is a favorite.

★ Overlay two segments, then test the gapfrom lifelines import KaplanMeierFitter from lifelines.statistics import logrank_test m2m = df[df["contract"] == "month-to-month"] two = df[df["contract"] == "two-year"] ax = KaplanMeierFitter().fit(m2m["tenure_months"], m2m["churned"], label="Month-to-month").plot_survival_function() KaplanMeierFitter().fit(two["tenure_months"], two["churned"], label="Two-year").plot_survival_function(ax=ax) res = logrank_test(m2m["tenure_months"], two["tenure_months"], event_observed_A=m2m["churned"], event_observed_B=two["churned"]) print(res.p_value) # small = the gap is unlikely to be luck

Two reading rules. First, a tiny p-value on a tiny gap can still be commercially boring - report the gap size and the p-value together. Second, if the curves cross, the log-rank test loses power and "which segment is better" depends on the horizon; say so instead of averaging it away.

Part 3 · covers Cox proportional hazards + when not to bother

Cox proportional hazards 14 min live

KM compares one segmentation at a time. Cox proportional hazards handles many covariates at once, and it is semi-parametric: it estimates each covariate's effect as a hazard ratio without ever specifying the baseline churn curve. The canonical Telco read: a month-to-month contract carries roughly 3x the churn hazard of a two-year contract, other covariates held fixed. Read exp(coef) as multiplicative risk, verify the proportional-hazards assumption with check_assumptions(), and judge ranking quality with the C-index.

Reading a forest plot: which side of 1.0, and does the interval cross it? HR = 1.0 - no effect Month-to-month (vs two-year) HR ~3.0 - the canonical Telco read Covariate B - protective HR 0.7 - lowers risk (schematic) Covariate C - no evidence CI crosses 1.0 - report nothing 0.5 1.0 2.0 4.0 hazard ratio (log scale) exp(coef) is multiplicative: HR 3.0 = three times the instantaneous churn risk among survivors, at every t. Right of 1.0 raises risk, left lowers it. Bars B and C are schematic reading practice, not Telco estimates.
🔍 Click to zoom - the whole model summary in one picture: point, interval, and the 1.0 line
Live★ Build-along: CoxPHFitter on a Telco-shaped frame8 min▶

One row per customer: tenure in months, a churn flag, and covariates. Fit, print the summary, and read the exp(coef) column out loud before anything else.

★ Fit, read hazard ratios, check the assumption, judge rankingfrom lifelines import CoxPHFitter model_df = telco[["tenure_months", "churned", "contract_m2m", "monthly_charges", "tech_support"]] cph = CoxPHFitter() cph.fit(model_df, duration_col="tenure_months", event_col="churned") cph.print_summary() # exp(coef) column = hazard ratios cph.plot_partial_effects_on_outcome("contract_m2m", values=[0, 1]) cph.check_assumptions(model_df) # proportional-hazards diagnostics print(cph.concordance_index_) # C-index: 0.5 = coin flip, ~0.7+ useful
  • exp(coef) = 3.0 reads: "at any moment, a month-to-month customer who is still with us has three times the instantaneous churn risk of an otherwise-identical two-year customer."
  • Proportional hazards means that ratio is assumed constant over time. check_assumptions() flags covariates that violate it - stratify or add time-varying terms rather than shipping a broken ratio.
  • C-index is the probability the model correctly orders a random pair by churn time - a ranking score, not a calibration score.
Self-studyHazard vs probability, and why models get versions4 min read▶

Hazard is not probability. The hazard at t is the instantaneous churn risk among customers who survived to t - risk per unit time, conditional on still being here. S(t) is the cumulative result of running that gauntlet. A segment can have high early hazard and still contain the most durable survivors, which is exactly the a5 lesson a6 leans on: churn falls with tenure because the fragile leave first.

The frozen-history tie-in. a2's completed triangle cells are facts: frozen, immutable, never restated. Survival estimates are not facts - they are models, and they legitimately update as more customers age and censored lifetimes resolve. So version them: stamp every KM median and hazard ratio with a fit date and a data cutoff, and never splice a re-fit estimate into a frozen reported table as if it were the same kind of number.

When NOT to reach for survival: descriptive reporting where the cohort table already answers the question. Reach for it when censoring is material (many still-active customers), when you need covariate effects, or when you want individual lifetime estimates. A tool upgrade you do not need is just a slower way to say the same thing.

Real world

Subscription and non-subscription businesses are playing different games. A contract business observes churn: the cancellation is an event with a timestamp, so KM and Cox apply directly - which is why Telco churn is the canonical teaching dataset. A shop or a game only ever infers churn from silence, through the inactivity-window definitions from Part 1. That inference gap is precisely why the buy-till-you-die models in a6 exist: they replace "define a window and hope" with a per-customer P(alive).

Homework

Try it yourself - this week ◐ 30 min total

Same orders, three churn definitions, three medians (illustrative) 60-day window median ~5.1 mo strict: silence becomes churn fast, lifetimes cut short 90-day window median ~7.4 mo the middle-of-the-road pick 120-day window ~9.8 mo lenient: slow buyers stay censored longer, fewer churn events, longer lifetimes 0 2 4 6 8 10 months From 60 to 120 days the median nearly doubles - the definition set the answer before any model did. None of the three is wrong. The churn window is a modeling choice: print it on the chart, keep it fixed across reruns.
🔍 Click to zoom - three churn windows, three medians: the definition is a modeling choice, so print it
Sources covered

What this session draws on

Survival analysis is a mature statistical field; this session teaches the working retention-analyst core of:

✓lifelines documentation - KaplanMeierFitter, logrank_test, CoxPHFitterParts 1-3 · the build-along API
✓Kaplan-Meier and Cox proportional hazards foundationsParts 1 + 3 · estimator, hazard ratios, PH assumption, C-index
◐Telco churn teaching dataset - the canonical contract-type readPart 3 · used for shape and the ~3x month-to-month hazard; full modeling in a7
✓Course-map churn-window guidance for non-subscription dataPart 1 + homework · 60/90/120 days or a multiple of own inter-purchase gap
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Mean tenure of your churned customers is 4.3 months. Why is "average customer lifetime is 4.3 months" wrong?

Still-active customers are right-censored: their lifetimes are at least what you observed, and they are disproportionately your durable customers. Averaging only observed churns systematically understates lifetime. KM keeps them in the risk set and gets the honest answer.

2 · Your Cox summary shows exp(coef) = 3.0 for month-to-month contracts. What does that mean?

A hazard ratio is multiplicative instantaneous risk among survivors, assumed constant over time (that is the proportional-hazards assumption - check it). It is not a difference in months and not a percentage-point gap.

3 · When is the plain a2 cohort table enough, with no survival model at all?

The cohort table is the right tool for describing what happened. Survival earns its complexity when censoring is material, when you need covariate effects (Cox), or when you want per-customer lifetime estimates. Matching tool to question is the skill.

Analyst session 5 cheat sheet · pin this

Right-censoringStill active at data end = not churned, lifetime at least what you saw. Dropping them biases every lifetime estimate low.
Kaplan-Meier S(t)Probability of surviving past t. Steps down only at observed churn events; censored customers exit the risk set without a step.
The naive-lifetime trapMean tenure of churned customers counts only quitters. Six-customer example: naive 4.3 months vs honest KM median 7.
Log-rank testIs the gap between two KM curves real? Tests whole curves, not one time point. Report gap size and p-value together.
Hazard vs probabilityHazard = instantaneous risk among survivors at t, per unit time. S(t) is the cumulative result. High early hazard can coexist with durable survivors.
Cox PHSemi-parametric: exp(coef) = multiplicative hazard ratio, no baseline needed. check_assumptions() before trusting; C-index for ranking quality.
Churn windows for shopsNon-subscription churn = inactivity window (60/90/120d, or a multiple of own inter-purchase gap). The definition is a modeling choice - print it.
Models get versionsSurvival estimates update as data accrues - stamp fit date + cutoff. Unlike a2's frozen triangle, they are models, not frozen facts.