Drawing the Map: DAGs and the Backdoor Criterion (Part 2)

This is Part 2 of a series where we walk through causal inference end-to-end. In Part 1, we defined a precise causal question — “what is the average effect of a promotional email on customer spend?” — and showed how a naive analysis overestimated the true $10 effect by 6x due to selection bias. Now we formalize that intuition into something we can reason with systematically.


Why You Need a Map

In Part 1, we identified loyalty as a confounder by talking to the marketing team. That worked because our example was simple: one treatment, one outcome, one obvious confounder. But real-world problems aren’t this clean.

Imagine your marketing team comes back and says: “Actually, we also have data on past purchase history, customer income, website visits after the email, and the customer’s preferred shopping channel. Should we control for all of them?”

Your gut might say “more controls = better.” That gut is wrong, and it will lead you to biased estimates. Controlling for the wrong variable can create bias where none existed. This isn’t a theoretical curiosity — it happens constantly in practice, and it’s one of the most common mistakes in applied causal analysis.

What you need is a map of the causal relationships between your variables — a map that tells you exactly which variables to control for and which to leave alone. That map is called a Directed Acyclic Graph, or DAG.

What Is a DAG?

A DAG is a diagram where nodes represent variables and arrows represent direct causal effects. “Directed” means the arrows have a direction (causes point to effects). “Acyclic” means you can’t follow the arrows in a loop back to where you started.

Here’s the critical thing to understand: a DAG represents your assumptions about how the world works, not something you discover from data. You draw it based on domain knowledge, conversations with stakeholders, literature, and reasoning. The data can’t tell you the causal structure — that’s the whole point of causal inference.

Every DAG is built from three basic structures. Understanding these three patterns is the foundation for everything else in this article.

The Fork (Confounder): X ← Z → Y

Z causes both X and Y. This creates a spurious association between X and Y even if there’s no direct causal relationship between them. This is the pattern we saw in Part 1: loyalty (Z) causes both email targeting (X) and spending (Y).

What to do: Control for Z. This blocks the non-causal path and isolates the true causal effect.

The Chain (Mediator): X → M → Y

X causes M, which causes Y. The variable M mediates X’s effect on Y. If you’re estimating the total effect of X on Y, you should not control for M — doing so would block part of the causal path you’re trying to measure.

What to do: Leave M alone when estimating total effects. (You’d only control for it if you specifically want the direct effect of X on Y, bypassing M.)

The Collider: X → C ← Y

Both X and Y cause C. This is the dangerous one. By default, this path is already blocked — X and Y are not associated through C. But if you control for C (condition on it, stratify by it, include it in a regression), you open this path and create a spurious association between X and Y.

What to do: Do not control for C. Leave it alone. Controlling for a collider is one of the most common sources of bias in observational studies, and it’s counterintuitive because it feels like “more information should help.”

The Hollywood Collider: An Intuition Pump

The collider concept trips people up, so let’s build intuition with a classic example before returning to our marketing data.

Suppose talent and beauty are completely independent in the general population. No correlation at all. But Hollywood selects actors based on a combination of both — if you’re extremely talented, you can get hired even if you’re average-looking, and vice versa. “Getting hired” is a collider: Talent → Hired ← Beauty.

Now, if you look only at hired actors (conditioning on the collider), you’ll see a negative correlation between talent and beauty. The beautiful actors in the sample tend to be less talented, and the talented ones tend to be less beautiful. This isn’t because beauty destroys talent — it’s an artifact of selecting on a variable caused by both.

Let’s see it in code:

import numpy as np
import pandas as pd

np.random.seed(42)
n = 5_000

# Talent and beauty are completely independent
talent = np.random.normal(50, 15, n)
beauty = np.random.normal(50, 15, n)

# Getting hired depends on both (this is the collider)
hire_score = 0.5 * talent + 0.5 * beauty + np.random.normal(0, 10, n)
hired = hire_score > np.percentile(hire_score, 80)  # top 20%

# In the general population: no correlation
r_all = np.corrcoef(beauty, talent)[0, 1]
print(f"Correlation (general population): {r_all:.3f}")

# Among hired actors only: negative correlation appears!
r_hired = np.corrcoef(beauty[hired], talent[hired])[0, 1]
print(f"Correlation (hired actors only):  {r_hired:.3f}")

Correlation (general population): 0.006
Correlation (hired actors only):  -0.32

The left panel shows no correlation in the general population. The right panel shows that once you condition on being hired, a strong negative correlation appears out of thin air. That’s collider bias.

