Customer Lifetime Value
Experimental
CLV preparation is experimental. Its API and import path (under openretailscience.experimental)
may change without notice.
CLVStats (BTYD model input)
CLVStats turns transaction data into the per-customer summary that the "buy-till-you-die" (BTYD)
models in pymc-marketing consume: ParetoNBDModel (churn and
purchase frequency) and GammaGammaModel (per-transaction spend). It does not fit the models and does
not depend on pymc-marketing.
Each customer is "born" at their first purchase in the data and observed until the
observation_period_end. A purchase occasion is a distinct calendar day with positive net spend, so
multiple baskets on the same day count once and a returns-only day (net spend zero or less) is not an
occasion. Undated rows are also dropped, and a customer left with no occasion drops out entirely.
| Column | Meaning |
|---|---|
customer_id |
Your configured customer id column. |
frequency |
Number of repeat purchase occasions (distinct positive-spend days minus one). |
recency |
Time from the first purchase to the last purchase, in period units. |
T |
Customer age: time from the first purchase to observation_period_end, in period units. |
monetary_value |
Mean spend across the repeat purchases; NaN for one-time buyers. |
recency and T are fractional (for example, 2.5 weeks). The elapsed time is measured in whole days
and divided by the days-per-period, because some backends (notably Oracle) support only
day-granularity date deltas:
period may be "day", "week", or "month" (case-insensitive, with short forms like "d"/"m"
accepted). A month is a fixed 365.25/12-day unit, not a calendar month, so continuous age stays well
defined. Day, week, and month map to pymc-marketing's "D"/"W"/"M" time units.
monetary_value excludes the first purchase, matching the Gamma-Gamma assumption. One-time buyers
(frequency of 0) have no repeat spend, cannot be fit by the standard Gamma-Gamma model, and are
excluded from it (their expected spend falls back to the population mean). They remain valid rows for
the Pareto/NBD model.
The clv.repeat_buyers property returns the Gamma-Gamma-ready subset: the frequency > 0 rows.
monetary_value is always positive here (an occasion is a positive-spend day), so no spend filter is
needed. It warns if frequency and monetary_value correlate (|Pearson r| > 0.15), which breaks
Gamma-Gamma's independence assumption and biases its spend estimates.
frequency, recency, T, and monetary_value are pymc-marketing's required literal column names,
so the frame can be passed straight to the models.
Example:
import pandas as pd
from openretailscience.experimental.clv import CLVStats
transactions = pd.DataFrame({
"customer_id": [101, 101, 101, 102, 103, 103],
"transaction_date": pd.to_datetime(
["2023-01-01", "2023-01-08", "2023-01-15", "2023-01-01", "2023-01-10", "2023-01-24"]
),
"unit_spend": [100.0, 50.0, 70.0, 200.0, 50.0, 80.0],
})
# .df rows are one-per-customer in engine order; sort for a stable view.
clv = CLVStats(transactions, period="week", observation_period_end="2023-01-29")
print(clv.df.sort_values("customer_id").reset_index(drop=True))
# customer_id frequency recency T monetary_value
# 0 101 2 2.0 4.000000 60.0
# 1 102 0 0.0 4.000000 NaN
# 2 103 1 2.0 2.714286 80.0
The summary feeds pymc-marketing directly (install it separately):
from pymc_marketing.clv import ParetoNBDModel, GammaGammaModel
clv = CLVStats(transactions, period="week", observation_period_end="2023-01-29")
pareto = ParetoNBDModel(data=clv.df) # frequency, recency, T
pareto.fit()
gamma_gamma = GammaGammaModel(data=clv.repeat_buyers) # frequency > 0
gamma_gamma.fit()
Finite-horizon CLV: pass time_unit, or it fails silently
GammaGammaModel.expected_customer_lifetime_value(future_t=12, time_unit=...) reads future_t in
months. The default time_unit="D" is wrong for a weekly or monthly summary and scales the
horizon by ~7x (weekly) to ~30x (monthly) with no error raised. Pass time_unit=clv.pymc_time_unit:
clv_12mo = gamma_gamma.expected_customer_lifetime_value(
transaction_model=pareto, data=clv.df, future_t=12, time_unit=clv.pymc_time_unit,
)
Want an undiscounted CLV over a fixed horizon set directly in the model's units (e.g. exactly the
next 52 weeks)?
pareto.expected_purchases(data=clv.df, future_t=52) * gamma_gamma.expected_customer_spend(data=clv.repeat_buyers)
computes it with no month conversion and no time_unit.
Extra columns and covariates
customer_attributes attaches a caller-supplied per-customer table (one row per customer_id) to the
summary via a left join: customer descriptors (signup channel, region) or pre-computed aggregates
(stores shopped). Build it however you like, e.g. SegTransactionStats grouped on customer_id, or a
plain group_by. one_hot_col names column(s) of that table to one-hot encode into 0/1 dummy
columns suitable for ParetoNBDModel covariates, and the original column is removed.
transactions = pd.DataFrame({...}) # customer_id, transaction_date, unit_spend
# One row per customer: a pre-computed count joined as-is, plus a categorical to one-hot encode.
customer_attributes = pd.DataFrame({
"customer_id": [101, 102, 103, 104],
"stores_shopped": [3, 1, 2, 1],
"signup_channel": ["email", "paid_search", "organic", None], # None -> the dropped reference level
})
clv = CLVStats(
transactions, period="week", customer_attributes=customer_attributes, one_hot_col="signup_channel",
)
# adds: stores_shopped, signup_channel_email, signup_channel_organic, signup_channel_paid_search
# covariate_cols lists the attached columns (stores_shopped + the one-hot dummies), no prefix matching.
covariate_cols = clv.covariate_cols
pareto = ParetoNBDModel(
data=clv.df,
model_config={"purchase_covariate_cols": covariate_cols, "dropout_covariate_cols": covariate_cols},
)
customer_attributes must have one row per customer_id. When it is an Ibis table ensure it is on
the same backend as the transactions data. A pandas frame also works, but do not mix live
backends (e.g. BigQuery transactions with a separate DuckDB attributes file).
Every customer needs a row in customer_attributes: a missing one leaves NULL covariates (one-hot
dummies included) that silently break ParetoNBDModel's fit. A NULL attribute value is a NULL
covariate too, except in a one-hot source column, where NULL is just the reference level (all dummies 0).
CLVStats rejects NULL covariates only on .df; feed .table to the model and you must check yourself.
For each one-hot column, one level is dropped as the reference level, because a full set of
dummies would be collinear with the model intercept: NULL when the column contains NULLs, otherwise
the first value in sorted order. Values are used verbatim in the column names, so keep categorical
values short and free of characters you would not want in a column name. A column producing more than 32
dummies emits a UserWarning (one-hot is for low-cardinality categoricals, not IDs). A single-category
column produces zero dummies and also warns, since it contributes no covariate. GammaGammaModel has no covariate
support in pymc-marketing, so spend-by-channel needs a separate Gamma-Gamma fit per channel.
Truncated history
The first purchase is always the earliest transaction in the supplied data, exactly as
pymc_marketing.clv.rfm_summary treats it. A customer who was already active before the data
window is treated as born at the window's start, which understates their true age. This is a
limitation of a truncated observation period, not something CLVStats corrects. Substituting an
earlier first-purchase date while the intervening transactions are missing would break the model's
frequency/recency/T consistency and silently deflate the estimated purchase rate.