Module 8 · Day 79 of 80

Final Capstone Workshop

About 5 hours
Video lesson coming soon
Day 79 · Final Capstone Workshop

What you'll learn today

  • I ran the monitor on new data and judged each alert for materiality, not just significance
  • I rebuilt the parameters with a reasoned choice of window, and committed the new file
  • My scenario pack compares old and rebuilt parameters through the API
  • My recommendation leads with the decision, the money and the probability
  • Every number in the deck comes from committed code

Today's plan

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

LessonHands-on projectBreak5:00 total
Written lesson
TimeBlock
0:00 – 0:3079.1 The brief
0:30 – 1:2079.2 Step 1: what does the monitor say?
1:20 – 1:35Break
1:35 – 2:4079.3 Step 2: rebuild the parameters
2:40 – 3:3079.4 Step 3: the scenario pack
3:30 – 3:45Break
3:45 – 4:4079.5 Step 4: the recommendation and the defence deck
4:40 – 5:0079.6 Save your work with Git

79.1 The brief (30 min)

It's NorthMart's annual pricing review. The simulator has been live for 130 weeks on the parameters in models/causal_parameters.json. The board wants: (1) are the estimates still right? (2) which of the standard scenarios should we adopt this year, and what will it earn? (3) what should we do differently in how we run the simulator? You present tomorrow: 10 minutes, then questions.

You work from new data only. Generate it:

"""
Final capstone data (Days 79-80). Provided: run it, then work from data/final/ only.

    python data/make_final_capstone.py

data/final/weekly_feed.csv        130 weeks of NorthMart demand, as the monitoring feed delivers it
data/final/coupon_experiment.csv  this year's randomised coupon test (40,000 customers)
data/truth/final_truth.json       the simulator's truth, for the instructor's answer key. Do not open it.
"""
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from causalkit.coupons import coupons  # noqa: E402
from causalkit.coupons import public as coupons_public  # noqa: E402
from causalkit.demand import CATEGORIES, SEGMENTS, demand, elasticity, public  # noqa: E402

DRIFT = {"segment": "suburban family", "week": 105, "factor": 1.25}
root = Path(__file__).parent
(root / "final").mkdir(exist_ok=True)
(root / "truth").mkdir(exist_ok=True)
df = demand(seed=80, weeks=130, drift_week=DRIFT["week"], drift_segment=DRIFT["segment"], drift_factor=DRIFT["factor"])
public(df).write_csv(root / "final" / "weekly_feed.csv")
coupons_public(coupons(n=40_000, seed=810, randomized=True)).write_csv(root / "final" / "coupon_experiment.csv")
truth = {"drift": DRIFT, "elasticity_now": {f"{s}/{c}": round(elasticity(s, c) * (DRIFT["factor"] if s == DRIFT["segment"] else 1), 3)
                                             for s in SEGMENTS for c in CATEGORIES}}
(root / "truth" / "final_truth.json").write_text(json.dumps(truth, indent=2))
print("Wrote data/final/weekly_feed.csv, data/final/coupon_experiment.csv and data/truth/final_truth.json")
python data/make_final_capstone.py

Do not open data/truth/. The instructor uses it tomorrow.

Rules, as for every capstone: every number in your deck comes from code in day79/, committed; you state each assumption you rely on; and you say what you didn't check.


79.2 Step 1: what does the monitor say? (50 min)

Run Day 77's monitor on this year's feed. Create day79/check_feed.py:

# day79/check_feed.py   (python -m day79.check_feed)   — capstone step 1
"""Run Day 77's monitor on this year's feed against the parameters the app is serving. Which segment moved, and when?"""
import pandas as pd
import polars as pl

from day77.monitor import alerts, reference, rolling_estimates
from engine import load_parameters

feed = pd.read_csv("data/final/weekly_feed.csv")
est = alerts(rolling_estimates(feed), reference(load_parameters()))
summary = est.groupby("segment").agg(reference=("reference", "first"), windows_outside=("outside", "sum"),
                                     first_alert=("end_week", lambda w: w[est.loc[w.index, "alert"]].min())).reset_index()
late = est[est.end_week >= 120].groupby("segment").estimate.mean()
summary["late_estimate"] = summary.segment.map(late)
print(pl.from_pandas(summary).with_columns(pl.col("reference", "late_estimate").round(2)))
python -m day79.check_feed
│ segment         ┆ reference ┆ windows_outside ┆ first_alert ┆ late_estimate │
│ rural value     ┆ -3.15     ┆ 1               ┆ null        ┆ -3.03         │
│ student         ┆ -3.53     ┆ 3               ┆ null        ┆ -4.0          │
│ suburban family ┆ -2.54     ┆ 7               ┆ 119.0       ┆ -3.21         │
│ urban premium   ┆ -1.38     ┆ 9               ┆ 79.0        ┆ -1.51         │

