Skip to main content

Dynamic Pricing Across Regions

Hierarchical panel check of a planted price elasticity under confounding. This sample does not recover it.
Author

Benjamin Vincent

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.

season season price price season->price demand demand season->demand price->demand causal competitor_price competitor_price competitor_price->price competitor_price->demand
Figure 1: Price-demand confounding DAG. Seasonality and competitor pricing are common causes of both price and demand. Without adjusting for these confounders, the estimated price effect is biased — often appearing positive when the true effect is negative.
WarningThe positive-price-coefficient trap

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.

price price demand demand price->demand  β_price lag(price) lag(price) lag(price)->demand  β_lag competitor_price competitor_price competitor_price->price competitor_price->demand season season season->price season->demand trend trend trend->demand
Figure 2: Full pricing DAG with lagged effects and confounders. Current demand depends on the current price, last week’s price (lagged response), competitor pricing, and seasonality. Each region has its own price sensitivity (random slopes).

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()
Figure 3: Raw price versus demand across all regions. The ordinary-least-squares slope on this scatter is confounded and is not the planted elasticity.

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
ImportantThe naive slope is attenuated, not reversed

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)
Figure 4: PyMC plate diagram for the hierarchical pricing model with random intercepts and random slopes for price sensitivity.

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()
Figure 5: Posterior of the fixed price coefficient. Black dashed line: planted mean elasticity. Red dashed line: naive OLS slope. The posterior is wide and its mean is not the planted value.

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()
Figure 6: Posteriors of the immediate, lagged, and cumulative price coefficients. The cumulative posterior is the sum of the other two; it is not a recovery of the planted long-run effect.

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())
Figure 7: Region-level price elasticity. Each violin is the posterior of the fixed price coefficient plus the region slope; black diamonds are planted values. The dashed line is the planted population mean, not a posterior group mean the estimates have shrunk toward.

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()
Figure 8: True elasticity (x) versus posterior mean (y). The only reference line on the plot is the perfect-recovery diagonal. The points are not pulled toward the planted mean.

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_impact
Scenario: raise price from $10.1 to $12.1
DoResult — 2000 draws, 6 variables
variablemean94% HDI
price2.00[2.00, 2.00]
lag(price)0.00[0.00, 0.00]
competitor_price0.00[0.00, 0.00]
season0.00[0.00, 0.00]
trend0.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(
Figure 9: Left: expected demand falls as price rises. Right: expected revenue versus price. The dashed line is the last price on the grid, not an interior maximum. Do not read a revenue-maximizing label on the line as an optimum.

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.
NoteReflection

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.