Module 2 · Day 17 of 80

Front-Door Paths, Mediators and Bad Controls

About 5 hours
Video lesson coming soon
Day 17 · Front-Door Paths, Mediators and Bad Controls

What you'll learn today

  • I can distinguish total and direct effects, and explain what controlling for a mediator does
  • I can state the front-door conditions and show what breaks when one fails
  • I can spot bad controls and explain why "the estimate looks more reasonable" is not evidence
  • I can classify every column of a dataset as a control, using a DAG

Today's plan

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

LessonHands-on projectBreak5:00 total
Written lesson
TimeBlock
0:00 – 0:4517.1 Mediators: total versus direct effects
0:45 – 1:3017.2 The front-door criterion
1:30 – 1:45Break
1:45 – 2:4017.3 Bad controls in NorthMart
2:40 – 3:2017.4 A field guide to controls
3:20 – 3:35Break
3:35 – 4:4517.5 Day 17 mini-project: let the DAG classify every column
4:45 – 5:0017.6 Save your work with Git

17.1 Mediators: total versus direct effects (45 min)

Concept. A mediator lies on a causal path from treatment to outcome: email → site visits → orders. The total effect of the email includes everything it causes, through visits and otherwise. Controlling for the mediator leaves only the direct effect: what the email does other than through visits.

Business questions almost always want the total effect ("did the email increase orders?"). Controlling for a mediator answers a different, narrower question, usually without anyone noticing.

Create day17/mediator.py:

# day17/mediator.py   (python -m day17.mediator)
"""A mediator carries part of the effect. Controlling for it removes that part."""
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

rng = np.random.default_rng(17)
n = 50_000
email = rng.integers(0, 2, n)                          # randomised: no confounding at all
visits = 1.0 + 0.8 * email + rng.normal(size=n)        # email -> site visits
orders = 0.5 * visits + 0.2 * email + rng.normal(size=n)   # visits -> orders, plus a direct effect

df = pd.DataFrame({"email": email, "visits": visits, "orders": orders})
total = smf.ols("orders ~ email", df).fit().params["email"]
direct = smf.ols("orders ~ email + visits", df).fit().params["email"]

print(f"True total effect   0.8 x 0.5 + 0.2 = {0.8 * 0.5 + 0.2:.2f}")
print(f"orders ~ email                     {total:.3f}   <- the total effect")
print(f"orders ~ email + visits            {direct:.3f}   <- only the direct part")
python -m day17.mediator
True total effect   0.8 x 0.5 + 0.2 = 0.60
orders ~ email                     0.631   <- the total effect
orders ~ email + visits            0.223   <- only the direct part

Even in a randomised experiment, "controlling for visits to be safe" throws away two thirds of the effect. (And it can do worse: if anything unobserved affects both visits and orders, visits becomes a collider too, and the "direct effect" is biased.)


17.2 The front-door criterion (45 min)

