Repository: alan-turing-institute/sktime
Pull request #3467 (2022-09-22 20:51:15)

[MNT] update `test_interpolate` to be `pandas 1.5.0` compatible
This updates `test_interpolate` to be `pandas 1.5.0` compatible.

Apparently, it is no longer possible to assign `pd.Series` to fields of `pandas.DataFrame` via `iloc` or `at`.

Assignment via `iloc` breaks, and `at` now seems to result in `numpy` rather than `pd.Series`. Bug or not, this is fixed by accessing `values` directly.


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 784131933..64559721a 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1779,7 +1779,23 @@
         "doc",
       ]
     },
-
+    {
+      "login": "jasmineliaw",
+      "name": "Jasmine Liaw",
+      "profile": "https://github.com/jasmineliaw",
+      "contributions": [
+        "code"
+      ]
+    },
+    {
+      "login": "topher-lo",
+      "name": "Christopher Lo",
+      "profile": "https://github.com/topher-lo",
+      "contributions": [
+        "code",
+        "ideas"
+      ]
+    },
   ],
   "projectName": "sktime",
   "projectOwner": "alan-turing-institute",
diff --git a/sktime/forecasting/naive.py b/sktime/forecasting/naive.py
index 5b420649a..8e6717d0b 100644
--- a/sktime/forecasting/naive.py
+++ b/sktime/forecasting/naive.py
@@ -48,6 +48,16 @@ class NaiveForecaster(_BaseWindowForecaster):
       - "mean": np.nanmean over rows
     - tile the predictions using the seasonal periodicity
 
+    To compute prediction quantiles, we first estimate the standard error
+    of prediction residuals under the assumption of uncorrelated residuals.
+    The forecast variance is then computed by multiplying the residual
+    variance by a constant. This constant is a small-sample bias adjustment
+    and each method (mean, last, drift) have different formulas for computing
+    the constant. These formulas can be found in the Forecasting:
+    Principles and Practice textbook (Table 5.2) [1]_. Lastly, under the assumption that
+    residuals follow a normal distribution, we use the forecast variance and
+    z-scores of a normal distribution to estimate the prediction quantiles.
+
     Parameters
     ----------
     strategy : {"last", "mean", "drift"}, default="last"
@@ -78,6 +88,12 @@ class NaiveForecaster(_BaseWindowForecaster):
         Window length to use in the `mean` strategy. If None, entire training
             series will be used.
 
+    References
+    ----------
+    .. [1] Hyndman, R.J., & Athanasopoulos, G. (2021) Forecasting:
+        principles and practice, 3rd edition, OTexts: Melbourne, Australia.
+        OTexts.com/fpp3. Accessed on 22 September 2022.
+
     Examples
     --------
     >>> from sktime.datasets import load_airline
@@ -94,6 +110,8 @@ class NaiveForecaster(_BaseWindowForecaster):
         "requires-fh-in-fit": False,
         "handles-missing-data": True,
         "scitype:y": "univariate",
+        "capability:pred_var": True,
+        "capability:pred_int": True,
     }
 
     def __init__(self, strategy="last", window_length=None, sp=1):
@@ -330,6 +348,134 @@ class NaiveForecaster(_BaseWindowForecaster):
 
         return y_pred
 
