Predicting customer lifetime value with survival models and Temporal Fusion Transformer revenue forecasts
A walkthrough of a customer lifetime value model built from two forecasts — how long a customer stays and how much they pay while they do: the customer-month data structure both models read, a Temporal Fusion Transformer for revenue, survival models for retention, and how the two curves combine into one number per customer — with simplified code for each step.
Retention and marketing budgets were being allocated on current-state numbers — this month's ARPU, this month's churn score. Both answer the wrong question. Carrying today's bill forward ranks a new high-paying line that will leave at its first contract end above a loyal mid-paying household, and a churn score is a probability for one window, not a picture of how long the relationship will last. Customer lifetime value asks a forward question instead: how much will this customer bring over the relationship that remains? Answering it means estimating two things that are usually modelled apart — future revenue, which drifts with upgrades, downgrades and add-ons, and the probability that the customer is still there to pay it.
In this post we walk through the model we built. A customer-month data structure combines static attributes, usage history, product information, service behaviour and time-varying variables. A Temporal Fusion Transformer forecasts each customer's monthly revenue trajectory from static, past and known-future inputs. Survival analysis — Multi-Task Logistic Regression and a Cox proportional-hazards model with elastic net — gives each customer a retention curve. Expected revenue in each future month, weighted by the probability of still being a customer, is summed into customer-level long-term value. The live model re-implements the same structure with a person-month logistic hazard and a revenue regression.
Solution overview
The model has two forecasting components, trained separately on one shared customer-month table, and a scoring step that combines them. The revenue component answers "how much per month, if the customer stays?"; the survival component answers "how likely is the customer to still be there?". Their product, summed over the horizon, is the customer's value, and the two curves behind it are kept so every value can be explained.
Customer-month structure
Monthly revenue trajectory
Retention curve per customer
Penalized hazard model
Multiply and sum
Rank and allocate
The numbered steps in the diagram:
- Static attributes, historical usage, product information, service behaviour and time-varying variables are combined into one customer-month table, with every variable typed as static, past-observed or known in advance.
- A Temporal Fusion Transformer forecasts monthly revenue over the horizon from static, past and known-future inputs.
- Multi-Task Logistic Regression models the probability of leaving in each discrete month jointly, giving a full survival curve per customer that treats customers still active at the end of the data as censored.
- A Cox proportional-hazards model with elastic net is the second survival model; its penalty shrinks and selects among many correlated candidate variables.
- Expected revenue in each month times the survival probability for that month, summed over the horizon, gives customer-level long-term value.
- Customers are ranked by long-term value and retention likelihood to decide who receives retention or marketing resources.
Technology stack
| Layer | Technology | What it does here |
|---|---|---|
| Data | Customer × month panel (Python) | Static attributes, usage history, products, behaviour and time-varying variables in one structure |
| Revenue model | Temporal Fusion Transformer | Variable selection, sequence encoding and attention over static, past and known-future inputs |
| Survival model | Multi-Task Logistic Regression | Discrete-time survival curves estimated jointly across months |
| Survival · selection | Cox proportional hazards with elastic net | Penalized hazard model; variable selection |
| Value | Σ expected revenue × survival | Customer-level long-term value with the curves behind it |
| Live model | Person-month logistic hazard · log-revenue regression (JavaScript) | Re-implementation on generated subscribers, checked against realized value |
Step 1: Build a customer-month data structure
Both forecasts read from the same table: one row per customer per month, combining static customer attributes, historical usage, product information, service behaviour and time-varying variables. Its columns fall into three kinds, and the distinction matters for every later step:
- Static — attributes fixed over the forecast horizon, such as plan family, bundle membership or tenure at the forecast origin.
- Past-observed — variables known only up to the forecast origin: revenue, usage, support contacts.
- Known future — variables whose future values are known in advance, such as the month a contract ends or a scheduled promotion.
For survival modelling the same table is read as person-months: each customer contributes one row for every month at risk, with an event flag in the month they leave. Customers still active at the end of the data simply stop contributing rows. They are censored, not counted as customers who never leave — the most common mistake when churn is treated as a plain classification label.
import numpy as np
import pandas as pd
STATIC = ["tenure", "arpu", "bundle", "contacts", "rev_slope"]
def person_months(customers, horizon=24, contract_len=24):
"""One row per customer per month at risk; event = 1 in the month they leave.
Customers still active at the end are censored: their rows simply stop."""
rows = []
for c in customers.itertuples(index=False):
last = c.churn_month if c.churned else min(c.months_observed, horizon)
for t in range(1, last + 1):
age = c.tenure + t
rows.append({
"customer": c.customer, "t": t,
"log_t": np.log(t),
"contract_end": int(age % contract_len == 0),
"early_tenure": int(age <= 6),
"event": int(bool(c.churned) and t == c.churn_month),
})
pm = pd.DataFrame(rows)
return pm.merge(customers[["customer"] + STATIC], on="customer")Simplified person-month expansion. Column names are generic; the real variable set is not shown.
Step 2: Forecast revenue trajectories with a Temporal Fusion Transformer
Carrying today's bill forward is wrong in both directions: it ignores upgrades that make a growing customer more valuable, and it ignores the slow downgrades that precede many departures. Revenue is therefore forecast as a sequence, month by month.
The Temporal Fusion Transformer (TFT) suits this because it takes all three input types natively. Static covariates condition the whole network; past-observed series feed a recurrent encoder; known-future inputs feed the decoder; variable-selection networks weigh the inputs per customer and per time step; and multi-head attention picks out which past months matter for each forecast month. Known future events — a contract ending, a promotion starting or stopping — enter the forecast directly instead of being averaged away.
R̂_i,(t+1 … t+H) = TFT( s_i, z_i,(t−L+1 … t), x_i,(t+1 … t+H) ) s static covariates z past-observed series (L months) x known-future inputs (H months)
The combination in Step 5 relies on one convention: the revenue forecast is revenue conditional on staying, and retention is left entirely to the survival model. In the live model this is explicit — the revenue regression is fitted only on months in which the customer was still active.
import lightning.pytorch as pl
from pytorch_forecasting import TimeSeriesDataSet, TemporalFusionTransformer
from pytorch_forecasting.metrics import QuantileLoss
# panel: one row per active customer-month, up to the forecast origin
train = TimeSeriesDataSet(
panel[panel.month <= origin],
time_idx="month", target="revenue", group_ids=["customer"],
max_encoder_length=12, max_prediction_length=24,
static_categoricals=["plan_family", "bundle"], # who they are
static_reals=["tenure_at_origin"],
time_varying_known_reals=["month", "contract_end", "promo"], # known ahead
time_varying_unknown_reals=["revenue", "usage", "contacts"], # observed so far
)
tft = TemporalFusionTransformer.from_dataset(
train, hidden_size=32, attention_head_size=4, dropout=0.1,
loss=QuantileLoss(quantiles=[0.1, 0.5, 0.9]))
pl.Trainer(max_epochs=20, gradient_clip_val=0.1).fit(
tft, train_dataloaders=train.to_dataloader(train=True, batch_size=512))Simplified. Shown with the open-source pytorch-forecasting API for illustration; column names, sizes and training settings are not the project's.
Step 3: Model retention as a discrete-time survival curve
A churn score is a probability for one window. Value needs a whole curve — how likely the customer is to be present in month three, month twelve, month twenty-four — with the step at a contract end where it belongs. Survival analysis provides exactly that, and handles censored customers correctly.
Multi-Task Logistic Regression (MTLR) divides time into discrete intervals and fits a logistic model for the event in each one, jointly: the per-interval models share a single likelihood, and a penalty keeps neighbouring intervals' coefficients close so the curve is smooth. An observed churner contributes the probability of leaving in their month; a censored customer contributes the probability of surviving past their last observed month.
P(T_i = k | x_i) ∝ exp( Σ_(j ≥ k) (θ_jᵀ x_i + b_j) ) k = 1 … K
S_i(t) = Σ_(k > t) P(T_i = k | x_i)
log L = Σ_(churned) log P(T_i = k_i) + Σ_(censored) log S_i(c_i)
− C₁ Σ_j ‖θ_j‖² − C₂ Σ_j ‖θ_(j+1) − θ_j‖²
The live model uses the simplest member of the same family, a person-month logistic hazard. Each person-month row predicts whether the customer leaves that month from the static features and three time features — log of months ahead, an early-tenure flag and a contract-end flag — and survival is the running product of one minus the hazard.
h_i(t) = σ( βᵀ x_i + γᵀ u_i(t) ) u(t) = [ log t, contract_end(t), early_tenure(t) ] S_i(t) = Π_(k ≤ t) ( 1 − h_i(k) )
import numpy as np
import pandas as pd
import statsmodels.api as sm
TIME = ["log_t", "contract_end", "early_tenure"]
def fit_hazard(pm):
"""Discrete-time hazard: one logistic model over all person-month rows."""
X = sm.add_constant(pm[STATIC + TIME])
return sm.Logit(pm["event"], X).fit(disp=0)
def survival_curve(hazard, c, horizon=24, contract_len=24):
"""S(t) = Π_{k ≤ t} (1 − h(k)) for one customer c (a row of static features)."""
t = np.arange(1, horizon + 1)
age = c["tenure"] + t
grid = pd.DataFrame({**{k: c[k] for k in STATIC},
"log_t": np.log(t),
"contract_end": (age % contract_len == 0).astype(int),
"early_tenure": (age <= 6).astype(int)})
h = hazard.predict(sm.add_constant(grid[STATIC + TIME], has_constant="add"))
return np.cumprod(1 - np.asarray(h))Simplified discrete-time hazard, the live model's stand-in for MTLR. has_constant="add" is needed because the static columns are constant within one customer's grid.
Step 4: Add a Cox model with elastic net for variable selection
The customer-month table has many candidate variables, and many of them are correlated: usage facets, product holdings, behaviour indicators. The second survival model examined was a Cox proportional-hazards model with an elastic-net penalty. Cox leaves the baseline hazard unspecified and models how covariates scale it. The L1 part of the penalty drives weak coefficients towards zero, which selects variables; the L2 part keeps groups of correlated variables stable instead of picking one of them at random.
h(t | x) = h₀(t) · exp(βᵀx) β̂ = argmax_β ℓ_partial(β) − λ [ α‖β‖₁ + (1 − α)/2 · ‖β‖²₂ ]
from lifelines import CoxPHFitter
def cox_elastic_net(df, penalizer=0.05, l1_ratio=0.7, eps=1e-3):
"""df: one row per customer: months observed, churned flag and
standardized candidate covariates."""
cph = CoxPHFitter(penalizer=penalizer, l1_ratio=l1_ratio)
cph.fit(df, duration_col="months", event_col="churned")
selected = cph.params_[cph.params_.abs() > eps].index.tolist()
return cph, selected
# survival curves on a monthly grid: rows = months, columns = customers
# S = cph.predict_survival_function(X_new, times=range(1, 25))Simplified, shown with lifelines for illustration. lifelines smooths the L1 term, so dropped coefficients are near zero rather than exactly zero; covariates are standardized first so the penalty treats them alike.
Why look at two survival models? They are good at different things. MTLR makes no proportional-hazards assumption and produces flexible per-customer curves; Cox with elastic net yields a sparse, readable set of variables that carry the churn signal. Predicting retention and explaining it are separate jobs.
Step 5: Multiply and sum into lifetime value
With a revenue forecast conditional on staying and a survival curve, lifetime value over a horizon of H months is the sum of expected revenue in each month weighted by the probability that the customer is still there. A contract end shows up twice: as a step down in the survival curve and as a smaller contribution from every later month.
CLV_i = Σ_(t = 1 … H) E[ R_i,t | T_i > t ] · S_i(t) a discount factor δ^t can be inserted; the live model uses none, with H = 24 months
import numpy as np
from sklearn.linear_model import LinearRegression
def rev_design(t, slope, bundle, arpu, horizon=24):
u = np.asarray(t, dtype=float) / horizon
one = np.ones_like(u)
return np.column_stack([u, u ** 2, slope * one, bundle * one, arpu / 100 * one])
def fit_revenue(active):
"""Fitted on active customer-months only: revenue conditional on staying."""
X = rev_design(active.t, active.rev_slope, active.bundle, active.arpu)
return LinearRegression().fit(X, np.log(active.revenue / active.arpu))
def clv(c, rev_model, hazard, horizon=24):
t = np.arange(1, horizon + 1)
X = rev_design(t, c["rev_slope"], c["bundle"], c["arpu"], horizon)
revenue = c["arpu"] * np.exp(rev_model.predict(X)) # E[R_t | still a customer]
S = survival_curve(hazard, c, horizon) # P(still a customer after t)
return float(np.sum(revenue * S)), revenue, SSimplified, as in the live model: revenue is modelled as log(revenue / ARPU) on active months only, then multiplied by the hazard model's survival curve from Step 3.
Each customer gets a number and the two curves behind it, so a value can be explained: high because the customer is likely to stay, or high because revenue is growing. The two explanations call for different actions.
Step 6: Check the ranking against realized value
CLV is used mainly as a ranking — who should receive retention or marketing resources — so it is evaluated as one. In the live model, two thousand generated customers with 24 months of outcomes train both models, and a thousand others are scored at the origin and compared with what they actually paid over the next 24 months. Four measures are computed for the flat projection (ARPU × 24) and for CLV:
- Spearman rank correlation between predicted and realized value.
- Top-fifth capture — the share of all realized value held by the top 20% of customers under each ranking.
- Total error — predicted total against realized total, which the flat projection overstates because it ignores churn.
- Realized value by decile — the bars in the live model.
import numpy as np
from scipy.stats import spearmanr
def ranking_report(pred, realized, top=0.2, n_dec=10):
"""How well a value estimate orders held-out customers by what they paid."""
pred, realized = np.asarray(pred, float), np.asarray(realized, float)
order = np.argsort(-pred)
k = int(len(pred) * top)
rho, _ = spearmanr(pred, realized)
return {
"spearman": rho,
"top_share": realized[order[:k]].sum() / realized.sum(),
"total_error": pred.sum() / realized.sum() - 1,
"deciles": [realized[idx].mean() for idx in np.array_split(order, n_dec)],
}
flat = test.arpu * 24 # today's bill, carried forward
report = {name: ranking_report(v, test.realized_24m)
for name, v in [("flat", flat), ("clv", test.clv)]}Simplified. test holds the held-out customers with their predicted CLV and realized 24-month revenue.
Why judge the ranking, not just the error? Allocation decisions use the order of customers rather than the exact value. The two rankings — by ARPU and by CLV — overlap less than people expect, and the difference is exactly the set of customers a retention programme should be built around.
Try the live model
The live model below trains a monthly hazard model and a revenue model on generated subscribers, scores a held-out group, and compares today's bill carried forward with survival × revenue for three customer archetypes.
Everything is computed in your browser. Two thousand generated customers with 24 months of outcomes train a monthly hazard model and a revenue-trajectory model; a thousand others are scored today and checked against what they went on to pay. Left: today's bill carried forward, and who it would send the retention budget to. Right: survival × revenue for the same customer, what actually happened to them, and the same budget allocated by CLV. The production models — a Temporal Fusion Transformer and MTLR/Cox — are stood in for by a logistic hazard and a regression. Open the live model on its own page ↗
Results
The project shifted customer management from current-state metrics such as ARPU and churn score to a future-value perspective, and provided a basis for deciding which customers should receive retention or marketing resources based on long-term value and retention likelihood.
With value as a forward estimate, the retention question becomes "where is long-term value at risk?" rather than "who pays the most today?" — and because each estimate comes with a survival curve and a revenue curve, the answer can be explained customer by customer.
Lessons learned
- Split how long from how much. Revenue and retention have different drivers and different failure modes. Modelling them separately and multiplying kept each model simple to check.
- Agree on what the revenue forecast means. Revenue conditional on staying, multiplied by survival, avoids counting churn twice.
- Use curves, not scores. A survival function puts the contract-end step where it belongs and treats customers still active at the end of the data as censored rather than loyal.
- Evaluate the decision, not only the fit. Top-fifth capture and decile plots on held-out customers show directly whether a ranking would send the budget to the right people.
Conclusion
Customer lifetime value needs two forecasts that are usually built separately for different purposes. Putting both on one customer-month structure, forecasting revenue as a trajectory with a Temporal Fusion Transformer, modelling retention as a full survival curve with MTLR and a Cox model with elastic net, and multiplying the two month by month produced a per-customer value that could be explained and used for allocation.
The same decomposition — a survival curve times a conditional value per period — applies wherever value accrues over a relationship that may end: subscriptions, memberships, loans, warranties.
Limitations
- CLV is an expectation under the current environment; a competitor's move or a pricing change shifts every curve at once.
- Long horizons compound model error; the estimate is most reliable in the first year and most useful as a ranking.
- The live model's generator plants the drivers of churn, so its hazard model is better specified than any real one; its survival model is a person-month logistic hazard rather than MTLR or Cox, and its revenue model is a regression rather than the production transformer.
About the demo and confidentiality
Customers, revenue histories, contracts, churn and value in the embedded model are generated from a planted process. No subscriber, billing or churn data from any operator appears in this post, and the real variable set, horizon and model configurations are not described. Code is simplified and written for illustration; the libraries it uses are not a description of the production stack.