Repository: statsmodels/statsmodels

Issue:
DESIGN: state space `low_memory` 
Currently the `low_memory=True` argument to `fit` in state space models performs maximum memory conservation, so that (if I remember right) no Kalman filter output of length `nobs` is stored.

This is particularly useful when `nobs` and `k_states` are both large because the conditional covariance matrix storage has `nobs * k_states**2` elements.

Models fit this way are still useful for forecasting (and e.g. cross-validation-type model selection exercises), since that only depends on the final state estimates, but they can't do much else. For example, they done even have `fittedvalues` or `resid`.

The question here is: should we change `low_memory=True` to allow storing some or all of the `nobs * k_states` vectors. I guess the first thing to add back in would be `fittedvalues` and/or `resid`.

NB: regardless of what is decided for the `low_memory` option, users will still free to select custom memory conservation options directly (e.g. `mod.ssm.memory_no_filtered=True`), but this isn't well documented, and isn't flexible enough right now to allow keeping the `nobs * k_states` arrays while dropping the `nobs * k_states**2` arrays.


Discussion:
Would storing  the `fittedvalues` - adding `nobs` memory overhead, and compute the residuals etc on demand be a good compromise? Ideally you'd end up with the same functionality but with a different space / time tradeoff? So store whatever you need to evaluate the filter, and then `resid` etc effectively does a predict, applies the loss function and returns the result without holding a reference.
> Would storing the fittedvalues - adding nobs memory overhead, and compute the residuals etc on demand be a good compromise?

Thanks for the input. I agree, this seems like a good idea.

It will probably be best to get this change in for a v0.11 release.

Resolving patch:
diff --git a/statsmodels/tsa/statespace/kalman_filter.py b/statsmodels/tsa/statespace/kalman_filter.py
--- a/statsmodels/tsa/statespace/kalman_filter.py
+++ b/statsmodels/tsa/statespace/kalman_filter.py
@@ -36,17 +36,22 @@
 STABILITY_FORCE_SYMMETRY = 0x01
 
 MEMORY_STORE_ALL = 0
-MEMORY_NO_FORECAST = 0x01
-MEMORY_NO_PREDICTED = 0x02
-MEMORY_NO_FILTERED = 0x04
-MEMORY_NO_LIKELIHOOD = 0x08
-MEMORY_NO_GAIN = 0x10
-MEMORY_NO_SMOOTHING = 0x20
-MEMORY_NO_STD_FORECAST = 0x40
+MEMORY_NO_FORECAST_MEAN = 0x01
+MEMORY_NO_FORECAST_COV = 0x02
+MEMORY_NO_FORECAST = MEMORY_NO_FORECAST_MEAN | MEMORY_NO_FORECAST_COV
+MEMORY_NO_PREDICTED_MEAN = 0x04
+MEMORY_NO_PREDICTED_COV = 0x08
+MEMORY_NO_PREDICTED = MEMORY_NO_PREDICTED_MEAN | MEMORY_NO_PREDICTED_COV
+MEMORY_NO_FILTERED_MEAN = 0x10
+MEMORY_NO_FILTERED_COV = 0x20
+MEMORY_NO_FILTERED = MEMORY_NO_FILTERED_MEAN | MEMORY_NO_FILTERED_COV
+MEMORY_NO_LIKELIHOOD = 0x40
+MEMORY_NO_GAIN = 0x80
+MEMORY_NO_SMOOTHING = 0x100
+MEMORY_NO_STD_FORECAST = 0x200
 MEMORY_CONSERVE = (
-    MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED | MEMORY_NO_FILTERED |
-    MEMORY_NO_LIKELIHOOD | MEMORY_NO_GAIN | MEMORY_NO_SMOOTHING |
-    MEMORY_NO_STD_FORECAST
+    MEMORY_NO_FORECAST_COV | MEMORY_NO_PREDICTED | MEMORY_NO_FILTERED |
+    MEMORY_NO_LIKELIHOOD | MEMORY_NO_GAIN | MEMORY_NO_SMOOTHING
 )
 
 TIMING_INIT_PREDICTED = 0
@@ -212,8 +217,12 @@ class KalmanFilter(Representation):
     """
 
     memory_options = [
-        'memory_store_all', 'memory_no_forecast', 'memory_no_predicted',
-        'memory_no_filtered', 'memory_no_likelihood', 'memory_no_gain',
+        'memory_store_all', 'memory_no_forecast_mean',
+        'memory_no_forecast_cov', 'memory_no_forecast',
+        'memory_no_predicted_mean', 'memory_no_predicted_cov',
+        'memory_no_predicted', 'memory_no_filtered_mean',
+        'memory_no_filtered_cov', 'memory_no_filtered',
+        'memory_no_likelihood', 'memory_no_gain',
         'memory_no_smoothing', 'memory_no_std_forecast', 'memory_conserve'
     ]
 
@@ -221,18 +230,84 @@ class KalmanFilter(Representation):
     """
     (bool) Flag for storing all intermediate results in memory (default).
     """
-    memory_no_forecast = OptionWrapper('conserve_memory', MEMORY_NO_FORECAST)
+    memory_no_forecast_mean = OptionWrapper(
+        'conserve_memory', MEMORY_NO_FORECAST_MEAN)
     """
