16  Session: Synthetic Controls

16.1 Lecture Slides

Open slides in new tab | Download as PDF

16.2 Overview

S10 answered “what is the app worth on average?”; BeanBuzz’s board now asks a sharper question — what did it do to Northgate, the flagship (market M17, the largest, seven stores, go-live week 53) — and that single-flagship question breaks the difference-in-differences machinery, because one treated unit has no cohort to average over and a lone pre-trend is mostly noise. Synthetic control answers it the Part V way — by imputing the missing \(Y(0)\), now as a convex blend of donor markets chosen to track Northgate before launch, the natural next step after S10 gave every clean control weight 1. The catch is that classic Abadie-Diamond-Hainmueller (ADH) synthetic control overshoots to +15.2% against a true Northgate effect of +8.3%. The reason is mechanical and exact: Northgate is the largest market, sitting at the edge of the donor convex hull, so no convex blend reaches its level. That leaves a +6.8% pre-period offset which leaks one-for-one into the post-period gap (+15.2% ≈ +6.8% offset + +8.3% true). The honest verdict that follows is deliberately non-triumphant: the placebo-in-space test ranks Northgate only 2 of 16 (Fisher \(p = 0.125\), one donor beats it) and the modern scpi prediction interval places the honest effect band at [+6.1%, +10.1%], so single-unit synthetic-control inference is fragile and must be corroborated. Corroboration comes from the modern family that pulls the overshoot back to the truth: augmented synthetic control closes the pre-fit offset and lands at +8.1% [+7.6%, +8.6%], synthetic DiD gives +7.9%, and plain DiD +7.7% — three methods with independent failure modes clustering on +8.3% while classic SC stands alone as the outlier. The unifying idea is that DiD, SC, and synthetic DiD are three corners of one weighted-DiD design (uniform-on-both, units-only, both axes), and the weight vectors that build each imputation are the rows of a low-rank matrix. Two through-lines tie the session to its neighbours: back to S10 (the identical honest-uncertainty discipline — a point estimate without a band is not a finding) and forward to S12 (weights become a matrix, and DiD / SC / SDID / unconfoundedness become one matrix-completion problem under different regularizers).

Learning objectives. By the end of this session you will be able to:

  • Construct a synthetic control for a single treated unit — a convex donor blend (\(\omega\) in the simplex) — and read it as imputing the never-observed \(Y(0)\), explaining why the single-flagship case defeats cohort-based DiD.
  • Diagnose a synthetic-control fit: show how an imperfect pre-fit inflates the estimate (Northgate +15.2% = +6.8% hull-driven offset + true +8.3%) and why, per Ferman, a perfect pre-fit is necessary but never sufficient.
  • Infer honestly with placebo-in-space (rank 2/16, \(p = 0.125\)) and an scpi prediction interval ([+6.1%, +10.1%]) — reproducing the SC point to the digit in both R and Python (the graded bilingual step).
  • Reconcile classic, augmented, synthetic-DiD and plain DiD in one estimator forest, locating the three corners of a single weighted-DiD design and pulling the +15.2% overshoot back to the true +8.3%.
  • Read the unit and time weight vectors as the rows of a low-rank matrix, and see why stacking those rows hands S11 off to S12’s matrix-completion view (DiD / SC / SDID as one problem under different regularizers).

16.3 Lecture Notes

16.3.1 The single-flagship question breaks the DiD machinery

16.3.1.1 From a portfolio average to one unit

S10 delivered a portfolio answer — the overall ATT across all treated BeanBuzz markets, roughly +5%, close to the true overall effect of +4.9%. But a board does not run a portfolio; it runs flagship decisions. The question now is narrow and concrete: what did the loyalty app do to Northgate specifically — market M17, region 4, urban, the single largest market with seven stores, launched in the first wave at week 53 of 104 (Figure 16.1). The donor pool is fixed and clean: the 15 never-treated markets, observed across all 104 weeks. Notice already in the raw picture that Northgate (bold) sits above the donor fan even before launch — it is the biggest market, at the edge of the pack. That edge position is not a throwaway detail; it is the villain of Section 16.3.4, the reason a naive synthetic control will overshoot.

Figure 16.1: Log weekly revenue over 104 weeks (BeanBuzz). One bold line is Northgate, the largest market, sitting visibly above a fan of fifteen thin grey never-treated donor markets. A vertical line marks go-live at week 53, after which Northgate pulls further above the grey band. Northgate’s pre-launch position above every donor is the structural fact that drives the classic synthetic control’s overshoot in Section 16.3.4.

16.3.1.3 The Part V spine, one more turn

Hold the spreadsheet image from S10 one more time: a panel of markets and weeks with a blanked-out rectangle for the never-observed \(Y(0)\) of the treated market after launch. Part V is one long project to impute that rectangle under a progressively weaker restriction on trends. S10 filled it with additive unit-plus-time effects (parallel trends); S11 fills it with a weighted blend of donor markets; S12 will fill it with a flexible factor model. The single sentence to carry through the session: difference-in-differences is the special case that gives every clean control the same weight, 1; synthetic control asks the next question — which donors, and how much weight on each. Synthetic control is the first method to actually solve for those weights — that is today’s work; synthetic DiD later in the session will solve for weights on both axes at once.

16.3.1.4 A weighted control is a synthetic unit

DiD and synthetic control are the same idea — impute \(Y(0)\) from controls — differing only in the weights, and that one change flips the direction of the imputation. DiD borrows horizontally — it projects the treated unit forward in time on the donors’ common trend. Synthetic control borrows vertically — each missing post-period cell is a weighted column of donors (Figure 16.2). DiD implicitly assigns every clean control the same weight (a simple average) and asks the treated unit to have moved in parallel with that average. Synthetic control instead solves for the weights: it searches over convex combinations of donors — weights that are non-negative and sum to one — to find the particular blend whose pre-treatment trajectory most closely matches the treated unit’s. That weighted blend is a synthetic Northgate: a fictional market assembled from real donors, standing in for the one we cannot observe after launch. After treatment, when the real Northgate’s \(Y(0)\) is unobservable, the synthetic unit keeps tracking forward and serves as the counterfactual. The convexity restriction (Section 16.3.2) is the method’s signature design choice — and, for Northgate, its central limitation.

Figure 16.2: Two stacked units-by-time grids for the same panel. In both, the treated unit is the top row with a red-dashed missing \(Y(0)\) block after launch. Top (DiD): a horizontal arrow runs along the treated row into the missing block — imputing along time from the common trend. Bottom (synthetic control): a vertical arrow runs up a post-treatment donor column into the treated cell — imputing across units from a weighted blend of donors.

16.3.2 The ADH construction: choose weights in the simplex

16.3.2.1 The objective and the simplex

Abadie-Diamond-Hainmueller’s move is to choose donor weights \(\omega\) that make the synthetic unit’s pre-treatment outcome path track the treated unit’s — match the trajectory, the thing we actually want to extrapolate, not just a list of covariate means (Abadie, Diamond, and Hainmueller 2010). With \(T_0\) pre-treatment periods, donors indexed by \(j\), and outcomes \(Y\):

\[\min_{\omega}\ \sum_{t \le T_0}\Big(Y^{\text{treat}}_t - \sum_{j}\omega_j\,Y^{\text{donor}}_{j,t}\Big)^2 \quad \text{s.t. } \omega_j \ge 0,\ \textstyle\sum_j \omega_j = 1. \tag{16.1}\]

The two constraints — non-negative weights that sum to one — make \(\omega\) a point in the simplex, and the synthetic unit a convex combination of donors. Covariates and lagged outcomes can be folded into the matching as additional predictors (the labs match on market size, urban share, store count, and four lagged log-revenue snapshots), but the pre-period outcome path is the dominant target; keep the focus there.

16.3.2.2 Convexity = no extrapolation, with a cost

This convexity is the method’s virtue and its curse (Abadie 2021). Its virtue: forbidding negative weights and weights above one means the synthetic unit is an interpolation of observed donors — it never extrapolates beyond the range of behaviour any donor actually exhibited, which guards against the wild out-of-sample predictions unconstrained regression can produce. Its cost, which Section 16.3.4 will hit hard: if the treated unit lies outside the convex hull of the donors — for instance, if it is the largest market and no blend of smaller donors can match its level — then no convex weighting can fit it, and the residual pre-period misfit becomes bias. This is not a tuning problem you can fix by trying harder inside the simplex; it is the simplex itself.

16.3.3 The Proposition-99 warm-up: what a clean fit looks like

Ground the method in the case that made it famous, because it shows what a good fit looks like before we meet a bad one. Abadie, Diamond and Hainmueller studied California’s Proposition 99, the 1988 tobacco tax, asking what happened to per-capita cigarette consumption (Abadie, Diamond, and Hainmueller 2010, 2015). They built a synthetic California from a convex blend of other US states chosen to match California’s pre-1988 smoking trajectory and predictors. The fit is the textbook ideal (Figure 16.3): before 1988 the synthetic line lies almost exactly on top of real California, with a pre-treatment root-mean-squared prediction error (pre-RMSPE) of roughly 1.8 packs per capita — small relative to the scale. Why does it fit so well? California, though large, sits comfortably inside the convex hull of the other states on the smoking dimension, so a blend can match it. Contrast this deliberately with Northgate, which does not sit inside its donors’ hull.

Figure 16.3: Synthetic-control fit for California’s Proposition 99. Per-capita cigarette sales 1970–2000 for California (solid) and a synthetic California built from a weighted blend of other states (dashed). Before 1988 the two lines lie almost on top of each other (pre-RMSPE ≈ 1.8 packs); a vertical line marks Proposition 99 in 1988, after which the solid California line drops below the dashed synthetic line.

The standard synthetic-control read is the gap plot: subtract synthetic from real and plot the difference over time (Figure 16.4). Before treatment, a good fit means the gap hovers at zero — that flatness is the visual proof the synthetic unit is a faithful stand-in. After treatment, the gap is the estimated treatment effect at each period: for Prop 99 it opens steadily downward to about −18.7 cigarettes per capita by the late 1990s, a large and growing reduction. The crucial inferential point, which Section 16.3.4 exploits: the gap plot is only interpretable if the pre-period gap is flat at zero. A flat pre-gap licenses reading the post-gap as the effect; a non-flat pre-gap means the synthetic unit never matched the treated unit in the first place, so the post-gap mixes the true effect with the pre-existing misfit.

Figure 16.4: Gap plot for Proposition 99: real minus synthetic California per-capita cigarette sales over time. Before 1988 the gap hovers tightly around zero (the fit was clean); after the 1988 Proposition 99 line it opens steadily downward, reaching about −18.7 cigarettes per capita by the late 1990s — the estimated effect of the tax.

