Python & Data Science
Causal Inference Under review

Estimating and Validating Causal Effects With DoWhy

Last Time on the 90-Day Policy

Maya — the new data scientist at Blue Harbor Hotels — drew her first DAG (Directed Acyclic Graph: a picture of what causes what) for the hotel’s cancellation problem. She mapped the 90-day policy question — does booking far in advance cause cancellations? — checked that DAG against the data, and confirmed with real numbers that the sign of the relationship holds even after adjusting for the confounders she could see: longer lead times point toward higher cancellation risk.

She has the picture, and she’s seen the sign. Now Mr. Delgado, the VP of Revenue, wants the number: by how much does a long lead_time increase the chance a guest cancels? Maya has to prove she can trust it.

This tutorial is the core of the whole series: how to move from “I have a DAG” to “here is a number, and here’s why you should believe it.” It’s also where DoWhy earns its keep — it doesn’t just hand you an answer. It forces you to check whether the question was answerable in the first place.

What you’ll learn here:

  • What an estimand is and why DoWhy separates it from estimation
  • How to use identify_effect() to determine if your causal question is answerable
  • The main estimation methods in DoWhy and when to use each
  • How to interpret a causal estimate
  • How refutation works and what each refuter is actually testing
  • How to interpret refutation results and decide whether to trust your estimate

Prerequisites: Completed Tutorials 1 and 2 (or comfortable with DoWhy’s CausalModel and DAG construction). DoWhy installed. Familiar with basic regression concepts.


1. Quick Recap: The Four-Step Workflow

Before Maya runs anything, Priya has her write down four steps she’ll come back to:

# The four steps of DoWhy's CausalModel API:
# 1. model  = CausalModel(data, treatment, outcome, graph)   → encode assumptions
# 2. estimand = model.identify_effect()                      → prove it's answerable
# 3. estimate = model.estimate_effect(estimand, method_name) → compute the number
# 4. refute  = model.refute_estimate(estimand, estimate, ...) → try to break it

Step 1 came from Tutorial 2. This tutorial handles steps 2, 3, and 4. Maya starts by rebuilding the exact same dataset and DAG from Part 2 — same np.random.seed(0), same n = 3000, same six columns. Nothing is regenerated, resampled, or dropped:

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

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]
)

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)

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)

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%}")

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"),
])

# DoWhy's CausalModel wants the graph as a portable string (dot or GML),
# not a raw NetworkX object. Serializing the NetworkX graph we just built
# keeps it as the single source of truth — no second copy of the edge list
# to drift out of sync with Part 2's DAG.
gml_graph = "\n".join(nx.generate_gml(G))
model = CausalModel(data=df, treatment="lead_time", outcome="is_cancelled", graph=gml_graph)

Output:

   lead_time  is_repeat_guest  ...     adr  is_cancelled
0         16                0  ...  113.50             0
1          8                1  ...   80.78             0
2         68                0  ...  137.79             0
3         55                0  ...  106.11             0
4          4                0  ...  108.85             0

Cancellation rate: 22.6%

Same df.head(), same 22.6% cancellation rate as Part 2. This is not a fresh simulation — it’s the same 3,000 bookings, loaded into a fresh CausalModel.

2. Step 2 in Depth: Identification and the Estimand

Here’s a question most beginners never think to ask, and it’s the most important one in the whole workflow: given my causal graph and observational data, is it even mathematically possible to estimate the causal effect?

That question is identification. Sometimes the answer is a flat no. One unmeasured confounder, no workaround, and no amount of data saves you — a million rows and you’re still stuck. DoWhy makes you confront this before you compute anything.

If the answer is yes, DoWhy produces an estimand: the precise mathematical formula that, computed correctly, gives you the causal effect. Maya writes the key distinction in big letters in her notebook:

  • An estimand is an equation — “here’s the math that would give me the truth.”
  • An estimate is a number — “here’s the actual value I computed from my data.”

The estimand is the recipe. The estimate is the dish.

estimand = model.identify_effect(proceed_when_unidentifiable=True)
print(estimand)

Output:

Estimand type: nonparametric-ate

### Estimand : 1
Estimand name: backdoor
Estimand expression:
     d
