Hello Model Services
← Guides

Number prediction (regression) · 5 min read

How to predict customer lifetime value

Know within three months which new customers will be worth the most, so you can look after them, and spend your marketing budget where the valuable customers come from.

What you’ll build

  • A prediction of each customer’s spend over the next 12 months, from their first 90 days
  • A ranking that finds the customers who will bring in most of next year’s revenue
  • A comparison of acquisition channels by the value of the customers they bring

Tools

Python, pandas, scikit-learn

Step 1: Decide what “lifetime value” means for you

“Lifetime” is too vague to predict. Pick a window you can check: here, how much a customer spends in the 12 months after their first 90 days. Twelve months is long enough to matter for budgets and short enough that you’ll see whether the predictions were right.

Also decide what the number is for, because it changes what “good” means:

  • Finding your best customers early, for a welcome call or a loyalty offer: the ranking matters most.
  • Setting how much you can spend to win a customer: the average by channel or campaign matters most.
  • Forecasting revenue: the total matters most.

Step 2: Get customers and orders

You need two tables from your shop or billing system: customers (when they signed up and how they found you) and orders (who, when and how much). No data to hand? This script makes realistic sample data: 6,000 customers who signed up during 2024, from four channels, with the usual pattern of many one-time buyers and a few big spenders.

CodeOptional: make sample customers.csv and orders.csv
pythonRun in Colab
# pip install pandas numpy scikit-learn
import numpy as np
import pandas as pd

rng = np.random.default_rng(11)
n = 6_000
channels = rng.choice(["search", "social", "referral", "email"], n, p=[0.40, 0.30, 0.15, 0.15])
signup = pd.Timestamp("2024-01-01") + pd.to_timedelta(rng.integers(0, 365, n), unit="D")
# Hidden habits: how often each customer buys, how much they spend, and how long they stay.
loyal = pd.Series(channels).map({"search": 1.0, "social": 0.6, "referral": 1.5, "email": 1.2}).to_numpy()
per_month = rng.gamma(0.7, 1.0, n) * loyal
basket = rng.lognormal(3.5, 0.45, n)
stays_months = rng.exponential(9, n) * loyal

rows = []
for i in range(n):
    span = min(stays_months[i], 15)                    # we only watch 15 months after signup
    times = np.concatenate([[0.0], rng.uniform(0, span, rng.poisson(per_month[i] * span))])
    for t in times:
        rows.append((i, signup[i] + pd.Timedelta(days=float(t) * 30.4), round(basket[i] * rng.lognormal(0, 0.3), 2)))
customers = pd.DataFrame({"customer_id": np.arange(n), "signup_date": signup, "channel": channels})
orders = pd.DataFrame(rows, columns=["customer_id", "order_date", "amount"])
customers.to_csv("customers.csv", index=False)
orders.to_csv("orders.csv", index=False)
print(f"{n:,} customers, {len(orders):,} orders")
CodeLoad the orders
pythonRun in Colab
import numpy as np
import pandas as pd

customers = pd.read_csv("customers.csv", parse_dates=["signup_date"])
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
orders = orders.merge(customers[["customer_id", "signup_date"]], on="customer_id")
orders["day"] = (orders.order_date - orders.signup_date).dt.days
print(orders.groupby("customer_id").amount.sum().describe().round(0))

Notice how uneven spending is: the mean is far above the median. A small group of customers brings in most of the revenue, which is exactly why it pays to find them early.

Step 3: Use the first 90 days to predict the next 12 months

For every customer, build features from their first 90 days only, and measure the answer, their spend, over the 12 months after that. Counting from each customer’s own signup date means every customer gets the same 90 days to show who they are.

CodeFeatures from the first 90 days, value from the next 12 months
pythonRun in Colab
first = orders[orders.day < 90].groupby("customer_id").agg(
    orders_90d=("amount", "size"), spend_90d=("amount", "sum"),
    avg_order=("amount", "mean"), last_order_day=("day", "max"))
later = orders[(orders.day >= 90) & (orders.day < 90 + 365)].groupby("customer_id").amount.sum()

data = customers.set_index("customer_id").join(first).join(later.rename("value_next_12m"))
data["value_next_12m"] = data.value_next_12m.fillna(0)
data["days_since_last"] = 90 - data.last_order_day
data["signup_month"] = data.signup_date.dt.month
print(data[["spend_90d", "value_next_12m"]].describe().round(0))
print(f"{(data.value_next_12m == 0).mean():.0%} of customers spend nothing after their first 90 days")
Never let the features peek past day 90. Using total orders, or anything that includes later purchases, makes the model look excellent in testing and useless in practice. That’s data leakage.

About half of the customers spend nothing after their first 90 days. A good model has to predict those zeros as well as the big spenders.

Step 4: Test on the latest customers

Train on customers who signed up first, and test on the most recent ones. That’s how the model will be used: trained on the past, then applied to people who just arrived.

CodeSplit by signup date
pythonRun in Colab
features = ["orders_90d", "spend_90d", "avg_order", "days_since_last", "signup_month", "channel"]
data["channel"] = data.channel.astype("category")
cutoff = pd.Timestamp("2024-10-01")
train, test = data[data.signup_date < cutoff], data[data.signup_date >= cutoff]
print(len(train), "customers to learn from,", len(test), "to test on")

