MBG/NBD Model#

In this notebook we show how to fit a MBG/NBD model in PyMC-Marketing. The model is presented in the paper: Batislam E. P., Denizel M., Filiztekin A. (2007) Empirical validation and comparison of models for customer base analysis

Prepare Notebook#

import arviz as az
import arviz_plots as azp
import matplotlib.pyplot as plt
import pandas as pd
import xarray as xr
from fastprogress.fastprogress import progress_bar

from pymc_marketing import clv

# Plotting configuration
az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [12, 7]
plt.rcParams["figure.dpi"] = 100
plt.rcParams["figure.facecolor"] = "white"

%load_ext autoreload
%autoreload 2
%config InlineBackend.figure_format = "retina"

Read Data#

We use the CDNOW dataset.

data_path = "https://raw.githubusercontent.com/pymc-labs/pymc-marketing/main/data/clv_quickstart.csv"

df = pd.read_csv(data_path)

df.head()
frequency recency T monetary_value
0 2 30.43 38.86 22.35
1 1 1.71 38.86 11.77
2 0 0.00 38.86 0.00
3 0 0.00 38.86 0.00
4 0 0.00 38.86 0.00

The following definitions are standard in CLV modeling:

  • frequency represents the number of repeat purchases the customer has made. This means that it’s one less than the total number of purchases. This is actually slightly wrong. It’s the count of time periods the customer had a purchase in. So if using days as units, then it’s the count of days the customer had a purchase on.

  • T represents the age of the customer in whatever time units chosen (weekly, in the above dataset). This is equal to the duration between a customer’s first purchase and the end of the period under study.

  • recency represents the age of the customer when they made their most recent purchases. This is equal to the duration between a customer’s first purchase and their latest purchase. (Thus if they have made only 1 purchase, the recency is 0.)

Tip

We rename the index column to customer_id as this is required by the model

data = (
    df.reset_index()
    .rename(columns={"index": "customer_id"})
    .drop(columns="monetary_value")
)

Model Specification#

The MBG/NBD model is a probabilistic model that describes the buying behavior of a customer in the non-contractual setting. It is based on the following assumptions for each customer:

Dropout after first purchase#

Contrasting with the BG/NBD model, in the MBG/NBD a customer may drop out at time zero with probability p. This leads to the following individual level likelihood function:

\[ L(\lambda, p | X=x, T) = (1 - p)^{x+1} \lambda^x \exp(\lambda T) + p(1-p)^x \lambda^x \exp(-\lambda t_x)\]

Compare the previous expresion with the regular BG/NBD likelihood:

\[ L(\lambda, p | X=x, T) = (1 - p)^{x} \lambda^x \exp(\lambda T) + \delta_{x>0} p(1-p)^{x-1} \lambda^x \exp(-\lambda t_x)\]

Model Fitting#

Estimating such parameters is very easy in PyMC-Marketing. We instantiate the model in a similar way:

model = clv.ModifiedBetaGeoModel()

And build the model to see the model configuration:

model.build_model(data=data)
model
MBG/NBD
            alpha ~ Weibull(2, 10)
      phi_dropout ~ Uniform(0, 1)
    kappa_dropout ~ Pareto(1, 1)
                r ~ Weibull(2, 1)
                a = Deterministic(f(kappa_dropout, phi_dropout))
                b = Deterministic(f(kappa_dropout, phi_dropout))
recency_frequency ~ ModifiedBetaGeoNBD(a, b, r, alpha, <constant>)

Notice the additional phi_dropout and kappa_dropout priors. These were added to the default configuration to improve performance, but can be omitted when specifying a custom model_config with a and b.

The specified model structure can also be visualized:

model.graphviz()
../../_images/330c3c91e14c33d869258a312499c8bfd6e815359a8d90ec6fe86dba4bd8602c.svg

We can now fit the model. The default sampler in PyMC-Marketing is the No-U-Turn Sampler (NUTS). We use the default \(4\) chains and \(1000\) draws per chain.

Note

It is not necessary to build the model before fitting it. We can fit the model directly.

sample_kwargs = {
    "draws": 2_000,
    "chains": 4,
    "target_accept": 0.9,
    "random_seed": 42,
}