───────────(E[is_cancelled|market_segment,is_repeat_guest])
d[lead_time]
Estimand assumption 1, Unconfoundedness: If U→{lead_time} and U→is_cancelled
then P(is_cancelled|lead_time,market_segment,is_repeat_guest,U) =
P(is_cancelled|lead_time,market_segment,is_repeat_guest)

### Estimand : 2
Estimand name: iv
No such variable(s) found!

### Estimand : 3
Estimand name: frontdoor
No such variable(s) found!

Three things fall out of this real output:

  • Estimand type — DoWhy found a backdoor identification. No instrumental variable, no frontdoor path — just the backdoor adjustment, which is what Part 2’s DAG supports.
  • The estimand expression — the derivative of expected cancellation with respect to lead time, conditional on market_segment and is_repeat_guest. Those two names are DoWhy telling Maya exactly what to hold constant.
  • Backdoor variables — printed explicitly, not just implied:
print("Backdoor adjustment set:")
print(estimand.backdoor_variables)

Output:

Backdoor adjustment set:
{'backdoor1': ['market_segment', 'is_repeat_guest'], 'backdoor2': ['market_segment', 'is_repeat_guest'], 'backdoor': ['market_segment', 'is_repeat_guest']}

DoWhy considered a few candidate adjustment sets internally (backdoor1, backdoor2, backdoor) and every one of them converges on the same answer: market_segment and is_repeat_guest — precisely the two confounders Part 2’s DAG identified. special_requests does not appear here, because it’s a mediator, not a confounder; conditioning on it would block part of the effect Maya is trying to measure, not open a spurious path.

Note: DoWhy automatically finds the optimal backdoor adjustment set from your graph. You can also specify it manually if you have domain knowledge that overrides the automatic selection.

Maya can already see the payoff of Tutorial 2: she drew the DAG, and DoWhy turned that picture into “here is the exact set of variables to hold steady.”

3. The Backdoor Criterion (Without the Math)

A backdoor path is any path from treatment to outcome that runs against a causal arrow before running with one. These paths create spurious correlation without representing true causation.

Part 2’s DAG has two confounders pointing at both lead_time and is_cancelled — and the printed adjustment set above confirms it. That means there are two backdoor paths to walk through, not one:

Backdoor path 1:
lead_time ← is_repeat_guest → is_cancelled

Backdoor path 2:
lead_time ← market_segment → is_cancelled

Path 1. Repeat-guest status causes lead time (repeat guests book about 16 days out versus 53 days for everyone else — Part 2’s numbers), and it also causes cancellation (repeat guests cancel roughly 14x less often). So even if lead time had no real effect, the two would still look correlated through this shared cause.

Path 2. Market segment causes lead time too (Groups book ~68 days out, Corporate ~26), and it causes cancellation directly (Groups cancel at ~34%, Corporate at ~14%). Same shape as Path 1, different confounder — and this one reaches lead time through a different mechanism (booking channel, not guest loyalty).

Both paths run backward-then-forward: start at lead_time, go against an arrow to reach the confounder, then with an arrow to reach is_cancelled. Neither represents lead_time actually causing anything.

The backdoor criterion is what gets you out of both of them at once. If you can block every backdoor path by conditioning on a set of variables — without opening new ones through colliders — you can identify the causal effect. The estimand’s adjustment set does exactly that: control for market_segment and is_repeat_guest together, and both backdoor paths close simultaneously.

“Conditioning on” just means holding constant. When we control for both variables, we’re saying: compare long-lead-time guests and short-lead-time guests who are equally likely to be repeat guests, booked through the same channel. That closes both spurious paths at once.

4. Step 3 in Depth: Estimation Methods

Maya has the recipe (the estimand). Now the question is how to cook it — and there are several kitchens to choose from. The key thing to absorb: the estimand stays the same regardless of which estimator you choose. The recipe doesn’t change; only the cooking technique does.

4a. Linear Regression

estimate_lr = model.estimate_effect(
    estimand,
    method_name="backdoor.linear_regression",
    control_value=0,
    treatment_value=1,
)
print(f"Linear regression estimate: {estimate_lr.value:.4f}")

Output:

Linear regression estimate: 0.0026

The exact value, unrounded, is 0.0025681141739199997. Runs a regression of is_cancelled on lead_time plus the backdoor adjustment set (market_segment, is_repeat_guest). The coefficient on lead_time is the estimate.