Step 5: Measure two baselines

Measure two things for every approach. The MAE is how many dollars a prediction is off, on average. The top 10% share is how much of next year’s revenue comes from the 10% of customers the approach ranks highest: if you can only call one customer in ten, that’s how much revenue you reach.

CodeTwo baselines
pythonRun in Colab
from sklearn.metrics import mean_absolute_error

def top_share(actual, predicted, top=0.1):
    """Share of next year's revenue that comes from the customers ranked in the top 10%."""
    best = predicted.argsort()[::-1][: int(len(predicted) * top)]
    return actual.to_numpy()[best].sum() / actual.sum()

y = test.value_next_12m
average = np.full(len(test), train.value_next_12m.mean())
ratio = train.value_next_12m.sum() / train.spend_90d.sum()   # later spend per dollar of early spend
naive = test.spend_90d.to_numpy() * ratio
for name, pred in [("Everyone gets the average", average), ("First 90 days, scaled up", naive)]:
    print(f"{name:<26} MAE ${mean_absolute_error(y, pred):>6,.0f}   top 10% hold {top_share(y, pred):.0%} of revenue")

Giving everyone the average is useless for ranking: its top 10% is just a random 10%. Scaling up early spending is a strong baseline: on the sample data the top 10% it picks already brings in about half of next year’s revenue. Many teams stop here, and that’s a reasonable first version.

Step 6: Train a model built for spend

Spend is never negative, many customers have zero, and a few spend a lot. A Poisson loss fits that shape: it predicts an average spend that can’t go below zero, and doesn’t let a few big spenders dominate the way squared error does. Scikit-learn’s gradient boosting supports it directly, and reads the channel as a category.

CodeGradient boosting with a Poisson loss
pythonRun in Colab
from sklearn.ensemble import HistGradientBoostingRegressor

model = HistGradientBoostingRegressor(loss="poisson", max_iter=400, learning_rate=0.05,
                                      categorical_features=["channel"], random_state=0)
model.fit(train[features], train.value_next_12m)
pred = model.predict(test[features])
print(f"{'Gradient boosting':<26} MAE ${mean_absolute_error(y, pred):>6,.0f}   top 10% hold {top_share(y, pred):.0%} of revenue")
print(f"Predicted total ${pred.sum():,.0f} vs actual ${y.sum():,.0f}")

The model beats the scaled-up baseline on both measures, and its predicted total is close to the actual one, so it’s also usable for a revenue forecast. The gain over the baseline is modest on this data; on real data, where how recently someone bought and where they came from matter more, it’s often larger. Either way, you only know by measuring.

Step 7: Check the predictions by group

One number hides a lot. Sort the test customers into ten equal groups by their prediction and compare the average predicted spend with what each group really spent.

CodeCheck the predictions by group
pythonRun in Colab
test = test.assign(predicted=pred, decile=pd.qcut(pd.Series(pred, index=test.index).rank(method="first"), 10, labels=range(1, 11)))
print(test.groupby("decile", observed=True)[["predicted", "value_next_12m"]].mean().round(0))

The groups should climb steadily, with predictions close to the actual values, especially in the top groups, where the money is. If the top group is far off, don’t trust the model for budgets yet.

Step 8: Compare acquisition channels

Average the predicted value by the channel customers came from. This is often the most valuable output: it tells you how much each channel’s customers are worth, three months after they arrive instead of a year later.

CodeValue by acquisition channel
pythonRun in Colab
by_channel = test.groupby("channel", observed=True).agg(customers=("predicted", "size"), predicted_value=("predicted", "mean"), actual_value=("value_next_12m", "mean")).round(0)
print(by_channel.sort_values("predicted_value", ascending=False))
Predicted spend over the next 12 months, by channelReferral: $245; Email: $179; Search: $122; Social ads: $40.Referral$245Email$179Search$122Social ads$40
Average predicted spend per test customer, by the channel they came from (higher is better). The actual averages were close: $207, $216, $134 and $39.

On the sample data, referral customers are worth around five times as much as customers from social ads. Compare those values with what each channel costs per new customer: a channel can be cheap and still not be worth it.

Step 9: Put it to work, and keep it honest

Score each customer once they reach day 90, and refresh the scores monthly. Use them for decisions with a clear test: give your top-ranked new customers a welcome offer, and measure the effect with an A/B test against a group that doesn’t get it. Every month, check last year’s predictions against what customers actually spent, and retrain when they drift apart; prices, products and channels change, and so do customers. That’s data drift.

Common pitfalls

  • Predicting “lifetime” value with no fixed window, so the predictions can never be checked.
  • Building features from orders after day 90, which leaks the answer.
  • Judging only the average error and never checking the ranking of the top customers.
  • Comparing channels by the number of customers they bring instead of the value of those customers.
  • Acting on the scores without a control group, so you can’t tell whether the offer or the customer made the difference.

Check what you learned

4 quick questions. Choose an answer to see why each option is right or wrong.

  1. What does this guide predict, exactly?

  2. Which feature would leak the answer into the model?

  3. Why measure the top 10% share and not just the average error?

  4. Why use a Poisson loss for spend?

Want this tailored to your data, team and budget? Create a plan for your project

More on Number prediction (regression) · Next guide: How to build a support chatbot from your help center

Create a plan