Repository: sktime/sktime

Issue:
[ENH] Improve space and time performance during `transform` when using `PeriodIndex` for time related transformers
**Is your feature request related to a problem? Please describe.**
The `DateTimeFeatures` and `TimeSince` transformer are a lot slower and takes up a lot more memory than expected when working with pandas Periods. I think this is due to some of the type checking happening in the `transform` method rather than the implementation in the `_transform` method. On large datasets (e.g., M5 dataset) my kernel surprisingly dies when using `DateTimeFeatures`. 

Here is an example to show the issue:
 ```python
import pandas as pd
import numpy as np
from sktime.transformations.series.date import DateTimeFeatures
from sktime.transformations.series.time_since import TimeSince

# Create time series with datetime index
ts_datetime = pd.DataFrame(data={"y":np.random.rand(50000)},
                        index=pd.date_range(start="2000-01-01", freq="D", periods=50000))

# Create time series with period index
ts_period = pd.DataFrame(data={"y":np.random.rand(50000)},
                        index=pd.period_range(start="2000-01-01", freq="D", periods=50000))

# Instantiate transformers
date_time_transformer = DateTimeFeatures(manual_selection=[
    "day_of_month",
    "month_of_year",
    "day_of_week",
])

time_since_transformer = TimeSince()

# Select timeseries with datetime or period index
ts = ts_period

# Fit transformers
date_time_transformer.fit(ts)
time_since_transformer.fit(ts)

# Time the runs
%%timeit
results = time_since_transformer.transform(ts)
# 3.66 s ± 199 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%%timeit
results = time_since_transformer._transform(ts)
# 184 ms ± 4.48 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%%timeit
result = date_time_transformer.transform(ts)
# 3.41 s ± 131 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%%timeit
result = date_time_transformer._transform(ts)
# 24 ms ± 9.31 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

The time difference between running `transform` and `_transform` when the time series has a `PeriodIndex` are large (e.g., for `DateTimeFeatures` there is over a factor of 100 in the execution time).  

This issue does not seem to occur when the index is a `DatetimeIndex`. 

**Version**
System:
    python: 3.8.7 (default, May  6 2021, 21:53:45)  [Clang 12.0.0 (clang-1200.0.32.29)]
executable: /Users/kishan_manani/.pyenv/versions/3.8.7/envs/udemy-ts/bin/python3.8
   machine: macOS-11.6.6-x86_64-i386-64bit

Python dependencies:
          pip: 22.3.1
   setuptools: 49.2.1
      sklearn: 1.1.3
       sktime: 0.15.0
  statsmodels: 0.13.2
        numpy: 1.21.6
        scipy: 1.8.1
       pandas: 1.4.4
   matplotlib: 3.6.2
       joblib: 1.2.0
        numba: 0.56.4
     pmdarima: 1.8.5
      tsfresh: 0.19.0


Discussion:
@KishManani, yes, this has already been noticed by @danbartl here: https://github.com/sktime/sktime/issues/3827
It seems to be the "equally spaced" check.

PR with attempted fix here: https://github.com/sktime/sktime/pull/3935
may be great to have another pair of eyes as it does not seem to work for me?
more detail: as @danbartl has identified, the additional runtime indeed comes from the check on equidistand indices, more precisely from two calls to `np.diff` on `ts.index`.

With this example, I should be able to figure out an alternative way to speed up the check.

Resolving patch:
diff --git a/sktime/datatypes/_series/_check.py b/sktime/datatypes/_series/_check.py
--- a/sktime/datatypes/_series/_check.py
+++ b/sktime/datatypes/_series/_check.py
@@ -225,14 +225,36 @@ def _index_equally_spaced(index):
     if not is_in_valid_index_types(index):
         raise TypeError(f"index must be one of {VALID_INDEX_TYPES} or integer index")
 
-    # empty and single element indices are equally spaced
-    if len(index) < 2:
+    # empty, single and two-element indices are equally spaced
+    if len(index) < 3:
         return True
 
     # RangeIndex is always equally spaced
     if isinstance(index, pd.RangeIndex):
         return True
 
+    # we now treat a necessary condition for being equally spaced:
+    # the first two spaces are equal. From now on, we know this.
+    if index[1] - index[0] != index[2] - index[1]:
+        return False
+
+    # another necessary condition for equally spaced:
+    # index span is number of spaces times first space
+    n = len(index)
+    if index[n - 1] - index[0] != (n - 1) * (index[1] - index[0]):
+        return False
+
+    # if we arrive at this stage, and the index is PeriodIndex,
+    # we know it must be equally spaced:
+    # it cannot have duplicates and must be sorted (other conditions for mtype),
+    # therefore, by the pigeonhole principle, the
+    # two necessary conditions we checked are also sufficient
+    if isinstance(index, pd.PeriodIndex):
+        return True
+
+    # fallback for all other cases:
+    # in general, we need to compute all differences and check explicitly
+    # CAVEAT: this has a comparabily long runtime and high memory usage
     diffs = np.diff(index)
     all_equal = np.all(diffs == diffs[0])
 


Test patch: