NBD-Dirichlet Applied to Consumer Panel Data

Author

Abdullah Mahmood

Published

July 19, 2026

Applies the Dirichlet model to the edible grocery panel used in Analysing Buyer Behaviour Using Consumer Panel Data — 5,021 static panellists, two years, five brands (Alpha, Bravo, Charlie, Delta, Other).

The derivations, the literature and the marketing argument are in NBD-Dirichlet Model of Consumer Buying Behavior, which reproduces the 1984 toothpaste example.

1 Formula reference card

Four assumption levels: category purchases Poisson(\(\mu\)) × gamma across consumers → NBD; brand choice multinomial(\(\mathbf p\)) × Dirichlet(\(\boldsymbol\alpha\)) across consumers → Dirichlet-multinomial; the two independent.

Three parameters: \(M\) (per-capita category rate), \(K\) (diversity in how often), \(S = \sum_j\alpha_j\) (diversity in which brand). Brand \(j\) has \(\alpha_j = S\mu_j\) where \(\mu_j\) is market share.

Quantity Formula
Category incidence \(P_n = \left(1+\tfrac{MT}{K}\right)^{-K}\tfrac{\Gamma(K+n)}{n!\,\Gamma(K)}\left(\tfrac{MT}{MT+K}\right)^{n}\)
Fit \(M\) \(M = B \times W\) (category penetration × category buy rate)
Fit \(K\) (“mean and zeros”) solve \(1-B = (1+M/K)^{-K}\)
Brand choice given \(n\) \(p(r\mid n)=\binom{n}{r}\dfrac{B(\alpha_j+r,\;S-\alpha_j+n-r)}{B(\alpha_j,\;S-\alpha_j)}\)
— the \(r=0\) case \(p(0\mid n)=\prod_{a=0}^{n-1}\dfrac{S-\alpha_j+a}{S+a}\), \(\;p(0\mid 0)=1\)
Fit \(S\) per brand, solve \(1-\sum_n P_n\,p(0\mid n) = b_j^{\text{obs}}\); pool by share-weighted mean, dropping outliers
Penetration \(b_j = 1-\sum_{n\ge0}P_n\,p(0\mid n)\)
Purchases per brand buyer \(w_j = \tfrac{1}{b_j}\sum_{n\ge1}P_n\sum_{r=1}^{n}r\,p(r\mid n)\)
Category purchases per brand buyer \(w_{P,j} = \tfrac{1}{b_j}\sum_{n\ge1}n\,P_n\,[1-p(0\mid n)]\)
Brand purchase frequency dist. \(f_j(r)=\sum_{n\ge r}P_n\,p(r\mid n)\)
Restricted to category buyers in \(R\) \(b_{j\mid R}=1-\tfrac{\sum_{n\in R}P_n p(0\mid n)}{\sum_{n\in R}P_n}\)
Duplication \(b_{(j+k)}=1-\sum_n P_n\,p_{j+k}(0\mid n)\); \(\;b_{jk}=b_j+b_k-b_{(j+k)}\); \(\;b_{j\mid k}=b_{jk}/b_k\)
Time extrapolation replace \(M \to MT\). \(K\) and \(S\) are time-invariant

Additivity is what makes all of this computable: merging brands adds their \(\alpha\), so “brand \(j\) vs everything else” is a two-category Dirichlet-multinomial, i.e. a beta-binomial.

2 Imports

import numpy as np
import pandas as pd
from scipy.optimize import minimize_scalar
from scipy.special import gammaln, betaln

import matplotlib.pyplot as plt
from IPython.display import display_markdown

%config InlineBackend.figure_formats = ['svg']
plt.rcParams["axes.spines.right"] = False
plt.rcParams["axes.spines.top"] = False
pd.set_option("display.precision", 2)

PANEL_SIZE = 5021   # static panel
YEAR = 1            # weeks 1-52
NSTAR = 170         # truncation for the sums over n (justified below)

3 Deriving the model inputs from the panel

The Dirichlet needs four things. The panel gives transactions, so we aggregate.

A purchase occasion is a unique (panel_id, trans_id). A trip on which two brands were bought counts as one category occasion but as one occasion for each brand — the convention used in the source notebook.

df = pd.read_csv("data/panel-datasets/edible_grocery.csv", encoding="utf-8-sig")
yr = df[(df.week > (YEAR - 1) * 52) & (df.week <= YEAR * 52)]

brand_occ = yr[["panel_id", "trans_id", "brand"]].drop_duplicates()
cat_occ   = yr[["panel_id", "trans_id"]].drop_duplicates()

brand_name = np.array(sorted(brand_occ.brand.unique()))
nbrand = len(brand_name)

grp = brand_occ.groupby("brand")
occasions = grp.size().loc[brand_name]
buyers    = grp.panel_id.nunique().loc[brand_name]

brand_share   = (occasions / occasions.sum()).to_numpy()
brand_pen_obs = (buyers / PANEL_SIZE).to_numpy()

cat_pen     = cat_occ.panel_id.nunique() / PANEL_SIZE
cat_buyrate = len(cat_occ) / cat_occ.panel_id.nunique()

pd.DataFrame({"occasions": occasions, "buyers": buyers,
              "share": brand_share, "pen (obs)": brand_pen_obs},
             index=brand_name)
occasions buyers share pen (obs)
Alpha 9060 2624 0.44 0.52
Bravo 8255 2562 0.40 0.51
Charlie 1882 813 0.09 0.16
Delta 859 380 0.04 0.08
Other 422 176 0.02 0.04
Code
display_markdown(
    f"Category penetration **{cat_pen:.4f}**, buy rate **{cat_buyrate:.4f}** "
    f"→ $M$ = **{cat_pen * cat_buyrate:.4f}**\n\n"
    f"Brand occasions {occasions.sum():,} vs category occasions {len(cat_occ):,} "
    f"— **{100 * (occasions.sum() - len(cat_occ)) / len(cat_occ):.2f}%** of trips "
    f"involved more than one brand.",
    raw=True,
)

Category penetration 0.9110, buy rate 4.3791\(M\) = 3.9892

Brand occasions 20,478 vs category occasions 20,030 — 2.24% of trips involved more than one brand.

