statspai.did¶
did ¶
Difference-in-Differences (DID) module for StatsPAI.
Provides estimators for: - Classic 2×2 DID (two groups, two periods) - Triple Differences / DDD (two groups, two periods, within-unit subgroup) - Callaway & Sant'Anna (2021) — staggered DID with DR/IPW/REG - Sun & Abraham (2021) — interaction-weighted event study - Synthetic DID (Arkhangelsky et al. 2021) - Goodman-Bacon (2021) — TWFE decomposition diagnostic - Honest DID (Rambachan & Roth 2023) — parallel trends sensitivity - de Chaisemartin & D'Haultfoeuille (2020) — DID with treatment switching - Borusyak, Jaravel & Spiess (2024) — imputation DID estimator - Stacked DID (Cengiz, Dube, Lindner & Zipperer, 2019) - did_analysis() — one-call DID workflow - Wooldridge (2021) — extended TWFE with cohort × time interactions - Sant'Anna & Zhao (2020) — doubly robust DID - TWFE decomposition — Bacon (2021) + de Chaisemartin–D'Haultfoeuille (2020) weights
AbsorbingCheck ¶
Bases: NamedTuple
Whether treatment is absorbing, and where it is not.
EquivalenceResult ¶
Bases: NamedTuple
Joint pre-trend test plus its two equivalence counterparts.
verdict ¶
One-line reading of the detection and equivalence tests together.
Equivalence is judged on the TOST when a threshold was supplied, because that bound is in interpretable outcome units. The non-central-F test uses a dimensionless bound (0.6 by default) that rejects easily, so it is only used as the fallback.
StaggeredRolloutResult ¶
Bases: NamedTuple
Point estimate, both standard errors, and the fitted control weights.
DIDAnalysis
dataclass
¶
Bundled results from a full DID analysis workflow.
Returned by :func:sp.did_analysis; bundles the detected design, the
main ATT estimate, optional event study / Bacon decomposition /
honest-DID sensitivity, and a step-by-step log. Use .summary()
for a human-readable report and .plot() for the event study.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> post = np.tile([0, 1], n)
>>> policy = np.repeat(rng.integers(0, 2, size=n), 2)
>>> wage = (2.0 + 0.5 * post + 1.0 * (policy * post)
... + rng.normal(size=2 * n))
>>> df = pd.DataFrame({'post': post, 'policy': policy, 'wage': wage})
>>> report = sp.did_analysis(df, y='wage', treat='policy',
... time='post', run_sensitivity=False)
>>> isinstance(report, sp.DIDAnalysis)
True
>>> report.design
'2x2'
DiDBalanceResult
dataclass
¶
Balance table for a DiD design.
Attributes:
| Name | Type | Description |
|---|---|---|
table |
DataFrame
|
Long form, one row per (panel, covariate). Columns:
|
levels, changes |
DataFrame
|
The two panels, split out for convenience. |
threshold |
float
|
Absolute normalized-difference cutoff used for |
n_treated, n_comparison |
int
|
|
base_period, comparison_period |
Any
|
|
weighted |
bool
|
|
diagnostics |
dict
|
|
DiDClusterDiagnostics
dataclass
¶
Cluster counts for a staggered design, graded against known evidence.
DiDDesignContract
dataclass
¶
What a fitted DiD result pins down, against Baker et al.'s recipe.
EventStudyConventionResult
dataclass
¶
Paths and convention diagnostics for one panel.
Attributes:
| Name | Type | Description |
|---|---|---|
paths |
DataFrame
|
Long format: |
table |
DataFrame
|
One row per estimator with the registry fields plus |
reference |
str
|
Registry key of the benchmark path (dynamic TWFE). |
tolerance |
float
|
Scale-aware threshold used for |
DistributionalDiDResult
dataclass
¶
Bases: ResultProtocolMixin
Result of :func:distributional_did.
Attributes:
| Name | Type | Description |
|---|---|---|
table |
DataFrame
|
One row per outcome bin: |
n_units |
int
|
Units contributing to the influence functions. |
aggregation |
str
|
Which |
alpha |
float
|
|
diagnostics |
dict
|
|
plot ¶
Bar chart of the per-bin effect with pointwise confidence intervals.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
**kwargs
|
Any
|
Passed to |
{}
|
Returns:
| Type | Description |
|---|---|
Axes
|
|
FunctionalFormResult
dataclass
¶
Bases: ResultProtocolMixin
Result of :func:functional_form_test.
Attributes:
| Name | Type | Description |
|---|---|---|
pvalue |
float
|
P-value for |
table |
DataFrame
|
One row per outcome bin: |
statistic |
float
|
The max-t statistic |
n_bins |
int
|
Number of bins actually used (bins with a degenerate influence function are dropped, as in the reference implementation). |
n_units |
int
|
Units contributing to the influence functions. |
aggregation |
str
|
Which |
n_sims |
int
|
Simulation draws behind the critical value. |
alpha |
float
|
|
diagnostics |
dict
|
|
plot ¶
plot(ax: Any = None, figsize: tuple = (8, 5), lb: Optional[float] = None, ub: Optional[float] = None, **kwargs: Any) -> Any
Bar chart of the implied counterfactual density, negatives flagged.
This is the figure the test is really about: the p-value compresses into one number what the bars show directly — whether the distribution the design implies for the treated group's untreated outcome is a distribution at all. Bars below zero are the violation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
lb
|
float
|
Restrict the plotted outcome range, as |
None
|
ub
|
float
|
Restrict the plotted outcome range, as |
None
|
**kwargs
|
Any
|
Passed to |
{}
|
Returns:
| Type | Description |
|---|---|
Axes
|
|
Examples:
HarvestDIDResult
dataclass
¶
Bases: ResultProtocolMixin
Full diagnostic output of :func:harvest_did.
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> es = pd.DataFrame({
... "relative_time": [0, 1], "att": [0.4, 0.5],
... "se": [0.1, 0.1], "pvalue": [0.01, 0.02], "n_comparisons": [3, 3],
... })
>>> res = sp.HarvestDIDResult(
... estimate=0.45, se=0.07, ci=(0.31, 0.59), alpha=0.05,
... n_comparisons=6,
... comparisons=pd.DataFrame({"att": [0.4]}),
... event_study=es,
... pretrend_test={"pvalue": 0.6},
... )
>>> float(res.estimate)
0.45
SensitivityResult
dataclass
¶
Bases: ResultProtocolMixin
Result of Rambachan & Roth (2023) sensitivity analysis.
Attributes:
| Name | Type | Description |
|---|---|---|
mbar_grid |
ndarray
|
Grid of M-bar values tested. |
ci_lower |
ndarray
|
Lower bound of the honest CI at each M-bar. |
ci_upper |
ndarray
|
Upper bound of the honest CI at each M-bar. |
breakdown_mbar |
float
|
Smallest M-bar for which the CI includes zero (sign reversal). |
att |
float
|
Point estimate of the ATT. |
att_se |
float
|
Standard error of the ATT. |
method |
str
|
Extrapolation method used ( |
alpha |
float
|
Significance level. |
Methods:
| Name | Description |
|---|---|
summary |
Print a formatted summary table. |
plot |
Matplotlib sensitivity plot (M-bar vs CI). |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(60):
... g = 5 if i < 30 else 0
... for t in range(1, 9):
... post = 1 if (g and t >= 5) else 0
... y = 1.0 + 0.2 * t + i / 120 + 2.0 * post + rng.normal(0, 0.5)
... rows.append({"unit": i, "time": t, "y": y, "g": g})
>>> df = pd.DataFrame(rows)
>>> result = sp.event_study(df, y="y", treat_time="g", time="time",
... unit="unit", window=(-3, 3))
>>> sens = sp.sensitivity_rr(result, Mbar=[0.0, 0.5, 1.0])
>>> type(sens).__name__
'SensitivityResult'
>>> bool(isinstance(sens.summary(), str))
True
>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots()
>>> ax = sens.plot(ax=ax)
>>> fig.savefig("sensitivity.png")
References
Rambachan, A. & Roth, J. (2023). [@rambachan2023more]
plot ¶
Sensitivity plot: M-bar on x-axis, honest CI band on y-axis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
**kwargs
|
passed to ``ax.fill_between``.
|
|
{}
|
Returns:
| Type | Description |
|---|---|
Axes
|
|
CSReport
dataclass
¶
Structured output of :func:cs_report.
Attributes are plain pandas objects so downstream users can export to LaTeX, Markdown, or Excel without any custom converters.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for u in range(60):
... g = rng.choice([3, 4, 0]) # cohort: treated at t=3, t=4, or never
... ui = rng.normal(0, 1)
... for t in range(1, 7):
... post = 1 if (g != 0 and t >= g) else 0
... y = ui + 0.3 * t + 2.0 * post + rng.normal(0, 0.5)
... rows.append({"id": u, "t": t, "g": g, "y": y})
>>> df = pd.DataFrame(rows)
>>> rpt = sp.cs_report(
... df, y="y", g="g", t="t", i="id",
... n_boot=50, random_state=42, verbose=False)
>>> isinstance(rpt, sp.CSReport)
True
>>> isinstance(rpt.dynamic, pd.DataFrame)
True
>>> isinstance(rpt.breakdown, pd.DataFrame)
True
forward_engineering_checklist ¶
The eight steps of Baker et al. (2026, §6), with what was run.
The paper closes with an eight-step recipe for any DiD study. Steps 1-6 are things this report can execute or record; steps 7-8 are judgement calls it can only prompt. Returning them as a frame makes the checklist machine-readable for agent consumers, which is the point of running the workflow rather than eyeballing it.
plot ¶
Render a 2×2 summary figure of the report.
The four quadrants show:
- Top-left: event study (dynamic) with uniform confidence band
- Top-right: θ(g) per-cohort aggregation with uniform band
- Bottom-left: θ(t) per-calendar-time aggregation
- Bottom-right: Rambachan–Roth breakdown M* across post event times
Requires matplotlib. Returns (fig, axes).
to_markdown ¶
Render the report as GitHub-Flavoured Markdown.
Suitable for pasting directly into a pull request, blog post, or Jupyter notebook Markdown cell.
to_excel ¶
Dump the report to a multi-sheet Excel workbook.
Creates one sheet per block — Summary, Dynamic,
Group, Calendar, Breakdown, Meta — so
downstream Excel consumers (policy briefs, regulatory reports)
can link to or copy from the individual tables directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Destination |
required |
float_format
|
str
|
Passed through to :meth: |
'%.6f'
|
engine
|
str
|
Excel writer engine ( |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The path written. |
to_latex ¶
to_latex(float_format: str = '%.4f', caption: Optional[str] = None, label: Optional[str] = None) -> str
Render the report as a LaTeX fragment.
Uses the booktabs package for each sub-table and wraps the
result in a single table float. Requires \usepackage{booktabs}
in the preamble of the consuming document.
ParallelTrendsRobustnessResult
dataclass
¶
Bases: ResultProtocolMixin
Bundled pre-trends power + honest-DiD sensitivity for one DiD result.
Attributes:
| Name | Type | Description |
|---|---|---|
power_table |
DataFrame
|
One row per quantity from the Roth (2022) pre-test analysis (joint statistic, p-value, power, non-centrality). |
ci_grid |
DataFrame
|
Long-format robust-CI grid: |
breakdown |
dict
|
|
verdict |
str
|
One-line plain-language reading of the table. |
att, att_se |
float
|
Point estimate and standard error at relative time |
e |
int
|
Relative time the sensitivity analysis targets. |
alpha |
float
|
Significance level. |
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=80, n_periods=8, staggered=False, seed=0)
>>> df["first_treat"] = df["first_treat"].fillna(0)
>>> es = sp.event_study(df, y="y", treat_time="first_treat",
... time="time", unit="unit", window=(-3, 3))
>>> rob = sp.parallel_trends_robustness(es)
>>> bool(isinstance(rob.summary(), str))
True
References
Rambachan & Roth (2023) [@rambachan2023more]; Roth (2022) [@roth2022pretest].
to_latex ¶
Booktabs LaTeX table of the robust-CI grid and breakdown values.
plot ¶
Robust-CI bands against M, one band per restriction family.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
**kwargs
|
passed to ``ax.fill_between``.
|
|
{}
|
Returns:
| Type | Description |
|---|---|
Axes
|
|
DIDInputTypeError ¶
Bases: MethodIncompatibility, TypeError
DID input type error that preserves the historical TypeError catch.
check_absorbing ¶
check_absorbing(data: DataFrame, unit: str, time: str, treat: str, strict: bool = False) -> AbsorbingCheck
Check whether a time-varying treatment indicator is absorbing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long panel. |
required |
unit
|
str
|
Column names. |
required |
time
|
str
|
Column names. |
required |
treat
|
str
|
Column names. |
required |
strict
|
bool
|
Raise :class: |
False
|
Returns:
| Type | Description |
|---|---|
AbsorbingCheck
|
|
Examples:
>>> import numpy as np, pandas as pd, statspai as sp
>>> rows = []
>>> for u in range(6):
... for t in range(1, 7):
... on = 1 if (u < 3 and 3 <= t < 5) else 0 # units 0-2 revert
... rows.append({"i": u, "t": t, "d": on})
>>> chk = sp.check_absorbing(pd.DataFrame(rows), "i", "t", "d")
>>> chk.is_absorbing
False
>>> chk.n_reverting_units
3
References
dechaisemartin2024difference
pretrend_equivalence ¶
pretrend_equivalence(pre_estimates: ndarray, pre_cov: ndarray, n_bar: int, pre_se: Optional[ndarray] = None, f_threshold: float = 0.6, tost_threshold: Optional[float] = None, event_times: Optional[ndarray] = None) -> EquivalenceResult
Joint and equivalence tests on a vector of pre-period effects.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pre_estimates
|
ndarray
|
Pre-period event-study coefficients, excluding the normalised reference period. |
required |
pre_cov
|
ndarray
|
Their joint covariance matrix. The joint covariance is what makes the F test valid; a diagonal built from the standard errors would ignore the (substantial) cross-period correlation. |
required |
n_bar
|
int
|
Number of treated units contributing to the pre-periods. This enters the finite-sample scaling and the non-centrality parameter. |
required |
pre_se
|
Optional[ndarray]
|
Per-period standard errors used by the TOST. Defaults to
|
None
|
f_threshold
|
float
|
Dimensionless effect-size bound for the F equivalence test
( |
0.6
|
tost_threshold
|
Optional[float]
|
Equivalence bound in outcome units. Required for the TOST; when
omitted the TOST is reported as |
None
|
event_times
|
Optional[ndarray]
|
Optional labels, carried through for reporting. |
None
|
pretrends_equivalence ¶
pretrends_equivalence(result, f_threshold: float = 0.6, tost_threshold: Optional[float] = None, alpha: float = 0.05) -> EquivalenceResult
Pre-trend equivalence tests for a fitted DiD result.
Answers the question a plain pre-trend test cannot: is the pre-trend demonstrably small, as opposed to merely not detected? A failure to reject "no pre-trend" is frequently just low power (Roth 2022), so reporting it alone overstates the evidence for parallel trends.
Needs the joint covariance of the pre-period event-study coefficients, which is recovered from a Callaway-Sant'Anna fit's influence functions. Results without influence functions cannot support the joint F test and raise rather than silently substituting a diagonal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
A DiD fit carrying an event study and influence functions (e.g.
:func: |
required |
f_threshold
|
float
|
Dimensionless effect-size bound for the F equivalence test
( |
0.6
|
tost_threshold
|
float
|
Equivalence bound in outcome units. Without it the TOST is skipped —
there is no defensible universal default for "how big a pre-trend is
too big", and inventing one would be a substantive judgement made on
the user's behalf. |
None
|
alpha
|
float
|
Level used by :meth: |
0.05
|
Returns:
| Type | Description |
|---|---|
EquivalenceResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=200, n_periods=8, staggered=True, seed=42)
>>> df['first_treat'] = df['first_treat'].fillna(0)
>>> cs = sp.callaway_santanna(df, y='y', g='first_treat', t='time', i='unit')
>>> eq = sp.pretrends_equivalence(cs)
>>> 0.0 <= eq.f_pvalue <= 1.0
True
>>> isinstance(eq.verdict(), str)
True
References
liu2024practical, roth2022pretest
staggered_cs ¶
staggered_cs(data: DataFrame, y: str, i: str, t: str, g: str, estimand: str = 'simple', event_time: Union[float, Sequence[float]] = 0.0, se_type: str = 'neyman', fisher: bool = False, n_fisher: int = 500, random_state: Optional[int] = None, alpha: float = 0.05) -> 'CausalResult'
Callaway-Sant'Anna's estimand with design-based inference.
Same weights as sp.callaway_santanna — every not-yet-treated cohort
serves as control — but the standard error comes from random adoption
timing rather than parallel trends. Use it when timing was randomised and
you want the familiar CS estimand; use sp.callaway_santanna when it
was not.
This is R staggered::staggered_cs: the plug-in weights (beta = 1)
with every not-yet-treated cohort as control, after dropping units already
treated in the first period.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
DataFrame
|
|
required |
i
|
DataFrame
|
|
required |
t
|
DataFrame
|
|
required |
g
|
DataFrame
|
|
required |
estimand
|
DataFrame
|
|
required |
event_time
|
DataFrame
|
|
required |
se_type
|
DataFrame
|
|
required |
fisher
|
DataFrame
|
|
required |
n_fisher
|
DataFrame
|
|
required |
random_state
|
Optional[int]
|
As in :func: |
None
|
alpha
|
Optional[int]
|
As in :func: |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=200, n_periods=6, staggered=True, seed=7)
>>> res = sp.staggered_cs(df, y='y', i='unit', t='time', g='first_treat')
>>> res.se > 0
True
References
roth2023efficient callaway2021difference
staggered_rollout ¶
staggered_rollout(data: DataFrame, y: str, i: str, t: str, g: str, estimand: str = 'simple', efficient: bool = True, event_time: Union[float, Sequence[float]] = 0.0, use_last_treated_only: bool = False, use_did_a0: bool = True, se_type: str = 'neyman', fisher: bool = False, n_fisher: int = 500, random_state: Optional[int] = None, alpha: float = 0.05) -> 'CausalResult'
Efficient DiD for a randomised staggered rollout (Roth-Sant'Anna 2023).
Use this when treatment timing was randomly assigned — a policy lottery, a phased platform launch, an RCT rolled out in waves. Every other DiD estimator in StatsPAI identifies off parallel trends, which under random timing is both unnecessary and wasteful: this estimator uses the randomisation directly and is efficient in the class of linear combinations of cohort-period means.
.. warning::
This is a different estimand and identifying assumption from
sp.callaway_santanna. On canonical did::mpdta (where timing is
not randomised) this returns −0.0471 against CS's −0.0400; the gap is
not a discrepancy, it is what happens when you apply a design-based
estimator to an observational rollout. If timing was not randomised,
use a parallel-trends estimator instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Balanced panel in long form. |
required |
y
|
str
|
Outcome, unit id, period, and first-treatment period. Never-treated
units may be coded |
required |
i
|
str
|
Outcome, unit id, period, and first-treatment period. Never-treated
units may be coded |
required |
t
|
str
|
Outcome, unit id, period, and first-treatment period. Never-treated
units may be coded |
required |
g
|
str
|
Outcome, unit id, period, and first-treatment period. Never-treated
units may be coded |
required |
estimand
|
('simple', 'cohort', 'calendar', 'eventstudy')
|
|
'simple'
|
efficient
|
bool
|
|
True
|
event_time
|
float or sequence of float
|
Only read when |
0
|
use_last_treated_only
|
bool
|
Restrict the comparison group to the last-treated cohort, which is what Sun & Abraham's estimator does. The default uses every not-yet-treated cohort. |
False
|
use_did_a0
|
bool
|
Which controls the efficient weights are chosen over. |
True
|
se_type
|
('neyman', 'adjusted')
|
Which standard error lands in |
'neyman'
|
fisher
|
bool
|
Run a Fisher randomisation test: permute adoption dates across units
and compare studentised statistics. The p-value lands in
|
False
|
n_fisher
|
int
|
Permutation draws for the randomisation test. |
500
|
random_state
|
int
|
Seed for the permutation draws. |
None
|
alpha
|
float
|
Level for the reported confidence interval. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=200, n_periods=6, staggered=True, seed=7)
>>> res = sp.staggered_rollout(df, y='y', i='unit', t='time',
... g='first_treat')
>>> res.se > 0
True
See Also
staggered_cs : the Callaway-Sant'Anna estimand with design-based inference. staggered_sa : the Sun-Abraham estimand with design-based inference.
References
roth2023efficient
staggered_sa ¶
staggered_sa(data: DataFrame, y: str, i: str, t: str, g: str, estimand: str = 'simple', event_time: Union[float, Sequence[float]] = 0.0, se_type: str = 'neyman', fisher: bool = False, n_fisher: int = 500, random_state: Optional[int] = None, alpha: float = 0.05) -> 'CausalResult'
Sun-Abraham's estimand with design-based inference.
Identical to :func:staggered_cs except that only the last-treated
cohort serves as control, which is what Sun & Abraham's interaction-
weighted estimator does.
This is R staggered::staggered_sa.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
DataFrame
|
|
required |
i
|
DataFrame
|
|
required |
t
|
DataFrame
|
|
required |
g
|
DataFrame
|
|
required |
estimand
|
DataFrame
|
|
required |
event_time
|
DataFrame
|
|
required |
se_type
|
DataFrame
|
|
required |
fisher
|
DataFrame
|
|
required |
n_fisher
|
DataFrame
|
|
required |
random_state
|
Optional[int]
|
As in :func: |
None
|
alpha
|
Optional[int]
|
As in :func: |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=200, n_periods=6, staggered=True, seed=7)
>>> res = sp.staggered_sa(df, y='y', i='unit', t='time', g='first_treat')
>>> res.se > 0
True
References
roth2023efficient sun2021estimating
did_analysis ¶
did_analysis(data: DataFrame, y: str, treat: str, time: str, id: Optional[str] = None, covariates: Optional[List[str]] = None, method: str = 'auto', estimator: str = 'dr', control_group: str = 'nevertreated', run_bacon: bool = True, run_event_study: bool = True, run_sensitivity: bool = True, event_window: Optional[tuple[int, int]] = None, cluster: Optional[str] = None, robust: bool = True, alpha: float = 0.05, **kwargs: Any) -> DIDAnalysis
Comprehensive DID analysis workflow.
Runs the full DID analysis pipeline in one call: design detection, Bacon decomposition (staggered), estimation, event study, and honest_did sensitivity analysis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input dataset. |
required |
y
|
str
|
Outcome variable name. |
required |
treat
|
str
|
Treatment variable. For 2×2: binary (0/1). For staggered: first treatment period (0 = never treated). |
required |
time
|
str
|
Time period variable. |
required |
id
|
str
|
Unit identifier. Required for staggered designs. |
None
|
covariates
|
list of str
|
Control variables. |
None
|
method
|
str
|
Estimation method: 'auto', '2x2', 'cs', 'sa', 'bjs', 'sdid'. |
'auto'
|
estimator
|
str
|
For CS: 'dr', 'ipw', or 'reg'. |
'dr'
|
control_group
|
str
|
For CS/SA: 'nevertreated' or 'notyettreated'. |
'nevertreated'
|
run_bacon
|
bool
|
Run Bacon decomposition for staggered designs. |
True
|
run_event_study
|
bool
|
Run event study for dynamic effects + pre-trend test. |
True
|
run_sensitivity
|
bool
|
Run honest_did sensitivity analysis. |
True
|
event_window
|
tuple of (int, int)
|
Event study window, e.g. (-5, 5). Auto-detected if None. |
None
|
cluster
|
str
|
Cluster variable for standard errors. |
None
|
robust
|
bool
|
HC1 robust standard errors. |
True
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
DIDAnalysis
|
Bundled results with |
Examples:
Classic 2×2:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> post = np.tile([0, 1], n)
>>> policy = np.repeat(rng.integers(0, 2, size=n), 2)
>>> wage = 2.0 + 0.5 * post + 1.0 * (policy * post) + rng.normal(size=2 * n)
>>> df = pd.DataFrame({'post': post, 'policy': policy, 'wage': wage})
>>> report = sp.did_analysis(df, y='wage', treat='policy', time='post')
>>> type(report).__name__
'DIDAnalysis'
>>> print(report.summary())
Staggered — full pipeline (design auto-detected from id):
>>> rows = []
>>> cohorts = rng.choice([0, 3, 4], size=60)
>>> for i in range(60):
... g = cohorts[i]
... for t in range(1, 7):
... treated = 1 if (g > 0 and t >= g) else 0
... earn = 1.0 + 0.3 * t + 1.5 * treated + rng.normal()
... rows.append({'worker': i, 'year': t,
... 'first_treat': g, 'earnings': earn})
>>> panel = pd.DataFrame(rows)
>>> report = sp.did_analysis(panel, y='earnings', treat='first_treat',
... time='year', id='worker')
>>> report.design
'staggered'
>>> report.plot()
Quick estimate only (skip diagnostics):
bacon_decomposition ¶
bacon_decomposition(data: DataFrame, y: str, treat: str, time: str, id: str, alpha: float = 0.05) -> Dict[str, Any]
Goodman-Bacon (2021) decomposition of the TWFE DID estimator.
Decomposes the overall TWFE coefficient into a weighted sum of 2×2 DID comparisons between different treatment timing groups.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Balanced panel data. |
required |
y
|
str
|
Outcome variable. |
required |
treat
|
str
|
Binary treatment indicator (0 before treatment, 1 after). |
required |
time
|
str
|
Time period variable. |
required |
id
|
str
|
Unit identifier. |
required |
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys:
- |
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> cohorts = {1: 4, 2: 4, 3: 7, 4: 7, 5: 99, 6: 99} # 99 = never treated
>>> rows = []
>>> for unit, g in cohorts.items():
... for year in range(1, 11):
... treated = int(year >= g)
... y = unit + 0.3 * year + 2.0 * treated + rng.normal(0, 0.5)
... rows.append({'unit': unit, 'year': year,
... 'outcome': y, 'treated': treated})
>>> df = pd.DataFrame(rows)
>>> result = sp.bacon_decomposition(df, y='outcome', treat='treated',
... time='year', id='unit')
>>> sorted(result['decomposition']['type'].unique())
['Earlier vs Later Treated', 'Later vs Earlier Treated', 'Treated vs Untreated']
>>> bool(abs(result['weighted_sum'] - result['beta_twfe']) < 1e-8)
True
Notes
The decomposition identifies three types of comparisons:
- Earlier vs Later treated: Units treated at time g₁ vs units treated later at g₂ (g₁ < g₂). These are "good" comparisons.
- Later vs Earlier treated: Units treated at g₂ vs already-treated units at g₁. These are "forbidden" — they use treated units as controls and can introduce negative weighting bias.
- Treated vs Never treated: Always valid comparisons.
A large already_treated_control_weight_share signals that TWFE is
relying heavily on comparisons with already-treated controls, so a
heterogeneity-robust estimator (C&S, Sun-Abraham) should be used.
See Goodman-Bacon (2021, JEcon), Theorem 1.
did_balance ¶
did_balance(data: DataFrame, covariates: Sequence[str], *, g: str, t: str, i: str, weights: Optional[str] = None, base_period: Optional[Any] = None, comparison_period: Optional[Any] = None, cohort: Optional[Any] = None, control_group: str = 'nevertreated', threshold: float = _IR_THRESHOLD) -> DiDBalanceResult
Covariate balance in levels and changes for a DiD design.
Reproduces the shape of Table 4 in Baker et al. (2026): for each covariate, the treated and comparison group means plus the Imbens-Rubin normalized difference, computed once on baseline levels and once on changes across the treatment date, optionally both weighted and unweighted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
covariates
|
sequence of str
|
Columns to audit. May be time-varying; the changes panel is only meaningful for those that are. |
required |
g
|
str
|
First-treatment period per unit; |
required |
t
|
str
|
Time period column. |
required |
i
|
str
|
Unit identifier. |
required |
weights
|
str
|
Unit weights ω. When given, the weighted statistics are reported alongside the unweighted ones, exactly as the paper does — the two answer different questions and neither is a check on the other. .. note::
The weighted statistic uses weighted variances in the
denominator, following Baker et al. (2026, §4.1): "S²_ω,T and
S²_ω,C are the sample weighted or unweighted variances". This
differs from |
None
|
base_period
|
optional
|
Pre-treatment period for the levels panel. Defaults to |
None
|
comparison_period
|
optional
|
Second period for the changes panel. Defaults to the cohort's
treatment date |
None
|
cohort
|
optional
|
Which treated cohort to audit. Defaults to the largest one. Balance is a two-group statistic, so a staggered design has one table per cohort rather than one overall. |
None
|
control_group
|
('nevertreated', 'notyettreated')
|
'nevertreated' Which units form the comparison group. Should match the comparison group of the estimator you intend to run — balance against a group you will not use is not evidence about your design. |
'nevertreated'
|
threshold
|
float
|
Absolute normalized difference above which a covariate is flagged (Imbens and Rubin 2015, p. 277). |
0.25
|
Returns:
| Type | Description |
|---|---|
DiDBalanceResult
|
|
Examples:
>>> import statspai as sp
>>> mpdta = sp.datasets.mpdta()
>>> bal = sp.did_balance(
... mpdta, ["lpop"], g="first_treat", t="year", i="countyreal"
... )
>>> print(bal.summary())
References
baker2026difference, imbens2015causal
bjs_pretrend_joint ¶
bjs_pretrend_joint(result: CausalResult, data: DataFrame, y: str, group: str, time: str, first_treat: str, controls: Optional[List[str]] = None, cluster: Optional[str] = None, horizon: Optional[List[int]] = None, n_boot: int = 300, seed: Optional[int] = None) -> Dict[str, Any]
Cluster-bootstrap joint Wald test for BJS pre-treatment coefficients.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
data
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
y
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
group
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
time
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
first_treat
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
controls
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
cluster
|
DataFrame
|
Same arguments you passed to the original
:func: |
required |
horizon
|
list of int
|
If omitted, inferred from |
None
|
n_boot
|
int
|
Cluster-bootstrap replications. Clusters are sampled with replacement; unit ids are reassigned in the resampled frame so BJS refits cleanly. |
300
|
seed
|
int
|
RNG seed for reproducibility. |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
Cost: n_boot full BJS re-fits. On a 10 000-row balanced
panel with |horizon|=10, expect roughly
n_boot × 0.3 s = 90 s for the default n_boot=300 — the
function is therefore opt-in, not run by default inside
:func:did_imputation.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> df['first_treat'] = df['first_treat'].fillna(0).astype(int)
>>> imp = sp.did_imputation(
... df, y='y', group='unit', time='time',
... first_treat='first_treat', horizon=[-3, -2, -1, 0, 1, 2])
>>> jt = sp.bjs_pretrend_joint(
... imp, df, y='y', group='unit', time='time',
... first_treat='first_treat', n_boot=50, seed=0)
>>> jt['df']
3
>>> jt['method']
'cluster-bootstrap'
cgs_continuous_did ¶
cgs_continuous_did(data: DataFrame, y: str, *, dose: str, time: str, unit: str, cohort: str, degree: int = 3, num_knots: int = 0, knots: Optional[Sequence[float]] = None, dose_grid: Optional[Sequence[float]] = None, control_group: str = 'nevertreated', curve_basis: str = 'fitted', alpha: float = 0.05) -> ContinuousDoseResult
ATT(d) and ACRT(d) for a continuous treatment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
Outcome column. |
required |
dose
|
str
|
Continuous treatment intensity. Zero for untreated units. |
required |
time
|
str
|
Period column. |
required |
unit
|
str
|
Unit identifier. |
required |
cohort
|
str
|
First-treatment period; |
required |
degree
|
int
|
B-spline degree for the dose. |
3
|
num_knots
|
int
|
Interior knots, placed at equally spaced quantiles of the positive
doses. Ignored when |
0
|
knots
|
sequence of float
|
Explicit interior knots. |
None
|
dose_grid
|
sequence of float
|
Doses at which to report the curves. Defaults to the 10th-99th percentiles of the positive doses, which is where there are data to support them. |
None
|
control_group
|
('nevertreated', 'notyettreated')
|
|
"nevertreated"
|
curve_basis
|
('fitted', 'reference')
|
Which basis the reported curves are evaluated on.
|
"fitted"
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
ContinuousDoseResult
|
|
Notes
The overall ACRT standard error comes from the influence function of the
per-cell regression. contdid routes its standard errors through the
pte package's aggregation layer, which is not replicated here, so
the two differ by a few percent even though the point estimates agree
exactly.
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(400):
... g = 2 if i < 200 else 0
... d = float(rng.uniform(0.1, 1.0)) if g else 0.0
... fe = rng.normal()
... for t in (1, 2):
... eff = 2.0 * d if (g and t >= g) else 0.0
... rows.append((i, t, g, d, fe + 0.5 * t + eff + rng.normal(0, 0.3)))
>>> df = pd.DataFrame(rows, columns=["id", "t", "g", "dose", "y"])
>>> res = sp.cgs_continuous_did(
... df, y="y", dose="dose", time="t", unit="id", cohort="g", degree=1
... )
>>> bool(abs(res.overall_acrt - 2.0) < 0.4)
True
References
callaway2024difference
cohort_anchored_event_study ¶
cohort_anchored_event_study(data: DataFrame, y: str, treat: str, time: str, id: str, leads: int = 4, lags: int = 4, cluster: Optional[str] = None, alpha: float = 0.05) -> CausalResult
Cohort-anchored event-study estimator.
Reports per-cohort event-time effects averaged with cohort weights. Standard errors are cluster-robust and carry no protection against violations of parallel trends; the robust-inference half of Liu (2025) is not implemented here. See the module docstring.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
|
required |
treat
|
str
|
First-treatment-period column (0 = never-treated). |
required |
time
|
str
|
|
required |
id
|
str
|
|
required |
leads
|
int
|
Number of pre/post event-time periods to estimate. |
4
|
lags
|
int
|
Number of pre/post event-time periods to estimate. |
4
|
cluster
|
str
|
Cluster column for SE; defaults to |
None
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
References
Liu, Z. (2025). "Cohort-Anchored Robust Inference for Event-Study with Staggered Adoption." arXiv:2509.01829. [@liu2025cohort] This function implements the cohort-anchored estimator, not that paper's block-bias robust-inference procedure.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> df['first_treat'] = df['first_treat'].fillna(0).astype(int)
>>> res = sp.cohort_anchored_event_study(
... df, y='y', treat='first_treat', time='time', id='unit',
... leads=2, lags=2)
>>> round(float(res.estimate), 4)
0.3668
>>> list(res.model_info['event_study'].columns)
['rel_time', 'att', 'se', 'ci_low', 'ci_high']
did_cluster_diagnostics ¶
did_cluster_diagnostics(data: DataFrame, unit: str, first_treat: str, cluster: Optional[str] = None, *, warn: bool = True) -> DiDClusterDiagnostics
Count the clusters treatment is assigned at, and grade the count.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long panel. |
required |
unit
|
str
|
Unit identifier. |
required |
first_treat
|
str
|
First treated period; |
required |
cluster
|
str
|
The level treatment is assigned at -- a state, a provider
organisation, a school district. Defaults to |
None
|
warn
|
bool
|
Emit a warning when the design sits in or below the weakest cell of the reference grid. |
True
|
Returns:
| Type | Description |
|---|---|
DiDClusterDiagnostics
|
|
Notes
The grading is a statement about the evidence that exists, not a power calculation for this design. A "largest-cell-or-above" verdict means the closest published simulation found coverage near nominal at that cluster count for the estimators it evaluated; it is not a guarantee about the estimator or the data at hand.
Examples:
did_design_contract ¶
did_design_contract(result: Any) -> DiDDesignContract
Report which forward-engineering steps a fitted DiD result pins down.
Baker et al. (2026, section 6) argue that a DiD study should be forward engineered -- target parameter, identifying assumption, estimator, inference frame, in that order -- rather than reverse engineered from whichever regression is familiar. This reads a fitted result and reports which of those the call actually determined.
The function never invents a status. A slot that cannot be read off
the result is undetermined, which is the informative answer: it
means the choice was made by a default rather than by the analyst, and
that the write-up still owes the reader a statement of it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of a DiD estimator. |
required |
Returns:
| Type | Description |
|---|---|
DiDDesignContract
|
|
Examples:
design_robust_event_study ¶
design_robust_event_study(data: DataFrame, y: str, treat: str, time: str, id: str, leads: int = 4, lags: int = 4, cluster: Optional[str] = None, alpha: float = 0.05) -> CausalResult
Design-robust event-study with negative-weight diagnostics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
Same conventions as :func: |
required |
treat
|
str
|
Same conventions as :func: |
required |
time
|
str
|
Same conventions as :func: |
required |
id
|
str
|
Same conventions as :func: |
required |
leads
|
int
|
Event-time window. |
4
|
lags
|
int
|
Event-time window. |
4
|
cluster
|
str
|
|
None
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Headline = average post-treatment effect.
|
References
Wright, C. S. (2026). arXiv 2601.18801. See design_robust_es2026
bibkey at the bottom of this module for the full citation.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> df['first_treat'] = df['first_treat'].fillna(0).astype(int)
>>> res = sp.design_robust_event_study(
... df, y='y', treat='first_treat', time='time', id='unit',
... leads=2, lags=2)
>>> round(float(res.estimate), 4)
0.2886
>>> diag = res.model_info['diagnostics']
>>> diag['n_negative_weight_periods']
1
compare_event_study_conventions ¶
compare_event_study_conventions(data: DataFrame, y: str, unit: str, time: str, first_treat: str, *, estimators: Optional[Sequence[str]] = None, window: Optional[Tuple[int, int]] = None, cluster: Optional[str] = None, tolerance: Optional[float] = None) -> EventStudyConventionResult
Measure how each estimator's event-study path departs from TWFE.
Runs the requested estimators on one panel, aligns their event-study paths on shared relative times, and decomposes the difference from the dynamic TWFE benchmark into a common vertical shift within each half of the path and a residual. A symmetric estimator differs from TWFE by one shift and nothing else; an asymmetric one shows a different shift before and after the treatment date, which is the jump or kink Roth (2026) documents.
The diagnostic is only interpretable in a non-staggered design, because that is where every estimator here targets the same object and Roth's analysis applies, so a staggered panel raises rather than returning a number that cannot be read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel with unit, time, outcome and cohort columns. |
required |
y
|
str
|
Column names. |
required |
unit
|
str
|
Column names. |
required |
time
|
str
|
Column names. |
required |
first_treat
|
str
|
Column names. |
required |
estimators
|
sequence of str
|
Registry keys to run. Defaults to every estimator with a runner. |
None
|
window
|
(int, int)
|
Relative-time window. Defaults to the widest the panel supports. |
None
|
cluster
|
str
|
Cluster column; defaults to |
None
|
tolerance
|
float
|
Threshold for |
None
|
Returns:
| Type | Description |
|---|---|
EventStudyConventionResult
|
|
Raises:
| Type | Description |
|---|---|
MethodIncompatibility
|
If the panel is staggered, or has no never-treated units. |
Examples:
event_study_convention ¶
Report how an estimator builds its event-study reference periods.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
estimator
|
str
|
Either a registry key such as
|
None
|
Returns:
| Type | Description |
|---|---|
DataFrame or dict
|
A dict for an exact key, otherwise a DataFrame with one row per convention. |
Examples:
distributional_did ¶
distributional_did(data: DataFrame, y: str, *, g: str, t: str, i: str, n_bins: Union[int, str, None] = 'auto', binpoints: Optional[Sequence[float]] = None, aggregation: str = 'group', estimator: str = 'dr', control_group: str = 'nevertreated', x: Optional[List[str]] = None, weights: Optional[str] = None, anticipation: int = 0, panel: bool = True, allow_unbalanced_panel: bool = False, balance_e: Optional[int] = None, min_e: float = -inf, max_e: float = inf, alpha: float = 0.05) -> DistributionalDiDResult
Treatment effect on the distribution of the outcome, bin by bin.
Where :func:functional_form_test asks whether the design's implied
counterfactual density is a density at all, this asks a different
question: how did treatment move probability mass around? Bin the
outcome, run Callaway-Sant'Anna on each bin indicator, and read off the
effect on P(Y in bin).
The per-bin effects sum to approximately zero by construction — treatment redistributes mass, it does not create it — so the informative content is the shape: which parts of the outcome distribution gained and which lost. A mean ATT of zero is perfectly consistent with large offsetting movements in the tails, and this is what shows them.
This is R didFF::distDD. Unlike :func:functional_form_test it runs
no test and returns no p-value: the reference reports point estimates and
standard errors only.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
Outcome column. |
required |
g
|
str
|
First-treatment period; |
required |
t
|
str
|
Time period column. |
required |
i
|
str
|
Unit identifier. |
required |
n_bins
|
Union[int, str, None]
|
Binning, exactly as in :func: |
'auto'
|
binpoints
|
Union[int, str, None]
|
Binning, exactly as in :func: |
'auto'
|
aggregation
|
str
|
|
'group'
|
estimator
|
str
|
|
'group'
|
control_group
|
str
|
|
'group'
|
x
|
str
|
|
'group'
|
weights
|
str
|
|
'group'
|
anticipation
|
str
|
|
'group'
|
panel
|
str
|
|
'group'
|
allow_unbalanced_panel
|
bool
|
Passed through to :func: |
False
|
balance_e
|
bool
|
Passed through to :func: |
False
|
min_e
|
bool
|
Passed through to :func: |
False
|
max_e
|
bool
|
Passed through to :func: |
False
|
alpha
|
float
|
Level for the confidence intervals drawn by |
0.05
|
Returns:
| Type | Description |
|---|---|
DistributionalDiDResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.datasets.mpdta()
>>> res = sp.distributional_did(
... df, y="lemp", g="first_treat", t="year", i="countyreal", n_bins=6
... )
>>> len(res.table)
6
>>> bool(abs(res.diagnostics["effect_sum"]) < 1e-8) # mass is conserved
True
See Also
functional_form_test : is parallel trends sensitive to the outcome scale?
References
roth2023when
functional_form_test ¶
functional_form_test(data: DataFrame, y: str, *, g: str, t: str, i: str, n_bins: Union[int, str, None] = 'auto', binpoints: Optional[Sequence[float]] = None, aggregation: str = 'group', estimator: str = 'dr', control_group: str = 'nevertreated', x: Optional[List[str]] = None, weights: Optional[str] = None, anticipation: int = 0, panel: bool = True, allow_unbalanced_panel: bool = False, balance_e: Optional[int] = None, min_e: float = -inf, max_e: float = inf, n_sims: int = 100000, alpha: float = 0.05, random_state: Optional[int] = 0) -> FunctionalFormResult
Test whether parallel trends can hold for every monotonic transform.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
Outcome column. |
required |
g
|
str
|
First-treatment period; |
required |
t
|
str
|
Time period column. |
required |
i
|
str
|
Unit identifier. |
required |
n_bins
|
int or 'auto'
|
Number of equal-width outcome bins. |
"auto"
|
binpoints
|
sequence of float
|
Explicit bin edges, for when the outcome has natural cut points.
Padded to cover the outcome range (with a warning) if they fall
short. Cannot be combined with an explicit |
None
|
aggregation
|
('group', 'simple', 'dynamic', 'calendar')
|
Which :func: |
"group"
|
estimator
|
str
|
Passed through to :func: |
'dr'
|
control_group
|
str
|
Passed through to :func: |
'dr'
|
x
|
str
|
Passed through to :func: |
'dr'
|
weights
|
str
|
Passed through to :func: |
'dr'
|
anticipation
|
str
|
Passed through to :func: |
'dr'
|
panel
|
str
|
Passed through to :func: |
'dr'
|
allow_unbalanced_panel
|
str
|
Passed through to :func: |
'dr'
|
balance_e
|
Optional[int]
|
Passed through to :func: |
None
|
min_e
|
Optional[int]
|
Passed through to :func: |
None
|
max_e
|
Optional[int]
|
Passed through to :func: |
None
|
n_sims
|
int
|
Draws used for the least-favourable critical value. |
100000
|
alpha
|
float
|
|
0.05
|
random_state
|
int
|
Seed for the simulation. Fixed by default so the p-value is
reproducible; pass |
0
|
Returns:
| Type | Description |
|---|---|
FunctionalFormResult
|
|
Notes
A failure to reject is not evidence for functional-form insensitivity. The test compares an estimated density against zero, so with few units, coarse bins, or a short panel it will fail to reject almost regardless of the truth.
Examples:
>>> import statspai as sp
>>> df = sp.datasets.mpdta()
>>> res = sp.functional_form_test(
... df, y="lemp", g="first_treat", t="year", i="countyreal", n_bins=6
... )
>>> res.pvalue > 0.05
True
References
roth2023when
gardner_did ¶
gardner_did(data: DataFrame, y: str, group: str, time: str, first_treat: str, controls: Optional[List[str]] = None, event_study: bool = False, horizon: Optional[List[int]] = None, cluster: Optional[str] = None, alpha: float = 0.05, vce: str = 'analytic', n_boot: int = 199, boot_seed: int = 0) -> CausalResult
Gardner (2021) two-stage DID estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
Outcome column name. |
required |
group
|
str
|
Unit (panel-id) column. |
required |
time
|
str
|
Time column. |
required |
first_treat
|
str
|
First-treatment-period column. Never-treated units should be encoded
as |
required |
controls
|
list of str
|
Additional covariates included in both stages. |
None
|
event_study
|
bool
|
If True, Stage 2 reports coefficients by relative time
|
False
|
horizon
|
list of int
|
Relative-time leads/lags to report when |
None
|
cluster
|
str
|
Cluster variable for Stage-2 SEs. Defaults to |
None
|
alpha
|
float
|
Two-sided CI level. |
0.05
|
vce
|
('analytic', 'bootstrap')
|
Standard-error mode. |
'analytic'
|
n_boot
|
int
|
Number of cluster-bootstrap replications when |
199
|
boot_seed
|
int
|
Seed for the cluster bootstrap (deterministic results). |
0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
Identification requires the usual staggered-DID conditions (parallel trends, no anticipation) plus a linear two-way FE + additive covariate structure for the untreated potential outcome. Stage-2 standard errors cluster by unit — bootstrapping the whole two-step procedure gives a conservative covariance when covariate models are heavy.
References
Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943. [@gardner2022twostage]
Examples:
Staggered panel with a never-treated group (first_treat = 0).
sp.did_2stage is an alias of this function.
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n_units, n_periods = 40, 8
>>> unit = np.repeat(np.arange(n_units), n_periods)
>>> time = np.tile(np.arange(1, n_periods + 1), n_units)
>>> first = np.where(unit < 20, 5, 0) # 0 = never treated
>>> d = ((first > 0) & (time >= first)).astype(float)
>>> y = (0.5 * unit + 0.3 * time + 2.0 * d
... + rng.normal(0, 0.5, unit.size))
>>> df = pd.DataFrame(
... {"y": y, "unit": unit, "time": time, "g": first}
... )
>>> res = sp.gardner_did(
... df, y="y", group="unit", time="time", first_treat="g"
... )
>>> round(res.estimate, 2) # true ATT = 2.0
2.03
harvest_did ¶
harvest_did(data: DataFrame, *, unit: str, time: str, outcome: str, treat: Optional[str] = None, cohort: Optional[str] = None, never_value: Any = 0, horizons: Optional[Sequence[int]] = None, reference: int = -1, alpha: float = 0.05, weighting: str = 'precision') -> CausalResult
Harvest every valid 2×2 DID comparison and aggregate them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
unit
|
str
|
Column names. |
required |
time
|
str
|
Column names. |
required |
outcome
|
str
|
Column names. |
required |
treat
|
str
|
Binary treatment indicator. If provided, the cohort (first treatment time) is inferred per unit. |
None
|
cohort
|
str
|
Alternative to |
None
|
never_value
|
any
|
Value that marks "never treated" in the |
0
|
horizons
|
sequence of int
|
Event-time horizons to evaluate. Defaults to |
None
|
reference
|
int
|
Pre-treatment reference horizon relative to each cohort's
treatment time. |
-1
|
alpha
|
float
|
|
0.05
|
weighting
|
('precision', 'equal', 'n_treated')
|
How to aggregate the harvested 2×2 estimates.
|
'precision'
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
Inference assumes independence across units within each cohort
(unit-level cluster-robust SEs), but the cross-horizon covariance
induced by shared units is ignored when aggregating the event study
into a single ATT. For strict inference, wrap this call in
:func:sp.inference.bootstrap at the unit level, or use the
per-comparison table to feed :func:sp.inference.multiway_cluster_vcov.
Examples:
breakdown_m ¶
breakdown_m(result: CausalResult, e: int = 0, method: str = 'smoothness', alpha: float = 0.05) -> float
Compute the breakdown value of M.
The breakdown M is the largest violation magnitude under which
the treatment effect at relative time e remains statistically
significant. Larger M = more robust.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
DID result with event study. |
required |
e
|
int
|
Relative time period. |
0
|
method
|
str
|
|
'smoothness'
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
float
|
Breakdown value M. The effect is significant for all M < M. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> rows = [] # staggered panel: half treated at t=5, half never
>>> for i in range(30):
... g = 5 if i < 15 else 0
... ui = rng.normal(0, 1)
... for t in range(1, 9):
... post = 1 if (g != 0 and t >= g) else 0
... y = ui + 0.3 * t + 2.0 * post + rng.normal(0, 0.5)
... rows.append({"unit": i, "time": t, "y": y, "g": g})
>>> df = pd.DataFrame(rows)
>>> r = sp.sun_abraham(df, y='y', g='g', t='time', i='unit')
>>> m_star = sp.breakdown_m(r, e=0)
>>> bool(m_star >= 0) # parallel trends can deviate by up to M* per period
True
Notes
Formally, M* = sup{M : 0 ∉ CI(M)}.
For the smoothness restriction with n_drift periods: M* = (|θ̂| - z_{α/2} × SE) / n_drift
See Rambachan & Roth (2023, ReStud), Definition 2.
aggte_from_influence ¶
aggte_from_influence(source: Union[DataFrame, str, Path], type: str = 'simple', **aggte_kwargs: Any) -> CausalResult
Aggregate group-time ATTs from exported influence functions.
The post-hoc half of the Stata csdid saverif() workflow: rebuild
the ATT(g, t) grid and influence-function matrix from a frame written
by :func:influence_functions and run :func:statspai.aggte on it —
no refit, no original data needed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
DataFrame, str, or Path
|
A frame produced by :func: |
required |
type
|
('simple', 'dynamic', 'group', 'calendar')
|
Aggregation scheme, forwarded to :func: |
'simple'
|
**aggte_kwargs
|
Any
|
Any other :func: |
{}
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Same shape as |
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=60, n_periods=6, staggered=True, seed=1)
>>> df['first_treat'] = df['first_treat'].fillna(0)
>>> cs = sp.callaway_santanna(df, y='y', g='first_treat', t='time',
... i='unit')
>>> rif = sp.influence_functions(cs)
>>> es = sp.aggte_from_influence(rif, type='dynamic', n_boot=200,
... random_state=0)
>>> es.estimand
'ATT'
References
Callaway, B. and Sant'Anna, P. H. C. (2021). Difference-in-differences with multiple time periods. Journal of Econometrics, 225(2), 200-230. [@callaway2021difference]
influence_functions ¶
Export per-unit influence functions of a Callaway–Sant'Anna fit.
Equivalent to Stata csdid, saverif(): the returned frame is
self-contained — it carries everything :func:aggte_from_influence
needs to recompute any aggregation without refitting.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
path
|
str or Path
|
If given, also write the frame to disk — |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Long format, one row per unit × (g, t) pair, columns:
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=60, n_periods=6, staggered=True, seed=1)
>>> df['first_treat'] = df['first_treat'].fillna(0)
>>> cs = sp.callaway_santanna(df, y='y', g='first_treat', t='time',
... i='unit')
>>> rif = sp.influence_functions(cs)
>>> set(rif.columns) >= {'unit', 'group', 'time', 'influence'}
True
References
Callaway, B. and Sant'Anna, P. H. C. (2021). Difference-in-differences with multiple time periods. Journal of Econometrics, 225(2), 200-230. [@callaway2021difference]
did_misclassified ¶
did_misclassified(data: DataFrame, y: str, treat: str, time: str, id: str, pi_misclass: float = 0.0, anticipation_periods: int = 0, cluster: Optional[str] = None, alpha: float = 0.05) -> CausalResult
Staggered DiD robust to timing misclassification + anticipation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
|
required |
treat
|
str
|
|
required |
time
|
str
|
|
required |
id
|
str
|
|
required |
pi_misclass
|
float in [0, 0.5]
|
Probability that the recorded first-treatment period |
0.0
|
anticipation_periods
|
int
|
Number of leads to absorb as anticipation (subtracts the average of pre-event coefficients k = -1..-anticipation_periods from the post ATT estimate). |
0
|
cluster
|
str
|
|
None
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
References
arXiv 2507.20415, Staggered Adoption DiD Designs with Misclassification and Anticipation (2025).
Examples:
dl_propensity_score ¶
dl_propensity_score(data: DataFrame, *, treatment: str, covariates: Sequence[str], hidden_sizes: Sequence[int] = (64, 32), max_iter: int = 300, random_state: int = 0) -> ndarray
Neural-net propensity score with balance-targeted loss.
Fits a small multi-layer perceptron e(X) = P(T=1 | X); if
torch is available uses a proper MLP, otherwise falls back to
scikit-learn's :class:MLPClassifier (lbfgs optimiser, ReLU).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
treatment
|
str
|
|
required |
covariates
|
sequence of str
|
|
required |
hidden_sizes
|
sequence of int
|
|
(64, 32)
|
max_iter
|
int
|
|
300
|
random_state
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
ndarray of shape (n,)
|
Estimated propensity scores clipped to (0.02, 0.98). |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> treat = rng.binomial(1, 1 / (1 + np.exp(-(0.5 * x1 + 0.3 * x2))))
>>> df = pd.DataFrame({"treat": treat, "x1": x1, "x2": x2})
>>> e = sp.dl_propensity_score(df, treatment="treat",
... covariates=["x1", "x2"], random_state=0)
>>> e.shape == (n,)
True
>>> bool(((e >= 0.02) & (e <= 0.98)).all())
True
References
Peng, Li, Wu & Li (arXiv:2404.04794, 2024). [@peng2024local]
overlap_weighted_did ¶
overlap_weighted_did(data: DataFrame, *, y: str, treat: str, time: str, covariates: Optional[Sequence[str]] = None, ps_model: Any = 'logit', alpha: float = 0.05) -> CausalResult
Overlap-weighted 2x2 DID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Two-period panel with a binary |
required |
y
|
str
|
|
required |
treat
|
str
|
|
required |
time
|
str
|
|
required |
covariates
|
sequence of str
|
Pre-treatment covariates for the propensity score. If omitted, reduces to standard (unweighted) 2x2 DID. |
None
|
ps_model
|
('logit', 'gbm', 'dl')
|
How to estimate e(X) = P(treat=1 | X). |
'logit'
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
References
Li, Morgan & Zaslavsky (JASA 2018). "Overlap-weighted difference-in-differences" (Economics Letters 2025).
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 150
>>> x = rng.normal(0, 1, n)
>>> treat = rng.binomial(1, 1 / (1 + np.exp(-x)))
>>> base = 1.0 + 0.5 * x + rng.normal(0, 1, n)
>>> post = base + 0.4 + 1.5 * treat + rng.normal(0, 1, n)
>>> df = pd.DataFrame({
... "y": np.concatenate([base, post]),
... "treat": np.tile(treat, 2),
... "time": np.repeat([0, 1], n),
... "x": np.tile(x, 2),
... })
>>> res = sp.overlap_weighted_did(
... df, y="y", treat="treat", time="time",
... covariates=["x"],
... )
>>> round(res.estimate, 2) # true effect = 1.5
1.8
>>> res.estimand
'ATT (overlap)'
bacon_plot ¶
bacon_plot(bacon_result: Dict[str, Any], ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, colors: Optional[Dict[str, str]] = None, **kwargs: Any) -> Tuple[Any, Any]
Scatter plot of Goodman-Bacon decomposition.
Each point is a 2×2 sub-comparison: x = weight, y = DD estimate. Color distinguishes comparison types (Treated vs Never-treated, Earlier vs Later, Later vs Already-treated).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bacon_result
|
dict
|
Output from |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
title
|
str
|
|
None
|
colors
|
dict
|
Map comparison type → color. Defaults provided. |
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True, seed=0)
>>> bacon = sp.bacon_decomposition(df, y='y', treat='treated',
... time='time', id='unit')
>>> fig, ax = sp.bacon_plot(bacon)
>>> type(fig).__name__
'Figure'
References
goodmanbacon2021difference
cohort_event_study_plot ¶
cohort_event_study_plot(result: Any, ax: Any = None, figsize: Tuple[float, float] = (12, 7), title: Optional[str] = None, palette: Optional[List[str]] = None, show_aggregate: bool = True, aggregate_color: str = '#2C3E50', ci: bool = True, ci_alpha: float = 0.08, **kwargs: Any) -> Tuple[Any, Any]
Per-cohort event study plot (overlay).
Plots a separate event study line for each treatment cohort, showing heterogeneity in treatment effects across cohorts. Optionally overlays the aggregate event study.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(12, 7)
|
title
|
str
|
|
None
|
palette
|
list of str
|
Colors for each cohort. Auto-generated if None. |
None
|
show_aggregate
|
bool
|
Overlay the aggregate event study line. |
True
|
aggregate_color
|
str
|
Color for aggregate line. |
'#2C3E50'
|
ci
|
bool
|
Show confidence intervals for each cohort. |
True
|
ci_alpha
|
float
|
CI band transparency. |
0.08
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True, seed=0)
>>> result = sp.did(df, y='y', treat='first_treat', time='time',
... id='unit', method='cs')
>>> fig, ax = sp.cohort_event_study_plot(result)
>>> type(fig).__name__
'Figure'
References
callaway2021difference
did_plot ¶
did_plot(data: DataFrame, y: str, time: str, treat: str, treat_time: Optional[Union[int, float]] = None, show_counterfactual: bool = True, labels: Optional[Dict[str, str]] = None, colors: Optional[Tuple[str, str, str]] = None, ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, annotate_effect: bool = True, **kwargs: Any) -> Tuple[Any, Any]
Classic DID diagram showing treatment effect with counterfactual.
Plots group means over time and adds a dashed counterfactual line for the treatment group (extrapolated from pre-treatment trend parallel to the control group).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input dataset. |
required |
y
|
str
|
Outcome variable. |
required |
time
|
str
|
Time period variable. |
required |
treat
|
str
|
Binary treatment group indicator (0/1). |
required |
treat_time
|
int or float
|
Treatment onset time. If None, inferred as the midpoint. |
None
|
show_counterfactual
|
bool
|
Draw the dashed counterfactual line. |
True
|
labels
|
dict
|
Custom labels: |
None
|
colors
|
tuple
|
(treatment, control, counterfactual) colors. |
None
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
title
|
str
|
|
None
|
annotate_effect
|
bool
|
Annotate the treatment effect arrow on the plot. |
True
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
did_summary_plot ¶
did_summary_plot(result: Any, ax: Any = None, figsize: Tuple[float, float] = (9, 5), color: str = '#2C3E50', highlight_color: str = '#C0392B', reference: Optional[float] = None, title: Optional[str] = None, sort_by: Optional[str] = None) -> Tuple[Any, Any]
Forest plot of DID method-robustness summary.
Plots each method's point estimate with its confidence interval as a
horizontal errorbar. Designed to consume the CausalResult returned
by :func:statspai.did.did_summary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
ax
|
matplotlib Axes
|
Existing axes to draw on. If |
None
|
figsize
|
tuple
|
Figure size when creating a new figure. |
``(9, 5)``
|
color
|
str
|
Color for point estimates and CIs. |
``"#2C3E50"``
|
highlight_color
|
str
|
Color for the cross-method mean line. |
``"#C0392B"``
|
reference
|
float
|
Horizontal reference value (e.g. 0 for 'no effect'). Defaults
to |
None
|
title
|
str
|
Plot title. Defaults to |
None
|
sort_by
|
('estimate', None)
|
If |
'estimate'
|
Returns:
| Type | Description |
|---|---|
(fig, ax) : matplotlib figure and axes.
|
|
Examples:
enhanced_event_study_plot ¶
enhanced_event_study_plot(result: Any, ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, color: str = '#2C3E50', sig_color: Optional[str] = '#E74C3C', ci_alpha: float = 0.15, shade_pre: bool = True, shade_post: bool = True, pre_color: str = '#EBF5FB', post_color: str = '#FDEDEC', show_zero: bool = True, marker: str = 'o', markersize: int = 6, alpha_level: float = 0.05, **kwargs: Any) -> Tuple[Any, Any]
Enhanced event study plot with pre/post shading and significance coloring.
Improvement over the basic CausalResult.event_study_plot() — adds optional background shading for pre/post periods and colors significant coefficients differently.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
DID result with event study in |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
title
|
str
|
|
None
|
color
|
str
|
Default color for estimates. |
'#2C3E50'
|
sig_color
|
str or None
|
Color for significant estimates. None disables coloring. |
'#E74C3C'
|
ci_alpha
|
float
|
Confidence band transparency. |
0.15
|
shade_pre
|
bool
|
Shade pre-treatment region. |
True
|
shade_post
|
bool
|
Shade post-treatment region. |
True
|
pre_color
|
str
|
Pre-treatment shading color. |
'#EBF5FB'
|
post_color
|
str
|
Post-treatment shading color. |
'#FDEDEC'
|
show_zero
|
bool
|
Show horizontal zero line. |
True
|
marker
|
str
|
|
'o'
|
markersize
|
int
|
|
6
|
alpha_level
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
ggdid ¶
ggdid(result: Any, ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, point_color: str = '#2E86AB', band_color: str = '#F18F01', show_pointwise: bool = True, show_uniform: bool = True) -> Tuple[Any, Any]
Plot an aggte() result, mirroring R :func:did::ggdid.
Automatically dispatches on result.model_info['aggregation']:
simple: a single point with pointwise CIdynamic: event-study line with pointwise CI and uniform bandgroup: horizontal bars of θ̂(g) per cohortcalendar: time-series of θ̂(t) per calendar period
Uniform bands (sup-t simultaneous confidence bands) are drawn from the
cband_lower / cband_upper columns created by :func:aggte.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
title
|
str
|
|
None
|
point_color
|
str
|
Colours for the pointwise estimate and the uniform band. |
'#2E86AB'
|
band_color
|
str
|
Colours for the pointwise estimate and the uniform band. |
'#2E86AB'
|
show_pointwise
|
bool
|
Draw pointwise CI lines. |
True
|
show_uniform
|
bool
|
Draw uniform band (shaded region). |
True
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
group_time_plot ¶
group_time_plot(result: Any, plot_type: str = 'dot', ax: Any = None, figsize: Tuple[float, float] = (12, 7), title: Optional[str] = None, color: str = '#2C3E50', sig_color: str = '#E74C3C', insig_color: str = '#BDC3C7', alpha_level: float = 0.05, **kwargs: Any) -> Tuple[Any, Any]
Plot group-time ATT estimates from Callaway-Sant'Anna.
Two modes:
- 'dot' — dot plot with CI error bars, colored by significance
- 'heatmap' — (group × time) heatmap of ATT magnitudes
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
plot_type
|
str
|
'dot' or 'heatmap'. |
'dot'
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(12, 7)
|
title
|
str
|
|
None
|
color
|
str
|
Default color for dot plot. |
'#2C3E50'
|
sig_color
|
str
|
Color for significant estimates. |
'#E74C3C'
|
insig_color
|
str
|
Color for insignificant estimates. |
'#BDC3C7'
|
alpha_level
|
float
|
Significance threshold. |
0.05
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True, seed=0)
>>> result = sp.did(df, y='y', treat='first_treat', time='time',
... id='unit', method='cs')
>>> fig, ax = sp.group_time_plot(result)
>>> fig2, ax2 = sp.group_time_plot(result, plot_type='heatmap')
>>> type(fig).__name__
'Figure'
References
callaway2021difference
panel_view ¶
panel_view(data: DataFrame, unit: str, time: str, treat: str, y: Optional[str] = None, type: str = 'treat', ax: Any = None, figsize: Tuple[float, float] = (10, 6), **kwargs: Any) -> Tuple[Any, Any, Dict[str, Any]]
panelView-style display of a panel's treatment status and outcomes.
A thin, dependency-light counterpart of Mou, Liu and Xu's panelView
(R and Stata) for the three questions it answers before any panel
causal estimator is fitted: which units are treated when (and whether
treatment ever switches off), how outcomes move over time by treatment
status, and how many cells are missing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long panel. |
required |
unit
|
str
|
Unit and time identifiers. |
required |
time
|
str
|
Unit and time identifiers. |
required |
treat
|
str
|
0/1 treatment status in each unit-period. |
required |
y
|
str
|
Outcome column, required for |
None
|
type
|
('treat', 'outcome')
|
|
'treat'
|
ax
|
matplotlib axis
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
Returns:
| Type | Description |
|---|---|
(fig, ax, summary)
|
|
Examples:
>>> import statspai as sp
>>> fig, ax, info = sp.panel_view(df, unit="id", time="t", treat="d")
>>> info["staggered"], info["has_reversals"]
References
[@mou2023panel]
parallel_trends_plot ¶
parallel_trends_plot(data: DataFrame, y: str, time: str, treat: str, id: Optional[str] = None, treat_time: Optional[Union[int, float]] = None, agg: str = 'mean', labels: Optional[Dict[str, str]] = None, colors: Optional[Tuple[str, str]] = None, ci: bool = True, ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, **kwargs: Any) -> Tuple[Any, Any]
Plot raw outcome means over time for treatment and control groups.
The workhorse pre-analysis plot: shows whether parallel trends is plausible before running DID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input dataset. |
required |
y
|
str
|
Outcome variable. |
required |
time
|
str
|
Time period variable. |
required |
treat
|
str
|
Treatment group indicator. Binary (0/1) for 2×2, or first-treatment-period for staggered (0 = never treated). |
required |
id
|
str
|
Unit identifier (for panel data). |
None
|
treat_time
|
int or float
|
Treatment onset time. Draws a vertical line if provided. |
None
|
agg
|
str
|
Aggregation function: 'mean' or 'median'. |
'mean'
|
labels
|
dict
|
Custom labels, e.g. |
None
|
colors
|
tuple of str
|
Colors for (treatment, control). Default: ('#E74C3C', '#2C3E50'). |
None
|
ci
|
bool
|
Show 95% confidence intervals (±1.96 SE of mean). |
True
|
ax
|
matplotlib Axes
|
Existing axes to plot on. |
None
|
figsize
|
tuple
|
Figure size. |
(10, 6)
|
title
|
str
|
Plot title. |
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
sensitivity_plot ¶
sensitivity_plot(sensitivity: DataFrame, original_ci: Optional[Tuple[float, float]] = None, original_estimate: Optional[float] = None, ax: Any = None, figsize: Tuple[float, float] = (10, 6), title: Optional[str] = None, color: str = '#2C3E50', breakdown_color: str = '#E74C3C', original_color: str = '#27AE60', **kwargs: Any) -> Tuple[Any, Any]
Plot Rambachan & Roth (2023) sensitivity analysis.
Shows how the robust confidence interval changes as the maximum allowed parallel trends violation (M) increases.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sensitivity
|
DataFrame
|
Output from |
required |
original_ci
|
tuple of (float, float)
|
Original CI (at M=0) for comparison. |
None
|
original_estimate
|
float
|
Original point estimate. |
None
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(10, 6)
|
title
|
str
|
|
None
|
color
|
str
|
CI band color. |
'#2C3E50'
|
breakdown_color
|
str
|
Color for the breakdown point marker. |
'#E74C3C'
|
original_color
|
str
|
Color for original estimate marker. |
'#27AE60'
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True, seed=0)
>>> result = sp.did(df, y='y', treat='first_treat', time='time',
... id='unit', method='cs')
>>> sens = sp.honest_did(result, e=0)
>>> fig, ax = sp.sensitivity_plot(sens, original_estimate=result.estimate,
... original_ci=result.ci)
>>> type(fig).__name__
'Figure'
References
rambachan2023more
treatment_rollout_plot ¶
treatment_rollout_plot(data: DataFrame, time: str, treat: str, id: str, ax: Any = None, figsize: Tuple[float, float] = (12, 7), title: Optional[str] = None, treated_color: str = '#E74C3C', untreated_color: str = '#ECF0F1', never_color: str = '#BDC3C7', sort_by: str = 'treat_time', show_cohort_labels: bool = True, **kwargs: Any) -> Tuple[Any, Any]
Visualise staggered treatment adoption timing.
Draws a tile/heatmap where each row is a unit and each column is a time period. Treated periods are shaded, making the staggered rollout pattern immediately visible.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel data with unit, time, and treatment columns. |
required |
time
|
str
|
Time period variable. |
required |
treat
|
str
|
First-treatment-period column (0 = never treated), or binary treatment indicator. |
required |
id
|
str
|
Unit identifier. |
required |
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(12, 7)
|
title
|
str
|
|
None
|
treated_color
|
str
|
Color for treated unit-periods. |
'#E74C3C'
|
untreated_color
|
str
|
Color for untreated unit-periods. |
'#ECF0F1'
|
never_color
|
str
|
Color for never-treated units. |
'#BDC3C7'
|
sort_by
|
str
|
Sort units by: 'treat_time' (earliest first), 'id', or 'random'. |
'treat_time'
|
show_cohort_labels
|
bool
|
Annotate cohort boundaries on the y-axis. |
True
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
pretrends_power ¶
pretrends_power(result: Any, delta: Optional[ndarray] = None, alpha: float = 0.05, test: str = 'individual') -> Dict[str, Any]
Power of the pre-trend test against a hypothesised violation.
Implements the power calculation from Roth (2022, AER: Insights). A non-significant pre-trend test is uninformative when the test has low power against economically meaningful violations of parallel trends.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Event-study result with pre-treatment estimates and SEs. |
required |
delta
|
array - like
|
Hypothesised trend violation in the pre-period (length = number
of pre-periods). Default: linear trend
|
None
|
alpha
|
float
|
Significance level of the pre-trend test. |
0.05
|
test
|
('individual', 'joint')
|
Which pre-test the power refers to.
.. versionchanged:: 1.21.0
The default moved from |
"individual"
|
Returns:
| Type | Description |
|---|---|
dict
|
Always: |
Notes
bayes_factor and likelihood_ratio follow the pretrends
package: the Bayes factor is (1 - power) / (1 - power_under_null),
the odds that a passed pre-test moves in favour of the hypothesised
trend, and the likelihood ratio compares the observed pre-period
coefficients under delta versus under no violation.
References
Roth, J. (2022). Pretest with Caution: Event-Study Estimates after Testing for Parallel Trends. AER: Insights, 4(3), 305--322. [@roth2022pretest]
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(80):
... cohort = 4 if i < 40 else 0 # 0 = never treated
... for t in range(8):
... post = cohort > 0 and t >= cohort
... y = 0.3 * t + (2.0 if post else 0.0) + (i % 5) + rng.normal()
... rows.append((i, t, cohort, y))
>>> df = pd.DataFrame(rows, columns=["id", "t", "cohort", "y"])
>>> es = sp.event_study(df, y="y", treat_time="cohort", time="t", unit="id")
>>> sp.pretrends_power(es)
pretrends_slope_for_power ¶
pretrends_slope_for_power(result: Any, target_power: float = 0.5, alpha: float = 0.05, test: str = 'individual') -> Dict[str, Any]
Slope of a linear pre-trend the pre-test would detect target_power
of the time.
The mirror image of :func:pretrends_power: instead of asking how
much power the pre-test has against a chosen violation, it asks how
large a violation has to be before the pre-test is even a coin flip.
Roth's pretrends package exposes the same quantity as
slope_for_power, and it is the number to quote when a reader asks
what a passed pre-test actually rules out.
The hypothesised violation is linear in event time,
delta_t = slope * (t - t_ref), with the reference period taken to
be t = -1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Event-study result with pre-treatment estimates and SEs. As with
:func: |
required |
target_power
|
float
|
Power the returned slope achieves. 0.5 is the |
0.5
|
alpha
|
float
|
Significance level of the pre-test. |
0.05
|
test
|
('individual', 'joint')
|
Which pre-test to solve against; see :func: |
"individual"
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys: |
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(80):
... cohort = 4 if i < 40 else 0 # 0 = never treated
... for t in range(8):
... post = cohort > 0 and t >= cohort
... y = 0.3 * t + (2.0 if post else 0.0) + (i % 5) + rng.normal()
... rows.append((i, t, cohort, y))
>>> df = pd.DataFrame(rows, columns=["id", "t", "cohort", "y"])
>>> es = sp.event_study(df, y="y", treat_time="cohort", time="t", unit="id")
>>> out = sp.pretrends_slope_for_power(es)
>>> round(out["slope"], 3)
0.245
>>> out["target_power"]
0.5
References
Roth, J. (2022). Pretest with Caution: Event-Study Estimates after Testing for Parallel Trends. AER: Insights, 4(3), 305--322. [@roth2022pretest]
pretrends_summary ¶
Print a combined pre-trends diagnostic report.
Runs pretrends_test and pretrends_power and formats the
output in a single table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Event-study result. |
required |
delta
|
array - like
|
Passed to |
None
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
str
|
Formatted report. |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(60):
... g = 5 if i < 30 else 0
... for t in range(1, 9):
... post = 1 if (g and t >= 5) else 0
... y = 1.0 + 0.2 * t + i / 120 + 2.0 * post + rng.normal(0, 0.5)
... rows.append({"unit": i, "time": t, "y": y, "g": g})
>>> df = pd.DataFrame(rows)
>>> result = sp.event_study(df, y="y", treat_time="g", time="time",
... unit="unit", window=(-3, 3))
>>> report = sp.pretrends_summary(result) # also prints the report
>>> bool(isinstance(report, str))
True
pretrends_test ¶
Joint test of pre-treatment coefficients.
Tests H0: beta_pre = 0 (all pre-treatment event-study coefficients are jointly zero).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Event-study result containing pre-treatment estimates and SEs. |
required |
type
|
``'wald'`` or ``'f'``
|
|
'wald'
|
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
dict
|
Keys: |
References
Standard Wald test; see Roth (2022) for caveats on interpretation.
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(80):
... cohort = 4 if i < 40 else 0 # 0 = never treated
... for t in range(8):
... post = cohort > 0 and t >= cohort
... y = 0.3 * t + (2.0 if post else 0.0) + (i % 5) + rng.normal()
... rows.append((i, t, cohort, y))
>>> df = pd.DataFrame(rows, columns=["id", "t", "cohort", "y"])
>>> es = sp.event_study(df, y="y", treat_time="cohort", time="t", unit="id")
>>> sp.pretrends_test(es)
sensitivity_rr ¶
sensitivity_rr(result: Any, Mbar: Optional[Union[ndarray, List[float]]] = None, method: str = 'C-LF', alpha: float = 0.05, n_grid: int = 20) -> SensitivityResult
Rambachan & Roth (2023) honest confidence intervals.
Computes confidence intervals for the ATT that are valid under bounded departures from parallel trends. The conditional linear-in-relative-time (C-LF) restriction assumes the post-treatment violation is bounded by a linear extrapolation of the pre-trend plus an additional M-bar of slack.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Event-study result with pre- and post-treatment estimates. |
required |
Mbar
|
array - like
|
Grid of M-bar values. Default:
|
None
|
method
|
``'C-LF'``
|
Extrapolation method. Currently only C-LF is implemented. |
'C-LF'
|
alpha
|
float
|
Significance level. |
0.05
|
n_grid
|
int
|
Number of grid points when |
20
|
Returns:
| Type | Description |
|---|---|
SensitivityResult
|
|
Notes
.. versionchanged:: next
The pre-period trend is now fitted by generalised least squares
using the full pre-period covariance from
result.model_info['vcv_pre'] when it is available
(sp.event_study supplies it). Previously the fit always used
diagonal 1/se**2 weights, i.e. it assumed the pre-treatment
event-study coefficients were mutually independent -- they are not,
since they share the omitted reference period and the unit/time
fixed effects. Breakdown Mbar values therefore move slightly
relative to earlier releases. When no covariance is available the
diagonal fallback is still used, but it now warns loudly.
Object with .summary(), .plot(), .mbar_grid,
.ci_lower, .ci_upper, .breakdown_mbar.
References
Rambachan, A. & Roth, J. (2023). A More Credible Approach to Parallel Trends. Review of Economic Studies, 90(5), 2555--2591. [@rambachan2023more]
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for i in range(80):
... cohort = 4 if i < 40 else 0 # 0 = never treated
... for t in range(8):
... post = cohort > 0 and t >= cohort
... y = 0.3 * t + (2.0 if post else 0.0) + (i % 5) + rng.normal()
... rows.append((i, t, cohort, y))
>>> df = pd.DataFrame(rows, columns=["id", "t", "cohort", "y"])
>>> es = sp.event_study(df, y="y", treat_time="cohort", time="t", unit="id")
>>> sens = sp.sensitivity_rr(es, Mbar=[0, 0.01, 0.02, 0.05])
>>> sens.summary()
cs_report ¶
cs_report(data_or_result: Union[DataFrame, CausalResult], y: Optional[str] = None, g: Optional[str] = None, t: Optional[str] = None, i: Optional[str] = None, x: Optional[List[str]] = None, weights: Optional[str] = None, estimator: str = 'dr', control_group: str = 'nevertreated', anticipation: int = 0, alpha: float = 0.05, n_boot: int = 1000, random_state: Optional[int] = 0, min_e: float = -inf, max_e: float = inf, rr_method: str = 'smoothness', balance: bool = True, triangulate: bool = True, functional_form: bool = True, verbose: bool = True, save_to: Optional[str] = None) -> CSReport
One-call staggered-DID workflow following Baker et al. (2026).
Runs the eight-step "forward-engineering" recipe the practitioner's
guide closes with (§6): define the target parameter, state the
identification assumption and generate evidence about it, choose an
estimation method, declare the inference frame, estimate, and probe
sensitivity. report.forward_engineering_checklist() returns the
steps and what was actually run for each.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_or_result
|
DataFrame | CausalResult
|
Either a long-format panel (then |
required |
y
|
str
|
Outcome / cohort / time / unit id columns (required when
|
None
|
g
|
str
|
Outcome / cohort / time / unit id columns (required when
|
None
|
t
|
str
|
Outcome / cohort / time / unit id columns (required when
|
None
|
i
|
str
|
Outcome / cohort / time / unit id columns (required when
|
None
|
x
|
list of str
|
Covariates for conditional parallel trends. |
None
|
weights
|
str
|
Unit weights ω. Changes the target parameter, not just precision
— see :func: |
None
|
balance
|
bool
|
Run :func: |
True
|
triangulate
|
bool
|
Also fit the other covariate strategies (regression adjustment, IPW, doubly robust) and report them side by side, as in the paper's Table 7 / Figure 4. Only meaningful with covariates. |
True
|
functional_form
|
bool
|
Run :func: |
True
|
estimator
|
('dr', 'ipw', 'reg')
|
|
'dr'
|
control_group
|
('nevertreated', 'notyettreated')
|
|
'nevertreated'
|
anticipation
|
int
|
|
0
|
alpha
|
float
|
|
0.05
|
n_boot
|
int
|
Multiplier-bootstrap replications for :func: |
1000
|
random_state
|
int
|
Seed for the bootstrap (set to |
0
|
min_e
|
float
|
Event-time window passed to the dynamic aggregation. |
(-inf, inf)
|
max_e
|
float
|
Event-time window passed to the dynamic aggregation. |
(-inf, inf)
|
rr_method
|
('smoothness', 'relative_magnitude')
|
Sensitivity restriction handed to :func: |
'smoothness'
|
verbose
|
bool
|
If |
True
|
save_to
|
str
|
When set, treats the value as a path prefix and writes the report in every supported format in one call:
Missing parent directories are created on the fly. |
None
|
Returns:
| Type | Description |
|---|---|
CSReport
|
Structured container; call |
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for unit in range(80):
... g = 4 if unit < 40 else 0 # cohort; 0 = never treated
... for t in range(8):
... post = g > 0 and t >= g
... y = 0.3 * t + (2.0 if post else 0.0) + (unit % 5) + rng.normal()
... rows.append((unit, t, g, y))
>>> df = pd.DataFrame(rows, columns=["id", "t", "g", "y"])
>>> rpt = sp.cs_report(df, y="y", g="g", t="t", i="id", random_state=42)
>>> rpt.dynamic # event-study DataFrame w/ uniform bands
parallel_trends_robustness ¶
parallel_trends_robustness(result: Any, m_grid: Optional[Sequence[float]] = None, families: Sequence[str] = ('SD', 'RM'), alpha: float = 0.05, e: int = 0, delta: Optional[ndarray] = None) -> ParallelTrendsRobustnessResult
Run the full parallel-trends robustness pipeline on a DiD result.
Chains the joint pre-trend test, the Roth (2022) power calculation for that test, and Rambachan & Roth (2023) honest confidence intervals (plus their breakdown value Mbar*) for each requested restriction family, and reduces the whole thing to a one-line verdict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
A fitted DiD/event-study result carrying event-study estimates in
.. note::
If the result does not carry a pre-period covariance matrix in
|
required |
m_grid
|
sequence of float
|
Grid of violation magnitudes. Default: the |
None
|
families
|
sequence of str
|
Restriction families. |
``("SD", "RM")``
|
alpha
|
float
|
Significance level. |
0.05
|
e
|
int
|
Relative time whose effect the sensitivity analysis targets. |
0
|
delta
|
array - like
|
Hypothesised pre-trend violation passed to |
None
|
Returns:
| Type | Description |
|---|---|
ParallelTrendsRobustnessResult
|
With |
References
Rambachan, A. and Roth, J. (2023). A More Credible Approach to Parallel Trends. Review of Economic Studies, 90(5), 2555-2591. [@rambachan2023more]
Roth, J. (2022). Pretest with Caution: Event-Study Estimates after Testing for Parallel Trends. AER: Insights, 4(3), 305-322. [@roth2022pretest]
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=80, n_periods=8, staggered=False, seed=0)
>>> df["first_treat"] = df["first_treat"].fillna(0)
>>> es = sp.event_study(df, y="y", treat_time="first_treat",
... time="time", unit="unit", window=(-3, 3))
>>> rob = sp.parallel_trends_robustness(es, families=("SD", "RM"))
>>> sorted(rob.breakdown)
['RM', 'SD']
>>> bool("Mbar" in rob.verdict or "not robust" in rob.verdict)
True
spillover_did ¶
spillover_did(data: DataFrame, y: str, *, unit: str, time: str, cohort: str, coords: Optional[Sequence[str]] = None, distances: Optional[ndarray] = None, ring_edges: Sequence[float] = (0.0, 1.0), never_value: Any = 0, alpha: float = 0.05) -> SpilloverRingResult
Direct and spillover effects with distance-banded control groups.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
Outcome column. |
required |
unit
|
str
|
Unit identifier, period, and first-treatment period
( |
required |
time
|
str
|
Unit identifier, period, and first-treatment period
( |
required |
cohort
|
str
|
Unit identifier, period, and first-treatment period
( |
required |
coords
|
sequence of str
|
Two columns giving each unit's position. Distances are Euclidean in
whatever units these are. Ignored when |
None
|
distances
|
ndarray
|
Pre-computed |
None
|
ring_edges
|
sequence of float
|
Ring boundaries. |
(0.0, 1.0)
|
never_value
|
any
|
Value in |
0
|
alpha
|
float
|
|
0.05
|
Returns:
| Type | Description |
|---|---|
SpilloverRingResult
|
|
Notes
Standard errors are the influence-function form for a difference of
group means, aggregated across (cohort, period) cells so the shared
control units are accounted for. There is no reference implementation
to pin them against; see the module docstring on how correctness is
established instead.
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> x = rng.uniform(0, 10, n); yc = rng.uniform(0, 10, n)
>>> treated = (x < 3) & (yc < 3)
>>> rows = []
>>> for i in range(n):
... for t in (1, 2):
... rows.append((i, t, 2 if treated[i] else 0, x[i], yc[i],
... rng.normal() + (1.0 if (treated[i] and t == 2) else 0)))
>>> df = pd.DataFrame(rows, columns=["i", "t", "g", "x", "y2", "y"])
>>> res = sp.spillover_did(df, y="y", unit="i", time="t", cohort="g",
... coords=["x", "y2"], ring_edges=(0.0, 2.0))
>>> bool(np.isfinite(res.direct))
True
References
butts2021difference
did_report ¶
did_report(data: DataFrame, y: str, time: str, first_treat: str, group: str, save_to: str, methods: Union[str, List[str]] = 'auto', controls: Optional[List[str]] = None, cluster: Optional[str] = None, alpha: float = 0.05, include_sensitivity: bool = True, plot_sort_by: Optional[str] = 'estimate', verbose: bool = False) -> CausalResult
DID report bundle: fits selected methods and writes report artifacts.
Writes the following files to save_to:
did_summary.txt: text dump ofresult.summary().did_summary.md: GitHub-Flavoured Markdown table.did_summary.tex: LaTeXbooktabsfragment.did_summary.png: forest plot (requires matplotlib).did_summary.json: detail table + model_info in JSON.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Same as :func: |
required |
y
|
DataFrame
|
Same as :func: |
required |
time
|
DataFrame
|
Same as :func: |
required |
first_treat
|
DataFrame
|
Same as :func: |
required |
group
|
DataFrame
|
Same as :func: |
required |
methods
|
DataFrame
|
Same as :func: |
required |
controls
|
DataFrame
|
Same as :func: |
required |
cluster
|
DataFrame
|
Same as :func: |
required |
alpha
|
DataFrame
|
Same as :func: |
required |
save_to
|
str
|
Directory path. Created if it does not exist. |
required |
include_sensitivity
|
bool
|
Whether to run Rambachan-Roth breakdown M*. Defaults to |
True
|
plot_sort_by
|
(estimate, None)
|
Sort the forest plot by estimate ascending. |
'estimate'
|
verbose
|
bool
|
Print progress lines. |
False
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
The underlying :func: |
Examples:
>>> import tempfile
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> out_dir = tempfile.mkdtemp()
>>> res = sp.did_report(df, y='y', time='time',
... first_treat='first_treat', group='unit',
... save_to=out_dir, methods=['cs', 'bjs'],
... include_sensitivity=False)
>>> import os
>>> 'did_summary.md' in os.listdir(out_dir)
True
did_summary ¶
did_summary(data: DataFrame, y: str, time: str, first_treat: str, group: str, methods: Union[str, List[str]] = 'auto', controls: Optional[List[str]] = None, cluster: Optional[str] = None, alpha: float = 0.05, include_sensitivity: bool = False, verbose: bool = False) -> CausalResult
One-call method-robustness comparison for staggered DID.
Fits every requested estimator to the same data and returns a single
:class:CausalResult whose detail attribute is a tidy comparison
table — one row per method, columns (method, estimator, estimate,
se, pvalue, ci_low, ci_high, n_obs, note).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel dataset (long format). |
required |
y
|
str
|
Outcome variable. |
required |
time
|
str
|
Time / period variable (integer-valued). |
required |
first_treat
|
str
|
First-treatment period per unit; NaN (or 0) for never-treated. |
required |
group
|
str
|
Unit identifier. |
required |
methods
|
str or list of str
|
Methods to run. Valid keys: |
``'auto'``
|
controls
|
list of str
|
Time-varying covariates passed to methods that support them. |
None
|
cluster
|
str
|
Cluster variable for SE (defaults to |
None
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
include_sensitivity
|
bool
|
If |
False
|
verbose
|
bool
|
Print progress for each method. |
False
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
Each method's overall ATT has slightly different interpretation:
- CS
aggte(type='simple')averages ATT(g, t) for post-treatment :math:t \geq g, weighted by cohort size × exposure length. - ETWFE reports the R/Stata treated-observation-weighted simple ATT.
- SA / BJS / Stacked report their estimator-specific overall ATT aggregations.
Differences across methods are informative about heterogeneity,
model specification, and the sensitivity of conclusions to the
estimator choice. Large disagreement is a red flag that deserves
further investigation (e.g., via sp.bacon_decomposition or
sp.honest_did).
Examples:
did_summary_to_latex ¶
did_summary_to_latex(result: CausalResult, digits: int = 4, include_ci: bool = True, include_breakdown: bool = True, label: str = 'tab:did_summary', caption: str = 'DID method-robustness summary.') -> str
Render a :func:did_summary result as a LaTeX
booktabs table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
digits
|
int
|
Decimal precision. |
4
|
include_ci
|
bool
|
Include the 95 % CI column. |
True
|
include_breakdown
|
bool
|
Include the Rambachan-Roth breakdown M* column when sensitivity was requested. |
True
|
label
|
str
|
LaTeX label for the table. |
``'tab:did_summary'``
|
caption
|
str
|
LaTeX caption. |
``'DID method-robustness summary.'``
|
Returns:
| Type | Description |
|---|---|
str
|
Full |
Notes
Requires \usepackage{booktabs} in the LaTeX preamble.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> out = sp.did_summary(df, y='y', time='time',
... first_treat='first_treat', group='unit',
... methods=['cs', 'bjs'])
>>> tex = sp.did_summary_to_latex(out)
>>> isinstance(tex, str)
True
>>> tex.splitlines()[0].startswith('\\begin{table}')
True
did_summary_to_markdown ¶
did_summary_to_markdown(result: CausalResult, digits: int = 4, include_ci: bool = True, include_breakdown: bool = True) -> str
Render a :func:did_summary result as a GitHub-Flavoured Markdown table.
Columns shown (in order):
Method, Estimate, SE, 95 % CI, p-value, and
optionally Breakdown M* (when sensitivity was requested).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
digits
|
int
|
Decimal precision for numeric columns. |
4
|
include_ci
|
bool
|
Include the 95 % CI column. |
True
|
include_breakdown
|
bool
|
Include the Rambachan-Roth breakdown M* column (CS row only, blank for others). Ignored if sensitivity was not requested. |
True
|
Returns:
| Type | Description |
|---|---|
str
|
Multi-line Markdown table, ready to paste into notebooks or PRs. |
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True,
... seed=0)
>>> out = sp.did_summary(df, y='y', time='time',
... first_treat='first_treat', group='unit',
... methods=['cs', 'bjs'])
>>> md = sp.did_summary_to_markdown(out)
>>> isinstance(md, str)
True
>>> md.splitlines()[0].startswith('| Method')
True
drdid ¶
drdid(data: DataFrame, y: str, group: str, time: str, covariates: Optional[List[str]] = None, method: str = 'imp', alpha: float = 0.05, n_boot: int = 500, random_state: Optional[int] = None, seed: Optional[int] = None, id: Optional[str] = None, *, est_method: str = 'dr', normalized: bool = True, locally_efficient: bool = True, weights: Optional[str] = None, trim_level: float = 0.995) -> CausalResult
Doubly Robust Difference-in-Differences (Sant'Anna & Zhao 2020).
Combines outcome regression with inverse probability weighting for 2×2 DID with covariates. Consistent if either the outcome model or the propensity score model is correctly specified.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Dataset with one row per unit-period in 2x2 design. |
required |
y
|
str
|
Outcome variable. |
required |
group
|
str
|
Binary treatment-group indicator (1 = treated, 0 = control). |
required |
time
|
str
|
Binary time indicator (1 = post, 0 = pre). |
required |
covariates
|
list of str
|
Covariate names. If |
None
|
method
|
str
|
Which nuisance estimators the DR variants use, following R
Only consulted when .. versionchanged:: 1.23.0
On repeated cross-sections this argument used to do nothing:
both settings returned |
``'imp'``
|
est_method
|
(dr, ipw, reg, twfe)
|
Which estimator family to use. With ============ ======================= ==========================
est_method panel (
.. versionadded:: 1.23.0 |
'dr'
|
normalized
|
bool
|
Only for .. versionadded:: 1.23.0 |
True
|
locally_efficient
|
bool
|
Only for .. versionadded:: 1.23.0 |
True
|
weights
|
str
|
Column of observation weights (R .. versionadded:: 1.23.0 |
None
|
trim_level
|
float
|
Drop control units whose estimated propensity score reaches this
cutoff, matching .. versionadded:: 1.23.0 |
0.995
|
alpha
|
float
|
Significance level. |
0.05
|
n_boot
|
int
|
Number of bootstrap replications for inference. |
500
|
random_state
|
int
|
Seed for bootstrap reproducibility. |
None
|
id
|
str
|
Unit identifier for a true two-period panel. When supplied, the
improved estimator uses the Sant'Anna-Zhao panel formula with
calibrated propensity scores and influence-function standard errors,
matching |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(42)
>>> n = 500
>>> G = rng.integers(0, 2, n)
>>> T = rng.integers(0, 2, n)
>>> x = rng.normal(0, 1, n)
>>> y_val = 1 + 0.5*x + 2*G + 3*T + 4*G*T + rng.normal(0, 1, n)
>>> df = pd.DataFrame({'y': y_val, 'treated': G, 'post': T, 'x': x})
>>> result = sp.drdid(df, y='y', group='treated', time='post',
... covariates=['x'])
>>> abs(result.estimate - 4.0) < 1.0
True
etwfe ¶
etwfe(data: DataFrame, y: str, group: str, time: str, first_treat: str, controls: Optional[List[str]] = None, cluster: Optional[str] = None, alpha: float = 0.05, xvar: Optional[Any] = None, panel: bool = True, cgroup: str = 'notyet', family: Optional[str] = None) -> CausalResult
Public sp.etwfe entry point — see _dispatch_etwfe_impl for
the full docstring on options and behaviour.
family selects the outcome model. None/'gaussian' (default)
is the historical linear ETWFE and is unchanged. 'poisson' and
'logit' fit Wooldridge (2023) nonlinear ETWFE by maximum likelihood
and report the average marginal effect on the response scale,
matching R etwfe::emfx(type='simple') — so a Poisson fit returns an
effect in counts, not log points. The nonlinear branch uses
not-yet-treated identification and does not currently accept xvar,
panel=False, or cgroup='nevertreated'.
Thin wrapper around the 4-branch dispatcher (panel-with-xvar /
panel-never-only / panel-notyet / repeated-cross-section) that
attaches a :class:Provenance record to the returned result so
downstream replication_pack / Quarto appendix / table footers
can pick up the call without each branch having to opt in.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=120, n_periods=8, staggered=True, seed=42)
>>> res = sp.etwfe(df, y='y', group='unit', time='time',
... first_treat='first_treat')
>>> res.estimate > 0 # R/Stata simple ATT (true effect 0.5)
True
>>> res.detail is not None # cohort-specific ATTs
True
See Also
wooldridge_did : The same saturated cohort x period regression reported
under a different headline aggregation — the cohort-size-weighted
average of ATT(g) under a never-treated comparison group, i.e. R
etwfe::etwfe(cgroup='never') + emfx(type='group'). sp.etwfe
instead reports the treated-observation-weighted simple ATT that R
emfx(type='simple') and Stata jwdid, estat simple print, under
the not-yet-treated comparison group by default. The two are separate
documented aggregations of one estimator, not two estimators, and they
differ materially on the 17_etwfe parity bytes: the sp.etwfe
default is 15.9% from the sp.wooldridge_did headline, and
sp.etwfe(cgroup='nevertreated') — same comparison group, different
weights — is still 10.5% from it. Pick the one your write-up claims.
etwfe_emfx ¶
etwfe_emfx(result: CausalResult, type: str = 'simple', alpha: float = 0.05, include_leads: bool = False, weighting: str = 'treated') -> CausalResult
R etwfe::emfx-style aggregated marginal effects for an ETWFE fit.
Takes the result of :func:etwfe / :func:wooldridge_did and returns
one of four aggregations used in applied work:
================ ========================================================
type Aggregation
================ ========================================================
'simple' Overall treated-observation-weighted ATT (same as
result.estimate for current sp.etwfe results).
'group' ATT per treatment cohort g.
'event' ATT per event time e = t - g, averaged across cohorts.
'calendar' ATT per calendar time t, averaged across cohorts for
which t >= g.
================ ========================================================
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Output of :func: |
required |
type
|
(simple, group, event, calendar)
|
Aggregation type. |
'simple'
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
include_leads
|
bool
|
For |
False
|
weighting
|
(cohort, treated)
|
Aggregation weights for cohort-level marginal effects. |
'cohort'
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Notes
For 'event' and 'calendar', the reported SE treats the
per-cohort coefficients as independent — a standard approximation
that matches R etwfe's default under classical vcov. Cluster-robust
or fully-general SEs require the full regression vcov, which can
be requested via sp.wooldridge_did + the model_info matrix
in a future release.
Examples:
>>> import statspai as sp
>>> df = sp.dgp_did(n_units=200, n_periods=10, staggered=True)
>>> fit = sp.etwfe(df, y='y', time='time',
... first_treat='first_treat', group='unit')
>>> evt = sp.etwfe_emfx(fit, type='event')
>>> print(evt.detail) # ATT by event time
>>> grp = sp.etwfe_emfx(fit, type='group')
>>> cal = sp.etwfe_emfx(fit, type='calendar')
twfe_decomposition ¶
twfe_decomposition(data: DataFrame, y: str, group: str, time: str, first_treat: str, alpha: float = 0.05) -> CausalResult
TWFE decomposition: Goodman-Bacon (2021) + de Chaisemartin–D'Haultfoeuille weights.
Decomposes the standard two-way fixed effects estimator into all pairwise 2×2 DID comparisons, showing the weight and estimate for each. Also computes de Chaisemartin–D'Haultfoeuille (2020) weights to diagnose whether negative weights are present.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel dataset in long format. |
required |
y
|
str
|
Outcome variable. |
required |
group
|
str
|
Unit identifier. |
required |
time
|
str
|
Time period variable. |
required |
first_treat
|
str
|
Treatment timing column (NaN or 0 for never-treated). |
required |
alpha
|
float
|
Significance level. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
did ¶
did(data: DataFrame, y: str, treat: str, time: str, id: Optional[str] = None, covariates: Optional[Any] = None, method: str = 'auto', estimator: str = 'dr', control_group: str = 'nevertreated', base_period: str = 'universal', cluster: Optional[str] = None, robust: bool = True, alpha: float = 0.05, weights: Optional[str] = None, subgroup: Optional[str] = None, treat_unit: Any = None, treat_time: Any = None, se_method: str = 'placebo', aggregation: Optional[str] = None, n_boot: int = 1000, random_state: Optional[int] = None, panel: bool = True, allow_unbalanced_panel: bool = False, anticipation: int = 0, vce: Optional[str] = None, wild_reps: int = 999, wild_weight_type: str = 'rademacher', seed: Optional[int] = None, **kwargs: Any) -> CausalResult
Difference-in-Differences estimation.
Unified entry point that auto-detects design type and dispatches to the appropriate estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input dataset. |
required |
y
|
str
|
Outcome variable name. |
required |
treat
|
str
|
Treatment variable. The column semantics depend on the design — one of the most common pitfalls in DID:
If you only have a 0/1 |
required |
time
|
str
|
Time period variable. |
required |
id
|
str
|
Unit identifier. Required for staggered DID and SDID. |
None
|
covariates
|
list of str
|
Covariate names for conditional parallel trends / controls. |
None
|
method
|
str
|
|
'auto'
|
estimator
|
str
|
For staggered DID: |
'dr'
|
control_group
|
str
|
For staggered DID: |
'nevertreated'
|
base_period
|
str
|
For staggered DID: |
'universal'
|
cluster
|
str
|
Cluster variable for standard errors. |
None
|
robust
|
bool
|
HC1 robust standard errors (2×2 / DDD only). |
True
|
alpha
|
float
|
Significance level for confidence intervals. |
0.05
|
weights
|
str
|
Column name for analytical weights (e.g. population weights).
Supported for |
None
|
subgroup
|
str
|
For DDD: binary affected-subgroup indicator. |
None
|
treat_unit
|
optional
|
For SDID: treated unit(s). |
None
|
treat_time
|
optional
|
For SDID: treatment time. |
None
|
se_method
|
str
|
For SDID: 'placebo', 'bootstrap', or 'jackknife'. |
'placebo'
|
aggregation
|
str
|
When set and |
None
|
n_boot
|
int
|
Bootstrap replications for the multiplier bootstrap when
|
1000
|
random_state
|
int
|
Seed for the multiplier bootstrap. |
None
|
panel
|
bool
|
Forwarded to :func: |
True
|
allow_unbalanced_panel
|
bool
|
Forwarded to :func: .. versionadded:: 1.23.0 |
False
|
anticipation
|
int
|
Forwarded to :func: |
0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
Estimation results with |
References
Callaway, B. and Sant'Anna, P. H. C. (2021). Difference-in-differences with multiple time periods. Journal of Econometrics, 225(2), 200-230. [@callaway2021difference]
Sant'Anna, P. H. C. and Zhao, J. (2020). Doubly Robust Difference-in-Differences Estimators. Journal of Econometrics, 219(1), 101-122. [@santanna2020doubly]
Goodman-Bacon, A. (2021). Difference-in-differences with variation in treatment timing. Journal of Econometrics, 225(2), 254-277. [@goodmanbacon2021difference]
Examples:
Classic 2x2 DID (one binary treatment, two periods):
>>> n = 200
>>> df = pd.DataFrame({
... 'treated': np.repeat(rng.integers(0, 2, n), 2),
... 'post': np.tile([0, 1], n),
... })
>>> df['wage'] = (1.0 + 2.0 * df['treated'] + 1.5 * df['post']
... + 3.0 * df['treated'] * df['post']
... + rng.normal(0, 1, len(df)))
>>> result = sp.did(df, y='wage', treat='treated', time='post')
>>> bool(result.estimate > 0)
True
Triple Differences (a third dimension via subgroup):
>>> df['low_wage'] = np.repeat(rng.integers(0, 2, n), 2)
>>> ddd = sp.did(df, y='wage', treat='treated', time='post',
... method='ddd', subgroup='low_wage')
Staggered DID (cohort column gives first-treatment time; 0 = never):
>>> rows = []
>>> for u in range(30):
... first = int(rng.choice([2003, 2005, 0]))
... for yr in range(2000, 2008):
... on = 1 if (first != 0 and yr >= first) else 0
... rows.append({'unit': u, 'year': yr, 'first_treat': first,
... 'y': 5 + 2.0 * on + rng.normal(0, 1)})
>>> panel = pd.DataFrame(rows)
>>> staggered = sp.did(panel, y='y', treat='first_treat',
... time='year', id='unit')
>>> bool(staggered.estimate == staggered.estimate) # finite
True