Unlocking Customer Lifetime Value (CLV) with Kaplan-Meier Survival Curves

Nish · February 20, 2026

⏱️ 36 min read

Table of Contents

How much is a customer worth? Every acquisition budget, retention campaign, and audience targeting decision quietly depends on an answer, and the honest answer has two parts: how long the customer will stay, and how much they generate while they do. The second part is usually arithmetic on your pricing model. The first part is a genuine statistical problem, because at any moment most of your customers have not churned yet, so most of the lifetimes you want to average are unfinished. This post works through the classical tool for exactly that situation, the Kaplan-Meier survival curve: why the obvious shortcuts fail, where the estimator comes from (with every result derived, not asserted), how uncertain the fitted curve really is, how to build the input data and fit it with a few lines of Python, and how to turn the curve into a customer lifetime value you can defend in front of a finance team.

TL;DR

  • A live customer base is a censored dataset. For every active customer you know only that their lifetime exceeds their current tenure. Dropping them, or counting them as churned, biases lifetime estimates badly; on our simulated base the two shortcuts land 40 and 32 points too low at month 24.
  • The Kaplan-Meier estimator solves censoring with one idea: survival factorizes into conditional survivals. Surviving 24 months means surviving month 1, then month 2 given month 1, and so on. Each factor \(1 - d_i/n_i\) is estimated from exactly the customers observable at that point, so a censored customer contributes evidence for as long as we saw them and then exits without distorting anything.
  • Each factor is a maximum likelihood estimate, and the product inherits a computable variance: Greenwood’s formula gives the confidence band, and the band widening where the risk set thins is the curve telling you where to stop trusting it.
  • The most dangerous data bug is survivorship bias: include customers acquired before your history begins and you only see the ones who lived long enough to be visible, so the curve comes out flattering. The fix is one filter (or lifelines’ entry argument).
  • CLV is a discounted sum of survival probabilities. The expected lifetime of a discrete lifetime variable is just the area under the survival curve (derived below), so \(\mathrm{CLV} = \sum_t m\,\hat{S}(t)\,\delta^t\): margin, damped by the probability the customer is still there, damped by the time value of money. Our simulated base prices a new customer at \$557 over 36 months against a no-churn ceiling of \$1,008.
  • Everything here runs in a few lines of lifelines, and the post ends with the honest limitations: no covariates, no extrapolation past your observation window, and pointers to Cox, parametric, and discrete-time models when you outgrow it.

The two-part problem

Customer lifetime value factors cleanly:

\[\mathrm{CLV} \;=\; \mathbb{E}\big[\, \text{lifetime revenue} \,\big] \;=\; \sum_{t} \underbrace{\mathbb{E}\big[\, \text{revenue in period } t \,\big]}_{\text{pricing model}} \times \underbrace{P\big(\, \text{still a customer at } t \,\big)}_{\text{survival model}}\]

For a subscription business the first factor is close to a constant (the monthly contribution margin), which is why this post spends nearly all its effort on the second: estimating the probability that a customer is still around after \(t\) months. That probability, as a function of \(t\), is called the survival curve, and the whole art is estimating it from data where most customers are still alive.

We will carry one running example throughout: a telco-style subscription base of 2,500 customers across two products, broadband on 24-month contracts and SIM-only mobile on 12-month contracts, observed at a single snapshot date. The base is simulated with a known churn process (the full recipe is in a collapsible block later, so every number in this post is reproducible), which buys us something a real dataset never offers: we know the true survival curve, so we can see exactly how good or bad each estimate is.

The data you actually have

Pull a customer base from the warehouse and each customer contributes one interval: they joined on some date, and either churned on a known date or were still active when you looked. The second kind of record is called right-censored: the observation ends before the event does, so the true lifetime is known only to exceed the observed tenure. The word “right” refers to the timeline, with the unknown remainder of the lifetime lying to the right of where our view stops.

Two-panel diagram of eleven customer timelines. The left panel plots each customer against calendar time, all ending at a dashed vertical snapshot line: some end earlier in a filled ember churn dot, others reach the snapshot line with an open circle marking a censored lifetime. The right panel re-aligns the same customers to a shared tenure axis starting at zero, with gray arrows extending past each open circle to show that the true lifetime continues beyond what was observed.
Right-censoring, drawn. Left: in calendar time, every record ends at churn (filled dot) or at the snapshot (open circle). Right: survival analysis re-aligns everyone to their own clock, months since joining. A censored customer is not missing data; they are a hard fact, this lifetime lasted at least this long, whose ending we have not seen yet. Customers drawn from the simulated base used throughout the post.

Censoring is the entire difficulty, and it is worth seeing concretely why the two tempting shortcuts fail before building the estimator that does not.

Shortcut 1: analyze only churned customers. Compute lifetimes for customers who actually churned and average those. The problem: at any snapshot, the customers who have already churned are disproportionately the short-lived ones, while the long-lived are mostly still active and therefore excluded. You are averaging over a sample selected for dying early.

Shortcut 2: treat every record’s end as a churn. Use each customer’s observed tenure and pretend all of them ended in churn. Now a loyal customer who joined four months ago is recorded as a four-month lifetime, and the estimate drowns in fictional early exits.

