Python & Data Science
Causal Inference Under review

Building Your First Causal Model

Last Time on the 90-Day Policy

Last tutorial, Maya — a new data scientist at Blue Harbor Hotels — learned why the marketing team’s “email doubled sales” slide was a lie. A hidden trait called user intent was quietly causing people both to open emails and to buy. The connection wasn’t real. Her manager, Priya, called it a confounder.

Now the real assignment begins. Blue Harbor charges a fee on bookings made more than 90 days before arrival — a policy someone shipped before Maya joined. Mr. Delgado, the VP of Revenue, wants to know: is the policy right? Long lead times (booking far in advance) do correlate with cancellations. But does booking early cause cancellation — or is it just a marker for the kind of traveler who was going to cancel anyway?

Maya’s job today: turn that question into a picture of how the world works — a DAG — and load it into DoWhy for the first time.

What you’ll learn here:

  • How to think through causal structure from domain knowledge
  • Three ways to specify a causal graph in DoWhy (GML, NetworkX, adjacency string)
  • How to load a CausalModel with your own data and graph
  • How to visualize your DAG and inspect your assumptions
  • What happens when you get the graph wrong
  • How to use DoWhy to test your graph against the data

Prerequisites: Completed Tutorial 1 (or familiar with treatment, outcome, confounder). DoWhy installed (pip install dowhy dowhy[plotting]). Basic pandas.

1. The Dataset: Hotel Booking Cancellations

Priya drops a file on Maya’s desk: 3,000 hotel bookings. It’s a simplified, synthetic version of the classic hotel bookings dataset — no download needed. Everything runs right here in this tutorial.

Each row is one booking. The columns are the variables, the things we measured about that booking:

  • lead_time — how many days before arrival the guest booked. This is our treatment: the thing whose effect we care about.
  • is_cancelled — did the booking get cancelled? (1 = yes, 0 = no). This is our outcome: the thing we want to change.
  • is_repeat_guest — had this person stayed before? A guest trait.
  • market_segment — how the booking came in (Direct, Corporate, Online Travel Agency, Groups).
  • special_requests — how many special requests (extra pillows, late checkout) the guest made.
  • adr — the average daily rate: the price per night.
import pandas as pd
import numpy as np

np.random.seed(0)
n = 3000

is_repeat_guest = np.random.binomial(1, 0.3, n)
market_segment = np.random.choice(
    ["Direct", "Corporate", "Online TA", "Groups"], n,
    p=[0.2, 0.25, 0.45, 0.1]
)

# lead_time depends on who's booking (is_repeat_guest) and how they booked
# (market_segment) — repeat guests need less runway because they already
# know the property; Groups need lead time to coordinate; Corporate trips
# get booked close to the date.
segment_lead_scale = np.select(
    [market_segment == "Corporate", market_segment == "Groups"],
    [35, 90],
    default=60,
)
lead_time_scale = np.clip(segment_lead_scale - 45 * is_repeat_guest, 10, None)
lead_time = np.random.exponential(scale=lead_time_scale).astype(int)

# special_requests depends on lead_time (guests who plan far ahead have more
# time, and more reason, to personalize the stay) and on market_segment.
# That makes special_requests a MEDIATOR sitting on the path
# lead_time -> special_requests -> is_cancelled, not a second confounder.
segment_request_base = np.select(
    [market_segment == "Corporate", market_segment == "Groups"],
    [0.3, 1.1],
    default=0.7,
)
special_requests_rate = segment_request_base + 0.01 * lead_time
special_requests = np.random.poisson(special_requests_rate, n)

adr_base = np.where(market_segment == "Corporate", 120, 95)
adr = adr_base + lead_time * 0.1 + np.random.normal(0, 15, n)

# cancel_prob: lead_time raises cancellation risk directly (the effect we're
# investigating). is_repeat_guest and market_segment shift it directly too
# (the confounder shape). special_requests lowers it (the mediated slice of
# lead_time's effect that runs through special_requests).
segment_cancel_shift = np.select(
    [market_segment == "Corporate", market_segment == "Groups"],
    [-0.05, 0.08],
    default=0.0,
)
cancel_prob = (
    0.2
    + 0.003 * lead_time
    - 0.3 * is_repeat_guest
    - 0.03 * special_requests
    + segment_cancel_shift
    + np.random.normal(0, 0.08, n)
)
is_cancelled = np.random.binomial(1, np.clip(cancel_prob, 0, 1))