Two segments alert. Decide for each whether it's material:

  • Suburban family: from −2.54 to about −3.2, a quarter more price-sensitive, alerting at week 119. Material: several suburban cells were in the "raise" list.
  • Urban premium: alerted at week 79, before anything else, but −1.38 against −1.51 is a 9% difference in the least elastic segment, and it was there from early on. Day 77's Try-it explains it: a tight standard error turns a small method gap into an "alert". Statistically real, economically small.

Run day77.cusum's logic on this feed too, if you have time, and say whether it agrees.


79.3 Step 2: rebuild the parameters (65 min)

Rebuild on the weeks after the change, but think about what you lose: 26 weeks instead of 104 means larger standard errors everywhere. The sensible compromise is a hybrid: recent estimates for the segment that moved, long-sample estimates elsewhere. Create day79/rebuild.py:

# day79/rebuild.py   (python -m day79.rebuild)   — capstone step 2
"""
Rebuild the simulator's parameters from this year's data.
1. Re-estimate every cell on weeks 105-130 only (the drift started around week 105: older weeks describe
   customers who no longer exist) -> models/final_recent_only.json. Prices and volumes are this year's.
2. 26 weeks is a short sample. For segments where the monitor found no material change, keep the
   long-sample elasticities (smaller SE); take the recent ones only for the segment that moved
   -> models/final_parameters.json, the file the app will serve.
"""
import copy

import pandas as pd

from engine import load_parameters, save_parameters
from engine.build import build

MOVED = ["suburban family"]     # from day79.check_feed: urban premium's alert is real but tiny (-1.38 -> -1.51)

feed = pd.read_csv("data/final/weekly_feed.csv")
feed[feed.week >= 105].to_csv("data/final/recent_demand.csv", index=False)
recent = build("data/final/recent_demand.csv", "data/final/coupon_experiment.csv", out="models/final_recent_only.json")
old = {(c["segment"], c["category"]): c for c in load_parameters()["pricing"]}

final = copy.deepcopy(recent)
final["version"] += "-hybrid"
final["sources"]["pricing_method"] += f"; recent weeks for {', '.join(MOVED)}, long sample elsewhere"
for c in final["pricing"]:
    if c["segment"] not in MOVED:
        c["elasticity"], c["se"] = old[(c["segment"], c["category"])]["elasticity"], old[(c["segment"], c["category"])]["se"]
save_parameters(final, "models/final_parameters.json")

print("version", final["version"], "| weeks", recent["sources"]["demand_weeks"])
print(f"{'cell':32}{'old':>8}{'recent':>8}{'SE':>7}{'final':>8}")
for c, r in zip(final["pricing"], recent["pricing"]):
    o = old[(c["segment"], c["category"])]
    print(f"{c['segment'] + '/' + c['category']:32}{o['elasticity']:8.2f}{r['elasticity']:8.2f}{r['se']:7.2f}{c['elasticity']:8.2f}")
print("coupon uplift at 50%:", final["coupons"]["uplift"][5], "(old", load_parameters()["coupons"]["uplift"][5], ")")
python -m day79.rebuild            # about 4 minutes: 20 DML fits and the coupon curve
version 2026-11-05-082c2977-hybrid | weeks [105, 130]
cell                                 old  recent     SE   final
rural value/fresh                  -3.05   -3.03   0.18   -3.05
...
suburban family/fresh              -2.45   -3.19   0.21   -3.19
suburban family/household          -2.78   -3.38   0.16   -3.38
suburban family/pantry             -2.26   -2.75   0.49   -2.75
suburban family/personal care      -2.03   -2.52   0.32   -2.52
suburban family/snacks             -3.15   -3.92   0.39   -3.92
...
urban premium/snacks               -1.69   -1.40   0.17   -1.69
coupon uplift at 50%: 14.019 (old 14.388 )

Every suburban cell moved by about a quarter. Elsewhere the recent estimates are within noise of the old ones, but with SEs two to four times larger, so keeping the long-sample values loses nothing and gains precision. The coupon curve from this year's experiment is close to last year's.


79.4 Step 3: the scenario pack (50 min)

Run the standard scenarios through the API, old and rebuilt parameters side by side, the way the board's planning team will. Create day79/scenario_pack.py:

# day79/scenario_pack.py   (python -m day79.scenario_pack)   — capstone step 3
"""Run the preset menu through the API twice: old parameters and rebuilt ones. The pack for the memo."""
import os

import polars as pl
from fastapi.testclient import TestClient

import service.app as api
from engine.presets import PRESETS

