Module 6 · Day 60 of 80

Capstone: The Price Elasticity Engine

About 5 hours
Video lesson coming soon
Day 60 · Capstone: The Price Elasticity Engine

What you'll learn today

  • I computed each cell's break-even elasticity from its margin
  • I estimated every cell's elasticity with two estimators that rest on different assumptions
  • I made "raise", "don't" and "test first" calls that account for DML's narrow intervals
  • I put dollars on the decision and compared it with a blanket rise
  • I wrote a memo whose numbers match the table

Today's plan

Timed blocks for the day. Hover a bar for details.

LessonHands-on projectBreak5:00 total
Written lesson
TimeBlock
0:00 – 0:3060.1 The brief
0:30 – 1:3060.2 Step 1: an elasticity for every cell, two ways
1:30 – 1:45Break
1:45 – 2:4560.3 Step 2: from elasticities to profit
2:45 – 3:00Break
3:00 – 4:2060.4 Step 3: the pricing memo
4:20 – 5:0060.5 Answer key, final commit and Module 6 retrospective

60.1 The brief (30 min)

NorthMart's category directors want to raise prices by 5% to recover rising costs. The CFO asks: which shopper segments, in which categories, can absorb a 5% rise without losing gross profit?

The arithmetic. With gross margin m (as a share of the current price) and elasticity e, a 5% rise:

  • changes units by a factor of 1.05^e (with e = −3, units fall by 14%);
  • raises the profit per unit from m to m + 0.05 (as a share of the old price).

So gross profit changes by 1.05^e × (m + 0.05) / m − 1. Setting it to zero gives each category's break-even elasticity: a fresh category (m = 28%) can absorb a 5% rise only if its elasticity is above −3.37; personal care (m = 35%) only above −2.74. Low-margin categories can absorb more, because the extra 5% is a bigger share of what they earn per unit.

Generate a new year of data:

python data/make_demand_scenario.py
"""
Capstone data for Module 6: a new year of NorthMart weekly demand. Provided.

    python data/make_demand_scenario.py

Writes data/scenario/weekly_demand.csv (what the analyst gets) and data/truth/elasticities.csv.
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from causalkit.demand import demand, public, truth  # noqa: E402

full = demand(seed=60)
root = Path(__file__).parent
(root / "scenario").mkdir(exist_ok=True)
(root / "truth").mkdir(exist_ok=True)
public(full).write_csv(root / "scenario" / "weekly_demand.csv")
truth(full).write_csv(root / "truth" / "elasticities.csv")
print(f"Wrote {full.height:,} store-category-weeks to data/scenario/weekly_demand.csv")

Don't open data/truth/ until your memo is committed.


60.2 Step 1: an elasticity for every cell, two ways (60 min)

Create day60/estimates.py (about a minute):

# day60/estimates.py   (python -m day60.estimates)
"""Capstone step 1: an elasticity for every segment x category cell, two ways (Day 59)."""
import pandas as pd
import polars as pl
import pyfixest as pf

from day58.dml_elasticity import dml_elasticity, prepare

DRIVERS = ["promo", "temperature", "holiday", "local_event", "competitor_index", "week"]
raw = pd.read_csv("data/scenario/weekly_demand.csv")
df = prepare(raw)
rows = []
for (seg, cat), s in df.groupby(["segment", "category"]):
    fit = dml_elasticity(s, drivers=DRIVERS)
    iv = pf.feols("log_units ~ promo | store_id + week | log_price ~ cost_index", s, vcov={"CRV1": "store_id"})
    rows.append({"segment": seg, "category": cat, "margin": s.margin.iloc[0],
                 "annual gross profit ($k)": round((s.price * s.units * s.margin).sum() / 2 / 1000, 1),
                 "DML": round(fit.coef[0], 3), "DML SE": round(fit.se[0], 3),
                 "2SLS": round(iv.coef()["log_price"], 3), "2SLS SE": round(iv.se()["log_price"], 3)})
out = pl.DataFrame(rows)
out.write_csv("data/day60_estimates.csv")
with pl.Config(tbl_rows=20, tbl_width_chars=130):
    print(out)
python -m day60.estimates
│ segment         ┆ category      ┆ margin ┆ annual gross profit ($k) ┆ DML    ┆ DML SE ┆ 2SLS   ┆ 2SLS SE │
│ rural value     ┆ fresh         ┆ 0.28   ┆ 649.6                    ┆ -3.054 ┆ 0.025  ┆ -3.084 ┆ 0.101   │
│ rural value     ┆ household     ┆ 0.3    ┆ 1328.2                   ┆ -3.463 ┆ 0.022  ┆ -3.422 ┆ 0.145   │
...
│ urban premium   ┆ snacks        ┆ 0.3    ┆ 422.0                    ┆ -1.692 ┆ 0.03   ┆ -1.783 ┆ 0.12    │

60.3 Step 2: from elasticities to profit (60 min)

Create day60/decision.py. Its decision rule follows Day 59: DML is the best guess, but its interval is too narrow; the 2SLS interval is honest. A cell is a clear raise only if profit rises at the DML estimate and at the most elastic end of the 2SLS interval; a clear don't if it falls at both the DML estimate and the least elastic end; everything else is test first.

# day60/decision.py   (python -m day60.decision)
"""
Capstone step 2: can each cell absorb a 5% price rise?
With gross margin m (share of the current price) and elasticity e, a 5% rise changes gross profit by
    1.05**e * (m + 0.05) / m - 1
