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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **Covariate names that collide with reserved structural terms now raise `ValueError` instead of silently corrupting the coefficient dict (`DifferenceInDifferences`, `MultiPeriodDiD`, `TwoWayFixedEffects`).** These estimators build their `coefficients` dict by zipping a variable-name list -- structural term names PLUS the user covariate column names appended verbatim -- with the fitted coefficient vector. A covariate whose name equaled a reserved structural name (`const`; the treatment/time column names; the `{treatment}:{time}` interaction; MultiPeriodDiD `period_{p}` dummies and `{treatment}:period_{p}` interactions; `TwoWayFixedEffects` `ATT`; fixed-effect / unit / time dummy names; or an internal `_`-prefixed working column such as `_treat_time` / `_did_treatment` / `_treatment_post`) silently **overwrote** that structural coefficient via Python dict last-write-wins -- e.g. a covariate named `const` dropped the intercept -- with no error or warning. A new shared `validate_covariate_names` helper (`diff_diff/utils.py`) is now called in each of the three `fit()` methods before the design matrix is built; it raises `ValueError` on a collision (the comparison is case-sensitive, so e.g. `Const` is still allowed) **and** on duplicate names within `covariates` (which collapse to a single dict entry the same way). Fixed-effect/unit/time dummy reserved names are taken from the same `pd.get_dummies(..., drop_first=True)` call used to build them, so they match exactly (including for pandas `Categorical` columns with a non-default category order). For `TwoWayFixedEffects` the guard fires on **all** variance paths: the default within-transform path returns only `{"ATT": att}` (no covariate is a dict key there), but a covariate named `_treatment_post` would still clobber the internal interaction column, so guarding both paths is uniform and forward-compatible. **Potentially breaking:** a fit that previously *succeeded* with a colliding (or duplicated) covariate name -- silently returning a corrupted coefficient dict -- now raises; rename the covariate column(s). The staggered / influence-function estimators (CallawaySantAnna, SunAbraham, StaggeredTripleDifference, EfficientDiD, TwoStageDiD, ImputationDiD, WooldridgeDiD, dCDH, StackedDiD) key results by `(g, t)` tuples / relative-time indices, never covariate names, and `TripleDifference` / `SyntheticControl` / `SyntheticDiD` do not expose covariates by name, so none are affected. New tests in `tests/test_utils.py`, `tests/test_estimators.py`, and `tests/test_estimators_vcov_type.py`.

## [3.5.0] - 2026-06-01

### Added
Expand Down
73 changes: 70 additions & 3 deletions diff_diff/estimators.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
from diff_diff.utils import (
WildBootstrapResults,
demean_by_group,
fe_dummy_names,
safe_inference,
validate_binary,
validate_covariate_names,
validate_design_term_names,
wild_bootstrap_se,
)