Now map this back to our marketing example. If website visits are caused by both receiving the email (people click through) and spending level (big spenders visit the site more), then website visits are a collider. Controlling for it in your model would bias your estimate of the email’s effect on spending.

Building Our Marketing DAG

Let’s formalize the causal structure we identified in Part 1. We’ll start with the simple version, then extend it.

The Simple DAG

From our conversation with the marketing team, we know:

  1. Loyalty drives email targeting. High-loyalty customers were more likely to receive the email.
  2. Loyalty drives spending. Loyal customers spend more regardless of the email.
  3. The email has a causal effect on spending. This is the effect we want to estimate.

In code, we represent this as a directed graph:

import networkx as nx

G = nx.DiGraph()
G.add_edges_from([
    ("Loyalty", "Email"),      # loyalty drives targeting
    ("Loyalty", "Spend"),      # loyalty drives spending
    ("Email", "Spend"),        # email causes spending (the effect we want)
])

print("Nodes:", list(G.nodes()))
print("Edges:", list(G.edges()))

This is a textbook fork. Loyalty is the confounder sitting at the top, creating a backdoor path from Email to Spend: Email ← Loyalty → Spend. We can enumerate the paths programmatically:

# All paths between Email and Spend (ignoring arrow direction)
all_paths = list(nx.all_simple_paths(G.to_undirected(), "Email", "Spend"))
print("All paths between Email and Spend:")
for p in all_paths:
    print(f"  {' → '.join(p)}")

All paths between Email and Spend:
  Email → Loyalty → Spend
  Email → Spend

Two paths. The first one — Email → Loyalty → Spend — is the backdoor path (the arrow actually goes into Email from Loyalty, so it starts with an arrow pointing into the treatment). The second — Email → Spend — is the causal path we want to measure.

The Backdoor Criterion

Now we can state the rule that drives the rest of the analysis. A set of variables Z satisfies the backdoor criterion relative to the treatment-outcome pair (T, Y) if:

  1. No variable in Z is a descendant of T (i.e., don’t control for anything caused by the treatment).
  2. Z blocks every backdoor path between T and Y (i.e., every non-causal path from T to Y that starts with an arrow into T).

If you find such a set Z, you can estimate the causal effect by adjusting for Z — via regression, matching, weighting, or stratification.

For our simple DAG, the backdoor criterion tells us:

  • There’s one backdoor path: Email ← Loyalty → Spend.
  • Conditioning on Loyalty blocks this path.
  • Loyalty is not a descendant of Email, so it’s safe to condition on.

Therefore: control for loyalty, and the causal effect is identified.

# Identify backdoor paths programmatically
def find_backdoor_paths(G, treatment, outcome):
    """Find all backdoor paths: paths that start with an arrow INTO treatment."""
    paths = []
    undirected_paths = nx.all_simple_paths(G.to_undirected(), treatment, outcome)
    for path in undirected_paths:
        if len(path) >= 3:
            # Check if the first edge points INTO the treatment (backdoor)
            second_node = path[1]
            if G.has_edge(second_node, treatment):  # arrow goes INTO treatment
                paths.append(path)
    return paths

backdoor_paths = find_backdoor_paths(G, "Email", "Spend")
print("Backdoor paths:")
for p in backdoor_paths:
    print(f"  {' ← '.join([p[0], p[1]])} → {' → '.join(p[1:])}")

# What to control for
parents_of_treatment = list(G.predecessors("Email"))
print(f"\nParents of treatment: {parents_of_treatment}")
print(f"Adjustment set: {parents_of_treatment}")

Backdoor paths:
  Email ← Loyalty → Loyalty → Spend

Parents of treatment: ['Loyalty']
Adjustment set: ['Loyalty']

Verifying With Data: Stratification

Let’s apply the backdoor criterion to our data from Part 1 using the simplest possible method — stratification. We split customers into bins by loyalty score, compute the treatment effect within each bin, and average:

# Recreate Part 1 data
np.random.seed(42)
n = 10_000
loyalty = np.random.beta(2, 5, size=n) * 100
propensity = 1 / (1 + np.exp(-(loyalty - 50) / 10))
treatment = np.random.binomial(1, propensity)
TRUE_EFFECT = 10
base_spend = 20 + 1.5 * loyalty + np.random.normal(0, 15, size=n)
spend = base_spend + TRUE_EFFECT * treatment
spend = np.clip(spend, 0, None)

