Repository: statsmodels/statsmodels

Issue:
ENH: include fit_kwargs in extend and store cov_params
For `MLEModel.extend`, we need to:

1. Add a fit_kwargs argument to pass kwargs to the underlying filter/smooth method (e.g. cov_type)
2. Transfer the covariance parameters to the new results object (otherwise it will try to recompute the cov_params)


Discussion:

Resolving patch:
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
@@ -2882,6 +2882,44 @@ def impulse_responses(self, steps=1, impulse=0, orthogonalized=False,
                                                 **kwargs)
         return irfs
 
+    def _apply(self, mod, refit=False, fit_kwargs=None, **kwargs):
+        if fit_kwargs is None:
+            fit_kwargs = {}
+
+        if refit:
+            fit_kwargs.setdefault('start_params', self.params)
+            if self._has_fixed_params:
+                fit_kwargs.setdefault('includes_fixed', True)
+                res = mod.fit_constrained(self._fixed_params, **fit_kwargs)
+            else:
+                res = mod.fit(**fit_kwargs)
+        else:
+            if 'cov_type' in fit_kwargs:
+                raise ValueError('Cannot specify covariance type in'
+                                 ' `fit_kwargs` unless refitting'
+                                 ' parameters (not available in extend).')
+            if 'cov_kwds' in fit_kwargs:
+                raise ValueError('Cannot specify covariance keyword arguments'
+                                 ' in `fit_kwargs` unless refitting'
+                                 ' parameters (not available in extend).')
+
+            fit_kwargs['cov_type'] = 'custom'
+            fit_kwargs['cov_kwds'] = {
+                'custom_cov_type': self.cov_type,
+                'custom_cov_params': self.cov_params_default,
+                'custom_description': ('Parameters and standard errors'
+                                       ' were estimated using a different'
+                                       ' dataset and were then applied to this'
+                                       ' dataset. %s'
+                                       % self.cov_kwds['description'])}
+
+            if self.smoother_results is not None:
+                res = mod.smooth(self.params, **fit_kwargs)
+            else:
+                res = mod.filter(self.params, **fit_kwargs)
+
+        return res
+
     def append(self, endog, exog=None, refit=False, fit_kwargs=None, **kwargs):
         """
         Recreate the results object with new data appended to the original data
@@ -2900,6 +2938,9 @@ def append(self, endog, exog=None, refit=False, fit_kwargs=None, **kwargs):
             Whether to re-fit the parameters, based on the combined dataset.
             Default is False (so parameters from the current results object
             are used to create the new results object).
+        fit_kwargs : dict, optional
+            Keyword arguments to pass to `fit` (if `refit=True`) or `filter` /
+            `smooth`.
         **kwargs
             Keyword arguments may be used to modify model specification
             arguments when created the new model object.
@@ -2972,23 +3013,11 @@ def append(self, endog, exog=None, refit=False, fit_kwargs=None, **kwargs):
             new_exog = None
 
         mod = self.model.clone(new_endog, exog=new_exog, **kwargs)
+        res = self._apply(mod, refit=refit, fit_kwargs=fit_kwargs, **kwargs)
 
-        if refit:
-            if fit_kwargs is None:
-                fit_kwargs = {}
-            fit_kwargs.setdefault('start_params', self.params)
-            if self._has_fixed_params:
-                fit_kwargs.setdefault('includes_fixed', True)
-                res = mod.fit_constrained(self._fixed_params, **fit_kwargs)
-            else:
-                res = mod.fit(**fit_kwargs)
-        elif self.smoother_results is not None:
-            res = mod.smooth(self.params)
-        else:
-            res = mod.filter(self.params)
         return res
 
-    def extend(self, endog, exog=None, **kwargs):
+    def extend(self, endog, exog=None, fit_kwargs=None, **kwargs):
         """
         Recreate the results object for new data that extends the original data
 
@@ -3002,6 +3031,8 @@ def extend(self, endog, exog=None, **kwargs):
             New observations from the modeled time-series process.
         exog : array_like, optional
             New observations of exogenous regressors, if applicable.
+        fit_kwargs : dict, optional
+            Keyword arguments to pass to `filter` or `smooth`.
         **kwargs
             Keyword arguments may be used to modify model specification
             arguments when created the new model object.
@@ -3066,11 +3097,7 @@ def extend(self, endog, exog=None, **kwargs):
         mod.ssm.initialization = Initialization(
             mod.k_states, 'known', constant=self.predicted_state[..., -1],
             stationary_cov=self.predicted_state_cov[..., -1])
-
-        if self.smoother_results is not None:
-            res = mod.smooth(self.params)
-        else:
-            res = mod.filter(self.params)
+        res = self._apply(mod, refit=False, fit_kwargs=fit_kwargs, **kwargs)
 
         return res
 
@@ -3093,6 +3120,9 @@ def apply(self, endog, exog=None, refit=False, fit_kwargs=None, **kwargs):
             Whether to re-fit the parameters, using the new dataset.
             Default is False (so parameters from the current results object
             are used to create the new results object).
