Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Deprecated
- **Semantic rename wave** (v4 program 2(c)-ii PR-B, ledger rows [M-030]
[M-031] [M-043] [M-044] [M-045]..[M-047] [M-084] [M-086] [M-087] [M-095]
[M-115] plus the missed-rename amendments [M-136]..[M-138]): the
judgment-heavy renames land with 3.9 `FutureWarning` shims (removal in
4.0; positional calls unaffected; deprecated paths route to
bit-identical numbers; both-spellings calls raise `ValueError`).
- `DifferenceInDifferences.fit`: `time=` -> `post=` [M-030] (the 2x2
0/1 post-period indicator - the calendar `time=` vocabulary on every
other estimator is unchanged; `TwoWayFixedEffects.fit(time=)` moves
in a later phase with [M-082]). `TripleDifference.fit`: `time=` ->
`post=` [M-031]; its warning notes that from 4.0 `time=` means the
calendar column only ([M-085]). The `permutation_test` /
`leave_one_out_test` diagnostics follow with `time=` -> `post=`
[M-137] [M-138]; the `run_placebo_test`/`run_all_placebo_tests`
wrappers keep their overloaded `time` (redesign tracked in TODO.md).
- `robust=` constructor param deprecated everywhere it exists [M-045]
[M-046] [M-047] [M-115] (`DifferenceInDifferences` incl. inherited
`TwoWayFixedEffects`/`MultiPeriodDiD`, `TripleDifference`,
`HeterogeneousAdoptionDiD`, `LinearRegression`): use `vcov_type=`.
Through 3.9 the public `.robust` attribute keeps the RESOLVED legacy
bool; `get_params()` returns the raw sentinel-era arg (None when not
supplied) so clone round-trips stay silent. An instance CONFIGURED
via `robust=` re-warns on `set_params` probe re-init.
- `StackedDiD(clean_control=)` -> `control_group=` [M-043] with a
deprecated warning-property alias on the estimator, and the results
field `StackedDiDResults.clean_control` -> `.control_group` [M-095]
(alias property + `__setstate__` pickle migration; `to_dict()` and
the business-report block emit BOTH keys through 3.9;
`summary()` prints "Control group:").
- `WooldridgeDiDResults`: `aggregate(type="event")` ->
`type="event_study"` [M-086] (`aggregation_weights` carries both
keys through 3.9, old pickles are mirrored on load);
`to_dataframe(aggregation=)` -> `to_dataframe(level=)` [M-044] with
the default resolving to `"event_study"`; `summary(aggregation=)` is
deprecated [M-087] - `summary()` renders the simple row and the new
KEYWORD-ONLY `summary(alpha=)` controls the CI label (uniform
positional `summary(alpha=None)` arrives at 4.0).
`LPDiDResults.to_dataframe(level="event")` -> `"event_study"`
[M-136] (default flips to the canonical spelling; identical frame).
- `ContinuousDiD(covariates=)` constructor -> `fit(..., covariates=)`
[M-084] (the sklearn hyperparameter/data split; supplying both
raises).
- Note: sentinel-defaulted shim parameters are transitionally annotated
`Any`; the 4.0 removal restores the narrow annotations.

- **Mechanical rename wave** (v4 program 2(c)-ii, ledger rows [M-032]
[M-033] [M-034] [M-035]..[M-042] [M-088] [M-089] [M-094] [M-097]..[M-113]
[M-114]): the following parameters/fields gain their canonical names now,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ data = pd.DataFrame({
})

