Repository: statsmodels/statsmodels

Issue:
ENH: Enable ARIMA.fit(method='hannan_rissanen') with fixed parameters 
- (For record keeping purposes) In continuation of #7202 as part of the boarder #6159
- PR #7497 adds `fixed_params` arg to the ARIMA estimator function `hannan_rissanen` 
- This issue should subsequently enable `ARIMA.fit(method='hannan_rissanen')` with fixed parameters as follows:

```
import numpy as np
from statsmodels.tsa.arima.model import ARIMA
endog = np.random.normal(size=(1000))
mod = ARIMA(endog, order=(5, 0, 0), trend='n', enforce_stationarity=False)
fixed_params = {'ar.L2': 0, 'ar.L3': 1}

# with context manager .fix_params
with mod.fix_params(fixed_params):
    res_1 = mod.fit(method='hannan_rissanen')

# or equivalently with .fit_constrained
res_2 = mod.fit_constrained(fixed_params, method='hannan_rissanen')
```


Discussion:

Resolving patch:
diff --git a/statsmodels/tsa/arima/estimators/hannan_rissanen.py b/statsmodels/tsa/arima/estimators/hannan_rissanen.py
--- a/statsmodels/tsa/arima/estimators/hannan_rissanen.py
+++ b/statsmodels/tsa/arima/estimators/hannan_rissanen.py
@@ -134,7 +134,7 @@ def hannan_rissanen(endog, ar_order=0, ma_order=0, demean=True,
         X_with_fixed_params = lagged_endog[:, params_info.fixed_ar_ix]
         y = endog[max_ar_order:]
         if X_with_fixed_params.shape[1] != 0:
-            y -= X_with_fixed_params.dot(params_info.fixed_ar_params)
+            y = y - X_with_fixed_params.dot(params_info.fixed_ar_params)
 
         # no free ar params -> variance computation on the endog residual
         if X_with_free_params.shape[1] == 0:
@@ -152,7 +152,8 @@ def hannan_rissanen(endog, ar_order=0, ma_order=0, demean=True,
                 fixed_ar_or_ma_lags=params_info.fixed_ar_lags,
                 fixed_ar_or_ma_params=params_info.fixed_ar_params,
                 free_ar_or_ma_lags=params_info.free_ar_lags,
-                free_ar_or_ma_params=res.params
+                free_ar_or_ma_params=res.params,
+                spec_ar_or_ma_lags=spec.ar_lags
             )
     # Otherwise ARMA model
     else:
@@ -178,7 +179,7 @@ def hannan_rissanen(endog, ar_order=0, ma_order=0, demean=True,
         ]
         y = endog[initial_ar_order + max_ma_order:]
         if X_with_fixed_params.shape[1] != 0:
-            y -= X_with_fixed_params.dot(
+            y = y - X_with_fixed_params.dot(
                 np.r_[params_info.fixed_ar_params, params_info.fixed_ma_params]
             )
 
@@ -199,13 +200,15 @@ def hannan_rissanen(endog, ar_order=0, ma_order=0, demean=True,
                 fixed_ar_or_ma_lags=params_info.fixed_ar_lags,
                 fixed_ar_or_ma_params=params_info.fixed_ar_params,
                 free_ar_or_ma_lags=params_info.free_ar_lags,
-                free_ar_or_ma_params=res.params[:k_free_ar_params]
+                free_ar_or_ma_params=res.params[:k_free_ar_params],
+                spec_ar_or_ma_lags=spec.ar_lags
             )
             p.ma_params = _stitch_fixed_and_free_params(
                 fixed_ar_or_ma_lags=params_info.fixed_ma_lags,
                 fixed_ar_or_ma_params=params_info.fixed_ma_params,
                 free_ar_or_ma_lags=params_info.free_ma_lags,
-                free_ar_or_ma_params=res.params[k_free_ar_params:]
+                free_ar_or_ma_params=res.params[k_free_ar_params:],
+                spec_ar_or_ma_lags=spec.ma_lags
             )
             resid = res.resid
             p.sigma2 = res.scale
@@ -389,7 +392,8 @@ def _package_fixed_and_free_params_info(fixed_params, spec_ar_lags,
 
 
 def _stitch_fixed_and_free_params(fixed_ar_or_ma_lags, fixed_ar_or_ma_params,
-                                  free_ar_or_ma_lags, free_ar_or_ma_params):
+                                  free_ar_or_ma_lags, free_ar_or_ma_params,
+                                  spec_ar_or_ma_lags):
     """
     Stitch together fixed and free params, by the order of lags, for setting
     SARIMAXParams.ma_params or SARIMAXParams.ar_params
@@ -402,6 +406,8 @@ def _stitch_fixed_and_free_params(fixed_ar_or_ma_lags, fixed_ar_or_ma_params,
     free_ar_or_ma_lags : list or np.array
     free_ar_or_ma_params : list or np.array
         free_ar_or_ma_params corresponds with free_ar_or_ma_lags
+    spec_ar_or_ma_lags : list
+        SARIMAXSpecification.ar_lags or SARIMAXSpecification.ma_lags
 
     Returns
     -------
@@ -412,6 +418,12 @@ def _stitch_fixed_and_free_params(fixed_ar_or_ma_lags, fixed_ar_or_ma_params,
 
     all_lags = np.r_[fixed_ar_or_ma_lags, free_ar_or_ma_lags]
     all_params = np.r_[fixed_ar_or_ma_params, free_ar_or_ma_params]
+    assert set(all_lags) == set(spec_ar_or_ma_lags)
 
-    sorted_idx = all_lags.argsort()
-    return list(all_params[sorted_idx])
+    lag_to_param_map = dict(zip(all_lags, all_params))
+
+    # Sort params by the order of their corresponding lags in
+    # spec_ar_or_ma_lags (e.g. SARIMAXSpecification.ar_lags or
+    # SARIMAXSpecification.ma_lags)
+    all_params_sorted = [lag_to_param_map[lag] for lag in spec_ar_or_ma_lags]
+    return all_params_sorted
diff --git a/statsmodels/tsa/arima/model.py b/statsmodels/tsa/arima/model.py
--- a/statsmodels/tsa/arima/model.py
+++ b/statsmodels/tsa/arima/model.py
@@ -297,10 +297,13 @@ def fit(self, start_params=None, transformed=True, includes_fixed=False,
         else:
             method = 'statespace'
 
-        # Can only use fixed parameters with method='statespace'
-        if self._has_fixed_params and method != 'statespace':
-            raise ValueError('When parameters have been fixed, only the method'
-                             ' "statespace" can be used; got "%s".' % method)
+        # Can only use fixed parameters with the following methods
+        methods_with_fixed_params = ['statespace', 'hannan_rissanen']
+        if self._has_fixed_params and method not in methods_with_fixed_params:
+            raise ValueError(
+                f"When parameters have been fixed, only the methods "
+                f"{methods_with_fixed_params} can be used; got '{method}'."
+            )
 
         # Handle kwargs related to the fit method
         if method_kwargs is None:
@@ -345,6 +348,11 @@ def fit(self, start_params=None, transformed=True, includes_fixed=False,
             # Note: both GLS and statespace are able to handle models with
             # integration, so we don't need to difference endog or exog here.
             if has_exog and (gls or (gls is None and method != 'statespace')):
+                if self._has_fixed_params:
+                    raise NotImplementedError(
+                        'GLS estimation is not yet implemented for the case '
+                        'with fixed parameters.'
+                    )
                 p, fit_details = estimate_gls(
                     self.endog, exog=self.exog, order=self.order,
                     seasonal_order=self.seasonal_order, include_constant=False,
@@ -383,6 +391,8 @@ def fit(self, start_params=None, transformed=True, includes_fixed=False,
                 if seasonal_order[1] > 0:
                     seasonal_order = (seasonal_order[0], 0, seasonal_order[2],
                                       seasonal_order[3])
+            if self._has_fixed_params:
+                method_kwargs['fixed_params'] = self._fixed_params.copy()
 
             # Now, estimate parameters
             if method == 'yule_walker':


Test patch:
diff --git a/statsmodels/tsa/arima/estimators/tests/test_hannan_rissanen.py b/statsmodels/tsa/arima/estimators/tests/test_hannan_rissanen.py
--- a/statsmodels/tsa/arima/estimators/tests/test_hannan_rissanen.py
+++ b/statsmodels/tsa/arima/estimators/tests/test_hannan_rissanen.py
@@ -255,21 +255,28 @@ def test_package_fixed_and_free_params_info(fixed_params, spec_ar_lags,
 
 
 @pytest.mark.parametrize(
-    "fixed_lags, free_lags, fixed_params, free_params, expected_all_params",
+    "fixed_lags, free_lags, fixed_params, free_params, "
+    "spec_lags, expected_all_params",
     [
-        ([], [], [], [], []),
-        ([2], [], [0.2], [], [0.2]),
-        ([], [1], [], [0.2], [0.2]),
-        ([1], [3], [0.2], [-0.2], [0.2, -0.2]),
-        ([3], [1, 2], [0.2], [0.3, -0.2], [0.3, -0.2, 0.2]),
-        ([3, 1], [2, 4], [0.3, 0.1], [0.5, 0.], [0.1, 0.5, 0.3, 0.]),
-        ([3, 10], [1, 2], [0.2, 0.5], [0.3, -0.2], [0.3, -0.2, 0.2, 0.5]),
+        ([], [], [], [], [], []),
+        ([2], [], [0.2], [], [2], [0.2]),
+        ([], [1], [], [0.2], [1], [0.2]),
+        ([1], [3], [0.2], [-0.2], [1, 3],  [0.2, -0.2]),
+        ([3], [1, 2], [0.2], [0.3, -0.2], [1, 2, 3], [0.3, -0.2, 0.2]),
+        ([3, 1], [2, 4], [0.3, 0.1], [0.5, 0.],
+         [1, 2, 3, 4], [0.1, 0.5, 0.3, 0.]),
+        ([3, 10], [1, 2], [0.2, 0.5], [0.3, -0.2],
+         [1, 2, 3, 10], [0.3, -0.2, 0.2, 0.5]),
+        # edge case where 'spec_lags' is somehow not sorted
+        ([3, 10], [1, 2], [0.2, 0.5], [0.3, -0.2],
+         [3, 1, 10, 2], [0.2, 0.3, 0.5, -0.2]),
     ]
 )
 def test_stitch_fixed_and_free_params(fixed_lags, free_lags, fixed_params,
-                                      free_params, expected_all_params):
+                                      free_params, spec_lags,
+                                      expected_all_params):
     actual_all_params = _stitch_fixed_and_free_params(
-        fixed_lags, fixed_params, free_lags, free_params
+        fixed_lags, fixed_params, free_lags, free_params, spec_lags
     )
     assert actual_all_params == expected_all_params
 
diff --git a/statsmodels/tsa/arima/tests/test_model.py b/statsmodels/tsa/arima/tests/test_model.py
--- a/statsmodels/tsa/arima/tests/test_model.py
+++ b/statsmodels/tsa/arima/tests/test_model.py
@@ -60,7 +60,8 @@ def test_invalid():
     # Need valid method
     assert_raises(ValueError, mod.fit, method='not_a_method')
 
-    # Can only use 'statespace' with fixed parameters
+    # Can only use certain methods with fixed parameters
+    # (e.g. 'statespace' and 'hannan-rissanen')
     with mod.fix_params({'ar.L1': 0.5}):
         assert_raises(ValueError, mod.fit, method='yule_walker')
 
@@ -385,3 +386,30 @@ def test_nonstationary_gls_error():
     )
     with pytest.raises(ValueError, match="Roots of the autoregressive"):
         mod.fit(method="hannan_rissanen", low_memory=True, cov_type="none")
+
+
+@pytest.mark.parametrize(
+    "ar_order, ma_order, fixed_params",
+    [
+        (1, 1, {}),
+        (1, 1, {'ar.L1': 0}),
+        (2, 3, {'ar.L2': -1, 'ma.L1': 2}),
+        ([0, 1], 0, {'ar.L2': 0}),
+        ([1, 5], [0, 0, 1], {'ar.L5': -10, 'ma.L3': 5}),
+    ]
+)
+def test_hannan_rissanen_with_fixed_params(ar_order, ma_order, fixed_params):
+    # Test for basic uses of Hannan-Rissanen estimation with fixed parameters
+    endog = dta['infl'].diff().iloc[1:101]
+
+    desired_p, _ = hannan_rissanen(
+        endog, ar_order=ar_order, ma_order=ma_order,
+        demean=False, fixed_params=fixed_params
+    )
+    # no constant or trend (since constant or trend would imply GLS estimation)
+    mod = ARIMA(endog, order=(ar_order, 0, ma_order), trend='n',
+                enforce_stationarity=False, enforce_invertibility=False)
+    with mod.fix_params(fixed_params):
+        res = mod.fit(method='hannan_rissanen')
+
+    assert_allclose(res.params, desired_p.params)