Skip to content

Commit fa820a8

Browse files
igerberclaude
andcommitted
Codex re-review: time-label normalization + Conley summary label
P1 (Methodology): `_compute_conley_vcov` previously used raw `time` values in `abs(t_i - t_j)`, which meant `conley_lag_cutoff` semantics depended on label encoding rather than panel-period order. On non-dense encodings (YYYYMM like 202012/202101, datetime64, or binary `post`-style 0/1 panels) the raw difference does not equal the count of panel periods, so valid serial pairs could be silently dropped or misweighted. The fix normalizes `time` to dense panel-period codes `0..T-1` via `np.unique(return_inverse=True)` before the lag computation, so `conley_lag_cutoff` always counts panel periods regardless of how `time` is encoded (int year, YYYYMM, datetime64, pd.Period, strings). The spatial within-period loop also uses the same dense codes for consistency. On dense integer labels (the parity-test convention) this is a no-op and R conleyreg parity holds at 1e-14; on non-dense encodings diff-diff is the more robust default vs R's literal label-difference convention. P3 (Maintainability): `_format_vcov_label` in results.py gains a "conley" branch and surfaces "Conley spatial HAC (1999)" in DiD/MPD/TWFE result summaries (was previously omitting the label because Conley used to be unreachable on the panel surface). Doc surfaces: - REGISTRY § ConleySpatialHAC: new "Note (deviation from R conleyreg literal: time-label normalization)" documenting the dense-code convention and the R-divergence on non-dense encodings. - llms-full.txt: fixed the misleading `time="post", conley_lag_cutoff=2` example (now uses `time="period"`), added a "Note on `conley_lag_cutoff` semantics" paragraph. Regression tests: - `TestConleyPanelHelper::test_time_label_normalization_non_unit_spaced_int`: year-like (2020, 2021, 2022) and YYYYMM (202011, 202012, 202101) labels produce the same vcov as dense codes (1, 2, 3). - `TestConleyPanelHelper::test_time_label_normalization_datetime64`: irregularly-spaced datetime64 labels normalize correctly. - `TestConleyTWFE::test_twfe_conley_binary_post_label_normalization`: TWFE with binary `post` (the exact example the codex reviewer flagged) produces finite SE. - `TestConleyTWFE::test_twfe_conley_summary_emits_conley_label`: summary contains "Conley spatial HAC" for panel Conley fits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 23d63f5 commit fa820a8

5 files changed

Lines changed: 160 additions & 12 deletions

File tree