Survival probability against tenure in months for the simulated base. A thick teal curve marks the true survival function. An ember step curve for the Kaplan-Meier estimate tracks it closely. Two plum curves fall far below: a solid one from keeping only churned customers and a dashed one from counting censored customers as churned, annotated as sitting 40 and 32 points too low at month 24.
Both shortcuts, fitted to the same 2,500 simulated customers as the Kaplan-Meier estimate. Shortcut 1 (solid plum) drops censored customers, but the still-active customers are exactly the long-lived ones, so the estimate collapses. Shortcut 2 (dashed plum) counts every censored tenure as a churn and invents early exits. Kaplan-Meier (ember) uses censored customers for precisely as long as they were observed and lands on the truth (teal), which is known here because the data is simulated.

Both shortcuts are biased in the same direction, toward pessimism about survival, and the size of the bias grows with the fraction of the base that is censored, which for a healthy business is most of it (60% of our simulated base is still active). Any downstream CLV built on them undervalues customers dramatically. The fix is not a correction factor; it is a different way of composing the estimate.

The survival toolkit: two functions, one identity

Let \(T\) be a customer’s lifetime in whole months, a random variable. Two functions describe it, and the whole method rests on the relationship between them.

The survival function is the headline object, the probability of lasting beyond \(t\) months:

\[S(t) = P(T > t)\]

It starts at \(S(0) = 1\), never increases, and its value at each tenure is exactly the “probability still a customer” your CLV needs.

The hazard is the local risk: the probability of churning in month \(t\) given survival up to its start,

\[h(t) = P(T = t \mid T \ge t)\]

This conditional framing looks like a technicality and is actually the entire trick. Hazards are what businesses intuitively track (“what fraction of month-13 customers cancel during month 13?”), they are where the mechanism lives (onboarding friction shows up as high early hazard, contract expiries as spikes at months 13 and 25), and, crucially, each one can be estimated from whichever customers happen to be observable at that age.

The identity connecting them: surviving past month \(t\) means surviving month 1, then surviving month 2 given that, and so on. Formally, the event \(\{T > t\}\) is contained in \(\{T > t-1\}\), which is contained in \(\{T > t-2\}\), so the chain rule of conditional probability unrolls telescopically:

