@@ -1100,3 +1100,198 @@ def _did_2x2_simple(df_sub, unit_col, time_col, y_col, g1_units, g2_units):
11001100 model_info = model_info ,
11011101 _citation_key = "twfe_decomposition" ,
11021102 )
1103+
1104+
1105+ # ═══════════════════════════════════════════════════════════════════════
1106+ # 4. etwfe_emfx — R etwfe-style marginal-effects aggregations
1107+ # ═══════════════════════════════════════════════════════════════════════
1108+
1109+ def etwfe_emfx (
1110+ result : CausalResult ,
1111+ type : str = "simple" ,
1112+ alpha : float = 0.05 ,
1113+ ) -> CausalResult :
1114+ """
1115+ R ``etwfe::emfx``-style aggregated marginal effects for an ETWFE fit.
1116+
1117+ Takes the result of :func:`etwfe` / :func:`wooldridge_did` and returns
1118+ one of four aggregations used in applied work:
1119+
1120+ ================ ========================================================
1121+ ``type`` Aggregation
1122+ ================ ========================================================
1123+ ``'simple'`` Overall cohort-size-weighted ATT (same as ``result.estimate``).
1124+ ``'group'`` ATT per treatment cohort ``g``.
1125+ ``'event'`` ATT per event time ``e = t - g``, averaged across cohorts.
1126+ ``'calendar'`` ATT per calendar time ``t``, averaged across cohorts for
1127+ which ``t >= g``.
1128+ ================ ========================================================
1129+
1130+ Parameters
1131+ ----------
1132+ result : CausalResult
1133+ Output of :func:`etwfe` or :func:`wooldridge_did`.
1134+ type : {'simple', 'group', 'event', 'calendar'}, default 'simple'
1135+ Aggregation type.
1136+ alpha : float, default 0.05
1137+ Significance level for confidence intervals.
1138+
1139+ Returns
1140+ -------
1141+ CausalResult
1142+ ``estimate`` is the overall ATT (for ``type='simple'``) or the
1143+ mean of the sub-aggregation (for the other types). ``detail``
1144+ contains one row per group/event-time/calendar-time with
1145+ (estimate, se, pvalue, ci_low, ci_high).
1146+
1147+ Notes
1148+ -----
1149+ For ``'event'`` and ``'calendar'``, the reported SE treats the
1150+ per-cohort coefficients as independent — a standard approximation
1151+ that matches R etwfe's default under classical vcov. Cluster-robust
1152+ or fully-general SEs require the full regression vcov, which can
1153+ be requested via ``sp.wooldridge_did`` + the ``model_info`` matrix
1154+ in a future release.
1155+
1156+ Examples
1157+ --------
1158+ >>> import statspai as sp
1159+ >>> df = sp.dgp_did(n_units=200, n_periods=10, staggered=True)
1160+ >>> fit = sp.etwfe(df, y='y', time='time',
1161+ ... first_treat='first_treat', group='unit')
1162+ >>> evt = sp.etwfe_emfx(fit, type='event')
1163+ >>> print(evt.detail) # ATT by event time
1164+ >>> grp = sp.etwfe_emfx(fit, type='group')
1165+ >>> cal = sp.etwfe_emfx(fit, type='calendar')
1166+ """
1167+ valid = {"simple" , "group" , "event" , "calendar" }
1168+ if type not in valid :
1169+ raise ValueError (f"type must be one of { sorted (valid )} ; got { type !r} " )
1170+
1171+ if not isinstance (result .model_info , dict ) or "cohorts" not in result .model_info :
1172+ raise ValueError (
1173+ "etwfe_emfx requires a result produced by sp.etwfe / "
1174+ "sp.wooldridge_did — missing 'cohorts' in model_info."
1175+ )
1176+
1177+ mi = result .model_info
1178+ cohorts = mi ["cohorts" ]
1179+ cohort_weights = mi .get ("cohort_weights" , {})
1180+ event_study = mi .get ("event_study" )
1181+
1182+ # ── simple ──
1183+ if type == "simple" :
1184+ # Just return a tidy one-row result
1185+ df_resid = max (result .n_obs - len (cohorts ), 1 )
1186+ t_crit = stats .t .ppf (1 - alpha / 2 , df_resid )
1187+ est = float (result .estimate )
1188+ se = float (result .se )
1189+ ci = (est - t_crit * se , est + t_crit * se )
1190+ detail = pd .DataFrame ([{
1191+ "aggregation" : "simple" ,
1192+ "estimate" : est , "se" : se ,
1193+ "pvalue" : float (result .pvalue ) if result .pvalue is not None else np .nan ,
1194+ "ci_low" : ci [0 ], "ci_high" : ci [1 ],
1195+ "n_cohorts" : len (cohorts ),
1196+ }])
1197+ return CausalResult (
1198+ method = "ETWFE — simple aggregation (overall ATT)" ,
1199+ estimand = "Overall ATT" ,
1200+ estimate = est , se = se ,
1201+ pvalue = float (result .pvalue ) if result .pvalue is not None else np .nan ,
1202+ ci = ci , alpha = alpha , n_obs = int (result .n_obs ),
1203+ detail = detail ,
1204+ model_info = {"type" : "simple" , "source_method" : result .method },
1205+ _citation_key = "wooldridge_twfe" ,
1206+ )
1207+
1208+ # ── group ──
1209+ if type == "group" :
1210+ # Already in result.detail — normalise columns and add CI/pvalue
1211+ det = result .detail .copy ()
1212+ df_resid = max (result .n_obs - len (cohorts ), 1 )
1213+ t_crit = stats .t .ppf (1 - alpha / 2 , df_resid )
1214+ # Handle both plain etwfe (columns att, se) and xvar etwfe
1215+ if "att_at_xmean" in det .columns :
1216+ est_col = "att_at_xmean" ; se_col = "att_se" ; p_col = "att_pvalue"
1217+ else :
1218+ est_col = "att" ; se_col = "se" ; p_col = "pvalue"
1219+ rows = []
1220+ for _ , r in det .iterrows ():
1221+ est = float (r [est_col ]); se = float (r [se_col ])
1222+ rows .append ({
1223+ "cohort" : int (r ["cohort" ]),
1224+ "estimate" : est , "se" : se ,
1225+ "pvalue" : float (r [p_col ]) if p_col in r .index else np .nan ,
1226+ "ci_low" : est - t_crit * se ,
1227+ "ci_high" : est + t_crit * se ,
1228+ "n_obs" : int (r ["n_obs" ]),
1229+ })
1230+ out_det = pd .DataFrame (rows )
1231+ mean_est = float (np .average (out_det ["estimate" ],
1232+ weights = out_det ["n_obs" ]))
1233+ return CausalResult (
1234+ method = "ETWFE — group aggregation (ATT per cohort)" ,
1235+ estimand = "ATT(g) per cohort" ,
1236+ estimate = mean_est , se = np .nan ,
1237+ pvalue = np .nan , ci = (np .nan , np .nan ), alpha = alpha ,
1238+ n_obs = int (result .n_obs ), detail = out_det ,
1239+ model_info = {"type" : "group" , "source_method" : result .method },
1240+ _citation_key = "wooldridge_twfe" ,
1241+ )
1242+
1243+ # ── event / calendar ──
1244+ if event_study is None or len (event_study ) == 0 :
1245+ raise ValueError (
1246+ "type='event'/'calendar' requires event_study coefficients "
1247+ "in result.model_info['event_study']."
1248+ )
1249+ es = event_study .copy ()
1250+ # cohort weights for averaging (use n_obs per cohort from result.detail)
1251+ det = result .detail
1252+ weight_by_cohort = {}
1253+ if "n_obs" in det .columns :
1254+ weight_by_cohort = dict (zip (det ["cohort" ].astype (int ),
1255+ det ["n_obs" ].astype (float )))
1256+
1257+ df_resid = max (result .n_obs - len (cohorts ), 1 )
1258+ t_crit = stats .t .ppf (1 - alpha / 2 , df_resid )
1259+
1260+ if type == "event" :
1261+ key_col = "rel_time"
1262+ label_col = "event_time"
1263+ else : # calendar
1264+ es ["calendar_time" ] = es ["cohort" ].astype (int ) + es ["rel_time" ].astype (int )
1265+ key_col = "calendar_time"
1266+ label_col = "calendar_time"
1267+
1268+ rows = []
1269+ for k , sub in es .groupby (key_col ):
1270+ # cohort-weighted average estimate; SE assumes independence
1271+ w = np .array ([weight_by_cohort .get (int (c ), 1.0 )
1272+ for c in sub ["cohort" ].values ])
1273+ w = w / w .sum () if w .sum () > 0 else np .ones (len (w )) / len (w )
1274+ est = float (np .sum (w * sub ["estimate" ].values ))
1275+ se = float (np .sqrt (np .sum ((w * sub ["se" ].values ) ** 2 )))
1276+ t_stat = est / se if se > 0 else np .nan
1277+ p = float (2 * (1 - stats .t .cdf (abs (t_stat ), df_resid ))) if not np .isnan (t_stat ) else np .nan
1278+ rows .append ({
1279+ label_col : int (k ),
1280+ "estimate" : est , "se" : se , "pvalue" : p ,
1281+ "ci_low" : est - t_crit * se ,
1282+ "ci_high" : est + t_crit * se ,
1283+ "n_cohorts_used" : int (len (sub )),
1284+ })
1285+ out_det = pd .DataFrame (rows ).sort_values (label_col ).reset_index (drop = True )
1286+ mean_est = float (out_det ["estimate" ].mean ())
1287+
1288+ return CausalResult (
1289+ method = f"ETWFE — { type } aggregation" ,
1290+ estimand = f"ATT by { label_col .replace ('_' , ' ' )} " ,
1291+ estimate = mean_est , se = np .nan ,
1292+ pvalue = np .nan , ci = (np .nan , np .nan ), alpha = alpha ,
1293+ n_obs = int (result .n_obs ), detail = out_det ,
1294+ model_info = {"type" : type , "source_method" : result .method ,
1295+ "se_assumption" : "independent per-cohort coefficients" },
1296+ _citation_key = "wooldridge_twfe" ,
1297+ )
0 commit comments