diff_diff/conley.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,12 +342,25 @@ def _kernel_fn(u: np.ndarray) -> np.ndarray:
342342
# Phase 2 panel block-decomposed path (matches R conleyreg).
343343
time_arr = np.asarray(time)
344344
unit_arr = np.asarray(unit)
345+
# Normalize time labels to dense panel-period codes (0..T-1) so that
346+
# `conley_lag_cutoff` always refers to a count of panel periods, not
347+
# a raw-label difference. Works for any orderable encoding (year ints
348+
# like 2020/2021, YYYYMM like 202012/202101, datetime64, pd.Period,
349+
# strings). np.unique returns sorted unique values and `return_inverse`
350+
# gives the position of each row in that sort.
351+
# **Note (deviation from R-conleyreg literal):** R conleyreg uses raw
352+
# `time` values for the lag computation directly, which silently
353+
# mishandles non-dense encodings (e.g. lag 1 between 202012 and 202101
354+
# is 89 in raw integer differences). diff-diff normalizes first so
355+
# `conley_lag_cutoff` is meaningfully a "number of observed panel
356+
# periods" regardless of label scale.
357+
_, time_codes = np.unique(time_arr, return_inverse=True)
345358
k = X.shape[1]
346359
meat = np.zeros((k, k))
347360
# Spatial component: within-period sandwich, summed across periods.
348361
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
349-
for t_val in np.unique(time_arr):
350-
mask_t = time_arr == t_val
362+
for t_code in np.unique(time_codes):
363+
mask_t = time_codes == t_code
351364
S_t = S[mask_t]
352365
D_t = _pairwise_distance_matrix(coords_arr[mask_t], metric)
353366
K_t = _kernel_fn(D_t / cutoff)
@@ -362,7 +375,8 @@ def _kernel_fn(u: np.ndarray) -> np.ndarray:
362375
for u_val in np.unique(unit_arr):
363376
mask_u = unit_arr == u_val
364377
S_u = S[mask_u]
365-
t_u = np.asarray(time_arr[mask_u], dtype=np.float64)
378+
# Use dense panel-period codes (NOT raw labels) for lag math.
379+
t_u = time_codes[mask_u].astype(np.float64)
366380
lag_mat = np.abs(t_u[:, None] - t_u[None, :])
367381
K_u = ((lag_mat <= L) & (lag_mat != 0)).astype(np.float64) * (
368382
1.0 - lag_mat / (L + 1.0)

diff_diff/guides/llms-full.txt

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1924,24 +1924,40 @@ reg = LinearRegression(
19241924
).fit(X, y)
19251925
se = np.sqrt(np.diag(reg.vcov_))
19261926

1927-
# Panel design: TWFE with within-unit Bartlett serial HAC (lag = 2 periods)
1927+
# Panel design: TWFE with within-unit Bartlett serial HAC (lag = 2 periods).
1928+
# `time` must be a panel-period index column with one row per (unit, period).
1929+
# diff-diff internally normalizes the time labels to dense panel-period codes
1930+
# 0..T-1, so any orderable encoding works — int years (2020, 2021, ...),
1931+
# YYYYMM (202012, 202101, ...), datetime64, pd.Period, strings.
19281932
res = TwoWayFixedEffects(
19291933
vcov_type="conley",
19301934
conley_coords=("lat", "lon"), # column names on `data`
19311935
conley_cutoff_km=500.0,
1932-
conley_lag_cutoff=2, # within-unit Bartlett up to 2 lags
1933-
).fit(data, outcome="y", treatment="treated", time="post", unit="unit_id")
1936+
conley_lag_cutoff=2, # within-unit Bartlett up to 2 panel periods
1937+
).fit(data, outcome="y", treatment="treated", time="period", unit="unit_id")
19341938

