Skip to content

Commit e03318d

Browse files
igerberclaude
andcommitted
Codex CI R5 P0: thread raw data (not working_data) on MPD Conley path
P0 Methodology [new in R5] — `MultiPeriodDiD.fit` built `_conley_coords_arr` / `_conley_time_arr` / `_conley_unit_arr` from `working_data`, which is absorb-demeaned. If a caller listed any of those columns (coords, time, or unit) in `absorb`, the Conley helper would silently partition the within-period spatial sandwich on residualized values instead of the true geography/period labels and misstate Conley SEs. This is the same raw-data contract the local R1 fix established for DifferenceInDifferences (`estimators.py:L538-L561`); MultiPeriodDiD shared the same pattern but wasn't updated in that pass — codex CI R5 caught the holistic-fix omission. Now reads from `data` in MPD, mirroring DiD + TWFE (`twfe.py:L370-L371`). FWL composability holds: the meat is computed on demeaned scores but the kernel grid uses raw coords + time/unit. Adds `test_mpd_conley_with_absorb_uses_raw_coords_and_time` regression: monkeypatches `_compute_conley_vcov` to capture the time + coords args, asserts the helper sees raw integer time labels (0..T-1, not absorb- demeaned floats) AND n_units distinct (lat, lon) pairs (not collapsed to one demeaned mean per unit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 484620e commit e03318d

2 files changed

Lines changed: 101 additions & 6 deletions

File tree

diff_diff/estimators.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,21 +1666,30 @@ def fit( # type: ignore[override]
16661666
_fit_vcov_type = self._resolve_effective_vcov_type(effective_cluster_ids)
16671667

16681668
# Resolve Conley arrays from column names (init-time) plus the
1669-
# estimator's `time` / `unit` columns. When vcov_type != "conley",
1670-
# these are silently ignored downstream (Phase 1 / 2 convention).
1669+
# estimator's `time` / `unit` columns. CRITICAL: read from the
1670+
# ORIGINAL `data` frame, NOT `working_data` — if absorb is used
1671+
# with overlapping covariates (e.g. lat/lon or time listed in
1672+
# both `absorb` and `conley_coords`/`time`), `working_data` has
1673+
# those columns demeaned and the Conley helper would silently
1674+
# partition the spatial sandwich on residualized inputs.
1675+
# Mirrors the DiD/TWFE contract at `estimators.py::DifferenceInDifferences.fit`
1676+
# and `twfe.py::TwoWayFixedEffects.fit` (FWL composability: the meat
1677+
# is computed on demeaned scores but the kernel grid uses the raw
1678+
# coords + time/unit). When vcov_type != "conley", these are silently
1679+
# ignored downstream (Phase 1 / 2 convention).
16711680
if _fit_vcov_type == "conley":
16721681
_conley_coords_arr: Optional[np.ndarray] = np.column_stack(
16731682
[
1674-
working_data[self.conley_coords[0]].values.astype(np.float64),
1675-
working_data[self.conley_coords[1]].values.astype(np.float64),
1683+
data[self.conley_coords[0]].values.astype(np.float64),
1684+
data[self.conley_coords[1]].values.astype(np.float64),
16761685
]
16771686
)
16781687
# Preserve the original time-label dtype (int, datetime64, pd.Period,
16791688
# string). `_compute_conley_vcov` normalizes to dense 0..T-1 codes
16801689
# internally; float coercion here would break datetime64 / Period /
16811690
# string encodings before the normalizer runs.
1682-
_conley_time_arr: Optional[np.ndarray] = np.asarray(working_data[time].values)
1683-
_conley_unit_arr: Optional[np.ndarray] = working_data[unit].values
1691+
_conley_time_arr: Optional[np.ndarray] = np.asarray(data[time].values)
1692+
_conley_unit_arr: Optional[np.ndarray] = data[unit].values
16841693
else:
16851694
_conley_coords_arr = None
16861695
_conley_time_arr = None

tests/test_conley_vcov.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,92 @@ def _spy(*args, **kwargs):
12911291
)
12921292
assert set(uniques.tolist()) == {0, 1}, f"Expected raw integer labels 0/1, got {uniques}"
12931293

