"""Functions for inference under second differences with monotonicity 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_monotonicity_constraint_matrix
from ...delta.sd.sd import _create_sd_constraint_matrix, _create_sd_constraint_vector
from ...fixed_length_ci import compute_flci
from ...numba import find_rows_with_post_period_values
from ...utils import basis_vector
class DeltaSDMResult(NamedTuple):
"""Container for second differences with monotonicity identified set 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_sdm(
betahat,
sigma,
num_pre_periods,
num_post_periods,
l_vec=None,
m_bar=0,
alpha=0.05,
monotonicity_direction="increasing",
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^{SDI}(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 the second differences with monotonicity
restriction :math:`\Delta^{SDI}(M)`.
The combined smoothness and monotonicity restriction, denoted :math:`\Delta^{SDI}(M)` in [2]_, is
defined as the intersection of :math:`\Delta^{SD}(M)` and a monotonicity restriction :math:`\Delta^I`
.. math::
\Delta^{SDI}(M) := \Delta^{SD}(M) \cap \Delta^{I},
where
.. math::
\Delta^{SD}(M) := \{\delta: |(\delta_{t+1} - \delta_t) - (\delta_t - \delta_{t-1})| \le M, \forall t\},
and :math:`\Delta^{I} := \{\delta: \delta_t \ge \delta_{t-1}, \forall t\}` for an increasing trend. For a
decreasing trend, the restriction is :math:`\Delta^{SD}(M) \cap (-\Delta^{I})`.
This restriction is useful when economic theory suggests both smooth evolution of
trends and monotonic behavior (e.g., secular trends expected to continue post-treatment).
The intersection typically leads to smaller identified sets than using either restriction alone.
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. If None, defaults to first post-period.
m_bar : float, default=0
Smoothness parameter M for :math:`\Delta^{SDI}(M)`.
alpha : float, default=0.05
Significance level.
monotonicity_direction : {'increasing', 'decreasing'}, default='increasing'
Direction of monotonicity restriction.
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 or float
Returns dict with 'grid' and 'accept' arrays.
Notes
-----
:math:`\Delta^{SDI}(M)` is a polyhedron formed by the intersection of smoothness and monotonicity
constraints. The confidence set is constructed using either FLCIs or the moment inequality
approach from Section 3 of [2]_.
As noted in [2]_, monotonicity restrictions are often motivated by economic arguments. For
example, Lovenheim & Willen (2019) argue that pre-treatment trends in the "wrong direction"
(opposite to treatment effects) support their findings.
Unlike :math:`\Delta^{SD}(M)` alone, the optimal FLCI for :math:`\Delta^{SDI}(M)` has the same
worst-case bias as for :math:`\Delta^{SD}(M)`, meaning FLCIs do not adapt to the additional
monotonicity restriction.
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.
"""
if l_vec is None:
l_vec = basis_vector(1, num_post_periods)
if hybrid_kappa is None:
hybrid_kappa = alpha / 10
A_sdm = _create_sdm_constraint_matrix(
num_pre_periods, num_post_periods, monotonicity_direction, post_period_moments_only=False
)
d_sdm = _create_sdm_constraint_vector(num_pre_periods, num_post_periods, m_bar, post_period_moments_only=False)
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_sdm, post_period_indices)
else:
rows_for_arp = None
if num_post_periods == 1:
return _compute_cs_sdm_no_nuisance(
betahat=betahat,
sigma=sigma,
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
A_sdm=A_sdm,
d_sdm=d_sdm,
l_vec=l_vec,
m_bar=m_bar,
alpha=alpha,
hybrid_flag=hybrid_flag,
hybrid_kappa=hybrid_kappa,
monotonicity_direction=monotonicity_direction,
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_sdm.T, flci_result.optimal_vec, rcond=None)
hybrid_list["vbar"] = vbar
except np.linalg.LinAlgError:
hybrid_list["vbar"] = np.zeros(A_sdm.shape[0])
if grid_lb is None or grid_ub is None:
if hybrid_flag == "FLCI" and grid_lb is None:
grid_lb = (flci_result.optimal_vec @ betahat) - flci_result.optimal_half_length
if hybrid_flag == "FLCI" and grid_ub is None:
grid_ub = (flci_result.optimal_vec @ betahat) + flci_result.optimal_half_length
if grid_lb is None or grid_ub is None:
id_set = compute_identified_set_sdm(
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,
monotonicity_direction=monotonicity_direction,
)
sd_theta = np.sqrt(l_vec.flatten() @ sigma[num_pre_periods:, num_pre_periods:] @ l_vec.flatten())
if grid_lb is None:
grid_lb = id_set.id_lb - 20 * sd_theta
if grid_ub is None:
grid_ub = id_set.id_ub + 20 * sd_theta
result = compute_arp_nuisance_ci(
betahat=betahat,
sigma=sigma,
l_vec=l_vec,
a_matrix=A_sdm,
d_vec=d_sdm,
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_sdm(
m_bar,
true_beta,
l_vec,
num_pre_periods,
num_post_periods,
monotonicity_direction="increasing",
):
r"""Compute identified set for :math:`\Delta^{SDI}(M)`.
Computes the identified set for :math:`l'\tau_{post}` under the restriction that the underlying
trend :math:`\delta` lies in :math:`\Delta^{SDI}(M)`, which combines second differences bounds
with a monotonicity restriction.
The identified set is an interval :math:`[\theta^{lb}, \theta^{ub}]` derived from Lemma 2.1 in [2]_.
The bounds are given by
.. 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}\},
where :math:`\Delta` is :math:`\Delta^{SDI}(M)`, the intersection of the smoothness and
monotonicity restrictions.
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 (pre and post periods).
l_vec : ndarray
Vector defining parameter of interest.
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
-------
DeltaSDMResult
Lower and upper bounds of the identified set.
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_sdm = _create_sdm_constraint_matrix(num_pre_periods, num_post_periods, monotonicity_direction)
d_sdm = _create_sdm_constraint_vector(num_pre_periods, num_post_periods, 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_sdm,
b_ub=d_sdm,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
method="highs",
)
result_min = opt.linprog(
c=f_delta,
A_ub=A_sdm,
b_ub=d_sdm,
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 DeltaSDMResult(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 DeltaSDMResult(id_lb=id_lb, id_ub=id_ub)
def _create_sdm_constraint_matrix(
num_pre_periods,
num_post_periods,
monotonicity_direction="increasing",
post_period_moments_only=False,
):
r"""Create constraint matrix for :math:`\Delta^{SDI}(M)`.
Combines second differences (SD) and monotonicity (I) constraints into a single
constraint matrix :math:`A` such that :math:`\delta \in \Delta^{SDI}(M)` can be written
as :math:`A \delta \leq d`.
Parameters
----------
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.
post_period_moments_only : bool, default=False
If True, use only post-period moments.
Returns
-------
ndarray
Constraint matrix :math:`A`.
"""
A_sd = _create_sd_constraint_matrix(
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
post_period_moments_only=post_period_moments_only,
)
A_m = create_monotonicity_constraint_matrix(
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
monotonicity_direction=monotonicity_direction,
post_period_moments_only=post_period_moments_only,
)
A = np.vstack([A_sd, A_m])
return A
def _create_sdm_constraint_vector(
num_pre_periods,
num_post_periods,
m_bar,
post_period_moments_only=False,
):
r"""Create constraint vector for :math:`\Delta^{SDI}(M)`.
Creates vector :math:`d` such that :math:`\delta \in \Delta^{SDI}(M)` can be written
as :math:`A \delta \leq d`.
Parameters
----------
num_pre_periods : int
Number of pre-treatment periods.
num_post_periods : int
Number of post-treatment periods.
m_bar : float
Smoothness parameter M.
post_period_moments_only : bool, default=False
If True, use only post-period moments.
Returns
-------
ndarray
Constraint vector d.
"""
A_sd = _create_sd_constraint_matrix(
num_pre_periods=num_pre_periods,
num_post_periods=num_post_periods,
post_period_moments_only=post_period_moments_only,
)
d_sd = _create_sd_constraint_vector(A_sd, m_bar)
# Monotonicity constraints have RHS of 0
if post_period_moments_only:
d_m = np.zeros(num_post_periods if num_post_periods > 1 else num_pre_periods + num_post_periods)
else:
d_m = np.zeros(num_pre_periods + num_post_periods)
d = np.concatenate([d_sd, d_m])
return d
def _compute_cs_sdm_no_nuisance(
betahat,
sigma,
num_pre_periods,
num_post_periods,
A_sdm,
d_sdm,
l_vec,
m_bar,
alpha,
hybrid_flag,
hybrid_kappa,
monotonicity_direction,
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 the full optimal_vec for compute_arp_ci
hybrid_list["flci_l"] = flci_result.optimal_vec
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_sdm @ sigma @ A_sdm.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_sdm(
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,
monotonicity_direction=monotonicity_direction,
)
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
arp_kwargs = {
"beta_hat": betahat,
"sigma": sigma,
"A": A_sdm,
"d": d_sdm,
"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 == "FLCI":
if "flci_l" in hybrid_list:
arp_kwargs["flci_l"] = hybrid_list["flci_l"]
if "flci_halflength" in hybrid_list:
arp_kwargs["flci_halflength"] = hybrid_list["flci_halflength"]
elif hybrid_flag == "LF":
if "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}