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

Geo experiments and synthetic control

Sometimes you cannot randomize a person. TV, connected TV, and a privacy-first world push the experiment up to the region. This session builds two of the hardest, most credible tools in the kit - geo holdouts and the synthetic control method - in Python on Lumen's 9 US regions. You will construct a synthetic "twin" for the treated regions out of a donor pool, read the lift off the gap, and then try to break your own estimate with placebo tests. This is where quasi-experiments start to feel like real evidence.

🔴 Hardest Builders · DA / DE / DS Python · CausalImpact / pysyncon 45 min
0-3 · Setup 3-16 · Geo + SCM 16-40 · Build it in Python 40-45 · Wrap
Part 0

When the coin flip has to move up a level

Lumen wants to test a connected-TV burst - a two-month brand campaign running on CTV in a handful of US metros. You cannot randomize which shopper "sees TV"; the delivery is inherently geographic, and post-cookie you cannot even track it cleanly at the user level. So you randomize or select regions instead: treat a subset, hold the rest out, and reconstruct what the treated regions would have done without the burst. Session 8 teaches the two credible ways to build that counterfactual - Google's CausalImpact and the synthetic control method (SCM) - and, just as important, how to earn trust in the estimate with a good pre-period fit and placebo inference.

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 notebook that (1) assembles Lumen's 9-region weekly-revenue panel and splits it into treated vs donor regions, (2) fits a synthetic counterfactual with both CausalImpact and pysyncon and reads the cumulative lift with a credible interval, and (3) validates the whole thing with pre-period fit checks and a placebo permutation test. You leave able to say "the burst added X, and here is why I believe it" - not just point at a line going up.
Part 1 · the setup

Geo experiments - randomize the region, not the person 6 min live

A geo experiment makes the region the unit of randomization. You split markets (DMAs, states, metros) into treated and control, run the intervention in the treated markets only, and compare aggregate outcomes. This is the right tool when treatment cannot be delivered per user - broadcast and CTV media, pricing, a policy change - or when privacy loss (Safari/Firefox/ATT) has gutted user-level tracking. The cost is fewer units: you might have 9 regions, not 64,000 users, so the analysis leans hard on modeling the counterfactual well.

LiveTwo named traditions - do not conflate them3 min

There are two distinct lineages here, and mixing up their names is a classic tell that someone learned this from a blog post.

  • Google - Geo experiments (Vaver & Koehler 2011) plus CausalImpact (Brodersen et al. 2015), which uses a Bayesian structural time-series model to forecast the counterfactual from control markets.
  • Meta - GeoLift, which is built on the synthetic control method.
There is no "Google GeoLift" GeoLift is Meta's, and it is SCM-based. Google's tools are Geo experiments and CausalImpact (BSTS). If a deck says "Google GeoLift", the author blended two vendors - dock a credibility point and check the rest of the analysis.
Self-studyThe donor pool must be clean3 min read

Every method here reconstructs the treated region from untreated regions - the donor pool. That only works if the donors were genuinely unaffected by the treatment. If Lumen's CTV burst in California spills into neighbouring Nevada (shared media market, cross-border shoppers), Nevada is contaminated and cannot be a donor. This is the geo-level echo of SUTVA (no interference) from b3: a spillover onto a "control" region quietly deflates your measured effect.

Real world

Lumen's first geo test failed review because a treated metro and its "control" neighbour shared a DMA - the TV signal crossed the line. The fix was to drop adjacent regions from the donor pool and add a geographic buffer. Cleaner donors, smaller pool, more honest number.

Part 2 · the method

Synthetic control - build a twin from a weighted donor pool 5 min live

The synthetic control method (Abadie & Gardeazabal 2003; Abadie, Diamond & Hainmueller 2010) builds a synthetic treated unit as a weighted average of untreated donor units. The weights (non-negative, summing to 1) are chosen so the synthetic unit tracks the treated unit's outcome as closely as possible before treatment. After the intervention, the gap between the real treated line and its synthetic twin is the estimated effect. A tight pre-treatment fit is the whole credibility test - if the synthetic cannot match the treated region before anything happened, you have no reason to trust it afterward.

weekly revenue 52-week pre-period post: CTV burst live intervention treated (real) synthetic twin gap = lift The two lines are near-identical in the pre-period by construction - that tight fit is what licenses you to read the post-period gap as the effect. Cumulative lift = the summed post-period gap; CausalImpact and pysyncon both return it with an uncertainty band.
🔍 Click to zoom - real treated revenue vs its synthetic counterfactual; the post-period gap is the estimated lift
Donor pool (untreated regions) US-NY · w = 0.31 US-TX · w = 0.24 US-IL · w = 0.19 US-WA · w = 0.16 US-FL · w = 0.10 Σ wᵢ = 1.00 weights ≥ 0 Synthetic US-CA the treated region's twin Weights are picked to minimise pre-period fit error, are non-negative, and sum to 1 - so the synthetic unit stays inside the donors' range (no extrapolation).
🔍 Click to zoom - a weighted blend of donor regions (weights sum to 1) becomes the synthetic treated unit
Self-studyPlacebo tests = permutation inference3 min read

