learn-causal-inference-with-phoebe / Practitioner session 6 of 10
Learn Causal Inference with Phoebe · Practitioner session 6 of 10

Counterfactuals: the world that did not happen

Sessions 1 to 5 answered a question about everybody: send the email to the whole list and spend rises by 6.23. Tonight the question is about Amara, a lapsed customer who did get the email, who did open it, and whose spend is one row in the table. What would she have spent if the email had never been sent to her? That number is not in her row, it is not in anybody's row, and no amount of data will put it there. Getting at it means owning the equations, not just the arrows.

🔴 Hardest Data scientists and analysts 45 min
0-3 · Welcome 3-22 · Two worlds at once 22-40 · Build-along: compute one counterfactual 40-45 · Q&A
Part 0

Where rung three sits

The ladder has three rungs and each one asks a strictly harder question than the last. Rung 1 is association: what does seeing the email tell me about spend. Rung 2 is intervention: what happens to spend if the email is forced on. Rung 3 is the counterfactual: what would have happened to this customer, who did receive it, had she not. The first is about a joint distribution, the second is about a population under a change, and the third is about one individual and an alternative world that never existed.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. One counterfactual computed by hand for one named customer, in three steps you can defend line by line. The reason a graph and a table are enough for rung 2 and not enough for rung 3. And the uncomfortable finding that Amara's individual effect is either 28.0 or 31.0, that the population average of 6.23 is not one of the eight values any Kestrelmark customer holds, and that which of her two numbers is hers depends on a coin flip no column records.

LiveWhat rung 3 asks that rung 2 does not3 min▶

A rung-2 question fixes a variable by force and asks about the resulting distribution: send the email to all 5,000 customers, send it to none, difference the mean spend, and the answer is 6.23. Nothing in that question refers to a particular person. It is a statement about the population under a policy, and the whole of sessions 1 to 5 was about earning it.

A rung-3 question does refer to a particular person, and it refers to her twice. It says: take Amara exactly as she is, with the email she actually received and the spend she actually recorded, and hold everything about her fixed except the treatment. Now report her spend in the other world. Two worlds are being described at once, and one of them left no data behind, which is why the question cannot be phrased as a comparison between rows.

Real world

The retention meeting asks both questions and never separates them. "Does the win-back email work" is rung 2 and the campaign owns the answer. "Would this account have churned anyway" is rung 3 about one logo, and it decides whether the save gets credited to the campaign or to the customer success manager. The second question is asked far more often and defended far less.

Self-studyThree phrasings of the same Kestrelmark decision3 min▶
  • Rung 1. "Customers who got the email spent 10.54 more." True, reproducible, and mostly season and tenure. Session 1.
  • Rung 2. "Sending the email to everyone raises mean spend by 6.23." Needs a graph, an adjustment set that closes the back doors, and the data to hold it fixed. Sessions 2 and 3.
  • Rung 3. "Amara would have spent 31.0 less had we not sent it." Needs everything above, plus the functional form of every equation in the model.

The three sentences use the same words and cost wildly different amounts to defend. A useful habit is to write the rung number in the margin of any claim before checking anything else about it, because a rung-3 sentence backed by a rung-2 analysis is the most common way a defensible number turns into an indefensible one.

Part 1 · covers what a counterfactual is and what it demands

Rung three asks about a world that did not happen

10 min live

The recipe is three steps and it is the same three steps whether the model is four lines of arithmetic or a simulator with ten thousand parameters. Recover the individual's background factors from what was observed. Change the treatment by hand. Recompute the outcome. Everything difficult about rung 3 is hidden in the first step.

Rung 3 in three steps: recover, flip, recompute Observed world Amara, one treated row recency = lapsed the persuadable group email = 1, opened = 1 spend = her actual row 1 · Recover the noise solve each equation for its own error term u_spend comes out exact u_open only as a range this is the hard step 2 · Flip the treatment set email = 0 by hand every noise term stays exactly as recovered nothing else is touched she stays herself 3 · Recomputed world re-run the equations opened may flip to 0 spend falls with it her effect: 31.0 or 28.0 Her difference is 31.0 if the email is what made her open, and 28.0 if it did not. The population average of 6.23 is not one of the eight values anybody holds. Step 1 is where a graph stops being enough. Recovering noise needs the equations.
🔍 Click to zoom - observed row, recovered background, flipped treatment, recomputed world.
LiveWhy a graph plus data is not enough4 min▶