16.3.4 Northgate overshoots: an imperfect pre-fit leaks straight in

16.3.4.1 The diagnostic fit

Run the identical ADH recipe on Northgate — donor pool the fifteen never-treated markets — and the pre-period fit is visibly worse than California’s (Figure 16.5): the synthetic line sits persistently below real Northgate even before week 53, never quite reaching its level. The reason is the convexity constraint colliding with Northgate’s position. Northgate is the largest market in the portfolio; every donor is smaller. A convex blend — weights summing to one, none negative — can only produce a synthetic unit whose level lies between the donors’ levels, so it cannot reach above all of them. Northgate sits at the edge of (effectively outside) the convex hull, and no convex weighting can match a point outside the hull. The result is a structural pre-period offset: the synthetic is biased low throughout.

Figure 16.5: Classic synthetic-control fit for Northgate. Log weekly revenue for Northgate (solid) and its classic synthetic control (dashed) over 104 weeks. Before the week-53 launch line the synthetic line sits visibly below the solid Northgate line rather than on top of it — a persistent gap, because no convex blend of smaller donors reaches the largest market’s level. After launch the solid line pulls even further above the dashed one.

16.3.4.2 The exact decomposition

This is the mechanical heart of the session — the single most important arithmetic. Because the synthetic Northgate sits about 6.8% below the real one throughout the pre-period (pre-RMSPE ≈ 0.070), the gap plot does not start at zero — it starts at +6.8% (Figure 16.6). That baked-in offset does not vanish at launch; it persists into the post-period and adds to whatever true effect there is. So the post-period gap of +15.2% decomposes almost exactly into the pre-period offset plus the true Northgate effect:

\[\underbrace{+15.2\%}_{\text{classic SC post-gap}} \;\approx\; \underbrace{+6.8\%}_{\text{pre-period offset (edge of hull)}} \;+\; \underbrace{+8.3\%}_{\text{true Northgate effect}}. \tag{16.2}\]

Say this slowly: the classic SC overshoots not because the effect is large but because the fit is bad, and a bad fit contaminates the estimate one-for-one. This is the synthetic-control analog of S10’s contamination story (there a too-low estimate dragged down by forbidden comparisons; here a too-high estimate inflated by a pre-period offset), and it reframes the gap plot’s pre-period as a diagnostic you must check before trusting any number. Everything in Section 16.3.7 is about killing that +6.8% offset.

Figure 16.6: Gap plot for Northgate’s classic synthetic control. The gap is not flat before launch — it sits at roughly +6.8% (pre-RMSPE ≈ 0.070) rather than at zero. After the week-53 launch the gap rises further to about +15.2%. The non-flat pre-period is a baked-in offset that leaks one-for-one into the post-period: +15.2% ≈ +6.8% offset + +8.3% true effect (Equation 16.2).

16.3.4.3 The weights collapse onto one donor — and a seed for S12

Read the blend (Figure 16.7). Synthetic-control weights are typically sparse — the simplex constraint plus the optimization load most of the weight onto a few donors and leave the rest at zero. Northgate’s classic fit is the extreme case: it is maximally sparse, collapsing almost entirely onto a single donor, market M29 (weight approx 0.9998), with every other market at zero. Sparsity is still the interpretability that makes synthetic control attractive to a board — you can name the one market your synthetic Northgate is made of and sanity-check it (does M29 share Northgate’s region, urbanicity, size?). Plant the S12 seed quietly: even this single-donor vector is one row of instructions — how to reconstruct one unit’s outcome path from the others. In Section 16.3.10 we stack such rows and recognize them as a low-rank matrix.

Figure 16.7: Classic synthetic-control unit weights across the fifteen donor markets — a maximally sparse bar chart. A single donor, market M29, carries essentially all the weight (approx 1.00) while every other market sits at or near zero, so the synthetic Northgate is built from one market. Even a single-donor blend is a nameable, checkable row describing how to rebuild Northgate from the others (the seed for Section 16.3.10).

16.3.5 Placebo-in-space inference, and honest fragility

16.3.5.1 One treated unit has no standard error

The post-gap is +15.2% — but is it distinguishable from noise? Classical inference cannot say. A standard error needs repeated sampling or many treated units to average over; synthetic control on Northgate has neither — a single treated market, a single realized path, no replication. Abadie’s answer is not a standard error but a randomization (Fisher permutation) test (Abadie, Diamond, and Hainmueller 2010): if the treatment had no effect, the treated unit’s post-period gap should look like a typical donor’s gap when you pretend that donor was treated. The placebo-in-space recipe has three steps:

  1. Reassign treatment to each donor in turn, as if it launched at week 53.
  2. Score each unit by its MSPE ratio — post-period mean-squared gap divided by pre-period mean-squared gap — after re-running synthetic control for that placebo donor on the remaining pool.
  3. Rank every unit by that ratio; Northgate’s rank divided by the number of units is the Fisher \(p\)-value.

MSPE is the mean squared gap between a unit and its synthetic over a window. Dividing post by pre asks whether the gap grew after treatment relative to the pre-fit — so a placebo whose synthetic never tracked it (large pre-MSPE) cannot top the ranking just by being noisy, and a unit with a tight pre-fit but a modest post-gap can rank high. A common refinement (the fit-filter the lab reports) drops placebos whose pre-period MSPE exceeds 5x the treated unit’s before ranking.

16.3.5.2 The honest verdict

Northgate’s post-launch gap is the largest in the fan and visibly opens upward (Figure 16.8), but in the placebo fan it ranks only 2 of 16 — because the ranking statistic is the MSPE ratio, not the raw gap; Northgate’s post-launch gap is actually the largest in the fan. Donor M29 has a tiny post-gap but a near-perfect pre-fit (pre-MSPE approx 0.0003), so its ratio (approx 8.3) exceeds Northgate’s, whose imperfect pre-fit — the +6.8% hull-edge offset — deflates its ratio to approx 4.9. M29 outranks Northgate not because its effect is bigger but because the ratio rewards a tight pre-fit. The Fisher \(p\)-value is therefore 0.125 (rank 2 of 16); filtering out badly-fit placebos — like M35, whose pre-MSPE is approx 68x Northgate’s and is dropped by Abadie’s 5x fit-filter — leaves rank 2 of 15, \(p = 0.133\). By the conventional 0.05 bar, neither clears significance.

Figure 16.8: Placebo-in-space fan for Northgate. Each of the fifteen donor markets is reassigned treatment at week 53 and re-fit; their gap paths (grey) are plotted against Northgate’s (bold teal), which opens upward after launch to the largest gap in the fan. Yet Northgate ranks only 2 of 16 on the MSPE ratio (post-period MSPE divided by pre-period MSPE): donor M29 (purple), whose gap is near zero but whose pre-fit is near-perfect, wins the ratio and takes rank 1, while M35 (grey dashed, approx -0.6) fits so poorly it is dropped by the fit-filter. Fisher \(p = 0.125\). With only fifteen donors and an imperfect fit, single-unit synthetic-control inference is fragile.
WarningNorthgate: statistically borderline, and the lesson is fragility

Be explicit about why and what it means. With only fifteen donors the permutation distribution is coarse — the smallest possible \(p\)-value is about \(1/16 \approx 0.0625\) — and an imperfect pre-fit makes the single gap noisy. The correct conclusion is neither “no effect” nor “significant effect”; it is that single-unit synthetic-control inference is fragile here, and you should not stake a board decision on one \(p\)-value from one method. This deliberately mirrors S10’s non-triumphant HonestDiD framing (there the breakdown value \(\bar M^{*} \approx 0.25\) made the effect “statistically clear but only moderately robust”); the honesty discipline is identical. The constructive move is corroboration — a real prediction interval (Section 16.3.6), an augmented estimate (Section 16.3.7), and cross-method agreement (Section 16.3.9). The fragility is the argument for the rest of the session, not a defeat.

16.3.6 Modern prediction intervals: scpi and the honest band

The placebo test answers a ranking question — is the treated gap extreme among placebos — but it does not directly quantify how uncertain the counterfactual prediction is. Cattaneo, Feng, Palomba and Titiunik’s scpi framework does (Cattaneo et al. 2025): it constructs a genuine prediction interval around the synthetic unit’s predicted \(Y(0)\), decomposing the uncertainty into two pieces — the error in estimating the donor weights (in-sample) and the irreducible out-of-sample noise of predicting a future path. Formally, the synthetic counterfactual is \(\widehat{Y}^{(0)}_t=\sum_j \widehat{\omega}_j\,Y_{jt}\), and scpi reports a \((1-\alpha)\) prediction interval for that \(Y(0)_t\). The per-period effect is read as \(\widehat{\tau}_t = Y^{\text{obs}}_t - \widehat{Y}^{(0)}_t\), so the actual path lying above the whole band means \(\tau_t>0\). Stress the interpretation, because students will misread it: this is a band for the counterfactual \(Y(0)\), not a frequentist standard error attached to the point estimate. You read it by asking where the actual Northgate path falls relative to the band for its synthetic counterfactual.

Figure 16.9: scpi prediction interval for Northgate. The actual log-revenue path (solid) and its synthetic counterfactual (dashed) with a shaded prediction band around the counterfactual over the post-period. The actual path sits above the band, and the implied honest effect band spans roughly [+6.1%, +10.1%] — centred near the true +8.3% and well below the naive +15.2% point estimate.

Read two things off the figure (Figure 16.9). First, the actual Northgate path sits above the prediction band for its counterfactual, which means there is a positive effect — the band does not contain “no effect” in a way that erases the result. Second, and this is the lesson, the honest effect band — actual minus the counterfactual prediction interval — is [+6.1%, +10.1%], centred near the true +8.3% and lying well below the naive +15.2% point. The naive point was overconfident, exactly because it inherited the +6.8% pre-offset (Equation 16.2). So scpi both confirms a positive effect and corrects the magnitude downward toward the truth: the interval reveals what the point hid. A conformal-inference alternative (Chernozhukov, Wüthrich, Zhu) gives a similar prediction guarantee under a permutation-style assumption — a different route, the same spirit (Chernozhukov, Wüthrich, and Zhu 2021).

NoteThe graded bilingual anchor