With only a handful of regions, classical standard errors are shaky. SCM borrows an idea from permutation testing: pretend each donor region was the treated one, run the whole SCM on it, and collect its post-period gap. That gives you a distribution of "placebo" effects under no real treatment. If your genuine treated region's gap is extreme relative to that placebo distribution, the effect is unlikely to be noise. A common summary is the ratio of post- to pre-period fit error: the real unit should sit in the tail.

Fit first, or nothing else counts If the pre-period RMSPE is large - the synthetic never matched the treated region before treatment - stop. A bad pre-fit means the post-period gap is measuring model failure, not the burst. Report the pre-period fit alongside every SCM estimate.
Build-along 1 of 3

Assemble the geo panel and pick treated vs donors ★ 8 min · everyone

Lumen has 9 US regions in the geo field and 52 weeks of pre-period spend_weekly revenue. We treat a subset with a CTV burst and hold the rest out as the donor pool. First, shape the data into a region-by-week revenue panel.

Pivot weekly revenue into a wide panel - one column per region, one row per week - and mark the intervention week:

demo1_geo_panel.py
import pandas as pd, numpy as np

sw = pd.read_parquet("lumen_spend_weekly.parquet")   # week, channel, geo, revenue, ...
panel = (sw.groupby(["week", "geo"], as_index=False)["revenue"].sum()
           .pivot(index="week", columns="geo", values="revenue")
           .sort_index())

INTERVENTION = pd.Timestamp("2026-04-06")   # CTV burst goes live
pre  = panel.loc[panel.index <  INTERVENTION]   # 52 pre-period weeks
post = panel.loc[panel.index >= INTERVENTION]
print(panel.shape, "regions:", list(panel.columns))

Choose the treated region and a clean donor pool - drop any region that could receive spillover from the treated media market:

pick_units.py
treated = "US-CA"                                  # got the CTV burst
adjacent = ["US-NV"]                               # shares a media market -> contaminated
donors = [g for g in panel.columns
          if g not in [treated] + adjacent]         # clean donor pool
print("treated:", treated, "| donors:", donors)

# sanity: donors must be UNAFFECTED by the burst (no spillover) - SUTVA at the geo level

Eyeball the pre-period correlations between the treated region and each donor. Good donors move with the treated region before treatment - that is what makes a tight synthetic fit possible.

Build-along 2 of 3

Fit the counterfactual and read the lift ★ 9 min · everyone

Two credible ways to build the counterfactual. Fit CausalImpact (a Bayesian structural time-series forecast from the donors) and a classic synthetic control with pysyncon, then compare their estimated lift and interval.

CausalImpact - forecast what US-CA would have earned without the burst from the donor series:

demo2a_causalimpact.py
from causalimpact import CausalImpact   # tfcausalimpact / pycausalimpact

# response = treated region first, then donor covariates
data = panel[[treated] + donors].copy()
pre_period  = [str(panel.index.min().date()), "2026-03-30"]
post_period = ["2026-04-06", str(panel.index.max().date())]

ci = CausalImpact(data, pre_period, post_period)
print(ci.summary())            # abs + relative effect, 95% credible interval
# ci.plot()  -> observed vs counterfactual, pointwise gap, cumulative lift

Synthetic control with pysyncon - solve for donor weights that match the pre-period, then read the post-period gap:

demo2b_pysyncon.py
from pysyncon import Dataprep, Synth

long = panel.reset_index().melt(id_vars="week", var_name="geo", value_name="revenue")
prep = Dataprep(
    foo=long, predictors=["revenue"], predictors_op="mean",
    dependent="revenue", unit_variable="geo", time_variable="week",
    treatment_identifier=treated, controls_identifier=donors,
    time_predictors_prior=list(pre.index), time_optimize_ssr=list(pre.index),
)
synth = Synth(); synth.fit(dataprep=prep)
print(synth.weights())                     # donor weights, sum to 1
att = synth.att(time_period=list(post.index))
print("estimated ATT (weekly lift):", att)  # gap between real and synthetic

Read both numbers the same way: the cumulative post-period gap is the estimated incremental revenue from the burst. Report it with the interval and - critically - the pre-period fit quality. If CausalImpact and pysyncon roughly agree, that convergence is itself reassuring.

