| Time | Block |
|---|---|
| 0:00 – 0:40 | 72.1 A scenario is a counterfactual with a name |
| 0:40 – 1:30 | 72.2 The preset menu |
| 1:30 – 1:45 | Break |
| 1:45 – 2:30 | 72.3 Where the estimates stop being valid |
| 2:30 – 3:10 | 72.4 Combining scenarios |
| 3:10 – 3:25 | Break |
| 3:25 – 4:40 | 72.5 Day 72 mini-project: the optimiser that leaves the data |
| 4:40 – 5:00 | 72.6 Save your work with Git |
72.1 A scenario is a counterfactual with a name (40 min)
Concept. Every number the simulator shows is a comparison of two potential outcomes (Module 2): profit if we do X, against profit if we do nothing. The "nothing" must be stated. Here it's "this quarter's prices and volumes continue for a year", and the app says so.
Three design rules for a what-if tool:
- Name scenarios after decisions, not parameters. "Coupons to the top 50%" rather than "coupon_share = 0.5".
- Always show the change, the causal quantity, next to the level.
- Say when a scenario leaves the data. Day 72.3.
72.2 The preset menu (50 min)
The business asks the same five questions every quarter. Put them in one place, engine/presets.py. The "raise" cells come from Module 6's capstone recommendation (Day 60).
"""Named scenarios the business keeps asking about (Day 72). The simulator's menu, in one place."""
from engine.scenario import Scenario
MODULE6_RAISE = ["urban premium", "suburban family/fresh", "suburban family/pantry", "suburban family/personal care",
"student/pantry", "rural value/pantry", "rural value/fresh"]
PRESETS = {
"status quo": Scenario(),
"5% everywhere": Scenario(price_change={"all": 0.05}),
"5% in the 'raise' cells": Scenario(price_change={k: 0.05 for k in MODULE6_RAISE}),
"coupons to the top 50%": Scenario(coupon_share=0.5),
"'raise' cells + coupons": Scenario(price_change={k: 0.05 for k in MODULE6_RAISE}, coupon_share=0.5),
"coupons to everyone": Scenario(coupon_share=1.0),
}
Create day72/presets.py:
# day72/presets.py (python -m day72.presets)
"""
A scenario simulator is only useful if its menu matches the questions people ask. Run the named presets
through the engine and compare them in one table: change in annual gross profit against the counterfactual
(nothing changes), with a 90% range and the probability that profit goes up.
"""
import polars as pl
from engine import load_parameters, simulate
from engine.presets import PRESETS
params = load_parameters()
rows = []
for name, sc in PRESETS.items():
r = simulate(params, sc)
g, rev = r["change"]["gross_profit"], r["change"]["revenue"]
rows.append({"scenario": name, "revenue change ($k)": round(rev["median"] / 1000),
"profit change ($k)": round(g["median"] / 1000),
"90% range ($k)": f"{int(round(g['p5'] / 1000)):,} to {int(round(g['p95'] / 1000)):,}",
"P(profit up)": "-" if r["change"]["prob_profit_up"] is None else f"{r['change']['prob_profit_up']:.0%}"})
print(f"Parameters version {params['version']}; counterfactual gross profit "
f"${simulate(params, PRESETS['status quo'])['counterfactual']['gross_profit'] / 1e6:.1f}M a year")
with pl.Config(tbl_width_chars=120, fmt_str_lengths=40):
print(pl.DataFrame(rows))
python -m day72.presets
Parameters version 2026-11-05-e117a0ef; counterfactual gross profit $15.3M a year
│ scenario ┆ revenue change ($k) ┆ profit change ($k) ┆ 90% range ($k) ┆ P(profit up) │
│ status quo ┆ 0 ┆ 0 ┆ 0 to 0 ┆ - │
│ 5% everywhere ┆ -3700 ┆ 459 ┆ 419 to 502 ┆ 100% │
│ 5% in the 'raise' cells ┆ -1097 ┆ 517 ┆ 489 to 544 ┆ 100% │
│ coupons to the top 50% ┆ 58749 ┆ 5625 ┆ 4,824 to 6,361 ┆ 100% │
│ 'raise' cells + coupons ┆ 57650 ┆ 6142 ┆ 5,334 to 6,877 ┆ 100% │
│ coupons to everyone ┆ 70577 ┆ -2827 ┆ -3,902 to -1,840 ┆ 0% │
Three lessons in one table:
- Revenue and profit can move in opposite directions. 5% everywhere loses $3.7M of revenue and gains $459k of profit. A dashboard that showed only revenue would recommend the opposite.
- Targeting beats blanket rises: the 7 "raise" cells earn more than all 20 together, because in the others demand is elastic enough that a rise loses money.
- Coupons to everyone raise revenue the most and lose $2.8M: Module 7's lesson, now one click away.
72.3 Where the estimates stop being valid (45 min)
Concept. The elasticities were identified from price moves caused by supplier costs. Those were rarely more than ±10%. Constant elasticity is a fine approximation inside that range and an assumption outside it. This is the delivery version of Module 4's overlap and Module 5's bandwidth: don't let a tool answer questions the data can't.
Create day72/validity.py:
# day72/validity.py (python -m day72.validity)
"""
Every causal estimate has a range where it was learned. The elasticities come from price moves that
supplier costs caused: mostly within about ±10%. A 30% price rise asks the model about a world it has never
seen. The simulator still answers, but it should say so, loudly.
"""
from engine import Scenario, load_parameters, simulate
params = load_parameters()
print("Range of price changes the data support, by cell:")
for c in sorted(params["pricing"], key=lambda c: c["valid_change"]):
print(f" {c['segment']:<16} {c['category']:<14} ±{c['valid_change']:.0%}")
for change in [0.05, 0.10, 0.20]:
r = simulate(params, Scenario(price_change={"all": change}))
g = r["change"]["gross_profit"]
print(f"\n+{change:.0%} everywhere: profit change ${g['median'] / 1e3:,.0f}k; {len(r['warnings'])} warnings")
for w in r["warnings"][:3]:
print(" " + w)
python -m day72.validity
rural value pantry ±10%
...
rural value fresh ±22%
+5% everywhere: profit change $459k; 0 warnings
+10% everywhere: profit change $722k; 10 warnings
rural value/pantry: a +10% change is outside the range seen in the data (±10%); the projection extrapolates
...
+20% everywhere: profit change $867k; 19 warnings
Profit keeps "rising" as the price rises: +20% looks better than +10%. Nineteen warnings say why not to trust it. The simulator doesn't refuse (people legitimately ask about big moves), but it never answers silently.
72.4 Combining scenarios (40 min)
Price and coupon effects are added in the engine: coupon profit is computed on the base, not on the post-price-change base. That's an assumption (no interaction), and it's listed in the result's assumptions. With a 5% price change and a 0.3 margin the interaction is a few percent of the coupon effect: small, but say it.
Try it. Compare "5% everywhere" with "10% in urban premium stores only". Which earns more, and what do the warnings say? Create day72/try_urban.py.
Solution
# day72/try_urban.py (python -m day72.try_urban)
from engine import Scenario, load_parameters, simulate
params = load_parameters()
for name, sc in {"5% everywhere": Scenario(price_change={"all": 0.05}),
"10% urban premium": Scenario(price_change={"urban premium": 0.10})}.items():
r = simulate(params, sc)
g = r["change"]["gross_profit"]
print(f"{name:18} profit {g['median'] / 1e3:7.0f} $k (90%: {g['p5'] / 1e3:.0f} to {g['p95'] / 1e3:.0f}), "
f"revenue {r['change']['revenue']['median'] / 1e3:7.0f} $k, warnings {len(r['warnings'])}")
5% everywhere profit 459 $k (90%: 419 to 502), revenue -3700 $k, warnings 0
10% urban premium profit 666 $k (90%: 623 to 707), revenue -414 $k, warnings 2
Raising prices only where demand is least elastic earns more profit and loses far less revenue. But two urban cells' supported range is below ±10% (pantry ±10%, personal care ±9%), so part of the $666k is extrapolation. A recommendation would be "+10% in urban premium, starting with fresh and snacks, where the data cover it".
72.5 Day 72 mini-project: the optimiser that leaves the data (75 min)
Someone will ask: "what's the best price?" With constant elasticity, profit has a maximum at a finite price change, and an optimiser will find it. Create day72/best_price.py:
# day72/best_price.py (python -m day72.best_price)
"""
Mini-project: 'what price change maximises profit?' With a constant elasticity e and margin m, the
profit ratio (m + d)(1 + d)^e / m has a maximum, and an optimiser will find it. Find the best change for
each cell on a grid, and compare it with the range the data support.
"""
import numpy as np
import polars as pl
from engine import load_parameters
params = load_parameters()
grid = np.linspace(-0.3, 0.6, 901)
rows = []
for c in params["pricing"]:
ratio = (c["margin"] + grid) * (1 + grid) ** c["elasticity"] / c["margin"]
best = grid[np.argmax(ratio)]
rows.append({"segment": c["segment"], "category": c["category"], "elasticity": c["elasticity"],
"best change": f"{best:+.0%}", "profit gain at best": f"{ratio.max() - 1:+.1%}",
"supported range": f"±{c['valid_change']:.0%}",
"outside the data?": "YES" if abs(best) > c["valid_change"] else ""})
with pl.Config(tbl_rows=20, tbl_width_chars=130, fmt_str_lengths=20):
print(pl.DataFrame(rows).sort("segment", "category"))
python -m day72.best_price
│ segment ┆ category ┆ elasticity ┆ best change ┆ profit gain at best ┆ supported range ┆ outside the data? │
│ rural value ┆ fresh ┆ -3.054 ┆ +7% ┆ +1.7% ┆ ±22% ┆ │
│ rural value ┆ pantry ┆ -2.832 ┆ +21% ┆ +13.9% ┆ ±10% ┆ YES │
│ ...
│ urban premium ┆ pantry ┆ -1.262 ┆ +60% ┆ +106.0% ┆ ±10% ┆ YES │
13 of 20 "optimal" prices lie outside what the data support, and all five urban premium cells hit the +60% cap of the search grid. Urban premium elasticities near −1.3 imply that profit rises with price almost without limit, which no shopper behaves like. Optimisers find the edge of your model's validity and report it as an answer. The honest output is the 7 cells where the optimum is inside the data, plus "test larger moves before trusting them" for the rest. Day 78 is about those tests.
72.6 Save your work with Git (20 min)
git add engine/presets.py day72
git commit -m "Day 72: scenario presets, validity warnings, optimiser vs supported range"
git push
Day 72 checklist
- Every scenario I show names its counterfactual
- I can explain why revenue and profit moved in opposite directions
- The simulator warns when a price change leaves the range the data support
- I know why an unconstrained optimiser is dangerous with constant elasticities