Customer value · Survival analysis · ForecastingLG Uplus, subscription businessesTechnical write-up · October 2026 · 15 min read

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.

Built withPythonCustomer-month panelTemporal Fusion TransformerSurvival analysisMulti-Task Logistic RegressionCox PH · elastic netDiscrete-time hazard

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.

Architecture
1Data

Customer-month structure

StaticPast observedKnown future
2Revenue

Monthly revenue trajectory

Temporal Fusion Transformer
3Survival

Retention curve per customer

MTLRDiscrete time
4Selection

Penalized hazard model

Cox PHElastic net
5Value

Multiply and sum

Σ E[R]·S(t)
6Use

Rank and allocate

SegmentationRetention budget
One customer-month table feeds a revenue model and survival models; expected revenue in each future month is weighted by the probability of still being a customer and summed into lifetime value, which then ranks customers for retention and marketing resources.

The numbered steps in the diagram:

  1. 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.
  2. A Temporal Fusion Transformer forecasts monthly revenue over the horizon from static, past and known-future inputs.
  3. 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.
  4. A Cox proportional-hazards model with elastic net is the second survival model; its penalty shrinks and selects among many correlated candidate variables.
  5. Expected revenue in each month times the survival probability for that month, summed over the horizon, gives customer-level long-term value.
  6. Customers are ranked by long-term value and retention likelihood to decide who receives retention or marketing resources.

Technology stack

LayerTechnologyWhat it does here
DataCustomer × month panel (Python)Static attributes, usage history, products, behaviour and time-varying variables in one structure
Revenue modelTemporal Fusion TransformerVariable selection, sequence encoding and attention over static, past and known-future inputs
Survival modelMulti-Task Logistic RegressionDiscrete-time survival curves estimated jointly across months
Survival · selectionCox proportional hazards with elastic netPenalized hazard model; variable selection
ValueΣ expected revenue × survivalCustomer-level long-term value with the curves behind it
Live modelPerson-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:

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.

clv/panel.py
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.

clv/revenue_tft.py
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) )
clv/hazard.py
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 · ‖β‖²₂ ]
clv/cox_en.py
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
clv/value.py
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, S

Simplified, 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:

clv/evaluate.py
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

Future valuereplacing ARPU and churn score as the lens on customers
Two curvessurvival and revenue, per customer
Allocationof retention and marketing resources by long-term value and retention likelihood

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

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

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.

Taehee Lee · Data Scientist / Technical Lead, LG Uplus (2020 – 2023)Data structure, survival and revenue modelling, combination into CLV. Demo re-implemented on generated data for this site.