Inferring Consumers to Understand Markets with PyMC-Marketing
Data Science @ PandaDoc
and Open Source Contributor @ PyMC
2026-11-15
Who am I?
Link to my website
Code or it didn’t Happen
The companion notebook builds every figure in this talk, from the first integral to the cereal counterfactual: here. The model ships as BayesianBLP in PyMC-Marketing.
How much would sales of Corn Flakes fall if a competitor cut prices by 10%?
Answering needs a demand model, and real markets are full of things nobody observes:
The argument: the Berry–Levinsohn–Pakes model is a stack of integrals over a crowd we never see. Treat the crowd’s composition as a latent variable and the classical computational tricks become a generative model.
Act I: The Crowd Behind the Shares. What aggregate data hide, and why the crowd changes the answer
Act II: Integration All the Way Down. How economists handle the crowd, and the Bayesian alternative
Act III: Building the Crowd in PyMC. A Bayesian BLP from scratch, then the library
Act IV: Counterfactual Markets. Nevo’s cereal, and whether the crowd mattered
Nevo’s (2000) cereal panel: one row per brand, city and quarter.
| brand | share | price | sugar | mushy |
|---|---|---|---|---|
| Kelloggs_4 | 0.0124 | 0.072 | 2 | 1 |
| Kelloggs_6 | 0.0078 | 0.114 | 18 | 1 |
| Kelloggs_7 | 0.0130 | 0.132 | 4 | 1 |
| Kelloggs_9 | 0.0058 | 0.130 | 3 | 0 |
24 brands × 47 cities × 2 quarters. Inside shares sum to about 0.48; the rest buy no cereal.
Never observed
A share is the average decision of a crowd. To answer “what if?”, we need the crowd.
BLP (1995): consumer \(i\) in market \(m\) gets utility from product \(j\)
\[u_{ijm} = \color{teal}{\delta_{jm}} + \color{blue}{\sigma\, \nu_i\, p_{jm}} + \varepsilon_{ijm}, \qquad \color{teal}{\delta_{jm} = \alpha p_{jm} + x_{jm}'\beta + \xi_{jm}}\]
mean utility, shared by everyone + this consumer’s deviation + idiosyncratic shock
and the market share is the fraction of the crowd for whom \(j\) is best:
\[s_{jm} = \int \frac{\exp(\delta_{jm} + \sigma \nu\, p_{jm})}{1 + \sum_k \exp(\delta_{km} + \sigma \nu\, p_{km})}\, dF(\nu)\]
Price sensitivity varies, \(\alpha_i = \alpha + \sigma\nu_i\). Each of 500 synthetic consumers has their own choice probabilities; the share is the crowd’s average. The crowd buys the premium product at 22%; its “average member” would buy it at 9%.
A logit and a crowd fitted to the same shares. In the crowd, premium buyers sit in the insensitive tail and mid-range buyers are their nearest neighbours, so a 10% premium price rise sends 28% of lost customers to mid-range (logit: 15%).
\[\int f(\nu)\, dF(\nu) \approx \frac{1}{R}\sum_{r=1}^{R} f(\nu_r)\]
Each point \(\nu_r\) is one synthetic consumer. Scrambled Halton points spread out on purpose; random draws clump.
On a three-dimensional share integral, a crowd of 8,192 Halton consumers is about 33× more accurate than a random one of the same size.
For every guess of \(\sigma\):
def invert_shares(s_obs, sigma, nu, price):
delta = np.log(s_obs) - np.log(1 - s_obs.sum())
while True:
s_hat = crowd_shares(delta, sigma, nu, price)
step = np.log(s_obs) - np.log(s_hat)
delta = delta + step
if np.abs(step).max() < 1e-12:
return deltaPowerful, but computationally delicate: the inner loop’s tolerance leaks into the outer estimates (Dubé, Fox & Su, 2012), and uncertainty is a delta-method afterthought.
| Classical device | Bayesian counterpart |
|---|---|
| Simulation draws to approximate the share integral | A fixed synthetic crowd: Halton consumers as data |
| Contraction mapping to invert shares for \(\delta\) | No inversion: \(\xi_{jm}\) is sampled as a latent variable |
| GMM moment conditions \(\mathbb{E}[z\,\xi] = 0\) | A joint model of the price equation and \(\xi\) (a control function) |
| Standard errors by the delta method | Posterior integration: every quantity is averaged over the posterior |
. . .
Jiang, Manchanda & Rossi (2009) gave BLP a Bayesian likelihood but still ran the contraction inside every MCMC step. Sampling \(\xi\) directly removes the inner loop, in the spirit of Dubé, Fox & Su’s MPEC: unknowns become parameters, not a fixed point.
Everything so far assumed \(\alpha\) and \(\sigma\) were known. They aren’t.
The licence: shoppers are exchangeable given their market and taste profile, so de Finetti’s theorem says their choices are draws given a latent parameter with a prior. The crowd’s composition is that parameter.
\[\mathbb{E}[\text{elasticity} \mid \text{shares}] = \color{blue}{\int_\theta} \color{teal}{\int_\nu} \color{gray}{\int_\varepsilon} (\cdots)\, \color{gray}{dF(\varepsilon)}\, \color{teal}{dF(\nu)}\, \color{blue}{p(\theta \mid \text{shares})\, d\theta}\]
Gumbel shocks
The softmax is already an integral over idiosyncratic shocks, with a closed form (McFadden, 1974). Free.
The crowd
Halton average over synthetic consumers, inside the model.
The posterior
MCMC average over every crowd consistent with the shares.
Elasticities, diversion ratios and counterfactual shares are all the same nested average, evaluated at different prices.
with pm.Model(coords=coords) as blp_from_scratch:
# The average consumer, and the spread of the crowd
alpha = pm.Normal("alpha", 0, 2)
sigma = pm.HalfNormal("sigma", 1)
beta = pm.Normal("beta", 0, 1, dims="characteristic")
# Unmeasured appeal: product effect + market shocks
xi_j = pm.Normal("xi_j", 0, 0.5, dims="product")
sigma_xi = pm.HalfNormal("sigma_xi", 0.5)
xi = xi_j + sigma_xi * pm.Normal("xi_raw", 0, 1, dims=("market", "product"))
# Mean utility (M, J), then every consumer's utility (M, J, R)
delta = alpha * P + (X * beta).sum(-1) + xi
utility = delta[:, :, None] + sigma * crowd[None, None, :] * P[:, :, None]
# Integrate the logit over the crowd: .mean(axis=-1) IS the integral
u_full = pt.concatenate([utility, pt.zeros((M, 1, R))], axis=1)
shares = pt.special.softmax(u_full, axis=1).mean(axis=-1)
log_ratio = pt.log(shares[:, :J]) - pt.log(shares[:, J])[:, None]
# Berry (1994) log-share-ratio likelihood
pm.Normal("log_share_ratio", mu=log_ratio, sigma=noise,
observed=np.log(S) - np.log(S0)[:, None])\[\hat s_{jm} = \color{teal}{\frac{1}{R}\sum_r} \text{softmax}_j\big(\delta_{jm} + \sigma \color{teal}{\nu_r} p_{jm}\big)\]
The engineering
pm.Data vector of 200 Halton draws: data, not parameters..mean(axis=-1) is the whole share integral.BayesianBLP in PyMC-Marketingmodel = BayesianBLP(
market_data=nevo, # long format: one row per market × product
characteristics=["sugar", "mushy"],
instruments=[f"z{i}" for i in range(6, 11)], # price equation for endogenous prices
random_coef_on=["price", "sugar", "mushy"], # a three-dimensional crowd
region_col="region", time_col="period", n_mc_draws=200,
hierarchical_parameterisation="noncentered", # few markets per city
model_config={"alpha": Prior("Normal", mu=0, sigma=3), # what this data needed
"sigma_xi_j": Prior("HalfNormal", sigma=0.25)},
)
model.fit(nuts_sampler="nutpie", draws=2000, tune=1000, chains=4,
target_accept=0.95, init="adapt_full") # funnel-prone ξ block
elasticities = model.elasticities(at="samples")
cf = model.counterfactual_shares(price_change={"GenMills_19": -0.10})adapt_full and a high target_accept. Fit time: 3.3 minutes, zero divergences.The standard benchmark for BLP: Nevo’s (2000) Practitioner’s Guide.
Nevo’s panel is simulated to resemble the cereal market, and brand names are coded.
What the posterior says about this crowd
(89% intervals.) A real but modest spread. Does it change the answer to the Corn Flakes question?
GenMills_19 cuts its price by 10% and gains about 29% share [25%, 33%]. Kelloggs_11, our largest Kellogg’s brand, loses about 0.7% [0.5%, 0.9%]. Only 4% of the competitor’s gain comes from us; 55% comes from people who weren’t buying cereal at all.
Refit with random_coef_on=[]: the same data, instruments and priors, but one taste for everyone. The answers barely move: GenMills_19 +30.5% vs +29.1%, Kelloggs_11 −0.73% vs −0.69%, and 58% vs 55% of the gain from the outside good. For this question, the posterior says the crowd is doing little work.
“The representation theorem … proves that if observations are judged to be exchangeable, then they must indeed be a random sample from some model and there must exist a prior probability distribution over the parameter of the model, hence requiring a Bayesian approach.”
– José Bernardo, The Concept of Exchangeability and its Applications (1996)
“The crowd is not a numerical trick.
Its shape is what we infer.”
Resources
BayesianBLP and its synthetic and Nevo example notebooksThe Synthetic Crowd | PyCon Ireland 2026