A modelling wrinkle to note up front. The Dirichlet assumes every category purchase goes to exactly one brand, so \(\sum_j r_j = n\). Here 2.2% of trips break that — a household bought Alpha and Delta on one trip. \(M\) is therefore built from category occasions while shares are built from brand occasions, and the two bases differ by 2.2%. Small enough to proceed, but it is a real (if minor) violation of the model’s own arithmetic, not just a rounding matter.

Compare with the toothpaste example: category penetration is 0.91 here against 0.56, and two brands hold 85% of the category. This is a much more concentrated, much more widely bought category — a genuinely different test.

4 Fitting

4.1 \(M\) and \(K\)

def estimate_K(M, cat_pen, max_K=30.0):
    """Mean-and-zeros: solve (1 + M/K)^-K = 1 - B for K."""
    cp = np.log(1.0 - cat_pen)
    return minimize_scalar(lambda K: (K * np.log(1.0 + M / K) + cp) ** 2,
                           bounds=(1e-4, max_K), method="bounded").x

M0 = cat_pen * cat_buyrate
K = estimate_K(M0, cat_pen)

print(f"M = {M0:.4f}   K = {K:.4f}")
print(f"existence condition M > -log(P0):  {M0:.3f} > {-np.log(1 - cat_pen):.3f}  ->  "
      f"{M0 > -np.log(1 - cat_pen)}")
print(f"implied P(0) = {(1 + M0 / K) ** -K:.4f}   observed = {1 - cat_pen:.4f}")
M = 3.9892   K = 2.6031
existence condition M > -log(P0):  3.989 > 2.419  ->  True
implied P(0) = 0.0890   observed = 0.0890

\(K = 2.6\) against 0.78 for toothpaste. Higher \(K\) means less dispersion in category buying rates — consistent with a category that 91% of households buy.

4.2 \(P_n\) and the truncation check

def Pn(M, K, n):
    """NBD probability of n category purchases, computed in logs."""
    n = np.atleast_1d(np.asarray(n, dtype=int))
    a = np.arange(int(n.max()))
    logratio = np.concatenate(([0.0], np.cumsum(np.log(K + a) - np.log(1.0 + a))))
    return np.exp(-K * np.log(1.0 + M / K)
                  + logratio[n]
                  + np.where(n > 0, n * np.log(M / (M + K)), 0.0))


def check_truncation(M, K, nstar=NSTAR):
    ns = np.arange(nstar + 1)
    P = Pn(M, K, ns)
    return {"sum P(n)": P.sum(), "implied mean": (ns * P).sum(), "target M": M}

pd.DataFrame([check_truncation(M0 * t, K) | {"t": t} for t in (1, 2, 4)]).set_index("t")
sum P(n) implied mean target M
t
1 1.0 3.99 3.99
2 1.0 7.98 7.98
4 1.0 15.96 15.96

The default nstar = 50 in the R package is not safe here. With \(M \approx 4\) per year, the two-year and four-year sums need far more mass; NSTAR = 170 keeps \(\sum P_n\) at 1.000 and recovers the mean at every horizon used below. This diagnostic is the way to choose it — a too-small nstar silently biases every measure downward.

4.3 The brand-choice kernel

The whole beta-binomial is precomputed once per brand as a matrix \(Q[n, r] = p(r \mid n)\), upper-triangular since \(r \le n\).

def pr_matrix(alpha, S, nstar=NSTAR):
    """(nstar+1, nstar+1) matrix of p(r | n); zero where r > n.

    log p(r|n) = log C(n,r) + log B(a+r, S-a+n-r) - log B(a, S-a)
    """
    n = np.arange(nstar + 1)[:, None]
    r = np.arange(nstar + 1)[None, :]
    ok = r <= n
    nr = np.where(ok, n - r, 0)          # mask before gammaln to avoid poles
    log_q = (gammaln(n + 1) - gammaln(r + 1) - gammaln(nr + 1)
             + betaln(alpha + r, S - alpha + nr) - betaln(alpha, S - alpha))
    return np.where(ok, np.exp(log_q), 0.0)


def p_zero_vec(alpha, S, nstar=NSTAR):
    """p(0 | n) for n = 0..nstar via the telescoped product, as a cumulative sum."""
    a = np.arange(nstar)
    return np.concatenate(([1.0], np.exp(np.cumsum(np.log(S - alpha + a)
                                                   - np.log(S + a)))))

4.4 \(S\)

def S_for_brand(j, M, K, share, pen_obs, nstar=NSTAR, max_S=30.0):
    P = Pn(M, K, np.arange(nstar + 1))

    def obj(S):
        return (1.0 - np.sum(P * p_zero_vec(S * share[j], S, nstar)) - pen_obs[j]) ** 2

    return minimize_scalar(obj, bounds=(1e-9, max_S), method="bounded").x


def fivenum(x):
    """R's stats::fivenum — Tukey hinges, not interpolated quartiles."""
    x = np.sort(np.asarray(x, dtype=float))
    n = len(x)
    n4 = np.floor((n + 3) / 2) / 2
    d = np.array([1.0, n4, (n + 1) / 2, n + 1 - n4, float(n)])
    return 0.5 * (x[np.floor(d).astype(int) - 1] + x[np.ceil(d).astype(int) - 1])


def pool_S(S_all, share):
    """Share-weighted mean, dropping (a) 1.5*IQR outliers and (b) anything above
    the upper boxplot notch. Both tests come from R's boxplot.stats(), which the
    NBDdirichlet package calls but does not implement itself."""
    _, hinge_lo, median, hinge_hi, _ = fivenum(S_all)
    iqr = hinge_hi - hinge_lo
    fence = (S_all < hinge_lo - 1.5 * iqr) | (S_all > hinge_hi + 1.5 * iqr)
    notch_hi = median + 1.58 * iqr / np.sqrt(len(S_all))
    drop = fence | (S_all > notch_hi)
    return np.average(S_all[~drop], weights=share[~drop]), drop, notch_hi


S_all = np.array([S_for_brand(j, M0, K, brand_share, brand_pen_obs)
                  for j in range(nbrand)])
S, dropped, notch_hi = pool_S(S_all, brand_share)

display_markdown(
    f"Upper notch **{notch_hi:.4f}** → dropped **"
    + (", ".join(brand_name[dropped]) if dropped.any() else "none")
    + f"**  →  pooled **S = {S:.4f}**",
    raw=True,
)