(units fall by 1.05**e; each unit now earns m + 0.05 of the old price instead of m).
Decision rule, using DML as the estimate and the 2SLS 95% interval as an honest range (Day 59):
    raise      if profit rises at the DML estimate AND at the elastic end of the 2SLS interval
    don't      if profit falls at the DML estimate AND at the inelastic end of the 2SLS interval
    test first otherwise
"""
import numpy as np
import polars as pl


def profit_change(e, m):
    return 1.05**e * (m + 0.05) / m - 1


if __name__ == "__main__":
    est = pl.read_csv("data/day60_estimates.csv")
    rows = []
    for r in est.iter_rows(named=True):
        m = r["margin"]
        breakeven = np.log(m / (m + 0.05)) / np.log(1.05)
        lo, hi = r["2SLS"] - 1.96 * r["2SLS SE"], r["2SLS"] + 1.96 * r["2SLS SE"]
        at_dml, worst, best = profit_change(r["DML"], m), profit_change(lo, m), profit_change(hi, m)
        verdict = "raise" if at_dml > 0 and worst > 0 else "don't" if at_dml < 0 and best < 0 else "test first"
        rows.append({"segment": r["segment"], "category": r["category"], "break-even elasticity": round(breakeven, 2),
                     "DML": r["DML"], "profit change": f"{at_dml:+.1%}", "range (2SLS CI)": f"{worst:+.1%} to {best:+.1%}",
                     "$k per year": round(at_dml * r["annual gross profit ($k)"], 1), "verdict": verdict})
    out = pl.DataFrame(rows)
    out.write_csv("data/day60_decisions.csv")
    with pl.Config(tbl_rows=20, tbl_width_chars=150, fmt_str_lengths=30):
        print(out)
    summary = out.group_by("segment").agg(raise_=(pl.col("verdict") == "raise").sum(),
                                           dont=(pl.col("verdict") == "don't").sum(),
                                           test=(pl.col("verdict") == "test first").sum(),
                                           gain_if_raise_only=pl.when(pl.col("verdict") == "raise")
                                           .then(pl.col("$k per year")).otherwise(0).sum().round(1)).sort("segment")
    print(summary.rename({"raise_": "raise", "dont": "don't", "test": "test first",
                          "gain_if_raise_only": "$k/yr from 'raise' cells"}))
    print(f"Raising every cell: {out['$k per year'].sum():+.1f} $k/yr;  raising only 'raise' cells: "
          f"{out.filter(pl.col('verdict') == 'raise')['$k per year'].sum():+.1f} $k/yr")
python -m day60.decision
│ segment         ┆ category      ┆ break-even elasticity ┆ DML    ┆ profit change ┆ range (2SLS CI)  ┆ $k per year ┆ verdict    │
│ rural value     ┆ fresh         ┆ -3.37                 ┆ -3.054 ┆ +1.5%         ┆ +0.4% to +2.4%   ┆ 10.0        ┆ raise      │
│ rural value     ┆ household     ┆ -3.16                 ┆ -3.463 ┆ -1.5%         ┆ -2.6% to +0.1%   ┆ -19.5       ┆ test first │
│ rural value     ┆ pantry        ┆ -4.2                  ┆ -2.832 ┆ +6.9%         ┆ +4.5% to +8.2%   ┆ 28.0        ┆ raise      │
│ rural value     ┆ personal care ┆ -2.74                 ┆ -2.444 ┆ +1.4%         ┆ -0.9% to +1.9%   ┆ 19.4        ┆ test first │
│ rural value     ┆ snacks        ┆ -3.16                 ┆ -3.936 ┆ -3.7%         ┆ -7.3% to -3.1%   ┆ -15.5       ┆ don't      │
│ student         ┆ fresh         ┆ -3.37                 ┆ -3.522 ┆ -0.8%         ┆ -2.6% to +0.2%   ┆ -4.5        ┆ test first │
│ student         ┆ household     ┆ -3.16                 ┆ -3.811 ┆ -3.1%         ┆ -5.3% to -3.0%   ┆ -39.2       ┆ don't      │
│ student         ┆ pantry        ┆ -4.2                  ┆ -3.114 ┆ +5.4%         ┆ +3.2% to +6.5%   ┆ 20.5        ┆ raise      │
│ student         ┆ personal care ┆ -2.74                 ┆ -2.828 ┆ -0.4%         ┆ -2.2% to +0.3%   ┆ -5.6        ┆ test first │
│ student         ┆ snacks        ┆ -3.16                 ┆ -4.37  ┆ -5.7%         ┆ -7.7% to -4.5%   ┆ -23.1       ┆ don't      │
│ suburban family ┆ fresh         ┆ -3.37                 ┆ -2.45  ┆ +4.6%         ┆ +2.2% to +4.8%   ┆ 30.4        ┆ raise      │
│ suburban family ┆ household     ┆ -3.16                 ┆ -2.783 ┆ +1.9%         ┆ -0.6% to +1.4%   ┆ 23.8        ┆ test first │
│ suburban family ┆ pantry        ┆ -4.2                  ┆ -2.259 ┆ +9.9%         ┆ +9.1% to +11.9%  ┆ 39.9        ┆ raise      │
│ suburban family ┆ personal care ┆ -2.74                 ┆ -2.032 ┆ +3.5%         ┆ +2.2% to +4.8%   ┆ 47.2        ┆ raise      │
│ suburban family ┆ snacks        ┆ -3.16                 ┆ -3.153 ┆ +0.0%         ┆ -0.4% to +2.0%   ┆ 0.1         ┆ test first │
│ urban premium   ┆ fresh         ┆ -3.37                 ┆ -1.372 ┆ +10.2%        ┆ +8.1% to +11.6%  ┆ 70.7        ┆ raise      │
│ urban premium   ┆ household     ┆ -3.16                 ┆ -1.485 ┆ +8.5%         ┆ +7.8% to +9.5%   ┆ 99.7        ┆ raise      │
│ urban premium   ┆ pantry        ┆ -4.2                  ┆ -1.262 ┆ +15.4%        ┆ +14.3% to +16.8% ┆ 59.1        ┆ raise      │
│ urban premium   ┆ personal care ┆ -2.74                 ┆ -1.112 ┆ +8.3%         ┆ +7.2% to +10.0%  ┆ 104.8       ┆ raise      │
│ urban premium   ┆ snacks        ┆ -3.16                 ┆ -1.692 ┆ +7.4%         ┆ +5.7% to +8.2%   ┆ 31.3        ┆ raise      │
│ segment         ┆ raise ┆ don't ┆ test first ┆ $k/yr from 'raise' cells │
│ rural value     ┆ 2     ┆ 1     ┆ 2          ┆ 38.0                     │
│ student         ┆ 1     ┆ 2     ┆ 2          ┆ 20.5                     │
│ suburban family ┆ 3     ┆ 0     ┆ 2          ┆ 117.5                    │
│ urban premium   ┆ 5     ┆ 0     ┆ 0          ┆ 365.6                    │
Raising every cell: +477.5 $k/yr;  raising only 'raise' cells: +541.6 $k/yr

The answer to the CFO's question by segment:

  • Urban premium can absorb 5% everywhere: +$366k a year.
  • Suburban families in fresh, pantry and personal care; household and snacks are too close to call.
  • Students and rural value shoppers only in low-margin pantry (and rural fresh); household and snacks lose money.

A blanket 5% rise earns $64k a year less than the targeted one, because it loses money in five cells.


60.4 Step 3: the pricing memo (80 min)

Write docs/PRICE_RISE_DECISION.md, one page, for the CFO:

  1. Recommendation (which cells to raise now, which not, which to test).
  2. Why we didn't use last year's simple regression (one paragraph; the fixed-effects estimate of −2.1 against −2.7).
  3. Results (the 20-cell table, condensed to segment level, with dollars).
  4. How sure are we? (two estimators, what each assumes, the "test first" cells).
  5. Next steps (how to settle the test-first cells).
Model answer for sections 1, 4 and 5

Recommendation. Raise prices 5% in 11 of 20 segment-category cells: every category for urban premium shoppers; fresh, pantry and personal care for suburban families; pantry for students; pantry and fresh for rural value shoppers. Expected gain: about $540k of gross profit a year. Don't raise household or snacks prices for students, or snacks for rural value shoppers: those shoppers would buy enough less to cost about $78k a year. Test the remaining six cells before deciding.

How sure are we? Each elasticity was estimated twice, with methods that rely on different assumptions: one assumes we recorded everything the pricing team reacts to; the other uses supplier cost changes, which customers don't see. They agree closely in most cells. We only call a cell "raise" or "don't" if the decision holds across the plausible range of both. Six cells (household and personal care for rural value shoppers, fresh and personal care for students, household and snacks for suburban families) sit too close to the break-even point to call from historical data.

Next steps. For the test-first cells, run a 6-week price test (Module 3): raise prices by 5% in a random half of the stores in each segment and compare. With about 20 stores per segment, that settles whether each cell is above or below break-even. Revisit the "don't" cells if costs keep rising: the break-even point moves with the margin.


60.5 Answer key, final commit and Module 6 retrospective (40 min)

After the memo is committed, compare with the truth. Create day60/answer_key.py:

# day60/answer_key.py   (python -m day60.answer_key)
"""Instructor check, after the memo: true elasticities, true profit changes, and how the verdicts fared."""
import polars as pl

from day60.decision import profit_change

truth = pl.read_csv("data/truth/elasticities.csv")
dec = pl.read_csv("data/day60_decisions.csv")
est = pl.read_csv("data/day60_estimates.csv").select("segment", "category", "annual gross profit ($k)")
out = (dec.join(truth, on=["segment", "category"]).join(est, on=["segment", "category"])
       .with_columns(true_change=pl.struct("elasticity", "margin").map_elements(
           lambda r: profit_change(r["elasticity"], r["margin"]), return_dtype=pl.Float64)))
out = out.with_columns(true_dollars=(pl.col("true_change") * pl.col("annual gross profit ($k)")).round(1),
                       right=pl.when(pl.col("verdict") == "test first").then(pl.lit("-"))
                       .when((pl.col("verdict") == "raise") == (pl.col("true_change") > 0)).then(pl.lit("yes"))
                       .otherwise(pl.lit("NO")))
with pl.Config(tbl_rows=20, tbl_width_chars=140):
    print(out.select("segment", "category", "elasticity", "DML", pl.col("true_change").map_elements(
        lambda v: f"{v:+.1%}", return_dtype=pl.String).alias("true profit change"), "true_dollars", "verdict", "right"))
print(f"True gain from raising only the 'raise' cells: "
      f"{out.filter(pl.col('verdict') == 'raise')['true_dollars'].sum():+.1f} $k/yr; "
      f"from raising every cell: {out['true_dollars'].sum():+.1f} $k/yr; "
      f"best possible (raise exactly the profitable cells): {out.filter(pl.col('true_dollars') > 0)['true_dollars'].sum():+.1f} $k/yr")
python -m day60.answer_key
│ segment         ┆ category      ┆ elasticity ┆ DML    ┆ true profit change ┆ true_dollars ┆ verdict    ┆ right │
│ rural value     ┆ fresh         ┆ -3.2       ┆ -3.054 ┆ +0.8%              ┆ 5.3          ┆ raise      ┆ yes   │
│ rural value     ┆ household     ┆ -3.52      ┆ -3.463 ┆ -1.7%              ┆ -23.2        ┆ test first ┆ -     │
...
│ suburban family ┆ snacks        ┆ -3.25      ┆ -3.153 ┆ -0.4%              ┆ -1.9         ┆ test first ┆ -     │
...
True gain from raising only the 'raise' cells: +517.0 $k/yr; from raising every cell: +416.5 $k/yr; best possible (raise exactly the profitable cells): +547.6 $k/yr

Every "raise" and "don't" verdict was right. The test-first cells were genuinely close: four of the six would have lost money (suburban snacks by just 0.4%), two would have made a little. The targeted plan earns $517k of the $548k possible, and $101k more than a blanket rise. The DML point estimates alone would have called suburban snacks a (tiny) gain, wrongly; the cross-check kept it in "test first".

git add .
git commit -m "Day 60: price elasticity engine capstone: cell elasticities, profit, memo"
git push

What you learned in Module 6.

DayIdeaThe simulator lesson
51Prediction ≠ causationThe best predictor underestimated the coupon effect, even in an experiment
52Orthogonality, cross-fittingWithout either, 95% CIs covered 20–32% of the time; with both, 92%
53DoubleML PLRLinear nuisances gave OLS's answer; LightGBM within half an SE
54IRM and IIVMA hidden browsing signal nearly doubled the ATT; the random holdout recovered it
55Meta-learnersDR-learner best with enough data and a simple final model; S-learner best at 2,000
56Causal forestsForest group averages were shrunk; DR-score GATEs were within $1.5
57ValidationOn an experiment, BLP and calibration flagged the T-learner's noise and the forest's compression
58ElasticitiesFixed effects: −2.1; cost IV −2.8; DML −2.7; truth −2.7
59Segment elasticitiesDML precise but 3% biased; 2SLS noisy but honest; use both
60CapstoneTargeted rise +$517k vs blanket +$417k; no wrong "raise" or "don't" verdicts

Coming next: Module 7, Sensitivity, Robustness & Decisions. Every module so far ended with an assumption you couldn't test. Module 7 asks how wrong the assumption would have to be to change the decision: sensitivity analysis for unobserved confounding, specification curves, and decision analysis under uncertainty.

Day 60 checklist

  • I computed each cell's break-even elasticity from its margin
  • I estimated every cell's elasticity with two estimators that rest on different assumptions
  • I made "raise", "don't" and "test first" calls that account for DML's narrow intervals
  • I put dollars on the decision and compared it with a blanket rise
  • I wrote a memo whose numbers match the table