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.
Files changed (27) hide show
  1. {diff_diff-2.0.0 → diff_diff-2.0.2}/PKG-INFO +1 -1
  2. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/__init__.py +1 -1
  3. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/bacon.py +12 -60
  4. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/estimators.py +9 -8
  5. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/staggered.py +157 -6
  6. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/sun_abraham.py +2 -22
  7. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/twfe.py +2 -18
  8. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/utils.py +137 -0
  9. {diff_diff-2.0.0 → diff_diff-2.0.2}/pyproject.toml +1 -1
  10. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/Cargo.lock +14 -14
  11. {diff_diff-2.0.0 → diff_diff-2.0.2}/README.md +0 -0
  12. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/_backend.py +0 -0
  13. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/diagnostics.py +0 -0
  14. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/honest_did.py +0 -0
  15. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/linalg.py +0 -0
  16. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/power.py +0 -0
  17. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/prep.py +0 -0
  18. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/pretrends.py +0 -0
  19. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/results.py +0 -0
  20. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/synthetic_did.py +0 -0
  21. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/triple_diff.py +0 -0
  22. {diff_diff-2.0.0 → diff_diff-2.0.2}/diff_diff/visualization.py +0 -0
  23. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/Cargo.toml +0 -0
  24. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/bootstrap.rs +0 -0
  25. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/lib.rs +0 -0
  26. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/linalg.rs +0 -0
  27. {diff_diff-2.0.0 → diff_diff-2.0.2}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -113,7 +113,7 @@ from diff_diff.visualization import (
113
113
  plot_sensitivity,
114
114
  )
115
115
 
116
- __version__ = "2.0.0"
116
+ __version__ = "2.0.2"
117
117
  __all__ = [
118
118
  # Estimators
119
119
  "DifferenceInDifferences",
@@ -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
- # Demean by unit and time
577
- y = df[outcome].values
578
- d = df[treat_col].astype(float).values
579
-
580
- # Create unit and time dummies for demeaning
581
- units = df[unit].values
582
- times = df[time].values
583
-
584
- # Unit means
585
- unit_map = {u: i for i, u in enumerate(df[unit].unique())}
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
- n_absorbed_effects += working_data[ab_var].nunique() - 1
231
- for var in vars_to_demean:
232
- group_means = working_data.groupby(ab_var)[var].transform("mean")
233
- working_data[var] = working_data[var] - group_means
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
- n_absorbed_effects += working_data[ab_var].nunique() - 1
832
- for var in vars_to_demean:
833
- group_means = working_data.groupby(ab_var)[var].transform("mean")
834
- working_data[var] = working_data[var] - group_means
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 matches R's `did` package approach.
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
- weights = weights / np.sum(weights)
1502
+ total_weight = np.sum(weights)
1503
+ weights_norm = weights / total_weight
1498
1504
 
1499
1505
  # Weighted average
1500
- overall_att = np.sum(weights * effects)
1506
+ overall_att = np.sum(weights_norm * effects)
1501
1507
 
1502
- # Compute SE using influence function aggregation
1503
- overall_se = self._compute_aggregated_se(
1504
- gt_pairs, weights, influence_func_info
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 = df.copy()
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
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "diff-diff"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "A library for Difference-in-Differences causal inference analysis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -131,9 +131,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
131
131
 
132
132
  [[package]]
133
133
  name = "chrono"
134
- version = "0.4.42"
134
+ version = "0.4.43"
135
135
  source = "registry+https://github.com/rust-lang/crates.io-index"
136
- checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
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.83"
712
+ version = "0.3.85"
713
713
  source = "registry+https://github.com/rust-lang/crates.io-index"
714
- checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
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.106"
1898
+ version = "0.2.108"
1899
1899
  source = "registry+https://github.com/rust-lang/crates.io-index"
1900
- checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
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.106"
1911
+ version = "0.2.108"
1912
1912
  source = "registry+https://github.com/rust-lang/crates.io-index"
1913
- checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
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.106"
1921
+ version = "0.2.108"
1922
1922
  source = "registry+https://github.com/rust-lang/crates.io-index"
1923
- checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
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.106"
1934
+ version = "0.2.108"
1935
1935
  source = "registry+https://github.com/rust-lang/crates.io-index"
1936
- checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
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.13"
2394
+ version = "1.0.14"
2395
2395
  source = "registry+https://github.com/rust-lang/crates.io-index"
2396
- checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec"
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