pd.DataFrame({"share": brand_share, "pen (obs)": brand_pen_obs,
              "S_j": S_all, "dropped": dropped}, index=brand_name)

Upper notch 1.1689 → dropped Charlie → pooled S = 0.6171

share pen (obs) S_j dropped
Alpha 0.44 0.52 0.50 False
Bravo 0.40 0.51 0.68 False
Charlie 0.09 0.16 1.17 True
Delta 0.04 0.08 1.11 False
Other 0.02 0.04 0.87 False

\(S = 0.62\), roughly half the toothpaste value of 1.30. Low \(S\) means a polarised category: consumers have strongly differentiated brand propensities rather than picking near-randomly in proportion to share. That matches the source notebook’s finding that over two-thirds of category buyers bought only one brand all year.

Code
display_markdown(
    f"| | $M$ | $K$ | $S$ |\n|---|---|---|---|\n"
    f"| edible grocery (year, 5 brands) | {M0:.3f} | {K:.3f} | {S:.3f} |\n"
    f"| toothpaste (quarter, 8 brands) | 1.456 | 0.778 | 1.295 |",
    raw=True,
)
\(M\) \(K\) \(S\)
edible grocery (year, 5 brands) 3.989 2.603 0.617
toothpaste (quarter, 8 brands) 1.456 0.778 1.295

5 Brand performance measures

def brand_measures(M, K, S, alpha, nstar=NSTAR, limit=None):
    """(penetration, unnormalised E[brand purchases], unnormalised E[cat purchases])."""
    ns = np.arange(nstar + 1) if limit is None else np.asarray(limit, dtype=int)
    P = Pn(M, K, ns)
    Q = pr_matrix(alpha, S, nstar)[ns]
    r = np.arange(nstar + 1)
    pen = 1.0 - np.sum(P * Q[:, 0])
    return pen, np.sum(P * (Q * r).sum(axis=1)), np.sum(ns * P * (1.0 - Q[:, 0]))


def buy_table(M, K, S, share, names, nstar=NSTAR):
    rows = []
    for j in range(len(share)):
        b, e_brand, e_cat = brand_measures(M, K, S, S * share[j], nstar)
        rows.append([b, e_brand / b, e_cat / b])
    return pd.DataFrame(rows, index=names,
                        columns=["pen.brand", "pur.brand", "pur.cat"])

buy = buy_table(M0, K, S, brand_share, brand_name)
buy.round(2)
pen.brand pur.brand pur.cat
Alpha 0.54 3.27 4.75
Bravo 0.50 3.20 4.78
Charlie 0.14 2.70 5.01
Delta 0.06 2.63 5.05
Other 0.03 2.60 5.07

5.1 Observed versus theoretical

The point of using real panel data rather than the paper’s published summary is that we can compute the observeds ourselves.

# panellist x brand matrix of purchase occasions
X = (brand_occ.groupby(["panel_id", "brand"]).size()
     .unstack(fill_value=0).reindex(columns=brand_name, fill_value=0))
n_cat = cat_occ.groupby("panel_id").size()
X = X.reindex(n_cat.index, fill_value=0)
ever = (X > 0).astype(int)

obs = pd.DataFrame({
    "b (O)": ever.sum() / PANEL_SIZE,
    "w (O)": X.sum() / ever.sum(),
    "wp (O)": [n_cat[ever[b] == 1].mean() for b in brand_name],
}, index=brand_name)

ot = pd.DataFrame({
    "share": brand_share,
    "b (O)": obs["b (O)"], "b (T)": buy["pen.brand"],
    "w (O)": obs["w (O)"], "w (T)": buy["pur.brand"],
    "wp (O)": obs["wp (O)"], "wp (T)": buy["pur.cat"],
})
ot.loc["Average"] = ot.mean()
ot.round(2)
share b (O) b (T) w (O) w (T) wp (O) wp (T)
Alpha 0.44 0.52 0.54 3.45 3.27 4.88 4.75
Bravo 0.40 0.51 0.50 3.22 3.20 4.59 4.78
Charlie 0.09 0.16 0.14 2.31 2.70 5.24 5.01
Delta 0.04 0.08 0.06 2.26 2.63 5.58 5.05
Other 0.02 0.04 0.03 2.40 2.60 5.96 5.07
Average 0.20 0.26 0.25 2.73 2.88 5.25 4.93

Read it the way Ehrenberg would — down the columns, at two significant figures.

  • Penetration is close for the two big brands (0.52/0.54 and 0.51/0.50) but the model under-predicts the small brands: Charlie 0.16 observed against 0.14 theoretical, Delta 0.08 against 0.06.
  • \(w\) goes the other way: the model over-predicts loyalty for small brands (Charlie 2.3 observed vs 2.7 theoretical).
  • \(w_P\) shows the natural-monopoly trend rising as share falls in both columns, but the observed trend is steeper (4.9 → 6.0) than the theoretical (4.8 → 5.1).

So the fit is materially worse than the toothpaste example, where penetration came within a point across all eight brands. That is worth taking seriously rather than reporting an average and moving on.

5.2 Double Jeopardy

Code
dj = pd.DataFrame({
    "share": brand_share,
    "b (O)": obs["b (O)"], "w (O)": obs["w (O)"],
    "w(1-b) (O)": obs["w (O)"] * (1 - obs["b (O)"]),
    "w(1-b) (T)": buy["pur.brand"] * (1 - buy["pen.brand"]),
})
dj.round(2)
share b (O) w (O) w(1-b) (O) w(1-b) (T)
Alpha 0.44 0.52 3.45 1.65 1.51
Bravo 0.40 0.51 3.22 1.58 1.60
Charlie 0.09 0.16 2.31 1.94 2.34
Delta 0.04 0.08 2.26 2.09 2.46
Other 0.02 0.04 2.40 2.31 2.52

Both the observed and theoretical w(1-b) columns are far flatter than either \(b\) or \(w\) alone — the Double Jeopardy relation holds in the data. The observed column runs 1.6–2.2 while share varies twenty-fold; the theoretical runs 1.5–2.5. Neither is constant, but the residual variation is a fraction of the variation the relation absorbs.