Concept. Sometimes a mediator rescues identification. Suppose an unobserved confounder drives both treatment and outcome (no backdoor fix possible, like NorthMart's intensity), but:

  1. the treatment affects the outcome only through an observed mediator M, and
  2. the confounder does not affect M directly.

Then the effect can be computed in two steps, each free of confounding: T → M (no backdoor path into T affects M), and M → Y controlling for T (T blocks the backdoor path from M). Multiply them (in linear models).

Create day17/frontdoor.py:

# day17/frontdoor.py   (python -m day17.frontdoor)
"""
Front-door adjustment: an unobserved confounder blocks the backdoor route,
but the effect flows entirely through an observed mediator we can use instead.
"""
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

rng = np.random.default_rng(170)
n = 50_000
u = rng.normal(size=n)                                  # unobserved intensity
email = (u + rng.normal(size=n) > 0).astype(int)        # targeted on u
visits = 1.0 + 0.8 * email + rng.normal(size=n)         # email -> visits (u does NOT affect visits)
orders = 0.5 * visits + 1.0 * u + rng.normal(size=n)    # visits -> orders, u -> orders

df = pd.DataFrame({"email": email, "visits": visits, "orders": orders})
naive = smf.ols("orders ~ email", df).fit().params["email"]
a = smf.ols("visits ~ email", df).fit().params["email"]              # step 1: email -> visits
b = smf.ols("orders ~ visits + email", df).fit().params["visits"]    # step 2: visits -> orders, holding email
print(f"True effect of email          {0.8 * 0.5:.3f}")
print(f"Naive (confounded by u)       {naive:.3f}")
print(f"Front door: {a:.3f} x {b:.3f}       = {a * b:.3f}")
True effect of email          0.400
Naive (confounded by u)       1.545
Front door: 0.796 x 0.503       = 0.400

Try it. Break assumption 2: let intensity also raise site visits (visits = 1.0 + 0.8 * email + 0.5 * u + rng.normal(size=n)). What happens to the front-door estimate?

Solution
True effect of email          0.400
Naive (confounded by u)       1.830
Front door: 1.367 x 0.791       = 1.081

Step 1 (email → visits) is now confounded by u, and the estimate is badly off. The front door needs a mediator that is fully shielded from the confounder, which is rare. Use it when you can defend that, mostly in settings where the mechanism is technical (a recommendation algorithm, a delivery system) rather than behavioural.


17.3 Bad controls in NorthMart (55 min)

Concept. A bad control is a variable affected by the treatment. Controlling for it removes part of the effect (if it's a mediator), creates collider bias (if it shares causes with the outcome), or both. In NorthMart, two columns are measured after the email: post_active_weeks and post_revenue.

Create day17/bad_controls.py:

# day17/bad_controls.py   (python -m day17.bad_controls)
"""Controlling for something the email affects: the estimate collapses."""
import polars as pl
import statsmodels.formula.api as smf

from causalkit.data import load_customers

df = load_customers().with_columns(pl.col("email").cast(pl.Int8),
                                   loyalty=pl.col("is_loyalty").cast(pl.Int8)).to_pandas()
base = "post_orders ~ email + loyalty + pre_orders"
for name, f in [("pre-treatment controls only", base),
                ("+ post_active_weeks (BAD)", base + " + post_active_weeks"),
                ("+ post_revenue (BAD)", base + " + post_revenue")]:
    fit = smf.ols(f, data=df).fit(cov_type="HC2")
    print(f"{name:<30} email coefficient {fit.params['email']:+.3f}")
print("Truth (ATT): +0.443")
pre-treatment controls only    email coefficient +1.062
+ post_active_weeks (BAD)      email coefficient +0.111
+ post_revenue (BAD)           email coefficient +0.573
Truth (ATT): +0.443

Look at the second line: adding a bad control moved the estimate from 1.06 to 0.11, and 0.11 is closer to the truth than 1.06. That's a coincidence of two wrongs: confounding pushed the estimate up, the bad control pushed it down. Judge controls by the DAG, never by whether the estimate "looks more reasonable".


17.4 A field guide to controls (40 min)

KindDAG positionControl for it?NorthMart example
Confoundercauses T and Yyesloyalty
Proxy of a latent confounderchild of a confounderhelps, never fullypre_orders
Outcome predictorcauses Y onlyoptional: improves precision(none here; email in Day 20)
Treatment predictorcauses T onlyno: inflates variance (sometimes bias)a random targeting tie-breaker
Neutralunconnectedharmlessage, region
MediatorT → M → Yno (for total effects)site visits
Collider / descendant of TT → C ← …neverpost_active_weeks
M-bias colliderpre-treatment collider of two latentsnostore traffic (Day 15)

The two questions to ask about every candidate control:

  1. Could the treatment have changed it? If yes, leave it out.
  2. Does it sit on a backdoor path? If yes, include it (or something else that blocks that path).

17.5 Day 17 mini-project: let the DAG classify every column (70 min)

Goal. Extend the NorthMart DAG to every column of the customer table, then let code apply the field guide: descendants of the email are bad controls; variables connected to both email and outcome are useful; the rest are neutral.

Create day17/control_audit.py:

# day17/control_audit.py   (python -m day17.control_audit)
"""Mini-project: classify every column of the customer table as a control, using the DAG."""
import networkx as nx
import polars as pl

from causalkit.dags import make_dag

g = make_dag([
    # how the email was targeted (Day 14)
    ("loyalty", "intensity"), ("loyalty", "email"), ("intensity", "email"),
    # pre-period measurements of intensity
    ("intensity", "pre_orders"), ("pre_orders", "pre_revenue"), ("pre_orders", "pre_active_weeks"),
    ("intensity", "days_since_last_order"),
    # outcomes
    ("email", "post_orders"), ("intensity", "post_orders"),
    ("post_orders", "post_revenue"), ("post_orders", "post_active_weeks"),
], latent={"intensity"})
for neutral in ["age", "region", "tenure_days", "pre_discount_share"]:
    g.add_node(neutral, latent=False)       # in the data; no arrows in or out (the generator ignores them)

T, Y = "email", "post_orders"
no_effect = g.copy()
no_effect.remove_edges_from(list(g.out_edges(T)))   # the graph without the email's effect

rows = []
for v in sorted(n for n in g.nodes if not g.nodes[n]["latent"] and n not in (T, Y)):
    if v in nx.descendants(g, T):
        verdict, why = "BAD control", "affected by the email (post-treatment)"
    elif not nx.is_d_separator(no_effect, {v}, {T}, set()) and not nx.is_d_separator(no_effect, {v}, {Y}, set()):
        verdict, why = "useful control", "helps block a backdoor path (tied to email and outcome)"
    else:
        verdict, why = "neutral", "unrelated to the email or the outcome"
    rows.append({"column": v, "verdict": verdict, "why": why})

with pl.Config(tbl_rows=-1, fmt_str_lengths=60, tbl_width_chars=120):
    print(pl.DataFrame(rows))
python -m day17.control_audit
│ column                ┆ verdict        ┆ why                                                     │
│ age                   ┆ neutral        ┆ unrelated to the email or the outcome                   │
│ days_since_last_order ┆ useful control ┆ helps block a backdoor path (tied to email and outcome) │
│ loyalty               ┆ useful control ┆ helps block a backdoor path (tied to email and outcome) │
│ post_active_weeks     ┆ BAD control    ┆ affected by the email (post-treatment)                  │
│ post_revenue          ┆ BAD control    ┆ affected by the email (post-treatment)                  │
│ pre_active_weeks      ┆ useful control ┆ helps block a backdoor path (tied to email and outcome) │
│ pre_discount_share    ┆ neutral        ┆ unrelated to the email or the outcome                   │
│ pre_orders            ┆ useful control ┆ helps block a backdoor path (tied to email and outcome) │
│ pre_revenue           ┆ useful control ┆ helps block a backdoor path (tied to email and outcome) │
│ region                ┆ neutral        ┆ unrelated to the email or the outcome                   │
│ tenure_days           ┆ neutral        ┆ unrelated to the email or the outcome                   │

Paste the table into docs/ESTIMANDS.md under "Control audit". Add one sentence explaining why "useful control" doesn't mean "sufficient": Day 15 showed that all five useful controls together still leave a bias of more than 0.6 orders.


17.6 Save your work with Git (15 min)

git add .
git commit -m "Day 17: mediators, front door, bad controls, control audit"
git push

Coming next (Days 18–20). Day 18 hands the DAG to DoWhy, a library that performs identification, estimation and refutation for you, and shows what its checks can and can't catch. Day 19 resolves Simpson's paradox with a DAG. Day 20 is the Module 2 capstone: Defending the DAG for NorthMart's discount programme.

Day 17 checklist

  • I can distinguish total and direct effects, and explain what controlling for a mediator does
  • I can state the front-door conditions and show what breaks when one fails
  • I can spot bad controls and explain why "the estimate looks more reasonable" is not evidence
  • I can classify every column of a dataset as a control, using a DAG