Synthetic control in Python: read the pre-fit before the gap

A synthetic control reads a treatment effect off a gap, and the most reassuring thing on the screen, a zero-error pre-treatment fit, is the weakest evidence that the gap is real. This is a hands-on walk from panel data to a working estimator, and to the two questions that matter more than the number it prints.

Let's put ourselves in a common situation. We have panel data: one treated unit, a handful of untreated "donor" units, and an outcome measured over time. Treatment starts at some point partway through. We have Python open, and we have heard that synthetic control "reads a treatment effect off a gap." We want to see that happen with our own code.

We can do this in a few lines. We will build the synthetic a few different ways, look at what the graphs and numbers show, and find where the trap is. A synthetic control has three moving parts: the treated unit (the one that got the policy), the donor pool (untreated units we will average together), and the gap (the distance between the treated unit and its synthetic twin after treatment). Here is what a clean case looks like.

Line chart, 24 periods. A dark treated-unit line and a teal synthetic-control line track each other closely before period 12, where a dashed vertical treatment line sits. After period 12 the treated line jumps up and a gap of about 6 units opens between it and the synthetic line.
Before treatment the synthetic tracks the treated unit; after treatment a gap of about 6 opens. That gap is the estimated effect, and it is the least reliable thing on this chart.

Where the gap comes from

To test the method, we build data where we already know the answer. We stack a time series for the treated unit and time series for 15 donor units across 24 periods. Treatment starts in period 12, and after that we add a real effect of 6 units to the treated outcome. Because we built it, we know the truth is exactly 6.0, and we can check what each estimator reports against it.

Synthetic control follows a fixed procedure: build a weighted average of the donors that tracks the treated unit before treatment, extend that average afterward as the counterfactual, and read the gap between the treated unit and that synthetic twin as the effect. In code, we fit weights on the pre-period, apply them to the post-period, and average the difference.

import numpy as np

rng = np.random.default_rng(20260705)
T, T0, K, J, TAU = 24, 12, 3, 15, 6.0          # periods, pre-periods, factors, donors, effect
F = rng.normal(0, 1, (T, K))                   # common factors over time
donor_load = rng.normal(0, 1, (J, K))
treat_load = rng.dirichlet(np.ones(J)) @ donor_load   # treated sits INSIDE the donor range
donors  = donor_load @ F.T + rng.normal(0, 0.5, (J, T))
treated = treat_load  @ F.T + rng.normal(0, 0.5, T)
treated[T0:] += TAU                            # plant the effect after period 12

On screen the lines and the gap look clean, and that is where the problem begins.

First move: unrestricted weights and a zero-error pre-fit

Start with the most naive move. We fit unrestricted least-squares weights on the pre-period, with no constraints, asking only for the closest match to the treated unit before treatment. Because we have more donors (15) than pre-treatment periods (12), the fit reproduces the treated unit's pre-period path with zero error: the pre-period error, measured as root-mean-square prediction error (pre-RMSPE), is 0.000.

w, *_ = np.linalg.lstsq(donors[:, :T0].T, treated[:T0], rcond=None)
pre_rmspe = np.sqrt(np.mean((treated[:T0] - donors[:, :T0].T @ w) ** 2))
gap = np.mean(treated[T0:] - donors[:, T0:].T @ w)
print(round(pre_rmspe, 3), round(gap, 3))      # -> 0.0  6.132
Line chart of the 12 pre-treatment periods. The dark treated-unit line and the dotted Unrestricted synthetic line coincide, and a caption reads Pre-RMSPE = 0.000 with estimated gap about 6.13.
With more donors than pre-periods, the unrestricted synthetic reproduces the treated unit's pre-period path. A pre-RMSPE of 0.000 looks like the strongest possible evidence, and it is the trap.

When we read the gap after treatment, we get about 6.132 against the true planted effect of 6.0. Here is the surprise: that number is not biased. Across 200 simulated panels the unrestricted gap averages about 6.002, right on the truth. Overfitting did not produce a wrong effect in this clean setup. The number is right and the pre-fit has zero error, and that zero-error pre-fit is still the problem.

Why the zero-error pre-fit doesn't mean what we think