scpi is the designated graded step of the lab. R’s scpi and Python’s scpi_pkg agree on the synthetic-control point to the displayed digit (approx +15%) — the weights solve a deterministic quadratic program, so the point is a property of the design, not the toolchain. The simulated prediction interval agrees closely but not identically across languages, because the bands are simulation-based and the random-number streams differ (R reports [+6.1%, +10.1%]; Python’s scpi_pkg reports a wider but overlapping band, [+3.8%, +10.1%]). That is the honest parity story to teach: deterministic quantities match to the digit; Monte-Carlo quantities agree within simulation noise. This is the thinnest-bilingual session of Part V, and scpi is where the two ecosystems are pinned together.

16.3.7 Augmented SC: ridge bias-correction outside the hull

16.3.7.1 Relax the simplex, just enough

The +6.8% offset is a direct consequence of the simplex constraint: Northgate lies outside the donor convex hull, so no non-negative-weights-summing-to-one blend can reach it. Augmented synthetic control relaxes that constraint in a controlled way (Ben-Michael, Feller, and Rothstein 2021). It starts from the classic SC weights, then adds a ridge-regularized outcome-model correction for the remaining pre-treatment imbalance — in effect allowing the weights to step slightly outside the simplex (small negative or above-one weights) just enough to eliminate the residual misfit, with the ridge penalty preventing wild extrapolation. The cleanest analogy, which calls back to the matching session, is bias-corrected matching: match as well as you can, then run a regression to remove the leftover imbalance. Augmented SC does the same for trajectories.

Figure 16.10: Extrapolation outside the donor hull. Donor points span a dashed convex hull; Northgate (X) lies outside it. A regularization path runs from the uniform DiD-style average (deep inside the hull) out toward the treated unit — green while the weights stay in the simplex, red while inside the convex hull, and blue once outside it. Classic SC (SCM) is where the path first touches the hull boundary, the closest feasible synthetic; ridge augmented SC (Ridge ASCM) sits just outside the boundary, stepping toward Northgate. Source: Ben-Michael, Feller & Rothstein (2021).

16.3.7.2 Outcome model plus residuals: the doubly-robust form

Write the correction down. The augmented counterfactual is the classic SC prediction plus an imbalance-correction term, which rearranges into an equivalent doubly-robust form:

\[\widehat{Y}^{(0),\text{aug}}_{1T}=\underbrace{\sum_i \widehat{\omega}^{sc}_i\,Y_{iT}}_{\text{classic SC}}+\underbrace{\Big(\widehat{m}(X_1)-\sum_i \widehat{\omega}^{sc}_i\,\widehat{m}(X_i)\Big)}_{\text{imbalance correction}}=\underbrace{\widehat{m}(X_1)}_{\text{outcome model}}+\underbrace{\sum_i \widehat{\omega}^{sc}_i\big(Y_{iT}-\widehat{m}(X_i)\big)}_{\text{SC-reweighted residuals}} \tag{16.3}\]

