Module 8 · Day 73 of 80

Directed Acyclic Graphs

About 2.5 hours
Video lesson coming soon
Day 73 · Directed Acyclic Graphs
Module 8: Causal Inference with DoubleML · The recording will appear here as soon as it is published. The written lesson below is available now.

What you'll learn today

  • I can identify causal, backdoor and collider paths, and apply the backdoor criterion
  • My causal/dag.py finds backdoor paths, checks adjustment sets and lists minimal observed sets
  • I drew and defended a DAG for rate hikes and housing starts, edge by edge, with unobserved variables marked
  • I represent feedback with time-indexed variables and understand the reaction-function view
  • Placebo, negative-control and sensitivity tests are pre-registered in the design
  • causal/design_spec.yml specifies treatment, outcome, horizons, controls with lags, exclusions and robustness checks

Today's plan

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

LessonHands-on projectBreak2:30 total
Written lesson
TimeBlock
0:00 – 0:2573.1 DAGs: paths, backdoors and d-separation
0:25 – 0:5073.2 Code: finding backdoor paths and adjustment sets
0:50 – 0:58Break
0:58 – 1:2573.3 A DAG for rate hikes and housing starts
1:25 – 1:4573.4 Dynamics: reaction functions, lagged outcomes and horizons
1:45 – 1:53Break
1:53 – 2:1073.5 Testable implications, placebos and negative controls
2:10 – 2:3073.6 Capstone: DAG.md and the causal design specification

73.1 DAGs: Paths, Backdoors and d-Separation (25 min)

A directed acyclic graph (DAG) represents your assumptions about what causes what:

  • nodes are variables;
  • an arrow A → B means "A directly causes B";
  • acyclic: no variable causes itself through a loop (feedback over time is drawn by time-indexing variables, 73.4).

DAGs, developed by Judea Pearl and others, turn causal assumptions into precise, checkable statements.

Paths between treatment and outcome

Path typeExampleCarries
causal (front-door) pathD → M → Ythe effect we want
backdoor pathD ← C → Yspurious association (confounding)
path through a colliderD → S ← Ynothing, unless you condition on S (or a descendant of S)

Blocking rules (d-separation)

A path is blocked by a set of controls Z if it contains:

  1. a non-collider node (a chain A → B → C or a fork A ← B → C) that is in Z; or
  2. a collider (A → B ← C) where neither B nor any descendant of B is in Z.

The backdoor criterion

A set Z is a valid adjustment set for the effect of D on Y if:

  1. no variable in Z is a descendant of D (no mediators or post-treatment variables); and
  2. Z blocks every backdoor path from D to Y.

If such a Z exists and is observed, the causal effect is identified by adjusting for Z. That's exactly what DoubleML's partially linear model will do, flexibly.

Try it 73.1

For the DAG C → D, C → Y, D → M → Y, D → S ← Y, which of these sets satisfy the backdoor criterion for the total effect of D on Y: {}, {C}, {C, M}, {C, S}?

Answer

Only {C}. {} leaves the backdoor D ← C → Y open. {C, M} includes a descendant of D (the mediator M), violating condition 1. {C, S} includes the collider S, which is also a descendant of D, so conditioning on it violates condition 1 and opens a spurious path.


73.2 Code: Finding Backdoor Paths and Adjustment Sets (25 min)

Create causal/dag.py, a small, transparent implementation using networkx:

"""DAG utilities: backdoor paths, the backdoor criterion, d-separation, adjustment sets."""
from itertools import combinations

import networkx as nx


def make_dag(edges, unobserved=()):
    G = nx.DiGraph(edges)
    if not nx.is_directed_acyclic_graph(G):
        raise ValueError("graph has a cycle — time-index variables to represent feedback")
    for n in G.nodes:
        G.nodes[n]["observed"] = n not in set(unobserved)
    return G