idata_mcmc = model.fit(data=data, **sample_kwargs)
                                                                                                                   
                                                             Grad                                                  
  Progress               Draw        Divergen…   Step size   evals       Speed                Elapsed    Remaini…  
 ───────────────────────────────────────────────────────────────────────────────────────────────────────────────── 
  ━━━━━━━━━━━━━━━━━━━━   2400        0           0.417       7           384.21 draws/s       0:00:06    0:00:00   
  ━━━━━━━━━━━━━━━━━━━━   2400        0           0.465       7           404.73 draws/s       0:00:05    0:00:00   
  ━━━━━━━━━━━━━━━━━━━━   2400        0           0.363       7           388.99 draws/s       0:00:06    0:00:00   
  ━━━━━━━━━━━━━━━━━━━━   2400        0           0.432       3           380.85 draws/s       0:00:06    0:00:00   
                                                                                                                   

idata_mcmc
<xarray.DataTree>
Group: /
│   Attributes:
│       id:              7158b3031d6e4385
│       model_type:      MBG/NBD
│       version:         None
│       sampler_config:  {}
│       model_config:    {"alpha": {"dist": "Weibull", "kwargs": {"alpha": 2, "be...
├── Group: /posterior
│       Dimensions:        (chain: 4, draw: 2000)
│       Coordinates:
│         * chain          (chain) int64 32B 0 1 2 3
│         * draw           (draw) int64 16kB 0 1 2 3 4 5 ... 1995 1996 1997 1998 1999
│       Data variables:
│           alpha          (chain, draw) float64 64kB 6.199 6.165 5.669 ... 7.141 7.021
│           phi_dropout    (chain, draw) float64 64kB 0.3966 0.3789 ... 0.4244 0.4104
│           kappa_dropout  (chain, draw) float64 64kB 1.902 1.948 1.96 ... 1.823 1.929
│           r              (chain, draw) float64 64kB 0.5307 0.5501 ... 0.6805 0.7099
│           a              (chain, draw) float64 64kB 0.7545 0.7381 ... 0.7739 0.7918
│           b              (chain, draw) float64 64kB 1.148 1.21 1.223 ... 1.05 1.138
│       Attributes:
│           created_at:                 2026-07-10T11:02:22.835497+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           sample_dims:                ['chain', 'draw']
│           inference_library:          nutpie
│           inference_library_version:  0.16.11
│           sampling_time:              6.366796255111694
│           tuning_steps:               400
├── Group: /sample_stats
│       Dimensions:                   (chain: 4, draw: 2000)
│       Coordinates:
│         * chain                     (chain) int64 32B 0 1 2 3
│         * draw                      (draw) int64 16kB 0 1 2 3 ... 1996 1997 1998 1999
│       Data variables: (12/20)
│           depth                     (chain, draw) uint64 64kB 3 2 3 4 4 ... 4 4 3 3 2
│           maxdepth_reached          (chain, draw) bool 8kB False False ... False False
│           step_size                 (chain, draw) float64 64kB 0.4178 ... 0.4325
│           transformation_update_id  (chain, draw) int64 64kB 0 0 0 0 0 0 ... 0 0 0 0 0
│           step_size_bar             (chain, draw) float64 64kB 0.3996 ... 0.4101
│           mean_tree_accept          (chain, draw) float64 64kB 0.9941 0.963 ... 0.5502
│           ...                        ...
│           fisher_distance           (chain, draw) float64 64kB 19.53 1.062 ... 35.01
│           transformation_index      (chain, draw) int64 64kB 339 339 339 ... 339 339
│           diverging                 (chain, draw) bool 8kB False False ... False False
│           divergence_draw           (chain, draw) uint64 64kB 0 0 0 0 0 ... 0 0 0 0 0
│           divergence_message        (chain, draw) object 64kB None None ... None None
│           divergence_energy_error   (chain, draw) float64 64kB nan nan nan ... nan nan
│       Attributes:
│           created_at:                  2026-07-10T11:02:22.830129+00:00
│           creation_library:            ArviZ
│           creation_library_version:    1.2.0
│           creation_library_language:   Python
│           sample_dims:                 ['chain', 'draw']
│           inference_library:           nutpie
│           inference_library_version:   0.16.11
│           inference_library_settings:  {"sampler": "nuts", "adaptation": "diag", "s...
├── Group: /constant_data
│       Attributes:
│           created_at:                 2026-07-10T11:02:22.832743+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                []
├── Group: /observed_data
│       Dimensions:            (customer_id: 2357, obs_var: 2)
│       Coordinates:
│         * customer_id        (customer_id) int64 19kB 0 1 2 3 ... 2353 2354 2355 2356
│         * obs_var            (obs_var) <U9 72B 'recency' 'frequency'
│       Data variables:
│           recency_frequency  (customer_id, obs_var) float64 38kB 30.43 2.0 ... 0.0 0.0
│       Attributes:
│           created_at:                 2026-07-10T11:02:22.834357+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                []
└── Group: /fit_data
        Dimensions:      (index: 2357)
        Coordinates:
          * index        (index) int64 19kB 0 1 2 3 4 5 ... 2352 2353 2354 2355 2356
        Data variables:
            customer_id  (index) int64 19kB 0 1 2 3 4 5 ... 2352 2353 2354 2355 2356
            frequency    (index) int64 19kB 2 1 0 0 0 7 1 0 2 0 ... 7 1 2 0 0 0 5 0 4 0
            recency      (index) float64 19kB 30.43 1.71 0.0 0.0 ... 24.29 0.0 26.57 0.0
            T            (index) float64 19kB 38.86 38.86 38.86 38.86 ... 27.0 27.0 27.0

We can look into the summary table:

model.fit_summary()
mean sd eti89_lb eti89_ub ess_bulk ess_tail r_hat mcse_mean mcse_sd
alpha 6.55 0.72 5.5 7.8 2046 2401 1.00 0.016 0.013
phi_dropout 0.373 0.039 0.31 0.44 1731 2087 1.00 0.00094 0.00062
kappa_dropout 2.35 0.58 1.7 3.4 2152 2760 1.00 0.012 0.014
r 0.573 0.087 0.45 0.73 1641 1826 1.00 0.0022 0.0018
a 0.861 0.138 0.68 1.1 3464 4667 1.00 0.0023 0.0023
b 1.49 0.45 0.95 2.3 1946 2601 1.00 0.01 0.011

We see that the r_hat values are close to \(1\), which indicates convergence.

We can also plot posterior distributions of the parameters and the rank plots:

axes = azp.plot_trace(
    model.idata,
    figure_kwargs={"figsize": (12, 9), "layout": "constrained"},
)
plt.gcf().suptitle("MBG/NBD Model Trace", fontsize=18, fontweight="bold");

Using MAP fit#

CLV models such as BetaGeoModel, can provide the maximum a posteriori estimates using a numerical optimizer (L-BFGS-B from scipy.optimize) under the hood.

model_map = clv.ModifiedBetaGeoModel()
idata_map = model_map.fit(
    data=data,
    method="map",
)
MAP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━   0% 0:00:02 logp = -9,586.5, ||grad|| = 3.4368

idata_map
<xarray.DataTree>
Group: /
│   Attributes:
│       id:              7158b3031d6e4385
│       model_type:      MBG/NBD
│       version:         None
│       sampler_config:  {}
│       model_config:    {"alpha": {"dist": "Weibull", "kwargs": {"alpha": 2, "be...
├── Group: /posterior
│       Dimensions:        (chain: 1, draw: 1)
│       Coordinates:
│         * chain          (chain) int64 8B 0
│         * draw           (draw) int64 8B 0
│       Data variables:
│           alpha          (chain, draw) float64 8B 6.454
│           phi_dropout    (chain, draw) float64 8B 0.3773
│           kappa_dropout  (chain, draw) float64 8B 2.177
│           r              (chain, draw) float64 8B 0.5654
│           a              (chain, draw) float64 8B 0.8215
│           b              (chain, draw) float64 8B 1.356
│       Attributes:
│           created_at:                 2026-07-10T11:02:26.319595+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                ['chain', 'draw']
├── Group: /sample_stats
│       Attributes:
│           created_at:                 2026-07-10T11:02:26.320542+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                ['chain', 'draw']
├── Group: /observed_data
│       Dimensions:            (customer_id: 2357, obs_var: 2)
│       Coordinates:
│         * customer_id        (customer_id) int64 19kB 0 1 2 3 ... 2353 2354 2355 2356
│         * obs_var            (obs_var) <U9 72B 'recency' 'frequency'
│       Data variables:
│           recency_frequency  (customer_id, obs_var) float64 38kB 30.43 2.0 ... 0.0 0.0
│       Attributes:
│           created_at:                 2026-07-10T11:02:26.321353+00:00
│           creation_library:           ArviZ
│           creation_library_version:   1.2.0
│           creation_library_language:  Python
│           inference_library:          pymc
│           inference_library_version:  6.0.1
│           sample_dims:                []
└── Group: /fit_data
        Dimensions:      (index: 2357)
        Coordinates:
          * index        (index) int64 19kB 0 1 2 3 4 5 ... 2352 2353 2354 2355 2356
        Data variables:
            customer_id  (index) int64 19kB 0 1 2 3 4 5 ... 2352 2353 2354 2355 2356
            frequency    (index) int64 19kB 2 1 0 0 0 7 1 0 2 0 ... 7 1 2 0 0 0 5 0 4 0
            recency      (index) float64 19kB 30.43 1.71 0.0 0.0 ... 24.29 0.0 26.57 0.0
            T            (index) float64 19kB 38.86 38.86 38.86 38.86 ... 27.0 27.0 27.0

This time we get point estimates for the parameters.

map_summary = model_map.fit_summary()

map_summary
alpha             6.5
phi_dropout      0.38
kappa_dropout     2.2
r                0.57
a                0.82
b                 1.4
Name: value, dtype: object

The r and alpha purchase rate parameters are quite similar for both MCMC and MAP fits, but the a and b dropout parameters are better approximated with the default parameters when fitted with MCMC.

pc = azp.plot_dist(
    model.idata,
    var_names=["r", "alpha", "a", "b"],
    point_estimate="mean",
    figure_kwargs={"figsize": (12, 4)},
)

for var_name in ["r", "alpha", "a", "b"]:
    ax = pc.viz["plot"][var_name].item()
    ax.axvline(x=float(map_summary[var_name]), color="C1", linestyle="--", label="MAP")
    ax.legend(loc="upper right")

pc.viz["figure"].item().suptitle(
    "MBG/NBD Model Parameters", fontsize=18, fontweight="bold", y=1.1
);

Prior and Posterior Predictive Checks#

PPCs allow us to check the efficacy of our priors, and the peformance of the fitted posteriors.

Let’s see how the model performs in a prior predictive check, where we sample from the default priors before fitting the model:

# PPC histogram plot
clv.plot_expected_purchases_ppc(model, ppc="prior");
Sampling: [alpha, kappa_dropout, phi_dropout, r, recency_frequency]
../../_images/820d97de37a360904d8a4181514e4087d4fdecd040f6c91751a3117f849433c4.png
# PPC histogram plot
clv.plot_expected_purchases_ppc(model, ppc="posterior");
Sampling ... ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00 / 0:00:39

../../_images/67ef819584065e48fe4a886ce644ac38972a0b0e2d267379adec79c3f693d713.png

Some Applications#

Now that you have fitted the model, we can use it to make predictions. For example, we can predict the expected probability of a customer being alive as a function of time (steps). Here is a snippet of code to do that:

Expected Number of Purchases#

Let us take a sample of users:

example_customer_ids = [1, 6, 10, 18, 45, 1412]

data_small = data.query("customer_id.isin(@example_customer_ids)")

data_small.head(6)
customer_id frequency recency T
1 1 1 1.71 38.86
6 6 1 5.00 38.86
10 10 5 24.43 38.86
18 18 3 28.29 38.71
45 45 12 34.43 38.57
1412 1412 14 30.29 31.57

Observe that the last two customers are frequent buyers as compared to the others.

steps = 90

expected_num_purchases_steps = xr.concat(
    objs=[
        model.expected_purchases(
            data=data_small,
            future_t=t,
        )
        for t in progress_bar(range(steps))
    ],
    dim="t",
).transpose(..., "t")
100.00% [90/90 00:01<00:00]

We can plot the expected number of purchases for the next \(90\) periods:

Hide code cell source

fig, axes = plt.subplots(
    nrows=len(example_customer_ids),
    ncols=1,
    figsize=(12, 15),
    sharex=True,
    sharey=True,
    layout="constrained",
)

axes = axes.flatten()

for i, customer_id in enumerate(example_customer_ids):
    ax = axes[i]
    customer_expected_num_purchases_steps = expected_num_purchases_steps.sel(
        customer_id=customer_id
    )
    hdi_94 = az.hdi(customer_expected_num_purchases_steps, prob=0.94)
    lower_94 = hdi_94.sel(ci_bound="lower")
    upper_94 = hdi_94.sel(ci_bound="upper")
    ax.fill_between(
        range(steps), lower_94, upper_94, alpha=0.3, color="C0", label=r"$94 \%$ HDI"
    )
    hdi_50 = az.hdi(customer_expected_num_purchases_steps, prob=0.5)
    lower_50 = hdi_50.sel(ci_bound="lower")
    upper_50 = hdi_50.sel(ci_bound="upper")
    ax.fill_between(
        range(steps), lower_50, upper_50, alpha=0.5, color="C0", label=r"$50 \%$ HDI"
    )
    ax.plot(
        range(steps),
        customer_expected_num_purchases_steps.mean(dim=("chain", "draw")),
        color="C0",
        label="posterior mean",
    )
    ax.legend(loc="upper left")
    ax.set(title=f"Customer {customer_id}", xlabel="t", ylabel="purchases")

axes[-1].set(xlabel="steps")
plt.gcf().suptitle("Expected Number of Purchases", fontsize=18, fontweight="bold");

Note that the frequent buyers are expected to make more purchases in the future.

Probability of a Customer Being Alive#

We now look into the probability of a customer being alive for the next \(90\) periods:

steps = 90

future_alive_all = []

for t in progress_bar(range(steps)):
    future_data = data_small.copy()
    future_data["T"] = future_data["T"] + t
    future_alive = model.expected_probability_alive(data=future_data)
    future_alive_all.append(future_alive)

expected_probability_alive_steps = xr.concat(
    objs=future_alive_all,
    dim="t",
).transpose(..., "t")
100.00% [90/90 00:00<00:00]

Hide code cell source

fig, axes = plt.subplots(
    nrows=len(example_customer_ids),
    ncols=1,
    figsize=(12, 15),
    sharex=True,
    sharey=True,
    layout="constrained",
)

axes = axes.flatten()

for i, customer_id in enumerate(example_customer_ids):
    ax = axes[i]
    customer_expected_probability_alive_steps = expected_probability_alive_steps.sel(
        customer_id=customer_id
    )
    hdi_94 = az.hdi(customer_expected_probability_alive_steps, prob=0.94)
    lower_94 = hdi_94.sel(ci_bound="lower")
    upper_94 = hdi_94.sel(ci_bound="upper")
    ax.fill_between(
        range(steps), lower_94, upper_94, alpha=0.3, color="C1", label=r"$94 \%$ HDI"
    )
    hdi_50 = az.hdi(customer_expected_probability_alive_steps, prob=0.5)
    lower_50 = hdi_50.sel(ci_bound="lower")
    upper_50 = hdi_50.sel(ci_bound="upper")
    ax.fill_between(
        range(steps), lower_50, upper_50, alpha=0.5, color="C1", label=r"$50 \%$ HDI"
    )
    ax.plot(
        range(steps),
        customer_expected_probability_alive_steps.mean(dim=("chain", "draw")),
        color="C1",
        label="posterior mean",
    )
    ax.legend(loc="upper right")
    ax.set(title=f"Customer {customer_id}", ylabel="probability alive", ylim=(0, 1))

axes[-1].set(xlabel="steps")
plt.gcf().suptitle(
    "Expected Probability Alive over Time", fontsize=18, fontweight="bold"
);

Tip

Here are some general remarks:

  • These plots assume no future purchases.

  • The decay probability is not the same as it depends in the purchase history of the customer.

  • The probability of being alive is always decreasing as we are assuming there is no change in the other parameters.

  • These probabilities are always non-negative, as expected.

Warning

For the frequent buyers, the probability of being alive drops very fast as we are assuming no future purchases. It is very important to keep this in mind when interpreting the results.

%reload_ext watermark
%watermark -n -u -v -iv -w -p pymc_marketing,pymc,pytensor
Last updated: Fri, 10 Jul 2026

Python implementation: CPython
Python version       : 3.12.13
IPython version      : 9.15.0

pymc_marketing: 1.0.0.dev0
pymc          : 6.0.1
pytensor      : 3.0.7

arviz         : 1.2.0
arviz_plots   : 1.2.0
fastprogress  : 1.1.6
matplotlib    : 3.10.9
pandas        : 2.3.3
pymc_marketing: 1.0.0.dev0
xarray        : 2026.4.0

Watermark: 2.6.0