Randomised rollouts: design-based DiD¶
Every other DiD estimator in StatsPAI identifies off parallel trends. This page is about the case where you have something better: adoption timing was randomly assigned. A policy lottery, a phased product launch where the order was drawn, an RCT rolled out in waves.
When that is true, parallel trends is neither needed nor sufficient, and imposing it throws away the randomisation you paid for.
1. The one question that decides everything¶
Was the adoption date randomly assigned?
No test on your data can answer this. It is a claim about how the data came to
be, and you either know it from the design or you do not. sp.recommend
therefore surfaces this branch but never picks it for you.
| Answer | Use | Identifying assumption |
|---|---|---|
| Yes — timing was drawn | sp.staggered_rollout |
random adoption timing |
| No — units chose, or were chosen | sp.callaway_santanna and friends (§2 of choosing_did_estimator) |
parallel trends |
Getting this wrong is costly in both directions. Claiming design-based inference on an observational rollout asserts something false about the world. Imposing parallel trends on a genuinely randomised one is merely wasteful — but the waste is real: the design-based standard error is smaller, and the randomisation test needs no asymptotics at all.
This is not the same estimand as
sp.callaway_santanna. Ondid::mpdta— where timing was not randomised —sp.staggered_rolloutreturns −0.0471 against CS's −0.0400. That gap is the estimand difference, not a bug. If you see numbers disagree, check which assumption each one is using before looking for an error.
2. The basic call¶
import statspai as sp
res = sp.staggered_rollout(df, y="y", i="unit", t="time", g="first_treat")
res.summary()
g is the first-treated period. Never-treated units may be coded 0, NaN
or inf — all three are accepted and mean the same thing.
In R this is a trap.
staggeredrequiresg = Inffor never-treated units; passg = 0and it silently reads them as a cohort treated before the sample. On mpdta that turns −0.047 into −0.370. StatsPAI normalises all three codings, so the trap is unreachable through the public API.
Which estimand¶
sp.staggered_rollout(df, ..., estimand="simple") # per treated cell (default)
sp.staggered_rollout(df, ..., estimand="cohort") # average within cohort first
sp.staggered_rollout(df, ..., estimand="calendar") # average within period first
sp.staggered_rollout(df, ..., estimand="eventstudy", event_time=1)
Which comparison group¶
sp.staggered_cs(df, ...) # every not-yet-treated cohort (CS estimand)
sp.staggered_sa(df, ...) # last-treated cohort only (SA estimand)
Both are the familiar estimands with design-based inference — same weights
as sp.callaway_santanna / sp.sun_abraham, different standard errors.
3. Two standard errors, and which to report¶
R's staggered prints two, and so does StatsPAI:
| what it is | when to use | |
|---|---|---|
se_type="neyman" (default) |
conservative bound; treats the fitted control weights as fixed | reporting, unless you have a reason |
se_type="adjusted" |
subtracts the part of the variance the randomisation identifies | matching R's primary se; tighter |
The adjusted SE is never larger, which is why the conservative one is the default. Both are always available:
res = sp.staggered_rollout(df, ...)
res.model_info["se_neyman"] # conservative
res.model_info["se_adjusted"] # tighter
How much do they differ? On mpdta's simple estimand, by 3e-6 relative —
nothing. On the calendar estimand, 1.4e-3. On a genuinely randomised panel,
up to ~2%. Say which one you report.
4. Randomisation inference¶
This is the payoff of a randomised design: inference with no asymptotics.
res = sp.staggered_rollout(df, ..., fisher=True, n_fisher=1000, random_state=0)
res.model_info["fisher_pvalue"]
Adoption dates are permuted across units — exactly the null the design licenses — and the studentised statistic recomputed on each draw. The p-value is the share of draws whose |t| exceeds the observed one.
Because permuting adoption dates leaves every cohort size unchanged, the weight matrices are built once and reused, so this is much cheaper than it looks. Both p-values (conservative and adjusted) are reported.
5. Event studies¶
res = sp.staggered_rollout(df, ..., estimand="eventstudy", event_time=[0, 1, 2])
res.detail # one row per event time, with both SEs and a CI
res.model_info["vcov"] # the JOINT covariance across event times
.estimate averages the requested non-negative event times, and its standard
error comes from that joint covariance — not from pretending the event times
are independent, which would understate it.
A useful invariant: at event_time=-1 the outcome and control weights
coincide, so the estimate is mechanically zero. If it is not, something is
wrong with the weight construction.
6. The general control set¶
By default the efficient weights are chosen over a single control: the DiD
contrast at each cohort's last pre-period, g − 1. The general form uses
every pre-period:
beta then becomes a vector rather than a scalar, and the estimator is weakly
more efficient — at the cost of estimating more nuisance weights. It requires
efficient=True: the plug-in fixes beta = 1, which is a single contrast and
has no meaning against a vector control set (R errors here too).
7. What not to run afterwards¶
sp.did(method="staggered_rollout") deliberately skips the Bacon
decomposition, the pre-trend test and honest-DiD sensitivity, and says so in
its step log. All three are about parallel trends:
- a pre-trend test neither supports nor threatens an assumption you are not making;
- honest-DiD relaxes parallel trends, which you did not assume;
- the Bacon decomposition diagnoses TWFE's forbidden comparisons and advises switching to Callaway-Sant'Anna — advice that does not apply here.
Reporting a passing pre-trend test next to a design-based estimate is not extra reassurance. It is a category error, and it invites a reader to think the estimate rests on an assumption it does not rest on.
What does belong: the randomisation test (§4), and the balance of covariates across adoption cohorts, which is the thing randomisation actually promises.
8. Parity evidence¶
Every number above is pinned against R staggered 1.2.2:
| What | Agreement |
|---|---|
Every estimand × beta × comparison group, both SEs |
≤1e-9 (achieved ~1e-16) |
| Every feasible event time + the joint covariance | ≤1e-9 |
staggered_cs / staggered_sa |
≤1e-9 |
General control set (use_did_a0=False) |
≤1e-9 |
| Randomisation test | pinned draw-for-draw on 40 R-generated permutations |
Three panels are used: did::mpdta, a randomised rollout with no never-treated
units (so max(g) is finite — a branch mpdta never reaches), and the same
design with the effect switched off (so the randomisation p-value lands in the
interior, where an error would show).
See tests/reference_parity/test_staggered_extended_parity.py, Track A module
82_staggered, and tests/test_design_based_did.py for the Monte-Carlo
coverage and calibration checks that no reference implementation can supply.
References¶
- Roth, J. and Sant'Anna, P. H. C. (2023). "Efficient Estimation for Staggered
Rollout Designs." Journal of Political Economy Microeconomics, 1(4),
669–709. [
roth2023efficient] - Callaway, B. and Sant'Anna, P. H. C. (2021). Journal of Econometrics.
[
callaway2021difference] - Sun, L. and Abraham, S. (2021). Journal of Econometrics.
[
sun2021estimating]