The second line is AIPW-like, and that structure is exactly why augmented SC is doubly robust: if the SC weights already balance the pre-period the residual term vanishes in expectation, and if instead the outcome model \(\widehat{m}\) is correct the first term carries the estimate — you only need one of the two to be right. Here \(\widehat{m}\) is a ridge regression of the donors post-treatment outcomes on their centred pre-treatment outcomes, \(\widehat{m}(X)=\widehat{\eta}_0+X'\widehat{\eta}_x\), with penalty \(\lambda\) chosen by cross-validation over pre-periods. The ridge is what lets the effective donor weights leave the simplex (go slightly negative or above one) to reach a treated unit outside the hull (Figure 16.10), while the penalty stops the extrapolation running wild.

16.3.7.3 It works almost perfectly

Closing the pre-fit collapses the overshoot (Figure 16.11). The augmented counterfactual lies essentially on top of real Northgate before week 53 — a near-perfect pre-fit, offset \(\approx 0.000\) — so the contamination term in Equation 16.2 vanishes and the estimate snaps back to +8.1% with a 95% confidence interval of [+7.6%, +8.6%], statistically indistinguishable from the true +8.3%. Walk the arc out loud: classic SC +15.2% (inflated by a bad fit) becomes augmented SC +8.1% (a good fit, the truth). This is the mirror of S10’s correction story — there a too-low estimate was pulled up toward the truth; here a too-high estimate is pulled down.

Figure 16.11: Classic versus augmented synthetic control for Northgate. The classic counterfactual (red) sits persistently below the real path before week 53 — the +6.8% offset made visible. The augmented counterfactual (green) lies almost exactly on the real path before launch, closing the pre-period gap (offset ≈ 0.000). With the pre-fit imbalance driven to zero, the augmented estimate snaps from the classic +15.2% to +8.1% [+7.6%, +8.6%] — essentially the true +8.3%.

16.3.8 A good fit is not a free pass

16.3.8.1 The Ferman caution

Augmented SC just produced a near-perfect pre-fit and the right answer, which is seductive — it tempts you to treat pre-fit quality as proof of validity. Ferman’s caution says no (Ferman and Pinto 2021). When the donor pool is large relative to the length of the pre-period, the optimizer has enough free weights to drive the pre-period discrepancy to zero by fitting noise rather than capturing the structural relationship between treated and donor units — classic overfitting. Such weights fit the pre-period beautifully but project poorly into the post-period, so the counterfactual can be biased despite a flawless-looking fit. This is exactly parallel to S10’s “a flat pre-trend does not prove parallel trends”: there a flat pre-trend was evidence, not proof; here a flat pre-gap is evidence, not proof. Fit is a necessary condition for credibility, never a sufficient one.

16.3.8.2 Report three things, never one

The discipline that falls out is portable and identical to S10’s. Never report a synthetic-control point estimate alone — report three things:

  1. The fit — pre-period RMSPE and the gap plot (necessary, not sufficient; a bad fit like classic Northgate’s guarantees contamination).
  2. An interval — the scpi prediction band (Section 16.3.6) or the placebo \(p\)-value (Section 16.3.5) — quantifying how uncertain the counterfactual is.
  3. Cross-method agreement — do augmented SC, synthetic DiD, and plain DiD concur (read off the forest with placebo intervals, Figure 16.15, Section 16.3.9)?

Convergence across methods with independent failure modes is the real credibility check, exactly as it was in S10’s robust-estimator forest. This cashes the promise S10 made when it ended by telling students to “corroborate with synthetic control”: we have now built that corroboration, and the honesty rule transfers verbatim — a point estimate without a band and a robustness check is not a finding. Augmented SC’s bias-correction buys you the right point estimate, but it is not a substitute for the three-part report — the fit, the interval, and cross-method agreement together are the finding.

16.3.9 The bridge: units, time, or both

16.3.9.1 Three corners of one design

Look at the panel as a matrix of units by time. DiD and synthetic control each impute the missing \(Y(0)\) by weighting, but on different axes. DiD weights time: it implicitly treats every clean control unit equally (weight 1) and does its work through the period fixed effects, which capture the common time trend. Synthetic control weights units: it solves for bespoke donor weights \(\omega\) but treats every pre-period symmetrically. Each method fixes one axis (uniform) and optimizes the other — which makes the next step almost forced: if weighting units helps (SC) and weighting time helps (DiD’s period effects), why not do both? Arkhangelsky et al.’s synthetic difference-in-differences does exactly that (Arkhangelsky et al. 2021): solve for unit weights \(\omega\) and time weights \(\lambda\), then run a weighted two-way DiD. Writing the three estimators as one weighted two-way fixed-effects fit makes the family explicit — they differ only in the weights \((\omega, \lambda)\) they place on units and periods:

\[ \begin{aligned} \textbf{DiD:} &\quad \omega_j = \tfrac{1}{N_{\text{co}}},\ \lambda_t = \tfrac{1}{T_0} \quad &&\text{(uniform on both axes)}\\ \textbf{SC:} &\quad \omega_j \in \text{simplex},\ \lambda_t = \tfrac{1}{T_0} \quad &&\text{(sparse on units, uniform on time)}\\ \textbf{SDID:} &\quad \omega_j \in \text{simplex},\ \lambda_t \ \text{solved} \quad &&\text{(sparse on both axes)} \end{aligned} \tag{16.4}\]

The two SDID weightings buy robustness to the two failure modes we have seen (Figure 16.12). The unit weights \(\omega\) make donors resemble the treated unit, relaxing the parallel-trends requirement — you no longer need the raw donor average to move in parallel, only the matched blend. The time weights \(\lambda\) up-weight the pre-periods that most resemble the post-period, relaxing the perfect-pre-fit requirement — small residual misfit on irrelevant pre-periods is down-weighted rather than leaking into the estimate. The headline property is SDID’s double robustness: it needs neither a perfect SC pre-fit (which Northgate cannot achieve in the simplex) nor exact parallel trends (which the omitted regional-economy factor violates) — get either the unit weights or the time weights right and the estimate holds, the same insurance logic as augmented SC’s AIPW correction (Equation 16.3).

Figure 16.12: Three weighting grids, units down, time across. DiD’s grid is uniform on both axes (every clean control weighted 1, every period weighted equally). Synthetic control’s grid is sparse on the unit axis (a few donors carry the weight) but uniform on the time axis. Synthetic DiD’s grid is sparse on both axes — a few donors and a few informative pre-periods. The reveal: these are not three unrelated methods but three corners of one weighted-DiD family (Equation 16.4).

16.3.9.2 DiD, SC, and SDID are one weighted regression

Write the family as a single weighted two-way fixed-effects regression. Synthetic DiD solves

\[(\widehat\tau^{\,sdid},\widehat\mu,\widehat\alpha,\widehat\beta)=\arg\min_{\tau,\mu,\alpha,\beta}\ \sum_{i}\sum_{t}\big(Y_{it}-\mu-\alpha_i-\beta_t-D_{it}\,\tau\big)^2\,\widehat\omega_i\,\widehat\lambda_t \tag{16.5}\]

where \(\mu\) is the intercept, \(\alpha_i\) the market (unit) effects, \(\beta_t\) the week (period) effects, \(D_{it}=1\) for the treated market post-launch, and \(\tau\) the ATT. The three estimators are this one regression (Equation 16.5) under different settings:

Estimator Unit FE \(\alpha_i\) Unit weights \(\omega\) Time weights \(\lambda\)
DiD yes uniform uniform (both drop out) → plain TWFE
SC omitted in simplex uniform
SDID yes in simplex solved

All three keep the period effects \(\beta_t\) — DiD’s time leverage, the common trend, present in every column and not a time weight. They differ only in whether the unit fixed effect \(\alpha_i\) is kept and which of \(\omega,\lambda\) are non-uniform.

16.3.9.3 SDID lands near the truth

Figure 16.13: Synthetic difference-in-differences weight vectors for Northgate. Left: unit weights \(\omega\) — a sparse blend of donors matched to Northgate’s pre-trend, relaxing parallel trends. Right: time weights \(\lambda\) concentrated on the informative pre-treatment weeks, relaxing the perfect-pre-fit demand. DiD gives every clean control weight 1 (a flat bar); synthetic DiD solves for an uneven bar on both axes.

The two weight vectors in Figure 16.13 — now we know what they buy — give synthetic DiD +7.9% on Northgate against the true +8.3%; even plain DiD on Northgate gives +7.7%, also near the truth. The striking contrast: classic SC, insisting on a perfect convex fit it cannot achieve, overshoots to +15.2%, whereas SDID, which does not require a perfect fit, lands at +7.9%. The robustness of SDID comes precisely from not betting everything on one axis.

16.3.9.4 Watch SDID stay parallel where classic SC drifts

Figure 16.14: Three panels — DiD, classic SC, synthetic DiD — each plotting treated Northgate (dark) against its \(\omega\)-weighted synthetic control (cyan) over 104 weeks; the estimated effect is the arrow after week 53 and the shaded ribbon is the \(\lambda\) time weights. DiD (top) matches the trend, not the level, leaning entirely on parallel trends; classic SC (middle) cannot close the level gap, so the arrow is long (the +15.2% overshoot); SDID (bottom) anchors the \(\lambda\) pre-window where the lines are forced parallel, giving a short, well-anchored arrow (+7.9%).

Placed in a forest (Figure 16.15), the verdict that the single \(p\)-value could not deliver appears as convergence: against the true +8.3%, augmented SC (+8.1% [+7.6, +8.6]), synthetic DiD (+7.9% [+6.5, +9.3]) and plain DiD (+7.7% [+5.2, +10.2]) each carry a 95% interval that contains the truth and cluster tightly, while classic SC sits alone at +15.2% as a bare point (no interval — inflated by the +6.8% pre-offset) — exposed as the one that trusted a bad fit. Reconcile the two sessions explicitly: S10’s portfolio-wide robust ATT was +4.9% (the average market); Northgate, the flagship, has a larger effect (+8.3%), and the family of estimators recovers it. The classic SC’s +15.2% was the Northgate analog of S10’s naive +16.3% — a headline number demolished by discipline.

Figure 16.15: Northgate estimator forest against the true effect line at +8.3%. Classic synthetic control sits far right at +15.2% as a bare point (no interval — the lone outlier inflated by the +6.8% pre-offset); augmented SC +8.1% [+7.6, +8.6] (tight), synthetic DiD +7.9% [+6.5, +9.3] (moderate) and plain DiD +7.7% [+5.2, +10.2] (widest) each carry a 95% interval that contains the true +8.3%. Convergence across methods with independent failure modes is the credibility verdict the single placebo \(p\)-value could not give (Section 16.3.5).

16.3.10 From weights to a low-rank matrix: the S12 on-ramp

16.3.10.1 The weight vectors are a matrix in disguise

Two families, one ledger — the connective tissue of Part V, reused in S12 and the A5 memo:

Family What it buys What it costs
Parallel-trends (S10 — DiD, CS, BJS) Additive, transparent, cheap; borrows strength across a whole cohort. Blind to a unit-specific, time-varying factor; needs a credible cohort and testable pre-trends.
Hull + loadings (S11 — SC, augmented SC, SDID) Relaxes parallel trends; a unit-specific counterfactual that works at \(N=1\). Needs the treated unit inside the donor hull and a non-overfit pre-fit; single-unit inference is weak.

Go back to the “weights are a row” seed from Section 16.3.4. Every imputed \(Y(0)\) cell is a weighted combination of other cells in the panel — that is literally what \(\omega\) and \(\lambda\) do. Stack all those imputations together and you are reconstructing the missing entries of a units-by-time matrix from the observed entries. The structure that makes this possible is that the matrix is approximately low-rank — driven by a small number of latent factors (like the omitted regional-economy factor from S10). The weight vectors are the rows of the operator that does the reconstruction. The unifying claim S12 makes precise: DiD, synthetic control, synthetic DiD, and unconfoundedness-based imputation are all the same optimization problem — completing a low-rank matrix — differing only in the regularizer. DiD regularizes toward additive effects; SC toward sparse convex unit weights; matrix completion toward low rank.

NoteThe S12 reveal, planted here

Stack \(\omega\) and \(\lambda\) as rows and you are completing a low-rank matrix — driven by the same latent factors S10 could not see. S12 makes it precise: DiD, SC, synthetic DiD, and unconfoundedness-based imputation become one matrix-completion problem under different regularizers.

16.3.11 When SC is the right tool, and pointers beyond

16.3.11.1 The decision rule

Synthetic control is the right tool when you have a single (or very few) treated unit(s) and a clean, comparable donor pool, and you want an interpretable, credible counterfactual that does not lean on a cohort. The discipline is in the donor hygiene, in three rules: the treated unit must be spannable by the donors (the convex hull requirement — Northgate barely qualifies, and that marginal position is the +6.8% offset); only include plausible donors structurally comparable to the treated unit (a tight numerical fit on irrelevant donors is a mirage, the Ferman trap in Section 16.3.8); and the pool must satisfy SUTVA / no spillover — a donor contaminated by the treatment (a neighbour that also adopted the app) is not a clean control. These last points are active research areas (O’Riordan 2025 on donor selection; Di Stefano-Mellace 2024 on the inferential consequences of hull violations and donor choice).

16.3.11.2 Pointers and the A5 connection

Four signposts, each bending one part of the recipe — for the curious, not exam material:

  • SC asymptotics and inference — formal large-donor theory for when the placebo and prediction-interval logic is justified (Abadie-Zhao; the Abadie 2021 JEL review (Abadie 2021)).
  • Donor hygiene and SUTVA — how hull violations and spillovers bias the weights (O’Riordan 2025; Di Stefano-Mellace 2024).
  • DiD-meets-SC — staggered adoption with synthetic-control weighting (Sun-Xie-Zhang), the natural marriage of S10 and S11.
  • SC for design — using synthetic control prospectively to plan an intervention (choose where to launch so a clean counterfactual will exist), not only to analyze one after the fact (Abadie-Zhao).

The connection to Assignment V: single-flagship business cases — one treated region, one flagship store, one product launch with no comparable cohort — call for synthetic control, not the portfolio DiD of S10. The decision is the estimand’s: a board question about one unit selects the SC lens; a question about an average over many treated units selects the DiD lens. Both impute the same blanked-out \(Y(0)\) — the difference is which weights you are entitled to use.

NoteAssignment V — scenario teaser (due Sun 2 August; no quiz — A5 is itself the Part-V gate)

The setting. A regional grocery chain rolls out self-checkout to lift store footfall. One flagship store goes live first with no comparable cohort; a handful of other stores follow on a staggered schedule. You will estimate the flagship’s footfall effect and defend it, estimate the portfolio effect across the staggered pilots, and justify which lens each board question selects — from the estimand, not the toolbox.

Graded on the assumption ledger (name the assumption each estimator buys; argue it is plausible here) and the decision tree, plus a one-page board memo. Bilingual — R or Python, no penalty. The full rubric ships with the brief.

The data is a fresh DGP — today’s BeanBuzz exercises teach the tools, but nothing here pre-solves A5.

16.4 Coding Lab

The lab works the BeanBuzz synthetic-control problem end to end on the market panel where the truth is known (true Northgate ATT = +8.3%, true overall ATT = +4.9%), so each exercise is a recovery check, not a graded discovery — Assignment V uses a different domain, so nothing here pre-solves it. The arc follows the headline: warm up on the textbook California case and meet Northgate’s overshoot (+15.2%), ask whether chance explains it (placebo), report a real interval (scpi — the graded step), then pull the overshoot back with augmented SC (+8.1%) and synthetic DiD (+7.9%). Each exercise gives a Scaffold with # ___ YOUR CODE placeholders in both R and Python; attempt it, then expand Show solution to compare. The solutions mirror the verified pipeline in materials/data/make_s11_cache.{R,py} and reproduce the locked numbers.

Download the data: beanbuzz_markets.csv (the market-week aggregate — 40 markets × 104 weeks; treated unit = Northgate / market M17, donors = the 15 never-treated markets).

WarningHonest R ↔︎ Python parity caveat — the thinnest-bilingual session of Part V

The two languages share the clean, both-stack steps and diverge only where the ecosystems do. Four asymmetries are stated plainly rather than papered over:

  • Classic SC (Lab 0) agrees on the ATT to ~0.1pp: R tidysynth (built on Synth, with covariate predictors) returns +15.2%, Python pysyncon (outcome-only) +15.1% — the same ≈+15% overshoot, the small gap reflecting the predictor set, not a bug. Both stacks collapse onto the single donor M29 (R tidysynth 0.9998, Python pysyncon 1.000) — the classic ADH fit is maximally sparse, not a blend. The ~0.1pp ATT gap reflects the predictor set (R adds market covariates; pysyncon matches outcomes only), not the weights. (The deterministic parity that holds to the digit is augmented SC and the scpi point, below.)
  • Placebo-in-space (Lab 1) is R-led: R tidysynth (generate_placebos = TRUE + grab_significance()) produces the rank and Fisher \(p\)-value; the maintained Python pysyncon (1.5.x) ships no PlaceboTest, so Python students read the pre-baked R output and one sentence of interpretation.
  • scpi (Lab 2, the GRADED step) has clean parity on the point to the digit (R scpi ↔︎ Python scpi_pkg); the simulated interval agrees closely (RNG differs across languages).
  • Augmented SC (Lab 3a) has clean parity: R augsynth (progfunc = "Ridge") ↔︎ Python pysyncon.AugSynth, both +8.1%. Synthetic DiD (Lab 3b) is R-led (synthdid; pysynthdid is thin → Python students read the R output).

Notation: the market panel derives first_treat from cohort_lab ("never" → 0L, the never-treated code) and uses log(revenue) throughout; Northgate is is_northgate == TRUE (market M17, go-live week 53).

16.4.1 Lab 0 — Warm up on California, then meet Northgate (~25 min)

NoteTask — a clean fit, then an overshoot

Objective: run tidysynth on Abadie’s Proposition-99 case to see a clean fit and gap; then run the identical recipe on Northgate to get the classic ATT +15.2% and read the sparse unit weights. In Python, pysyncon reproduces the same M29 collapse and lands at +15.1% (~0.1pp from R’s +15.2%, the covariate-predictor gap). ~25 min. Expected: Prop-99 pre-RMSPE ≈ 1.8 packs, post gap ≈ −18.7 cigs; Northgate classic ATT ≈ +15% (R tidysynth +15.2% / Python pysyncon +15.1% — ~0.1pp apart).

16.4.1.1 Scaffold

library(data.table); library(tidysynth)
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG <- m[is_northgate == TRUE, unique(market_id)]   # "M17"
GO <- m[is_northgate == TRUE, unique(first_treat)] # 53
PRE <- 1:(GO - 1)

# (a) Northgate: build synthetic control (match lagged log_rev + market covariates)
# ___ YOUR CODE: so <- subdf |> synthetic_control(outcome=log_rev, unit=market_id, time=week,
#                       i_unit=NG, i_time=GO-1L, generate_placebos=TRUE) |>
#                       generate_predictor(...) |> generate_weights(optimization_window=PRE) |>
#                       generate_control()

# (b) read the post-period ATT off the gap, and the pre-RMSPE
# ___ YOUR CODE: ts <- so |> grab_synthetic_control(placebo=FALSE)  # real_y, synth_y by time
#                att <- mean(real_y - synth_y) over POST  -> +0.152

# (c) read the sparse unit weights
# ___ YOUR CODE: so |> grab_unit_weights()
import numpy as np, pandas as pd
from pysyncon import Dataprep, Synth
m = pd.read_csv("../../materials/data/beanbuzz_markets.csv")
m["is_northgate"] = m["is_northgate"].astype(str).str.upper().isin(["TRUE", "1"])
m["log_rev"] = np.log(m["revenue"])
NG = m.loc[m["is_northgate"], "market_id"].unique()[0]   # "M17"
GO = int(m.loc[m["is_northgate"], "cohort_lab"].unique()[0])
DON = sorted(m.loc[m["cohort_lab"] == "never", "market_id"].unique().tolist())
PRE = list(range(1, GO)); POST = list(range(GO, int(m["week"].max()) + 1))
sub = m[m["market_id"].isin([NG] + DON)][["market_id", "week", "log_rev"]].copy()

# (a) Dataprep: match lagged log_rev (weeks 13/26/39/52), then Synth().fit()
# ___ YOUR CODE: dp = Dataprep(foo=sub, dependent="log_rev", unit_variable="market_id",
#                  time_variable="week", treatment_identifier=NG, controls_identifier=DON,
#                  special_predictors=[("log_rev",[13],"mean"), ...], time_optimize_ssr=PRE, ...)
# ___ YOUR CODE: syn = Synth(); syn.fit(dataprep=dp)

# (b) ATT over POST -> +0.1515 = +15.1% (R tidysynth +15.2%; ~0.1pp apart)
# ___ YOUR CODE: syn.att(time_period=POST)["att"]

# (c) the weights — pysyncon also collapses onto M29 (same single-donor fit as R)
# ___ YOUR CODE: syn.weights().sort_values(ascending=False)
NoteReflection

California’s synthetic control fits almost perfectly (pre-gap flat at zero); Northgate’s does not — its synthetic sits ~6.8% below before launch. Why does that pre-period misfit not just disappear at launch? And how does it inflate the +15.2% relative to the true +8.3%?

suppressPackageStartupMessages({ library(data.table); library(tidysynth) })
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]
GO  <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]
PRE <- 1:(GO - 1); POST <- GO:max(m$week)