df = pd.DataFrame({
    "loyalty_score": loyalty,
    "received_email": treatment,
    "spend": spend,
})

# Stratify by loyalty score
df['loyalty_bin'] = pd.qcut(df.loyalty_score, q=10, labels=False)

effects = []
for b in range(10):
    sub = df[df.loyalty_bin == b]
    treated = sub[sub.received_email == 1].spend
    control = sub[sub.received_email == 0].spend
    if len(treated) > 10 and len(control) > 10:
        effect = treated.mean() - control.mean()
        effects.append((effect, len(sub)))
        print(f"  Bin {b}: effect = ${effect:.2f}  (n_treated={len(treated)}, n_control={len(control)})")

# Weighted average
stratified_ate = sum(e * w for e, w in effects) / sum(w for _, w in effects)
print(f"\nStratified ATE: ${stratified_ate:.2f}")
print(f"True effect:    ${TRUE_EFFECT}")

Bin 0: effect = $12.38  (n_treated=18, n_control=982)
  Bin 1: effect = $8.45   (n_treated=52, n_control=948)
  Bin 2: effect = $10.92  (n_treated=112, n_control=888)
  ...
  Bin 9: effect = $10.67  (n_treated=874, n_control=126)

Stratified ATE: $11.14
True effect:    $10

Within each loyalty bin, the treatment and control groups are more comparable — they have similar loyalty scores. So the within-bin comparisons are closer to the true effect. The weighted average across bins gives us $11.14, reasonably close to the true $10.

This is the backdoor criterion in action: condition on the confounder, and the bias largely disappears. The remaining imprecision comes from finite bins and sample size, not from confounding.

What Happens When You Control for the Wrong Thing

Now let’s extend the DAG and show where things go wrong. Suppose after the campaign you also have data on website visits — how many times each customer visited the site in the week after the email was sent.

A well-meaning analyst might think: “Website visits are correlated with both email receipt and spending. I should control for them to make the analysis more precise.”

This is a mistake, and the DAG tells you exactly why.

Website visits are a collider: the email causes people to visit the site (they click through), and spending also drives visits (people who buy a lot visit more to browse). The causal structure is:

Email → Website Visits ← Spend

# Extended DAG
G_ext = nx.DiGraph()
G_ext.add_edges_from([
    ("Loyalty", "Email"),
    ("Loyalty", "Spend"),
    ("Email", "Spend"),
    ("Email", "Website_Visits"),     # email drives visits
    ("Spend", "Website_Visits"),     # spending drives visits
])

# Is Website_Visits a descendant of Email?
descendants = nx.descendants(G_ext, "Email")
print(f"Descendants of Email: {descendants}")
print(f"Website_Visits is a descendant of Email: {'Website_Visits' in descendants}")

Descendants of Email: {'Spend', 'Website_Visits'}
Website_Visits is a descendant of Email: True

Website Visits is a descendant of the treatment — that alone violates rule 1 of the backdoor criterion. But it gets worse. Website Visits is a collider on the path Email → Website Visits ← Spend. Controlling for it opens a spurious path between Email and Spend through Website Visits, introducing bias that wasn’t there before.

Let’s see this empirically:

# Add website visits as a collider
website_visits = 3 + 0.5 * treatment + 0.02 * spend + np.random.normal(0, 2, n)
df['website_visits'] = website_visits

# WRONG: stratify by website visits (conditioning on collider)
high_visit = df[df.website_visits > df.website_visits.median()]
collider_estimate = (high_visit[high_visit.received_email == 1].spend.mean() - 
                     high_visit[high_visit.received_email == 0].spend.mean())

# RIGHT: stratify by loyalty (conditioning on confounder)
stratified_ate = 11.14  # from earlier

print(f"Controlling for loyalty (correct):        ${stratified_ate:.2f}")
print(f"Controlling for website visits (WRONG):   ${collider_estimate:.2f}")
print(f"True effect:                              ${TRUE_EFFECT:.2f}")

Controlling for loyalty (correct):        $11.14
Controlling for website visits (WRONG):   $38.71
True effect:                              $10.00

Controlling for the collider inflates our estimate from $11 to $39. We just made our analysis worse by including more information. This is the danger the DAG protects you from.

The Extended DAG: A More Realistic Scenario

Real marketing analyses have more than three variables. Let’s extend our DAG to something closer to what you’d actually encounter, and use the backdoor criterion to figure out the right adjustment set.

