This page ports the Chapter 9 “bridging finite and super-population” exercise from ding_ci into the crabbymetrics docs, then turns it into a fuller ablation.
The common point estimator is the treatment coefficient in a fully interacted regression for a randomized experiment with centered covariate adjustment. We compare four interval constructions:
HC2 finite-population: the Eicker-Huber-White variance from the fully interacted regression, treating the realized covariates as fixed.
Closed-form superpopulation: Ding’s correction that adds the interaction-slope term \(\hat{\gamma}^\top \widehat{\Sigma}_W \hat{\gamma} / n\) to the HC2 variance.
Bootstrap superpopulation: a nonparametric row bootstrap that resamples \((Y_i, Z_i, X_i)\) and rebuilds the centered interacted regression inside each bootstrap draw.
Stacked GMM superpopulation: an exactly identified moment system that jointly estimates the treatment effect together with the centering nuisance parameter \(\mu\), then reports the sandwich variance for \(\tau\).
The same reported interval can then be judged against two estimands:
SATE: the realized sample average treatment effect \(n^{-1} \sum_i (Y_i(1) - Y_i(0))\)
PATE: the population average treatment effect \(\mathbb{E}[Y_i(1) - Y_i(0)]\)
The first three procedures use the actual cm.OLS fit for point estimation. The fourth uses the first-class cm.GMM estimator directly. As with the other ablation pages, Quarto caching and freezing are enabled so later site renders reuse the saved outputs.
1 Imports And Helpers
Show code
from html import escapeimport matplotlib.pyplot as pltimport numpy as npfrom IPython.display import HTML, displayimport crabbymetrics as cmnp.set_printoptions(precision=4, suppress=True)def html_table(headers, rows): parts = ["<table>","<thead>","<tr>",*[f"<th>{escape(str(header))}</th>"for header in headers],"</tr>","</thead>","<tbody>", ]for row in rows: parts.append("<tr>")for cell in row: parts.append(f"<td>{cell}</td>") parts.append("</tr>") parts.extend(["</tbody>", "</table>"])return"".join(parts)def hc2_covariance(design, residuals): xtx_inv = np.linalg.inv(design.T @ design) leverage = np.sum(design * (design @ xtx_inv), axis=1) scale = residuals**2/ np.clip(1.0- leverage, 1e-12, None) meat = design.T @ (design * scale[:, None])return xtx_inv @ meat @ xtx_invdef bridging_gmm_moments(theta, data): x = data["x"] z = data["z"] y = data["y"] p = x.shape[1] mu = theta[:p] alpha = theta[p] tau = theta[p +1] beta = theta[p +2 : p +2+ p] gamma = theta[p +2+ p :] w = x - mu resid = y - alpha - tau * z - w @ beta - (w * z[:, None]) @ gamma l = np.column_stack([np.ones(x.shape[0]), z, w, w * z[:, None]])return np.column_stack( [ x - mu, l * resid[:, None], ] )def fit_tau_from_raw_rows(z, y, x): mu = x.mean(axis=0) w = x - mu interacted_design = np.column_stack([z, w, z[:, None] * w]) model = cm.OLS() model.fit(interacted_design, y)returnfloat(np.asarray(model.summary(vcov="hc1")["coef"])[0])def raw_row_bootstrap_se(z, y, x, n_bootstrap, seed): rng = np.random.default_rng(seed) n = z.shape[0] tau_draws = np.empty(n_bootstrap)for draw inrange(n_bootstrap): idx = rng.integers(0, n, size=n) tau_draws[draw] = fit_tau_from_raw_rows(z[idx], y[idx], x[idx])returnfloat(tau_draws.std(ddof=1))def fit_bridging_methods(z, y, x, n_bootstrap=300, bootstrap_seed=0): p = x.shape[1] mu = x.mean(axis=0) w = x - mu interacted_design = np.column_stack([z, w, z[:, None] * w]) ols = cm.OLS() ols.fit(interacted_design, y) ols_summary = ols.summary(vcov="hc1") coef = np.asarray(ols_summary["coef"]) params = np.concatenate([[ols_summary["intercept"]], coef]) full_design = np.column_stack([np.ones(z.shape[0]), interacted_design]) residuals = y - full_design @ params hc2_vcov = hc2_covariance(full_design, residuals) tau_hat =float(coef[0]) se_hc2 =float(np.sqrt(hc2_vcov[1, 1])) gamma_hat = coef[-p:] superpop_correction =float(gamma_hat @ np.cov(w.T) @ gamma_hat / z.shape[0]) se_closed_form =float(np.sqrt(hc2_vcov[1, 1] + superpop_correction)) se_bootstrap = raw_row_bootstrap_se(z, y, x, n_bootstrap=n_bootstrap, seed=bootstrap_seed) theta0 = np.concatenate([mu, [params[0], tau_hat], coef[1:]]) gmm = cm.GMM( bridging_gmm_moments, max_iterations=200, tolerance=1e-8, fd_eps=1e-5, ) gmm.fit({"x": x, "z": z, "y": y}, theta0, weighting="identity") gmm_summary = gmm.summary(vcov="sandwich", omega="iid") tau_index = p +1 tau_gmm =float(np.asarray(gmm_summary["coef"])[tau_index]) se_gmm =float(np.asarray(gmm_summary["se"])[tau_index])return {"tau_ols": tau_hat,"se_hc2": se_hc2,"se_closed_form": se_closed_form,"se_bootstrap": se_bootstrap,"tau_gmm": tau_gmm,"se_gmm": se_gmm, }def simulate_bridging(reps, n, seed, n_bootstrap): tau_hc2 = [] tau_closed = [] tau_bootstrap = [] tau_gmm = [] se_hc2 = [] se_closed = [] se_bootstrap = [] se_gmm = [] sate_targets = []for rep inrange(reps): rng = np.random.default_rng(seed + rep) x = rng.normal(size=(n, 2)) y0 = x[:, 0] + x[:, 0] **2+ rng.uniform(-0.5, 0.5, n) y1 = x[:, 1] + x[:, 1] **2+ rng.uniform(-1.0, 1.0, n) z = rng.binomial(1, 0.6, n).astype(float) y = y0 * (1.0- z) + y1 * z fit = fit_bridging_methods(z, y, x, n_bootstrap=n_bootstrap, bootstrap_seed=rep) tau_hc2.append(fit["tau_ols"]) tau_closed.append(fit["tau_ols"]) tau_bootstrap.append(fit["tau_ols"]) tau_gmm.append(fit["tau_gmm"]) se_hc2.append(fit["se_hc2"]) se_closed.append(fit["se_closed_form"]) se_bootstrap.append(fit["se_bootstrap"]) se_gmm.append(fit["se_gmm"]) sate_targets.append(float(np.mean(y1 - y0)))def summarize(estimates, ses): estimates = np.asarray(estimates) ses = np.asarray(ses) sate_targets_arr = np.asarray(sate_targets) sate_errors = estimates - sate_targets_arr pate_errors = estimatesreturn {"mean_estimate": float(estimates.mean()),"sate_error_sd": float(sate_errors.std(ddof=1)),"pate_error_sd": float(pate_errors.std(ddof=1)),"mean_se": float(ses.mean()),"coverage_sate": float((np.abs(sate_errors) <=1.96* ses).mean()),"coverage_pate": float((np.abs(pate_errors) <=1.96* ses).mean()), }return [ {"method": "HC2 finite-population", **summarize(tau_hc2, se_hc2)}, {"method": "Closed-form superpopulation", **summarize(tau_closed, se_closed)}, {"method": "Bootstrap superpopulation", **summarize(tau_bootstrap, se_bootstrap)}, {"method": "Stacked GMM superpopulation", **summarize(tau_gmm, se_gmm)}, ]
2 Design
The data generating process follows the Chapter 9 notebook:
In this design, \(\tau_{\mathrm{PATE}} = 0\) because both potential outcomes have expectation one, but \(\tau_{\mathrm{SATE}}\) varies from sample to sample.
The stacked GMM system treats the centering as a generated regressor and estimates it jointly with the regression coefficients. The parameter vector is
So the first block estimates the sample means, and the second block is the vectorized least-squares orthogonality condition. This makes the superpopulation correction automatic at summary time: the sandwich covariance for \(\tau\) already accounts for the nuisance estimation step.
Show code
n =500reps =2000n_bootstrap =300truth_pate =0.0display( HTML( html_table( ["Observations per replication", "Replications", "Bootstrap draws", "PATE"], [[n, reps, n_bootstrap, truth_pate]], ) ))
Representative-sample SATE: -0.0116
Population ATE: 0.0
Absolute gap between OLS and stacked-GMM tau: 0.0
The point estimates line up because the GMM stack contains the same normal equations as the interacted regression together with the nuisance moments for the sample centering step. The interval columns differ because HC2 is calibrated to the realized sample, while the other three procedures try to absorb the extra uncertainty from moving to a population target.
methods = [entry["method"] for entry in results]mean_ses = [entry["mean_se"] for entry in results]sate_error_sds = [entry["sate_error_sd"] for entry in results]pate_error_sds = [entry["pate_error_sd"] for entry in results]coverages_sate = [entry["coverage_sate"] for entry in results]coverages_pate = [entry["coverage_pate"] for entry in results]xpos = np.arange(len(methods))fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))axes[0].bar(xpos -0.24, sate_error_sds, width=0.24, label="SD vs SATE")axes[0].bar(xpos, pate_error_sds, width=0.24, label="SD vs PATE")axes[0].bar(xpos +0.24, mean_ses, width=0.24, label="Mean reported SE")axes[0].set_xticks(xpos)axes[0].set_xticklabels(methods, rotation=15, ha="right")axes[0].set_title("Target-specific error scale")axes[0].legend()axes[1].bar(xpos -0.16, coverages_sate, width=0.32, label="SATE")axes[1].bar(xpos +0.16, coverages_pate, width=0.32, label="PATE")axes[1].axhline(0.95, color="black", linestyle="--", linewidth=1.0)axes[1].set_ylim(0.85, 1.0)axes[1].set_xticks(xpos)axes[1].set_xticklabels(methods, rotation=15, ha="right")axes[1].set_title("95% CI coverage")axes[1].set_ylabel("Coverage")axes[1].legend()plt.tight_layout()plt.show()
The ablation now makes the estimand split explicit:
HC2 is the right scale for SATE coverage but is too narrow for PATE because it conditions on the realized covariates.
The closed-form superpopulation correction, the raw-row bootstrap, and the stacked GMM sandwich all move the interval width toward the PATE error scale.
Those same superpopulation procedures tend to over-cover SATE because they deliberately add uncertainty from sampling the covariates and potential outcomes.
The stacked GMM route is attractive because it gets to the superpopulation target without a bespoke variance patch: the nuisance parameters for centering are estimated inside the moment system itself.
The small remaining gap between the closed-form and GMM columns is expected in this finite sample: Ding’s formula starts from an HC2 leverage adjustment, while cm.GMM.summary(vcov="sandwich", omega="iid") is the standard large-sample sandwich variance for the exactly identified stack. The bootstrap column also differs slightly because it is simulation-based rather than analytic.