diff --git a/doc/source/iv/index.rst b/doc/source/iv/index.rst index 6ef173cdac..4799d67331 100644 --- a/doc/source/iv/index.rst +++ b/doc/source/iv/index.rst @@ -26,6 +26,7 @@ tests. examples/advanced-examples.ipynb examples/using-formulas.ipynb examples/absorbing-regression.ipynb + weak-instruments reference mathematical-formula diff --git a/doc/source/iv/weak-instruments.rst b/doc/source/iv/weak-instruments.rst new file mode 100644 index 0000000000..4794ff4107 --- /dev/null +++ b/doc/source/iv/weak-instruments.rst @@ -0,0 +1,48 @@ +Confidence intervals with a weak instrument +=========================================== + +:meth:`~linearmodels.iv.results.IVResults.tf_conf_int` constructs a conservative +95% tF interval for one endogenous regressor and one excluded instrument. It is +an explicit alternative to the conventional :meth:`~linearmodels.iv.results.IVResults.conf_int`. +It supports IV2SLS with unadjusted or heteroskedasticity-robust covariance, +``debiased=False``, and constant observation weights. Other configurations raise +``ValueError``. Included exogenous controls are allowed. + +For example, use proximity to a four-year college as an instrument for education: + +.. doctest:: + + >>> from linearmodels.datasets import card + >>> from linearmodels.iv import IV2SLS + >>> data = card.load() + >>> columns = ["lwage", "educ", "exper", "expersq", "black", "south", "smsa", "nearc4"] + >>> data = data[columns].dropna() + >>> formula = "lwage ~ 1 + exper + expersq + black + south + smsa + [educ ~ nearc4]" + >>> result = IV2SLS.from_formula(formula, data).fit(cov_type="robust") + >>> interval = result.tf_conf_int() + >>> list(interval.index), list(interval.columns) + (['educ'], ['lower', 'upper']) + >>> conventional = result.conf_int().loc["educ"] + >>> bool(interval.loc["educ", "lower"] <= conventional["lower"]) + True + >>> bool(interval.loc["educ", "upper"] >= conventional["upper"]) + True + +The first-stage statistic is the squared t statistic for the excluded instrument, +using the fitted model's covariance convention. The coefficient and SE are reused. +Neither the original SE nor the conventional p-values and intervals are changed. + +The implementation interpolates in F using the upward-rounded values in Table 3a +of the `February 16, 2022 author version +`_ of Lee, McCrary, Moreira and +Porter's `Valid t-Ratio Inference for IV `_. +Section 3.2 justifies this conservative interpolation by convexity. It uses an +unbounded interval below F=4, including the small range where the smooth tF +interval is bounded, and a critical value of 1.96 at and above F=104.67. + +This adjustment is calibrated only for 95% confidence. It is not a p-value +calculation or a guarantee of finite-sample coverage. Instrument validity and the +model's other identifying and sampling assumptions still matter. The newer VtF +procedure uses additional information and can yield shorter intervals; this +method implements tF, not VtF. The existing ``anderson_rubin`` property is a test +of overidentification, not a weak-instrument confidence set for this coefficient. diff --git a/linearmodels/iv/_tf.py b/linearmodels/iv/_tf.py new file mode 100644 index 0000000000..ce6989f9b0 --- /dev/null +++ b/linearmodels/iv/_tf.py @@ -0,0 +1,119 @@ +"""Conservative tabulated tF critical values for single-instrument IV.""" + +from numpy import array, inf, interp, isfinite + +# Table 3a, Lee, McCrary, Moreira and Porter, February 16, 2022 author version: +# https://eml.berkeley.edu/~jmccrary/LMMP_2_15_22.pdf (page 16). +# Both columns are rounded upward in the paper. Section 3.2 justifies linear +# interpolation in F by convexity of the critical-value function. +_TF_TABLE = array( + [ + (4.000, 18.656), + (4.008, 18.236), + (4.015, 17.826), + (4.023, 17.425), + (4.031, 17.033), + (4.040, 16.649), + (4.049, 16.275), + (4.059, 15.909), + (4.068, 15.551), + (4.079, 15.201), + (4.090, 14.859), + (4.101, 14.524), + (4.113, 14.197), + (4.125, 13.878), + (4.138, 13.566), + (4.151, 13.260), + (4.166, 12.962), + (4.180, 12.670), + (4.196, 12.385), + (4.212, 12.107), + (4.229, 11.834), + (4.247, 11.568), + (4.265, 11.308), + (4.285, 11.053), + (4.305, 10.804), + (4.326, 10.561), + (4.349, 10.324), + (4.372, 10.091), + (4.396, 9.864), + (4.422, 9.642), + (4.449, 9.425), + (4.477, 9.213), + (4.507, 9.006), + (4.538, 8.803), + (4.570, 8.605), + (4.604, 8.412), + (4.640, 8.222), + (4.678, 8.037), + (4.717, 7.856), + (4.759, 7.680), + (4.803, 7.507), + (4.849, 7.338), + (4.897, 7.173), + (4.948, 7.011), + (5.002, 6.854), + (5.059, 6.699), + (5.119, 6.549), + (5.182, 6.401), + (5.248, 6.257), + (5.319, 6.117), + (5.393, 5.979), + (5.472, 5.844), + (5.556, 5.713), + (5.644, 5.584), + (5.738, 5.459), + (5.838, 5.336), + (5.944, 5.216), + (6.056, 5.098), + (6.176, 4.984), + (6.304, 4.872), + (6.440, 4.762), + (6.585, 4.655), + (6.741, 4.550), + (6.907, 4.448), + (7.085, 4.348), + (7.276, 4.250), + (7.482, 4.154), + (7.702, 4.061), + (7.940, 3.969), + (8.196, 3.880), + (8.473, 3.793), + (8.773, 3.707), + (9.098, 3.624), + (9.451, 3.542), + (9.835, 3.463), + (10.253, 3.385), + (10.711, 3.309), + (11.214, 3.234), + (11.766, 3.161), + (12.374, 3.090), + (13.048, 3.021), + (13.796, 2.953), + (14.631, 2.886), + (15.566, 2.821), + (16.618, 2.758), + (17.810, 2.696), + (19.167, 2.635), + (20.721, 2.576), + (22.516, 2.518), + (24.605, 2.461), + (27.058, 2.406), + (29.967, 2.352), + (33.457, 2.299), + (37.699, 2.247), + (42.930, 2.197), + (49.495, 2.147), + (57.902, 2.099), + (68.930, 2.052), + (83.823, 2.006), + (104.67, 1.960), + ] +) + + +def _tf_critical_value(f_stat: float) -> float: + """Return the conservative 95% tF critical value from Table 3a.""" + if not isfinite(f_stat) or f_stat < 0: + raise ValueError("The first-stage F statistic must be finite and nonnegative.") + return float(interp(f_stat, _TF_TABLE[:, 0], _TF_TABLE[:, 1], left=inf)) diff --git a/linearmodels/iv/results.py b/linearmodels/iv/results.py index 05ef6b9ded..8e835e98e2 100644 --- a/linearmodels/iv/results.py +++ b/linearmodels/iv/results.py @@ -17,6 +17,7 @@ c_, diag, empty, + isfinite, isnan, log, ndarray, @@ -31,8 +32,13 @@ from statsmodels.iolib.table import default_txt_fmt import linearmodels +from linearmodels.iv._tf import _tf_critical_value from linearmodels.iv._utility import annihilate, proj from linearmodels.iv.common import _kleibergen_paap, cragg_donald, cragg_donald_f +from linearmodels.iv.covariance import ( + HeteroskedasticCovariance, + HomoskedasticCovariance, +) from linearmodels.iv.data import IVData, IVDataLike from linearmodels.shared.base import _ModelComparison, _SummaryStr from linearmodels.shared.hypotheses import ( @@ -1312,6 +1318,103 @@ def __init__( super().__init__(results, model) self._kappa = results.get("kappa", 1) + def tf_conf_int(self) -> DataFrame: + """ + Construct a conservative 95% tF confidence interval. + + Returns + ------- + DataFrame + Lower and upper bounds for the endogenous regressor's coefficient. + The columns are ``lower`` and ``upper``. When the first-stage + statistic is below 4, the bounds are negative and positive infinity. + + Raises + ------ + ValueError + If the result is not from unweighted IV2SLS with one endogenous + regressor, one excluded instrument, unadjusted or robust covariance, + and ``debiased=False``, or the required estimates are not finite + with strictly positive variances. + + See Also + -------- + conf_int + Construct conventional confidence intervals for all coefficients. + first_stage + Access first-stage regressions and diagnostics. + + Notes + ----- + The tF procedure of Lee et al. [1]_ adjusts the critical value using + the squared first-stage t statistic. It provides asymptotic inference + robust to weak identification under the single-instrument IV model's + assumptions. It does not address invalid instruments or guarantee + finite-sample coverage. + + This implementation linearly interpolates the upward-rounded critical + values in Table 3a of the February 16, 2022 author version. Section 3.2 + establishes that interpolation in F is conservative. Below the first + tabulated F of 4, the interval is the entire real line; above 104.67, + the critical value is 1.96. The extra conservatism below 4 includes + some cases where the underlying smooth tF procedure is bounded. + + Only 95% confidence is supported. The adjustment does not define + p-values or other confidence levels. The coefficient estimate, reported + standard error and conventional inference are not changed. + + References + ---------- + .. [1] Lee, D. S., J. McCrary, M. J. Moreira, and J. Porter (2022). + "Valid t-Ratio Inference for IV." American Economic Review, + 112(10), 3260-3290. https://doi.org/10.1257/aer.20211063. + Author version: https://eml.berkeley.edu/~jmccrary/LMMP_2_15_22.pdf. + """ + from linearmodels.iv.model import IV2SLS + + if ( + not isinstance(self.model, IV2SLS) + or self.model.endog.shape[1] != 1 + or self.model.instruments.shape[1] != 1 + ): + raise ValueError( + "tF inference requires IV2SLS with one endogenous regressor " + "and one excluded instrument." + ) + if self.debiased or type(self._cov_estimator) not in ( + HomoskedasticCovariance, + HeteroskedasticCovariance, + ): + raise ValueError( + "tF inference requires unadjusted or robust covariance " + "with debiased=False." + ) + if (self.model.weights.ndarray != 1).any(): + raise ValueError("tF inference does not support nonconstant weights.") + + name = self.model.endog.cols[0] + first_stage = self.first_stage.individual[str(name)] + estimate = float(self.params.iloc[-1]) + variance = float(self.cov.to_numpy()[-1, -1]) + first_estimate = float(first_stage.params.iloc[-1]) + first_variance = float(first_stage.cov.to_numpy()[-1, -1]) + if ( + not isfinite([estimate, variance, first_estimate, first_variance]).all() + or variance <= 0 + or first_variance <= 0 + ): + raise ValueError( + "tF inference requires finite estimates and positive variances." + ) + first_t = float(first_stage.tstats.iloc[-1]) + critical = _tf_critical_value(first_t * first_t) + half_width = critical * float(self.std_errors.iloc[-1]) + return DataFrame( + [[estimate - half_width, estimate + half_width]], + index=self.params.index[-1:], + columns=["lower", "upper"], + ) + @cached_property def sargan(self) -> InvalidTestStatistic | WaldTestStatistic: r""" diff --git a/linearmodels/tests/iv/test_jive.py b/linearmodels/tests/iv/test_jive.py index 5e99a87a70..4497db9010 100644 --- a/linearmodels/tests/iv/test_jive.py +++ b/linearmodels/tests/iv/test_jive.py @@ -829,7 +829,11 @@ def test_results(): # JIVE is not a k-class estimator assert res.kappa is None for attr in dir(res): - if attr.startswith("_") or attr in ("test_linear_constraint", "wald_test"): + if attr.startswith("_") or attr in ( + "test_linear_constraint", + "wald_test", + "tf_conf_int", + ): continue value = getattr(res, attr) if callable(value): diff --git a/linearmodels/tests/iv/test_results.py b/linearmodels/tests/iv/test_results.py index 69dd6cce06..927976c597 100644 --- a/linearmodels/tests/iv/test_results.py +++ b/linearmodels/tests/iv/test_results.py @@ -22,7 +22,11 @@ def model(request): def result_checker(res): for attr in dir(res): - if attr.startswith("_") or attr in ("test_linear_constraint", "wald_test"): + if attr.startswith("_") or attr in ( + "test_linear_constraint", + "wald_test", + "tf_conf_int", + ): continue print(attr) if attr == "summary": diff --git a/linearmodels/tests/iv/test_tf.py b/linearmodels/tests/iv/test_tf.py new file mode 100644 index 0000000000..df00af1d68 --- /dev/null +++ b/linearmodels/tests/iv/test_tf.py @@ -0,0 +1,272 @@ +import numpy as np +from numpy.testing import assert_allclose, assert_array_equal +from pandas import DataFrame, Series +from pandas.testing import assert_frame_equal, assert_series_equal +import pytest + +from linearmodels.iv import IV2SLS, IVJIVE, IVLIML +from linearmodels.iv._tf import _tf_critical_value +from linearmodels.iv.results import FirstStageResults +from linearmodels.shared.exceptions import MissingValueWarning + + +@pytest.fixture +def data(): + rng = np.random.default_rng(98765) + n = 200 + raw = np.column_stack([np.ones(n), rng.standard_normal((n, 4))]) + q, _ = np.linalg.qr(raw) + w, z, v, e = q[:, :2], q[:, 2], q[:, 3], q[:, 4] + z, v, e = (a * np.sqrt(n) for a in (z, v, e)) + x = np.sqrt(10 / n) * z + v + u = 0.4 * np.sqrt(10) * (0.8 * v + 0.6 * e) + y = x + w @ np.array([0.5, 2.0]) + u + return y, w, x, z + + +@pytest.mark.parametrize( + ("f_stat", "expected"), + [ + (4, 18.656), + (4.008, 18.236), + (9.835, 3.463), + (10.253, 3.385), + (24.605, 2.461), + (104.67, 1.96), + (1e12, 1.96), + ], +) +def test_published_values(f_stat, expected): + # Table 3a, February 16, 2022 author version, page 16. + assert _tf_critical_value(f_stat) == expected + + +def test_interpolation_in_f(): + # Section 3.2's worked F=10 example, using the critical-value row rather + # than its separately rounded standard-error-adjustment row. + expected = 3.463 + (10 - 9.835) / (10.253 - 9.835) * (3.385 - 3.463) + assert_allclose(_tf_critical_value(10), expected, rtol=1e-14) + assert _tf_critical_value(10) != 3.463 + + +@pytest.mark.parametrize("f_stat", [0, 1e-15, 1.96**2, np.nextafter(4.0, 0)]) +def test_below_table_is_unbounded(f_stat): + assert _tf_critical_value(f_stat) == np.inf + + +@pytest.mark.parametrize("f_stat", [-1, -np.inf, np.inf, np.nan]) +def test_invalid_f(f_stat): + with pytest.raises(ValueError, match="finite and nonnegative"): + _tf_critical_value(f_stat) + + +def test_monotonicity_and_table_boundaries(): + f = np.concatenate([np.linspace(4, 104.67, 10001), [104.67, 1e6]]) + critical = np.array([_tf_critical_value(x) for x in f]) + assert np.all(np.diff(critical) <= 0) + assert np.all(critical >= 1.96) + for knot, expected in [(4, 18.656), (9.835, 3.463), (104.67, 1.96)]: + assert_allclose(_tf_critical_value(np.nextafter(knot, np.inf)), expected) + if knot > 4: + assert_allclose(_tf_critical_value(np.nextafter(knot, 0)), expected) + + +def test_hand_calculated_interval(data): + y, w, x, z = data + res = IV2SLS(y, w, x, z).fit(cov_type="unadjusted") + # Orthogonal construction: first-stage F=10, beta=1, and IV SE=0.4. + assert_allclose(res.params.iloc[-1], 1, atol=1e-13) + assert_allclose(res.std_errors.iloc[-1], 0.4, rtol=1e-13) + critical = 3.4322105263157896 + assert_allclose(res.tf_conf_int().iloc[0], [1 - 0.4 * critical, 1 + 0.4 * critical]) + assert res.conf_int().iloc[-1, 0] > 0 + assert res.tf_conf_int().iloc[0, 0] < 0 + + +@pytest.mark.parametrize( + "cov_type", + [ + "unadjusted", + "robust", + "homoskedastic", + "heteroskedastic", + "homo", + "hccm", + "HomoskedasticCovariance", + "HeteroskedasticCovariance", + ], +) +@pytest.mark.parametrize("controls", [True, False]) +def test_independent_covariance_calculation(data, cov_type, controls): + y, w, x, z = data + if not controls: + w = np.empty((len(y), 0)) + res = IV2SLS(y, w, x, z).fit(cov_type=cov_type) + design = np.column_stack([w, z]) + inverse = np.linalg.inv(design.T @ design) + first = inverse @ design.T @ x + v = x - design @ first + z_resid = z - w @ np.linalg.lstsq(w, z, rcond=None)[0] + beta = z_resid @ y / (z_resid @ x) + u0 = y - beta * x + u = u0 - w @ np.linalg.lstsq(w, u0, rcond=None)[0] + if cov_type.lower() in ( + "unadjusted", + "homoskedastic", + "homo", + "homoskedasticcovariance", + ): + first_var = (v @ v / len(v)) * inverse[-1, -1] + variance = (u @ u / len(u)) * (z_resid @ z_resid) / (z_resid @ x) ** 2 + else: + scores = design * v[:, None] + first_var = (inverse @ scores.T @ scores @ inverse)[-1, -1] + variance = ((z_resid * u) @ (z_resid * u)) / (z_resid @ x) ** 2 + f_stat = first[-1] ** 2 / first_var + expected = beta + np.array([-1, 1]) * np.sqrt(variance) * _tf_critical_value(f_stat) + assert_allclose(res.tf_conf_int().iloc[0], expected, rtol=1e-12, atol=1e-12) + + +def test_formula_labels_and_retained_sample(data): + y, w, x, z = data + frame = DataFrame({"y": y, "w": w[:, 1], "x": x, "z": z}) + frame.index = np.arange(len(frame)) * 3 + frame = frame.iloc[::2] + res = IV2SLS.from_formula("y ~ 1 + w + [x ~ z]", frame).fit() + array_res = IV2SLS( + frame.y, np.column_stack([np.ones(len(frame)), frame.w]), frame.x, frame.z + ).fit() + assert_frame_equal(res.tf_conf_int(), array_res.tf_conf_int()) + assert list(res.tf_conf_int().index) == ["x"] + assert list(res.tf_conf_int().columns) == ["lower", "upper"] + + +def test_duplicate_control_and_endogenous_labels(data): + y, w, x, z = data + expected = IV2SLS(y, w, DataFrame({"x": x}), z).fit().tf_conf_int() + controls = DataFrame(w, columns=["x", "w"]) + res = IV2SLS(y, controls, DataFrame({"x": x}), z).fit() + assert_frame_equal(res.tf_conf_int(), expected) + + +@pytest.mark.parametrize("label", [7, ("endogenous", "x")]) +def test_nonstring_endogenous_label(data, label): + y, w, x, z = data + expected = IV2SLS(y, w, x, z).fit().tf_conf_int() + actual = IV2SLS(y, w, Series(x, name=label), z).fit().tf_conf_int() + assert_allclose(actual, expected) + assert list(actual.index) == [label] + + +@pytest.mark.parametrize("instrument_scale", [-1000, -1, 1e-6, 1e6]) +def test_instrument_scale_and_sign(data, instrument_scale): + y, w, x, z = data + expected = IV2SLS(y, w, x, z).fit().tf_conf_int() + actual = IV2SLS(y, w, x, z * instrument_scale).fit().tf_conf_int() + assert_allclose(actual, expected, rtol=1e-9, atol=1e-9) + + +@pytest.mark.parametrize( + ("outcome_scale", "endog_scale"), [(2, 1), (-2, 1), (1, -3), (1e-4, 1e4)] +) +def test_coefficient_scale(data, outcome_scale, endog_scale): + y, w, x, z = data + expected = IV2SLS(y, w, x, z).fit().tf_conf_int().to_numpy() + expected = np.sort(expected * outcome_scale / endog_scale, axis=1) + actual = IV2SLS(y * outcome_scale, w, x * endog_scale, z).fit().tf_conf_int() + assert_allclose(actual, expected, rtol=1e-8, atol=1e-12) + + +def test_row_permutation_and_no_mutation(data): + y, w, x, z = data + res = IV2SLS(y, w, x, z).fit() + params, se, pvalues, conventional = ( + res.params.copy(), + res.std_errors.copy(), + res.pvalues.copy(), + res.conf_int().copy(), + ) + expected = res.tf_conf_int() + actual = IV2SLS(y[::-1], w[::-1], x[::-1], z[::-1]).fit().tf_conf_int() + assert_allclose(actual, expected, rtol=1e-12, atol=1e-12) + assert_series_equal(res.params, params) + assert_series_equal(res.std_errors, se) + assert_series_equal(res.pvalues, pvalues) + assert_frame_equal(res.conf_int(), conventional) + + +def test_weak_first_stage(data): + y, w, x, z = data + weak_x = x - (np.sqrt(10 / len(y)) - 0.01) * z + res = IV2SLS(y, w, weak_x, z).fit(cov_type="unadjusted") + assert_array_equal(res.tf_conf_int(), [[-np.inf, np.inf]]) + + +@pytest.mark.parametrize("model", [IVLIML, IVJIVE]) +def test_unsupported_estimator(data, model): + y, w, x, z = data + with pytest.raises(ValueError, match="requires IV2SLS"): + model(y, w, x, z).fit().tf_conf_int() + + +@pytest.mark.parametrize("two_endog", [True, False]) +def test_unsupported_dimensions(data, two_endog): + y, w, x, z = data + instruments = np.column_stack([z, z**2]) + endog = np.column_stack([x, x**2]) if two_endog else x + with pytest.raises(ValueError, match="one endogenous regressor"): + IV2SLS(y, w, endog, instruments).fit().tf_conf_int() + + +@pytest.mark.parametrize( + ("cov_type", "config"), + [ + ("robust", {"debiased": True}), + ("unadjusted", {"debiased": True}), + ("clustered", {"clusters": np.repeat(np.arange(20), 10)}), + ("kernel", {"bandwidth": 2}), + ], +) +def test_unsupported_covariance(data, cov_type, config): + y, w, x, z = data + with pytest.raises(ValueError, match="unadjusted or robust covariance"): + IV2SLS(y, w, x, z).fit(cov_type=cov_type, **config).tf_conf_int() + + +def test_weights(data): + y, w, x, z = data + expected = IV2SLS(y, w, x, z).fit().tf_conf_int() + actual = IV2SLS(y, w, x, z, weights=np.full(len(y), 7)).fit().tf_conf_int() + assert_frame_equal(actual, expected) + with pytest.raises(ValueError, match="nonconstant weights"): + IV2SLS(y, w, x, z, weights=np.linspace(1, 2, len(y))).fit().tf_conf_int() + + +@pytest.mark.parametrize("variance", [0, -1, np.nan, np.inf]) +def test_invalid_structural_variance(data, variance): + y, w, x, z = data + res = IV2SLS(y, w, x, z).fit() + res.cov.iloc[-1, -1] = variance + with pytest.raises(ValueError, match="finite estimates and positive variances"): + res.tf_conf_int() + + +@pytest.mark.parametrize("variance", [0, -1, np.nan, np.inf]) +def test_invalid_first_stage_variance(data, variance, monkeypatch): + y, w, x, z = data + res = IV2SLS(y, w, x, z).fit() + first = res.first_stage.individual + first[res.model.endog.cols[0]].cov.iloc[-1, -1] = variance + monkeypatch.setattr(FirstStageResults, "individual", property(lambda self: first)) + with pytest.raises(ValueError, match="finite estimates and positive variances"): + res.tf_conf_int() + + +def test_missing_observations(data): + y, w, x, z = data + y, x = y.copy(), x.copy() + y[0], x[1] = np.nan, np.nan + with pytest.warns(MissingValueWarning): + res = IV2SLS(y, w, x, z).fit() + complete = IV2SLS(y[2:], w[2:], x[2:], z[2:]).fit() + assert_frame_equal(res.tf_conf_int(), complete.tf_conf_int())