Repository: statsmodels/statsmodels

Issue:
The Wald test statistic is returned as a 1 x 1 matrix, not a scalar.
#### Describe the bug
The Wald test method returns a 1 x 1 array as the Wald test statistic. Because the statistic is always a scalar, I think that the statistic should be given as a scalar. In fact, when I tried to print out the statistic using the following command:

`print(f'Wald statistic = {waldRes.statistic:.2f}')`

where waldRes is what the wald_test method returned, I got an error message:

    TypeError: unsupported format string passed to numpy.ndarray.__format__

which puzzled me a minute or so.

I think that the attached patch to model.py fixes the problem. 

#### Code Sample, a copy-pastable example if possible
import numpy as np
import pandas as pd
import statsmodels.tools as smtools
import statsmodels.regression as smreg


df = pd.DataFrame(data=np.random.rand(100, 6),
                  columns=['Y', 'X1', 'X2', 'X3', 'X4', 'X5'])
Y = df['Y']
X = smtools.add_constant(df.iloc[:, 1:], prepend=False)
model = smreg.linear_model.OLS(Y, X)
res = model.fit(cov_type='HC0')
R = np.identity(6)[:3, :]
r = np.ones((3, 1))
waldRes = res.wald_test((R, r))
# The next line shows the Wald statistic in the double brackets.
print(waldRes)
# Also, see:
print(f'Wald statistic = {waldRes.statistic}')

#### Expected Output

The statistic attribute should be a float scalar, not a 1x1 array.

#### Output of ``import statsmodels.api as sm; sm.show_versions()``
INSTALLED VERSIONS
------------------
Python: 3.9.2.final.0
OS: Linux 5.10.0-9-amd64 #1 SMP Debian 5.10.70-1 (2021-09-30) x86_64
byteorder: little
LC_ALL: None
LANG: en_US.utf8

statsmodels
===========

Installed: 0.13.0 (/home/ssakata/.local/lib/python3.9/site-packages/statsmodels)

Required Dependencies
=====================

cython: 0.29.21 (/usr/lib/python3/dist-packages/Cython)
numpy: 1.19.5 (/usr/lib/python3/dist-packages/numpy)
scipy: 1.6.0 (/usr/lib/python3/dist-packages/scipy)
pandas: 1.3.4 (/home/ssakata/.local/lib/python3.9/site-packages/pandas)
    dateutil: 2.8.1 (/usr/lib/python3/dist-packages/dateutil)
patsy: 0.5.2 (/home/ssakata/.local/lib/python3.9/site-packages/patsy)

Optional Dependencies
=====================

matplotlib: 3.3.4 (/usr/lib/python3/dist-packages/matplotlib)
    backend: TkAgg 
cvxopt: 1.2.5 (/usr/lib/python3/dist-packages/cvxopt)
joblib: 0.17.0 (/usr/lib/python3/dist-packages/joblib)

Developer Tools
================

IPython: 7.20.0 (/usr/lib/python3/dist-packages/IPython)
    jinja2: 2.11.3 (/usr/lib/python3/dist-packages/jinja2)
sphinx: Not installed
    pygments: 2.7.1 (/usr/lib/python3/dist-packages/pygments)
pytest: 6.0.2 (/usr/lib/python3/dist-packages/pytest)
virtualenv: 20.4.0+ds (/usr/lib/python3/dist-packages/virtualenv)

