Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions causal-marketing-pymc/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ html-iv-slides: ## build the Ch.13 interactive slide deck (apps/iv_slides.html)
# /*__DATA__*/, inlines MathJax. Edit apps/iv_slides_src.html, then rebuild.
$(CORE)/python apps/build_iv_slides.py

html-iv-slides-sh: ## build the management-first rework of the Ch.13 deck (apps/iv_slides_sh.html)
# Same pipeline as html-iv-slides, different template/output. Edit apps/iv_slides_sh_src.html.
$(CORE)/python apps/build_iv_slides_sh.py

html-geo: ## build the standalone Ch.9 slide deck (apps/geo_lift_slides.html) — one offline file
# Assembles apps/geo_slides_src.html: shard-token substitution (numbers never retyped),
# the nb07 lecture bundle for the live charts, book figures inlined as PNGs, MathJax vendored.
Expand All @@ -98,6 +102,13 @@ html-geo-sh: ## build the SHORT Ch.9 deck (apps/geo_lift_sh.html) — classical
$(CORE)/python apps/build_geo_slides.py --src apps/geo_slides_sh_src.html --out apps/geo_lift_sh.html
$(CORE)/python apps/verify_geo_deck.py --deck apps/geo_lift_sh.html --allow-missing-slides

html-unified: ## build the UNIFIED session deck (apps/unified_slides.html) — labs hook + short geo arc
# Part 1 (real PyMC Labs engagements) in front of the short classical geo deck, one file.
# Tokens from BOTH sources ({{labs.*}} pins + {{nb07*.*}} shards), one merged DATA bundle.
# Edit apps/unified_slides_src.html, then rebuild. Narrative: apps/geo_lift_lecture_emphasis.md.
$(CORE)/python apps/build_unified_slides.py
$(CORE)/python apps/verify_unified_deck.py

site: ## export the WASM-safe apps to static, in-browser pages under site/
$(CORE)/marimo export html-wasm apps/experiment_design.py -o site/experiment_design --mode run
$(CORE)/marimo export html-wasm apps/iv_lecture.py -o site/iv_lecture --mode run
Expand Down
132 changes: 101 additions & 31 deletions causal-marketing-pymc/apps/build_geo_slides.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,24 +126,57 @@ def naive_grid() -> dict:
"diff-in-differences", "synthetic control"]}


def main(src: Path = SRC, out: Path = OUT) -> None:
html = src.read_text()
tokens = load_tokens()

# 1 · scalar tokens ------------------------------------------------------------------
used, missing = set(), []
def sub_token(m: re.Match) -> str:
key = m.group(1).strip()
if key not in tokens:
missing.append(key)
return m.group(0)
used.add(key)
return tokens[key]
html = re.sub(r"\{\{([a-z0-9_.]+)\}\}", sub_token, html)
if missing:
sys.exit("FAIL: unknown tokens (not in the shards): " + ", ".join(sorted(set(missing))))

# 2 · the data bundle ----------------------------------------------------------------
def dgp_parts(labels_top: list, treated: list, donors_top: list) -> dict:
"""Bake the seed-5 STANDARDIZED draws behind cmp.dgp.geo_panel so the DGP-sandbox
slide can re-assemble the SAME world at any dial setting.

geo_panel is exactly linear in every dial the slide exposes — normal(0, sd) = sd*z,
uniform(1-s, 1+s) = 1 + s*u, and the lift enters as lift*(level + g.F) — so shipping
the unit variates (z, u, e) plus the levels lets the browser rebuild
Y_jt = a_j + (1+s*u_j).F_t(sigma_eta) + sigma_eps*e_jt for arbitrary dials. At the case
dials that reconstruction IS the seed-5 panel (asserted below), so the picture deforms
continuously from the case instead of jumping to a different-seed toy world.
Only the markets the deck draws are shipped: treated first, then the top donors in
the bundle's own donor_labels_top order.
"""
import numpy as np
from cmp import dgp