+    def _predict_quantiles(self, fh, X=None, alpha=0.5):
+        """Compute/return prediction quantiles for a forecast.
+
+        Uses normal distribution as predictive distribution to compute the
+        quantiles.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous variables are ignored.
+        alpha : float or list of float, optional (default=0.5)
+            A probability or list of, at which quantile forecasts are computed.
+
+        Returns
+        -------
+        pred_quantiles : pd.DataFrame
+            Column has multi-index: first level is variable name from y in fit,
+                second level being the quantile forecasts for each alpha.
+                Quantile forecasts are calculated for each a in alpha.
+            Row index is fh. Entries are quantile forecasts, for var in col index,
+                at quantile probability in second-level col index, for each row index.
+        """
+        y_pred = self.predict(fh)
+        y_pred = convert(y_pred, from_type=self._y_mtype_last_seen, to_type="pd.Series")
+
+        pred_var = self.predict_var(fh)
+        z_scores = norm.ppf(alpha)
+
+        errors = (
+            np.sqrt(pred_var.to_numpy().reshape(len(pred_var), 1)) * z_scores
+        ).reshape(len(y_pred), len(alpha))
+
+        pred_quantiles = pd.DataFrame(
+            errors + y_pred.values.reshape(len(y_pred), 1),
+            columns=pd.MultiIndex.from_product([["Quantiles"], alpha]),
+            index=fh.to_absolute(self.cutoff).to_pandas(),
+        )
+
+        return pred_quantiles
+
+    def _predict_var(self, fh, X=None, cov=False):
+        """Compute/return prediction variance for naive forecasts.
+
+        Variance are computed according to formulas from (Table 5.2)
+        Forecasting: Principles and Practice textbook [1]_.
+
+        Must be run *after* the forecaster has been fitted.
+
+        Parameters
+        ----------
+        fh : int, list, np.array or ForecastingHorizon
+            Forecasting horizon
+        X : pd.DataFrame, optional (default=None)
+            Exogenous variables are ignored.
+        cov : bool, optional (default=False)
+            If True, return the covariance matrix.
+            If False, return the marginal variance.
+
+        Returns
+        -------
+        pred_var :
+            if cov=False, pd.DataFrame with index fh.
+                a vector of same length as fh with predictive marginal variances;
+            if cov=True, pd.DataFrame with index fh and columns fh.
+                a square matrix of size len(fh) with predictive covariance matrix.
+
+        References
+        ----------
+        .. [1] https://otexts.com/fpp3/prediction-intervals.html#benchmark-methods
+        """
+        y = self._y
+        y = convert_to(y, "pd.Series")
+        T = len(y)
+
+        # Compute "past" residuals
+        if self.strategy == "last":
+            y_res = y - y.shift(self.sp)
+        elif self.strategy == "mean":
+            # Since this strategy returns a constant, just predict fh=1 and
+            # transform the constant into a repeated array
+            y_pred = np.repeat(np.squeeze(self.predict(fh=1)), T)
+            y_res = y - y_pred
+        else:
+            # Slope equation from:
+            # https://otexts.com/fpp3/simple-methods.html#drift-method
+            slope = (y.iloc[-1] - y.iloc[0]) / (T - 1)
+
+            # Fitted value = previous value + slope
+            # https://github.com/robjhyndman/forecast/blob/master/R/naive.R#L34
+            y_res = y - (y.shift(self.sp) + slope)
+
+        # Residuals MSE and SE
+        # + 1 degrees of freedom to estimate drift coefficient standard error
+        # https://github.com/robjhyndman/forecast/blob/master/R/naive.R#L79
+        n_nans = np.sum(pd.isna(y_res))
+        mse_res = np.sum(np.square(y_res)) / (T - n_nans - (self.strategy == "drift"))
+        se_res = np.sqrt(mse_res)
+
+        sp = self.sp
+        window_length = self.window_length or T
+        # Formulas from:
+        # https://otexts.com/fpp3/prediction-intervals.html (Table 5.2)
+        partial_se_formulas = {
+            "last": lambda h: np.sqrt(h)
+            if sp == 1
+            else np.sqrt(np.floor((h - 1) / sp) + 1),
+            "mean": lambda h: np.repeat(np.sqrt(1 + (1 / window_length)), len(h)),
+            "drift": lambda h: np.sqrt(h * (1 + (h / (T - 1)))),
+        }
+
+        fh_periods = np.array(fh.to_relative(self.cutoff))
+        marginal_se = se_res * partial_se_formulas[self.strategy](fh_periods)
+        marginal_vars = marginal_se**2
+
+        fh_idx = fh.to_absolute(self.cutoff).to_pandas()
+        if cov:
+            fh_size = len(fh)
+            cov_matrix = np.fill_diagonal(
+                np.zeros(shape=(fh_size, fh_size)), marginal_vars
+            )
+            pred_var = pd.DataFrame(cov_matrix, columns=fh_idx, index=fh_idx)
+        else:
+            pred_var = pd.DataFrame(marginal_vars, index=fh_idx)
+
+        return pred_var
+
     @classmethod
     def get_test_params(cls, parameter_set="default"):
         """Return testing parameter settings for the estimator.


Test patch:
diff --git a/sktime/forecasting/tests/test_naive.py b/sktime/forecasting/tests/test_naive.py
index 1a648cb10..1a4aba27c 100644
--- a/sktime/forecasting/tests/test_naive.py
+++ b/sktime/forecasting/tests/test_naive.py
@@ -257,3 +257,118 @@ def test_strategy_mean_and_last_seasonal_additional_combinations(
         for i in range(1 + len(test_data) // sp):
             test_data[i * sp : i * sp + sp - window_length] = np.nan
         pd.testing.assert_series_equal(forecast_data, test_data)
+
+
+@pytest.mark.parametrize(
+    "strategy,sp,window_length",
+    [
+        ("last", 1, None),
+        ("last", 24, None),
+        ("mean", 1, None),
+        ("mean", 24, None),
+        ("mean", 1, 24),
+        ("mean", 24, 24),
+        ("drift", 1, None),
+    ],
+)
+def test_naive_predict_var_backwards(strategy, sp, window_length):
+    """Checks naive prediction variance computations.
+
+    Test whether, given h=1 and large T, the forecast standard error
+    is approximately equal to the residual standard errors.
+    This property is noted in the Forecasting: Principles and
+    Practice textbook (FPP3) [1]_.
+
+    More specifically, predict_var computes the forecast standard errors
+    (and hence variance) from the residuals standard errors times
+    some constant. According to FPP3, this operation can be fully inverted.
+    Hence, for this unit test, we redo our computations backwards and
+    check that our results are approximately equal.
+
+    References
+    ----------
+    .. [1] https://otexts.com/fpp3/prediction-intervals.html#benchmark-methods
+    """
+    n_timepoints = 100000
+    mu, sigma = 0.0, 10.0
+    fake_idx = pd.date_range("1980", periods=n_timepoints + 1, freq="H")
+    np.random.seed(42)
+    y = pd.Series(np.random.normal(mu, sigma, size=n_timepoints), index=fake_idx[:-1])
+
+    h = 1
+    forecaster = NaiveForecaster(strategy, sp=sp, window_length=window_length)
+    sigma2 = forecaster.fit(y).predict_var(fh=h)
+    sigma = np.sqrt(sigma2).iloc[0, 0]
+
+    T = len(y.dropna())
+    if strategy == "last":
+        # This is trival because square root of (h) when h=1 is just 1
+        sigma_res = sigma / np.sqrt(h)
+    elif strategy == "mean":
+        sigma_res = sigma / np.sqrt(1 + (1 / T))
+    else:
+        sigma_res = sigma / np.sqrt(1 + (1 / (T - 1)))
+
+    upper_bound, lower_bound = (sigma + 0.001), (sigma - 0.001)
+    assert lower_bound < sigma_res < upper_bound
+
+
+@pytest.mark.parametrize(
+    "strategy,sp,lower,upper",
+    [
+        (
+            "last",
+            1,
+            [-22.07661, -31.71836, -39.11673, -45.35385, -50.84886],
+            [24.47787, 34.11962, 41.51799, 47.75511, 53.25012],
+        ),
+        (
+            "last",
+            24,
+            [-45.45926, -18.718647, -36.369212, -12.929395, -13.960794],
+            [1.025702, 27.766313, 10.115747, 33.555565, 32.524165],
+        ),
+        ("mean", 1, -16.45385, 16.47319),
+        (
+            "drift",
+            1,
+            [-22.07676, -31.71876, -39.11745, -45.35493, -50.85035],
+            [24.47795, 34.11987, 41.51848, 47.75589, 53.25123],
+        ),
+    ],
+)
+def test_naive_predict_interval_against_R_naive(strategy, sp, lower, upper):
+    """Checks naive prediction interval computations.
+
+    Compare prediction interval results with R implementation in [1]_.
+
+    Note:
+    - Seasonality is not applicable to "mean" strategy in R.
+    - Argument "window_length" is not available in R.
+
+    References
+    ----------
+    .. [1] https://github.com/robjhyndman/forecast/blob/master/R/naive.R
+    """
+    n_timepoints = 100000
+    mu, sigma = 0.0, 10.0
+    fake_idx = pd.date_range("1980", periods=n_timepoints + 1, freq="H")
+    np.random.seed(42)
+    y = pd.Series(np.random.normal(mu, sigma, size=n_timepoints), index=fake_idx[:-1])
+
+    h = list(range(1, 6))
+    coverage = 0.90
+    forecaster = NaiveForecaster(strategy, sp=sp)
+    y_pred_ints = forecaster.fit(y).predict_interval(fh=h, coverage=coverage)
+
+    expected = pd.DataFrame(
+        columns=pd.MultiIndex.from_product(
+            [["Coverage"], [coverage], ["lower", "upper"]]
+        ),
+        index=y_pred_ints.index,
+    )
+
+    expected[("Coverage", coverage, "lower")] = lower
+    expected[("Coverage", coverage, "upper")] = upper
+
+    pd.testing.assert_frame_equal(y_pred_ints, expected)
diff --git a/sktime/transformations/panel/tests/test_interpolate.py b/sktime/transformations/panel/tests/test_interpolate.py
index 1639ed516..70a091b19 100644
--- a/sktime/transformations/panel/tests/test_interpolate.py
+++ b/sktime/transformations/panel/tests/test_interpolate.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+"""Tests for TSInterpolator."""
 import pandas as pd
 
 from sktime.datasets import load_basic_motions
@@ -6,19 +7,22 @@ from sktime.transformations.panel.interpolate import TSInterpolator
 
 
 def cut_X_ts(X):
+    """Cut X values to different lengths across variables."""
     for row_i in range(X.shape[0]):
         for dim_i in range(X.shape[1]):
-            ts = X.at[row_i, f"dim_{dim_i}"]
-            X.at[row_i, f"dim_{dim_i}"] = pd.Series(ts.tolist()[: len(ts) - dim_i - 1])
+            ts = X.iloc[row_i, dim_i]
+            X.values[row_i][dim_i] = pd.Series(ts.tolist()[: len(ts) - dim_i - 1])
 
 
 def test_resizing():
-    # 1) all lengths are equal
-    # 2) cut lengths and check that they are really different
-    # 3) use transformer for resizing to resize time series to equal length
-    # 4) check that result length are equal to length that was set for
-    #       transformer
-
+    """Test TSInterpolator resizing.
+
+    Test that:
+    1) all lengths are equal
+    2) cut lengths and check that they are really different
+    3) use transformer for resizing to resize time series to equal length
+    4) result lengths are equal to length that was set for transformer
+    """
     X, _ = load_basic_motions(split="train", return_X_y=True)
 
     # 1) Check that lengths of all time series (all via the axis=1 - for