A zero-error pre-fit is available for any treated unit if we give the optimizer enough slack. A pre-RMSPE of exactly zero signals that the weights were free enough to match anything we handed them, whether or not the comparison is good. Two consequences follow. First, the unrestricted gap is noisier from panel to panel: across 200 simulations its spread (standard deviation) is about 0.425, against 0.185 for the constrained estimator we build next.

Overlaid histograms of estimated gaps from 200 simulations. The coral unrestricted distribution is wide with standard deviation 0.425; the teal convex distribution is narrower with standard deviation 0.185. Both center on a dashed line at the truth of 6.0.
Both estimators center on the truth, so neither is biased here. The unrestricted fit (coral) is simply noisier, with more than double the spread of the convex fit (teal).

Second, and more important, the zero-error fit removes the one number that would flag an unusable donor pool: a non-zero pre-RMSPE that reflects real tracking difficulty. We put that number to use next.

We might try the obvious repair: we overfit, so let's add a ridge penalty and shrink the weights. That changes the pre-RMSPE, but each penalty setting reports a different pre-fit quality for the same panel, around 0.018, 0.111, and 0.273 for penalties of 0.1, 1.0, and 10.0, while the gaps stay near 6. The dial we pick, not the data, sets the pre-fit.

Two panels against ridge penalty lambda of 0.1, 1, and 10. Left panel: pre-RMSPE rises from 0.018 to 0.111 to 0.273. Right panel: the estimated gap stays near 6.0 (6.126, 6.096, 6.054) across all three penalties.
Ridge does not rescue the pre-fit. The pre-RMSPE it reports climbs with the penalty we chose while the gap barely moves, so the number is a knob setting, not a diagnostic.

Second move: constrained weights make the pre-RMSPE mean something

Now we switch to the version synthetic control is actually built around. We constrain the weights: every weight is non-negative, the weights sum to one, and they minimize pre-period error under those two rules. The synthetic can then only be a weighted average of the donors, not an arbitrary extrapolation. We lift it as a small helper and read three things off it: the weights, the pre-RMSPE, and the gap.

from scipy.optimize import minimize

def synthetic_control(treated, donors, t0):
    """Convex SC: weights >= 0 that sum to 1, minimizing pre-period error.
    Returns (weights, pre_rmspe, gap). The pre_rmspe is a DIAGNOSTIC, not a target."""
    n = donors.shape[0]
    yt, yd = treated[:t0], donors[:, :t0]
    obj  = lambda w: np.sum((yt - w @ yd) ** 2)
    cons = ({"type": "eq", "fun": lambda w: np.sum(w) - 1},)
    w = minimize(obj, np.ones(n) / n, method="SLSQP",
                 bounds=[(0, 1)] * n, constraints=cons).x
    pre_rmspe = np.sqrt(np.mean((yt - w @ yd) ** 2))
    gap = float(np.mean(treated[t0:] - w @ donors[:, t0:]))
    return w, pre_rmspe, gap

w, pre_rmspe, gap = synthetic_control(treated, donors, T0)
print(round(pre_rmspe, 3), round(gap, 3))       # -> 0.303  6.058

On the same panel, the pre-RMSPE is no longer zero (about 0.303), the gap is close to the planted 6.0 (about 6.058), and the spread across simulations tightens. That non-zero pre-RMSPE now measures something real: how well a weighted counterfactual can track the treated unit. The unrestricted fit produced no such number; the constraint is what makes it available.

Two bar panels comparing Unrestricted and Convex SC. Left: pre-fit RMSPE is 0.000 for unrestricted and 0.303 for convex. Right: single-draw gap is 6.132 for unrestricted and 6.058 for convex, with error bars showing standard deviations 0.425 and 0.185 around the truth line at 6.0.
Both recover the effect; the difference is the pre-fit. The convex estimator's 0.303 is the real measurement the unrestricted fit discarded.

Checking sensitivity to the fit window

Before we trust the constrained gap, we check whether it swings when we change how much pre-history we fit on. We slide the fit window across the last 6, 9, and 12 pre-periods and re-read the gap each time.

Two panels against fit window of the last 6, 9, and 12 periods. Left: pre-RMSPE rises with the window from 0.012 to 0.111 to 0.303. Right: the estimated gap stays near the truth line at 6.0, at 6.047, 5.970, and 6.058.
The gap holds near 6 across every window, so the estimate is a property of the design, not of the window we happened to pick. Recovery is close, not exact, and that residual is part of the result.

Third move: a donor pool with no valid counterfactual