4b/4c. Propensity Score Methods — and Why They Need a Different Treatment Variable

Propensity score matching and inverse probability weighting both work by estimating the probability of receiving the treatment given the confounders, then using that probability to balance the comparison. DoWhy’s implementation requires the treatment to be binary for this — a continuous lead_time doesn’t have “the propensity to receive treatment,” it has a propensity to receive any one of hundreds of values.

Maya’s business question already has a natural binary cut hiding in it: the 90-day policy threshold itself. So for this comparison, she defines long_lead_time — booked 90+ days out or not — and rebuilds the model around that binary treatment, keeping the same confounders and the same mediator:

df_bin = df.copy()
df_bin["long_lead_time"] = (df_bin["lead_time"] >= 90).astype(int)
print(df_bin["long_lead_time"].value_counts())

G_bin = nx.DiGraph()
G_bin.add_edges_from([
    ("long_lead_time", "is_cancelled"),
    ("long_lead_time", "special_requests"),
    ("is_repeat_guest", "long_lead_time"),
    ("is_repeat_guest", "is_cancelled"),
    ("market_segment", "long_lead_time"),
    ("market_segment", "special_requests"),
    ("market_segment", "is_cancelled"),
    ("special_requests", "is_cancelled"),
])
gml_graph_bin = "\n".join(nx.generate_gml(G_bin))
model_bin = CausalModel(data=df_bin, treatment="long_lead_time", outcome="is_cancelled", graph=gml_graph_bin)
estimand_bin = model_bin.identify_effect(proceed_when_unidentifiable=True)

estimate_psm = model_bin.estimate_effect(
    estimand_bin,
    method_name="backdoor.propensity_score_matching",
    target_units="ate",
)
print(f"Propensity score matching estimate: {estimate_psm.value:.4f}")

estimate_ipw = model_bin.estimate_effect(
    estimand_bin,
    method_name="backdoor.propensity_score_weighting",
    method_params={"weighting_scheme": "ips_weight"},
)
print(f"IPW estimate: {estimate_ipw.value:.4f}")

Output:

long_lead_time
0    2606
1     394
Name: count, dtype: int64

Propensity score matching estimate: 0.0710
IPW estimate: 0.3428

Note this is now a different quantity than the linear regression number above — it’s the average effect of crossing the 90-day line at all (a probability jump), not the per-day slope. It shouldn’t be compared to 0.0026 directly.

What’s striking is how far apart PSM and IPW land: 0.0710 versus 0.3428 — nearly a 5x gap on the same data, same graph, same treatment definition. This is exactly the situation the Tip below warns about, and it’s worth taking seriously rather than picking whichever number is convenient. With only 394 of 3,000 bookings crossing the 90-day line (13.1%), and confounders that are continuous or high-cardinality (market_segment has four levels, is_repeat_guest is at least balanced), propensity score matching has thin pickings — many treated units don’t have a close match among the 2,606 controls, and DoWhy’s default matching is not forgiving about it. IPW uses every unit but can be dominated by a handful of large weights when overlap is poor. Neither number should be taken as gospel here; the gap itself is the signal that this particular comparison needs closer inspection (checking propensity score overlap) before either number goes in front of Delgado.

4d. Choosing a Method

MethodUse WhenAssumes
linear_regressionRelationships are roughly linear; fast baseline; treatment can be continuousLinearity, no interaction effects
propensity_score_matchingBinary treatment; non-linear confounding; large N with good overlapGood overlap between treated/control
propensity_score_weightingBinary treatment; you want all data points to contributePositivity: all units have some probability of treatment
econml.* (EconML)You want heterogeneous treatment effectsVaries by method

Tip: try multiple estimators. If they give very different answers, your model assumptions probably need revisiting — not because one estimator is right and the other is wrong. The PSM/IPW gap above (0.0710 vs. 0.3428) is a live example: the divergence traces back to weak overlap in the binarized treatment, not to a flaw in the DAG.

This tip drives the next several tutorials. If estimators agree, you can breathe. If they disagree wildly, look at why before concluding anything about the causal question itself.

5. Interpreting Your Estimate

Maya’s headline number, for the rest of this article and the rest of the series, is the linear regression estimate from Section 4a: 0.0026 per day (0.26 percentage points per day of lead_time).