For rung 2 the graph carries all the assumptions that matter. It tells you which paths are causal, which are back doors and which set closes them, and once a valid set exists the arithmetic can be anything: a group mean, a regression, a stratified average. The shape of the equations is not part of the question.

For rung 3 it is the entire question. To hold Amara fixed you have to know what "Amara" consists of beyond her observed columns, and what she consists of is the collection of error terms that made her row come out the way it did rather than at the average for her covariates. Those errors are only recoverable by inverting the equations that generated the row, so the equations have to exist and be written down.

  • The graph gives you the inputs to each equation. It says spend is a function of season, tenure, email, opened and something unmeasured. It does not say the function is linear, or additive, or monotone.
  • The functional form gives you the residual. Subtracting the model's prediction for her covariates from her actual spend is what isolates the part of her that is hers.
  • The flipped world is then a re-run, not an estimate. Once her errors are pinned, setting the email to 0 and recomputing is deterministic arithmetic with no sampling in it.

This is exactly what the bench's engine does when it produces the number 6.23: it runs the written model twice over the same 5,000 draws, forcing the email on and then off, and differences the mean. The truth is available there only because the model is available. Outside a simulator it is not, and that gap is the honest cost of rung 3.

LiveThe three steps, said plainly3 min▶
  • Recover. Take the observed row and solve each structural equation for its own noise term. Kestrelmark's spend equation is additive, so her spend minus the model's prediction at her own values is her residual, exactly.
  • Flip. Overwrite the treatment with the value you want to ask about, and change nothing else. Not her season, not her tenure, and above all not the noise you just recovered, because that noise is what makes this her rather than a customer like her.
  • Recompute. Push the flipped row back through the equations in causal order. Downstream variables move: with the email off, her chance of opening drops from sigmoid(1.4) to sigmoid(-0.2), and if she stops opening then the mediated 3.0 goes with it, on top of whatever her segment's direct effect was worth.

The step everyone skips is the second half of "change nothing else". Recomputing her covariates from a fitted model, or replacing her residual with the group average, produces the answer for an average customer with her profile. That is a legitimate quantity and it is not a counterfactual. It has lost the individual.

Self-studyWhy her answer is 28.0 or 31.0, and why 6.23 is nobody's4 min▶

Kestrelmark's spend equation gives opening a coefficient of 3.0 for everybody, and gives the email a direct coefficient that depends on which recency segment the customer sits in: 1.0 for an active "sure thing", 28.0 for a lapsed "persuadable", 0.0 for a dormant "lost cause", and -10.0 for a "sleeping dog" who has cancelled once before. So flipping one customer's email off can still do exactly two things, and her segment decides what each of them is worth.

  • She would have opened anyway, or she never opens. The open flag is unchanged, only the direct term drops, and her individual effect is her segment's direct effect. Amara is a persuadable, so hers is 28.0.
  • The email is what made her open. Both terms drop, and her individual effect is her segment's direct effect plus 3.0, which for Amara is 31.0.

Four segments, two branches each, and the population holds exactly the eight values that fall out of it: -10.0 and -7.0 for the sleeping dogs, 0.0 and 3.0 for the lost causes, 1.0 and 4.0 for the sure things, 28.0 and 31.0 for the persuadables. The average of those eight is 6.23, and 6.23 is not among them.

Read the average the other way round and it decomposes cleanly: a mean direct effect of 5.16, plus 3.0 times the change in open rate the email causes, a mediated 1.056, giving an analytic 6.22 against the bench's brute-forced 6.23. The two agree, which is the engine checking itself against the algebra rather than against its own output.

That eight-point distribution is real heterogeneity rather than noise, and it is what session 7 tries to recover with a model instead of with the equations.

