Correlation Isn't Causation
Meet Maya
Maya just started as a data scientist at Blue Harbor Hotels. It’s her first week. Her manager, Priya, is walking her through the company’s numbers when a marketing slide catches her eye: “Our promo email DOUBLED purchase rates.”
It looks like a win. But Priya stops her: “Before you believe that slide, tell me whether the email caused those purchases — or just happened to move together with them. There’s a difference, and it’s the difference between a raise and a disaster.”
This is Maya’s first lesson — and yours: correlation is not causation. Two things moving together does not mean one causes the other. By the end, you’ll know why that email slide is probably lying, and you’ll get a first taste of the tools — like DoWhy — that find the truth anyway.
What you’ll learn here:
- Why correlation misleads even experienced data scientists
- The vocabulary of causal inference: treatment, outcome, confounder, DAG
- When a standard regression or ML model gives you the wrong answer
- How DoWhy fits into your Python data science toolkit
- How to install DoWhy and run your first four-line causal analysis
Prerequisites: comfortable with Python and pandas DataFrames; familiar with basic statistics (mean, correlation, linear regression). No prior causal inference knowledge required.
1. The Ice Cream Problem
Here’s the classic example: ice cream sales and drowning rates are strongly correlated. Both climb in summer. Ice cream doesn’t cause drowning — hot weather does, and hot weather sends people to both the beach and the ice cream stand. Banning ice cream to save swimmers would be absurd.
Business makes the same mistake all the time, just less obviously. The marketing team thinks the email caused purchases — and Maya is about to find out why they’re probably wrong.
Let’s simulate 5,000 customers. Each has a hidden trait we’ll call user intent — how likely they were to buy anyway, even with no email at all.
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
n = 5000
# Simulate a hidden variable: "user intent" (how likely they were to buy anyway)
user_intent = np.random.normal(loc=0.5, scale=0.2, size=n)
user_intent = np.clip(user_intent, 0, 1)
# Users with higher intent are MORE LIKELY to open emails (selection bias)
received_email = (user_intent + np.random.normal(0, 0.1, n)) > 0.6
# Purchase probability driven mostly by intent, email has a small real effect
purchase_prob = user_intent * 0.7 + received_email * 0.05
purchased = np.random.binomial(1, np.clip(purchase_prob, 0, 1))
df = pd.DataFrame({
"received_email": received_email.astype(int),
"purchased": purchased,
"user_intent": user_intent, # In real life, we often can't observe this
})
# Naive analysis: email looks very effective
print(df.groupby("received_email")["purchased"].mean())
Expected output (captured with numpy==2.4.4; np.random.seed on this legacy RNG path reproduces bit-for-bit across numpy 1.26–2.4, so you should see exactly this regardless of which numpy you have installed):
received_email
0 0.292125
1 0.562691
Name: purchased, dtype: float64
Note: The naive analysis suggests the email nearly doubles purchase rates. But most of this gap exists because high-intent users were more likely to receive the email in the first place. The true causal effect of the email is much smaller.
People who got the email bought 56% of the time; those who didn’t, 29%. Huge, right? But the kind of person already likely to buy was also the kind who opened the email. So the email didn’t necessarily make anyone buy more. It just showed up for the people buying anyway.
2. What Correlation Actually Tells You
Correlation just means two things move together. It says nothing about why. For any correlation between A and B, there are exactly three explanations:
- A causes B (the email causes purchases)
- B causes A (buying makes you read the email — unlikely, but possible)
- A third thing, C, causes both (user intent makes people both open emails and buy)
This three-way split of what a correlation can mean is standard applied-econometrics territory — see Wooldridge, Introductory Econometrics: A Modern Approach, on omitted-variable bias and the correlation/causation distinction.
Maya’s slide team picked explanation #1. But #3 is right there in the data, invisible. A confounder (explanation #3’s C) is a variable that secretly drives both the treatment and the outcome, creating a fake connection between them.
3. Introducing the Confounder
Now suppose we do know the user_intent score — we can see the confounder and control for it. Hold intent steady and ask: for customers with the same intent, does the email still matter?
# When we control for intent, the email effect shrinks
import statsmodels.formula.api as smf
# Naive: no controls
naive = smf.ols("purchased ~ received_email", data=df).fit()
print(f"Naive email effect: {naive.params['received_email']:.3f}")
# Controlled: include the confounder
controlled = smf.ols("purchased ~ received_email + user_intent", data=df).fit()
print(f"Controlled email effect: {controlled.params['received_email']:.3f}")
Expected output:
Naive email effect: 0.271
Controlled email effect: 0.070
Key insight: The “true” effect we baked in was 0.05. Controlling for the one confounder we know about brings the estimate to 0.070 — much closer to the truth than the naive 0.271, though not exact (a single regression on a noisy binomial outcome rarely lands exactly on the true parameter). Real datasets don’t come with labeled confounders — that’s where DoWhy helps.
Maya just learned the core idea of this series: the naive number was 0.271, the true number was 0.05. The email wasn’t 5.4 times as effective. It was roughly 5.4 times decorated by a hidden confounder.
4. Thinking in Graphs: Your First DAG
Priya now teaches Maya to draw her assumptions as a picture. A DAG (short for Directed Acyclic Graph) is a diagram of your causal beliefs:
- Nodes are the variables (circles)
- Arrows are “causes” (pointing from cause to effect)
- No cycles — a variable can’t cause itself (that’s the “acyclic” part)
Here’s the email story as a DAG:
user_intent ──→ received_email
user_intent ──→ purchased
received_email ──→ purchased
Drawing this picture makes Maya’s assumptions explicit. She’s committing to a claim: user intent influences whether people open emails, it influences whether they buy, and the email itself influences whether they buy. The diagram doesn’t fix anything. But it makes the assumption impossible to hide. That’s the core philosophy of DoWhy: assumptions are first-class citizens. You write them down before you compute anything.
5. Key Vocabulary
| Term | Plain English Definition |
|---|---|
| Treatment | The variable whose causal effect you want to measure (e.g., receiving an email) |
| Outcome | The variable you want to change (e.g., made a purchase) |
| Confounder | A variable that affects both treatment and outcome, faking a connection between them |
| DAG | A diagram of your causal assumptions |
| Estimand | The precise mathematical quantity you’re trying to estimate — an equation, not a number |
| Identification | Proving your causal question can be answered from observational data |
| Estimation | Actually computing a number from the data |
| Refutation | Testing whether your estimate is robust — trying to break it on purpose |
6. Where sklearn and statsmodels Fall Short
Suppose Maya skipped the lesson and trained a standard machine-learning model to predict purchases from the email flag. It would learn a strong “email → purchase” signal. But ask “what if we email everyone?” and the answer would be wrong — it has no way to know that low-intent users, the ones who rarely open emails, aren’t much like high-intent users at all.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
# Train without the confounder (as we'd have to in a real deployment)
X = df[["received_email"]]
y = df["purchased"]
model = LogisticRegression()
model.fit(X, y)
print(f"Email coefficient: {model.coef_[0][0]:.3f}")
# Now predict: "what if we emailed EVERYONE?"
df_intervene = df.copy()
df_intervene["received_email"] = 1
predicted_lift = model.predict_proba(df_intervene[["received_email"]])[:, 1].mean()
print(f"Predicted purchase rate if everyone gets email: {predicted_lift:.3f}")
print(f"Actual purchase rate in data: {df['purchased'].mean():.3f}")
The model overestimates the impact of the email because it can’t distinguish “email caused the purchase” from “the kind of person who gets emails also buys more.” DoWhy forces you to make that distinction explicit.
A coefficient is just a number the model prints to say how much one variable matters. A big coefficient here is a lie — because the model never saw user_intent.
It’s tempting to think the fix is simple: add user_intent as another input feature and retrain. That fixes the coefficient — Section 3’s controlled regression already showed the coefficient on received_email drops from 0.271 to 0.070 once user_intent is included. But it does not, by itself, fix the interventional question “what happens if we email everyone?” A predictive model that merely has user_intent as one more column to fit still doesn’t know that user_intent is a confounder rather than, say, something downstream of the email — it just sees more numbers to draw a line through. Getting the intervention right requires knowing, from a DAG, which variables to hold fixed at their observed values and which one to actually flip. That causal bookkeeping is exactly what a bare sklearn model has no way to do on its own, and exactly what DoWhy makes explicit.
7. Enter DoWhy
DoWhy is a Python library that brings structure to causal inference. Four principles guide it (see the pywhy DoWhy documentation for the canonical statement of these four):
- Explicit assumptions: Draw a graph before you compute anything
- Separation of identification and estimation: Knowing whether you can answer a causal question is separate from how you compute it
- Automated validation: DoWhy helps you test whether your assumptions hold
- Default parameters: Sensible defaults so beginners aren’t overwhelmed
DoWhy offers two main APIs (covered in Tutorials 2 and 3):
CausalModel— for estimating average causal effectsgcmmodule — for graphical causal models, root cause analysis, counterfactuals
8. Installing DoWhy
pip install dowhy "numpy<2" # numpy 2.x currently breaks dowhy's dataset generator, see note below
pip install dowhy[plotting] # for graph visualization
pip install econml # for advanced estimators (Tutorial 3)
Some examples need graphviz installed at the system level for graph rendering. In a Jupyter notebook, pip install notebook — or Google Colab — handles most of those dependencies for you.
Compatibility note: as of DoWhy 0.14,
dowhy.datasets.linear_dataset()(used in Section 9 below) raisesValueError: setting an array element with a sequenceundernumpy>=2.0— a real incompatibility in how it converts a continuous treatment to binary, not something you did wrong. Installingnumpy<2alongsidedowhyavoids it. Everything else in this tutorial (the pandas/statsmodels/sklearn simulation in Sections 1–6) reproduces identically on numpy 1.x or 2.x.
9. Your First Causal Analysis (Preview)
Now the payoff: DoWhy’s whole workflow in one block, in four steps. Don’t worry about every line — Maya unpacks each step in the next tutorials. Just see the shape of it.
from dowhy import CausalModel
import dowhy.datasets
# Generate a clean dataset where we know the true causal effect
np.random.seed(42) # same seed as Section 1, so this preview is reproducible too
data = dowhy.datasets.linear_dataset(
beta=10, # True causal effect we'll try to recover
num_common_causes=3,
num_samples=5000,
)
# Step 1: Tell DoWhy your causal assumptions (the DAG)
model = CausalModel(
data=data["df"],
treatment=data["treatment_name"],
outcome=data["outcome_name"],
graph=data["gml_graph"],
)
# Step 2: Ask: can we identify the causal effect at all?
estimand = model.identify_effect()
# Step 3: Estimate the effect using statistical methods
estimate = model.estimate_effect(
estimand,
method_name="backdoor.linear_regression",
)
print(f"Estimated causal effect: {estimate.value:.2f}")
# Expected: close to 10.0
# Step 4: Try to break our own result (robustness check)
refutation = model.refute_estimate(
estimand, estimate,
method_name="random_common_cause",
)
print(refutation)
Expected output (with numpy<2 installed, per the compatibility note above; requires dowhy==0.14):
Estimated causal effect: 10.00
Refute: Add a random common cause
Estimated effect:10.000528061153087
New effect:10.000527745358971
p value:0.94
Adding a random variable to the model barely changes our estimate — a good sign. In Tutorial 3, you’ll understand exactly what this means and how to interpret it.
See what happened? Step 1 named the assumptions. Step 2 asked “can this be answered?” Step 3 produced a number close to the true 10. Step 4 tried to break it — and failed, which is a good thing. That’s the whole method in miniature.
10. Conclusion
Priya smiles at Maya. “Now you know why I stopped you at that slide. Email didn’t double sales — the kind of customer did, and the email just rode along. Tomorrow I’m giving you a real problem: Blue Harbor charges a fee on bookings made more than 90 days out, and Mr. Delgado, the VP of Revenue, wants to know if it was right. You’ll need everything from this tutorial — and a lot more.”
In this tutorial, you learned:
- How correlation differs from causation, and why the distinction matters
- What confounders are and how they distort naive analysis
- The vocabulary of causal inference (treatment, outcome, DAG, estimand)
- How DoWhy approaches causal inference with four explicit steps
- How to install DoWhy and preview the full workflow
Next steps:
- Tutorial 2: Building Your First Causal Model — From Data to DAG, where Maya starts her investigation into the 90-day policy
- Further reading: the
dowhyGetting Started guide
Check Your Understanding
Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.
Remember What is a confounder, and which variable played that role in the email/purchase example?
Understand In your own words, explain why “A causes B,” “B causes A,” and “C causes both A and B” can all produce the exact same correlation coefficient between A and B.
Apply
The tutorial’s simulation baked in a true email effect of 0.05. Using the naive regression result (0.271) and the true effect, calculate how many times larger the naive (uncontrolled) estimate is than the true causal effect.
Analyze
The tutorial shows that a logistic regression trained only on received_email overestimates the impact of emailing everyone. Walk through why adding user_intent as a feature to a predictive model wouldn’t automatically fix this problem, even though it fixes the regression’s coefficient estimate.
Evaluate
The tutorial’s four DoWhy principles include “automated validation” and “sensible defaults.” Critique the idea that a beginner could rely entirely on DoWhy’s defaults without understanding DAGs: what’s the risk of treating causal inference as a black box you just call .fit() on?
Create Design a DAG (in the article’s plain-arrow notation) for a different business scenario: a support team notices customers who use live chat have higher retention. Name the treatment, outcome, and at least one plausible confounder, and draw the arrows between all three.
Apply What You Learned is for Supporter and Insider subscribers.
Subscribe to unlock the exercises on this post.
See plansRelated articles
- Causal Inference Under review
Sensitivity Analysis: How Robust Is Your Estimate to an Unmeasured Confounder?
Learn how to stress-test your causal estimates against unmeasured confounders using sensitivity analysis, E-values, and tipping-point plots in DoWhy.
- Causal Inference Under review
Building Your First Causal Model
Learn to specify a causal graph in DoWhy three ways, load a CausalModel with your own data, visualize assumptions, and test your DAG against the data to catch hidden bias.
- Causal Inference Under review
Root Cause Analysis: Why Did My Data Suddenly Go Weird?
Learn root cause analysis with DoWhy's GCM API: fit normal data, attribute anomalies to variables, and separate intrinsic breaks from input-driven shifts.
- Causal Inference Under review
Estimating and Validating Causal Effects With DoWhy
Learn how to use DoWhy to identify, estimate, and validate causal effects using backdoor adjustment, multiple estimation methods, and refutation tests.
Looking for something else?
Search every article by title, summary or topic.