# --- Prop-99 warm-up: the textbook clean fit ---
data("smoking", package = "tidysynth")
so99 <- smoking |>
  synthetic_control(outcome = cigsale, unit = state, time = year,
                    i_unit = "California", i_time = 1988L, generate_placebos = FALSE) |>
  generate_predictor(time_window = 1980:1988,
                     ln_income = mean(lnincome, na.rm = TRUE),
                     ret_price = mean(retprice, na.rm = TRUE),
                     youth     = mean(age15to24, na.rm = TRUE)) |>
  generate_predictor(time_window = 1984:1988, beer_sales = mean(beer, na.rm = TRUE)) |>
  generate_predictor(time_window = 1975L, cigsale_1975 = cigsale) |>
  generate_predictor(time_window = 1980L, cigsale_1980 = cigsale) |>
  generate_predictor(time_window = 1988L, cigsale_1988 = cigsale) |>
  generate_weights(optimization_window = 1970:1988) |>
  generate_control()
ts99 <- as.data.table(so99 |> grab_synthetic_control(placebo = FALSE))
att99  <- ts99[time_unit >= 1989, mean(real_y - synth_y)]
rmse99 <- sqrt(ts99[time_unit <= 1988, mean((real_y - synth_y)^2)])
cat(sprintf("[Prop-99] pre-RMSPE = %.3f packs | post gap = %+.1f cigs/capita (clean fit)\n",
            rmse99, att99))
[Prop-99] pre-RMSPE = 1.791 packs | post gap = -18.7 cigs/capita (clean fit)
# --- Northgate: same recipe, harder unit ---
sub <- m[market_id %in% c(NG, DON)]
sub[, treat := as.integer(market_id == NG & week >= GO)]
setorder(sub, market_id, week)
so <- as.data.frame(sub) |>
  synthetic_control(outcome = log_rev, unit = market_id, time = week,
                    i_unit = NG, i_time = GO - 1L, generate_placebos = TRUE) |>
  generate_predictor(time_window = PRE,
                     market_size = mean(market_size),
                     urban_share = mean(urban_share),
                     n_stores    = mean(n_stores)) |>
  generate_predictor(time_window = 13L, lag13 = log_rev) |>
  generate_predictor(time_window = 26L, lag26 = log_rev) |>
  generate_predictor(time_window = 39L, lag39 = log_rev) |>
  generate_predictor(time_window = 52L, lag52 = log_rev) |>
  generate_weights(optimization_window = PRE) |>
  generate_control()
ts  <- as.data.table(so |> grab_synthetic_control(placebo = FALSE))
att <- ts[time_unit %in% POST, mean(real_y - synth_y)]
pre_off  <- ts[time_unit %in% PRE, mean(real_y - synth_y)]   # the +6.8% offset
pre_rmse <- sqrt(ts[time_unit %in% PRE, mean((real_y - synth_y)^2)])
cat(sprintf("\n[Northgate classic SC] ATT = %+.4f (%+.1f%%)  | pre-offset = %+.4f  pre-RMSPE = %.4f\n",
            att, 100 * att, pre_off, pre_rmse))

[Northgate classic SC] ATT = +0.1517 (+15.2%)  | pre-offset = +0.0683  pre-RMSPE = 0.0699
cat(sprintf("[decomposition] %+.1f%% post-gap  ~  %+.1f%% pre-offset + %+.1f%% true effect\n",
            100 * att, 100 * pre_off, 100 * 0.0831))
[decomposition] +15.2% post-gap  ~  +6.8% pre-offset + +8.3% true effect
# the unit weights — a maximally sparse synthetic Northgate: one donor (M29 approx 1.00) carries essentially all the weight
uw <- as.data.table(so |> grab_unit_weights())[order(-weight)]
cat("\n[Northgate] top donor (unit) weights:\n"); print(head(uw, 5))

[Northgate] top donor (unit) weights:
     unit       weight
   <char>        <num>
1:    M29 9.997535e-01
2:    M01 7.152213e-05
3:    M07 5.355315e-05
4:    M39 3.899122e-05
5:    M08 2.147376e-05
import numpy as np, pandas as pd
from pysyncon import Dataprep, Synth
m = pd.read_csv("../../materials/data/beanbuzz_markets.csv")
m["is_northgate"] = m["is_northgate"].astype(str).str.upper().isin(["TRUE", "1"])
m["log_rev"] = np.log(m["revenue"])
NG = m.loc[m["is_northgate"], "market_id"].unique()[0]
GO = int(m.loc[m["is_northgate"], "cohort_lab"].unique()[0])
DON = sorted(m.loc[m["cohort_lab"] == "never", "market_id"].unique().tolist())
PRE = list(range(1, GO)); POST = list(range(GO, int(m["week"].max()) + 1))
sub = m[m["market_id"].isin([NG] + DON)][["market_id", "week", "log_rev"]].copy()

dp = Dataprep(
    foo=sub, predictors=[], predictors_op="mean", time_predictors_prior=PRE,
    special_predictors=[("log_rev", [13], "mean"), ("log_rev", [26], "mean"),
                        ("log_rev", [39], "mean"), ("log_rev", [52], "mean")],
    dependent="log_rev", unit_variable="market_id", time_variable="week",
    treatment_identifier=NG, controls_identifier=DON, time_optimize_ssr=PRE,
)
syn = Synth(); syn.fit(dataprep=dp)
att = float(syn.att(time_period=POST)["att"])
print(f"[Northgate classic SC] ATT = {att:+.4f} ({100*att:+.1f}%)  -> R tidysynth +15.2%, ~0.1pp apart")
[Northgate classic SC] ATT = +0.1515 (+15.1%)  -> R tidysynth +15.2%, ~0.1pp apart
# pysyncon also loads ~all weight on one donor (M29) — the SAME single-donor collapse as R
w = syn.weights().sort_values(ascending=False)
print("\n[Northgate] top donor weights (note: same M29 collapse as R):")

[Northgate] top donor weights (note: same M29 collapse as R):
print(w.head(5).round(3))
M29    1.0
M01    0.0
M02    0.0
M07    0.0
M08    0.0
Name: weights, dtype: float64

The Prop-99 warm-up shows the clean fit: pre-RMSPE ≈ 1.8 packs and a post gap of ≈ −18.7 cigs/capita (Figure 16.3, Figure 16.4) — California sits inside the donors’ hull, so the synthetic tracks it almost exactly and the gap reads the effect straight off. Northgate is the harder unit: the classic ADH synthetic control returns +15.2% (Figure 16.5), and the diagnostic is in the pre-period — the synthetic sits ~+6.8% below real Northgate before launch (pre-RMSPE ≈ 0.070), because the largest market sits at the edge of the convex hull and no convex blend reaches its level. That offset does not vanish at launch; it leaks one-for-one into the post-gap, so +15.2% ≈ +6.8% offset + +8.3% true (Equation 16.2). Python’s pysyncon gives +15.1%, within ~0.1pp of R’s +15.2% (the gap is the predictor set — pysyncon matches outcomes only) — and both collapse onto the single donor M29 (R 0.9998, Python 1.000): the classic ADH fit is maximally sparse, the same approx +15% overshoot from the same one-donor synthetic.

16.4.2 Lab 1 — Could chance explain Northgate’s gap? (~25 min)

NoteTask — pretend every donor was treated

