Skip to content

Commit e4d0044

Browse files
authored
Merge pull request #108 from igerber/fix/plot-event-study-reference-period-normalization
Fix plot_event_study reference_period normalization
2 parents a4e0f93 + 710f2c1 commit e4d0044

4 files changed

Lines changed: 482 additions & 16 deletions

File tree

diff_diff/visualization.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ def plot_event_study(
7373
periods : list, optional
7474
List of periods to plot. If None, uses all periods from results.
7575
reference_period : any, optional
76-
The reference period (normalized to effect=0). Will be shown as a
77-
hollow marker. If None, tries to infer from results.
76+
The reference period to highlight. When explicitly provided, effects
77+
are normalized (ref effect subtracted) and ref SE is set to NaN.
78+
When None and auto-inferred from results, only hollow marker styling
79+
is applied (no normalization). If None, tries to infer from results.
7880
pre_periods : list, optional
7981
List of pre-treatment periods. Used for shading.
8082
post_periods : list, optional
@@ -151,8 +153,9 @@ def plot_event_study(
151153
trends holds. Large pre-treatment effects suggest the assumption may
152154
be violated.
153155
154-
2. **Reference period**: Usually the last pre-treatment period (t=-1),
155-
normalized to zero. This is the omitted category.
156+
2. **Reference period**: Usually the last pre-treatment period (t=-1).
157+
When explicitly specified via ``reference_period``, effects are normalized
158+
to zero at this period. When auto-inferred, shown with hollow marker only.
156159
157160
3. **Post-treatment periods**: The treatment effects of interest. These
158161
show how the outcome evolved after treatment.
@@ -170,10 +173,18 @@ def plot_event_study(
170173

171174
from scipy import stats as scipy_stats
172175

176+
# Track if reference_period was explicitly provided by user
177+
reference_period_explicit = reference_period is not None
178+
173179
# Extract data from results if provided
174180
if results is not None:
175-
effects, se, periods, pre_periods, post_periods, reference_period = \
176-
_extract_plot_data(results, periods, pre_periods, post_periods, reference_period)
181+
extracted = _extract_plot_data(
182+
results, periods, pre_periods, post_periods, reference_period
183+
)
184+
effects, se, periods, pre_periods, post_periods, reference_period, reference_inferred = extracted
185+
# If reference was inferred from results, it was NOT explicitly provided
186+
if reference_inferred:
187+
reference_period_explicit = False
177188
elif effects is None or se is None:
178189
raise ValueError(
179190
"Must provide either 'results' or both 'effects' and 'se'"
@@ -192,6 +203,19 @@ def plot_event_study(
192203
# Compute confidence intervals
193204
critical_value = scipy_stats.norm.ppf(1 - alpha / 2)
194205

206+
# Normalize effects to reference period ONLY if explicitly specified by user
207+
# Auto-inferred reference periods (from CallawaySantAnna) just get hollow marker styling,
208+
# NO normalization. This prevents unintended normalization when the reference period
209+
# isn't a true identifying constraint (e.g., CallawaySantAnna with base_period="varying").
210+
if (reference_period is not None and reference_period in effects and
211+
reference_period_explicit):
212+
ref_effect = effects[reference_period]
213+
if np.isfinite(ref_effect):
214+
effects = {p: e - ref_effect for p, e in effects.items()}
215+
# Set reference SE to NaN (it's now a constraint, not an estimate)
216+
# This follows fixest convention where the omitted category has no SE/CI
217+
se = {p: (np.nan if p == reference_period else s) for p, s in se.items()}
218+
195219
plot_data = []
196220
for period in periods:
197221
effect = effects.get(period, np.nan)
@@ -304,14 +328,17 @@ def _extract_plot_data(
304328
pre_periods: Optional[List[Any]],
305329
post_periods: Optional[List[Any]],
306330
reference_period: Optional[Any],
307-
) -> Tuple[Dict, Dict, List, List, List, Any]:
331+
) -> Tuple[Dict, Dict, List, List, List, Any, bool]:
308332
"""
309333
Extract plotting data from various result types.
310334
311335
Returns
312336
-------
313337
tuple
314-
(effects, se, periods, pre_periods, post_periods, reference_period)
338+
(effects, se, periods, pre_periods, post_periods, reference_period, reference_inferred)
339+
340+
reference_inferred is True if reference_period was auto-detected from results
341+
rather than explicitly provided by the user.
315342
"""
316343
# Handle DataFrame input
317344
if isinstance(results, pd.DataFrame):
@@ -328,7 +355,8 @@ def _extract_plot_data(
328355
if periods is None:
329356
periods = list(results['period'])
330357

331-
return effects, se, periods, pre_periods, post_periods, reference_period
358+
# DataFrame input: reference_period was already set by caller, never inferred here
359+
return effects, se, periods, pre_periods, post_periods, reference_period, False
332360

333361
# Handle MultiPeriodDiDResults
334362
if hasattr(results, 'period_effects'):
@@ -348,7 +376,8 @@ def _extract_plot_data(
348376
if periods is None:
349377
periods = post_periods
350378

351-
return effects, se, periods, pre_periods, post_periods, reference_period
379+
# MultiPeriodDiDResults: reference_period was already set by caller, never inferred here
380+
return effects, se, periods, pre_periods, post_periods, reference_period, False
352381

353382
# Handle CallawaySantAnnaResults (event study aggregation)
354383
if hasattr(results, 'event_study_effects') and results.event_study_effects is not None:
@@ -362,8 +391,12 @@ def _extract_plot_data(
362391
if periods is None:
363392
periods = sorted(effects.keys())
364393

394+
# Track if reference_period was explicitly provided vs auto-inferred
395+
reference_inferred = False
396+
365397
# Reference period is typically -1 for event study
366398
if reference_period is None:
399+
reference_inferred = True # We're about to infer it
367400
# Detect reference period from n_groups=0 marker (normalization constraint)
368401
# This handles anticipation > 0 where reference is at e = -1 - anticipation
369402
for period, effect_data in results.event_study_effects.items():
@@ -380,7 +413,7 @@ def _extract_plot_data(
380413
if post_periods is None:
381414
post_periods = [p for p in periods if p >= 0]
382415

383-
return effects, se, periods, pre_periods, post_periods, reference_period
416+
return effects, se, periods, pre_periods, post_periods, reference_period, reference_inferred
384417

385418
raise TypeError(
386419
f"Cannot extract plot data from {type(results).__name__}. "

docs/methodology/REGISTRY.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,52 @@ n = 2(t_{α/2} + t_{1-κ})² σ² / MDE²
728728

729729
---
730730

731+
# Visualization
732+
733+
## Event Study Plotting (`plot_event_study`)
734+
735+
**Reference Period Normalization**
736+
737+
Normalization only occurs when `reference_period` is **explicitly specified** by the user:
738+
739+
- **Explicit `reference_period=X`**: Normalizes effects (subtracts ref effect), sets ref SE to NaN
740+
- Point estimates: `effect_normalized = effect - effect_ref`
741+
- Reference period SE → NaN (it's now a constraint, not an estimate)
742+
- Other periods' SEs unchanged (uncertainty relative to the constraint)
743+
- CIs recomputed from normalized effects and original SEs
744+
745+
- **Auto-inferred reference** (from CallawaySantAnna results): Hollow marker styling only, no normalization
746+
- Original effects are plotted unchanged
747+
- Reference period shown with hollow marker for visual indication
748+
- All periods retain their original SEs and error bars
749+
750+
This design prevents unintended normalization when the reference period isn't a true
751+
identifying constraint (e.g., CallawaySantAnna with `base_period="varying"` where different
752+
cohorts use different comparison periods).
753+
754+
The explicit-only normalization follows the `fixest` (R) convention where the omitted/reference
755+
category is an identifying constraint with no associated uncertainty. Auto-inferred references
756+
follow the `did` (R) package convention which does not normalize and reports full inference.
757+
758+
**Rationale**: When normalizing to a reference period, we're treating that period as an
759+
identifying constraint (effect ≡ 0 by definition). The variance of a constant is zero,
760+
but since it's a constraint rather than an estimated quantity, we report NaN rather than 0.
761+
Auto-inferred references may not represent true identifying constraints, so normalization
762+
should be a deliberate user choice.
763+
764+
**Edge Cases:**
765+
- If `reference_period` not in data: No normalization applied
766+
- If reference effect is NaN: No normalization applied
767+
- Reference period CI becomes (NaN, NaN) after normalization (explicit only)
768+
- Reference period is plotted with hollow marker (both explicit and auto-inferred)
769+
- Reference period error bars: removed for explicit, retained for auto-inferred
770+
771+
**Reference implementation(s):**
772+
- R: `fixest::coefplot()` with reference category shown at 0 with no CI
773+
- R: `did::ggdid()` does not normalize; shows full inference for all periods
774+
775+
---
776+
731777
# Cross-Reference: Standard Errors Summary
732778

733779
| Estimator | Default SE | Alternatives |

0 commit comments

Comments
 (0)