Eight individual effects, and the average is none of them Sleeping dog Lost cause Sure thing Persuadable customers effect 245 148 1,614 709 851 411 650 372 -10.0 -7.0 0.0 +1.0 +3.0 +4.0 +28.0 +31.0 average 6.23 falls here Within every segment the two bars differ by exactly 3.0: whether the email is what tipped that person into opening. 1,614 customers get nothing at all, and 393 are actively harmed by a campaign whose average is positive. No feature separates a segment's two bars. A model can buy you the segment. It cannot buy you the person.
🔍 Click to zoom - the eight effects the population actually holds, and the empty gap the average sits in.
Part 2 · covers what rung 3 buys and where it stops

What it buys and what it cannot

8 min live

Rung 3 is the only rung that answers a question about a named individual, and there is a short list of business questions that are genuinely of that kind. There is a much longer list of questions that get phrased that way out of habit, and reaching for rung 3 when rung 2 would do buys you an assumption you cannot defend in exchange for nothing.

Two forms that fit the same rows and answer differently spend tenure untreated rows stop here her tenure Form A: a straight line fits the observed rows Form B: one bend fits them just as well Same rows, same residuals different answer for her no test separates the two so the choice is an assumption Averaged over the population the two forms move in the second decimal. For one customer they move by units, and her residual cannot tell you which is right. The average is stable because errors cancel across people. One person has nothing to cancel.
🔍 Click to zoom - identical fit inside the data, two different worlds outside it.
LiveThe questions only rung 3 can answer3 min▶

Three shapes of question genuinely require an individual counterfactual, and they are worth recognising because each one is usually asked in language that hides it.

  • "Would this customer have churned anyway?" The retention team wants to know whether the save was caused by the intervention or would have happened without it. That is her outcome in a world where nothing was sent, and it is the attribution question underneath every save-rate report.
  • "Was this particular sale caused by the campaign?" A question about one transaction that already happened, sometimes with money attached to the answer. The formal version is the probability that the outcome would have been different had the treatment not occurred.
  • "Is this individual a good person to treat next time?" Forward looking, and therefore the friendliest of the three, because it can be softened into a question about people like her rather than about her. That softening is what session 7 does.

Notice that the first two are retrospective and about a specific event, which is what makes them expensive. There is no future experiment that settles them.

LiveThe honest limit: the form is an assumption and no data checks it4 min▶

Two models can fit the observed rows equally well, agree on every prediction you can check, and still give different counterfactuals for the same person. This is not a small-sample problem and it does not shrink with more data. The observed rows constrain the model only where the rows are, and a counterfactual asks about a point where, by definition, there is no row.

  • The residuals are identical, so the usual diagnostics are silent. There is no held-out set for a world that did not happen, and cross-validation scores the same predictions both forms already agree on.
  • The disagreement is asymmetric. Averaged over the population the two forms tend to agree closely, because their extrapolation errors point in different directions for different people and cancel. One individual has nothing to cancel against.
  • A binary variable makes it worse. Amara's open flag pins her open-equation noise to an interval rather than a point, so even with the true equations her counterfactual is bounded rather than determined. Partial identification is the normal case, not an edge case.

Report the range, not the point. If two defensible forms give two different answers for the same customer, that pair is the finding. Averaging them into a midpoint and quoting it to two decimals converts an honest disagreement into a false precision that survives into the deck.

LiveMost business questions are rung 23 min▶

Before paying for rung 3, check that the decision actually needs it. The test is whether the action attaches to a person or to a policy. "Should we send this campaign" is a policy and rung 2 answers it with an average. "Which segment should receive it" is still a policy, one per segment, and rung 2 answers that too by conditioning. Only "what happened to this account" is unavoidably rung 3.

  • Reaching for rung 3 unnecessarily adds a functional form to your assumption list, which is the one assumption no reviewer can check and no sensitivity analysis in session 9 will price for you.
  • It also invites individual-level reporting, and a number attached to a named customer is treated as a fact by everybody downstream, regardless of the range it came from.
  • The intermediate position is a conditional average: the effect for customers like her rather than for her. It needs no structural form beyond what rung 2 already required, and it is what session 7 estimates.
Real world

A save-rate dashboard that credits each retained account to whichever campaign touched it last is making 5,000 rung-3 claims a quarter, each one asserting the account would have churned otherwise. None of the underlying models were built to answer that, and the total is reported as a single revenue figure.