Objective: run the placebo-in-space test — reassign treatment to each donor at week 53, re-run synthetic control, and rank Northgate’s gap against the placebo distribution. ~25 min. Expected: Northgate ranks 2 of 16 (Fisher \(p = 0.125\); fit-filtered 2 of 15, \(p = 0.133\)) — one donor beats it, so single-unit SC inference is fragile. R-led (R tidysynth grab_significance()); Python students read the pre-baked R output (pysyncon 1.5.x ships no PlaceboTest).

16.4.2.1 Scaffold

# Reuse `so` from Lab 0 (built with generate_placebos = TRUE).
# (a) the significance table: rank, MSPE ratio, Fisher p-value
# ___ YOUR CODE: sig <- so |> grab_significance()
# ___ YOUR CODE: read sig[unit_name == NG, .(rank, fishers_exact_pvalue)]   # rank 2, p 0.125

# (b) Abadie's fit filter: drop placebos whose pre-fit is far worse than Northgate's,
#     then re-rank by post/pre MSPE ratio
# ___ YOUR CODE: ng_pre <- sig[unit_name == NG, pre_mspe]; sig_f <- sig[pre_mspe <= 5*ng_pre]
# ___ YOUR CODE: re-rank -> 2 of 15, p = 0.133
# Placebo-in-space is R-led here: the maintained pysyncon (1.5.x) ships no PlaceboTest.
# Read the pre-baked R output (from make_s11_cache.R / the R solution below):
#
#   [placebo] Northgate rank = 2 / 16   Fisher p = 0.125
#   [placebo, fit-filtered]  rank = 2 / 15   p = 0.133
#
# ___ YOUR CODE: in one sentence, state why rank 2/16 (one donor beats Northgate) plus
#                only 15 donors (coarse permutation grid) means single-unit SC inference
#                is FRAGILE -> corroborate with scpi (Lab 2) and cross-method (Lab 3).
NoteReflection

With only 15 donors the smallest achievable \(p\)-value is ~1/16 ≈ 0.06, and Northgate ranks 2nd, not 1st. Is the right conclusion “no effect”, “significant effect”, or something else? What does this say about staking a board decision on one \(p\)-value from one method?

suppressPackageStartupMessages({ library(data.table); library(tidysynth) })
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]
GO  <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]
PRE <- 1:(GO - 1)
sub <- m[market_id %in% c(NG, DON)]; setorder(sub, market_id, week)

so <- as.data.frame(sub) |>
  synthetic_control(outcome = log_rev, unit = market_id, time = week,
                    i_unit = NG, i_time = GO - 1L, generate_placebos = TRUE) |>
  generate_predictor(time_window = PRE,
                     market_size = mean(market_size), urban_share = mean(urban_share),
                     n_stores = mean(n_stores)) |>
  generate_predictor(time_window = 13L, lag13 = log_rev) |>
  generate_predictor(time_window = 26L, lag26 = log_rev) |>
  generate_predictor(time_window = 39L, lag39 = log_rev) |>
  generate_predictor(time_window = 52L, lag52 = log_rev) |>
  generate_weights(optimization_window = PRE) |>
  generate_control()

# (a) raw placebo-in-space significance: rank + Fisher p
sig <- as.data.table(so |> grab_significance())
ng_rank <- sig[unit_name == NG, rank]; ng_p <- sig[unit_name == NG, fishers_exact_pvalue]
cat(sprintf("[placebo] Northgate rank = %d / %d   Fisher p = %.3f\n", ng_rank, nrow(sig), ng_p))
[placebo] Northgate rank = 2 / 16   Fisher p = 0.125
# (b) Abadie fit-filter: keep placebos with pre-fit not much worse than Northgate's, re-rank
ng_pre <- sig[unit_name == NG, pre_mspe]
sig_f  <- sig[pre_mspe <= 5 * ng_pre]
sig_f[, rank_f := frank(-mspe_ratio, ties.method = "first")]
cat(sprintf("[placebo, fit-filtered] Northgate rank = %d / %d   p = %.3f  (one donor still beats it)\n",
            sig_f[unit_name == NG, rank_f], nrow(sig_f), sig_f[unit_name == NG, rank_f] / nrow(sig_f)))
[placebo, fit-filtered] Northgate rank = 2 / 15   p = 0.133  (one donor still beats it)
# Placebo-in-space is R-led (no PlaceboTest in the maintained pysyncon 1.5.x).
# Pre-baked R output (reproduced by the R solution above / make_s11_cache.R):
print("[placebo] Northgate rank = 2 / 16   Fisher p = 0.125")
[placebo] Northgate rank = 2 / 16   Fisher p = 0.125
print("[placebo, fit-filtered]  rank = 2 / 15   p = 0.133  (one donor still beats it)")
[placebo, fit-filtered]  rank = 2 / 15   p = 0.133  (one donor still beats it)
print()
print("Honest takeaway: Northgate's gap is large but ranks 2nd, not 1st, and with only 15")
Honest takeaway: Northgate's gap is large but ranks 2nd, not 1st, and with only 15
print("donors the permutation grid is coarse (smallest p ~ 1/16). Single-unit SC inference")
donors the permutation grid is coarse (smallest p ~ 1/16). Single-unit SC inference
print("is FRAGILE here -> corroborate with scpi (Lab 2) and cross-method agreement (Lab 3).")
is FRAGILE here -> corroborate with scpi (Lab 2) and cross-method agreement (Lab 3).

Northgate’s post-launch gap is large and opens upward (Figure 16.8), but in the placebo fan it ranks only 2 of 16: one donor’s placebo gap is larger, so the Fisher \(p\)-value is 0.125 (rank 2 of 16); the fit-filter (dropping placebos whose pre-fit is far worse than Northgate’s) leaves rank 2 of 15, \(p = 0.133\). By the 0.05 bar this does not clear significance — and the reason is structural: with only 15 donors the permutation distribution is coarse (smallest possible \(p \approx 1/16\)), and an imperfect pre-fit makes the single gap noisy. The honest conclusion is neither “no effect” nor “significant effect” but that single-unit SC inference is fragile here — exactly mirroring S10’s non-triumphant HonestDiD verdict. Do not stake a board decision on one \(p\)-value from one method; corroborate with a prediction interval (Lab 2) and cross-method agreement (Lab 3).

16.4.3 Lab 2 — Report a real interval: the GRADED step (~30 min)

NoteTask — the honest band the point estimate hid

Objective: use scpi (R) / scpi_pkg (Python) to build a genuine prediction interval for Northgate’s synthetic counterfactual, then convert it to an honest effect band. ~30 min — this is the graded bilingual deliverable. Expected: classic SC point +15.1% (R↔︎Python match to the digit, deterministic QP — the simplex point sits ~0.1pp below the covariate-predictor headline of +15.2%); honest effect band [+6.1%, +10.1%] (R) / [+3.8%, +10.1%] (Python) — centred near the true +8.3%, well below the naive point. Emphasize: the point/weights match to the digit; the simulated interval agrees closely (RNG differs across languages).

16.4.3.1 Scaffold

library(data.table); library(scpi)
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]; GO <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]; PRE <- 1:(GO-1); POST <- GO:max(m$week)
sub <- m[market_id %in% c(NG, DON)]

# (a) scdata: simplex SC, no constant, treated = Northgate, controls = donors
# ___ YOUR CODE: sdat <- scdata(df=as.data.frame(sub[,.(market_id,week,log_rev)]),
#                  id.var="market_id", time.var="week", outcome.var="log_rev",
#                  period.pre=PRE, period.post=POST, unit.tr=NG, unit.co=DON, constant=FALSE)

# (b) scpi: simulated prediction interval for the counterfactual Y(0)
# ___ YOUR CODE: res <- scpi(sdat, sims=500, e.method="gaussian", u.missp=TRUE,
#                            w.constr=list(name="simplex"))

# (c) convert the counterfactual PI to an honest EFFECT band: actual - counterfactual PI
# ___ YOUR CODE: eff = Y.post - Y.post.fit ; eff band = Y.post - [CI hi, CI lo]
import numpy as np, pandas as pd
from scpi_pkg.scdata import scdata
from scpi_pkg.scpi import scpi
m = pd.read_csv("../../materials/data/beanbuzz_markets.csv")
m["is_northgate"] = m["is_northgate"].astype(str).str.upper().isin(["TRUE", "1"])
m["log_rev"] = np.log(m["revenue"])
NG = m.loc[m["is_northgate"], "market_id"].unique()[0]
GO = int(m.loc[m["is_northgate"], "cohort_lab"].unique()[0])
DON = sorted(m.loc[m["cohort_lab"] == "never", "market_id"].unique().tolist())
PRE = list(range(1, GO)); POST = list(range(GO, int(m["week"].max()) + 1))
sub = m[m["market_id"].isin([NG] + DON)][["market_id", "week", "log_rev"]].copy()
df = sub.rename(columns={"market_id": "id", "week": "time", "log_rev": "y"})

# (a) scdata -> (b) scpi (same options as R) -> (c) effect band = actual - counterfactual PI
# ___ YOUR CODE: sd = scdata(df=df, id_var="id", time_var="time", outcome_var="y",
#                  period_pre=np.array(PRE), period_post=np.array(POST),
#                  unit_tr=NG, unit_co=DON, constant=False)
# ___ YOUR CODE: res = scpi(data=sd, sims=500, e_method="gaussian", u_missp=True,
#                           w_constr={"name":"simplex"})
NoteReflection

scpi’s effect band [+6.1%, +10.1%] is centred near the true +8.3% and lies below the naive classic point (~+15%). What does the band tell you that the point did not — and where does the actual Northgate path fall relative to the counterfactual band?

suppressPackageStartupMessages({ library(data.table); library(scpi) })
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]; GO <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]; PRE <- 1:(GO - 1); POST <- GO:max(m$week)
sub <- m[market_id %in% c(NG, DON)]

sdat <- scdata(df = as.data.frame(sub[, .(market_id, week, log_rev)]),
               id.var = "market_id", time.var = "week", outcome.var = "log_rev",
               period.pre = PRE, period.post = POST, unit.tr = NG, unit.co = DON,
               constant = FALSE, cov.adj = NULL)
set.seed(20260706)
res <- scpi(sdat, sims = 500, e.method = "gaussian", u.missp = TRUE,
            w.constr = list(name = "simplex"))
