diff-diff 3.6.1__tar.gz → 3.6.2__tar.gz
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-3.6.1 → diff_diff-3.6.2}/PKG-INFO +1 -1
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/__init__.py +1 -1
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_backend.py +12 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_nprobust_port.py +13 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_reporting_helpers.py +20 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bacon.py +15 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did.py +46 -81
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/diagnostic_report.py +181 -7
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did.py +4 -23
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_covariates.py +33 -10
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/estimators.py +81 -6
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-full.txt +7 -7
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms.txt +2 -2
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/had.py +89 -44
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/linalg.py +21 -4
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/results.py +5 -3
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/stacked_did.py +4 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/sun_abraham.py +16 -14
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/survey.py +65 -2
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_control.py +198 -21
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_control_results.py +717 -72
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/twfe.py +29 -35
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/utils.py +436 -69
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/wooldridge.py +21 -1
- {diff_diff-3.6.1 → diff_diff-3.6.2}/pyproject.toml +1 -1
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/Cargo.lock +8 -8
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/Cargo.toml +5 -1
- diff_diff-3.6.2/rust/src/alloc_profile.rs +70 -0
- diff_diff-3.6.2/rust/src/demean.rs +333 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/lib.rs +17 -1
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/linalg.rs +70 -48
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/trop.rs +212 -87
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/weights.rs +227 -61
- {diff_diff-3.6.1 → diff_diff-3.6.2}/LICENSE +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/README.md +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/_guides_api.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/agent_workflow.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/balancing.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bootstrap_chunking.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/business_report.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/chaisemartin_dhaultfoeuille_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/conformal.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/conley.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/datasets.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/diagnostics.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/__init__.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-autonomous.txt +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/guides/llms-practitioner.txt +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/had_pretests.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/honest_did.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/imputation_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/local_linear.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/lpdid.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/lpdid_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/power.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/practitioner.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/prep.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/prep_dgp.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/pretrends.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/profile.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/spillover.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_aggregation.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_bootstrap.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/staggered_triple_diff_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/triple_diff.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_global.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_local.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/trop_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage_bootstrap.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/two_stage_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/__init__.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_common.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_continuous.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_diagnostic.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_event_study.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_power.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_staggered.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/visualization/_synthetic.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/diff_diff/wooldridge_results.py +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/build.rs +0 -0
- {diff_diff-3.6.1 → diff_diff-3.6.2}/rust/src/bootstrap.rs +0 -0
|
@@ -65,6 +65,14 @@ except ImportError:
|
|
|
65
65
|
_rust_sc_weight_fw_weighted_with_convergence = None
|
|
66
66
|
_rust_backend_info = None
|
|
67
67
|
|
|
68
|
+
# FE-absorption MAP demeaning kernel: imported independently so a stale or
|
|
69
|
+
# mixed-version extension missing only this newer symbol degrades to the
|
|
70
|
+
# numpy demeaning engine WITHOUT disabling the older Rust accelerations.
|
|
71
|
+
try:
|
|
72
|
+
from diff_diff._rust_backend import demean_map as _rust_demean_map
|
|
73
|
+
except ImportError:
|
|
74
|
+
_rust_demean_map = None
|
|
75
|
+
|
|
68
76
|
# Determine final backend based on environment variable and availability
|
|
69
77
|
if _backend_env == "python":
|
|
70
78
|
# Force pure Python mode - disable Rust even if available
|
|
@@ -73,6 +81,8 @@ if _backend_env == "python":
|
|
|
73
81
|
_rust_project_simplex = None
|
|
74
82
|
_rust_solve_ols = None
|
|
75
83
|
_rust_compute_robust_vcov = None
|
|
84
|
+
# FE-absorption MAP demeaning kernel
|
|
85
|
+
_rust_demean_map = None
|
|
76
86
|
# TROP estimator acceleration (local method)
|
|
77
87
|
_rust_unit_distance_matrix = None
|
|
78
88
|
_rust_loocv_grid_search = None
|
|
@@ -124,6 +134,8 @@ __all__ = [
|
|
|
124
134
|
"_rust_project_simplex",
|
|
125
135
|
"_rust_solve_ols",
|
|
126
136
|
"_rust_compute_robust_vcov",
|
|
137
|
+
# FE-absorption MAP demeaning kernel
|
|
138
|
+
"_rust_demean_map",
|
|
127
139
|
# TROP estimator acceleration (local method)
|
|
128
140
|
"_rust_unit_distance_matrix",
|
|
129
141
|
"_rust_loocv_grid_search",
|
|
@@ -1361,6 +1361,19 @@ def lprobust(
|
|
|
1361
1361
|
se_cl = float(np.sqrt((deriv_fact**2) * V_Y_cl[deriv, deriv]))
|
|
1362
1362
|
se_rb = float(np.sqrt((deriv_fact**2) * V_Y_bc[deriv, deriv]))
|
|
1363
1363
|
|
|
1364
|
+
# Cluster-robust variance is unidentified when fewer than two clusters
|
|
1365
|
+
# contribute to the ACTIVE kernel window (``eC = cluster[ind]``): the
|
|
1366
|
+
# between-cluster meat is degenerate, so a finite ``se`` here would report
|
|
1367
|
+
# unidentified clustered inference as if identified. NaN both SEs so any
|
|
1368
|
+
# downstream inference (the ``safe_inference`` gate in
|
|
1369
|
+
# ``bias_corrected_local_linear``; HAD's beta-scale rescale) is NaN-coupled.
|
|
1370
|
+
# Unclustered fits (``eC is None``) are unaffected, and a clustered window
|
|
1371
|
+
# with >= 2 distinct clusters is bit-identical, so the DGP-4 golden parity
|
|
1372
|
+
# is preserved.
|
|
1373
|
+
if eC is not None and len(np.unique(eC)) < 2:
|
|
1374
|
+
se_cl = float("nan")
|
|
1375
|
+
se_rb = float("nan")
|
|
1376
|
+
|
|
1364
1377
|
# --- Per-observation influence function for the BIAS-CORRECTED point
|
|
1365
1378
|
# estimate at ``deriv`` (Phase 4.5 survey composition).
|
|
1366
1379
|
# Aligned with ``V_Y_bc`` (NOT ``V_Y_cl``) so survey-composed variance
|
|
@@ -635,6 +635,26 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
|
|
|
635
635
|
"reference": "REGISTRY.md Sec. SyntheticControl",
|
|
636
636
|
}
|
|
637
637
|
|
|
638
|
+
if name == "SpilloverDiDResults":
|
|
639
|
+
return {
|
|
640
|
+
"name": "total effect on the treated (Butts spillover-aware ATT)",
|
|
641
|
+
"definition": (
|
|
642
|
+
"The total effect on the treated ``tau_total`` from Butts (2021) "
|
|
643
|
+
"ring-indicator spillover DiD, identified off FAR-AWAY control "
|
|
644
|
+
"observations (``d_it > d_bar``, Assumption 5) rather than any "
|
|
645
|
+
"not-yet-/never-treated pool. The estimator decomposes into the "
|
|
646
|
+
"DIRECT effect on treated units plus per-ring spillover-on-control "
|
|
647
|
+
"effects that relax SUTVA within the treated units' spatial "
|
|
648
|
+
"neighborhood; ``att`` is the headline total effect, while the "
|
|
649
|
+
"per-ring ``spillover_effects`` and (when ``event_study=True``) the "
|
|
650
|
+
"per-event-time direct dynamics are available on the result object "
|
|
651
|
+
"for disaggregated inference."
|
|
652
|
+
),
|
|
653
|
+
"aggregation": "spillover",
|
|
654
|
+
"headline_attribute": "att",
|
|
655
|
+
"reference": "Butts (2021); REGISTRY.md Sec. SpilloverDiD",
|
|
656
|
+
}
|
|
657
|
+
|
|
638
658
|
# Default: unrecognized result class. Fall through with a neutral
|
|
639
659
|
# block — agents / downstream consumers can still dispatch on
|
|
640
660
|
# ``aggregation="unknown"`` and fall back to generic ATT narration.
|
|
@@ -18,6 +18,7 @@ import numpy as np
|
|
|
18
18
|
import pandas as pd
|
|
19
19
|
|
|
20
20
|
from diff_diff.results import _format_survey_block
|
|
21
|
+
from diff_diff.utils import pre_demean_norms, snap_absorbed_regressors
|
|
21
22
|
from diff_diff.utils import within_transform as _within_transform_util
|
|
22
23
|
|
|
23
24
|
|
|
@@ -795,6 +796,7 @@ class BaconDecomposition:
|
|
|
795
796
|
) -> float:
|
|
796
797
|
"""Compute TWFE estimate using within-transformation."""
|
|
797
798
|
# Apply two-way within transformation (weighted if survey weights provided)
|
|
799
|
+
_pre_norms = pre_demean_norms(df, [treat_col], weights=weights)
|
|
798
800
|
df_dm = _within_transform_util(
|
|
799
801
|
df,
|
|
800
802
|
[outcome, treat_col],
|
|
@@ -803,6 +805,19 @@ class BaconDecomposition:
|
|
|
803
805
|
suffix="_within",
|
|
804
806
|
weights=weights,
|
|
805
807
|
)
|
|
808
|
+
# Snap an FE-spanned treatment to exact zero: the d_var == 0 guard
|
|
809
|
+
# below then returns its deterministic 0.0 (with the cause warning)
|
|
810
|
+
# instead of an arbitrary junk/junk division.
|
|
811
|
+
snap_absorbed_regressors(
|
|
812
|
+
df_dm,
|
|
813
|
+
[treat_col],
|
|
814
|
+
_pre_norms,
|
|
815
|
+
absorbed_desc=f"unit '{unit}' and time '{time}' fixed effects",
|
|
816
|
+
group_vars=[unit, time],
|
|
817
|
+
suffix="_within",
|
|
818
|
+
display_names={treat_col: "treatment"},
|
|
819
|
+
weights=weights,
|
|
820
|
+
)
|
|
806
821
|
|
|
807
822
|
# Extract within-transformed values
|
|
808
823
|
y_within = df_dm[f"{outcome}_within"].values
|
|
@@ -31,9 +31,9 @@ from diff_diff.continuous_did_results import (
|
|
|
31
31
|
)
|
|
32
32
|
from diff_diff.linalg import _rank_guarded_inv, solve_ols
|
|
33
33
|
from diff_diff.survey import (
|
|
34
|
-
ResolvedSurveyDesign,
|
|
35
34
|
_resolve_survey_for_fit,
|
|
36
35
|
_validate_unit_constant_survey,
|
|
36
|
+
build_unit_first_row_index,
|
|
37
37
|
compute_survey_vcov,
|
|
38
38
|
)
|
|
39
39
|
from diff_diff.utils import safe_inference
|
|
@@ -413,8 +413,7 @@ class ContinuousDiD:
|
|
|
413
413
|
|
|
414
414
|
# Filter out NaN cells (e.g., from zero effective survey mass)
|
|
415
415
|
gt_results = {
|
|
416
|
-
gt: r for gt, r in gt_results.items()
|
|
417
|
-
if np.isfinite(r.get("att_glob", np.nan))
|
|
416
|
+
gt: r for gt, r in gt_results.items() if np.isfinite(r.get("att_glob", np.nan))
|
|
418
417
|
}
|
|
419
418
|
|
|
420
419
|
if len(gt_results) == 0:
|
|
@@ -573,9 +572,12 @@ class ContinuousDiD:
|
|
|
573
572
|
# Survey df for t-distribution inference (unit-level, not panel-level)
|
|
574
573
|
_survey_df = analytic.get("df_survey")
|
|
575
574
|
# Guard: replicate design with undefined df → NaN inference
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
|
|
575
|
+
if (
|
|
576
|
+
_survey_df is None
|
|
577
|
+
and resolved_survey is not None
|
|
578
|
+
and hasattr(resolved_survey, "uses_replicate_variance")
|
|
579
|
+
and resolved_survey.uses_replicate_variance
|
|
580
|
+
):
|
|
579
581
|
_survey_df = 0
|
|
580
582
|
|
|
581
583
|
# Recompute survey_metadata from unit-level design so reported
|
|
@@ -589,8 +591,7 @@ class ContinuousDiD:
|
|
|
589
591
|
|
|
590
592
|
# Propagate replicate df override to survey_metadata for display
|
|
591
593
|
# (but not the df=0 sentinel — keep metadata as None for undefined df)
|
|
592
|
-
if
|
|
593
|
-
and survey_metadata is not None):
|
|
594
|
+
if _survey_df is not None and _survey_df != 0 and survey_metadata is not None:
|
|
594
595
|
if survey_metadata.df_survey != _survey_df:
|
|
595
596
|
survey_metadata.df_survey = _survey_df
|
|
596
597
|
|
|
@@ -624,30 +625,8 @@ class ContinuousDiD:
|
|
|
624
625
|
unit_resolved_es = None
|
|
625
626
|
if resolved_survey is not None:
|
|
626
627
|
row_idx = precomp["unit_first_panel_row"]
|
|
627
|
-
|
|
628
|
-
precomp.get("unit_survey_weights")
|
|
629
|
-
if precomp.get("unit_survey_weights") is not None
|
|
630
|
-
else np.ones(n_units)
|
|
631
|
-
)
|
|
632
|
-
us = (
|
|
633
|
-
resolved_survey.strata[row_idx]
|
|
634
|
-
if resolved_survey.strata is not None
|
|
635
|
-
else None
|
|
636
|
-
)
|
|
637
|
-
up = (
|
|
638
|
-
resolved_survey.psu[row_idx]
|
|
639
|
-
if resolved_survey.psu is not None
|
|
640
|
-
else None
|
|
641
|
-
)
|
|
642
|
-
uf = (
|
|
643
|
-
resolved_survey.fpc[row_idx]
|
|
644
|
-
if resolved_survey.fpc is not None
|
|
645
|
-
else None
|
|
646
|
-
)
|
|
647
|
-
n_strata_u = len(np.unique(us)) if us is not None else 0
|
|
648
|
-
n_psu_u = len(np.unique(up)) if up is not None else 0
|
|
649
|
-
unit_resolved_es = resolved_survey.subset_to_units(
|
|
650
|
-
row_idx, uw, us, up, uf, n_strata_u, n_psu_u,
|
|
628
|
+
unit_resolved_es = resolved_survey.subset_to_units_by_row_idx(
|
|
629
|
+
row_idx, unit_weights=precomp.get("unit_survey_weights")
|
|
651
630
|
)
|
|
652
631
|
|
|
653
632
|
for e_val, info_e in event_study_effects.items():
|
|
@@ -711,13 +690,21 @@ class ContinuousDiD:
|
|
|
711
690
|
|
|
712
691
|
# Score-scale: psi = w * if_es (matches TSL bread)
|
|
713
692
|
psi_es = unit_resolved_es.weights * if_es
|
|
714
|
-
variance, _nv = compute_replicate_if_variance(
|
|
715
|
-
|
|
693
|
+
variance, _nv = compute_replicate_if_variance(
|
|
694
|
+
psi_es, unit_resolved_es
|
|
695
|
+
)
|
|
696
|
+
es_se = (
|
|
697
|
+
float(np.sqrt(max(variance, 0.0)))
|
|
698
|
+
if np.isfinite(variance)
|
|
699
|
+
else np.nan
|
|
700
|
+
)
|
|
716
701
|
else:
|
|
717
702
|
X_ones_es = np.ones((n_units, 1))
|
|
718
703
|
tsl_scale_es = float(unit_resolved_es.weights.sum())
|
|
719
704
|
if_es_tsl = if_es * tsl_scale_es
|
|
720
|
-
vcov_es = compute_survey_vcov(
|
|
705
|
+
vcov_es = compute_survey_vcov(
|
|
706
|
+
X_ones_es, if_es_tsl, unit_resolved_es
|
|
707
|
+
)
|
|
721
708
|
es_se = float(np.sqrt(np.abs(vcov_es[0, 0])))
|
|
722
709
|
else:
|
|
723
710
|
es_se = float(np.sqrt(np.sum(if_es**2)))
|
|
@@ -831,15 +818,11 @@ class ContinuousDiD:
|
|
|
831
818
|
unit_cohorts[i] = unit_first.loc[u, first_treat]
|
|
832
819
|
dose_vector[i] = unit_first.loc[u, dose]
|
|
833
820
|
|
|
834
|
-
# Build unit-to-first-panel-row mapping (for subsetting panel-level
|
|
835
|
-
#
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
u = row[unit]
|
|
840
|
-
if u not in seen_units:
|
|
841
|
-
seen_units.add(u)
|
|
842
|
-
unit_first_panel_row[unit_to_idx[u]] = pos_idx
|
|
821
|
+
# Build unit-to-first-panel-row mapping (for subsetting panel-level
|
|
822
|
+
# arrays): the positional index of each unit's first row in df, aligned
|
|
823
|
+
# to ``all_units`` (== ``unit_to_idx`` order since
|
|
824
|
+
# ``unit_to_idx = {u: i for i, u in enumerate(all_units)}``).
|
|
825
|
+
unit_first_panel_row = build_unit_first_row_index(df[unit].values, all_units)
|
|
843
826
|
|
|
844
827
|
# Per-unit survey weights (take first obs per unit from panel data)
|
|
845
828
|
unit_survey_weights = None
|
|
@@ -949,8 +932,10 @@ class ContinuousDiD:
|
|
|
949
932
|
# Guard against zero effective mass (e.g., after subpopulation)
|
|
950
933
|
if np.sum(w_treated) <= 0 or np.sum(w_control) <= 0:
|
|
951
934
|
return {
|
|
952
|
-
"att_glob": np.nan,
|
|
953
|
-
"
|
|
935
|
+
"att_glob": np.nan,
|
|
936
|
+
"acrt_glob": np.nan,
|
|
937
|
+
"n_treated": 0,
|
|
938
|
+
"n_control": 0,
|
|
954
939
|
"att_d": np.full(len(dvals), np.nan),
|
|
955
940
|
"acrt_d": np.full(len(dvals), np.nan),
|
|
956
941
|
}
|
|
@@ -1293,23 +1278,8 @@ class ContinuousDiD:
|
|
|
1293
1278
|
# but influence functions are unit-level (n_units). Build a unit-level
|
|
1294
1279
|
# ResolvedSurveyDesign by subsetting to one obs per unit.
|
|
1295
1280
|
row_idx = precomp["unit_first_panel_row"]
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
unit_weights = np.ones(n_units)
|
|
1299
|
-
|
|
1300
|
-
unit_strata = (
|
|
1301
|
-
resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
|
|
1302
|
-
)
|
|
1303
|
-
unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
|
|
1304
|
-
unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
|
|
1305
|
-
|
|
1306
|
-
# Count unique strata/PSU in the unit-level subset
|
|
1307
|
-
n_strata_unit = len(np.unique(unit_strata)) if unit_strata is not None else 0
|
|
1308
|
-
n_psu_unit = len(np.unique(unit_psu)) if unit_psu is not None else 0
|
|
1309
|
-
|
|
1310
|
-
unit_resolved = resolved_survey.subset_to_units(
|
|
1311
|
-
row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
|
|
1312
|
-
n_strata_unit, n_psu_unit,
|
|
1281
|
+
unit_resolved = resolved_survey.subset_to_units_by_row_idx(
|
|
1282
|
+
row_idx, unit_weights=precomp.get("unit_survey_weights")
|
|
1313
1283
|
)
|
|
1314
1284
|
|
|
1315
1285
|
X_ones = np.ones((n_units, 1))
|
|
@@ -1370,7 +1340,11 @@ class ContinuousDiD:
|
|
|
1370
1340
|
|
|
1371
1341
|
# Return unit-level survey df and resolved design for metadata recomputation
|
|
1372
1342
|
# Only override with n_valid-based df when replicates were actually dropped
|
|
1373
|
-
if
|
|
1343
|
+
if (
|
|
1344
|
+
resolved_survey is not None
|
|
1345
|
+
and hasattr(resolved_survey, "uses_replicate_variance")
|
|
1346
|
+
and resolved_survey.uses_replicate_variance
|
|
1347
|
+
):
|
|
1374
1348
|
if _rep_n_valid < unit_resolved.n_replicates:
|
|
1375
1349
|
unit_df_survey = _rep_n_valid - 1 if _rep_n_valid > 1 else None
|
|
1376
1350
|
else:
|
|
@@ -1415,7 +1389,11 @@ class ContinuousDiD:
|
|
|
1415
1389
|
|
|
1416
1390
|
# Reject replicate-weight designs for bootstrap — replicate variance
|
|
1417
1391
|
# is an analytical alternative to bootstrap, not compatible with it
|
|
1418
|
-
if
|
|
1392
|
+
if (
|
|
1393
|
+
resolved_survey is not None
|
|
1394
|
+
and hasattr(resolved_survey, "uses_replicate_variance")
|
|
1395
|
+
and resolved_survey.uses_replicate_variance
|
|
1396
|
+
):
|
|
1419
1397
|
raise NotImplementedError(
|
|
1420
1398
|
"ContinuousDiD bootstrap (n_bootstrap > 0) is not supported "
|
|
1421
1399
|
"with replicate-weight survey designs. Replicate weights provide "
|
|
@@ -1429,22 +1407,9 @@ class ContinuousDiD:
|
|
|
1429
1407
|
# Build unit-level ResolvedSurveyDesign for survey-aware bootstrap
|
|
1430
1408
|
unit_resolved = None
|
|
1431
1409
|
if resolved_survey is not None:
|
|
1432
|
-
from diff_diff.survey import ResolvedSurveyDesign
|
|
1433
|
-
|
|
1434
1410
|
row_idx = precomp["unit_first_panel_row"]
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
unit_weights = np.ones(n_units)
|
|
1438
|
-
unit_strata = (
|
|
1439
|
-
resolved_survey.strata[row_idx] if resolved_survey.strata is not None else None
|
|
1440
|
-
)
|
|
1441
|
-
unit_psu = resolved_survey.psu[row_idx] if resolved_survey.psu is not None else None
|
|
1442
|
-
unit_fpc = resolved_survey.fpc[row_idx] if resolved_survey.fpc is not None else None
|
|
1443
|
-
n_strata_u = len(np.unique(unit_strata)) if unit_strata is not None else 0
|
|
1444
|
-
n_psu_u = len(np.unique(unit_psu)) if unit_psu is not None else 0
|
|
1445
|
-
unit_resolved = resolved_survey.subset_to_units(
|
|
1446
|
-
row_idx, unit_weights, unit_strata, unit_psu, unit_fpc,
|
|
1447
|
-
n_strata_u, n_psu_u,
|
|
1411
|
+
unit_resolved = resolved_survey.subset_to_units_by_row_idx(
|
|
1412
|
+
row_idx, unit_weights=precomp.get("unit_survey_weights")
|
|
1448
1413
|
)
|
|
1449
1414
|
|
|
1450
1415
|
# Generate bootstrap weights — PSU-level when survey design is present
|
|
@@ -1682,7 +1647,7 @@ class ContinuousDiD:
|
|
|
1682
1647
|
boot_es[e],
|
|
1683
1648
|
alpha=self.alpha,
|
|
1684
1649
|
context=f"event study e={e}",
|
|
1685
|
-
|
|
1650
|
+
)
|
|
1686
1651
|
es_se[e] = se_e
|
|
1687
1652
|
es_ci[e] = ci_e
|
|
1688
1653
|
es_p[e] = p_e
|
|
@@ -65,8 +65,8 @@ _CHECK_NAMES: Tuple[str, ...] = (
|
|
|
65
65
|
"placebo",
|
|
66
66
|
)
|
|
67
67
|
|
|
68
|
-
# Type-level applicability: which checks are *ever* applicable for each
|
|
69
|
-
#
|
|
68
|
+
# Type-level applicability: which checks are *ever* applicable for each
|
|
69
|
+
# registered result type. Instance-level applicability further filters by whether
|
|
70
70
|
# required attributes are present (e.g. ``survey_metadata`` for DEFF) and by
|
|
71
71
|
# whether the user disabled a check via ``run_*=False``.
|
|
72
72
|
# See ``docs/methodology/REPORTING.md`` for the full matrix and rationale.
|
|
@@ -74,7 +74,7 @@ _CHECK_NAMES: Tuple[str, ...] = (
|
|
|
74
74
|
# Implementation note: The keys are result-class names looked up via
|
|
75
75
|
# ``type(results).__name__``. This string-based dispatch mirrors the
|
|
76
76
|
# ``_HANDLERS`` pattern in ``diff_diff/practitioner.py`` and avoids circular
|
|
77
|
-
# imports across the
|
|
77
|
+
# imports across the result modules. Renaming or aliasing any result class
|
|
78
78
|
# requires updating both this table and ``_PT_METHOD`` below; the
|
|
79
79
|
# applicability-matrix test parametrized over all result types serves as the
|
|
80
80
|
# regression guard.
|
|
@@ -131,6 +131,27 @@ _APPLICABILITY: Dict[str, FrozenSet[str]] = {
|
|
|
131
131
|
"heterogeneity",
|
|
132
132
|
}
|
|
133
133
|
),
|
|
134
|
+
"SpilloverDiDResults": frozenset(
|
|
135
|
+
# Butts (2021) ring-indicator spillover DiD is a two-stage-GMM
|
|
136
|
+
# estimator, so it inherits TwoStage's diagnostic set MINUS
|
|
137
|
+
# ``bacon``. ``bacon`` is excluded because SpilloverDiD identifies
|
|
138
|
+
# the direct effect off FAR-AWAY units (Butts Assumption 5), not
|
|
139
|
+
# off the TWFE 2x2 comparisons a Goodman-Bacon decomposition
|
|
140
|
+
# enumerates: ``bacon_decompose`` on the raw binary treatment
|
|
141
|
+
# ignores the ring/distance structure and would pool spillover-
|
|
142
|
+
# contaminated in-ring units into the control group — the exact
|
|
143
|
+
# SUTVA violation the estimator exists to handle (same rationale
|
|
144
|
+
# that excludes bacon for SyntheticControl / TROP / Continuous).
|
|
145
|
+
# ``parallel_trends`` routes to ``event_study`` on the per-event-
|
|
146
|
+
# time DIRECT-effect dynamics (populated when ``event_study=True``);
|
|
147
|
+
# ``design_effect`` is instance-gated on ``survey_metadata`` (Wave
|
|
148
|
+
# E.1); ``heterogeneity`` reads ``event_study_effects``.
|
|
149
|
+
{
|
|
150
|
+
"parallel_trends",
|
|
151
|
+
"design_effect",
|
|
152
|
+
"heterogeneity",
|
|
153
|
+
}
|
|
154
|
+
),
|
|
134
155
|
"StackedDiDResults": frozenset(
|
|
135
156
|
{
|
|
136
157
|
"parallel_trends",
|
|
@@ -218,6 +239,7 @@ _PT_METHOD: Dict[str, str] = {
|
|
|
218
239
|
"SunAbrahamResults": "event_study",
|
|
219
240
|
"ImputationDiDResults": "event_study",
|
|
220
241
|
"TwoStageDiDResults": "event_study",
|
|
242
|
+
"SpilloverDiDResults": "event_study",
|
|
221
243
|
"StackedDiDResults": "event_study",
|
|
222
244
|
"EfficientDiDResults": "hausman",
|
|
223
245
|
"ContinuousDiDResults": "event_study",
|
|
@@ -263,7 +285,7 @@ class DiagnosticReport:
|
|
|
263
285
|
----------
|
|
264
286
|
results : Any
|
|
265
287
|
A fitted diff-diff results object (e.g. ``CallawaySantAnnaResults``,
|
|
266
|
-
``DiDResults``, ``SyntheticDiDResults``). Any
|
|
288
|
+
``DiDResults``, ``SyntheticDiDResults``). Any registered result type
|
|
267
289
|
in the library is accepted.
|
|
268
290
|
data : pandas.DataFrame, optional
|
|
269
291
|
The underlying panel. Required for checks that need raw data
|
|
@@ -703,6 +725,19 @@ class DiagnosticReport:
|
|
|
703
725
|
# summary emits the "inconclusive" identifying-
|
|
704
726
|
# assumption warning rather than silently dropping PT.
|
|
705
727
|
if not pre_coefs and n_dropped_undefined == 0:
|
|
728
|
+
# SpilloverDiD's event-study switch is the
|
|
729
|
+
# ``SpilloverDiD(..., event_study=True)`` constructor
|
|
730
|
+
# kwarg, not the ``aggregate='event_study'`` argument
|
|
731
|
+
# the generic staggered-estimator message points at
|
|
732
|
+
# (SpilloverDiD has no ``aggregate`` kwarg). Emit an
|
|
733
|
+
# estimator-accurate remediation for this family.
|
|
734
|
+
if name == "SpilloverDiDResults":
|
|
735
|
+
return (
|
|
736
|
+
"No pre-period event-study coefficients are exposed "
|
|
737
|
+
"on this fit. Re-fit with "
|
|
738
|
+
"SpilloverDiD(..., event_study=True) to populate the "
|
|
739
|
+
"per-event-time direct-effect output."
|
|
740
|
+
)
|
|
706
741
|
return (
|
|
707
742
|
"No pre-period event-study coefficients are exposed on "
|
|
708
743
|
"this fit. For staggered estimators, re-fit with "
|
|
@@ -2287,6 +2322,7 @@ class DiagnosticReport:
|
|
|
2287
2322
|
"rmspe_ratio": _to_python_float(getattr(r, "rmspe_ratio", None)),
|
|
2288
2323
|
"n_placebos": _to_python_scalar(n_placebos),
|
|
2289
2324
|
"n_failed": _to_python_scalar(getattr(r, "n_failed", None)),
|
|
2325
|
+
"n_infeasible": _to_python_scalar(getattr(r, "n_infeasible", None)),
|
|
2290
2326
|
}
|
|
2291
2327
|
# Distinguish a valid run from an attempted-but-infeasible one so BR/DR
|
|
2292
2328
|
# consumers see an explicit status/reason rather than a bare NaN p-value.
|
|
@@ -2312,15 +2348,35 @@ class DiagnosticReport:
|
|
|
2312
2348
|
"all_placebos_failed": (
|
|
2313
2349
|
"in_space_placebo() was run but every donor refit failed to "
|
|
2314
2350
|
"converge, so no placebo entered the reference set; "
|
|
2315
|
-
"placebo_p_value is NaN."
|
|
2351
|
+
"placebo_p_value is NaN. Raise n_starts or loosen the "
|
|
2352
|
+
"optimizer tolerances."
|
|
2353
|
+
),
|
|
2354
|
+
"all_placebos_infeasible": (
|
|
2355
|
+
"in_space_placebo() was run but every donor refit was "
|
|
2356
|
+
"structurally infeasible under v_method='cv' (the "
|
|
2357
|
+
"pseudo-treated donor pool is indistinguishable in a "
|
|
2358
|
+
"re-aggregated CV window), so no placebo entered the reference "
|
|
2359
|
+
"set; placebo_p_value is NaN. Adjust the predictors, v_cv_t0, "
|
|
2360
|
+
"or the donor pool."
|
|
2361
|
+
),
|
|
2362
|
+
"all_placebos_unusable": (
|
|
2363
|
+
"in_space_placebo() was run but no donor refit was usable: "
|
|
2364
|
+
"some failed to converge AND some were structurally infeasible "
|
|
2365
|
+
"(see n_failed / n_infeasible); placebo_p_value is NaN."
|
|
2316
2366
|
),
|
|
2317
2367
|
}
|
|
2318
2368
|
block["status"] = "infeasible"
|
|
2369
|
+
# Machine-readable code distinguishing a solver convergence failure
|
|
2370
|
+
# ("all_placebos_failed") from structural infeasibility
|
|
2371
|
+
# ("all_placebos_infeasible" / "too_few_donors") or a mix
|
|
2372
|
+
# ("all_placebos_unusable"), without parsing `reason`.
|
|
2373
|
+
block["reason_code"] = placebo_status
|
|
2319
2374
|
block["reason"] = _reasons.get(
|
|
2320
2375
|
placebo_status,
|
|
2321
2376
|
"in_space_placebo() was run but produced no valid reference set "
|
|
2322
2377
|
"(fewer than 2 donors, a non-converged treated fit, or all donor "
|
|
2323
|
-
"refits failed); placebo_p_value is
|
|
2378
|
+
"refits failed / were structurally infeasible); placebo_p_value is "
|
|
2379
|
+
"NaN.",
|
|
2324
2380
|
)
|
|
2325
2381
|
out["in_space_placebo"] = block
|
|
2326
2382
|
else:
|
|
@@ -2353,6 +2409,7 @@ class DiagnosticReport:
|
|
|
2353
2409
|
else None
|
|
2354
2410
|
),
|
|
2355
2411
|
"n_failed": _to_python_scalar(getattr(r, "_loo_n_failed", None)),
|
|
2412
|
+
"n_infeasible": _to_python_scalar(getattr(r, "_loo_n_infeasible", None)),
|
|
2356
2413
|
}
|
|
2357
2414
|
else:
|
|
2358
2415
|
_loo_reasons = {
|
|
@@ -2371,13 +2428,29 @@ class DiagnosticReport:
|
|
|
2371
2428
|
"(see the status='failed' rows); raise n_starts or loosen the "
|
|
2372
2429
|
"optimizer tolerances."
|
|
2373
2430
|
),
|
|
2431
|
+
"all_refits_infeasible": (
|
|
2432
|
+
"leave_one_out() was run but every donor-drop refit was "
|
|
2433
|
+
"structurally infeasible under v_method='cv' (the reduced donor "
|
|
2434
|
+
"pool is indistinguishable in a re-aggregated CV window; see the "
|
|
2435
|
+
"status='infeasible' rows); adjust the predictors, v_cv_t0, or "
|
|
2436
|
+
"the donor pool."
|
|
2437
|
+
),
|
|
2438
|
+
"all_refits_unusable": (
|
|
2439
|
+
"leave_one_out() was run but no donor-drop refit was usable: "
|
|
2440
|
+
"some failed to converge AND some were structurally infeasible "
|
|
2441
|
+
"(see n_failed / n_infeasible)."
|
|
2442
|
+
),
|
|
2374
2443
|
}
|
|
2375
2444
|
out["leave_one_out"] = {
|
|
2376
2445
|
"status": "infeasible",
|
|
2377
2446
|
# Machine-readable code so consumers can distinguish a numerical
|
|
2378
2447
|
# convergence failure ("all_refits_failed") from structural
|
|
2379
|
-
# infeasibility ("too_few_donors")
|
|
2448
|
+
# infeasibility ("all_refits_infeasible" / "too_few_donors") or a
|
|
2449
|
+
# mix ("all_refits_unusable") without parsing `reason`. The n_failed
|
|
2450
|
+
# / n_infeasible counts give the exact breakdown.
|
|
2380
2451
|
"reason_code": loo_status,
|
|
2452
|
+
"n_failed": _to_python_scalar(getattr(r, "_loo_n_failed", None)),
|
|
2453
|
+
"n_infeasible": _to_python_scalar(getattr(r, "_loo_n_infeasible", None)),
|
|
2381
2454
|
"reason": _loo_reasons.get(
|
|
2382
2455
|
loo_status, "leave_one_out() produced no valid refits."
|
|
2383
2456
|
),
|
|
@@ -2459,6 +2532,107 @@ class DiagnosticReport:
|
|
|
2459
2532
|
),
|
|
2460
2533
|
}
|
|
2461
2534
|
|
|
2535
|
+
# Regression-weight extrapolation diagnostic (ADH 2015 §4): opt-in, surfaced once
|
|
2536
|
+
# the user has run results.regression_weights() (pure linear algebra — no refits).
|
|
2537
|
+
if getattr(r, "_regw_df", None) is not None:
|
|
2538
|
+
regw_status = getattr(r, "_regw_status", None)
|
|
2539
|
+
if regw_status == "ran":
|
|
2540
|
+
out["regression_weights"] = {
|
|
2541
|
+
"status": "ran",
|
|
2542
|
+
# Headline: how many donors an OLS/regression counterfactual would push
|
|
2543
|
+
# outside [0,1] (extrapolate). The SC simplex never does.
|
|
2544
|
+
"n_extrapolating": _to_python_scalar(getattr(r, "_regw_n_extrapolating", None)),
|
|
2545
|
+
# True if W^reg is a non-unique min-norm solution (not full row rank);
|
|
2546
|
+
# weight_sum then need not equal 1.
|
|
2547
|
+
"rank_deficient": bool(getattr(r, "_regw_rank_deficient", False)),
|
|
2548
|
+
"weight_sum": _to_python_float(getattr(r, "_regw_weight_sum", None)),
|
|
2549
|
+
}
|
|
2550
|
+
else:
|
|
2551
|
+
_regw_reasons = {
|
|
2552
|
+
"treated_fit_nonconverged": (
|
|
2553
|
+
"regression_weights() was run but the treated unit's own SCM fit "
|
|
2554
|
+
"did not converge at fit time, so the synthetic control it is "
|
|
2555
|
+
"compared against is not a valid optimum."
|
|
2556
|
+
),
|
|
2557
|
+
"too_few_donors": (
|
|
2558
|
+
"regression_weights() was run but fewer than 2 donors are available "
|
|
2559
|
+
"(W^reg is trivially [1] with a single donor)."
|
|
2560
|
+
),
|
|
2561
|
+
}
|
|
2562
|
+
out["regression_weights"] = {
|
|
2563
|
+
"status": "infeasible",
|
|
2564
|
+
"reason_code": regw_status,
|
|
2565
|
+
"reason": _regw_reasons.get(
|
|
2566
|
+
regw_status, "regression_weights() produced no table."
|
|
2567
|
+
),
|
|
2568
|
+
}
|
|
2569
|
+
else:
|
|
2570
|
+
out["regression_weights"] = {
|
|
2571
|
+
"status": "not_run",
|
|
2572
|
+
"reason": (
|
|
2573
|
+
"Call results.regression_weights() to flag donors an OLS/regression "
|
|
2574
|
+
"counterfactual would weight outside [0,1] (ADH 2015 §4 extrapolation "
|
|
2575
|
+
"diagnostic; opt-in, no refit)."
|
|
2576
|
+
),
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
# Sparse-SC subset search (ADH 2015 §4): opt-in, surfaced once the user has run
|
|
2580
|
+
# results.sparse_synthetic_control() (exhaustive subset search, V held fixed).
|
|
2581
|
+
if getattr(r, "_sparse_df", None) is not None:
|
|
2582
|
+
sparse_status = getattr(r, "_sparse_status", None)
|
|
2583
|
+
if sparse_status == "ran":
|
|
2584
|
+
# Compact per-size summary: how far the ATT / fit move as the synthetic is
|
|
2585
|
+
# forced sparse (the baseline row is dropped — it is the full-fit reference).
|
|
2586
|
+
records = r._sparse_df.to_dict("records")
|
|
2587
|
+
per_size = [
|
|
2588
|
+
{
|
|
2589
|
+
"size": _to_python_scalar(rec["size"]),
|
|
2590
|
+
"att": _to_python_float(rec["att"]),
|
|
2591
|
+
"delta_att": _to_python_float(rec["delta_att"]),
|
|
2592
|
+
"pre_rmspe": _to_python_float(rec["pre_rmspe"]),
|
|
2593
|
+
"n_failed": _to_python_scalar(rec["n_failed"]),
|
|
2594
|
+
"status": rec["status"],
|
|
2595
|
+
}
|
|
2596
|
+
for rec in records
|
|
2597
|
+
if rec["status"] != "baseline"
|
|
2598
|
+
]
|
|
2599
|
+
out["sparse_synthetic_control"] = {
|
|
2600
|
+
"status": "ran",
|
|
2601
|
+
# Headline: the largest baseline-relative ATT swing across searched sizes.
|
|
2602
|
+
"max_abs_delta_att": _to_python_float(
|
|
2603
|
+
getattr(r, "_sparse_max_abs_delta_att", None)
|
|
2604
|
+
),
|
|
2605
|
+
"sizes": per_size,
|
|
2606
|
+
}
|
|
2607
|
+
else:
|
|
2608
|
+
_sparse_reasons = {
|
|
2609
|
+
"treated_fit_nonconverged": (
|
|
2610
|
+
"sparse_synthetic_control() was run but the treated unit's own SCM "
|
|
2611
|
+
"fit did not converge at fit time, so the baseline ATT is not a "
|
|
2612
|
+
"valid reference for the sparse deltas."
|
|
2613
|
+
),
|
|
2614
|
+
"too_few_donors": (
|
|
2615
|
+
"sparse_synthetic_control() was run but fewer than 2 donors are "
|
|
2616
|
+
"available (a sparse subset must be smaller than the pool)."
|
|
2617
|
+
),
|
|
2618
|
+
}
|
|
2619
|
+
out["sparse_synthetic_control"] = {
|
|
2620
|
+
"status": "infeasible",
|
|
2621
|
+
"reason_code": sparse_status,
|
|
2622
|
+
"reason": _sparse_reasons.get(
|
|
2623
|
+
sparse_status, "sparse_synthetic_control() produced no valid subsets."
|
|
2624
|
+
),
|
|
2625
|
+
}
|
|
2626
|
+
else:
|
|
2627
|
+
out["sparse_synthetic_control"] = {
|
|
2628
|
+
"status": "not_run",
|
|
2629
|
+
"reason": (
|
|
2630
|
+
"Call results.sparse_synthetic_control() to test how the fit / ATT "
|
|
2631
|
+
"degrade when the synthetic is forced to a few donors (ADH 2015 §4 "
|
|
2632
|
+
"sparse subset search; opt-in, V held fixed)."
|
|
2633
|
+
),
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2462
2636
|
# Test-inversion confidence set (Firpo & Possebom 2018 §4): opt-in, surfaced once
|
|
2463
2637
|
# the user has run results.confidence_set() (it reuses the in-space placebo
|
|
2464
2638
|
# reference set — no refits). The analytical conf_int stays NaN; this is a SEPARATE
|