Source code for moderndid.didhonest.delta.sdrm.sdrmb

"""Functions for inference under second differences with relative magnitudes and bias sign restrictions."""

from typing import NamedTuple

import numpy as np
import scipy.optimize as opt

from ...arp_no_nuisance import compute_arp_ci
from ...arp_nuisance import compute_arp_nuisance_ci, compute_least_favorable_cv
from ...bounds import create_sign_constraint_matrix
from ...numba import create_sdrm_constraint_matrix, find_rows_with_post_period_values
from ...utils import basis_vector


class DeltaSDRMBResult(NamedTuple):
    """Container for second differences with relative magnitudes and bias restriction results.

    Attributes
    ----------
    id_lb : float
        Lower bound of the identified set.
    id_ub : float
        Upper bound of the identified set.
    """

    #: Lower bound of the identified set.
    id_lb: float
    #: Upper bound of the identified set.
    id_ub: float


[docs] def compute_conditional_cs_sdrmb( betahat, sigma, num_pre_periods, num_post_periods, l_vec=None, m_bar=0.0, alpha=0.05, hybrid_flag="LF", hybrid_kappa=None, post_period_moments_only=True, bias_direction="positive", grid_points=1000, grid_lb=None, grid_ub=None, seed=0, ): r"""Compute conditional confidence set for :math:`\Delta^{SDRMB}(\bar{M})`. Computes a confidence set for :math:`l'\tau_{post}` under the restriction that delta lies in :math:`\Delta^{SDRMB}(\bar{M})`, which intersects :math:`\Delta^{SDRM}(\bar{M})` with a sign restriction on the bias. This combined restriction is defined in Section 2.4.4 of [1]_ as .. math:: \Delta^{SDRMB}(\bar{M}) = \Delta^{SDRM}(\bar{M}) \cap \Delta^{B} where :math:`\Delta^{B}` represents a sign restriction on the bias. For a positive bias, .. math:: \Delta^{B} = \Delta^{PB} = \{\delta : \delta_t \geq 0, \forall t \geq 0\}, and for a negative bias, .. math:: \Delta^{B} = \Delta^{NB} = \{\delta : \delta_t \leq 0, \forall t \geq 0\}. This restriction combines three intuitions: smoothness of differential trends, relative magnitude bounds based on pre-treatment variation, and a known direction of bias. Parameters ---------- betahat : ndarray Estimated event study coefficients. sigma : ndarray Covariance matrix of :math:`\hat{\beta}`. num_pre_periods : int Number of pre-treatment periods. num_post_periods : int Number of post-treatment periods. l_vec : ndarray, optional Vector defining parameter of interest. If None, defaults to first post-period. m_bar : float, default=0 Relative magnitude parameter :math:`\bar{M}`. Second differences in post-treatment periods can be at most :math:`\bar{M}` times the maximum absolute second difference in pre-treatment periods. alpha : float, default=0.05 Significance level. hybrid_flag : {'LF', 'ARP'}, default='LF' Type of hybrid test. hybrid_kappa : float, optional First-stage size for hybrid test. If None, defaults to :math:`\alpha/10`. post_period_moments_only : bool, default=True If True, use only post-period moments for ARP test. bias_direction : {'positive', 'negative'}, default='positive' Direction of bias sign restriction. grid_points : int, default=1000 Number of grid points for confidence interval search. grid_lb : float, optional Lower bound for grid search. grid_ub : float, optional Upper bound for grid search. seed : int, default=0 Random seed for reproducibility. Returns ------- dict Returns dict with 'grid' and 'accept' arrays. Raises ------ ValueError If num_pre_periods == 1 (not enough pre-periods for second differences). If hybrid_flag is not in {'LF', 'ARP'}. Notes ----- The confidence set is constructed using the moment inequality approach from Section 3. Since :math:`\Delta^{SDRMB}(\bar{M})` is a finite union of polyhedra, we can apply Lemma 2.2 to construct a valid confidence set by taking the union of the confidence sets for each of its components. This restriction is not convex, so Fixed Length Confidence Intervals (FLCIs) are not recommended. The conditional/hybrid approach provides better power when multiple constraints are binding. References ---------- .. [1] Rambachan, A., & Roth, J. (2023). A more credible approach to parallel trends. Review of Economic Studies, 90(5), 2555-2591. """ if num_pre_periods == 1: raise ValueError( "Not enough pre-periods for Delta^{SDRMB}. Need at least 2 pre-periods to compute second differences." ) if hybrid_flag not in {"LF", "ARP"}: raise ValueError("hybrid_flag must be 'LF' or 'ARP'.") if l_vec is None: l_vec = basis_vector(1, num_post_periods) l_vec = np.asarray(l_vec).flatten() if hybrid_kappa is None: hybrid_kappa = alpha / 10 # Set default grid bounds if grid_lb is None or grid_ub is None: sel = slice(num_pre_periods, num_pre_periods + num_post_periods) sigma_post = sigma[sel, sel] sd_theta = np.sqrt(l_vec.T @ sigma_post @ l_vec) if num_pre_periods > 1: pre_betas_with_zero = np.concatenate([betahat[:num_pre_periods], [0]]) maxpre = np.max(np.abs(np.diff(pre_betas_with_zero))) else: maxpre = np.abs(betahat[0]) gridoff = betahat[sel] @ l_vec post_period_indices = np.arange(1, num_post_periods + 1) gridhalf = (m_bar * post_period_indices @ np.abs(l_vec) * maxpre) + (20 * sd_theta) if grid_ub is None: grid_ub = gridoff + gridhalf if grid_lb is None: grid_lb = gridoff - gridhalf min_s = -(num_pre_periods - 2) s_values = range(min_s, 1) grid = np.linspace(grid_lb, grid_ub, grid_points) n_s = len(s_values) # Compute CS for all (s, sign) combinations all_cs_pos = np.zeros((grid_points, n_s)) all_cs_neg = np.zeros((grid_points, n_s)) for i, s in enumerate(s_values): # Positive maximum cs_pos = _compute_conditional_cs_sdrmb_fixed_s( s=s, max_positive=True, m_bar=m_bar, betahat=betahat, sigma=sigma, num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, l_vec=l_vec, alpha=alpha, hybrid_flag=hybrid_flag, hybrid_kappa=hybrid_kappa, post_period_moments_only=post_period_moments_only, bias_direction=bias_direction, grid_points=grid_points, grid_lb=grid_lb, grid_ub=grid_ub, seed=seed, ) all_cs_pos[:, i] = cs_pos["accept"] # Negative maximum cs_neg = _compute_conditional_cs_sdrmb_fixed_s( s=s, max_positive=False, m_bar=m_bar, betahat=betahat, sigma=sigma, num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, l_vec=l_vec, alpha=alpha, hybrid_flag=hybrid_flag, hybrid_kappa=hybrid_kappa, post_period_moments_only=post_period_moments_only, bias_direction=bias_direction, grid_points=grid_points, grid_lb=grid_lb, grid_ub=grid_ub, seed=seed, ) all_cs_neg[:, i] = cs_neg["accept"] # Take union: accept if ANY (s, sign) accepts accept_pos = np.max(all_cs_pos, axis=1) accept_neg = np.max(all_cs_neg, axis=1) accept = np.maximum(accept_pos, accept_neg) return {"grid": grid, "accept": accept}
[docs] def compute_identified_set_sdrmb( m_bar, true_beta, l_vec, num_pre_periods, num_post_periods, bias_direction="positive", ): r"""Compute identified set for :math:`\Delta^{SDRMB}(\bar{M})`. Computes the identified set for :math:`l'\tau_{post}` under the restriction that the underlying trend delta lies in :math:`\Delta^{SDRMB}(\bar{M})`. The identified set under :math:`\Delta^{SDRMB}(\bar{M})` represents the values of :math:`\theta = l'\tau_{post}` consistent with the observed pre-treatment coefficients :math:`\beta_{pre} = \delta_{pre}`, the combined smoothness and relative magnitude constraints, and a sign restriction on the post-treatment bias, as described in Section 2.4.4 of [1]_. The set is constructed by taking the union of identified sets for each sub-polyhedron, each corresponding to a specific pre-treatment period `s` and sign for the maximum second difference, intersected with the bias sign restriction. Parameters ---------- m_bar : float Relative magnitude parameter. Second differences in post-treatment periods can be at most :math:`\bar{M}` times the maximum absolute second difference in pre-treatment periods. true_beta : ndarray True coefficient values (pre and post periods). l_vec : ndarray Vector defining parameter of interest :math:`\theta = l'\tau_{post}`. num_pre_periods : int Number of pre-treatment periods. num_post_periods : int Number of post-treatment periods. bias_direction : {'positive', 'negative'}, default='positive' Direction of bias sign restriction. Returns ------- DeltaSDRMBResult Lower and upper bounds of the identified set. Notes ----- The identified set is computed by solving linear programs for each choice of period :math:`s` and sign (positive/negative maximum), then taking the union of all resulting intervals, intersected with the sign restriction. References ---------- .. [1] Rambachan, A., & Roth, J. (2023). A more credible approach to parallel trends. Review of Economic Studies. """ l_vec = np.asarray(l_vec).flatten() min_s = -(num_pre_periods - 2) s_values = range(min_s, 1) # Include s=0 to match R's min_s:0 all_bounds = [] for s in s_values: bounds_pos = _compute_identified_set_sdrmb_fixed_s( s, m_bar, True, true_beta, l_vec, num_pre_periods, num_post_periods, bias_direction ) all_bounds.append(bounds_pos) bounds_neg = _compute_identified_set_sdrmb_fixed_s( s, m_bar, False, true_beta, l_vec, num_pre_periods, num_post_periods, bias_direction ) all_bounds.append(bounds_neg) # Take union: min of lower bounds, max of upper bounds id_lb = min(bound.id_lb for bound in all_bounds) id_ub = max(bound.id_ub for bound in all_bounds) return DeltaSDRMBResult(id_lb=id_lb, id_ub=id_ub)
def _compute_identified_set_sdrmb_fixed_s( s, m_bar, max_positive, true_beta, l_vec, num_pre_periods, num_post_periods, bias_direction, ): r"""Compute identified set for fixed s and sign. Helper function that computes bounds for a specific choice of :math:`s` and sign (max_positive). Parameters ---------- s : int Period index for maximum second difference (must be <= 0). m_bar : float Relative magnitude parameter. Second differences in post-treatment periods can be at most :math:`\bar{M}` times the maximum absolute second difference in pre-treatment periods. max_positive : bool Sign of maximum second difference. true_beta : ndarray Vector of true event study coefficients. l_vec : ndarray Vector defining parameter of interest :math:`\theta = l'\tau_{post}`. num_pre_periods : int Number of pre-treatment periods. num_post_periods : int Number of post-treatment periods. bias_direction : str Direction of bias sign restriction. Returns ------- DeltaSDRMBResult Identified set bounds. """ # Objective: min/max l'delta_post l_vec = np.asarray(l_vec).flatten() c = np.concatenate([np.zeros(num_pre_periods), l_vec]) a_sdrmb = _create_sdrmb_constraint_matrix(num_pre_periods, num_post_periods, m_bar, s, max_positive, bias_direction) b_sdrmb = _create_sdrmb_constraint_vector(a_sdrmb).flatten() a_eq = np.hstack([np.eye(num_pre_periods), np.zeros((num_pre_periods, num_post_periods))]) b_eq = true_beta[:num_pre_periods] n_vars = num_pre_periods + num_post_periods unbounded = [(None, None)] * n_vars result_max = opt.linprog( c=-c, A_ub=a_sdrmb, b_ub=b_sdrmb, A_eq=a_eq, b_eq=b_eq, bounds=unbounded, method="highs", ) result_min = opt.linprog( c=c, A_ub=a_sdrmb, b_ub=b_sdrmb, A_eq=a_eq, b_eq=b_eq, bounds=unbounded, method="highs", ) l_beta_post = l_vec @ true_beta[num_pre_periods:] if result_max.success and result_min.success: id_ub = l_beta_post - result_min.fun id_lb = l_beta_post + result_max.fun else: id_ub = id_lb = l_beta_post return DeltaSDRMBResult(id_lb=id_lb, id_ub=id_ub) def _compute_conditional_cs_sdrmb_fixed_s( s, max_positive, m_bar, betahat, sigma, num_pre_periods, num_post_periods, l_vec, alpha, hybrid_flag, hybrid_kappa, post_period_moments_only, bias_direction, grid_points, grid_lb, grid_ub, seed, ): """Compute conditional CS for fixed :math:`s` and sign. Helper function for computing ARP confidence interval for a specific choice of :math:`s` and sign. Parameters ---------- s : int Period index for maximum second difference. max_positive : bool Sign of maximum second difference. m_bar : float Relative magnitude parameter. betahat : ndarray Estimated event study coefficients. sigma : ndarray Covariance matrix of event study coefficients. num_pre_periods : int Number of pre-treatment periods. num_post_periods : int Number of post-treatment periods. l_vec : ndarray Vector defining parameter of interest. alpha : float Significance level. hybrid_flag : str Hybrid method: "LF" or "ARP". hybrid_kappa : float Hybrid kappa parameter. post_period_moments_only : bool Whether to use only post-period moments. bias_direction : str Direction of bias sign restriction. grid_points : int Number of grid points for confidence interval. grid_lb : float Lower bound of grid. grid_ub : float Upper bound of grid. seed : int Random seed. Returns ------- dict Results with 'grid' and 'accept' keys. """ a_sdrmb = _create_sdrmb_constraint_matrix(num_pre_periods, num_post_periods, m_bar, s, max_positive, bias_direction) d_sdrmb = _create_sdrmb_constraint_vector(a_sdrmb) rows_for_arp = None if post_period_moments_only and num_post_periods > 1: post_period_indices = list(range(num_pre_periods, a_sdrmb.shape[1])) rows_for_arp = find_rows_with_post_period_values(a_sdrmb, post_period_indices) hybrid_list = {"hybrid_kappa": hybrid_kappa} if num_post_periods == 1: # Single post-period: use no-nuisance parameter method return _compute_cs_sdrmb_no_nuisance( betahat=betahat, sigma=sigma, num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, a_sdrmb=a_sdrmb, d_sdrmb=d_sdrmb, alpha=alpha, hybrid_flag=hybrid_flag, hybrid_kappa=hybrid_kappa, grid_lb=grid_lb, grid_ub=grid_ub, grid_points=grid_points, seed=seed, ) # Multiple post-periods: use nuisance parameter method result = compute_arp_nuisance_ci( betahat=betahat, sigma=sigma, l_vec=l_vec, a_matrix=a_sdrmb, d_vec=d_sdrmb, num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, alpha=alpha, hybrid_flag=hybrid_flag, hybrid_list=hybrid_list, grid_lb=grid_lb, grid_ub=grid_ub, grid_points=grid_points, rows_for_arp=rows_for_arp, ) return {"grid": result.accept_grid[:, 0], "accept": result.accept_grid[:, 1]} def _compute_cs_sdrmb_no_nuisance( betahat, sigma, num_pre_periods, num_post_periods, a_sdrmb, d_sdrmb, alpha, hybrid_flag, hybrid_kappa, grid_lb, grid_ub, grid_points, seed, ): """Compute confidence set for single post-period case (no nuisance parameters).""" kwargs = { "beta_hat": betahat, "sigma": sigma, "A": a_sdrmb, "d": d_sdrmb, "n_pre_periods": num_pre_periods, "n_post_periods": num_post_periods, "post_period_index": 1, "alpha": alpha, "hybrid_flag": hybrid_flag, "hybrid_kappa": hybrid_kappa, "grid_lb": grid_lb, "grid_ub": grid_ub, "grid_points": grid_points, } if hybrid_flag == "LF": lf_cv = compute_least_favorable_cv( x_t=None, sigma=a_sdrmb @ sigma @ a_sdrmb.T, hybrid_kappa=hybrid_kappa, seed=seed, ) kwargs["lf_cv"] = lf_cv result = compute_arp_ci(**kwargs) return {"grid": result.theta_grid, "accept": result.accept_grid.astype(int)} def _create_sdrmb_constraint_matrix( num_pre_periods, num_post_periods, m_bar, s, max_positive=True, bias_direction="positive", drop_zero=True, ): r"""Create constraint matrix A for :math:`\Delta^{SDRMB}_{s,sign}(\bar{M})`. Creates a matrix for the linear constraints that define :math:`\Delta^{SDRMB}_{s,sign}(\bar{M})`. This set combines the second-difference relative magnitude constraint with a sign restriction on the bias, as discussed in Section 2.4.4 of [1]_. The constraint set is the intersection of two polyhedra .. math:: \Delta^{SDRMB}_{s,sign}(\bar{M}) = \Delta^{SDRM}_{s,sign}(\bar{M}) \cap \Delta^{B}, where :math:`\Delta^{SDRM}_{s,sign}(\bar{M})` constrains post-treatment second differences relative to a specific pre-treatment period :math:`s`, and :math:`\Delta^{B}` enforces a sign restriction on all post-treatment biases. This function stacks the constraint matrices from both restrictions to create a combined system :math:`A\delta \leq d` that captures this intersection. Parameters ---------- num_pre_periods : int Number of pre-treatment periods. num_post_periods : int Number of post-treatment periods. m_bar : float Relative magnitude parameter. Post-period second differences can be at most :math:`\bar{M}` times the second difference in period :math:`s`. s : int Period index for maximum second difference (must be <= 0). max_positive : bool, default=True If True, period s has maximum positive second difference. If False, period s has maximum negative second difference. bias_direction : str, default='positive' Direction of bias sign restriction ('positive' or 'negative'). drop_zero : bool, default=True Whether to drop the constraint for period t=0. Returns ------- ndarray Constraint matrix A such that :math:`\delta \in \Delta^{SDRMB}` iff :math:`A \delta \leq d`. Notes ----- The resulting constraint matrix has dimensions :math:`(n_{constraints}, T_{pre} + T_{post})`, where the number of constraints depends on the specific restrictions being imposed. """ a_sdrm = create_sdrm_constraint_matrix( num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, m_bar=m_bar, s=s, max_positive=max_positive, drop_zero=drop_zero, ) a_sign = create_sign_constraint_matrix( num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, bias_direction=bias_direction, ) a_sdrmb = np.vstack([a_sdrm, a_sign]) return a_sdrmb def _create_sdrmb_constraint_vector(a_matrix): r"""Create constraint vector d for :math:`\Delta^{SDRMB}`. For the combined smoothness with relative magnitudes and bias restriction, the constraint vector :math:`d` is a vector of zeros. This is because both the :math:`\Delta^{SDRM}` and :math:`\Delta^{B}` restrictions, as defined in [1]_, can be written with homogeneous inequality constraints of the form :math:`A\delta \leq 0`. Parameters ---------- a_matrix : ndarray The constraint matrix A. Returns ------- ndarray Constraint vector d (all zeros for :math:`\Delta^{SDRMB}`). Notes ----- The zero vector arises because the relative magnitudes constraint compares scaled second differences to zero, and the bias sign restriction constrains post-treatment effects relative to zero. """ return np.zeros(a_matrix.shape[0])