Skip to content

Commit b58787d

Browse files
committed
fix(lwdid): native time-scale pre-period identification + review polish
1 parent ed948bc commit b58787d

3 files changed

Lines changed: 47 additions & 9 deletions

File tree

diff_diff/lwdid_trend_diagnostics.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
import warnings
2929
from dataclasses import dataclass
30-
from typing import List, Optional
30+
from typing import Any, List, Optional
3131

3232
import numpy as np
3333
import pandas as pd
@@ -55,8 +55,9 @@ class PreTrendEstimate:
5555
5656
Attributes
5757
----------
58-
period : int
59-
Calendar period (pseudo-post) used for this estimate.
58+
period : scalar
59+
Calendar period (pseudo-post) used for this estimate, on the
60+
native scale of the ``time`` column (integer, datetime64, ...).
6061
att : float
6162
Estimated average treatment effect on the treated.
6263
se : float
@@ -67,7 +68,7 @@ class PreTrendEstimate:
6768
Two-sided p-value for testing H0: ATT = 0.
6869
"""
6970

70-
period: int
71+
period: Any
7172
att: float
7273
se: float
7374
t_stat: float
@@ -216,14 +217,16 @@ def _identify_pre_periods(data: pd.DataFrame, time: str, treatment: str, unit: s
216217
217218
Returns
218219
-------
219-
tuple of (list, int)
220-
(pre_periods sorted, first_treat_time)
220+
tuple of (list, scalar)
221+
(pre_periods sorted, first_treat_time). The first-treatment time
222+
is kept on the native scale of the ``time`` column (integer,
223+
datetime64, ...), matching the LWDiD estimator.
221224
"""
222225
treated_times = data.loc[data[treatment] == 1, time].unique()
223226
if len(treated_times) == 0:
224227
raise ValueError("No treated observations found in the data.")
225228

226-
first_treat = int(min(treated_times))
229+
first_treat = min(treated_times)
227230
all_times = sorted(data[time].unique())
228231
pre_periods = [t for t in all_times if t < first_treat]
229232

@@ -372,7 +375,7 @@ def _placebo_pre_trends(
372375
pre_periods, first_treat = _identify_pre_periods(data, time, treatment, unit)
373376

374377
if len(pre_periods) < 2:
375-
raise ValueError(
378+
raise InsufficientPrePeriodsError(
376379
f"Need at least 2 pre-treatment periods for parallel trends test, "
377380
f"got {len(pre_periods)}."
378381
)
@@ -416,7 +419,7 @@ def _placebo_pre_trends(
416419
pval = 2 * (1 - stats.norm.cdf(abs(t_stat)))
417420
pre_effects.append(
418421
PreTrendEstimate(
419-
period=int(pseudo_post_start),
422+
period=pseudo_post_start,
420423
att=float(result.att),
421424
se=float(result.se),
422425
t_stat=float(t_stat),

docs/api/lwdid.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,25 @@ Results container returned by :meth:`~diff_diff.LWDiD.fit`.
269269
~LWDiDResults.to_dataframe
270270
~LWDiDResults.to_dict
271271

272+
Input Contract
273+
--------------
274+
275+
:meth:`~diff_diff.LWDiD.fit` validates the treatment design before any
276+
transformation is applied. Three requirements are enforced:
277+
278+
- **Absorbing treatment** — within each unit the ``treatment`` indicator
279+
must be non-decreasing over time: once a unit switches from 0 to 1 it
280+
must remain treated. Units that revert to 0 raise ``ValueError``.
281+
- **Common timing** — when ``first_treat`` is not supplied, all treated
282+
units must first switch on in the same period. Heterogeneous onsets
283+
are rejected with a ``ValueError`` pointing to the staggered interface
284+
(pass ``first_treat``).
285+
- **Staggered consistency** — when ``first_treat`` is supplied, the
286+
``treatment`` indicator must satisfy :math:`D_{it} = 1[t \ge g_i]`,
287+
where :math:`g_i` is the unit's first-treatment period. Units that are
288+
never treated (``first_treat`` coded NaN or 0) must have no treated
289+
rows.
290+
272291
Example Usage
273292
-------------
274293

tests/test_lwdid_trend_diagnostics.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@ def test_recommendation_summary(self, panel_data):
169169
assert isinstance(s, str)
170170
assert "RECOMMENDATION" in s
171171

172+
def test_datetime_time_scale(self):
173+
"""A datetime64 time column is handled on its native scale."""
174+
rng = np.random.default_rng(42)
175+
years = pd.date_range("2000-01-01", periods=8, freq="YS")
176+
records = []
177+
for i in range(80):
178+
d = int(i < 25)
179+
for k, t in enumerate(years, start=1):
180+
y = 1.0 + 0.1 * k + rng.normal(0, 0.3)
181+
if d and k > 4:
182+
y += 2.0
183+
records.append({"unit": i, "time": t, "y": y, "treat": d * int(k > 4)})
184+
df = pd.DataFrame(records)
185+
rec = recommend_transformation(df, outcome="y", unit="unit", time="time", treatment="treat")
186+
assert isinstance(rec, TransformationRecommendation)
187+
172188

173189
# ---------------------------------------------------------------------------
174190
# Edge cases

0 commit comments

Comments
 (0)