\[\begin{aligned} S(t) &= P(T > 1)\; P(T > 2 \mid T > 1) \cdots P(T > t \mid T > t-1) && \textcolor{#7b8794}{\small\text{chain rule over nested events}} \\[4pt] &= \prod_{u=1}^{t} P(T > u \mid T \ge u) && \textcolor{#7b8794}{\small\text{for integer lifetimes, } T > u-1 \text{ and } T \ge u \text{ are the same event}} \\[4pt] &= \prod_{u=1}^{t} \big(1 - h(u)\big) && \textcolor{#7b8794}{\small\text{each factor is one month's conditional survival}} \end{aligned}\]

Survival is a product of one-month conditional survivals. Estimate the hazards and the curve follows.

The Kaplan-Meier estimator

Estimating one hazard

Fix a month \(u\) and ask: of the customers we could actually watch during their \(u\)-th month, what fraction churned? Two counts define the answer. The risk set size \(n_u\) is the number of customers still under observation and still active as month \(u\) begins, meaning neither churned nor censored earlier. The event count \(d_u\) is how many of those \(n_u\) churned during month \(u\). The natural estimate is

\[\hat{h}(u) = \frac{d_u}{n_u}\]

and this is not just natural but the maximum likelihood estimate. Conditional on being at risk, each of the \(n_u\) customers either churns that month (probability \(h_u\)) or does not, independently, so the month’s evidence about \(h_u\) is a binomial experiment with likelihood \(L(h_u) = h_u^{\,d_u} (1 - h_u)^{\,n_u - d_u}\), and maximizing it gives the sample fraction (tool 1 in the appendix does the two-line calculus). The full likelihood of the whole censored dataset factorizes into one such term per month, with each customer appearing in exactly the months they were observed, which is the formal sense in which the estimator wastes nothing: a customer censored at month 9 contributed their presence to \(n_1, \dots, n_9\) and then stopped making claims about months they were not seen in.

Plugging the estimated hazards into the product identity gives the Kaplan-Meier estimator, also called the product-limit estimator:

\[\hat{S}(t) = \prod_{i:\; t_i \le t} \left( 1 - \frac{d_i}{n_i} \right)\]

where the product runs over the observed event times \(t_i\) up to \(t\) (months with no churn contribute a factor of exactly 1, so only event times matter). Between events the curve is flat; at each event it steps down by the factor above. Censored customers never trigger a step. Their only effect is to quietly leave the risk set, shrinking the \(n_i\) of every later factor.

The estimator by hand

Formulas earn trust by being executed once at small scale. Take twelve customers: six churned, at months 3, 5, 5, 9, 13, and 17; six censored, at months 2, 7, 11, 15, 19, and 20. Walking the event times:

event time \(t_i\) at risk \(n_i\) churned \(d_i\) factor \(1 - d_i/n_i\) \(\hat{S}(t_i)\)
3 11 1 10/11 0.909
5 10 2 8/10 0.727
9 7 1 6/7 0.623
13 5 1 4/5 0.499
17 3 1 2/3 0.332

Read one row closely to see censoring at work. At month 3, only 11 customers are at risk, not 12, because the customer censored at month 2 has already left our view; one of the 11 churns, so the curve drops to 10/11. By month 9 the risk set is down to 7: the two censored at 2 and 7 have exited silently, the three churners at 3, 5, 5 loudly. Every factor is an honest fraction over exactly the customers we could see at that age. One bookkeeping convention to know when reproducing tables like this: a customer censored in the same month as an event still counts in that month’s risk set, censoring being treated as happening just after the month’s events, which is the convention lifelines and every standard implementation follow.

Two stacked panels sharing a tenure axis. The top panel shows twelve customer timelines sorted by length, six ending in filled ember churn dots and six in open censoring circles, with faint vertical lines at each churn time. The bottom panel shows the resulting ember Kaplan-Meier step curve, each step annotated with its at-risk count, churn count, and multiplication factor, such as n equals 11, d equals 1, times ten elevenths gives 0.91. Small tick marks on the curve mark where censored customers exit without a step.
The worked example, drawn. Top: twelve customers, sorted by observed tenure. Bottom: the product-limit curve they imply. Each churn time contributes one factor built from the customers still visible at that age; censored customers (tick marks) never move the curve down, they only shrink the n of every later factor.

This is the same calculation the original 1958 Kaplan and Meier paper proposed, and it remains one of the most cited statistics papers ever written, because the idea generalizes far beyond customers: patients in clinical trials, machine components, loan defaults, anywhere lifetimes meet an observation boundary.

What the estimator assumes

Four assumptions do the load-bearing work, and each one fails in a specific, checkable way.

  • The event and its timing are unambiguous. “Churn” needs a clean definition and date. Contractual businesses mostly have one; usage-based businesses have to draw a line (say, 60 days inactive) and should check the conclusions are not sensitive to where the line sits.
  • Censoring is non-informative. Customers censored at tenure \(t\) must face the same future risk as uncensored customers at tenure \(t\). Administrative censoring at a snapshot date is usually harmless. What is not: censoring that correlates with risk, for instance excluding customers mid-way through a save-desk intervention, or losing a risky segment in a data migration. If being censored predicts what would have happened next, every factor after that point is estimated on a distorted risk set.
  • No calendar drift within the sample. The estimator pools everyone by tenure regardless of join date, implicitly assuming a customer’s survival prospects do not depend on when they joined. Price rises, competitor launches, and product changes violate this. The practical check is to fit the curve per acquisition-year cohort and look for divergence; the practical fix is stratification, which we get to shortly.
  • The output is a step function over the observed window. The curve is defined up to the largest observed tenure and says nothing beyond it, and it has no covariates: it describes a population, not an individual. Both limits have proper escape routes, covered at the end of the post.

How wrong could the curve be? Greenwood’s formula

A curve fitted from finite data needs an error bar, and this one has a classical closed form. The derivation is a nice showcase of two workhorse moves, turning a product into a sum, and pushing variance through a function with the delta method, so it is worth the half page. (As in previous posts, every probability fact these derivations lean on is stated and proved in the appendix toolbox; nothing rests on an unproven “it can be shown”.)

Products are awkward to analyze; logarithms turn them into sums:

\[\log \hat{S}(t) = \sum_{i:\; t_i \le t} \log\big(1 - \hat{h}_i\big)\]

The estimated hazards at different event times are uncorrelated, because each is a fresh conditional experiment on its own risk set (rigorously an appeal to martingale theory, but the intuition is exactly that), so the variance of the sum is the sum of the variances, and each term yields to the delta method (tool 2: for a function of a noisy quantity, \(\mathrm{Var}\,g(X) \approx g'(\mu)^2\, \mathrm{Var}(X)\)):

\[\begin{aligned} \mathrm{Var}\big[\log(1 - \hat{h}_i)\big] &\approx \frac{\mathrm{Var}(\hat{h}_i)}{(1 - h_i)^2} && \textcolor{#7b8794}{\small\text{delta method with } g(x) = \log(1-x),\ g'(x)^2 = (1-x)^{-2}} \\[4pt] &= \frac{h_i (1 - h_i) / n_i}{(1 - h_i)^2} = \frac{h_i}{n_i (1 - h_i)} && \textcolor{#7b8794}{\small\text{variance of a binomial proportion is } p(1-p)/n} \\[4pt] \widehat{\mathrm{Var}}\big[\log \hat{S}(t)\big] &= \sum_{i:\; t_i \le t} \frac{d_i}{n_i (n_i - d_i)} && \textcolor{#7b8794}{\small\text{sum the terms and plug in } \hat{h}_i = d_i / n_i} \end{aligned}\]

One more delta-method step (this time through \(g(x) = e^x\)) carries the variance back to the survival scale, giving Greenwood’s formula:

\[\widehat{\mathrm{Var}}\big[\hat{S}(t)\big] \;\approx\; \hat{S}(t)^2 \sum_{i:\; t_i \le t} \frac{d_i}{n_i (n_i - d_i)}\]

Read the summand as a running record of how much each event time taught us: months where the risk set \(n_i\) was large contribute almost nothing to the uncertainty, months where it was thin contribute a lot. A naive interval of \(\hat{S} \pm 1.96\,\widehat{\mathrm{se}}\) can escape \([0, 1]\), so software (lifelines included) builds the interval on the doubly-logged scale, where the quantity \(\log(-\log \hat{S})\) is unconstrained, and maps back, producing the asymmetric band \(\hat{S}(t)^{\exp(\pm 1.96\, \widehat{\mathrm{se}}_{\theta})}\) with \(\widehat{\mathrm{se}}_{\theta}\) the Greenwood sum’s square root divided by \(\lvert \log \hat{S}(t) \rvert\).

Kaplan-Meier survival curve for 2,500 simulated customers in ember with a translucent gold Greenwood 95 percent confidence band. The curve steps down from 1.0, with annotated accelerations where 12-month mobile contracts and 24-month broadband contracts end. Dashed guide lines mark the median lifetime of 26 months at survival 0.5. A row of at-risk counts under the axis falls from 2,500 at month zero to 25 at month 40, and the band visibly widens toward the right edge.
The full fitted curve with its Greenwood 95% band. The two accelerations are the products' contract expiries showing up as hazard spikes. The row of at-risk counts beneath the axis is the honest denominator behind each stretch of curve: by month 40 only 25 customers inform the estimate, and the band widens accordingly. Reading the median off the curve: the first tenure where the curve crosses 0.5, here 26 months.

Two reading habits are worth building. First, always plot the band; a survival curve without one invites overconfident decisions in exactly the tail region where the data is thinnest. Second, report the median lifetime (first \(t\) with \(\hat{S}(t) \le 0.5\), here 26 months) rather than the mean when summarizing, because the mean is undefined until the curve reaches zero, and ours, like most customer curves, does not.

Building the dataset from a real customer base

Everything the estimator needs is two columns, plus any features you want to stratify by:

snapshot_date id product acquisition_date churn_date tenure churned
2024-05-28 1234 broadband 2017-01-12 NULL 88 0
2024-05-28 2345 broadband 2021-12-09 NULL 29 0
2024-05-28 3456 mobile 2021-12-12 NULL 29 0
2024-05-28 5678 mobile 2021-12-13 2023-12-13 24 1

tenure is months from acquisition to churn (if churned) or to the snapshot (if not); churned is the event flag, with 0 marking a censored record. In pandas:

import pandas as pd

snapshot = pd.Timestamp("2024-05-28")
base = pd.read_parquet("customer_base.parquet")  # one row per customer

end = base["churn_date"].fillna(snapshot)
base["tenure"] = (
    (end.dt.year - base["acquisition_date"].dt.year) * 12
    + (end.dt.month - base["acquisition_date"].dt.month)
).clip(lower=1)
base["churned"] = base["churn_date"].notna().astype(int)

Warning, survivorship bias: the one data bug that silently ruins survival curves. Suppose your reliable churn history starts in January 2021, but the base contains customers acquired in 2017. The 2017 joiners you can see today are, by construction, the ones who survived at least four years; their cohort-mates who churned in 2018 never enter your extract. Include them and the left side of your curve is estimated partly from a sample of certified survivors, so the whole curve comes out flattering. The clean fix is one filter, keep only customers acquired after your history begins:

base = base[base["acquisition_date"] >= pd.Timestamp("2021-01-01")]

The statistical name for the situation is left truncation, and if discarding the old cohorts hurts too much, lifelines supports the proper correction via the entry argument to fit, which tells the estimator each customer’s delayed entry into observation so they join the risk sets late instead of poisoning the early ones.

Fitting it in Python

The lifelines library is the standard Python home for this. Fitting is three lines; the objects it returns are the survival function, the Greenwood band, and the summaries we have been deriving:

from lifelines import KaplanMeierFitter

kmf = KaplanMeierFitter()
kmf.fit(df["tenure"], event_observed=df["churned"], label="all customers")

kmf.median_survival_time_                 # 26.0 months
kmf.survival_function_at_times([12, 24, 36])
# 12    0.734
# 24    0.564
# 36    0.387

ax = kmf.plot_survival_function()         # step curve + Greenwood band

Those are the exact numbers in the figures above: a new customer has a 73% chance of reaching one year, 56% of reaching two, and a median lifetime of 26 months. If you want to reproduce everything locally, the block below generates the simulated base used throughout this post; on real data, the shaping snippet from the previous section replaces it.

The simulated customer base (click to expand)

Each product has a monthly hazard with fading onboarding churn plus a spike when its contract term ends. Lifetimes are drawn by inverting the survival function; staggered acquisition over 40 months provides the censoring.

import numpy as np
import pandas as pd

def monthly_hazard(product: str, horizon: int = 120) -> np.ndarray:
    """Monthly churn hazard h(t) = P(churn in month t | still active at its start)."""
    t = np.arange(1, horizon + 1)
    if product == "broadband":
        h = 0.012 + 0.035 * np.exp(-(t - 1) / 2.5)   # onboarding churn fades
        h[(t >= 25) & (t <= 27)] += 0.085            # 24-month contracts end
    else:  # mobile
        h = 0.028 + 0.045 * np.exp(-(t - 1) / 2.0)
        h[(t >= 13) & (t <= 15)] += 0.075            # 12-month contracts end
    return h

def simulate_customers(n: int = 2500, seed: int = 42) -> pd.DataFrame:
    """A subscription base observed at one snapshot: staggered acquisition
    over the last 40 months, so most true lifetimes are right-censored."""
    rng = np.random.default_rng(seed)
    product = rng.choice(["broadband", "mobile"], size=n, p=[0.6, 0.4])
    observed_for = rng.integers(1, 41, size=n)       # months on book at the snapshot
    lifetime = np.zeros(n, dtype=int)
    for prod in ["broadband", "mobile"]:
        mask = product == prod
        surv = np.concatenate([[1.0], np.cumprod(1 - monthly_hazard(prod))])
        u = rng.uniform(size=mask.sum())
        t = (surv[None, :] < u[:, None]).argmax(axis=1)   # first t with S(t) < u
        lifetime[mask] = np.where(u < surv[-1], len(surv), t)
    return pd.DataFrame({
        "product": product,
        "tenure": np.minimum(lifetime, observed_for),     # what we can see
        "churned": (lifetime <= observed_for).astype(int),
        "observed_for": observed_for,
    })

df = simulate_customers()

Stratify before you average

The pooled curve above is an average over two genuinely different populations, and averages of survival curves can mislead: our pooled curve shows two mysterious accelerations that belong to different products’ contract expiries. Fitting per segment is the same three lines inside a loop, and comparing segments has its own classical significance test, the log-rank test, which asks whether the observed churn events split between two groups the way chance would split them if both shared one survival curve. It compares each group’s observed events against its expected share at every event time, then aggregates; tool 4 in the appendix builds the statistic from scratch.

from lifelines import KaplanMeierFitter
from lifelines.statistics import logrank_test

for product, sub in df.groupby("product"):
    KaplanMeierFitter().fit(
        sub["tenure"], event_observed=sub["churned"], label=product,
    ).plot_survival_function()

bb, mob = (df[df["product"] == p] for p in ["broadband", "mobile"])
result = logrank_test(bb["tenure"], mob["tenure"],
                      bb["churned"], mob["churned"])
result.test_statistic, result.p_value    # (174.7, 7.0e-40)
Two Kaplan-Meier curves with translucent confidence bands: a teal broadband curve declining gently with a step down after month 25, ending near 0.45 at month 40, and an ember mobile SIM-only curve falling faster with a sharp drop after month 13, ending near 0.25. A gold annotation reports a log-rank chi-squared of 175 with p below ten to the minus 39.
The pooled curve hid two different businesses. Broadband customers (teal, 24-month contracts) have a median lifetime of 32 months; mobile SIM-only customers (ember, 12-month contracts) have a median of 14, with the contract-expiry cliff arriving a year earlier. The log-rank test confirms what the eye sees: these are not one population.

The medians are 32 months for broadband against 14 for mobile, a gap the pooled median of 26 conceals entirely, and any CLV, audience valuation, or payback calculation that uses the pooled curve will overvalue mobile customers and undervalue broadband ones. In practice you stratify on the few features that plausibly shift the lifecycle (product, plan tier, acquisition channel), fit one curve per combination, and at scoring time look up each customer’s curve by matching those same features.

Tip: stratification spends data fast; ten binary features would mean 1,024 cells, most with risk sets too thin for a trustworthy curve (Greenwood will tell you, via bands wide enough to be useless). Keep strata few and coarse. When you genuinely need many segments, the right tools are models that share strength across groups instead of fitting each alone, in the same spirit as the partial pooling of the mixed effects post, with survival-specific options in the final section.

From survival curve to customer lifetime value

The curve is fitted; now the payoff. The bridge between survival probabilities and expected lifetimes is a small identity worth knowing on its own: for a non-negative integer lifetime, the expected value is the sum of the survival curve,

\[\mathbb{E}[T] \;=\; \sum_{t=0}^{\infty} S(t)\]

that is, the area under the curve (tool 3 derives it in three lines by swapping a double sum). The intuition is lovely: a customer’s lifetime is a sum of month-indicators, one per month they survive into, and each indicator’s expected value is exactly a point on the survival curve, so summing the curve sums the expected months.

CLV upgrades this identity twice. First, weight each month by the margin \(m\) it generates. Second, discount: a dollar of margin twelve months out is worth less than one today, by the monthly factor \(\delta = (1 + r)^{-1/12}\) for an annual discount rate \(r\). Together, over a planning horizon of \(H\) months:

\[\mathrm{CLV}_H \;=\; \sum_{t=1}^{H} m \cdot \hat{S}(t) \cdot \delta^{\,t}\]

margin, damped by the probability the customer is still there to pay it, damped by the time value of money. The horizon matters for honesty: the fitted curve only exists out to the largest observed tenure (40 months here), so we compute a 36-month CLV rather than pretend to know the far tail. What this truncated sum estimates is, in survival-analysis terms, a discounted restricted mean lifetime, and treating it as a floor rather than the whole story is the defensible move.

Three-panel pipeline. Panel one, titled Survive, shows the ember Kaplan-Meier curve over 36 months. Panel two, titled Expect and discount, shows gold bars of expected discounted margin per month declining from about 27 dollars toward 8, beneath a dashed line marking the 28 dollar margin if churn were impossible. Panel three, titled Accumulate, shows the cumulative sum rising to a point labeled CLV of 36 months approximately 557 dollars, well below a dashed no-churn ceiling of 1,008 dollars.
CLV as three mechanical steps applied to the fitted curve, at a margin of \$28 per month and a 10% annual discount rate. Each month contributes its margin, damped by the probability of still being a customer and by the time value of money; the accumulated total is the price of a new customer, \$557 over 36 months against a \$1,008 no-churn ceiling.

In code, working directly from the fitted lifelines object:

import numpy as np

def clv_from_km(kmf, margin=28.0, annual_discount=0.10, horizon=36):
    """Expected discounted margin over the next `horizon` months, new customer."""
    delta = (1 + annual_discount) ** (-1 / 12)
    t = np.arange(1, horizon + 1)
    s = kmf.survival_function_at_times(t).to_numpy()
    return float((margin * s * delta ** t).sum())

clv_from_km(kmf)    # 557.30

A new customer is worth about \$557 over three years, 55% of the \$1,008 a churn-free customer would deliver. Run per stratum instead of pooled and the gap from the previous section becomes money: the broadband curve prices its customers far above the mobile curve, which is exactly the input acquisition budgeting needs.

One refinement makes this serviceable for the base you already own, not just for new acquisitions. An existing customer at tenure 12 is not a fresh draw from \(\hat{S}\); they have already survived a year, so their future is governed by the conditional survival \(P(T > 12 + k \mid T > 12) = S(12+k)/S(12)\). No new machinery hides in that equality: surviving past month \(12+k\) already implies surviving past month 12, so the intersection in the definition of conditional probability collapses and what remains is a ratio of two points on the curve you already have:

def conditional_clv(kmf, tenure_now, margin=28.0, annual_discount=0.10, horizon=24):
    """Expected discounted margin over the next `horizon` months, given
    the customer has already survived to `tenure_now`."""
    delta = (1 + annual_discount) ** (-1 / 12)
    k = np.arange(1, horizon + 1)
    s_now = float(kmf.survival_function_at_times([tenure_now]).iloc[0])
    s_future = kmf.survival_function_at_times(tenure_now + k).to_numpy()
    return float((margin * (s_future / s_now) * delta ** k).sum())

conditional_clv(kmf, tenure_now=12)    # 440.57

A customer twelve months in is worth about \$441 over their next two years.

The last step from analysis to something a team can actually use is scoring the whole base: every active customer gets a forward value from their own stratum’s curve, conditioned on their own tenure. Two production details need care, and both are handled in the function below. Vectorize the arithmetic instead of calling conditional_clv in a loop (a tenure-by-horizon matrix does every customer of a stratum at once), and never let \(t + k\) walk past the observed window, because indexing a step function beyond its last point silently extrapolates a flat curve; here those months contribute zero, keeping the score an honest floor.

def score_base(df, margin=28.0, annual_discount=0.10, horizon=24):
    """Attach a forward CLV to every active customer: expected discounted
    margin over the next `horizon` months, conditional on current tenure,
    from the customer's own stratum's curve."""
    delta = (1 + annual_discount) ** (-1 / 12)
    k = np.arange(1, horizon + 1)
    scored = df.copy()
    scored["future_clv"] = np.nan
    for _, sub in df.groupby("product"):
        kmf = KaplanMeierFitter().fit(sub["tenure"], event_observed=sub["churned"])
        t_max = int(sub["tenure"].max())
        s = kmf.survival_function_at_times(np.arange(t_max + 1)).to_numpy()
        active = sub.index[sub["churned"] == 0]
        tenure = sub.loc[active, "tenure"].to_numpy()
        future = np.minimum(tenure[:, None] + k, t_max)   # clamp to the window
        value = margin * (s[future] / s[tenure][:, None]) * delta ** k
        value[tenure[:, None] + k > t_max] = 0.0          # never extrapolate
        scored.loc[active, "future_clv"] = value.sum(axis=1)
    return scored

scored = score_base(df)
scored.loc[scored["churned"] == 0, "future_clv"].describe()
# count 1,507   mean 366.50   50% 374.66   max 518.41

That column is the deliverable: 1,507 active customers priced over their next two years (averaging \$383 for broadband against \$330 for mobile), ready to join a campaign table or a finance forecast, refreshed as easily as the curves are refitted. One honesty note carries over from the horizon discussion: for customers already near the edge of the observed window the zeroed months make the floor very loose (the longest-tenured customers here score near zero), which is the no-extrapolation limitation surfacing in production, and exactly the point at which the parametric models of the next section earn their keep.

What CLV is for

The classic consumer of these numbers is acquisition economics. Every channel has a cost of acquisition (CAC); putting each channel’s CAC against the CLV of the customers it delivers turns marketing allocation from a reach argument into an expected-value calculation.

Scatter plot of 36-month CLV against customer acquisition cost for eight illustrative acquisition segments, with bubble area showing monthly volume. A dashed gray break-even line where CLV equals CAC separates a red-tinted value-destroying region below from a teal-tinted region above, and a steeper dashed teal line marks CLV equal to three times CAC. Referral and organic search segments sit high and left; price-comparison and social ads sit near the break-even diagonal.
The targeting view (segments illustrative). Every acquisition segment is a point: what a customer from it costs against what the survival curve says they are worth. Below the diagonal, acquisition destroys value; the common rule of thumb asks for CLV around three times CAC. The interesting calls are rarely the corners: referral customers are cheap and valuable (buy more), while door-to-door here is valuable but so expensive the margin barely clears.

Beyond acquisition, the same per-customer forward value drives retention prioritization (a save offer worth \$50 is trivially justified against a \$441 conditional CLV, and unjustifiable for a customer whose stratum’s curve prices the next two years at \$60) and plain financial planning, since summing conditional CLV across the base is a revenue forecast with churn already priced in. Deciding which at-risk customers to intervene on, and with what, is its own discipline beyond the scope of a survival curve; the engineering retention post covers a production system built around exactly that question.

Beyond Kaplan-Meier

Kaplan-Meier is the right first tool, and knowing its edges tells you when to reach for the next one.

No covariates. The estimator describes populations, so individual-level features enter only through stratification, which runs out of data quickly. The Cox proportional hazards model is the classical continuous answer: it models each customer’s hazard as a shared baseline scaled by \(\exp(\beta^\top x)\), giving interpretable multiplicative risk factors (“each support ticket in the first month multiplies churn hazard by 1.3”) while leaving the baseline as flexible as KM itself; lifelines.CoxPHFitter has the same API shape as everything above. Alternatively, for monthly data there is a trick that reuses tools you may already have: expand each customer into one row per month survived (person-period format) and fit a logistic regression predicting the churn indicator from tenure dummies plus features; that is a discrete-time hazard model, estimating exactly the \(h(t)\) this post started from, with features.

No extrapolation. The step function ends at your largest observed tenure, and CLV beyond it requires a shape assumption. Parametric survival models (Weibull, log-normal, log-logistic; lifelines.WeibullFitter and friends) buy extrapolation by assuming a functional form, and should always be sanity-checked against the KM curve on the observed window before being trusted beyond it.

Contractual assumptions. Everything here treats churn as an observable event, which fits subscriptions. In non-contractual settings (retail, e-commerce) customers never announce their death, they just stop buying, and the right tools are latent-lifetime models like BG/NBD and Gamma-Gamma from the Fader-Hardie line of work, which infer aliveness from purchase timing.

None of this displaces the KM curve as the first artifact to produce: it is assumption-light, it is the benchmark every fancier model must reproduce on the observed window, and nine times out of ten its stratified version already answers the business question.

Conclusion

The Kaplan-Meier estimator earns its place in the customer-analytics toolkit by solving the one problem every lifetime dataset has: the customers you most want to learn from are the ones whose stories have not ended. The whole construction is three ideas snapped together, survival factorizes into conditional survivals, each conditional survival is a fraction over the customers actually observable at that age, and uncertainty propagates through the product in closed form. From the fitted curve, lifetime value is a discounted sum, conditioning on current tenure is a ratio, and segment comparisons come with a proper significance test. The derivations fit in a blog post, the implementation fits in twenty lines of lifelines, and the output is a defensible dollar value on every customer you have or might buy.

Appendix: the derivation toolbox

The four facts the derivations above lean on, each with its proof, so the post stands alone.

1. The MLE of a proportion

Claim. If \(d\) events occur among \(n\) independent trials each with probability \(h\), the likelihood \(L(h) = h^d (1-h)^{n-d}\) is maximized at \(\hat{h} = d/n\).

Proof. Work with the log-likelihood (same maximizer, easier calculus) and follow the standard recipe: differentiate, set to zero, solve.

\[\begin{aligned} \ell(h) &= d \log h + (n - d) \log(1 - h) && \textcolor{#7b8794}{\small\text{take logs: the product becomes a sum}} \\[4pt] \ell'(h) &= \frac{d}{h} - \frac{n - d}{1 - h} \;=\; 0 && \textcolor{#7b8794}{\small\text{differentiate and set to zero}} \\[4pt] d\,(1 - h) &= (n - d)\, h && \textcolor{#7b8794}{\small\text{multiply through by } h(1-h)} \\[4pt] \hat{h} &= \frac{d}{n} && \textcolor{#7b8794}{\small\text{the } dh \text{ terms cancel: events over trials}} \end{aligned}\]

This critical point is a maximum because \(\ell''(h) = -d/h^2 - (n-d)/(1-h)^2\) is negative everywhere. For the variance used by Greenwood’s formula: \(\hat{h}\) is a binomial count divided by \(n\), so \(\mathrm{Var}(\hat{h}) = n h(1-h) / n^2 = h(1-h)/n\). \(\blacksquare\)

2. The delta method

Claim. If a random quantity \(X\) has mean \(\mu\) and small variance \(\sigma^2\), and \(g\) is smooth near \(\mu\), then \(g(X)\) has variance approximately \(g'(\mu)^2 \sigma^2\).

Proof (sketch). The idea: a smooth curve looks like its tangent line up close, and \(X\) stays close to \(\mu\) when \(\sigma\) is small, so replace the curve with the tangent line and read off the variance.

\[\begin{aligned} g(X) &\approx g(\mu) + g'(\mu)\,(X - \mu) && \textcolor{#7b8794}{\small\text{first-order Taylor expansion; the error is order } (X-\mu)^2 \text{, negligible when } \sigma \text{ is small}} \\[4pt] \mathrm{Var}\big[g(X)\big] &\approx \mathrm{Var}\big[\,g(\mu) + g'(\mu)\,(X - \mu)\,\big] && \textcolor{#7b8794}{\small\text{take the variance of both sides}} \\[4pt] &= g'(\mu)^2\; \mathrm{Var}(X) && \textcolor{#7b8794}{\small\text{added constants do not move variance; scaling by } c \text{ scales it by } c^2} \end{aligned}\]

Greenwood’s formula uses this twice. First with \(g(x) = \log(1-x)\), whose derivative gives the factor \(g'(x)^2 = (1-x)^{-2}\). Then with \(g(x) = e^x\) to come back from the log scale: evaluated at \(x = \log S\), the factor is \(g'(\log S)^2 = (e^{\log S})^2 = S^2\), which is where the leading \(\hat{S}(t)^2\) comes from. \(\blacksquare\)

3. The tail-sum formula for expectations

Claim. For a random lifetime \(T\) taking values in the non-negative integers, \(\mathbb{E}[T] = \sum_{s=1}^{\infty} P(T \ge s) = \sum_{t=0}^{\infty} S(t)\).

Proof. The trick is to stop counting how long the lifetime is and instead count which months it reaches: a lifetime of 3 reaches months 1, 2, and 3, so it is a sum of three indicator variables. In general,

\[\begin{aligned} T &= \sum_{s=1}^{\infty} \mathbf{1}\{T \ge s\} && \textcolor{#7b8794}{\small\text{one indicator per month reached; exactly } T \text{ of them equal } 1} \\[4pt] \mathbb{E}[T] &= \sum_{s=1}^{\infty} \mathbb{E}\big[\mathbf{1}\{T \ge s\}\big] && \textcolor{#7b8794}{\small\text{expectation and sum swap safely: every term is non-negative}} \\[4pt] &= \sum_{s=1}^{\infty} P(T \ge s) && \textcolor{#7b8794}{\small\text{the expected value of an indicator is the probability of its event}} \\[4pt] &= \sum_{s=1}^{\infty} S(s - 1) \;=\; \sum_{t=0}^{\infty} S(t) && \textcolor{#7b8794}{\small\text{for integer } T,\ \{T \ge s\} = \{T > s-1\} \text{; re-index with } t = s - 1} \end{aligned}\]

Truncating the sum at a horizon \(H\) gives the expected value of the capped lifetime \(\min(T, H)\), the restricted mean the CLV section computes. \(\blacksquare\)

4. The log-rank statistic

Claim. Suppose two groups truly share one survival curve (the null hypothesis). At each event time \(t_i\) let the risk set hold \(n_i\) customers, \(n_{1i}\) of them from group 1, and let \(d_i\) events occur, \(d_{1i}\) of them in group 1. Then

\[\chi^2 \;=\; \frac{\Big( \sum_i \big( d_{1i} - \mathbb{E}[d_{1i}] \big) \Big)^2}{\sum_i \mathrm{Var}(d_{1i})}\]

follows approximately a chi-squared distribution with 1 degree of freedom, and large values are evidence against the shared curve.

Proof (sketch). The whole construction is one observation: under the null, group labels carry no risk information, so which \(d_{1i}\) of the month’s \(d_i\) events land in group 1 is pure chance, a draw of \(d_i\) customers without replacement from an urn holding \(n_{1i}\) group-1 members among \(n_i\). That makes \(d_{1i}\) hypergeometric, with known mean and variance:

\[\begin{aligned} \mathbb{E}[d_{1i}] &= d_i \cdot \frac{n_{1i}}{n_i} && \textcolor{#7b8794}{\small\text{group 1's fair share: its fraction of the risk set, times the events}} \\[4pt] \mathrm{Var}(d_{1i}) &= d_i \cdot \frac{n_{1i}}{n_i} \Big( 1 - \frac{n_{1i}}{n_i} \Big) \cdot \frac{n_i - d_i}{n_i - 1} && \textcolor{#7b8794}{\small\text{binomial-style spread, shrunk by a without-replacement correction}} \end{aligned}\]

Now aggregate the surprise. Each month’s discrepancy \(d_{1i} - \mathbb{E}[d_{1i}]\) has mean zero under the null, and discrepancies at different event times are uncorrelated for the same reason the Greenwood terms were: each is a fresh conditional experiment on its own risk set. So the summed discrepancy has mean zero and variance \(\sum_i \mathrm{Var}(d_{1i})\), the central limit theorem makes the standardized sum approximately standard normal, and squaring a standard normal gives a chi-squared with 1 degree of freedom. On our simulated base the observed value of 174.7 sits absurdly far into the tail of that distribution, hence the \(p\)-value of \(10^{-40}\). \(\blacksquare\)

Resources

Citation Information

If you find this content useful, please cite this work as:

Bhana, Nish. "Unlocking Customer Lifetime Value (CLV) with Kaplan-Meier Survival Curves". Nish Blog (February 2026). https://www.nishbhana.com/CLV/

Or use the BibTeX citation:

@article{bhana2026clv,
  title   = {Unlocking Customer Lifetime Value (CLV) with Kaplan-Meier Survival Curves},
  author  = {Bhana, Nish},
  journal = {nishbhana.com},
  year    = {2026},
  month   = {February},
  url     = {https://www.nishbhana.com/CLV/}
}

x.com, Facebook