---------------------------------------------------------------
Estimating Weights...
Quantifying Uncertainty
Treated unit 1: 50/500 iterations completed (10%) 
Treated unit 1: 100/500 iterations completed (20%) 
Treated unit 1: 150/500 iterations completed (30%) 
Treated unit 1: 200/500 iterations completed (40%) 
Treated unit 1: 250/500 iterations completed (50%) 
Treated unit 1: 300/500 iterations completed (60%) 
Treated unit 1: 350/500 iterations completed (70%) 
Treated unit 1: 400/500 iterations completed (80%) 
Treated unit 1: 450/500 iterations completed (90%) 
Treated unit 1: 500/500 iterations completed (100%) 
Yp   <- as.numeric(res$data$Y.post)                       # actual Northgate post path
fit  <- as.numeric(res$est.results$Y.post.fit)            # classic-SC counterfactual point
ci   <- res$inference.results$CI.all.gaussian             # prediction interval for Y(0)
eff_point <- mean(Yp - fit)                               # +0.152, the classic point
eff_lo <- mean(Yp - ci[, 2]); eff_hi <- mean(Yp - ci[, 1])  # honest EFFECT band
cat(sprintf("[scpi] classic point ATT = %+.4f (%+.1f%%)\n", eff_point, 100 * eff_point))
[scpi] classic point ATT = +0.1515 (+15.1%)
cat(sprintf("[scpi] honest avg EFFECT band = [%+.4f, %+.4f]  ([%+.1f%%, %+.1f%%], near true +8.3%%)\n",
            eff_lo, eff_hi, 100 * eff_lo, 100 * eff_hi))
[scpi] honest avg EFFECT band = [+0.0607, +0.1013]  ([+6.1%, +10.1%], near true +8.3%)
import numpy as np, pandas as pd
from scpi_pkg.scdata import scdata
from scpi_pkg.scpi import scpi
m = pd.read_csv("../../materials/data/beanbuzz_markets.csv")
m["is_northgate"] = m["is_northgate"].astype(str).str.upper().isin(["TRUE", "1"])
m["log_rev"] = np.log(m["revenue"])
NG = m.loc[m["is_northgate"], "market_id"].unique()[0]
GO = int(m.loc[m["is_northgate"], "cohort_lab"].unique()[0])
DON = sorted(m.loc[m["cohort_lab"] == "never", "market_id"].unique().tolist())
PRE = list(range(1, GO)); POST = list(range(GO, int(m["week"].max()) + 1))
sub = m[m["market_id"].isin([NG] + DON)][["market_id", "week", "log_rev"]].copy()
df = sub.rename(columns={"market_id": "id", "week": "time", "log_rev": "y"})

sd = scdata(df=df, id_var="id", time_var="time", outcome_var="y",
            period_pre=np.array(PRE), period_post=np.array(POST),
            unit_tr=NG, unit_co=DON, constant=False, cov_adj=None)
np.random.seed(20260706)
res = scpi(data=sd, sims=500, e_method="gaussian", u_missp=True, w_constr={"name": "simplex"})
-----------------------------------------------
Estimating Weights...
Quantifying Uncertainty

50/500 iterations completed (10%)

100/500 iterations completed (20%)

150/500 iterations completed (30%)

200/500 iterations completed (40%)

250/500 iterations completed (50%)

300/500 iterations completed (60%)

350/500 iterations completed (70%)

400/500 iterations completed (80%)

450/500 iterations completed (90%)

500/500 iterations completed (100%)
Yp  = np.asarray(res.Y_post).flatten()
fit = np.asarray(res.Y_post_fit).flatten()
ci  = np.asarray(res.CI_all_gaussian)            # [lb, ub] for the counterfactual
eff_point = float(np.mean(Yp - fit))
eff_lo = float(np.mean(Yp - ci[:, 1])); eff_hi = float(np.mean(Yp - ci[:, 0]))
print(f"[scpi] classic point ATT = {eff_point:+.4f} ({100*eff_point:+.1f}%)  -> matches R to the digit")
[scpi] classic point ATT = +0.1515 (+15.1%)  -> matches R to the digit
print(f"[scpi] honest avg EFFECT band = [{eff_lo:+.4f}, {eff_hi:+.4f}]  (close to R's band; RNG differs)")
[scpi] honest avg EFFECT band = [+0.0384, +0.1013]  (close to R's band; RNG differs)

The classic SC point is +15.1% in both languages — identical to the digit, because the simplex weights solve a deterministic quadratic program, so the point is a property of the design, not the toolchain (this is the outcome-only simplex point; the covariate-predictor headline in Lab 0 is +15.2%, ~0.1pp higher). The honest effect band — actual minus the counterfactual prediction interval — is [+6.1%, +10.1%] in R and [+3.8%, +10.1%] in Python: both centred near the true +8.3% and well below the naive +15.2% (Figure 16.9). The two read off the figure as: the actual Northgate path sits above the counterfactual band (a genuinely positive effect), but the band exposes the +15.2% point as overconfident — it inherited the +6.8% pre-offset (Equation 16.2). The bilingual lesson is precise: deterministic quantities (point, weights) match to the digit; Monte-Carlo quantities (the simulated interval) agree closely but not identically, because the random-number streams differ. scpi is the designated graded step because it pins the two ecosystems together on the deterministic part and is honest about the simulated part.

16.4.4 Lab 3 — Pull the overshoot back: augmented SC + synthetic DiD (~40 min)

NoteTask — close the offset, then weight both axes

Objective: (a) augmented SC — R augsynth (progfunc = "Ridge") and Python pysyncon.AugSynth both close the pre-fit offset and land at +8.1%, contrasted with progfunc = "None" (the classic ≈+15%); mention the Ferman caution. (b) synthetic DiD — R synthdid (panel.matrices → synthdid_estimate) gives +7.9%; plot the \(\omega\) and \(\lambda\) weights and reconcile with S10’s portfolio ATT (+4.9% average market) vs Northgate’s flagship +8.3%. ~40 min. Expected: augmented SC = +8.1% [+7.6%, +8.6%] (R = Python, clean parity), SDID = +7.9% [+6.5%, +9.3%], plain DiD = +7.7% [+5.2%, +10.2%] — all near the true +8.3%. Synthetic DiD is R-led (Python students read the R output).

16.4.4.1 Scaffold

library(data.table); library(augsynth); library(synthdid)
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]; GO <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]
sub <- m[market_id %in% c(NG, DON)]
sub[, treat := as.integer(market_id == NG & week >= GO)]

# (a) augmented SC: ridge bias-correction (+8.1%) vs progfunc="None" (classic ~+15%)
# ___ YOUR CODE: a_ridge <- augsynth(log_rev ~ treat, unit=market_id, time=week, data=sub,
#                            progfunc="Ridge", scm=TRUE); summary(a_ridge, inf_type="jackknife")
# ___ YOUR CODE: a_none  <- augsynth(..., progfunc="None", ...)  # contrast: classic ~+15%

# (b) synthetic DiD: panel.matrices -> synthdid_estimate (+7.9%); also sc + did corners
# ___ YOUR CODE: pm <- panel.matrices(as.data.frame(sub[,.(market_id,week,log_rev,treat)]),
#                       unit="market_id", time="week", outcome="log_rev", treatment="treat")
# ___ YOUR CODE: synthdid_estimate(pm$Y, pm$N0, pm$T0)  # +7.9% ; did_estimate(...) # +7.7%
# ___ YOUR CODE: read attr(., "weights")$omega and $lambda  -> the two weight vectors
import numpy as np, pandas as pd
from pysyncon import Dataprep, AugSynth
# (a) Augmented SC — clean parity with R augsynth (+8.1%). Build dp as in Lab 0, then:
# ___ YOUR CODE: aug = AugSynth(); aug.fit(dataprep=dp); aug.att(time_period=POST)["att"]  # +0.081
#
# (b) Synthetic DiD is R-led (pysynthdid is thin). Read the R output:
#     SDID = +7.9%, plain DiD = +7.7% — both near the true +8.3%, WITHOUT a perfect SC fit.
print("Augmented SC (pysyncon) = +8.1% (parity w/ R augsynth);  synthdid is R-led (+7.9%).")
NoteReflection

Augmented SC closes the +6.8% offset and lands at +8.1%, and synthetic DiD — which needs neither a perfect fit nor exact parallel trends — gives +7.9%. Why does classic SC, the more “exact” convex method, do worse than both? (Hint: what does insisting on a fit it cannot achieve cost?)

suppressPackageStartupMessages({ library(data.table); library(augsynth) })
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]; GO <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]
sub <- m[market_id %in% c(NG, DON)]
sub[, treat := as.integer(market_id == NG & week >= GO)]

a_none  <- augsynth(log_rev ~ treat, unit = market_id, time = week, data = sub,
                    progfunc = "None",  scm = TRUE)
a_ridge <- augsynth(log_rev ~ treat, unit = market_id, time = week, data = sub,
                    progfunc = "Ridge", scm = TRUE)
sn <- summary(a_none,  inf_type = "jackknife")
sr <- summary(a_ridge, inf_type = "jackknife")
r_est <- sr$average_att$Estimate; r_se <- sr$average_att$Std.Error
cat(sprintf("[augsynth] classic (progfunc='None')  = %+.4f (%+.1f%%)  -- the overshoot\n",
            sn$average_att$Estimate, 100 * sn$average_att$Estimate))
[augsynth] classic (progfunc='None')  = +0.1515 (+15.1%)  -- the overshoot
cat(sprintf("[augsynth] augmented (progfunc='Ridge') = %+.4f (%+.1f%%)  95%% CI [%+.4f, %+.4f]\n",
            r_est, 100 * r_est, r_est - 1.96 * r_se, r_est + 1.96 * r_se))
[augsynth] augmented (progfunc='Ridge') = +0.0806 (+8.1%)  95% CI [+0.0756, +0.0857]
cat(sprintf("           -> offset closed; ~ true +8.3%%.  Ferman: a perfect pre-fit is NECESSARY,\n"))
           -> offset closed; ~ true +8.3%.  Ferman: a perfect pre-fit is NECESSARY,
cat(sprintf("           not SUFFICIENT -- with many donors, weights can overfit pre-period noise.\n"))
           not SUFFICIENT -- with many donors, weights can overfit pre-period noise.
import numpy as np, pandas as pd
from pysyncon import Dataprep, AugSynth
m = pd.read_csv("../../materials/data/beanbuzz_markets.csv")
m["is_northgate"] = m["is_northgate"].astype(str).str.upper().isin(["TRUE", "1"])
m["log_rev"] = np.log(m["revenue"])
NG = m.loc[m["is_northgate"], "market_id"].unique()[0]
GO = int(m.loc[m["is_northgate"], "cohort_lab"].unique()[0])
DON = sorted(m.loc[m["cohort_lab"] == "never", "market_id"].unique().tolist())
PRE = list(range(1, GO)); POST = list(range(GO, int(m["week"].max()) + 1))
sub = m[m["market_id"].isin([NG] + DON)][["market_id", "week", "log_rev"]].copy()

