Module 2 · Days 11–20 · Part I

Causal Thinking, Estimands & DAGs

Stating the question precisely and making every causal assumption explicit.

Start Day 11
0/10 days completed
0/10 videos published 10/10 lessons written

What you'll learn

  1. 1
    Days 11–13
    Potential 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.

  2. 2
    Days 14–17
    DAGs, 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.

  3. 3
    Days 18–20
    Encoding 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

DoWhyNetworkXstatsmodelsPolarsmatplotlib

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:

DaysLanguageQuestion it answers
11–13Potential outcomesWhat effect do we want, and why does a comparison miss it?
14–17DAGs (causal graphs)Which variables must we adjust for, and which must we leave alone?
18–20Both, with DoWhyCan 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.