Skip to content

Commit 32db10b

Browse files
authored
feat(dml): replicate-weight survey designs via IF-reweighting (per-cell + aggregate) (#807)
1 parent b613098 commit 32db10b

18 files changed

Lines changed: 632 additions & 118 deletions

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Related tracking surfaces:
3737
| `EventStudyResults` inference-provenance fields: the container records no `vcov_type`/`cluster_name`/`n_clusters`/`df_convention`/Conley metadata, so a serialized surface cannot distinguish unit auto-clustering from explicit clustering, survey, Conley, or the one-way carve-out (3(a) R9 review). Adding them is a cross-producer M-092 schema amendment (six builders, to_dict/summary rendering, surface-suite pins) - follow the pre-cut amendment convention (optional fields appended last, ledger note same-diff) rather than bolting onto one producer | `diff_diff/results_base.py` | 3(a) R9 | Mid | Low |
3838
| Opt-in singleton-group pruning for TwoWayFixedEffects (static + event-study mode; reghdfe parity): singleton units/periods are currently RETAINED class-wide - the within-demeaned row is zero so points are unchanged, but N/G/residual-df count it and CR1/finite-sample SEs shift (~0.41019 -> 0.40962 measured; REGISTRY "Deviation from R" Note, R5 review) - reghdfe iteratively drops singletons by default while fixest retains them (diff-diff matches fixest); an opt-in knob needs iterative unit+period pruning with consistent cluster/survey/replicate/Conley array subsetting and a default-flip decision protocol (moves published SEs) | `diff_diff/twfe.py`, `diff_diff/estimators.py`, `diff_diff/utils.py` | 3(a) R5 | Mid | Low |
3939
| Cohort-timing validation input for the simultaneous-adoption event-study family (TWFE `event_study=True` + MultiPeriodDiD through 3.9): an optional `first_treat=`/`cohort=` column so simultaneous adoption becomes checkable under the contract-valid time-invariant `D_i` indicator - today the staggered-adoption advisory derives timing from within-unit 0->1 transitions, so it can only fire on off-contract time-varying `D_it` input, and with valid `D_i` adoption timing is not observable in the inputs at all (REGISTRY "staggered-adoption detection limit" Notes, both sections); design questions: validate-only vs steering error, and interplay with the M-011 removal | `diff_diff/twfe.py`, `diff_diff/estimators.py` | 3(a) R2 | Mid | Medium |
40-
| DMLDiD replicate-weight survey designs (currently fail closed with `NotImplementedError` pointing here): the IF-reweighting route (`compute_replicate_if_variance` on the augmented scores) is BETTER justified for DMLDiD than for CS — Neyman orthogonality makes the score first-order insensitive to nuisance perturbation — but needs per-cell replicate SE plumbing, the aggregate `_se_from_psi` replicate branch already activates on kit keys, and its own rejection/df test matrix (QR-rank df, `n_valid - 1` tightening) | `diff_diff/dml_did.py` | DML survey PR | Mid | Low |
40+
| CS parity with DMLDiD's replicate conventions: per-cell replicate SEs for `CallawaySantAnna` (CS leaves per-cell SEs on the weighted sqrt-sum under replicate designs; DMLDiD now computes them via `compute_replicate_if_variance`) AND flip the replace-style effective-df relays to min-cap (`min(df_survey, n_valid - 1)`) at all five sites: CS overall (`staggered.py` overall relay), the shared event-study/group aggregation sites (`staggered_aggregation.py` `df_survey_val = min(non_none_dfs)` x2), and the staggered-DDD engine twins (`_staggered_triple_diff_engine.py` overall + event-study) — the replace convention can RAISE df above the QR-rank design df (anti-conservative); latent, fires only when `n_valid < R` (all-zero/non-finite replicate column). DMLDiD's min-cap + the pinned inherited ES/group behavior are the templates (REGISTRY DMLDiD replicate Note) | `diff_diff/staggered.py`, `diff_diff/staggered_aggregation.py`, `diff_diff/_staggered_triple_diff_engine.py` | DML replicate PR | Mid | Low |
4141
| ImputationDiD/TwoStageDiD `aggregate()` recompute levels on bootstrapped fits fail closed ('simple' relays since the M-027 per-level convergence; M-021/M-022); ImputationDiD's per-target psi machinery makes seeded replay tractable (the panel-backed kit retains everything the psi precompute reads), TwoStageDiD's per-level GMM scores are function-locals and would need retention | `diff_diff/imputation_results.py`, `diff_diff/two_stage_results.py`, `diff_diff/aggregation.py` | 2(b) PR-3b | Mid | Low |
4242
| ContinuousDiD `aggregate('event_study')` on bootstrapped fits fails closed (M-025); a seeded post-fit bootstrap-ES replay is tractable - the multiplier draws are seeded (`np.random.default_rng(self.seed)`) - but needs the FULL per-cell `_bootstrap_info` (bread/ee_treated/Psi_eval/dPsi_*/beta_pred) the pruned kit deliberately drops, so shipping it means a kit-payload change with its own memory contract | `diff_diff/continuous_did_aggregation.py`, `diff_diff/continuous_did_results.py` | 2(b) PR-3c | Mid | Low |
4343
| practitioner `step_name="heterogeneity"` producer-side collisions: three OTHER estimators' advice steps reuse the key with non-heterogeneity labels (`:975` ContinuousDiD dose-response, `:1022` Triple placebo-group, `:1413` LPDiD WAS arrays), so DiagnosticReport's heterogeneity completion silently drops that unrelated advice from `next_steps` via `_filter_steps` - the same latent collision fixed for StackedDiD in M-024 (renamed to `sub_experiment_balance`). Renaming these changes those estimators' report output; audit + rename with per-estimator pins. | `diff_diff/practitioner.py` | 2(b) PR-2 review R9 | Quick | Low |
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
### Added
2+
- **DMLDiD replicate-weight survey designs**: `DMLDiD` now accepts
3+
replicate-weight `SurveyDesign`s (BRR / Fay / JK1 / JKn / SDR) on both
4+
lanes (panel and repeated cross sections), computing per-cell AND
5+
aggregate variances by IF-reweighting the augmented cross-fitted scores
6+
(`compute_replicate_if_variance`; nuisances are not re-estimated per
7+
replicate). Inference uses `df = rank(replicate matrix) - 1` with
8+
`min(df_survey, n_valid - 1)` capping; degenerate cells (zero or
9+
non-finite replicate variance) fail closed to NaN inference. Replicate +
10+
`cluster=` and replicate + `n_bootstrap > 0` are rejected with targeted
11+
errors (previously all replicate designs failed closed with a blanket
12+
`NotImplementedError`).

diff_diff/dml_did.py

Lines changed: 126 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,15 +1364,43 @@ def _compute_dml_gt(
13641364
"control_inf": inf_full[control_idx],
13651365
}
13661366

1367-
# Per-cell SE. PSU designs (declared OR bare cluster=) route through
1368-
# the CS per-cell CR1 helper (3-valued contract: float = use it,
1369-
# NaN = unidentified clustered variance and MUST propagate,
1370-
# None = malformed -> fall back). Non-PSU survey designs use the
1371-
# weighted sqrt-sum (CS mirror: the full design enters aggregate SEs
1372-
# only); the no-survey branch is verbatim.
1367+
# Per-cell SE. Replicate designs use IF-reweighting on the Hajek
1368+
# payload (compute_replicate_if_variance; the same psi the aggregate
1369+
# _se_from_psi call consumes) — a zero or non-finite replicate
1370+
# variance is degenerate and fails closed to NaN (stricter than the
1371+
# shared aggregate clamp; REGISTRY DMLDiD Note). PSU designs
1372+
# (declared OR bare cluster=) route through the CS per-cell CR1
1373+
# helper (3-valued contract: float = use it, NaN = unidentified
1374+
# clustered variance and MUST propagate, None = malformed -> fall
1375+
# back). Non-PSU survey designs use the weighted sqrt-sum (CS
1376+
# mirror: the full design enters aggregate SEs only); the no-survey
1377+
# branch is verbatim.
13731378
se: float
13741379
with np.errstate(over="ignore", invalid="ignore"):
1375-
if (
1380+
if resolved_survey_unit is not None and resolved_survey_unit.uses_replicate_variance:
1381+
from diff_diff.survey import compute_replicate_if_variance
1382+
1383+
variance, n_valid_rep = compute_replicate_if_variance(
1384+
inf_full, resolved_survey_unit
1385+
)
1386+
if not np.isfinite(variance) or variance <= 0.0:
1387+
se = float("nan")
1388+
else:
1389+
se = float(np.sqrt(variance))
1390+
# Per-cell df: min(design df, n_valid - 1) — the dCDH
1391+
# _effective_df_survey rule, inlined. n_valid is computed
1392+
# over the WHOLE replicate columns, so it equals R for every
1393+
# cell in practice (defensive; REGISTRY Note). df_survey is
1394+
# a CELL-LOCAL binding — never mutate
1395+
# precomputed["df_survey"], which feeds the post-fit
1396+
# aggregation kit.
1397+
if df_survey is not None:
1398+
df_survey = min(int(df_survey), int(n_valid_rep) - 1)
1399+
else:
1400+
# Undefined replicate df (QR rank <= 1): df=0 sentinel
1401+
# -> NaN inference (CS sentinel parity).
1402+
df_survey = 0
1403+
elif (
13761404
resolved_survey_unit is not None
13771405
and getattr(resolved_survey_unit, "psu", None) is not None
13781406
):
@@ -1386,9 +1414,10 @@ def _compute_dml_gt(
13861414
else:
13871415
se = float(np.sqrt(np.mean(psi_bar**2) / n_cell))
13881416
# NOTE: `se` is deliberately OUTSIDE the non_finite_score gate on the
1389-
# design-based branches — a NaN from the CR1 helper is the
1390-
# unidentified-variance signal and must flow to safe_inference as a
1391-
# NaN-consistent inference tuple on a RETAINED cell.
1417+
# design-based branches — a NaN from the CR1 helper (or a degenerate
1418+
# replicate variance) is the unidentified-variance signal and must
1419+
# flow to safe_inference as a NaN-consistent inference tuple on a
1420+
# RETAINED cell.
13921421
if resolved_survey_unit is None and not np.isfinite(se):
13931422
diagnostics["skip_reason"] = "non_finite_score"
13941423
return (
@@ -1760,12 +1789,28 @@ def _compute_dml_rcs_gt(
17601789
}
17611790

17621791
# Per-cell SE (same dispatch as the panel cell; see the comment
1763-
# there): PSU designs -> CS per-cell CR1 helper (NaN propagates as
1792+
# there): replicate designs -> IF-reweighting with the degenerate
1793+
# fail-closed guard and the cell-local min(df, n_valid - 1) rule;
1794+
# PSU designs -> CS per-cell CR1 helper (NaN propagates as
17641795
# the deliberate unidentified-variance signal on a RETAINED cell);
17651796
# non-PSU survey -> weighted sqrt-sum; no-survey verbatim.
17661797
se: float
17671798
with np.errstate(over="ignore", invalid="ignore"):
1768-
if (
1799+
if resolved_survey_unit is not None and resolved_survey_unit.uses_replicate_variance:
1800+
from diff_diff.survey import compute_replicate_if_variance
1801+
1802+
variance, n_valid_rep = compute_replicate_if_variance(
1803+
inf_full, resolved_survey_unit
1804+
)
1805+
if not np.isfinite(variance) or variance <= 0.0:
1806+
se = float("nan")
1807+
else:
1808+
se = float(np.sqrt(variance))
1809+
if df_survey is not None:
1810+
df_survey = min(int(df_survey), int(n_valid_rep) - 1)
1811+
else:
1812+
df_survey = 0
1813+
elif (
17691814
resolved_survey_unit is not None
17701815
and getattr(resolved_survey_unit, "psu", None) is not None
17711816
):
@@ -1831,19 +1876,24 @@ def fit(
18311876
Parameters
18321877
----------
18331878
survey_design : SurveyDesign, optional
1834-
Complex survey design (pweight-only; full-design TSL —
1835-
weights/strata/PSU/FPC). Declared designs weight the moment
1836-
kernels (Hajek p-hat/lambda-hat/theta), pass ``sample_weight``
1837-
into the nuisance learners (user learner objects must accept
1838-
``sample_weight`` by keyword — a learner without it is rejected
1839-
up front), switch cross-fitting to PSU-cohesive folds when the
1840-
PSU is strictly coarser than the sampling unit, and route the
1841-
per-cell and aggregate variances through the design-based
1842-
kernels with ``df = n_PSU - n_strata`` t-inference. Survey
1843-
support is a documented library extension of Chang (2020),
1844-
which assumes i.i.d. sampling — Theorem 2's coverage claim
1845-
does not carry over (REGISTRY DMLDiD Notes). Replicate-weight
1846-
designs are not supported yet (fail closed; TODO.md).
1879+
Complex survey design (pweight-only). Declared designs weight
1880+
the moment kernels (Hajek p-hat/lambda-hat/theta) and pass
1881+
``sample_weight`` into the nuisance learners (user learner
1882+
objects must accept ``sample_weight`` by keyword — a learner
1883+
without it is rejected up front). Two variance lanes:
1884+
full-design TSL (weights/strata/PSU/FPC) switches cross-fitting
1885+
to PSU-cohesive folds when the PSU is strictly coarser than the
1886+
sampling unit and routes the per-cell and aggregate variances
1887+
through the design-based kernels with ``df = n_PSU - n_strata``
1888+
t-inference; replicate-weight designs (BRR / Fay / JK1 / JKn /
1889+
SDR) compute per-cell AND aggregate variances by IF-reweighting
1890+
the cross-fitted scores with ``df = rank(replicate matrix) - 1``
1891+
t-inference (nuisances are not re-estimated per replicate;
1892+
REGISTRY DMLDiD Note). Replicate designs reject ``cluster=``
1893+
and ``n_bootstrap > 0`` combinations. Survey support is a
1894+
documented library extension of Chang (2020), which assumes
1895+
i.i.d. sampling — Theorem 2's coverage claim does not carry
1896+
over (REGISTRY DMLDiD Notes).
18471897
"""
18481898
df, covariates = self._validate_and_prepare(
18491899
data, outcome, unit, time, first_treat, covariates
@@ -1866,13 +1916,18 @@ def fit(
18661916
survey_metadata,
18671917
) = _resolve_survey_for_fit(survey_design, data, "analytical")
18681918

1869-
# Replicate designs fail closed FIRST (None-guarded): no replicate
1870-
# variance path exists for the cross-fitted scores yet.
1871-
if resolved_survey is not None and resolved_survey.uses_replicate_variance:
1919+
# Replicate + bootstrap rejected FIRST (before any fit work) —
1920+
# replicate variance is an analytical alternative, not compatible
1921+
# with bootstrap (CS parity, staggered.py).
1922+
if (
1923+
self.n_bootstrap > 0
1924+
and resolved_survey is not None
1925+
and resolved_survey.uses_replicate_variance
1926+
):
18721927
raise NotImplementedError(
1873-
"DMLDiD does not support replicate-weight survey designs yet "
1874-
"(tracked in TODO.md); use a full-design SurveyDesign "
1875-
"(weights/strata/psu/fpc) instead."
1928+
"DMLDiD bootstrap (n_bootstrap > 0) is not supported "
1929+
"with replicate-weight survey designs. Replicate weights provide "
1930+
"analytical variance; use n_bootstrap=0 instead."
18761931
)
18771932

18781933
# Raw (pre-normalization) per-obs design weights, for metadata
@@ -1902,6 +1957,25 @@ def fit(
19021957
"drop or impute them before fitting"
19031958
)
19041959
cluster_ids_for_check = _cluster_col.to_numpy()
1960+
# Reject replicate-weight + cluster= AFTER the column checks (CS
1961+
# ordering: a bogus cluster name raises ValueError first).
1962+
# Replicate IF variance is computed by replicate reweighting and
1963+
# ignores PSU/cluster entirely (replicate_weights are mutually
1964+
# exclusive with strata/psu/fpc) — honoring cluster= would
1965+
# silently have no effect on the variance, and the inject-as-PSU
1966+
# paths below would violate that mutual exclusion.
1967+
if resolved_survey is not None and resolved_survey.uses_replicate_variance:
1968+
raise NotImplementedError(
1969+
f"DMLDiD(cluster={self.cluster!r}) is not "
1970+
"supported with replicate-weight survey designs. "
1971+
"Replicate-weight variance is computed by replicate "
1972+
"reweighting (BRR / Fay / JK1 / JKn / SDR) and ignores "
1973+
"PSU/cluster entirely — setting cluster= would silently "
1974+
"have no effect on the variance estimate. Either omit "
1975+
"cluster= (the replicate weights encode the design "
1976+
"structure implicitly) or use a non-replicate survey "
1977+
"design (with explicit strata/psu/fpc)."
1978+
)
19051979
if resolved_survey is None:
19061980
# Bare cluster=: synthesize a PSU-only design. survey_metadata
19071981
# stays None DELIBERATELY (it is the declared-survey marker:
@@ -2188,15 +2262,31 @@ def fit(
21882262

21892263
# Overall ATT (simple aggregation over post-treatment finite cells).
21902264
# overall_effective_df is non-None only when replicate variance
2191-
# dropped replicates — unreachable while replicate designs are
2192-
# rejected, but kept for structural parity with CS.
2265+
# dropped replicates (n_valid < R). MIN-CAP, not CS's replace: the
2266+
# QR-rank design df stays the ceiling (CS's replace convention can
2267+
# RAISE df above the design df — anti-conservative; deliberate
2268+
# documented divergence, REGISTRY DMLDiD Note + CS-parity TODO row).
21932269
overall_att, overall_se, overall_effective_df = self._aggregate_simple(
21942270
group_time_effects, influence_func_info, df, unit, precomputed
21952271
)
2196-
if overall_effective_df is not None:
2197-
df_survey = overall_effective_df
2272+
if overall_effective_df is not None and df_survey is not None:
2273+
df_survey = min(int(df_survey), int(overall_effective_df))
2274+
# Propagate to survey_metadata for display consistency (CS
2275+
# parity) — the capped value, never the sentinel below.
2276+
if survey_metadata is not None:
2277+
survey_metadata.df_survey = df_survey
2278+
# Replicate design with undefined df (QR rank <= 1): df=0 sentinel
2279+
# -> NaN inference, applied to the LOCAL df only (survey_metadata
2280+
# keeps None).
2281+
df_overall = df_survey
2282+
if (
2283+
df_survey is None
2284+
and resolved_survey is not None
2285+
and resolved_survey.uses_replicate_variance
2286+
):
2287+
df_overall = 0
21982288
overall_t_stat, overall_p_value, overall_conf_int = safe_inference(
2199-
overall_att, overall_se, alpha=self.alpha, df=df_survey
2289+
overall_att, overall_se, alpha=self.alpha, df=df_overall
22002290
)
22012291

22022292
# Optional multiplier bootstrap (keyword form; aggregate=None is the

diff_diff/dml_did_results.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@
1515
and DMLDiD's augmented-score SE ``sqrt(mean(psi_bar**2)/n)`` is exactly
1616
that on NO-DESIGN fits — per UNIT on panel fits, per OBSERVATION on
1717
repeated-cross-section fits (rows are the sampling units there). Under a
18-
``survey_design=``/``cluster=`` the per-cell SE is the design-based CR1 /
19-
weighted-IF variance instead (the CS clustered-``hc1`` convention:
20-
``SurveyDesign(psu=...)`` routed through the shared stratified-PSU meat).
18+
``survey_design=``/``cluster=`` the per-cell SE is design-based instead:
19+
full-design TSL fits use the CR1 / weighted-IF variance (the CS
20+
clustered-``hc1`` convention: ``SurveyDesign(psu=...)`` routed through the
21+
shared stratified-PSU meat), while replicate-weight fits use IF-reweighting
22+
via ``compute_replicate_if_variance`` on the same per-cell payload
23+
(``df = rank(replicate matrix) - 1``; REGISTRY DMLDiD Note).
2124
"""
2225

2326
from dataclasses import dataclass, field

0 commit comments

Comments
 (0)