Repository: statsmodels/statsmodels

Issue:
(Minor) BUG: statespace MLEModel throws a false validation error in a rare edge case of nested fix_params 
#### Describe the bug

##### Context: 
- The parameters we can fix depend on parameters like `enforce_stationarity` and `enforce_invertibility`.
  - As an example, when fixing parameters for `mod = SARIMAX(endog, order=(2, 0, 0), enforce_stationarity=False)`, we cannot fix individual AR parameters (reference: `._validate_can_fix_params`). In other words, we should fix either 1) both `ar.L1` and `ar.L2` or 2) neither. 
- The implementation of `SARIMAX` (and more generally `MLEModel`) generally supports nested `fix_params`.
- However, the following rare edge case throws a false-negative validation error.

##### Edge case: 
The following edge case first fixes both `ar.L1` and `ar.L2` in the outer `fix_params` (which works as expected), and then overwrites the fixed value of `ar.L1` in an inner nested `fixed_params` (which should work but throws an error). The reason is that only the newly added fixed params are passed into `_validate_can_fix_params` while the already fixed params are ignored.  

```
mod = SARIMAX(endog, order=(2, 0, 0), enforce_stationarity=False)
with mod.fix_params({'ar.L1': 1, 'ar.L2': 5}):
    # overwrites outer fixed ar.L1 with a nested fix_param
    with mod.fix_params({'ar.L1': -1}):
        print(mod._fixed_params)   # should be {'ar.L1': -1, 'ar.L2': 5}
```

Please see below for a full code snippet .

##### Other similarly affected modules: 
The same goes to a few other subclasses of `MLEModel` which implements a similar `_validate_can_fix_params` function, such as `DynamicFactor`, `UnobservedComponents`, and `VARMAX`. 

##### Fix: 
This should only require a one-liner-ish fix that passes both already fixed params and newly fixed params into `_validate_can_fix_params` inside `MLEModel`. 

#### Code Sample, a copy-pastable example if possible


```python
# imports
from statsmodels import datasets
from statsmodels.tsa.statespace import sarimax as sarimax_model
# data
macrodata = datasets.macrodata.load_pandas().data
endog = macrodata['infl'].copy()
# models
mod_1 = sarimax_model.SARIMAX(endog, order=(2, 0, 0), enforce_stationarity=False)
mod_2 = sarimax_model.SARIMAX(endog, order=(2, 0, 0), enforce_stationarity=True)
# this works as expected
with mod_1.fix_params({'ar.L1': 1, 'ar.L2': 5}):
    # overwrites with a nested fix_param s
    with mod_1.fix_params({'ar.L1': -1}):  
        print(mod_1._fixed_params)   # output: {'ar.L1': -1, 'ar.L2': 5}
# BUG: this throws error although mod_2._fixed_params is valid 
with mod_2.fix_params({'ar.L1': 1, 'ar.L2': 5}):
    # overwrites with a nested fix_param
    with mod_2.fix_params({'ar.L1': -1}):
        print(mod_2._fixed_params)   # should be {'ar.L1': -1, 'ar.L2': 5} but throws error
```
<details>

**Note**: As you can see, there are many issues on our GitHub tracker, so it is very possible that your issue has been posted before. Please check first before submitting so that we do not have to handle and close duplicates.

**Note**: Please be sure you are using the latest released version of `statsmodels`, or a recent build of `main`. If your problem has been fixed in an unreleased version, you might be able to use `main` until a new release occurs. 

**Note**: If you are using a released version, have you verified that the bug exists in the main branch of this repository? It helps the limited resources if we know problems exist in the current main branch so that they do not need to check whether the code sample produces a bug in the next release.

If the issue has not been resolved, please file it in the issue tracker.

</details>



#### Output of ``import statsmodels.api as sm; sm.show_versions()``

<details>

