|
28 | 28 | from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect |
29 | 29 | from diff_diff.utils import ( |
30 | 30 | WildBootstrapResults, |
31 | | - demean_by_group, |
| 31 | + demean_by_groups, |
32 | 32 | fe_dummy_names, |
33 | 33 | safe_inference, |
34 | 34 | validate_binary, |
@@ -414,17 +414,9 @@ def fit( |
414 | 414 | absorbed_vars = [] |
415 | 415 | n_absorbed_effects = 0 |
416 | 416 |
|
417 | | - # Reject multi-absorb with survey weights (single-pass demeaning is |
418 | | - # not the correct weighted FWL projection for N > 1 dimensions). Only |
419 | | - # fires when absorb is still set — i.e., the auto-route above didn't |
420 | | - # consume it. |
421 | | - if absorb and len(absorb) > 1 and survey_weights is not None: |
422 | | - raise ValueError( |
423 | | - f"Multiple absorbed fixed effects (absorb={absorb}) with survey " |
424 | | - "weights is not supported. Single-pass sequential demeaning is not " |
425 | | - "the correct weighted FWL projection for multiple absorbed dimensions. " |
426 | | - "Use absorb with a single variable, or use fixed_effects= instead." |
427 | | - ) |
| 417 | + # Weighted multiple absorbed FE is supported: the absorb path below uses |
| 418 | + # iterative alternating projections (demean_by_groups), the exact weighted |
| 419 | + # FWL projection for N > 1 dimensions on both balanced and unbalanced panels. |
428 | 420 |
|
429 | 421 | # Validate vcov_type="conley" wire-up. DiD.fit() accepts `unit` |
430 | 422 | # as a fit-time arg (NOT on __init__) because cluster/unit |
@@ -462,16 +454,18 @@ def fit( |
462 | 454 | float |
463 | 455 | ) * working_data[time].values.astype(float) |
464 | 456 | vars_to_demean = [outcome, treatment, time, "_treat_time"] + (covariates or []) |
465 | | - for ab_var in absorb: |
466 | | - working_data, n_fe = demean_by_group( |
467 | | - working_data, |
468 | | - vars_to_demean, |
469 | | - ab_var, |
470 | | - inplace=True, |
471 | | - weights=survey_weights, |
472 | | - ) |
473 | | - n_absorbed_effects += n_fe |
474 | | - absorbed_vars.append(ab_var) |
| 457 | + # Method of alternating projections: for N > 1 absorbed dimensions a |
| 458 | + # single sequential sweep is only exact on balanced (orthogonal-FE) |
| 459 | + # panels; demean_by_groups iterates to the exact (W)LS-FWL residual. |
| 460 | + working_data, n_fe = demean_by_groups( |
| 461 | + working_data, |
| 462 | + vars_to_demean, |
| 463 | + list(absorb), |
| 464 | + inplace=True, |
| 465 | + weights=survey_weights, |
| 466 | + ) |
| 467 | + n_absorbed_effects += n_fe |
| 468 | + absorbed_vars = list(absorb) |
475 | 469 |
|
476 | 470 | # Extract variables (may be demeaned if absorb was used) |
477 | 471 | y = working_data[outcome].values.astype(float) |
@@ -644,8 +638,7 @@ def _refit_did_absorb(w_r): |
644 | 638 | float |
645 | 639 | ) |
646 | 640 | vars_dm = [outcome, treatment, time, "_treat_time"] + (covariates or []) |
647 | | - for ab_var in _absorb_list: |
648 | | - wd, _ = demean_by_group(wd, vars_dm, ab_var, inplace=True, weights=w_nz) |
| 641 | + wd, _ = demean_by_groups(wd, vars_dm, _absorb_list, inplace=True, weights=w_nz) |
649 | 642 | y_r = wd[outcome].values.astype(float) |
650 | 643 | d_r = wd[treatment].values.astype(float) |
651 | 644 | t_r = wd[time].values.astype(float) |
@@ -1572,17 +1565,9 @@ def fit( # type: ignore[override] |
1572 | 1565 | absorb = None |
1573 | 1566 | n_absorbed_effects = 0 |
1574 | 1567 |
|
1575 | | - # Reject multi-absorb with survey weights (single-pass demeaning is |
1576 | | - # not the correct weighted FWL projection for N > 1 dimensions). |
1577 | | - # Only fires when absorb is still set — i.e., the auto-route above |
1578 | | - # didn't consume it. |
1579 | | - if absorb and len(absorb) > 1 and survey_weights is not None: |
1580 | | - raise ValueError( |
1581 | | - f"Multiple absorbed fixed effects (absorb={absorb}) with survey " |
1582 | | - "weights is not supported. Single-pass sequential demeaning is not " |
1583 | | - "the correct weighted FWL projection for multiple absorbed dimensions. " |
1584 | | - "Use absorb with a single variable, or use fixed_effects= instead." |
1585 | | - ) |
| 1568 | + # Weighted multiple absorbed FE is supported: the absorb path below uses |
| 1569 | + # iterative alternating projections (demean_by_groups), the exact weighted |
| 1570 | + # FWL projection for N > 1 dimensions on both balanced and unbalanced panels. |
1586 | 1571 |
|
1587 | 1572 | # MultiPeriodDiD is intrinsically a multi-period panel estimator; |
1588 | 1573 | # Phase 2 panel block-decomposed Conley (matches R conleyreg) needs |
@@ -1622,15 +1607,16 @@ def fit( # type: ignore[override] |
1622 | 1607 | + [f"_did_interact_{p}" for p in non_ref_periods] |
1623 | 1608 | + (covariates or []) |
1624 | 1609 | ) |
1625 | | - for ab_var in absorb: |
1626 | | - working_data, n_fe = demean_by_group( |
1627 | | - working_data, |
1628 | | - vars_to_demean, |
1629 | | - ab_var, |
1630 | | - inplace=True, |
1631 | | - weights=survey_weights, |
1632 | | - ) |
1633 | | - n_absorbed_effects += n_fe |
| 1610 | + # Method of alternating projections (exact for unbalanced panels; a |
| 1611 | + # single sequential sweep is exact only on balanced orthogonal-FE panels). |
| 1612 | + working_data, n_fe = demean_by_groups( |
| 1613 | + working_data, |
| 1614 | + vars_to_demean, |
| 1615 | + list(absorb), |
| 1616 | + inplace=True, |
| 1617 | + weights=survey_weights, |
| 1618 | + ) |
| 1619 | + n_absorbed_effects += n_fe |
1634 | 1620 |
|
1635 | 1621 | # Extract outcome and treatment (may be demeaned if absorb was used) |
1636 | 1622 | y = working_data[outcome].values.astype(float) |
@@ -1854,8 +1840,7 @@ def _refit_mp_absorb(w_r): |
1854 | 1840 | + [f"_did_interact_{p}" for p in non_ref_periods] |
1855 | 1841 | + (covariates or []) |
1856 | 1842 | ) |
1857 | | - for ab_var_ in _absorb_list_mp: |
1858 | | - wd, _ = demean_by_group(wd, vars_dm_, ab_var_, inplace=True, weights=w_nz) |
| 1843 | + wd, _ = demean_by_groups(wd, vars_dm_, _absorb_list_mp, inplace=True, weights=w_nz) |
1859 | 1844 | y_r = wd[outcome].values.astype(float) |
1860 | 1845 | d_r = wd["_did_treatment"].values.astype(float) |
1861 | 1846 | X_r = np.column_stack([np.ones(len(y_r)), d_r]) |
|
0 commit comments