Self-studyThe bridge into session 73 min▶

A conditional average treatment effect is the per-person counterfactual with the individual sanded off. Instead of asking for Amara's difference, it asks for the average difference among customers who share her observed covariates, which is a quantity that averages away the residual you would otherwise have to recover.

  • It needs no structural equations. Whatever identified the average effect in session 3 identifies the conditional one within each covariate cell, so the assumption list does not grow.
  • It gives up the individual. Two persuadables with identical covariates get identical answers, even though the truth here says one of them gets 28.0 and the other 31.0.
  • It is what the business usually wanted. Targeting acts on segments, and a segment is exactly what a conditional average describes.

So the honest ordering is: use rung 2 where the decision is a policy, use a conditional average where the policy varies by segment, and reserve rung 3 for the retrospective questions that genuinely name an individual. Session 7 builds the middle one, and gets remarkably far: the recency segment on its own reaches an R^2 of 0.986 against the individual effect. What is left over is exactly the 3.0 coin flip, and no feature in the table predicts it. That is the honest statement of what rung 3 gives you here. A fully specified model buys you the segment. It never buys you the person.

Build-along

Compute one counterfactual

★ 18 min · everyone builds

One treated customer, the written model, and three steps. The point of doing it on Kestrelmark rather than on your own data is that here the equations are available, so the answer can be checked. Then the same computation is repeated under a second functional form to see what moves.

Pick one treated customer and write her row out in full. Season, tenure, email, opened, spend. Say her name out loud. Everything that follows is about her and not about a group that resembles her.

Write the structural equations down beside her row. The open equation with its sigmoid, the spend equation with the direct effect her segment carries and its mediated 3.0. If you cannot write them, stop: the question you are about to answer is not answerable, and knowing that now is the cheap outcome.

Recover her noise. Subtract the spend equation's prediction at her own values from her observed spend. That residual is exact because the equation is additive. Note separately that her open flag pins its noise only to an interval.

Flip the email to zero and recompute, twice. Once holding her open flag as observed, once letting it flip shut. The two answers are 28.0 and 31.0, and her row does not say which is hers.

Compare against the population average. 6.23 is the mean of a distribution with exactly eight values, and 6.23 is not one of them. Sit with the fact that the headline number is held by nobody, and that 393 of the 5,000 are harmed by a campaign whose average is positive.

Refit under a second functional form and repeat the whole thing. Add a curve in tenure, confirm the two forms fit the observed rows about as well as each other, then look at what happened to her number and what happened to the average.

Write both individual answers into the record, as a range with the assumption named. One line, in the same file as the DAG and the roles list. A counterfactual quoted without its functional form is a number with no owner.

★ Try it now - recover, flip, recompute, for one customer import numpy as np, pandas as pd df = pd.read_csv("kestrelmark.csv") # 5,000 rows, one per customer TRUTH = 6.23 # population average, known by construction # the structural model the data came from. rung 3 needs every line of this; # the graph alone gives you the inputs but not the shapes. SIG = lambda z: 1.0 / (1.0 + np.exp(-z)) A0, A_EMAIL = -0.2, 1.6 # open equation: sigmoid(-0.2 + 1.6 * email) DIRECT = {0: 1.0, 1: 28.0, 2: 0.0, 3: -10.0} # spend: the direct effect, BY SEGMENT B_OPENED = 3.0 # spend: the jump carried by opening BASE, B_SEASON, B_TENURE, B_REC = generator_coefficients() # the rest, as written def spend_eq(row, email, opened, u): """Her spend under any (email, opened), holding her own background fixed.""" return (BASE + B_SEASON * row.season + B_TENURE * row.tenure + B_REC[row.recency] + DIRECT[row.recency] * email + B_OPENED * opened + u) # recency 1 is the lapsed "persuadable" segment. Amara is one of them. her = df[(df.email == 1) & (df.opened == 1) & (df.recency == 1)].iloc[0] # STEP 1 - recover. the spend equation is additive, so her noise comes out exactly. u_her = her.spend - spend_eq(her, her.email, her.opened, 0.0) # the open equation is NOT invertible. opened = 1 tells us only that her draw # fell below sigmoid(1.4); it does not say where. an interval, not a point. p1, p0 = SIG(A0 + A_EMAIL), SIG(A0) # her open chance with the email, and without print("she opens with the email at", round(p1, 3), "and without it at", round(p0, 3)) # STEP 2 and 3 - flip the treatment, recompute, changing nothing else. would_open_anyway = spend_eq(her, 0, her.opened, u_her) would_stay_shut = spend_eq(her, 0, 0, u_her) print(her.spend - would_open_anyway) # 28.0 her segment's direct effect only print(her.spend - would_stay_shut) # 31.0 direct 28.0 plus mediated 3.0 # which one is hers depends on where her open draw fell, and her row is silent. print("chance it is the 31.0:", round((p1 - p0) / p1, 3))