def _is_collider(G, a, b, c) -> bool:
    return G.has_edge(a, b) and G.has_edge(c, b)


def path_blocked(G, path, Z) -> bool:
    Z = set(Z)
    for i in range(1, len(path) - 1):
        a, b, c = path[i - 1], path[i], path[i + 1]
        if _is_collider(G, a, b, c):
            if not ({b} | nx.descendants(G, b)) & Z:
                return True                      # unconditioned collider blocks
        elif b in Z:
            return True                          # conditioned chain/fork node blocks
    return False


def all_paths(G, a, b):
    return list(nx.all_simple_paths(G.to_undirected(), a, b))


def backdoor_paths(G, d, y):
    """Paths from d to y whose first edge points INTO d."""
    return [p for p in all_paths(G, d, y) if G.has_edge(p[1], d)]


def satisfies_backdoor(G, d, y, Z) -> bool:
    if set(Z) & nx.descendants(G, d):
        return False
    return all(path_blocked(G, p, Z) for p in backdoor_paths(G, d, y))


def d_separated(G, x, y, Z) -> bool:
    return all(path_blocked(G, p, Z) for p in all_paths(G, x, y))


def minimal_adjustment_sets(G, d, y, max_size=6):
    """All minimal OBSERVED adjustment sets (no proper subset is also valid)."""
    candidates = [n for n in G.nodes
                  if n not in (d, y) and G.nodes[n]["observed"]
                  and n not in nx.descendants(G, d)]
    found = []
    for k in range(0, min(max_size, len(candidates)) + 1):
        for Z in combinations(candidates, k):
            if any(set(f) <= set(Z) for f in found):
                continue                          # a smaller valid set exists
            if satisfies_backdoor(G, d, y, Z):
                found.append(Z)
    return found

Check it on the Try-it DAG:

from causal.dag import backdoor_paths, make_dag, minimal_adjustment_sets, satisfies_backdoor

G = make_dag([("C", "D"), ("C", "Y"), ("D", "M"), ("M", "Y"), ("D", "S"), ("Y", "S")])
print(backdoor_paths(G, "D", "Y"))                         # [['D', 'C', 'Y']]
for Z in [(), ("C",), ("C", "M"), ("C", "S")]:
    print(Z, satisfies_backdoor(G, "D", "Y", Z))
print(minimal_adjustment_sets(G, "D", "Y"))                # [('C',)]

Enumerating all paths is fine for teaching-sized graphs (a few dozen nodes). Libraries such as DoWhy implement these checks at scale. Day 73.6 cross-checks our answer with it, if installed.


73.3 A DAG for Rate Hikes and Housing Starts (28 min)

Now draw our DAG. Every arrow is an assumption you must be able to defend, and every missing arrow is an even stronger assumption (no direct effect).

EDGES = [
    # The Bank's reaction function: what drives rate changes (D)
    ("Inflation_lag", "D"), ("CoreInflation_lag", "D"), ("Unemployment_lag", "D"),
    ("Activity_lag", "D"), ("Housing_lag", "D"), ("USRates", "D"),
    ("PolicyRate_lag", "D"), ("Oil", "D"), ("FX_lag", "D"),

    # What drives housing starts (Y) besides policy
    ("Activity_lag", "Y"), ("Unemployment_lag", "Y"), ("Housing_lag", "Y"),
    ("Population", "Y"), ("Inflation_lag", "Y"), ("USRates", "Y"),
    ("Oil", "Y"), ("SupplyConstraints", "Y"), ("Sentiment", "Y"),

    # Transmission: the causal channels we must NOT block
    ("D", "MortgageRates"), ("MortgageRates", "Y"),
    ("D", "BondYields5y"), ("BondYields5y", "MortgageRates"),
    ("D", "HousePrices"), ("HousePrices", "Y"),
    ("D", "Y"),                                         # other direct channels

    # Links among confounders
    ("USRates", "BondYields5y"), ("Oil", "Inflation_lag"), ("Activity_lag", "Unemployment_lag"),
    ("Population", "Activity_lag"), ("Sentiment", "Activity_lag"), ("Sentiment", "D"),
    ("FX_lag", "Inflation_lag"),
]
UNOBSERVED = ["Sentiment", "SupplyConstraints", "Population"]   # not (yet) in the platform
from causal.dag import backdoor_paths, make_dag, minimal_adjustment_sets, satisfies_backdoor