[model-py-diff.txt](https://github.com/statsmodels/statsmodels/files/7493453/model-py-diff.txt)



Discussion:
I agree that this should be fixed.  It will need a deprecation cycle and a method to make it scalar.

Resolving patch:
diff --git a/statsmodels/base/model.py b/statsmodels/base/model.py
--- a/statsmodels/base/model.py
+++ b/statsmodels/base/model.py
@@ -1762,12 +1762,12 @@ def f_test(self, r_matrix, cov_p=None, invcov=None):
         >>> print(f_test)
         <F test: F=array([[ 144.17976065]]), p=6.322026217355609e-08, df_denom=9, df_num=3>
         """
-        res = self.wald_test(r_matrix, cov_p=cov_p, invcov=invcov, use_f=True)
+        res = self.wald_test(r_matrix, cov_p=cov_p, invcov=invcov, use_f=True, scalar=True)
         return res
 
     # TODO: untested for GLMs?
     def wald_test(self, r_matrix, cov_p=None, invcov=None,
-                  use_f=None, df_constraints=None):
+                  use_f=None, df_constraints=None, scalar=None):
         """
         Compute a Wald-test for a joint linear hypothesis.
 
@@ -1799,6 +1799,13 @@ def wald_test(self, r_matrix, cov_p=None, invcov=None,
         df_constraints : int, optional
             The number of constraints. If not provided the number of
             constraints is determined from r_matrix.
+        scalar : bool, optional
+            Flag indicating whether the Wald test statistic should be returned
+            as a sclar float. The current behavior is to return an array.
+            This will switch to a scalar float after 0.14 is released. To
+            get the future behavior now, set scalar to True. To silence
+            the warning and retain the legacy behavior, set scalar to
+            False.
 
         Returns
         -------
@@ -1823,6 +1830,7 @@ def wald_test(self, r_matrix, cov_p=None, invcov=None,
         where the rank of the covariance of the noise is not full.
         """
         use_f = bool_like(use_f, "use_f", strict=True, optional=True)
+        scalar = bool_like(scalar, "scalar", strict=True, optional=True)
         if use_f is None:
             # switch to use_t false if undefined
             use_f = (hasattr(self, 'use_t') and self.use_t)
@@ -1877,6 +1885,17 @@ def wald_test(self, r_matrix, cov_p=None, invcov=None,
             F = np.dot(np.dot(Rbq.T, invcov), Rbq)
 
         df_resid = getattr(self, 'df_resid_inference', self.df_resid)
+        if scalar is None:
+            warnings.warn(
+                "The behavior of wald_test will change after 0.14 to returning "
+                "scalar test statistic values. To get the future behavior now, "
+                "set scalar to True. To silence this message while retaining "
+                "the legacy behavior, set scalar to False.",
+                FutureWarning
+            )
+            scalar = False
+        if scalar and F.size == 1:
+            F = float(F)
         if use_f:
             F /= J
             return ContrastResults(F=F, df_denom=df_resid,
@@ -1886,7 +1905,7 @@ def wald_test(self, r_matrix, cov_p=None, invcov=None,
                                    distribution='chi2', distargs=(J,))
 
     def wald_test_terms(self, skip_single=False, extra_constraints=None,
-                        combine_terms=None):
+                        combine_terms=None, scalar=None):
         """
         Compute a sequence of Wald tests for terms over multiple columns.
 
@@ -1907,6 +1926,13 @@ def wald_test_terms(self, skip_single=False, extra_constraints=None,
             Each string in this list is matched to the name of the terms or
             the name of the exogenous variables. All columns whose name
             includes that string are combined in one joint test.
+        scalar : bool, optional
+            Flag indicating whether the Wald test statistic should be returned
+            as a sclar float. The current behavior is to return an array.
+            This will switch to a scalar float after 0.14 is released. To
+            get the future behavior now, set scalar to True. To silence
+            the warning and retain the legacy behavior, set scalar to
+            False.
 
         Returns
         -------
@@ -2001,8 +2027,8 @@ def wald_test_terms(self, skip_single=False, extra_constraints=None,
         res_wald = []
         index = []
         for name, constraint in constraints + combined_constraints + extra_constraints:
-            wt = result.wald_test(constraint)
-            row = [wt.statistic.item(), wt.pvalue.item(), constraint.shape[0]]
+            wt = result.wald_test(constraint, scalar=scalar)
+            row = [wt.statistic, wt.pvalue, constraint.shape[0]]
             if use_t:
                 row.append(wt.df_denom)
             res_wald.append(row)
diff --git a/statsmodels/stats/diagnostic.py b/statsmodels/stats/diagnostic.py
--- a/statsmodels/stats/diagnostic.py
+++ b/statsmodels/stats/diagnostic.py
@@ -303,8 +303,8 @@ def _test_nested(endog, a, b, cov_est, cov_kwds):
         res = OLS(endog, aug_reg).fit(cov_type=cov_est, cov_kwds=cov_kwds)
         r_matrix = np.zeros((k_a, k))
         r_matrix[:, -k_a:] = np.eye(k_a)
-        test = res.wald_test(r_matrix, use_f=True)
-        stat, pvalue = float(np.squeeze(test.statistic)), float(test.pvalue)
+        test = res.wald_test(r_matrix, use_f=True, scalar=True)
+        stat, pvalue = test.statistic, test.pvalue
         df_num, df_denom = int(test.df_num), int(test.df_denom)
         return stat, pvalue, df_num, df_denom
 
@@ -593,7 +593,7 @@ def acorr_lm(resid, nlags=None, autolag=None, store=False, *, period=None,
         # Note: deg of freedom for LM test: nvars - constant = lags used
     else:
         r_matrix = np.hstack((np.zeros((usedlag, 1)), np.eye(usedlag)))
-        test_stat = resols.wald_test(r_matrix, use_f=False)
+        test_stat = resols.wald_test(r_matrix, use_f=False, scalar=True)
         lm = float(test_stat.statistic)
         lmpval = float(test_stat.pvalue)
 
@@ -1105,7 +1105,7 @@ def linear_reset(res, power=3, test_type="fitted", use_f=False,
     nrestr = aug_exog.shape[1] - exog.shape[1]
     nparams = aug_exog.shape[1]
     r_mat = np.eye(nrestr, nparams, k=nparams-nrestr)
-    return res.wald_test(r_mat, use_f=use_f)
+    return res.wald_test(r_mat, use_f=use_f, scalar=True)
 
 
 def linear_harvey_collier(res, order_by=None, skip=None):


Test patch:
diff --git a/statsmodels/base/tests/test_generic_methods.py b/statsmodels/base/tests/test_generic_methods.py
--- a/statsmodels/base/tests/test_generic_methods.py
+++ b/statsmodels/base/tests/test_generic_methods.py
@@ -506,7 +506,7 @@ def setup_class(cls):
 
     def test_combined(self):
         res = self.res
-        wa = res.wald_test_terms(skip_single=False, combine_terms=['Duration', 'Weight'])
+        wa = res.wald_test_terms(skip_single=False, combine_terms=['Duration', 'Weight'], scalar=True)
         eye = np.eye(len(res.params))
         c_const = eye[0]
         c_w = eye[[2,3]]
@@ -520,7 +520,7 @@ def test_combined(self):
     def test_categories(self):
         # test only multicolumn terms
         res = self.res
-        wa = res.wald_test_terms(skip_single=True)
+        wa = res.wald_test_terms(skip_single=True, scalar=True)
         eye = np.eye(len(res.params))
         c_w = eye[[2,3]]
         c_dw = eye[[4,5]]
@@ -530,7 +530,7 @@ def test_categories(self):
 
 def compare_waldres(res, wa, constrasts):
     for i, c in enumerate(constrasts):
-        wt = res.wald_test(c)
+        wt = res.wald_test(c, scalar=True)
         assert_allclose(wa.table.values[i, 0], wt.statistic)
         assert_allclose(wa.table.values[i, 1], wt.pvalue)
         df = c.shape[0] if c.ndim == 2 else 1
@@ -572,7 +572,8 @@ def test_noformula(self):
 
         res = sm.OLS(endog, exog).fit()
         wa = res.wald_test_terms(skip_single=False,
-                                 combine_terms=['Duration', 'Weight'])
+                                 combine_terms=['Duration', 'Weight'],
+                                 scalar=True)
         eye = np.eye(len(res.params))
 
         c_single = [row for row in eye]
diff --git a/statsmodels/discrete/tests/test_discrete.py b/statsmodels/discrete/tests/test_discrete.py
--- a/statsmodels/discrete/tests/test_discrete.py
+++ b/statsmodels/discrete/tests/test_discrete.py
@@ -1771,7 +1771,8 @@ def test_llf(self):
         assert_allclose(self.res1.llf, self.res2.llf)
 
     def test_wald(self):
-        result = self.res1.wald_test(np.eye(len(self.res1.params))[:-2])
+        result = self.res1.wald_test(np.eye(len(self.res1.params))[:-2],
+                                     scalar=True)
         assert_allclose(result.statistic, self.res2.wald_statistic)
         assert_allclose(result.pvalue, self.res2.wald_pvalue, atol=1e-15)
 
diff --git a/statsmodels/gam/tests/test_penalized.py b/statsmodels/gam/tests/test_penalized.py
--- a/statsmodels/gam/tests/test_penalized.py
+++ b/statsmodels/gam/tests/test_penalized.py
@@ -608,7 +608,8 @@ def test_wald(self):
         res2 = self.res2
         wtt = res1.wald_test_terms(skip_single=True,
                                    combine_terms=['fuel', 'drive',
-                                                  'weight', 'hp'])
+                                                  'weight', 'hp'],
+                                   scalar=True)
         # mgcv has term test for linear part
         assert_allclose(wtt.statistic[:2], res2.pTerms_chi_sq, rtol=1e-7)
         assert_allclose(wtt.pvalues[:2], res2.pTerms_pv, rtol=1e-6)
diff --git a/statsmodels/genmod/tests/test_constrained.py b/statsmodels/genmod/tests/test_constrained.py
--- a/statsmodels/genmod/tests/test_constrained.py
+++ b/statsmodels/genmod/tests/test_constrained.py
@@ -235,8 +235,8 @@ def test_wald(self):
         use_f = False
         with warnings.catch_warnings():
             warnings.simplefilter('ignore', ValueWarning)
-            wt2 = res2.wald_test(np.eye(k2)[1:], use_f=use_f)
-            wt1 = res1.wald_test(np.eye(k1)[1:], use_f=use_f)
+            wt2 = res2.wald_test(np.eye(k2)[1:], use_f=use_f, scalar=True)
+            wt1 = res1.wald_test(np.eye(k1)[1:], use_f=use_f, scalar=True)
         assert_allclose(wt2.pvalue, wt1.pvalue, atol=1e-20) # pvalue = 0
         assert_allclose(wt2.statistic, wt1.statistic, rtol=1e-8)
         assert_equal(wt2.df_denom, wt1.df_denom)
@@ -244,8 +244,8 @@ def test_wald(self):
         use_f = True
         with warnings.catch_warnings():
             warnings.simplefilter('ignore', ValueWarning)
-            wt2 = res2.wald_test(np.eye(k2)[1:], use_f=use_f)
-            wt1 = res1.wald_test(np.eye(k1)[1:], use_f=use_f)
+            wt2 = res2.wald_test(np.eye(k2)[1:], use_f=use_f, scalar=True)
+            wt1 = res1.wald_test(np.eye(k1)[1:], use_f=use_f, scalar=True)
         assert_allclose(wt2.pvalue, wt1.pvalue, rtol=1) # pvalue = 8e-273
         assert_allclose(wt2.statistic, wt1.statistic, rtol=1e-8)
         assert_equal(wt2.df_denom, wt1.df_denom)
diff --git a/statsmodels/genmod/tests/test_score_test.py b/statsmodels/genmod/tests/test_score_test.py
--- a/statsmodels/genmod/tests/test_score_test.py
+++ b/statsmodels/genmod/tests/test_score_test.py
@@ -26,7 +26,7 @@ def test_wald_score(self):
         res_constr = mod_full.fit_constrained('x5=0, x6=0')
         res_drop = mod_drop.fit()
 
-        wald = res_full.wald_test(restriction)
+        wald = res_full.wald_test(restriction, scalar=True)
         # note: need to use method for res_constr for correct df_resid
         lm_constr = np.hstack(res_constr.score_test())
         lm_extra = np.hstack(score_test(res_drop, exog_extra=self.exog_extra))
@@ -34,7 +34,7 @@ def test_wald_score(self):
             params_constrained=res_constr.params,
             k_constraints=res_constr.k_constr))
 
-        res_wald = np.hstack([wald.statistic.squeeze(), wald.pvalue, [wald.df_denom]])
+        res_wald = np.hstack([wald.statistic, wald.pvalue, [wald.df_denom]])
         assert_allclose(lm_constr, res_wald, rtol=self.rtol_ws, atol=self.atol_ws)
         assert_allclose(lm_extra, res_wald, rtol=self.rtol_ws, atol=self.atol_ws)
         assert_allclose(lm_constr, lm_extra, rtol=1e-12, atol=1e-14)
@@ -44,12 +44,12 @@ def test_wald_score(self):
 
         cov_type='HC0'
         res_full_hc = mod_full.fit(cov_type=cov_type, start_params=res_full.params)
-        wald = res_full_hc.wald_test(restriction)
+        wald = res_full_hc.wald_test(restriction, scalar=True)
         lm_constr = np.hstack(score_test(res_constr, cov_type=cov_type))
         lm_extra = np.hstack(score_test(res_drop, exog_extra=self.exog_extra,
                                         cov_type=cov_type))
 
-        res_wald = np.hstack([wald.statistic.squeeze(), wald.pvalue, [wald.df_denom]])
+        res_wald = np.hstack([wald.statistic, wald.pvalue, [wald.df_denom]])
         assert_allclose(lm_constr, res_wald, rtol=self.rtol_ws, atol=self.atol_ws)
         assert_allclose(lm_extra, res_wald, rtol=self.rtol_ws, atol=self.atol_ws)
         assert_allclose(lm_constr, lm_extra, rtol=1e-13)
diff --git a/statsmodels/sandbox/regression/tests/test_gmm.py b/statsmodels/sandbox/regression/tests/test_gmm.py
--- a/statsmodels/sandbox/regression/tests/test_gmm.py
+++ b/statsmodels/sandbox/regression/tests/test_gmm.py
@@ -236,7 +236,7 @@ def test_hypothesis(self):
         # assert_allclose(res_f.pvalue, res2.Fp, rtol=1e-08, atol=0)
 
         # Smoke test for Wald
-        res_wald = res1.wald_test(restriction[:-1])
+        res_wald = res1.wald_test(restriction[:-1], scalar=True)
 
     @pytest.mark.smoke
     def test_summary(self):
diff --git a/statsmodels/stats/tests/test_diagnostic.py b/statsmodels/stats/tests/test_diagnostic.py
--- a/statsmodels/stats/tests/test_diagnostic.py
+++ b/statsmodels/stats/tests/test_diagnostic.py
@@ -1723,7 +1723,7 @@ def test_encompasing_direct(cov_type, reset_randomstate):
     direct1 = OLS(y, np.hstack([x1, x[:, 1:], z_extra])).fit(cov_type=cov_type)
     r1 = np.zeros((4, 3 + 1 + 3))
     r1[:, -4:] = np.eye(4)
-    direct_test_1 = direct1.wald_test(r1, use_f=True)
+    direct_test_1 = direct1.wald_test(r1, use_f=True, scalar=True)
     expected = (
         float(np.squeeze(direct_test_1.statistic)),
         float(np.squeeze(direct_test_1.pvalue)),
@@ -1735,7 +1735,7 @@ def test_encompasing_direct(cov_type, reset_randomstate):
     direct2 = OLS(y, np.hstack([z1, x_extra])).fit(cov_type=cov_type)
     r2 = np.zeros((2, 2 + 3 + 2))
     r2[:, -2:] = np.eye(2)
-    direct_test_2 = direct2.wald_test(r2, use_f=True)
+    direct_test_2 = direct2.wald_test(r2, use_f=True, scalar=True)
     expected = (
         float(np.squeeze(direct_test_2.statistic)),
         float(np.squeeze(direct_test_2.pvalue)),
diff --git a/statsmodels/stats/tests/test_oneway.py b/statsmodels/stats/tests/test_oneway.py
--- a/statsmodels/stats/tests/test_oneway.py
+++ b/statsmodels/stats/tests/test_oneway.py
@@ -466,7 +466,7 @@ def test_ols_noncentrality(self):
         v = np.zeros(c_equal.shape[0])
 
         # noncentrality at estimated parameters
-        wt = res_ols.wald_test(c_equal)
+        wt = res_ols.wald_test(c_equal, scalar=True)
         df_num, df_denom = wt.df_num, wt.df_denom
 
         cov_p = res_ols.cov_params()
@@ -495,7 +495,7 @@ def test_ols_noncentrality(self):
         params_alt = res_ols.params * 0.75
         # compute constraint value so we can get noncentrality from wald_test
         v_off = _offset_constraint(c_equal, res_ols.params, params_alt)
-        wt_off = res_ols.wald_test((c_equal, v + v_off))
+        wt_off = res_ols.wald_test((c_equal, v + v_off), scalar=True)
         nc_wt_off = wald_test_noncent_generic(params_alt, c_equal, v,
                                               cov_p, diff=None, joint=True)
         assert_allclose(nc_wt_off, wt_off.statistic * wt_off.df_num,
diff --git a/statsmodels/stats/tests/test_outliers_influence.py b/statsmodels/stats/tests/test_outliers_influence.py
--- a/statsmodels/stats/tests/test_outliers_influence.py
+++ b/statsmodels/stats/tests/test_outliers_influence.py
@@ -15,7 +15,7 @@ def test_reset_stata():
     mod = OLS(data.violent, add_constant(data[["murder", "hs_grad"]]))
     res = mod.fit()
     stat = reset_ramsey(res, degree=4)
-    assert_almost_equal(stat.fvalue[0, 0], 1.52, decimal=2)
+    assert_almost_equal(stat.fvalue, 1.52, decimal=2)
     assert_almost_equal(stat.pvalue, 0.2221, decimal=4)
 
     exog_idx = list(data.columns).index("urban")
diff --git a/statsmodels/tools/_testing.py b/statsmodels/tools/_testing.py
--- a/statsmodels/tools/_testing.py
+++ b/statsmodels/tools/_testing.py
@@ -93,12 +93,12 @@ def check_ftest_pvalues(results):
     use_t = res.use_t
     k_vars = len(res.params)
     # check default use_t
-    pvals = [res.wald_test(np.eye(k_vars)[k], use_f=use_t).pvalue
+    pvals = [res.wald_test(np.eye(k_vars)[k], use_f=use_t, scalar=True).pvalue
              for k in range(k_vars)]
     assert_allclose(pvals, res.pvalues, rtol=5e-10, atol=1e-25)
 
     # automatic use_f based on results class use_t
-    pvals = [res.wald_test(np.eye(k_vars)[k]).pvalue
+    pvals = [res.wald_test(np.eye(k_vars)[k], scalar=True).pvalue
              for k in range(k_vars)]
     assert_allclose(pvals, res.pvalues, rtol=5e-10, atol=1e-25)
 
diff --git a/statsmodels/tsa/tests/test_ar.py b/statsmodels/tsa/tests/test_ar.py
--- a/statsmodels/tsa/tests/test_ar.py
+++ b/statsmodels/tsa/tests/test_ar.py
@@ -191,7 +191,7 @@ def test_other_tests_autoreg(ols_autoreg_result):
     r = np.ones_like(a.params)
     a.t_test(r)
     r = np.eye(a.params.shape[0])
-    a.wald_test(r)
+    a.wald_test(r, scalar=True)
 
 
 # TODO: test likelihood for ARX model?