Causal inference is a set of methods that estimate a cause-and-effect from data collected without randomisation.
A randomised test does one job: it makes groups comparable, so any difference can be pinned on the change. But in many situations randomisation is impossible, unethical, or already too late:
| Situation | Why an A/B test won't work |
|---|---|
| A price change | You can't legally or reputationally show random customers different prices |
| Feature already shipped to everyone | No control group left β the test wasn't set up in advance |
| Offline / city-level launch | Units are cities or stores; there are few of them, random split doesn't work |
| An external shock | Regulation, a competitor, a pandemic β life already ran the "experiment" |
| Marketplace / social network | Groups affect each other β randomising by user distorts the result |
The gist: an A/B test answers "will it work if we launch." Causal inference answers "did the thing that already happened work" and "what happens if we roll it out to everyone." It's not a replacement for testing β it's the tool for when a test isn't available.
The whole field rests on one question: what would have happened to these same users if the change had not occurred? That "parallel world" is the counterfactual, and you can never observe it directly β the same customer either got the feature or didn't.
So every causal method is really a different way to build a believable counterfactual: find a group or period that honestly shows "what would have happened anyway." A good analysis = a good counterfactual. A bad counterfactual = a beautiful but false number.
A check for any analysis: "What exactly am I comparing the result against β and is that really an honest answer to 'what would have happened otherwise?'" If there's no answer, don't trust the number.
Everyone knows the phrase, yet it catches people constantly. Three mechanisms that make an observed association lie:
Something drives both the "treatment" and the outcome at once. A product-analytics classic: "users of feature X retain better β the feature boosts retention." But feature X is chosen by the most engaged users β they'd have retained anyway. Engagement is the confounder, and it inflates the effect.
Groups differ from the start because of how they entered the analysis. Buyers of an extended warranty return items less often β not because the warranty protects them, but because more careful shoppers buy it.
A doesn't cause B; it's the other way round. "Customers who contact support often churn more β support drives churn." Actually, churning customers contact support more because they already have problems.
Practical takeaway: before building a complex model, ask β who ended up in each group, and why? Most false "insights" are born here, not in the maths.
For each: when it works, what it needs, and where it breaks. No formula derivations β what matters is when a method is honest and when it deceives.
Idea: compare the change in the affected group with the change in a similar untouched group over the same period. The common trend cancels out.
When: a change hit one group/region, a similar untouched one exists, and before the change both moved in parallel.
Limit: rests on the "parallel trends" assumption β without the intervention the groups would have moved identically. Checked on pre-period history. If trends diverged, it lies.
Idea: if "treatment" switches on at a sharp threshold (discount above 5,000; status above N points), customers just above and just below the cutoff are almost identical. The jump in the outcome at the boundary is the effect.
When: there's a clear threshold rule. Very convincing, close to an experiment.
Limit: measures the effect only for those near the threshold β it may differ for everyone else. Needs lots of data around the boundary.
Idea: find an "instrument" β something that randomly nudges people toward treatment but doesn't affect the outcome directly. Through it, isolate the causal part.
When: there are hidden confounders and you can find a plausibly random nudge (e.g. an arbitrary delay in feature rollout).
Limit: a good instrument is hard to find, and its validity can't be fully proven β it's a matter of argument, not proof. The most fragile of the methods.
Idea: for each "treated" user find a statistically similar "untreated" one and compare pairs, balancing observed differences.
When: lots of user data and confidence that the key differences are measured.
Limit β critical: it balances only what you observe. A hidden factor (motivation, intent) remains and spoils the result. Often creates a false sense of rigour.
Idea: when one large unit is affected (a city, a country), build a "synthetic copy" from a weighted blend of untouched units that reproduces its pre-period history. The gap afterwards is the effect.
When: one big treated unit, many donor candidates, a long history.
Limit: needs a long, stable pre-history; sensitive to external shocks that also hit the donors.
| Method | Key condition | Where it breaks |
|---|---|---|
| DiD | Parallel trends before | Trends diverged |
| RDD | A sharp threshold | Effect only near the boundary |
| IV | A valid instrument | Instrument hard to justify |
| PSM | Key factors measured | Hidden factors remain |
| Synthetic Control | Stable pre-history | External shocks on donors |
The most used method in practice β and simple enough to show in full. Here's the idea as a formula, then runnable Python.
Four mean values of the metric β by group (treated / control) and period (before / after):
The first bracket is how the affected group changed. The second is how the background would have changed even without the intervention (the control estimates it). Their difference is the clean effect.
In practice DiD is run as a regression β it gives the standard error, p-value and confidence interval directly:
Here Treated = 1 for the affected group, After = 1 for the post period. The Treated Γ After term only "switches on" in the bottom-right cell β so its coefficient Ξ²β captures exactly the extra change that can't be blamed on the common trend or on the initial gap between groups.
Key assumption (parallel trends): without the intervention, both groups would have moved the same way. Check it on pre-intervention history β if the curves ran parallel, the estimate is trustworthy.
Self-contained β needs only numpy. Data is generated inside (a true effect of +2.5 pp) so you can run it as-is and watch the method recover it. Swap the generated arrays for your own.
# Difference-in-Differences β needs only numpy. import numpy as np rng = np.random.default_rng(42) # Synthetic: retention (%) in two groups, before and after a launch. # The treated group has a true +2.5 pp effect baked in after launch. def cell(base, trend, effect, after, treated, n=500): return base + trend*after + effect*(after*treated) + rng.normal(0, 3, n) c_before = cell(30, 1.5, 2.5, after=0, treated=0) c_after = cell(30, 1.5, 2.5, after=1, treated=0) t_before = cell(28, 1.5, 2.5, after=0, treated=1) t_after = cell(28, 1.5, 2.5, after=1, treated=1) # Method 1: the 2x2 formula d_treated = t_after.mean() - t_before.mean() d_control = c_after.mean() - c_before.mean() did = d_treated - d_control print(f"Change in treated: {d_treated:+.2f} pp") print(f"Change in control: {d_control:+.2f} pp (background)") print(f"DiD effect: {did:+.2f} pp") # Method 2: same estimate via regression (adds SE, t and 95% CI) y = np.concatenate([c_before, c_after, t_before, t_after]) n = len(c_before) treated = np.concatenate([np.zeros(2*n), np.ones(2*n)]) after = np.concatenate([np.zeros(n), np.ones(n), np.zeros(n), np.ones(n)]) X = np.column_stack([np.ones_like(y), treated, after, treated*after]) beta, *_ = np.linalg.lstsq(X, y, rcond=None) resid = y - X @ beta sigma2 = (resid @ resid) / (len(y) - X.shape[1]) se = np.sqrt(np.diag(sigma2 * np.linalg.inv(X.T @ X)))[3] print(f"\nRegression effect = {beta[3]:+.2f} pp") print(f" SE={se:.3f}, 95% CI = [{beta[3]-1.96*se:+.2f}, {beta[3]+1.96*se:+.2f}]")
What the result shows: the naive "after β before" in treated would give +3.54 pp β inflated, because part of the rise is just the background (+1.41). DiD subtracts the background and gives an honest +2.13 pp, close to the true +2.5. Both methods agree to two decimals.
Situation: the product shipped a new onboarding only in Russia, keeping the old one in Kazakhstan. You want the effect on day-30 retention.
Naive approach: "retention rose 4 pp in RU β onboarding works." The trap: retention may have risen everywhere due to seasonality.
Done right: take the change in RU (treated) and in KZ (control) over the same period. If RU is +4 pp and KZ +1.5 pp, the onboarding effect β 2.5 pp, not 4.
Check: look at 3β6 months before launch β did the retention curves run parallel? If yes, the estimate is trustworthy.
Situation: "Silver" status is granted at 10,000 points and unlocks free delivery. Does the status itself affect purchase frequency?
Naive approach: compare "Silver" vs "no status." The trap: Silver members are simply active buyers β they already bought more.
Done right: compare customers with 9,800β9,999 points (just missed) and 10,000β10,200 (just got it). They're nearly identical; the behaviour gap is the status effect.
Situation: you opened a large offline store in one city. How did it affect the brand's total revenue in that city, accounting for online cannibalisation?
Problem: there's only one city, so there's no honest control group.
Done right: build a "synthetic city" from a weighted blend of similar cities without a store, matching the target's revenue history before opening. The gap after opening is the clean effect.
Situation: customers on premium support renew more often. Should you offer it to everyone?
Naive approach: "renewal +15 pp β roll out to all." The trap: premium is chosen by the most engaged β they'd renew anyway.
Done right (and its limit): match on the observed β company size, tenure, usage. Already fairer. But: "intent to stay" isn't measured, and it drives both buying support and renewing. So even careful PSM here probably overstates the effect.
| If you have⦠| Look at |
|---|---|
| A change in one group + a similar untouched one | Difference-in-Differences |
| A clear threshold rule (points, amount, rank) | Regression Discontinuity |
| One large unit (a city, a country) | Synthetic Control |
| Many observed features, all key ones measured | Propensity Matching (carefully) |
| A plausibly random "nudge" toward treatment | Instrumental Variables |
| The ability to hold back part of the audience | An A/B test after all β it's more honest |
Default rule: if you can run a real experiment, run it. Causal inference is more expensive to justify and always leans on assumptions that can't be fully verified.
Causal inference is powerful, and that's exactly why it's dangerous: it returns a confident-looking number even when the assumptions don't hold. Keep in mind:
| Limitation | What to do |
|---|---|
| Every method leans on unverifiable assumptions | State the assumption explicitly and check what you can (pre-trends, group balance) |
| Hidden factors can't be balanced away | Ask: "what could enter both sides and stay unaccounted for?" |
| The effect may be local (only near the threshold / compliers) | Don't extrapolate the estimate to the whole base automatically |
| Temptation to try methods until one looks "nice" | Fix the method and assumptions before the analysis |