learn-experimentation-with-phoebe / Builder session 1 of 10
Learn Experimentation with Phoebe · Builder session 1 of 10

Potential outcomes, and the estimand you can never fully see

Before you power a test or run a t-test, you need the mental model underneath all of it: every unit has two potential outcomes, you only ever observe one, and the whole discipline exists to recover the gap between them. We build that model in Python on Lumen data, watch the "fundamental problem of causal inference" bite, and see exactly why randomization is the one move that rescues a clean answer. This is your ▶ start button for the builder track.

🟢 Start here Builders · DA / DE / DS Python · numpy / pandas 45 min
0-3 · Setup 3-16 · Potential outcomes 16-40 · Simulate it in Python 40-45 · Wrap
Part 0

The question every experiment is secretly asking

Lumen's CMO wants to know one thing: "If we ship the new product-page layout, will more people check out?" That sounds like a reporting question, but it is a causal one. It compares the world where we ship the change to the world where we do not - for the same shoppers, at the same time. Only one of those worlds is ever real. Session 1 gives you the framework that makes this precise (potential outcomes), names the thing you are actually estimating (the estimand), and shows in code why a plain before/after comparison quietly lies while a randomized one does not.

Live - built in session Self-study - read after class ★ Build-along - everyone codes it The data: Lumen Skincare
★ What you walk out with today A Python notebook that (1) creates a synthetic Lumen population where you know both potential outcomes for every shopper, (2) demonstrates the fundamental problem by hiding the counterfactual, (3) computes the true ATE you were trying to recover, and (4) shows a confounded naive estimate landing far from it while a randomized estimate lands on it. Every later session estimates this same ATE - now you know exactly what it is.
Part 1 · the framework

Two potential outcomes, one observed 6 min live

The Neyman-Rubin framework says: for each unit i there are two potential outcomes - Y_i(1), what happens if it gets the treatment, and Y_i(0), what happens if it does not. The causal effect for that unit is Y_i(1) - Y_i(0). The catch is brutal and permanent: you can only ever observe one of the two, because a shopper either sees the new layout or the old one, never both. The other is the missing counterfactual.

Lumen shopper Y(1) new layout Y(0) old layout effect Y(1)-Y(0) what you actually see Ava (treated) buys ✓ ? missing ? saw new → bought Ben (control) ? missing no buy ✗ ? saw old → left Cara (treated) no buy ✗ ? missing ? saw new → left If we were omniscient (the simulation in Part 3): Ava buys no buy +1 (lift!) only known in a simulation The greyed cell is never observed for a real unit - that is the "fundamental problem of causal inference" (Holland 1986). Because per-unit effects are unknowable, we estimate an AVERAGE across many units - the ATE. Averages are recoverable; individuals are not.
🔍 Click to zoom - two potential outcomes per shopper, only one ever observed
LiveThe fundamental problem, stated plainly3 min

Holland (1986) named it: you can never observe both potential outcomes for the same unit. Causal inference is therefore a missing-data problem, not a fancier regression. Ava saw the new layout and bought; we will never know whether she would have bought under the old one. That single missing cell is why "she bought after we shipped it, so it worked" is not evidence of anything.

  • Y_i(1) - the outcome if unit i is treated.
  • Y_i(0) - the outcome if unit i is control.
  • Y_i(1) - Y_i(0) - the individual treatment effect. Unknowable for any real unit.
Real world

A PM says "conversion went up 8% the week after we launched the new page - ship it everywhere." That compares this week's shoppers to last week's - different people, different weather, a payday, a promo. It never touches the counterfactual (what these shoppers would have done under the old page). It is a before/after story, and before/after is where careers quietly go wrong.

LiveThe estimands: ATE, ATT, CATE3 min

Since individual effects are hidden, we target an average. Which average is the "estimand" - the precise quantity you are trying to estimate, chosen before you touch data.

EstimandDefinitionAnswers
ATEE[Y(1) - Y(0)] over everyone"If we rolled the new page to all shoppers?"
ATTE[Y(1) - Y(0) | treated]"Did it help the people who actually got it?"
CATEE[Y(1) - Y(0) | X = x]"Did it help mobile users specifically?"
Name the estimand first Deciding ATE vs ATT vs CATE after seeing results is how people fool themselves. In a clean randomized A/B test ATE and ATT coincide (assignment is independent of the potential outcomes), which is one more reason randomization is the gold standard - it removes the choice.
Part 2 · the rescue

