@@ -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
269271def 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
0 commit comments