Until now the treated unit has sat inside the range of the donors, so a valid counterfactual exists. Now we remove it. We place the treated unit outside the donors' range, so no weighted average of them can track it, and refit both estimators.

The unrestricted fit still reports a pre-RMSPE of 0.000. It reproduces the treated unit's pre-period path, but the gap it reports is now about 4.299 instead of 6.0: a zero-error pre-fit and a wrong effect. The convex fit cannot reproduce that path. Its pre-RMSPE rises to about 7.613, because no combination of these donors can track the treated unit, and that large number is the evidence of an unusable pool.

Bar chart. On a valid pool convex pre-RMSPE is 0.303; on an unusable pool convex pre-RMSPE is 7.613. A note reads that the unrestricted pre-RMSPE is 0.000 in both cases, so it gives no such signal.
On an unusable pool the convex pre-RMSPE rises above 7, the signal that no valid counterfactual exists. The unrestricted pre-RMSPE is 0.000 on both pools and gives no such signal.

Where the diagnostics stop and judgment begins

Here we reach the limit of what any in-sample number can show. A small pre-RMSPE from a constrained synthetic control is necessary for a credible design: if the synthetic cannot track the treated unit before treatment, the counterfactual is weak. But a small pre-RMSPE is never sufficient. Even with good pre-period tracking, the donor pool might still be the wrong set of comparisons. No pre-RMSPE, placebo test, or robustness sweep inside the design can prove that a valid counterfactual exists. That judgment comes from knowing what the donors are.

A diagram with two boxes. A Statistical checks box lists pre-RMSPE, placebos, and robustness. An arrow labeled necessary, not sufficient points to a larger Substantive judgment box listing policy context, geography, population, and timing.
Statistical checks can rule a design out, but they cannot certify it. Donor-pool validity is a substantive question about whether these units are plausible stand-ins, answered from context, not fit.

Inference limits: what the permutation p can and can't say

Significance in a synthetic control usually comes from a permutation test. We reassign the treatment to each donor in turn, recompute a synthetic for it, and compare the real treated unit's gap against that placebo distribution. With 15 donors, the smallest p-value we can possibly get is 1 divided by 16, or about 0.062, and in our setup the permutation p sits exactly at that floor even though the planted effect is a large 6.0.

Histogram of placebo gaps from 15 donor units, mostly small and clustered near zero, with a coral vertical line marking the treated unit's gap near 6.0 far to the right. A note reads p equals rank over J plus 1, smallest possible 1 over 16, about 0.062.
The treated gap sits far outside the placebo distribution, yet the smallest p-value 15 donors can produce is 0.062. A small pool cannot reach conventional significance no matter how real the effect.

A p-value above 0.05 can mean the pool is too small to reach conventional significance even when the effect is real. When we report the permutation p, we report its floor alongside it: 1 divided by (number of donors plus one).

What we read before we look at the gap

When we are at the keyboard about to interpret a synthetic control, we walk these four checks before we trust the gap.

  1. Read the pre-RMSPE before the gap, and distrust a pre-RMSPE of exactly zero. A zero-error pre-fit signals unconstrained weights, not a good counterfactual.
  2. Use convex weights, non-negative and summing to one, so the pre-RMSPE is a diagnostic rather than a target. Unrestricted or penalized fits trade that diagnostic for a hand-tuned dial.
  3. Argue donor-pool validity on substance, not fit. Each donor needs a real story as a plausible stand-in for the treated unit; no in-sample number can supply it.
  4. Report the permutation p alongside its floor of 1 over (J + 1). In a small pool, "not significant" can be a design limit rather than an absent effect.

The gap is the output. The finding we care about is whether the counterfactual is credible and the estimate is stable across reasonable design choices.

Reproduction

The full simulation framework is at github.com/dphdame/tooearlytosay-analysis. It includes the factor-model data generation, the unrestricted and convex estimators, the ridge sweep, the fit-window walk, the invalid-pool case, the permutation test, and the script that generates every figure above from the same seeded data. Results reproduce exactly. Shifting the treated unit inside or outside the donor range is what flips the pre-fit diagnostic from small to large.

Cite this article

Cholette, V. (2026, July 5). Synthetic control in Python: read the pre-fit before the gap. Too Early To Say. https://tooearlytosay.com/research/methodology/synthetic-control-python/