n_weeks, n_dmas, launch = 60, 30, 40
# replicate geo_panel(seed=5)'s draw stream call-for-call, recording unit variates
rng = np.random.default_rng(5)
z = rng.normal(0, 1.2, n_weeks) / 1.2 # unit macro increments
levels = rng.uniform(80, 140, n_dmas)
u = (rng.uniform(0.6, 1.4, (n_dmas, 3)) - 1.0) / 0.4 # unit loading deviations
e = rng.normal(0, 3, (n_dmas, n_weeks)) / 3.0 # unit noise
zc = np.cumsum(z)

# the reconstruction at the case dials must equal geo_panel(seed=5) to float precision
t = np.arange(n_weeks)
trend = 0.4 * t
season = 8 * np.sin(2 * np.pi * t / 26) + 4 * np.sin(2 * np.pi * t / 13)
F = np.column_stack([trend, season, 1.2 * zc])
loads = 1.0 + 0.4 * u
sales = levels[:, None] + loads @ F.T + 3.0 * e
eff = 0.12 * (levels[0] + loads[0] @ F.T) * (t >= launch)
df, eff_ref, launch_ref, lab = dgp.geo_panel(seed=5)
assert launch_ref == launch and np.allclose(eff, eff_ref, atol=1e-9)
assert np.allclose(sales[0] + eff, df[lab].values, atol=1e-9)
assert np.allclose(sales[1:], df.values.T[1:], atol=1e-9)

# and match the bundle's (rounded-to-0.1) shipped series within rounding
idx = [0] + [int(l.split("_")[1]) for l in labels_top]
assert np.abs(sales[0] + eff - np.asarray(treated)).max() < 0.051
for k, l_idx in enumerate(idx[1:]):
assert np.abs(sales[l_idx] - np.asarray(donors_top[k])).max() < 0.051

r = lambda a: np.round(np.asarray(a), 5).tolist()
return {"levels": r(levels[idx]), "u": r(u[idx]), "e": r(e[idx]), "zc": r(zc)}


def build_bundle() -> dict:
"""Assemble the full DATA bundle (nb07 lecture bundle + shard scalars + aliases +
real-data bundle + extras + the naive grid). Shared with build_unified_slides.py."""
if not BUNDLE.exists():
sys.exit(f"FAIL: {BUNDLE} missing — re-execute nb07 (its lecture-bundle cell writes it).")
bundle_meta = json.loads(BUNDLE.read_text())
Expand Down Expand Up @@ -196,25 +229,62 @@ def sub_token(m: re.Match) -> str:
print(f"naive grid: {len(bundle_meta['naive_grid']['s'])}×"
f"{len(bundle_meta['naive_grid']['sd'])} cells from cmp.dgp.geo_panel")

# the DGP-sandbox slide: the seed-5 unit draws, so the live figure re-assembles the
# SAME world at off-case dials instead of re-simulating a different-seed toy world.
bundle_meta["dgp_parts"] = dgp_parts(bundle_meta["donor_labels_top"],
bundle_meta["treated"],
bundle_meta["donor_series_top"])
print(f"dgp parts: {len(bundle_meta['dgp_parts']['levels'])} markets' seed-5 unit draws")
return bundle_meta


def inject_data(html: str, bundle_meta: dict) -> str:
"""Replace the JSON object after the /*__DATA__*/ marker with the baked bundle."""
if "/*__DATA__*/" not in html:
sys.exit("FAIL: template has no /*__DATA__*/ marker.")
j = html.index("{", html.index("/*__DATA__*/"))
_, end = json.JSONDecoder().raw_decode(html, j)
html = html[:j] + json.dumps(bundle_meta, separators=(",", ":")) + html[end:]
return html[:j] + json.dumps(bundle_meta, separators=(",", ":")) + html[end:]

# 2b · the N map: formatted number strings the deck's JS writes into prose spans.
# The template's /*__NUMS__*/{...}/*__ENDNUMS__*/ map supplies the KEYS; every value is
# re-derived from the shards (nb07.<key>), so a number can no more go stale here than in
# the {{token}} prose layer. A key missing from the shards is a build error.

