*! diddesign_check.ado - Diagnostic tests for parallel trends assumption *! version 1.0.2 03jul2026 *! *! Implements placebo tests for assessing the parallel trends assumption in *! difference-in-differences designs. Computes standardized pre-treatment DID *! estimates and equivalence confidence intervals for both standard DID and *! staggered adoption designs. program define diddesign_check, eclass version 16.0 // Clear stale estimation results before any validation or setup failure. ereturn clear // ------------------------------------------------------------------------- // Load Mata Functions // ------------------------------------------------------------------------- capture mata: _did_check_tail_loaded() if _rc != 0 { local mata_loaded = 0 // Method 1: Direct findfile for diddesign_mata.do (works after net install) qui capture findfile diddesign_mata.do if _rc == 0 { quietly do "`r(fn)'" local mata_loaded = 1 } // Method 2: Relative path from ado file (works in development environment) if !`mata_loaded' { qui capture findfile diddesign_check.ado if _rc == 0 { local ado_path = subinstr("`r(fn)'", char(92), "/", .) local ado_dir = reverse(substr(reverse("`ado_path'"), strpos(reverse("`ado_path'"), "/") + 1, .)) local mata_path "`ado_dir'/../mata/diddesign_mata.do" capture confirm file "`mata_path'" if _rc == 0 { quietly do "`mata_path'" local mata_loaded = 1 } } } // Verify loading succeeded if !`mata_loaded' { capture mata: _did_check_tail_loaded() if _rc != 0 { display as error "E015: DIDdesign Mata library not loaded" display as error " Please use 'diddesign' command which auto-loads the library" display as error " Or reinstall: ssc install diddesign, replace" exit 499 } } } // ------------------------------------------------------------------------- // Syntax Parsing // ------------------------------------------------------------------------- // Supports both panel data (with id()) and repeated cross-section (with post()) // Standardization is always performed, returning both standardized and raw estimates syntax anything(name=rawvars) [if] [in], /// TREATment(varname) /// Required: treatment indicator TIME(varname) /// Required: time identifier [ID(varname)] /// Unit identifier (required for panel) [POST(varname)] /// Post-treatment indicator (required for RCS) [DESIGN(string)] /// Design type: "did" (default) or "sa" [PANEL] /// Panel data format [RCS] /// Repeated cross-section format [CLuster(varname)] /// Cluster variable for SEs [NBoot(integer 30)] /// Bootstrap iterations; default is 30 [LAG(numlist >=0 integer)] /// Lag values for placebo tests [THRes(integer 2)] /// SA threshold; default is 2 [PARALlel] /// Use parallel computing [SEED(integer -1)] /// Random seed (-1 = not specified) [QUIET] /// Suppress progress display // Store full command line for e(cmdline) local cmdline "diddesign_check `0'" local id_orig "`id'" local time_orig "`time'" local post_orig "`post'" // ------------------------------------------------------------------------- // Parse Variable List // ------------------------------------------------------------------------- // The varlist contains the outcome variable followed by optional covariates gettoken depvar covariates : rawvars // Handle covariates (may be empty) if "`covariates'" == "" { local covars_str "" } else { local covars_str "`covariates'" } // ------------------------------------------------------------------------- // Duplicate Covariate Check // ------------------------------------------------------------------------- // Remove duplicate covariates to ensure proper model specification if "`covars_str'" != "" { local unique_covars : list uniq covars_str local n_all : word count `covars_str' local n_unique : word count `unique_covars' if `n_unique' < `n_all' { // Find duplicate variables by comparing original and unique lists local dups "" local seen "" foreach v of local covars_str { local is_seen : list v in seen if `is_seen' { local is_dup : list v in dups if !`is_dup' { local dups "`dups' `v'" } } local seen "`seen' `v'" } local dups = strtrim("`dups'") display as text "Warning: Duplicate covariates detected and removed: `dups'" local covars_str "`unique_covars'" } } local covars_spec : list retokenize covars_str // ------------------------------------------------------------------------- // Early Validation: Treatment Variable // ------------------------------------------------------------------------- // Verify treatment is numeric before expensive operations. capture confirm numeric variable `treatment' if _rc { display as error "E017: Variable `treatment' must be numeric" exit _rc } // ------------------------------------------------------------------------- // Factor Variable Expansion // ------------------------------------------------------------------------- // Expand factor variables (i.var, ibn.var) into dummy variables // Base category is excluded to avoid collinearity with intercept if "`covars_str'" != "" { // Create temporary sample marker for factor expansion marksample touse_temp, novarlist markout `touse_temp' `treatment' `time', strok if "`id'" != "" { markout `touse_temp' `id', strok } quietly _diddesign_expand_covariates, covars(`covars_str') touse(`touse_temp') local covars_str "`r(varlist)'" local generated_covariates "`r(generated_vars)'" local encoded_string_covariates "`r(encoded_sources)'" local n_fv_expanded = r(n_factor_expanded) foreach covar_name of local encoded_string_covariates { display as text "Note: String factor covariate `covar_name' automatically encoded to numeric" } if `n_fv_expanded' > 0 { display as text "Note: Factor variables expanded to `n_fv_expanded' dummy variables (base/constant columns excluded)" } } // ------------------------------------------------------------------------- // Set Default Values // ------------------------------------------------------------------------- // Default design: standard DID if "`design'" == "" { local design "did" } else { local design = lower("`design'") } local thres_specified = strpos(lower("`0'"), "thres(") > 0 if `thres_specified' & "`design'" != "sa" { display as error "E002: thres() is only allowed with design(sa)" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Default lag: 1 period if "`lag'" == "" { local lag "1" } local unique_lags : list uniq lag local n_lag_all : word count `lag' local n_lag_unique : word count `unique_lags' if `n_lag_unique' < `n_lag_all' { local duplicate_lags "" local seen_lags "" foreach l of numlist `lag' { local lag_token "`l'" local already_seen : list lag_token in seen_lags if `already_seen' { local already_listed : list lag_token in duplicate_lags if !`already_listed' { local duplicate_lags "`duplicate_lags' `lag_token'" } } else { local seen_lags "`seen_lags' `lag_token'" } } local duplicate_lags = strtrim("`duplicate_lags'") display as error "E002: Option lag() contains duplicate values: `duplicate_lags'" display as error " Each placebo lag may be requested at most once" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Bootstrap iterations: defaults to 30 local nboot_val = `nboot' // Staggered adoption threshold: defaults to 2 local thres_val = `thres' // Random seed: -1 indicates user did not specify if `seed' == -1 { local seed_val = . } else { local seed_val = `seed' } // Quiet option - suppress bootstrap progress display local quiet_val = 0 if "`quiet'" != "" { local quiet_val = 1 } // Parallel option is not yet implemented; bootstrap runs sequentially if "`parallel'" != "" { display as text "{p 0 4 2}" display as text "Note: The {bf:parallel} option is currently not implemented. " display as text "Bootstrap iterations will run sequentially.{p_end}" } // ------------------------------------------------------------------------- // Validate Data Type // ------------------------------------------------------------------------- // Determine data type: panel vs repeated cross-section (RCS) // Panel and rcs options are mutually exclusive local is_panel_opt = ("`panel'" != "") local is_rcs_opt = ("`rcs'" != "") if "`id'" != "" & "`post'" != "" { display as error "E016: Options id() and post() are mutually exclusive" display as error " Use id() for panel data or post() for repeated cross-section data" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } if `is_panel_opt' & `is_rcs_opt' { display as error "E016: Options panel and rcs are mutually exclusive" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Determine data type (auto-detect if neither specified) local is_panel = 0 if `is_panel_opt' { local is_panel = 1 } else if `is_rcs_opt' { local is_panel = 0 } else { // Auto-detect: panel if id() is specified, RCS if post() is specified if "`id'" != "" { local is_panel = 1 } else if "`post'" != "" { local is_panel = 0 } else { // Neither id() nor post() specified - require explicit choice display as error "E016: Must specify id() for panel data or post() for RCS data" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } } if `is_panel' & "`post'" != "" { display as error "E016: Option post() is only valid for RCS data" display as error " Remove post() or re-run without panel/id()" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } if !`is_panel' & "`id'" != "" { display as error "E016: Option id() is only valid for panel data" display as error " Remove id() or re-run with the panel option" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Validate required options based on data type if `is_panel' { // Panel data requires id() if "`id'" == "" { display as error "E001: Option id() is required for panel data" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } } else { // RCS data requires post() to identify treatment timing if "`post'" == "" { display as error "E001: Option post() is required for RCS data" display as error " Specify the post-treatment indicator variable" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } } // ------------------------------------------------------------------------- // Validate Parameters // ------------------------------------------------------------------------- // nboot >= 2 required for variance estimation (denominator is n_boot - 1) if `nboot_val' < 2 { display as error "E002: Option nboot() must be at least 2 for variance estimation" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Validate thres >= 1 (only used in SA design) if `thres_val' < 1 { display as error "E002: Option thres() must be a positive integer >= 1" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // Validate seed range if specified (must be in [0, 2147483647]) if `seed' != -1 & (`seed' < 0 | `seed' > 2147483647) { display as error "E002: Option seed() must be a valid integer (0 to 2147483647)" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // ------------------------------------------------------------------------- // Validate Design Type // ------------------------------------------------------------------------- if !inlist("`design'", "did", "sa") { display as error "E002: design() must be 'did' or 'sa'" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // ------------------------------------------------------------------------- // Handle Cluster Variable // ------------------------------------------------------------------------- // For panel data, default cluster to unit identifier if not specified local clustvar "`cluster'" if "`clustvar'" == "" & `is_panel' & "`id'" != "" { local clustvar "`id'" } local clustvar_report "`cluster'" if "`clustvar_report'" == "" { if `is_panel' & "`id'" != "" { local clustvar_report "`id'" } else if !`is_panel' { display as error "E018: cluster() is required for RCS data" display as error " Specify cluster() at the treatment-assignment level for bootstrap inference" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } } // ------------------------------------------------------------------------- // Validate SA Design Requires Panel Data // ------------------------------------------------------------------------- // Staggered adoption design only supports panel data structure if "`design'" == "sa" & !`is_panel' { display as error "E014: SA design requires panel data" display as error " Only the standard DID design supports RCS data" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // ------------------------------------------------------------------------- // Mark Sample // ------------------------------------------------------------------------- marksample touse, novarlist markout `touse' `depvar' `treatment' `time', strok // Panel data: mark out missing id if `is_panel' & "`id'" != "" { markout `touse' `id', strok } // RCS data: mark out missing post indicator if !`is_panel' & "`post'" != "" { markout `touse' `post', strok } // cluster() affects placebo/bootstrap inference, not the diagnostic sample. // Missing cluster values are handled in the cluster-support guards below. // Keep covariate-missing rows in the diagnostic sample so baseline control // standardization uses the full outcome distribution. Regression-stage // listwise deletion is handled in Mata. // Count observations quietly count if `touse' local N = r(N) if `N' == 0 { display as error "E003: No observations" _ddcheck_cleanup, vars(`generated_covariates') exit 2000 } // ------------------------------------------------------------------------- // Canonicalize Treatment Variable // ------------------------------------------------------------------------- // Near-binary treatment values within tolerance are mapped to exact 0/1 // before any downstream validation or Mata calls. local treatment_orig "`treatment'" tempvar treatment_work quietly gen double `treatment_work' = . if `touse' quietly replace `treatment_work' = 0 if abs(`treatment_orig') < 1e-6 & `touse' quietly replace `treatment_work' = 1 if abs(`treatment_orig' - 1) < 1e-6 & `touse' quietly count if missing(`treatment_work') & `touse' if r(N) > 0 { display as error "E003: Treatment variable must be binary (0/1)" display as error " Found `r(N)' observations outside the 1e-6 tolerance around 0/1" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } quietly count if `treatment_work' == 1 & `touse' if r(N) == 0 { display as error "E003: No treated observations found in data (treatment all 0)" display as error " Placebo tests require at least one treated unit" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } quietly count if `treatment_work' == 0 & `touse' if r(N) == 0 { display as error "E003: No control observations found in data (treatment all 1)" display as error " Placebo tests require at least one control unit" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } local treatment "`treatment_work'" // ------------------------------------------------------------------------- // Compute Number of Clusters // ------------------------------------------------------------------------- if "`clustvar'" != "" { tempvar cluster_tag quietly egen `cluster_tag' = tag(`clustvar') if `touse' & !missing(`clustvar') quietly count if `cluster_tag' == 1 & `touse' & !missing(`clustvar') local n_clusters = r(N) } else { local n_clusters = `N' } // Cluster bootstrap inference requires at least two distinct clusters. if "`clustvar'" != "" & `n_clusters' < 2 { display as error "E003: At least 2 clusters are required for cluster bootstrap inference" display as error " Found only `n_clusters' unique cluster in the estimation sample" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } // ------------------------------------------------------------------------- // Set Random Seed // ------------------------------------------------------------------------- if `seed_val' != . { set seed `seed_val' } // ------------------------------------------------------------------------- // Validate Variables // ------------------------------------------------------------------------- // Validate outcome variable capture confirm numeric variable `depvar' if _rc { display as error "E017: Variable `depvar' must be numeric" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } // Validate treatment variable capture confirm numeric variable `treatment' if _rc { display as error "E017: Variable `treatment' must be numeric" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } // Validate id variable (only for panel data) // String variables are automatically encoded to numeric if `is_panel' & "`id'" != "" { capture confirm variable `id' if _rc { display as error "E001: Variable `id' not found" _ddcheck_cleanup, vars(`generated_covariates') exit 111 } // Check if string variable - auto convert using egen group() capture confirm string variable `id' if _rc == 0 { // String variable detected - create numeric encoding tempvar id_encoded quietly egen `id_encoded' = group(`id') display as text "Note: String variable `id' automatically encoded to numeric" local id "`id_encoded'" } else { capture confirm numeric variable `id' if _rc { display as error "E017: Variable `id' must be numeric or string" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } } } // Validate time variable // String time variables are encoded to numeric period indices using the // same sorted-factor-order contract as the main diddesign command and the // reference R implementation. capture confirm variable `time' if _rc { display as error "E001: Variable `time' not found" _ddcheck_cleanup, vars(`generated_covariates') exit 111 } capture confirm string variable `time' if _rc == 0 { local time_order_mismatch_n = 0 tempvar time_guard_touse quietly gen byte `time_guard_touse' = `touse' if "`covars_str'" != "" { quietly markout `time_guard_touse' `covars_str' } preserve quietly keep if `time_guard_touse' keep `time' tempvar time_first_idx time_alpha_idx time_seen_idx tempvar time_num_suffix time_prefix time_suffix_tag time_suffix_prefix_tag quietly gen long `time_first_idx' = _n quietly bysort `time' (`time_first_idx'): keep if _n == 1 quietly egen long `time_alpha_idx' = group(`time') quietly sort `time_first_idx' quietly gen long `time_seen_idx' = _n quietly count if `time_alpha_idx' != `time_seen_idx' local time_order_mismatch_n = r(N) quietly gen double `time_num_suffix' = . quietly replace `time_num_suffix' = real(regexs(1)) if regexm(`time', "([0-9]+)$") quietly gen str244 `time_prefix' = "" quietly replace `time_prefix' = regexr(`time', "[0-9]+$", "") if regexm(`time', "([0-9]+)$") quietly egen byte `time_suffix_tag' = tag(`time') quietly count if missing(`time_num_suffix') & `time_suffix_tag' == 1 local time_suffix_missing_n = r(N) local time_suffix_prefix_count = 0 local time_suffix_order_mismatch_n = 0 if `time_suffix_missing_n' == 0 { quietly egen byte `time_suffix_prefix_tag' = tag(`time_prefix') if `time_suffix_tag' == 1 quietly count if `time_suffix_prefix_tag' == 1 local time_suffix_prefix_count = r(N) if `time_suffix_prefix_count' == 1 { quietly sort `time_num_suffix' `time' quietly gen long time_numeric_idx = _n quietly count if `time_alpha_idx' != time_numeric_idx local time_suffix_order_mismatch_n = r(N) } } restore if `time_order_mismatch_n' > 0 | `time_suffix_order_mismatch_n' > 0 { display as error "E002: Ambiguous string time order detected for `time'" display as error " Automatic encoding would reorder observed time labels lexicographically" display as error " Recode time() to numeric or lexically ordered strings before estimation" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } tempvar time_encoded quietly egen `time_encoded' = group(`time') display as text "Note: String variable `time' automatically encoded to numeric" local time "`time_encoded'" } else { capture confirm numeric variable `time' if _rc { display as error "E017: Variable `time' must be numeric or string" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } } // Validate that panel samples still contain at least one control unit // after any if/in restriction is applied and after string IDs are encoded. if `is_panel' & "`id'" != "" { tempvar did_dup_cell quietly bysort `id' `time': gen byte `did_dup_cell' = (_N > 1) if `touse' quietly count if `did_dup_cell' == 1 & `touse' if r(N) > 0 { display as error "E003: Panel data must be uniquely identified by id() and time()" display as error " Found duplicate unit-time observations in the estimation sample" _ddcheck_cleanup, vars(`generated_covariates') exit 459 } if "`design'" == "sa" { tempvar did_unit_tag did_time_tag quietly egen `did_unit_tag' = tag(`id') if `touse' quietly count if `did_unit_tag' == 1 & `touse' local n_units_sa = r(N) quietly egen `did_time_tag' = tag(`time') if `touse' quietly count if `did_time_tag' == 1 & `touse' local n_periods_sa = r(N) local expected_cells_sa = `n_units_sa' * `n_periods_sa' if `N' != `expected_cells_sa' { display as error "E003: SA design requires a balanced panel with one observation per id() x time() cell" display as error " Found `N' observations but expected `expected_cells_sa' from `n_units_sa' units x `n_periods_sa' periods" display as error " Resolve missing or duplicated unit-time cells before using design(sa)" _ddcheck_cleanup, vars(`generated_covariates') exit 459 } } if "`design'" == "did" { tempvar treat_lag treat_diff quietly bysort `id' (`time'): gen double `treat_lag' = `treatment'[_n-1] if `touse' quietly gen double `treat_diff' = `treatment' - `treat_lag' if `touse' & `treat_lag' < . quietly count if `treat_diff' < 0 & `touse' if r(N) > 0 { display as error "E003: Treatment variable must be cumulative (absorbing)" display as error " Found `r(N)' observations with treatment decreasing over time" display as error " Standard DID placebo checks require treatment to remain 1 once it starts" _ddcheck_cleanup, vars(`generated_covariates') exit 459 } tempvar first_treat_obs first_treat_unit unit_treat_tag quietly gen double `first_treat_obs' = `time' if `treatment' == 1 & `touse' quietly egen double `first_treat_unit' = min(`first_treat_obs') if `touse', by(`id') quietly egen `unit_treat_tag' = tag(`id') if `touse' & `first_treat_unit' < . quietly levelsof `first_treat_unit' if `unit_treat_tag' == 1 & `touse', local(first_treat_levels) local n_treat_times : word count `first_treat_levels' if `n_treat_times' > 1 { display as error "E003: Staggered adoption detected under the standard DID design" display as error " Treated units do not share a common treatment adoption time" display as error " Re-run with design(sa) for staggered-adoption placebo checks" _ddcheck_cleanup, vars(`generated_covariates') exit 459 } } tempvar Gi_check quietly egen `Gi_check' = max(`treatment') if `touse', by(`id') replace `Gi_check' = round(`Gi_check', 1) if `touse' quietly count if `Gi_check' == 0 & `touse' if r(N) == 0 { display as error "E003: No control units found in data (all units eventually treated)" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } } // Validate cluster variable // String variables are automatically encoded to numeric if "`clustvar'" != "" { capture confirm variable `clustvar' if _rc { display as error "E001: Variable `clustvar' not found" _ddcheck_cleanup, vars(`generated_covariates') exit 111 } capture confirm string variable `clustvar' if _rc == 0 { tempvar cluster_encoded quietly egen `cluster_encoded' = group(`clustvar') display as text "Note: String variable `clustvar' automatically encoded to numeric" local clustvar "`cluster_encoded'" } else { capture confirm numeric variable `clustvar' if _rc { display as error "E017: Variable `clustvar' must be numeric or string" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } } } // Validate post variable (only for RCS data) if !`is_panel' & "`post'" != "" { capture confirm numeric variable `post' if _rc { display as error "E017: Variable `post' must be numeric" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } tempvar post_work quietly gen double `post_work' = . if `touse' quietly replace `post_work' = 0 if abs(`post') < 1e-6 & `touse' quietly replace `post_work' = 1 if abs(`post' - 1) < 1e-6 & `touse' quietly count if missing(`post_work') & `touse' if r(N) > 0 { display as error "E003: Post-treatment indicator must be binary (0/1)" display as error " Found `r(N)' observations outside the 1e-6 tolerance around 0/1" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } quietly tab `post_work' if `touse' if r(r) > 2 { display as error "E003: Post-treatment indicator must be binary (0/1)" display as error " Found " r(r) " distinct values after tolerance canonicalization (expected 2)" _ddcheck_cleanup, vars(`generated_covariates') exit 198 } quietly summarize `post_work' if `touse' local post_min_round = r(min) local post_max_round = r(max) // Ensure both 0 and 1 values exist for valid DID estimation if r(min) == r(max) { if `post_min_round' == 0 { display as error "E003: Post-treatment indicator is all 0 (no post-treatment observations)" display as error " Placebo tests require at least one post-treatment period" } else { display as error "E003: Post-treatment indicator is all 1 (no pre-treatment observations)" display as error " Placebo tests require at least one pre-treatment period" } _ddcheck_cleanup, vars(`generated_covariates') exit 198 } local post "`post_work'" } // Validate covariates if "`covars_str'" != "" { foreach var of local covars_str { capture confirm numeric variable `var' if _rc { display as error "E017: Variable `var' must be numeric" _ddcheck_cleanup, vars(`generated_covariates') exit _rc } } } // ------------------------------------------------------------------------- // Validate SA Design Requires Absorbing Treatment Paths // ------------------------------------------------------------------------- // Once treatment starts in the staggered adoption design, it must remain on // for all subsequent periods within the same unit. if `is_panel' { tempvar treat_bin treat_lag treat_diff quietly gen double `treat_bin' = round(`treatment', 1e-6) if `touse' quietly bysort `id' (`time'): gen double `treat_lag' = `treat_bin'[_n-1] if `touse' quietly gen double `treat_diff' = `treat_bin' - `treat_lag' if `touse' & `treat_lag' < . quietly count if `treat_diff' < 0 & `touse' if r(N) > 0 { display as error "E003: Treatment variable must be cumulative (absorbing)" display as error " Found `r(N)' observations with treatment decreasing over time" display as error " Panel DID designs require treatment to only transition from 0 to 1" _ddcheck_cleanup, vars(`generated_covariates') exit 459 } } // ------------------------------------------------------------------------- // Call Mata for Computation // ------------------------------------------------------------------------- // Convert lag numlist to Mata format local lag_mata = subinstr("`lag'", " ", ", ", .) local n_lags : word count `lag' // Set id_var for Mata (empty string for RCS) local id_var = "" if `is_panel' & "`id'" != "" { local id_var "`id'" } // Set post_var for Mata (empty string for panel) local post_var = "" if !`is_panel' & "`post'" != "" { local post_var "`post'" } // Call Mata main function for placebo test computation mata: _diddesign_check_main( /// "`depvar'", /// "`treatment'", /// "`id_var'", /// "`time'", /// "`post_var'", /// "`covars_str'", /// "`clustvar'", /// "`touse'", /// "`design'", /// (`lag_mata'), /// `nboot_val', /// `thres_val', /// `is_panel', /// `quiet_val' /// ) // ------------------------------------------------------------------------- // Store e() Returns // ------------------------------------------------------------------------- // Retrieve scalar results before touching result matrices. Stata does not // materialize 0 x k Mata matrices as named matrices, so zero-lag cases // must be handled before st_matrix()/matrix rownames calls. mata: st_local("n_lags_valid", strofreal(_check_n_lags)) mata: st_local("n_boot_valid", strofreal(_check_n_boot_valid)) mata: st_local("filtered_lags", _check_filtered_lags) mata: st_local("max_preperiods", strofreal(_check_max_preperiods)) mata: st_local("check_no_valid_periods", strofreal(_check_no_valid_periods)) if "`design'" == "sa" & `check_no_valid_periods' == 1 { display as error "E011: SA placebo check failed - no valid periods found" display as error " Try reducing the threshold value (thres option)" _ddcheck_cleanup, vars(`generated_covariates') exit 498 } if `n_lags_valid' == 0 { local max_preperiods_int = int(`max_preperiods') local max_requested_lag = 0 foreach lag_val of numlist `lag' { if `lag_val' > `max_requested_lag' { local max_requested_lag = `lag_val' } } display as error "E011: No feasible lag() values remain for placebo tests" display as error " Current sample has only `max_preperiods_int' pre-treatment period(s)" display as error " The requested lag window requires at least `=`max_requested_lag' + 1' pre-treatment periods" _ddcheck_cleanup, vars(`generated_covariates') exit 498 } // Retrieve matrices from Mata tempname placebo_mat trends_mat Gmat_mat lag_support_mat cluster_support_mat mata: st_matrix("`placebo_mat'", _check_placebo) mata: st_matrix("`trends_mat'", _check_trends) mata: st_matrix("`lag_support_mat'", _check_n_boot_valid_lag) mata: st_local("n_posted_vcov", strofreal(rows(_check_posted_vcov))) tempname placebo_keep lag_support_keep row_tmp support_row_tmp b_post V_post local b_names "" local identified_lags "" local posted_lags "" local raw_only_lags "" local unidentified_lags "" local n_lags_identified 0 local n_lags_posted 0 if `n_lags_valid' > 0 { forvalues i = 1/`n_lags_valid' { local lag_i = `placebo_mat'[`i', 1] local lag_i_int = int(`lag_i') local est_i = `placebo_mat'[`i', 2] local se_i = `placebo_mat'[`i', 3] local est_orig_i = `placebo_mat'[`i', 4] local se_orig_i = `placebo_mat'[`i', 5] local n_valid_std_i = `lag_support_mat'[`i', 1] local n_valid_raw_i = `lag_support_mat'[`i', 2] if `n_valid_raw_i' < 2 | missing(`est_orig_i') | missing(`se_orig_i') { local unidentified_lags "`unidentified_lags' `lag_i_int'" continue } matrix `row_tmp' = `placebo_mat'[`i', 1...] matrix `placebo_keep' = nullmat(`placebo_keep') \ `row_tmp' matrix `support_row_tmp' = `lag_support_mat'[`i', 1...] matrix `lag_support_keep' = nullmat(`lag_support_keep') \ `support_row_tmp' local identified_lags "`identified_lags' `lag_i_int'" local ++n_lags_identified if `n_valid_std_i' < 2 | missing(`est_i') | missing(`se_i') { local raw_only_lags "`raw_only_lags' `lag_i_int'" continue } matrix `b_post' = nullmat(`b_post'), `est_i' local b_names "`b_names' placebo:lag_`lag_i_int'" local posted_lags "`posted_lags' `lag_i_int'" local ++n_lags_posted } } local b_names = trim("`b_names'") local identified_lags = trim("`identified_lags'") local posted_lags = trim("`posted_lags'") local raw_only_lags = trim("`raw_only_lags'") local unidentified_lags = trim("`unidentified_lags'") if `n_lags_identified' == 0 { display as error "E011: No identifiable lag() values remain for placebo tests" display as error " Requested lags are time-feasible, but raw placebo inference is not identifiable after support checks, including valid bootstrap-draw requirements" _ddcheck_cleanup, vars(`generated_covariates') exit 498 } matrix `placebo_mat' = `placebo_keep' matrix `lag_support_mat' = `lag_support_keep' matrix `cluster_support_mat' = J(`n_lags_identified', 1, .) // Set matrix row and column names for e(placebo) local placebo_rownames "" forvalues i = 1/`n_lags_identified' { local lag_i = `placebo_mat'[`i', 1] local lag_i_int = int(`lag_i') local placebo_rownames "`placebo_rownames' `lag_i_int'" } local placebo_rownames = trim("`placebo_rownames'") if "`placebo_rownames'" != "" { matrix rownames `placebo_mat' = `placebo_rownames' capture matrix rownames `lag_support_mat' = `placebo_rownames' } matrix colnames `placebo_mat' = lag estimate std_error estimate_orig std_error_orig EqCI95_LB EqCI95_UB capture matrix colnames `lag_support_mat' = n_boot_valid_std n_boot_valid_raw // Set matrix column names for e(trends) matrix colnames `trends_mat' = id_time_std Gi outcome_mean outcome_sd n_obs // Post placeholder estimation results so that estimates store/restore works. // The posted coefficient vector uses standardized placebo estimates, while // e(placebo) retains the filtered diagnostic table used by diddesign_plot. local placeholder_posted = 0 if `n_lags_posted' == 0 { matrix `b_post' = 0 matrix `V_post' = 0 // Keep a stable sentinel name for the internal placeholder matrix so // downstream callers never see a session-specific implementation leak. local b_names "__no_posted_standardized_lags__" local placeholder_posted = 1 } else { if `n_posted_vcov' != `n_lags_posted' { display as error "E011: Posted placebo covariance could not be reconstructed" display as error " Joint bootstrap support for the posted standardized placebo vector is inconsistent with e(b)" _ddcheck_cleanup, vars(`generated_covariates') exit 498 } mata: st_matrix("`V_post'", _check_posted_vcov) } if `n_lags_posted' > 0 | `placeholder_posted' { matrix colnames `b_post' = `b_names' matrix rownames `V_post' = `b_names' matrix colnames `V_post' = `b_names' } // Reconstruct the union of placebo windows retained in e(placebo) so // cluster support is reported for the actual diagnostic sample rather than // the full pre-Mata touse sample. local posted_sample "`touse'" local N_support = `N' local n_clusters_support = `n_clusters' if "`design'" == "did" { tempvar placebo_support placebo_regress_sample time_order cluster_support_tag quietly gen byte `placebo_support' = 0 quietly egen long `time_order' = group(`time') if `touse' local treat_order = . if `is_panel' { quietly summarize `time_order' if `touse' & `treatment' == 1, meanonly local treat_order = r(min) } else { quietly summarize `time_order' if `touse' & `post' == 1, meanonly local treat_order = r(min) } local lag_row = 0 foreach lag_i of local identified_lags { local ++lag_row local placebo_post_order = `treat_order' - `lag_i' local placebo_pre_order = `placebo_post_order' - 1 tempvar placebo_support_lag placebo_regress_sample_lag cluster_support_tag_lag quietly gen byte `placebo_support_lag' = 0 quietly replace `placebo_support_lag' = 1 if `touse' & /// inlist(`time_order', `placebo_post_order', `placebo_pre_order') quietly replace `placebo_support' = 1 if `placebo_support_lag' == 1 & `touse' quietly gen byte `placebo_regress_sample_lag' = 0 quietly replace `placebo_regress_sample_lag' = `placebo_support_lag' if `touse' if "`covars_str'" != "" { markout `placebo_regress_sample_lag' `covars_str', strok } if "`clustvar'" != "" { quietly egen byte `cluster_support_tag_lag' = tag(`clustvar') if `placebo_regress_sample_lag' == 1 & `touse' quietly count if `cluster_support_tag_lag' == 1 & `placebo_regress_sample_lag' == 1 & `touse' } else { quietly count if `placebo_regress_sample_lag' == 1 & `touse' } matrix `cluster_support_mat'[`lag_row', 1] = r(N) } // Match the posted sample to the actual placebo OLS sample. // Standardization still uses the wider placebo window, but e(sample) // must exclude rows later dropped by regression-stage listwise deletion. quietly gen byte `placebo_regress_sample' = 0 quietly replace `placebo_regress_sample' = `placebo_support' if `touse' if "`covars_str'" != "" { markout `placebo_regress_sample' `covars_str', strok } quietly count if `placebo_regress_sample' == 1 & `touse' local N_support = r(N) local posted_sample "`placebo_regress_sample'" if "`clustvar'" != "" { quietly count if `placebo_regress_sample' == 1 & `touse' & missing(`clustvar') local n_missing_cluster_support = r(N) if `n_missing_cluster_support' > 0 { _ddcheck_cleanup, vars(`generated_covariates') display as error "E003: cluster() contains missing values in the posted diagnostic sample" display as error " Found `n_missing_cluster_support' observations with undefined bootstrap blocks" display as error " Fill cluster() at the treatment-assignment level or omit cluster() for panel unit-level bootstrap" exit 198 } quietly egen byte `cluster_support_tag' = tag(`clustvar') if `placebo_regress_sample' == 1 & `touse' & !missing(`clustvar') quietly count if `cluster_support_tag' == 1 & `placebo_regress_sample' == 1 & `touse' & !missing(`clustvar') local n_clusters_support = r(N) } else { quietly count if `placebo_regress_sample' == 1 & `touse' local n_clusters_support = r(N) } } else if "`design'" == "sa" { local identified_pos "" local lag_col = 0 foreach lag_req of numlist `lag' { local ++lag_col local lag_req_int = int(`lag_req') local lag_is_identified : list lag_req_int in identified_lags if `lag_is_identified' { local identified_pos "`identified_pos' `lag_col'" } } local identified_pos = trim("`identified_pos'") if "`identified_pos'" != "" { tempvar placebo_support_sa cluster_support_tag_sa quietly gen byte `placebo_support_sa' = 0 if `touse' local identified_pos_mata = subinstr("`identified_pos'", " ", ", ", .) mata: st_store(selectindex(st_data(., "`touse'")), st_varindex("`placebo_support_sa'"), rowsum(_check_sa_support_mask_raw[., (`identified_pos_mata')]) :> 0) quietly count if `placebo_support_sa' == 1 & `touse' if r(N) > 0 { local N_support = r(N) local posted_sample "`placebo_support_sa'" if "`clustvar'" != "" { quietly egen byte `cluster_support_tag_sa' = tag(`clustvar') if `placebo_support_sa' == 1 & `touse' & !missing(`clustvar') quietly count if `cluster_support_tag_sa' == 1 & `placebo_support_sa' == 1 & `touse' & !missing(`clustvar') local n_clusters_support = r(N) } else { quietly count if `placebo_support_sa' == 1 & `touse' local n_clusters_support = r(N) } } } } ereturn post `b_post' `V_post', esample(`posted_sample') obs(`N_support') depname("`depvar'") if `n_lags_posted' > 0 { ereturn local properties "b V" } else { ereturn local properties "" } // --- Scalars --- ereturn scalar N = `N_support' ereturn scalar n_lags = `n_lags_identified' ereturn scalar n_lags_posted = `n_lags_posted' ereturn scalar n_boot = `nboot_val' ereturn scalar n_boot_valid = `n_boot_valid' ereturn scalar n_clusters = `n_clusters_support' // Placebo and equivalence intervals are defined from the 90% CI. ereturn scalar level = 90 // --- Macros --- ereturn local cmd "diddesign_check" ereturn local cmdline "`cmdline'" ereturn local design "`design'" ereturn local depvar "`depvar'" ereturn local treatment "`treatment_orig'" ereturn local id "`id_orig'" ereturn local time "`time_orig'" ereturn local post "`post_orig'" ereturn local sample_ifin `"`if' `in'"' ereturn local clustvar "`clustvar_report'" ereturn local covars "`covars_spec'" ereturn local covariates "`covars_spec'" ereturn local identified_lags "`identified_lags'" ereturn local posted_lags "`posted_lags'" ereturn local raw_only_lags "`raw_only_lags'" ereturn local unidentified_lags "`unidentified_lags'" // This command stores e(placebo) and e(trends), not e(b) and e(V) // --- Matrices --- ereturn matrix placebo = `placebo_mat' ereturn matrix trends = `trends_mat' ereturn matrix n_boot_valid_lag = `lag_support_mat' if "`design'" == "did" { matrix rownames `cluster_support_mat' = `placebo_rownames' matrix colnames `cluster_support_mat' = n_clusters ereturn matrix n_clusters_lag = `cluster_support_mat' } // SA design: store treatment timing matrix (Gmat) only if valid // Invalid or placeholder matrices are not stored to prevent misleading plots if "`design'" == "sa" { capture mata: st_matrix("`Gmat_mat'", _check_Gmat) if _rc == 0 { capture confirm matrix `Gmat_mat' if _rc == 0 { local gmat_rows = rowsof(`Gmat_mat') local gmat_cols = colsof(`Gmat_mat') // Only store Gmat if it represents valid data (not 1x1 placeholder) if (`gmat_rows' > 1 | `gmat_cols' > 1) & `gmat_rows' > 0 & `gmat_cols' > 0 { ereturn matrix Gmat = `Gmat_mat' } } } } // ------------------------------------------------------------------------- // Store Data Type Info // ------------------------------------------------------------------------- ereturn scalar is_panel = `is_panel' if `is_panel' { ereturn local datatype "panel" } else { ereturn local datatype "rcs" } // ------------------------------------------------------------------------- // Display Results // ------------------------------------------------------------------------- local quiet_display = ("`quiet'" != "") _diddesign_check_display, design("`design'") filtered_lags("`filtered_lags'") /// is_panel(`is_panel') cluster("`clustvar_report'") quiet(`quiet_display') _ddcheck_cleanup, vars(`generated_covariates') end program define _ddcheck_cleanup version 16.0 syntax , [VARS(varlist)] if "`vars'" != "" { capture drop `vars' } end // ============================================================================= // _diddesign_check_display // Formats and displays placebo test results // // Displays the parallel trends assessment output including design information, // sample statistics, and a formatted table of placebo estimates with bootstrap // standard errors and equivalence confidence intervals. // ============================================================================= program define _diddesign_check_display syntax, design(string) [filtered_lags(string) is_panel(integer 1) cluster(string) quiet(integer 0)] // Header display "" display as text "Parallel Trends Assessment" display as text "{hline 60}" // Design info if "`design'" == "did" { display as text "Design: " as result "Standard DID" } else { display as text "Design: " as result "Staggered Adoption" } // Data type info if `is_panel' { display as text "Data type: " as result "Panel" } else { display as text "Data type: " as result "Repeated Cross-Section (RCS)" } display as text "Standardization: " as result "Yes (by control group baseline)" if "`cluster'" != "" { display as text "Clustering: " as result "`cluster'" } // Sample info display "" display as text "Sample: N = " as result e(N) as text ", Clusters = " as result e(n_clusters) display as text "Bootstrap: n_boot = " as result e(n_boot) as text ", n_boot_valid = " as result e(n_boot_valid) capture matrix lag_support = e(n_boot_valid_lag) if _rc == 0 { local n_support = rowsof(lag_support) if `n_support' > 0 { local lag_rows : rownames lag_support display as text "Lag bootstrap support (std/raw):" forvalues i = 1/`n_support' { local lag_label : word `i' of `lag_rows' local lag_support_std = lag_support[`i', 1] local lag_support_raw = lag_support[`i', 2] display as text " lag " as result "`lag_label'" as text ": " /// as result `lag_support_std' as text " / " as result `lag_support_raw' } } } if "`design'" == "did" { capture matrix lag_clusters = e(n_clusters_lag) if _rc == 0 { local n_cluster_rows = rowsof(lag_clusters) if `n_cluster_rows' > 0 { local lag_cluster_rows : rownames lag_clusters display as text "Lag cluster support:" forvalues i = 1/`n_cluster_rows' { local lag_label : word `i' of `lag_cluster_rows' local lag_cluster_count = lag_clusters[`i', 1] display as text " lag " as result "`lag_label'" as text ": " /// as result `lag_cluster_count' } } } } // Warning for filtered lags if !`quiet' & "`filtered_lags'" != "" { display "" display as text "{p 0 4 2}" display as text "Warning: The following lag(s) were filtered out (exceed max available): `filtered_lags'" display as text "{p_end}" } local raw_only_lags "`e(raw_only_lags)'" if !`quiet' & "`raw_only_lags'" != "" { display "" display as text "{p 0 4 2}" display as text "Warning: The following lag(s) retain raw placebo estimates in e(placebo), but standardized placebo inference is not identifiable: `raw_only_lags'" display as text "{p_end}" } local unidentified_lags "`e(unidentified_lags)'" if !`quiet' & "`unidentified_lags'" != "" { display "" display as text "{p 0 4 2}" display as text "Warning: The following lag(s) were dropped after support checks because placebo inference is not identifiable on either standardized or raw scales: `unidentified_lags'" display as text "{p_end}" } // Table header display "" display as text "Placebo Tests (Pre-treatment DID):" display as text "{hline 78}" display as text " Lag | Estimate Std.Err. Estimate(raw) SE(raw) 95% Eq. CI" display as text "{hline 9}+{hline 68}" // Table content tempname placebo matrix `placebo' = e(placebo) local nrows = rowsof(`placebo') if `nrows' == 0 { display as text " (no valid lags)" } else { forvalues i = 1/`nrows' { local lag_val = `placebo'[`i', 1] local est = `placebo'[`i', 2] local se = `placebo'[`i', 3] local est_orig = `placebo'[`i', 4] local se_orig = `placebo'[`i', 5] local ci_lb = `placebo'[`i', 6] local ci_ub = `placebo'[`i', 7] display as text %7.0f `lag_val' " |" /// as result %10.4f `est' %10.4f `se' /// %14.4f `est_orig' %10.4f `se_orig' /// " [" %6.3f `ci_lb' ", " %6.3f `ci_ub' "]" } } display as text "{hline 78}" // Interpretation display "" display as text "Interpretation:" display as text "- 'Estimate' column shows standardized placebo effects (divided by control SD)" display as text "- Estimates close to zero suggest parallel pre-treatment trends" display as text "- Narrower equivalence CI indicates stronger evidence for parallel trends" end