Skip to content

Commit 1dc6a59

Browse files
authored
Merge pull request #462 from igerber/spillover-conley-wave-d-gmm-correction
2 parents 6a3e50b + 4bec418 commit 1dc6a59

9 files changed

Lines changed: 1186 additions & 142 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Large diffs are not rendered by default.

TODO.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ Deferred items from PR reviews that were not addressed before merge.
127127
| SyntheticDiD: bootstrap cross-language parity anchor against R's default `synthdid::vcov(method="bootstrap")` (refit; rebinds `opts` per draw) or Julia `Synthdid.jl::src/vcov.jl::bootstrap_se` (refit by construction). Same-library validation (placebo-SE tracking, AER §6.3 MC truth) is in place; a cross-language anchor is desirable to bolster the methodology contract. Julia is the cleanest target — minimal wrapping work and refit-native vcov. Tolerance target: 1e-6 on Monte Carlo samples (different BLAS + RNG paths preclude 1e-10). The R-parity fixture from the previous release was deleted because it pinned the now-removed fixed-weight path. | `benchmarks/R/`, `benchmarks/julia/`, `tests/` | follow-up | Low |
128128
| Conley + survey weights / `survey_design`. Score-reweighted meat `s_i = w_i · X_i · ε_i` is mechanical, but PSU clustering interaction with the spatial kernel and replicate-weights variance under spatial correlation are non-trivial (Bertanha-Imbens 2014 covers cluster-sample but not the explicit Conley case). Phase 5 of the spillover-conley initiative; paper review prerequisite. Currently raises `NotImplementedError` at the linalg validator. | `linalg.py::_validate_vcov_args` | Phase 5 (spillover-conley) | Medium |
129129
| `SyntheticDiD(vcov_type="conley")` support. Currently raises `TypeError` at `__init__` because SyntheticDiD uses `variance_method ∈ {bootstrap, jackknife, placebo}` rather than the analytical sandwich that Conley plugs into. Wiring would require either reimplementing an analytical sandwich path for SyntheticDiD or designing a spatial-block bootstrap (new methodology, Politis-Romano 1994 territory). | `synthetic_did.py::SyntheticDiD` | follow-up (spillover-conley) | Low |
130-
| `SpilloverDiD` Gardner GMM first-stage uncertainty correction at stage 2. Wave B MVP uses standard `solve_ols` variance (HC1 / Conley / cluster) without the influence-function adjustment for stage-1 FE estimation. Extending `two_stage.py::_compute_gmm_variance` to accept a Conley kernel matrix in place of HC1's identity at the IF outer-product step gives the full Butts (2021) Section 3.1 + Gardner (2022) Section 4 composition. See plan Risks #2 for the IF formula. | `spillover.py::SpilloverDiD.fit`, `two_stage.py::_compute_gmm_variance` | follow-up (Wave B) | Medium |
131130
| `SpilloverDiD(survey_design=...)` integration. Currently raises `NotImplementedError`. Requires threading survey weights through the inline stage 1 + stage 2 and lifting `two_stage.py`'s survey path patterns. | `spillover.py::SpilloverDiD.fit` | follow-up (Wave B) | Low |
132131
| `SpilloverDiD(ring_method="count")` extension. Currently only the nearest-treated-ring specification is exposed. Count-of-treated-in-ring (paper Section 3.2 end) is methodologically supported by Butts but re-introduces functional-form dependence; expose with an explicit kwarg gate and documentation warning. | `spillover.py::SpilloverDiD.fit` | follow-up | Low |
133132
| `SpilloverDiD` data-driven `d_bar` selection (Butts 2021b / Butts 2023 JUE Insight cross-validation). | `spillover.py::SpilloverDiD` | follow-up | Low |

diff_diff/conley.py

Lines changed: 92 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -765,36 +765,40 @@ def _validate_conley_kwargs(
765765
)
766766

767767

