diff --git a/linearmodels/iv/model.py b/linearmodels/iv/model.py index abdaa4b548..42b506b10d 100644 --- a/linearmodels/iv/model.py +++ b/linearmodels/iv/model.py @@ -564,6 +564,16 @@ def _f_statistic( ) -> WaldTestStatistic | InvalidTestStatistic: const_loc = find_constant(self._x) nobs, nvar = self._x.shape + if self.has_constant and const_loc is None: + # Under the null, the coefficients are proportional to the vector + # that produces an intercept. Test its orthogonal complement rather + # than dropping an arbitrary dummy or testing the intercept itself. + scale = abs(self._x).max(axis=0) + intercept = pinv(self._x / scale) @ ones((nobs, 1)) + basis, _ = qr(intercept, mode="complete") + restriction = basis[:, 1:].T * scale + params = restriction @ params + cov = restriction @ cov @ restriction.T return f_statistic(params, cov, debiased, nobs - nvar, const_loc) def _estimate_kappa(self) -> float: diff --git a/linearmodels/iv/results.py b/linearmodels/iv/results.py index 05ef6b9ded..e8aa08b4f6 100644 --- a/linearmodels/iv/results.py +++ b/linearmodels/iv/results.py @@ -337,6 +337,11 @@ def f_statistic(self) -> WaldTestStatistic: This version of the F-statistic directly uses the model covariance estimator and so is robust against the same specification issues. + For IV estimators, if the regressors span a constant without an explicit + constant column, the null is that the fitted values are constant. For + example, with a dummy for every group, the group coefficients must be + equal and all other coefficients must be zero. + An ``InvalidTestStatistic`` is returned if the model has no non-constant exogenous terms, or if the covariance of the parameters is singular, e.g., the dependent variable is all zero, so that the model fits exactly. diff --git a/linearmodels/tests/iv/test_implicit_constant_f_statistic.py b/linearmodels/tests/iv/test_implicit_constant_f_statistic.py new file mode 100644 index 0000000000..653109bac0 --- /dev/null +++ b/linearmodels/tests/iv/test_implicit_constant_f_statistic.py @@ -0,0 +1,39 @@ +"""The IV model F test excludes an intercept spanned by dummy columns.""" + +import numpy as np +from numpy.testing import assert_allclose +import pytest + +from linearmodels.iv import IV2SLS, IVGMM, IVLIML + + +@pytest.mark.parametrize("model", [IV2SLS, IVLIML, IVGMM]) +@pytest.mark.parametrize("debiased", [False, True]) +@pytest.mark.parametrize("cov_type", ["unadjusted", "robust"]) +@pytest.mark.parametrize("weighted", [False, True]) +@pytest.mark.parametrize("scales", [(1.0, 1.0), (-2.0, 5.0)]) +def test_model_f_statistic_with_implicit_constant( + model, debiased, cov_type, weighted, scales +): + rng = np.random.default_rng(0) + nobs = 200 + group = np.arange(nobs) % 2 + x = rng.standard_normal(nobs) + z = rng.standard_normal((nobs, 2)) + endog = z @ np.array([0.8, 0.4]) + rng.standard_normal(nobs) + y = 1.0 + 0.3 * group + 0.2 * x + 0.4 * endog + rng.standard_normal(nobs) + exog = np.column_stack([scales[0] * (group == 0), scales[1] * (group == 1), x]) + weights = rng.uniform(0.5, 2.0, nobs) if weighted else None + res = model(y, exog, endog, z, weights=weights).fit( + cov_type=cov_type, debiased=debiased + ) + # A constant fitted value requires equal group effects and zero slopes. + restriction = np.array([[scales[0], -scales[1], 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + expected = res.wald_test(restriction).stat / (3 if debiased else 1) + assert res.f_statistic.df == 3 + assert_allclose(res.f_statistic.stat, expected, rtol=1e-10) + explicit = model( + y, np.column_stack([np.ones(nobs), group, x]), endog, z, weights=weights + ).fit(cov_type=cov_type, debiased=debiased) + assert_allclose(res.f_statistic.stat, explicit.f_statistic.stat, rtol=1e-10) + assert_allclose(res.f_statistic.pval, explicit.f_statistic.pval, rtol=1e-10)