6 The summary tables

6.1 Purchase frequency distribution

def freq_table(M, K, S, share, names, cutoff=5, nstar=NSTAR):
    P = Pn(M, K, np.arange(nstar + 1))
    rows = []
    for j in range(len(share)):
        f = (P[:, None] * pr_matrix(S * share[j], S, nstar)).sum(axis=0)
        rows.append(list(f[:cutoff + 1]) + [f[cutoff + 1:].sum()])
    cols = [str(i) for i in range(cutoff + 1)] + [f"{cutoff + 1}+"]
    return pd.DataFrame(rows, index=names, columns=cols)

theo_freq = freq_table(M0, K, S, brand_share, brand_name)
theo_freq.round(2)
0 1 2 3 4 5 6+
Alpha 0.46 0.16 0.11 0.08 0.06 0.04 0.09
Bravo 0.50 0.16 0.10 0.07 0.05 0.04 0.08
Charlie 0.86 0.05 0.03 0.02 0.01 0.01 0.01
Delta 0.94 0.03 0.01 0.01 0.01 0.00 0.01
Other 0.97 0.01 0.01 0.00 0.00 0.00 0.00
Code
obs_freq = pd.DataFrame(
    {b: X[b].clip(upper=6).value_counts().reindex(range(7), fill_value=0)
     for b in brand_name}).T
obs_freq[0] += PANEL_SIZE - len(X)          # households that bought no category at all
obs_freq = obs_freq / PANEL_SIZE
obs_freq.columns = [str(i) for i in range(6)] + ["6+"]

pd.concat({"Observed": obs_freq.round(2), "Theoretical": theo_freq.round(2)},
          axis=1).swaplevel(axis=1).sort_index(axis=1)
0 1 2 3 4 5 6+
Observed Theoretical Observed Theoretical Observed Theoretical Observed Theoretical Observed Theoretical Observed Theoretical Observed Theoretical
Alpha 0.48 0.46 0.15 0.16 0.10 0.11 0.08 0.08 0.06 0.06 0.05 0.04 0.09 0.09
Bravo 0.49 0.50 0.14 0.16 0.10 0.10 0.08 0.07 0.06 0.05 0.04 0.04 0.08 0.08
Charlie 0.84 0.86 0.08 0.05 0.03 0.03 0.02 0.02 0.01 0.01 0.01 0.01 0.01 0.01
Delta 0.92 0.94 0.04 0.03 0.01 0.01 0.01 0.01 0.00 0.01 0.00 0.00 0.01 0.01
Other 0.96 0.97 0.02 0.01 0.01 0.01 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

The reverse-J shape is reproduced for every brand. The visible miss is at \(r = 1\) for the small brands — Charlie has 8% of households buying once against a predicted 5%. Too many light buyers relative to the model, the same signature Ehrenberg discusses as an “excess of occasional buyers”.

6.2 Heavy versus light category buyers

def heavy_table(M, K, S, share, names, limit, nstar=NSTAR):
    limit = np.asarray(limit, dtype=int)
    P_sum = Pn(M, K, limit).sum()
    rows = []
    for j in range(len(share)):
        b_lim, e_brand, _ = brand_measures(M, K, S, S * share[j], nstar, limit=limit)
        not_buying = 1.0 - b_lim
        rows.append([1.0 - not_buying / P_sum, e_brand / (P_sum - not_buying)])
    return pd.DataFrame(rows, index=names,
                        columns=["Penetration", "Avg Purchase Freq"])

pd.concat({"light (1-6)": heavy_table(M0, K, S, brand_share, brand_name, range(1, 7)),
           "heavy (7+)": heavy_table(M0, K, S, brand_share, brand_name,
                                     range(7, NSTAR + 1))}, axis=1).round(2)
light (1-6) heavy (7+)
Penetration Avg Purchase Freq Penetration Avg Purchase Freq
Alpha 0.57 2.45 0.69 5.90
Bravo 0.53 2.41 0.65 5.71
Charlie 0.14 2.10 0.19 4.39
Delta 0.06 2.05 0.09 4.21
Other 0.03 2.03 0.05 4.13

Heavy category buyers are more likely to buy every brand, and to buy each more often — a selection effect, not a segment.

6.3 Duplication of purchase — where the model breaks

def dup_table(M, K, S, share, names, focal, nstar=NSTAR):
    P = Pn(M, K, np.arange(nstar + 1))
    b_k = brand_measures(M, K, S, S * share[focal], nstar)[0]
    out = np.zeros(len(share))
    out[focal] = 1.0
    for j in range(len(share)):
        if j == focal:
            continue
        # additivity: the composite brand (j+k) has alpha = alpha_j + alpha_k
        b_comp = 1.0 - np.sum(P * pr_matrix(S * (share[focal] + share[j]), S, nstar)[:, 0])
        b_j = brand_measures(M, K, S, S * share[j], nstar)[0]
        out[j] = (b_j + b_k - b_comp) / b_k
    return pd.Series(out, index=names, name=names[focal])

theo_dup = pd.concat([dup_table(M0, K, S, brand_share, brand_name, f)
                      for f in range(nbrand)], axis=1).T
theo_dup.round(2)
Alpha Bravo Charlie Delta Other
Alpha 1.00 0.39 0.1 0.05 0.02
Bravo 0.41 1.00 0.1 0.05 0.02
Charlie 0.40 0.37 1.0 0.05 0.02
Delta 0.40 0.37 0.1 1.00 0.02
Other 0.40 0.37 0.1 0.05 1.00
Code
counts = ever.T.values @ ever.values                 # duplication count table
obs_dup = pd.DataFrame(counts / np.diag(counts)[:, None],
                       index=brand_name, columns=brand_name)
obs_dup.round(2)
Alpha Bravo Charlie Delta Other
Alpha 1.00 0.34 0.15 0.09 0.03
Bravo 0.35 1.00 0.15 0.05 0.04
Charlie 0.50 0.47 1.00 0.14 0.03
Delta 0.63 0.37 0.31 1.00 0.03
Other 0.39 0.62 0.15 0.06 1.00
Code
resid = (obs_dup - theo_dup).where(~np.eye(nbrand, dtype=bool))
resid.round(2)
Alpha Bravo Charlie Delta Other
Alpha NaN -0.04 0.05 0.04 0.00
Bravo -0.06 NaN 0.05 0.01 0.02
Charlie 0.09 0.10 NaN 0.10 0.01
Delta 0.23 -0.00 0.21 NaN 0.00
Other -0.01 0.25 0.05 0.01 NaN