The two prints are the whole session. Her individual effect is 28.0 or 31.0. Not a distribution around 6.23, not 6.23 with error bars: two values, one of which is true for her, chosen by a coin flip nobody observed. Run the same three steps on a sleeping dog and the two prints come back -10.0 and -7.0. Everything else tonight is about how much worse this gets when the equations are guessed instead of known.

★ Try it now - the average is a mixture nobody holds # every customer's true effect is her segment's direct effect, plus the mediated 3.0 # when the email is what tipped her into opening. four segments, two branches, eight values. eff = df.recency.map(DIRECT) + B_OPENED * tipped_into_opening(df) print(sorted(eff.unique())) # [-10.0, -7.0, 0.0, 1.0, 3.0, 4.0, 28.0, 31.0] print(eff.value_counts().sort_index()) # 245, 148, 1614, 709, 851, 411, 650, 372 MEAN_DIRECT = 5.16 # the direct effect averaged over the four segments MEDIATED = 1.056 # 3.0 times the change in open rate the email causes print(round(MEAN_DIRECT + MEDIATED, 2)) # 6.22, the analytic value print(TRUTH) # 6.23, brute-forced over the 5,000 rows # the two agree, which is the engine checking itself against theory rather than # against its own output. and the average is nobody's effect: it does not appear # in the list of eight, and it sits in the empty gap between +4.0 and +28.0. print(int((eff < 0).sum())) # 393 customers the campaign actively harms
★ Try it now - a second form that fits just as well and answers differently def fit(cols): """Plain least squares. No inference and no standard errors: arithmetic only.""" X = np.column_stack([np.ones(len(df))] + [df[c].to_numpy(float) for c in cols]) y = df["spend"].to_numpy(float) beta, *_ = np.linalg.lstsq(X, y, rcond=None) resid = y - X @ beta return beta, float(resid @ resid) df["tenure2"] = df.tenure ** 2 # the only difference between the two forms FORM_A = ["email", "opened", "season", "tenure"] FORM_B = FORM_A + ["tenure2"] beta_a, rss_a = fit(FORM_A) beta_b, rss_b = fit(FORM_B) print(rss_a, rss_b) # near enough identical: no diagnostic separates them def cf_gap(beta, cols, row): """Her spend with the email minus her spend without it, under this form. Her own values enter everywhere except the treatment.""" def yhat(email, opened): vals = {**{c: float(row[c]) for c in cols}, "email": email, "opened": opened} return beta[0] + sum(b * vals[c] for b, c in zip(beta[1:], cols)) return yhat(1, 1) - yhat(0, 0) print(cf_gap(beta_a, FORM_A, her)) # the model's answer for her under form A print(cf_gap(beta_b, FORM_B, her)) # the model's answer for her under form B: it moves # and notice what neither form can do. with no interaction term, cf_gap never reads # her row, so a persuadable and a sleeping dog are handed the same difference. letting # the fitted effect vary by segment is session 7's job, not tonight's. # now the same two forms averaged over everybody gap_a = np.mean([cf_gap(beta_a, FORM_A, r) for r in df.itertuples()]) gap_b = np.mean([cf_gap(beta_b, FORM_B, r) for r in df.itertuples()]) print(round(gap_a, 2), round(gap_b, 2)) # the population numbers barely budge # the individual answer is sensitive to a choice the data cannot arbitrate. # the average is not. that asymmetry is the reason to prefer rung 2 when it will do.