Why randomization is the one move that works 4 min live

If you let shoppers self-select into the new layout (say, only logged-in loyalty members see it), then treatment and outcome share a common cause - engagement. Engaged people convert more and are likelier to be treated. That common cause is a confounder, and it opens a "backdoor path" that makes the naive difference a blend of real effect plus selection. Randomization severs that path by making assignment independent of everything - observed and unobserved.

Self-selected (confounded) Engagement New layout T Checkout Y backdoor path T ← Engagement → Y naive diff = real effect + selection bias Randomized (clean) Engagement New layout T Checkout Y 🎲 coin a coin flip decides T, so nothing causes T naive diff = the real effect, full stop Randomization balances confounders in expectation - even ones you never measured or thought of. No observational method can promise that.
🔍 Click to zoom - a confounder biases the naive comparison; randomization cuts the backdoor path
Self-studyIgnorability, SUTVA, positivity - the fine print3 min read

For any estimate to be causal, three assumptions must hold. Randomization hands you the first two for free; the third you still watch.

  • Ignorability / unconfoundedness - assignment is independent of the potential outcomes (given covariates). Randomization makes this true by construction.
  • SUTVA - no interference (one shopper's treatment does not affect another's outcome) and no hidden versions of the treatment. Marketplaces and social features break this; we return to it in b3.
  • Positivity / overlap - every unit has a non-zero chance of either arm (0 < P(T=1) < 1). A 50/50 split trivially satisfies it; observational data (b10) often does not.
Where we are headed

The tool you will build by hand in b2 try it

Every experiment in this course starts by deciding how many users it needs. Here is the finished planner - drag the dials and feel how baseline rate, the lift you want to detect, and your error tolerances trade off against sample size and runtime. In Builder Session 2 you rebuild this math from scratch in statsmodels.

Build-along 1 of 3

Create a world where you know both outcomes ★ 8 min · everyone

In real life the counterfactual is missing. In a simulation, we play god: we generate Y(1) and Y(0) for every synthetic Lumen shopper, so we know the true effect and can check whether our estimators recover it.

Build a population of 20,000 shoppers with an engagement score. Engagement lifts baseline conversion - it is our confounder.

demo1_potential_outcomes.py
import numpy as np, pandas as pd
rng = np.random.default_rng(42)

N = 20_000
engagement = rng.beta(2, 5, N)            # skewed 0..1, most shoppers low

# TRUE potential outcomes (we are omniscient here):
# baseline buy-prob rises with engagement; the new layout adds a flat +2pp lift
base = 0.02 + 0.10 * engagement           # Y(0) probability
p0   = base
p1   = np.clip(base + 0.02, 0, 1)          # Y(1) probability = +2pp TRUE effect

Y0 = (rng.random(N) < p0).astype(int)     # outcome if OLD layout
Y1 = (rng.random(N) < p1).astype(int)     # outcome if NEW layout
df = pd.DataFrame({"engagement": engagement, "Y0": Y0, "Y1": Y1})

Compute the estimand you are chasing - the true ATE - directly from the two columns you would never both have in reality:

true_ate.py
true_ate = (df.Y1 - df.Y0).mean()
print(f"True ATE = {true_ate:.4f}")   # ~0.020 by construction (the +2pp)

This true_ate is the target. Every method in the course - t-tests, CUPED, DiD, propensity scores - is a different attempt to recover this number when one of Y0 / Y1 is hidden. Keep it on screen.

Build-along 2 of 3

Watch the naive estimate lie under self-selection ★ 8 min · everyone

Now assign treatment the way it usually happens in the wild: the most engaged shoppers opt into the new experience. Reveal only the observed outcome for each, and compare group means. The naive number will miss the true ATE - on purpose.

Confounded assignment - probability of treatment rises with engagement:

demo2_confounded.py
# self-selection: engaged shoppers are far likelier to get the new layout
p_treat = 0.15 + 0.70 * df.engagement
T = (rng.random(N) < p_treat).astype(int)

