Skip to content

Commit 67b5cf9

Browse files
feat(did): did_summary_to_markdown() and did_summary_to_latex() exports
Publication-ready table export helpers for sp.did_summary() results: - did_summary_to_markdown(result): GitHub-flavoured Markdown table with Method / Estimate (with significance stars) / SE / 95% CI / p-value / optional Breakdown M* / Notes. Includes a footer with cross-method mean and SD. - did_summary_to_latex(result): full booktabs LaTeX block with \toprule / \midrule / \bottomrule, auto-escaped ampersands in method names, and a footer matching the Markdown version. Both helpers: - Auto-detect whether breakdown_m column is populated and show that column only when sensitivity analysis was run. - Validate input via _ensure_did_summary() — non-did_summary CausalResults raise ValueError with a clear message. - Accept digits / include_ci / include_breakdown / label / caption parameters for presentation tuning. Verified: both formats render correctly with 5-method output including the sensitivity column; malformed input correctly raises. Commit touches only the DID module files — no collateral sweeps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 416a0fe commit 67b5cf9

4 files changed

Lines changed: 219 additions & 2 deletions

File tree

src/statspai/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
did, did_2x2, ddd, callaway_santanna, sun_abraham,
3535
bacon_decomposition, honest_did, breakdown_m, event_study,
3636
did_analysis, DIDAnalysis, did_multiplegt, did_imputation, stacked_did, cic,
37-
wooldridge_did, etwfe, drdid, twfe_decomposition, did_summary,
37+
wooldridge_did, etwfe, drdid, twfe_decomposition,
38+
did_summary, did_summary_to_markdown, did_summary_to_latex,
3839
pretrends_test, pretrends_power, sensitivity_rr, SensitivityResult, pretrends_summary,
3940
parallel_trends_plot, bacon_plot, group_time_plot, did_plot,
4041
enhanced_event_study_plot, treatment_rollout_plot,
@@ -228,6 +229,8 @@
228229
"wooldridge_did",
229230
"etwfe",
230231
"did_summary",
232+
"did_summary_to_markdown",
233+
"did_summary_to_latex",
231234
"drdid",
232235
"twfe_decomposition",
233236
# RD

