*! did_utils.mata - Core utility functions for DIDdesign *! *! This module serves as the foundational layer (Layer 1) of the DIDdesign Mata *! library, providing essential utilities for the double difference-in-differences *! (double DID) estimator and its extension to the staggered adoption (SA) design. *! All other modules in the package depend upon this foundation. *! *! Functional groups: *! - Staggered adoption utilities: group indicator matrix (Gmat) construction, *! period and subject selection, and time weight computation for aggregating *! period-specific SA-ATT estimates *! - Statistical utilities: bootstrap variance-covariance estimation for GMM *! optimal weighting, robust matrix inversion, and quantile computation *! - Numerical utilities: safe arithmetic operations for edge case handling *! - Visualization utilities: treatment timing computation for pattern plots *! - Sampling utilities: random index generation for bootstrap resampling version 16.0 mata: mata set matastrict on // ------------------------------------------------------------------------- // Staggered Adoption Design Utilities // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * create_gmat() - Create Group Indicator Matrix for Staggered Adoption Design * * In the staggered adoption (SA) design, different units receive treatment at * different time periods. This function constructs an N_units × T group indicator * matrix encoding treatment timing: * * G_{it} = 1 if A_i = t (unit i first treated at time t) * G_{it} = 0 if A_i > t (unit i not yet treated by time t) * G_{it} = -1 if A_i < t (unit i already treated before time t) * * where A_i = min{t : D_{it} = 1} denotes the treatment adoption time. * * Arguments: * id_subject : real colvector - unit identifiers (N × 1) * id_time : real colvector - time period indices (N × 1), values 1, 2, ..., T * treatment : real colvector - cumulative treatment indicator (N × 1), binary 0/1 * * Returns: * real matrix (N_units × T): group indicator matrix Gmat * * Algorithm: * The treatment adoption time A_i follows the paper definition: * A_i = min{t : D_{it} = 1} * based on the observed treatment path for each unit. For never-treated * units, A_i = T + 1 > T, so G_{it} = 0 for all t. *---------------------------------------------------------------------------*/ real matrix create_gmat(real colvector id_subject, real colvector id_time, real colvector treatment) { real matrix Gmat real colvector units, idx, treat_i, time_i, treated_time_i real scalar N_units, T, N, i, t, g_sum N = rows(id_subject) if (N == 0 || rows(id_time) != N || rows(treatment) != N) { return(J(0, 0, .)) } units = uniqrows(id_subject) N_units = rows(units) T = max(id_time) if (missing(T)) { errprintf("Error: Cannot determine time periods (all id_time values are missing)\n") return(J(0, 0, .)) } if (T < 1) { errprintf("Error: Invalid time period range (max id_time = %g)\n", T) return(J(0, 0, .)) } Gmat = J(N_units, T, 0) for (i = 1; i <= N_units; i++) { idx = selectindex(id_subject :== units[i]) treat_i = treatment[idx] time_i = id_time[idx] treated_time_i = select(time_i, treat_i :== 1) if (rows(treated_time_i) == 0) { g_sum = T + 1 } else { g_sum = min(treated_time_i) } for (t = 1; t <= T; t++) { if (g_sum > t) { Gmat[i, t] = 0 } else if (g_sum == t) { Gmat[i, t] = 1 } else { Gmat[i, t] = -1 } } } return(Gmat) } /*--------------------------------------------------------------------------- * get_periods() - Select Valid Time Periods for SA Design Analysis * * Time periods with sufficient treated units (>= threshold) are selected. * The edge case where all units are eventually treated (no control group) is handled. * * Arguments: * Gmat : real matrix (N_units × T) - group indicator matrix from create_gmat() * thres : real scalar - minimum treated units threshold (default: 2) * * Returns: * real colvector: indices of valid time periods (may be empty) * * Algorithm: * 1. Count treated units per period: n_treated[t] = Σ_i 1{G_{it} = 1} * 2. Select periods where n_treated >= threshold * 3. Edge case: if all units are treated within valid periods, the last * period is removed to ensure at least one G=0 control unit exists *---------------------------------------------------------------------------*/ real colvector get_periods(real matrix Gmat, | real scalar thres) { real colvector n_treated, use_id real scalar N, T, t, total_treated if (args() < 2 || missing(thres)) thres = 2 if (thres < 1) thres = 1 N = rows(Gmat) T = cols(Gmat) if (N == 0 || T == 0) { return(J(0, 1, .)) } n_treated = J(T, 1, 0) for (t = 1; t <= T; t++) { n_treated[t] = sum(Gmat[, t] :== 1) } use_id = selectindex(n_treated :>= thres) if (rows(use_id) == 0) { return(J(0, 1, .)) } // When all units are treated within valid periods, exclude last period // to ensure at least one control unit (G=0) exists total_treated = sum(n_treated[use_id]) if (total_treated == N) { if (rows(use_id) > 1) { use_id = use_id[1..(rows(use_id) - 1)] } else { return(J(0, 1, .)) } } return(use_id) } /*--------------------------------------------------------------------------- * get_subjects() - Select Valid Subjects for Each Time Period * * For each valid time period, indices of units that are either treated at * that time (G = 1) or not yet treated (G = 0) are returned. * Units already treated in previous periods (G = -1) are excluded. * * Arguments: * Gmat : real matrix (N_units × T) - group indicator matrix * id_time_use : real colvector - valid time period indices from get_periods() * * Returns: * pointer vector (K × 1): each element points to a colvector of valid unit indices * * Algorithm: * For each valid period t in id_time_use, find indices where G_{it} >= 0. *---------------------------------------------------------------------------*/ pointer vector get_subjects(real matrix Gmat, real colvector id_time_use) { pointer vector id_use real colvector col real scalar K, T, i, t K = rows(id_time_use) if (K == 0) { return(J(0, 1, NULL)) } T = cols(Gmat) id_use = J(K, 1, NULL) for (i = 1; i <= K; i++) { t = id_time_use[i] if (t < 1 | t > T | missing(t)) { errprintf("Error: id_time_use[%g] = %g is out of valid range [1, %g]\n", i, t, T) return(J(0, 1, NULL)) } col = Gmat[, t] id_use[i] = &(selectindex(col :>= 0)) } return(id_use) } /*--------------------------------------------------------------------------- * get_time_weight() - Compute Time Weights for SA Design * * Weights proportional to the number of treated units at each period are computed. * These weights are used for aggregating period-specific treatment effects in SA design. * * Arguments: * Gmat : real matrix (N_units × T) - group indicator matrix * id_time_use : real colvector - valid time period indices from get_periods() * * Returns: * real colvector (K × 1): time weights summing to 1.0 * * Formula: * π_t = n_{1t} / Σ_{t' ∈ T_use} n_{1t'} * where n_{1t} = Σ_i 1{A_i = t} = number of units treated at time t * * In the staggered adoption design, these weights aggregate period-specific * SA-ATT estimates into the time-average SA-ATT. *---------------------------------------------------------------------------*/ real colvector get_time_weight(real matrix Gmat, real colvector id_time_use) { real colvector time_weight real scalar K, T, n_total, i, t, n_t K = rows(id_time_use) if (K == 0) { return(J(0, 1, .)) } T = cols(Gmat) for (i = 1; i <= K; i++) { t = id_time_use[i] if (t < 1 | t > T | missing(t)) { errprintf("Error: id_time_use[%g] = %g is out of valid range [1, %g]\n", i, t, T) return(J(K, 1, .)) } } n_total = 0 for (i = 1; i <= K; i++) { t = id_time_use[i] n_total = n_total + sum(Gmat[, t] :== 1) } if (n_total == 0) { return(J(K, 1, .)) } time_weight = J(K, 1, .) for (i = 1; i <= K; i++) { t = id_time_use[i] n_t = sum(Gmat[, t] :== 1) time_weight[i] = n_t / n_total } return(time_weight) } // ------------------------------------------------------------------------- // Statistical Utility Functions // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * compute_vcov() - Compute Variance-Covariance Matrix from Bootstrap Samples * * The sample variance-covariance matrix from bootstrap estimates is computed * using the unbiased estimator with (B-1) denominator. * * Arguments: * boot_est : real matrix (B × k) - bootstrap estimates * B = number of bootstrap iterations * k = number of estimators (typically 2: standard DID, sequential DID) * * Returns: * real matrix (k × k): variance-covariance matrix * * Formula: * VCOV[i,j] = (1/(B-1)) Σ_b (θ̂_i^{(b)} - θ̄_i)(θ̂_j^{(b)} - θ̄_j) * * where θ̄_i = (1/B) Σ_b θ̂_i^{(b)} is the bootstrap mean. * * In the double DID framework, this variance-covariance matrix serves as the * basis for computing optimal GMM weights: W = VCOV^{-1}. * * Note: If any value in boot_est is missing, a missing matrix is returned. * This conservative behavior ensures GMM weights are well-defined. *---------------------------------------------------------------------------*/ real matrix compute_vcov(real matrix boot_est) { real scalar n_boot, k real rowvector means real matrix centered, vcov k = cols(boot_est) n_boot = rows(boot_est) if (n_boot == 0 || k == 0) { return(J(k, k, .)) } // Require at least 2 observations for (B-1) denominator if (n_boot < 2) { return(J(k, k, .)) } if (missing(boot_est)) { return(J(k, k, .)) } means = mean(boot_est) centered = boot_est :- means vcov = (centered' * centered) / (n_boot - 1) return(vcov) } /*--------------------------------------------------------------------------- * compute_pairwise_cov() - Pairwise covariance for bootstrap columns * * Computes sample covariance using only rows where both series are observed. * Returns missing when fewer than two paired draws exist. *---------------------------------------------------------------------------*/ real scalar compute_pairwise_cov(real colvector x, real colvector y) { real colvector valid_idx real matrix paired valid_idx = selectindex((x :< .) :& (y :< .)) if (rows(valid_idx) < 2) { return(.) } paired = x[valid_idx], y[valid_idx] return(compute_vcov(paired)[1, 2]) } /*--------------------------------------------------------------------------- * compute_vcov_pairwise() - Bootstrap VCOV with marginal variances * * Diagonal variances are computed from all non-missing draws in each column, * while off-diagonal covariances use only paired non-missing draws. * This helper is appropriate for component-level uncertainty summaries, but it * should not be treated as the joint covariance matrix of a bootstrap vector * when different components are observed on different supports. *---------------------------------------------------------------------------*/ real matrix compute_vcov_pairwise(real matrix boot_est) { real scalar n_boot, k, i, j real scalar psd_tol, vcov_scale real matrix vcov, joint_vcov, complete_est real colvector valid_col real colvector complete_idx real scalar cov_ij real rowvector eigs n_boot = rows(boot_est) k = cols(boot_est) if (n_boot == 0 || k == 0) { return(J(k, k, .)) } if (n_boot < 2) { return(J(k, k, .)) } vcov = J(k, k, .) for (i = 1; i <= k; i++) { valid_col = select(boot_est[., i], boot_est[., i] :< .) if (rows(valid_col) >= 2) { vcov[i, i] = variance(valid_col) } } for (i = 1; i <= k; i++) { for (j = i + 1; j <= k; j++) { cov_ij = compute_pairwise_cov(boot_est[., i], boot_est[., j]) vcov[i, j] = cov_ij vcov[j, i] = cov_ij } } // Mixed marginal/pairwise moments can violate PSD in partial-missing // bootstrap samples. When that happens for the 2x2 DID / sDID system, // fall back to the complete-case covariance of observed bootstrap pairs so // downstream GMM weights always see a valid covariance matrix. if (k == 2 && !missing(vcov[1, 1]) && !missing(vcov[1, 2]) && !missing(vcov[2, 2])) { vcov_scale = max(abs(vcov)) psd_tol = max((1e-12, 1e-8 * vcov_scale)) eigs = symeigenvalues(vcov) if (min(eigs) < -psd_tol) { complete_idx = selectindex(rowmissing(boot_est) :== 0) if (rows(complete_idx) >= 2) { complete_est = boot_est[complete_idx, .] joint_vcov = compute_vcov(complete_est) return(joint_vcov) } } } return(vcov) } /*--------------------------------------------------------------------------- * compute_vcov_joint_valid() - Bootstrap VCOV on complete joint draws * * Computes the variance-covariance matrix using only bootstrap rows where all * components are jointly observed. This is the correct GMM input when the * weight matrix is defined as the covariance of a jointly observed bootstrap * vector. *---------------------------------------------------------------------------*/ real matrix compute_vcov_joint_valid(real matrix boot_est) { real scalar n_boot, k real colvector complete_idx real matrix complete_est n_boot = rows(boot_est) k = cols(boot_est) if (n_boot == 0 || k == 0) { return(J(k, k, .)) } if (n_boot < 2) { return(J(k, k, .)) } complete_idx = selectindex(rowmissing(boot_est) :== 0) if (rows(complete_idx) < 2) { return(J(k, k, .)) } complete_est = boot_est[complete_idx, .] return(compute_vcov(complete_est)) } /*--------------------------------------------------------------------------- * safe_invert() - Safe Matrix Inversion with Singularity Handling * * A matrix is inverted with robust handling of singular and near-singular cases. * * Arguments: * A : real matrix (k × k) - matrix to invert * tol : real scalar - tolerance for singularity detection (default: 1e-10) * success : pointer to scalar - returns 1 if successful, 0 if singular * * Returns: * real matrix (k × k): inverse of A, or missing matrix if singular * * Algorithm: * 1. Verify matrix is square * 2. Compute reciprocal condition number * 3. If rcond < tol, matrix is near-singular, return failure * 4. Otherwise, compute inverse via LU decomposition *---------------------------------------------------------------------------*/ real matrix safe_invert(real matrix A, | real scalar tol, pointer(real scalar) scalar success) { real scalar k, kA, rcond, success_local real matrix Ainv if (args() < 2) tol = 1e-10 success_local = 0 k = rows(A) kA = cols(A) if (k != kA) { if (success != NULL) *success = 0 return(J(k, kA, .)) } if (k == 0) { if (success != NULL) *success = 0 return(J(0, 0, .)) } if (missing(A)) { if (success != NULL) *success = 0 return(J(k, k, .)) } rcond = 1 / cond(A) if (rcond < tol | missing(rcond)) { if (success != NULL) *success = 0 return(J(k, k, .)) } Ainv = luinv(A) if (missing(Ainv)) { if (success != NULL) *success = 0 return(J(k, k, .)) } success_local = 1 if (success != NULL) *success = success_local return(Ainv) } /*--------------------------------------------------------------------------- * quantile_sorted() - Compute Sample Quantile * * Sample quantiles are computed using linear interpolation (type 7 method). * * Arguments: * x : real colvector - sample values * p : real scalar - probability (0 to 1) * * Returns: * real scalar: p-th quantile value * * Algorithm (linear interpolation): * index = 1 + (n-1) * p * lo = floor(index), hi = ceil(index) * h = index - lo * result = (1-h) * x_sorted[lo] + h * x_sorted[hi] *---------------------------------------------------------------------------*/ real scalar quantile_sorted(real colvector x, real scalar p) { real scalar n, index, lo, hi, h real colvector xs if (p < 0 | p > 1 | missing(p)) { return(.) } xs = select(x, x :< .) n = rows(xs) if (n == 0) return(.) if (n == 1) return(xs[1]) xs = sort(xs, 1) if (p == 0) return(xs[1]) if (p == 1) return(xs[n]) index = 1 + (n - 1) * p lo = floor(index) hi = ceil(index) if (lo < 1) lo = 1 if (hi > n) hi = n if (lo == hi) return(xs[lo]) h = index - lo return((1 - h) * xs[lo] + h * xs[hi]) } /*--------------------------------------------------------------------------- * _percentile() - Convenience wrapper for quantile_sorted() *---------------------------------------------------------------------------*/ real scalar _percentile(real colvector x, real scalar p) { return(quantile_sorted(x, p)) } // ------------------------------------------------------------------------- // Numerical Utility Functions // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * safe_divide() - Safe Division Avoiding Divide by Zero * * Arguments: * num : real scalar - numerator * denom : real scalar - denominator * default_val : real scalar - value returned if division is undefined (default: .) * * Returns: * num/denom if denom != 0 and not missing, default_val otherwise *---------------------------------------------------------------------------*/ real scalar safe_divide(real scalar num, real scalar denom, | real scalar default_val) { if (args() < 3) default_val = . if (missing(num) | missing(denom)) { return(default_val) } if (denom == 0) { return(default_val) } return(num / denom) } /*--------------------------------------------------------------------------- * rel_error() - Compute Relative Error * * Arguments: * computed : real scalar - computed value * reference : real scalar - reference value * * Returns: * |computed - reference| / |reference| * Returns 0 if both values are zero, missing if reference is zero *---------------------------------------------------------------------------*/ real scalar rel_error(real scalar computed, real scalar reference) { if (missing(computed) | missing(reference)) { return(.) } if (reference == 0) { if (computed == 0) return(0) return(.) } return(abs(computed - reference) / abs(reference)) } /*--------------------------------------------------------------------------- * is_zero() - Check if Value is Effectively Zero * * Arguments: * x : real scalar - value to check * tol : real scalar - tolerance (default: 1e-10) * * Returns: * 1 if |x| < tol, 0 otherwise (returns 0 if x is missing) *---------------------------------------------------------------------------*/ real scalar is_zero(real scalar x, | real scalar tol) { if (args() < 2) tol = 1e-10 if (missing(x)) return(0) return(abs(x) < tol) } /*--------------------------------------------------------------------------- * normalize_binary01() - Snap near-binary values to exact 0/1 * * Values within tolerance of 0 or 1 are normalized to exact binary values. * Missing values and values outside the tolerance band are preserved. *---------------------------------------------------------------------------*/ real colvector normalize_binary01(real colvector x, | real scalar tol) { real colvector y real colvector idx0, idx1 if (args() < 2) tol = 1e-6 y = x if (rows(y) == 0) { return(y) } idx0 = selectindex(abs(y) :< tol) if (rows(idx0) > 0) { y[idx0] = J(rows(idx0), 1, 0) } idx1 = selectindex(abs(y :- 1) :< tol) if (rows(idx1) > 0) { y[idx1] = J(rows(idx1), 1, 1) } return(y) } /*--------------------------------------------------------------------------- * safe_log() - Safe Natural Logarithm * * Arguments: * x : real scalar - value to take log of * default_val : real scalar - value returned if x <= 0 (default: .) * * Returns: * ln(x) if x > 0, default_val otherwise *---------------------------------------------------------------------------*/ real scalar safe_log(real scalar x, | real scalar default_val) { if (args() < 2) default_val = . if (missing(x) | x <= 0) { return(default_val) } return(ln(x)) } /*--------------------------------------------------------------------------- * safe_sqrt() - Safe Square Root * * Arguments: * x : real scalar - value to take sqrt of * default_val : real scalar - value returned if x < 0 (default: .) * * Returns: * sqrt(x) if x >= 0, default_val otherwise *---------------------------------------------------------------------------*/ real scalar safe_sqrt(real scalar x, | real scalar default_val) { if (args() < 2) default_val = . if (missing(x) | x < 0) { return(default_val) } return(sqrt(x)) } // ------------------------------------------------------------------------- // Pattern Plot Utility Functions // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * _compute_treat_timing() - Compute Treatment Timing for SA Pattern Plot * * The treatment adoption time A_i for each unit is computed and mapped to * Y-axis positions for visualizing staggered adoption (SA) design patterns. * * Arguments: * Gmat_name : string scalar - name of Stata matrix containing Gmat * Gmat is N_units × T matrix where: * - Gmat[i,t] = 0: unit i not yet treated at time t * - Gmat[i,t] = 1: unit i treated at time t * - Gmat[i,t] = -1: unit i already treated before time t * * Returns (via st_matrix): * r(sort_order) : N_units × 1 matrix * sorted_pos[i] = Y-axis position for unit i * - Never-treated units appear at the bottom (Y = 1) * - Latest-treated units appear in the middle * - Earliest-treated units appear at the top (Y = N_units) * * Algorithm: * 1. For each unit, find first_treat = first period where G_{it} = 1 * 2. Sort units by first_treat in descending order * 3. Map sorted indices to Y-axis positions *---------------------------------------------------------------------------*/ void _compute_treat_timing(string scalar Gmat_name) { real matrix Gmat real colvector first_treat, first_treat_adj, sort_order, sorted_pos real scalar n_units, n_times, i, j Gmat = st_matrix(Gmat_name) n_units = rows(Gmat) n_times = cols(Gmat) if (n_units == 0 || n_times == 0) { st_matrix("r(sort_order)", J(0, 1, .)) return } first_treat = J(n_units, 1, .) for (i = 1; i <= n_units; i++) { first_treat[i] = . for (j = 1; j <= n_times; j++) { if (Gmat[i, j] == 1) { first_treat[i] = j break } } } // Never-treated units (missing) sort to the front in descending order first_treat_adj = first_treat for (i = 1; i <= n_units; i++) { if (first_treat_adj[i] == .) { first_treat_adj[i] = n_times + 1 } } sort_order = order(first_treat_adj, -1) sorted_pos = J(n_units, 1, .) for (i = 1; i <= n_units; i++) { sorted_pos[sort_order[i]] = i } st_matrix("r(sort_order)", sorted_pos) } // ------------------------------------------------------------------------- // Random Sampling Utilities // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * unique_in_order() - Unique values preserving first appearance order * * Returns the first occurrence of each finite value without sorting. This is * useful when bootstrap resampling should be invariant to pure relabeling of * cluster identifiers under a fixed RNG seed. *---------------------------------------------------------------------------*/ real colvector unique_in_order(real colvector x) { transmorphic scalar seen real colvector out real scalar i out = J(0, 1, .) seen = asarray_create("real", 1) for (i = 1; i <= rows(x); i++) { if (missing(x[i])) continue if (!asarray_contains(seen, x[i])) { asarray(seen, x[i], 1) out = out \ x[i] } } return(out) } /*--------------------------------------------------------------------------- * safe_sample_idx() - Generate Random Sample Indices with Boundary Handling * * Random indices for sampling with replacement are generated, ensuring valid * 1-based Mata indices. * * Arguments: * n : real scalar - range of indices (1 to n) * size : real scalar - number of indices to generate * * Returns: * real colvector (size × 1): random indices in range [1, n] * * Algorithm: * 1. Generate random values using ceil(runiform() * n) * 2. Clamp to ensure all indices are in [1, n] *---------------------------------------------------------------------------*/ real colvector safe_sample_idx(real scalar n, real scalar size) { real colvector idx if (n <= 0 || size <= 0) { return(J(0, 1, .)) } idx = ceil(runiform(size, 1) * n) // Clamp to ensure valid 1-based indices idx = rowmax((idx, J(size, 1, 1))) idx = rowmin((idx, J(size, 1, n))) return(idx) } // ------------------------------------------------------------------------- // Module Verification // ------------------------------------------------------------------------- /*--------------------------------------------------------------------------- * _did_utils_loaded() - Verify module is loaded *---------------------------------------------------------------------------*/ void _did_utils_loaded() { printf("{txt}did_utils.mata loaded successfully\n") } // ---------------------------------------------------------------------------- // Forward struct declarations for cross-module dependencies // ---------------------------------------------------------------------------- // These structs are defined here (in the first-loaded module) because they are // referenced by did_bootstrap.mata (loaded before did_gmm.mata where they were // originally defined). Defining them here avoids "already exists" errors. /*--------------------------------------------------------------------------- * struct sa_point - Staggered Adoption Point Estimates * * Stores time-weighted DID and sequential DID estimates for each lead * value in staggered adoption designs. *---------------------------------------------------------------------------*/ struct sa_point { real rowvector DID // Time-weighted tau_DID for each lead (1 x n_lead) real rowvector sDID // Time-weighted tau_sDID for each lead (1 x n_lead) real colvector periods // Public period index for SA aggregation weights real matrix weights_common // Common-support time weights by lead } end