Dynamic Pricing Across Regions
Estimating price elasticity of demand — how much demand changes when you change the price — is one of the highest-value applications of causal inference in business. Get it right, and you can optimize revenue. Get it wrong, and you either leave money on the table (prices too low) or destroy demand (prices too high).
The problem is that price and demand are confounded by everything. Managers set prices in response to the same factors that drive demand: seasonality, competitor actions, inventory levels, promotional calendars. A naive regression of demand on price conflates the causal effect of price with these common causes — and almost always gets the sign wrong, showing that higher prices cause higher demand.
This example uses pathmc’s panel mode to estimate the true price elasticity from observational data, accounting for confounding, lagged responses, and regional heterogeneity.
Why naive regression fails
Consider a simple thought experiment. A retailer raises prices during the holiday season — when demand is also naturally high. A regression of demand on price sees high prices paired with high demand and concludes that raising prices increases demand.
The fix is to model the causal structure: season causes both price and demand. Once we condition on season (and other confounders), the coefficient on price is the causal effect. In the simulation below that effect is −1.2, and the fitted model is checked against that planted value rather than assumed to have recovered it.
This is not hypothetical. In many real-world datasets, a regression of demand ~ price produces a positive coefficient — implying that raising prices increases demand. This happens because prices are set endogenously: managers raise prices when they expect demand to be high.
The solution is not to abandon regression but to model the confounding structure explicitly. pathmc’s DAG-based approach makes this transparent.
The full model
In practice, price effects are not instantaneous. A price increase today may take a week or more to fully affect demand — consumers have purchase cycles, comparison-shopping habits, and switching costs. We capture this with lagged price effects in a panel model.
The model for region g at week t:
\text{demand}_{g,t} = \alpha_g + (\beta_{\text{price}} + u_{g,\text{price}}) \cdot \text{price}_{g,t} + \beta_{\text{lag}} \cdot \text{lag(price)}_{g,t} + \beta_{\text{comp}} \cdot \text{competitor}_{g,t} + \beta_{\text{season}} \cdot \text{season}_{g,t} + \beta_{\text{trend}} \cdot t + \varepsilon_{g,t}
where \alpha_g is a region-specific intercept and u_{g,\text{price}} is a region-specific deviation in price sensitivity, both partially pooled toward population means.
Simulate panel data
We simulate 5 retail regions over 40 weeks, with known price elasticities that vary by region.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import arviz as az
import pymc as pm
import pathmc
FIG_WIDTH = 8
FIG_HEIGHT = 4
COLOR_PRICE = "#e6550d"
COLOR_DEMAND = "#2171b5"
COLOR_COMPETITOR = "#636363"
COLOR_BASELINE = "#969696"
rng = np.random.default_rng(42)
regions = ["Metro", "Suburban", "Rural", "Coastal", "College"]
n_weeks = 40
true_mu_price = -1.2
true_sigma_price = 0.3
true_lag = -0.4
true_competitor = -0.3
true_season_demand = 3.0
true_season_price = 0.5
true_trend = 0.05
true_price_effects = {r: rng.normal(true_mu_price, true_sigma_price) for r in regions}
true_intercepts = {
"Metro": 100,
"Suburban": 85,
"Rural": 70,
"Coastal": 95,
"College": 80,
}
rows = []
for region in regions:
prev_price = 10.0
for week in range(1, n_weeks + 1):
season = np.sin(2 * np.pi * week / 52)
competitor_price = 10 + rng.normal(scale=1.0) + 0.5 * season
price = (
10.0
+ true_season_price * season
+ 0.3 * (competitor_price - 10)
+ rng.normal(scale=0.8)
)
demand = (
true_intercepts[region]
+ true_price_effects[region] * price
+ true_lag * prev_price
+ true_competitor * competitor_price
+ true_season_demand * season
+ true_trend * week
+ rng.normal(scale=2.0)
)
rows.append({
"region": region,
"week": week,
"price": price,
"demand": demand,
"competitor_price": competitor_price,
"season": season,
"trend": week,
})
prev_price = price
df_raw = pd.DataFrame(rows)
print(f"Panel: {len(regions)} regions × {n_weeks} weeks = {len(df_raw)} rows")
df_raw.head()Panel: 5 regions × 40 weeks = 200 rows
| region | week | price | demand | competitor_price | season | trend | |
|---|---|---|---|---|---|---|---|
| 0 | Metro | 1 | 9.789967 | 82.298689 | 8.758089 | 0.120537 | 1 |
| 1 | Metro | 2 | 9.468080 | 85.133729 | 10.102857 | 0.239316 | 2 |
| 2 | Metro | 3 | 10.516655 | 84.735932 | 10.955094 | 0.354605 | 3 |
| 3 | Metro | 4 | 9.754889 | 84.100925 | 10.699871 | 0.464723 | 4 |
| 4 | Metro | 5 | 10.784338 | 83.199488 | 9.325150 | 0.568065 | 5 |
true_effects_df = pd.DataFrame({
"region": regions,
"true_price_elasticity": [true_price_effects[r] for r in regions],
})
true_effects_df| region | true_price_elasticity | |
|---|---|---|
| 0 | Metro | -1.108585 |
| 1 | Suburban | -1.511995 |
| 2 | Rural | -0.974865 |
| 3 | Coastal | -0.917831 |
| 4 | College | -1.785311 |
Visualise the data
Code
fig, axes = plt.subplots(1, 2, figsize=(FIG_WIDTH, FIG_HEIGHT))
REGION_COLORS = {
"Metro": "#2171b5",
"Suburban": "#e6550d",
"Rural": "#31a354",
"Coastal": "#756bb1",
"College": "#e7298a",
}
ax = axes[0]
for region in regions:
d = df_raw[df_raw["region"] == region]
ax.scatter(
d["price"],
d["demand"],
s=12,
alpha=0.5,
color=REGION_COLORS[region],
label=region,
)
ax.set_xlabel("Price")
ax.set_ylabel("Demand")
ax.legend(fontsize=7)
ax = axes[1]
for region in regions:
d = df_raw[df_raw["region"] == region]
ax.plot(
d["week"], d["demand"], color=REGION_COLORS[region], alpha=0.7, label=region
)
ax.set_xlabel("Week")
ax.set_ylabel("Demand")
ax.legend(fontsize=7, ncol=2)
plt.tight_layout()
plt.show()
The left panel is the confounded scatter: the fitted slope is negative, but much smaller than the causal elasticity. The right panel shows the seasonal pattern and the differences across regions.
The naive regression
A naive regression of demand on price ignores the DAG. Compare its slope with the planted elasticity of −1.2.
from numpy.linalg import lstsq
X_naive = np.column_stack([np.ones(len(df_raw)), df_raw["price"].values])
y = df_raw["demand"].values
coefs_naive = lstsq(X_naive, y, rcond=None)[0]
ols_slope = float(coefs_naive[1])
print(f"Naive regression: demand = {coefs_naive[0]:.1f} + {coefs_naive[1]:.2f} × price")
print(f"Price coefficient: {coefs_naive[1]:+.2f}")
print(f"True mean price effect: {true_mu_price:+.2f}")
print(f"Naive minus truth: {coefs_naive[1] - true_mu_price:+.2f}")Naive regression: demand = 72.8 + -0.51 × price
Price coefficient: -0.51
True mean price effect: -1.20
Naive minus truth: +0.69
In this sample the OLS slope is -0.51. The planted mean elasticity is −1.2. The sign matches and the magnitude is about half, so a price increase still looks less damaging than it is. Stronger confounding can push that slope through zero — that is the trap in the callout above — but this notebook’s regression does not.
Specify and fit the model
The spec includes the lagged price, confounders, and trend. We use random slopes on price to let each region have its own price elasticity, partially pooled toward a population mean. pooling={"intercept": True} already drops the fixed intercept and uses the regional random intercept instead. The formula starts with 0 + so equations() does not print a phantom 1 + intercept that is not in the design matrix.
spec = """
demand ~ 0 + b_price*price + b_lag*lag(price) + b_comp*competitor_price + b_season*season + trend
"""
model = pathmc.model(
spec,
data=df_raw,
panel={"unit": "region", "time": "week"},
pooling={"intercept": True, "slopes": ["price"]},
)model.graph()model.equations()\begin{aligned} \beta_{demand} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{demand} &\sim \text{HalfNormal}(sigma=1) \\ \mu_{alpha,demand} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{alpha,demand} &\sim \text{HalfNormal}(sigma=1) \\ \alpha_{demand} &\sim \text{Normal}(mu\_alpha,\, sigma\_alpha) \\ \mu_{slope,demand,price} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{slope,demand,price} &\sim \text{HalfNormal}(sigma=1) \\ slope_{demand,price} &\sim \text{Normal}(mu\_slope,\, sigma\_slope) \\[6pt] \mu_{demand} &= b_{price} \cdot \mathrm{price} \\ &\quad + b_{lag} \cdot \mathrm{lag(price)} \\ &\quad + b_{comp} \cdot \mathrm{competitor\_price} \\ &\quad + b_{season} \cdot \mathrm{season} \\ &\quad + \mathrm{trend} \\ \mathrm{demand} &\sim \text{Normal}(\mu_{demand},\, \sigma_{demand}) \end{aligned}
PyMC model graph
pm.model_to_graphviz(model.pymc_model)Sample
This fit pins nuts_sampler="nutpie" and a shorter run, which is the sampler that produced the published sample. Unit intercepts and slopes are sampled as a unit-normal offset, so the summary below leads with the free *_offset rows. Those offset rows are not the centered slopes; the centered slopes are Deterministics and this truncation omits them.
idata = model.fit(
draws=500,
tune=500,
chains=4,
nuts_sampler="nutpie",
random_seed=42,
)NUTS[nutpie]: [slope_demand_price_offset, sigma_slope_demand_price, mu_slope_demand_price, alpha_demand_offset, sigma_alpha_demand, mu_alpha_demand, beta_demand, sigma_demand]
Results
Population-level coefficients
The table below leads with the free slope_demand_price_offset rows. Those are not the centered slopes.
model.summary()| mean | sd | eti89_lb | eti89_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| slope_demand_price_offset[Coastal] | 0.933235 | 0.586540 | 0.017708 | 1.874914 | 1086.696105 | 1307.552848 | 1.002882 | 0.017748 | 0.012630 |
| slope_demand_price_offset[College] | -1.052717 | 0.588830 | -2.024564 | -0.149374 | 1006.919462 | 1294.945665 | 1.001900 | 0.018531 | 0.013427 |
| slope_demand_price_offset[Metro] | 0.907409 | 0.584902 | -0.015822 | 1.874205 | 1128.445298 | 1375.507883 | 1.001317 | 0.017408 | 0.012246 |
| slope_demand_price_offset[Rural] | -0.578311 | 0.559835 | -1.445204 | 0.311509 | 1053.782254 | 1481.817561 | 1.000821 | 0.017265 | 0.011720 |
| slope_demand_price_offset[Suburban] | -0.301357 | 0.535111 | -1.164065 | 0.556225 | 1101.562656 | 1292.236747 | 0.998929 | 0.016095 | 0.011146 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| alpha_demand[Coastal] | 86.590379 | 3.387959 | 81.032032 | 92.001941 | 2421.269745 | 1679.836849 | 1.002590 | 0.069052 | 0.047880 |
| alpha_demand[College] | 79.015688 | 3.226369 | 73.967593 | 84.191117 | 1856.832989 | 1532.910541 | 0.999622 | 0.074907 | 0.051578 |
| alpha_demand[Metro] | 90.133471 | 3.981123 | 83.782045 | 96.555075 | 2059.112479 | 1623.201317 | 0.999777 | 0.087721 | 0.060008 |
| alpha_demand[Rural] | 73.997993 | 3.585547 | 68.299802 | 79.846101 | 1983.489291 | 1641.091519 | 1.000414 | 0.080409 | 0.058009 |
| alpha_demand[Suburban] | 81.192213 | 3.179921 | 76.038363 | 86.157223 | 1899.794078 | 1448.983238 | 0.999758 | 0.073475 | 0.051866 |
230 rows × 9 columns
The fixed coefficient on price has posterior mean -0.67, not −1.2. The lag (-0.41 versus −0.4) and the competitor effect (-0.28 versus −0.3) land close to the planted values. The hierarchical mean mu_slope_demand_price is -0.23 with a standard deviation of 5.9 and an r-hat of 1.00, so the price elasticity is not identified tightly enough to use in this sample.
Recovery of key parameters
beta_price = (
idata.posterior["beta_demand"].sel(demand_predictors="price").values.flatten()
)
beta_lag = (
idata.posterior["beta_demand"].sel(demand_predictors="lag(price)").values.flatten()
)
beta_comp = (
idata
.posterior["beta_demand"]
.sel(demand_predictors="competitor_price")
.values.flatten()
)
print(
f"Price effect: posterior mean = {beta_price.mean():.2f} (true = {true_mu_price})"
)
print(f"Lagged price: posterior mean = {beta_lag.mean():.2f} (true = {true_lag})")
print(
f"Competitor effect: posterior mean = {beta_comp.mean():.2f} (true = {true_competitor})"
)Price effect: posterior mean = -0.67 (true = -1.2)
Lagged price: posterior mean = -0.41 (true = -0.4)
Competitor effect: posterior mean = -0.28 (true = -0.3)
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
x_kde, y_kde, _ = az.kde(beta_price)
ax.plot(x_kde, y_kde, color=COLOR_PRICE, lw=2, label="Posterior (path model)")
ax.fill_between(x_kde, y_kde, alpha=0.3, color=COLOR_PRICE)
ax.axvline(
true_mu_price, color="black", ls="--", lw=1.5, label=f"True ({true_mu_price})"
)
ax.axvline(
coefs_naive[1],
color="red",
ls="--",
lw=1.5,
alpha=0.7,
label=f"Naive regression ({coefs_naive[1]:+.2f})",
)
ax.axvline(0, color="black", ls=":", alpha=0.3)
ax.set_xlabel("Price coefficient (units of demand per unit price)")
ax.set_ylabel("Density")
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
The fixed price coefficient mean is -0.67, not the planted −1.2. The naive slope is -0.51.
Cumulative price effect
The long-run effect of a permanent price change is the immediate coefficient plus the lag.
cumulative = beta_price + beta_lag
cumulative_mean = float(cumulative.mean())
print(f"Immediate effect: {beta_price.mean():.2f} units of demand")
print(f"Lagged effect: {beta_lag.mean():.2f} units of demand")
print(f"Cumulative (long-run) effect: {cumulative.mean():.2f} units of demand")
print(f"True cumulative: {true_mu_price + true_lag:.2f}")Immediate effect: -0.67 units of demand
Lagged effect: -0.41 units of demand
Cumulative (long-run) effect: -1.08 units of demand
True cumulative: -1.60
The printed long-run effect is -1.08, against a planted −1.60, because the immediate coefficient (-0.67) did not recover. The lag term on its own is -0.41, against a planted −0.4.
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
for draws, color, label in [
(beta_price, COLOR_PRICE, "Immediate (current week)"),
(beta_lag, COLOR_BASELINE, "Lagged (next week)"),
(cumulative, COLOR_DEMAND, "Cumulative (long-run)"),
]:
x_kde, y_kde, _ = az.kde(draws)
ax.plot(x_kde, y_kde, color=color, lw=2, label=label)
ax.fill_between(x_kde, y_kde, alpha=0.2, color=color)
ax.axvline(draws.mean(), color=color, ls="--", alpha=0.7, lw=1)
ax.axvline(0, color="black", ls=":", alpha=0.3)
ax.set_xlabel("Demand change per $1 price increase")
ax.set_ylabel("Density")
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
Regional heterogeneity: who is price-sensitive?
The random slopes on price let each region deviate from the population mean. The violins are the sum of the fixed price coefficient and the region slope. They are wide, so they are not a ranking of who should be priced differently.
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT * 1.2))
slope_price = idata.posterior["slope_demand_price"]
for i, region in enumerate(regions):
region_slope = slope_price.sel(unit=region).values.flatten()
region_total = beta_price + region_slope
parts = ax.violinplot([region_total], positions=[i], showmedians=True, widths=0.7)
for pc in parts["bodies"]:
pc.set_facecolor(REGION_COLORS[region])
pc.set_alpha(0.4)
for key in ["cmins", "cmaxes", "cbars", "cmedians"]:
if key in parts:
parts[key].set_color(REGION_COLORS[region])
ax.plot(i, true_price_effects[region], "D", color="black", ms=6, zorder=5)
ax.set_xticks(range(len(regions)))
ax.set_xticklabels(regions)
ax.set_ylabel("Price elasticity (demand per $1)")
ax.axhline(
true_mu_price,
color=COLOR_PRICE,
ls="--",
alpha=0.5,
label=f"True pop. mean ({true_mu_price})",
)
ax.axhline(0, color="black", ls=":", alpha=0.3)
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
region_post_means = {}
n_regions_hdi_cross_zero = 0
for region in regions:
region_total = beta_price + slope_price.sel(unit=region).values.flatten()
region_post_means[region] = float(region_total.mean())
region_hdi = np.asarray(az.hdi(region_total, prob=0.94)).reshape(-1)
if region_hdi[0] < 0 < region_hdi[1]:
n_regions_hdi_cross_zero += 1
cross_zero_note = "cross zero" if n_regions_hdi_cross_zero else "do not cross zero"
region_recovery_gap = {
region: abs(region_post_means[region] - true_price_effects[region])
for region in regions
}
closest_recovery = min(region_recovery_gap, key=region_recovery_gap.get)
region_mean_min = min(region_post_means.values())
region_mean_max = max(region_post_means.values())
The violins are too wide to read as a regional price list. Metro and Coastal posterior means are -0.17 and -0.17, not near their planted elasticities around −1. The 94% intervals cross zero.
Shrinkage
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
true_vals = [true_price_effects[r] for r in regions]
post_means = []
for region in regions:
region_slope = slope_price.sel(unit=region).values.flatten()
region_total = beta_price + region_slope
post_means.append(region_total.mean())
for i, region in enumerate(regions):
ax.scatter(
true_vals[i],
post_means[i],
color=REGION_COLORS[region],
s=80,
zorder=3,
label=region,
)
lims = [
min(min(true_vals), min(post_means)) - 0.2,
max(max(true_vals), max(post_means)) + 0.2,
]
ax.plot(lims, lims, "k:", alpha=0.3, label="Perfect recovery")
ax.axhline(beta_price.mean(), color=COLOR_PRICE, ls="--", alpha=0.3)
ax.set_xlabel("True price elasticity")
ax.set_ylabel("Posterior mean elasticity")
ax.legend(fontsize=7, ncol=2)
ax.set_xlim(lims)
ax.set_ylim(lims)
plt.tight_layout()
plt.show()
College is nearest the diagonal. Rural’s posterior mean is -1.37 against its planted elasticity of -0.97. Metro and Coastal are -0.17 and -0.17, far from their planted values. The points are not pulled toward the planted mean of −1.2.
Hierarchical scale
sigma_price_post = idata.posterior["sigma_slope_demand_price"].values.flatten()
print(f"Between-region SD in price elasticity:")
print(f" Posterior mean: {sigma_price_post.mean():.3f}")
print(f" True value: {true_sigma_price}")
sigma_price_mean = float(sigma_price_post.mean())Between-region SD in price elasticity:
Posterior mean: 0.869
True value: 0.3
The posterior mean is 0.869 against a planted 0.3. That scale was not recovered.
Policy simulation: what if we raise prices?
The do() operator lets us simulate pricing policy changes and predict their effect on demand. With panel mode and simulate_over="time", the simulation propagates forward through time, correctly accounting for lagged effects.
Scenario: $2 price increase for all regions
current_price = float(df_raw["price"].mean())
new_price = current_price + 2.0
r_baseline = model.do(
set={"price": current_price, "competitor_price": 10.0},
simulate_over="time",
kind="mean",
)
r_increase = model.do(
set={"price": new_price, "competitor_price": 10.0},
simulate_over="time",
kind="mean",
)
demand_impact = r_increase - r_baseline
print(f"Scenario: raise price from ${current_price:.1f} to ${new_price:.1f}")
demand_impactScenario: raise price from $10.1 to $12.1
| variable | mean | 94% HDI |
|---|---|---|
| price | 2.00 | [2.00, 2.00] |
| lag(price) | 0.00 | [0.00, 0.00] |
| competitor_price | 0.00 | [0.00, 0.00] |
| season | 0.00 | [0.00, 0.00] |
| trend | 0.00 | [0.00, 0.00] |
| demand | -2.64 | [-3.56, -1.71] |
Revenue trade-off
A price increase raises revenue per unit and reduces units sold.
Code
price_levels = np.arange(8.0, 14.1, 0.5)
demand_draws_list = []
r_ref = model.do(
set={"price": 10.0, "competitor_price": 10.0},
simulate_over="time",
kind="mean",
)
for p in price_levels:
r = model.do(
set={"price": float(p), "competitor_price": 10.0},
simulate_over="time",
kind="mean",
)
demand_draws_list.append(r.draws("demand"))
demand_draws_2d = np.column_stack(demand_draws_list)
demand_means = demand_draws_2d.mean(axis=0)
revenue_draws_2d = price_levels[np.newaxis, :] * demand_draws_2d
revenue_means = price_levels * demand_means
fig, axes = plt.subplots(1, 2, figsize=(FIG_WIDTH, FIG_HEIGHT))
ax = axes[0]
ax.plot(price_levels, demand_means, "o-", color=COLOR_DEMAND, lw=2, ms=5)
hdi_1 = az.hdi(demand_draws_2d, prob=0.94, axis=0)
ax.fill_between(price_levels, hdi_1[:, 0], hdi_1[:, 1], alpha=0.15, color=COLOR_DEMAND)
ax.set_xlabel("Price ($)")
ax.set_ylabel("Expected demand (units)")
ax = axes[1]
ax.plot(price_levels, revenue_means, "s-", color=COLOR_PRICE, lw=2, ms=5)
hdi_2 = az.hdi(revenue_draws_2d, prob=0.94, axis=0)
ax.fill_between(price_levels, hdi_2[:, 0], hdi_2[:, 1], alpha=0.15, color=COLOR_PRICE)
ax.set_xlabel("Price ($)")
ax.set_ylabel("Expected revenue ($)")
ax.axvline(
price_levels[-1],
color=COLOR_PRICE,
ls="--",
alpha=0.5,
label=f"Grid edge: ${price_levels[-1]:.1f}",
)
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
price_obs_min = float(df_raw["price"].min())
price_obs_max = float(df_raw["price"].max())
grid_edge_price = float(price_levels[-1])
first_extrap_price = float(price_levels[price_levels > price_obs_max][0])
demand_drop = float(demand_means[0] - demand_means[-1])
demand_slope = float(
(demand_means[-1] - demand_means[0]) / (price_levels[-1] - price_levels[0])
)
i10 = int(np.argmin(np.abs(price_levels - 10.0)))
price_near_10 = float(price_levels[i10])
demand_near_10 = float(demand_means[i10])
elasticity_at_10 = float(demand_slope * price_near_10 / demand_near_10)
revenue_still_rising = bool(np.argmax(revenue_means) == len(revenue_means) - 1)
revenue_edge_note = (
"still rising at the last grid price"
if revenue_still_rising
else "not still rising at the last grid price"
)/var/folders/r0/nf1kgxsx6zx3rw16xc3wnnzr0000gn/T/ipykernel_70649/3301535645.py:11: UserWarning: Intervention value 12.50 for 'price' is outside the observed data range [7.65, 12.42]. Results are extrapolations and should be interpreted with caution.
r = model.do(
/var/folders/r0/nf1kgxsx6zx3rw16xc3wnnzr0000gn/T/ipykernel_70649/3301535645.py:11: UserWarning: Intervention value 13.00 for 'price' is outside the observed data range [7.65, 12.42]. Results are extrapolations and should be interpreted with caution.
r = model.do(
/var/folders/r0/nf1kgxsx6zx3rw16xc3wnnzr0000gn/T/ipykernel_70649/3301535645.py:11: UserWarning: Intervention value 13.50 for 'price' is outside the observed data range [7.65, 12.42]. Results are extrapolations and should be interpreted with caution.
r = model.do(
/var/folders/r0/nf1kgxsx6zx3rw16xc3wnnzr0000gn/T/ipykernel_70649/3301535645.py:11: UserWarning: Intervention value 14.00 for 'price' is outside the observed data range [7.65, 12.42]. Results are extrapolations and should be interpreted with caution.
r = model.do(
On this grid, demand falls by 8 units from $8 to $14, -1.3 demand units per dollar. Near a price of $10 and demand of 68, that is a dimensionless elasticity of -0.2. The revenue curve is still rising at the last grid price. Prices from $12.50 up are outside the observed range [$7.65, $12.42]. That is not a recovered optimum.
Regional point estimates
The table prints posterior means of the same sums as the violins. Those intervals overlap, and they cross zero. The between-region SD was not recovered (0.869 versus 0.3). Do not treat the printed means as region-specific prices.
print("Demand impact of +$2 price increase by region:")
print(f"{'Region':<12} {'Elasticity (post. mean)':>24} {'Demand Δ (est.)':>16}")
print("-" * 54)
for region in regions:
region_slope = slope_price.sel(unit=region).values.flatten()
region_total = beta_price + region_slope
region_lag_total = region_total + beta_lag
est_demand_change = region_total.mean() * 2
print(f"{region:<12} {region_total.mean():>+24.2f} {est_demand_change:>+16.1f}")Demand impact of +$2 price increase by region:
Region Elasticity (post. mean) Demand Δ (est.)
------------------------------------------------------
Metro -0.17 -0.3
Suburban -1.14 -2.3
Rural -1.37 -2.7
Coastal -0.17 -0.3
College -1.73 -3.5
Identification check
print(f"Is price → demand identifiable? {model.is_identifiable('price', 'demand')}")
print(f"Adjustment sets: {model.adjustment_sets('price', 'demand')}")Is price → demand identifiable? True
Adjustment sets: [set()]
This check is about the formula graph. In that graph price, season, and competitor price are exogenous, so the empty adjustment set does not mean the naive regression demand ~ price was identified. The failed recovery of the price coefficient is a separate fact.
Standardized effects
model.standardized()| predictor | outcome | mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|---|---|
| name | ||||||
| b_price | price | demand | -0.056744 | 0.498530 | -1.171653 | 0.724760 |
| b_comp | competitor_price | demand | -0.023395 | 0.014034 | -0.048886 | 0.003858 |
| b_season | season | demand | 0.169401 | 0.023498 | 0.125676 | 0.212456 |
The standardized coefficients put all predictors on the same scale (SD units), making it easy to compare the relative importance of price vs competitors vs seasonality.
Summary
- The confounded slope is attenuated here. Naive regression gives -0.51 rather than −1.2. The hierarchical price slope does not recover the planted elasticity either. The lag and competitor coefficients do.
- The lag landed; the cumulative effect did not. Immediate mean -0.67, lag -0.41 (planted −0.4), cumulative -1.08 (planted −1.60). This sample’s cumulative mean is not a larger recovered effect.
- Regional point estimates are not a price list. Posterior means range from -1.73 to -0.17. The 94% intervals cross zero, and the between-region SD is 0.869 against a planted 0.3.
- Partial pooling is not shrinkage to −1.2 here. Metro and Coastal posterior means are -0.17 and -0.17. College lies nearest the perfect-recovery line.
- do() propagates lags. The revenue curve is still rising at the last grid price, including prices outside the observed range [$7.65, $12.42], so it is not a recovered optimum.
- Revenue optimization needs a recovered demand curve. This fit did not recover the planted elasticity, so the grid-edge price is not a pricing recommendation.
In your own pricing context, what common causes might confound the price-demand relationship?
- Retail: Do you raise prices on trending products (where demand is already high for other reasons)?
- SaaS: Do you offer discounts to churning customers (who have low engagement for other reasons)?
- Ride-sharing: Do surge prices coincide with events that independently increase ride requests?
- Hospitality: Do you raise room rates during peak seasons when occupancy would be high regardless?
If any of these apply, your historical price-demand data likely overstates the positive association between price and demand — and a naive regression will underestimate (or reverse) the true price elasticity.