df = pd.DataFrame({
    "lead_time": lead_time,
    "is_repeat_guest": is_repeat_guest,
    "market_segment": market_segment,
    "special_requests": special_requests,
    "adr": adr.round(2),
    "is_cancelled": is_cancelled,
})

print(df.head())
print(f"\nCancellation rate: {df['is_cancelled'].mean():.1%}")

Note: This is the exact dataset the rest of this tutorial — and the rest of this series — works from: 3,000 rows, generated with a single np.random.seed(0) at the top of the script, with these six columns: lead_time, is_repeat_guest, market_segment, special_requests, adr, is_cancelled. If a later tutorial in this series says it “rebuilds this dataset unchanged” but the row count or column set don’t match this, it isn’t actually the same dataset — worth checking before you trust a downstream result.

Maya’s research question, written down so there’s no confusion: Does a longer lead time cause higher cancellation rates? The whole investigation, in one sentence.

2. Causal Thinking Before Coding

Before Maya writes a single line of DoWhy code, Priya makes her reason through the data. For each variable, three questions:

  1. Could this variable influence lead time or cancellation? (Is it a cause?)
  2. Could lead time or cancellation influence this variable? (Is it an effect? Causes happen before effects.)
  3. Is this variable determined before or after the booking is made? (Time order matters — the past can’t be caused by the future.)

Here’s how each column breaks down:

  • lead_time → likely affects is_cancelled. That’s the treatment → outcome arrow — the one we’re investigating.
  • is_repeat_guest → a loyal guest books earlier (they plan ahead) and cancels less (they’re committed). It influences both lead time and cancellation, which makes it a confounder. From Tutorial 1: a confounder affects both treatment and outcome, faking a connection between them.
  • market_segment → the booking type influences lead time, special requests, and cancellation directly. Another confounder — and a busier one than is_repeat_guest, since it reaches three downstream variables instead of two.
  • special_requests → made at booking time, but not independently of when the guest booked: someone who locks in a room 90 days out has more time — and more reason — to start requesting extra pillows and a late checkout than someone booking next week. lead_time influences special_requests, and special_requests is in turn associated with fewer cancellations (a guest who’s made three requests is invested in the stay). That puts it on the path from lead_time to is_cancelled — a mediator, not a confounder.
  • adr → the price is set after booking conditions are known, so it’s affected by both lead_time and market_segment. It’s a collider — a place where causal arrows meet. More on colliders in a moment.

Note: You don’t need to be certain about every relationship. Causal graphs encode your best current understanding of the domain. Tutorial 3 shows how to test and refute these assumptions.

3. Drawing Your DAG on Paper First

Priya makes Maya draw before she codes. Here’s the picture they agree on — the DAG (Directed Acyclic Graph), a map of causal beliefs where arrows mean “causes.”

is_repeat_guest  ──→ lead_time
is_repeat_guest  ──→ is_cancelled
market_segment   ──→ lead_time
market_segment   ──→ special_requests
market_segment   ──→ is_cancelled
lead_time        ──→ special_requests
lead_time        ──→ is_cancelled
special_requests ──→ is_cancelled

Four things are in this picture:

  • Arrows point forward in time. Nothing points backward.
  • is_repeat_guest and market_segment both point at lead_time and is_cancelled. That’s the confounder shape from Tutorial 1 — two variables quietly pulling both the treatment and the outcome.
  • lead_time also points at special_requests, which points at is_cancelled. That chain — not a direct confounder arrow — is what makes special_requests a mediator: part of lead_time’s effect on cancellation runs through it.
  • There are no cycles. No arrow loops back to where it started. That’s the “acyclic” part of the name.

Tip: When unsure whether an arrow should exist, ask: “If I could wave a magic wand and change only this variable, would that plausibly change the other?” If yes, draw the arrow.