dp = Dataprep(
    foo=sub, predictors=[], predictors_op="mean", time_predictors_prior=PRE,
    special_predictors=[("log_rev", [13], "mean"), ("log_rev", [26], "mean"),
                        ("log_rev", [39], "mean"), ("log_rev", [52], "mean")],
    dependent="log_rev", unit_variable="market_id", time_variable="week",
    treatment_identifier=NG, controls_identifier=DON, time_optimize_ssr=PRE,
)
aug = AugSynth(); aug.fit(dataprep=dp)
att_aug = float(aug.att(time_period=POST)["att"])
print(f"[pysyncon AugSynth] augmented ATT = {att_aug:+.4f} ({100*att_aug:+.1f}%)  -> parity w/ R augsynth")
[pysyncon AugSynth] augmented ATT = +0.0806 (+8.1%)  -> parity w/ R augsynth
print("           Ferman caution: a perfect pre-fit is necessary, not sufficient (can overfit noise).")
           Ferman caution: a perfect pre-fit is necessary, not sufficient (can overfit noise).

Augmenting the classic weights with a ridge outcome-model correction drives the pre-period imbalance to ≈ 0, so the contamination term in Equation 16.2 vanishes and the estimate snaps from the classic ≈+15% (progfunc = "None" gives +15.1% here) to +8.1% with a 95% CI of [+7.6%, +8.6%] — statistically indistinguishable from the true +8.3% (Figure 16.11). Python’s pysyncon.AugSynth reproduces the +8.1% — clean bilingual parity, because augmented SC’s correction is deterministic. But heed the Ferman caution (Section 16.3.8): a near-perfect pre-fit feels like proof and is not. With many donors and a short pre-period, weights can drive the pre-fit to zero by fitting noise, leaving a biased counterfactual. Fit is necessary, never sufficient — which is why Lab 3b adds a method robust to imperfect fit, and why the final report (Figure 16.15) leans on cross-method agreement, not any single number.

suppressPackageStartupMessages({ library(data.table); library(synthdid) })
m <- fread(here::here("materials/data/beanbuzz_markets.csv"))
m[, first_treat := ifelse(cohort_lab == "never", 0L, as.integer(cohort_lab))]
m[, log_rev := log(revenue)]
NG  <- m[is_northgate == TRUE, unique(market_id)]; GO <- m[is_northgate == TRUE, unique(first_treat)]
DON <- m[first_treat == 0, unique(market_id)]
sub <- m[market_id %in% c(NG, DON)]
sub[, treat := as.integer(market_id == NG & week >= GO)]
setorder(sub, market_id, week)

pm <- panel.matrices(as.data.frame(sub[, .(market_id, week, log_rev, treat)]),
                     unit = "market_id", time = "week", outcome = "log_rev", treatment = "treat")
set.seed(20260706)
sdid <- synthdid_estimate(pm$Y, pm$N0, pm$T0)   # weights units AND time
didm <- did_estimate(pm$Y, pm$N0, pm$T0)        # the DiD corner (uniform weights)
w    <- attr(sdid, "weights")
# placebo 95% CIs -- the inference synthdid supports at N=1 (bootstrap/jackknife need >1 treated unit)
se_sdid <- sqrt(vcov(sdid, method = "placebo"))
se_did  <- sqrt(vcov(didm, method = "placebo"))
cat(sprintf("[synthdid] Synthetic DiD = %+.4f (%+.1f%%)  95%% CI [%+.1f%%, %+.1f%%]\n",
            as.numeric(sdid), 100 * as.numeric(sdid),
            100 * (as.numeric(sdid) - 1.96 * se_sdid), 100 * (as.numeric(sdid) + 1.96 * se_sdid)))
[synthdid] Synthetic DiD = +0.0791 (+7.9%)  95% CI [+6.5%, +9.3%]
cat(sprintf("[synthdid] plain DiD     = %+.4f (%+.1f%%)  95%% CI [%+.1f%%, %+.1f%%]   (true +8.3%%)\n",
            as.numeric(didm), 100 * as.numeric(didm),
            100 * (as.numeric(didm) - 1.96 * se_did), 100 * (as.numeric(didm) + 1.96 * se_did)))
[synthdid] plain DiD     = +0.0768 (+7.7%)  95% CI [+5.2%, +10.2%]   (true +8.3%)
# the two weight vectors ARE the content of the session (@fig-synthdid-bars, @fig-corners)
unit_w <- data.table(donor = rownames(pm$Y)[1:pm$N0], omega = round(as.numeric(w$omega), 3))
cat("\n[synthdid] top donor (unit) weights -- a sparse synthetic Northgate:\n")

[synthdid] top donor (unit) weights -- a sparse synthetic Northgate:
print(head(unit_w[order(-omega)], 5))
    donor omega
   <char> <num>
1:    M29 0.090
2:    M31 0.085
3:    M34 0.082
4:    M39 0.082
5:    M40 0.080
cat(sprintf("\n[synthdid] time weights concentrate on %d of %d pre-periods (lambda > 0)\n",
            sum(w$lambda > 1e-4), pm$T0))

[synthdid] time weights concentrate on 18 of 52 pre-periods (lambda > 0)

Synthetic difference-in-differences solves for unit weights \(\omega\) and time weights \(\lambda\) and runs a weighted two-way DiD — the corner of Equation 16.4 that is sparse on both axes. It lands at +7.9% on Northgate against the true +8.3%, and even plain DiD (the uniform-weight corner) gives +7.7% — both near the truth (Figure 16.15), while classic SC overshoots to +15.2%. The reason: SDID needs neither a perfect SC pre-fit (which Northgate cannot achieve in the simplex) nor exact parallel trends (which the omitted regional-economy factor violates), so it does not pay the price classic SC pays for insisting on a fit it cannot get. The two weight vectors are exactly the bars in Figure 16.13 — the unit weights pick a sparse synthetic Northgate, the time weights emphasize informative pre-weeks — and the +7.9% lands right on the true +8.3%. synthdid is R-led; Python’s pysynthdid is thin, so Python students read this output. The reconciliation with S10: the portfolio robust ATT was +4.9% (the average market); Northgate, the flagship, is larger at +8.3%, and the estimator family recovers it.

16.5 References & Resources

16.5.1 Academic Literature

The synthetic-control canon — construction and inference

  • (Abadie, Diamond, and Hainmueller 2010) — Synthetic Control Methods for Comparative Case Studies: Estimating the Effect of California’s Tobacco Control Program (Journal of the American Statistical Association). The ADH estimator: the convex-hull objective (Equation 16.1), the gap plot, and the placebo-in-space randomization test (Section 16.3.2, Section 16.3.3, Section 16.3.5). The Proposition-99 warm-up is this paper’s case.
  • (Abadie, Diamond, and Hainmueller 2015) — Comparative Politics and the Synthetic Control Method (American Journal of Political Science). The companion treatment of fit diagnostics and the pre-period RMSPE as the credibility gate (Section 16.3.3).
  • (Abadie 2021) — Using Synthetic Controls: Feasibility, Data Requirements, and Methodological Aspects (Journal of Economic Literature). The definitive review: convexity vs. extrapolation, donor hygiene, the convex-hull requirement, and when SC is the right tool (Section 16.3.2, Section 16.3.11).
  • (Ferman and Pinto 2021) — Synthetic Controls with Imperfect Pretreatment Fit (Quantitative Economics) and the related overfitting results. The caution that drives Section 16.3.8: a perfect pre-fit is necessary, never sufficient — many donors over a short pre-period fit noise.

Pulling the overshoot back — the modern family

  • (Ben-Michael, Feller, and Rothstein 2021) — The Augmented Synthetic Control Method (Journal of the American Statistical Association). Ridge bias-correction that lets the weights leave the simplex just enough to close a hull-driven offset; the bias-corrected-matching analogy (Section 16.3.7, Figure 16.11). The augsynth package.
  • (Arkhangelsky et al. 2021) — Synthetic Difference-in-Differences (American Economic Review). The unit + time weighting that does both DiD’s and SC’s job at once — the three-corners family (Equation 16.4, Figure 16.12), the Northgate +7.9%, and the diagnostic that stays parallel where classic SC drifts (Figure 16.14). The synthdid package.

Honest intervals — prediction and conformal

  • (Cattaneo et al. 2025) — Uncertainty Quantification in Synthetic Controls with Staggered Treatment Adoption and the scpi framework. Prediction intervals for the synthetic counterfactual \(Y(0)\), decomposing in-sample weight error and out-of-sample noise — the honest prediction band for the synthetic counterfactual (Section 16.3.6, Figure 16.9). The scpi / scpi_pkg packages.
  • (Chernozhukov, Wüthrich, and Zhu 2021) — An Exact and Robust Conformal Inference Method for Counterfactual and Synthetic Controls (Journal of the American Statistical Association). The conformal-inference alternative to scpi — a prediction guarantee under a permutation-style assumption (Section 16.3.6).

16.5.2 Practitioner Resources

  • tidysynth (R) — a tidy interface to the classic ADH estimator: synthetic_control(), generate_predictor(), generate_weights(), generate_control(), plus generate_placebos = TRUE and grab_significance() for placebo-in-space inference. The backbone of Labs 0–1; ships Abadie’s smoking (Prop-99) dataset. github.com/edunford/tidysynth.
  • pysyncon (Python) — the Python counterpart: Dataprep + Synth for classic SC (parity on the ATT to the digit; both stacks collapse onto donor M29), AugSynth for augmented SC (clean parity, +8.1%), and PlaceboTest for placebo-in-space inference. The bilingual workhorse for Labs 0–1 and 3a. github.com/sdfordham/pysyncon.
  • scpi (R) / scpi_pkg (Python) — the modern prediction-interval suite (scdata → scpi): an honest band for the synthetic counterfactual, accounting for weight-estimation and out-of-sample error. The graded bilingual step (Lab 2) — point to the digit, interval close. nppackages.github.io/scpi.
  • augsynth (R) — the augmented synthetic control method (augsynth(..., progfunc = "Ridge")) with jackknife inference; the ridge bias-correction that pulls Northgate’s +15.2% back to +8.1% (Lab 3a). github.com/ebenmichael/augsynth.
  • synthdid (R) — synthetic difference-in-differences (panel.matrices, synthdid_estimate, did_estimate, sc_estimate): the unit + time weight vectors for the lone Northgate flagship and the three corners of the weighted-DiD family (Lab 3b). R-led; pysynthdid is a thin Python port. synth-inference.github.io/synthdid.