def inject_nums(html: str, tokens: dict[str, str]) -> str:
"""The N map: formatted number strings the deck's JS writes into prose spans.
The template's /*__NUMS__*/{...}/*__ENDNUMS__*/ map supplies the KEYS; every value is
re-derived from the shards (nb07.<key>), so a number can no more go stale here than in
the {{token}} prose layer. A key missing from the shards is a build error."""
nm = re.search(r"/\*__NUMS__\*/(\{.*?\})/\*__ENDNUMS__\*/", html, re.S)
if nm:
n_keys = list(json.loads(nm.group(1)))
missing_n = [k for k in n_keys if f"nb07.{k}" not in tokens and f"nb07b.{k}" not in tokens]
if missing_n:
sys.exit("FAIL: N-map keys not in the shards: " + ", ".join(missing_n))
nmap = {k: tokens.get(f"nb07.{k}", tokens.get(f"nb07b.{k}")) for k in n_keys}
html = html[:nm.start(1)] + json.dumps(nmap, separators=(",", ":")) + html[nm.end(1):]
print(f"N map: {len(nmap)} formatted numbers injected from shards")
if not nm:
return html
n_keys = list(json.loads(nm.group(1)))
missing_n = [k for k in n_keys if f"nb07.{k}" not in tokens and f"nb07b.{k}" not in tokens]
if missing_n:
sys.exit("FAIL: N-map keys not in the shards: " + ", ".join(missing_n))
nmap = {k: tokens.get(f"nb07.{k}", tokens.get(f"nb07b.{k}")) for k in n_keys}
html = html[:nm.start(1)] + json.dumps(nmap, separators=(",", ":")) + html[nm.end(1):]
print(f"N map: {len(nmap)} formatted numbers injected from shards")
return html


def main(src: Path = SRC, out: Path = OUT) -> None:
html = src.read_text()
tokens = load_tokens()

# 1 · scalar tokens ------------------------------------------------------------------
used, missing = set(), []
def sub_token(m: re.Match) -> str:
key = m.group(1).strip()
if key not in tokens:
missing.append(key)
return m.group(0)
used.add(key)
return tokens[key]
html = re.sub(r"\{\{([a-z0-9_.]+)\}\}", sub_token, html)
if missing:
sys.exit("FAIL: unknown tokens (not in the shards): " + ", ".join(sorted(set(missing))))

# 2 · the data bundle, 2b · the N map -----------------------------------------------
html = inject_data(html, build_bundle())
html = inject_nums(html, tokens)

# 3 · book figures -------------------------------------------------------------------
fig_names = sorted(set(re.findall(r"<!--FIG:([a-z0-9_]+)-->", html)))
Expand Down
19 changes: 19 additions & 0 deletions causal-marketing-pymc/apps/build_iv_slides_sh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Generate apps/iv_slides_sh.html, the management-first rework of the Ch. 13 IV deck.

Same pipeline as build_iv_slides.py (tokens from the executed shards, DATA bundle at
/*__DATA__*/, vendored MathJax at /*__MATHJAX__*/), different template and output:

Template: apps/iv_slides_sh_src.html (editable source)
Output: apps/iv_slides_sh.html (generated, self-contained)

