Source code for mmm_framework.ltv.preprocess
"""Transaction-log → RFM summaries for the buy-till-you-die LTV models.
The BG/NBD + Gamma-Gamma likelihoods condition on exactly four per-customer
sufficient statistics computed over a calibration window:
* ``frequency`` — number of REPEAT purchases (transactions − 1; the first
purchase is the acquisition event and carries no information about the
purchase rate under the model),
* ``recency`` — time of the LAST purchase measured from the FIRST purchase
(0 for one-time buyers),
* ``T`` — the customer's age: observation end − first purchase,
* ``monetary`` — mean value of the REPEAT transactions (the Gamma-Gamma input;
NaN for one-time buyers, who carry no monetary signal).
pandas only — no model imports.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
_FREQ_TO_DAYS = {"D": 1.0, "W": 7.0, "M": 30.4375}
[docs]
def transactions_to_rfm(
df: pd.DataFrame,
*,
customer_col: str = "customer_id",
date_col: str = "date",
value_col: str | None = None,
observation_end: pd.Timestamp | str | None = None,
freq: str = "W",
segment_col: str | None = None,
) -> pd.DataFrame:
"""Collapse a transaction log to one row per customer (RFM summaries).
Multiple transactions by the same customer on the same date are merged into
one purchase (values summed) — sub-period repeats are indistinguishable at
the model's time resolution and would otherwise inflate ``frequency``.
Args:
df: long transaction log (one row per transaction).
value_col: transaction value column; omit for frequency-only (no
``monetary`` output column).
observation_end: end of the calibration window (transactions after it
are dropped). Defaults to the last transaction date.
freq: time unit for ``recency``/``T`` — 'D', 'W' (default) or 'M'.
segment_col: per-customer attribute (e.g. acquisition channel) carried
through as a ``segment`` column — the FIRST transaction's value per
customer (the acquisition event's attribution).
Returns:
DataFrame indexed by customer with columns ``frequency``, ``recency``,
``T``, ``n_txn`` (raw purchase count) and, when ``value_col`` is given,
``monetary`` (mean REPEAT-transaction value; NaN for one-time buyers),
plus ``segment`` when ``segment_col`` is given.
"""
if freq not in _FREQ_TO_DAYS:
raise ValueError(f"freq must be one of {sorted(_FREQ_TO_DAYS)}, got {freq!r}")
unit_days = _FREQ_TO_DAYS[freq]
keep = [customer_col, date_col] + ([value_col] if value_col else [])
if segment_col:
keep.append(segment_col)
work = df[keep].copy()
work[date_col] = pd.to_datetime(work[date_col])
if observation_end is None:
observation_end = work[date_col].max()
observation_end = pd.to_datetime(observation_end)
work = work[work[date_col] <= observation_end]
if work.empty:
raise ValueError("no transactions on or before observation_end")
# merge same-day repeats: one purchase event per (customer, date)
grouped = (
work.groupby([customer_col, date_col], as_index=False)[value_col].sum()
if value_col
else work.drop_duplicates([customer_col, date_col])
)
g = grouped.groupby(customer_col)[date_col]
first = g.min()
last = g.max()
n_txn = g.count()
# fractional days (total_seconds), NOT .dt.days — day-truncation would
# zero the recency of close-together repeats and break the x>0 ⇒ t_x>0
# invariant the likelihood relies on.
day = 86400.0 * unit_days
out = pd.DataFrame(
{
"frequency": (n_txn - 1).astype(int),
"recency": (last - first).dt.total_seconds() / day,
"T": (observation_end - first).dt.total_seconds() / day,
"n_txn": n_txn.astype(int),
}
)
if value_col:
# monetary = mean value of REPEAT purchases only (drop the first event)
grouped = grouped.sort_values([customer_col, date_col])
grouped["_rank"] = grouped.groupby(customer_col).cumcount()
repeats = grouped[grouped["_rank"] > 0]
out["monetary"] = repeats.groupby(customer_col)[value_col].mean()
# one-time buyers: NaN (no repeat-value signal), preserved explicitly
out["monetary"] = out["monetary"].astype(float)
if segment_col:
# acquisition attribution: the FIRST transaction's segment value
first_rows = work.sort_values(date_col).groupby(customer_col).first()
out["segment"] = first_rows[segment_col].astype(str)
out.index.name = customer_col
# invariants the likelihood relies on
assert (out["recency"] <= out["T"] + 1e-9).all()
assert ((out["frequency"] == 0) == (out["recency"] == 0)).all()
return out
[docs]
def rfm_arrays(
rfm: pd.DataFrame,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None]:
"""``(frequency, recency, T, monetary-or-None)`` float arrays for the model."""
x = rfm["frequency"].to_numpy(dtype=float)
t_x = rfm["recency"].to_numpy(dtype=float)
T = rfm["T"].to_numpy(dtype=float)
m = rfm["monetary"].to_numpy(dtype=float) if "monetary" in rfm.columns else None
return x, t_x, T, m
__all__ = ["transactions_to_rfm", "rfm_arrays"]