```
INSTALLED VERSIONS
------------------
Python: 3.9.2.final.0
OS: Linux 5.8.0-55-generic #62~20.04.1-Ubuntu SMP Wed Jun 2 08:55:04 UTC 2021 x86_64
byteorder: little
LC_ALL: None
LANG: en_US.UTF-8

statsmodels
===========

Installed: v0.13.0.dev0+380.g79ddd9882 

Required Dependencies
=====================

cython: 0.29.22 
numpy: 1.20.1 
scipy: 1.6.1 
pandas: 1.2.3 
    dateutil: 2.8.1 
patsy: 0.5.1 

Optional Dependencies
=====================

matplotlib: 3.3.4 
Backend TkAgg is interactive backend. Turning interactive mode on.
    backend: TkAgg 
cvxopt: Not installed
joblib: Not installed

Developer Tools
================

IPython: 7.21.0 
    jinja2: 2.11.3 
sphinx: 3.5.3 
    pygments: 2.8.1 
pytest: 6.2.2
virtualenv: Not installed

```

</details>



Discussion:

Resolving patch:
diff --git a/statsmodels/tsa/exponential_smoothing/base.py b/statsmodels/tsa/exponential_smoothing/base.py
--- a/statsmodels/tsa/exponential_smoothing/base.py
+++ b/statsmodels/tsa/exponential_smoothing/base.py
@@ -103,7 +103,10 @@ def fix_params(self, params):
         cache_free_params_index = self._free_params_index
 
         # Validate parameter names and values
-        self._validate_can_fix_params(set(params.keys()))
+        all_fixed_param_names = (
+            set(params.keys()) | set(self._fixed_params.keys())
+        )
+        self._validate_can_fix_params(all_fixed_param_names)
 
         # Set the new fixed parameters, keeping the order as given by
         # param_names
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
@@ -492,7 +492,10 @@ def fix_params(self, params):
         cache_free_params_index = self._free_params_index
 
         # Validate parameter names and values
-        self._validate_can_fix_params(set(params.keys()))
+        all_fixed_param_names = (
+            set(params.keys()) | set(self._fixed_params.keys())
+        )
+        self._validate_can_fix_params(all_fixed_param_names)
 
         # Set the new fixed parameters, keeping the order as given by
         # param_names


Test patch:
diff --git a/statsmodels/tsa/statespace/tests/test_fixed_params.py b/statsmodels/tsa/statespace/tests/test_fixed_params.py
--- a/statsmodels/tsa/statespace/tests/test_fixed_params.py
+++ b/statsmodels/tsa/statespace/tests/test_fixed_params.py
@@ -32,6 +32,21 @@ def test_fix_params():
     assert_equal(mod._free_params_index, None)
 
 
+def test_nested_fix_params():
+    mod = mlemodel.MLEModel([], 1)
+    mod._param_names = ['a', 'b', 'c']
+    with mod.fix_params({'a': 2, 'b': 0}):
+        with mod.fix_params({'b': 1.}):
+            assert_(mod._has_fixed_params)
+            assert_equal(mod._fixed_params, {'a': 2, 'b': 1.})
+            assert_equal(mod._fixed_params_index, [0, 1])
+            assert_equal(mod._free_params_index, [2])
+    assert_(not mod._has_fixed_params)
+    assert_equal(mod._fixed_params, {})
+    assert_equal(mod._fixed_params_index, None)
+    assert_equal(mod._free_params_index, None)
+
+
 def test_results_append():
     endog = macrodata['infl']
     endog1 = endog.iloc[:100]
@@ -117,7 +132,17 @@ def test_results_apply():
     assert_allclose(res2_fit.llf_obs, res_fit.llf_obs)
 
 
-def test_sarimax_invalid():
+def test_mle_validate():
+    mod = mlemodel.MLEModel([], 1)
+    mod._param_names = ['a', 'b', 'c']
+    msg = 'Invalid parameter name passed: "d"'
+
+    with pytest.raises(ValueError, match=msg):
+        with mod.fix_params({'d': 1}):
+            pass
+
+
+def test_sarimax_validate():
     # Test for invalid uses of parameter fixing
     endog = macrodata['infl']
     mod1 = sarimax.SARIMAX(endog, order=(2, 0, 0))
@@ -139,6 +164,7 @@ def test_sarimax_invalid():
         assert_equal(mod1._fixed_params, {'ar.L1': 0.5, 'ar.L2': 0.2})
         assert_equal(mod1._fixed_params_index, [0, 1])
         assert_equal(mod1._free_params_index, [2])
+
     res = mod1.fit_constrained({'ar.L1': 0.5, 'ar.L2': 0.2},
                                start_params=[7.0], disp=False)
     assert_(res._has_fixed_params)