Real world

Lumen's finance team will not fund a channel on "revenue went up." They will fund "the synthetic twin says US-CA would have made about $X without CTV; it made about $Y; the $Y-minus-$X gap sits outside the placebo distribution." That sentence is what SCM buys you.

Build-along 3 of 3

Validate it - pre-fit and placebo permutation ★ 7 min · everyone

An SCM estimate is only as good as its checks. Confirm the pre-period fit is tight, then run the placebo test: treat each donor as if it were the treated unit and see whether the real effect is extreme against that distribution.

Check pre-period fit with RMSPE - the synthetic must track the treated region before the burst:

demo3a_prefit.py
def rmspe(actual, synthetic):
    return np.sqrt(np.mean((actual - synthetic) ** 2))

pre_fit = rmspe(pre[treated].values, synth_pre_values)   # from the fitted model
print(f"pre-period RMSPE = {pre_fit:.1f}")
# rule of thumb: pre-RMSPE should be small vs the revenue scale.
# if it is large, the synthetic never matched -> do NOT trust the post gap

Placebo permutation - refit SCM pretending each donor was treated, then compare gaps:

demo3b_placebo.py
placebo_gaps = {}
for g in donors:
    fake_donors = [d for d in donors if d != g]
    gap = fit_scm_gap(panel, treated=g, donors=fake_donors,   # your demo-2 fit, reused
                      pre=pre.index, post=post.index)
    placebo_gaps[g] = gap                                      # post-period effect if "treated"

real_gap = fit_scm_gap(panel, treated=treated, donors=donors,
                       pre=pre.index, post=post.index)

# ratio of post- to pre-period RMSPE puts every unit on one scale
ranks = sorted([real_gap] + list(placebo_gaps.values()), reverse=True)
p_perm = (ranks.index(real_gap) + 1) / (len(ranks))
print(f"permutation p ≈ {p_perm:.3f}  (real effect should sit in the tail)")

If the real region's gap is the most extreme (or near it) among all placebos, the burst effect is unlikely to be an artifact. If a placebo donor shows an equally large "effect", your model is picking up noise - tighten the donor pool or the pre-period.

Credibility is a chain, not a number Good pre-fit → sensible donor weights → a post-period gap → a placebo distribution that makes the gap look extreme. Break any link and the estimate is decoration. SCM's honesty is that it forces you to show every link.
Before Session 9

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · In the synthetic control method, the donor weights are chosen to...

Weights (non-negative, summing to 1) are fit on the pre-period so the synthetic twin tracks the treated unit before treatment. The post-period gap is then read as the effect - a tight pre-fit is the credibility test.

2 · A placebo test in SCM works by...

Permutation inference: refit SCM treating each untreated donor as the treated unit to build a distribution of placebo effects. If the genuine treated region's gap sits in the tail, the effect is unlikely to be noise.

3 · Which statement is correct about the tooling?

GeoLift is Meta's and built on SCM. Google's lineage is Vaver-Koehler geo experiments plus Brodersen's CausalImpact (Bayesian structural time series). There is no "Google GeoLift".

Source material

What this session covers

Geo + synthetic control is a genuine gap on every platform we surveyed - we teach it here from the primary sources. The deep econometric proofs stay in the papers and the Udemy econometrics course.

Geo holdout, donor pool, pre-fit, placebo tests, CausalImpactParts 1-2 + all three build-alongs on Lumen's 9 regions
Synthetic control weights, credibility chainpysyncon + permutation inference - build-alongs 2-3
Abadie-Gardeazabal 2003; Abadie-Diamond-Hainmueller 2010the Basque Country and California Prop 99 papers - primary sources
Econometrics & Statistics for Business (Resende, Udemy)CausalImpact code depth - extends the fit here
Meta GeoLift / Google geo methodology docsvendor implementations - named, not operated

Builder Session 8 cheat sheet · pin this

Geo experimentRandomize or select regions when you cannot randomize people (TV/CTV, privacy). Region is the unit.
Synthetic controlBuild a treated twin as a weighted (≥0, Σ=1) blend of donors that matches the pre-period. Post gap = effect.
Clean donorsDonor pool must be unaffected by treatment - no spillover. Geo-level SUTVA. Drop adjacent markets.
Pre-fit is the testSmall pre-period RMSPE licenses the post gap. Bad fit = you are measuring model failure.
Placebo inferenceRefit SCM on each donor as if treated; the real effect should be extreme vs the placebo distribution.
NamesGeoLift = Meta (SCM). Google = Geo experiments (Vaver-Koehler) + CausalImpact (BSTS). No "Google GeoLift".