Build: .venv/bin/python apps/build_iv_slides_sh.py
"""
from __future__ import annotations

import build_iv_slides as base

base.SRC = base.HERE / "iv_slides_sh_src.html"
base.OUT = base.HERE / "iv_slides_sh.html"

if __name__ == "__main__":
base.main()
113 changes: 113 additions & 0 deletions causal-marketing-pymc/apps/build_unified_slides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Generate the UNIFIED SDA session deck: apps/unified_slides.html.

Part 1 ("Causal Inference in the Wild": real PyMC Labs engagements, the business hook)
spliced in FRONT of the short classical geo deck (synthetic control, Acts I-IV), then
Part 3 (the short instrumental-variables deck) after the geo Act IV closer, closing with
the labs synthesis slides. One file, one chrome (the geo deck's, a strict superset), one
DATA bundle. The narrative this deck implements: apps/geo_lift_lecture_emphasis.md;
the spoken script: apps/geo_lift_lecture_speech.md.

Inputs, none hand-typed:
tokens : {{nb07*.*}} from the executed geo shards (build_geo_slides.load_tokens),
{{labs.*}} from apps/labs_deck_data.json (blog-pinned facts with source URLs;
build_labs_slides.load_pins), and {{nb11*.*}} from the executed IV shards
(build_iv_slides.load_tokens). The three prefixes are disjoint (asserted).
DATA : the geo bundle (build_geo_slides.build_bundle) merged with the labs chart keys
(deterministic counterfactual schematic + pinned ROAS pair; disjoint, asserted),
plus the IV bundle nested under DATA.iv (its dgp/scalars keys collide with geo's,
so the spliced IV figure code was rebased to read DATA.iv.* by the splicer).
SOURCES: the labs Sources backup table rows at <!--SOURCES_ROWS-->.
MATHJAX: the vendored tex-svg build, inlined once at /*__MATHJAX__*/.

Build: .venv/bin/python apps/build_unified_slides.py (or `make html-unified`)
Template: apps/unified_slides_src.html (canonical; edit it, then rebuild).
Verify: .venv/bin/python apps/verify_unified_deck.py (geo claims subset + labs claims)
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import build_geo_slides as bg
import build_iv_slides as bi
import build_labs_slides as bl

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
SRC = HERE / "unified_slides_src.html"
OUT = HERE / "unified_slides.html"


def main() -> None:
if not SRC.exists():
sys.exit(f"FAIL: {SRC} missing.")
html = SRC.read_text()

# 1 · scalar/quote tokens from BOTH sources ------------------------------------------
pins = bl.load_pins()
tokens = bg.load_tokens()
labs_tokens = {k: str(v["text"]) for k, v in pins.items()}
dup = set(tokens) & set(labs_tokens)
if dup:
sys.exit("FAIL: token collision between shards and labs pins: " + ", ".join(sorted(dup)))
tokens |= labs_tokens

# Part 3 (IV) prose tokens: {{nb11.*}} / {{nb11b.*}} from the executed IV shards
iv_tokens = bi.load_tokens()
dup_iv = set(tokens) & set(iv_tokens)
if dup_iv:
sys.exit("FAIL: token collision between existing tokens and IV shards: " + ", ".join(sorted(dup_iv)))
tokens |= iv_tokens

used, missing = set(), []

def sub_token(m: re.Match) -> str:
key = m.group(1).strip()
if key not in tokens:
missing.append(key)
return m.group(0)
used.add(key)
return tokens[key]

html = re.sub(r"\{\{([a-z0-9_.]+)\}\}", sub_token, html)
if missing:
sys.exit("FAIL: unknown tokens (neither shards nor labs pins): "
+ ", ".join(sorted(set(missing))))

# 2 · ONE data bundle (geo + labs chart keys), 2b · the N map ------------------------
bundle = bg.build_bundle()
labs_bundle = bl.bake_bundle(pins)
overlap = set(bundle) & set(labs_bundle)
if overlap:
sys.exit("FAIL: DATA key overlap between geo and labs bundles: " + ", ".join(sorted(overlap)))
bundle.update(labs_bundle)

# Part 3 (IV) chart data: nested under a single key (its dgp/scalars/... collide with geo's,
# so the IV figure code was rebased to read DATA.iv.* by the splicer).
if "iv" in bundle:
sys.exit("FAIL: DATA key 'iv' already present; cannot nest the IV bundle.")
bundle["iv"] = bi.bake_bundle()
html = bg.inject_data(html, bundle)
html = bg.inject_nums(html, tokens)

# 3 · the labs Sources backup slide --------------------------------------------------
if "<!--SOURCES_ROWS-->" not in html:
sys.exit("FAIL: template has no <!--SOURCES_ROWS--> marker.")
html = html.replace("<!--SOURCES_ROWS-->", bl.sources_rows(pins), 1)

# 4 · MathJax ------------------------------------------------------------------------
if "/*__MATHJAX__*/" not in html:
sys.exit("FAIL: template has no /*__MATHJAX__*/ marker.")
html = html.replace("/*__MATHJAX__*/", bg.mathjax_js(), 1)

OUT.write_text(html)
size = OUT.stat().st_size / 1e6
n_slides = html.count('<section class="slide')
print(f"wrote {OUT.relative_to(REPO)} ({size:.1f} MB, {n_slides} slides, "
f"{len(used)} tokens, MathJax inlined)")


if __name__ == "__main__":
main()
Loading
Loading