That’s close to — but not identical to — the 0.003 coefficient baked into Part 2’s data-generating process. The gap (0.003 true vs. 0.0026 estimated) is expected: it’s sampling noise plus whatever the linear functional form doesn’t capture exactly. Nobody recovers a DGP coefficient to four decimal places from 3,000 noisy rows. The important thing is that the estimate lands close to the true value and keeps the right sign.

An estimate of 0.0026 means: for every one extra day of lead_time, the probability that a guest cancels goes up by about 0.26 percentage points. So a guest who books 90 days out versus 1 day out would have a cancellation probability roughly 0.23 higher (89 extra days × 0.0026 ≈ 0.2286) because of the lead time alone — assuming the DAG is right.

Three things to always check when reading an estimate:

  1. The sign. Positive means the treatment increases the outcome; negative means it decreases it.
  2. The size. Is it big enough to matter for the business decision? A 0.0026-per-day effect compounds a lot over 90 days.
  3. The uncertainty. Any estimate from data carries some sloppiness. DoWhy reports confidence intervals — a range the true effect likely lives in. A wide range means you’re not sure; a tight one means you are.

6. Step 4 in Depth: Why Refutation Matters

Here’s where DoWhy separates itself from every other library Maya has used. After getting a number, DoWhy’s philosophy is: don’t trust it — try to break it.

This is called refutation. The idea is simple and brutal: if our causal analysis is correct, then certain “cheap tricks” should not be able to reproduce the result. A refuter runs one of those tricks. If the trick succeeds — producing a big effect where there should be none — our analysis is suspect. If it fails, producing roughly zero as it should, our analysis earns a little more trust.

refutation = model.refute_estimate(
    estimand,
    estimate_lr,
    method_name="random_common_cause",
)
print(refutation)

Output:

Refute: Add a random common cause
Estimated effect:0.0025681141739199997
New effect:0.00256779275218729
p value:0.88

Adding a fabricated, purely random variable to the model barely moves the estimate — 0.002568 to 0.002568, four decimal places of agreement. That’s the expected result: a real effect shouldn’t budge just because a random variable joined the party.

Notice the shape here: this is deliberately playing the skeptic. Maya is no longer trying to prove herself right — she’s trying to prove herself wrong, on purpose, before Delgado does it for her.

7. The Four Main Refuters

Maya keeps four refuters in regular rotation. Each one targets a different weak spot, and each ran, for real, against the same estimate_lr.

Refuter 1: Random Common Cause. Adds a completely random variable to the model and re-estimates. Already shown above: 0.00256810.0025678, p = 0.88. It barely moved.

Refuter 2: Placebo Treatment. Swaps the real treatment (lead_time) for random noise and re-estimates. If pure noise produces a large “effect,” your model is finding patterns where none exist. It should be ~0.

refute_placebo = model.refute_estimate(
    estimand,
    estimate_lr,
    method_name="placebo_treatment_refuter",
    placebo_type="permute",
)
print(refute_placebo)

Output:

Refute: Use a Placebo Treatment
Estimated effect:0.0025681141739199997
New effect:9.547597542562125e-06
p value:0.82

The placebo effect is 0.0000095 — essentially zero, four orders of magnitude smaller than the real estimate. This is what “the model isn’t finding patterns in nothing” looks like as an actual number, not an assertion.

Refuter 3: Data Subset. Drops a random chunk of the data — say 20% — and re-estimates. If the answer swings wildly, the result was a fluke of one particular sample. It should stay roughly the same.

refute_subset = model.refute_estimate(
    estimand,
    estimate_lr,
    method_name="data_subset_refuter",
    subset_fraction=0.8,
)
print(refute_subset)

Output:

Refute: Use a subset of data
Estimated effect:0.0025681141739199997
New effect:0.002576563459393561
p value:0.9

Dropping a fifth of the data moves the estimate from 0.002568 to 0.002577 — well within noise. The result isn’t riding on a handful of unusual bookings.

Refuter 4: Unobserved Common Cause. This is the sensitivity-analysis refuter, and it’s worth being precise about what it actually does, because it’s easy to overstate.

It doesn’t search the data for a hidden confounder — it simulates one, at a strength you choose, and reports how far the estimate moves. Run it once with a token strength and it looks almost boring:

r = model.refute_estimate(
    estimand,
    estimate_lr,
    method_name="add_unobserved_common_cause",
    confounders_effect_on_treatment="linear",
    confounders_effect_on_outcome="linear",
    effect_strength_on_treatment=0.01,
    effect_strength_on_outcome=0.01,
)
print(f"new_effect={r.new_effect:.6f}")

Output:

new_effect=0.002566

That single run tells Maya almost nothing on its own — a confounder that weak wouldn’t threaten any result. The refuter only becomes useful as a sweep: try a range of strengths and watch for the point where the estimate crosses zero or flips sign. That crossing point is the sensitivity bound — the minimum strength a hidden confounder would need, on both the treatment side and the outcome side, to overturn the conclusion:

strength_pairs = [(0.01, 0.01), (0, 0.3), (20, 0.3), (50, 0.3)]
for strength_t, strength_o in strength_pairs:
    r = model.refute_estimate(
        estimand,
        estimate_lr,
        method_name="add_unobserved_common_cause",
        confounders_effect_on_treatment="linear",
        confounders_effect_on_outcome="linear",
        effect_strength_on_treatment=strength_t,
        effect_strength_on_outcome=strength_o,
    )
    print(f"strength_t={strength_t}, strength_o={strength_o}: new_effect={r.new_effect:.6f}")

Output (strength_on_treatment, strength_on_outcome → new estimate):

(0.01, 0.01)  → new_effect =  0.002566   (barely moves)
(0,    0.30)  → new_effect =  0.002556   (outcome-only, still barely moves)
(20,   0.30)  → new_effect = -0.000124   (crosses zero)
(50,   0.30)  → new_effect = -0.001960   (flips negative)

Reading this straight: a hidden confounder that shifts lead_time by only a token amount, or shifts is_cancelled alone, doesn’t touch the result. But a confounder strong enough to shift lead_time by roughly 20 days and cancellation probability by roughly 0.3 — per one standard-deviation unit of the confounder — is enough to erase the effect entirely, and anything stronger flips its sign. That’s the number Maya can hand Delgado: “our conclusion survives anything weaker than this; I can’t tell you whether something stronger exists.”

8. Putting It All Together: A Complete Analysis

Here’s the full continuous-treatment workflow in one run — how Maya plans to present it to Delgado. (The propensity-score comparison from Section 4 needs the binarized long_lead_time version and isn’t repeated here — see Section 4b/4c for those numbers.)

# Step 1: encode assumptions
model2 = CausalModel(data=df, treatment="lead_time", outcome="is_cancelled", graph=gml_graph)

# Step 2: prove it's answerable
estimand2 = model2.identify_effect(proceed_when_unidentifiable=True)

# Step 3: compute the number
estimate_1 = model2.estimate_effect(estimand2, method_name="backdoor.linear_regression")
print(f"Linear regression:  {estimate_1.value:.4f}")

# Step 4: try to break it
refute_placebo2 = model2.refute_estimate(estimand2, estimate_1, method_name="placebo_treatment_refuter")
refute_subset2 = model2.refute_estimate(estimand2, estimate_1, method_name="data_subset_refuter")
print("Placebo refutation:", refute_placebo2)
print("Subset refutation:", refute_subset2)

Output:

Linear regression:  0.0026

Placebo refutation: Refute: Use a Placebo Treatment
Estimated effect:0.0025681141739199997
New effect:-7.386517973871631e-06
p value:0.88

Subset refutation: Refute: Use a subset of data
Estimated effect:0.0025681141739199997
New effect:0.0025667654330518573
p value:0.88

Same story as Sections 6 and 7, reproduced end to end: the estimate is 0.0026, the placebo collapses to ~0, and the subset refutation barely moves it. Small differences from the individual runs above (-0.0000074 here versus 0.0000095 earlier) come from the refuters’ own internal randomness — permutation and subsampling aren’t deterministic between calls — not from anything unstable about the underlying estimate. Maya has a story she can defend.

9. What Refutation Can and Can’t Tell You

Refutation has real value, but it’s no magic wand. Maya has to be precise about what each piece actually proves — precise enough that she doesn’t accidentally claim more than the numbers support.