G = make_dag(EDGES, unobserved=UNOBSERVED)
print("backdoor paths:", len(backdoor_paths(G, "D", "Y")))
print("minimal observed adjustment sets:")
for Z in minimal_adjustment_sets(G, "D", "Y"):
    print("  ", Z)

What the graph tells you

  1. Mediators are excluded automatically. Mortgage rates, 5-year yields and house prices are descendants of D, so they can never be in a valid adjustment set for the total effect.
  2. Unobserved confounders may break identification. Sentiment → D and → Y (via activity and directly). If it's unobserved and no observed set blocks its paths, the algorithm finds no valid set. That's a precise statement of the risk that sensitivity analysis (Days 78–80) must quantify.
  3. Arrows you choose matter. If you believe sentiment affects housing only through measured activity, delete ("Sentiment", "Y") and re-run: identification may become possible. That's a substantive economic assumption, and it must be written and defended, not slipped in.

Visualise the graph for discussion:

import matplotlib.pyplot as plt
import networkx as nx

pos = nx.spring_layout(G, seed=7, k=1.2)
colors = ["#C95555" if n in ("D", "Y") else
          "#A0B4CC" if not G.nodes[n]["observed"] else
          "#D4924A" if n in nx.descendants(G, "D") else "#4A90D9" for n in G.nodes]
plt.figure(figsize=(12, 8))
nx.draw_networkx(G, pos, node_color=colors, node_size=1800, font_size=8, arrowsize=15)
plt.title("DAG: overnight-rate changes (D) → housing starts (Y)\n"
          "red = treatment/outcome · orange = mediators · grey = unobserved · blue = observed")
plt.axis("off"); plt.tight_layout(); plt.savefig("causal/dag.png", dpi=120)

73.4 Dynamics: Reaction Functions, Lagged Outcomes and Horizons (20 min)

Monetary policy and housing interact over time: rates affect housing, and housing (with the economy) affects future rates. A DAG must be acyclic, so feedback is represented by time-indexing:

Housing_{t−1} ──► D_t ──► Housing_{t+h}
      │                      ▲
      └──────────────────────┘     (housing is persistent)