The extended DAG includes:

  • Loyalty Score → Email, Spend (confounder, as before)
  • Past Purchases → Email, Spend (another confounder — targeting also used purchase history)
  • Income → Past Purchases, Spend (confounder — wealthier customers spend more and buy more historically)
  • Channel Preference → Email, Spend (confounder — customers who prefer email engage more with both the campaign and shopping)
  • Email → Spend (the causal effect)
  • Email → Website Visits and Spend → Website Visits (collider — do NOT control)
G_full = nx.DiGraph()
G_full.add_edges_from([
    ("Loyalty", "Email"),
    ("Loyalty", "Spend"),
    ("PastPurchases", "Email"),
    ("PastPurchases", "Spend"),
    ("Income", "PastPurchases"),
    ("Income", "Spend"),
    ("ChannelPref", "Email"),
    ("ChannelPref", "Spend"),
    ("Email", "Spend"),
    ("Email", "WebsiteVisits"),
    ("Spend", "WebsiteVisits"),
])

# Find all parents of treatment that are also ancestors of outcome
def minimal_adjustment_set(G, treatment, outcome):
    """Find variables that satisfy the backdoor criterion."""
    ancestors_of_treatment = nx.ancestors(G, treatment)
    descendants_of_treatment = nx.descendants(G, treatment)
    
    # Candidates: ancestors of treatment that aren't descendants of treatment
    candidates = ancestors_of_treatment - descendants_of_treatment
    
    # Filter to those that are also ancestors of outcome (or directly connected)
    ancestors_of_outcome = nx.ancestors(G, outcome)
    adjustment = candidates & (ancestors_of_outcome | {outcome})
    
    return adjustment

adj_set = minimal_adjustment_set(G_full, "Email", "Spend")
print(f"Adjustment set: {adj_set}")
print(f"\nDo NOT control for: WebsiteVisits (collider / descendant of treatment)")

Adjustment set: {'Loyalty', 'PastPurchases', 'Income', 'ChannelPref'}

The DAG tells us: control for Loyalty, Past Purchases, Income, and Channel Preference. Do not control for Website Visits. This isn’t a matter of statistical preference — it’s a structural fact about the causal relationships in the system.

A Practical Summary: The Three Rules

Every time you’re deciding whether to include a variable in your causal model, check which pattern it fits.

1. Fork (confounder): The variable causes both treatment and outcome. → Control for it. This blocks the backdoor path.

2. Chain (mediator): The variable sits on the causal path from treatment to outcome. → Don’t control for it if you want the total effect. Controlling for it removes part of the effect you’re trying to measure.

3. Collider: Both the treatment and outcome (or their causes) feed into this variable. → Don’t control for it. Conditioning on it opens a spurious path that creates bias.

These three patterns compose. Any DAG, no matter how complex, is made of forks, chains, and colliders connected together. If you can identify which pattern each variable falls into, you can navigate any causal diagram.

Common DAG Mistakes in Practice

A few patterns I see over and over in real analyses:

“Kitchen sink” regression. Throwing every available variable into a regression as a control. This almost certainly includes colliders and descendants of treatment, and can make bias worse than a simple unadjusted comparison. The DAG tells you which variables to include — and which to exclude.

Controlling for a post-treatment variable. Any variable that occurs after the treatment (and could be caused by it) is suspect. In our example, website visits happen after the email is sent. Even if it’s not a pure collider, controlling for post-treatment variables risks blocking the causal path or introducing collider bias through downstream effects.

Forgetting about unobserved confounders. A DAG should include variables you can’t measure, drawn with a dashed circle or a different notation. If there’s an unobserved confounder on a backdoor path, the backdoor criterion tells you the effect is not identified by adjustment alone — you need a different strategy (instrumental variables, regression discontinuity, etc.). Knowing this before you start modeling saves enormous amounts of wasted effort.

Treating the DAG as fixed. Your DAG is a set of assumptions, and assumptions can be wrong. Good practice is to draw multiple plausible DAGs, check whether your conclusions are robust across them, and identify which specific assumptions matter most. We’ll formalize this with sensitivity analysis in Part 5.

Drawing Your Own DAG: A Practical Process

Here’s how I approach DAG construction in real projects:

Step 1: List all relevant variables. Start with treatment and outcome. Then add everything from your dataset and domain knowledge that might be related to either. Include unobserved variables you know about but can’t measure.

