Skip to content

Commit 489b03b

Browse files
callaway_santanna(panel=False): repeated cross-sections support
Close the last significant capability gap vs `csdid` / `differences`: repeated-cross-sections (RCS) data where observations are not matched across time. Under `panel=False`, each (g, t) ATT is estimated as the unconditional 2×2 cell-mean DID ATT(g, t) = (Ȳ_{g, t} - Ȳ_{g, base}) - (Ȳ_{c, t} - Ȳ_{c, base}) with c = never-treated cohort, and observation-level influence functions ψ_i = 1{G_i=g, T_i=t} (Y_i - Ȳ_{g, t}) / p_{g, t} - 1{G_i=g, T_i=base} (Y_i - Ȳ_{g, base}) / p_{g, base} - 1{G_i=c, T_i=t} (Y_i - Ȳ_{c, t}) / p_{c, t} + 1{G_i=c, T_i=base} (Y_i - Ȳ_{c, base}) / p_{c, base} stacked into the inf_matrix slot that aggte() / cs_report() / ggdid() / honest_did() already consume — so the RCS path gets the full pipeline (Mammen multiplier bootstrap uniform bands, all four aggregations, R-R breakdown M*) *for free* with no changes to the downstream modules. Scope of this commit (documented as NotImplementedError on other paths): - only estimator='reg' (unconditional 2×2 DID; IPW/DR for RCS later) - only control_group='nevertreated' - no covariates (future: OLS residualisation per cell) Tests (tests/test_cs_rcs.py, 11 cases): * RCS path returns CausalResult with `panel=False` flag set * overall ATT > 0 with p < 0.01 on a 1200-obs RCS DGP * detail grid has the expected (g, t) rows * influence functions are obs-level (n_obs × K) * aggte(type='dynamic') runs and delivers uniform bands * cs_report composes on an RCS CausalResult * honest_did consumes the RCS event study * informative NotImplementedError on {covariates, IPW/DR, notyettreated} * RCS and panel modes agree point-by-point on a balanced panel Full DiD suite: 142/142 (was 131). References: Callaway, B. and Sant'Anna, P.H.C. (2021). "Difference-in-Differences with Multiple Time Periods." J. of Econometrics 225(2), 200-230. Section 2 / eqn (2.4) — RCS version of the 2×2 DID. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ff401d commit 489b03b

2 files changed

Lines changed: 375 additions & 0 deletions

File tree