768-
def _compute_conley_vcov(
769-
X: np.ndarray,
770-
residuals: np.ndarray,
768+
def _compute_conley_meat(
769+
scores: np.ndarray,
771770
coords: np.ndarray,
772771
cutoff: float,
773772
metric: ConleyMetric,
774773
kernel: str,
775-
bread_matrix: np.ndarray,
776774
*,
777775
time: Optional[np.ndarray] = None,
778776
unit: Optional[np.ndarray] = None,
779777
lag_cutoff: Optional[int] = None,
780778
cluster_ids: Optional[np.ndarray] = None,
781779
_conley_sparse: Optional[bool] = None,
782780
) -> np.ndarray:
783-
"""Conley (1999) spatial HAC sandwich variance.
781+
"""Conley (1999) spatial HAC meat — ``scores' K scores`` for the product kernel.
782+
783+
Factors the kernel-construction-and-application step out of
784+
:func:`_compute_conley_vcov` so a second caller (SpilloverDiD's Wave D
785+
Gardner GMM first-stage correction) can reuse the cross-sectional /
786+
panel-block / sparse k-d-tree / cluster-product code path with an
787+
arbitrary score matrix substituted for the canonical ``X * residuals``.
784788
785789
Two operating modes:
786790
787791
**Cross-sectional** (``time`` / ``unit`` / ``lag_cutoff`` all None):
788792
789-
Var̂(β) = bread_inv · (Σ_{i,j} K(d_ij/h) · X_i ε_i ε_j X_j') · bread_inv
793+
meat = Σ_{i,j} K(d_ij/h) · scores_i scores_j'
790794
791-
Implemented via ``meat = S' K S`` where ``S = X * residuals[:, None]``.
795+
Implemented via ``meat = scores' K scores``.
792796
793797
**Panel block-decomposed** (all three keyword-only args set):
794798
795-
XeeX_spatial = Σ_t S_t' · K_space_t · S_t (within-period sum)
796-
XeeX_serial = Σ_u S_u' · K_time_u · S_u if lag_cutoff > 0 (within-unit sum)
797-
Var̂(β) = bread_inv · (XeeX_spatial + XeeX_serial) · bread_inv
799+
meat_spatial = Σ_t scores_t' · K_space_t · scores_t (within-period sum)
800+
meat_serial = Σ_u scores_u' · K_time_u · scores_u if lag_cutoff > 0
801+
meat = meat_spatial + meat_serial
798802
799803
The serial Bartlett kernel ``K_time_u[i, j] = 1{|t_i-t_j| <= L, i != j} ·
800804
(1 - |t_i-t_j|/(L+1))`` is hardcoded regardless of the user-supplied
@@ -815,23 +819,17 @@ def _compute_conley_vcov(
815819
Inputs are assumed already validated by :func:`_validate_conley_kwargs`;
816820
the helper only does the math. Caller is responsible for the validator.
817821
822+
Emits a ``UserWarning`` if the smallest meat eigenvalue is materially
823+
negative (< -1e-12) — radial 1-D Bartlett and uniform kernels are
824+
practitioner specializations of Conley 1999 and are not formally
825+
PSD-guaranteed.
826+
818827
Returns
819828
-------
820-
vcov : ndarray of shape (k, k)
821-
822-
Notes
823-
-----
824-
Neither the uniform kernel (negative spectral regions, Conley 1999
825-
footnote 11) nor the **radial 1-D Bartlett** specialization implemented
826-
here is PSD-guaranteed. Conley's explicit PSD formula (Eq 3.14) is the
827-
2-D separable product window on a lattice; the radial pairwise form is
828-
a practitioner specialization (R ``conleyreg``, Stata ``acreg``, Hsiang
829-
2010) that is not formally PSD. We emit a ``UserWarning`` if the smallest
830-
meat eigenvalue is materially negative (< -1e-12) regardless of kernel.
829+
meat : ndarray of shape (p, p)
831830
"""
832831
coords_arr = np.asarray(coords, dtype=np.float64)
833-
S = X * residuals[:, np.newaxis]
834-
n = X.shape[0]
832+
n = scores.shape[0]
835833

836834
# Factorize cluster_ids once if supplied, so per-slice mask construction
837835
# below can use integer comparisons rather than re-factorizing per call.
@@ -877,7 +875,7 @@ def _kernel_fn(u: np.ndarray) -> np.ndarray:
877875
)
878876

879877
def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
880-
"""Compute the spatial meat ``S' K S`` for the given subset of rows.
878+
"""Compute the spatial meat ``scores' K scores`` for the given subset.
881879
882880
``mask`` may be ``None`` (use all rows) or a boolean array of length n.
883881
Dispatches to the sparse helper when ``use_sparse`` is True, otherwise
@@ -886,11 +884,11 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
886884
(per-slice mask, NOT full n×n — saves memory for panel paths).
887885
"""
888886
if mask is None:
889-
S_sub = S
887+
scores_sub = scores
890888
coords_sub = coords_arr
891889
cluster_sub = cluster_codes
892890
else:
893-
S_sub = S[mask]
891+
scores_sub = scores[mask]
894892
coords_sub = coords_arr[mask]
895893
cluster_sub = cluster_codes[mask] if cluster_codes is not None else None
896894
if use_sparse:
@@ -902,7 +900,7 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
902900
# would use more memory than dense); fall through to dense in
903901
# that case (the warning is already emitted by the helper).
904902
sparse_meat = _compute_spatial_bartlett_meat_sparse(
905-
S_sub, coords_sub, cutoff, cast(str, metric), cluster_codes=cluster_sub
903+
scores_sub, coords_sub, cutoff, cast(str, metric), cluster_codes=cluster_sub
906904
)
907905
if sparse_meat is not None:
908906
return sparse_meat
@@ -912,19 +910,19 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
912910
if cluster_sub is not None:
913911
cluster_mask = cluster_sub[:, None] == cluster_sub[None, :]
914912
K = K * cluster_mask
915-
return S_sub.T @ K @ S_sub
913+
return scores_sub.T @ K @ scores_sub
916914

917915
# Suppress spurious BLAS-level "divide by zero / overflow" warnings on
918916
# macOS Accelerate when K is sparse-ish (most off-diagonals are exactly
919917
# 0 outside the cutoff). The matmul result is mathematically correct;
920918
# the warning is a subnormal-handling false-positive in the AVX path.
921919
# We verify finiteness immediately after.
922920
if time is None:
923-
# Phase 1 cross-sectional path: full n×n spatial sandwich.
921+
# Cross-sectional path: full n×n spatial sandwich.
924922
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
925923
meat = _spatial_meat_for_mask(None)
926924
else:
927-
# Phase 2 panel block-decomposed path (matches R conleyreg).
925+
# Panel block-decomposed path (matches R conleyreg).
928926
time_arr = np.asarray(time)
929927
unit_arr = np.asarray(unit)
930928
# Normalize time labels to dense panel-period codes (0..T-1) so that
@@ -940,8 +938,8 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
940938
# `conley_lag_cutoff` is meaningfully a "number of observed panel
941939
# periods" regardless of label scale.
942940
_, time_codes = np.unique(time_arr, return_inverse=True)
943-
k = X.shape[1]
944-
meat = np.zeros((k, k))
941+
p = scores.shape[1]
942+
meat = np.zeros((p, p))
945943
# Spatial component: within-period sandwich, summed across periods.
946944
# _spatial_meat_for_mask dispatches to sparse or dense per the toggle.
947945
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
@@ -957,14 +955,14 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
957955
if L > 0:
958956
for u_val in np.unique(unit_arr):
959957
mask_u = unit_arr == u_val
960-
S_u = S[mask_u]
958+
scores_u = scores[mask_u]
961959
# Use dense panel-period codes (NOT raw labels) for lag math.
962960
t_u = time_codes[mask_u].astype(np.float64)
963961
lag_mat = np.abs(t_u[:, None] - t_u[None, :])
964962
K_u = ((lag_mat <= L) & (lag_mat != 0)).astype(np.float64) * (
965963
1.0 - lag_mat / (L + 1.0)
966964
)
967-
meat += S_u.T @ K_u @ S_u
965+
meat += scores_u.T @ K_u @ scores_u
968966
if not np.all(np.isfinite(meat)):
969967
raise ValueError(
970968
"Conley meat contains non-finite values; check residuals and "
@@ -991,6 +989,66 @@ def _spatial_meat_for_mask(mask: Optional[np.ndarray] = None) -> np.ndarray:
991989
stacklevel=3,
992990
)
993991

992+
return meat
993+
994+
995+
def _compute_conley_vcov(
996+
X: np.ndarray,
997+
residuals: np.ndarray,
998+
coords: np.ndarray,
999+
cutoff: float,
1000+
metric: ConleyMetric,
1001+
kernel: str,
1002+
bread_matrix: np.ndarray,
1003+
*,
1004+
time: Optional[np.ndarray] = None,
1005+
unit: Optional[np.ndarray] = None,
1006+
lag_cutoff: Optional[int] = None,
1007+
cluster_ids: Optional[np.ndarray] = None,
1008+
_conley_sparse: Optional[bool] = None,
1009+
) -> np.ndarray:
1010+
"""Conley (1999) spatial HAC sandwich variance.
1011+
1012+
Thin wrapper around :func:`_compute_conley_meat`: builds
1013+
``scores = X * residuals[:, None]``, delegates the meat construction,
1014+
then wraps with the supplied bread inverse.
1015+
1016+
Two operating modes (both delegated to :func:`_compute_conley_meat`):
1017+
1018+
**Cross-sectional** (``time`` / ``unit`` / ``lag_cutoff`` all None):
1019+
1020+
Var̂(β) = bread_inv · (Σ_{i,j} K(d_ij/h) · X_i ε_i ε_j X_j') · bread_inv
1021+
1022+
**Panel block-decomposed** (all three keyword-only args set):
1023+
1024+
XeeX_spatial = Σ_t S_t' · K_space_t · S_t (within-period sum)
1025+
XeeX_serial = Σ_u S_u' · K_time_u · S_u if lag_cutoff > 0 (within-unit sum)
1026+
Var̂(β) = bread_inv · (XeeX_spatial + XeeX_serial) · bread_inv
1027+
1028+
See :func:`_compute_conley_meat` for the kernel choice, sparse
1029+
k-d-tree fallback, cluster-product kernel, and PSD-warning details.
1030+
1031+
Inputs are assumed already validated by :func:`_validate_conley_kwargs`;
1032+
this wrapper only does the math. Caller is responsible for the validator.
1033+
1034+
Returns
1035+
-------
1036+
vcov : ndarray of shape (k, k)
1037+
"""
1038+
scores = X * residuals[:, np.newaxis]
1039+
meat = _compute_conley_meat(
1040+
scores,
1041+
coords,
1042+
cutoff,
1043+
metric,
1044+
kernel,
1045+
time=time,
1046+
unit=unit,
1047+
lag_cutoff=lag_cutoff,
1048+
cluster_ids=cluster_ids,
1049+
_conley_sparse=_conley_sparse,
1050+
)
1051+
9941052
# Sandwich via two solves (mirrors _compute_cr2_bm pattern in linalg.py)
9951053
try:
9961054
temp = np.linalg.solve(bread_matrix, meat)

diff_diff/guides/llms-full.txt

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -498,12 +498,13 @@ sp.fit(
498498
) -> SpilloverDiDResults
499499
```
500500

501-
**Restrictions (Wave B MVP — planned follow-ups):**
501+
**Restrictions and Wave C/D status:**
502502

503-
- `covariates=` raises `NotImplementedError`. Gardner two-stage requires covariate effects estimated on the untreated-and-unexposed Omega_0 subsample at stage 1; appending raw covariates only at stage 2 silently biases `tau_total` / `delta_j` on panels with time-varying covariates. Planned follow-up.
504-
- `survey_design=` raises `NotImplementedError` (planned: SurveyDesign integration)
505-
- `event_study=True` SHIPPED (Wave C): emits per-event-time `tau_k` and per-(ring, event-time) `delta_jk` as `att_dynamic: pd.DataFrame` (indexed by event-time `k`) plus MultiIndex `spillover_effects: pd.DataFrame` (levels `(ring_label, event_time)`). TwoStageDiD-compatible `event_study_effects: Dict[int, Dict]` alias also emitted for `plot_event_study` consumption — `_extract_plot_data` prefers the new `reference_period` attribute over the legacy `n_obs==0` heuristic. (DiagnosticReport integration: NOT yet wired; queued as a follow-up.) (schema: `{k: {"effect", "se", "n_obs", "t_stat", "p_value", "conf_int": (low, high)}}` mirroring `two_stage.py:1355-1389`). Reference period `ref_period = -1 - anticipation` (TwoStageDiD `two_stage.py:486` convention); reference row uses `coef=0.0, se=0.0, n_obs=0, conf_int=(0.0, 0.0)`. Scalar `att` field becomes a sample-share-weighted average of post-treatment `tau_k` (`att = sum_{k>=0} w_k * tau_k` with `w_k = n_treated_at_k / total`) with SE from linear-combination inference `Var(att) = w' V_subset w` on the post-treatment vcov block — no separate fit. **Two-clock K_it:** direct-effect clock is `K_direct = t - effective_first_treat(i)` for ever-treated rows; spillover clock is `K_spill = t - earliest-in-range-cohort-onset(i)` (running min across activated cohorts, NaN pre-trigger). `K_spill >= 0` structurally; negative-k spillover cells are rectangularly emitted with `coef = NaN, n_obs = 0`. **`horizon_max` semantics:** bins event-times outside `[-H, +H]` into endpoint pools (no observations dropped — divergence from TwoStageDiD which filters; intentional, per `feedback_no_silent_failures`). With `horizon_max=None`, auto-detects bin set from observed K. **Validation:** `horizon_max < 0` raises `ValueError`; `ref_period < -horizon_max` (i.e., `anticipation > horizon_max - 1`) raises `ValueError` — silently floor-shifting the reference would change identification. **Reduce-to-aggregate:** under constant-tau DGP with `horizon_max=None`, the share-weighted scalar `att` reproduces Wave B's aggregate bit-identically. **Note:** `horizon_max=0` does NOT reduce to Wave B (binning collapses pre-treatment K values to `k=0`, making `D^0 = D_i` ever-treated indicator rather than `D_it`). Per-event-time SEs share the same Wave B Gardner-GMM caveat (biased downward by a few percent; Wave D follow-up).
506-
- Stage-2 variance is `solve_ols` HC1 / Conley / cluster — Gardner GMM first-stage uncertainty correction NOT applied (planned follow-up; SE is biased downward / too small, CIs too narrow, p-values too small — treat reported significance conservatively until the GMM correction lands)
503+
- `covariates=` raises `NotImplementedError` (planned follow-up). Gardner two-stage requires covariate effects estimated on the untreated-and-unexposed Omega_0 subsample at stage 1; appending raw covariates only at stage 2 silently biases `tau_total` / `delta_j` on panels with time-varying covariates.
504+
- `survey_design=` raises `NotImplementedError` (planned follow-up — SurveyDesign integration)
505+
- `vcov_type="classical"` raises `NotImplementedError` (Wave D restriction). Wave D GMM first-stage correction has not been derived for the homoskedastic meat structure `sigma_hat^2 * (X_10' X_10)`. Use `vcov_type="hc1"`, `vcov_type="conley"`, or pair with `cluster=<col>` for CR1 — all three apply the Wave D GMM correction.
506+
- `event_study=True` SHIPPED (Wave C): emits per-event-time `tau_k` and per-(ring, event-time) `delta_jk` as `att_dynamic: pd.DataFrame` (indexed by event-time `k`) plus MultiIndex `spillover_effects: pd.DataFrame` (levels `(ring_label, event_time)`). TwoStageDiD-compatible `event_study_effects: Dict[int, Dict]` alias also emitted for `plot_event_study` consumption — `_extract_plot_data` prefers the new `reference_period` attribute over the legacy `n_obs==0` heuristic. (DiagnosticReport integration: NOT yet wired; queued as a follow-up.) (schema: `{k: {"effect", "se", "n_obs", "t_stat", "p_value", "conf_int": (low, high)}}` mirroring `two_stage.py:1355-1389`). Reference period `ref_period = -1 - anticipation` (TwoStageDiD `two_stage.py:486` convention); reference row uses `coef=0.0, se=0.0, n_obs=0, conf_int=(0.0, 0.0)`. Scalar `att` field becomes a sample-share-weighted average of post-treatment `tau_k` (`att = sum_{k>=0} w_k * tau_k` with `w_k = n_treated_at_k / total`) with SE from linear-combination inference `Var(att) = w' V_subset w` on the post-treatment vcov block — no separate fit. **Two-clock K_it:** direct-effect clock is `K_direct = t - effective_first_treat(i)` for ever-treated rows; spillover clock is `K_spill = t - earliest-in-range-cohort-onset(i)` (running min across activated cohorts, NaN pre-trigger). `K_spill >= 0` structurally; negative-k spillover cells are rectangularly emitted with `coef = NaN, n_obs = 0`. **`horizon_max` semantics:** bins event-times outside `[-H, +H]` into endpoint pools (no observations dropped — divergence from TwoStageDiD which filters; intentional, per `feedback_no_silent_failures`). With `horizon_max=None`, auto-detects bin set from observed K. **Validation:** `horizon_max < 0` raises `ValueError`; `ref_period < -horizon_max` (i.e., `anticipation > horizon_max - 1`) raises `ValueError` — silently floor-shifting the reference would change identification. **Reduce-to-aggregate:** under constant-tau DGP with `horizon_max=None`, the share-weighted scalar `att` reproduces Wave B's aggregate bit-identically. **Note:** `horizon_max=0` does NOT reduce to Wave B (binning collapses pre-treatment K values to `k=0`, making `D^0 = D_i` ever-treated indicator rather than `D_it`). Per-event-time SEs include the Wave D Gardner GMM first-stage correction (see next bullet).
507+
- Stage-2 variance applies the Gardner GMM first-stage uncertainty correction across HC1 / Conley / cluster (Wave D, SHIPPED). The IF outer-product formula `psi_i = gamma_hat' X_{10,i} eps_{10,i} - X_{2,i} eps_{2,i}` is used unconditionally; kernel `K` is path-dependent (identity for HC1, block-indicator for cluster, spatial kernel for Conley). Documented synthesis of Butts (2021) §3.1 + Gardner (2022) §4 + Conley (1999); no reference software combines all three. Point estimates unchanged from Wave B/C; SE values shift upward by 1-few percent.
507508
- Only nearest-treated rings supported; `ring_method="count"` (count of treated neighbors in ring) not yet exposed
508509

509510
**Usage:**

0 commit comments

Comments
 (0)