Economy_{t−1} ──► D_t,  Economy_{t−1} ──► Housing_{t+h}
D_{t−1} ──► Economy_{t−1}?  …    (past policy shapes today's state: control for the policy stance going in)

Implications for the design:

ElementDecisionWhy
lagged outcomecontrol for housing growth over recent monthsthe Bank reacts to housing; housing is persistent (a classic confounder in time series)
past policycontrol for the overnight rate level and recent changes before ttoday's change depends on the stance; past policy also affects the economy
horizonestimate θ separately for h = 3, 6, 12, 24 monthseffects build up with lags (local-projection logic)
overlapping outcomesoutcomes for nearby months share future periodsstandard errors must account for serial correlation (Days 74–77)

The reaction-function view

Under unconfoundedness, the part of D_t not explained by the information set X_t,

v_t = D_t − E[D_t | X_t],

behaves like a policy surprise: a rate change the Bank made beyond what its usual reaction to the economy would predict. DoubleML's first stage estimates exactly E[D | X] with ML (the "m(X)" nuisance function), and the effect θ is identified from how Y responds to v. That's the conceptual bridge to Days 74–77.


73.5 Testable Implications, Placebos and Negative Controls (18 min)

You can't test unconfoundedness directly, but a DAG implies things you can check.

Conditional independencies

The DAG implies that certain variables are independent given others (d-separation). For example, if your graph has no path between Oil and Population except through blocked colliders, they should be uncorrelated in the data. Use d_separated(G, a, b, Z) to list implied independencies, then test a few with regressions (a coefficient near zero, given Z). A strongly violated implication means the graph is wrong somewhere.

Placebo (pre-treatment) outcomes

A rate change at month t can't cause housing growth before t. Estimate the same model with the outcome replaced by past housing growth (e.g. from t−12 to t−6). The estimated "effect" should be zero. A significant placebo effect signals remaining confounding or anticipation, and is a red flag for the main estimate.

Negative-control outcomes

Pick an outcome that the treatment shouldn't affect at the chosen horizon, but that shares the confounders. If your adjusted estimate shows an "effect" on it, confounding remains.

Negative-control treatments

Shift the treatment into the future (D_{t+12}) and estimate its "effect" on Y_{t,h}. A future rate change can't cause today's housing, so a non-zero estimate again flags confounding by slow-moving trends.

Write these tests into the design now, before seeing the main estimates, so they can't be chosen after the fact to support a preferred answer. They'll run on Days 78–80.


73.6 Capstone: DAG.md and the Causal Design Specification (20 min)

causal/DAG.md

Document the graph like a data contract: one row per edge (and per notable missing edge), with justification.

# Causal DAG — overnight-rate changes → housing starts

Figure: causal/dag.png (generated by notebooks/day73.ipynb)

## Edges
| From | To | Justification | Confidence |
|---|---|---|---|
| Inflation_lag | D | 2% inflation-targeting mandate; the Bank reacts to inflation | high |
| Housing_lag | D | financial-stability concerns; housing in the Bank's assessments | medium |
| D | MortgageRates | pass-through from policy to variable and fixed mortgage rates | high |
| … | | | |

## Notable missing edges (assumptions!)
| Missing | Why we assume it absent | How we probe it |
|---|---|---|
| Sentiment → Y (direct) | sentiment affects housing mainly via measured activity | sensitivity analysis; placebo tests |
| … | | |

## Unobserved variables
Sentiment, supply constraints, population growth (candidate data addition).

## Adjustment set chosen
{…}, from causal/dag.py; mediators excluded by construction.

causal/design_spec.yml

Translate the DAG into a reviewed specification, the causal counterpart of Day 68's feature spec. Days 74–80 implement exactly this, nothing more and nothing less:

question: >
  Effect of a 1 percentage-point increase in the Bank of Canada overnight rate during month t
  on housing starts growth h months later, holding the pre-decision economic situation fixed.
unit: month
sample: {start: "2006-01", note: "extend if the panel is backfilled (Day 68)"}

treatment:
  name: d_overnight
  definition: "overnight_rate(end of t) - overnight_rate(end of t-1), percentage points"
  source: macro_monthly.overnight_rate
  type: continuous

outcome:
  name: housing_growth_h
  definition: "100 * (log HS[t+h] - log HS[t-1])"
  horizons: [3, 6, 12, 24]
  source: "housing starts (added on Day 78 via the platform: source → staging → mart → tests)"

controls:            # pre-treatment, publication lags respected (Day 68 availability builder)
  - {name: cpi_yoy,      column: headline_yoy,       lag: 1, role: confounder}
  - {name: core_yoy,     column: core_ex_fe_yoy,     lag: 1, role: confounder}
  - {name: unemp,        column: unemployment_rate,  lag: 1, role: confounder}
  - {name: unemp_chg12,  column: unemployment_rate,  lag: 1, transform: diff12, role: confounder}
  - {name: cli,          column: oecd_cli,           lag: 3, role: confounder}
  - {name: fed_funds,    column: fed_funds,          lag: 1, role: confounder}
  - {name: wti_pct12,    column: wti_eom,            lag: 1, transform: pct12, role: confounder}
  - {name: usd_cad_pct12, column: usd_cad_eom,       lag: 1, transform: pct12, role: confounder}
  - {name: overnight,    column: overnight_rate,     lag: 1, role: policy_stance}
  - {name: d_overnight_l1_6, column: overnight_rate, lag: 1, transform: diff6, role: past_policy}
  - {name: housing_growth_l1_6, column: housing_starts, lag: 1, transform: logdiff6, role: lagged_outcome}

excluded:            # never controls, with reasons (bad controls)
  - {name: mortgage_rates,   reason: "mediator (D → mortgage rates → housing)"}
  - {name: yield_5yr,        reason: "mediator (D → 5-year yields → mortgage rates)"}
  - {name: house_prices,     reason: "mediator"}
  - {name: any variable dated ≥ t, reason: "post-treatment"}

unobserved_confounders: [sentiment, supply_constraints, population_growth]

estimator: "DoubleML partially linear regression (PLR), cross-fitted, ML nuisance models (Days 74–77)"
inference: "standard errors robust to serial correlation from overlapping horizons"

robustness:          # pre-registered before seeing results
  - placebo outcome: housing growth from t-12 to t-6
  - negative-control treatment: d_overnight at t+12
  - alternative treatment: 3-month cumulative change
  - sample excluding 2020-03 to 2021-06 (pandemic)
  - sensitivity analysis for unobserved confounding (DoubleML)
  - linear (OLS) controls vs ML controls

Optional: cross-check with DoWhy

import networkx as nx
import pandas as pd
from dowhy import CausalModel

from causal.dag import make_dag

G = make_dag(EDGES, unobserved=UNOBSERVED)
observed = [n for n in G.nodes if G.nodes[n]["observed"]]
dummy = pd.DataFrame(0.0, index=range(10), columns=observed)
model = CausalModel(data=dummy, treatment="D", outcome="Y",
                    graph="".join(nx.generate_gml(G)))
print(model.identify_effect())

DoWhy reports whether a backdoor estimand is identified, and with which variables. Compare with minimal_adjustment_sets. (APIs evolve; if this snippet needs adjustment for your version, the comparison remains the goal.)

Recap: Days 71–73

ConceptYou can now…
causal questionsseparate association, prediction and causation; define potential outcomes and estimands
identificationstate unconfoundedness, overlap, SUTVA and no anticipation, and their limits in macro
confoundingdemonstrate and quantify omitted-variable bias by simulation
bad controlsrecognise mediators, colliders and instrument-like variables, and avoid them
timinguse pre-treatment controls with publication lags
diagnosticsassess balance and overlap in real data
DAGsfind backdoor paths, valid adjustment sets and testable implications, in code
designwrite a reviewed causal design specification before estimating anything

Looking ahead: Days 74–77

  • Nuisance parameters: the reaction function m(X) = E[D | X] and the outcome function ℓ(X) = E[Y | X], learned with ML (random forests, XGBoost, regularised regression).
  • Why naive "plug-in" ML is biased for θ (regularisation and overfitting bias), and how orthogonal scores and cross-fitting fix it.
  • The partially linear regression model in the doubleml package: first on simulations with known effects, then with time-series-aware sample splitting for monthly macro data.

Day 73 checklist

  • I can identify causal, backdoor and collider paths, and apply the backdoor criterion
  • My causal/dag.py finds backdoor paths, checks adjustment sets and lists minimal observed sets
  • I drew and defended a DAG for rate hikes and housing starts, edge by edge, with unobserved variables marked
  • I represent feedback with time-indexed variables and understand the reaction-function view
  • Placebo, negative-control and sensitivity tests are pre-registered in the design
  • causal/design_spec.yml specifies treatment, outcome, horizons, controls with lags, exclusions and robustness checks
git add causal notebooks && git commit -m "Day 73: DAG, adjustment sets, causal design specification" && git push