Skip to content

Commit c2aa92a

Browse files
authored
Merge pull request #5 from igerber/claude/code-review-session-fnLIe
2 parents 00d26c2 + b65b937 commit c2aa92a

3 files changed

Lines changed: 368 additions & 26 deletions

File tree

‎diff_diff/estimators.py‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ def fit(
169169
# Validate inputs
170170
self._validate_data(data, outcome, treatment, time, covariates)
171171

172+
# Validate binary variables BEFORE any transformations
173+
validate_binary(data[treatment].values, "treatment")
174+
validate_binary(data[time].values, "time")
175+
172176
# Validate fixed effects and absorb columns
173177
if fixed_effects:
174178
for fe in fixed_effects:
@@ -186,6 +190,9 @@ def fit(
186190

187191
if absorb:
188192
# Apply within-transformation for each absorbed variable
193+
# Only demean outcome and covariates, NOT treatment/time indicators
194+
# Treatment is typically time-invariant (within unit), and time is
195+
# unit-invariant, so demeaning them would create multicollinearity
189196
vars_to_demean = [outcome] + (covariates or [])
190197
for ab_var in absorb:
191198
n_absorbed_effects += working_data[ab_var].nunique() - 1
@@ -194,15 +201,11 @@ def fit(
194201
working_data[var] = working_data[var] - group_means
195202
absorbed_vars.append(ab_var)
196203

197-
# Extract variables
204+
# Extract variables (may be demeaned if absorb was used)
198205
y = working_data[outcome].values.astype(float)
199206
d = working_data[treatment].values.astype(float)
200207
t = working_data[time].values.astype(float)
201208

202-
# Validate binary variables
203-
validate_binary(d, "treatment")
204-
validate_binary(t, "time")
205-
206209
# Create interaction term
207210
dt = d * t
208211

@@ -220,7 +223,8 @@ def fit(
220223
if fixed_effects:
221224
for fe in fixed_effects:
222225
# Create dummies, drop first category to avoid multicollinearity
223-
dummies = pd.get_dummies(data[fe], prefix=fe, drop_first=True)
226+
# Use working_data to be consistent with absorbed FE if both are used
227+
dummies = pd.get_dummies(working_data[fe], prefix=fe, drop_first=True)
224228
for col in dummies.columns:
225229
X = np.column_stack([X, dummies[col].values.astype(float)])
226230
var_names.append(col)
@@ -298,7 +302,21 @@ def _fit_ols(self, X: np.ndarray, y: np.ndarray) -> tuple:
298302
-------
299303
tuple
300304
(coefficients, residuals, fitted_values, r_squared)
305+
306+
Raises
307+
------
308+
ValueError
309+
If design matrix is rank-deficient (perfect multicollinearity).
301310
"""
311+
# Check for rank deficiency (perfect multicollinearity)
312+
rank = np.linalg.matrix_rank(X)
313+
if rank < X.shape[1]:
314+
raise ValueError(
315+
f"Design matrix is rank-deficient (rank {rank} < {X.shape[1]} columns). "
316+
"This indicates perfect multicollinearity. Check your fixed effects "
317+
"and covariates for linear dependencies."
318+
)
319+
302320
# Solve normal equations: β = (X'X)^(-1) X'y
303321
coefficients = np.linalg.lstsq(X, y, rcond=None)[0]
304322

‎diff_diff/utils.py‎

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -218,13 +218,18 @@ def compute_trend(group_data):
218218
mean_t = np.mean(time_norm)
219219
mean_y = np.mean(outcome_values)
220220

221-
slope = np.sum((time_norm - mean_t) * (outcome_values - mean_y)) / np.sum((time_norm - mean_t) ** 2)
221+
# Check for zero variance in time (all same time period)
222+
time_var = np.sum((time_norm - mean_t) ** 2)
223+
if time_var == 0:
224+
return np.nan, np.nan
225+
226+
slope = np.sum((time_norm - mean_t) * (outcome_values - mean_y)) / time_var
222227

223228
# Compute standard error of slope
224229
y_hat = mean_y + slope * (time_norm - mean_t)
225230
residuals = outcome_values - y_hat
226231
mse = np.sum(residuals ** 2) / (n - 2)
227-
se_slope = np.sqrt(mse / np.sum((time_norm - mean_t) ** 2))
232+
se_slope = np.sqrt(mse / time_var)
228233

229234
return slope, se_slope
230235

@@ -258,7 +263,8 @@ def check_parallel_trends_robust(
258263
unit: str = None,
259264
pre_periods: list = None,
260265
n_permutations: int = 1000,
261-
seed: int = None
266+
seed: int = None,
267+
wasserstein_threshold: float = 0.2
262268
) -> dict:
263269
"""
264270
Perform robust parallel trends testing using distributional comparisons.
@@ -286,6 +292,9 @@ def check_parallel_trends_robust(
286292
Number of permutations for computing p-value.
287293
seed : int, optional
288294
Random seed for reproducibility.
295+
wasserstein_threshold : float, default=0.2
296+
Threshold for normalized Wasserstein distance. Values below this
297+
threshold (combined with p > 0.05) suggest parallel trends are plausible.
289298
290299
Returns
291300
-------
@@ -321,8 +330,8 @@ def check_parallel_trends_robust(
321330
of pre-treatment changes are similar, supporting the parallel trends
322331
assumption.
323332
"""
324-
if seed is not None:
325-
np.random.seed(seed)
333+
# Use local RNG to avoid affecting global random state
334+
rng = np.random.default_rng(seed)
326335

327336
# Identify pre-treatment periods
328337
if pre_periods is None:
@@ -361,7 +370,7 @@ def check_parallel_trends_robust(
361370

362371
permuted_distances = np.zeros(n_permutations)
363372
for i in range(n_permutations):
364-
perm_idx = np.random.permutation(n_total)
373+
perm_idx = rng.permutation(n_total)
365374
perm_treated = all_changes[perm_idx[:n_treated]]
366375
perm_control = all_changes[perm_idx[n_treated:]]
367376
permuted_distances[i] = stats.wasserstein_distance(perm_treated, perm_control)
@@ -383,10 +392,10 @@ def check_parallel_trends_robust(
383392
wasserstein_normalized = wasserstein_dist / pooled_std if pooled_std > 0 else np.nan
384393

385394
# Assessment: parallel trends plausible if p-value > 0.05
386-
# and normalized Wasserstein is small (< 0.2 as rule of thumb)
395+
# and normalized Wasserstein is small (below threshold)
387396
plausible = bool(
388397
wasserstein_p > 0.05 and
389-
(wasserstein_normalized < 0.2 if not np.isnan(wasserstein_normalized) else True)
398+
(wasserstein_normalized < wasserstein_threshold if not np.isnan(wasserstein_normalized) else True)
390399
)
391400

392401
return {
@@ -523,37 +532,82 @@ def equivalence_test_trends(
523532
pre_data, outcome, time, treatment_group, unit
524533
)
525534

535+
# Need at least 2 observations per group to compute variance
536+
# and at least 3 total for meaningful df calculation
526537
if len(treated_changes) < 2 or len(control_changes) < 2:
527538
return {
528539
"mean_difference": np.nan,
540+
"se_difference": np.nan,
529541
"equivalence_margin": np.nan,
542+
"lower_t_stat": np.nan,
543+
"upper_t_stat": np.nan,
530544
"lower_p_value": np.nan,
531545
"upper_p_value": np.nan,
532546
"tost_p_value": np.nan,
547+
"degrees_of_freedom": np.nan,
533548
"equivalent": None,
534-
"error": "Insufficient data",
549+
"error": "Insufficient data (need at least 2 observations per group)",
535550
}
536551

537552
# Compute statistics
553+
var_t = np.var(treated_changes, ddof=1)
554+
var_c = np.var(control_changes, ddof=1)
555+
n_t = len(treated_changes)
556+
n_c = len(control_changes)
557+
538558
mean_diff = np.mean(treated_changes) - np.mean(control_changes)
539-
se_diff = np.sqrt(
540-
np.var(treated_changes, ddof=1) / len(treated_changes) +
541-
np.var(control_changes, ddof=1) / len(control_changes)
542-
)
559+
560+
# Handle zero variance case
561+
if var_t == 0 and var_c == 0:
562+
return {
563+
"mean_difference": mean_diff,
564+
"se_difference": 0.0,
565+
"equivalence_margin": np.nan,
566+
"lower_t_stat": np.nan,
567+
"upper_t_stat": np.nan,
568+
"lower_p_value": np.nan,
569+
"upper_p_value": np.nan,
570+
"tost_p_value": np.nan,
571+
"degrees_of_freedom": np.nan,
572+
"equivalent": None,
573+
"error": "Zero variance in both groups - cannot perform t-test",
574+
}
575+
576+
se_diff = np.sqrt(var_t / n_t + var_c / n_c)
577+
578+
# Handle zero SE case (cannot divide by zero in t-stat calculation)
579+
if se_diff == 0:
580+
return {
581+
"mean_difference": mean_diff,
582+
"se_difference": 0.0,
583+
"equivalence_margin": np.nan,
584+
"lower_t_stat": np.nan,
585+
"upper_t_stat": np.nan,
586+
"lower_p_value": np.nan,
587+
"upper_p_value": np.nan,
588+
"tost_p_value": np.nan,
589+
"degrees_of_freedom": np.nan,
590+
"equivalent": None,
591+
"error": "Zero standard error - cannot perform t-test",
592+
}
543593

544594
# Set equivalence margin if not provided
545595
if equivalence_margin is None:
546596
pooled_changes = np.concatenate([treated_changes, control_changes])
547597
equivalence_margin = 0.5 * np.std(pooled_changes, ddof=1)
548598

549599
# Degrees of freedom (Welch-Satterthwaite approximation)
550-
var_t = np.var(treated_changes, ddof=1)
551-
var_c = np.var(control_changes, ddof=1)
552-
n_t = len(treated_changes)
553-
n_c = len(control_changes)
554-
555-
df = ((var_t/n_t + var_c/n_c)**2 /
556-
((var_t/n_t)**2/(n_t-1) + (var_c/n_c)**2/(n_c-1)))
600+
# Guard against division by zero when one group has zero variance
601+
numerator = (var_t/n_t + var_c/n_c)**2
602+
denom_t = (var_t/n_t)**2/(n_t-1) if var_t > 0 else 0
603+
denom_c = (var_c/n_c)**2/(n_c-1) if var_c > 0 else 0
604+
denominator = denom_t + denom_c
605+
606+
if denominator == 0:
607+
# Fall back to minimum of n_t-1 and n_c-1 when one variance is zero
608+
df = min(n_t - 1, n_c - 1)
609+
else:
610+
df = numerator / denominator
557611

558612
# TOST: Two one-sided tests
559613
# Test 1: H0: diff <= -margin vs H1: diff > -margin

0 commit comments

Comments
 (0)