Repository: alteryx/evalml
Pull request #3741 (2022-10-04 14:57:38)

STL Decomposer
Decompose things...STL'ly!

chukarsten: praise god

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index d5c1e07e..3184f0bc 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -16,6 +16,7 @@ Release Notes
 **v0.60.0 Oct. 19, 2022**
     * Enhancements
         * Add forecast functions to time series regression pipeline :pr:`3742`
+        * Added the STL Decomposer :pr:`3741`
     * Fixes
         * Fix to allow ``IDColumnsDataCheck`` to work with ``IntegerNullable`` inputs :pr:`3740`
         * Fixed datasets name for main performance tests :pr:`3743`
diff --git a/evalml/pipelines/components/__init__.py b/evalml/pipelines/components/__init__.py
index 66225776..be8dbbc0 100644
--- a/evalml/pipelines/components/__init__.py
+++ b/evalml/pipelines/components/__init__.py
@@ -57,6 +57,7 @@ from evalml.pipelines.components.transformers import (
     Undersampler,
     TargetImputer,
     PolynomialDecomposer,
+    STLDecomposer,
     Oversampler,
     LogTransformer,
     EmailFeaturizer,
diff --git a/evalml/pipelines/components/transformers/__init__.py b/evalml/pipelines/components/transformers/__init__.py
index 8f975507..57bd29ca 100644
--- a/evalml/pipelines/components/transformers/__init__.py
+++ b/evalml/pipelines/components/transformers/__init__.py
@@ -40,6 +40,7 @@ from evalml.pipelines.components.transformers.preprocessing import (
     TimeSeriesFeaturizer,
     DFSTransformer,
     PolynomialDecomposer,
+    STLDecomposer,
     LogTransformer,
     EmailFeaturizer,
     URLFeaturizer,
diff --git a/evalml/pipelines/components/transformers/preprocessing/__init__.py b/evalml/pipelines/components/transformers/preprocessing/__init__.py
index 788ab9bc..6fbb8da3 100644
--- a/evalml/pipelines/components/transformers/preprocessing/__init__.py
+++ b/evalml/pipelines/components/transformers/preprocessing/__init__.py
@@ -22,6 +22,9 @@ from evalml.pipelines.components.transformers.preprocessing.decomposer import De
 from evalml.pipelines.components.transformers.preprocessing.polynomial_decomposer import (
     PolynomialDecomposer,
 )
+from evalml.pipelines.components.transformers.preprocessing.stl_decomposer import (
+    STLDecomposer,
+)
 from evalml.pipelines.components.transformers.preprocessing.log_transformer import (
     LogTransformer,
 )
diff --git a/evalml/pipelines/components/transformers/preprocessing/decomposer.py b/evalml/pipelines/components/transformers/preprocessing/decomposer.py
index 90072340..6c82c584 100644
--- a/evalml/pipelines/components/transformers/preprocessing/decomposer.py
+++ b/evalml/pipelines/components/transformers/preprocessing/decomposer.py
@@ -1,9 +1,13 @@
 """Component that removes trends from time series and returns the decomposed components."""
+from __future__ import annotations
+
 from abc import abstractmethod
 
+import matplotlib.pyplot as plt
 import numpy as np
 import pandas as pd
 import statsmodels.api as sm
+from pandas.core.index import Int64Index
 from scipy.signal import argrelextrema
 
 from evalml.pipelines.components.transformers.transformer import Transformer
@@ -17,14 +21,39 @@ class Decomposer(Transformer):
         parameters (dict): Dictionary of parameters to pass to component object.
         component_obj (class) : Instance of a detrender/deseasonalizer class.
         random_seed (int): Seed for the random number generator. Defaults to 0.
+        degree (int) : Currently the degree of the PolynomialDecomposer, not used for STLDecomposer.
+        seasonal_period (int) : The best guess, in units, for the period of the seasonal signal.
+        time_index (str) : The column name of the feature matrix (X) that the datetime information
+            should be pulled from.
     """
 
     name = "Decomposer"
     hyperparameter_ranges = None
     modifies_features = False
     modifies_target = True
+    needs_fitting = True
 
-    def __init__(self, parameters=None, component_obj=None, random_seed=0, **kwargs):
+    def __init__(
+        self,
+        component_obj=None,
+        random_seed: int = 0,
+        degree: int = 1,
+        seasonal_period: int = -1,
+        time_index: str = None,
+        **kwargs,
+    ):
+        degree = self._raise_typeerror_if_not_int("degree", degree)
+        self.seasonal_period = self._raise_typeerror_if_not_int(
+            "seasonal_period",
+            seasonal_period,
+        )
+        self.time_index = time_index
+        parameters = {
+            "degree": degree,
+            "seasonal_period": self.seasonal_period,
+            "time_index": time_index,
+        }
+        parameters.update(kwargs)
         super().__init__(
             parameters=parameters,
             component_obj=component_obj,
@@ -32,7 +61,17 @@ class Decomposer(Transformer):
             **kwargs,
         )
 
-    def _set_time_index(self, X, y):
+    def _raise_typeerror_if_not_int(self, var_name: str, var_value):
+        if not isinstance(var_value, int):
+            if isinstance(var_value, float) and var_value.is_integer():
+                var_value = int(var_value)
+            else:
+                raise TypeError(
+                    f"Parameter {var_name} must be an integer!: Received {type(var_value).__name__}",
+                )
+        return var_value
+
+    def _set_time_index(self, X: pd.DataFrame, y: pd.Series):
         """Ensures that target data has a pandas.DatetimeIndex that matches feature data."""
         dt_df = infer_feature_types(X)
 
@@ -42,7 +81,7 @@ class Decomposer(Transformer):
         # If user's provided time_index doesn't exist, log it and find some datetimes to use
         elif (self.time_index is None) or self.time_index not in dt_df.columns:
             self.logger.warning(
-                f"PolynomialDecomposer could not find requested time_index {self.time_index}",
+                f"Decomposer could not find requested time_index {self.time_index}",
             )
             # Use the feature data's index, preferentially
             num_datetime_features = dt_df.ww.select("Datetime").shape[1]
@@ -72,15 +111,37 @@ class Decomposer(Transformer):
         )
         return y.set_axis(time_index)
 
+    def fit_transform(
+        self,
+        X: pd.DataFrame,
+        y: pd.Series = None,
+    ) -> tuple[pd.DataFrame, pd.Series]:
+        """Removes fitted trend and seasonality from target variable.
+
+        Args:
+            X (pd.DataFrame, optional): Ignored.
+            y (pd.Series): Target variable to detrend and deseasonalize.
+
+        Returns:
+            tuple of pd.DataFrame, pd.Series: The first element are the input features returned without modification.
+                The second element is the target variable y with the fitted trend removed.
+        """
+        return self.fit(X, y).transform(X, y)
+
     @abstractmethod
-    def get_trend_dataframe(self, y):
+    def get_trend_dataframe(self, y: pd.Series):
         """Return a list of dataframes, each with 3 columns: trend, seasonality, residual."""
 
     @abstractmethod
-    def inverse_transform(self, y):
+    def inverse_transform(self, y: pd.Series):
         """Add the trend + seasonality back to y."""
 
-    def determine_periodicity(self, X, y, method="autocorrelation"):
+    def determine_periodicity(
+        self,
+        X: pd.DataFrame,
+        y: pd.Series,
+        method: str = "autocorrelation",
+    ):
         """Function that uses autocorrelative methods to determine the first, signficant period of the seasonal signal.
 
         Args:
@@ -149,7 +210,7 @@ class Decomposer(Transformer):
             relative_maxima = [None]
         return relative_maxima[0]
 
-    def set_seasonal_period(self, X, y):
+    def set_seasonal_period(self, X: pd.DataFrame, y: pd.Series):
         """Function to set the component's seasonal period based on the target's seasonality.
 
         Args:
@@ -158,4 +219,113 @@ class Decomposer(Transformer):
 
         """
         self.seasonal_period = self.determine_periodicity(X, y)
-        self.parameters["seasonal_period"] = self.seasonal_period
+        self.parameters.update({"seasonal_period": self.seasonal_period})
+
+    def _choose_proper_index(self, y):
+        """Function that provides support for targets with integer and datetime indices."""
+        # TODO: Need to update this after we upgrade to Pandas 1.5+ and Int64Index is deprecated.
+        if isinstance(y.index, (Int64Index, pd.RangeIndex)):
+            index = self.original_index
+        elif isinstance(y.index, pd.DatetimeIndex):
+            index = self.trend.index
+        else:
+            raise ValueError(
+                f"Decomposer doesn't support target data with index of type ({type(y.index)})",
+            )
+        return index
+
+    def _project_seasonal(
+        self,
+        y: pd.Series,
+        periodic_signal: pd.Series,
+        periodicity: pd.Series,
+        frequency: str,
+    ):
+        """Projects the seasonal signal forward to cover the target data.
+
+        Args:
+            y (pandas.Series): Target data to be transformed
+            periodic_signal (pandas.Series): Single period of the detected seasonal signal
+            periodicity (int): Number of time units in a single cycle of the seasonal signal
+            frequency (str): String representing the detected frequency of the time series data.
+                Uses the same codes as the freqstr attribute of a pandas Series with DatetimeIndex.
+                e.g. "D", "M", "Y" for day, month and year respectively
+                See: https://pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases
+                See: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.to_timedelta.html
+
+        Returns:
+            pandas.Series: the seasonal signal extended to cover the target data to be transformed
+        """
+        index = self._choose_proper_index(y)
+
+        # Determine where the seasonality starts
+        if isinstance(y.index, pd.DatetimeIndex):
+            transform_first_ind = (
+                len(pd.date_range(start=index[0], end=y.index[0], freq=frequency))
+                % periodicity
+                - 1
+            )
+        elif isinstance(y.index, (Int64Index, pd.RangeIndex)):
+            first_index_diff = y.index[0] - index[0]
+            transform_first_ind = first_index_diff % periodicity
+
+        # Cycle the sample of seasonal data so the transformed data's effective index is first
+        rotated_seasonal_sample = np.roll(
+            periodic_signal.T.values,
+            -transform_first_ind,
+        )
+
+        # Repeat the single, rotated period of seasonal data to cover the entirety of the data
+        # to be transformed.
+        seasonal = np.tile(rotated_seasonal_sample, len(y) // periodicity + 1).T[
+            : len(y)
+        ]  # The extrapolated seasonal data will be too long, so truncate.
+
+        # Add the date times back in.
+        return pd.Series(seasonal, index=y.index)
+
+    def plot_decomposition(
+        self,
+        X: pd.DataFrame,
+        y: pd.Series,
+        show: bool = False,
+    ) -> tuple[plt.Figure, list]:
+        """Plots the decomposition of the target signal.
+
+        Args:
+            X (pd.DataFrame): Input data with time series data in index.
+            y (pd.Series or pd.DataFrame): Target variable data provided as a Series for univariate problems or
+                a DataFrame for multivariate problems.
+            show (bool): Whether to display the plot or not. Defaults to False.
+
+        Returns:
+            matplotlib.pyplot.Figure, list[matplotlib.pyplot.Axes]: The figure and axes that have the decompositions
+                plotted on them
+
+        """
+        decomposition_results = self.get_trend_dataframe(X, y)
+        fig, axs = plt.subplots(4)
+        fig.set_size_inches(18.5, 14.5)
+        axs[0].plot(decomposition_results[0]["signal"], "r")
+        axs[0].set_title("signal")
+        axs[1].plot(decomposition_results[0]["trend"], "b")
+        axs[1].set_title("trend")
+        axs[2].plot(decomposition_results[0]["seasonality"], "g")
+        axs[2].set_title("seasonality")
+        axs[3].plot(decomposition_results[0]["residual"], "y")
+        axs[3].set_title("residual")
+        if show:  # pragma: no cover
+            plt.show()
+        return fig, axs
+
+    def _check_target(self, X: pd.DataFrame, y: pd.Series):
+        """Function to ensure target is not None and has a pandas.DatetimeIndex."""
+        if y is None:
+            raise ValueError("y cannot be None for Decomposer!")
+
+        # Change the y index to a matching datetimeindex or else we get a failure
+        # in ForecastingHorizon during decomposition.
+        if not isinstance(y.index, pd.DatetimeIndex):
+            y = self._set_time_index(X, y)
+
+        return X, y
diff --git a/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py b/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
index b805242c..dafe2420 100644
--- a/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
+++ b/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
@@ -3,10 +3,8 @@ from __future__ import annotations
 
 import logging
 
-import matplotlib.pyplot as plt
 import numpy as np
 import pandas as pd
-import sklearn.exceptions
 from skopt.space import Integer
 from sktime.forecasting.base._fh import ForecastingHorizon
 from statsmodels.tsa.seasonal import seasonal_decompose
@@ -57,27 +55,9 @@ class PolynomialDecomposer(Decomposer):
         random_seed: int = 0,
         **kwargs,
     ):
-        def raise_typeerror_if_not_int(var_name, var_value):
-            if not isinstance(var_value, int):
-                if isinstance(var_value, float) and var_value.is_integer():
-                    var_value = int(var_value)
-                else:
-                    raise TypeError(
-                        f"Parameter 'degree' must be an integer!: Received {type(degree).__name__}",
-                    )
-            return var_value
-
         self.logger = logging.getLogger(__name__)
-        degree = raise_typeerror_if_not_int("degree", degree)
-        self.seasonal_period = raise_typeerror_if_not_int(
-            "seasonal_period",
-            seasonal_period,
-        )
 
-        params = {"degree": degree, "seasonal_period": self.seasonal_period}
-        params.update(kwargs)
         error_msg = "sktime is not installed. Please install using 'pip install sktime'"
-
         trend = import_or_raise("sktime.forecasting.trend", error_msg=error_msg)
         detrend = import_or_raise(
             "sktime.transformations.series.detrend",
@@ -86,54 +66,15 @@ class PolynomialDecomposer(Decomposer):
 
         decomposer = detrend.Detrender(trend.PolynomialTrendForecaster(degree=degree))
 
-        self.time_index = time_index
-        params["time_index"] = time_index
-
         super().__init__(
-            parameters=params,
             component_obj=decomposer,
             random_seed=random_seed,
+            degree=degree,
+            seasonal_period=seasonal_period,
+            time_index=time_index,
+            **kwargs,
         )
 
-    def _build_seasonal_signal(self, y, periodic_signal, periodicity, frequency):
-        """Projects the cyclical, seasonal signal forward to cover the target data.
-
-        Args:
-            y (pandas.Series): Target data to be transformed
-            periodic_signal (pandas.Series): Single period of the detected seasonal signal
-            periodicity (int): Number of time units in a single cycle of the seasonal signal
-            frequency (str): String representing the detected frequency of the time series data.
-                Uses the same codes as the freqstr attribute of a pandas Series with DatetimeIndex.
-                e.g. "D", "M", "Y" for day, month and year respectively
-                See: https://pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases
-                See: https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.to_timedelta.html
-
-        Returns:
-            pandas.Series: the seasonal signal extended to cover the target data to be transformed
-        """
-        # Determine where the seasonality starts
-        first_index_diff = y.index[0] - periodic_signal.index[0]
-        delta = pd.to_timedelta(1, frequency)
-        period = pd.to_timedelta(periodicity, frequency)
-
-        # Determine which index of the sample of seasonal data the transformed data starts at
-        transform_first_ind = int((first_index_diff % period) / delta)
-
-        # Cycle the sample of seasonal data so the transformed data's effective index is first
-        rotated_seasonal_sample = np.roll(
-            periodic_signal.T.values,
-            -transform_first_ind,
-        )
-
-        # Repeat the single, rotated period of seasonal data to cover the entirety of the data
-        # to be transformed.
-        seasonal = np.tile(rotated_seasonal_sample, len(y) // periodicity + 1).T[
-            : len(y)
-        ]  # The extrapolated seasonal data will be too long, so truncate.
-
-        # Add the date times back in.
-        return pd.Series(seasonal, index=y.index)
-
     def fit(self, X: pd.DataFrame, y: pd.Series = None) -> PolynomialDecomposer:
         """Fits the PolynomialDecomposer and determine the seasonal signal.
 
@@ -152,15 +93,10 @@ class PolynomialDecomposer(Decomposer):
             ValueError: If y is None.
             ValueError: If target data doesn't have DatetimeIndex AND no Datetime features in features data
         """
-        if y is None:
-            raise ValueError("y cannot be None for PolynomialDecomposer!")
-
-        # Change the y index to a matching datetimeindex or else we get a failure
-        # in ForecastingHorizon during decomposition.
-        if not isinstance(y.index, pd.DatetimeIndex):
-            y = self._set_time_index(X, y)
+        self.original_index = y.index if y is not None else None
+        X, y = self._check_target(X, y)
 
-        # Copying y as we might modify it's index
+        # Copying y as we might modify its index
         y_orig = infer_feature_types(y).copy()
         self._component_obj.fit(y_orig)
 
@@ -173,16 +109,14 @@ class PolynomialDecomposer(Decomposer):
         # the given array.  We'll extract the first iteration and save it for use in .transform()
         # TODO: Resolve with https://github.com/alteryx/evalml/issues/3708
         if self.seasonal_period == -1:
-            self.periodicity = freq_to_period(self.frequency)
-            self.seasonal_period = self.periodicity
-        else:
-            self.periodicity = self.seasonal_period
+            self.seasonal_period = freq_to_period(self.frequency)
 
-        self.seasonality = seasonal_decompose(
+        self.seasonal = seasonal_decompose(
             y_detrended_with_time_index,
-            period=self.periodicity,
-        ).seasonal[0 : self.periodicity]
-
+            period=self.seasonal_period,
+        ).seasonal
+        self.seasonality = self.seasonal[0 : self.seasonal_period]
+        self.trend = y - (y_detrended_with_time_index - self.seasonal) - self.seasonal
         return self
 
     def transform(
@@ -193,7 +127,7 @@ class PolynomialDecomposer(Decomposer):
         """Transforms the target data by removing the polynomial trend and rolling average seasonality.
 
         Applies the fit polynomial detrender to the target data, removing the additive polynomial trend. Then,
-        utilizes the first period's worth of seasonal data determined in the .train() function to extrapolate
+        utilizes the first period's worth of seasonal data determined in the .fit() function to extrapolate
         the seasonal signal of the data to be transformed.  This seasonal signal is also assumed to be additive
         and is removed.
 
@@ -210,11 +144,10 @@ class PolynomialDecomposer(Decomposer):
         """
         if y is None:
             return X, y
+        X, y = self._check_target(X, y)
 
         # Give the internal target signal a datetime index built from X
         y = y.copy()
-        if not isinstance(y.index, pd.DatetimeIndex):
-            y = self._set_time_index(X, y)
 
         # Remove polynomial trend then seasonality of detrended signal
         y_ww = infer_feature_types(y)
@@ -224,38 +157,21 @@ class PolynomialDecomposer(Decomposer):
             # Repeat the seasonal signal over the target data
             seasonal = np.tile(
                 self.seasonality.T,
-                len(y_detrended) // self.periodicity + 1,
+                len(y_detrended) // self.seasonal_period + 1,
             ).T[: len(y_detrended)]
 
         y_t = pd.Series(y_detrended - seasonal)
         y_t.ww.init(logical_type="double")
         return X, y_t
 
-    def fit_transform(
-        self,
-        X: pd.DataFrame,
-        y: pd.Series = None,
-    ) -> tuple[pd.DataFrame, pd.Series]:
-        """Removes fitted trend and seasonality from target variable.
-
-        Args:
-            X (pd.DataFrame, optional): Ignored.
-            y (pd.Series): Target variable to detrend and deseasonalize.
-
-        Returns:
-            tuple of pd.DataFrame, pd.Series: The first element are the input features returned without modification.
-                The second element is the target variable y with the fitted trend removed.
-        """
-        return self.fit(X, y).transform(X, y)
-
-    def inverse_transform(self, y: pd.Series) -> tuple[pd.DataFrame, pd.Series]:
+    def inverse_transform(self, y_t: pd.Series) -> tuple[pd.DataFrame, pd.Series]:
         """Adds back fitted trend and seasonality to target variable.
 
         The polynomial trend is added back into the signal, calling the detrender's inverse_transform().
         Then, the seasonality is projected forward to and added back into the signal.
 
         Args:
-            y (pd.Series): Target variable.
+            y_t (pd.Series): Target variable.
 
         Returns:
             tuple of pd.DataFrame, pd.Series: The first element are the input features returned without modification.
@@ -264,24 +180,24 @@ class PolynomialDecomposer(Decomposer):
         Raises:
             ValueError: If y is None.
         """
-        if y is None:
-            raise ValueError("y cannot be None for PolynomialDecomposer!")
-        y_ww = infer_feature_types(y)
+        _, y_t = self._check_target(None, y_t)
+
+        y_t = infer_feature_types(y_t)
 
         # Add polynomial trend back to signal
-        y_retrended = self._component_obj.inverse_transform(y_ww)
+        y_retrended = self._component_obj.inverse_transform(y_t)
 
-        seasonal = self._build_seasonal_signal(
-            y_ww,
+        seasonal = self._project_seasonal(
+            y_t,
             self.seasonality,
-            self.periodicity,
+            self.seasonal_period,
             self.frequency,
         )
-        y_t = infer_feature_types(pd.Series(y_retrended + seasonal, index=y_ww.index))
-        return y_t
+        y = infer_feature_types(pd.Series(y_retrended + seasonal, index=y_t.index))
+        return y
 
     def get_trend_dataframe(self, X: pd.DataFrame, y: pd.Series) -> list[pd.DataFrame]:
-        """Return a list of dataframes with 3 columns: trend, seasonality, residual.
+        """Return a list of dataframes with 4 columns: signal, trend, seasonality, residual.
 
         Scikit-learn's PolynomialForecaster is used to generate the trend portion of the target data. statsmodel's
         seasonal_decompose is used to generate the seasonality of the data.
@@ -324,12 +240,9 @@ class PolynomialDecomposer(Decomposer):
             forecaster = (
                 self._component_obj.forecaster_
             )  # the .forecaster attribute is an unfitted version
-            try:
-                trend = forecaster.predict(fh=fh, X=y)
-            except (sklearn.exceptions.NotFittedError, AttributeError):
-                raise ValueError(
-                    "PolynomialDecomposer has not been fit yet.  Please fit it and then build the decomposed dataframe.",
-                )
+
+            trend = forecaster.predict(fh=fh, X=y)
+
             seasonality = seasonal_decompose(
                 y - trend,
                 period=self.seasonal_period,
@@ -351,32 +264,3 @@ class PolynomialDecomposer(Decomposer):
                 result_dfs.append(_decompose_target(X, y[colname], fh))
 
         return result_dfs
-
-    def plot_decomposition(self, X: pd.DataFrame, y: pd.Series, show=False):
-        """Plots the decomposition of the target signal.
-
-        Args:
-            X (pd.DataFrame): Input data with time series data in index.
-            y (pd.Series or pd.DataFrame): Target variable data provided as a Series for univariate problems or
-                a DataFrame for multivariate problems.
-            show (bool): Whether to display the plot or not. Defaults to False.
-
-        Returns:
-            matplotlib.pyplot.Figure, matplotlib.pyplot.Axes: The figure and axes that have the decompositions
-                plotted on them
-
-        """
-        decomposition_results = self.get_trend_dataframe(X, y)
-        fig, axs = plt.subplots(4)
-        fig.set_size_inches(18.5, 14.5)
-        axs[0].plot(decomposition_results[0]["signal"], "r")
-        axs[0].set_title("signal")
-        axs[1].plot(decomposition_results[0]["trend"], "b")
-        axs[1].set_title("trend")
-        axs[2].plot(decomposition_results[0]["seasonality"], "g")
-        axs[2].set_title("seasonality")
-        axs[3].plot(decomposition_results[0]["residual"], "y")
-        axs[3].set_title("residual")
-        if show:  # pragma: no cover
-            plt.show()
-        return fig, axs
diff --git a/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py b/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py
new file mode 100644
index 00000000..f940c214
--- /dev/null
+++ b/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py
@@ -0,0 +1,389 @@
+"""Component that removes trends and seasonality from time series using STL."""
+from __future__ import annotations
+
+import logging
+
+import pandas as pd
+from pandas.core.index import Int64Index
+from statsmodels.tsa.arima.model import ARIMA
+from statsmodels.tsa.forecasting.stl import STLForecast
+from statsmodels.tsa.seasonal import STL
+
+from evalml.pipelines.components.transformers.preprocessing.decomposer import Decomposer
+from evalml.utils import infer_feature_types
+
+
+class STLDecomposer(Decomposer):
+    """Removes trends and seasonality from time series using the STL algorithm.
+
+    https://www.statsmodels.org/dev/generated/statsmodels.tsa.seasonal.STL.html
+
+    Args:
+        time_index (str): Specifies the name of the column in X that provides the datetime objects. Defaults to None.
+        degree (int): Not currently used.  STL 3x "degree-like" values.  None are able to be set at
+            this time. Defaults to 1.
+        seasonal_period (int): The number of entries in the time series data that corresponds to one period of a
+            cyclic signal.  For instance, if data is known to possess a weekly seasonal signal, and if the data
+            is daily data, seasonal_period should be 7.  For daily data with a yearly seasonal signal, seasonal_period
+            should be 365.  For compatibility with the underlying STL algorithm, must be odd. If an even number
+            is provided, the next, highest odd number will be used. Defaults to 7.
+        random_seed (int): Seed for the random number generator. Defaults to 0.
+    """
+
+    name = "STL Decomposer"
+    hyperparameter_ranges = {}
+
+    modifies_features = False
+    modifies_target = True
+
+    def __init__(
+        self,
+        time_index: str = None,
+        degree: int = 1,  # Currently unused.
+        seasonal_period: int = 7,
+        random_seed: int = 0,
+        **kwargs,
+    ):
+        self.logger = logging.getLogger(__name__)
+
+        # Programmatically adjust seasonal_period to fit underlying STL requirements,
+        # that seasonal_period must be odd.
+        if seasonal_period % 2 == 0:
+            self.logger.warning(
+                f"STLDecomposer provided with an even period of {seasonal_period}"
+                f"Changing seasonal period to {seasonal_period+1}",
+            )
+            seasonal_period += 1
+
+        super().__init__(
+            component_obj=None,
+            random_seed=random_seed,
+            degree=degree,
+            seasonal_period=seasonal_period,
+            time_index=time_index,
+            **kwargs,
+        )
+
+    def _check_oos_past(self, y):
+        """Function to check whether provided target data is out-of-sample and in the past."""
+        index = self._choose_proper_index(y)
+
+        if y.index[0] < index[0]:
+            raise ValueError(
+                f"STLDecomposer cannot transform/inverse transform data out of sample and before the data used"
+                f"to fit the decomposer."
+                f"\nRequested range: {str(y.index[0])}:{str(y.index[-1])}."
+                f"\nSample range: {str(index[0])}:{str(index[-1])}.",
+            )
+
+    def _project_trend(self, y):
+        """Function to project the in-sample trend into the future."""
+        self._check_oos_past(y)
+
+        index = self._choose_proper_index(y)
+
+        # Determine how many units forward to project by finding the difference,
+        # in index values, between the requested target and the fit data.
+        if isinstance(y.index, pd.DatetimeIndex):
+            units_forward = (
+                len(
+                    pd.date_range(
+                        start=self.trend.index[-1],
+                        end=y.index[-1],
+                        freq=self.frequency,
+                    ),
+                )
+                - 1
+            )
+        elif isinstance(y.index, Int64Index):
+            units_forward = int(y.index[-1] - index[-1])
+
+        # Model the trend and project it forward
+        stlf = STLForecast(
+            self.trend,
+            ARIMA,
+            model_kwargs=dict(order=(1, 1, 0), trend="t"),
+        )
+        stlf_res = stlf.fit()
+        forecast = stlf_res.forecast(units_forward)
+
+        # Handle out-of-sample forecasts.  The forecast will have additional data
+        # between the end of the in-sample data and the beginning of the
+        # requested out-of-sample data to inverse transform.
+        overlapping_ind = [ind for ind in y.index if ind in forecast.index]
+        if len(overlapping_ind) > 0:
+            return forecast[overlapping_ind]
+        # This branch handles the cross-validation cases where the indices are
+        # integer indices and we know the forecast length will match the requested
+        # transform data length.
+        else:
+            fore = forecast[-len(y) :]
+            fore.index = y.index
+            return fore
+
+    def _project_trend_and_seasonality(self, y):
+        """Function to project both trend and seasonality forward into the future."""
+        projected_trend = self._project_trend(y)
+
+        projected_seasonality = self._project_seasonal(
+            y,
+            self.seasonality,
+            self.seasonal_period,
+            self.frequency,
+        )
+        return projected_trend, projected_seasonality
+
+    def fit(self, X: pd.DataFrame, y: pd.Series = None) -> STLDecomposer:
+        """Fits the STLDecomposer and determine the seasonal signal.
+
+        Instantiates a statsmodels STL decompose object with the component's stored
+        parameters and fits it.  Since the statsmodels object does not fit the sklearn
+        api, it is not saved during __init__() in _component_obj and will be re-instantiated
+        each time fit is called.
+
+        To emulate the sklearn API, when the STL decomposer is fit, the full seasonal
+        component, a single period sample of the seasonal component, the full
+        trend-cycle component and the residual are saved.
+
+        y(t) = S(t) + T(t) + R(t)
+
+        Args:
+            X (pd.DataFrame, optional): Conditionally used to build datetime index.
+            y (pd.Series): Target variable to detrend and deseasonalize.
+
+        Returns:
+            self
+
+        Raises:
+            ValueError: If y is None.
+            ValueError: If target data doesn't have DatetimeIndex AND no Datetime features in features data
+        """
+        self.original_index = y.index if y is not None else None
+        X, y = self._check_target(X, y)
+
+        # Warn for poor decomposition use with higher periods
+        if self.seasonal_period > 14:
+            str_dict = {"D": "daily", "M": "monthly"}
+            data_str = ""
+            if y.index.freqstr in str_dict:
+                data_str = str_dict[y.index.freqstr]
+            self.logger.warning(
+                f"STLDecomposer may perform poorly on {data_str} data with a high seasonal period ({self.seasonal_period}).",
+            )
+
+        # Save the frequency of the fitted series for checking against transform data.
+        self.frequency = y.index.freqstr
+
+        stl = STL(y, seasonal=self.seasonal_period)
+        res = stl.fit()
+        self.seasonal = res.seasonal
+        self.seasonal_period = stl.period
+        dist = len(y) % self.seasonal_period
+        self.seasonality = (
+            self.seasonal[-(dist + self.seasonal_period) : -dist]
+            if dist > 0
+            else self.seasonal[-self.seasonal_period :]
+        )
+        self.trend = res.trend
+        self.residual = res.resid
+
+        return self
+
+    def transform(
+        self,
+        X: pd.DataFrame,
+        y: pd.Series = None,
+    ) -> tuple[pd.DataFrame, pd.Series]:
+        """Transforms the target data by removing the STL trend and seasonality.
+
+        Uses an ARIMA model to project forward the addititve trend and removes it. Then, utilizes the first period's
+        worth of seasonal data determined in the .fit() function to extrapolate the seasonal signal of the data to be
+        transformed.  This seasonal signal is also assumed to be additive and is removed.
+
+        Args:
+            X (pd.DataFrame, optional): Conditionally used to build datetime index.
+            y (pd.Series): Target variable to detrend and deseasonalize.
+
+        Returns:
+            tuple of pd.DataFrame, pd.Series: The input features are returned without modification. The target
+                variable y is detrended and deseasonalized.
+
+        Raises:
+            ValueError: If target data doesn't have DatetimeIndex AND no Datetime features in features data
+        """
+        if y is None:
+            return X, y
+        original_index = y.index
+        X, y = self._check_target(X, y)
+
+        self._check_oos_past(y)
+
+        y_in_sample = pd.Series([])
+        y_out_of_sample = pd.Series([])
+
+        # For partially and wholly in-sample data, retrieve stored results.
+        if self.trend.index[0] <= y.index[0] <= self.trend.index[-1]:
+            y_in_sample = self.residual[y.index[0] : y.index[-1]]
+
+        # For out of sample data....
+        if y.index[-1] > self.trend.index[-1]:
+            try:
+                # ...that is partially out of sample and partially in sample.
+                truncated_y = y[y.index.get_loc(self.trend.index[-1]) + 1 :]
+            except KeyError:
+                # ...that is entirely out of sample.
+                truncated_y = y
+
+            (
+                projected_trend,
+                projected_seasonality,
+            ) = self._project_trend_and_seasonality(truncated_y)
+
+            y_out_of_sample = infer_feature_types(
+                pd.Series(
+                    truncated_y - projected_trend - projected_seasonality,
+                    index=truncated_y.index,
+                ),
+            )
+        y_t = y_in_sample.append(y_out_of_sample)
+        y_t.index = original_index
+        return X, y_t
+
+    def inverse_transform(self, y_t: pd.Series) -> tuple[pd.DataFrame, pd.Series]:
+        """Adds back fitted trend and seasonality to target variable.
+
+        The STL trend is projected to cover the entire requested target range, then added back into the signal. Then,
+        the seasonality is projected forward to and added back into the signal.
+
+        Args:
+            y_t (pd.Series): Target variable.
+
+        Returns:
+            tuple of pd.DataFrame, pd.Series: The first element are the input features returned without modification.
+                The second element is the target variable y with the trend and seasonality added back in.
+
+        Raises:
+            ValueError: If y is None.
+        """
+        if y_t is None:
+            raise ValueError("y_t cannot be None for Decomposer!")
+        original_index = y_t.index
+
+        y_t = infer_feature_types(y_t)
+        self._check_oos_past(y_t)
+
+        index = self._choose_proper_index(y_t)
+
+        y_in_sample = pd.Series([])
+        y_out_of_sample = pd.Series([])
+
+        # For partially and wholly in-sample data, retrieve stored results.
+        if index[0] <= y_t.index[0] <= index[-1]:
+            left_index = y_t.index[0]
+            right_index = (
+                y_t.index[-1] + 1
+                if isinstance(y_t.index, (Int64Index, pd.RangeIndex))
+                else y_t.index[-1] + 1 * y_t.index.freq
+            )
+            trend = (
+                self.trend.reset_index(drop=True)[left_index:right_index]
+                if isinstance(y_t.index, (Int64Index, pd.RangeIndex))
+                else self.trend[left_index:right_index]
+            )
+            seasonal = (
+                self.seasonal.reset_index(drop=True)[left_index:right_index]
+                if isinstance(y_t.index, (Int64Index, pd.RangeIndex))
+                else self.seasonal[left_index:right_index]
+            )
+            y_in_sample = y_t + trend + seasonal
+            y_in_sample = y_in_sample.dropna()
+
+        # For out of sample data....
+        if y_t.index[-1] > index[-1]:
+            try:
+                # ...that is partially out of sample and partially in sample.
+                truncated_y_t = y_t[y_t.index.get_loc(index[-1]) + 1 :]
+            except KeyError:
+                # ...that is entirely out of sample.
+                truncated_y_t = y_t
+            (
+                projected_trend,
+                projected_seasonality,
+            ) = self._project_trend_and_seasonality(truncated_y_t)
+
+            y_out_of_sample = infer_feature_types(
+                pd.Series(
+                    truncated_y_t + projected_trend + projected_seasonality,
+                    index=truncated_y_t.index,
+                ),
+            )
+        y = y_in_sample.append(y_out_of_sample)
+        y.index = original_index
+        return y
+
+    def get_trend_dataframe(self, X, y):
+        """Return a list of dataframes with 4 columns: signal, trend, seasonality, residual.
+
+        Args:
+            X (pd.DataFrame): Input data with time series data in index.
+            y (pd.Series or pd.DataFrame): Target variable data provided as a Series for univariate problems or
+                a DataFrame for multivariate problems.
+
+        Returns:
+            list of pd.DataFrame: Each DataFrame contains the columns "signal", "trend", "seasonality" and "residual,"
+                with the latter 3 column values being the decomposed elements of the target data.  The "signal" column
+                is simply the input target signal but reindexed with a datetime index to match the input features.
+
+        Raises:
+            TypeError: If X does not have time-series data in the index.
+            ValueError: If time series index of X does not have an inferred frequency.
+            ValueError: If the forecaster associated with the detrender has not been fit yet.
+            TypeError: If y is not provided as a pandas Series or DataFrame.
+
+        """
+        X = infer_feature_types(X)
+        if not isinstance(X.index, pd.DatetimeIndex):
+            raise TypeError("Provided X should have datetimes in the index.")
+        if X.index.freq is None:
+            raise ValueError(
+                "Provided DatetimeIndex of X should have an inferred frequency.",
+            )
+        # Change the y index to a matching datetimeindex or else we get a failure
+        # in ForecastingHorizon during decomposition.
+        if not isinstance(y.index, pd.DatetimeIndex):
+            y = self._set_time_index(X, y)
+
+        self._check_oos_past(y)
+
+        result_dfs = []
+
+        def _decompose_target(X, y, fh):
+            """Function to generate a single DataFrame with trend, seasonality and residual components."""
+            if len(y.index) == len(self.trend.index) and all(
+                y.index == self.trend.index,
+            ):
+                trend = self.trend
+                seasonal = self.seasonal
+                residual = self.residual
+            else:
+                # TODO: Do a better job cloning.
+                decomposer = STLDecomposer(seasonal_period=self.seasonal_period)
+                decomposer.fit(X, y)
+                trend = decomposer.trend
+                seasonal = decomposer.seasonal
+                residual = decomposer.residual
+            return pd.DataFrame(
+                {
+                    "signal": y,
+                    "trend": trend,
+                    "seasonality": seasonal,
+                    "residual": residual,
+                },
+            )
+
+        if isinstance(y, pd.Series):
+            result_dfs.append(_decompose_target(X, y, None))
+        elif isinstance(y, pd.DataFrame):
+            for colname in y.columns:
+                result_dfs.append(_decompose_target(X, y[colname], None))
+        return result_dfs
diff --git a/evalml/utils/base_meta.py b/evalml/utils/base_meta.py
index 24e9a427..0b780e22 100644
--- a/evalml/utils/base_meta.py
+++ b/evalml/utils/base_meta.py
@@ -7,7 +7,13 @@ class BaseMeta(ABCMeta):
     """Metaclass that overrides creating a new component or pipeline by wrapping methods with validators and setters."""
 
     FIT_METHODS = ["fit", "fit_transform"]
-    METHODS_TO_CHECK = ["predict", "predict_proba", "transform", "inverse_transform"]
+    METHODS_TO_CHECK = [
+        "predict",
+        "predict_proba",
+        "transform",
+        "inverse_transform",
+        "get_trend_dataframe",
+    ]
     PROPERTIES_TO_CHECK = ["feature_importance"]
 
     @classmethod


Test patch:
diff --git a/evalml/tests/component_tests/decomposer_tests/test_decomposer.py b/evalml/tests/component_tests/decomposer_tests/test_decomposer.py
index 134eeb73..e9a3234b 100644
--- a/evalml/tests/component_tests/decomposer_tests/test_decomposer.py
+++ b/evalml/tests/component_tests/decomposer_tests/test_decomposer.py
@@ -1,14 +1,26 @@
+import itertools
 from datetime import datetime
 
+import matplotlib
 import numpy as np
 import pandas as pd
+import pytest
 
-from evalml.pipelines.components.transformers.preprocessing.polynomial_decomposer import (
+import evalml.exceptions
+from evalml.pipelines.components.transformers.preprocessing import (
     PolynomialDecomposer,
+    STLDecomposer,
 )
 
+# All the decomposers to run common tests over.
+decomposer_list = [STLDecomposer, PolynomialDecomposer]
 
-def test_set_time_index():
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_set_time_index(decomposer_child_class):
     x = np.arange(0, 2 * np.pi, 0.01)
     dts = pd.date_range(datetime.today(), periods=len(x))
     X = pd.DataFrame({"x": x})
@@ -17,8 +29,460 @@ def test_set_time_index():
 
     assert isinstance(y.index, pd.RangeIndex)
 
-    # Use the PolynomialDecomposer since we can't use a Decomposer class as it
-    # has abstract methods.
-    decomposer = PolynomialDecomposer()
+    decomposer = decomposer_child_class()
     y_time_index = decomposer._set_time_index(X, y)
     assert isinstance(y_time_index.index, pd.DatetimeIndex)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize(
+    "y_has_time_index",
+    ["y_has_time_index", "y_doesnt_have_time_index"],
+)
+def test_decomposer_plot_decomposition(
+    decomposer_child_class,
+    y_has_time_index,
+    generate_seasonal_data,
+):
+    step = 0.01
+    period = 9
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(period, step)
+    if y_has_time_index == "y_has_time_index":
+        y = y.set_axis(X.index)
+
+    dec = decomposer_child_class(degree=1, seasonal_period=period)
+    dec.fit_transform(X, y)
+    fig, axs = dec.plot_decomposition(X, y, show=False)
+    assert isinstance(fig, matplotlib.pyplot.Figure)
+    assert isinstance(axs, np.ndarray)
+    assert all([isinstance(ax, matplotlib.pyplot.Axes) for ax in axs])
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize("X_num_time_columns", [0, 1, 2, 3])
+@pytest.mark.parametrize(
+    "X_has_time_index",
+    ["X_has_time_index", "X_doesnt_have_time_index"],
+)
+@pytest.mark.parametrize(
+    "y_has_time_index",
+    ["y_has_time_index", "y_doesnt_have_time_index"],
+)
+@pytest.mark.parametrize(
+    "time_index_specified",
+    [
+        "time_index_is_specified",
+        "time_index_not_specified",
+        "time_index_is_specified_but_wrong",
+    ],
+)
+def test_decomposer_uses_time_index(
+    decomposer_child_class,
+    ts_data,
+    X_has_time_index,
+    X_num_time_columns,
+    y_has_time_index,
+    time_index_specified,
+):
+    X, _, y = ts_data()
+
+    time_index_col_name = "date"
+    assert isinstance(X.index, pd.DatetimeIndex)
+    assert isinstance(y.index, pd.DatetimeIndex)
+
+    # Modify time series data to match testing conditions
+    if X_has_time_index == "X_doesnt_have_time_index":
+        X = X.ww.reset_index(drop=True)
+    if y_has_time_index == "y_doesnt_have_time_index":
+        y = y.reset_index(drop=True)
+    if X_num_time_columns == 0:
+        X = X.ww.drop(columns=[time_index_col_name])
+    elif X_num_time_columns > 1:
+        for addn_col in range(X_num_time_columns - 1):
+            X.ww[time_index_col_name + str(addn_col + 1)] = X.ww[time_index_col_name]
+    time_index = {
+        "time_index_is_specified": "date",
+        "time_index_not_specified": None,
+        "time_index_is_specified_but_wrong": "d4t3s",
+    }[time_index_specified]
+    decomposer = decomposer_child_class(time_index=time_index)
+
+    err_msg = None
+
+    # The time series data has no time data
+    if (
+        X_num_time_columns == 0
+        and X_has_time_index == "X_doesnt_have_time_index"
+        and y_has_time_index == "y_doesnt_have_time_index"
+    ):
+        err_msg = "There are no Datetime features in the feature data and neither the feature nor the target data have a DateTime index."
+
+    # The time series data has too much time data
+    elif (
+        X_num_time_columns > 1
+        and time_index_specified == "time_index_not_specified"
+        and y_has_time_index == "y_doesnt_have_time_index"
+        and X_has_time_index != "X_has_time_index"
+    ):
+        err_msg = "Too many Datetime features provided in data but no time_index column specified during __init__."
+
+    # If the wrong time_index column is specified with multiple datetime columns
+    elif (
+        time_index_specified == "time_index_is_specified_but_wrong"
+        and X_num_time_columns > 1
+        and X_has_time_index != "X_has_time_index"
+        and y_has_time_index != "y_has_time_index"
+    ):
+        err_msg = "Too many Datetime features provided in data and provided time_index column d4t3s not present in data."
+
+    if err_msg is not None:
+        with pytest.raises(
+            ValueError,
+            match=err_msg,
+        ):
+            decomposer.fit_transform(X, y)
+    else:
+        X_t, y_t = decomposer.fit_transform(X, y)
+
+        # If the fit_transform() succeeds, assert the original X and y
+        # have unchanged indices.
+        if X_has_time_index == "X_doesnt_have_time_index":
+            assert not isinstance(X.index, pd.DatetimeIndex)
+        else:
+            assert isinstance(X.index, pd.DatetimeIndex)
+        if y_has_time_index == "y_doesnt_have_time_index":
+            assert not isinstance(y.index, pd.DatetimeIndex)
+        else:
+            assert isinstance(y.index, pd.DatetimeIndex)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize(
+    "dataframe_has_datatime_index",
+    ["df_has_datetime_index", "df_doesnt_have_datetime_index"],
+)
+@pytest.mark.parametrize(
+    "multiple_time_features",
+    ["has_another_time_feature", "doesnt_have_another_time_feature"],
+)
+@pytest.mark.parametrize(
+    "time_index_exists",
+    ["time_index_does_exist", "time_index_does_not_exist"],
+)
+def test_decomposer_prefers_users_time_index(
+    decomposer_child_class,
+    dataframe_has_datatime_index,
+    multiple_time_features,
+    time_index_exists,
+    ts_data,
+):
+    periods = 30
+    dates_1 = pd.date_range("2020-01-01", periods=periods)
+    dates_2 = pd.date_range("2021-01-01", periods=periods)
+    dates_3 = pd.date_range("2022-01-01", periods=periods)
+    vals = np.arange(0, periods)
+    y = pd.Series(vals)
+    X = pd.DataFrame({"values": vals})
+
+    if time_index_exists == "time_index_does_exist":
+        X["dates"] = dates_1
+
+    if multiple_time_features == "has_another_time_feature":
+        X["more_dates"] = dates_2
+
+    if dataframe_has_datatime_index == "df_has_datetime_index":
+        X.set_axis(pd.DatetimeIndex(dates_3))
+
+    dec = decomposer_child_class(time_index="dates")
+
+    err_msg = None
+    if time_index_exists == "time_index_does_not_exist":
+        if multiple_time_features == "has_another_time_feature":
+            expected_values = dates_2.values
+        else:
+            err_msg = "There are no Datetime features in the feature data and neither the feature nor the target data have a DateTime index."
+    else:
+        expected_values = dates_1.values
+
+    if err_msg:
+        with pytest.raises(ValueError, match=err_msg):
+            X_t, y_t = dec.fit_transform(X, y)
+    else:
+        X_t, y_t = dec.fit_transform(X, y)
+        assert all(dec.trend.index.values == expected_values)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize(
+    "frequency",
+    [
+        "D",
+        "W",
+        "S",
+        "h",
+        "T",
+        pytest.param(
+            "m",
+            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
+        ),
+        pytest.param(
+            "M",
+            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
+        ),
+        pytest.param(
+            "Y",
+            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
+        ),
+        pytest.param(
+            "MS",
+            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "test_first_index",
+    ["on period", "before period", "just after period", "mid period"],
+)
+def test_decomposer_build_seasonal_signal(
+    decomposer_child_class,
+    ts_data,
+    test_first_index,
+    frequency,
+):
+    period = 10
+    test_first_index = {
+        "on period": 3 * period,
+        "before period": 3 * period - 1,
+        "just after period": 3 * period + 1,
+        "mid period": 3 * period + 4,
+    }[test_first_index]
+
+    # Data spanning 2021-01-01 to 2021-02-09
+    X, _, y = ts_data()
+
+    # Change the date time index to start at the same time but have different frequency
+    y.set_axis(
+        pd.date_range(start="2021-01-01", periods=len(y), freq=frequency),
+        inplace=True,
+    )
+
+    decomposer = decomposer_child_class(degree=2)
+
+    # Synthesize a one-week long cyclic signal
+    single_period_seasonal_signal = np.sin(y[0:period] * 2 * np.pi / len(y[0:period]))
+    full_seasonal_signal = np.sin(y * 2 * np.pi / len(y[0:period]))
+
+    # Split the target data.  Since the period of this data is 7 days, we'll test
+    # when the cycle begins, an index before it begins, an index after it begins
+    # and in the middle of a cycle
+    y_test = y[test_first_index:]
+
+    # Set the decomposer's trend attribute since the function uses it to select the
+    # proper integer/datetime index.  The actual value doesn't matter, just that
+    # something with an index exists there.
+    decomposer.trend = full_seasonal_signal
+
+    projected_seasonality = decomposer._project_seasonal(
+        y_test,
+        single_period_seasonal_signal,
+        period,
+        frequency,
+    )
+
+    # Make sure that the function extracted the correct portion of the repeating, full seasonal signal
+    assert np.allclose(
+        full_seasonal_signal[projected_seasonality.index],
+        projected_seasonality,
+    )
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_get_trend_dataframe_raises_errors(
+    decomposer_child_class,
+    ts_data,
+):
+    X, _, y = ts_data()
+    dec = decomposer_child_class()
+    dec.fit_transform(X, y)
+
+    with pytest.raises(
+        TypeError,
+        match="Provided X should have datetimes in the index.",
+    ):
+        X_int_index = X.reset_index()
+        dec.get_trend_dataframe(X_int_index, y)
+
+    with pytest.raises(
+        ValueError,
+        match="Provided DatetimeIndex of X should have an inferred frequency.",
+    ):
+        X.index.freq = None
+        dec.get_trend_dataframe(X, y)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize("period", [7, 30, 365])
+def test_decomposer_set_period(decomposer_child_class, period, generate_seasonal_data):
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(period)
+    dec = decomposer_child_class()
+
+    if isinstance(dec, STLDecomposer):
+        assert dec.seasonal_period == 7
+        assert dec.parameters["seasonal_period"] == 7
+    elif isinstance(dec, PolynomialDecomposer):
+        assert dec.seasonal_period == -1
+        assert dec.parameters["seasonal_period"] == -1
+
+    dec.set_seasonal_period(X, y)
+
+    assert 0.95 * period <= dec.seasonal_period <= 1.05 * period
+    # TODO: Fix this with https://github.com/alteryx/evalml/issues/3771
+    # assert dec.parameters["seasonal_period"] == dec.seasonal_period
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize(
+    "periodicity_determination_method",
+    [
+        "autocorrelation",
+        pytest.param(
+            "partial-autocorrelation",
+            marks=pytest.mark.xfail(reason="Partial Autocorrelation not working yet."),
+        ),
+    ],
+)
+@pytest.mark.parametrize("decomposer_picked_correct_degree", [True, False])
+@pytest.mark.parametrize(
+    "synthetic_data,trend_degree,period",
+    [*itertools.product(["synthetic"], [1, 2, 3], [7, 30, 365]), ("real", 1, 365)],
+)
+def test_decomposer_determine_periodicity(
+    decomposer_child_class,
+    period,
+    trend_degree,
+    decomposer_picked_correct_degree,
+    periodicity_determination_method,
+    synthetic_data,
+    generate_seasonal_data,
+):
+
+    X, y = generate_seasonal_data(real_or_synthetic=synthetic_data)(
+        period,
+        trend_degree=trend_degree,
+    )
+
+    # Test that the seasonality can be determined if trend guess isn't spot on.
+    if not decomposer_picked_correct_degree:
+        trend_degree = 1 if trend_degree in [2, 3] else 2
+
+    dec = decomposer_child_class(degree=trend_degree, seasonal_period=period)
+    ac = dec.determine_periodicity(X, y, method=periodicity_determination_method)
+
+    # There's one flaky test case, but only in GitHub CI.
+    # Will file an issue to investigate why it's different in CI.
+    if (
+        synthetic_data != "synthetic"
+        and trend_degree != 3
+        and period != 365
+        and not isinstance(decomposer_child_class, STLDecomposer)
+    ):
+        assert 0.95 * period <= ac <= 1.05 * period
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@pytest.mark.parametrize("fit_before_decompose", [True, False])
+def test_decomposer_get_trend_dataframe_error_not_fit(
+    decomposer_child_class,
+    ts_data,
+    fit_before_decompose,
+):
+    X, _, y = ts_data()
+
+    dec = decomposer_child_class()
+    if fit_before_decompose:
+        dec.fit_transform(X, y)
+        dec.get_trend_dataframe(X, y)
+    else:
+        with pytest.raises(evalml.exceptions.ComponentNotYetFittedError):
+            dec.transform(X, y)
+        with pytest.raises(evalml.exceptions.ComponentNotYetFittedError):
+            dec.get_trend_dataframe(X, y)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_transform_returns_same_when_y_none(
+    decomposer_child_class,
+    ts_data,
+):
+    X, _, y = ts_data()
+    dec = decomposer_child_class().fit(X, y)
+    X_t, y_t = dec.transform(X, None)
+    pd.testing.assert_frame_equal(X, X_t)
+    assert y_t is None
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_raises_value_error_target_is_none(
+    decomposer_child_class,
+    ts_data,
+):
+    X, _, y = ts_data()
+
+    with pytest.raises(ValueError, match="cannot be None for Decomposer!"):
+        decomposer_child_class(degree=3).fit_transform(X, None)
+
+    with pytest.raises(ValueError, match="cannot be None for Decomposer!"):
+        decomposer_child_class(degree=3).fit(X, None)
+
+    dec = decomposer_child_class(degree=3).fit(X, y)
+
+    with pytest.raises(ValueError, match="cannot be None for Decomposer!"):
+        dec.inverse_transform(None)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_bad_target_index(
+    decomposer_child_class,
+    ts_data,
+):
+    X, _, y = ts_data()
+    dec = decomposer_child_class()
+    y.index = pd.CategoricalIndex(["cat_index" for x in range(len(y))])
+    with pytest.raises(
+        ValueError,
+        match="doesn't support target data with index of type",
+    ):
+        dec._choose_proper_index(y)
diff --git a/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py b/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py
index e6f8d897..b5acc13d 100644
--- a/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py
+++ b/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py
@@ -29,41 +29,6 @@ def test_polynomial_decomposer_init_raises_error_if_degree_not_int():
     PolynomialDecomposer(degree=3.0)
 
 
-def test_polynomial_decomposer_raises_value_error_target_is_none(ts_data):
-    X, _, y = ts_data()
-
-    with pytest.raises(ValueError, match="y cannot be None for PolynomialDecomposer!"):
-        PolynomialDecomposer(degree=3).fit_transform(X, None)
-
-    with pytest.raises(ValueError, match="y cannot be None for PolynomialDecomposer!"):
-        PolynomialDecomposer(degree=3).fit(X, None)
-
-    pdt = PolynomialDecomposer(degree=3).fit(X, y)
-
-    with pytest.raises(ValueError, match="y cannot be None for PolynomialDecomposer!"):
-        pdt.inverse_transform(None)
-
-
-def test_polynomial_decomposer_get_trend_dataframe_raises_errors(ts_data):
-    X, _, y = ts_data()
-    pdt = PolynomialDecomposer(degree=3)
-    pdt.fit_transform(X, y)
-
-    with pytest.raises(
-        TypeError,
-        match="Provided X should have datetimes in the index.",
-    ):
-        X_int_index = X.reset_index()
-        pdt.get_trend_dataframe(X_int_index, y)
-
-    with pytest.raises(
-        ValueError,
-        match="Provided DatetimeIndex of X should have an inferred frequency.",
-    ):
-        X.index.freq = None
-        pdt.get_trend_dataframe(X, y)
-
-
 def test_polynomial_decomposer_transform_returns_same_when_y_none(
     ts_data,
 ):
@@ -190,22 +155,6 @@ def test_polynomial_decomposer_get_trend_dataframe(
         ]
 
 
-@pytest.mark.parametrize("fit_before_decompose", [True, False])
-def test_polynomial_decomposer_get_trend_dataframe_error_not_fit(
-    ts_data,
-    fit_before_decompose,
-):
-    X, _, y = ts_data()
-
-    pdt = PolynomialDecomposer(degree=3)
-    if fit_before_decompose:
-        pdt.fit_transform(X, y)
-        pdt.get_trend_dataframe(X, y)
-    else:
-        with pytest.raises(ValueError):
-            pdt.get_trend_dataframe(X, y)
-
-
 @pytest.mark.parametrize("degree", [1, 2, 3])
 def test_polynomial_decomposer_inverse_transform(degree, ts_data):
     X, _, y = ts_data()
@@ -227,79 +176,6 @@ def test_polynomial_decomposer_needs_monotonic_index(ts_data):
     assert any([error in str(exec_info.value) for error in expected_errors])
 
 
-@pytest.mark.parametrize(
-    "frequency",
-    [
-        "D",
-        "W",
-        "S",
-        "h",
-        "T",
-        pytest.param(
-            "m",
-            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
-        ),
-        pytest.param(
-            "M",
-            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
-        ),
-        pytest.param(
-            "Y",
-            marks=pytest.mark.xfail(reason="Frequency considered ambiguous by pandas"),
-        ),
-    ],
-)
-@pytest.mark.parametrize(
-    "test_first_index",
-    ["on period", "before period", "just after period", "mid period"],
-)
-def test_polynomial_decomposer_build_seasonal_signal(
-    ts_data,
-    test_first_index,
-    frequency,
-):
-    period = 10
-    test_first_index = {
-        "on period": 3 * period,
-        "before period": 3 * period - 1,
-        "just after period": 3 * period + 1,
-        "mid period": 3 * period + 4,
-    }[test_first_index]
-
-    # Data spanning 2021-01-01 to 2021-02-09
-    X, _, y = ts_data()
-
-    # Change the date time index to start at the same time but have different frequency
-    y.set_axis(
-        pd.date_range(start="2021-01-01", periods=len(y), freq=frequency),
-        inplace=True,
-    )
-
-    decomposer = PolynomialDecomposer(degree=2)
-
-    # Synthesize a one-week long cyclic signal
-    single_period_seasonal_signal = np.sin(y[0:period] * 2 * np.pi / len(y[0:period]))
-    full_seasonal_signal = np.sin(y * 2 * np.pi / len(y[0:period]))
-
-    # Split the target data.  Since the period of this data is 7 days, we'll test
-    # when the cycle begins, an index before it begins, an index after it begins
-    # and in the middle of a cycle
-    y_test = y[test_first_index:]
-
-    projected_seasonality = decomposer._build_seasonal_signal(
-        y_test,
-        single_period_seasonal_signal,
-        period,
-        frequency,
-    )
-
-    # Make sure that the function extracted the correct portion of the repeating, full seasonal signal
-    assert np.allclose(
-        full_seasonal_signal[projected_seasonality.index],
-        projected_seasonality,
-    )
-
-
 @pytest.mark.parametrize("X_num_time_columns", [0, 1, 2, 3])
 @pytest.mark.parametrize(
     "X_has_time_index",
@@ -416,136 +292,3 @@ def test_polynomial_decomposer_plot_decomposition(
     assert isinstance(fig, matplotlib.pyplot.Figure)
     assert isinstance(axs, np.ndarray)
     assert all([isinstance(ax, matplotlib.pyplot.Axes) for ax in axs])
-
-
-@pytest.mark.parametrize(
-    "dataframe_has_datatime_index",
-    ["df_has_datetime_index", "df_doesnt_have_datetime_index"],
-)
-@pytest.mark.parametrize(
-    "multiple_time_features",
-    ["has_another_time_feature", "doesnt_have_another_time_feature"],
-)
-@pytest.mark.parametrize(
-    "time_index_exists",
-    ["time_index_does_exist", "time_index_does_not_exist"],
-)
-def test_polynomial_decomposer_prefers_users_time_index(
-    dataframe_has_datatime_index,
-    multiple_time_features,
-    time_index_exists,
-    ts_data,
-):
-    periods = 30
-    dates_1 = pd.date_range("2020-01-01", periods=periods)
-    dates_2 = pd.date_range("2021-01-01", periods=periods)
-    dates_3 = pd.date_range("2022-01-01", periods=periods)
-    vals = np.arange(0, periods)
-    y = pd.Series(vals)
-    X = pd.DataFrame({"values": vals})
-
-    if time_index_exists == "time_index_does_exist":
-        X["dates"] = dates_1
-
-    if multiple_time_features == "has_another_time_feature":
-        X["more_dates"] = dates_2
-
-    if dataframe_has_datatime_index == "df_has_datetime_index":
-        X.set_axis(pd.DatetimeIndex(dates_3))
-
-    pdc = PolynomialDecomposer(time_index="dates")
-
-    err_msg = None
-    if time_index_exists == "time_index_does_not_exist":
-        if multiple_time_features == "has_another_time_feature":
-            expected_values = dates_2.values
-        else:
-            err_msg = "There are no Datetime features in the feature data and neither the feature nor the target data have a DateTime index."
-    else:
-        expected_values = dates_1.values
-
-    if err_msg:
-        with pytest.raises(ValueError, match=err_msg):
-            X_t, y_t = pdc.fit_transform(X, y)
-    else:
-        X_t, y_t = pdc.fit_transform(X, y)
-        assert all(y_t.index.values == expected_values)
-
-
-@pytest.mark.parametrize(
-    "periodicity_determination_method",
-    [
-        "autocorrelation",
-        pytest.param(
-            "partial-autocorrelation",
-            marks=pytest.mark.xfail(reason="Partial Autocorrelation not working yet."),
-        ),
-    ],
-)
-@pytest.mark.parametrize(
-    "period",
-    [
-        7,
-        30,
-        365,
-        pytest.param(
-            None,
-            marks=pytest.mark.xfail(
-                reason="Don't have a good heuristic to distinguish bad period guess.",
-            ),
-        ),
-    ],
-)
-@pytest.mark.parametrize("trend_degree", [1, 2, 3])
-@pytest.mark.parametrize("decomposer_picked_correct_degree", [True, False])
-@pytest.mark.parametrize("synthetic_data", ["synthetic", "real"])
-def test_polynomial_decomposer_determine_periodicity(
-    period,
-    trend_degree,
-    decomposer_picked_correct_degree,
-    periodicity_determination_method,
-    synthetic_data,
-    generate_seasonal_data,
-):
-
-    X, y = generate_seasonal_data(real_or_synthetic=synthetic_data)(
-        period,
-        trend_degree=trend_degree,
-    )
-
-    if period is None:
-        component_period = 1
-    else:
-        component_period = period
-
-    # Test that the seasonality can be determined if trend guess isn't spot on.
-    if not decomposer_picked_correct_degree:
-        trend_degree = 1 if trend_degree in [2, 3] else 2
-
-    pdc = PolynomialDecomposer(degree=trend_degree, seasonal_period=component_period)
-    ac = pdc.determine_periodicity(X, y, method=periodicity_determination_method)
-
-    if synthetic_data == "synthetic":
-        if period is None:
-            assert ac is None
-        else:
-            assert period - 1 <= ac <= period + 1
-    else:
-        if period is None:
-            assert ac is None
-        else:
-            assert 360 < ac < 370
-
-
-@pytest.mark.parametrize("period", [7, 30, 365])
-def test_polynomial_decomposer_set_period(period, generate_seasonal_data):
-    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(period)
-    pdc = PolynomialDecomposer()
-
-    assert pdc.seasonal_period == -1
-    assert pdc.parameters["seasonal_period"] == -1
-
-    pdc.set_seasonal_period(X, y)
-
-    assert period - 1 <= pdc.seasonal_period <= period + 1
-    assert pdc.parameters["seasonal_period"]
diff --git a/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py b/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py
new file mode 100644
index 00000000..a27f0fac
--- /dev/null
+++ b/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py
@@ -0,0 +1,466 @@
+import datetime
+
+import numpy as np
+import pandas as pd
+import pytest
+from sklearn.linear_model import LinearRegression
+from sklearn.preprocessing import PolynomialFeatures
+
+from evalml.pipelines.components import STLDecomposer
+
+
+def test_stl_decomposer_init():
+    delayed_features = STLDecomposer(degree=3, time_index="dates")
+    assert delayed_features.parameters == {
+        "degree": 3,
+        "seasonal_period": 7,
+        "time_index": "dates",
+    }
+
+
+def test_stl_decomposer_init_raises_error_if_degree_not_int():
+
+    with pytest.raises(TypeError, match="Received str"):
+        STLDecomposer(degree="1")
+
+    with pytest.raises(TypeError, match="Received float"):
+        STLDecomposer(degree=3.4)
+
+    STLDecomposer(degree=3.0)
+
+
+def test_stl_decomposer_auto_sets_seasonal_period_to_odd(ts_data):
+    X, _, y = ts_data()
+
+    stl = STLDecomposer(seasonal_period=3)
+    assert stl.seasonal_period == 3
+
+    stl = STLDecomposer(seasonal_period=4)
+    assert stl.seasonal_period == 5
+
+
+def build_test_target(subset_y, seasonal_period, transformer_fit_on_data, to_test):
+    """Function to build a sample target.  Based on subset_y being daily data containing 5 periods of a periodic signal."""
+    if transformer_fit_on_data == "in-sample-less-than-sample":
+        # Re-compose 14-days worth of data within, but not spanning the entire sample
+        delta = -3
+    if transformer_fit_on_data == "wholly-out-of-sample":
+        # Re-compose 14-days worth of data with a one period gap between end of
+        # fit data and start of data to inverse-transform
+        delta = seasonal_period
+    elif transformer_fit_on_data == "wholly-out-of-sample-no-gap":
+        # Re-compose 14-days worth of data with no gap between end of
+        # fit data and start of data to inverse-transform
+        delta = 1
+    elif transformer_fit_on_data == "partially-out-of-sample":
+        # Re-compose 14-days worth of data overlapping the in and out-of
+        # sample data.
+        delta = -1
+    elif transformer_fit_on_data == "out-of-sample-in-past":
+        # Re-compose 14-days worth of data both out of sample and in the
+        # past.
+        delta = -12
+    elif transformer_fit_on_data == "partially-out-of-sample-in-past":
+        # Re-compose 14-days worth of data partially out of sample and in the
+        # past.
+        delta = -6
+
+    if isinstance(subset_y.index, pd.DatetimeIndex):
+        delta = datetime.timedelta(days=delta * seasonal_period)
+
+        new_index = pd.date_range(
+            subset_y.index[-1] + delta,
+            periods=2 * seasonal_period,
+            freq="D",
+        )
+    else:
+        delta = delta * seasonal_period
+        new_index = np.arange(
+            subset_y.index[-1] + delta,
+            subset_y.index[-1] + delta + 2 * seasonal_period,
+        )
+
+    if to_test == "inverse_transform":
+        y_t_new = pd.Series(np.zeros(len(new_index))).set_axis(new_index)
+    elif to_test == "transform":
+        y_t_new = pd.Series(np.sin([x for x in range(len(new_index))])).set_axis(
+            new_index,
+        )
+    return y_t_new
+
+
+@pytest.mark.parametrize(
+    "period,freq",
+    [
+        (7, "D"),  # Weekly season
+        pytest.param(
+            30,
+            "D",
+            marks=pytest.mark.xfail(
+                reason="STL doesn't perform well on seasonal data with high periods.",
+            ),
+        ),
+        pytest.param(
+            365,
+            "D",
+            marks=pytest.mark.xfail(
+                reason="STL doesn't perform well on seasonal data with high periods.",
+            ),
+        ),
+        (12, "M"),  # Annual season
+        (4, "M"),  # Quarterly season
+    ],
+)
+@pytest.mark.parametrize("trend_degree", [1, 2, 3])
+@pytest.mark.parametrize("synthetic_data", ["synthetic"])
+def test_stl_fit_transform_in_sample(
+    period,
+    freq,
+    trend_degree,
+    synthetic_data,
+    generate_seasonal_data,
+):
+
+    X, y = generate_seasonal_data(real_or_synthetic=synthetic_data)(
+        period,
+        freq_str=freq,
+        trend_degree=trend_degree,
+    )
+
+    # Get the expected answer
+    lin_reg = LinearRegression(fit_intercept=True)
+    features = PolynomialFeatures(degree=trend_degree).fit_transform(
+        np.arange(X.shape[0]).reshape(-1, 1),
+    )
+    lin_reg.fit(features, y)
+    expected_trend = lin_reg.predict(features)
+
+    stl = STLDecomposer(seasonal_period=period)
+
+    X_t, y_t = stl.fit_transform(X, y)
+
+    # Check to make sure STL detrended/deseasoned
+    pd.testing.assert_series_equal(
+        pd.Series(np.zeros(len(y_t))),
+        y_t,
+        check_exact=False,
+        check_index=False,
+        check_names=False,
+        atol=0.1,
+    )
+
+    # Check the trend to make sure STL worked properly
+    pd.testing.assert_series_equal(
+        pd.Series(expected_trend),
+        pd.Series(stl.trend),
+        check_exact=False,
+        check_index=False,
+        check_names=False,
+        atol=0.3,
+    )
+
+    # Verify the X is not changed
+    pd.testing.assert_frame_equal(X, X_t)
+
+
+@pytest.mark.parametrize(
+    "transformer_fit_on_data",
+    [
+        "in-sample",
+        "in-sample-less-than-sample",
+        "wholly-out-of-sample",
+        "wholly-out-of-sample-no-gap",
+        "partially-out-of-sample",
+        "out-of-sample-in-past",
+        "partially-out-of-sample-in-past",
+    ],
+)
+def test_stl_decomposer_fit_transform_out_of_sample(
+    generate_seasonal_data,
+    transformer_fit_on_data,
+):
+    # Generate 10 periods (the default) of synthetic seasonal data
+    seasonal_period = 7
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=seasonal_period,
+        freq_str="D",
+        set_time_index=True,
+    )
+    subset_X = X[2 * seasonal_period : 7 * seasonal_period]
+    subset_y = y[2 * seasonal_period : 7 * seasonal_period]
+
+    decomposer = STLDecomposer(seasonal_period=seasonal_period)
+    decomposer.fit(subset_X, subset_y)
+
+    if transformer_fit_on_data == "in-sample":
+        output_X, output_y = decomposer.transform(subset_X, subset_y)
+        pd.testing.assert_series_equal(
+            pd.Series(np.zeros(len(output_y))).set_axis(subset_y.index),
+            output_y,
+            check_dtype=False,
+            check_names=False,
+        )
+
+    if transformer_fit_on_data != "in-sample":
+        y_new = build_test_target(
+            subset_y,
+            seasonal_period,
+            transformer_fit_on_data,
+            to_test="transform",
+        )
+        if transformer_fit_on_data in [
+            "out-of-sample-in-past",
+            "partially-out-of-sample-in-past",
+        ]:
+            with pytest.raises(
+                ValueError,
+                match="STLDecomposer cannot transform/inverse transform data out of sample",
+            ):
+                output_X, output_inverse_y = decomposer.transform(None, y_new)
+        else:
+            output_X, output_y_t = decomposer.transform(None, y[y_new.index])
+
+            pd.testing.assert_series_equal(
+                pd.Series(np.zeros(len(output_y_t))).set_axis(y_new.index),
+                output_y_t,
+                check_exact=False,
+                atol=5.0e-4,
+            )
+
+
+@pytest.mark.parametrize("index_type", ["integer_index", "datetime_index"])
+@pytest.mark.parametrize(
+    "transformer_fit_on_data",
+    [
+        "in-sample",
+        "in-sample-less-than-sample",
+        "wholly-out-of-sample",
+        "wholly-out-of-sample-no-gap",
+        "partially-out-of-sample",
+        "out-of-sample-in-past",
+        "partially-out-of-sample-in-past",
+    ],
+)
+def test_stl_decomposer_inverse_transform(
+    index_type,
+    generate_seasonal_data,
+    transformer_fit_on_data,
+):
+    # Generate 10 periods (the default) of synthetic seasonal data
+    seasonal_period = 7
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=seasonal_period,
+        freq_str="D",
+        set_time_index=True,
+    )
+    if index_type == "integer_index":
+        y = y.reset_index(drop=True)
+    subset_X = X[: 5 * seasonal_period]
+    subset_y = y[: 5 * seasonal_period]
+
+    decomposer = STLDecomposer(seasonal_period=seasonal_period)
+    output_X, output_y = decomposer.fit_transform(subset_X, subset_y)
+
+    if transformer_fit_on_data == "in-sample":
+        output_inverse_y = decomposer.inverse_transform(output_y)
+        pd.testing.assert_series_equal(subset_y, output_inverse_y, check_dtype=False)
+
+    if transformer_fit_on_data != "in-sample":
+        y_t_new = build_test_target(
+            subset_y,
+            seasonal_period,
+            transformer_fit_on_data,
+            to_test="inverse_transform",
+        )
+        if transformer_fit_on_data in [
+            "out-of-sample-in-past",
+            "partially-out-of-sample-in-past",
+        ]:
+            with pytest.raises(
+                ValueError,
+                match="STLDecomposer cannot transform/inverse transform data out of sample",
+            ):
+                output_inverse_y = decomposer.inverse_transform(y_t_new)
+        else:
+            output_inverse_y = decomposer.inverse_transform(y_t_new)
+            pd.testing.assert_series_equal(
+                y[y_t_new.index],
+                output_inverse_y,
+                check_exact=False,
+                rtol=1.0e-2,
+            )
+
+
+@pytest.mark.parametrize(
+    "transformer_fit_on_data",
+    [
+        "in-sample",
+        "in-sample-less-than-sample",
+        "wholly-out-of-sample",
+        "wholly-out-of-sample-no-gap",
+        "partially-out-of-sample",
+        "out-of-sample-in-past",
+        "partially-out-of-sample-in-past",
+    ],
+)
+@pytest.mark.parametrize(
+    "variateness",
+    [
+        "univariate",
+        "multivariate",
+    ],
+)
+@pytest.mark.parametrize("fit_before_decompose", [True, False])
+def test_stl_decomposer_get_trend_dataframe(
+    generate_seasonal_data,
+    transformer_fit_on_data,
+    fit_before_decompose,
+    variateness,
+):
+    def get_trend_dataframe_format_correct(df):
+        return set(df.columns) == {"signal", "trend", "seasonality", "residual"}
+
+    seasonal_period = 7
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=seasonal_period,
+        freq_str="D",
+        set_time_index=True,
+    )
+    subset_X = X[: 5 * seasonal_period]
+    subset_y = y[: 5 * seasonal_period]
+
+    if transformer_fit_on_data == "in-sample":
+        dec = STLDecomposer()
+        dec.fit(subset_X, subset_y)
+
+        # get_trend_dataframe() is only expected to work with datetime indices
+        if variateness == "multivariate":
+            subset_y = pd.concat([subset_y, subset_y], axis=1)
+
+        result_dfs = dec.get_trend_dataframe(subset_X, subset_y)
+
+        assert isinstance(result_dfs, list)
+        assert all(isinstance(x, pd.DataFrame) for x in result_dfs)
+        assert all(get_trend_dataframe_format_correct(x) for x in result_dfs)
+        if variateness == "univariate":
+            assert len(result_dfs) == 1
+            [get_trend_dataframe_format_correct(x) for x in result_dfs]
+        elif variateness == "multivariate":
+            assert len(result_dfs) == 2
+            [get_trend_dataframe_format_correct(x) for idx, x in enumerate(result_dfs)]
+
+    elif transformer_fit_on_data != "in-sample":
+
+        y_t_new = build_test_target(
+            subset_y,
+            seasonal_period,
+            transformer_fit_on_data,
+            to_test="transform",
+        )
+        dec = STLDecomposer()
+        dec.fit(subset_X, subset_y)
+
+        # get_trend_dataframe() is only expected to work with datetime indices
+        if variateness == "multivariate":
+            y_t_new = pd.concat([y_t_new, y_t_new], axis=1)
+
+        if transformer_fit_on_data in [
+            "out-of-sample-in-past",
+            "partially-out-of-sample-in-past",
+        ]:
+            with pytest.raises(
+                ValueError,
+                match="STLDecomposer cannot transform/inverse transform data out of sample",
+            ):
+                result_dfs = dec.get_trend_dataframe(X, y_t_new)
+
+        else:
+            result_dfs = dec.get_trend_dataframe(X.loc[y_t_new.index], y_t_new)
+
+            assert isinstance(result_dfs, list)
+            assert all(isinstance(x, pd.DataFrame) for x in result_dfs)
+            assert all(get_trend_dataframe_format_correct(x) for x in result_dfs)
+            if variateness == "univariate":
+                assert len(result_dfs) == 1
+                [get_trend_dataframe_format_correct(x) for x in result_dfs]
+            elif variateness == "multivariate":
+                assert len(result_dfs) == 2
+                [
+                    get_trend_dataframe_format_correct(x)
+                    for idx, x in enumerate(result_dfs)
+                ]
+
+
+def test_stl_decomposer_get_trend_dataframe_sets_time_index_internally(
+    generate_seasonal_data,
+):
+    def get_trend_dataframe_format_correct(df):
+        return set(df.columns) == {"signal", "trend", "seasonality", "residual"}
+
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=7,
+        set_time_index=False,
+    )
+    assert not isinstance(y.index, pd.DatetimeIndex)
+
+    stl = STLDecomposer()
+    stl.fit(X, y)
+    result_dfs = stl.get_trend_dataframe(X, y)
+
+    assert isinstance(result_dfs, list)
+    assert all(isinstance(x, pd.DataFrame) for x in result_dfs)
+    assert all(get_trend_dataframe_format_correct(x) for x in result_dfs)
+
+
+@pytest.mark.parametrize(
+    "bad_frequency",
+    [
+        pytest.param(
+            "T",
+            marks=pytest.mark.xfail(
+                reason="statsmodels freq_to_period doesn't support this frequency",
+            ),
+        ),
+        pytest.param(
+            "A",
+            marks=pytest.mark.xfail(
+                reason="statsmodels freq_to_period doesn't support this frequency",
+            ),
+        ),
+    ],
+)
+def test_unsupported_frequencies(
+    bad_frequency,
+    generate_seasonal_data,
+):
+    """This test exists to highlight that the underlying statsmodels STL component won't work for minute or annual frequencies."""
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=7,
+        freq_str=bad_frequency,
+    )
+
+    stl = STLDecomposer()
+    X_t, y_t = stl.fit_transform(X, y)
+
+
+def test_stl_decomposer_doesnt_modify_target_index(
+    generate_seasonal_data,
+):
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=7,
+        set_time_index=False,
+    )
+    original_X_index = X.index
+    original_y_index = y.index
+
+    stl = STLDecomposer()
+    stl.fit(X, y)
+    pd.testing.assert_index_equal(X.index, original_X_index)
+    pd.testing.assert_index_equal(y.index, original_y_index)
+
+    X_t, y_t = stl.transform(X, y)
+    pd.testing.assert_index_equal(X_t.index, original_X_index)
+    pd.testing.assert_index_equal(y_t.index, original_y_index)
+
+    y_new = stl.inverse_transform(y_t)
+    pd.testing.assert_index_equal(y_new.index, original_y_index)
diff --git a/evalml/tests/component_tests/test_components.py b/evalml/tests/component_tests/test_components.py
index 47109542..fc48ce36 100644
--- a/evalml/tests/component_tests/test_components.py
+++ b/evalml/tests/component_tests/test_components.py
@@ -54,6 +54,7 @@ from evalml.pipelines.components import (
     SelectColumns,
     SimpleImputer,
     StandardScaler,
+    STLDecomposer,
     SVMClassifier,
     SVMRegressor,
     TargetImputer,
@@ -841,9 +842,10 @@ def test_transformer_transform_output_type(component_class, X_y_binary):
     ]
 
     if component_class in [
-        PolynomialDecomposer,
         LogTransformer,
         LabelEncoder,
+        PolynomialDecomposer,
+        STLDecomposer,
         TimeSeriesRegularizer,
     ]:
         pytest.skip(
@@ -1111,6 +1113,7 @@ def test_all_transformers_check_fit(component_class, X_y_binary, ts_data):
         TimeSeriesFeaturizer,
         TimeSeriesRegularizer,
         PolynomialDecomposer,
+        STLDecomposer,
     ]:
         X, _, y = ts_data(problem_type="time series binary")
         component = component_class(time_index="date")
@@ -1237,6 +1240,7 @@ def test_all_transformers_check_fit_input_type(
         TimeSeriesFeaturizer,
         TimeSeriesRegularizer,
         PolynomialDecomposer,
+        STLDecomposer,
     ]:
         X, _, y = ts_data(problem_type="time series binary")
         kwargs = {"time_index": "date"}
@@ -1277,6 +1281,7 @@ def test_serialization(
         TimeSeriesFeaturizer,
         TimeSeriesRegularizer,
         PolynomialDecomposer,
+        STLDecomposer,
     ]
 
     print("Testing serialization of component {}".format(component_class.name))
@@ -1577,10 +1582,10 @@ def test_transformer_fit_and_transform_respect_custom_indices(
         check_names = False
         if use_custom_index:
             pytest.skip("The DFSTransformer changes the index so we skip it.")
-    if transformer_class == PolynomialDecomposer:
+    if transformer_class in [PolynomialDecomposer, STLDecomposer]:
         pytest.skip(
-            "Skipping PolynomialDecomposer because we test that it respects custom indices in "
-            "test_polynomial_decomposer.py",
+            "Skipping Decomposer because we test that it respects custom indices in "
+            "test_decomposer.py",
         )
 
     X, y = X_y_binary
diff --git a/evalml/tests/component_tests/test_utils.py b/evalml/tests/component_tests/test_utils.py
index aac9e16f..ef6baa62 100644
--- a/evalml/tests/component_tests/test_utils.py
+++ b/evalml/tests/component_tests/test_utils.py
@@ -89,6 +89,7 @@ all_requirements_set = set(
         "Oversampler",
         "Ordinal Encoder",
         "Polynomial Decomposer",
+        "STL Decomposer",
         "Prophet Regressor",
         "Target Encoder",
         "Vowpal Wabbit Binary Classifier",
diff --git a/evalml/tests/conftest.py b/evalml/tests/conftest.py
index 2d8299e6..58194297 100644
--- a/evalml/tests/conftest.py
+++ b/evalml/tests/conftest.py
@@ -2212,7 +2212,15 @@ def imputer_test_data():
 def generate_seasonal_data():
     """Function that returns data with a linear trend and a seasonal signal with specified period."""
 
-    def generate_real_data(period, step=None, num_periods=10, scale=1, trend_degree=1):
+    def generate_real_data(
+        period,
+        step=None,
+        num_periods=20,
+        scale=1,
+        trend_degree=1,
+        freq_str="D",
+        set_time_index=False,
+    ):
         X, y = load_weather()
         y = y.set_axis(X["Date"]).asfreq(pd.infer_freq(X["Date"]))
         X = X.set_index("Date").asfreq(pd.infer_freq(X["Date"]))
@@ -2221,9 +2229,11 @@ def generate_seasonal_data():
     def generate_synthetic_data(
         period,
         step=None,
-        num_periods=10,
+        num_periods=20,
         scale=1,
         trend_degree=1,
+        freq_str="D",
+        set_time_index=False,
     ):
         if period is None:
             x = np.arange(0, 1, 0.01)
@@ -2233,7 +2243,7 @@ def generate_seasonal_data():
         else:
             freq = 2 * np.pi / period
             x = np.arange(0, period * num_periods, 1)
-        dts = pd.date_range(datetime.today(), periods=len(x))
+        dts = pd.date_range(datetime.today(), periods=len(x), freq=freq_str)
         X = pd.DataFrame({"x": x})
         X = X.set_index(dts)
 
@@ -2248,6 +2258,8 @@ def generate_seasonal_data():
         else:
             y_seasonal = pd.Series(np.zeros(len(x)))
         y = y_trend + y_seasonal
+        if set_time_index:
+            y = y.set_axis(dts)
         return X, y
 
     def _return_proper_func(real_or_synthetic):