Expand Down Expand Up @@ -255,6 +258,11 @@ def fit(
If provided, overrides outcome, treatment, and time parameters.
covariates : list, optional
List of covariate column names to include as linear controls.
Names must not collide with reserved structural terms (``const``,
the treatment/time column names, the ``{treatment}:{time}``
interaction, fixed-effect dummy names, or internal working columns)
and must be unique; a collision or duplicate raises ``ValueError``
(it would otherwise silently overwrite a structural coefficient).
fixed_effects : list, optional
List of categorical column names to include as fixed effects.
Creates dummy variables for each category (drops first level).
Expand Down Expand Up @@ -286,7 +294,9 @@ def fit(
Raises
------
ValueError
If required parameters are missing or data validation fails.
If required parameters are missing or data validation fails, or if
a covariate name collides with a reserved structural term name or
duplicates another covariate.

Examples
--------
Expand Down Expand Up @@ -465,6 +475,21 @@ def fit(
else:
dt = d * t

# Reject covariate names that collide with reserved structural terms.
# Covariate names are appended verbatim to var_names below and zipped
# into coef_dict, so a covariate named like a structural term would
# silently overwrite that coefficient (dict last-write-wins). The
# reserved set covers the intercept, treatment/time indicators, the
# interaction, the internal _treat_time working column, and any
# fixed-effect dummy names (derived via fe_dummy_names WITHOUT
# materializing the dummy matrix; names match the get_dummies build
# below exactly). validate_design_term_names re-checks the FINAL list.
_reserved = {"const", treatment, time, f"{treatment}:{time}", "_treat_time"}
if fixed_effects:
for fe in fixed_effects:
_reserved.update(fe_dummy_names(working_data[fe], fe))
validate_covariate_names(covariates, _reserved, estimator="DifferenceInDifferences")

# Build design matrix
X = np.column_stack([np.ones(len(y)), d, t, dt])
var_names = ["const", treatment, time, f"{treatment}:{time}"]
Expand All @@ -485,6 +510,12 @@ def fit(
X = np.column_stack([X, dummies[col].values.astype(float)])
var_names.append(col)

# Reject any duplicate in the FINAL term list (e.g. a fixed-effect dummy
# colliding with a structural term) BEFORE the regression — so the fit is
# not wasted and no misleading multicollinearity warning is emitted ahead
# of the intended ValueError.
validate_design_term_names(var_names, estimator="DifferenceInDifferences")

# Extract ATT index (coefficient on interaction term)
att_idx = 3 # Index of interaction term
att_var_name = f"{treatment}:{time}"
Expand Down Expand Up @@ -1244,6 +1275,12 @@ def fit( # type: ignore[override]
All other periods are treated as pre-treatment.
covariates : list, optional
List of covariate column names to include as linear controls.
Names must not collide with reserved structural terms (``const``,
the treatment column name, ``period_{p}`` dummies, the
``{treatment}:period_{p}`` interactions, fixed-effect dummy names, or
internal working columns) and must be unique; a collision or
duplicate raises ``ValueError`` (it would otherwise silently
overwrite a structural coefficient).
fixed_effects : list, optional
List of categorical column names to include as fixed effects.
absorb : list, optional
Expand Down Expand Up @@ -1271,7 +1308,9 @@ def fit( # type: ignore[override]
Raises
------
ValueError
If required parameters are missing or data validation fails.
If required parameters are missing or data validation fails, or if
a covariate name collides with a reserved structural term name or
duplicates another covariate.
"""
# Fall back to analytical inference if wild bootstrap requested
# (must happen before _resolve_survey_for_fit which rejects bootstrap+survey).
Expand Down Expand Up @@ -1567,6 +1606,27 @@ def fit( # type: ignore[override]
d = working_data[treatment].values.astype(float)
t = working_data[time].values

# Reject covariate names that collide with reserved structural terms.
# Covariates are appended verbatim to var_names below and zipped into
# coef_dict, so a covariate named like a structural term (intercept,
# treatment, a period dummy, a treatment-period interaction, an internal
# _did_* working column, or a fixed-effect dummy) would silently
# overwrite that coefficient (dict last-write-wins). FE dummy names are
# derived via fe_dummy_names (no dummy-matrix materialization), matching
# the construction below (and applying the same fe==time skip).
# validate_design_term_names re-checks the FINAL list before coef_dict.
_reserved = {"const", treatment, "_did_treatment"}
_reserved.update(f"period_{p}" for p in non_ref_periods)
_reserved.update(f"{treatment}:period_{p}" for p in non_ref_periods)
_reserved.update(f"_did_period_{p}" for p in non_ref_periods)
_reserved.update(f"_did_interact_{p}" for p in non_ref_periods)
if fixed_effects:
for fe in fixed_effects:
if fe == time:
continue
_reserved.update(fe_dummy_names(working_data[fe], fe))
validate_covariate_names(covariates, _reserved, estimator="MultiPeriodDiD")

# Build design matrix
# Start with intercept and treatment main effect
X = np.column_stack([np.ones(len(y)), d])
Expand Down Expand Up @@ -1625,6 +1685,12 @@ def fit( # type: ignore[override]
X = np.column_stack([X, dummies[col].values.astype(float)])
var_names.append(col)

# Reject any duplicate in the FINAL term list (e.g. a fixed-effect dummy
# colliding with a structural period_{p} key) BEFORE the regression — so
# the fit is not wasted and no misleading multicollinearity warning is
# emitted ahead of the intended ValueError.
validate_design_term_names(var_names, estimator="MultiPeriodDiD")

# Fit OLS using unified backend
# Pass cluster_ids to solve_ols for proper vcov computation
# This handles rank-deficient matrices by returning NaN for dropped columns
Expand Down Expand Up @@ -1983,7 +2049,8 @@ def _refit_mp_absorb(w_r):
n_treated = n_treated_raw
n_control = n_control_raw

# Create coefficient dictionary
# Create coefficient dictionary (var_names uniqueness already enforced
# before the fit above).
coef_dict = {name: coef for name, coef in zip(var_names, coefficients)}

# Store results
Expand Down
40 changes: 39 additions & 1 deletion diff_diff/twfe.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
from diff_diff.estimators import DifferenceInDifferences
from diff_diff.linalg import LinearRegression
from diff_diff.results import DiDResults
from diff_diff.utils import (
fe_dummy_names,
validate_covariate_names,
validate_design_term_names,
)
from diff_diff.utils import (
within_transform as _within_transform_util,
)
Expand Down Expand Up @@ -135,7 +140,12 @@ def fit( # type: ignore[override]
unit : str
Name of unit identifier column.
covariates : list, optional
List of covariate column names.
List of covariate column names. Names must not collide with reserved
structural terms (``const``, ``ATT``, unit/time fixed-effect dummy
names, or the internal ``_treatment_post`` column) and must be
unique; a collision or duplicate raises ``ValueError`` (it would
otherwise silently overwrite a structural coefficient on the
full-dummy HC2/HC2-BM path).
survey_design : SurveyDesign, optional
Survey design specification for design-based inference. When provided,
uses Taylor Series Linearization for variance estimation and
Expand All @@ -145,6 +155,12 @@ def fit( # type: ignore[override]
-------
DiDResults
Estimation results.

Raises
------
ValueError
If a covariate name collides with a reserved structural term name
or duplicates another covariate.
"""
# Validate unit column exists
if unit not in data.columns:
Expand Down Expand Up @@ -282,6 +298,24 @@ def fit( # type: ignore[override]
n_units = data[unit].nunique()
n_times = data[time].nunique()

# Reject covariate names that collide with reserved structural terms.
# On the full-dummy (HC2/HC2-BM) path covariates are zipped into the
# coefficient dict alongside "const"/"ATT" and the unit/time dummies, so
# a colliding covariate would silently overwrite that coefficient (dict
# last-write-wins). The within-transform path does not expose covariates
# in the dict, but the covariate is still in X and a covariate named
# "_treatment_post" would clobber the internal interaction column; we
# therefore guard ALL paths. Unit/time dummy names are derived via
# fe_dummy_names WITHOUT materializing the dummy matrix — critical here
# because the within-transform path (the default hc1/classical/conley
# branch) deliberately never expands the full FE dummies (its scaling
# contract for high-cardinality panels); a get_dummies build would
# defeat that. validate_design_term_names re-checks the full-dummy list.
_reserved = {"const", "ATT", "_treatment_post"}
_reserved.update(fe_dummy_names(data[unit], f"_fe_{unit}"))
_reserved.update(fe_dummy_names(data[time], f"_fe_{time}"))
validate_covariate_names(covariates, _reserved, estimator="TwoWayFixedEffects")

if use_full_dummy:
# HC2 / HC2-BM full-dummy build: bypass the within-transform
# and stack [intercept, treated×post, covariates, unit_dummies,
Expand Down Expand Up @@ -339,6 +373,10 @@ def fit( # type: ignore[override]
+ list(unit_dummies_df.columns)
+ list(time_dummies_df.columns)
)
# Backstop: reject any duplicate in the FINAL term list (e.g. a
# unit/time dummy colliding with a structural term or another dummy)
# before it silently overwrites a coefficient in the dict below.
validate_design_term_names(_twfe_var_names, estimator="TwoWayFixedEffects")
else:
# Default within-transform path (HC1 / classical / Conley):
# demean outcome, covariates, AND interaction in a single pass
Expand Down
Loading
Loading