Causal Thinking, Estimands & DAGs
Stating the question precisely and making every causal assumption explicit.
What you'll learn
- 1Days 11–13Potential outcomes, estimands (ATE, ATT, CATE, LATE) and randomization as the benchmark.
By the end: Students can state precisely which causal effect a business question asks for, decompose a naive comparison into the true effect plus selection bias, and show with repeated simulated experiments why randomisation removes that bias.
- 2Days 14–17DAGs, backdoor and frontdoor criteria, colliders and bad controls.
By the end: Students draw a DAG for a business problem, test its implications against data, find valid adjustment sets with the backdoor criterion, and recognise colliders, mediators and bad controls, all checked against NorthMart's known truth.
- 3Days 18–20Encoding assumptions in DoWhy and defending a DAG in writing.
By the end: Students take a new business problem from narrative to DAG, test the DAG against data, derive and defend an adjustment set, estimate the effect, and explain in a written memo why the alternatives would mislead.
Tools
Mini-capstone
Defending the DAG: map pricing and email decisions, find adjustment sets, and write an assumptions memo.
The ten days
Before you start: module setup and project structure
Course: Applied Causal Inference & Decision Science Audience: Economists and analysts who completed Module 1 Environment: GitHub Codespaces (VS Code in the browser) Time per day: ~5 hours (including breaks and exercises) Goal by end of Day 13: Students can state precisely which causal effect a business question asks for, decompose a naive comparison into the true effect plus selection bias, and show with repeated simulated experiments why randomisation removes that bias.
Where we are
Module 1 delivered the Causal Data Mart: data/published/panel_customer_week.parquet and customer_features.parquet, rebuilt and checked every week. It also showed, twice, that the naive comparison says the SPRING26 email raised orders by +106% when the truth is about +15%.
Module 2 explains why, with two languages that every later method relies on:
| Days | Language | Question it answers |
|---|---|---|
| 11–13 | Potential outcomes | What effect do we want, and why does a comparison miss it? |
| 14–17 | DAGs (causal graphs) | Which variables must we adjust for, and which must we leave alone? |
| 18–20 | Both, with DoWhy | Can the effect be identified from our data at all? |
No new estimation methods yet: those start in Module 3. Module 2 is about thinking clearly enough that the methods answer the right question.
NorthMart's hidden truth
Real data shows each customer in one world only: emailed, or not. Because NorthMart is simulated, we can rebuild both worlds for every customer. A new provided script does this.
data/northmart_truth.py (provided; click to expand)
"""
NorthMart's hidden truth: BOTH potential outcomes for every customer. Provided for Module 2.
Run once from the repository root (after data/make_northmart.py):
python data/northmart_truth.py
Writes data/truth/potential_outcomes.parquet with one row per customer:
treated 1 if the customer got the SPRING26 email (what really happened)
y_observed completed orders in weeks 9-16 (what the data shows)
y0, y1 orders in weeks 9-16 WITHOUT and WITH the email
(one of them is y_observed; the other is simulated by this script)
tau y1 - y0: the individual effect (never observable in real life)
tau_expected the expected individual effect, without Poisson noise
No real dataset has these columns. We use them to check our methods against the truth.
"""
from pathlib import Path
import numpy as np
import polars as pl
import make_northmart as nm # the Module 1 generator, in the same folder
CANCEL_RATE = 0.03 # the generator cancels ~3% of orders at random
POST_WEEKS = range(nm.CAMPAIGN_WEEK, nm.N_WEEKS + 1)
OUT = Path(__file__).parent / "truth"
def main() -> None:
# 1. Replay the generator's random draws to recover each customer's latent rate and treatment
rng = np.random.default_rng(nm.SEED)
customers = nm.make_customers(rng)
nm.make_products(rng)
treated = nm.assign_campaign(rng, customers)
recorded = set(pl.read_csv(nm.OUT / "email_campaign.csv")["customer_id"])
assert recorded == set(customers.filter(pl.Series(treated))["customer_id"]), "replay mismatch"
# 2. Observed post-period orders (completed, deduplicated) from the raw file
orders = (pl.read_csv(nm.OUT / "orders.csv", try_parse_dates=True)
.unique().filter(pl.col("status") == "completed")
.filter(pl.col("order_date") >= pl.date(2026, 3, 2)))
y_obs = orders.group_by("customer_id").agg(y_observed=pl.len())
# 3. Expected orders over weeks 9-16 without the email (sum of weekly Poisson means)
base = customers["_base_rate"].to_numpy()
season = sum(1.0 + 0.10 * np.sin(2 * np.pi * w / 16) for w in POST_WEEKS)
mu0 = base * season * (1 - CANCEL_RATE)
mu1 = mu0 * (1 + nm.TRUE_EFFECT)
# 4. Draw the potential outcome that did NOT happen, coupled to the one that did:
# with the email, a customer makes every order they would have made anyway, plus extra ones.
# Treated: y0 keeps each observed order with probability 1/1.15 (so y0 <= y1).
# Control: y1 adds extra orders, Poisson with mean mu1 - mu0 (so y1 >= y0).
rng_cf = np.random.default_rng(nm.SEED + 1)
df = (customers.select("customer_id", "is_loyalty", "region")
.with_columns(treated=pl.Series(treated.astype(np.int8)),
mu0=pl.Series(mu0), mu1=pl.Series(mu1))
.join(y_obs, on="customer_id", how="left")
.with_columns(pl.col("y_observed").fill_null(0).cast(pl.Int64)))
y = df["y_observed"].to_numpy()
kept = rng_cf.binomial(y, 1 / (1 + nm.TRUE_EFFECT))
extra = rng_cf.poisson(mu1 - mu0)
t = treated
df = df.with_columns(
y0=pl.Series(np.where(t, kept, y)),
y1=pl.Series(np.where(t, y, y + extra)),
).with_columns(
tau=pl.col("y1") - pl.col("y0"),
tau_expected=pl.col("mu1") - pl.col("mu0"),
)
OUT.mkdir(exist_ok=True)
df.select("customer_id", "treated", "is_loyalty", "region", "y_observed", "y0", "y1",
"tau", "tau_expected", "mu0", "mu1").write_parquet(OUT / "potential_outcomes.parquet")
print(f"Wrote {df.height:,} customers to {OUT / 'potential_outcomes.parquet'}")
if __name__ == "__main__":
main()
Run it from the data/ folder (it imports the Module 1 generator next to it):
cd data && python northmart_truth.py && cd ..
Wrote 4,000 customers to /workspaces/causal-decision-lab/data/truth/potential_outcomes.parquet
Use the truth file only to check your work. It contains columns no real dataset has. Every method in this course must work without it; the truth tells us how well it did.
Update your environment
Module 2 adds four packages. Append these lines to requirements.txt and to .gitignore, then reinstall:
networkx==3.7
statsmodels==0.15.0
matplotlib==3.11.2
dowhy==0.14
data/truth/
data/scenario/
figures/
pip install -r requirements.txt
mkdir -p figures
Running scripts in Module 2
From now on, run scripts as modules from the repository root:
python -m day11.gods_view # instead of: python day11/gods_view.py
python -m puts the repository root on Python's search path, so scripts in any dayNN/ folder can import shared helpers from the causalkit/ folder we start on Day 14. Each script's first line shows its command.