@@ -146,11 +172,19 @@ def test_sarimax_invalid():
     assert_equal(res._fixed_params_index, [0, 1])
     assert_equal(res._free_params_index, [2])
 
+    with mod1.fix_params({'ar.L1': 0.5, 'ar.L2': 0.}):
+        # overwrite ar.L2 with nested fix_params
+        with mod1.fix_params({'ar.L2': 0.2}):
+            assert_(mod1._has_fixed_params)
+            assert_equal(mod1._fixed_params, {'ar.L1': 0.5, 'ar.L2': 0.2})
+            assert_equal(mod1._fixed_params_index, [0, 1])
+            assert_equal(mod1._free_params_index, [2])
 
-def test_structural_invalid():
+
+def test_structural_validate():
     # Test for invalid uses of parameter fixing
     endog = macrodata['infl']
-    mod1 = structural.UnobservedComponents(endog, 'rwalk', ar=2)
+    mod1 = structural.UnobservedComponents(endog, 'rwalk', autoregressive=2)
 
     # Try to fix invalid parameter
     assert_raises(ValueError, mod1.fit_constrained, {'AR.L1': 0.5})
@@ -161,8 +195,31 @@ def test_structural_invalid():
             pass
     assert_raises(ValueError, mod1.fit_constrained, {'ar.L1': 0.5})
 
+    # But can fix the entire set of parameters that are part of a multivariate
+    # transformation
+    with mod1.fix_params({'ar.L1': 0.5, 'ar.L2': 0.2}):
+        assert_(mod1._has_fixed_params)
+        assert_equal(mod1._fixed_params, {'ar.L1': 0.5, 'ar.L2': 0.2})
+        assert_equal(mod1._fixed_params_index, [2, 3])
+        assert_equal(mod1._free_params_index, [0, 1])
+
+    res = mod1.fit_constrained({'ar.L1': 0.5, 'ar.L2': 0.2},
+                               start_params=[7.0], disp=False)
+    assert_(res._has_fixed_params)
+    assert_equal(res._fixed_params, {'ar.L1': 0.5, 'ar.L2': 0.2})
+    assert_equal(res._fixed_params_index, [2, 3])
+    assert_equal(res._free_params_index, [0, 1])
+
+    with mod1.fix_params({'ar.L1': 0.5, 'ar.L2': 0.}):
+        # overwrite ar.L2 with nested fix_params
+        with mod1.fix_params({'ar.L2': 0.2}):
+            assert_(mod1._has_fixed_params)
+            assert_equal(mod1._fixed_params, {'ar.L1': 0.5, 'ar.L2': 0.2})
+            assert_equal(mod1._fixed_params_index, [2, 3])
+            assert_equal(mod1._free_params_index, [0, 1])
+
 
-def test_dynamic_factor_invalid():
+def test_dynamic_factor_validate():
     # Test for invalid uses of parameter fixing
     endog = np.log(macrodata[['cpi', 'realgdp', 'realinv']]).diff().iloc[1:]
     endog = (endog - endog.mean()) / endog.std()
@@ -200,24 +257,37 @@ def test_dynamic_factor_invalid():
     with pytest.raises(ValueError):
         with mod2.fix_params({'L1.f1.f1': 0.5}):
             pass
+
     constraints = {'L1.f1.f1': 0.3, 'L2.f1.f1': 0.1}
     with mod2.fix_params(constraints):
         assert_(mod2._has_fixed_params)
         assert_equal(mod2._fixed_params, constraints)
         assert_equal(mod2._fixed_params_index, [6, 7])
         assert_equal(mod2._free_params_index, [0, 1, 2, 3, 4, 5])
+
     res2 = mod2.fit_constrained(constraints, disp=False)
     assert_(res2._has_fixed_params)
     assert_equal(res2._fixed_params, constraints)
     assert_equal(res2._fixed_params_index, [6, 7])
     assert_equal(res2._free_params_index, [0, 1, 2, 3, 4, 5])
 
