Skip to content

Commit 493a432

Browse files
authored
Merge pull request #11 from igerber/claude/add-data-prep-utilities-4Unoc
Address code review feedback for data prep utilities
2 parents 022b721 + 40fc41f commit 493a432

2 files changed

Lines changed: 78 additions & 38 deletions

File tree

diff_diff/prep.py

Lines changed: 64 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -249,21 +249,23 @@ def wide_to_long(
249249
# Get other columns to preserve (not id or value columns)
250250
other_cols = [c for c in data.columns if c != id_column and c not in value_columns]
251251

252-
# Build long format
253-
records = []
254-
for _, row in data.iterrows():
255-
for time_val, value_col in zip(time_values, value_columns):
256-
record = {id_column: row[id_column], time_name: time_val, value_name: row[value_col]}
257-
# Preserve other columns
258-
for col in other_cols:
259-
record[col] = row[col]
260-
records.append(record)
252+
# Use pd.melt for better performance (vectorized)
253+
long_df = pd.melt(
254+
data,
255+
id_vars=[id_column] + other_cols,
256+
value_vars=value_columns,
257+
var_name='_temp_var',
258+
value_name=value_name
259+
)
261260

262-
long_df = pd.DataFrame(records)
261+
# Map column names to time values
262+
col_to_time = dict(zip(value_columns, time_values))
263+
long_df[time_name] = long_df['_temp_var'].map(col_to_time)
264+
long_df = long_df.drop('_temp_var', axis=1)
263265

264-
# Reorder columns
266+
# Reorder columns and sort
265267
cols = [id_column, time_name, value_name] + other_cols
266-
return long_df[cols]
268+
return long_df[cols].sort_values([id_column, time_name]).reset_index(drop=True)
267269

268270

269271
def balance_panel(
@@ -347,18 +349,21 @@ def balance_panel(
347349
result = full_df.merge(data, on=[unit_column, time_column], how="left")
348350

349351
if method == "fill":
352+
# Identify columns to fill (exclude unit and time columns)
353+
cols_to_fill = [c for c in result.columns if c not in [unit_column, time_column]]
354+
350355
if fill_value is not None:
351-
# Fill all numeric columns with fill_value
356+
# Fill specified columns with fill_value
352357
numeric_cols = result.select_dtypes(include=[np.number]).columns
353358
for col in numeric_cols:
354-
if col not in [unit_column, time_column]:
359+
if col in cols_to_fill:
355360
result[col] = result[col].fillna(fill_value)
356361
else:
357-
# Forward fill within each unit
362+
# Forward fill within each unit for non-key columns
358363
result = result.sort_values([unit_column, time_column])
359-
result = result.groupby(unit_column).ffill()
364+
result[cols_to_fill] = result.groupby(unit_column)[cols_to_fill].ffill()
360365
# Backward fill any remaining NaN at start
361-
result = result.groupby(unit_column).bfill()
366+
result[cols_to_fill] = result.groupby(unit_column)[cols_to_fill].bfill()
362367

363368
return result
364369

@@ -481,13 +486,25 @@ def validate_did_data(
481486
errors.append("No control observations found (treatment column is all 1).")
482487

483488
# Check for each treatment-time combination
484-
for t_val in [0, 1]:
485-
for p_val in [0, 1] if len(time_vals) == 2 else time_vals[:2]:
486-
count = len(data[(data[treatment] == t_val) & (data[time] == p_val)])
487-
if count == 0:
489+
if len(time_vals) == 2:
490+
# For 2-period DiD, check all four cells
491+
for t_val in [0, 1]:
492+
for p_val in time_vals:
493+
count = len(data[(data[treatment] == t_val) & (data[time] == p_val)])
494+
if count == 0:
495+
errors.append(
496+
f"No observations for treatment={t_val}, time={p_val}. "
497+
"DiD requires observations in all treatment-time cells."
498+
)
499+
else:
500+
# For multi-period, check that both treatment groups exist in multiple periods
501+
for t_val in [0, 1]:
502+
n_periods_with_obs = data[data[treatment] == t_val][time].nunique()
503+
if n_periods_with_obs < 2:
504+
group_name = "Treated" if t_val == 1 else "Control"
488505
errors.append(
489-
f"No observations for treatment={t_val}, time={p_val}. "
490-
"DiD requires observations in all treatment-time cells."
506+
f"{group_name} group has observations in only {n_periods_with_obs} period(s). "
507+
"DiD requires multiple periods per group."
491508
)
492509

493510
# Panel-specific validation
@@ -571,24 +588,25 @@ def summarize_did_data(
571588
("max", "max")
572589
]).round(4)
573590

574-
# Add group labels
575-
summary.index = summary.index.map(
576-
lambda x: f"{'Treated' if x[0] == 1 else 'Control'} - "
577-
f"{'Post' if x[1] == 1 else 'Pre'}"
578-
if len(data[time].unique()) == 2
579-
else f"{'Treated' if x[0] == 1 else 'Control'} - Period {x[1]}"
580-
)
581-
582-
# Calculate DiD components if binary time
591+
# Calculate time values for labeling
583592
time_vals = sorted(data[time].unique())
593+
594+
# Add group labels based on sorted time values (not literal 0/1)
584595
if len(time_vals) == 2:
585-
pre, post = time_vals[0], time_vals[1]
596+
pre_val, post_val = time_vals[0], time_vals[1]
597+
598+
def format_label(x):
599+
treatment_label = 'Treated' if x[0] == 1 else 'Control'
600+
time_label = 'Post' if x[1] == post_val else 'Pre'
601+
return f"{treatment_label} - {time_label}"
602+
603+
summary.index = summary.index.map(format_label)
586604

587605
# Calculate means for each cell
588-
treated_pre = data[(data[treatment] == 1) & (data[time] == pre)][outcome].mean()
589-
treated_post = data[(data[treatment] == 1) & (data[time] == post)][outcome].mean()
590-
control_pre = data[(data[treatment] == 0) & (data[time] == pre)][outcome].mean()
591-
control_post = data[(data[treatment] == 0) & (data[time] == post)][outcome].mean()
606+
treated_pre = data[(data[treatment] == 1) & (data[time] == pre_val)][outcome].mean()
607+
treated_post = data[(data[treatment] == 1) & (data[time] == post_val)][outcome].mean()
608+
control_pre = data[(data[treatment] == 0) & (data[time] == pre_val)][outcome].mean()
609+
control_post = data[(data[treatment] == 0) & (data[time] == post_val)][outcome].mean()
592610

593611
# Calculate DiD
594612
treated_diff = treated_post - treated_pre
@@ -607,6 +625,10 @@ def summarize_did_data(
607625
index=["DiD Estimate"]
608626
)
609627
summary = pd.concat([summary, did_row])
628+
else:
629+
summary.index = summary.index.map(
630+
lambda x: f"{'Treated' if x[0] == 1 else 'Control'} - Period {x[1]}"
631+
)
610632

611633
return summary
612634

@@ -776,7 +798,11 @@ def create_event_time(
776798
df[new_column] = df[time_column] - df[treatment_time_column]
777799

778800
# Handle never-treated (inf or NaN in treatment time)
779-
never_treated = df[treatment_time_column].isna() | np.isinf(df[treatment_time_column])
801+
col = df[treatment_time_column]
802+
if pd.api.types.is_numeric_dtype(col):
803+
never_treated = col.isna() | np.isinf(col)
804+
else:
805+
never_treated = col.isna()
780806
df.loc[never_treated, new_column] = np.nan
781807

782808
return df

tests/test_prep.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,20 @@ def test_fill_with_value(self):
229229
missing_row = result[(result["unit"] == 2) & (result["period"] == 2)]
230230
assert missing_row["y"].values[0] == 0.0
231231

232+
def test_fill_forward_backward(self):
233+
"""Test fill method with forward/backward fill."""
234+
df = pd.DataFrame({
235+
"unit": [1, 1, 1, 2, 2],
236+
"period": [1, 2, 3, 1, 3], # Unit 2 missing period 2
237+
"y": [10.0, 11.0, 12.0, 20.0, 22.0]
238+
})
239+
result = balance_panel(df, "unit", "period", method="fill", fill_value=None)
240+
assert len(result) == 6
241+
# Check that unit 2, period 2 was filled
242+
filled_row = result[(result["unit"] == 2) & (result["period"] == 2)]
243+
assert len(filled_row) == 1
244+
assert filled_row["y"].values[0] == 20.0 # Forward filled from period 1
245+
232246
def test_error_invalid_method(self):
233247
"""Test error with invalid method."""
234248
df = pd.DataFrame({"unit": [1], "period": [1], "y": [10]})

0 commit comments

Comments
 (0)