diff-diff 3.0.1__cp314-cp314-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diff_diff/__init__.py +382 -0
- diff_diff/_backend.py +134 -0
- diff_diff/_rust_backend.cp314-win_amd64.pyd +0 -0
- diff_diff/bacon.py +1140 -0
- diff_diff/bootstrap_utils.py +730 -0
- diff_diff/continuous_did.py +1626 -0
- diff_diff/continuous_did_bspline.py +190 -0
- diff_diff/continuous_did_results.py +374 -0
- diff_diff/datasets.py +815 -0
- diff_diff/diagnostics.py +882 -0
- diff_diff/efficient_did.py +1770 -0
- diff_diff/efficient_did_bootstrap.py +359 -0
- diff_diff/efficient_did_covariates.py +899 -0
- diff_diff/efficient_did_results.py +368 -0
- diff_diff/efficient_did_weights.py +617 -0
- diff_diff/estimators.py +1501 -0
- diff_diff/honest_did.py +2585 -0
- diff_diff/imputation.py +2458 -0
- diff_diff/imputation_bootstrap.py +418 -0
- diff_diff/imputation_results.py +448 -0
- diff_diff/linalg.py +2538 -0
- diff_diff/power.py +2588 -0
- diff_diff/practitioner.py +869 -0
- diff_diff/prep.py +1738 -0
- diff_diff/prep_dgp.py +1718 -0
- diff_diff/pretrends.py +1105 -0
- diff_diff/results.py +918 -0
- diff_diff/stacked_did.py +1049 -0
- diff_diff/stacked_did_results.py +339 -0
- diff_diff/staggered.py +3895 -0
- diff_diff/staggered_aggregation.py +864 -0
- diff_diff/staggered_bootstrap.py +752 -0
- diff_diff/staggered_results.py +416 -0
- diff_diff/staggered_triple_diff.py +1545 -0
- diff_diff/staggered_triple_diff_results.py +416 -0
- diff_diff/sun_abraham.py +1685 -0
- diff_diff/survey.py +1981 -0
- diff_diff/synthetic_did.py +1136 -0
- diff_diff/triple_diff.py +2047 -0
- diff_diff/trop.py +952 -0
- diff_diff/trop_global.py +1270 -0
- diff_diff/trop_local.py +1307 -0
- diff_diff/trop_results.py +356 -0
- diff_diff/twfe.py +542 -0
- diff_diff/two_stage.py +1952 -0
- diff_diff/two_stage_bootstrap.py +520 -0
- diff_diff/two_stage_results.py +400 -0
- diff_diff/utils.py +1902 -0
- diff_diff/visualization/__init__.py +61 -0
- diff_diff/visualization/_common.py +328 -0
- diff_diff/visualization/_continuous.py +274 -0
- diff_diff/visualization/_diagnostic.py +817 -0
- diff_diff/visualization/_event_study.py +1086 -0
- diff_diff/visualization/_power.py +661 -0
- diff_diff/visualization/_staggered.py +833 -0
- diff_diff/visualization/_synthetic.py +197 -0
- diff_diff/wooldridge.py +1285 -0
- diff_diff/wooldridge_results.py +349 -0
- diff_diff-3.0.1.dist-info/METADATA +2997 -0
- diff_diff-3.0.1.dist-info/RECORD +62 -0
- diff_diff-3.0.1.dist-info/WHEEL +4 -0
- diff_diff-3.0.1.dist-info/sboms/diff_diff_rust.cyclonedx.json +5843 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Practitioner guidance for Difference-in-Differences analysis.
|
|
3
|
+
|
|
4
|
+
Implements Baker et al. (2025) "Difference-in-Differences Designs:
|
|
5
|
+
A Practitioner's Guide" as context-aware runtime guidance. Call
|
|
6
|
+
``practitioner_next_steps(results)`` after estimation to get a
|
|
7
|
+
structured set of recommended next steps.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Valid step names (Baker et al. 8-step framework)
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
STEPS: Set[str] = {
|
|
17
|
+
"target_parameter",
|
|
18
|
+
"assumptions",
|
|
19
|
+
"parallel_trends",
|
|
20
|
+
"estimator_selection",
|
|
21
|
+
"estimation",
|
|
22
|
+
"sensitivity",
|
|
23
|
+
"heterogeneity",
|
|
24
|
+
"robustness",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Estimator name mapping
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
_ESTIMATOR_NAMES: Dict[str, str] = {
|
|
31
|
+
"DiDResults": "DifferenceInDifferences",
|
|
32
|
+
"MultiPeriodDiDResults": "MultiPeriodDiD (Event Study)",
|
|
33
|
+
"CallawaySantAnnaResults": "CallawaySantAnna",
|
|
34
|
+
"SunAbrahamResults": "SunAbraham",
|
|
35
|
+
"ImputationDiDResults": "ImputationDiD (Borusyak-Jaravel-Spiess)",
|
|
36
|
+
"TwoStageDiDResults": "TwoStageDiD (Gardner)",
|
|
37
|
+
"StackedDiDResults": "StackedDiD",
|
|
38
|
+
"SyntheticDiDResults": "SyntheticDiD",
|
|
39
|
+
"TROPResults": "TROP",
|
|
40
|
+
"EfficientDiDResults": "EfficientDiD",
|
|
41
|
+
"ContinuousDiDResults": "ContinuousDiD",
|
|
42
|
+
"TripleDifferenceResults": "TripleDifference (DDD)",
|
|
43
|
+
"BaconDecompositionResults": "BaconDecomposition",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Public API
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
def practitioner_next_steps(
|
|
50
|
+
results: Any,
|
|
51
|
+
*,
|
|
52
|
+
completed_steps: Optional[List[str]] = None,
|
|
53
|
+
verbose: bool = True,
|
|
54
|
+
) -> Dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Context-aware practitioner guidance based on Baker et al. (2025).
|
|
57
|
+
|
|
58
|
+
Inspects the type and attributes of *results* to recommend which
|
|
59
|
+
Baker et al. steps remain. Returns a structured dict and optionally
|
|
60
|
+
prints a human-readable summary.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
results : Any
|
|
65
|
+
A diff-diff results object (e.g. ``DiDResults``,
|
|
66
|
+
``CallawaySantAnnaResults``, etc.).
|
|
67
|
+
completed_steps : list of str, optional
|
|
68
|
+
Steps the caller has already completed. Valid names:
|
|
69
|
+
``"target_parameter"``, ``"assumptions"``, ``"parallel_trends"``,
|
|
70
|
+
``"estimator_selection"``, ``"estimation"``, ``"sensitivity"``,
|
|
71
|
+
``"heterogeneity"``, ``"robustness"``.
|
|
72
|
+
verbose : bool, default True
|
|
73
|
+
If True, print a human-readable summary to stdout.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
dict
|
|
78
|
+
Keys: ``"estimator"`` (str), ``"completed"`` (list of str),
|
|
79
|
+
``"next_steps"`` (list of dict), ``"warnings"`` (list of str).
|
|
80
|
+
Each next_step dict has: ``"baker_step"`` (int), ``"label"`` (str),
|
|
81
|
+
``"why"`` (str), ``"code"`` (str), ``"priority"`` (str).
|
|
82
|
+
"""
|
|
83
|
+
completed = set(completed_steps or [])
|
|
84
|
+
unknown = completed - STEPS
|
|
85
|
+
if unknown:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Unknown step names: {unknown}. Valid names: {sorted(STEPS)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Estimation is always complete if we have a results object
|
|
91
|
+
completed.add("estimation")
|
|
92
|
+
|
|
93
|
+
type_name = type(results).__name__
|
|
94
|
+
handler = _HANDLERS.get(type_name, _handle_generic)
|
|
95
|
+
steps, warnings = handler(results)
|
|
96
|
+
|
|
97
|
+
# Prepend Steps 1-2 (pre-estimation reasoning) to every handler's output.
|
|
98
|
+
# These are always relevant and filterable via completed_steps.
|
|
99
|
+
pre_estimation = [
|
|
100
|
+
_step(
|
|
101
|
+
baker_step=1,
|
|
102
|
+
label="Define target parameter",
|
|
103
|
+
why=(
|
|
104
|
+
"State explicitly what causal effect you are estimating "
|
|
105
|
+
"(ATT, ATT(g,t), weighted/unweighted) and what policy "
|
|
106
|
+
"question it answers."
|
|
107
|
+
),
|
|
108
|
+
code="# What is the target parameter? ATT? Weighted or unweighted?",
|
|
109
|
+
priority="high",
|
|
110
|
+
step_name="target_parameter",
|
|
111
|
+
),
|
|
112
|
+
_step(
|
|
113
|
+
baker_step=2,
|
|
114
|
+
label="State identification assumptions",
|
|
115
|
+
why=(
|
|
116
|
+
"Name the parallel trends variant you are invoking "
|
|
117
|
+
"(unconditional, conditional, PT-GT-NYT, etc.), the "
|
|
118
|
+
"no-anticipation assumption, and any overlap conditions."
|
|
119
|
+
),
|
|
120
|
+
code="# Which PT variant? No-anticipation? Overlap?",
|
|
121
|
+
priority="high",
|
|
122
|
+
step_name="assumptions",
|
|
123
|
+
),
|
|
124
|
+
]
|
|
125
|
+
steps = pre_estimation + steps
|
|
126
|
+
|
|
127
|
+
# Filter out completed steps
|
|
128
|
+
steps = _filter_steps(steps, completed)
|
|
129
|
+
|
|
130
|
+
output = {
|
|
131
|
+
"estimator": _ESTIMATOR_NAMES.get(type_name, type_name),
|
|
132
|
+
"completed": sorted(completed),
|
|
133
|
+
"next_steps": steps,
|
|
134
|
+
"warnings": warnings,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if verbose:
|
|
138
|
+
_print_output(output)
|
|
139
|
+
|
|
140
|
+
return output
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Step builder helper
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
def _step(
|
|
147
|
+
baker_step: int,
|
|
148
|
+
label: str,
|
|
149
|
+
why: str,
|
|
150
|
+
code: str,
|
|
151
|
+
priority: str = "high",
|
|
152
|
+
step_name: str = "",
|
|
153
|
+
) -> Dict[str, Any]:
|
|
154
|
+
return {
|
|
155
|
+
"baker_step": baker_step,
|
|
156
|
+
"label": label,
|
|
157
|
+
"why": why,
|
|
158
|
+
"code": code,
|
|
159
|
+
"priority": priority,
|
|
160
|
+
"_step_name": step_name,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# Common steps reused across handlers
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
def _parallel_trends_step(staggered: bool = False) -> Dict[str, Any]:
|
|
168
|
+
if staggered:
|
|
169
|
+
return _step(
|
|
170
|
+
baker_step=3,
|
|
171
|
+
label="Test parallel trends (event-study pre-periods)",
|
|
172
|
+
why=(
|
|
173
|
+
"For staggered designs, inspect event-study pre-period "
|
|
174
|
+
"coefficients rather than the generic check_parallel_trends() "
|
|
175
|
+
"which assumes a single binary treatment with universal "
|
|
176
|
+
"pre-periods. Pre-treatment ATTs should be near zero. "
|
|
177
|
+
"Use CS with aggregate='event_study' or check the estimator's "
|
|
178
|
+
"event-study output directly."
|
|
179
|
+
),
|
|
180
|
+
code=(
|
|
181
|
+
"# Inspect pre-treatment event-study coefficients:\n"
|
|
182
|
+
"# (available after fitting with event-study aggregation)\n"
|
|
183
|
+
"# Pre-period effects should be near zero and insignificant."
|
|
184
|
+
),
|
|
185
|
+
step_name="parallel_trends",
|
|
186
|
+
)
|
|
187
|
+
return _step(
|
|
188
|
+
baker_step=3,
|
|
189
|
+
label="Test parallel trends assumption",
|
|
190
|
+
why=(
|
|
191
|
+
"Parallel trends is the core identifying assumption. "
|
|
192
|
+
"Insignificant pre-trends do NOT prove it holds. For "
|
|
193
|
+
"MultiPeriodDiD or CS results, use HonestDiD to bound "
|
|
194
|
+
"the impact of violations."
|
|
195
|
+
),
|
|
196
|
+
code=(
|
|
197
|
+
"from diff_diff import check_parallel_trends\n"
|
|
198
|
+
"pt = check_parallel_trends(data, outcome='y', time='period',\n"
|
|
199
|
+
" treatment_group='treated')"
|
|
200
|
+
),
|
|
201
|
+
step_name="parallel_trends",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _honest_did_step() -> Dict[str, Any]:
|
|
206
|
+
return _step(
|
|
207
|
+
baker_step=6,
|
|
208
|
+
label="Run HonestDiD sensitivity analysis",
|
|
209
|
+
why=(
|
|
210
|
+
"Bounds the treatment effect under plausible violations of "
|
|
211
|
+
"parallel trends. Essential for assessing result robustness."
|
|
212
|
+
),
|
|
213
|
+
code=(
|
|
214
|
+
"from diff_diff import compute_honest_did\n"
|
|
215
|
+
"honest = compute_honest_did(results, method='relative_magnitude', M=1.0)\n"
|
|
216
|
+
"print(honest.summary())"
|
|
217
|
+
),
|
|
218
|
+
step_name="sensitivity",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _placebo_step() -> Dict[str, Any]:
|
|
223
|
+
"""Placebo tests for simple 2x2 DiD designs only."""
|
|
224
|
+
return _step(
|
|
225
|
+
baker_step=6,
|
|
226
|
+
label="Run placebo tests",
|
|
227
|
+
why=(
|
|
228
|
+
"Falsification tests using fake timing, permutation, and "
|
|
229
|
+
"leave-one-out diagnostics to probe assumption validity."
|
|
230
|
+
),
|
|
231
|
+
code=(
|
|
232
|
+
"from diff_diff import run_all_placebo_tests\n"
|
|
233
|
+
"# Requires binary time indicator (post=0/1), not multi-period:\n"
|
|
234
|
+
"placebo = run_all_placebo_tests(\n"
|
|
235
|
+
" data, outcome='y', treatment='treated', time='post',\n"
|
|
236
|
+
" unit='unit_id', pre_periods=[0], post_periods=[1],\n"
|
|
237
|
+
" n_permutations=500, seed=42)"
|
|
238
|
+
),
|
|
239
|
+
priority="medium",
|
|
240
|
+
step_name="sensitivity",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _robustness_compare_step(alternatives: str) -> Dict[str, Any]:
|
|
245
|
+
return _step(
|
|
246
|
+
baker_step=8,
|
|
247
|
+
label=f"Compare with alternative estimators ({alternatives})",
|
|
248
|
+
why=(
|
|
249
|
+
"Agreement across estimators with different assumptions "
|
|
250
|
+
"strengthens conclusions. Disagreement reveals sensitivity."
|
|
251
|
+
),
|
|
252
|
+
code=(
|
|
253
|
+
f"# Re-estimate with {alternatives} and compare ATT, SE, CI\n"
|
|
254
|
+
f"# If results agree, confidence increases.\n"
|
|
255
|
+
f"# If they disagree, investigate which assumptions differ."
|
|
256
|
+
),
|
|
257
|
+
step_name="robustness",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _covariates_step() -> Dict[str, Any]:
|
|
262
|
+
return _step(
|
|
263
|
+
baker_step=8,
|
|
264
|
+
label="Report with and without covariates",
|
|
265
|
+
why=(
|
|
266
|
+
"Shows whether results are sensitive to covariate conditioning. "
|
|
267
|
+
"Large shifts suggest covariates are driving identification."
|
|
268
|
+
),
|
|
269
|
+
code=(
|
|
270
|
+
"# Re-estimate without covariates and compare:\n"
|
|
271
|
+
"result_no_cov = estimator.fit(data, ..., covariates=None)\n"
|
|
272
|
+
"# Compare ATT with and without covariates.\n"
|
|
273
|
+
"# Use .att (basic DiD) or .overall_att (staggered estimators)."
|
|
274
|
+
),
|
|
275
|
+
priority="medium",
|
|
276
|
+
step_name="robustness",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Per-type handlers — each returns (steps, warnings)
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
def _handle_did(results: Any):
|
|
284
|
+
steps = [
|
|
285
|
+
_step(
|
|
286
|
+
baker_step=3,
|
|
287
|
+
label="Test parallel trends assumption",
|
|
288
|
+
why=(
|
|
289
|
+
"Parallel trends is the core identifying assumption. "
|
|
290
|
+
"Insignificant pre-trends do NOT prove it holds."
|
|
291
|
+
),
|
|
292
|
+
code=(
|
|
293
|
+
"from diff_diff import check_parallel_trends\n"
|
|
294
|
+
"pt = check_parallel_trends(data, outcome='y', time='period',\n"
|
|
295
|
+
" treatment_group='treated')"
|
|
296
|
+
),
|
|
297
|
+
step_name="parallel_trends",
|
|
298
|
+
),
|
|
299
|
+
_placebo_step(), # valid: basic 2x2 DiD with binary time
|
|
300
|
+
_step(
|
|
301
|
+
baker_step=4,
|
|
302
|
+
label="Check if data is actually staggered",
|
|
303
|
+
why=(
|
|
304
|
+
"If treatment timing varies across units, basic DiD produces "
|
|
305
|
+
"biased estimates. Use CallawaySantAnna or another "
|
|
306
|
+
"heterogeneity-robust estimator instead."
|
|
307
|
+
),
|
|
308
|
+
code=(
|
|
309
|
+
"# Check if there are multiple treatment cohorts:\n"
|
|
310
|
+
"print(data.groupby('unit')['treatment_date'].first().nunique())\n"
|
|
311
|
+
"# If > 1 cohort, switch to CallawaySantAnna"
|
|
312
|
+
),
|
|
313
|
+
step_name="estimator_selection",
|
|
314
|
+
),
|
|
315
|
+
]
|
|
316
|
+
warnings = _check_nan_att(results)
|
|
317
|
+
return steps, warnings
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _handle_multi_period(results: Any):
|
|
321
|
+
steps = [
|
|
322
|
+
_parallel_trends_step(),
|
|
323
|
+
_honest_did_step(),
|
|
324
|
+
# Note: run_all_placebo_tests() requires binary time indicator,
|
|
325
|
+
# which MultiPeriodDiD does not use. Omit placebo for this type.
|
|
326
|
+
_robustness_compare_step("CS, SA, or BJS"),
|
|
327
|
+
]
|
|
328
|
+
warnings = _check_nan_att(results)
|
|
329
|
+
return steps, warnings
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _handle_cs(results: Any):
|
|
333
|
+
steps = [
|
|
334
|
+
_parallel_trends_step(staggered=True),
|
|
335
|
+
_step(
|
|
336
|
+
baker_step=6,
|
|
337
|
+
label="Run HonestDiD sensitivity analysis",
|
|
338
|
+
why=(
|
|
339
|
+
"Bounds the treatment effect under plausible violations of "
|
|
340
|
+
"parallel trends. Requires event study effects — refit with "
|
|
341
|
+
"aggregate='event_study' or 'all' if not already done."
|
|
342
|
+
),
|
|
343
|
+
code=(
|
|
344
|
+
"from diff_diff import compute_honest_did\n"
|
|
345
|
+
"# CS results must have event_study_effects:\n"
|
|
346
|
+
"results = cs.fit(data, ..., aggregate='event_study')\n"
|
|
347
|
+
"honest = compute_honest_did(results, method='relative_magnitude', M=1.0)\n"
|
|
348
|
+
"print(honest.summary())"
|
|
349
|
+
),
|
|
350
|
+
step_name="sensitivity",
|
|
351
|
+
),
|
|
352
|
+
_step(
|
|
353
|
+
baker_step=7,
|
|
354
|
+
label="Examine group and event study effects",
|
|
355
|
+
why=(
|
|
356
|
+
"Aggregate ATT may mask heterogeneity across cohorts or "
|
|
357
|
+
"dynamic effects over time. Inspect group and event study "
|
|
358
|
+
"aggregations."
|
|
359
|
+
),
|
|
360
|
+
code=(
|
|
361
|
+
"# Re-fit with aggregate='all' to get all aggregations:\n"
|
|
362
|
+
"results = cs.fit(data, ..., aggregate='all')\n"
|
|
363
|
+
"print(results.group_effects) # Per-cohort ATTs\n"
|
|
364
|
+
"print(results.event_study_effects) # Dynamic effects"
|
|
365
|
+
),
|
|
366
|
+
step_name="heterogeneity",
|
|
367
|
+
),
|
|
368
|
+
_robustness_compare_step("SA, BJS, or Gardner"),
|
|
369
|
+
_covariates_step(),
|
|
370
|
+
]
|
|
371
|
+
warnings = _check_nan_att(results)
|
|
372
|
+
return steps, warnings
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _handle_sa(results: Any):
|
|
376
|
+
steps = [
|
|
377
|
+
_parallel_trends_step(staggered=True),
|
|
378
|
+
_step(
|
|
379
|
+
baker_step=6,
|
|
380
|
+
label="Specification-based falsification",
|
|
381
|
+
why=(
|
|
382
|
+
"Compare results across control group definitions "
|
|
383
|
+
"(never_treated vs not_yet_treated) and anticipation "
|
|
384
|
+
"settings to assess robustness."
|
|
385
|
+
),
|
|
386
|
+
code=(
|
|
387
|
+
"# Re-estimate with different control group / anticipation:\n"
|
|
388
|
+
"# sa_alt = SunAbraham(control_group='not_yet_treated')"
|
|
389
|
+
),
|
|
390
|
+
priority="medium",
|
|
391
|
+
step_name="sensitivity",
|
|
392
|
+
),
|
|
393
|
+
_step(
|
|
394
|
+
baker_step=7,
|
|
395
|
+
label="Examine event-study and cohort effects",
|
|
396
|
+
why=(
|
|
397
|
+
"SunAbraham results include event_study_effects (dynamic "
|
|
398
|
+
"effects by relative period) and cohort_effects (per-cohort "
|
|
399
|
+
"effects). Note: SA does not have an aggregate parameter — "
|
|
400
|
+
"these are computed automatically during fit()."
|
|
401
|
+
),
|
|
402
|
+
code=(
|
|
403
|
+
"# SA event-study effects:\n"
|
|
404
|
+
"sa_es_df = results.to_dataframe(level='event_study')\n"
|
|
405
|
+
"# SA cohort effects:\n"
|
|
406
|
+
"sa_cohort_df = results.to_dataframe(level='cohort')"
|
|
407
|
+
),
|
|
408
|
+
step_name="heterogeneity",
|
|
409
|
+
),
|
|
410
|
+
_robustness_compare_step("CS, BJS, or Gardner"),
|
|
411
|
+
_covariates_step(),
|
|
412
|
+
]
|
|
413
|
+
warnings = _check_nan_att(results)
|
|
414
|
+
return steps, warnings
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _handle_imputation(results: Any):
|
|
418
|
+
steps = [
|
|
419
|
+
_parallel_trends_step(staggered=True),
|
|
420
|
+
_step(
|
|
421
|
+
baker_step=6,
|
|
422
|
+
label="Specification-based falsification",
|
|
423
|
+
why=(
|
|
424
|
+
"ImputationDiD does not have a control_group parameter. "
|
|
425
|
+
"Compare results with and without covariates, vary the "
|
|
426
|
+
"sample (drop cohorts), and compare with CS/SA as "
|
|
427
|
+
"falsification checks."
|
|
428
|
+
),
|
|
429
|
+
code=(
|
|
430
|
+
"# Compare with alternative estimators as robustness:\n"
|
|
431
|
+
"# Leave-one-cohort-out sensitivity analysis"
|
|
432
|
+
),
|
|
433
|
+
priority="medium",
|
|
434
|
+
step_name="sensitivity",
|
|
435
|
+
),
|
|
436
|
+
_robustness_compare_step("CS, SA, or Gardner"),
|
|
437
|
+
_covariates_step(),
|
|
438
|
+
]
|
|
439
|
+
warnings = _check_nan_att(results)
|
|
440
|
+
return steps, warnings
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _handle_two_stage(results: Any):
|
|
444
|
+
steps = [
|
|
445
|
+
_parallel_trends_step(staggered=True),
|
|
446
|
+
_step(
|
|
447
|
+
baker_step=6,
|
|
448
|
+
label="Specification-based falsification",
|
|
449
|
+
why=(
|
|
450
|
+
"TwoStageDiD does not have a control_group parameter. "
|
|
451
|
+
"Compare results with and without covariates, vary the "
|
|
452
|
+
"sample (drop cohorts), and compare with CS/SA as "
|
|
453
|
+
"falsification checks."
|
|
454
|
+
),
|
|
455
|
+
code=(
|
|
456
|
+
"# Compare with alternative estimators as robustness:\n"
|
|
457
|
+
"# Leave-one-cohort-out sensitivity analysis"
|
|
458
|
+
),
|
|
459
|
+
priority="medium",
|
|
460
|
+
step_name="sensitivity",
|
|
461
|
+
),
|
|
462
|
+
_robustness_compare_step("CS, BJS, or SA"),
|
|
463
|
+
_covariates_step(),
|
|
464
|
+
]
|
|
465
|
+
warnings = _check_nan_att(results)
|
|
466
|
+
return steps, warnings
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _handle_stacked(results: Any):
|
|
470
|
+
steps = [
|
|
471
|
+
_parallel_trends_step(staggered=True),
|
|
472
|
+
_step(
|
|
473
|
+
baker_step=6,
|
|
474
|
+
label="Vary clean control definition",
|
|
475
|
+
why=(
|
|
476
|
+
"StackedDiD uses clean_control parameter (not control_group). "
|
|
477
|
+
"Compare results with different clean control definitions "
|
|
478
|
+
"and event window widths as falsification."
|
|
479
|
+
),
|
|
480
|
+
code=(
|
|
481
|
+
"# Re-estimate with different clean_control settings:\n"
|
|
482
|
+
"# stacked_alt = StackedDiD(clean_control='not_yet_treated')"
|
|
483
|
+
),
|
|
484
|
+
priority="medium",
|
|
485
|
+
step_name="sensitivity",
|
|
486
|
+
),
|
|
487
|
+
_step(
|
|
488
|
+
baker_step=7,
|
|
489
|
+
label="Check sub-experiment balance",
|
|
490
|
+
why=(
|
|
491
|
+
"Stacked DiD constructs sub-experiments for each cohort. "
|
|
492
|
+
"Verify that each sub-experiment has sufficient controls."
|
|
493
|
+
),
|
|
494
|
+
code="# Check results.n_sub_experiments and inspect results.stacked_data",
|
|
495
|
+
priority="medium",
|
|
496
|
+
step_name="heterogeneity",
|
|
497
|
+
),
|
|
498
|
+
_robustness_compare_step("CS, SA, or BJS"),
|
|
499
|
+
]
|
|
500
|
+
warnings = _check_nan_att(results)
|
|
501
|
+
return steps, warnings
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _handle_synthetic(results: Any):
|
|
505
|
+
steps = [
|
|
506
|
+
_step(
|
|
507
|
+
baker_step=6,
|
|
508
|
+
label="Check pre-treatment fit quality",
|
|
509
|
+
why=(
|
|
510
|
+
"Synthetic DiD relies on pre-treatment fit to construct "
|
|
511
|
+
"weights. Poor fit suggests the synthetic control may not "
|
|
512
|
+
"approximate the counterfactual well."
|
|
513
|
+
),
|
|
514
|
+
code=(
|
|
515
|
+
"# Check pre-treatment fit and unit weight concentration:\n"
|
|
516
|
+
"print(f'Pre-treatment fit (RMSE): {results.pre_treatment_fit:.4f}')\n"
|
|
517
|
+
"# Highly concentrated weights suggest fragile estimates"
|
|
518
|
+
),
|
|
519
|
+
step_name="sensitivity",
|
|
520
|
+
),
|
|
521
|
+
_step(
|
|
522
|
+
baker_step=6,
|
|
523
|
+
label="In-time or in-space placebo",
|
|
524
|
+
why=(
|
|
525
|
+
"Test robustness by re-estimating on a placebo treatment "
|
|
526
|
+
"period (in-time) or excluding treated units one at a time "
|
|
527
|
+
"(leave-one-out). These are the natural falsification "
|
|
528
|
+
"checks for synthetic control methods."
|
|
529
|
+
),
|
|
530
|
+
code=(
|
|
531
|
+
"# In-time placebo: re-estimate with a fake treatment date\n"
|
|
532
|
+
"# Leave-one-out: drop each treated unit and re-estimate"
|
|
533
|
+
),
|
|
534
|
+
priority="medium",
|
|
535
|
+
step_name="sensitivity",
|
|
536
|
+
),
|
|
537
|
+
_step(
|
|
538
|
+
baker_step=8,
|
|
539
|
+
label="Compare with staggered estimators (CS, SA)",
|
|
540
|
+
why=(
|
|
541
|
+
"SyntheticDiD is for few treated units; compare with "
|
|
542
|
+
"staggered estimators if applicable. Use TROP only if "
|
|
543
|
+
"factor confounding is suspected (different use case)."
|
|
544
|
+
),
|
|
545
|
+
code=(
|
|
546
|
+
"from diff_diff import CallawaySantAnna\n"
|
|
547
|
+
"cs = CallawaySantAnna()\n"
|
|
548
|
+
"cs_result = cs.fit(data, ...)\n"
|
|
549
|
+
"print(f'SDiD ATT: {results.att:.4f}, CS ATT: {cs_result.overall_att:.4f}')"
|
|
550
|
+
),
|
|
551
|
+
step_name="robustness",
|
|
552
|
+
),
|
|
553
|
+
]
|
|
554
|
+
warnings = _check_nan_att(results)
|
|
555
|
+
return steps, warnings
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _handle_trop(results: Any):
|
|
559
|
+
steps = [
|
|
560
|
+
_step(
|
|
561
|
+
baker_step=6,
|
|
562
|
+
label="Verify factor structure assumptions",
|
|
563
|
+
why=(
|
|
564
|
+
"TROP assumes an approximate factor model for untreated "
|
|
565
|
+
"potential outcomes. If the factor structure is misspecified, "
|
|
566
|
+
"estimates may be biased."
|
|
567
|
+
),
|
|
568
|
+
code=(
|
|
569
|
+
"# Check LOOCV-selected number of factors:\n"
|
|
570
|
+
"# Compare with SyntheticDiD as a robustness check"
|
|
571
|
+
),
|
|
572
|
+
step_name="sensitivity",
|
|
573
|
+
),
|
|
574
|
+
_step(
|
|
575
|
+
baker_step=6,
|
|
576
|
+
label="In-time or in-space placebo",
|
|
577
|
+
why=(
|
|
578
|
+
"Test robustness by re-estimating on a placebo treatment "
|
|
579
|
+
"period or dropping treated units one at a time. These "
|
|
580
|
+
"are the natural falsification checks for factor-model "
|
|
581
|
+
"panel estimators."
|
|
582
|
+
),
|
|
583
|
+
code=(
|
|
584
|
+
"# In-time placebo: re-estimate with a fake treatment date\n"
|
|
585
|
+
"# Leave-one-out: drop each treated unit and re-estimate"
|
|
586
|
+
),
|
|
587
|
+
priority="medium",
|
|
588
|
+
step_name="sensitivity",
|
|
589
|
+
),
|
|
590
|
+
_robustness_compare_step("SyntheticDiD or CS"),
|
|
591
|
+
]
|
|
592
|
+
warnings = _check_nan_att(results)
|
|
593
|
+
return steps, warnings
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _handle_efficient(results: Any):
|
|
597
|
+
steps = [
|
|
598
|
+
_parallel_trends_step(staggered=True),
|
|
599
|
+
_step(
|
|
600
|
+
baker_step=6,
|
|
601
|
+
label="Compare control group definitions",
|
|
602
|
+
why=(
|
|
603
|
+
"EfficientDiD supports never_treated and last_cohort "
|
|
604
|
+
"control groups (not not_yet_treated). Compare results "
|
|
605
|
+
"across both to assess robustness."
|
|
606
|
+
),
|
|
607
|
+
code=(
|
|
608
|
+
"# Re-estimate with alternative control group:\n"
|
|
609
|
+
"# edid_alt = EfficientDiD(control_group='last_cohort')"
|
|
610
|
+
),
|
|
611
|
+
priority="medium",
|
|
612
|
+
step_name="sensitivity",
|
|
613
|
+
),
|
|
614
|
+
_step(
|
|
615
|
+
baker_step=7,
|
|
616
|
+
label="Run Hausman pretest (PT-All vs PT-Post)",
|
|
617
|
+
why=(
|
|
618
|
+
"EfficientDiD supports both PT-All and PT-Post assumptions. "
|
|
619
|
+
"The Hausman pretest compares them — report which was selected."
|
|
620
|
+
),
|
|
621
|
+
code=(
|
|
622
|
+
"# Hausman pretest is a classmethod on the estimator:\n"
|
|
623
|
+
"from diff_diff import EfficientDiD\n"
|
|
624
|
+
"pretest = EfficientDiD.hausman_pretest(\n"
|
|
625
|
+
" data, outcome='y', unit='id', time='t', first_treat='g')"
|
|
626
|
+
),
|
|
627
|
+
step_name="heterogeneity",
|
|
628
|
+
),
|
|
629
|
+
_robustness_compare_step("CS, SA, or BJS"),
|
|
630
|
+
_covariates_step(),
|
|
631
|
+
]
|
|
632
|
+
warnings = _check_nan_att(results)
|
|
633
|
+
return steps, warnings
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _handle_continuous(results: Any):
|
|
637
|
+
steps = [
|
|
638
|
+
_step(
|
|
639
|
+
baker_step=3,
|
|
640
|
+
label="Assess parallel trends for continuous treatment",
|
|
641
|
+
why=(
|
|
642
|
+
"ContinuousDiD has dose-specific parallel trends assumptions "
|
|
643
|
+
"(PT/SPT) that differ from the binary treatment case. No "
|
|
644
|
+
"built-in formal test exists; inspect dose-specific "
|
|
645
|
+
"pre-treatment outcome trends across dose groups manually."
|
|
646
|
+
),
|
|
647
|
+
code=(
|
|
648
|
+
"# No built-in formal PT test for continuous treatment.\n"
|
|
649
|
+
"# Inspect pre-treatment outcome trends by dose group."
|
|
650
|
+
),
|
|
651
|
+
step_name="parallel_trends",
|
|
652
|
+
),
|
|
653
|
+
_step(
|
|
654
|
+
baker_step=7,
|
|
655
|
+
label="Plot dose-response curve",
|
|
656
|
+
why=(
|
|
657
|
+
"Continuous DiD estimates treatment effects at each dose "
|
|
658
|
+
"level. The dose-response curve reveals the functional form "
|
|
659
|
+
"of the treatment-dose relationship."
|
|
660
|
+
),
|
|
661
|
+
code=(
|
|
662
|
+
"from diff_diff import plot_dose_response\n"
|
|
663
|
+
"plot_dose_response(results)"
|
|
664
|
+
),
|
|
665
|
+
step_name="heterogeneity",
|
|
666
|
+
),
|
|
667
|
+
_step(
|
|
668
|
+
baker_step=6,
|
|
669
|
+
label="Check dose distribution",
|
|
670
|
+
why=(
|
|
671
|
+
"Sparse regions of the dose distribution produce imprecise "
|
|
672
|
+
"estimates. Verify sufficient support across dose values."
|
|
673
|
+
),
|
|
674
|
+
code="# Inspect the distribution of treatment doses in your data",
|
|
675
|
+
priority="medium",
|
|
676
|
+
step_name="sensitivity",
|
|
677
|
+
),
|
|
678
|
+
]
|
|
679
|
+
warnings = _check_nan_att(results)
|
|
680
|
+
return steps, warnings
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _handle_triple(results: Any):
|
|
684
|
+
steps = [
|
|
685
|
+
_step(
|
|
686
|
+
baker_step=3,
|
|
687
|
+
label="Assess DDD identifying assumption",
|
|
688
|
+
why=(
|
|
689
|
+
"DDD identification is weaker than requiring separate "
|
|
690
|
+
"parallel trends for two DiDs — it allows group-specific "
|
|
691
|
+
"and partition-specific PT violations as long as they "
|
|
692
|
+
"cancel in the triple difference. No built-in formal "
|
|
693
|
+
"test exists; inspect pre-treatment outcome patterns "
|
|
694
|
+
"across the treatment/eligibility/time cells."
|
|
695
|
+
),
|
|
696
|
+
code=(
|
|
697
|
+
"# No built-in formal DDD assumption test.\n"
|
|
698
|
+
"# Inspect pre-treatment means across treatment x eligibility\n"
|
|
699
|
+
"# cells to assess whether the DDD structure is plausible."
|
|
700
|
+
),
|
|
701
|
+
step_name="parallel_trends",
|
|
702
|
+
),
|
|
703
|
+
_step(
|
|
704
|
+
baker_step=7,
|
|
705
|
+
label="Test placebo group",
|
|
706
|
+
why=(
|
|
707
|
+
"Re-estimate using a placebo eligibility group to check "
|
|
708
|
+
"whether the DDD result could be an artifact of the "
|
|
709
|
+
"group structure rather than the treatment."
|
|
710
|
+
),
|
|
711
|
+
code="# Re-estimate with a placebo eligibility group",
|
|
712
|
+
step_name="heterogeneity",
|
|
713
|
+
),
|
|
714
|
+
_covariates_step(),
|
|
715
|
+
]
|
|
716
|
+
warnings = _check_nan_att(results)
|
|
717
|
+
return steps, warnings
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _handle_bacon(results: Any):
|
|
721
|
+
steps = [
|
|
722
|
+
_step(
|
|
723
|
+
baker_step=4,
|
|
724
|
+
label="Switch to heterogeneity-robust estimator",
|
|
725
|
+
why=(
|
|
726
|
+
"Bacon decomposition is diagnostic, not an estimator. "
|
|
727
|
+
"If substantial weight falls on 'later vs earlier' "
|
|
728
|
+
"comparisons, TWFE is biased. Use CS, SA, BJS, or another "
|
|
729
|
+
"heterogeneity-robust estimator for causal estimates."
|
|
730
|
+
),
|
|
731
|
+
code=(
|
|
732
|
+
"from diff_diff import CallawaySantAnna\n"
|
|
733
|
+
"cs = CallawaySantAnna(control_group='never_treated',\n"
|
|
734
|
+
" estimation_method='dr')\n"
|
|
735
|
+
"results = cs.fit(data, ...)"
|
|
736
|
+
),
|
|
737
|
+
step_name="estimator_selection",
|
|
738
|
+
),
|
|
739
|
+
]
|
|
740
|
+
warnings = []
|
|
741
|
+
# Check for forbidden comparisons (later vs earlier treated)
|
|
742
|
+
weight = getattr(results, "total_weight_later_vs_earlier", 0)
|
|
743
|
+
if isinstance(weight, (int, float)) and weight > 0.01:
|
|
744
|
+
warnings.append(
|
|
745
|
+
f"Forbidden comparisons (later vs earlier treated) carry "
|
|
746
|
+
f"{weight:.0%} of TWFE weight — TWFE estimate is contaminated. "
|
|
747
|
+
f"Switch to a heterogeneity-robust estimator."
|
|
748
|
+
)
|
|
749
|
+
return steps, warnings
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _handle_generic(results: Any):
|
|
753
|
+
"""Fallback for unknown result types."""
|
|
754
|
+
steps = [
|
|
755
|
+
_parallel_trends_step(),
|
|
756
|
+
_step(
|
|
757
|
+
baker_step=6,
|
|
758
|
+
label="Run sensitivity analysis",
|
|
759
|
+
why=(
|
|
760
|
+
"Without sensitivity analysis, you cannot assess how "
|
|
761
|
+
"robust results are to assumption violations."
|
|
762
|
+
),
|
|
763
|
+
code=(
|
|
764
|
+
"# Use compute_honest_did() if result type supports it,\n"
|
|
765
|
+
"# or run_all_placebo_tests() for falsification."
|
|
766
|
+
),
|
|
767
|
+
step_name="sensitivity",
|
|
768
|
+
),
|
|
769
|
+
_step(
|
|
770
|
+
baker_step=8,
|
|
771
|
+
label="Compare with alternative estimators",
|
|
772
|
+
why=(
|
|
773
|
+
"Different estimators make different assumptions. "
|
|
774
|
+
"Agreement strengthens conclusions."
|
|
775
|
+
),
|
|
776
|
+
code="# Re-estimate with a different estimator and compare",
|
|
777
|
+
step_name="robustness",
|
|
778
|
+
),
|
|
779
|
+
]
|
|
780
|
+
warnings = _check_nan_att(results)
|
|
781
|
+
return steps, warnings
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ---------------------------------------------------------------------------
|
|
785
|
+
# Handler registry — maps result type *names* (not classes) to avoid
|
|
786
|
+
# import-time circular dependencies
|
|
787
|
+
# ---------------------------------------------------------------------------
|
|
788
|
+
_HANDLERS = {
|
|
789
|
+
"DiDResults": _handle_did,
|
|
790
|
+
"MultiPeriodDiDResults": _handle_multi_period,
|
|
791
|
+
"CallawaySantAnnaResults": _handle_cs,
|
|
792
|
+
"SunAbrahamResults": _handle_sa,
|
|
793
|
+
"ImputationDiDResults": _handle_imputation,
|
|
794
|
+
"TwoStageDiDResults": _handle_two_stage,
|
|
795
|
+
"StackedDiDResults": _handle_stacked,
|
|
796
|
+
"SyntheticDiDResults": _handle_synthetic,
|
|
797
|
+
"TROPResults": _handle_trop,
|
|
798
|
+
"EfficientDiDResults": _handle_efficient,
|
|
799
|
+
"ContinuousDiDResults": _handle_continuous,
|
|
800
|
+
"TripleDifferenceResults": _handle_triple,
|
|
801
|
+
"BaconDecompositionResults": _handle_bacon,
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ---------------------------------------------------------------------------
|
|
806
|
+
# Internal helpers
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
def _check_nan_att(results: Any) -> List[str]:
|
|
809
|
+
"""Return warnings if ATT is NaN."""
|
|
810
|
+
# Check .att (DiDResults), .overall_att (staggered), .avg_att (MultiPeriod)
|
|
811
|
+
att = getattr(results, "att", None)
|
|
812
|
+
if att is None:
|
|
813
|
+
att = getattr(results, "overall_att", None)
|
|
814
|
+
if att is None:
|
|
815
|
+
att = getattr(results, "avg_att", None)
|
|
816
|
+
if att is not None:
|
|
817
|
+
try:
|
|
818
|
+
att = float(att)
|
|
819
|
+
except (TypeError, ValueError):
|
|
820
|
+
return []
|
|
821
|
+
if att is not None and math.isnan(att):
|
|
822
|
+
return [
|
|
823
|
+
"Estimation produced NaN ATT — check data preparation and "
|
|
824
|
+
"model specification before proceeding with diagnostics."
|
|
825
|
+
]
|
|
826
|
+
return []
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _filter_steps(
|
|
830
|
+
steps: List[Dict[str, Any]], completed: Set[str]
|
|
831
|
+
) -> List[Dict[str, Any]]:
|
|
832
|
+
"""Remove steps whose _step_name is in the completed set."""
|
|
833
|
+
filtered = []
|
|
834
|
+
for s in steps:
|
|
835
|
+
step_name = s.get("_step_name", "")
|
|
836
|
+
if step_name not in completed:
|
|
837
|
+
# Remove internal field from output
|
|
838
|
+
out = {k: v for k, v in s.items() if k != "_step_name"}
|
|
839
|
+
filtered.append(out)
|
|
840
|
+
return filtered
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _print_output(output: Dict[str, Any]) -> None:
|
|
844
|
+
"""Print human-readable guidance to stdout."""
|
|
845
|
+
print(f"\n{'='*60}")
|
|
846
|
+
print(f"Practitioner Guidance — {output['estimator']}")
|
|
847
|
+
print("Baker et al. (2025) 8-Step Workflow")
|
|
848
|
+
print(f"{'='*60}")
|
|
849
|
+
|
|
850
|
+
if output["warnings"]:
|
|
851
|
+
print("\nWARNINGS:")
|
|
852
|
+
for w in output["warnings"]:
|
|
853
|
+
print(f" ! {w}")
|
|
854
|
+
|
|
855
|
+
if output["next_steps"]:
|
|
856
|
+
print(f"\nRecommended next steps ({len(output['next_steps'])} remaining):")
|
|
857
|
+
for step in output["next_steps"]:
|
|
858
|
+
priority = step.get("priority", "high")
|
|
859
|
+
marker = "*" if priority == "high" else "-"
|
|
860
|
+
print(f"\n {marker} [{priority.upper()}] Step {step['baker_step']}: "
|
|
861
|
+
f"{step['label']}")
|
|
862
|
+
print(f" Why: {step['why']}")
|
|
863
|
+
if step.get("code"):
|
|
864
|
+
for line in step["code"].split("\n"):
|
|
865
|
+
print(f" >>> {line}")
|
|
866
|
+
else:
|
|
867
|
+
print("\nAll Baker et al. steps completed!")
|
|
868
|
+
|
|
869
|
+
print(f"\n{'='*60}\n")
|