The residuals are structured rather than noisy — but this table overstates part of the story, and it is worth being careful about why.

Two things are mixed together here. The Dirichlet’s own \(b_j\) is off for the small brands (§ Observed versus theoretical), and any duplication computed from a wrong \(b_j\) inherits that error. To separate a genuine duplication anomaly from penetration miss propagating, compare against the Duplication of Purchase Law fitted to the observed penetrations, with no Dirichlet involved:

\[b_{jk} = D\,b_j\,b_k\]

Ehrenberg (Repeat-Buying §10.5, footnote) recommends exactly this symmetric form over the conditional \(b_{j\mid k}\), because it is symmetric and homoscedastic — the residuals do not grow with the size of the brands being compared.

off = ~np.eye(nbrand, dtype=bool)
b_obs = obs["b (O)"].values
b_jk = (ever.T.values @ ever.values) / PANEL_SIZE        # joint penetration

D = b_jk[off].sum() / np.outer(b_obs, b_obs)[off].sum()
sym_resid = pd.DataFrame(100 * (b_jk - D * np.outer(b_obs, b_obs)),
                         index=brand_name, columns=brand_name).where(off)

display_markdown(f"Single duplication coefficient **D = {D:.3f}** — "
                 "residuals below in **percentage points of the panel**", raw=True)
sym_resid.round(2)

Single duplication coefficient D = 0.837 — residuals below in percentage points of the panel

Alpha Bravo Charlie Delta Other
Alpha NaN -4.48 0.94 1.45 -0.16
Bravo -4.48 NaN 0.69 -0.44 0.69
Charlie 0.94 0.69 NaN 1.28 0.04
Delta 1.45 -0.44 1.28 NaN -0.02
Other -0.16 0.69 0.04 -0.02 NaN

On this scale one residual dominates everything else:

  • Alpha ↔︎ Bravo: −4.5 points. The two leaders share far fewer buyers than their sizes imply.
  • Every other pair falls between −0.4 and +1.5, averaging about +0.5.

So the honest reading is narrower than “leaders under-duplicate, followers over-duplicate”. The follower-side excess is mostly the Dirichlet’s penetration miss showing through; against the observed penetrations it largely disappears. The one robust anomaly is that Alpha and Bravo behave as near-mutually-exclusive alternatives.

That corroborates something found independently in the source notebook: the weekly volume shares of Alpha and Bravo are strongly negatively correlated. Two brands trading share back and forth, while sharing few buyers, is one phenomenon seen two ways.

6.4 Sole-brand buyers — the sharpest miss

The proportion of the population buying only brand \(j\) all year is \(\sum_{n\ge1}P_n\,p(n\mid n)\), and since \(p(n\mid n)=B(\alpha_j+n,\,S-\alpha_j)/B(\alpha_j,\,S-\alpha_j)\) it is cheap to compute. The sum must start at \(n=1\)\(p(0\mid 0)=1\), so including \(n=0\) would count every category non-buyer as a “sole buyer”.

from scipy.special import betaln as _betaln

pos = np.arange(1, NSTAR + 1)
P_pos = Pn(M0, K, np.arange(NSTAR + 1))[1:]

def sole_theoretical(alpha, S):
    return np.sum(P_pos * np.exp(_betaln(alpha + pos, S - alpha)
                                 - _betaln(alpha, S - alpha)))

n_brands_bought = ever.sum(axis=1)
sole_obs = np.array([((ever[b] == 1) & (n_brands_bought == 1)).sum() / PANEL_SIZE
                     for b in brand_name])
sole_theo = np.array([sole_theoretical(S * s, S) for s in brand_share])

pd.DataFrame({
    "share": brand_share,
    "sole (O)": sole_obs, "sole (T)": sole_theo,
    "sole/pen (O)": sole_obs / obs["b (O)"].values,
    "sole/pen (T)": sole_theo / buy["pen.brand"].values,
}, index=brand_name).map("{:.3f}".format)
share sole (O) sole (T) sole/pen (O) sole/pen (T)
Alpha 0.442 0.270 0.272 0.517 0.504
Bravo 0.403 0.270 0.241 0.529 0.479
Charlie 0.092 0.031 0.044 0.194 0.324
Delta 0.042 0.014 0.019 0.187 0.304
Other 0.021 0.005 0.009 0.131 0.296

Look at the last two columns — the share of a brand’s own buyers who bought nothing else all year:

  • Leaders: essentially exact. Alpha 0.52 observed against 0.50 theoretical; Bravo 0.53 against 0.48.
  • Followers: over-predicted by about 60%. Charlie 0.19 observed against 0.32 theoretical; Delta 0.19 against 0.30; Other 0.13 against 0.30.

The model thinks small brands should have a proportionally loyal core. They do not.

7 Diagnosis: which assumptions actually fail here

Four independent measurements now point the same way for the small brands. Each is a different quantity, and all four are consistent with one story:

Measure Observed Theoretical Direction
penetration \(b\) (Charlie) 0.16 0.14 more buyers than predicted
purchase rate \(w\) (Charlie) 2.3 2.7 who buy it less often
category rate \(w_P\) (Charlie) 5.2 5.0 and buy the category more
sole loyalty (Charlie) 0.19 0.32 and are far less exclusive

That is a coherent description: the small brands are a repertoire add-on for heavy category buyers, not a proportionally scaled-down version of a leader. Meanwhile the two leaders behave like near-monogamous alternatives — about half of each one’s buyers bought nothing else all year, and they share 4.5 points fewer buyers than their sizes imply.

7.1 Ruling things out first

Before blaming a modelling assumption it is worth checking the cheap explanations.

def year_summary(y):
    d = df[(df.week > (y - 1) * 52) & (df.week <= y * 52)]
    o = d[["panel_id", "trans_id", "brand"]].drop_duplicates()
    c = d[["panel_id", "trans_id"]].drop_duplicates()
    g = o.groupby("brand")
    return pd.DataFrame({"share": (g.size() / g.size().sum()).loc[brand_name],
                         "pen": (g.panel_id.nunique() / PANEL_SIZE).loc[brand_name]}), \
           c.panel_id.nunique() / PANEL_SIZE