-    (bool) Flag to prevent storing forecasts.
+    (bool) Flag to prevent storing forecasts and forecast errors.
     """
-    memory_no_predicted = OptionWrapper('conserve_memory', MEMORY_NO_PREDICTED)
+    memory_no_forecast_cov = OptionWrapper(
+        'conserve_memory', MEMORY_NO_FORECAST_COV)
     """
-    (bool) Flag to prevent storing predicted state and covariance matrices.
+    (bool) Flag to prevent storing forecast error covariance matrices.
     """
-    memory_no_filtered = OptionWrapper('conserve_memory', MEMORY_NO_FILTERED)
+    @property
+    def memory_no_forecast(self):
+        """
+        (bool) Flag to prevent storing all forecast-related output.
+        """
+        return self.memory_no_forecast_mean or self.memory_no_forecast_cov
+
+    @memory_no_forecast.setter
+    def memory_no_forecast(self, value):
+        if bool(value):
+            self.memory_no_forecast_mean = True
+            self.memory_no_forecast_cov = True
+        else:
+            self.memory_no_forecast_mean = False
+            self.memory_no_forecast_cov = False
+
+    memory_no_predicted_mean = OptionWrapper(
+        'conserve_memory', MEMORY_NO_PREDICTED_MEAN)
+    """
+    (bool) Flag to prevent storing predicted states.
+    """
+    memory_no_predicted_cov = OptionWrapper(
+        'conserve_memory', MEMORY_NO_PREDICTED_COV)
+    """
+    (bool) Flag to prevent storing predicted state covariance matrices.
+    """
+    @property
+    def memory_no_predicted(self):
+        """
+        (bool) Flag to prevent storing predicted state and covariance matrices.
+        """
+        return self.memory_no_predicted_mean or self.memory_no_predicted_cov
+
+    @memory_no_predicted.setter
+    def memory_no_predicted(self, value):
+        if bool(value):
+            self.memory_no_predicted_mean = True
+            self.memory_no_predicted_cov = True
+        else:
+            self.memory_no_predicted_mean = False
+            self.memory_no_predicted_cov = False
+
+    memory_no_filtered_mean = OptionWrapper(
+        'conserve_memory', MEMORY_NO_FILTERED_MEAN)
     """
