diff-diff 2.0.0__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diff_diff-2.0.0 → diff_diff-2.0.2}/PKG-INFO +1 -1
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/__init__.py +1 -1
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/bacon.py +12 -60
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/estimators.py +9 -8
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/staggered.py +157 -6
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/sun_abraham.py +2 -22
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/twfe.py +2 -18
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/utils.py +137 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/pyproject.toml +1 -1
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/Cargo.lock +14 -14
- {diff_diff-2.0.0 → diff_diff-2.0.2}/README.md +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/_backend.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/diagnostics.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/honest_did.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/linalg.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/power.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/prep.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/pretrends.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/results.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/triple_diff.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/visualization.py +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/Cargo.toml +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/bootstrap.rs +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/lib.rs +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/linalg.rs +0 -0
- {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/weights.rs +0 -0
|
@@ -17,6 +17,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
17
17
|
import numpy as np
|
|
18
18
|
import pandas as pd
|
|
19
19
|
|
|
20
|
+
from diff_diff.utils import within_transform as _within_transform_util
|
|
21
|
+
|
|
20
22
|
|
|
21
23
|
@dataclass
|
|
22
24
|
class Comparison2x2:
|
|
@@ -573,66 +575,16 @@ class BaconDecomposition:
|
|
|
573
575
|
treat_col: str = '__bacon_treated_internal__',
|
|
574
576
|
) -> float:
|
|
575
577
|
"""Compute TWFE estimate using within-transformation."""
|
|
576
|
-
#
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
unit_idx = np.array([unit_map[u] for u in units])
|
|
587
|
-
n_units = len(unit_map)
|
|
588
|
-
|
|
589
|
-
# Time means
|
|
590
|
-
time_map = {t: i for i, t in enumerate(df[time].unique())}
|
|
591
|
-
time_idx = np.array([time_map[t] for t in times])
|
|
592
|
-
n_times = len(time_map)
|
|
593
|
-
|
|
594
|
-
# Compute means
|
|
595
|
-
y_unit_mean = np.zeros(n_units)
|
|
596
|
-
d_unit_mean = np.zeros(n_units)
|
|
597
|
-
unit_counts = np.zeros(n_units)
|
|
598
|
-
|
|
599
|
-
for i in range(len(y)):
|
|
600
|
-
u = unit_idx[i]
|
|
601
|
-
y_unit_mean[u] += y[i]
|
|
602
|
-
d_unit_mean[u] += d[i]
|
|
603
|
-
unit_counts[u] += 1
|
|
604
|
-
|
|
605
|
-
y_unit_mean /= np.maximum(unit_counts, 1)
|
|
606
|
-
d_unit_mean /= np.maximum(unit_counts, 1)
|
|
607
|
-
|
|
608
|
-
y_time_mean = np.zeros(n_times)
|
|
609
|
-
d_time_mean = np.zeros(n_times)
|
|
610
|
-
time_counts = np.zeros(n_times)
|
|
611
|
-
|
|
612
|
-
for i in range(len(y)):
|
|
613
|
-
t = time_idx[i]
|
|
614
|
-
y_time_mean[t] += y[i]
|
|
615
|
-
d_time_mean[t] += d[i]
|
|
616
|
-
time_counts[t] += 1
|
|
617
|
-
|
|
618
|
-
y_time_mean /= np.maximum(time_counts, 1)
|
|
619
|
-
d_time_mean /= np.maximum(time_counts, 1)
|
|
620
|
-
|
|
621
|
-
# Overall mean
|
|
622
|
-
y_mean = np.mean(y)
|
|
623
|
-
d_mean = np.mean(d)
|
|
624
|
-
|
|
625
|
-
# Within transformation: y_it - y_i - y_t + y
|
|
626
|
-
y_within = np.zeros(len(y))
|
|
627
|
-
d_within = np.zeros(len(d))
|
|
628
|
-
|
|
629
|
-
for i in range(len(y)):
|
|
630
|
-
u = unit_idx[i]
|
|
631
|
-
t = time_idx[i]
|
|
632
|
-
y_within[i] = y[i] - y_unit_mean[u] - y_time_mean[t] + y_mean
|
|
633
|
-
d_within[i] = d[i] - d_unit_mean[u] - d_time_mean[t] + d_mean
|
|
634
|
-
|
|
635
|
-
# OLS on demeaned data
|
|
578
|
+
# Apply two-way within transformation
|
|
579
|
+
df_dm = _within_transform_util(
|
|
580
|
+
df, [outcome, treat_col], unit, time, suffix="_within"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# Extract within-transformed values
|
|
584
|
+
y_within = df_dm[f"{outcome}_within"].values
|
|
585
|
+
d_within = df_dm[f"{treat_col}_within"].values
|
|
586
|
+
|
|
587
|
+
# OLS on demeaned data: beta = sum(d * y) / sum(d^2)
|
|
636
588
|
d_var = np.sum(d_within ** 2)
|
|
637
589
|
if d_var > 0:
|
|
638
590
|
beta = np.sum(d_within * y_within) / d_var
|
|
@@ -23,6 +23,7 @@ from diff_diff.utils import (
|
|
|
23
23
|
WildBootstrapResults,
|
|
24
24
|
compute_confidence_interval,
|
|
25
25
|
compute_p_value,
|
|
26
|
+
demean_by_group,
|
|
26
27
|
validate_binary,
|
|
27
28
|
wild_bootstrap_se,
|
|
28
29
|
)
|
|
@@ -227,10 +228,10 @@ class DifferenceInDifferences:
|
|
|
227
228
|
# unit-invariant, so demeaning them would create multicollinearity
|
|
228
229
|
vars_to_demean = [outcome] + (covariates or [])
|
|
229
230
|
for ab_var in absorb:
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
working_data, n_fe = demean_by_group(
|
|
232
|
+
working_data, vars_to_demean, ab_var, inplace=True
|
|
233
|
+
)
|
|
234
|
+
n_absorbed_effects += n_fe
|
|
234
235
|
absorbed_vars.append(ab_var)
|
|
235
236
|
|
|
236
237
|
# Extract variables (may be demeaned if absorb was used)
|
|
@@ -828,10 +829,10 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
828
829
|
if absorb:
|
|
829
830
|
vars_to_demean = [outcome] + (covariates or [])
|
|
830
831
|
for ab_var in absorb:
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
832
|
+
working_data, n_fe = demean_by_group(
|
|
833
|
+
working_data, vars_to_demean, ab_var, inplace=True
|
|
834
|
+
)
|
|
835
|
+
n_absorbed_effects += n_fe
|
|
835
836
|
|
|
836
837
|
# Extract outcome and treatment
|
|
837
838
|
y = working_data[outcome].values.astype(float)
|
|
@@ -1479,29 +1479,36 @@ class CallawaySantAnna:
|
|
|
1479
1479
|
|
|
1480
1480
|
Standard errors are computed using influence function aggregation,
|
|
1481
1481
|
which properly accounts for covariances across (g,t) pairs due to
|
|
1482
|
-
shared control units. This
|
|
1482
|
+
shared control units. This includes the wif (weight influence function)
|
|
1483
|
+
adjustment from R's `did` package that accounts for uncertainty in
|
|
1484
|
+
estimating the group-size weights.
|
|
1483
1485
|
"""
|
|
1484
1486
|
effects = []
|
|
1485
1487
|
weights_list = []
|
|
1486
1488
|
gt_pairs = []
|
|
1489
|
+
groups_for_gt = []
|
|
1487
1490
|
|
|
1488
1491
|
for (g, t), data in group_time_effects.items():
|
|
1489
1492
|
effects.append(data['effect'])
|
|
1490
1493
|
weights_list.append(data['n_treated'])
|
|
1491
1494
|
gt_pairs.append((g, t))
|
|
1495
|
+
groups_for_gt.append(g)
|
|
1492
1496
|
|
|
1493
1497
|
effects = np.array(effects)
|
|
1494
1498
|
weights = np.array(weights_list, dtype=float)
|
|
1499
|
+
groups_for_gt = np.array(groups_for_gt)
|
|
1495
1500
|
|
|
1496
1501
|
# Normalize weights
|
|
1497
|
-
|
|
1502
|
+
total_weight = np.sum(weights)
|
|
1503
|
+
weights_norm = weights / total_weight
|
|
1498
1504
|
|
|
1499
1505
|
# Weighted average
|
|
1500
|
-
overall_att = np.sum(
|
|
1506
|
+
overall_att = np.sum(weights_norm * effects)
|
|
1501
1507
|
|
|
1502
|
-
# Compute SE using influence function aggregation
|
|
1503
|
-
overall_se = self.
|
|
1504
|
-
gt_pairs,
|
|
1508
|
+
# Compute SE using influence function aggregation with wif adjustment
|
|
1509
|
+
overall_se = self._compute_aggregated_se_with_wif(
|
|
1510
|
+
gt_pairs, weights_norm, effects, groups_for_gt,
|
|
1511
|
+
influence_func_info, df, unit
|
|
1505
1512
|
)
|
|
1506
1513
|
|
|
1507
1514
|
return overall_att, overall_se
|
|
@@ -1566,6 +1573,150 @@ class CallawaySantAnna:
|
|
|
1566
1573
|
variance = np.sum(psi_overall ** 2)
|
|
1567
1574
|
return np.sqrt(variance)
|
|
1568
1575
|
|
|
1576
|
+
def _compute_aggregated_se_with_wif(
|
|
1577
|
+
self,
|
|
1578
|
+
gt_pairs: List[Tuple[Any, Any]],
|
|
1579
|
+
weights: np.ndarray,
|
|
1580
|
+
effects: np.ndarray,
|
|
1581
|
+
groups_for_gt: np.ndarray,
|
|
1582
|
+
influence_func_info: Dict,
|
|
1583
|
+
df: pd.DataFrame,
|
|
1584
|
+
unit: str,
|
|
1585
|
+
) -> float:
|
|
1586
|
+
"""
|
|
1587
|
+
Compute SE with weight influence function (wif) adjustment.
|
|
1588
|
+
|
|
1589
|
+
This matches R's `did` package approach for "simple" aggregation,
|
|
1590
|
+
which accounts for uncertainty in estimating group-size weights.
|
|
1591
|
+
|
|
1592
|
+
The wif adjustment adds variance due to the fact that aggregation
|
|
1593
|
+
weights w_g = n_g / N depend on estimated group sizes.
|
|
1594
|
+
|
|
1595
|
+
Formula (matching R's did::aggte):
|
|
1596
|
+
agg_inf_i = Σ_k w_k × inf_i_k + wif_i × ATT_k
|
|
1597
|
+
se = sqrt(mean(agg_inf^2) / n)
|
|
1598
|
+
|
|
1599
|
+
where:
|
|
1600
|
+
- k indexes "keepers" (post-treatment (g,t) pairs)
|
|
1601
|
+
- w_k = pg[k] / sum(pg[keepers]) where pg = n_g / n_all
|
|
1602
|
+
- wif captures how unit i influences the weight estimation
|
|
1603
|
+
"""
|
|
1604
|
+
if not influence_func_info:
|
|
1605
|
+
return 0.0
|
|
1606
|
+
|
|
1607
|
+
# Build unit index mapping
|
|
1608
|
+
all_units = set()
|
|
1609
|
+
for (g, t) in gt_pairs:
|
|
1610
|
+
if (g, t) in influence_func_info:
|
|
1611
|
+
info = influence_func_info[(g, t)]
|
|
1612
|
+
all_units.update(info['treated_units'])
|
|
1613
|
+
all_units.update(info['control_units'])
|
|
1614
|
+
|
|
1615
|
+
if not all_units:
|
|
1616
|
+
return 0.0
|
|
1617
|
+
|
|
1618
|
+
all_units = sorted(all_units)
|
|
1619
|
+
n_units = len(all_units)
|
|
1620
|
+
unit_to_idx = {u: i for i, u in enumerate(all_units)}
|
|
1621
|
+
|
|
1622
|
+
# Get unique groups and their information
|
|
1623
|
+
unique_groups = sorted(set(groups_for_gt))
|
|
1624
|
+
group_to_idx = {g: i for i, g in enumerate(unique_groups)}
|
|
1625
|
+
|
|
1626
|
+
# Compute group-level probabilities matching R's formula:
|
|
1627
|
+
# pg[g] = n_g / n_all (fraction of ALL units in group g)
|
|
1628
|
+
# This differs from our old formula which used n_g / total_treated
|
|
1629
|
+
group_sizes = {}
|
|
1630
|
+
for g in unique_groups:
|
|
1631
|
+
treated_in_g = df[df['first_treat'] == g][unit].nunique()
|
|
1632
|
+
group_sizes[g] = treated_in_g
|
|
1633
|
+
|
|
1634
|
+
# pg indexed by group
|
|
1635
|
+
pg_by_group = np.array([group_sizes[g] / n_units for g in unique_groups])
|
|
1636
|
+
|
|
1637
|
+
# pg indexed by keeper (each (g,t) pair gets its group's pg)
|
|
1638
|
+
# This matches R's: pg <- pgg[match(group, originalglist)]
|
|
1639
|
+
pg_keepers = np.array([pg_by_group[group_to_idx[g]] for g in groups_for_gt])
|
|
1640
|
+
sum_pg_keepers = np.sum(pg_keepers)
|
|
1641
|
+
|
|
1642
|
+
# Standard aggregated influence (without wif)
|
|
1643
|
+
psi_standard = np.zeros(n_units)
|
|
1644
|
+
|
|
1645
|
+
for j, (g, t) in enumerate(gt_pairs):
|
|
1646
|
+
if (g, t) not in influence_func_info:
|
|
1647
|
+
continue
|
|
1648
|
+
|
|
1649
|
+
info = influence_func_info[(g, t)]
|
|
1650
|
+
w = weights[j]
|
|
1651
|
+
|
|
1652
|
+
for i, uid in enumerate(info['treated_units']):
|
|
1653
|
+
idx = unit_to_idx[uid]
|
|
1654
|
+
psi_standard[idx] += w * info['treated_inf'][i]
|
|
1655
|
+
|
|
1656
|
+
for i, uid in enumerate(info['control_units']):
|
|
1657
|
+
idx = unit_to_idx[uid]
|
|
1658
|
+
psi_standard[idx] += w * info['control_inf'][i]
|
|
1659
|
+
|
|
1660
|
+
# Build unit-group membership indicator
|
|
1661
|
+
unit_groups = {}
|
|
1662
|
+
for uid in all_units:
|
|
1663
|
+
unit_first_treat = df[df[unit] == uid]['first_treat'].iloc[0]
|
|
1664
|
+
if unit_first_treat in unique_groups:
|
|
1665
|
+
unit_groups[uid] = unit_first_treat
|
|
1666
|
+
else:
|
|
1667
|
+
unit_groups[uid] = None # Never-treated or other
|
|
1668
|
+
|
|
1669
|
+
# Compute wif using R's exact formula (iterate over keepers, not groups)
|
|
1670
|
+
# R's wif function:
|
|
1671
|
+
# if1[i,k] = (indicator(G_i == group_k) - pg[k]) / sum(pg[keepers])
|
|
1672
|
+
# if2[i,k] = indicator_sum[i] * pg[k] / sum(pg[keepers])^2
|
|
1673
|
+
# wif[i,k] = if1[i,k] - if2[i,k]
|
|
1674
|
+
#
|
|
1675
|
+
# Then: wif_contrib[i] = sum_k(wif[i,k] * att[k])
|
|
1676
|
+
|
|
1677
|
+
n_keepers = len(gt_pairs)
|
|
1678
|
+
wif_contrib = np.zeros(n_units)
|
|
1679
|
+
|
|
1680
|
+
# Pre-compute indicator_sum for each unit
|
|
1681
|
+
# indicator_sum[i] = sum_k(indicator(G_i == group_k) - pg[k])
|
|
1682
|
+
indicator_sum = np.zeros(n_units)
|
|
1683
|
+
for j, g in enumerate(groups_for_gt):
|
|
1684
|
+
pg_k = pg_keepers[j]
|
|
1685
|
+
for uid in all_units:
|
|
1686
|
+
i = unit_to_idx[uid]
|
|
1687
|
+
unit_g = unit_groups[uid]
|
|
1688
|
+
indicator = 1.0 if unit_g == g else 0.0
|
|
1689
|
+
indicator_sum[i] += (indicator - pg_k)
|
|
1690
|
+
|
|
1691
|
+
# Compute wif contribution for each keeper
|
|
1692
|
+
for j, (g, t) in enumerate(gt_pairs):
|
|
1693
|
+
pg_k = pg_keepers[j]
|
|
1694
|
+
att_k = effects[j]
|
|
1695
|
+
|
|
1696
|
+
for uid in all_units:
|
|
1697
|
+
i = unit_to_idx[uid]
|
|
1698
|
+
unit_g = unit_groups[uid]
|
|
1699
|
+
indicator = 1.0 if unit_g == g else 0.0
|
|
1700
|
+
|
|
1701
|
+
# R's formula for wif
|
|
1702
|
+
if1_ik = (indicator - pg_k) / sum_pg_keepers
|
|
1703
|
+
if2_ik = indicator_sum[i] * pg_k / (sum_pg_keepers ** 2)
|
|
1704
|
+
wif_ik = if1_ik - if2_ik
|
|
1705
|
+
|
|
1706
|
+
# Add contribution: wif[i,k] * att[k]
|
|
1707
|
+
wif_contrib[i] += wif_ik * att_k
|
|
1708
|
+
|
|
1709
|
+
# Scale by 1/n_units to match R's getSE formula: sqrt(mean(IF^2)/n)
|
|
1710
|
+
psi_wif = wif_contrib / n_units
|
|
1711
|
+
|
|
1712
|
+
# Combine standard and wif terms
|
|
1713
|
+
psi_total = psi_standard + psi_wif
|
|
1714
|
+
|
|
1715
|
+
# Compute variance and SE
|
|
1716
|
+
# R's formula: sqrt(mean(IF^2) / n) = sqrt(sum(IF^2) / n^2)
|
|
1717
|
+
variance = np.sum(psi_total ** 2)
|
|
1718
|
+
return np.sqrt(variance)
|
|
1719
|
+
|
|
1569
1720
|
def _aggregate_event_study(
|
|
1570
1721
|
self,
|
|
1571
1722
|
group_time_effects: Dict,
|
|
@@ -21,6 +21,7 @@ from diff_diff.results import _get_significance_stars
|
|
|
21
21
|
from diff_diff.utils import (
|
|
22
22
|
compute_confidence_interval,
|
|
23
23
|
compute_p_value,
|
|
24
|
+
within_transform as _within_transform_util,
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
|
|
@@ -789,28 +790,7 @@ class SunAbraham:
|
|
|
789
790
|
|
|
790
791
|
y_it - y_i. - y_.t + y_..
|
|
791
792
|
"""
|
|
792
|
-
df =
|
|
793
|
-
|
|
794
|
-
# Build all demeaned columns at once to avoid fragmentation
|
|
795
|
-
demeaned_data = {}
|
|
796
|
-
for var in variables:
|
|
797
|
-
# Unit means
|
|
798
|
-
unit_means = df.groupby(unit)[var].transform("mean")
|
|
799
|
-
# Time means
|
|
800
|
-
time_means = df.groupby(time)[var].transform("mean")
|
|
801
|
-
# Grand mean
|
|
802
|
-
grand_mean = df[var].mean()
|
|
803
|
-
|
|
804
|
-
# Within transformation
|
|
805
|
-
demeaned_data[f"{var}_dm"] = (
|
|
806
|
-
df[var] - unit_means - time_means + grand_mean
|
|
807
|
-
).values
|
|
808
|
-
|
|
809
|
-
# Add all demeaned columns at once
|
|
810
|
-
demeaned_df = pd.DataFrame(demeaned_data, index=df.index)
|
|
811
|
-
df = pd.concat([df, demeaned_df], axis=1)
|
|
812
|
-
|
|
813
|
-
return df
|
|
793
|
+
return _within_transform_util(df, variables, unit, time, suffix="_dm")
|
|
814
794
|
|
|
815
795
|
def _compute_iw_effects(
|
|
816
796
|
self,
|
|
@@ -17,6 +17,7 @@ from diff_diff.results import DiDResults
|
|
|
17
17
|
from diff_diff.utils import (
|
|
18
18
|
compute_confidence_interval,
|
|
19
19
|
compute_p_value,
|
|
20
|
+
within_transform as _within_transform_util,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
|
|
@@ -211,25 +212,8 @@ class TwoWayFixedEffects(DifferenceInDifferences):
|
|
|
211
212
|
pd.DataFrame
|
|
212
213
|
Data with demeaned variables.
|
|
213
214
|
"""
|
|
214
|
-
data = data.copy()
|
|
215
215
|
variables = [outcome] + (covariates or [])
|
|
216
|
-
|
|
217
|
-
# Cache groupby objects for efficiency (avoids re-computing group indexes)
|
|
218
|
-
unit_grouper = data.groupby(unit, sort=False)
|
|
219
|
-
time_grouper = data.groupby(time, sort=False)
|
|
220
|
-
|
|
221
|
-
for var in variables:
|
|
222
|
-
# Unit means (using cached grouper)
|
|
223
|
-
unit_means = unit_grouper[var].transform("mean")
|
|
224
|
-
# Time means (using cached grouper)
|
|
225
|
-
time_means = time_grouper[var].transform("mean")
|
|
226
|
-
# Grand mean
|
|
227
|
-
grand_mean = data[var].mean()
|
|
228
|
-
|
|
229
|
-
# Within transformation
|
|
230
|
-
data[f"{var}_demeaned"] = data[var] - unit_means - time_means + grand_mean
|
|
231
|
-
|
|
232
|
-
return data
|
|
216
|
+
return _within_transform_util(data, variables, unit, time, suffix="_demeaned")
|
|
233
217
|
|
|
234
218
|
def _check_staggered_treatment(
|
|
235
219
|
self,
|
|
@@ -1342,3 +1342,140 @@ def compute_placebo_effects(
|
|
|
1342
1342
|
placebo_effects.append(placebo_tau)
|
|
1343
1343
|
|
|
1344
1344
|
return np.asarray(placebo_effects)
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def demean_by_group(
|
|
1348
|
+
data: pd.DataFrame,
|
|
1349
|
+
variables: List[str],
|
|
1350
|
+
group_var: str,
|
|
1351
|
+
inplace: bool = False,
|
|
1352
|
+
suffix: str = "",
|
|
1353
|
+
) -> Tuple[pd.DataFrame, int]:
|
|
1354
|
+
"""
|
|
1355
|
+
Demean variables by a grouping variable (one-way within transformation).
|
|
1356
|
+
|
|
1357
|
+
For each variable, computes: x_ig - mean(x_g) where g is the group.
|
|
1358
|
+
|
|
1359
|
+
Parameters
|
|
1360
|
+
----------
|
|
1361
|
+
data : pd.DataFrame
|
|
1362
|
+
DataFrame containing the variables to demean.
|
|
1363
|
+
variables : list of str
|
|
1364
|
+
Column names to demean.
|
|
1365
|
+
group_var : str
|
|
1366
|
+
Column name for the grouping variable.
|
|
1367
|
+
inplace : bool, default False
|
|
1368
|
+
If True, modifies the original columns. If False, leaves original
|
|
1369
|
+
columns unchanged (demeaning is still applied to return value).
|
|
1370
|
+
suffix : str, default ""
|
|
1371
|
+
Suffix to add to demeaned column names (only used when inplace=False
|
|
1372
|
+
and you want to keep both original and demeaned columns).
|
|
1373
|
+
|
|
1374
|
+
Returns
|
|
1375
|
+
-------
|
|
1376
|
+
data : pd.DataFrame
|
|
1377
|
+
DataFrame with demeaned variables.
|
|
1378
|
+
n_effects : int
|
|
1379
|
+
Number of absorbed fixed effects (nunique - 1).
|
|
1380
|
+
|
|
1381
|
+
Examples
|
|
1382
|
+
--------
|
|
1383
|
+
>>> df, n_fe = demean_by_group(df, ['y', 'x1', 'x2'], 'unit')
|
|
1384
|
+
>>> # df['y'], df['x1'], df['x2'] are now demeaned by unit
|
|
1385
|
+
"""
|
|
1386
|
+
if not inplace:
|
|
1387
|
+
data = data.copy()
|
|
1388
|
+
|
|
1389
|
+
# Count fixed effects (categories - 1 for identification)
|
|
1390
|
+
n_effects = data[group_var].nunique() - 1
|
|
1391
|
+
|
|
1392
|
+
# Cache the groupby object for efficiency
|
|
1393
|
+
grouper = data.groupby(group_var, sort=False)
|
|
1394
|
+
|
|
1395
|
+
for var in variables:
|
|
1396
|
+
col_name = var if not suffix else f"{var}{suffix}"
|
|
1397
|
+
group_means = grouper[var].transform("mean")
|
|
1398
|
+
data[col_name] = data[var] - group_means
|
|
1399
|
+
|
|
1400
|
+
return data, n_effects
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def within_transform(
|
|
1404
|
+
data: pd.DataFrame,
|
|
1405
|
+
variables: List[str],
|
|
1406
|
+
unit: str,
|
|
1407
|
+
time: str,
|
|
1408
|
+
inplace: bool = False,
|
|
1409
|
+
suffix: str = "_demeaned",
|
|
1410
|
+
) -> pd.DataFrame:
|
|
1411
|
+
"""
|
|
1412
|
+
Apply two-way within transformation to remove unit and time fixed effects.
|
|
1413
|
+
|
|
1414
|
+
Computes: y_it - y_i. - y_.t + y_.. for each variable.
|
|
1415
|
+
|
|
1416
|
+
This is the standard fixed effects transformation for panel data that
|
|
1417
|
+
removes both unit-specific and time-specific effects.
|
|
1418
|
+
|
|
1419
|
+
Parameters
|
|
1420
|
+
----------
|
|
1421
|
+
data : pd.DataFrame
|
|
1422
|
+
Panel data containing the variables to transform.
|
|
1423
|
+
variables : list of str
|
|
1424
|
+
Column names to transform.
|
|
1425
|
+
unit : str
|
|
1426
|
+
Column name for unit identifier.
|
|
1427
|
+
time : str
|
|
1428
|
+
Column name for time period identifier.
|
|
1429
|
+
inplace : bool, default False
|
|
1430
|
+
If True, modifies the original columns. If False, creates new columns
|
|
1431
|
+
with the specified suffix.
|
|
1432
|
+
suffix : str, default "_demeaned"
|
|
1433
|
+
Suffix for new column names when inplace=False.
|
|
1434
|
+
|
|
1435
|
+
Returns
|
|
1436
|
+
-------
|
|
1437
|
+
pd.DataFrame
|
|
1438
|
+
DataFrame with within-transformed variables.
|
|
1439
|
+
|
|
1440
|
+
Notes
|
|
1441
|
+
-----
|
|
1442
|
+
The within transformation removes variation that is constant within units
|
|
1443
|
+
(unit fixed effects) and constant within time periods (time fixed effects).
|
|
1444
|
+
The resulting estimates are equivalent to including unit and time dummies
|
|
1445
|
+
but is computationally more efficient for large panels.
|
|
1446
|
+
|
|
1447
|
+
Examples
|
|
1448
|
+
--------
|
|
1449
|
+
>>> df = within_transform(df, ['y', 'x'], 'unit_id', 'year')
|
|
1450
|
+
>>> # df now has 'y_demeaned' and 'x_demeaned' columns
|
|
1451
|
+
"""
|
|
1452
|
+
if not inplace:
|
|
1453
|
+
data = data.copy()
|
|
1454
|
+
|
|
1455
|
+
# Cache groupby objects for efficiency
|
|
1456
|
+
unit_grouper = data.groupby(unit, sort=False)
|
|
1457
|
+
time_grouper = data.groupby(time, sort=False)
|
|
1458
|
+
|
|
1459
|
+
if inplace:
|
|
1460
|
+
# Modify columns in place
|
|
1461
|
+
for var in variables:
|
|
1462
|
+
unit_means = unit_grouper[var].transform("mean")
|
|
1463
|
+
time_means = time_grouper[var].transform("mean")
|
|
1464
|
+
grand_mean = data[var].mean()
|
|
1465
|
+
data[var] = data[var] - unit_means - time_means + grand_mean
|
|
1466
|
+
else:
|
|
1467
|
+
# Build all demeaned columns at once to avoid DataFrame fragmentation
|
|
1468
|
+
demeaned_data = {}
|
|
1469
|
+
for var in variables:
|
|
1470
|
+
unit_means = unit_grouper[var].transform("mean")
|
|
1471
|
+
time_means = time_grouper[var].transform("mean")
|
|
1472
|
+
grand_mean = data[var].mean()
|
|
1473
|
+
demeaned_data[f"{var}{suffix}"] = (
|
|
1474
|
+
data[var] - unit_means - time_means + grand_mean
|
|
1475
|
+
).values
|
|
1476
|
+
|
|
1477
|
+
# Add all columns at once
|
|
1478
|
+
demeaned_df = pd.DataFrame(demeaned_data, index=data.index)
|
|
1479
|
+
data = pd.concat([data, demeaned_df], axis=1)
|
|
1480
|
+
|
|
1481
|
+
return data
|
|
@@ -131,9 +131,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
|
131
131
|
|
|
132
132
|
[[package]]
|
|
133
133
|
name = "chrono"
|
|
134
|
-
version = "0.4.
|
|
134
|
+
version = "0.4.43"
|
|
135
135
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
136
|
-
checksum = "
|
|
136
|
+
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
|
137
137
|
dependencies = [
|
|
138
138
|
"iana-time-zone",
|
|
139
139
|
"js-sys",
|
|
@@ -709,9 +709,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
|
|
709
709
|
|
|
710
710
|
[[package]]
|
|
711
711
|
name = "js-sys"
|
|
712
|
-
version = "0.3.
|
|
712
|
+
version = "0.3.85"
|
|
713
713
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
714
|
-
checksum = "
|
|
714
|
+
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
|
715
715
|
dependencies = [
|
|
716
716
|
"once_cell",
|
|
717
717
|
"wasm-bindgen",
|
|
@@ -1895,9 +1895,9 @@ dependencies = [
|
|
|
1895
1895
|
|
|
1896
1896
|
[[package]]
|
|
1897
1897
|
name = "wasm-bindgen"
|
|
1898
|
-
version = "0.2.
|
|
1898
|
+
version = "0.2.108"
|
|
1899
1899
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1900
|
-
checksum = "
|
|
1900
|
+
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
|
1901
1901
|
dependencies = [
|
|
1902
1902
|
"cfg-if",
|
|
1903
1903
|
"once_cell",
|
|
@@ -1908,9 +1908,9 @@ dependencies = [
|
|
|
1908
1908
|
|
|
1909
1909
|
[[package]]
|
|
1910
1910
|
name = "wasm-bindgen-macro"
|
|
1911
|
-
version = "0.2.
|
|
1911
|
+
version = "0.2.108"
|
|
1912
1912
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1913
|
-
checksum = "
|
|
1913
|
+
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
|
1914
1914
|
dependencies = [
|
|
1915
1915
|
"quote",
|
|
1916
1916
|
"wasm-bindgen-macro-support",
|
|
@@ -1918,9 +1918,9 @@ dependencies = [
|
|
|
1918
1918
|
|
|
1919
1919
|
[[package]]
|
|
1920
1920
|
name = "wasm-bindgen-macro-support"
|
|
1921
|
-
version = "0.2.
|
|
1921
|
+
version = "0.2.108"
|
|
1922
1922
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1923
|
-
checksum = "
|
|
1923
|
+
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
|
1924
1924
|
dependencies = [
|
|
1925
1925
|
"bumpalo",
|
|
1926
1926
|
"proc-macro2",
|
|
@@ -1931,9 +1931,9 @@ dependencies = [
|
|
|
1931
1931
|
|
|
1932
1932
|
[[package]]
|
|
1933
1933
|
name = "wasm-bindgen-shared"
|
|
1934
|
-
version = "0.2.
|
|
1934
|
+
version = "0.2.108"
|
|
1935
1935
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1936
|
-
checksum = "
|
|
1936
|
+
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
|
1937
1937
|
dependencies = [
|
|
1938
1938
|
"unicode-ident",
|
|
1939
1939
|
]
|
|
@@ -2391,6 +2391,6 @@ dependencies = [
|
|
|
2391
2391
|
|
|
2392
2392
|
[[package]]
|
|
2393
2393
|
name = "zmij"
|
|
2394
|
-
version = "1.0.
|
|
2394
|
+
version = "1.0.14"
|
|
2395
2395
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2396
|
-
checksum = "
|
|
2396
|
+
checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|