Repository: statsmodels/statsmodels

Issue:
BUG: SARIMAX with both exog and trend broken for h=1
This was introduced in v0.12, and is based on too-aggressive checking for a constant column in `exog` when there is also a constant trend.

```python
np.random.seed(1234)

y = np.random.normal(size=100)
x = np.random.normal(size=100)

mod = sm.tsa.SARIMAX(y, exog=x, order=(1, 0, 0), trend='c')
res = mod.smooth([-1., 0.5, 0.5, 1.0])
res.forecast(1, exog=[10.3])
```

```
~/projects/statsmodels/statsmodels/tsa/arima/specification.py in __init__(self, endog, exog, order, seasonal_order, ar_order, diff, ma_order, seasonal_ar_order, seasonal_diff, seasonal_ma_order, seasonal_periods, trend, enforce_stationarity, enforce_invertibility, concentrate_scale, trend_offset, dates, freq, missing)
    387             # If we already have a constant column, raise an error
    388             if np.any(col_const):
--> 389                 raise ValueError('A constant trend was included in the model'
    390                                  ' specification, but the `exog` data already'
    391                                  ' contains a column of constants.')

ValueError: A constant trend was included in the model specification, but the `exog` data already contains a column of constants.
```


Discussion:

Resolving patch:
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
@@ -110,7 +110,7 @@ def __init__(self, endog, exog=None, order=(0, 0, 0),
                  seasonal_order=(0, 0, 0, 0), trend=None,
                  enforce_stationarity=True, enforce_invertibility=True,
                  concentrate_scale=False, trend_offset=1, dates=None,
-                 freq=None, missing='none'):
+                 freq=None, missing='none', validate_specification=True):
         # Default for trend
         # 'c' if there is no integration and 'n' otherwise
         # TODO: if trend='c', then we could alternatively use `demean=True` in
@@ -131,7 +131,8 @@ def __init__(self, endog, exog=None, order=(0, 0, 0),
             endog, exog=exog, order=order, seasonal_order=seasonal_order,
             trend=trend, enforce_stationarity=None, enforce_invertibility=None,
             concentrate_scale=concentrate_scale, trend_offset=trend_offset,
-            dates=dates, freq=freq, missing=missing)
+            dates=dates, freq=freq, missing=missing,
+            validate_specification=validate_specification)
         exog = self._spec_arima._model.data.orig_exog
 
         # Keep the given `exog` by removing the prepended trend variables
@@ -152,7 +153,7 @@ def __init__(self, endog, exog=None, order=(0, 0, 0),
             enforce_stationarity=enforce_stationarity,
             enforce_invertibility=enforce_invertibility,
             concentrate_scale=concentrate_scale, dates=dates, freq=freq,
-            missing=missing)
+            missing=missing, validate_specification=validate_specification)
         self.trend = trend
 
         # Save the input exog and input exog names, so that we can refer to
diff --git a/statsmodels/tsa/arima/specification.py b/statsmodels/tsa/arima/specification.py
--- a/statsmodels/tsa/arima/specification.py
+++ b/statsmodels/tsa/arima/specification.py
@@ -214,7 +214,7 @@ def __init__(self, endog=None, exog=None, order=None,
                  seasonal_ma_order=None, seasonal_periods=None, trend=None,
                  enforce_stationarity=None, enforce_invertibility=None,
                  concentrate_scale=None, trend_offset=1, dates=None, freq=None,
-                 missing='none'):
+                 missing='none', validate_specification=True):
 
         # Basic parameters
         self.enforce_stationarity = enforce_stationarity
@@ -271,17 +271,20 @@ def __init__(self, endog=None, exog=None, order=None,
                              ' with four elements.')
 
         # Validate differencing parameters
-        if order[1] < 0:
-            raise ValueError('Cannot specify negative differencing.')
-        if order[1] != int(order[1]):
-            raise ValueError('Cannot specify fractional differencing.')
-        if seasonal_order[1] < 0:
-            raise ValueError('Cannot specify negative seasonal differencing.')
-        if seasonal_order[1] != int(seasonal_order[1]):
-            raise ValueError('Cannot specify fractional seasonal'
-                             ' differencing.')
-        if seasonal_order[3] < 0:
-            raise ValueError('Cannot specify negative seasonal periodicity.')
+        if validate_specification:
+            if order[1] < 0:
+                raise ValueError('Cannot specify negative differencing.')
+            if order[1] != int(order[1]):
+                raise ValueError('Cannot specify fractional differencing.')
+            if seasonal_order[1] < 0:
+                raise ValueError('Cannot specify negative seasonal'
+                                 ' differencing.')
+            if seasonal_order[1] != int(seasonal_order[1]):
+                raise ValueError('Cannot specify fractional seasonal'
+                                 ' differencing.')
+            if seasonal_order[3] < 0:
+                raise ValueError('Cannot specify negative seasonal'
+                                 ' periodicity.')
 
         # Standardize to integers or lists of integers
         order = (
@@ -295,12 +298,15 @@ def __init__(self, endog=None, exog=None, order=None,
             int(seasonal_order[3]))
 
         # Validate seasonals
-        if seasonal_order[3] == 1:
-            raise ValueError('Seasonal periodicity must be greater than 1.')
-        if ((seasonal_order[0] != 0 or seasonal_order[1] != 0 or
-                seasonal_order[2] != 0) and seasonal_order[3] == 0):
-            raise ValueError('Must include nonzero seasonal periodicity if'
-                             ' including seasonal AR, MA, or differencing.')
+        if validate_specification:
+            if seasonal_order[3] == 1:
+                raise ValueError('Seasonal periodicity must be greater'
+                                 ' than 1.')
+            if ((seasonal_order[0] != 0 or seasonal_order[1] != 0 or
+                    seasonal_order[2] != 0) and seasonal_order[3] == 0):
+                raise ValueError('Must include nonzero seasonal periodicity if'
+                                 ' including seasonal AR, MA, or'
+                                 ' differencing.')
 
         # Basic order
         self.order = order
@@ -353,7 +359,7 @@ def __init__(self, endog=None, exog=None, order=None,
         seasonal_ar_lags = set(np.array(self.seasonal_ar_lags)
                                * self.seasonal_periods)
         duplicate_ar_lags = ar_lags.intersection(seasonal_ar_lags)
-        if len(duplicate_ar_lags) > 0:
+        if validate_specification and len(duplicate_ar_lags) > 0:
             raise ValueError('Invalid model: autoregressive lag(s) %s are'
                              ' in both the seasonal and non-seasonal'
                              ' autoregressive components.'
@@ -363,7 +369,7 @@ def __init__(self, endog=None, exog=None, order=None,
         seasonal_ma_lags = set(np.array(self.seasonal_ma_lags)
                                * self.seasonal_periods)
         duplicate_ma_lags = ma_lags.intersection(seasonal_ma_lags)
-        if len(duplicate_ma_lags) > 0:
+        if validate_specification and len(duplicate_ma_lags) > 0:
             raise ValueError('Invalid model: moving average lag(s) %s are'
                              ' in both the seasonal and non-seasonal'
                              ' moving average components.'
@@ -375,8 +381,8 @@ def __init__(self, endog=None, exog=None, order=None,
 
         # Check for a constant column in the provided exog
         exog_is_pandas = _is_using_pandas(exog, None)
-        if (exog is not None and len(self.trend_poly) > 0 and
-                self.trend_poly[0] == 1):
+        if (validate_specification and exog is not None and
+                len(self.trend_poly) > 0 and self.trend_poly[0] == 1):
             # Figure out if we have any constant columns
             x = np.asanyarray(exog)
             ptp0 = np.ptp(x, axis=0)
@@ -443,7 +449,8 @@ def __init__(self, endog=None, exog=None, order=None,
         self.exog = self._model.exog
 
         # Validate endog shape
-        if not faux_endog and self.endog.ndim > 1 and self.endog.shape[1] > 1:
+        if (validate_specification and not faux_endog and
+                self.endog.ndim > 1 and self.endog.shape[1] > 1):
             raise ValueError('SARIMAX models require univariate `endog`. Got'
                              ' shape %s.' % str(self.endog.shape))
 
diff --git a/statsmodels/tsa/statespace/sarimax.py b/statsmodels/tsa/statespace/sarimax.py
--- a/statsmodels/tsa/statespace/sarimax.py
+++ b/statsmodels/tsa/statespace/sarimax.py
@@ -322,13 +322,14 @@ def __init__(self, endog, exog=None, order=(1, 0, 0),
                  enforce_stationarity=True, enforce_invertibility=True,
                  hamilton_representation=False, concentrate_scale=False,
                  trend_offset=1, use_exact_diffuse=False, dates=None,
-                 freq=None, missing='none', **kwargs):
+                 freq=None, missing='none', validate_specification=True,
+                 **kwargs):
 
         self._spec = SARIMAXSpecification(
             endog, exog=exog, order=order, seasonal_order=seasonal_order,
             trend=trend, enforce_stationarity=None, enforce_invertibility=None,
             concentrate_scale=concentrate_scale, dates=dates, freq=freq,
-            missing=missing)
+            missing=missing, validate_specification=validate_specification)
         self._params = SARIMAXParams(self._spec)
 
         # Save given orders
@@ -1734,10 +1735,11 @@ def _get_extension_time_varying_matrices(
         if not self.simple_differencing and self._k_trend > 0:
             extend_kwargs.setdefault(
                 'trend_offset', self.trend_offset + self.nobs)
+        extend_kwargs.setdefault('validate_specification', False)
         mod_extend = self.clone(
             endog=tmp_endog, exog=tmp_exog, **extend_kwargs)
         mod_extend.update(params, transformed=transformed,
-                          includes_fixed=includes_fixed)
+                          includes_fixed=includes_fixed,)
 
         # Retrieve the extensions to the time-varying system matrices
         # and put them in kwargs


Test patch:
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
@@ -2763,3 +2763,20 @@ def test_sarimax_starting_values_few_obsevations(reset_randomstate):
     assert np.all(
         np.isfinite(sarimax_model.predict(start=len(y), end=len(y) + 11))
     )
+
+
+def test_sarimax_forecast_exog_trend(reset_randomstate):
+    # Test that an error is not raised that the given `exog` for the forecast
+    # period is a constant when forecating with an intercept
+    # GH 7019
+    y = np.zeros(10)
+    x = np.zeros(10)
+
+    mod = sarimax.SARIMAX(endog=y, exog=x, order=(1, 0, 0), trend='c')
+    res = mod.smooth([0.2, 0.4, 0.5, 1.0])
+
+    # Test for h=1
+    assert_allclose(res.forecast(1, exog=1), 0.2 + 0.4)
+
+    # Test for h=2
+    assert_allclose(res.forecast(2, exog=[1., 1.]), 0.2 + 0.4, 0.2 + 0.4 + 0.5)