src/statspai/did/callaway_santanna.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def callaway_santanna(
4646
base_period: str = 'universal',
4747
anticipation: int = 0,
4848
alpha: float = 0.05,
49+
panel: bool = True,
4950
) -> CausalResult:
5051
"""
5152
Callaway & Sant'Anna (2021) estimator for staggered DID.
@@ -78,6 +79,15 @@ def callaway_santanna(
7879
Sant'Anna (2021), Section 3.2.
7980
alpha : float, default 0.05
8081
Significance level.
82+
panel : bool, default True
83+
If ``True`` (default), treat the data as a balanced panel and
84+
estimate ATT(g, t) via within-unit first differences.
85+
If ``False``, treat the data as *repeated cross-sections* —
86+
observations are not matched across time. In RCS mode the
87+
estimator is the unconditional 2×2 cell-mean DID per (g, t)
88+
pair (CS2021 §3.2, eqn 2.4, RCS version). The covariate-free
89+
``estimator='reg'`` path is the only one currently supported
90+
for RCS; IPW / DR can be added later.
8191
8292
Returns
8393
-------
@@ -112,6 +122,31 @@ def callaway_santanna(
112122
if anticipation < 0:
113123
raise ValueError(f"anticipation must be >= 0, got {anticipation}")
114124

125+
# ---- Repeated cross-sections branch --------------------------------
126+
if not panel:
127+
if x:
128+
raise NotImplementedError(
129+
"panel=False (repeated cross-sections) does not yet support "
130+
"covariates. Pass x=None or run panel=True if your data are "
131+
"balanced across units."
132+
)
133+
if estimator != 'reg':
134+
raise NotImplementedError(
135+
"panel=False currently only supports estimator='reg' "
136+
"(unconditional 2×2 cell-mean DID). IPW / DR for RCS are "
137+
"planned for a future release."
138+
)
139+
if control_group != 'nevertreated':
140+
raise NotImplementedError(
141+
"panel=False currently requires "
142+
"control_group='nevertreated'."
143+
)
144+
return _callaway_santanna_rcs(
145+
data=data, y=y, g=g, t=t,
146+
base_period=base_period, anticipation=anticipation,
147+
alpha=alpha,
148+
)
149+
115150
# 1. Prepare panel data
116151
y_wide, unit_info, time_periods, cohorts, n_units = _prepare_panel(
117152
data, y, g, t, i, x
@@ -652,3 +687,176 @@ def _pretrend_test(
652687
pvalue = float(1 - stats.chi2.cdf(W, k))
653688

654689
return {'statistic': W, 'df': k, 'pvalue': pvalue}
690+
691+
692+
# ======================================================================
693+
# Repeated cross-sections (panel=False) branch
694+
# ======================================================================
695+
696+
def _callaway_santanna_rcs(
697+
data: pd.DataFrame,
698+
y: str,
699+
g: str,
700+
t: str,
701+
base_period: str,
702+
anticipation: int,
703+
alpha: float,
704+
) -> CausalResult:
705+
"""Unconditional 2×2 cell-mean DID for repeated cross-sections.
706+
707+
For each (g, t) pair with base period b:
708+
709+
ATT(g, t) = (Ȳ_{g,t} - Ȳ_{g,b}) - (Ȳ_{c,t} - Ȳ_{c,b})
710+
711+
where c = never-treated cohort. Observation-level influence
712+
functions are assembled as
713+
714+
ψ_i = 1{G_i=g, T_i=t} (Y_i - Ȳ_{g,t}) / p_{g,t}
715+
- 1{G_i=g, T_i=b} (Y_i - Ȳ_{g,b}) / p_{g,b}
716+
- 1{G_i=c, T_i=t} (Y_i - Ȳ_{c,t}) / p_{c,t}
717+
+ 1{G_i=c, T_i=b} (Y_i - Ȳ_{c,b}) / p_{c,b}
718+
719+
with ``p_{g,t} = #{i: G_i=g, T_i=t} / n``. SE(ATT) is the sample
720+
variance of ψ divided by ``n``, matching CS2021 eqn (2.4) for RCS.
721+
"""
722+
df = data.copy()
723+
for col in (y, g, t):
724+
if col not in df.columns:
725+
raise ValueError(f"Column '{col}' not found in data")
726+
727+
df[g] = df[g].fillna(0).replace([np.inf, -np.inf], 0).astype(int)
728+
df = df.dropna(subset=[y, t]).reset_index(drop=True)
729+
n_obs = len(df)
730+
if n_obs == 0:
731+
raise ValueError("No observations after dropping NaNs.")
732+
733+
time_periods = sorted(df[t].unique())
734+
t_max = max(time_periods)
735+
cohorts = sorted([v for v in df[g].unique() if v > 0 and v <= t_max])
736+
if not cohorts:
737+
raise ValueError("No treatment cohorts found.")
738+
739+
gt_pairs = _get_gt_pairs(cohorts, time_periods, base_period, anticipation)
740+
if not gt_pairs:
741+
raise ValueError("No valid (group, time) pairs to estimate.")
742+
743+
y_arr = df[y].values.astype(float)
744+
g_arr = df[g].values
745+
t_arr = df[t].values
746+
747+
gt_results: List[Dict[str, Any]] = []
748+
inf_funcs_list: List[np.ndarray] = []
749+
z_crit = stats.norm.ppf(1 - alpha / 2)
750+
751+
for g_val, t_val, base_val in gt_pairs:
752+
att, se, inf_func = _estimate_single_att_rcs(
753+
y_arr, g_arr, t_arr,
754+
g_val=g_val, t_val=t_val, base_val=base_val,
755+
n_obs=n_obs,
756+
)
757+
pval = (float(2 * (1 - stats.norm.cdf(abs(att / se))))
758+
if se > 0 else 1.0)
759+
gt_results.append({
760+
'group': g_val,
761+
'time': t_val,
762+
'att': att,
763+
'se': se,
764+
'ci_lower': att - z_crit * se,
765+
'ci_upper': att + z_crit * se,
766+
'pvalue': pval,
767+
'relative_time': t_val - g_val,
768+
})
769+
inf_funcs_list.append(inf_func)
770+
771+
detail = pd.DataFrame(gt_results)
772+
inf_matrix = np.column_stack(inf_funcs_list) if inf_funcs_list else None
773+
774+
# Cohort sizes for aggregation weights — use observation counts.
775+
cohort_sizes = pd.Series(
776+
{g_val: int((g_arr == g_val).sum()) for g_val in cohorts}
777+
)
778+
779+
post_mask = detail['relative_time'] >= 0
780+
agg_est, agg_se, agg_pval, agg_ci = _aggregate_simple(
781+
detail[post_mask],
782+
inf_matrix[:, post_mask.values] if inf_matrix is not None else None,
783+
cohort_sizes, n_obs, alpha,
784+
)
785+
event_study = _aggregate_event_study(
786+
detail, inf_matrix, cohort_sizes, n_obs, alpha,
787+
)
788+
pretrend = _pretrend_test(detail, inf_matrix, n_obs)
789+
790+
model_info: Dict[str, Any] = {
791+
'estimator': 'REG (RCS)',
792+
'control_group': 'nevertreated',
793+
'base_period': base_period,
794+
'anticipation': anticipation,
795+
'panel': False,
796+
'n_units': n_obs, # treated as "n" for aggte bootstrap
797+
'n_obs': n_obs,
798+
'n_periods': len(time_periods),
799+
'n_cohorts': len(cohorts),
800+
'cohorts': cohorts,
801+
'event_study': event_study,
802+
'pretrend_test': pretrend,
803+
'cohort_sizes': cohort_sizes,
804+
}
805+
806+
return CausalResult(
807+
method="Callaway and Sant'Anna (2021) — repeated cross-sections",
808+
estimand='ATT',
809+
estimate=agg_est,
810+
se=agg_se,
811+
pvalue=agg_pval,
812+
ci=agg_ci,
813+
alpha=alpha,
814+
n_obs=n_obs,
815+
detail=detail,
816+
model_info=model_info,
817+
_influence_funcs=inf_matrix,
818+
_citation_key='callaway_santanna',
819+
)
820+
821+
822+
def _estimate_single_att_rcs(
823+
y_arr: np.ndarray,
824+
g_arr: np.ndarray,
825+
t_arr: np.ndarray,
826+
g_val: int,
827+
t_val: int,
828+
base_val: int,
829+
n_obs: int,
830+
) -> Tuple[float, float, np.ndarray]:
831+
"""Observation-level 2×2 cell-mean DID + influence function."""
832+
m_gt = (g_arr == g_val) & (t_arr == t_val)
833+
m_gb = (g_arr == g_val) & (t_arr == base_val)
834+
m_ct = (g_arr == 0) & (t_arr == t_val)
835+
m_cb = (g_arr == 0) & (t_arr == base_val)
836+
837+
# Any empty cell kills the estimator for this (g, t).
838+
for m in (m_gt, m_gb, m_ct, m_cb):
839+
if m.sum() < 2:
840+
return 0.0, np.inf, np.zeros(n_obs)
841+
842+
mu_gt = y_arr[m_gt].mean()
843+
mu_gb = y_arr[m_gb].mean()
844+
mu_ct = y_arr[m_ct].mean()
845+
mu_cb = y_arr[m_cb].mean()
846+
847+
att = float((mu_gt - mu_gb) - (mu_ct - mu_cb))
848+
849+
p_gt = m_gt.sum() / n_obs
850+
p_gb = m_gb.sum() / n_obs
851+
p_ct = m_ct.sum() / n_obs
852+
p_cb = m_cb.sum() / n_obs
853+
854+
inf = np.zeros(n_obs)
855+
inf[m_gt] += (y_arr[m_gt] - mu_gt) / p_gt
856+
inf[m_gb] += -(y_arr[m_gb] - mu_gb) / p_gb
857+
inf[m_ct] += -(y_arr[m_ct] - mu_ct) / p_ct
858+
inf[m_cb] += (y_arr[m_cb] - mu_cb) / p_cb
859+
860+
# SE from sample variance of the influence function.
861+
se = float(np.sqrt(np.mean(inf ** 2) / n_obs))
862+
return att, se, inf

0 commit comments

Comments
 (0)