1294+
def test_mpd_conley_with_absorb_uses_raw_coords_and_time(self, monkeypatch):
1295+
"""MultiPeriodDiD + Conley + absorb=[<col>] must feed the Conley
1296+
helper the ORIGINAL coords/time/unit columns from `data`, not the
1297+
absorb-demeaned `working_data`. If a user lists the `time` column
1298+
(or coord columns) in `absorb`, working_data has those demeaned and
1299+
the Conley helper would partition the within-period spatial sandwich
1300+
on residualized floats. Mirrors the DiD raw-time contract.
1301+
Codex CI R5 P0.
1302+
"""
1303+
import pandas as _pd
1304+
1305+
import diff_diff.linalg as linalg_module
1306+
from diff_diff import MultiPeriodDiD
1307+
1308+
rng = np.random.default_rng(seed=71)
1309+
n_units = 10
1310+
T = 4
1311+
rows = []
1312+
for u in range(n_units):
1313+
treated = u >= n_units // 2
1314+
lat = rng.uniform(-30, 30)
1315+
lon = rng.uniform(-100, 100)
1316+
for t in range(T):
1317+
effect = 1.0 if (treated and t >= 2) else 0.0
1318+
yv = 0.2 * t + effect + rng.normal(0, 0.3)
1319+
rows.append(
1320+
{
1321+
"unit": u,
1322+
"time": t,
1323+
"y": yv,
1324+
"treated": int(treated),
1325+
"lat": lat,
1326+
"lon": lon,
1327+
}
1328+
)
1329+
df = _pd.DataFrame(rows)
1330+
1331+
captured: dict = {"time_arg": None, "unit_arg": None, "coords_arg": None}
1332+
orig = linalg_module._compute_conley_vcov
1333+
1334+
def _spy(*args, **kwargs):
1335+
captured["time_arg"] = kwargs.get("time")
1336+
captured["unit_arg"] = kwargs.get("unit")
1337+
# coords is the 3rd positional arg to _compute_conley_vcov
1338+
if len(args) >= 3:
1339+
captured["coords_arg"] = args[2]
1340+
return orig(*args, **kwargs)
1341+
1342+
monkeypatch.setattr(linalg_module, "_compute_conley_vcov", _spy)
1343+
MultiPeriodDiD(
1344+
vcov_type="conley",
1345+
conley_coords=("lat", "lon"),
1346+
conley_cutoff_km=2000.0,
1347+
conley_lag_cutoff=1,
1348+
).fit(
1349+
df,
1350+
outcome="y",
1351+
treatment="treated",
1352+
time="time",
1353+
unit="unit",
1354+
post_periods=[2, 3],
1355+
reference_period=0,
1356+
absorb=["unit"],
1357+
)
1358+
# Raw time labels span exactly 0..T-1 = 4 distinct values; demeaned
1359+
# absorb would collapse to per-unit means → ~n_units distinct values.
1360+
time_arg = np.asarray(captured["time_arg"])
1361+
uniques = np.unique(time_arg)
1362+
assert len(uniques) == T, (
1363+
f"Expected {T} unique time labels (raw 0..{T - 1}), got {len(uniques)}: "
1364+
f"{uniques[:5]} — absorb is leaking demeaned time into the Conley helper."
1365+
)
1366+
assert set(uniques.tolist()) == set(range(T))
1367+
# Raw coords are time-invariant within unit and span n_units distinct
1368+
# (lat, lon) pairs. If absorb=["unit"] leaked demeaned coords, all
1369+
# within-unit coord values would collapse to 0 (per-unit mean), giving
1370+
# only 1 distinct row across all observations.
1371+
coords_arg = np.asarray(captured["coords_arg"])
1372+
# Expect n_units distinct (lat, lon) pairs since each unit has its own
1373+
unique_coords = np.unique(coords_arr_view := coords_arg, axis=0)
1374+
del coords_arr_view
1375+
assert len(unique_coords) == n_units, (
1376+
f"Expected {n_units} unique coord pairs, got {len(unique_coords)} — "
1377+
"absorb=['unit'] is leaking demeaned coords into the Conley helper."
1378+
)
1379+
12941380
def test_multi_period_did_with_conley_panel(self):
12951381
"""Phase 2 MultiPeriodDiD + vcov_type='conley' uses the block-decomposed
12961382
sandwich (matches R conleyreg). Verifies that finite SEs are produced

0 commit comments

Comments
 (0)