{ "cells": [ { "cell_type": "markdown", "id": "a5f3d36b", "metadata": {}, "source": [ "# Spillover-aware DiD with `SpilloverDiD` \u2014 a TVA-style worked example\n", "\n", "**Use this notebook when:** your treatment plausibly affects nearby\n", "*untreated* units through geographic externalities, and your standard\n", "DiD specification treats those neighbors as part of the control group.\n", "\n", "Place-based interventions (industrial policy, public-works investment,\n", "vaccination campaigns, retail rollouts, technology subsidies) routinely\n", "fit this pattern. The canonical reference is Kline & Moretti (2014)'s\n", "study of the Tennessee Valley Authority (TVA): treated counties received\n", "federal investment, but adjacent untreated counties absorbed positive\n", "spillovers via labor flows, supply chains, and population movement.\n", "Naive DiD that lumps adjacent counties into the control group then\n", "*underestimates* the direct effect on treated counties, because the\n", "controls are contaminated by spillover.\n", "\n", "Butts (2021) formalizes this with a single-stage regression that\n", "explicitly models per-distance-band spillover effects on near-controls\n", "while keeping far-controls as the clean comparison group:\n", "\n", "$$ y_{it} = \\mu_i + \\lambda_t + \\tau\\,D_{it} + \\sum_j \\delta_j\\,(1 - D_{it})\\,\\text{Ring}_{it,j} + \\varepsilon_{it} $$\n", "\n", "The diff-diff library's `SpilloverDiD` estimator (Butts 2021 + Gardner\n", "2022 two-stage) implements this on panels with one or more spatial\n", "rings, recovers the direct effect $\\tau$ and the spillover coefficients\n", "$\\delta_j$, and supports Conley (1999) spatial-HAC standard errors out\n", "of the box.\n", "\n", "This tutorial walks through the practitioner workflow:\n", "\n", "1. Build a synthetic TVA-style panel with known $\\tau$ and $\\delta_1$.\n", "2. Fit a naive multi-period TWFE and observe the bias.\n", "3. Choose a spillover bandwidth via theory + a sensitivity grid.\n", "4. Fit `SpilloverDiD`; verify recovery of $\\tau$ and $\\delta_1$.\n", "5. Add Conley spatial-HAC variance for robust inference.\n", "\n", "**References:** Butts (2021) \u2014 *arXiv:2105.03737*; Kline & Moretti\n", "(2014) \u2014 *QJE* 129(1); Conley (1999) \u2014 *Journal of Econometrics* 92(1)." ] }, { "cell_type": "markdown", "id": "a16e76a4", "metadata": {}, "source": [ "## 2. The synthetic panel\n", "\n", "We build a 4-period, 200-unit panel laid out as three geographic bands:\n", "\n", "| Band | Count | Location | Treatment |\n", "|--------------|-------|-----------------------------------------------------|-----------|\n", "| Treated | 25 | clustered around (0,0); max ~12 km from origin, cluster diameter ~22 km at seed 23 | Yes, from period 3 |\n", "| Near-control | 120 | ~12-82 km north (0.1\u00b0-0.7\u00b0 latitude) | No (but absorbs spillover) |\n", "| Far-control | 55 | ~224-331 km north (2.0\u00b0-3.0\u00b0 latitude) | No (clean control) |\n", "\n", "True parameters: $\\tau_{\\text{total}} = -7.4$ (the direct effect on\n", "treated, matching the Butts \u00a74 agriculture-employment magnitude),\n", "$\\delta_1 = -4.5$ (the per-period spillover on near-controls), and a\n", "locked random seed of `23`. The data-generating equation is\n", "\n", "$$ y_{it} = \\alpha_i + \\lambda_t + \\tau_{\\text{total}}\\,D_{it} + \\delta_1\\,(1 - D_{it})\\,\\text{Ring}_{it,1} + \\mathcal{N}(0, 0.5) $$\n", "\n", "We tune the band sizes and $\\delta_1$ so the naive TWFE coefficient\n", "lands at ~58% of $\\tau_{\\text{total}}$ \u2014 i.e., the ~40% understatement\n", "direction documented in Butts (2021) \u00a74 Table 1 Panel A." ] }, { "cell_type": "code", "execution_count": null, "id": "1712f2bb", "metadata": {}, "outputs": [], "source": [ "import warnings\n", "\n", "import numpy as np\n", "import pandas as pd\n", "\n", "from diff_diff import MultiPeriodDiD, SpilloverDiD\n", "\n", "MAIN_SEED = 23\n", "N_TREATED = 25\n", "N_NEAR = 120\n", "N_FAR = 55\n", "T_PERIODS = 4\n", "FIRST_TREAT = 3\n", "TAU_TOTAL = -7.4\n", "DELTA_1 = -4.5\n", "D_BAR_KM = 100.0\n", "NOISE_SD = 0.5\n", "\n", "\n", "def build_t23_panel(seed: int = MAIN_SEED) -> pd.DataFrame:\n", " \"\"\"4-period TVA-style synthetic panel with known direct + spillover.\n", "\n", " Layout: treated cluster + near-control band (within D_BAR_KM) + far-control\n", " band (clean). Outcomes: y_it = alpha_i + lambda_t + TAU_TOTAL * D_it +\n", " DELTA_1 * Ring1_it * (1 - D_it) + N(0, NOISE_SD).\n", " \"\"\"\n", " rng = np.random.default_rng(seed)\n", " n_units = N_TREATED + N_NEAR + N_FAR\n", " units = [f\"u{i:04d}\" for i in range(n_units)]\n", " alpha = rng.normal(0.0, 1.0, size=n_units)\n", " lambda_t = np.array([0.0, 0.5, 1.0, 1.5])[:T_PERIODS]\n", "\n", " coords = np.empty((n_units, 2))\n", " is_treated_unit = np.zeros(n_units, dtype=bool)\n", " is_near_unit = np.zeros(n_units, dtype=bool)\n", " for i in range(N_TREATED):\n", " coords[i] = (rng.normal(0, 0.05), rng.normal(0, 0.05))\n", " is_treated_unit[i] = True\n", " for i in range(N_TREATED, N_TREATED + N_NEAR):\n", " coords[i] = (rng.uniform(0.1, 0.7), rng.uniform(-0.3, 0.3))\n", " is_near_unit[i] = True\n", " for i in range(N_TREATED + N_NEAR, n_units):\n", " coords[i] = (rng.uniform(2.0, 3.0), rng.uniform(-0.5, 0.5))\n", "\n", " rows = []\n", " for i, u in enumerate(units):\n", " for t in range(1, T_PERIODS + 1):\n", " D_it = int(is_treated_unit[i] and t >= FIRST_TREAT)\n", " Ring1_it = int(is_near_unit[i] and t >= FIRST_TREAT)\n", " y = (\n", " alpha[i]\n", " + lambda_t[t - 1]\n", " + TAU_TOTAL * D_it\n", " + DELTA_1 * Ring1_it * (1 - D_it)\n", " + rng.normal(0, NOISE_SD)\n", " )\n", " rows.append(\n", " {\n", " \"unit\": u,\n", " \"time\": t,\n", " \"lat\": coords[i, 0],\n", " \"lon\": coords[i, 1],\n", " \"ever_treated\": int(is_treated_unit[i]),\n", " \"D\": D_it,\n", " \"y\": y,\n", " }\n", " )\n", " return pd.DataFrame(rows)\n", "\n", "\n", "df = build_t23_panel()\n", "print(f\"Panel: {len(df)} rows, {df['unit'].nunique()} units, T={df['time'].nunique()}\")\n", "df.head()\n" ] }, { "cell_type": "code", "execution_count": null, "id": "21aa12c0", "metadata": {}, "outputs": [], "source": [ "# Quick geographic check: treated cluster, near-control band, far-control band\n", "band_counts = (\n", " df.drop_duplicates(\"unit\")\n", " .assign(\n", " band=lambda d: np.select(\n", " [d[\"ever_treated\"] == 1, d[\"lat\"] <= 1.0],\n", " [\"treated\", \"near\"],\n", " default=\"far\",\n", " )\n", " )\n", " .groupby(\"band\")\n", " .size()\n", " .reindex([\"treated\", \"near\", \"far\"])\n", ")\n", "print(band_counts)\n" ] }, { "cell_type": "markdown", "id": "8be4e7c9", "metadata": {}, "source": [ "## 3. The naive headline \u2014 multi-period TWFE on the full sample\n", "\n", "A practitioner reaching for a default DiD specification fits a\n", "multi-period TWFE on the full panel, treating ALL non-treated-cluster\n", "units as controls \u2014 both the near-controls (which absorb spillover)\n", "and the far-controls (which don't).\n" ] }, { "cell_type": "code", "execution_count": null, "id": "0c563f94", "metadata": {}, "outputs": [], "source": [ "naive = MultiPeriodDiD()\n", "with warnings.catch_warnings():\n", " # absorb=['unit'] makes the unit-invariant 'ever_treated' indicator\n", " # perfectly collinear with unit FE; MultiPeriodDiD drops it (with a\n", " # UserWarning) and identifies the ATT through the ever_treated \u00d7 post\n", " # interaction columns. This is the expected TWFE specification.\n", " warnings.filterwarnings(\"ignore\", category=UserWarning, message=\"Rank-deficient design matrix\")\n", " naive_res = naive.fit(\n", " df,\n", " outcome=\"y\",\n", " treatment=\"ever_treated\",\n", " time=\"time\",\n", " post_periods=[3, 4],\n", " unit=\"unit\",\n", " absorb=[\"unit\"],\n", " reference_period=2, # explicit pre-period; matches the current MPD default\n", " )\n", "\n", "print(f\"Naive TWFE ATT: {naive_res.att:.4f} (true tau_total = {TAU_TOTAL})\")\n", "print(f\" SE: {naive_res.se:.4f}\")\n", "print(f\" Ratio to true: {naive_res.att / TAU_TOTAL:.3f}\")\n", "print(f\" Understatement: {(1 - naive_res.att / TAU_TOTAL) * 100:.1f}%\")\n" ] }, { "cell_type": "markdown", "id": "7253fe4c", "metadata": {}, "source": [ "The naive estimate is roughly **-4.29**, about 58% of the true\n", "$\\tau_{\\text{total}} = -7.4$ \u2014 a **~42% understatement** that matches\n", "the Butts (2021) \u00a74 agriculture direction. The mechanism is\n", "straightforward: with $D_{i,t} = 1$ only for the treated cluster, the\n", "near-control band contributes negative outcomes in the post-period (via\n", "$\\delta_1 = -4.5$) into the *control* arm. The TWFE within-comparison\n", "then sees a smaller pre/post gap on the treatment side relative to the\n", "contaminated control side, and reports a deflated direct effect.\n", "\n", "This is exactly the bias `SpilloverDiD` is designed to remove." ] }, { "cell_type": "markdown", "id": "cb77d87a", "metadata": {}, "source": [ "## 4. Choosing the spillover bandwidth\n", "\n", "`SpilloverDiD` is parameterized by `rings=[r_0, r_1, ..., r_K]` where\n", "the $j$-th ring covers distance band $[r_{j-1}, r_j]$ km. The\n", "outermost ring edge `max(rings)` defines the spillover horizon\n", "(`d_bar`); units outside that horizon are treated as the clean control\n", "group. For a single near-control band, the simplest spec is\n", "`rings=[0.0, d_bar]` \u2014 one ring, one $\\delta_1$ coefficient.\n", "\n", "Choosing `d_bar` is a design decision driven by (i) theory about the\n", "plausible spillover range and (ii) a sensitivity check that you have\n", "not under- or over-stated it. Below we vary the outer edge across 50,\n", "100, 150, 200 km and report the recovered $\\tau$ and $\\delta_1$ at\n", "each.\n", "\n", "> **API note:** `SpilloverDiD` accepts an optional explicit `d_bar`\n", "> kwarg, but if set it must equal `max(rings)`. We omit it and let the\n", "> default (`d_bar = max(rings)`) apply." ] }, { "cell_type": "code", "execution_count": null, "id": "4804334d", "metadata": {}, "outputs": [], "source": [ "sensitivity = []\n", "for outer in (50.0, 100.0, 150.0, 200.0):\n", " est = SpilloverDiD(rings=[0.0, outer], conley_coords=(\"lat\", \"lon\"))\n", " with warnings.catch_warnings():\n", " # Narrow filter for the Apple Silicon M4 + numpy<2.3 Accelerate\n", " # BLAS RuntimeWarnings (\"divide by zero\" / \"overflow\" / \"invalid\"\n", " # in matmul) documented at TODO.md \"RuntimeWarnings in Linear\n", " # Algebra Operations\". These do NOT fire on M3 / Intel / Linux\n", " # or numpy>=2.3, so this filter is a no-op there. Values are\n", " # recovered correctly on every platform.\n", " warnings.filterwarnings(\n", " \"ignore\", category=RuntimeWarning, message=\".*encountered in matmul\"\n", " )\n", " res = est.fit(df, outcome=\"y\", unit=\"unit\", time=\"time\", treatment=\"D\")\n", " delta1 = float(res.spillover_effects.iloc[0][\"coef\"])\n", " sensitivity.append({\"d_bar_km\": outer, \"tau_total\": res.att, \"delta_1\": delta1})\n", "\n", "sens_df = pd.DataFrame(sensitivity)\n", "print(sens_df.to_string(index=False, float_format=lambda x: f\"{x:>8.4f}\"))\n" ] }, { "cell_type": "markdown", "id": "787428d1", "metadata": {}, "source": [ "At `d_bar = 50` km the ring is too narrow: near-controls in the\n", "50-78 km band ARE exposed in the DGP (they're within the true 100 km\n", "spillover horizon and carry $\\delta_1 = -4.5$), but the ring spec\n", "misclassifies them as far-away clean controls. Both estimates suffer\n", "from the misspecification \u2014 $\\tau$ deflates to ~-5.4 because the\n", "\"clean control\" arm now contains genuinely-affected units, and the\n", "spillover coefficient $\\delta_1$ attenuates to ~-2.6 because the\n", "$S = 1$ ring averages 50-78 km exposure into the cleaner\n", "$S = 0$ comparison. This is the registry-documented failure mode for\n", "undershooting `d_bar`. (The exact values are quoted to one decimal\n", "here rather than two because the `d_bar = 50` design is borderline-\n", "rank-deficient \u2014 the two-decimal value can shift slightly across BLAS\n", "paths even at the same locked seed.)\n", "\n", "At `d_bar = 100` km the ring covers the entire DGP near-control band\n", "(0.1\u00b0-0.7\u00b0 latitude \u2248 11-78 km). $\\tau$ recovers to -7.34 and\n", "$\\delta_1$ to -4.53 \u2014 both within 1% of the truth.\n", "\n", "At `d_bar = 150` km and `d_bar = 200` km, the estimates are *identical*\n", "to `d_bar = 100`. This is by DGP design: there are no units in the\n", "80-200 km band, so widening the ring adds zero observations and\n", "zero new information. Once `d_bar` covers the true spillover horizon,\n", "further widening is benign on this panel.\n", "\n", "**Verdict:** `d_bar = 100` km is the right choice here, balancing\n", "\"capture all spillover\" against \"don't sweep clean controls into the\n", "spillover bin\". On real data, a similar sensitivity grid plus paper /\n", "theory guidance is the practitioner workflow Butts (2021) \u00a73.1\n", "recommends." ] }, { "cell_type": "markdown", "id": "ce1cb17e", "metadata": {}, "source": [ "## 5. Fit `SpilloverDiD` and interpret\n", "\n", "With `d_bar = 100`, the headline fit recovers both the direct effect\n", "and the spillover. We compare side-by-side with the naive TWFE from \u00a73.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "4558bcfe", "metadata": {}, "outputs": [], "source": [ "est = SpilloverDiD(rings=[0.0, D_BAR_KM], conley_coords=(\"lat\", \"lon\"))\n", "with warnings.catch_warnings():\n", " # See \u00a74 for the rationale on this narrow matmul-RuntimeWarning filter.\n", " warnings.filterwarnings(\n", " \"ignore\", category=RuntimeWarning, message=\".*encountered in matmul\"\n", " )\n", " spill = est.fit(df, outcome=\"y\", unit=\"unit\", time=\"time\", treatment=\"D\")\n", "\n", "delta_1_row = spill.spillover_effects.iloc[0]\n", "\n", "comparison = pd.DataFrame(\n", " {\n", " \"estimate\": [naive_res.att, spill.att, delta_1_row[\"coef\"]],\n", " \"se\": [naive_res.se, spill.se, delta_1_row[\"se\"]],\n", " \"true value\": [TAU_TOTAL, TAU_TOTAL, DELTA_1],\n", " },\n", " index=[\"naive TWFE (direct, biased)\", \"SpilloverDiD tau_total\", \"SpilloverDiD delta_1\"],\n", ")\n", "print(comparison.to_string(float_format=lambda x: f\"{x:>8.4f}\"))\n" ] }, { "cell_type": "markdown", "id": "e28652ea", "metadata": {}, "source": [ "With the spillover term in the regression, `SpilloverDiD` cleanly\n", "separates the direct effect (-7.34, very close to true -7.4) from the\n", "spillover on near-controls (-4.53, very close to true -4.5). The\n", "spillover-aware estimator removes the bias the naive TWFE suffered.\n", "\n", "The interpretation is straightforward: every period in the post window,\n", "treated units lose 7.4 units of $y$ on average from the policy, AND\n", "every near-control unit (within 100 km of any treated unit) loses 4.5\n", "units of $y$ from the spillover. A standard DiD that ignored the\n", "spillover would report only the contaminated -4.3 and miss the\n", "spillover-on-controls entirely." ] }, { "cell_type": "markdown", "id": "2e005043", "metadata": {}, "source": [ "## 6. Robust inference with Conley spatial-HAC\n", "\n", "The HC1 standard error in \u00a75 treats each observation's score as\n", "independent of every other. On spatially-correlated panels \u2014 and this\n", "is the canonical case for place-based interventions \u2014 that\n", "independence assumption is violated. Conley (1999) provides a\n", "spatial-HAC variance estimator that downweights pairs by their\n", "geographic distance through a Bartlett kernel out to a `conley_cutoff_km`\n", "bandwidth. Newey-West (1987) provides the analogous serial term via\n", "`conley_lag_cutoff`.\n", "\n", "Per Butts (2021) \u00a73.1, the Conley bandwidth is typically chosen to\n", "match the spillover horizon `d_bar`. Below we compare the \u00a75 HC1 SE\n", "against Conley with `conley_cutoff_km = d_bar = 100`, both with\n", "`conley_lag_cutoff = 0` (spatial only) and `conley_lag_cutoff = 1` (add\n", "within-unit serial correlation across consecutive periods).\n" ] }, { "cell_type": "code", "execution_count": null, "id": "25d20f8c", "metadata": {}, "outputs": [], "source": [ "def fit_with(vcov_type, lag=None):\n", " kwargs = dict(rings=[0.0, D_BAR_KM], conley_coords=(\"lat\", \"lon\"), vcov_type=vcov_type)\n", " if vcov_type == \"conley\":\n", " kwargs[\"conley_cutoff_km\"] = D_BAR_KM\n", " kwargs[\"conley_lag_cutoff\"] = lag\n", " est = SpilloverDiD(**kwargs)\n", " with warnings.catch_warnings():\n", " # See \u00a74 for the rationale on this narrow matmul-RuntimeWarning filter.\n", " warnings.filterwarnings(\n", " \"ignore\", category=RuntimeWarning, message=\".*encountered in matmul\"\n", " )\n", " return est.fit(df, outcome=\"y\", unit=\"unit\", time=\"time\", treatment=\"D\")\n", "\n", "\n", "res_hc1 = fit_with(\"hc1\")\n", "res_c0 = fit_with(\"conley\", lag=0)\n", "res_c1 = fit_with(\"conley\", lag=1)\n", "\n", "se_table = pd.DataFrame(\n", " {\n", " \"tau_total\": [res_hc1.att, res_c0.att, res_c1.att],\n", " \"SE\": [res_hc1.se, res_c0.se, res_c1.se],\n", " },\n", " index=[\"HC1\", \"Conley (lag=0, spatial only)\", \"Conley (lag=1, spatial + serial)\"],\n", ")\n", "print(se_table.to_string(float_format=lambda x: f\"{x:>8.4f}\"))\n" ] }, { "cell_type": "markdown", "id": "e6d5ee1b", "metadata": {}, "source": [ "Point estimates are identical across all three rows \u2014 the variance\n", "choice doesn't move the coefficients. But the standard errors differ:\n", "on this DGP, the Conley spatial-HAC SE comes in *lower* than HC1.\n", "\n", "The mechanism is the pairwise covariance structure of the\n", "influence-function score matrix. With `conley_cutoff_km = 100`, the\n", "spatial term in the no-survey Conley meat sums Bartlett-weighted\n", "score cross-products over every within-period observation pair whose\n", "units lie within 100 km **of each other** (support is pairwise, not\n", "relative to the treated cluster \u2014 no survey-design PSU aggregation\n", "here, the no-survey path operates on the observation-level Wave D\n", "Gardner GMM-corrected influence rows $\\psi_i$). On this DGP that\n", "means non-zero contribution from:\n", "\n", "- treated \u00d7 treated pairs (cluster diameter ~22 km at seed 23),\n", "- treated \u00d7 near-control pairs (max pairwise separation ~90 km),\n", "- near \u00d7 near pairs (100% of within-band pairs are within 100 km), and\n", "- far \u00d7 far pairs **within** the far-control band (max within-band\n", " pairwise distance is ~131 km, but ~95% of within-band pair distances\n", " are within 100 km of each other \u2014 median far/far pairwise distance\n", " is ~56 km).\n", "\n", "Cross-band pairs at long distance \u2014 treated \u00d7 far and near \u00d7 far \u2014\n", "sit outside the 100 km support and contribute zero kernel weight.\n", "The Conley meat is the sum of the observation-level diagonal\n", "self-product terms $\\psi_i \\psi_i'$ PLUS the Bartlett-weighted\n", "off-diagonal score cross-products $K(d_{ij}/h)\\,\\psi_i \\psi_j'$\n", "from all the within-support pair classes enumerated above; the ATT\n", "variance is then the corresponding sandwich entry. (Note: the\n", "no-survey Conley path does NOT apply an HC1 `n/(n-p)` finite-sample\n", "multiplier \u2014 that lives on the HC1 path only. Whether the resulting\n", "sandwich ends up above or below HC1 depends on both the missing\n", "multiplier AND the net sign and magnitude of the weighted\n", "off-diagonal cross-products under the specific DGP and design.) On\n", "this seed they net out to a smaller ATT variance, so Conley < HC1,\n", "but the direction is a feature of the data and the sign mechanism\n", "is not easily attributable to any one pair class. Adding the serial\n", "term (`lag = 1`) further reshapes the SE through a separate\n", "within-unit cross-period score covariance sum.\n", "\n", "The takeaway: Conley vcov is a *correction*. It can move the SE in\n", "either direction relative to HC1 depending on the residual covariance\n", "structure within the kernel's support, but it's the methodologically\n", "correct choice when scores are spatially or serially correlated. On\n", "real applications the correction often inflates the SE (and the\n", "t-stat shrinks), but the direction is a feature of the data, not a\n", "guarantee. See the `ConleySpatialHAC` and `SpilloverDiD` sections of\n", "`docs/methodology/REGISTRY.md` for the full variance formula." ] }, { "cell_type": "markdown", "id": "5cc89608", "metadata": {}, "source": [ "## 7. Practitioner takeaways and where to go next\n", "\n", "**When to reach for `SpilloverDiD`.** Whenever your treatment has a\n", "plausible geographic externality \u2014 labor-market spillovers,\n", "agglomeration effects, disease transmission, supply-chain ripples,\n", "peer effects in retail or technology adoption \u2014 a naive DiD that\n", "treats nearby units as controls will give you a biased estimate of the\n", "direct effect. The magnitude of the bias is roughly\n", "$(\\text{near share}) \\times \\delta_1$ in absolute value (here:\n", "$(120/175) \\times 4.5 \\approx 3.1$, matching the observed\n", "-4.3 vs -7.4 gap).\n", "\n", "**How to choose `d_bar`.** Start from theory: what's the plausible\n", "spatial range of the spillover mechanism? Triangulate with a\n", "sensitivity grid (\u00a74 above). The estimate stabilizes once `d_bar`\n", "covers the true horizon; further widening is benign unless it sweeps\n", "clean controls into the spillover bin.\n", "\n", "**When Conley matters.** Whenever your residuals have spatial or\n", "serial correlation. On clustered-treatment designs (treatment radius\n", "$\\ll$ panel extent) the spatial-HAC adjustment can move the SE in\n", "either direction relative to HC1; the direction is a feature of the\n", "DGP. Per Butts (2021) \u00a73.1, set `conley_cutoff_km = d_bar` as the\n", "default and run a sensitivity grid on the cutoff if the SE matters\n", "for your inference.\n", "\n", "**Extensions not covered here.**\n", "- *Multiple rings* \u2014 pass `rings=[0, 50, 100]` for separate near-vs-mid\n", " spillover coefficients $\\delta_1, \\delta_2$ when theory or\n", " diagnostics suggest distance-graded externalities.\n", "- *Staggered treatment* \u2014 `SpilloverDiD` automatically handles\n", " unit-specific treatment onsets via the `first_treat` column.\n", "- *Event study* \u2014 pass `event_study=True` and `horizon_max` for\n", " per-event-time direct and spillover effects (Wave C; see\n", " `tutorials/12_two_stage_did.ipynb` for the TwoStageDiD event-study\n", " pattern this mirrors).\n", "- *Survey weights* \u2014 `survey_design=SurveyDesign(...)` supports\n", " H\u00e1jek-weighted point estimates with Binder TSL variance. Combined\n", " with `vcov_type=\"conley\"` and `conley_lag_cutoff=0` (cross-\n", " sectional spatial only) this is the Wave E.2 stratified-Conley\n", " sandwich on PSU totals. The panel-block extension\n", " `conley_lag_cutoff > 0` (spatial + within-PSU serial Bartlett HAC)\n", " is the Wave E.2 follow-up library synthesis, and it requires an\n", " effective PSU on the survey design (either explicit\n", " `survey_design.psu=` or `cluster=