Checking the picture against the data. A DAG is a claim, and claims get checked. Maya runs a few quick groupbys against the df she just built:

print(df.groupby("is_repeat_guest")["lead_time"].mean())
print(df.groupby("market_segment")["lead_time"].mean())
print(df.groupby("market_segment")["special_requests"].mean())
print(df["lead_time"].corr(df["special_requests"]))
print(df.groupby("is_repeat_guest")["is_cancelled"].mean())
print(df.groupby("market_segment")["is_cancelled"].mean())

The numbers back up every arrow above: repeat guests book about 16 days out on average versus 53 days for everyone else — the arrow into lead_time is real, not decorative. By segment, Groups book furthest out (~68 days) and Corporate closest in (~26 days). special_requests climbs with both lead_time (correlation ≈0.47) and market_segment (from ~0.5 requests for Corporate up to ~1.9 for Groups) — the mediator arrow is real too. And guests who don’t return cancel roughly 14x more often than repeat guests (31.7% vs 2.3%), with cancellation rates by segment ranging from ~14% (Corporate) to ~34% (Groups) — the direct arrows into is_cancelled are doing real work, not just sitting on the page.

Now, adr is a collider, and this is where Maya needs to be careful. A collider is a node where two arrows meet (think A → C ← B):

lead_time       ──┐

              [ adr ]   ← excluded from every graph below — do not condition on this

market_segment  ──┘

Both lead_time and market_segment influence the price (adr) — the data-generating code above builds it directly as adr = adr_base(market_segment) + 0.1 * lead_time + noise. So if we “control for” adr — just to be safe — we open up a fake connection between lead time and cancellation. Controlling for a collider is a classic beginner mistake. That’s why adr never appears as a node in any CausalModel graph in this tutorial — that’s a deliberate omission, not an oversight, and the essay prompt below asks you to defend it.

4. Three Ways to Specify a Graph in DoWhy

DoWhy lets you describe the same picture three ways. Maya learns all three because each has its purpose.

4a. NetworkX DiGraph (most readable)

import networkx as nx

G = nx.DiGraph()
G.add_nodes_from([
    "lead_time", "is_cancelled", "is_repeat_guest",
    "market_segment", "special_requests",
])
G.add_edges_from([
    ("lead_time", "is_cancelled"),
    ("lead_time", "special_requests"),
    ("is_repeat_guest", "lead_time"),
    ("is_repeat_guest", "is_cancelled"),
    ("market_segment", "lead_time"),
    ("market_segment", "special_requests"),
    ("market_segment", "is_cancelled"),
    ("special_requests", "is_cancelled"),
])

When to use this: building the graph programmatically or integrating with other NetworkX tools.

4b. GML String (portable format)

gml_graph = """
graph [
    directed 1
    node [ id "lead_time" label "lead_time" ]
    node [ id "is_cancelled" label "is_cancelled" ]
    node [ id "is_repeat_guest" label "is_repeat_guest" ]
    node [ id "market_segment" label "market_segment" ]
    node [ id "special_requests" label "special_requests" ]
    edge [ source "lead_time" target "is_cancelled" ]
    edge [ source "lead_time" target "special_requests" ]
    edge [ source "is_repeat_guest" target "lead_time" ]
    edge [ source "is_repeat_guest" target "is_cancelled" ]
    edge [ source "market_segment" target "lead_time" ]
    edge [ source "market_segment" target "special_requests" ]
    edge [ source "market_segment" target "is_cancelled" ]
    edge [ source "special_requests" target "is_cancelled" ]
]
"""

When to use this: loading a graph from a file, or sharing assumptions with collaborators.

4c. Adjacency String (quickest for experiments)

graph_str = (
    "lead_time->is_cancelled,"
    "lead_time->special_requests,"
    "is_repeat_guest->lead_time,"
    "is_repeat_guest->is_cancelled,"
    "market_segment->lead_time,"
    "market_segment->special_requests,"
    "market_segment->is_cancelled,"
    "special_requests->is_cancelled"
)

When to use this: quick prototyping. Easy to read but harder to keep organized for large graphs.