What it CAN tell you: that your model is internally consistent, in three specific, narrow senses — each backed by a number above, not an assertion. It isn’t picking up pure noise (random common cause: 0.0025680.002568). It isn’t a fluke of one sample (data subset: 0.0025680.002577). And — this is the one that’s easy to overstate — the unobserved-common-cause refuter gives you a sensitivity bound: how strong a hidden confounder would have to be, on both the treatment and outcome side, before it could erase or reverse your result. For this analysis, that bound is roughly “shifts lead_time by ~20 days and cancellation probability by ~0.3 per unit of confounder strength.” That is a real, useful, quantified boundary.

What it CAN’T tell you: whether a confounder that strong — or any confounder at all — actually exists. The sensitivity bound tells Maya what it would take to break her result; it says nothing about whether anything in the real world is doing that. Every refutation, including the sensitivity sweep, tests the model given the graph. If the graph itself is wrong — if there’s a real confounder that isn’t random noise, isn’t erased by a subset, and happens to sit above the sensitivity bound — no refuter here catches it. The placebo refuter proves “your model isn’t finding patterns in nothing.” The unobserved-common-cause refuter proves “here’s how strong a ghost would have to be.” Neither proves “there is no ghost.”

Think of it like a car inspection: it can tell you the brakes work, and it can tell you how hard something would have to hit the car to total it. It can’t tell you there isn’t a pothole around the next corner.

10. Conclusion

Maya finally has her number: 0.0026 per day (0.26 percentage points), close to the 0.003 coefficient baked into Part 2’s simulated data, and stable across three separate refutation checks. Long lead times do cause cancellations, and the effect is real enough to matter for a 90-day policy — a guest booking 90 days out carries roughly 23 percentage points more cancellation risk than one booking the day before, from lead time alone. She knows how far to trust that result, and — thanks to the sensitivity sweep — she can even quantify exactly how strong a hidden confounder would need to be to threaten it.

But there’s a shadow over the result. Mr. Delgado’s sharpest question hasn’t been answered: “What if there’s a ‘flaky traveler’ trait we never collected — a guest type that both books early and cancels? You can’t refute that away with random noise, Maya. That’s not random. That’s specific.”

He’s right, and Section 9’s honest answer stands: the sensitivity bound tells Maya how strong that trait would have to be. It doesn’t tell her whether it exists. That ghost is exactly what the next tutorial hunts.

In this tutorial, you learned:

  • What an estimand is and why DoWhy separates it from estimation
  • How identify_effect() checks whether your question is answerable
  • The main estimation methods and when to use each — including why propensity-score methods need a binary treatment
  • How to interpret a causal estimate (sign, size, uncertainty)
  • Why refutation matters and what the four refuters test
  • What refutation can and cannot tell you — and specifically, that the unobserved-common-cause refuter gives you a sensitivity bound, not proof that no confounder exists

Next up: Tutorial 4 — Sensitivity Analysis, where Maya hunts the unmeasured confounder.

Check Your Understanding

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

Remember What is the difference between an “estimand” and an “estimate” in DoWhy’s workflow?

Understand In your own words, explain what a “backdoor path” is, using both of this tutorial’s examples (lead_time ← is_repeat_guest → is_cancelled and lead_time ← market_segment → is_cancelled).

Apply Using the tutorial’s method-selection table, which estimation method would you pick if you suspected the relationship between your confounders and treatment was non-linear and you had a large sample size with a binary treatment? Which assumption does that method require that linear_regression doesn’t?

Analyze The tutorial notes that “the estimand stays the same regardless of which estimator you choose.” Walk through why that’s true — what does the estimand actually specify, and why doesn’t switching from linear regression to propensity score matching change it?

Evaluate Section 4 shows PSM and IPW landing on 0.0710 and 0.3428 — a nearly 5x gap — on the same binarized treatment. Critique the tutorial’s explanation (poor overlap given only 13.1% of units are treated): what evidence would you want to see before accepting “weak overlap” as the explanation, rather than “the DAG is wrong” or “IPW is unstable”?

Create Design a scenario (a treatment, an outcome, and a data-size constraint) where you’d deliberately choose propensity_score_weighting over propensity_score_matching, based on the tutorial’s stated tradeoff about using “all data points” — and where you’d expect good overlap, unlike the long_lead_time example here.


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.