forked from igerber/diff-diff
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdiagnostic_report.py
More file actions
3380 lines (3175 loc) · 154 KB
/
Copy pathdiagnostic_report.py
File metadata and controls
3380 lines (3175 loc) · 154 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
DiagnosticReport — unified, plain-English validity assessment for diff-diff results.
Orchestrates the library's existing diagnostic functions (parallel trends,
pre-trends power, HonestDiD sensitivity, Goodman-Bacon, design-effect
diagnostics, EPV, heterogeneity, and estimator-native checks for SDiD/TROP)
into a single report with a stable AI-legible schema.
Design principles:
- No hard pass/fail gates. Severity is conveyed by natural-language phrasing,
not a traffic-light enum. See ``docs/methodology/REPORTING.md``.
- No estimator fitting and no variance re-derivation from raw data. Every
effect, SE, p-value, CI, and sensitivity bound is either read from
``results`` or produced by an existing diff-diff utility. May call
``check_parallel_trends`` / ``bacon_decompose`` /
``EfficientDiD.hausman_pretest`` when the caller supplies the panel +
column kwargs. Report-layer cross-period aggregations (joint-Wald /
Bonferroni pre-trends p-value, heterogeneity dispersion over
post-treatment effects) are enumerated in
``docs/methodology/REPORTING.md``.
- Lazy evaluation. ``DiagnosticReport(results, ...)`` is free; ``run_all()``
triggers compute and caches.
- Never prove a null. Pre-trends phrasing uses power information from
``compute_pretrends_power`` to distinguish well-powered from underpowered
non-violations.
The ``to_dict()`` surface is an AI-legible contract. See the schema reference
in ``docs/methodology/REPORTING.md`` and the ``DIAGNOSTIC_REPORT_SCHEMA_VERSION``
constant below. The schema is marked experimental in v3.2.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, FrozenSet, List, Optional, Tuple
import numpy as np
import pandas as pd
from diff_diff._reporting_helpers import describe_target_parameter # noqa: E402 (top-level import)
DIAGNOSTIC_REPORT_SCHEMA_VERSION = "2.0"
__all__ = [
"DiagnosticReport",
"DiagnosticReportResults",
"DIAGNOSTIC_REPORT_SCHEMA_VERSION",
]
# ---------------------------------------------------------------------------
# Canonical check names and per-type applicability
# ---------------------------------------------------------------------------
# The set of check names that ``DiagnosticReport`` supports.
_CHECK_NAMES: Tuple[str, ...] = (
"parallel_trends",
"pretrends_power",
"sensitivity",
"bacon",
"design_effect",
"heterogeneity",
"epv",
"estimator_native",
"placebo",
)
# Type-level applicability: which checks are *ever* applicable for each of the
# 16 result types. Instance-level applicability further filters by whether
# required attributes are present (e.g. ``survey_metadata`` for DEFF) and by
# whether the user disabled a check via ``run_*=False``.
# See ``docs/methodology/REPORTING.md`` for the full matrix and rationale.
#
# Implementation note: The keys are result-class names looked up via
# ``type(results).__name__``. This string-based dispatch mirrors the
# ``_HANDLERS`` pattern in ``diff_diff/practitioner.py`` and avoids circular
# imports across the 16 result modules. Renaming or aliasing any result class
# requires updating both this table and ``_PT_METHOD`` below; the
# applicability-matrix test parametrized over all result types serves as the
# regression guard.
# ``pretrends_power`` is restricted to the result families for which
# ``compute_pretrends_power`` has an explicit adapter — see
# ``diff_diff/pretrends.py`` around the result-type dispatch. Expanding
# beyond this set (Imputation / Stacked / TwoStage / EfficientDiD /
# StaggeredTripleDiff / Wooldridge / dCDH) would cause the helper to
# raise ``TypeError("Unsupported results type ...")`` and mark the check
# as ``error``, so the narrower set is the right contract.
#
# ``sensitivity`` is restricted to families with a ``HonestDiD``
# adapter: MultiPeriod, CS, dCDH (via ``placebo_event_study``). SDiD
# and TROP use their own native paths (``estimator_native``) instead
# of HonestDiD.
_APPLICABILITY: Dict[str, FrozenSet[str]] = {
"DiDResults": frozenset({"parallel_trends", "design_effect"}),
"MultiPeriodDiDResults": frozenset(
{"parallel_trends", "pretrends_power", "sensitivity", "bacon", "design_effect"}
),
"CallawaySantAnnaResults": frozenset(
{
"parallel_trends",
"pretrends_power",
"sensitivity",
"bacon",
"design_effect",
"heterogeneity",
"epv",
}
),
"SunAbrahamResults": frozenset(
{
"parallel_trends",
"pretrends_power",
"bacon",
"design_effect",
"heterogeneity",
}
),
"ImputationDiDResults": frozenset(
{
"parallel_trends",
"bacon",
"design_effect",
"heterogeneity",
}
),
"TwoStageDiDResults": frozenset(
{
"parallel_trends",
"bacon",
"design_effect",
"heterogeneity",
}
),
"StackedDiDResults": frozenset(
{
"parallel_trends",
"bacon",
"design_effect",
"heterogeneity",
}
),
"SyntheticDiDResults": frozenset(
{"parallel_trends", "sensitivity", "design_effect", "estimator_native"}
),
"TROPResults": frozenset(
# TROP identification is factor-model-based, not parallel-trends-
# based: the estimator native ``_pt_factor()`` handler returns
# ``status="not_applicable"``, and REPORTING.md routes TROP PT
# to factor-model diagnostics instead. Exposing PT in
# ``applicable_checks`` advertised a handler that never runs —
# round-28 P2 CI review on PR #318 flagged the contract mismatch
# for callers who gate workflows on ``applicable_checks``.
{
"sensitivity",
"design_effect",
"heterogeneity",
"estimator_native",
}
),
"EfficientDiDResults": frozenset(
{
"parallel_trends",
"bacon",
"design_effect",
"heterogeneity",
"epv",
}
),
"ContinuousDiDResults": frozenset({"design_effect", "heterogeneity"}),
"TripleDifferenceResults": frozenset({"design_effect", "epv"}),
"StaggeredTripleDiffResults": frozenset({"parallel_trends", "design_effect"}),
"WooldridgeDiDResults": frozenset(
{
"parallel_trends",
"bacon",
"design_effect",
"heterogeneity",
}
),
"ChaisemartinDHaultfoeuilleResults": frozenset(
{
"parallel_trends",
"sensitivity",
"bacon",
"design_effect",
}
),
"BaconDecompositionResults": frozenset({"bacon"}),
}
# Per-type parallel-trends method. The PT check dispatches internally on this.
# Values:
# "two_x_two" — uses utils.check_parallel_trends (requires ``data``)
# "event_study" — joint Wald on pre-period event-study coefficients
# "hausman" — EfficientDiD.hausman_pretest (native PT-All vs PT-Post)
# "synthetic_fit" — SDiD weighted pre-treatment fit (surfaces pre_treatment_fit)
# "factor" — TROP factor-model identification (no PT; renders "N/A" prose)
_PT_METHOD: Dict[str, str] = {
"DiDResults": "two_x_two",
"MultiPeriodDiDResults": "event_study",
"CallawaySantAnnaResults": "event_study",
"SunAbrahamResults": "event_study",
"ImputationDiDResults": "event_study",
"TwoStageDiDResults": "event_study",
"StackedDiDResults": "event_study",
"EfficientDiDResults": "hausman",
"ContinuousDiDResults": "event_study",
"StaggeredTripleDiffResults": "event_study",
"WooldridgeDiDResults": "event_study",
"ChaisemartinDHaultfoeuilleResults": "event_study",
"SyntheticDiDResults": "synthetic_fit",
"TROPResults": "factor",
}
@dataclass(frozen=True)
class DiagnosticReportResults:
"""Frozen container holding the outcome of a ``DiagnosticReport.run_all()`` call.
Attributes
----------
schema : dict
The AI-legible structured schema (also returned by ``to_dict()``).
interpretation : str
The ``overall_interpretation`` paragraph synthesizing findings across
checks.
applicable_checks : tuple of str
The names of checks that applied to this estimator + options.
skipped_checks : dict of str -> str
Mapping from skipped-check name to plain-English reason.
warnings : tuple of str
Warnings captured while running the underlying diagnostic functions.
"""
schema: Dict[str, Any]
interpretation: str
applicable_checks: Tuple[str, ...]
skipped_checks: Dict[str, str] = field(default_factory=dict)
warnings: Tuple[str, ...] = ()
class DiagnosticReport:
"""Run the standard diff-diff diagnostic battery on a fitted result.
Parameters
----------
results : Any
A fitted diff-diff results object (e.g. ``CallawaySantAnnaResults``,
``DiDResults``, ``SyntheticDiDResults``). Any of the 16 result types
in the library is accepted.
data : pandas.DataFrame, optional
The underlying panel. Required for checks that need raw data
(2x2 parallel-trends check on ``DiDResults``; Bacon-from-scratch when
``results`` is not itself a Bacon fit; the opt-in placebo battery).
outcome, treatment, time, unit, first_treat : str, optional
Column names identifying the panel structure.
pre_periods, post_periods : list, optional
Explicit pre- and post-treatment period labels.
run_parallel_trends, run_sensitivity, run_placebo, run_bacon, run_design_effect, run_heterogeneity, run_epv, run_pretrends_power : bool
Per-check opt-in flags. ``run_placebo`` defaults to ``False`` (opt-in,
expensive, currently not implemented - placebo key remains reserved
as ``skipped`` in the schema). All other checks default to ``True``
and are further gated by estimator-type and instance-level
applicability (see ``docs/methodology/REPORTING.md``).
sensitivity_M_grid : tuple of float, default (0.5, 1.0, 1.5, 2.0)
Grid of M values passed to ``HonestDiD.sensitivity``. Yields a
``SensitivityResults`` object with ``breakdown_M`` populated.
sensitivity_method : str, default "relative_magnitude"
HonestDiD restriction type.
alpha : float, default 0.05
Significance level used across checks.
survey_design : SurveyDesign, optional
The ``SurveyDesign`` object used to fit a survey-weighted
estimator. Required for fit-faithful replay of Goodman-Bacon on a
survey-backed fit; threaded to ``bacon_decompose(survey_design=...)``.
When the fit carries ``survey_metadata`` but ``survey_design`` is
not supplied, Bacon is skipped with an explicit reason rather than
replaying an unweighted decomposition for a design that does not
match the estimate. The simple 2x2 parallel-trends helper
(``utils.check_parallel_trends``) has no survey-aware variant;
on a survey-backed ``DiDResults`` it is skipped unconditionally
regardless of ``survey_design``. Supply
``precomputed={'parallel_trends': ...}`` with a survey-aware
pretest to opt in. See ``docs/methodology/REPORTING.md``.
precomputed : dict, optional
Map of check name to a pre-computed result object. Accepted keys
(this is the full implemented list; unsupported keys raise
``ValueError``):
- ``"parallel_trends"`` — a dict returned by
``utils.check_parallel_trends`` (adapted into the schema shape).
- ``"sensitivity"`` — a ``SensitivityResults`` (grid) or
``HonestDiDResults`` (single-M) object; used verbatim and no
``HonestDiD.sensitivity_analysis`` call is made.
- ``"pretrends_power"`` — a ``PreTrendsPowerResults`` object.
- ``"bacon"`` — a ``BaconDecompositionResults`` object.
Other sections (``design_effect``, ``heterogeneity``, ``epv``) are
read directly from the fitted result object and do not currently
accept precomputed values — there is no expensive call to bypass.
``placebo`` is reserved in the schema but opt-in / deferred in MVP.
outcome_label, treatment_label : str, optional
Plain-English labels used in prose rendering.
"""
def __init__(
self,
results: Any,
*,
data: Optional[pd.DataFrame] = None,
outcome: Optional[str] = None,
treatment: Optional[str] = None,
time: Optional[str] = None,
unit: Optional[str] = None,
first_treat: Optional[str] = None,
pre_periods: Optional[List[Any]] = None,
post_periods: Optional[List[Any]] = None,
run_parallel_trends: bool = True,
run_sensitivity: bool = True,
run_placebo: bool = False,
run_bacon: bool = True,
run_design_effect: bool = True,
run_heterogeneity: bool = True,
run_epv: bool = True,
run_pretrends_power: bool = True,
sensitivity_M_grid: Tuple[float, ...] = (0.5, 1.0, 1.5, 2.0),
sensitivity_method: str = "relative_magnitude",
alpha: float = 0.05,
survey_design: Optional[Any] = None,
precomputed: Optional[Dict[str, Any]] = None,
outcome_label: Optional[str] = None,
treatment_label: Optional[str] = None,
):
self._results = results
self._data = data
self._outcome = outcome
self._treatment = treatment
self._time = time
self._unit = unit
self._first_treat = first_treat
self._pre_periods = pre_periods
self._post_periods = post_periods
self._run_flags: Dict[str, bool] = {
"parallel_trends": run_parallel_trends,
"pretrends_power": run_pretrends_power,
"sensitivity": run_sensitivity,
"bacon": run_bacon,
"design_effect": run_design_effect,
"heterogeneity": run_heterogeneity,
"epv": run_epv,
"placebo": run_placebo,
"estimator_native": True,
}
self._sensitivity_M_grid = tuple(sensitivity_M_grid)
self._sensitivity_method = sensitivity_method
self._alpha = float(alpha)
# Round-40 P1 CI review on PR #318: survey-backed fits need the
# ``SurveyDesign`` object threaded through to ``bacon_decompose``
# for a fit-faithful Goodman-Bacon replay, and the unweighted
# 2x2 parallel-trends helper (``utils.check_parallel_trends``)
# cannot be called on a survey-weighted DiDResults without
# silently reporting an unweighted verdict for a weighted fit.
# When the fit carries ``survey_metadata`` but the caller did
# not supply ``survey_design``, both checks skip with an
# explicit reason instead of replaying a different design than
# the estimate. See REPORTING.md "Survey-backed fits".
self._survey_design = survey_design
self._precomputed = dict(precomputed or {})
# Validate precomputed keys against the actually-implemented passthrough
# set so advertised contracts do not silently diverge from behavior.
_supported_precomputed = {"parallel_trends", "sensitivity", "pretrends_power", "bacon"}
_unsupported = set(self._precomputed) - _supported_precomputed
if _unsupported:
raise ValueError(
"precomputed= contains keys that are not implemented: "
f"{sorted(_unsupported)}. Supported keys: "
f"{sorted(_supported_precomputed)}. ``design_effect``, "
"``heterogeneity``, and ``epv`` are read directly from the "
"fitted result and do not accept precomputed overrides."
)
# Estimator-aware precomputed validation. SDiD / TROP route
# robustness to ``estimator_native_diagnostics`` (SDiD: weighted
# pre-treatment fit, in-time placebo, zeta-omega sensitivity;
# TROP: factor-model fit metrics), and TROP PT is not applicable
# (factor-model identification, not PT). Accepting generic
# HonestDiD / parallel-trends precomputed inputs on these
# estimators would surface methodology-incompatible diagnostics
# through the generic report sections — the opposite of the
# native-routing contract documented in REPORTING.md.
# Round-21 P1 CI review on PR #318 flagged this bypass.
_result_name = type(self._results).__name__
_native_routed_names = {"SyntheticDiDResults", "TROPResults"}
if _result_name in _native_routed_names:
_incompatible_keys = []
if "sensitivity" in self._precomputed:
_incompatible_keys.append("sensitivity")
if "parallel_trends" in self._precomputed:
_incompatible_keys.append("parallel_trends")
# Round-32 P1 CI review on PR #318: ``pretrends_power`` is a
# Roth-style power analysis on pre-period event-study
# coefficients under the PT identifying contract. SDiD's PT
# analogue is design-enforced pre-treatment fit and TROP uses
# factor-model identification (PT not applicable); surfacing
# a Roth-style power tier on either would bypass the native-
# routing contract. Round-21's guard covered ``sensitivity``
# and ``parallel_trends`` but not ``pretrends_power``, so the
# round-31 ``_compute_applicable_checks`` broadening exposed
# it.
if "pretrends_power" in self._precomputed:
_incompatible_keys.append("pretrends_power")
if _incompatible_keys:
raise ValueError(
f"{_result_name} routes robustness and pre-trends "
"diagnostics to ``estimator_native_diagnostics`` — "
"generic HonestDiD, parallel-trends, and pre-trends "
"power precomputed passthroughs are methodology-"
"incompatible with this estimator. Rejected "
f"precomputed keys: {sorted(_incompatible_keys)}. "
"Use the native diagnostics on the result object "
"(SDiD: ``in_time_placebo``, ``sensitivity_to_zeta_omega``, "
"``pre_treatment_fit``; TROP: ``effective_rank``, "
"``loocv_score``) — DR surfaces these automatically."
)
# Round-44 P1 CI review on PR #318: mirror the SDiD/TROP
# __init__ rejection pattern for ``CallawaySantAnna`` with
# ``base_period != "universal"``. HonestDiD bounds are not
# valid for interpretation on consecutive-comparison
# (``base_period='varying'``) pre-period surfaces (REGISTRY.md
# §CallawaySantAnna line 410 plus §HonestDiD line 2458).
# ``precomputed["sensitivity"]`` would otherwise bypass the
# applicability-gate guard (which already existed for the auto
# path) and let BR/DR narrate the Rambachan-Roth bounds as
# ordinary robustness on a displayed fit whose interpretation
# does not match the bounds' provenance. Reject at
# construction so users get the error up-front rather than a
# late skip in the schema.
if _result_name == "CallawaySantAnnaResults" and "sensitivity" in self._precomputed:
_base_period = getattr(self._results, "base_period", "universal")
if _base_period != "universal":
raise ValueError(
"precomputed['sensitivity'] on "
"CallawaySantAnnaResults requires "
"``base_period='universal'`` on the displayed fit — "
"HonestDiD Rambachan-Roth bounds are not valid for "
"interpretation on the consecutive-comparison "
"pre-period surface produced by "
f"``base_period={_base_period!r}``. Narrating the "
"bounds as robustness alongside a varying-base fit "
"mixes provenance the bounds don't support. Re-fit "
"the main estimator with "
"``CallawaySantAnna(base_period='universal')`` "
"before passing precomputed sensitivity."
)
self._outcome_label = outcome_label
self._treatment_label = treatment_label
self._cached: Optional[DiagnosticReportResults] = None
# -- Public API ---------------------------------------------------------
def run_all(self) -> DiagnosticReportResults:
"""Run all applicable diagnostics. Idempotent; caches on first call."""
if self._cached is None:
self._cached = self._execute()
return self._cached
def to_dict(self) -> Dict[str, Any]:
"""Return the AI-legible structured schema."""
return self.run_all().schema
def summary(self) -> str:
"""Return a short plain-English paragraph."""
return self.run_all().interpretation
def full_report(self) -> str:
"""Return the multi-section markdown report."""
return _render_dr_full_report(self.run_all())
def export_markdown(self) -> str:
"""Alias for ``full_report()``."""
return self.full_report()
def to_dataframe(self) -> pd.DataFrame:
"""Return one row per check with status and headline metric."""
schema = self.to_dict()
rows = []
for check in _CHECK_NAMES:
section_key = "estimator_native_diagnostics" if check == "estimator_native" else check
section = schema.get(section_key, {})
rows.append(
{
"check": check,
"status": section.get("status"),
"headline": _check_headline(check, section),
"reason": section.get("reason"),
}
)
return pd.DataFrame(rows)
@property
def applicable_checks(self) -> Tuple[str, ...]:
"""Names of checks that will run, given estimator + instance + options.
No compute is triggered; this reflects only the applicability matrix
filtered by instance state (survey_metadata, epv_diagnostics, vcov)
and the user's ``run_*`` flags.
"""
return tuple(sorted(self._compute_applicable_checks()[0]))
@property
def skipped_checks(self) -> Dict[str, str]:
"""Mapping of skipped check -> plain-English reason. Requires ``run_all()``."""
return dict(self.run_all().skipped_checks)
# -- Implementation detail ---------------------------------------------
def _compute_applicable_checks(self) -> Tuple[set, Dict[str, str]]:
"""Compute the applicable-check set + per-check skipped reasons.
Returns
-------
applicable : set of str
Checks that will run.
skipped : dict
Mapping from check name -> plain-English reason for any check
that is type-applicable but skipped for this instance or by user
opt-out. Checks that are not type-applicable for this estimator
are omitted from both sets (not surfaced as "skipped").
"""
type_name = type(self._results).__name__
type_level = set(_APPLICABILITY.get(type_name, frozenset()))
# A precomputed passthrough is a caller-supplied override, not
# a claim about estimator-native applicability. Round-31 P1 CI
# review on PR #318: when a caller passes
# ``precomputed["sensitivity"] = ...`` on an estimator family
# whose ``_APPLICABILITY`` row lacks ``"sensitivity"`` (SA,
# Imputation, TwoStage, Stacked, EfficientDiD, Wooldridge,
# TripleDifference, StaggeredTripleDiff, ContinuousDiD, plain
# DiD), the gate previously filtered the section out silently
# and the supplied result disappeared from the schema. SDiD
# and TROP are still rejected up front in ``__init__``
# (round-21) because their native-routing contract makes
# HonestDiD methodology-incompatible; those never reach here.
# For every other estimator, an explicit passthrough wins
# over the default applicability matrix.
type_level = type_level | set(self._precomputed)
applicable: set = set()
skipped: Dict[str, str] = {}
for check in type_level:
# Per-check user opt-out
if not self._run_flags.get(check, True):
skipped[check] = f"run_{check}=False (user opted out)"
continue
# Instance-level gating — skipped when the caller supplied
# a precomputed override (the per-check ``_instance_skip_reason``
# branches already return None for precomputed keys, but this
# short-circuit makes the override contract explicit and
# survives any future gate additions).
if check in self._precomputed:
applicable.add(check)
continue
reason = self._instance_skip_reason(check)
if reason is not None:
skipped[check] = reason
continue
applicable.add(check)
# Placebo is reserved for every result type in MVP so the schema
# shape is stable: ``schema["placebo"]["status"] == "skipped"``
# always holds regardless of estimator. The opt-in execution path
# is deferred to a follow-up; ``REPORTING.md`` documents this.
skipped.setdefault(
"placebo",
"Placebo battery runs on opt-in only; not yet implemented in MVP. "
"Reserved in the schema for forward compatibility.",
)
return applicable, skipped
def _instance_skip_reason(self, check: str) -> Optional[str]:
"""Return a plain-English reason this check cannot run on this instance, or None."""
r = self._results
name = type(r).__name__
if check == "design_effect":
if getattr(r, "survey_metadata", None) is None:
return "No survey design attached to results.survey_metadata."
return None
if check == "epv":
if getattr(r, "epv_diagnostics", None) is None:
return "Estimator did not produce results.epv_diagnostics for this fit."
return None
if check == "parallel_trends":
# Precomputed parallel-trends always unlocks this check. The
# EfficientDiD Hausman skip message already points users at
# ``precomputed={'parallel_trends': ...}`` when replay fails
# (DR / survey fits), so applicability must honor the
# override before the replay-gate below fires. Round-22 P1
# CI review on PR #318 flagged that PT precomputed was
# advertised but skipped before use.
if "parallel_trends" in self._precomputed:
return None
method = _PT_METHOD.get(name)
if method == "two_x_two":
# Mirror the full argument contract of ``_pt_two_x_two``:
# the runner needs ``data`` AND all three column names to
# call ``check_parallel_trends``. Gating only on ``data``
# (as before) left ``applicable_checks`` overstated when
# one of the column kwargs was missing (round-11 CI
# review on PR #318).
two_x_two_missing = [
arg
for arg, val in (
("data", self._data),
("outcome", self._outcome),
("time", self._time),
("treatment", self._treatment),
)
if val is None
]
if two_x_two_missing:
return (
"2x2 parallel-trends check needs raw panel data + "
"outcome / time / treatment column names. Missing: "
+ ", ".join(two_x_two_missing)
+ "."
)
# Round-40 P1 CI review on PR #318: the simple 2x2 helper
# ``utils.check_parallel_trends`` is unweighted — it has
# no ``survey_design`` parameter and cannot faithfully
# diagnose the pre-period trajectory of a survey-
# weighted DiDResults. Rather than silently emitting
# an unweighted verdict alongside the weighted estimate,
# skip with an explicit reason. Users can supply
# ``precomputed={'parallel_trends': ...}`` with a
# survey-aware pretest result if they have one.
if getattr(r, "survey_metadata", None) is not None:
return (
"Original fit used a survey design; the simple "
"2x2 parallel-trends check (``utils."
"check_parallel_trends``) is unweighted and "
"would diagnose a different design than the "
"weighted estimate. Supply a survey-aware "
"pretest via "
"``precomputed={'parallel_trends': ...}`` to "
"opt in."
)
if method == "event_study":
pre_coefs, n_dropped_undefined = _collect_pre_period_coefs(r)
# Round-42 P1 CI review on PR #318: the all-undefined
# pre-period case (every pre-row dropped for ``se <= 0``
# / non-finite inference) is the twin of the partial-
# undefined case from round-33. It must route to the
# inconclusive runner rather than skip, so the explicit
# ``method="inconclusive"`` / ``n_dropped_undefined``
# provenance is surfaced through DR's schema and BR's
# summary emits the "inconclusive" identifying-
# assumption warning rather than silently dropping PT.
if not pre_coefs and n_dropped_undefined == 0:
return (
"No pre-period event-study coefficients are exposed on "
"this fit. For staggered estimators, re-fit with "
"aggregate='event_study' to populate event-study output."
)
# vcov is optional for the Bonferroni fallback.
if method == "hausman":
# EfficientDiD's Hausman pretest requires the raw panel
# to refit under PT-All and PT-Post. Gate at applicability
# rather than letting ``_pt_hausman`` skip at runtime, so
# ``applicable_checks`` and ``completed_steps`` reflect
# reality.
hausman_missing = [
arg
for arg, val in (
("data", self._data),
("outcome", self._outcome),
("unit", self._unit),
("time", self._time),
("first_treat", self._first_treat),
)
if val is None
]
if hausman_missing:
return (
"EfficientDiD.hausman_pretest needs raw panel data; "
"pass data + outcome + unit + time + first_treat to "
"DiagnosticReport. Missing: " + ", ".join(hausman_missing) + "."
)
# Fit-faithful guard: DR / survey fits cannot be replayed
# under defaults, so skip with an explicit reason rather
# than rerunning a different design.
if getattr(r, "estimation_path", "nocov") != "nocov":
return (
"Original EfficientDiD fit used the doubly-robust "
"covariate path; ``covariates`` is not stored on "
"the result, so the Hausman pretest cannot be "
"faithfully replayed."
)
if getattr(r, "survey_metadata", None) is not None:
return (
"Original EfficientDiD fit used a survey design; "
"replaying the Hausman pretest would require the "
"full ``SurveyDesign`` object."
)
return None
if check == "pretrends_power":
# ``compute_pretrends_power`` handles CS / SA / ImputationDiD
# event-study results by reading ``event_study_effects``
# directly, so we accept either a top-level ``vcov`` OR a
# populated event-study surface. Precomputed overrides also
# bypass this gate.
if "pretrends_power" in self._precomputed:
return None
has_vcov = getattr(r, "vcov", None) is not None
has_event_vcov = getattr(r, "event_study_vcov", None) is not None
has_event_es = getattr(r, "event_study_effects", None) is not None
if not (has_vcov or has_event_vcov or has_event_es):
return (
"Pre-trends power needs either results.vcov or "
"event_study_effects (from aggregate='event_study' on "
"staggered estimators); neither available."
)
pre_coefs, _ = _collect_pre_period_coefs(r)
if len(pre_coefs) < 2:
return "Pre-trends power needs >= 2 pre-treatment periods."
return None
if check == "sensitivity":
# Native SDiD/TROP paths substitute for HonestDiD.
if name in {"SyntheticDiDResults", "TROPResults"}:
return None
# Round-44 P1 CI review on PR #318: the CS varying-base
# guard MUST fire before the precomputed early-return.
# Previously, ``precomputed["sensitivity"]`` unlocked this
# check unconditionally, letting BR/DR narrate the
# Rambachan-Roth bounds as ordinary robustness even though
# HonestDiD explicitly warns those bounds are not valid
# for interpretation on consecutive-comparison
# (``base_period='varying'``) pre-period surfaces
# (REGISTRY.md §CallawaySantAnna line 410, §HonestDiD line
# 2458). The previous skip message also mis-pointed users
# at ``precomputed`` as the opt-in; that path now routes
# through the same guard, so the correct remediation is to
# re-fit the main estimator with ``base_period='universal'``
# or to consult HonestDiD outside the report layer.
if name == "CallawaySantAnnaResults":
base_period = getattr(r, "base_period", "universal")
if base_period != "universal":
return (
"HonestDiD on CallawaySantAnna requires "
"``base_period='universal'`` for valid interpretation "
"(Rambachan-Roth bounds are not comparable across the "
"consecutive pre-period comparisons produced by "
f"``base_period={base_period!r}``). Re-fit with "
"``CallawaySantAnna(base_period='universal')``; "
"``precomputed={'sensitivity': ...}`` is rejected here "
"because the precomputed bounds would be narrated as "
"robustness for a displayed fit whose pre-period "
"surface has a different interpretation than the one "
"the bounds were computed against."
)
# Precomputed sensitivity unlocks this check for every
# other estimator (SDiD/TROP were already rejected at DR
# __init__; CS varying-base is gated above). The CS
# guard above runs on the *displayed fit*, not on the
# provenance of the precomputed bounds; it protects
# against narrating bounds whose interpretation is
# incompatible with the fit being summarized.
if "sensitivity" in self._precomputed:
return None
# dCDH uses ``placebo_event_study`` as its pre-period surface,
# which HonestDiD consumes via a dedicated branch. Accept the
# fit when that attribute is populated.
if name == "ChaisemartinDHaultfoeuilleResults":
pes = getattr(r, "placebo_event_study", None)
if pes is None:
return (
"HonestDiD on dCDH requires results.placebo_event_study "
"(re-fit with a placebo-producing configuration)."
)
return None
# MultiPeriod / CS path: ``HonestDiD.sensitivity_analysis``
# consumes ``event_study_effects`` plus either ``vcov`` +
# ``interaction_indices`` (MultiPeriod) or ``event_study_vcov``
# + ``event_study_vcov_index`` (CS), with a per-SE diagonal
# fallback otherwise.
has_vcov = getattr(r, "vcov", None) is not None
has_event_vcov = getattr(r, "event_study_vcov", None) is not None
has_event_es = getattr(r, "event_study_effects", None) is not None
if not (has_vcov or has_event_vcov or has_event_es):
return (
"HonestDiD needs either results.vcov, event_study_vcov, "
"or event_study_effects; none available."
)
pre_coefs, _ = _collect_pre_period_coefs(r)
if len(pre_coefs) < 1:
return "HonestDiD requires at least one pre-period coefficient."
return None
if check == "bacon":
# Precomputed Bacon always unlocks this check. Users with an
# already-computed ``BaconDecompositionResults`` (e.g., run
# separately against a stored panel that isn't available at
# report time) need the passthrough to land on the Bacon
# runner instead of being skipped for missing column kwargs.
# Round-22 P1 CI review on PR #318 flagged that Bacon
# precomputed was advertised but skipped before use.
if "bacon" in self._precomputed:
return None
# ``BaconDecompositionResults`` carries the decomposition
# directly; no data/column kwargs needed.
if name == "BaconDecompositionResults":
return None
# Otherwise mirror the full argument contract of
# ``_check_bacon`` / ``bacon_decompose``: the runner needs
# ``data``, ``first_treat``, and the ``outcome`` / ``time`` /
# ``unit`` column names. Gating on only ``data`` +
# ``first_treat`` (as before) left ``applicable_checks``
# overstated when a column kwarg was missing (round-11 CI
# review on PR #318).
bacon_missing = [
arg
for arg, val in (
("data", self._data),
("outcome", self._outcome),
("time", self._time),
("unit", self._unit),
("first_treat", self._first_treat),
)
if val is None
]
if bacon_missing:
return (
"Bacon decomposition needs panel data + outcome / time "
"/ unit / first_treat column names. Missing: " + ", ".join(bacon_missing) + "."
)
# Round-40 P1 CI review on PR #318: ``bacon_decompose``
# supports a ``survey_design`` kwarg for survey-weighted
# decomposition. When the fitted result carries
# ``survey_metadata`` but the caller did not supply a
# ``survey_design`` object, replaying with defaults would
# produce an unweighted decomposition for a different
# design than the weighted estimate. Skip with an explicit
# reason; users can pass ``survey_design=<design>`` on
# ``DiagnosticReport`` / ``BusinessReport`` or supply
# ``precomputed={'bacon': ...}`` with a survey-aware
# decomposition.
if getattr(r, "survey_metadata", None) is not None and self._survey_design is None:
return (
"Original fit used a survey design; Goodman-Bacon "
"replay under defaults would produce an unweighted "
"decomposition for a different design than the "
"weighted estimate. Pass ``survey_design=<SurveyDesign>`` "
"on DiagnosticReport / BusinessReport, or supply "
"``precomputed={'bacon': ...}`` with a survey-aware "
"decomposition."
)
return None
if check == "heterogeneity":
# Needs multiple group or event-study effects. Use len() rather than
# truthiness because some estimators expose these as DataFrames,
# which raise on bool() conversion.
for attr in (
"group_effects",
"event_study_effects",
"treatment_effects", # TROP per-(unit, time)
"group_time_effects", # CS default aggregation
"period_effects", # MultiPeriod
):
val = getattr(r, attr, None)
if val is None:
continue
try:
if len(val) > 0:
return None
except TypeError:
continue
return "No group/event-study effects available to compute heterogeneity."
if check == "estimator_native":
if name not in {"SyntheticDiDResults", "TROPResults"}:
return f"{name} does not expose native validation methods."
return None
return None
def _execute(self) -> DiagnosticReportResults:
"""Run the diagnostic battery and assemble the schema."""
applicable, skipped = self._compute_applicable_checks()
# Initialize all schema sections to either "ran"/"skipped"/"not_applicable".
sections: Dict[str, Dict[str, Any]] = {}
for check in _CHECK_NAMES:
if check in applicable:
sections[check] = {"status": "not_run", "reason": "pending implementation"}
elif check in skipped:
sections[check] = {"status": "skipped", "reason": skipped[check]}
else:
sections[check] = {
"status": "not_applicable",
"reason": f"{check} is not applicable to " f"{type(self._results).__name__}.",
}
# Run the checks that are applicable. Each returns a schema-section dict
# that replaces the placeholder above.
if "parallel_trends" in applicable:
sections["parallel_trends"] = self._check_parallel_trends()
if "pretrends_power" in applicable:
sections["pretrends_power"] = self._check_pretrends_power()
if "sensitivity" in applicable:
sections["sensitivity"] = self._check_sensitivity()
if "bacon" in applicable:
sections["bacon"] = self._check_bacon()
if "design_effect" in applicable:
sections["design_effect"] = self._check_design_effect()
if "heterogeneity" in applicable:
sections["heterogeneity"] = self._check_heterogeneity()
if "epv" in applicable:
sections["epv"] = self._check_epv()
if "estimator_native" in applicable:
sections["estimator_native"] = self._check_estimator_native()
# Estimator-native placeholder: SDiD/TROP diagnostics come in a later task.
if "estimator_native" not in applicable and "estimator_native" not in skipped:
sections["estimator_native"] = {
"status": "not_applicable",
"reason": f"{type(self._results).__name__} does not expose native "
"validation methods beyond what's captured above.",
}
# Headline metric — best-effort across estimator types.
# PR #347 R4 P1: the dCDH ``trends_linear=True`` + ``L_max>=2``
# configuration does not produce a scalar headline by design
# (``overall_att`` is intentionally NaN per
# ``chaisemartin_dhaultfoeuille.py:2828-2834``). Route the
# headline through a dedicated no-scalar block when the
# target-parameter helper flags this case so prose does not
# narrate it as an estimation failure.
_tp_agg = describe_target_parameter(self._results).get("aggregation")
if _tp_agg == "no_scalar_headline":
# PR #347 R12 P1: distinguish populated vs empty per-horizon
# surface. Pointing users at ``linear_trends_effects`` is
# dead-end guidance when that dict is ``None``.
_surface_empty = getattr(self._results, "linear_trends_effects", None) is None
# PR #347 R14 P1: control-aware empty-surface label.
_has_controls = getattr(self._results, "covariate_residuals", None) is not None
_empty_surface_label = "DID^{X,fd}_l" if _has_controls else "DID^{fd}_l"
if _surface_empty:
headline_name = "no scalar headline (empty per-horizon surface)"
headline_reason = (
"The fitted estimator intentionally does not produce a "
"scalar overall ATT on this configuration "
"(``trends_linear=True`` with ``L_max >= 2``), and on "
f"this fit no cumulated level effects ``{_empty_surface_label}`` "
"survived estimation — the per-horizon surface is "
"empty. Re-fit with a larger ``L_max`` or with "
"``trends_linear=False`` if you need a reportable "
"estimand."
)
else:
headline_name = "no scalar headline (see linear_trends_effects)"
headline_reason = (
"The fitted estimator intentionally does not produce a "
"scalar overall ATT on this configuration "
"(``trends_linear=True`` with ``L_max >= 2``). Per-horizon "
"cumulated level effects are on "
"``results.linear_trends_effects[l]``."
)
headline = {
"status": "no_scalar_by_design",
"name": headline_name,
"value": None,
"se": None,
"p_value": None,
"conf_int": (None, None),
"alpha": self._alpha,
"is_significant": False,
"sign": "none",
"reason": headline_reason,
}
else:
headline = self._extract_headline_metric()
# Pull suggested next steps from the practitioner workflow.
next_steps = self._collect_next_steps(sections)
# Populate schema-level warnings for every section that ended in "error",
# so users and agents do not have to scan each section dict to discover
# that a diagnostic failed. Preserves provenance per the "no silent
# failures" convention.
top_warnings: List[str] = []
for check in _CHECK_NAMES:
section_key = "estimator_native" if check == "estimator_native" else check
section = sections.get(section_key, {})
if section.get("status") == "error":
reason = section.get("reason") or "diagnostic raised an exception"
top_warnings.append(f"{check}: {reason}")
# Surface non-fatal warnings captured by delegated diagnostics
# (e.g., HonestDiD's "base_period='varying' is not valid for
# interpretation" on CallawaySantAnna, or the diag-covariance
# fallback on bootstrap-fitted CS). These rode up on each
# section's ``warnings`` field and must not be swallowed.