Repository: statsmodels/statsmodels

Issue:
ENH: separate the core logic of test_heteroskedasticity in state space model as a function
#### Is your feature request related to a problem? Please describe

[`test_heteroskedasticity`](https://www.statsmodels.org/stable/generated/statsmodels.tsa.statespace.mlemodel.MLEResults.test_heteroskedasticity.html) method of state space model (MLEModel) is not flexible.
- You cannot change the data range to apply the test (see also #7342 )
    - length of diffuse periods (d) is fixed
- You cannot apply it for other type of time series models.
    - it is only for state space models.

On the other hand, I don't feel frustration with other two diagnostic test methods in MLEModel, [test_serial_correlation](https://www.statsmodels.org/stable/generated/statsmodels.tsa.statespace.mlemodel.MLEResults.test_serial_correlation.html) and [test_normality](https://www.statsmodels.org/stable/generated/statsmodels.tsa.statespace.mlemodel.MLEResults.test_normality.html), 
because we can just use their core logic functions directly,  [`stats.diagnostic.acorr_ljungbox`](https://www.statsmodels.org/stable/generated/statsmodels.stats.diagnostic.acorr_ljungbox.html) and [`stats.stattools.jarque_bera`](https://www.statsmodels.org/stable/generated/statsmodels.stats.stattools.jarque_bera.html) respectively.

#### Describe the solution you'd like

Create a new function and move [core logic of the `test_heteroskedasticity` method](https://github.com/statsmodels/statsmodels/blob/ad4b474b712517515400b0447998c6771897e02d/statsmodels/tsa/statespace/mlemodel.py#L3088-L3144) into it.
Then, you can both call this function in `test_heteroskedasticity` method and use it as an independent function for other time series models.

My idea:
`stats.diagnostic.het_timeseries_residual_breakvar(resid, alternatives="two-sided", use_f=True)`

Parameters

- resid: ndarray
residual of a time series model
- *alternative: str, ‘increasing’, ‘decreasing’ or ‘two-sided’
- *use_f: bool, optional

Returns
- *output: ndarray
an array with (test_statistic, pvalue)

marked with `*` ... same as in the [original method](https://www.statsmodels.org/stable/generated/statsmodels.tsa.statespace.mlemodel.MLEResults.test_heteroskedasticity.html)

#### Describe alternatives you have considered

Add optional arguments to the current `test_heteroskedasticity` method to change the diffuse periods.
In this case, however, you cannot apply the test to other time series models.

#### Additional context

Another merit of separation of the logic is that we can easily add new options of test logics in `test_heteroskedasticity` method in the future.



Discussion:

Resolving patch:
diff --git a/statsmodels/tsa/api.py b/statsmodels/tsa/api.py
--- a/statsmodels/tsa/api.py
+++ b/statsmodels/tsa/api.py
@@ -8,7 +8,7 @@
            'stattools',
            'acovf', 'acf', 'pacf', 'pacf_yw', 'pacf_ols', 'ccovf', 'ccf',
            'q_stat', 'coint', 'arma_order_select_ic',
-           'adfuller', 'kpss', 'bds',
+           'adfuller', 'kpss', 'bds', 'breakvar_heteroskedasticity_test',
            'datetools',
            'seasonal_decompose',
            'graphics',
@@ -36,7 +36,7 @@
 from .stattools import (
     acovf, acf, pacf, pacf_yw, pacf_ols, ccovf, ccf,
     q_stat, coint, arma_order_select_ic,
-    adfuller, kpss, bds)
+    adfuller, kpss, bds, breakvar_heteroskedasticity_test)
 from .base import datetools
 from .seasonal import seasonal_decompose
 from ..graphics import tsaplots as graphics
diff --git a/statsmodels/tsa/statespace/mlemodel.py b/statsmodels/tsa/statespace/mlemodel.py
--- a/statsmodels/tsa/statespace/mlemodel.py
+++ b/statsmodels/tsa/statespace/mlemodel.py
@@ -3021,24 +3021,13 @@ def test_heteroskedasticity(self, method, alternative='two-sided',
             where `het[0][0]` is the test statistic, and `het[0][1]` is the
             p-value.
 
+        See Also
+        --------
+        statsmodels.tsa.stattools.breakvar_heteroskedasticity_test
+
         Notes
         -----
-        The null hypothesis is of no heteroskedasticity. That means different
-        things depending on which alternative is selected:
-
-        - Increasing: Null hypothesis is that the variance is not increasing
-          throughout the sample; that the sum-of-squares in the later
-          subsample is *not* greater than the sum-of-squares in the earlier
-          subsample.
-        - Decreasing: Null hypothesis is that the variance is not decreasing
-          throughout the sample; that the sum-of-squares in the earlier
-          subsample is *not* greater than the sum-of-squares in the later
-          subsample.
-        - Two-sided: Null hypothesis is that the variance is not changing
-          throughout the sample. Both that the sum-of-squares in the earlier
-          subsample is not greater than the sum-of-squares in the later
-          subsample *and* that the sum-of-squares in the later subsample is
-          not greater than the sum-of-squares in the earlier subsample.
+        The null hypothesis is of no heteroskedasticity.
 
         For :math:`h = [T/3]`, the test statistic is:
 
@@ -3054,7 +3043,7 @@ def test_heteroskedasticity(self, method, alternative='two-sided',
         This statistic can be tested against an :math:`F(h,h)` distribution.
         Alternatively, :math:`h H(h)` is asymptotically distributed according
         to :math:`\chi_h^2`; this second test can be applied by passing
-        `asymptotic=True` as an argument.
+        `use_f=True` as an argument.
 
         See section 5.4 of [1]_ for the above formula and discussion, as well
         as additional details.
@@ -3076,68 +3065,27 @@ def test_heteroskedasticity(self, method, alternative='two-sided',
                              ' forecast errors have not been computed.')
 
         if method == 'breakvar':
+            from statsmodels.tsa.stattools import (
+                breakvar_heteroskedasticity_test
+                )
             # Store some values
-            squared_resid = self.filter_results.standardized_forecasts_error**2
+            resid = self.filter_results.standardized_forecasts_error
             d = np.maximum(self.loglikelihood_burn, self.nobs_diffuse)
             # This differs from self.nobs_effective because here we want to
             # exclude exact diffuse periods, whereas self.nobs_effective only
             # excludes explicitly burned (usually approximate diffuse) periods.
             nobs_effective = self.nobs - d
+            h = int(np.round(nobs_effective / 3))
 
             test_statistics = []
             p_values = []
             for i in range(self.model.k_endog):
-                h = int(np.round(nobs_effective / 3))
-                numer_resid = squared_resid[i, -h:]
-                numer_resid = numer_resid[~np.isnan(numer_resid)]
-                numer_dof = len(numer_resid)
-
-                denom_resid = squared_resid[i, d:d+h]
-                denom_resid = denom_resid[~np.isnan(denom_resid)]
-                denom_dof = len(denom_resid)
-
-                if numer_dof < 2:
-                    warnings.warn('Early subset of data for variable %d'
-                                  '  has too few non-missing observations to'
-                                  ' calculate test statistic.' % i)
-                    numer_resid = np.nan
-                if denom_dof < 2:
-                    warnings.warn('Later subset of data for variable %d'
-                                  '  has too few non-missing observations to'
-                                  ' calculate test statistic.' % i)
-                    denom_resid = np.nan
-
-                test_statistic = np.sum(numer_resid) / np.sum(denom_resid)
-
-                # Setup functions to calculate the p-values
-                if use_f:
-                    from scipy.stats import f
-                    pval_lower = lambda test_statistics: f.cdf(  # noqa:E731
-                        test_statistics, numer_dof, denom_dof)
-                    pval_upper = lambda test_statistics: f.sf(  # noqa:E731
-                        test_statistics, numer_dof, denom_dof)
-                else:
-                    from scipy.stats import chi2
-                    pval_lower = lambda test_statistics: chi2.cdf(  # noqa:E731
-                        numer_dof * test_statistics, denom_dof)
-                    pval_upper = lambda test_statistics: chi2.sf(  # noqa:E731
-                        numer_dof * test_statistics, denom_dof)
-
-                # Calculate the one- or two-sided p-values
-                alternative = alternative.lower()
-                if alternative in ['i', 'inc', 'increasing']:
-                    p_value = pval_upper(test_statistic)
-                elif alternative in ['d', 'dec', 'decreasing']:
-                    test_statistic = 1. / test_statistic
-                    p_value = pval_upper(test_statistic)
-                elif alternative in ['2', '2-sided', 'two-sided']:
-                    p_value = 2 * np.minimum(
-                        pval_lower(test_statistic),
-                        pval_upper(test_statistic)
+                test_statistic, p_value = breakvar_heteroskedasticity_test(
+                    resid[i, d:],
+                    subset_length=h,
+                    alternative=alternative,
+                    use_f=use_f
                     )
-                else:
-                    raise ValueError('Invalid alternative.')
-
                 test_statistics.append(test_statistic)
                 p_values.append(p_value)
 
diff --git a/statsmodels/tsa/stattools.py b/statsmodels/tsa/stattools.py
--- a/statsmodels/tsa/stattools.py
+++ b/statsmodels/tsa/stattools.py
@@ -1295,6 +1295,147 @@ def levinson_durbin_pacf(pacf, nlags=None):
     return arcoefs, acf
 
 
+def breakvar_heteroskedasticity_test(resid, subset_length=1/3,
+                                     alternative='two-sided', use_f=True):
+    r"""
+    Test for heteroskedasticity of residuals
+
+    Tests whether the sum-of-squares in the first subset of the sample is
+    significantly different than the sum-of-squares in the last subset
+    of the sample. Analogous to a Goldfeld-Quandt test. The null hypothesis
+    is of no heteroskedasticity.
+
+    Parameters
+    ----------
+    resid : array_like
+        Residuals of a time series model.
+        The shape is 1d (nobs,) or 2d (nobs, nvars).
+    subset_length : {int, float}
+        Length of the subsets to test (h in Notes below).
+        If a float in 0 < subset_length < 1, it is interpreted as fraction.
+        Default is 1/3.
+    alternative : str, 'increasing', 'decreasing' or 'two-sided'
+        This specifies the alternative for the p-value calculation. Default
+        is two-sided.
+    use_f : bool, optional
+        Whether or not to compare against the asymptotic distribution
+        (chi-squared) or the approximate small-sample distribution (F).
+        Default is True (i.e. default is to compare against an F
+        distribution).
+
+    Returns
+    -------
+    test_statistic : {float, ndarray}
+        Test statistic(s) H(h).
+    p_value : {float, ndarray}
+        p-value(s) of test statistic(s).
+
+    Notes
+    -----
+    The null hypothesis is of no heteroskedasticity. That means different
+    things depending on which alternative is selected:
+
+    - Increasing: Null hypothesis is that the variance is not increasing
+        throughout the sample; that the sum-of-squares in the later
+        subsample is *not* greater than the sum-of-squares in the earlier
+        subsample.
+    - Decreasing: Null hypothesis is that the variance is not decreasing
+        throughout the sample; that the sum-of-squares in the earlier
+        subsample is *not* greater than the sum-of-squares in the later
+        subsample.
+    - Two-sided: Null hypothesis is that the variance is not changing
+        throughout the sample. Both that the sum-of-squares in the earlier
+        subsample is not greater than the sum-of-squares in the later
+        subsample *and* that the sum-of-squares in the later subsample is
+        not greater than the sum-of-squares in the earlier subsample.
+
+    For :math:`h = [T/3]`, the test statistic is:
+
+    .. math::
+
+        H(h) = \sum_{t=T-h+1}^T  \tilde v_t^2
+        \Bigg / \sum_{t=1}^{h} \tilde v_t^2
+
+    This statistic can be tested against an :math:`F(h,h)` distribution.
+    Alternatively, :math:`h H(h)` is asymptotically distributed according
+    to :math:`\chi_h^2`; this second test can be applied by passing
+    `use_f=False` as an argument.
+
+    See section 5.4 of [1]_ for the above formula and discussion, as well
+    as additional details.
+
+    References
+    ----------
+    .. [1] Harvey, Andrew C. 1990. *Forecasting, Structural Time Series*
+            *Models and the Kalman Filter.* Cambridge University Press.
+    """
+    squared_resid = np.asarray(resid, dtype=float)**2
+    if squared_resid.ndim == 1:
+        squared_resid = squared_resid.reshape(-1, 1)
+    nobs = len(resid)
+
+    if 0 < subset_length < 1:
+        h = int(np.round(nobs * subset_length))
+    elif type(subset_length) is int and subset_length >= 1:
+        h = subset_length
+
+    numer_resid = squared_resid[-h:]
+    numer_dof = (~np.isnan(numer_resid)).sum(axis=0)
+    numer_squared_sum = np.nansum(numer_resid, axis=0)
+    for i, dof in enumerate(numer_dof):
+        if dof < 2:
+            warnings.warn('Early subset of data for variable %d'
+                          ' has too few non-missing observations to'
+                          ' calculate test statistic.' % i)
+            numer_squared_sum[i] = np.nan
+
+    denom_resid = squared_resid[:h]
+    denom_dof = (~np.isnan(denom_resid)).sum(axis=0)
+    denom_squared_sum = np.nansum(denom_resid, axis=0)
+    for i, dof in enumerate(denom_dof):
+        if dof < 2:
+            warnings.warn('Later subset of data for variable %d'
+                          ' has too few non-missing observations to'
+                          ' calculate test statistic.' % i)
+            denom_squared_sum[i] = np.nan
+
+    test_statistic = numer_squared_sum / denom_squared_sum
+
+    # Setup functions to calculate the p-values
+    if use_f:
+        from scipy.stats import f
+        pval_lower = lambda test_statistics: f.cdf(  # noqa:E731
+            test_statistics, numer_dof, denom_dof)
+        pval_upper = lambda test_statistics: f.sf(  # noqa:E731
+            test_statistics, numer_dof, denom_dof)
+    else:
+        from scipy.stats import chi2
+        pval_lower = lambda test_statistics: chi2.cdf(  # noqa:E731
+            numer_dof * test_statistics, denom_dof)
+        pval_upper = lambda test_statistics: chi2.sf(  # noqa:E731
+            numer_dof * test_statistics, denom_dof)
+
+    # Calculate the one- or two-sided p-values
+    alternative = alternative.lower()
+    if alternative in ['i', 'inc', 'increasing']:
+        p_value = pval_upper(test_statistic)
+    elif alternative in ['d', 'dec', 'decreasing']:
+        test_statistic = 1. / test_statistic
+        p_value = pval_upper(test_statistic)
+    elif alternative in ['2', '2-sided', 'two-sided']:
+        p_value = 2 * np.minimum(
+            pval_lower(test_statistic),
+            pval_upper(test_statistic)
+        )
+    else:
+        raise ValueError('Invalid alternative.')
+
+    if len(test_statistic) == 1:
+        return test_statistic[0], p_value[0]
+
+    return test_statistic, p_value
+
+
 def grangercausalitytests(x, maxlag, addconst=True, verbose=True):
     """
     Four tests for granger non causality of 2 time series.


Test patch:
diff --git a/statsmodels/tsa/tests/test_stattools.py b/statsmodels/tsa/tests/test_stattools.py
--- a/statsmodels/tsa/tests/test_stattools.py
+++ b/statsmodels/tsa/tests/test_stattools.py
@@ -32,6 +32,7 @@
     acovf,
     adfuller,
     arma_order_select_ic,
+    breakvar_heteroskedasticity_test,
     coint,
     grangercausalitytests,
     innovations_algo,
@@ -336,6 +337,127 @@ def test_ld(self):
         assert_almost_equal(pacfyw, pacfld, DECIMAL_8)
 
 
+class TestBreakvarHeteroskedasticityTest(object):
+    from scipy.stats import f, chi2
+
+    def test_1d_input(self):
+
+        input_residuals = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
+        expected_statistic = (4.0**2 + 5.0**2) / (0.0**2 + 1.0**2)
+        # ~ F(2, 2), two-sided test
+        expected_pvalue = 2 * min(
+            self.f.cdf(expected_statistic, 2, 2),
+            self.f.sf(expected_statistic, 2, 2)
+            )
+        actual_statistic, actual_pvalue = breakvar_heteroskedasticity_test(
+            input_residuals
+            )
+
+        assert actual_statistic == expected_statistic
+        assert actual_pvalue == expected_pvalue
+
+    def test_2d_input_with_missing_values(self):
+
+        input_residuals = np.array(
+            [
+                [0.0, 0.0, np.nan],
+                [1.0, np.nan, 1.0],
+                [2.0, 2.0, np.nan],
+                [3.0, 3.0, 3.0],
+                [4.0, 4.0, 4.0],
+                [5.0, 5.0, 5.0],
+                [6.0, 6.0, 6.0],
+                [7.0, 7.0, 7.0],
+                [8.0, 8.0, 8.0],
+            ]
+        )
+        expected_statistic = np.array(
+            [
+                (8.0**2 + 7.0**2 + 6.0**2) / (0.0**2 + 1.0**2 + 2.0**2),
+                (8.0**2 + 7.0**2 + 6.0**2) / (0.0**2 + 2.0**2),
+                np.nan,
+            ]
+        )
+        expected_pvalue = np.array(
+            [
+                2 * min(
+                    self.f.cdf(expected_statistic[0], 3, 3),
+                    self.f.sf(expected_statistic[0], 3, 3)
+                ),
+                2 * min(
+                    self.f.cdf(expected_statistic[1], 3, 2),
+                    self.f.sf(expected_statistic[1], 3, 2)
+                ),
+                np.nan,
+            ]
+        )
+        actual_statistic, actual_pvalue = breakvar_heteroskedasticity_test(
+            input_residuals
+            )
+
+        assert_equal(actual_statistic, expected_statistic)
+        assert_equal(actual_pvalue, expected_pvalue)
+
+    @pytest.mark.parametrize(
+        "subset_length,expected_statistic,expected_pvalue",
+        [
+            (2, 41, 2 * min(f.cdf(41, 2, 2), f.sf(41, 2, 2))),
+            (0.5, 10, 2 * min(f.cdf(10, 3, 3), f.sf(10, 3, 3))),
+        ]
+    )
+    def test_subset_length(self,
+                           subset_length,
+                           expected_statistic,
+                           expected_pvalue
+                           ):
+
+        input_residuals = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
+        actual_statistic, actual_pvalue = breakvar_heteroskedasticity_test(
+            input_residuals,
+            subset_length=subset_length,
+            )
+
+        assert actual_statistic == expected_statistic
+        assert actual_pvalue == expected_pvalue
+
+    @pytest.mark.parametrize(
+        "alternative,expected_statistic,expected_pvalue",
+        [
+            ("two-sided", 41, 2 * min(f.cdf(41, 2, 2), f.sf(41, 2, 2))),
+            ("decreasing", 1 / 41, f.sf(1 / 41, 2, 2)),
+            ("increasing", 41, f.sf(41, 2, 2)),
+        ]
+    )
+    def test_alternative(self,
+                         alternative,
+                         expected_statistic,
+                         expected_pvalue
+                         ):
+
+        input_residuals = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
+        actual_statistic, actual_pvalue = breakvar_heteroskedasticity_test(
+            input_residuals,
+            alternative=alternative,
+            )
+        assert actual_statistic == expected_statistic
+        assert actual_pvalue == expected_pvalue
+
+    def test_use_chi2(self):
+
+        input_residuals = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
+        expected_statistic = (4.0**2 + 5.0**2) / (0.0**2 + 1.0**2)
+        expected_pvalue = 2 * min(
+            self.chi2.cdf(2 * expected_statistic, 2),
+            self.chi2.sf(2 * expected_statistic, 2)
+            )
+        actual_statistic, actual_pvalue = breakvar_heteroskedasticity_test(
+            input_residuals,
+            use_f=False,
+            )
+        assert actual_statistic == expected_statistic
+        assert actual_pvalue == expected_pvalue
+
+
 class CheckCoint(object):
     """
     Test Cointegration Test Results for 2-variable system