-    (bool) Flag to prevent storing filtered state and covariance matrices.
+    (bool) Flag to prevent storing filtered states.
     """
+    memory_no_filtered_cov = OptionWrapper(
+        'conserve_memory', MEMORY_NO_FILTERED_COV)
+    """
+    (bool) Flag to prevent storing filtered state covariance matrices.
+    """
+    @property
+    def memory_no_filtered(self):
+        """
+        (bool) Flag to prevent storing filtered state and covariance matrices.
+        """
+        return self.memory_no_filtered_mean or self.memory_no_filtered_cov
+
+    @memory_no_filtered.setter
+    def memory_no_filtered(self, value):
+        if bool(value):
+            self.memory_no_filtered_mean = True
+            self.memory_no_filtered_cov = True
+        else:
+            self.memory_no_filtered_mean = False
+            self.memory_no_filtered_cov = False
+
     memory_no_likelihood = (
         OptionWrapper('conserve_memory', MEMORY_NO_LIKELIHOOD)
     )
@@ -424,15 +499,15 @@ def set_filter_method(self, filter_method=None, **kwargs):
         The filtering method is defined by a collection of boolean flags, and
         is internally stored as a bitmask. The methods available are:
 
-        FILTER_CONVENTIONAL = 0x01
+        FILTER_CONVENTIONAL
             Conventional Kalman filter.
-        FILTER_UNIVARIATE = 0x10
+        FILTER_UNIVARIATE
             Univariate approach to Kalman filtering. Overrides conventional
             method if both are specified.
-        FILTER_COLLAPSED = 0x20
+        FILTER_COLLAPSED
             Collapsed approach to Kalman filtering. Will be used *in addition*
             to conventional or univariate filtering.
-        FILTER_CONCENTRATED = 0x20
+        FILTER_CONCENTRATED
             Use the concentrated log-likelihood function. Will be used
             *in addition* to the other options.
 
@@ -505,19 +580,19 @@ def set_inversion_method(self, inversion_method=None, **kwargs):
         The inversion method is defined by a collection of boolean flags, and
         is internally stored as a bitmask. The methods available are:
 
-        INVERT_UNIVARIATE = 0x01
+        INVERT_UNIVARIATE
             If the endogenous time series is univariate, then inversion can be
             performed by simple division. If this flag is set and the time
             series is univariate, then division will always be used even if
             other flags are also set.
-        SOLVE_LU = 0x02
+        SOLVE_LU
             Use an LU decomposition along with a linear solver (rather than
             ever actually inverting the matrix).
-        INVERT_LU = 0x04
+        INVERT_LU
             Use an LU decomposition along with typical matrix inversion.
-        SOLVE_CHOLESKY = 0x08
+        SOLVE_CHOLESKY
             Use a Cholesky decomposition along with a linear solver.
-        INVERT_CHOLESKY = 0x10
+        INVERT_CHOLESKY
             Use an Cholesky decomposition along with typical matrix inversion.
 
         If the bitmask is set directly via the `inversion_method` argument,
@@ -650,35 +725,46 @@ def set_conserve_memory(self, conserve_memory=None, **kwargs):
         flags, and is internally stored as a bitmask. The methods available
         are:
 
-        MEMORY_STORE_ALL = 0
+        MEMORY_STORE_ALL
             Store all intermediate matrices. This is the default value.
-        MEMORY_NO_FORECAST = 0x01
+        MEMORY_NO_FORECAST_MEAN
+            Do not store the forecast or forecast errors. If this option is
+            used, the `predict` method from the results class is unavailable.
+        MEMORY_NO_FORECAST_COV
+            Do not store the forecast error covariance matrices.
+        MEMORY_NO_FORECAST
             Do not store the forecast, forecast error, or forecast error
             covariance matrices. If this option is used, the `predict` method
             from the results class is unavailable.
-        MEMORY_NO_PREDICTED = 0x02
+        MEMORY_NO_PREDICTED_MEAN
+            Do not store the predicted state.
+        MEMORY_NO_PREDICTED_COV
+            Do not store the predicted state covariance
+            matrices.
+        MEMORY_NO_PREDICTED
             Do not store the predicted state or predicted state covariance
             matrices.
-        MEMORY_NO_FILTERED = 0x04
+        MEMORY_NO_FILTERED_MEAN
+            Do not store the filtered state.
+        MEMORY_NO_FILTERED_COV
+            Do not store the filtered state covariance
+            matrices.
+        MEMORY_NO_FILTERED
             Do not store the filtered state or filtered state covariance
             matrices.
-        MEMORY_NO_LIKELIHOOD = 0x08
+        MEMORY_NO_LIKELIHOOD
             Do not store the vector of loglikelihood values for each
             observation. Only the sum of the loglikelihood values is stored.
-        MEMORY_NO_GAIN = 0x10
+        MEMORY_NO_GAIN
             Do not store the Kalman gain matrices.
-        MEMORY_NO_SMOOTHING = 0x20
+        MEMORY_NO_SMOOTHING
             Do not store temporary variables related to Klaman smoothing. If
             this option is used, smoothing is unavailable.
-        MEMORY_NO_SMOOTHING = 0x20
+        MEMORY_NO_STD_FORECAST
             Do not store standardized forecast errors.
         MEMORY_CONSERVE
             Do not store any intermediate matrices.
 
-        Note that if using a Scipy version less than 0.16, the options
-        MEMORY_NO_GAIN, MEMORY_NO_SMOOTHING, and MEMORY_NO_STD_FORECAST
-        have no effect.
-
         If the bitmask is set directly via the `conserve_memory` argument,
         then the full method must be provided.
 
@@ -879,7 +965,7 @@ def loglike(self, **kwargs):
         kfilter = self._filter(**kwargs)
         loglikelihood_burn = kwargs.get('loglikelihood_burn',
                                         self.loglikelihood_burn)
-        if not self.memory_no_likelihood:
+        if not (kwargs['conserve_memory'] & MEMORY_NO_LIKELIHOOD):
             loglike = np.sum(kfilter.loglikelihood[loglikelihood_burn:])
         else:
             loglike = np.sum(kfilter.loglikelihood)
@@ -896,7 +982,7 @@ def loglike(self, **kwargs):
             # associated with a singular forecast error covariance matrix
             nobs_k_endog -= kfilter.nobs_kendog_univariate_singular
 
-            if not self.memory_no_likelihood:
+            if not (kwargs['conserve_memory'] & MEMORY_NO_LIKELIHOOD):
                 scale = np.sum(kfilter.scale[d:]) / nobs_k_endog
             else:
                 scale = kfilter.scale[0] / nobs_k_endog
@@ -1654,7 +1740,11 @@ def update_filter(self, kalman_filter):
         # Kalman filter implements observations that are either partly or
         # completely missing)
         # Construct the predictions, forecasts
-        if not (self.memory_no_forecast or self.memory_no_predicted):
+        can_compute_mean = not (self.memory_no_forecast_mean or
+                                self.memory_no_predicted_mean)
+        can_compute_cov = not (self.memory_no_forecast_cov or
+                               self.memory_no_predicted_cov)
+        if can_compute_mean or can_compute_cov:
             for t in range(self.nobs):
                 design_t = 0 if self.design.shape[2] == 1 else t
                 obs_cov_t = 0 if self.obs_cov.shape[2] == 1 else t
@@ -1683,39 +1773,45 @@ def update_filter(self, kalman_filter):
                     # missing_forecasts, etc. then provide the forecasts, etc.
                     # provided by the Kalman filter, from which the data can be
                     # retrieved if desired.
-                    self.forecasts[:, t] = np.dot(
-                        self.design[:, :, design_t], self.predicted_state[:, t]
-                    ) + self.obs_intercept[:, obs_intercept_t]
-                    self.forecasts_error[:, t] = np.nan
-                    self.forecasts_error[mask, t] = (
-                        self.endog[mask, t] - self.forecasts[mask, t])
+                    if can_compute_mean:
+                        self.forecasts[:, t] = np.dot(
+                            self.design[:, :, design_t],
+                            self.predicted_state[:, t]
+                        ) + self.obs_intercept[:, obs_intercept_t]
+                        self.forecasts_error[:, t] = np.nan
+                        self.forecasts_error[mask, t] = (
+                            self.endog[mask, t] - self.forecasts[mask, t])
                     # TODO: We should only fill in the non-masked elements of
                     # this array. Also, this will give the multivariate version
                     # even if univariate filtering was selected. Instead, we
                     # should use the reordering methods and then replace the
                     # masked values with NaNs
-                    self.forecasts_error_cov[:, :, t] = np.dot(
-                        np.dot(self.design[:, :, design_t],
-                               self.predicted_state_cov[:, :, t]),
-                        self.design[:, :, design_t].T
-                    ) + self.obs_cov[:, :, obs_cov_t]
+                    if can_compute_cov:
+                        self.forecasts_error_cov[:, :, t] = np.dot(
+                            np.dot(self.design[:, :, design_t],
+                                   self.predicted_state_cov[:, :, t]),
+                            self.design[:, :, design_t].T
+                        ) + self.obs_cov[:, :, obs_cov_t]
                 # In the collapsed case, everything just needs to be rebuilt
                 # for the original observed data, since the Kalman filter
                 # produced these values for the collapsed data.
                 elif self.filter_collapsed:
-                    self.forecasts[:, t] = np.dot(
-                        self.design[:, :, design_t], self.predicted_state[:, t]
-                    ) + self.obs_intercept[:, obs_intercept_t]
-
-                    self.forecasts_error[:, t] = (
-                        self.endog[:, t] - self.forecasts[:, t]
-                    )
+                    if can_compute_mean:
+                        self.forecasts[:, t] = np.dot(
+                            self.design[:, :, design_t],
+                            self.predicted_state[:, t]
+                        ) + self.obs_intercept[:, obs_intercept_t]
+
+                        self.forecasts_error[:, t] = (
+                            self.endog[:, t] - self.forecasts[:, t]
+                        )
 
-                    self.forecasts_error_cov[:, :, t] = np.dot(
-                        np.dot(self.design[:, :, design_t],
-                               self.predicted_state_cov[:, :, t]),
-                        self.design[:, :, design_t].T
-                    ) + self.obs_cov[:, :, obs_cov_t]
+                    if can_compute_cov:
+                        self.forecasts_error_cov[:, :, t] = np.dot(
+                            np.dot(self.design[:, :, design_t],
+                                   self.predicted_state_cov[:, :, t]),
+                            self.design[:, :, design_t].T
+                        ) + self.obs_cov[:, :, obs_cov_t]
 
         # Note: if we concentrated out the scale, need to adjust the
         # loglikelihood values and all of the covariance matrices and the
@@ -2008,13 +2104,19 @@ def predict(self, start=None, end=None, dynamic=None, **kwargs):
             # (use max(., 0), since dynamic can be prior to start)
             nstatic = max(dynamic - start, 0)
 
-        # Cannot do in-sample prediction if we do not have appropriate arrays
-        # (we can do out-of-sample forecasting, however)
-        insample = nstatic > 0 or ndynamic > 0
-        if insample and (self.memory_no_forecast or self.memory_no_predicted):
-            raise ValueError('In-sample prediction is not possible if memory'
+        # Cannot do in-sample prediction if we do not have appropriate
+        # arrays (we can do out-of-sample forecasting, however)
+        if nstatic > 0 and self.memory_no_forecast_mean:
+            raise ValueError('In-sample prediction is not available if memory'
                              ' conservation has been used to avoid storing'
-                             ' forecasts or predicted values.')
+                             ' forecast means.')
+        # Cannot do dynamic in-sample prediction if we do not have appropriate
+        # arrays (we can do out-of-sample forecasting, however)
+        if ndynamic > 0 and self.memory_no_predicted:
+            raise ValueError('In-sample dynamic prediction is not available if'
+                             ' memory conservation has been used to avoid'
+                             ' storing forecasted or predicted state means'
+                             ' or covariances.')
 
         # Construct the design and observation intercept and covariance
         # matrices for start-npadded:end. If not time-varying in the original
@@ -2120,9 +2222,10 @@ def predict(self, start=None, end=None, dynamic=None, **kwargs):
                                  nobs=endog.shape[1], **model_kwargs)
             model.bind(endog)
 
-            # The only valid case in which we have not stored predicted values
-            # is pure out-of-sample forecasting, in which case we want to start
-            # with the last predicted value
+            # Even if we have not stored all predicted values (means and covs),
+            # we can still do pure out-of-sample forecasting because we will
+            # always have stored the last predicted values. In this case, we
+            # will initialize the forecasting filter with these values
             if self.memory_no_predicted:
                 constant = self.predicted_state[..., -1]
                 stationary_cov = self.predicted_state_cov[..., -1]
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
@@ -1920,15 +1920,18 @@ def __init__(self, model, params, results, cov_type=None, cov_kwds=None,
             setattr(self, name, getattr(self.filter_results, name, None))
 
         # Remove too-short results when memory conservation was used
-        if self.filter_results.memory_no_forecast:
+        if self.filter_results.memory_no_forecast_mean:
             self.forecasts = None
             self.forecasts_error = None
+        if self.filter_results.memory_no_forecast_cov:
             self.forecasts_error_cov = None
-        if self.filter_results.memory_no_predicted:
+        if self.filter_results.memory_no_predicted_mean:
             self.predicted_state = None
+        if self.filter_results.memory_no_predicted_cov:
             self.predicted_state_cov = None
-        if self.filter_results.memory_no_filtered:
+        if self.filter_results.memory_no_filtered_mean:
             self.filtered_state = None
+        if self.filter_results.memory_no_filtered_cov:
             self.filtered_state_cov = None
         if self.filter_results.memory_no_gain:
             pass
@@ -1951,13 +1954,19 @@ def __init__(self, model, params, results, cov_type=None, cov_kwds=None,
         # Note: a complication here is that we also include the initial values
         # here, so that we need an extended index in the Pandas case
         if (self.predicted_state is None or
-                self.filter_results.memory_no_predicted):
+                self.filter_results.memory_no_predicted_mean):
             self._states.predicted = None
-            self._states.predicted_cov = None
         elif use_pandas:
             extended_index = self.model._get_index_with_initial_state()
             self._states.predicted = pd.DataFrame(
                 self.predicted_state.T, index=extended_index, columns=columns)
+        else:
+            self._states.predicted = self.predicted_state.T
+        if (self.predicted_state_cov is None or
+                self.filter_results.memory_no_predicted_cov):
+            self._states.predicted_cov = None
+        elif use_pandas:
+            extended_index = self.model._get_index_with_initial_state()
             tmp = np.transpose(self.predicted_state_cov, (2, 0, 1))
             self._states.predicted_cov = pd.DataFrame(
                 np.reshape(tmp, (tmp.shape[0] * tmp.shape[1], tmp.shape[2])),
@@ -1965,42 +1974,48 @@ def __init__(self, model, params, results, cov_type=None, cov_kwds=None,
                     [extended_index, columns]).swaplevel(),
                 columns=columns)
         else:
-            self._states.predicted = self.predicted_state.T
             self._states.predicted_cov = np.transpose(
                 self.predicted_state_cov, (2, 0, 1))
 
         # Filtered states
         if (self.filtered_state is None or
-                self.filter_results.memory_no_filtered):
+                self.filter_results.memory_no_filtered_mean):
             self._states.filtered = None
-            self._states.filtered_cov = None
         elif use_pandas:
             self._states.filtered = pd.DataFrame(
                 self.filtered_state.T, index=index, columns=columns)
+        else:
+            self._states.filtered = self.filtered_state.T
+        if (self.filtered_state_cov is None or
+                self.filter_results.memory_no_filtered_cov):
+            self._states.filtered_cov = None
+        elif use_pandas:
             tmp = np.transpose(self.filtered_state_cov, (2, 0, 1))
             self._states.filtered_cov = pd.DataFrame(
                 np.reshape(tmp, (tmp.shape[0] * tmp.shape[1], tmp.shape[2])),
                 index=pd.MultiIndex.from_product([index, columns]).swaplevel(),
                 columns=columns)
         else:
-            self._states.filtered = self.filtered_state.T
             self._states.filtered_cov = np.transpose(
                 self.filtered_state_cov, (2, 0, 1))
 
         # Smoothed states
         if self.smoothed_state is None:
             self._states.smoothed = None
-            self._states.smoothed_cov = None
         elif use_pandas:
             self._states.smoothed = pd.DataFrame(
                 self.smoothed_state.T, index=index, columns=columns)
+        else:
+            self._states.smoothed = self.smoothed_state.T
+        if self.smoothed_state_cov is None:
+            self._states.smoothed_cov = None
+        elif use_pandas:
             tmp = np.transpose(self.smoothed_state_cov, (2, 0, 1))
             self._states.smoothed_cov = pd.DataFrame(
                 np.reshape(tmp, (tmp.shape[0] * tmp.shape[1], tmp.shape[2])),
                 index=pd.MultiIndex.from_product([index, columns]).swaplevel(),
                 columns=columns)
         else:
-            self._states.smoothed = self.smoothed_state.T
             self._states.smoothed_cov = np.transpose(
                 self.smoothed_state_cov, (2, 0, 1))
 
@@ -3613,7 +3628,7 @@ class PredictionResults(pred.PredictionResults):
     """
     def __init__(self, model, prediction_results, row_labels=None):
         if model.model.k_endog == 1:
-            endog = pd.Series(prediction_results.endog[:, 0],
+            endog = pd.Series(prediction_results.endog[0],
                               name=model.model.endog_names)
         else:
             endog = pd.DataFrame(prediction_results.endog.T,
@@ -3623,13 +3638,22 @@ def __init__(self, model, prediction_results, row_labels=None):
         self.prediction_results = prediction_results
 
         # Get required values
-        predicted_mean = self.prediction_results.forecasts
+        k_endog, nobs = prediction_results.endog.shape
+        if not prediction_results.results.memory_no_forecast_mean:
+            predicted_mean = self.prediction_results.forecasts
+        else:
+            predicted_mean = np.zeros((k_endog, nobs)) * np.nan
+
         if predicted_mean.shape[0] == 1:
             predicted_mean = predicted_mean[0, :]
         else:
             predicted_mean = predicted_mean.transpose()
 
-        var_pred_mean = self.prediction_results.forecasts_error_cov
+        if not prediction_results.results.memory_no_forecast_cov:
+            var_pred_mean = self.prediction_results.forecasts_error_cov
+        else:
+            var_pred_mean = np.zeros((k_endog, k_endog, nobs)) * np.nan
+
         if var_pred_mean.shape[0] == 1:
             var_pred_mean = var_pred_mean[0, 0, :]
         else:


Test patch:
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
@@ -194,7 +194,6 @@ def test_low_memory():
 
     # Check that low memory was actually used (just check a couple)
     assert_(res2.llf_obs is None)
-    assert_(res2.forecasts is None)
     assert_(res2.predicted_state is None)
     assert_(res2.filtered_state is None)
     assert_(res2.smoothed_state is None)
diff --git a/statsmodels/tsa/statespace/tests/test_conserve_memory.py b/statsmodels/tsa/statespace/tests/test_conserve_memory.py
--- a/statsmodels/tsa/statespace/tests/test_conserve_memory.py
+++ b/statsmodels/tsa/statespace/tests/test_conserve_memory.py
@@ -16,7 +16,9 @@
 from statsmodels.tsa.statespace import (
     sarimax, varmax, dynamic_factor)
 from statsmodels.tsa.statespace.kalman_filter import (
-    MEMORY_NO_GAIN, MEMORY_CONSERVE)
+    MEMORY_NO_FORECAST_MEAN, MEMORY_NO_FORECAST_COV,
+    MEMORY_NO_PREDICTED_MEAN, MEMORY_NO_PREDICTED_COV, MEMORY_NO_PREDICTED,
+    MEMORY_NO_SMOOTHING, MEMORY_NO_GAIN, MEMORY_CONSERVE)
 
 dta = macrodata.load_pandas().data
 dta.index = pd.date_range(start='1959-01-01', end='2009-07-01', freq='QS')
@@ -159,7 +161,7 @@ def test_fit():
     res = mod.fit(disp=False)
 
     options_smooth = [
-        'memory_no_forecast',  'memory_no_filtered', 'memory_no_likelihood',
+        'memory_no_forecast', 'memory_no_filtered', 'memory_no_likelihood',
         'memory_no_std_forecast']
     for option in options_smooth:
         mod.ssm.set_conserve_memory(0)
@@ -211,8 +213,9 @@ def test_fit():
 
         # Specific checks for each type
         if option in ['memory_no_predicted', 'memory_conserve']:
-            assert_(res2.predicted_state is None)
             assert_(res2.predicted_state_cov is None)
+            if option == 'memory_no_predicted':
+                assert_(res2.predicted_state is None)
         else:
             assert_allclose(res2.predicted_state, res.predicted_state)
             assert_allclose(res2.predicted_state_cov, res.predicted_state_cov)
@@ -244,3 +247,110 @@ def test_low_memory_fit():
     assert_equal(res.filter_results.conserve_memory, MEMORY_CONSERVE)
     assert_(res.llf_obs is None)
     assert_equal(mod.ssm.conserve_memory, MEMORY_NO_GAIN)
+
+
+@pytest.mark.parametrize("conserve_memory", [
+    MEMORY_CONSERVE, MEMORY_NO_FORECAST_COV])
+def test_fittedvalues_resid_predict(conserve_memory):
+    # Basic test that as long as MEMORY_NO_FORECAST_MEAN is not set, we should
+    # be able to use fittedvalues, resid, predict() with dynamic=False and
+    # forecast
+    endog = dta['infl'].iloc[:20]
+    mod1 = sarimax.SARIMAX(endog, order=(1, 0, 0), concentrate_scale=True)
+    mod2 = sarimax.SARIMAX(endog, order=(1, 0, 0), concentrate_scale=True)
+
+    mod1.ssm.set_conserve_memory(conserve_memory)
+    assert_equal(mod1.ssm.conserve_memory, conserve_memory)
+    assert_equal(mod2.ssm.conserve_memory, 0)
+
+    res1 = mod1.filter([0])
+    res2 = mod2.filter([0])
+    assert_equal(res1.filter_results.conserve_memory,
+                 conserve_memory | MEMORY_NO_SMOOTHING)
+    assert_equal(res2.filter_results.conserve_memory, MEMORY_NO_SMOOTHING)
+
+    # Test output against known values
+    assert_allclose(res1.fittedvalues, 0)
+    assert_allclose(res1.predict(), 0)
+    assert_allclose(res1.predict(start=endog.index[10]), np.zeros(10))
+    assert_allclose(res1.resid, endog)
+    assert_allclose(res1.forecast(3), np.zeros(3))
+
+    # Test output against results without memory conservation
+    assert_allclose(res1.fittedvalues, res2.fittedvalues)
+    assert_allclose(res1.predict(), res2.predict())
+    assert_allclose(res1.predict(start=endog.index[10]),
+                    res2.predict(start=endog.index[10]))
+    assert_allclose(res1.resid, res2.resid)
+    assert_allclose(res1.forecast(3), res2.forecast(3))
+
+    assert_allclose(res1.test_normality('jarquebera'),
+                    res2.test_normality('jarquebera'))
+    assert_allclose(res1.test_heteroskedasticity('breakvar'),
+                    res2.test_heteroskedasticity('breakvar'))
+    assert_allclose(res1.test_serial_correlation('ljungbox'),
+                    res2.test_serial_correlation('ljungbox'))
+
+
+def test_get_prediction_memory_conserve():
+    endog = dta['infl'].iloc[:20]
+    mod1 = sarimax.SARIMAX(endog, order=(1, 0, 0), concentrate_scale=True)
+    mod2 = sarimax.SARIMAX(endog, order=(1, 0, 0), concentrate_scale=True)
+
+    mod1.ssm.set_conserve_memory(MEMORY_CONSERVE)
+    assert_equal(mod1.ssm.conserve_memory, MEMORY_CONSERVE)
+    assert_equal(mod2.ssm.conserve_memory, 0)
+
+    res1 = mod1.filter([0])
+    res2 = mod2.filter([0])
+    assert_equal(res1.filter_results.conserve_memory, MEMORY_CONSERVE)
+    assert_equal(res2.filter_results.conserve_memory, MEMORY_NO_SMOOTHING)
+
+    p1 = res1.get_prediction()
+    p2 = res2.get_prediction()
+
+    assert_allclose(p1.predicted_mean, p2.predicted_mean)
+    assert_allclose(p1.se_mean, np.nan)
+    assert_allclose(p1.conf_int(), np.nan)
+
+    s1 = p1.summary_frame()
+    s2 = p2.summary_frame()
+
+    assert_allclose(s1['mean'], s2['mean'])
+    assert_allclose(s1.mean_se, np.nan)
+    assert_allclose(s1.mean_ci_lower, np.nan)
+    assert_allclose(s1.mean_ci_upper, np.nan)
+
+
+def test_invalid_fittedvalues_resid_predict():
+    endog = dta['infl'].iloc[:20]
+    mod = sarimax.SARIMAX(endog, order=(1, 0, 0), concentrate_scale=True)
+
+    # Check that we can't do any prediction without forecast means
+    res = mod.filter([0], conserve_memory=MEMORY_NO_FORECAST_MEAN)
+    assert_equal(res.filter_results.conserve_memory,
+                 MEMORY_NO_FORECAST_MEAN)
+
+    message = ('In-sample prediction is not available if memory conservation'
+               ' has been used to avoid storing forecast means.')
+    with pytest.raises(ValueError, match=message):
+        res.predict()
+    with pytest.raises(ValueError, match=message):
+        res.get_prediction()
+
+    # Check that we can't do dynamic prediction without predicted means,
+    # predicted covs
+    options = [
+        MEMORY_NO_PREDICTED_MEAN, MEMORY_NO_PREDICTED_COV, MEMORY_NO_PREDICTED]
+    for option in options:
+        res = mod.filter([0], conserve_memory=option)
+        assert_equal(res.filter_results.conserve_memory, option)
+
+        message = ('In-sample dynamic prediction is not available if'
+                   ' memory conservation has been used to avoid'
+                   ' storing forecasted or predicted state means'
+                   ' or covariances.')
+        with pytest.raises(ValueError, match=message):
+            res.predict(dynamic=True)
+        with pytest.raises(ValueError, match=message):
+            res.predict(start=endog.index[10], dynamic=True)
diff --git a/statsmodels/tsa/statespace/tests/test_kalman.py b/statsmodels/tsa/statespace/tests/test_kalman.py
--- a/statsmodels/tsa/statespace/tests/test_kalman.py
+++ b/statsmodels/tsa/statespace/tests/test_kalman.py
@@ -26,6 +26,8 @@
 
 from scipy.linalg.blas import find_best_blas_type
 from scipy.linalg import solve_discrete_lyapunov
+from statsmodels.tsa.statespace.kalman_filter import (
+    MEMORY_NO_FORECAST, MEMORY_NO_PREDICTED, MEMORY_CONSERVE)
 from statsmodels.tsa.statespace.mlemodel import MLEModel
 from statsmodels.tsa.statespace import _representation, _kalman_filter
 from .results import results_kalman_filter
@@ -316,7 +318,8 @@ class TestClark1987Conserve(Clark1987):
     @classmethod
     def setup_class(cls):
         super(TestClark1987Conserve, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02
+            dtype=float,
+            conserve_memory=MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED
         )
         cls.model, cls.filter = cls.init_filter()
         cls.result = cls.run_filter()
@@ -386,7 +389,8 @@ class TestClark1987ForecastConserve(Clark1987Forecast):
     @classmethod
     def setup_class(cls):
         super(TestClark1987ForecastConserve, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02
+            dtype=float,
+            conserve_memory=MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED
         )
         cls.model, cls.filter = cls.init_filter()
         cls.result = cls.run_filter()
@@ -400,7 +404,7 @@ class TestClark1987ConserveAll(Clark1987):
     @classmethod
     def setup_class(cls):
         super(TestClark1987ConserveAll, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02 | 0x04 | 0x08
+            dtype=float, conserve_memory=MEMORY_CONSERVE
         )
         cls.loglikelihood_burn = cls.true['start']
         cls.model, cls.filter = cls.init_filter()
@@ -596,7 +600,8 @@ class TestClark1989Conserve(Clark1989):
     @classmethod
     def setup_class(cls):
         super(TestClark1989Conserve, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02
+            dtype=float,
+            conserve_memory=MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED
         )
         cls.model, cls.filter = cls.init_filter()
         cls.result = cls.run_filter()
@@ -677,7 +682,8 @@ class TestClark1989ForecastConserve(Clark1989Forecast):
     @classmethod
     def setup_class(cls):
         super(TestClark1989ForecastConserve, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02
+            dtype=float,
+            conserve_memory=MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED
         )
         cls.model, cls.filter = cls.init_filter()
         cls.result = cls.run_filter()
@@ -691,7 +697,7 @@ class TestClark1989ConserveAll(Clark1989):
     @classmethod
     def setup_class(cls):
         super(TestClark1989ConserveAll, cls).setup_class(
-            dtype=float, conserve_memory=0x01 | 0x02 | 0x04 | 0x08,
+            dtype=float, conserve_memory=MEMORY_CONSERVE,
         )
         # cls.loglikelihood_burn = cls.true['start']
         cls.loglikelihood_burn = 0
diff --git a/statsmodels/tsa/statespace/tests/test_options.py b/statsmodels/tsa/statespace/tests/test_options.py
--- a/statsmodels/tsa/statespace/tests/test_options.py
+++ b/statsmodels/tsa/statespace/tests/test_options.py
@@ -206,10 +206,10 @@ def test_conserve_memory(self):
         assert_equal(
             model.conserve_memory,
             MEMORY_NO_FORECAST | MEMORY_NO_PREDICTED | MEMORY_NO_FILTERED |
-            MEMORY_NO_LIKELIHOOD | MEMORY_NO_GAIN | MEMORY_NO_SMOOTHING |
-            MEMORY_NO_STD_FORECAST
+            MEMORY_NO_LIKELIHOOD | MEMORY_NO_GAIN |
+            MEMORY_NO_SMOOTHING | MEMORY_NO_STD_FORECAST
         )
-        assert_equal(model.conserve_memory, MEMORY_CONSERVE)
+        assert_equal(model.conserve_memory & MEMORY_CONSERVE, MEMORY_CONSERVE)
         for name in model.memory_options:
             if name == 'memory_conserve':
                 continue
diff --git a/statsmodels/tsa/statespace/tests/test_prediction.py b/statsmodels/tsa/statespace/tests/test_prediction.py
--- a/statsmodels/tsa/statespace/tests/test_prediction.py
+++ b/statsmodels/tsa/statespace/tests/test_prediction.py
@@ -73,9 +73,9 @@ def test_memory_no_predicted():
     assert_equal(res1.predicted_state_cov.shape, (1, 1, 5))
     assert_(res2.predicted_state_cov is None)
 
-    # Check that we can't do in-sample prediction
-    assert_raises(ValueError, res2.predict)
-    assert_raises(ValueError, res2.get_prediction)
+    # Check that we can't do dynamic in-sample prediction
+    assert_raises(ValueError, res2.predict, dynamic=True)
+    assert_raises(ValueError, res2.get_prediction, dynamic=True)
 
     # Make sure the point forecasts are the same
     assert_allclose(res1.forecast(10), res2.forecast(10))
diff --git a/statsmodels/tsa/statespace/tests/test_representation.py b/statsmodels/tsa/statespace/tests/test_representation.py
--- a/statsmodels/tsa/statespace/tests/test_representation.py
+++ b/statsmodels/tsa/statespace/tests/test_representation.py
@@ -876,7 +876,8 @@ def test_predict():
     mod['selection', :] = 1
     mod['state_cov', :] = 1
 
-    # Check that we need both forecasts and predicted output for prediction
+    # Check that we need both forecasts and predicted output for dynamic
+    # prediction
     mod.memory_no_forecast = True
     res = mod.filter()
     assert_raises(ValueError, res.predict)
@@ -884,7 +885,7 @@ def test_predict():
 
     mod.memory_no_predicted = True
     res = mod.filter()
-    assert_raises(ValueError, res.predict)
+    assert_raises(ValueError, res.predict, dynamic=True)
     mod.memory_no_predicted = False
 
     # Now get a clean filter object