# reveal only the OBSERVED outcome (the fundamental problem in code)
Y_obs = np.where(T == 1, df.Y1, df.Y0)
df["T"], df["Y"] = T, Y_obs

naive = df.loc[df.T==1, "Y"].mean() - df.loc[df.T==0, "Y"].mean()
print(f"Naive diff = {naive:.4f}  vs true ATE {true_ate:.4f}")
# naive is INFLATED - treated group was more engaged to begin with

Run it. The naive difference comes out well above 0.020 - often 2-4x too big. The extra is pure selection bias: you credited the layout for conversions that engaged shoppers would have made anyway.

This is the before/after PM story from Part 1, now quantified. The number is not noise - it is confidently wrong, which is worse.

Real world

This is exactly how "our email opt-in users convert 3x better, so email is our best channel" happens. Opt-in users self-selected on intent. The 3x is mostly who they already were, not what email did. Builder Session 10 de-confounds this properly with propensity scores.

Build-along 3 of 3

Randomize, and watch the bias vanish ★ 7 min · everyone

Change one line - assign by coin flip instead of by engagement - and the same naive difference now lands on the true ATE. Nothing else changed. That is the entire argument for controlled experiments in three lines of Python.

Replace self-selection with a fair coin:

demo3_randomized.py
T_rand = (rng.random(N) < 0.5).astype(int)          # 🎲 independent of everything
Y_rand = np.where(T_rand == 1, df.Y1, df.Y0)

est = Y_rand[T_rand==1].mean() - Y_rand[T_rand==0].mean()
print(f"Randomized estimate = {est:.4f}  vs true ATE {true_ate:.4f}")
# now they match within sampling noise - bias is gone

Run it a few times (change the seed). The randomized estimate wobbles around 0.020 from sampling noise but is unbiased - no systematic gap. The confounded one was biased every single time.

Confirm balance: df.groupby(T_rand)["engagement"].mean() shows the two arms have near-identical engagement. Randomization balanced the confounder without anyone measuring it. That is the magic - and why we spend b3 protecting it.

One line separates truth from bias Self-selection vs a coin flip - that is the whole difference between an observational guess and a causal answer. Everything else in this course is either (a) running that coin flip well, or (b) recovering the truth when you were not allowed to flip it.
Before Session 2

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · The "fundamental problem of causal inference" is that...

Each unit has Y(1) and Y(0), but only one is ever realized - the counterfactual is missing. That is why causal inference is a missing-data problem and why per-unit effects are unknowable.

2 · A confounder biases the naive treated-minus-control difference because it...

Engagement drives both who gets treated and who converts, so the naive gap mixes the real effect with selection. Randomization cuts the arrow into treatment and removes the backdoor.

3 · Why does a coin-flip assignment give an unbiased estimate even for confounders you never measured?

Assignment independent of the potential outcomes means the two arms are comparable on everything in expectation - the one guarantee no observational adjustment can make.

Source material

What this session covers

The potential-outcomes framework is the shared foundation of every causal course. We teach ~80% of the working core here; the deep proofs and the graphical (Pearlian) treatment live in the sources below.

Potential outcomes, ATE/ATT/CATE, fundamental problemRubin/Holland framework - Part 1, build-along 1
Confounding and why randomization worksbackdoor intuition + coded demo - Part 2, build-alongs 2-3
Crash Course in Causality (UPenn, Roy)DAGs, propensity, IPTW in depth - we build these in b10
Brady Neal - Intro to Causal Inferencethe graphical (do-calculus) framing - concept-level here
Formal proofs (Imbens & Rubin 2015)identification theory - out of scope by design

Builder Session 1 cheat sheet · pin this

Two outcomesEvery unit has Y(1) and Y(0); the effect is Y(1)-Y(0). You observe one - the other is the missing counterfactual.
Fundamental problemNever see both for one unit (Holland 1986). So estimate an average, not an individual, effect.
The estimandATE (everyone) · ATT (the treated) · CATE (a subgroup). Pick it before you see data.
ConfounderCommon cause of T and Y. Opens a backdoor path; makes the naive diff = effect + bias.
RandomizationCoin flip makes T independent of everything - balances confounders observed AND unobserved. The gold standard.
Three assumptionsIgnorability (free from randomization) · SUTVA (no interference) · positivity (0<P(T=1)<1).