+    with mod2.fix_params(constraints):
+        # overwrite L1.f1.f1 with nested fix_params
+        with mod2.fix_params({'L1.f1.f1': -0.3}):
+            assert_(mod2._has_fixed_params)
+            assert_equal(
+                mod2._fixed_params, {'L1.f1.f1': -0.3, 'L2.f1.f1': 0.1}
+            )
+            assert_equal(mod2._fixed_params_index, [6, 7])
+            assert_equal(mod2._free_params_index, [0, 1, 2, 3, 4, 5])
+
     # (same as previous, now k_factors=2)
     mod3 = dynamic_factor.DynamicFactor(
         endog, k_factors=2, factor_order=1, error_cov_type='diagonal')
     with pytest.raises(ValueError):
         with mod3.fix_params({'L1.f1.f1': 0.3}):
             pass
+
     constraints = dict([('L1.f1.f1', 0.3), ('L1.f2.f1', 0.1),
                         ('L1.f1.f2', -0.05), ('L1.f2.f2', 0.1)])
     with mod3.fix_params(constraints):
@@ -225,12 +295,25 @@ def test_dynamic_factor_invalid():
         assert_equal(mod3._fixed_params, constraints)
         assert_equal(mod3._fixed_params_index, [9, 10, 11, 12])
         assert_equal(mod3._free_params_index, [0, 1, 2, 3, 4, 5, 6, 7, 8])
+
     res3 = mod3.fit_constrained(constraints, disp=False)
     assert_(res3._has_fixed_params)
     assert_equal(res3._fixed_params, constraints)
     assert_equal(res3._fixed_params_index, [9, 10, 11, 12])
     assert_equal(res3._free_params_index, [0, 1, 2, 3, 4, 5, 6, 7, 8])
 
+    with mod3.fix_params(constraints):
+        # overwrite L1.f1.f1 and L1.f2.f2 with nested fix_params
+        with mod3.fix_params({'L1.f1.f1': -0.3, 'L1.f2.f2': -0.1}):
+            assert_(mod3._has_fixed_params)
+            assert_equal(
+                mod3._fixed_params,
+                dict([('L1.f1.f1', -0.3), ('L1.f2.f1', 0.1),
+                      ('L1.f1.f2', -0.05), ('L1.f2.f2', -0.1)])
+            )
+            assert_equal(mod3._fixed_params_index, [9, 10, 11, 12])
+            assert_equal(mod3._free_params_index, [0, 1, 2, 3, 4, 5, 6, 7, 8])
+
     # Now, with enforce_stationarity=False, we can fix any of the factor AR
     # coefficients
     mod4 = dynamic_factor.DynamicFactor(
@@ -286,7 +369,7 @@ def test_dynamic_factor_invalid():
     assert_equal(res6._free_params_index, [4, 5])
 
 
-def test_varmax_invalid():
+def test_varmax_validate():
     # Test for invalid uses of parameter fixing
     endog = np.log(macrodata[['cpi', 'realgdp']]).diff().iloc[1:]
     exog = np.log(macrodata[['realinv']]).diff().iloc[1:]
@@ -330,18 +413,30 @@ def test_varmax_invalid():
     with pytest.raises(ValueError):
         with mod3.fix_params({'L1.cpi.cpi': 0.5}):
             pass
+
     constraints = {'L1.cpi.cpi': 0.3, 'L2.cpi.cpi': 0.1}
     with mod3.fix_params(constraints):
         assert_(mod3._has_fixed_params)
         assert_equal(mod3._fixed_params, constraints)
         assert_equal(mod3._fixed_params_index, [1, 2])
         assert_equal(mod3._free_params_index, [0, 3])
+
     res3 = mod3.fit_constrained(constraints, start_params=[0, 1.], disp=False)
     assert_(res3._has_fixed_params)
     assert_equal(res3._fixed_params, constraints)
     assert_equal(res3._fixed_params_index, [1, 2])
     assert_equal(res3._free_params_index, [0, 3])
 
+    with mod3.fix_params(constraints):
+        # overwrite L1.cpi.cpi with nested fix_params
+        with mod3.fix_params({'L1.cpi.cpi': -0.3}):
+            assert_(mod3._has_fixed_params)
+            assert_equal(
+                mod3._fixed_params, {'L1.cpi.cpi': -0.3, 'L2.cpi.cpi': 0.1}
+            )
+            assert_equal(mod3._fixed_params_index, [1, 2])
+            assert_equal(mod3._free_params_index, [0, 3])
+
     # With k_endog > 1, we can only fix the entire set of AR coefficients when
     # `enforce_stationarity=True`.
     mod4 = varmax.VARMAX(endog, order=(1, 0))