y1, cp1 = year_summary(1)
y2, cp2 = year_summary(2)
display_markdown(f"Category penetration: year 1 **{cp1:.3f}**, year 2 **{cp2:.3f}**. "
                 f"Largest share change **{(y2.share - y1.share).abs().max():.3f}**.",
                 raw=True)
pd.concat({"year 1": y1, "year 2": y2}, axis=1).round(3)

Category penetration: year 1 0.911, year 2 0.912. Largest share change 0.018.

year 1 year 2
share pen share pen
brand
Alpha 0.44 0.52 0.46 0.55
Bravo 0.40 0.51 0.41 0.50
Charlie 0.09 0.16 0.08 0.14
Delta 0.04 0.08 0.04 0.07
Other 0.02 0.04 0.02 0.04

Non-stationarity is ruled out. Category penetration is identical across the two years (0.911 → 0.912) and no brand’s share moves more than 1.8 points. Whatever is wrong, it is not that the market is in motion. (Charlie drifts down 9.2% → 7.7%, which is the largest movement and worth noting, but far too small to explain a 60% miss on sole loyalty.)

The multi-brand-trip wrinkle is too small. 2.2% of occasions allocate to more than one brand, violating \(\sum_j r_j = n\). Real, but an order of magnitude smaller than the effects above.

7.2 The assumption that fails: A2

Mapping onto the five assumptions:

Assumption Verdict here
B1 Poisson category incidence Plausible. Frequently-bought grocery, 4 purchases/year, no seasonality or stockpiling visible in the annual aggregates.
B2 Gamma-distributed rates Plausible. \(K=2.6\) reproduces observed non-buyers exactly by construction; the category frequency distribution is well-behaved.
A1 Multinomial brand choice Mostly holds, apart from the 2.2% of multi-brand trips.
A2 Dirichlet-distributed choice vectors Fails. This is the one.
C Rate ⟂ choice Partly implicated — small brands are disproportionately bought by heavy category buyers, which is a mild dependence between \(\mu\) and \(\mathbf p\).

The Dirichlet is the unique distribution expressing “independence except for the constraint \(\sum_j p_j = 1\). Fitting one says the category is unsegmented: your propensity for Alpha carries no information about how you split the remainder.

Here it plainly does. The population looks closer to a two-camp mixture — a mass of households with \(\mathbf p\) concentrated on Alpha, another concentrated on Bravo, with the small brands picked up occasionally by heavy buyers from either camp. A single Dirichlet cannot represent that: to reproduce the leaders’ high sole loyalty it must drive \(S\) down (we got \(S=0.62\)), but a low \(S\) then predicts polarised loyalty for every brand, including the followers — which is exactly the over-prediction we see.

This shows up in the fit before duplication is ever computed. The per-brand \(S_j\) run 0.50, 0.68, 1.17, 1.11, 0.87 — ordered so that the leaders want a low \(S\) and the followers a high one. In the toothpaste example the \(S_j\) scatter is just as wide but unordered with respect to share. Systematic ordering of \(S_j\) by brand size is the early warning; the duplication residuals are the confirmation.

Terminology worth getting right. This is not “partitioning” in the usual sense of decaf-vs-regular or diet-vs-sugar, where a product attribute splits the category and within-partition duplication runs high. Here the leaders duplicate low. What is segmented is the consumer base, not the product space — two loyal camps rather than two sub-markets. The distinction matters for what you would do about it.

7.3 Cross-checking B1/B2 — is the incidence layer really innocent?

The verdict above rests on the NBD layer being well specified. That is worth testing rather than asserting, and there is a cheap check: fit \(K\) two independent ways. If the gamma-mixed Poisson is right they should agree.

n_by_hh = cat_occ.groupby("panel_id").size()
full = np.concatenate([n_by_hh.values, np.zeros(PANEL_SIZE - len(n_by_hh))])

K_moments = full.mean() ** 2 / (full.var(ddof=1) - full.mean())

display_markdown(
    f"| estimator | $K$ |\n|---|---|\n"
    f"| mean-and-zeros (used above) | {K:.4f} |\n"
    f"| method of moments | {K_moments:.4f} |\n\n"
    f"disagreement **{abs(K - K_moments) / K * 100:.0f}%** — "
    f"observed variance {full.var(ddof=1):.2f} vs NBD-implied "
    f"{full.mean() * (1 + full.mean() / K):.2f}",
    raw=True,
)
estimator \(K\)
mean-and-zeros (used above) 2.6031
method of moments 2.5858

disagreement 1% — observed variance 10.14 vs NBD-implied 10.10

They agree to 1%. Two estimators using completely different features of the data — one the proportion of non-buyers, the other the variance — land in the same place. The incidence half of the model is fine. Whatever is wrong is on the brand-choice side, which is what the rest of the diagnosis says.

7.4 Where the Dirichlet breaks in general

Everything above is a claim about this data, tested against it. The list below is the broader catalogue of known failure modes, mapped onto the assumptions. None of these are diagnosed here — they are the checklist to run against the next category.

B1 — non-Poisson incidence. Strongly seasonal or episodic categories (sun care, antifreeze, Christmas goods); long-interpurchase-cycle durables (cars, appliances, mattresses), where replacement timing is a hazard process rather than a constant rate and you cannot observe enough repeat events anyway; categories with stockpiling, purchase acceleration or genuine satiation; and any analysis window short relative to the interpurchase interval — Ehrenberg’s “very short periods” boundary.

A1 — constrained choice sets. The model assumes every consumer could have chosen any brand. In practice incomplete distribution is the big one: a brand on shelf in 40% of stores is not being rejected by the other 60%, it is not on offer. This shows up as under-predicted penetration and is the standard explanation for deviations from Double Jeopardy. Same mechanism for regional brands, retailer-exclusive lines, and category-level restrictions (pet food for non-pet-owners, alcohol for abstainers).

The usual remedy is to fit to the category-user base rather than the population. Worth knowing what that costs:

M_buyers = n_by_hh.mean()
K_buyers = M_buyers ** 2 / (n_by_hh.var(ddof=1) - M_buyers)

