Source code for mmm_framework.ltv.kpi

"""LTV → measurement-loop bridges (Phase 5).

Three ways lifetime value enters the loop:

* :func:`new_customer_clv_series` — a **cohort CLV KPI**: weekly acquired-
  customer lifetime value (new customers per period × their posterior CLV).
  Feed it to an MMM / experiment as a monetary KPI (``margin_per_kpi = 1``) so
  media is valued on the LIFETIME value of the customers it acquires, not the
  first purchase.
* :func:`clv_to_cac` — per-segment ``CLV / CAC`` and ``CLV − CAC`` given
  per-segment acquisition costs: the blended acquisition-economics view (a
  channel acquiring low-frequency one-time buyers is correctly discounted vs
  one acquiring high-CLV customers even at equal CPA).
* the ghost-ads / net-economics hookup — a fitted :class:`BayesianCLV`'s
  ``mean_clv`` (or a segment's CLV) IS the ``value_per_conversion`` /
  ``margin_per_kpi`` for an acquisition experiment; the ``clv_value`` model op
  serves it to the calculators.

pandas/numpy only.
"""

from __future__ import annotations

import numpy as np
import pandas as pd


[docs] def new_customer_clv_series( transactions: pd.DataFrame, clv_per_customer: pd.Series, *, customer_col: str = "customer_id", date_col: str = "date", freq: str = "W-MON", ) -> pd.DataFrame: """Weekly (or ``freq``-period) cohort CLV: for each period, the number of NEWLY ACQUIRED customers and the sum of their (posterior-mean) CLV. ``clv_per_customer`` is indexed by customer id (e.g. the posterior mean of the fitted model's per-customer ``clv`` deterministic). Customers in the log but missing from the CLV index contribute count but zero value (and are counted in ``n_unvalued`` for honesty). Returns a DataFrame indexed by period start with columns ``new_customers``, ``cohort_clv``, ``mean_clv``, ``n_unvalued``. """ work = transactions[[customer_col, date_col]].copy() work[date_col] = pd.to_datetime(work[date_col]) acquired = work.groupby(customer_col)[date_col].min() clv = pd.Series(clv_per_customer).astype(float) aligned = clv.reindex(acquired.index) period = acquired.dt.to_period(freq.split("-")[0]).dt.start_time frame = pd.DataFrame( { "period": period, "clv": aligned.fillna(0.0), "valued": aligned.notna(), } ) out = frame.groupby("period").agg( new_customers=("clv", "size"), cohort_clv=("clv", "sum"), n_unvalued=("valued", lambda s: int((~s).sum())), ) out["mean_clv"] = np.where( out["new_customers"] > 0, out["cohort_clv"] / out["new_customers"], 0.0 ) out.index.name = "period" return out
[docs] def clv_to_cac( segment_clv: dict[str, float], cac: dict[str, float], ) -> pd.DataFrame: """Blended acquisition economics per segment/channel. Args: segment_clv: posterior mean CLV per acquired customer, by segment (e.g. from the fitted model's ``segment_clv_<name>`` deterministics). cac: customer-acquisition cost per segment (spend / new customers — supplied by the caller; the CLV model has no spend data). Returns: DataFrame with ``clv``, ``cac``, ``clv_minus_cac`` and ``clv_to_cac`` per segment, sorted by ``clv_minus_cac`` descending. Segments missing a CAC get NaN economics (still listed, flagged). """ rows = [] for seg, clv in segment_clv.items(): c = cac.get(seg) rows.append( { "segment": seg, "clv": float(clv), "cac": float(c) if c is not None else float("nan"), "clv_minus_cac": float(clv - c) if c is not None else float("nan"), "clv_to_cac": ( float(clv / c) if c is not None and c > 0 else float("nan") ), } ) out = pd.DataFrame(rows).set_index("segment") return out.sort_values("clv_minus_cac", ascending=False)
__all__ = ["new_customer_clv_series", "clv_to_cac"]