+        fit_kwargs : dict, optional
+            Keyword arguments to pass to `fit` (if `refit=True`) or `filter` /
+            `smooth`.
         **kwargs
             Keyword arguments may be used to modify model specification
             arguments when created the new model object.
@@ -3151,20 +3181,8 @@ def apply(self, endog, exog=None, refit=False, fit_kwargs=None, **kwargs):
         statsmodels.tsa.statespace.mlemodel.MLEResults.apply
         """
         mod = self.model.clone(endog, exog=exog, **kwargs)
+        res = self._apply(mod, refit=refit, fit_kwargs=fit_kwargs, **kwargs)
 
-        if refit:
-            if fit_kwargs is None:
-                fit_kwargs = {}
-            fit_kwargs.setdefault('start_params', self.params)
-            if self._has_fixed_params:
-                fit_kwargs.setdefault('includes_fixed', True)
-                res = mod.fit_constrained(self._fixed_params, **fit_kwargs)
-            else:
-                res = mod.fit(**fit_kwargs)
-        elif self.smoother_results is not None:
-            res = mod.smooth(self.params)
-        else:
-            res = mod.filter(self.params)
         return res
 
     def plot_diagnostics(self, variable=0, lags=10, fig=None, figsize=None):


Test patch:
diff --git a/statsmodels/tsa/statespace/tests/test_dynamic_factor.py b/statsmodels/tsa/statespace/tests/test_dynamic_factor.py
--- a/statsmodels/tsa/statespace/tests/test_dynamic_factor.py
+++ b/statsmodels/tsa/statespace/tests/test_dynamic_factor.py
@@ -873,8 +873,8 @@ def test_append_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
@@ -951,8 +951,8 @@ def test_apply_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
diff --git a/statsmodels/tsa/statespace/tests/test_mlemodel.py b/statsmodels/tsa/statespace/tests/test_mlemodel.py
--- a/statsmodels/tsa/statespace/tests/test_mlemodel.py
+++ b/statsmodels/tsa/statespace/tests/test_mlemodel.py
@@ -1016,3 +1016,26 @@ def test_lutkepohl_information_criteria():
     bic = res.info_criteria('bic') - 6 * np.log(res.nobs_effective)
     assert_allclose(aic, true['estat_aic'])
     assert_allclose(bic, true['estat_bic'])
+
+
+def test_append_extend_apply_invalid():
+    # Test for invalid options to append, extend, and apply
+    niledata = nile.data.load_pandas().data['volume']
+    niledata.index = pd.date_range('1871-01-01', '1970-01-01', freq='AS')
+
+    endog1 = niledata.iloc[:20]
+    endog2 = niledata.iloc[20:40]
+
+    mod = sarimax.SARIMAX(endog1, order=(1, 0, 0), concentrate_scale=True)
+    res1 = mod.smooth([0.5])
+
+    assert_raises(ValueError, res1.append, endog2,
+                  fit_kwargs={'cov_type': 'approx'})
+    assert_raises(ValueError, res1.extend, endog2,
+                  fit_kwargs={'cov_type': 'approx'})
+    assert_raises(ValueError, res1.apply, endog2,
+                  fit_kwargs={'cov_type': 'approx'})
+
+    assert_raises(ValueError, res1.append, endog2, fit_kwargs={'cov_kwds': {}})
+    assert_raises(ValueError, res1.extend, endog2, fit_kwargs={'cov_kwds': {}})
+    assert_raises(ValueError, res1.apply, endog2, fit_kwargs={'cov_kwds': {}})
diff --git a/statsmodels/tsa/statespace/tests/test_sarimax.py b/statsmodels/tsa/statespace/tests/test_sarimax.py
--- a/statsmodels/tsa/statespace/tests/test_sarimax.py
+++ b/statsmodels/tsa/statespace/tests/test_sarimax.py
@@ -2518,8 +2518,8 @@ def test_append_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
@@ -2596,8 +2596,8 @@ def test_apply_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
diff --git a/statsmodels/tsa/statespace/tests/test_structural.py b/statsmodels/tsa/statespace/tests/test_structural.py
--- a/statsmodels/tsa/statespace/tests/test_structural.py
+++ b/statsmodels/tsa/statespace/tests/test_structural.py
@@ -557,8 +557,8 @@ def test_append_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
@@ -632,8 +632,8 @@ def test_apply_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
diff --git a/statsmodels/tsa/statespace/tests/test_varmax.py b/statsmodels/tsa/statespace/tests/test_varmax.py
--- a/statsmodels/tsa/statespace/tests/test_varmax.py
+++ b/statsmodels/tsa/statespace/tests/test_varmax.py
@@ -1028,8 +1028,8 @@ def test_append_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [
@@ -1108,8 +1108,8 @@ def test_apply_results():
 
     assert_equal(res1.specification, res3.specification)
 
-    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn',
-                 'cov_params_default']:
+    assert_allclose(res3.cov_params_default, res2.cov_params_default)
+    for attr in ['nobs', 'llf', 'llf_obs', 'loglikelihood_burn']:
         assert_equal(getattr(res3, attr), getattr(res1, attr))
 
     for attr in [