Step 2: Draw arrows based on domain knowledge. For each pair of variables, ask: “Does A plausibly cause B?” If yes, draw an arrow. The absence of an arrow is also an assumption — it says “no direct causal effect.” Be thoughtful about what you’re leaving out.

Step 3: Classify each variable. Walk through your DAG and label each non-treatment, non-outcome variable as a confounder, mediator, collider, or irrelevant (no path to treatment or outcome). This tells you your adjustment set.

Step 4: Apply the backdoor criterion. Check if the set of confounders you can measure blocks all backdoor paths. If yes, proceed to estimation. If not, you need a different identification strategy.

Step 5: Check with colleagues. A DAG should be a collaborative artifact. Share it with the marketing team, the product manager, the domain expert. Ask: “Does this arrow make sense? Am I missing anything?” The most valuable feedback often comes from people who know the data-generating process, not from other data scientists.

# A template for documenting your DAG in a notebook
DAG_ASSUMPTIONS = """
Marketing Campaign DAG — Key Assumptions:

1. Loyalty → Email:    Marketing targeted high-loyalty customers (confirmed by marketing team)
2. Loyalty → Spend:    Loyal customers spend more (well-established in customer analytics)
3. Income → Spend:     Higher income enables higher spending (economic theory)
4. Income → PastPurch: Higher income leads to more historical purchases (reasonable)
5. PastPurch → Email:  Targeting model uses purchase history (confirmed by marketing team)
6. PastPurch → Spend:  Past behavior predicts future behavior (strong empirical regularity)
7. ChanPref → Email:   Email-preferring customers more likely targeted (confirmed)
8. ChanPref → Spend:   Channel preference correlates with shopping behavior (assumed)
9. Email → Spend:      THE CAUSAL EFFECT WE'RE ESTIMATING
10. Email → Visits:    Email drives website traffic (reasonable; click-through)
11. Spend → Visits:    Buyers browse the site more (reasonable)

NOT included (assumed no direct effect):
- Income → Email (targeting didn't use income data — confirmed)
- Loyalty → Website Visits (only indirect, through Email and Spend)

UNOBSERVED potential confounders:
- Customer motivation / intent to purchase (could confound Email → Spend if correlated
  with loyalty but not captured by it). Addressed in sensitivity analysis (Part 5).
"""
print(DAG_ASSUMPTIONS)

Document your assumptions. Every arrow — and every missing arrow — is a claim about the world. Write them down, justify them, and be explicit about which ones you’re uncertain about.

Where We Stand

In Part 1, we defined the causal question and identified the confounding problem through conversation and intuition. In this article, we formalized that intuition into a DAG — a visual, logical structure that tells us exactly what to control for and what to leave alone.

The key takeaways:

A DAG is a map of your assumptions, not a data discovery. You draw it from domain knowledge, then use it to derive your statistical strategy.

The backdoor criterion is your guide to choosing control variables. Condition on confounders (forks). Leave mediators (chains) and colliders alone. Get this wrong and you can make your estimate worse than doing nothing.

Collider bias is real and common. The instinct to “control for everything” actively harms your analysis when some of those variables are colliders. The DAG is what protects you.

For our marketing example, the adjustment set is {Loyalty, Past Purchases, Income, Channel Preference}. Website Visits is a collider and must be excluded. This gives us a concrete plan for estimation.

What’s Coming Next

Part 3: Methods That Work When Regression Doesn’t — Now that we know what to control for, we need to figure out how. We’ll implement three approaches on our marketing data: propensity score matching, inverse probability weighting, and stratification. Each makes different tradeoffs, and we’ll see how they perform against the known true effect.

Part 4: When Simple Methods Break Down — Instrumental variables, difference-in-differences, and regression discontinuity for when the backdoor criterion can’t be satisfied.

Part 5: Sensitivity Analysis — What if our DAG is wrong? How much unmeasured confounding would it take to flip our conclusions?

Part 6: The Modern Toolkit — Double/debiased machine learning, EconML, and when to let the algorithms do the heavy lifting.


All code from this series is available as a notebook on GitHub. Built with Python 3.11, numpy, pandas, networkx, and matplotlib.

Part 3 drops next week. We’re done thinking — next time, we estimate.

Brian James Curry is an Enterprise AI Strategist, Data and Marketing Scientist, and Applied Researcher. He is the founder of Vector1 Research, an independent research lab focused on marketing economics, causal systems, cognitive AI architecture, and the economic implications of intelligent systems. He also founded KC AI Lab, a data science community and incubator for applied machine learning education.

Related research