You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
"5. Simulation-based power analysis for complex designs\n",
17
-
"6. Panel data considerations (ICC, multiple periods)"
18
-
]
7
+
"source": "# Power Analysis for Difference-in-Differences\n\nThis notebook demonstrates how to use the power analysis tools in `diff-diff` for study design. We'll cover:\n\n1. Computing minimum detectable effects (MDE)\n2. Calculating required sample sizes\n3. Estimating statistical power\n4. Creating power curves for visualization\n5. Panel data considerations (ICC, multiple periods)\n6. Simulation-based power analysis for complex designs\n7. Power analysis for any estimator (staggered, synthetic DiD, triple difference)\n8. Finding MDE via simulation (bisection search)\n9. Finding required sample size via simulation (bisection search)\n10. Custom data generators\n11. Convenience functions\n12. Practical recommendations"
19
8
},
20
9
{
21
10
"cell_type": "code",
22
11
"execution_count": null,
23
12
"id": "cell-1",
24
13
"metadata": {},
25
14
"outputs": [],
26
-
"source": [
27
-
"import numpy as np\n",
28
-
"import pandas as pd\n",
29
-
"import matplotlib.pyplot as plt\n",
30
-
"\n",
31
-
"from diff_diff import (\n",
32
-
" PowerAnalysis,\n",
33
-
" DifferenceInDifferences,\n",
34
-
" simulate_power,\n",
35
-
" compute_mde,\n",
36
-
" compute_power,\n",
37
-
" compute_sample_size,\n",
38
-
" plot_power_curve,\n",
39
-
")"
40
-
]
15
+
"source": "import numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\n\nfrom diff_diff import (\n PowerAnalysis,\n DifferenceInDifferences,\n CallawaySantAnna,\n SyntheticDiD,\n TripleDifference,\n simulate_power,\n simulate_mde,\n simulate_sample_size,\n compute_mde,\n compute_power,\n compute_sample_size,\n plot_power_curve,\n)"
41
16
},
42
17
{
43
18
"cell_type": "markdown",
@@ -475,15 +450,197 @@
475
450
")"
476
451
]
477
452
},
453
+
{
454
+
"cell_type": "markdown",
455
+
"id": "cjpvh2ze7lh",
456
+
"source": "## 8. Power Analysis for Any Estimator\n\nThe simulation-based approach works with **all 12 supported estimators** — not just basic DiD. An internal registry automatically selects the appropriate data-generating process (DGP) and fit signature for each registered estimator. Just swap in the estimator object and everything else is handled. See the support table below for the full list, and Section 11 for using custom DGPs with unsupported estimators.\n\n### Staggered Adoption Estimators",
457
+
"metadata": {}
458
+
},
459
+
{
460
+
"cell_type": "code",
461
+
"id": "la7ps3nxufq",
462
+
"source": "# Power analysis with Callaway-Sant'Anna — the registry auto-selects\n# generate_staggered_data as the DGP and the correct fit kwargs\ncs = CallawaySantAnna()\n\ncs_results = simulate_power(\n estimator=cs,\n n_units=100,\n n_periods=6,\n treatment_effect=5.0,\n treatment_fraction=0.5,\n treatment_period=3,\n sigma=5.0,\n n_simulations=100,\n seed=42,\n progress=False,\n)\n\nprint(cs_results.summary())",
463
+
"metadata": {},
464
+
"execution_count": null,
465
+
"outputs": []
466
+
},
467
+
{
468
+
"cell_type": "markdown",
469
+
"id": "q61cjchqjrd",
470
+
"source": "### Factor Model Estimators (Synthetic DiD)\n\nFor `SyntheticDiD` with the default placebo variance method, the DGP must generate **more control than treated units** (`treatment_fraction < 0.5`). The registry uses `generate_factor_data` automatically.",
471
+
"metadata": {}
472
+
},
473
+
{
474
+
"cell_type": "code",
475
+
"id": "x068rpe24gf",
476
+
"source": "# Synthetic DiD — note treatment_fraction=0.3 (placebo variance requires\n# more control units than treated units)\nsdid = SyntheticDiD()\n\nsdid_results = simulate_power(\n estimator=sdid,\n n_units=60,\n n_periods=6,\n treatment_effect=5.0,\n treatment_fraction=0.3,\n treatment_period=3,\n sigma=3.0,\n n_simulations=100,\n seed=42,\n progress=False,\n)\n\nprint(sdid_results.summary())",
477
+
"metadata": {},
478
+
"execution_count": null,
479
+
"outputs": []
480
+
},
481
+
{
482
+
"cell_type": "markdown",
483
+
"id": "6qpu05hi18s",
484
+
"source": "### Triple Difference\n\n`TripleDifference` uses a fixed 2×2×2 factorial design (group × partition × time). Sample sizes are **rounded via `n_per_cell = max(2, n_units // 8)`**, so the minimum effective N is 16 (2 units per cell × 8 cells). The `effective_n_units` field in results tracks any rounding. Note that `simulate_sample_size()` uses a higher search floor of 64 from the registry.",
485
+
"metadata": {}
486
+
},
487
+
{
488
+
"cell_type": "code",
489
+
"id": "91uackfwqp",
490
+
"source": "# Triple Difference — n_units snaps to multiples of 8\nddd = TripleDifference()\n\nddd_results = simulate_power(\n estimator=ddd,\n n_units=64,\n treatment_effect=3.0,\n sigma=2.0,\n n_simulations=100,\n seed=42,\n progress=False,\n)\n\nprint(ddd_results.summary())\nif ddd_results.effective_n_units is not None:\n print(f\"\\nEffective N (after grid rounding): {ddd_results.effective_n_units}\")",
491
+
"metadata": {},
492
+
"execution_count": null,
493
+
"outputs": []
494
+
},
495
+
{
496
+
"cell_type": "markdown",
497
+
"id": "6kb8ovmue4m",
498
+
"source": "### Supported Estimators\n\nThe following 12 estimators are supported by the simulation power analysis registry. Each is automatically paired with the correct data-generating process:\n\n| DGP Family | Estimators | Min N |\n|---|---|---|\n| **Basic DiD** (`generate_did_data`) | DifferenceInDifferences, TwoWayFixedEffects, MultiPeriodDiD | 20 |\n| **Staggered** (`generate_staggered_data`) | CallawaySantAnna, SunAbraham, ImputationDiD, TwoStageDiD, StackedDiD, EfficientDiD | 40 |\n| **Factor Model** (`generate_factor_data`) | TROP, SyntheticDiD | 30 |\n| **Triple Difference** (`generate_ddd_data`) | TripleDifference | 16* |\n\n\\* DDD effective N rounds to `max(2, n_units // 8) * 8` with minimum 16. `simulate_sample_size()` uses a higher search floor of 64.\n\n> **Note:** `ContinuousDiD` is not in the registry because continuous/dose-response treatments require a different DGP structure. `BaconDecomposition` and `HonestDiD` are diagnostic/sensitivity tools rather than treatment effect estimators. For unsupported estimators, you can pass a custom `data_generator` and `result_extractor` (see Section 11).",
499
+
"metadata": {}
500
+
},
501
+
{
502
+
"cell_type": "markdown",
503
+
"id": "c4erqwll1af",
504
+
"source": "### Power Curve for a Staggered Estimator",
505
+
"metadata": {}
506
+
},
507
+
{
508
+
"cell_type": "code",
509
+
"id": "ox3uab7h5bj",
510
+
"source": "# Power curve across effect sizes for Callaway-Sant'Anna\ncs_curve = simulate_power(\n estimator=CallawaySantAnna(),\n n_units=100,\n n_periods=6,\n effect_sizes=[1.0, 2.0, 3.0, 5.0, 7.0],\n treatment_period=3,\n sigma=5.0,\n n_simulations=100,\n seed=42,\n progress=False,\n)\n\nplot_power_curve(\n cs_curve.power_curve_df(),\n target_power=0.80,\n title=\"CS Power Curve (100 units, 6 periods, SD=5)\",\n figsize=(10, 6),\n)",
511
+
"metadata": {},
512
+
"execution_count": null,
513
+
"outputs": []
514
+
},
515
+
{
516
+
"cell_type": "markdown",
517
+
"id": "kqw6y4du5u",
518
+
"source": "## 9. Finding MDE via Simulation\n\nThe analytical `PowerAnalysis.mde()` works for basic DiD, but for complex estimators there is no closed-form formula. `simulate_mde()` uses **bisection search** to find the minimum detectable effect: it repeatedly calls `simulate_power()` at different effect sizes, narrowing the bracket until it finds the smallest effect that achieves the target power.",
519
+
"metadata": {}
520
+
},
521
+
{
522
+
"cell_type": "code",
523
+
"id": "p9a03aycu2",
524
+
"source": "# Find MDE for basic DiD via simulation\nmde_result = simulate_mde(\n DifferenceInDifferences(),\n n_units=100,\n n_periods=4,\n sigma=5.0,\n n_simulations=100,\n seed=42,\n progress=False,\n)\n\nprint(mde_result.summary())",
525
+
"metadata": {},
526
+
"execution_count": null,
527
+
"outputs": []
528
+
},
529
+
{
530
+
"cell_type": "markdown",
531
+
"id": "1mgrw7qmish",
532
+
"source": "### Inspecting the Search Path\n\nThe `search_path` attribute records the effect size and power at each bisection step, which is useful for diagnosing convergence:",
533
+
"metadata": {}
534
+
},
535
+
{
536
+
"cell_type": "code",
537
+
"id": "3p2tmxivi2g",
538
+
"source": "# search_path is a List[Dict] — convert to DataFrame for display\nsearch_df = pd.DataFrame(mde_result.search_path)\nprint(search_df.to_string(index=False))",
539
+
"metadata": {},
540
+
"execution_count": null,
541
+
"outputs": []
542
+
},
543
+
{
544
+
"cell_type": "markdown",
545
+
"id": "o4se5ofngjh",
546
+
"source": "### MDE for a Staggered Estimator\n\nThe same function works with any registered estimator:",
"source": "**Key parameters for `simulate_mde()`:**\n- `effect_range=(lo, hi)` — custom search bracket (auto-detected if omitted)\n- `tol` — convergence tolerance on power (default 0.02)\n- `max_steps` — maximum bisection steps (default 15)\n- `n_simulations` — simulations per step (use 500+ for production analyses)",
561
+
"metadata": {}
562
+
},
563
+
{
564
+
"cell_type": "markdown",
565
+
"id": "qrfhvtizg58",
566
+
"source": "## 10. Finding Required Sample Size via Simulation\n\n`simulate_sample_size()` uses bisection search over `n_units` to find the smallest sample size that achieves the target power for a given effect size.",
"source": "# View the bisection steps\nn_search_df = pd.DataFrame(n_result.search_path)\nprint(n_search_df.to_string(index=False))",
587
+
"metadata": {},
588
+
"execution_count": null,
589
+
"outputs": []
590
+
},
591
+
{
592
+
"cell_type": "markdown",
593
+
"id": "dblnwe076kf",
594
+
"source": "### Comparing Analytical and Simulation Results\n\nFor basic DiD, we can compare the simulation result against the analytical formula. With only 100 simulations per bisection step there will be Monte Carlo noise, so we expect **approximate** — not exact — agreement:",
595
+
"metadata": {}
596
+
},
597
+
{
598
+
"cell_type": "code",
599
+
"id": "bgpin5xmsud",
600
+
"source": "# Analytical sample size\nanalytical = pa.sample_size(effect_size=5.0, sigma=5.0)\n\nprint(f\"{'Method':<25} {'Required N':>12}\")\nprint(\"-\" * 40)\nprint(f\"{'Analytical:':<25} {analytical.required_n:>12}\")\nprint(f\"{'Simulation:':<25} {n_result.required_n:>12}\")\nprint(f\"\\nSimulation power at N: {n_result.power_at_n:.1%}\")",
601
+
"metadata": {},
602
+
"execution_count": null,
603
+
"outputs": []
604
+
},
605
+
{
606
+
"cell_type": "markdown",
607
+
"id": "rnte1h09hra",
608
+
"source": "**Key parameters for `simulate_sample_size()`:**\n- `n_range=(lo, hi)` — custom search bracket for sample size (auto-detected if omitted)\n- `max_steps` — maximum bisection steps (default 15)\n- `n_simulations` — simulations per step (use 500+ for production analyses)",
609
+
"metadata": {}
610
+
},
611
+
{
612
+
"cell_type": "markdown",
613
+
"id": "usp5iwyacop",
614
+
"source": "## 11. Custom Data Generators\n\nThe default DGPs cover common designs, but you can customize them in two ways:\n1. **Tweak the default DGP** with `data_generator_kwargs` (e.g., add multiple treatment cohorts)\n2. **Supply a fully custom DGP** with `data_generator`\n\n### Tweaking the Default DGP\n\nPass additional keyword arguments to the registry's DGP via `data_generator_kwargs`. For example, the default staggered DGP generates a single treatment cohort — here we create a multi-cohort design:\n\n> **Note:** Some keys are *protected* and cannot be overridden via `data_generator_kwargs` because they are controlled by the simulation function itself: `treatment_effect`, `noise_sd`, `n_units`, `n_periods`, `treatment_fraction`, `treatment_period`, `n_pre`, `n_post`.",
615
+
"metadata": {}
616
+
},
617
+
{
618
+
"cell_type": "code",
619
+
"id": "igft0epkiic",
620
+
"source": "# Multi-cohort staggered design: treatment starts at periods 2 and 4\ncs_multi = simulate_power(\n estimator=CallawaySantAnna(),\n n_units=120,\n n_periods=6,\n treatment_effect=5.0,\n sigma=5.0,\n n_simulations=100,\n seed=42,\n progress=False,\n data_generator_kwargs={\n\"cohort_periods\": [2, 4],\n\"never_treated_frac\": 0.3,\n },\n)\n\nprint(cs_multi.summary())",
621
+
"metadata": {},
622
+
"execution_count": null,
623
+
"outputs": []
624
+
},
625
+
{
626
+
"cell_type": "markdown",
627
+
"id": "hznafqrhzoq",
628
+
"source": "### Fully Custom Data Generator\n\nFor designs not covered by the built-in DGPs, supply your own `data_generator` function. It receives the standard simulation parameters and must return a DataFrame. You may also need a custom `result_extractor` if your estimator returns non-standard results, and `estimator_kwargs` to pass the right column names to `fit()`.",
629
+
"metadata": {}
630
+
},
631
+
{
632
+
"cell_type": "code",
633
+
"id": "v06p7ubbj9p",
634
+
"source": "def my_dgp(n_units, n_periods, treatment_effect, treatment_fraction,\n treatment_period, noise_sd, seed=None):\n \"\"\"Custom DGP with heterogeneous unit effects.\"\"\"\n rng = np.random.default_rng(seed)\n n_treat = int(n_units * treatment_fraction)\n\n rows = []\n for i in range(n_units):\n unit_fe = rng.normal(0, 3) # heterogeneous unit effect\n treated_unit = i < n_treat\n for t in range(n_periods):\n post = int(t >= treatment_period)\n effect = treatment_effect * post if treated_unit else 0.0\n y = unit_fe + 2.0 * t + effect + rng.normal(0, noise_sd)\n rows.append({\n \"unit\": i, \"period\": t, \"outcome\": y,\n \"ever_treated\": int(treated_unit), \"post\": post,\n })\n return pd.DataFrame(rows)\n\n# Use the custom DGP with simulate_power\ncustom_results = simulate_power(\n estimator=DifferenceInDifferences(),\n n_units=80,\n n_periods=4,\n treatment_effect=4.0,\n sigma=3.0,\n n_simulations=100,\n seed=42,\n progress=False,\n data_generator=my_dgp,\n estimator_kwargs={\"outcome\": \"outcome\", \"treatment\": \"ever_treated\", \"time\": \"post\"},\n)\n\nprint(custom_results.summary())",
635
+
"metadata": {},
636
+
"execution_count": null,
637
+
"outputs": []
638
+
},
478
639
{
479
640
"cell_type": "markdown",
480
641
"id": "cell-28",
481
642
"metadata": {},
482
-
"source": [
483
-
"## 8. Convenience Functions\n",
484
-
"\n",
485
-
"For quick calculations, use the convenience functions:"
486
-
]
643
+
"source": "## 12. Convenience Functions\n\nFor quick calculations, use the convenience functions:"
487
644
},
488
645
{
489
646
"cell_type": "code",
@@ -509,18 +666,7 @@
509
666
"cell_type": "markdown",
510
667
"id": "cell-30",
511
668
"metadata": {},
512
-
"source": [
513
-
"## 9. Practical Recommendations\n",
514
-
"\n",
515
-
"### Estimating Sigma (Residual SD)\n",
516
-
"\n",
517
-
"The residual standard deviation is crucial for power calculations. Options:\n",
518
-
"\n",
519
-
"1. **Pilot data**: Fit a model on historical data and get residual SD\n",
520
-
"2. **Literature**: Find similar studies and use their reported SDs\n",
521
-
"3. **Domain knowledge**: Expert judgment about outcome variability\n",
522
-
"4. **Sensitivity analysis**: Calculate power for a range of sigma values"
523
-
]
669
+
"source": "## 13. Practical Recommendations\n\n### Estimating Sigma (Residual SD)\n\nThe residual standard deviation is crucial for power calculations. Options:\n\n1. **Pilot data**: Fit a model on historical data and get residual SD\n2. **Literature**: Find similar studies and use their reported SDs\n3. **Domain knowledge**: Expert judgment about outcome variability\n4. **Sensitivity analysis**: Calculate power for a range of sigma values"
"source": "### Analytical vs. Simulation: When to Use Each\n\n| Approach | Best for | Advantages |\n|---|---|---|\n| **Analytical** (`PowerAnalysis`) | Basic 2×2 DiD, panel DiD | Fast, exact, closed-form |\n| **Simulation** (`simulate_power/mde/sample_size`) | Staggered, SDID, TROP, DDD, custom designs | Works with any estimator, reports bias/RMSE/coverage |\n\n**Rule of thumb:** Start with analytical power analysis for basic designs. Move to simulation when using specialized estimators or non-standard DGPs.",
722
+
"metadata": {}
723
+
},
572
724
{
573
725
"cell_type": "markdown",
574
726
"id": "cell-34",
575
727
"metadata": {},
576
-
"source": [
577
-
"## Summary\n",
578
-
"\n",
579
-
"Key takeaways for DiD power analysis:\n",
580
-
"\n",
581
-
"1. **Always do a power analysis** before running a study\n",
582
-
"2. **MDE decreases** with sample size, more periods, and lower variance\n",
583
-
"3. **ICC matters** for panel data - high autocorrelation reduces effective sample size\n",
584
-
"4. **Use simulation** for complex designs (staggered, synthetic DiD)\n",
585
-
"5. **Be realistic about sigma** - err on the side of larger values\n",
586
-
"6. **Consider your smallest meaningful effect** - don't just target statistical significance\n",
587
-
"\n",
588
-
"For more on DiD estimation, see the other tutorials:\n",
589
-
"- `01_basic_did.ipynb` - Basic DiD estimation\n",
"- `07_pretrends_power.ipynb` - Pre-trends power analysis (Roth 2022)"
595
-
]
728
+
"source": "## Summary\n\nKey takeaways for DiD power analysis:\n\n1. **Always do a power analysis** before running a study\n2. **MDE decreases** with sample size, more periods, and lower variance\n3. **ICC matters** for panel data — high autocorrelation reduces effective sample size\n4. **Use simulation** for complex designs (staggered, synthetic DiD, triple difference)\n5. **12 estimators are supported** out of the box via the auto-registry — just swap in the estimator\n6. **`simulate_mde()` and `simulate_sample_size()`** extend MDE and sample size calculations to any estimator via bisection search\n7. **Custom DGPs** let you model non-standard designs with `data_generator` and `data_generator_kwargs`\n8. **Be realistic about sigma** — err on the side of larger values\n9. **Consider your smallest meaningful effect** — don't just target statistical significance\n\nFor more on DiD estimation, see the other tutorials:\n- `01_basic_did.ipynb` — Basic DiD estimation\n- `02_staggered_did.ipynb` — Staggered adoption designs\n- `03_synthetic_did.ipynb` — Synthetic DiD\n- `04_parallel_trends.ipynb` — Testing assumptions\n- `05_honest_did.ipynb` — Sensitivity analysis\n- `07_pretrends_power.ipynb` — Pre-trends power analysis (Roth 2022)"
0 commit comments