src/statspai/did/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
pretrends_summary,
4747
)
4848
from .wooldridge_did import wooldridge_did, etwfe, drdid, twfe_decomposition
49-
from .summary import did_summary
49+
from .summary import did_summary, did_summary_to_markdown, did_summary_to_latex
5050
from .continuous_did import continuous_did
5151
from .plots import (
5252
parallel_trends_plot,
@@ -388,6 +388,8 @@ def did(
388388
'wooldridge_did',
389389
'etwfe',
390390
'did_summary',
391+
'did_summary_to_markdown',
392+
'did_summary_to_latex',
391393
'drdid',
392394
'twfe_decomposition',
393395
# Pre-trends

src/statspai/did/summary.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@
3838
from ..core.results import CausalResult
3939

4040

41+
def _stars(p: float) -> str:
42+
"""Significance stars for a p-value."""
43+
if pd.isna(p):
44+
return ""
45+
if p < 0.01:
46+
return "***"
47+
if p < 0.05:
48+
return "**"
49+
if p < 0.1:
50+
return "*"
51+
return ""
52+
53+
4154
_DEFAULT_METHODS: List[str] = ["cs", "sa", "bjs", "etwfe", "stacked"]
4255

4356
_METHOD_LABELS = {
@@ -309,3 +322,202 @@ def did_summary(
309322
},
310323
_citation_key="did_summary",
311324
)
325+
326+
327+
# ═══════════════════════════════════════════════════════════════════════
328+
# Export helpers: publication-ready Markdown / LaTeX from a did_summary
329+
# ═══════════════════════════════════════════════════════════════════════
330+
331+
def _ensure_did_summary(result: CausalResult) -> pd.DataFrame:
332+
"""Validate that result came from did_summary() and return its detail."""
333+
det = getattr(result, "detail", None)
334+
if not isinstance(det, pd.DataFrame) or "estimator" not in det.columns:
335+
raise ValueError(
336+
"did_summary_to_markdown / _to_latex require a CausalResult "
337+
"produced by sp.did_summary()."
338+
)
339+
return det
340+
341+
342+
def did_summary_to_markdown(
343+
result: CausalResult,
344+
digits: int = 4,
345+
include_ci: bool = True,
346+
include_breakdown: bool = True,
347+
) -> str:
348+
"""
349+
Render a :func:`did_summary` result as a GitHub-Flavoured Markdown table.
350+
351+
Columns shown (in order):
352+
``Method``, ``Estimate``, ``SE``, ``95 % CI``, ``p-value``, and
353+
optionally ``Breakdown M*`` (when sensitivity was requested).
354+
355+
Parameters
356+
----------
357+
result : CausalResult
358+
Output of :func:`did_summary`.
359+
digits : int, default 4
360+
Decimal precision for numeric columns.
361+
include_ci : bool, default True
362+
Include the 95 % CI column.
363+
include_breakdown : bool, default True
364+
Include the Rambachan-Roth breakdown M* column (CS row only,
365+
blank for others). Ignored if sensitivity was not requested.
366+
367+
Returns
368+
-------
369+
str
370+
Multi-line Markdown table, ready to paste into notebooks or PRs.
371+
"""
372+
det = _ensure_did_summary(result)
373+
374+
has_bd = include_breakdown and det["breakdown_m"].notna().any()
375+
lines = []
376+
header_cells = ["Method", "Estimate", "SE"]
377+
if include_ci:
378+
header_cells.append("95% CI")
379+
header_cells += ["p"]
380+
if has_bd:
381+
header_cells.append("Breakdown M*")
382+
header_cells.append("Notes")
383+
lines.append("| " + " | ".join(header_cells) + " |")
384+
lines.append("|" + "|".join([":---"] + ["---:"] * (len(header_cells) - 2) + [":---"]) + "|")
385+
386+
fmt = f"{{:.{digits}f}}"
387+
for _, row in det.iterrows():
388+
cells = [row["estimator"]]
389+
if pd.isna(row["estimate"]):
390+
cells.append("—")
391+
cells.append("—")
392+
if include_ci:
393+
cells.append("—")
394+
cells.append("—")
395+
if has_bd:
396+
cells.append("—")
397+
else:
398+
est_str = fmt.format(row["estimate"]) + _stars(row["pvalue"])
399+
cells.append(est_str)
400+
cells.append(fmt.format(row["se"]))
401+
if include_ci:
402+
cells.append(
403+
f"[{fmt.format(row['ci_low'])}, {fmt.format(row['ci_high'])}]"
404+
)
405+
cells.append(fmt.format(row["pvalue"]))
406+
if has_bd:
407+
bd = row.get("breakdown_m", np.nan)
408+
cells.append(fmt.format(bd) if pd.notna(bd) else "—")
409+
cells.append(str(row.get("note", "") or ""))
410+
lines.append("| " + " | ".join(cells) + " |")
411+
412+
# Footer
413+
lines.append("")
414+
if getattr(result, "estimate", None) is not None and not pd.isna(result.estimate):
415+
lines.append(
416+
f"*Mean across methods = {fmt.format(result.estimate)}, "
417+
f"SD across methods = {fmt.format(result.se)}.* "
418+
"\\* p<0.1, \\*\\* p<0.05, \\*\\*\\* p<0.01."
419+
)
420+
return "\n".join(lines)
421+
422+
423+
def did_summary_to_latex(
424+
result: CausalResult,
425+
digits: int = 4,
426+
include_ci: bool = True,
427+
include_breakdown: bool = True,
428+
label: str = "tab:did_summary",
429+
caption: str = "DID method-robustness summary.",
430+
) -> str:
431+
"""
432+
Render a :func:`did_summary` result as a publication-ready LaTeX
433+
``booktabs`` table.
434+
435+
Parameters
436+
----------
437+
result : CausalResult
438+
Output of :func:`did_summary`.
439+
digits : int, default 4
440+
Decimal precision.
441+
include_ci : bool, default True
442+
Include the 95 % CI column.
443+
include_breakdown : bool, default True
444+
Include the Rambachan-Roth breakdown M* column when sensitivity
445+
was requested.
446+
label : str, default ``'tab:did_summary'``
447+
LaTeX label for the table.
448+
caption : str, default ``'DID method-robustness summary.'``
449+
LaTeX caption.
450+
451+
Returns
452+
-------
453+
str
454+
Full ``\\begin{table} ... \\end{table}`` block using the
455+
``booktabs`` package (``\\toprule``, ``\\midrule``, ``\\bottomrule``).
456+
457+
Notes
458+
-----
459+
Requires ``\\usepackage{booktabs}`` in the LaTeX preamble.
460+
"""
461+
det = _ensure_did_summary(result)
462+
has_bd = include_breakdown and det["breakdown_m"].notna().any()
463+
464+
cols = ["l", "c", "c"]
465+
header = ["Method", "Estimate", "SE"]
466+
if include_ci:
467+
cols.append("c")
468+
header.append("95\\% CI")
469+
cols.append("c")
470+
header.append("$p$")
471+
if has_bd:
472+
cols.append("c")
473+
header.append("Breakdown $M^*$")
474+
475+
fmt = f"{{:.{digits}f}}"
476+
lines = [
477+
"\\begin{table}[htbp]",
478+
"\\centering",
479+
f"\\caption{{{caption}}}",
480+
f"\\label{{{label}}}",
481+
"\\begin{tabular}{" + "".join(cols) + "}",
482+
"\\toprule",
483+
" & ".join(header) + " \\\\",
484+
"\\midrule",
485+
]
486+
487+
for _, row in det.iterrows():
488+
cells = [str(row["estimator"]).replace("&", "\\&")]
489+
if pd.isna(row["estimate"]):
490+
cells += ["---"] * (len(header) - 1)
491+
else:
492+
est_str = fmt.format(row["estimate"]) + _stars(row["pvalue"])
493+
cells.append(est_str)
494+
cells.append(fmt.format(row["se"]))
495+
if include_ci:
496+
cells.append(
497+
f"[{fmt.format(row['ci_low'])}, {fmt.format(row['ci_high'])}]"
498+
)
499+
cells.append(fmt.format(row["pvalue"]))
500+
if has_bd:
501+
bd = row.get("breakdown_m", np.nan)
502+
cells.append(fmt.format(bd) if pd.notna(bd) else "---")
503+
lines.append(" & ".join(cells) + " \\\\")
504+
505+
lines += [
506+
"\\bottomrule",
507+
"\\end{tabular}",
508+
]
509+
510+
# Footnote row
511+
note_parts = []
512+
if getattr(result, "estimate", None) is not None and not pd.isna(result.estimate):
513+
note_parts.append(
514+
f"Mean across methods = {fmt.format(result.estimate)}, "
515+
f"SD across methods = {fmt.format(result.se)}."
516+
)
517+
note_parts.append("$^*p<0.1,\\,^{**}p<0.05,\\,^{***}p<0.01$.")
518+
if note_parts:
519+
lines.append(
520+
"\\vspace{0.5ex}\\footnotesize " + " ".join(note_parts)
521+
)
522+
lines.append("\\end{table}")
523+
return "\n".join(lines)

0 commit comments

Comments
 (0)