Estimating the average from the data alone, without the written model, is a different job. The fitting recipes for that live in learn-experimentation-with-phoebe, which owns potential outcomes, propensity scores, inverse probability weighting, doubly robust estimation, difference-in-differences and synthetic control. Tonight's arithmetic assumes the model is known, which is what makes the individual answer computable and what makes it fragile.

Homework

Before session 7

30 min
  1. Find one claim your team has made in the last quarter that is secretly rung 3: a named account, a specific sale, a save credited to a campaign. Write it out and underline the individual.
  2. For that claim, try to write the structural equations you would need to defend it. Note where you stop, because that is the honest boundary of the claim.
  3. Take one customer from a model you own, recover the residual under the current specification, then refit with one extra term and recover it again. Report how much the individual prediction moved and how much the population mean moved.
  4. Rewrite one rung-3 sentence as the rung-2 sentence that would support the same decision. If the decision survives the rewrite, the counterfactual was never needed.
  5. List the covariates you would condition on to describe customers like Amara rather than Amara. Bring that list to session 7, where it becomes the input to a conditional effect model.
Check yourself

Three questions

A team has the Kestrelmark DAG, a validated adjustment set and all 5,000 rows. They want Amara's spend in the world where the email was never sent to her. What is still missing?

A graph plus data answers rung 2. Rung 3 holds one individual fixed, and what makes her an individual is her residual, which is only recoverable by solving the structural equations she came from.

The population effect at Kestrelmark is 6.23. What is the effect for a single customer?

The spend equation gives opening 3.0 for everybody and gives the email a direct effect set by the recency segment: 1.0, 28.0, 0.0 or -10.0. That is eight possible individual effects, their mean is 6.23, and no customer holds it.

Two specifications fit the observed rows with identical residuals and give Amara counterfactuals several units apart. Which diagnostic settles it?

Held-out scoring measures predictions on worlds that happened. The counterfactual is a point with no data by construction, so the extrapolation is unconstrained and the honest output is the range plus the named assumption.

Sources covered

What tonight maps to

✓ Rung 3 of the ladder Reasoning about an alternative world that did not happen, which means modelling two worlds at once and therefore requires a fully specified causal model.
✓ The three-step recipe Recover the individual's background factors, change the treatment by hand, recompute the outcome. The same procedure the bench engine runs to produce 6.23.
✓ What a fully specified model buys Would this customer have churned anyway, was this sale caused by the campaign, is this individual worth treating. All three need the equations, not just the arrows, and even with every equation written down the answer arrives as the segment and never as the person.
✓ The functional-form limit Two forms fitting equally well give different individual answers and identical averages, and no data separates them. Partial identification when a variable is binary.
◐ Heterogeneous effects and CATE Session 7. A conditional average is the per-person counterfactual approximated without a structural model, it reaches an R^2 of 0.986 here, and it is what most targeting questions actually need.
◐ Sensitivity to what cannot be measured Session 9. Tonight names the functional form as the unpriced assumption; the sensitivity machinery there prices the unmeasured confounder instead.
· Potential outcomes, propensity scores, IPW, doubly robust, difference-in-differences, synthetic control, DoWhy Not covered by design, in any session. Estimating an effect from observational data belongs to learn-experimentation-with-phoebe; tonight assumes the model is written down and asks what follows.

Session 6 cheat sheet

Rung 2 against rung 3Rung 2 asks about a population under a policy. Rung 3 asks about one person in a world that never happened.
Recover, flip, recomputeSolve for her noise, overwrite the treatment, push it back through the equations.
A graph is not enoughIt names each equation's inputs. Recovering a residual needs the shape as well.
Change nothing elseReplacing her residual with the group average answers about a customer like her, not about her.
Eight values, never 6.23Her segment's direct effect, or that plus the mediated 3.0. The average is held by nobody.
Two forms, two answersIdentical residuals, different counterfactuals, no diagnostic between them. Report the range.
The segment, never the personHer open flag pins its noise to an interval, so the 3.0 either landed or did not and no feature says which.
Prefer rung 2 when it fitsPolicies need averages. Only a retrospective claim about a named account truly needs rung 3.