All three describe the exact same picture — the same eight edges as Section 3’s diagram. The format doesn’t change the science — pick what’s easiest for the job.

5. Loading the CausalModel

Now Maya hands DoWhy the data and the picture together. That’s the whole point of DoWhy: the graph is a first-class citizen, not an afterthought.

from dowhy import CausalModel

model = CausalModel(
    data=df,
    treatment="lead_time",
    outcome="is_cancelled",
    graph=G,                  # or: graph=gml_graph, or graph=graph_str
)
print(model)

The model summary lists the treatment node and its “parents” — the variables that influence it — the outcome node and its parents, and the estimands DoWhy identified. We’ll unpack what an estimand is in Tutorial 3.

6. Visualizing and Inspecting Your Graph

Maya can look at her assumptions:

model.view_model()

What to check: are all expected confounders present? Does each arrow’s direction match intuition? Any missing connections?

She can also enumerate every path from treatment to outcome. That’s a sanity check worth doing — bias hides in those paths:

import networkx as nx
all_paths = list(nx.all_simple_paths(G, "lead_time", "is_cancelled"))
print("All paths from lead_time to is_cancelled:")
for path in all_paths:
    print(" → ".join(path))

This prints two paths: the direct arrow lead_time → is_cancelled, and the mediated chain lead_time → special_requests → is_cancelled. Both are legitimate parts of lead_time’s total effect — neither should be blocked. The paths to watch out for are the ones that run backward-then-forward, like lead_time ← is_repeat_guest → is_cancelled. Those are backdoor paths — fake connections that need to be blocked, not walked. That’s the exact problem the next tutorials solve.

7. What If Your Graph Is Wrong?

Priya now runs an experiment on Maya — the fastest way to see why the graph matters. Scenario A: Maya builds the model without the is_repeat_guest confounder, as if she’d never thought of it. Scenario B: the full, correct graph.

# Scenario A: Missing a confounder
G_incomplete = nx.DiGraph()
G_incomplete.add_edges_from([
    ("lead_time", "is_cancelled"),
    ("lead_time", "special_requests"),
    ("market_segment", "lead_time"),
    ("market_segment", "special_requests"),
    ("market_segment", "is_cancelled"),
    ("special_requests", "is_cancelled"),
])

model_incomplete = CausalModel(
    data=df, treatment="lead_time", outcome="is_cancelled", graph=G_incomplete,
)
estimand_incomplete = model_incomplete.identify_effect()
estimate_incomplete = model_incomplete.estimate_effect(
    estimand_incomplete, method_name="backdoor.linear_regression"
)
print(f"Estimate (missing confounder): {estimate_incomplete.value:.4f}")
# Scenario B: Correct full graph
model_full = CausalModel(data=df, treatment="lead_time", outcome="is_cancelled", graph=G)
estimand_full = model_full.identify_effect()
estimate_full = model_full.estimate_effect(estimand_full, method_name="backdoor.linear_regression")
print(f"Estimate (full graph): {estimate_full.value:.4f}")

Run both, and the numbers land at roughly:

  • Scenario A (missing confounder): 0.0032
  • Scenario B (full graph): 0.0026

That’s a real gap — Scenario A overstates the per-day effect of lead time by about 25%, not a difference six decimal places out. Why? is_repeat_guest is still in the dataset — it’s a column in df. But Maya left it out of the graph, so DoWhy had no way to know it should block that backdoor path. Leaving a variable out of the picture — not out of the data — is enough to bias the estimate, and here the bias runs in a specific, explainable direction: repeat guests book earlier and cancel less, so ignoring them makes the rest of the lead-time-cancellation relationship look stronger than it actually is.

Notice something else, too: both estimates come out positive. Even after correctly blocking every backdoor path, longer lead times are still associated with a higher chance of cancellation. That’s the sign this whole series is built around — longer lead time → higher cancellation risk — and it’s what the rest of the tutorials will keep testing, sharpening, and trying to break. Getting that sign right was the easy part; getting the size of the effect right is what depends on getting the graph right.

Which raises the question Maya can’t stop thinking about: “So how do we know if our graph is right?”

8. Testing Your DAG Against the Data

