Shen, Ding, Sekhon, and Yu frame a useful distinction for panel causal designs: the same observed panel can support horizontal time-series style prediction, vertical cross-sectional style prediction, and weighting estimators that interpolate between them. The point estimates may look like close cousins; the inference changes because the source of randomness changes.
This cached ablation puts that idea into one reproducible page for two canonical synthetic-control cases:
the Basque Country GDP case from Abadie and Gardeazabal;
the California Proposition 99 tobacco case from Abadie, Diamond, and Hainmueller.
For each case, the page runs the same panel through DID/TWFE, horizontal ridge prediction, vertical ridge prediction, synthetic control, synthetic DID, and matrix completion. It then reports the relevant placebo standard error and donor-placebo \(p\)-value that treat the donor pool as the comparison population, while leaving bootstrap/jackknife entries null where they are not meaningful for the design.
The final section runs a small semi-synthetic placebo simulation on the two real donor panels: each donor takes a turn as the treated unit, a known effect is added after the historical treatment date, and the estimators are scored against that known truth.
Data are vendored under docs/data/ so the page renders without network access.
1 Setup
Show code
from dataclasses import dataclassfrom html import escapefrom pathlib import Pathimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdfrom IPython.display import HTML, displayimport crabbymetrics as cmnp.set_printoptions(precision=4, suppress=True)DATA_DIR = Path("..") /"data"def html_table(headers, rows): parts = ["<table>", "<thead>", "<tr>"] parts.extend(f"<th>{escape(str(h))}</th>"for h in headers) parts.extend(["</tr>", "</thead>", "<tbody>"])for row in rows: parts.append("<tr>") parts.extend(f"<td>{cell}</td>"for cell in row) parts.append("</tr>") parts.extend(["</tbody>", "</table>"])return"".join(parts)@dataclass(frozen=True)class PanelCase: key: str title: str outcome_label: str panel: np.ndarray units: list[str] years: np.ndarray treated_units: list[int] t_pre: int simulation_tau: float@propertydef treatment_year(self):returnint(self.years[self.t_pre])@propertydef donor_units(self): treated =set(self.treated_units)return [i for i inrange(self.panel.shape[0]) if i notin treated]
2 Load The Two Case Panels
Show code
def wide_from_long(df, unit_col, time_col, outcome_col, units=None):if units isNone: units =sorted(df[unit_col].drop_duplicates().tolist()) years = np.array(sorted(df[time_col].drop_duplicates().astype(int).tolist())) lookup = df.set_index([unit_col, time_col])[outcome_col] panel = np.array([[lookup.loc[(u, y)] for y in years] for u in units], dtype=float)return panel, [str(u) for u in units], yearsbasque_raw = pd.read_csv(DATA_DIR /"basque.csv")basque_raw["regionno"] = basque_raw["regionno"].astype(int)basque_raw["year"] = basque_raw["year"].astype(int)# Region 1 is the national aggregate, not a donor region. Region 17 is Basque Country.basque_raw = basque_raw[basque_raw["regionno"] !=1].copy()basque_raw["label"] = basque_raw["regionno"].astype(str) +": "+ basque_raw["regionname"]basque_units = ( basque_raw[["regionno", "regionname", "label"]] .drop_duplicates() .sort_values("regionno"))basque_panel, basque_unit_labels, basque_years = wide_from_long( basque_raw, unit_col="label", time_col="year", outcome_col="gdpcap", units=basque_units["label"].tolist(),)basque_treated = [basque_unit_labels.index(next(u for u in basque_unit_labels if u.startswith("17:")))]basque_t_pre =int(np.where(basque_years ==1970)[0][0])cal_raw = pd.read_csv(DATA_DIR /"california_prop99.csv", sep=";")cal_units =sorted(cal_raw["State"].drop_duplicates().tolist())cal_panel, cal_unit_labels, cal_years = wide_from_long( cal_raw, unit_col="State", time_col="Year", outcome_col="PacksPerCapita", units=cal_units,)cal_treated = [cal_unit_labels.index("California")]cal_t_pre =int(np.where(cal_years ==1989)[0][0])cases = [ PanelCase( key="basque", title="Basque Country GDP", outcome_label="real per-capita GDP, thousand 1986 USD", panel=basque_panel, units=basque_unit_labels, years=basque_years, treated_units=basque_treated, t_pre=basque_t_pre, simulation_tau=-0.75, ), PanelCase( key="california", title="California Proposition 99", outcome_label="cigarette packs per capita", panel=cal_panel, units=cal_unit_labels, years=cal_years, treated_units=cal_treated, t_pre=cal_t_pre, simulation_tau=-15.0, ),]display( HTML( html_table( ["Case", "Units", "Years", "Treated", "First treated year", "Outcome"], [ [ c.title, c.panel.shape[0],f"{int(c.years[0])}-{int(c.years[-1])}",", ".join(c.units[i] for i in c.treated_units), c.treatment_year, c.outcome_label, ]for c in cases ], ) ))
Case
Units
Years
Treated
First treated year
Outcome
Basque Country GDP
17
1955-1997
17: Basque Country (Pais Vasco)
1970
real per-capita GDP, thousand 1986 USD
California Proposition 99
39
1970-2000
California
1989
cigarette packs per capita
3 Estimators
Each estimator returns an average post-treatment ATT estimate and, when meaningful, a period-by-period post-treatment effect path.
horizontal ridge is now the first-class crabbymetrics.HorizontalPanelRidge: it uses the common fit(Y, W) panel API, learns treated cohorts and never-treated donors internally, and forecasts treated counterfactual paths from donor paths;
vertical ridge remains a short reference calculation: it regresses donor outcomes in each post-treatment period on donor pre-treatment histories, then predicts the treated unit’s missing counterfactual from its pre-treatment history.
Synthetic control remains the low-level simplex-weight reference. Synthetic DID and matrix completion now use the same fit(Y, W) panel contract as horizontal ridge, so the vignette no longer constructs estimator-specific masks or treated-unit arguments for the modal panel estimators.
Show code
@dataclassclass Estimate: name: str att: float effect_path: np.ndarray counterfactual: np.ndarray |None=None pre_rmse: float|None=Nonedef split_panel(panel, treated_units): treated_units =list(treated_units) controls = [i for i inrange(panel.shape[0]) if i notinset(treated_units)] treated_mean = panel[treated_units].mean(axis=0) control_mean = panel[controls].mean(axis=0)return treated_mean, control_mean, controlsdef treatment_matrix(panel, treated_units, t_pre): w = np.zeros_like(panel, dtype=float) w[np.ix_(list(treated_units), np.arange(t_pre, panel.shape[1]))] =1.0return wdef panel_counterfactual_mean(summary, treated_units):return np.asarray(summary["counterfactual"])[list(treated_units)].mean(axis=0)def panel_effect_path(summary, treated_units, t_pre):return np.asarray(summary["treatment_effect"])[list(treated_units), t_pre:].mean(axis=0)def ridge_solve(x, y, alpha=1.0, penalize_intercept=False): x = np.asarray(x, dtype=float) y = np.asarray(y, dtype=float) penalty = alpha * np.eye(x.shape[1])ifnot penalize_intercept: penalty[0, 0] =0.0return np.linalg.solve(x.T @ x + penalty, x.T @ y)def estimate_did(panel, treated_units, t_pre): treated_mean, control_mean, _ = split_panel(panel, treated_units) pre_gap = treated_mean[:t_pre].mean() - control_mean[:t_pre].mean() effect = treated_mean[t_pre:] - control_mean[t_pre:] - pre_gapreturn Estimate("DID", float(effect.mean()), effect, control_mean + pre_gap)def estimate_twfe(panel, treated_units, t_pre): n, tt = panel.shape unit = np.repeat(np.arange(n, dtype=np.uint32), tt) time = np.tile(np.arange(tt, dtype=np.uint32), n) treated = np.zeros(n) treated[treated_units] =1.0 post = (np.arange(tt) >= t_pre).astype(float) d = np.repeat(treated, tt) * np.tile(post, n) fe = np.column_stack([unit, time]) model = cm.FixedEffectsOLS() model.fit(d[:, None], fe, panel.reshape(-1)) att =float(np.asarray(model.summary()["coef"])[0])# For a single adoption date in a balanced panel, TWFE and DID have the same# coefficient. Use the DID post-period effect path for the time-series# inference diagnostic rather than a degenerate constant coefficient path. effect = estimate_did(panel, treated_units, t_pre).effect_pathreturn Estimate("TWFE", att, effect)def estimate_horizontal_ridge(panel, treated_units, t_pre, alpha=0.25): model = cm.HorizontalPanelRidge(penalty=alpha) model.fit(panel, treatment_matrix(panel, treated_units, t_pre)) summary = model.summary()return Estimate("Horizontal ridge",float(summary["att"]), panel_effect_path(summary, treated_units, t_pre), panel_counterfactual_mean(summary, treated_units),float(summary["pre_rmse"]), )def estimate_vertical_ridge(panel, treated_units, t_pre, alpha=1.0): treated_mean, _, controls = split_panel(panel, treated_units) x_controls = np.column_stack([np.ones(len(controls)), panel[controls, :t_pre]]) x_treated = np.r_[1.0, treated_mean[:t_pre]] counter_post = []for t inrange(t_pre, panel.shape[1]): beta = ridge_solve(x_controls, panel[controls, t], alpha=alpha) counter_post.append(float(x_treated @ beta)) counter_post = np.asarray(counter_post) effect = treated_mean[t_pre:] - counter_post counterfactual = np.r_[treated_mean[:t_pre], counter_post]return Estimate("Vertical ridge", float(effect.mean()), effect, counterfactual)def estimate_sc(panel, treated_units, t_pre): treated_mean, _, controls = split_panel(panel, treated_units) donors_pre = panel[controls, :t_pre].T donors_all = panel[controls].T model = cm.SyntheticControl(max_iterations=800) model.fit(donors_pre, treated_mean[:t_pre]) y0_hat = np.asarray(model.predict(donors_all)) effect = treated_mean[t_pre:] - y0_hat[t_pre:]return Estimate("Synthetic control", float(effect.mean()), effect, y0_hat, float(model.summary()["pre_rmse"]))def estimate_sdid(panel, treated_units, t_pre): model = cm.SyntheticDID(max_iterations=5000) model.fit(panel, treatment_matrix(panel, treated_units, t_pre)) summary = model.summary() y0_hat = panel_counterfactual_mean(summary, treated_units) effect = panel_effect_path(summary, treated_units, t_pre)return Estimate("Synthetic DID", float(summary["att"]), effect, y0_hat, float(summary["pre_rmse"]))def estimate_mc(panel, treated_units, t_pre): treated_mean, _, _ = split_panel(panel, treated_units) model = cm.MatrixCompletion(lambda_fraction=0.035, max_iterations=350, tolerance=1e-7) model.fit(panel, treatment_matrix(panel, treated_units, t_pre)) summary = model.summary() y0_hat = panel_counterfactual_mean(summary, treated_units) effect = panel_effect_path(summary, treated_units, t_pre) pre_rmse =float(np.sqrt(np.mean((treated_mean[:t_pre] - y0_hat[:t_pre]) **2)))return Estimate("Matrix completion", float(summary["att"]), effect, y0_hat, pre_rmse)ESTIMATORS = [ ("DID", estimate_did), ("TWFE", estimate_twfe), ("Horizontal ridge", estimate_horizontal_ridge), ("Vertical ridge", estimate_vertical_ridge), ("Synthetic control", estimate_sc), ("Synthetic DID", estimate_sdid), ("Matrix completion", estimate_mc),]
4 Inference Helper
The donor-placebo standard error and \(p\)-value rotate treatment across donor units at the same intervention date and compare the realized estimate with that placebo distribution. Bootstrap and jackknife columns are kept explicit in the summary table, but are left null where that inferential leaf is not meaningful or not exposed for the estimator. In particular, both case studies have a single treated unit/group, so SDID’s bootstrap and jackknife SEs are undefined; placebo is the relevant SDID inference method here.
Show code
def donor_placebo_distribution(case, estimator_fn, tau_to_add=0.0): actual =set(case.treated_units) donor_pool = [i for i inrange(case.panel.shape[0]) if i notin actual] out = []for pseudo in donor_pool: sub_units = [pseudo] + [i for i in donor_pool if i != pseudo] sub_panel = case.panel[sub_units].copy()if tau_to_add !=0.0: sub_panel[0, case.t_pre:] += tau_to_addtry: out.append(float(estimator_fn(sub_panel, [0], case.t_pre).att))exceptException: out.append(np.nan)return np.asarray(out, dtype=float)def placebo_standard_error(placebo): vals = placebo[np.isfinite(placebo)]iflen(vals) <=1:return np.nanreturnfloat(np.std(vals, ddof=1))def placebo_p_value(actual, placebo): vals = placebo[np.isfinite(placebo)]iflen(vals) ==0:return np.nanreturnfloat((1.0+ np.sum(np.abs(vals) >=abs(actual))) / (len(vals) +1.0))def format_number(value):returnf"{value:.3f}"if np.isfinite(value) else"--"def inference_summary(name, placebo):return {"bootstrap_se": np.nan,"jackknife_se": np.nan,"placebo_se": placebo_standard_error(placebo), }
5 Case Study Estimates
Show code
case_results = {}for case in cases: case_results[case.key] = {}for name, fn in ESTIMATORS: est = fn(case.panel, case.treated_units, case.t_pre) placebo = donor_placebo_distribution(case, fn) case_results[case.key][name] = {"estimate": est,"placebo": placebo,"inference": inference_summary(name, placebo),"placebo_p": placebo_p_value(est.att, placebo), }rows = []for case in cases:for name, _ in ESTIMATORS: item = case_results[case.key][name] est = item["estimate"] inf = item["inference"] rows.append( [ case.title, name,f"{est.att:.3f}",""if est.pre_rmse isNoneelsef"{est.pre_rmse:.3f}", format_number(inf["bootstrap_se"]), format_number(inf["jackknife_se"]), format_number(inf["placebo_se"]), format_number(item["placebo_p"]), ] )display( HTML( html_table( ["Case","Estimator","ATT","Pre RMSE","Bootstrap SE","Jackknife SE","Placebo SE","Donor-placebo p", ], rows, ) ))
This simulation keeps the empirical donor panels intact. For each case and each donor unit, we temporarily declare that donor treated, remove the real treated unit from the donor pool, add a known post-treatment effect, and rerun every estimator. This is not a universal Monte Carlo; it is a compact stress test of the estimator/inference code on the two empirical designs used above.
The Basque and California panels are small enough that no single inferential convention should be treated as dispositive. That is exactly the Shen-Ding-Sekhon-Yu lesson. The table therefore keeps unavailable bootstrap/jackknife leaves null and reports the donor-placebo leaf where it is relevant. For SDID in these two single-treated-unit designs, that means placebo inference only. A donor-placebo standard error or \(p\)-value asks whether the treated unit looks unusual relative to donor units assigned the same historical intervention date.
The semi-synthetic donor-turn simulation is the sanity check: when the true effect is known and injected into real donor paths, the matching and matrix-completion leaves usually beat raw DID/TWFE when pre-treatment factor mismatch is important, but their placebo inference remains limited by the size and quality of the donor pool. In other words, the estimators and the inference really are leaves from the same root, not interchangeable black boxes.