display_markdown(
    f"| population | $B$ | $M$ | $K$ | mean-and-zeros usable? |\n|---|---|---|---|---|\n"
    f"| whole panel ({PANEL_SIZE:,}) | {cat_pen:.3f} | {M0:.3f} | {K:.3f} | yes |\n"
    f"| category buyers ({len(n_by_hh):,}) | 1.000 | {M_buyers:.3f} | {K_buyers:.3f} "
    f"| **no** — $\\log(1-B)=\\log 0$ |",
    raw=True,
)
population \(B\) \(M\) \(K\) mean-and-zeros usable?
whole panel (5,021) 0.911 3.989 2.603 yes
category buyers (4,574) 1.000 4.379 3.798 no\(\log(1-B)=\log 0\)

Two consequences, both easy to trip over. Restricting to category buyers sets \(B=1\), which makes “mean and zeros” undefined — you must fall back to the method of moments. And \(K\) moves from 2.60 to 3.80, a 46% change, purely from redefining who counts as a potential buyer. Here the choice barely matters for the brand-level conclusions because penetration is already 91%; in a category bought by 20% of households it would dominate everything.

Contractual and subscription settings. Banking, insurance, telecoms, SaaS. Switching is discrete and costly, so repertoire buying is replaced by monogamy plus churn, and loyalty is structurally rather than stochastically high. This is the contractual column of the Fader–Hardie taxonomy, and it is the wrong tool entirely — not a misfit but a category error.

Small-\(N\), heavily concentrated B2B. Gamma heterogeneity across a handful of large accounts is a poor description, and \(S\) estimation becomes unstable — recall that \(S\) here is already a share-weighted average over per-brand estimates with outliers dropped, which needs enough brands to be meaningful.

7.5 What kind of product does this imply?

Reading the parameters as Ehrenberg would, as descriptors rather than fitted noise:

  • \(K = 2.6\) (vs 0.78 for toothpaste) — a near-universal staple. 91% of households buy it, and they differ relatively little in how often.
  • \(S = 0.62\) (vs 1.30) — polarised choice. Two-thirds of category buyers bought one brand all year.
  • Effectively a duopoly with a fringe: 85% share in two brands that do not share buyers, plus three small brands functioning as occasional alternates.

Categories that behave this way tend to be ones where households settle on a staple and repurchase it without much deliberation, and where the two leaders are close functional substitutes differentiated by something habitual — taste, format, or household convention — rather than by occasion. The Dirichlet’s home ground is the opposite: a repertoire category where buyers rotate across several acceptable brands.

8 Alternatives within the Ehrenberg–Bass tradition

The tradition’s response to a Dirichlet misfit is rarely “use a fancier model”. It is usually to keep the model as the norm and treat the gap as the finding, or to apply a more targeted tool. Options, roughly in order of how well they suit this data:

8.1 Partitioned duplication coefficients — the direct fix here

Ehrenberg’s own remedy for exactly this situation (Repeat-Buying §10.5, Tables 10.8–10.9): abandon a single \(D\) and fit one per pair-type. In his worked example two manufacturer groups gave \(D = 3.5\) and \(2.5\) within, \(1.5\) across.

import itertools

group = {"Alpha": "leader", "Bravo": "leader",
         "Charlie": "follower", "Delta": "follower", "Other": "follower"}
pair_key = lambda i, j: tuple(sorted((group[brand_name[i]], group[brand_name[j]])))

cells = {}
for key in [("leader", "leader"), ("follower", "leader"), ("follower", "follower")]:
    num = den = 0.0
    for i, j in itertools.combinations(range(nbrand), 2):
        if pair_key(i, j) == key:
            num += b_jk[i, j]
            den += b_obs[i] * b_obs[j]
    cells[key] = num / den

D_matrix = np.array([[cells[pair_key(i, j)] if i != j else 1.0
                      for j in range(nbrand)] for i in range(nbrand)])

r_single = (b_jk - D * np.outer(b_obs, b_obs))[off]
r_part = (b_jk - D_matrix * np.outer(b_obs, b_obs))[off]

display_markdown(
    "| coefficient | value |\n|---|---|\n"
    f"| single $D$, all pairs | {D:.3f} |\n"
    f"| $D$ within leaders | {cells[('leader', 'leader')]:.3f} |\n"
    f"| $D$ leader × follower | {cells[('follower', 'leader')]:.3f} |\n"
    f"| $D$ within followers | {cells[('follower', 'follower')]:.3f} |\n\n"
    f"mean abs. residual: single $D$ **{100 * np.abs(r_single).mean():.2f} pp** → "
    f"partitioned **{100 * np.abs(r_part).mean():.2f} pp** "
    f"(**{100 * (1 - np.abs(r_part).mean() / np.abs(r_single).mean()):.0f}%** reduction)",
    raw=True,
)
coefficient value
single \(D\), all pairs 0.837
\(D\) within leaders 0.669
\(D\) leader × follower 0.950
\(D\) within followers 1.471

mean abs. residual: single \(D\) 1.02 pp → partitioned 0.40 pp (61% reduction)

Three numbers replace one and cut the mean absolute residual by 61%. They are also directly interpretable: the leaders avoid each other (\(D=0.67\)), leader-and-follower are near-independent (\(D=0.95\)), and the small brands cluster (\(D=1.47\) — heavy category buyers collecting alternates).

8.2 Other options, and when they apply

Situation Tool Source
Non-stationarity Conditional Trend Analysis — split buyers by prior-period purchase level and locate where the change came from, rather than fitting a dynamic model Goodhardt & Ehrenberg (1967), JMR 4, 155–161; Repeat-Buying §7.6
Non-stationarity, single brand NBD alone for the focal brand — single-brand predictions are unaffected by other brands moving Repeat-Buying §4.10
Any misfit Dirichlet-as-benchmark — fit stationary norms, report the gap as the result 1984 paper §3.1
Category incidence fits badly Empirical Dirichlet — substitute the observed \(P_n\) for the fitted NBD. Better base-period fit, but you lose time extrapolation 1984 paper §2.4
Repeat-buying across periods NBD/LSD — one-parameter formulae in \(q\) where the full Dirichlet has no closed form Repeat-Buying Ch. 8
Just need brand-vs-not Beta-binomial conditional on category purchases — cheaper and often sufficient Chatfield & Goodhardt (1970), Appl. Statist. 19, 240–250
Heavy-buyer tail understated Poisson-generalised inverse Gaussian — heavier-tailed than the gamma mixture Sichel (1982), Appl. Statist. 31, 193–204
Buying more regular than Poisson Erlang interpurchase times Chatfield & Goodhardt (1973), JASA 68, 828–835
Nested choice (brand→flavour, brand→pack, brand→store) Hierarchical Dirichlet — fit within partition, then across Sketched in 1984 paper §5.3; store-choice in Kau & Ehrenberg (1984)

