Skip to content

Commit a53009a

Browse files
feat(did): did_summary gains include_sensitivity — 3-way robustness
Extends sp.did_summary() with an include_sensitivity=True flag that runs the Rambachan-Roth (2023) breakdown M* on top of the Callaway- Sant'Anna fit (when 'cs' is among the requested methods). The value is exposed both in model_info['breakdown_m'] and in a new breakdown_m column on the detail DataFrame (populated for the CS row; NaN for other methods since honest_did is CS-specific). This gives researchers a one-call three-way robustness readout: method (CS/SA/BJS/ETWFE/Stacked) × estimate × sensitivity threshold Backward compatible: default include_sensitivity=False preserves prior output exactly (tested — breakdown_m column is all-NaN, model_info field is None). Graceful fallback: when 'cs' is not in methods_list, the flag is a no-op rather than an error. If breakdown_m itself fails, the failure is recorded in model_info['methods_failed']['__sensitivity__']. Verified: forest plot still renders correctly with the added column. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 27a37ec commit a53009a

1 file changed

Lines changed: 49 additions & 6 deletions

File tree

src/statspai/did/summary.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def did_summary(
128128
controls: Optional[List[str]] = None,
129129
cluster: Optional[str] = None,
130130
alpha: float = 0.05,
131+
include_sensitivity: bool = False,
131132
verbose: bool = False,
132133
) -> CausalResult:
133134
"""
@@ -159,6 +160,13 @@ def did_summary(
159160
Cluster variable for SE (defaults to ``group`` in each sub-method).
160161
alpha : float, default 0.05
161162
Significance level for confidence intervals.
163+
include_sensitivity : bool, default False
164+
If ``True`` and ``'cs'`` is among the methods fit, compute the
165+
Rambachan–Roth (2023) *breakdown M\\** — the largest relative
166+
violation of parallel trends under which the treatment effect
167+
is still significantly different from zero. The value is added
168+
to ``model_info['breakdown_m']`` and to the ``breakdown_m``
169+
column of ``detail`` (CS row only; other methods leave ``NaN``).
162170
verbose : bool, default False
163171
Print progress for each method.
164172
@@ -213,17 +221,29 @@ def did_summary(
213221
rows: List[dict] = []
214222
failed: dict = {}
215223
fit: List[str] = []
224+
cs_raw = None # raw CS result for sensitivity analysis
216225

217226
for name in methods_list:
218227
label = _METHOD_LABELS[name]
219228
if verbose:
220229
print(f" running {name} ({label})...", flush=True)
221230
try:
222-
res = _DISPATCH[name](
223-
data, y=y, group=group, time=time,
224-
first_treat=first_treat, controls=controls,
225-
cluster=cluster, alpha=alpha,
226-
)
231+
if name == "cs" and include_sensitivity:
232+
# Run CS + aggte inline so we can hold on to the raw
233+
# CS result for the subsequent breakdown_m call.
234+
from .callaway_santanna import callaway_santanna
235+
from .aggte import aggte as _aggte
236+
cs_raw = callaway_santanna(
237+
data, y=y, g=first_treat, t=time, i=group,
238+
x=controls, alpha=alpha,
239+
)
240+
res = _aggte(cs_raw, type="simple", alpha=alpha, bstrap=False)
241+
else:
242+
res = _DISPATCH[name](
243+
data, y=y, group=group, time=time,
244+
first_treat=first_treat, controls=controls,
245+
cluster=cluster, alpha=alpha,
246+
)
227247
vals = _extract(res)
228248
rows.append(dict(method=name, estimator=label, note="", **vals))
229249
fit.append(name)
@@ -236,9 +256,31 @@ def did_summary(
236256
note=f"FAILED: {type(exc).__name__}",
237257
))
238258

259+
# Optional Rambachan–Roth breakdown M*
260+
breakdown_m_value: Optional[float] = None
261+
breakdown_m_col: List[float] = [np.nan] * len(rows)
262+
if include_sensitivity and cs_raw is not None:
263+
try:
264+
from .honest_did import breakdown_m
265+
breakdown_m_value = float(breakdown_m(cs_raw, e=0, alpha=alpha))
266+
# Fill the CS row
267+
for i, r in enumerate(rows):
268+
if r["method"] == "cs":
269+
breakdown_m_col[i] = breakdown_m_value
270+
if r["note"] == "":
271+
r["note"] = f"breakdown M* = {breakdown_m_value:.3f}"
272+
break
273+
except Exception as exc:
274+
failed["__sensitivity__"] = (
275+
f"breakdown_m failed: {type(exc).__name__}: {str(exc)[:120]}"
276+
)
277+
278+
for i, r in enumerate(rows):
279+
r["breakdown_m"] = breakdown_m_col[i]
280+
239281
detail = pd.DataFrame(rows, columns=[
240282
"method", "estimator", "estimate", "se", "pvalue",
241-
"ci_low", "ci_high", "n_obs", "note",
283+
"ci_low", "ci_high", "n_obs", "breakdown_m", "note",
242284
])
243285

244286
ests = detail.loc[detail["estimate"].notna(), "estimate"].values
@@ -263,6 +305,7 @@ def did_summary(
263305
"methods_fit": fit,
264306
"methods_failed": failed,
265307
"dispersion": disp,
308+
"breakdown_m": breakdown_m_value,
266309
},
267310
_citation_key="did_summary",
268311
)

0 commit comments

Comments
 (0)