You can’t prove your graph is correct. You can check whether it’s consistent with the data. DoWhy offers a graph refutation that tests the independence relationships your graph implies.

from dowhy.causal_refuters import CausalRefuter

refutation = model.refute_graph(
    k=1,
    independence_test={
        "test_for_continuous": "partial_correlation",
        "test_for_discrete": "conditional_mutual_information",
    }
)
print(refutation)

Note: These tests catch when your graph is inconsistent with the data. They can’t prove it correct. Treat them as a sanity check, not a guarantee.

Maya is getting comfortable with that tension. In causal inference, you can falsify assumptions, but you can’t prove them. The graph is a bet about how the world works — and the rest of the series is about stress-testing that bet.

9. Common Pitfalls When Building DAGs

Maya’s notebook now has a “don’t do this” list. You should copy it:

Pitfall 1: Forgetting time ordering. Variables determined later can’t cause variables determined earlier. adr (set after booking) can’t cause lead_time (set at booking).

Pitfall 2: Confusing mediators and confounders. A mediator sits on the path between treatment and outcome — the treatment changes the mediator, which changes the outcome. A confounder affects both from outside the chain. Don’t control for a mediator. It blocks the very effect you’re trying to measure. (special_requests is this tutorial’s mediator — see Section 3.)

Pitfall 3: Controlling for colliders. A collider is a node where two arrows meet (A → C ← B). Control for it and you open a spurious path between A and B, creating bias where there was none. This is the adr trap from earlier.

Pitfall 4: Making the graph too complex. Start with the variables you believe matter most. Estimate, then add complexity only if needed.

10. Conclusion

Everything in this tutorial ran on the same 3,000-row synthetic dataset, seeded once with np.random.seed(0) at the top of Section 1, with six columns: lead_time, is_repeat_guest, market_segment, special_requests, adr, is_cancelled. That’s the dataset the rest of this series builds on.

Maya’s first causal model is built. She drew her assumptions as a picture, checked that picture against the data, loaded it into DoWhy, and visualized the result. She even saw what happens when the picture is wrong — and confirmed, with real numbers, that longer lead times point toward higher cancellation risk.

Mr. Delgado isn’t going to accept a pretty diagram, though. He wants a number: how much does booking 90+ days out actually increase the chance of cancellation? Maya hasn’t run the full pipeline yet. She hasn’t asked DoWhy whether her question is even answerable from this data. She hasn’t tried to break her own result.

That’s the next episode. If Maya thinks a DAG is nerve-wracking, Priya has a surprise for her: sometimes the data itself refuses to give an answer. You have to prove it can.

In this tutorial, you learned:

  • How to reason about causal structure before writing code
  • The three ways to specify a causal graph in DoWhy
  • How to load a CausalModel with your own data and graph
  • How to visualize and inspect your causal assumptions
  • What missing confounders do to your estimates
  • How to test your DAG against the data using independence tests

Next up: Tutorial 3 — Estimating and Validating Causal Effects: the full four-step workflow, where Maya finally gets her number.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What are the three ways DoWhy lets you specify a causal graph, and when would you use each one?

Understand In your own words, explain the difference between a mediator and a confounder, and why controlling for a mediator is a mistake.

Apply Using the tutorial’s DAG-building question (“If I could wave a magic wand and change only this variable, would that plausibly change the other?”), decide whether special_requests should have an arrow pointing to is_cancelled, based on the reasoning given in Section 2.

Analyze The tutorial shows that model_incomplete (missing the is_repeat_guest confounder) produces a different estimate than model_full. Walk through why omitting a variable from the graph—not from the dataset itself, since is_repeat_guest is still a column in df—is enough to bias the estimate.

Evaluate Pitfall 3 warns that controlling for a collider “opens” a spurious path between two variables. Critique the common instinct to “just control for everything you have data on to be safe”: why does that instinct actively hurt you when a collider is present, unlike simply including an irrelevant variable?

Create Design a DAG for a new scenario: a software company wants to know if code_review_turnaround_time causes bug_rate in production. Name at least one confounder, one potential mediator, and one potential collider you’d want to reason through before drawing arrows, and explain your choice for each.


Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.