Repository: alteryx/evalml
Pull request #3768 (2022-10-20 16:44:00)

Get PolynomialDecomposer to Parity with STLDecomposer
A follow up PR to move more of the tests over to `test_decomposer.py` and parametrize them between the two child decomposers in order to prepare for more decomposers.


codecov[bot]: # [Codecov](https://codecov.io/gh/alteryx/evalml/pull/3768?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx) Report
> Merging [#3768](https://codecov.io/gh/alteryx/evalml/pull/3768?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx) (d11e500) into [stl_decomposer](https://codecov.io/gh/alteryx/evalml/commit/c2710443f9f24b260bdc769529f887310a9cb487?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx) (c271044) will **decrease** coverage by `48.4%`.
> The diff coverage is `0.0%`.

```diff
@@                Coverage Diff                @@
##           stl_decomposer   #3768      +/-   ##
=================================================
- Coverage            99.7%   51.3%   -48.3%     
=================================================
  Files                 343     343              
  Lines               35696   35703       +7     
=================================================
- Hits                35560   18302   -17258     
- Misses                136   17401   +17265     
```


| [Impacted Files](https://codecov.io/gh/alteryx/evalml/pull/3768?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx) | Coverage Δ | |
|---|---|---|
| [...omponent\_tests/decomposer\_tests/test\_decomposer.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy9kZWNvbXBvc2VyX3Rlc3RzL3Rlc3RfZGVjb21wb3Nlci5weQ==) | `0.0% <0.0%> (-99.4%)` | :arrow_down: |
| [...sts/decomposer\_tests/test\_polynomial\_decomposer.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy9kZWNvbXBvc2VyX3Rlc3RzL3Rlc3RfcG9seW5vbWlhbF9kZWNvbXBvc2VyLnB5) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [...nent\_tests/decomposer\_tests/test\_stl\_decomposer.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy9kZWNvbXBvc2VyX3Rlc3RzL3Rlc3Rfc3RsX2RlY29tcG9zZXIucHk=) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/automl\_tests/test\_search.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2F1dG9tbF90ZXN0cy90ZXN0X3NlYXJjaC5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/component\_tests/test\_lda.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy90ZXN0X2xkYS5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/component\_tests/test\_lsa.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy90ZXN0X2xzYS5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/component\_tests/test\_pca.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2NvbXBvbmVudF90ZXN0cy90ZXN0X3BjYS5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/demo\_tests/test\_datasets.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2RlbW9fdGVzdHMvdGVzdF9kYXRhc2V0cy5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/objective\_tests/test\_sla.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL29iamVjdGl2ZV90ZXN0cy90ZXN0X3NsYS5weQ==) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| [evalml/tests/automl\_tests/test\_progress.py](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx#diff-ZXZhbG1sL3Rlc3RzL2F1dG9tbF90ZXN0cy90ZXN0X3Byb2dyZXNzLnB5) | `0.0% <0.0%> (-100.0%)` | :arrow_down: |
| ... and [210 more](https://codecov.io/gh/alteryx/evalml/pull/3768/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=alteryx)


Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index d2b57e41..8045bcb4 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -2,6 +2,8 @@ Release Notes
 -------------
 **Future Releases**
     * Enhancements
+        * Added the STLDecomposer :pr:`3741`
+        * Brought the PolynomialDecomposer up to parity with STLDecomposer :pr:`3768`
     * Fixes
     * Changes
         * Cap Featuretools at < 1.15.0 :pr:`3775`
@@ -17,7 +19,6 @@ 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/transformers/preprocessing/decomposer.py b/evalml/pipelines/components/transformers/preprocessing/decomposer.py
index 6c82c584..359436f0 100644
--- a/evalml/pipelines/components/transformers/preprocessing/decomposer.py
+++ b/evalml/pipelines/components/transformers/preprocessing/decomposer.py
@@ -221,13 +221,60 @@ class Decomposer(Transformer):
         self.seasonal_period = self.determine_periodicity(X, y)
         self.parameters.update({"seasonal_period": self.seasonal_period})
 
+    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 _map_dt_to_integer(self, original_index, dt_index):
+        """Function to generate an initial mapping of integer indices to datetime indices."""
+        # Set an initial mapping of integers <-> datetimes at fit
+        if isinstance(original_index, pd.DatetimeIndex):
+            int_index = pd.RangeIndex(len(original_index))
+        # Standardize the integer index as a RangeIndex and use existing integer indices
+        elif isinstance(original_index, (pd.RangeIndex, Int64Index)):
+            int_index = pd.RangeIndex(original_index)
+
+        assert isinstance(dt_index, pd.DatetimeIndex)
+        assert len(original_index) == len(dt_index)
+
+        self.in_sample_integer_index = int_index
+        self.in_sample_datetime_index = dt_index
+
+    def _int_to_dt(self, integer_index_value):
+        """Function to convert an integer index value to a datetime value based on the mapping made during fit."""
+        try:
+            dt = self.in_sample_datetime_index[
+                self.in_sample_integer_index.get_loc(integer_index_value)
+            ]
+        except KeyError:
+            more_than = integer_index_value - self.in_sample_integer_index[-1]
+            dt = (
+                self.in_sample_datetime_index.freq * more_than
+                + self.in_sample_datetime_index[-1]
+            )
+        return dt
+
+    def _convert_int_index_to_dt_index(self, integer_index):
+        """Function to convert an entire index full of integers to datetimes."""
+        dts = [self._int_to_dt(integer) for integer in integer_index]
+        dt_index = pd.DatetimeIndex(dts, freq=self.frequency)
+        return dt_index
+
     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
+            index = self.in_sample_integer_index
         elif isinstance(y.index, pd.DatetimeIndex):
-            index = self.trend.index
+            index = self.in_sample_datetime_index
         else:
             raise ValueError(
                 f"Decomposer doesn't support target data with index of type ({type(y.index)})",
diff --git a/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py b/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
index dafe2420..2986b258 100644
--- a/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
+++ b/evalml/pipelines/components/transformers/preprocessing/polynomial_decomposer.py
@@ -3,8 +3,8 @@ from __future__ import annotations
 
 import logging
 
-import numpy as np
 import pandas as pd
+from pandas.core.index import Int64Index
 from skopt.space import Integer
 from sktime.forecasting.base._fh import ForecastingHorizon
 from statsmodels.tsa.seasonal import seasonal_decompose
@@ -90,11 +90,20 @@ class PolynomialDecomposer(Decomposer):
             self
 
         Raises:
+            NotImplementedError: If the input data has a frequency of "month-begin".  This isn't supported by statsmodels decompose
+                as the freqstr "MS" is misinterpreted as milliseconds.
             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)
+        self._map_dt_to_integer(self.original_index, y.index)
+
+        if y.index.freqstr == "MS":
+            raise NotImplementedError(
+                "statsmodels decompose does not handle datasets with month-begin (e.g. 10/01/2000, 11/01/2000)"
+                "datetime data.  These values are incorrectly interpreted as milliseconds.",
+            )
 
         # Copying y as we might modify its index
         y_orig = infer_feature_types(y).copy()
@@ -144,8 +153,11 @@ class PolynomialDecomposer(Decomposer):
         """
         if y is None:
             return X, y
+        original_index = y.index
         X, y = self._check_target(X, y)
 
+        self._check_oos_past(y)
+
         # Give the internal target signal a datetime index built from X
         y = y.copy()
 
@@ -153,15 +165,16 @@ class PolynomialDecomposer(Decomposer):
         y_ww = infer_feature_types(y)
         y_detrended = self._component_obj.transform(y_ww)
 
-        if isinstance(y.index, pd.DatetimeIndex):
-            # Repeat the seasonal signal over the target data
-            seasonal = np.tile(
-                self.seasonality.T,
-                len(y_detrended) // self.seasonal_period + 1,
-            ).T[: len(y_detrended)]
+        seasonal = self._project_seasonal(
+            y,
+            self.seasonality,
+            self.seasonal_period,
+            self.frequency,
+        )
 
         y_t = pd.Series(y_detrended - seasonal)
         y_t.ww.init(logical_type="double")
+        y_t.index = original_index
         return X, y_t
 
     def inverse_transform(self, y_t: pd.Series) -> tuple[pd.DataFrame, pd.Series]:
@@ -180,20 +193,80 @@ class PolynomialDecomposer(Decomposer):
         Raises:
             ValueError: If y is None.
         """
-        _, y_t = self._check_target(None, y_t)
-
-        y_t = infer_feature_types(y_t)
+        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).copy()
+        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 = min(y_t.index[-1], index[-1])
+
+            if isinstance(y_t.index, (pd.RangeIndex, Int64Index)):
+                y_t_ind = pd.RangeIndex(
+                    start=left_index,
+                    stop=right_index + 1,
+                )  # stop value is not inclusive
+            elif isinstance(y_t.index, pd.DatetimeIndex):
+                y_t_ind = pd.DatetimeIndex(
+                    pd.date_range(left_index, end=right_index),
+                )  # end value is inclusive
+
+            # Build index
+            y_t_in_sample = y_t[y_t_ind]
+
+            # Convert y_t to datetime index to use the built in component
+            if isinstance(y_t.index, (pd.RangeIndex, Int64Index)):
+                y_t_dt_ind = self._convert_int_index_to_dt_index(y_t_in_sample.index)
+            elif isinstance(y_t.index, pd.DatetimeIndex):
+                y_t_dt_ind = y_t_ind
+            trend = self._component_obj.inverse_transform(
+                y_t_in_sample.set_axis(y_t_dt_ind),
+            )
 
-        # Add polynomial trend back to signal
-        y_retrended = self._component_obj.inverse_transform(y_t)
+            # self.seasonal will always have a datetime index
+            seasonal = self.seasonal[y_t_dt_ind].set_axis(y_t_dt_ind)
+            y_in_sample = 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_seasonality = self._project_seasonal(
+                truncated_y_t,
+                self.seasonality,
+                self.seasonal_period,
+                self.frequency,
+            )
 
-        seasonal = self._project_seasonal(
-            y_t,
-            self.seasonality,
-            self.seasonal_period,
-            self.frequency,
-        )
-        y = infer_feature_types(pd.Series(y_retrended + seasonal, index=y_t.index))
+            if isinstance(truncated_y_t.index, (pd.RangeIndex, Int64Index)):
+                dt_index = self._convert_int_index_to_dt_index(truncated_y_t.index)
+                truncated_y_t.index = dt_index
+                projected_seasonality.index = dt_index
+            retrended_y = self._component_obj.inverse_transform(truncated_y_t)
+
+            y_out_of_sample = infer_feature_types(
+                pd.Series(
+                    retrended_y + 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: pd.DataFrame, y: pd.Series) -> list[pd.DataFrame]:
diff --git a/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py b/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py
index f940c214..196d5c4f 100644
--- a/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py
+++ b/evalml/pipelines/components/transformers/preprocessing/stl_decomposer.py
@@ -64,18 +64,6 @@ class STLDecomposer(Decomposer):
             **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)
@@ -160,6 +148,7 @@ class STLDecomposer(Decomposer):
         """
         self.original_index = y.index if y is not None else None
         X, y = self._check_target(X, y)
+        self._map_dt_to_integer(self.original_index, y.index)
 
         # Warn for poor decomposition use with higher periods
         if self.seasonal_period > 14:


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 e9a3234b..9a6455b5 100644
--- a/evalml/tests/component_tests/decomposer_tests/test_decomposer.py
+++ b/evalml/tests/component_tests/decomposer_tests/test_decomposer.py
@@ -1,5 +1,5 @@
 import itertools
-from datetime import datetime
+from datetime import datetime, timedelta
 
 import matplotlib
 import numpy as np
@@ -16,6 +16,10 @@ from evalml.pipelines.components.transformers.preprocessing import (
 decomposer_list = [STLDecomposer, PolynomialDecomposer]
 
 
+def get_trend_dataframe_format_correct(df):
+    return set(df.columns) == {"signal", "trend", "seasonality", "residual"}
+
+
 @pytest.mark.parametrize(
     "decomposer_child_class",
     decomposer_list,
@@ -34,6 +38,21 @@ def test_set_time_index(decomposer_child_class):
     assert isinstance(y_time_index.index, pd.DatetimeIndex)
 
 
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_init_raises_error_if_degree_not_int(decomposer_child_class):
+
+    with pytest.raises(TypeError, match="Received str"):
+        decomposer_child_class(degree="1")
+
+    with pytest.raises(TypeError, match="Received float"):
+        decomposer_child_class(degree=3.4)
+
+    decomposer_child_class(degree=3.0)
+
+
 @pytest.mark.parametrize(
     "decomposer_child_class",
     decomposer_list,
@@ -292,7 +311,7 @@ def test_decomposer_build_seasonal_signal(
     # 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
+    decomposer.in_sample_datetime_index = full_seasonal_signal.index
 
     projected_seasonality = decomposer._project_seasonal(
         y_test,
@@ -486,3 +505,246 @@ def test_decomposer_bad_target_index(
         match="doesn't support target data with index of type",
     ):
         dec._choose_proper_index(y)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@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_decomposer_fit_transform_out_of_sample(
+    decomposer_child_class,
+    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,
+        seasonal_scale=0.05,  # Increasing this value causes the decomposer to miscalculate trend
+    )
+    subset_X = X[2 * seasonal_period : 7 * seasonal_period]
+    subset_y = y[2 * seasonal_period : 7 * seasonal_period]
+
+    decomposer = decomposer_child_class(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,
+            atol=0.2,
+        )
+
+    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=0.1,  # STLDecomposer is within atol=5.0e-4
+            )
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+@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_decomposer_inverse_transform(
+    decomposer_child_class,
+    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,
+        seasonal_scale=0.05,
+    )
+    if index_type == "integer_index":
+        y = y.reset_index(drop=True)
+    subset_X = X[: 5 * seasonal_period]
+    subset_y = y[: 5 * seasonal_period]
+
+    decomposer = decomposer_child_class(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-1,
+            )
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_doesnt_modify_target_index(
+    decomposer_child_class,
+    generate_seasonal_data,
+):
+    X, y = generate_seasonal_data(real_or_synthetic="synthetic")(
+        period=7,
+        set_time_index=True,
+    )
+    original_X_index = X.index
+    original_y_index = y.index
+
+    dec = decomposer_child_class()
+    dec.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 = dec.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 = dec.inverse_transform(y_t)
+    pd.testing.assert_index_equal(y_new.index, original_y_index)
+
+
+@pytest.mark.parametrize(
+    "decomposer_child_class",
+    decomposer_list,
+)
+def test_decomposer_monthly_begin_data(decomposer_child_class, ts_data):
+    X, _, y = ts_data()
+    dts = pd.date_range("01-01-2000", periods=len(X), freq="MS")
+    datetime_index = pd.DatetimeIndex(dts)
+    X.index = datetime_index
+    y.index = datetime_index
+    X["date"] = dts
+    assert (
+        X.index.freqstr == "MS"
+    ), "The frequency string that was causing this problem in statsmodels decompose has changed."
+
+    pdc = decomposer_child_class(degree=1, time_index="date")
+
+    if isinstance(pdc, PolynomialDecomposer):
+        with pytest.raises(NotImplementedError, match="statsmodels decompose"):
+            pdc.fit(X, y)
+    else:
+        pdc.fit(X, y)
+
+
+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 = 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
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 b5acc13d..a5a15ba5 100644
--- a/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py
+++ b/evalml/tests/component_tests/decomposer_tests/test_polynomial_decomposer.py
@@ -1,4 +1,3 @@
-import matplotlib.pyplot
 import numpy as np
 import pandas as pd
 import pytest
@@ -7,6 +6,9 @@ from sklearn.linear_model import LinearRegression
 from sklearn.preprocessing import PolynomialFeatures
 
 from evalml.pipelines.components import PolynomialDecomposer
+from evalml.tests.component_tests.decomposer_tests.test_decomposer import (
+    get_trend_dataframe_format_correct,
+)
 
 
 def test_polynomial_decomposer_init():
@@ -18,71 +20,6 @@ def test_polynomial_decomposer_init():
     }
 
 
-def test_polynomial_decomposer_init_raises_error_if_degree_not_int():
-
-    with pytest.raises(TypeError, match="Received str"):
-        PolynomialDecomposer(degree="1")
-
-    with pytest.raises(TypeError, match="Received float"):
-        PolynomialDecomposer(degree=3.4)
-
-    PolynomialDecomposer(degree=3.0)
-
-
-def test_polynomial_decomposer_transform_returns_same_when_y_none(
-    ts_data,
-):
-    X, _, y = ts_data()
-    pdc = PolynomialDecomposer().fit(X, y)
-    X_t, y_t = pdc.transform(X, None)
-    pd.testing.assert_frame_equal(X, X_t)
-    assert y_t is None
-
-
-@pytest.mark.parametrize("index_type", ["datetime", "integer"])
-@pytest.mark.parametrize("input_type", ["pd", "ww"])
-@pytest.mark.parametrize("degree", [1, 2, 3])
-def test_polynomial_decomposer_fit_transform(
-    degree,
-    input_type,
-    index_type,
-    ts_data,
-):
-
-    X, _, y = ts_data()
-
-    # Get the expected answer
-    lin_reg = LinearRegression(fit_intercept=True)
-    features = PolynomialFeatures(degree=degree).fit_transform(
-        np.arange(X.shape[0]).reshape(-1, 1),
-    )
-    lin_reg.fit(features, y)
-    detrended_values = y.values - lin_reg.predict(features)
-    expected_index = y.index if input_type != "np" else range(y.shape[0])
-    expected_answer = pd.Series(detrended_values, index=expected_index)
-
-    if input_type == "ww":
-        X = X.copy()
-        X.ww.init()
-        y = ww.init_series(y.copy())
-
-    if index_type == "integer":
-        y = y.reset_index(drop=True)
-        X = X.reset_index(drop=True)
-        X.ww.init()
-
-    output_X, output_y = PolynomialDecomposer(degree=degree).fit_transform(X, y)
-    pd.testing.assert_series_equal(
-        expected_answer,
-        output_y,
-        check_exact=False,
-        rtol=1.9,
-    )
-
-    # Verify the X is not changed
-    pd.testing.assert_frame_equal(X, output_X)
-
-
 @pytest.mark.parametrize(
     "variateness",
     [
@@ -132,9 +69,6 @@ def test_polynomial_decomposer_get_trend_dataframe(
         y = pd.concat([y, y], axis=1)
     result_dfs = pdt.get_trend_dataframe(X, y)
 
-    def get_trend_dataframe_format_correct(df):
-        return set(df.columns) == {"signal", "trend", "seasonality", "residual"}
-
     def get_trend_dataframe_values_correct(df, y):
         np.testing.assert_array_almost_equal(
             (df["trend"] + df["seasonality"] + df["residual"]).values,
@@ -155,16 +89,6 @@ def test_polynomial_decomposer_get_trend_dataframe(
         ]
 
 
-@pytest.mark.parametrize("degree", [1, 2, 3])
-def test_polynomial_decomposer_inverse_transform(degree, ts_data):
-    X, _, y = ts_data()
-
-    decomposer = PolynomialDecomposer(degree=degree)
-    output_X, output_y = decomposer.fit_transform(X, y)
-    output_inverse_y = decomposer.inverse_transform(output_y)
-    pd.testing.assert_series_equal(y, output_inverse_y, check_dtype=False)
-
-
 def test_polynomial_decomposer_needs_monotonic_index(ts_data):
     X, _, y = ts_data()
     decomposer = PolynomialDecomposer(degree=2)
@@ -174,121 +98,3 @@ def test_polynomial_decomposer_needs_monotonic_index(ts_data):
         decomposer.fit_transform(X, y_shuffled)
     expected_errors = ["monotonically", "X must be in an sktime compatible format"]
     assert any([error in str(exec_info.value) for error in expected_errors])
-
-
-@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_polynomial_decomposer_uses_time_index(
-    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 = PolynomialDecomposer(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(
-    "y_has_time_index",
-    ["y_has_time_index", "y_doesnt_have_time_index"],
-)
-def test_polynomial_decomposer_plot_decomposition(
-    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)
-
-    pdc = PolynomialDecomposer(degree=1, seasonal_period=period)
-    pdc.fit_transform(X, y)
-    fig, axs = pdc.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])
diff --git a/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py b/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py
index a27f0fac..852b3e86 100644
--- a/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py
+++ b/evalml/tests/component_tests/decomposer_tests/test_stl_decomposer.py
@@ -1,5 +1,3 @@
-import datetime
-
 import numpy as np
 import pandas as pd
 import pytest
@@ -7,6 +5,10 @@ from sklearn.linear_model import LinearRegression
 from sklearn.preprocessing import PolynomialFeatures
 
 from evalml.pipelines.components import STLDecomposer
+from evalml.tests.component_tests.decomposer_tests.test_decomposer import (
+    build_test_target,
+    get_trend_dataframe_format_correct,
+)
 
 
 def test_stl_decomposer_init():
@@ -18,17 +20,6 @@ def test_stl_decomposer_init():
     }
 
 
-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()
 
@@ -39,56 +30,6 @@ def test_stl_decomposer_auto_sets_seasonal_period_to_odd(ts_data):
     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",
     [
@@ -163,71 +104,6 @@ def test_stl_fit_transform_in_sample(
     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",
@@ -317,8 +193,6 @@ def test_stl_decomposer_get_trend_dataframe(
     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")(
@@ -394,8 +268,6 @@ def test_stl_decomposer_get_trend_dataframe(
 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,
diff --git a/evalml/tests/conftest.py b/evalml/tests/conftest.py
index 58194297..feb560ae 100644
--- a/evalml/tests/conftest.py
+++ b/evalml/tests/conftest.py
@@ -2217,6 +2217,7 @@ def generate_seasonal_data():
         step=None,
         num_periods=20,
         scale=1,
+        seasonal_scale=1,
         trend_degree=1,
         freq_str="D",
         set_time_index=False,
@@ -2231,10 +2232,35 @@ def generate_seasonal_data():
         step=None,
         num_periods=20,
         scale=1,
+        seasonal_scale=1,
         trend_degree=1,
         freq_str="D",
         set_time_index=False,
     ):
+        """Function to generate a sinusoidal signal with a polynomial trend.
+
+        Args:
+            period: The length, in units, of the seasonal signal.
+            step:
+            num_periods: How many periods of the seasonal signal to generate.
+            scale: The relative scale of the trend.  Setting it higher increases
+                the comparative strength of the trend.
+            seasonal_scale: The relative scale of the sinusoidal seasonality.
+                Setting it higher increases the comparative strength of the
+                trend.
+            trend_degree: The degree of the polynomial trend. 1 = linear, 2 =
+                quadratic, 3 = cubic.  Specific functional forms defined
+                below.
+            freq_str: The pandas frequency string used to define the unit of
+                time in the series time index.
+            set_time_index: Whether to set the time index with a pandas.
+                DatetimeIndex.
+
+        Returns:
+            X (pandas.DateFrame): A placeholder feature matrix.
+            y (pandas.Series): A synthetic, time series target Series.
+
+        """
         if period is None:
             x = np.arange(0, 1, 0.01)
         elif step is not None:
@@ -2254,7 +2280,7 @@ def generate_seasonal_data():
         elif trend_degree == 3:
             y_trend = pd.Series(scale * minmax_scale((x - 5) ** 3 + x**2))
         if period is not None:
-            y_seasonal = pd.Series(np.sin(freq * x))
+            y_seasonal = pd.Series(seasonal_scale * np.sin(freq * x))
         else:
             y_seasonal = pd.Series(np.zeros(len(x)))
         y = y_trend + y_seasonal