body = [{"name": n, "scenario": s.model_dump()} for n, s in PRESETS.items() if n != "status quo"]
rows = {}
for label, path in [("old", "models/causal_parameters.json"), ("rebuilt", "models/final_parameters.json")]:
    os.environ["PARAMS_PATH"] = path
    api.params.cache_clear()
    with TestClient(api.app) as client:
        out = client.post("/compare", json=body).json()
        print(label, "parameters", out["parameters_version"])
        for r in out["rows"]:
            row = rows.setdefault(r["name"], {"scenario": r["name"]})
            row[f"{label} median $k"] = round(r["profit_change_median"] / 1e3)
            row[f"{label} 90% range"] = f"{r['p5'] / 1e3:,.0f} to {r['p95'] / 1e3:,.0f}"
            if label == "rebuilt":
                row["P(profit up)"] = f"{r['prob_profit_up']:.0%}"
                row["warnings"] = r["warnings"]
with pl.Config(tbl_width_chars=160, fmt_str_lengths=30):
    print(pl.DataFrame(list(rows.values())))
python -m day79.scenario_pack
old parameters 2026-11-05-e117a0ef
rebuilt parameters 2026-11-05-082c2977-hybrid
│ scenario                ┆ old median $k ┆ old 90% range    ┆ rebuilt median $k ┆ rebuilt 90% range ┆ P(profit up) ┆ warnings │
│ 5% everywhere           ┆ 459           ┆ 419 to 502       ┆ 431               ┆ 359 to 507        ┆ 100%         ┆ 0        │
│ 5% in the 'raise' cells ┆ 517           ┆ 489 to 544       ┆ 576               ┆ 518 to 632        ┆ 100%         ┆ 0        │
│ coupons to the top 50%  ┆ 5625          ┆ 4,824 to 6,361   ┆ 5171              ┆ 4,477 to 5,809    ┆ 100%         ┆ 0        │
│ 'raise' cells + coupons ┆ 6142          ┆ 5,334 to 6,877   ┆ 5745              ┆ 5,041 to 6,394    ┆ 100%         ┆ 0        │
│ coupons to everyone     ┆ -2827         ┆ -3,902 to -1,840 ┆ -3812             ┆ -4,828 to -2,879  ┆ 0%           ┆ 0        │

(The rebuilt figures also use this year's prices and volumes, which is why some move even though their elasticities didn't.) Then explore beyond the menu in the Streamlit app, pointed at the new file. The app reads the default path, so copy it over first and restore it afterwards, or set PARAMS_PATH and run with API_URL:

PARAMS_PATH=models/final_parameters.json uvicorn service.app:app --port 8001 &
API_URL=http://127.0.0.1:8001 streamlit run app/streamlit_app.py

Questions worth answering with the sliders: should the suburban cells stay in the "raise" list? What does the board lose if suburban prices are left alone? How much of the plan's value depends on the error-correlation slider?


79.5 Step 4: the recommendation and the defence deck (55 min)

Write day79/RECOMMENDATION.md (one page) and a 6–8 slide deck. Structure:

  1. The answer: the scenario you recommend, its median and 90% range (at ρ = 0 and ρ = 0.5), and the probability it raises profit.
  2. What changed: the suburban drift, when it was detected, and how you rebuilt.
  3. What didn't: urban premium, and why you didn't act on its alert.
  4. What it rests on: constant elasticity inside the supported range, no price-coupon interaction, 15% pull-forward.
  5. What we'll do differently: the re-run rules (Day 78), monitoring with CUSUM, a 5% coupon holdout.
  6. The test we're not running, and why: the value-of-information argument.
A model recommendation (read after writing yours)

Adopt the 'raise' cells with coupons to the top 50%: about +$5.7M gross profit a year (90% range $5.0M–$6.4M, almost unchanged at the cautious correlation setting, because the coupon programme dominates). Profit going up is virtually certain. Suburban family shoppers became about 25% more price-sensitive from around week 105; the monitor flagged it at week 119 and we rebuilt their five elasticities on the 26 weeks since. Urban premium also triggered an alert, but the shift is 9% in our least price-sensitive segment and changes no decision; we kept its long-run estimates. With the new suburban values the three suburban cells in the plan still gain, by less ($7k, $29k and $17k a year); dropping them would cost only about $51k, so if the board prefers caution with a segment that is still changing, that is a cheap option. These numbers assume constant elasticity within ±10% price moves, no interaction between price and coupons, and that 15% of coupon spend is pulled forward. From this year we monitor with CUSUM, rebuild annually or on alert, and keep a 5% coupon holdout. A new price test isn't worth its cost for this decision.


79.6 Save your work with Git (20 min)

git add data/make_final_capstone.py day79 models/final_parameters.json models/final_recent_only.json
git commit -m "Day 79: final capstone: monitor, hybrid rebuild, scenario pack, recommendation"
git push

Day 79 checklist

  • I ran the monitor on new data and judged each alert for materiality, not just significance
  • I rebuilt the parameters with a reasoned choice of window, and committed the new file
  • My scenario pack compares old and rebuilt parameters through the API
  • My recommendation leads with the decision, the money and the probability
  • Every number in the deck comes from committed code