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

"""Functions for inference under second differences with relative magnitudes and monotonicity."""

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_monotonicity_constraint_matrix
from ...delta.sdrm.sdrm import _create_sdrm_constraint_matrix
from ...fixed_length_ci import compute_flci
from ...numba import find_rows_with_post_period_values
from ...utils import basis_vector


class DeltaSDRMMResult(NamedTuple):
    """Container for second differences with relative magnitudes and monotonicity 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_sdrmm( betahat, sigma, num_pre_periods, num_post_periods, l_vec=None, m_bar=0, alpha=0.05, hybrid_flag="LF", hybrid_kappa=None, monotonicity_direction="increasing", post_period_moments_only=True, grid_points=1000, grid_lb=None, grid_ub=None, seed=None, ): r"""Compute conditional confidence set for :math:`\Delta^{SDRMM}(\bar{M})`. Computes a confidence set for :math:`l'\tau_{post}` under the restriction that delta lies in :math:`\Delta^{SDRMM}(\bar{M})`, which combines the second-differences-with-relative-magnitudes restriction with a monotonicity constraint. This combined restriction is the intersection of :math:`\Delta^{SDRM}(\bar{M})` and a monotonicity restriction :math:`\Delta^{Mon}`, as discussed in Section 2.4.4 of [1]_, .. math:: \Delta^{SDRMM}(\bar{M}) = \Delta^{SDRM}(\bar{M}) \cap \Delta^{Mon} where for an increasing trend, :math:`\Delta^{Mon} = \Delta^{I} = \{\delta : \delta_t \geq \delta_{t-1}, \forall t\}`. This restriction is useful when pre-treatment trends suggest smoothly evolving confounders and economic theory suggests monotonic effects over time. 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 :math:`\theta = l'\tau_{post}`. 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', 'FLCI'}, default='LF' Type of hybrid test. hybrid_kappa : float, optional First-stage size for hybrid test. If None, defaults to :math:`\alpha/10`. monotonicity_direction : {'increasing', 'decreasing'}, default='increasing' Direction of monotonicity restriction. post_period_moments_only : bool, default=True If True, use only post-period moments for ARP test. 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, optional Random seed for reproducibility. Returns ------- dict or float 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', 'FLCI'}. Notes ----- The confidence set is constructed using the moment inequality approach from Section 3 of Rambachan & Roth (2023). Since :math:`\Delta^{SDRMM}(\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 provides a middle ground between the flexibility of :math:`\Delta^{SDRM}` and the additional structure imposed by monotonicity, potentially yielding tighter confidence intervals when both assumptions are plausible. 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^{SDRMM}. Need at least 2 pre-periods to compute second differences." ) if hybrid_flag not in {"LF", "ARP", "FLCI"}: raise ValueError("hybrid_flag must be 'LF', 'ARP', or 'FLCI'.") 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 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) 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): cs_pos = _compute_conditional_cs_sdrmm_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, monotonicity_direction=monotonicity_direction, post_period_moments_only=post_period_moments_only, grid_points=grid_points, grid_lb=grid_lb, grid_ub=grid_ub, seed=seed, ) all_cs_pos[:, i] = cs_pos["accept"] cs_neg = _compute_conditional_cs_sdrmm_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, monotonicity_direction=monotonicity_direction, post_period_moments_only=post_period_moments_only, 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_sdrmm( m_bar, true_beta, l_vec, num_pre_periods, num_post_periods, monotonicity_direction="increasing", ): r"""Compute identified set for :math:`\Delta^{SDRMM}(\bar{M})`. Computes the identified set for :math:`l'\tau_{post}` under the restriction that the underlying trend delta lies in :math:`\Delta^{SDRMM}(\bar{M})`. This set combines the second-differences-with-relative-magnitudes constraint with a monotonicity constraint, as discussed in Section 2.4.4 of [1]_. The identified set is the union of identified sets for each component polyhedron, .. math:: \mathcal{S}(\beta, \Delta^{SDRMM}(\bar{M})) = \bigcup_{s<0, sign \in \{+,-\}} \mathcal{S}(\beta, \Delta^{SDRM}_{s,sign}(\bar{M}) \cap \Delta^{Mon}) where each component set is computed by solving linear programs to find the range of :math:`l'\tau_{post}` consistent with the constraints. 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. monotonicity_direction : {'increasing', 'decreasing'}, default='increasing' Direction of monotonicity restriction. Returns ------- DeltaSDRMMResult 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 \in \{-(T_{pre}-2), ..., 0\}` and sign (positive/negative maximum), then taking the union of all resulting intervals. The monotonicity constraint is enforced in each linear program, ensuring that treatment effects are either non-decreasing or non-increasing over time. The linear programs solve for the maximum and minimum of :math:`l'\delta_{post}` subject to constraints including :math:`\delta_{pre} = \beta_{pre}` and :math:`\delta \in \Delta^{SDRM}_{s,sign}(\bar{M}) \cap \Delta^{Mon}`. References ---------- .. [1] Rambachan, A., & Roth, J. (2023). A more credible approach to parallel trends. Review of Economic Studies, 90(5), 2555-2591. """ 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_sdrmm_fixed_s( s, m_bar, True, true_beta, l_vec, num_pre_periods, num_post_periods, monotonicity_direction, ) all_bounds.append(bounds_pos) bounds_neg = _compute_identified_set_sdrmm_fixed_s( s, m_bar, False, true_beta, l_vec, num_pre_periods, num_post_periods, monotonicity_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 DeltaSDRMMResult(id_lb=id_lb, id_ub=id_ub)
def _compute_identified_set_sdrmm_fixed_s( s, m_bar, max_positive, true_beta, l_vec, num_pre_periods, num_post_periods, monotonicity_direction="increasing", ): 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), subject to both SDRM and monotonicity constraints. 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. monotonicity_direction : str Direction of monotonicity ('increasing' or 'decreasing'). Returns ------- DeltaSDRMMResult 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_sdrmm = _create_sdrmm_constraint_matrix( num_pre_periods, num_post_periods, m_bar, s, max_positive, monotonicity_direction ) b_sdrmm = _create_sdrmm_constraint_vector(a_sdrmm) 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_sdrmm, b_ub=b_sdrmm, A_eq=a_eq, b_eq=b_eq, bounds=unbounded, method="highs", ) result_min = opt.linprog( c=c, A_ub=a_sdrmm, b_ub=b_sdrmm, 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 DeltaSDRMMResult(id_lb=id_lb, id_ub=id_ub) def _compute_conditional_cs_sdrmm_fixed_s( s, max_positive, m_bar, betahat, sigma, num_pre_periods, num_post_periods, l_vec, alpha, hybrid_flag, hybrid_kappa, monotonicity_direction, post_period_moments_only, grid_points, grid_lb, grid_ub, seed, ): r"""Compute conditional CS for fixed s and sign. Helper function for computing ARP confidence interval for a specific choice of s and sign, with both SDRM and monotonicity constraints. Parameters ---------- s : int Period index for maximum second difference (must be <= 0). max_positive : bool Sign of maximum second difference. 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. 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 :math:`\theta = l'\tau_{post}`. alpha : float Significance level. hybrid_flag : str Hybrid method: "LF", "ARP", or "FLCI". hybrid_kappa : float Hybrid kappa parameter. monotonicity_direction : str Direction of monotonicity ('increasing' or 'decreasing'). post_period_moments_only : bool Whether to use only post-period moments. 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_sdrmm = _create_sdrmm_constraint_matrix( num_pre_periods, num_post_periods, m_bar, s, max_positive, monotonicity_direction ) d_sdrmm = _create_sdrmm_constraint_vector(a_sdrmm) rows_for_arp = None if post_period_moments_only and num_post_periods > 1: post_period_indices = list(range(num_pre_periods, a_sdrmm.shape[1])) rows_for_arp = find_rows_with_post_period_values(a_sdrmm, post_period_indices) if num_post_periods == 1: # Single post-period: use no-nuisance parameter method return _compute_cs_sdrmm_no_nuisance( betahat=betahat, sigma=sigma, num_pre_periods=num_pre_periods, num_post_periods=num_post_periods, a_sdrmm=a_sdrmm, d_sdrmm=d_sdrmm, 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 hybrid_list = {"hybrid_kappa": hybrid_kappa} if hybrid_flag == "FLCI": flci_result = compute_flci( beta_hat=betahat, sigma=sigma, smoothness_bound=m_bar, n_pre_periods=num_pre_periods, n_post_periods=num_post_periods, post_period_weights=l_vec, alpha=hybrid_kappa, ) hybrid_list["flci_l"] = flci_result.optimal_vec hybrid_list["flci_halflength"] = flci_result.optimal_half_length try: vbar, _, _, _ = np.linalg.lstsq(a_sdrmm.T, flci_result.optimal_vec, rcond=None) hybrid_list["vbar"] = vbar except np.linalg.LinAlgError: hybrid_list["vbar"] = np.zeros(a_sdrmm.shape[0]) result = compute_arp_nuisance_ci( betahat=betahat, sigma=sigma, l_vec=l_vec, a_matrix=a_sdrmm, d_vec=d_sdrmm, 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_sdrmm_no_nuisance( betahat, sigma, num_pre_periods, num_post_periods, a_sdrmm, d_sdrmm, alpha, hybrid_flag, hybrid_kappa, grid_lb, grid_ub, grid_points, seed, ): """Compute confidence set for single post-period case (no nuisance parameters).""" hybrid_list = {"hybrid_kappa": hybrid_kappa} if hybrid_flag == "LF": lf_cv = compute_least_favorable_cv( x_t=None, sigma=a_sdrmm @ sigma @ a_sdrmm.T, hybrid_kappa=hybrid_kappa, seed=seed, ) hybrid_list["lf_cv"] = lf_cv arp_kwargs = { "beta_hat": betahat, "sigma": sigma, "A": a_sdrmm, "d": d_sdrmm, "n_pre_periods": num_pre_periods, "n_post_periods": num_post_periods, "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" and "lf_cv" in hybrid_list: arp_kwargs["lf_cv"] = hybrid_list["lf_cv"] result = compute_arp_ci(**arp_kwargs) return {"grid": result.theta_grid, "accept": result.accept_grid} def _create_sdrmm_constraint_matrix( num_pre_periods, num_post_periods, m_bar, s, max_positive=True, monotonicity_direction="increasing", drop_zero=True, ): r"""Create constraint matrix A for :math:`\Delta^{SDRMM}_{s,sign}(\bar{M})`. Creates a matrix for the linear constraints that define :math:`\Delta^{SDRMM}_{s,sign}(\bar{M})`. This set combines the second-differences-with-relative-magnitudes constraint with a monotonicity constraint, as discussed in Section 2.4.4 of [1]_. The constraint set is the intersection of two polyhedra, .. math:: \Delta^{SDRMM}_{s,sign}(\bar{M}) = \Delta^{SDRM}_{s,sign}(\bar{M}) \cap \Delta^{Mon} This function stacks the constraint matrices from the relative magnitudes constraint for period :math:`s` and the monotonicity constraint to create a combined system. 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. Controls how much larger post-treatment second differences can be relative to 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. monotonicity_direction : str, default='increasing' Direction of monotonicity constraint ('increasing' or 'decreasing'). drop_zero : bool, default=True Whether to drop the constraint for period t=0. Returns ------- ndarray Constraint matrix :math:`A` such that :math:`\delta \in \Delta^{SDRMM}` iff :math:`A\delta \leq d`. Notes ----- The monotonicity constraints are adjusted to match the dimensionality of the SDRM constraints when drop_zero=False, ensuring proper alignment of the constraint system. """ a_sdrm = _create_sdrm_constraint_matrix(num_pre_periods, num_post_periods, m_bar, s, max_positive, drop_zero) a_mono = create_monotonicity_constraint_matrix( num_pre_periods, num_post_periods, monotonicity_direction, post_period_moments_only=False ) if not drop_zero: a_mono = np.insert(a_mono, num_pre_periods, 0, axis=1) return np.vstack([a_sdrm, a_mono]) def _create_sdrmm_constraint_vector(a_matrix): r"""Create constraint vector d for :math:`\Delta^{SDRMM}`. For the combined smoothness with relative magnitudes and monotonicity restriction, the constraint vector :math:`d` is a vector of zeros. This arises because the relative magnitudes constraints in :math:`\Delta^{SDRM}` and the monotonicity constraints in :math:`\Delta^{Mon}` can be written as homogeneous inequalities, as shown in [1]_. Parameters ---------- a_matrix : ndarray The constraint matrix :math:`A`. Returns ------- ndarray Constraint vector d (all zeros for :math:`\Delta^{SDRMM}`). Notes ----- The zero constraint vector reflects that all restrictions in :math:`\Delta^{SDRMM}` are relative comparisons between different elements of :math:`\delta`, rather than absolute bounds on individual components. """ return np.zeros(a_matrix.shape[0])