did = DifferenceInDifferences()
results = did.fit(data, outcome='outcome', treatment='treated', time='post')
results = did.fit(data, outcome='outcome', treatment='treated', post='post')
print(results) # DiDResults(ATT=3.0000, SE=1.7321, p=0.1583)
results.print_summary() # full statsmodels-style table
```
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Related tracking surfaces:

| Issue | Location | Origin | Effort | Priority |
|-------|----------|--------|--------|----------|
| `run_placebo_test`/`run_all_placebo_tests` expose ONE overloaded `time` param (calendar for timing/group tests, 0/1 post dummy forwarded into the M-137/M-138 callees - works only because a two-period 0/1 calendar column is both); redesign the wrapper signatures (dual `time=`/`post=` params or a split) so each callee gets an honestly-named argument | `diff_diff/diagnostics.py` | 2(c)-ii PR-B | Mid | Medium |
| `absorbed_fe_rank` N>=3 general rank: the helper keeps `sum(levels-1)` for 3+ absorbed dims, exact for independent connected dims but an over-count for duplicated/nested triples (measured `a(5),b(4),c==b(4)`: true 7 vs formula 10) and for disconnected N-way graphs. Two-way is component-exact. Deriving general N-way FE rank is a hypergraph problem; do it with a reference (fixest's `fixef.rm`/reghdfe df_a) rather than a guess. See `docs/methodology/variance-conventions.md` D3. | `diff_diff/utils.py` | #variance-inventory | Mid | Low |
| `SyntheticControl` conformal (CWZ 2021) AR / innovation-permutation path (Lemmas 5-7) for time-series proxies — the residual-permutation shortcut is only valid for time-permutation-invariant proxies (SC/Lasso/DiD); an AR proxy needs innovation permutation. | `diff_diff/conformal.py`, `diff_diff/synthetic_control_results.py` | CWZ-2021 | Heavy | Low |
| Make the post-fit `results.aggregate("event_study")` container consumable downstream. `EventStudyResults` is rejected by all THREE consumers that read a CS event study — `compute_honest_did` (`honest_did.py`, dispatches on `CallawaySantAnnaResults` and raises `TypeError`), `compute_pretrends_power` (`pretrends.py`, same), and `plot_event_study` (`visualization`, same) — so `fit(aggregate="event_study")` is still the only route for them and their error messages say so explicitly. Needs an `EventStudyResults` branch in each extraction path (consuming `event_time` / `is_reference` / `vcov` / `vcov_index` / per-row `df`) PLUS `base_period` and `anticipation` provenance, which the unified container does not carry and HonestDiD needs for its universal-base-period warning and pre-period classification. Gate with end-to-end tests: `compute_honest_did(res.aggregate("event_study"))` at `base_period="universal"`, and `compute_pretrends_power(...)` at `anticipation=1`. | `diff_diff/honest_did.py`, `diff_diff/pretrends.py`, `diff_diff/results_base.py` | #726 | Mid | Medium |
Expand Down
12 changes: 8 additions & 4 deletions diff_diff/_reporting_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
ETWFE (``method="logit"`` / ``"poisson"``) it is the
average-structural-function (ASF) contrast across cohort x
time cells. Both paths preserve ``overall_att`` across
``.aggregate("event")`` calls (which only populate additional
``.aggregate("event_study")`` calls (which only populate additional
event-study tables).
"""
name = type(results).__name__
Expand Down Expand Up @@ -197,7 +197,11 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
}

if name == "StackedDiDResults":
clean_control = getattr(results, "clean_control", None)
# M-095: the field is control_group; __setstate__ migration
# guarantees it exists even on pre-rename pickles, so no
# clean_control fallback (a getattr default would silently
# misreport the comparison group after the 4.0 removal).
clean_control = getattr(results, "control_group", None)
if clean_control == "never_treated":
control_clause = "Controls are the never-treated units (``A_s = infinity``)."
elif clean_control == "strict":
Expand Down Expand Up @@ -253,7 +257,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
"saturated regression fits cohort x time ATT(g, t) "
"coefficients, and ``overall_att`` is their "
"observation-count-weighted average across post-"
'treatment cells. Calling ``.aggregate("event")`` '
'treatment cells. Calling ``.aggregate("event_study")`` '
"populates additional event-study tables but does NOT "
"change the ``overall_att`` scalar."
),
Expand All @@ -273,7 +277,7 @@ def describe_target_parameter(results: Any) -> Dict[str, Any]:
f"outcomes averaged across cohort x time cells with "
f"observation-count weights. The ASF handles the "
f"nonlinearity; OLS ETWFE uses the saturated-regression "
f'coefficient path instead. Calling ``.aggregate("event")`` '
f'coefficient path instead. Calling ``.aggregate("event_study")`` '
f"populates additional event-study tables but does NOT "
f"change the ``overall_att`` scalar."
),
Expand Down
2 changes: 1 addition & 1 deletion diff_diff/agent_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def agent_workflow(
]
else:
fit_example_kwargs = _join_kwargs(
outcome=outcome, unit=unit, time=time, treatment=treatment
outcome=outcome, unit=unit, post=time, treatment=treatment
)
fit_example_call = (
f"diff_diff.DifferenceInDifferences().fit({df_name}, {fit_example_kwargs})"
Expand Down
34 changes: 15 additions & 19 deletions diff_diff/business_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,21 +1075,15 @@ def _control_group_choice(results: Any) -> Optional[str]:
"""Return the control-group choice string for a fitted result, normalized
across estimator-specific attribute names.

Most anticipation-capable estimators expose the control-group choice as
``results.control_group``. ``StackedDiDResults`` exposes the same choice
as ``clean_control`` (the public Wing-Freedman-Hollingsworth-2024 kwarg
name). Without this alias, a StackedDiD fit with
``clean_control="not_yet_treated"`` would surface as ``control_group=None``
in the business-report schema, and the dynamic-control branch in
``_extract_sample`` would never fire.
Every anticipation-capable estimator now exposes the control-group
choice as ``results.control_group`` (``StackedDiDResults`` joined via
row M-095; its ``__setstate__`` migrates pre-rename pickles, so no
``clean_control`` fallback is needed - reading the deprecated alias
here would fire its FutureWarning on every report build).
"""
cg = getattr(results, "control_group", None)
if isinstance(cg, str):
return cg
if type(results).__name__ == "StackedDiDResults":
clean = getattr(results, "clean_control", None)
if isinstance(clean, str):
return clean
return None


Expand Down Expand Up @@ -1418,11 +1412,11 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str,
# within the data range) and IC2 (clean controls exist for the
# event) inclusion conditions, NOT the generic "group-time ATT
# parallel trends" clause used for CS / SA / etc. (round-22 P1
# CI review on PR #318). The active ``clean_control`` rule
# determines which units qualify as valid controls for each
# adoption event. REGISTRY.md §StackedDiD lines 1189-1193
# (identification) and 1234-1256 (clean-control rules).
clean_control = getattr(results, "clean_control", None)
# CI review on PR #318). The active ``control_group`` rule
# (pre-M-095 name: ``clean_control``) determines which units
# qualify as valid controls for each adoption event. REGISTRY.md
# §StackedDiD (identification / clean-control rules).
clean_control = getattr(results, "control_group", None)
if clean_control == "never_treated":
control_clause = (
"controls are restricted to units that are never treated "
Expand Down Expand Up @@ -1463,6 +1457,8 @@ def _describe_assumption(estimator_name: str, results: Any = None) -> Dict[str,
}
if isinstance(clean_control, str):
block["control_group"] = clean_control
# Deprecated reporting key kept through the 3.9 shim window;
# retired at 4.0 (row M-095, section 5 policy).
block["clean_control"] = clean_control
return block
if estimator_name == "ImputationDiDResults":
Expand Down Expand Up @@ -2358,7 +2354,7 @@ def _render_summary(schema: Dict[str, Any]) -> str:

# Sample sentence. For fits with a dynamic comparison set (CS /
# ContinuousDiD / StaggeredTripleDiff / EfficientDiD /
# StackedDiD under ``clean_control in {"not_yet_treated",
# StackedDiD under ``control_group in {"not_yet_treated",
# "strict"}``) the fixed control count is suppressed because the
# comparison group varies by cohort/sub-experiment; narrate the
# mode explicitly rather than misreporting a fixed-subset tally as
Expand Down Expand Up @@ -2408,7 +2404,7 @@ def _render_summary(schema: Dict[str, Any]) -> str:
sentences.append(
f"Sample: {n_obs:,} observations ({n_t:,} treated) with a "
f"sub-experiment-specific clean-control comparison "
f"(``clean_control='{cc_label}'``): each adoption event is "
f"(``control_group='{cc_label}'``): each adoption event is "
f"compared against the units satisfying the rule relative "
f"to that event's window, not a single fixed control "
f"group{distinct_clause}{subset_clause}."
Expand Down Expand Up @@ -2668,7 +2664,7 @@ def _render_full_report(schema: Dict[str, Any]) -> str:
cc_label = cg if isinstance(cg, str) else "clean_control"
lines.append(
f"- Comparison group: sub-experiment-specific clean controls "
f"(``clean_control='{cc_label}'``; each adoption event is "
f"(``control_group='{cc_label}'``; each adoption event is "
"compared against units satisfying the rule relative to that "
"event's window, not a single fixed control group)"
)
Expand Down
45 changes: 42 additions & 3 deletions diff_diff/continuous_did.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import pandas as pd

from diff_diff._base import BaseEstimator
from diff_diff._deprecation import warn_deprecated_kwarg
from diff_diff.bootstrap_utils import (
compute_effect_bootstrap_stats,
generate_bootstrap_weights_batch,
Expand Down Expand Up @@ -88,6 +89,9 @@ class ContinuousDiD(BaseEstimator):
rank_deficient_action : str, default="warn"
Action for rank-deficient B-spline OLS: ``"warn"``, ``"error"``, or ``"silent"``.
covariates : list of str, optional
DEPRECATED constructor home (row M-084; warns with
``FutureWarning``, removed in 4.0) - pass ``covariates=`` to
``fit()`` instead (the sklearn hyperparameter/data split).
Column names of covariates for **conditional** parallel trends
(``E[ΔY(0) | D=d, X] = E[ΔY(0) | D=0, X]``). When ``None`` (default) the
estimator uses unconditional parallel trends. Covariates enter through a
Expand Down Expand Up @@ -178,6 +182,16 @@ def __init__(
self.bootstrap_weights = bootstrap_weights
self.seed = seed
self.rank_deficient_action = rank_deficient_action
# M-084: constructor covariates= is deprecated (removed in 4.0);
# the design-matrix column spec moves to fit() per the sklearn
# hyperparameter/data split. Raw-keep storage: the value still
# routes exactly as before, and get_params round-trips it.
if covariates is not None:
warn_deprecated_kwarg(
type(self).__name__,
"covariates",
"pass covariates to fit() instead",
)
self.covariates = covariates
self.estimation_method = estimation_method
self.pscore_trim = pscore_trim
Expand Down Expand Up @@ -249,6 +263,7 @@ def fit(
dose: str,
aggregate: Optional[str] = None,
survey_design: Optional["SurveyDesign"] = None,
covariates: Optional[List[str]] = None,
) -> ContinuousDiDResults:
"""
Fit the continuous DiD estimator.
Expand All @@ -275,6 +290,12 @@ def fit(
Supports weighted estimation and Taylor series linearization
variance with strata, PSU, and FPC.

covariates : list of str, optional
Covariate column names for the conditional-parallel-trends
estimand (the canonical fit-level home - row M-084). The
deprecated constructor ``covariates=`` still routes and warns;
supplying both raises ``ValueError``.

Returns
-------
ContinuousDiDResults
Expand All @@ -297,8 +318,26 @@ def fit(

# Bootstrap + survey supported via PSU-level multiplier bootstrap.

# M-084: fit-time covariates= is the canonical home; the deprecated
# constructor spec still routes (raw-keep). Supplying both is
# ambiguous and fails loudly.
if covariates is not None and self.covariates is not None:
raise ValueError(
"covariates= was supplied both to the constructor "
"(deprecated, row M-084) and to fit(); pass it to fit() only."
)
effective_covariates = covariates if covariates is not None else self.covariates
if self.control_group == "lowest_dose" and effective_covariates is not None:
# Mirror of the constructor-time guard for the fit-level spec.
raise NotImplementedError(
"control_group='lowest_dose' does not yet compose with covariates= "
"(the conditional-parallel-trends estimand relative to the lowest "
"dose d_L is deferred). Use covariates=None for the unconditional "
"lowest-dose fit."
)

df = data.copy()
cov_cols = list(self.covariates) if self.covariates else []
cov_cols = list(effective_covariates) if effective_covariates else []
for col in [outcome, unit, time, first_treat, dose, *cov_cols]:
if col not in df.columns:
raise ValueError(f"Column '{col}' not found in data.")
Expand Down Expand Up @@ -584,7 +623,7 @@ def fit(
dose,
time_periods,
survey_weights=survey_weights,
covariates=self.covariates,
covariates=effective_covariates,
)
# Thread the lowest-dose reference d_L (Remark 3.1) to the per-cell
# dose-response so it swaps the control group and shifts the discrete
Expand Down Expand Up @@ -1107,7 +1146,7 @@ def fit(
reference_dose=reference_dose_out,
alpha=self.alpha,
control_group=self.control_group,
covariates=self.covariates,
covariates=effective_covariates,
estimation_method=self.estimation_method,
pscore_trim=self.pscore_trim,
epv_threshold=self.epv_threshold,
Expand Down
4 changes: 2 additions & 2 deletions diff_diff/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ def load_card_krueger(force_download: bool = False) -> pd.DataFrame:
>>>
>>> # Estimate DiD
>>> did = DifferenceInDifferences()
>>> results = did.fit(ck_long, outcome='employment', treatment='treated', time='post')
>>> results = did.fit(ck_long, outcome='employment', treatment='treated', post='post')
"""
return _load_verified_dataset(
cache_name="card_krueger",
Expand Down Expand Up @@ -1506,7 +1506,7 @@ def load_prop99(force_download: bool = False) -> pd.DataFrame:
>>>
>>> did = DifferenceInDifferences()
>>> results = did.fit(
... prop99, outcome="lcigsale", treatment="treated_state", time="post"
... prop99, outcome="lcigsale", treatment="treated_state", post="post"
... )
"""
url = "http://fmwww.bc.edu/repec/bocode/l/lw_smoking.dta"
Expand Down
Loading