"""Functions for inference under second differences 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 ...fixed_length_ci import compute_flci
from ...numba import create_second_differences_matrix, find_rows_with_post_period_values
from ...utils import basis_vector
class DeltaSDResult(NamedTuple):
"""Container for second differences identified set computation 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_sd(
betahat,
sigma,
num_pre_periods,
num_post_periods,
l_vec=None,
m_bar=0,
alpha=0.05,
hybrid_flag="FLCI",
hybrid_kappa=None,
post_period_moments_only=True,
grid_points=1000,
grid_lb=None,
grid_ub=None,
seed=None,
):
r"""Compute conditional confidence set for :math:`\Delta^{SD}(M)`.
Computes a confidence set for :math:`l'\tau_{post}` that is valid conditional on the event study
coefficients being in the identified set under :math:`\Delta^{SD}(M)`.
The smoothness restriction :math:`\Delta^{SD}(M)` formalizes the concern about confounding from
secular trends that evolve smoothly over time. It bounds the discrete analog of the second
derivative, as defined in Equation (8) of [3]_
.. math::
\Delta^{SD}(M) := \{\delta: |(\delta_{t+1} - \delta_t) - (\delta_t - \delta_{t-1})| \le M, \forall t\}.
When :math:`M = 0`, this requires the differential trend to be exactly linear,
corresponding to the common practice of controlling for linear group-specific trends.
For :math:`M > 0`, it allows for approximate linearity, acknowledging that linear
specifications may not be exactly correct.
Parameters
----------
betahat : ndarray
Estimated event study coefficients.
sigma : ndarray
Covariance matrix of betahat.
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
Smoothness parameter M. Bounds the second differences: :math:`|\delta_{t-1} - 2\delta_t + \delta_{t+1}| \leq M`.
alpha : float, default=0.05
Significance level.
hybrid_flag : {'FLCI', 'LF', 'ARP'}, default='FLCI'
Type of hybrid test.
hybrid_kappa : float, optional
First-stage size for hybrid test. If None, defaults to alpha/10.
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
Returns dict with 'grid' and 'accept' arrays.
Notes
-----
:math:`\Delta^{SD}(M)` is convex and centrosymmetric (i.e. :math:`\tilde{\delta} \in \Delta` implies
:math:`-\tilde{\delta} \in \Delta`), which allows for the use of Fixed Length Confidence Intervals
(FLCIs) with near-optimal finite-sample properties [2]_. The identified set under
:math:`\Delta^{SD}(M)` has constant length :math:`2M` regardless of the pre-treatment coefficients.
The confidence set is constructed using either FLCIs (default) or the moment inequality approach
from Section 3 of [3]_. For FLCIs, the expected length is at most 28% longer than the shortest
possible confidence set satisfying the coverage requirement when the true parameter is at the
center of the identified set (Proposition 4.1 in [2]_).
References
----------
.. [1] Andrews, I., Roth, J., & Pakes, A. (2021). Inference for linear
conditional moment inequalities. Review of Economic Studies.
.. [2] Armstrong, T. B., & Kolesár, M. (2018). Optimal inference in a class of
regression models. Econometrica, 86(2), 655-683.
.. [3] Rambachan, A., & Roth, J. (2023). A more credible approach to
parallel trends. Review of Economic Studies, 90(5), 2555-2591.
"""
if l_vec is None:
l_vec = basis_vector(1, num_post_periods)
if hybrid_kappa is None:
hybrid_kappa = alpha / 10
A_sd = _create_sd_constraint_matrix(num_pre_periods, num_post_periods, post_period_moments_only=False)
d_sd = _create_sd_constraint_vector(A_sd, m_bar)
if post_period_moments_only and num_post_periods > 1:
post_period_indices = list(range(num_pre_periods, num_pre_periods + num_post_periods))
rows_for_arp = find_rows_with_post_period_values(A_sd, post_period_indices)
else:
rows_for_arp = None
# Handle special case of single post-period
if num_post_periods == 1:
return _compute_cs_sd_no_nuisance(
betahat=betahat,
sigma=sigma,
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
A_sd=A_sd,
d_sd=d_sd,
l_vec=l_vec,
m_bar=m_bar,
alpha=alpha,
hybrid_flag=hybrid_flag,
hybrid_kappa=hybrid_kappa,
grid_points=grid_points,
grid_lb=grid_lb,
grid_ub=grid_ub,
seed=seed,
)
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_sd.T, flci_result.optimal_vec, rcond=None)
hybrid_list["vbar"] = vbar
except np.linalg.LinAlgError:
hybrid_list["vbar"] = np.zeros(A_sd.shape[0])
if grid_lb is None or grid_ub is None:
sd_theta = np.sqrt(l_vec.flatten() @ sigma[num_pre_periods:, num_pre_periods:] @ l_vec.flatten())
if hybrid_flag == "FLCI":
# For FLCI hybrid, use the FLCI bounds centered at optimal estimate
if grid_ub is None:
grid_ub = flci_result.optimal_vec @ betahat + flci_result.optimal_half_length
if grid_lb is None:
grid_lb = flci_result.optimal_vec @ betahat - flci_result.optimal_half_length
else:
# For LF or ARP, compute identified set under parallel trends
id_set = compute_identified_set_sd(
m_bar=m_bar,
true_beta=np.zeros(num_pre_periods + num_post_periods),
l_vec=l_vec,
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
)
if grid_ub is None:
grid_ub = id_set.id_ub + 20 * sd_theta
if grid_lb is None:
grid_lb = id_set.id_lb - 20 * sd_theta
result = compute_arp_nuisance_ci(
betahat=betahat,
sigma=sigma,
l_vec=l_vec,
a_matrix=A_sd,
d_vec=d_sd,
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]}
[docs]
def compute_identified_set_sd(
m_bar,
true_beta,
l_vec,
num_pre_periods,
num_post_periods,
):
r"""Compute identified set for :math:`\Delta^{SD}(M)`.
Computes the identified set for :math:`l'\tau_{post}` under the restriction that the underlying
trend :math:`\delta` lies in :math:`\Delta^{SD}(M)`.
Following Lemma 2.1 in [2]_, if :math:`\Delta` is closed and convex, then :math:`\mathcal{S}(\beta, \Delta)`
is an interval, :math:`[\theta^{lb}(\beta, \Delta), \theta^{ub}(\beta, \Delta)]`, where
.. math::
\theta^{lb}(\beta, \Delta) := l'\beta_{post} - \max_{\delta} \{l'\delta_{post} :
\delta \in \Delta, \delta_{pre} = \beta_{pre}\},
\theta^{ub}(\beta, \Delta) := l'\beta_{post} - \min_{\delta} \{l'\delta_{post} :
\delta \in \Delta, \delta_{pre} = \beta_{pre}\}.
The identified set is constructed subject to :math:`\delta \in \Delta^{SD}(M)` and
:math:`\delta_{pre} = \beta_{pre}`.
Under the decomposition :math:`\beta = \tau + \delta` with :math:`\tau_{pre} = 0`,
the causal parameter :math:`\theta = l'\tau_{post}` is partially identified. Since
:math:`\delta_{pre} = \beta_{pre}` is point identified, the restriction
:math:`\delta \in \Delta^{SD}(M)` constrains the possible values of :math:`\delta_{post}`.
Parameters
----------
m_bar : float
Smoothness parameter M. Bounds the second differences: :math:`|\delta_{t-1} - 2\delta_t + \delta_{t+1}| \leq M`.
true_beta : ndarray
True coefficient values :math:`\beta = (\beta_{pre}', \beta_{post}')'`.
l_vec : ndarray
Vector defining parameter of interest :math:`\theta = l'\tau_{post}`.
num_pre_periods : int
Number of pre-treatment periods :math:`\underline{T}`.
num_post_periods : int
Number of post-treatment periods :math:`\bar{T}`.
Returns
-------
DeltaSDResult
Lower and upper bounds of the identified set.
Notes
-----
The constraint :math:`\delta_{pre} = \beta_{pre}` reflects that pre-treatment event study coefficients
identify the pre-treatment trend under the no-anticipation assumption.
References
----------
.. [1] Andrews, I., Roth, J., & Pakes, A. (2021). Inference for linear
conditional moment inequalities. Review of Economic Studies.
.. [2] Rambachan, A., & Roth, J. (2023). A more credible approach to
parallel trends. Review of Economic Studies, 90(5), 2555-2591.
"""
f_delta = np.concatenate([np.zeros(num_pre_periods), l_vec.flatten()])
A_sd = _create_sd_constraint_matrix(num_pre_periods, num_post_periods)
d_sd = _create_sd_constraint_vector(A_sd, m_bar)
A_eq = np.hstack([np.eye(num_pre_periods), np.zeros((num_pre_periods, num_post_periods))])
b_eq = true_beta[:num_pre_periods]
bounds = [(None, None) for _ in range(num_pre_periods + num_post_periods)]
result_max = opt.linprog(
c=-f_delta,
A_ub=A_sd,
b_ub=d_sd,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
method="highs",
)
result_min = opt.linprog(
c=f_delta,
A_ub=A_sd,
b_ub=d_sd,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
method="highs",
)
if not result_max.success or not result_min.success:
observed_val = l_vec.flatten() @ true_beta[num_pre_periods:]
return DeltaSDResult(id_lb=observed_val, id_ub=observed_val)
# Compute bounds of identified set
# ID set = observed value ± bias
observed = l_vec.flatten() @ true_beta[num_pre_periods:]
id_ub = observed - result_min.fun
id_lb = observed + result_max.fun
return DeltaSDResult(id_lb=id_lb, id_ub=id_ub)
def _create_sd_constraint_matrix(
num_pre_periods,
num_post_periods,
drop_zero_period=True,
post_period_moments_only=False,
):
r"""Create constraint matrix for second differences restriction.
Creates matrix A such that the constraint :math:`\delta \in \Delta^{SD}(M)` can be written as
:math:`A \delta \leq d`, where d is a vector with all elements equal to M.
The constraint matrix implements the pair of inequalities that define the bound on the
discrete second derivative of :math:`\delta`
.. math::
(\delta_{t+1} - \delta_t) - (\delta_t - \delta_{t-1}) \le M
-((\delta_{t+1} - \delta_t) - (\delta_t - \delta_{t-1})) \le M,
for each period :math:`t`. This ensures :math:`|(\delta_{t+1} - \delta_t) - (\delta_t - \delta_{t-1})| \le M`.
The second differences constraint can be viewed as a discrete analog of bounding
the second derivative of a smooth function. This is motivated by concerns about
confounding from secular trends that evolve smoothly over time.
Parameters
----------
num_pre_periods : int
Number of pre-treatment periods :math:`\underline{T}`.
num_post_periods : int
Number of post-treatment periods :math:`\bar{T}`.
drop_zero_period : bool, default=True
Whether to drop the period t=0 (treatment period) from the constraints.
This is standard as we typically normalize :math:`\delta_0 = 0`.
post_period_moments_only : bool, default=False
If True, exclude moments that only involve pre-periods.
Returns
-------
ndarray
Constraint matrix A of shape (2*(num_constraints), num_periods).
"""
# First construct the positive moments matrix
# Each row represents one second difference constraint
num_constraints = num_pre_periods + num_post_periods - 1
total_periods = num_pre_periods + num_post_periods + 1
A_positive = create_second_differences_matrix(num_constraints, total_periods)
# If drop_zero_period, remove the column for period 0
if drop_zero_period:
A_positive = np.delete(A_positive, num_pre_periods, axis=1)
# If requested, remove constraints that only involve pre-periods
if post_period_moments_only and num_post_periods > 1:
post_period_start = num_pre_periods if drop_zero_period else num_pre_periods + 1
post_period_indices = np.arange(post_period_start, A_positive.shape[1])
rows_to_keep = []
for i in range(A_positive.shape[0]):
if np.any(A_positive[i, post_period_indices] != 0):
rows_to_keep.append(i)
A_positive = A_positive[rows_to_keep, :]
A = np.vstack([A_positive, -A_positive])
return A
def _compute_cs_sd_no_nuisance(
betahat,
sigma,
num_pre_periods,
num_post_periods,
A_sd,
d_sd,
l_vec,
m_bar,
alpha,
hybrid_flag,
hybrid_kappa,
grid_points,
grid_lb,
grid_ub,
seed,
):
"""Compute confidence set for single post-period case (no nuisance parameters)."""
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,
)
# For single post-period, we need only the post-period part of optimal_vec
hybrid_list["flci_l"] = flci_result.optimal_vec[num_pre_periods:]
hybrid_list["flci_halflength"] = flci_result.optimal_half_length
if grid_ub is None:
grid_ub = flci_result.optimal_vec @ betahat + flci_result.optimal_half_length
if grid_lb is None:
grid_lb = flci_result.optimal_vec @ betahat - flci_result.optimal_half_length
else: # LF or ARP
if hybrid_flag == "LF":
lf_cv = compute_least_favorable_cv(
x_t=None,
sigma=A_sd @ sigma @ A_sd.T,
hybrid_kappa=hybrid_kappa,
seed=seed,
)
hybrid_list["lf_cv"] = lf_cv
if grid_ub is None or grid_lb is None:
id_set = compute_identified_set_sd(
m_bar=m_bar,
true_beta=np.zeros(num_pre_periods + num_post_periods),
l_vec=l_vec,
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
)
sd_theta = np.sqrt(l_vec.flatten() @ sigma[num_pre_periods:, num_pre_periods:] @ l_vec.flatten())
if grid_ub is None:
grid_ub = id_set.id_ub + 20 * sd_theta
if grid_lb is None:
grid_lb = id_set.id_lb - 20 * sd_theta
result = compute_arp_ci(
beta_hat=betahat,
sigma=sigma,
A=A_sd,
d=d_sd,
n_pre_periods=num_pre_periods,
n_post_periods=num_post_periods,
alpha=alpha,
hybrid_flag=hybrid_flag,
hybrid_kappa=hybrid_kappa,
lf_cv=hybrid_list.get("lf_cv"),
flci_l=hybrid_list.get("flci_l"),
flci_halflength=hybrid_list.get("flci_halflength"),
grid_lb=grid_lb,
grid_ub=grid_ub,
grid_points=grid_points,
)
return {"grid": result.theta_grid, "accept": result.accept_grid}
def _create_sd_constraint_vector(A, m_bar):
r"""Create the d vector for second differences constraints.
Parameters
----------
A : ndarray
Constraint matrix from _create_sd_constraint_matrix.
m_bar : float
Smoothness parameter M for :math:`\Delta^{SD}(M)`.
Returns
-------
ndarray
Vector d such that :math:`A \delta \leq d` defines :math:`\Delta^{SD}(M)`.
"""
return np.full(A.shape[0], m_bar)