19351939
# Panel design: MultiPeriodDiD with cross-sectional within-period only (lag=0)
19361940
mp_res = MultiPeriodDiD(
19371941
vcov_type="conley",
19381942
conley_coords=("lat", "lon"),
19391943
conley_cutoff_km=200.0,
19401944
conley_lag_cutoff=0, # spatial-within-period only
1941-
).fit(data, outcome="y", treatment="treated", time="t",
1945+
).fit(data, outcome="y", treatment="treated", time="period",
19421946
post_periods=[2, 3], unit="unit_id")
19431947
```
19441948

1949+
**Note on `conley_lag_cutoff` semantics:** the lag is counted in **panel
1950+
periods** (number of distinct sorted values in the `time` column), NOT in
1951+
raw-label differences. Internally, time labels are normalized to dense codes
1952+
`0..T-1` via `np.unique(return_inverse=True)`. For example, `time` values
1953+
`(2020, 2021, 2022)` and `(202012, 202101, 202102)` both produce the same
1954+
codes `(0, 1, 2)` and the same lag matrix. **Deviation from R `conleyreg`:**
1955+
R uses raw `time` values directly in the lag computation, which silently
1956+
mishandles non-dense encodings (`time = 202012, 202101` and `lag_cutoff=1`
1957+
under R produces 0 weight because the raw difference is 89). diff-diff is
1958+
the more robust default; for bit-exact R parity, pass `time` as a dense
1959+
integer index.
1960+
19451961
**Variance estimator — cross-sectional:**
19461962

19471963
Var̂(β) = (X'X)^{-1} · ( Σ_{i,j} K(d_ij / h) · X_i ε_i ε_j X_j' ) · (X'X)^{-1}

diff_diff/results.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,7 @@ def _format_vcov_label(
5656
"""Compose a human-readable variance-family label for summary output.
5757
5858
Returns None when vcov_type is not recognized so the caller can skip the
59-
line silently (backward-compat). vcov_type='conley' is intentionally
60-
not labeled here: DifferenceInDifferences / MultiPeriodDiD / TwoWayFixedEffects
61-
all reject vcov_type='conley' at fit-time (Phase 1 supports cross-sectional
62-
Conley only via direct compute_robust_vcov / LinearRegression), so a
63-
Conley label cannot be reached on these result classes.
59+
line silently (backward-compat).
6460
"""
6561
if vcov_type == "classical":
6662
return "Classical OLS SEs (non-robust)"
@@ -77,6 +73,11 @@ def _format_vcov_label(
7773
return f"CR2 Bell-McCaffrey cluster-robust at {cluster_name}{suffix}"
7874
suffix = f", n={n_obs}" if n_obs else ""
7975
return f"HC2 + Bell-McCaffrey DOF (one-way{suffix})"
76+
if vcov_type == "conley":
77+
# Cross-sectional Conley on direct LinearRegression / compute_robust_vcov,
78+
# or panel block-decomposed Conley (within-period spatial + within-unit
79+
# Bartlett serial) on MultiPeriodDiD / TwoWayFixedEffects.
80+
return "Conley spatial HAC (1999)"
8081
return None
8182

8283

docs/methodology/REGISTRY.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2988,6 +2988,19 @@ through). diff-diff matches this asymmetry exactly for R parity. Independent
29882988
temporal kernel choice would be a follow-up API extension if user demand
29892989
emerges.
29902990

2991+
**Note (deviation from R conleyreg literal: time-label normalization):**
2992+
R `conleyreg` uses raw `time` values directly in the lag computation
2993+
(`time_dist.cpp`'s `t_diff = abs(times - times[i])`). On non-dense time
2994+
encodings (e.g., `time = 202012, 202101` for monthly panels), the raw
2995+
difference is 89, so a `lag_cutoff=1` request silently drops valid lag-1
2996+
serial pairs in R. diff-diff normalizes `time` to dense panel-period codes
2997+
`0..T-1` via `np.unique(return_inverse=True)` before the lag computation,
2998+
so `conley_lag_cutoff` always counts panel periods regardless of label
2999+
encoding (int year, YYYYMM, datetime64, `pd.Period`, strings). On dense
3000+
integer labels (the parity-test convention), the two paths produce
3001+
bit-identical results. For non-dense encodings, diff-diff is the more
3002+
robust default; pass `time` as a dense integer index for bit-exact R parity.
3003+
29913004
**Kernel functions:**
29923005
- `conley_kernel="bartlett"` (default): `K(u) = max(0, 1 - |u|)` evaluated on the pairwise distance `d_ij/h`. The radial 1-D form on pairwise distance, matching R `conleyreg`, Stata `acreg` (Colella et al. 2019), and Hsiang (2010).
29933006
- `conley_kernel="uniform"`: `K(u) = 1{|u| ≤ 1}`. Conley 1999 page 11; spectral window negative in regions (footnote 11).

tests/test_conley_vcov.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,40 @@ def test_twfe_conley_missing_lag_cutoff_raises(self, panel):
13161316
conley_cutoff_km=2000.0,
13171317
).fit(panel, outcome="y", treatment="treated", time="time", unit="unit")
13181318

1319+
def test_twfe_conley_binary_post_label_normalization(self, panel):
1320+
"""TWFE with binary `post` (values {0,1}) + `conley_lag_cutoff=1`
1321+
produces the same finite vcov as the equivalent dense-period-index
1322+
fit. Closes the Codex P1 example — the time-label normalization
1323+
means lag is counted in panel periods regardless of how `time` is
1324+
encoded (binary post indicator vs. dense period index).
1325+
"""
1326+
from diff_diff import TwoWayFixedEffects
1327+
1328+
# `panel` fixture uses `time` in {0, 1}, identical to a binary post.
1329+
# Rename to `post` to make the test scenario explicit.
1330+
df_post = panel.rename(columns={"time": "post"})
1331+
res = TwoWayFixedEffects(
1332+
vcov_type="conley",
1333+
conley_coords=("lat", "lon"),
1334+
conley_cutoff_km=2000.0,
1335+
conley_lag_cutoff=1,
1336+
).fit(df_post, outcome="y", treatment="treated", time="post", unit="unit")
1337+
assert np.isfinite(res.se) and res.se > 0
1338+
1339+
def test_twfe_conley_summary_emits_conley_label(self, panel):
1340+
"""Panel result summary must label the variance family as Conley
1341+
spatial HAC when vcov_type='conley'. Closes Codex P3."""
1342+
from diff_diff import TwoWayFixedEffects
1343+
1344+
res = TwoWayFixedEffects(
1345+
vcov_type="conley",
1346+
conley_coords=("lat", "lon"),
1347+
conley_cutoff_km=2000.0,
1348+
conley_lag_cutoff=1,
1349+
).fit(panel, outcome="y", treatment="treated", time="time", unit="unit")
1350+
summary = res.summary()
1351+
assert "Conley spatial HAC" in summary
1352+
13191353
def test_twfe_conley_within_vs_dummy_expansion_equivalence(self, panel):
13201354
"""FWL composability: TWFE (within-transform) + Conley should produce
13211355
the SAME ATT SE as a dummy-expansion design with the same Conley
@@ -1830,6 +1864,76 @@ def test_panel_matches_block_decomposed_reference(self):
18301864
)
18311865
np.testing.assert_allclose(V_helper, V_ref, atol=1e-12)
18321866

1867+
def test_time_label_normalization_non_unit_spaced_int(self):
1868+
"""Year-like int labels (2020, 2021, 2022) and YYYYMM labels
1869+
(202011, 202012, 202101) produce the same vcov as the equivalent
1870+
dense codes (0, 1, 2). Closes Codex P1: `conley_lag_cutoff` is a
1871+
count of panel periods, not raw label difference."""
1872+
X, residuals, coords, _, unit, bread, cutoff = self._panel_fixture(n_units=8, T=3, k=2)
1873+
time_dense = np.tile([1, 2, 3], 8)
1874+
time_years = np.tile([2020, 2021, 2022], 8)
1875+
time_yyyymm = np.tile([202011, 202012, 202101], 8)
1876+
V_dense = _compute_conley_vcov(
1877+
X,
1878+
residuals,
1879+
coords,
1880+
cutoff,
1881+
"haversine",
1882+
"bartlett",
1883+
bread,
1884+
time=time_dense,
1885+
unit=unit,
1886+
lag_cutoff=1,
1887+
)
1888+
for time_alt in (time_years, time_yyyymm):
1889+
V_alt = _compute_conley_vcov(
1890+
X,
1891+
residuals,
1892+
coords,
1893+
cutoff,
1894+
"haversine",
1895+
"bartlett",
1896+
bread,
1897+
time=time_alt,
1898+
unit=unit,
1899+
lag_cutoff=1,
1900+
)
1901+
np.testing.assert_allclose(V_alt, V_dense, atol=1e-12)
1902+
1903+
def test_time_label_normalization_datetime64(self):
1904+
"""datetime64 time labels normalize to dense codes via np.unique."""
1905+
X, residuals, coords, _, unit, bread, cutoff = self._panel_fixture(n_units=6, T=3, k=2)
1906+
time_dense = np.tile([0, 1, 2], 6)
1907+
time_dt = np.tile(
1908+
np.array(["2024-01-01", "2024-04-01", "2024-08-01"], dtype="datetime64[D]"),
1909+
6,
1910+
)
1911+
V_dense = _compute_conley_vcov(
1912+
X,
1913+
residuals,
1914+
coords,
1915+
cutoff,
1916+
"haversine",
1917+
"bartlett",
1918+
bread,
1919+
time=time_dense,
1920+
unit=unit,
1921+
lag_cutoff=1,
1922+
)
1923+
V_dt = _compute_conley_vcov(
1924+
X,
1925+
residuals,
1926+
coords,
1927+
cutoff,
1928+
"haversine",
1929+
"bartlett",
1930+
bread,
1931+
time=time_dt,
1932+
unit=unit,
1933+
lag_cutoff=1,
1934+
)
1935+
np.testing.assert_allclose(V_dt, V_dense, atol=1e-12)
1936+
18331937
def test_serial_kernel_bartlett_hardcoded_even_when_kernel_uniform(self):
18341938
"""conleyreg::time_dist hardcodes Bartlett-style temporal kernel
18351939
regardless of the user's `kernel` choice. We mirror that asymmetry."""

0 commit comments

Comments
 (0)