For long-cycle durables, contractual and B2B categories, the tradition largely abandons the compound-Poisson machinery and works with the empirical generalisations directly — Double Jeopardy, Natural Monopoly, Duplication of Purchase, the Law of Buyer Moderation (heavy buyers regress toward the mean in the next period, light buyers upward), Pareto Share (the top 20% of buyers account for roughly 50–60% of volume, not 80%) — validated on those categories rather than modelled through the Dirichlet. That evidence sits in How Brands Grow Part 2 (Sharp & Romaniuk 2016) rather than in the 1984 paper.

Note what that implies methodologically: the laws outlive the model. This data set is a small instance of the same thing — the Dirichlet misfits, but Double Jeopardy holds in the observed columns regardless. Ehrenberg’s order of business was always empirical generalisation first, model second.

8.3 Adjacency to the rest of this repository

The Pareto/NBD and BG/NBD family in models/purchasing/ is the same Poisson-gamma skeleton with the brand-choice layer removed and a dropout process bolted on. The Dirichlet asks “which of several brands, in a stationary market”; BTYD asks “is this customer still alive, at one firm”.

The bridge is Assumption C. The Dirichlet insists purchase rate and brand preference are independent; a customer-base view is precisely where you would relax it. This data set gives a concrete reason to want to — the small brands are disproportionately bought by heavy category buyers, which is a \(\mu\)\(\mathbf p\) dependence that C forbids.

9 Time extrapolation

Only \(M\) scales; \(K\) and \(S\) are structural.

yr2 = buy_table(M0 * 2, K, S, brand_share, brand_name)
pd.concat({"year 1": buy, "years 1-2": yr2}, axis=1).round(2)
year 1 years 1-2
pen.brand pur.brand pur.cat pen.brand pur.brand pur.cat
Alpha 0.54 3.27 4.75 0.64 5.54 8.75
Bravo 0.50 3.20 4.78 0.60 5.38 8.79
Charlie 0.14 2.70 5.01 0.17 4.25 9.19
Delta 0.06 2.63 5.05 0.08 4.09 9.26
Other 0.03 2.60 5.07 0.04 4.02 9.29
Code
tseq = np.arange(0, 2.01, 0.1)
pen_g = np.zeros((nbrand, len(tseq)))
buy_g = np.ones((nbrand, len(tseq)))
for i, t in enumerate(tseq):
    if t == 0:
        continue
    tbl = buy_table(M0 * t, K, S, brand_share, brand_name)
    pen_g[:, i] = tbl["pen.brand"].values
    buy_g[:, i] = tbl["pur.brand"].values

fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))
colors = plt.cm.viridis(np.linspace(0, 0.85, nbrand))
for j in range(nbrand):
    axes[0].plot(tseq, pen_g[j], color=colors[j], lw=1.8, label=brand_name[j])
    axes[1].plot(tseq, buy_g[j], color=colors[j], lw=1.8)

# observed year-1 penetration, for reference
axes[0].scatter(np.ones(nbrand), obs["b (O)"].values, color=colors,
                zorder=5, s=28, marker="D", edgecolor="white", linewidth=0.8)

axes[0].set(xlabel="Years", ylabel="Penetration",
            title="Penetration growth (♦ = observed year 1)")
axes[1].set(xlabel="Years", ylabel="Purchases per buyer", title="Buying-rate growth")
for ax in axes:
    ax.grid(alpha=0.25, lw=0.5)
axes[0].legend(fontsize=8, frameon=False, loc="upper left")
fig.tight_layout()
plt.show()

Theoretical penetration and buying-rate growth over two years.

Penetration grows less than pro rata; the extra buyers in a longer window are lighter buyers, and \(w\) absorbs the rest. The observed year-1 markers sit close to the curves for Alpha and Bravo and above them for the small brands — the same under-prediction seen in the O-vs-T table, now visible as a vertical gap.

10 What this application shows

Running the model on a category it was not chosen to flatter is more instructive than another clean reproduction:

  1. The parameters are readable. \(K = 2.6\) (vs 0.78) says a near-universally bought category; \(S = 0.62\) (vs 1.30) says a polarised one. Both match what the descriptive analysis found independently.
  2. Double Jeopardy survives. \(w(1-b)\) is far flatter than \(b\) or \(w\) across a twenty-fold share range, in the observed data as well as the fit. The empirical generalisation outlives the model that predicts it — which is Ehrenberg’s whole methodological point.
  3. The misfit is a finding, not a failure. Four independent measures agree that the small brands are repertoire add-ons rather than scaled-down leaders, and that Alpha and Bravo are near-mutually-exclusive. Assumption A2 is what breaks: the consumer base is segmented into two camps, which no single Dirichlet can represent.
  4. Measure duplication on the symmetric scale. Against the Dirichlet’s own \(b_{j\mid k}\) the anomaly looked broad; against \(D\,b_j b_k\) with observed penetrations it collapses to one dominant residual. Choosing the homoscedastic form changed the conclusion.
  5. nstar is not a set-and-forget default. The package ships 50; this category needs ~170. The check is one line and belongs in any application.

The caveats from the main essay all still apply — stationarity, short periods, the heavy-buyer tail, the instability of \(S\) across period lengths. To those, this data set adds the multi-brand-trip wrinkle: 2.2% of occasions here allocate to more than one brand, which the model’s \(\sum_j r_j = n\) arithmetic does not admit.

The constructive close is that the tradition already has the tool for this: three partitioned duplication coefficients cut the residuals by 61% and say something usable about the market — the leaders avoid each other, the followers cluster. That is a better outcome than a better-fitting black box.