Ejemplo n.º 1
0
    def group_silverkite_seas_components(self, df):
        """Groups and renames``Silverkite`` seasonalities.

        Parameters
        ----------
        df: `pandas.DataFrame`
            DataFrame containing two columns:

            - ``time_col``: Timestamps of the original timeseries.
            - ``seas``: A seasonality component. It must match a component name from the
            `~greykite.algo.forecast.silverkite.constants.silverkite_component.SilverkiteComponentsEnum`.

        Returns
        -------
            `pandas.DataFrame`
            DataFrame grouped by the time feature corresponding to the seasonality
            and renamed as defined in
            `~greykite.algo.forecast.silverkite.constants.silverkite_component.SilverkiteComponentsEnum`.
        """
        time_col, seas = df.columns
        groupby_time_feature = self._silverkite_components_enum[
            seas].value.groupby_time_feature
        xlabel = self._silverkite_components_enum[seas].value.xlabel
        ylabel = self._silverkite_components_enum[seas].value.ylabel

        def grouping_func(grp):
            return np.nanmean(grp[seas])

        result = add_groupby_column(df=df,
                                    time_col=time_col,
                                    groupby_time_feature=groupby_time_feature)
        grouped_df = grouping_evaluation(df=result["df"],
                                         groupby_col=result["groupby_col"],
                                         grouping_func=grouping_func,
                                         grouping_func_name=ylabel)
        grouped_df.rename({result["groupby_col"]: xlabel},
                          axis=1,
                          inplace=True)
        return grouped_df
Ejemplo n.º 2
0
def test_add_groupby_column():
    """Tests add_groupby_column function"""
    # ``groupby_time_feature``
    df = pd.DataFrame({
        cst.TIME_COL: [
            datetime.datetime(2018, 1, 1),
            datetime.datetime(2018, 1, 2),
            datetime.datetime(2018, 1, 3),
            datetime.datetime(2018, 1, 4),
            datetime.datetime(2018, 1, 5)],
        cst.VALUE_COL: [1.0, 2.0, 3.0, 4.0, 5.0]
    })
    result = add_groupby_column(
        df=df,
        time_col=cst.TIME_COL,
        groupby_time_feature="dow",
        groupby_sliding_window_size=None,
        groupby_custom_column=None)
    expected_col = "dow"  # ``groupby_time_feature`` is used as the column name
    expected = df.copy()
    expected[expected_col] = pd.Series([1, 2, 3, 4, 5])  # Monday, Tuesday, etc.
    assert_frame_equal(result["df"], expected)
    assert result["groupby_col"] == expected_col

    # ``groupby_sliding_window_size``
    result = add_groupby_column(
        df=df,
        time_col=cst.TIME_COL,
        groupby_time_feature=None,
        groupby_sliding_window_size=2,
        groupby_custom_column=None)
    expected_col = f"{cst.TIME_COL}_downsample"
    expected = df.copy()
    expected[expected_col] = pd.Series([
        datetime.datetime(2018, 1, 1),
        datetime.datetime(2018, 1, 3),
        datetime.datetime(2018, 1, 3),
        datetime.datetime(2018, 1, 5),
        datetime.datetime(2018, 1, 5),
    ])
    assert_frame_equal(result["df"], expected)
    assert result["groupby_col"] == expected_col

    # ``groupby_custom_column`` without a name
    df.index.name = "index_name"
    custom_groups = pd.Series(["g1", "g2", "g1", "g3", "g2"])
    result = add_groupby_column(
        df=df,
        time_col=cst.TIME_COL,
        groupby_time_feature=None,
        groupby_sliding_window_size=None,
        groupby_custom_column=custom_groups)
    expected_col = "groups"  # default name
    expected = df.copy()
    expected[expected_col] = custom_groups.values
    assert_frame_equal(result["df"], expected)
    assert result["groupby_col"] == expected_col

    # ``groupby_custom_column`` with a name
    custom_groups = pd.Series(
        ["g1", "g2", "g1", "g3", "g2"],
        name="custom_groups")
    result = add_groupby_column(
        df=df,
        time_col=cst.TIME_COL,
        groupby_time_feature=None,
        groupby_sliding_window_size=None,
        groupby_custom_column=custom_groups)
    expected_col = custom_groups.name
    expected = df.copy()
    expected[expected_col] = custom_groups.values
    assert_frame_equal(result["df"], expected)
    assert result["groupby_col"] == expected_col

    # If a column has the same name as the index, the
    # index name is set to None.
    custom_groups = pd.Series(["g1", "g2", "g1", "g3", "g2"], name="index_name")
    result = add_groupby_column(
        df=df,
        time_col=cst.TIME_COL,
        groupby_time_feature=None,
        groupby_sliding_window_size=None,
        groupby_custom_column=custom_groups)
    expected_col = custom_groups.name
    expected = df.copy()
    expected[expected_col] = custom_groups.values
    expected.index.name = None
    assert_frame_equal(result["df"], expected)
    assert result["groupby_col"] == expected_col

    # Throws exception if multiple grouping dimensions are provided
    with pytest.raises(ValueError, match="Exactly one of.*must be specified"):
        add_groupby_column(
            df=df,
            time_col=cst.TIME_COL,
            groupby_time_feature=None,
            groupby_sliding_window_size=2,
            groupby_custom_column=custom_groups)

    with pytest.raises(ValueError, match="Exactly one of.*must be specified"):
        add_groupby_column(
            df=df,
            time_col=cst.TIME_COL,
            groupby_time_feature="dow",
            groupby_sliding_window_size=None,
            groupby_custom_column=custom_groups)

    with pytest.raises(ValueError, match="Exactly one of.*must be specified"):
        add_groupby_column(
            df=df,
            time_col=cst.TIME_COL,
            groupby_time_feature="dow",
            groupby_sliding_window_size=2,
            groupby_custom_column=None)

    with pytest.raises(ValueError, match="Exactly one of.*must be specified"):
        add_groupby_column(
            df=df,
            time_col=cst.TIME_COL,
            groupby_time_feature="dow",
            groupby_sliding_window_size=2,
            groupby_custom_column=custom_groups)
Ejemplo n.º 3
0
# You can pass a custom `pandas.Series` to the plotting function
# to define overlays. The series assigns a label to each row, and
# must have the same length as your input data.
#
# In the code below, we create two overlays using
# a (rough) indicator for ``is_football_season``.
# We used
# `~greykite.common.viz.timeseries_plotting.add_groupby_column`
# to get the derived time feature used to define this indicator.
# See the function's documentation for details.

# Defines `is_football_season` by "week of year",
# using `add_groupby_column` to get the "week of year" time feature.
df_week_of_year = add_groupby_column(
    df=ts.df,
    time_col=TIME_COL,  # The time column in ts.df is always TIME_COL
    groupby_time_feature="woy"
)  # Computes "week of year" based on the time column
added_column = df_week_of_year["groupby_col"]
week_of_year = df_week_of_year["df"][added_column]
is_football_season = (week_of_year <= 6) | (week_of_year >= 36
                                            )  # rough approximation
fig = ts.plot_quantiles_and_overlays(
    groupby_time_feature="str_dow",
    show_mean=True,
    show_quantiles=False,
    show_overlays=True,
    center_values=True,
    overlay_label_custom_column=
    is_football_season,  # splits overlays by `is_football_season` value
    overlay_style={
def test_get_quantiles_and_overlays():
    """Tests get_quantiles_and_overlays"""
    dl = DataLoaderTS()
    peyton_manning_ts = dl.load_peyton_manning_ts()

    # no columns are requested
    with pytest.raises(
            ValueError,
            match=
            "Must enable at least one of: show_mean, show_quantiles, show_overlays."
    ):
        peyton_manning_ts.get_quantiles_and_overlays(
            groupby_time_feature="doy")

    # show_mean only
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="dow",
        show_mean=True,
        mean_col_name="custom_name")
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays([[MEAN_COL_GROUP], ["custom_name"]],
                                  names=["category", "name"]))
    assert grouped_df.index.name == "dow"
    assert grouped_df.shape == (7, 1)
    assert grouped_df.index[0] == 1

    # show_quantiles only (bool)
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_sliding_window_size=180, show_quantiles=True)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[QUANTILE_COL_GROUP, QUANTILE_COL_GROUP], ["Q0.1", "Q0.9"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "ts_downsample"
    assert grouped_df.shape == (17, 2)
    assert grouped_df.index[0] == pd.Timestamp(2007, 12, 10)

    # show_quantiles only (list)
    custom_col = pd.Series(
        np.random.choice(list("abcd"), size=peyton_manning_ts.df.shape[0]))
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_custom_column=custom_col,
        show_quantiles=[0, 0.25, 0.5, 0.75, 1],
        quantile_col_prefix="prefix")
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[QUANTILE_COL_GROUP] * 5,
             ["prefix0", "prefix0.25", "prefix0.5", "prefix0.75", "prefix1"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "groups"
    assert grouped_df.shape == (4, 5)
    assert grouped_df.index[0] == "a"
    # checks quantile computation
    df = peyton_manning_ts.df.copy()
    df["custom_col"] = custom_col.values
    quantile_df = df.groupby("custom_col")[VALUE_COL].agg(
        [np.nanmin, np.nanmedian, np.nanmax])
    assert_equal(grouped_df["quantile"]["prefix0"],
                 quantile_df["nanmin"],
                 check_names=False)
    assert_equal(grouped_df["quantile"]["prefix0.5"],
                 quantile_df["nanmedian"],
                 check_names=False)
    assert_equal(grouped_df["quantile"]["prefix1"],
                 quantile_df["nanmax"],
                 check_names=False)

    # show_overlays only (bool), no overlay label
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="doy", show_overlays=True)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 9, [f"overlay{i}" for i in range(9)]],
            names=["category", "name"]))
    assert grouped_df.index.name == "doy"
    assert grouped_df.shape == (366, 9)
    assert grouped_df.index[0] == 1

    # show_overlays only (int below the available number), time feature overlay label
    np.random.seed(123)
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="doy",
        show_overlays=4,
        overlay_label_time_feature="year")
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 4, ["2007", "2011", "2012", "2014"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "doy"
    assert grouped_df.shape == (366, 4)
    assert grouped_df.index[0] == 1

    # show_overlays only (int above the available number), custom overlay label
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="dom",
        show_overlays=200,
        overlay_label_custom_column=custom_col)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 4, ["a", "b", "c", "d"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "dom"
    assert grouped_df.shape == (31, 4)
    assert grouped_df.index[0] == 1

    # show_overlays only (list of indices), sliding window overlay label
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="dom",
        show_overlays=[0, 4],
        overlay_label_sliding_window_size=365 * 2)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 2,
             ["2007-12-10 00:00:00", "2015-12-08 00:00:00"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "dom"
    assert grouped_df.shape == (31, 2)
    assert grouped_df.index[0] == 1

    # show_overlays only (np.ndarray), sliding window overlay label
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="dom",
        show_overlays=np.arange(0, 6, 2),
        overlay_label_sliding_window_size=365 * 2)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 3,
             [
                 "2007-12-10 00:00:00", "2011-12-09 00:00:00",
                 "2015-12-08 00:00:00"
             ]],
            names=["category", "name"]))
    assert grouped_df.index.name == "dom"
    assert grouped_df.shape == (31, 3)
    assert grouped_df.index[0] == 1

    # show_overlays only (list of column names), sliding window overlay label
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_time_feature="dom",
        show_overlays=["2007-12-10 00:00:00", "2015-12-08 00:00:00"],
        overlay_label_sliding_window_size=365 * 2)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[OVERLAY_COL_GROUP] * 2,
             ["2007-12-10 00:00:00", "2015-12-08 00:00:00"]],
            names=["category", "name"]))
    assert grouped_df.index.name == "dom"
    assert grouped_df.shape == (31, 2)
    assert grouped_df.index[0] == 1

    # Show all 3 (no overlay label)
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_sliding_window_size=50,  # 50 per group (50 overlays)
        show_mean=True,
        show_quantiles=[0.05, 0.5, 0.95],  # 3 quantiles
        show_overlays=True)
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[MEAN_COL_GROUP] + [QUANTILE_COL_GROUP] * 3 +
             [OVERLAY_COL_GROUP] * 50, ["mean", "Q0.05", "Q0.5", "Q0.95"] +
             [f"overlay{i}" for i in range(50)]],
            names=["category", "name"]))
    assert grouped_df.index.name == "ts_downsample"
    assert grouped_df.shape == (60, 54)
    assert grouped_df.index[-1] == pd.Timestamp(2016, 1, 7)

    # Show all 3 (with overlay label).
    # Pass overlay_pivot_table_kwargs.
    grouped_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_sliding_window_size=180,
        show_mean=True,
        show_quantiles=[0.05, 0.5, 0.95],  # 3 quantiles
        show_overlays=True,
        overlay_label_time_feature="dow",  # 7 possible values
        aggfunc="median")
    assert_equal(
        grouped_df.columns,
        pd.MultiIndex.from_arrays(
            [[MEAN_COL_GROUP] + [QUANTILE_COL_GROUP] * 3 +
             [OVERLAY_COL_GROUP] * 7,
             [
                 "mean", "Q0.05", "Q0.5", "Q0.95", "1", "2", "3", "4", "5",
                 "6", "7"
             ]],
            names=["category", "name"]))
    assert grouped_df.index.name == "ts_downsample"
    assert grouped_df.shape == (17, 11)
    assert grouped_df.index[-1] == pd.Timestamp(2015, 10, 29)
    assert np.linalg.norm(
        grouped_df[OVERLAY_COL_GROUP].mean()) > 1.0  # not centered

    with pytest.raises(
            TypeError,
            match="pivot_table\\(\\) got an unexpected keyword argument 'aggfc'"
    ):
        peyton_manning_ts.get_quantiles_and_overlays(
            groupby_sliding_window_size=180,
            show_mean=True,
            show_quantiles=[0.05, 0.5, 0.95],
            show_overlays=True,
            overlay_label_time_feature="dow",
            aggfc=np.nanmedian)  # unrecognized parameter

    # center_values with show_mean=True
    centered_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_sliding_window_size=180,
        show_mean=True,
        show_quantiles=[0.05, 0.5, 0.95],
        show_overlays=True,
        overlay_label_time_feature="dow",
        aggfunc="median",
        center_values=True)
    assert np.linalg.norm(centered_df[[MEAN_COL_GROUP, OVERLAY_COL_GROUP
                                       ]].mean()) < 1e-8  # centered at 0
    assert_equal(
        centered_df[QUANTILE_COL_GROUP],
        grouped_df[QUANTILE_COL_GROUP] - grouped_df[MEAN_COL_GROUP].mean()[0])

    # center_values with show_mean=False
    centered_df = peyton_manning_ts.get_quantiles_and_overlays(
        groupby_sliding_window_size=180,
        show_mean=False,
        show_quantiles=[0.05, 0.5, 0.95],
        show_overlays=True,
        overlay_label_time_feature="dow",
        aggfunc="median",
        center_values=True)
    assert np.linalg.norm(centered_df[[OVERLAY_COL_GROUP
                                       ]].mean()) < 1e-8  # centered at 0
    overall_mean = peyton_manning_ts.df[VALUE_COL].mean()
    assert_equal(centered_df[QUANTILE_COL_GROUP],
                 grouped_df[QUANTILE_COL_GROUP] - overall_mean)

    # new value_col
    df = generate_df_with_reg_for_tests(freq="D", periods=700)["df"]
    ts = UnivariateTimeSeries()
    ts.load_data(df=df)
    grouped_df = ts.get_quantiles_and_overlays(
        groupby_time_feature="dow",
        show_mean=True,
        show_quantiles=True,
        show_overlays=True,
        overlay_label_time_feature="woy",
        value_col="regressor1")

    df_dow = add_groupby_column(df=ts.df,
                                time_col=TIME_COL,
                                groupby_time_feature="dow")
    dow_mean = df_dow["df"].groupby("dow").agg(
        mean=pd.NamedAgg(column="regressor1", aggfunc=np.nanmean))
    assert_equal(grouped_df["mean"], dow_mean, check_names=False)
Ejemplo n.º 5
0
    def get_flexible_grouping_evaluation(
            self,
            which="train",
            groupby_time_feature=None,
            groupby_sliding_window_size=None,
            groupby_custom_column=None,
            map_func_dict=None,
            agg_kwargs=None,
            extend_col_names=False):
        """Group-wise computation of evaluation metrics. Whereas ``self.get_grouping_evaluation``
        computes one metric, this allows computation of any number of custom metrics.

        For example:

            * Mean and quantiles of squared error by group.
            * Mean and quantiles of residuals by group.
            * Mean and quantiles of actual and forecast by group.
            * % of actuals outside prediction intervals by group
            * any combination of the above metrics by the same group

        First adds a groupby column by passing ``groupby_`` parameters to
        `~greykite.common.viz.timeseries_plotting.add_groupby_column`.
        Then computes grouped evaluation metrics by passing ``map_func_dict``,
        ``agg_kwargs`` and ``extend_col_names`` to
        `~greykite.common.viz.timeseries_plotting.flexible_grouping_evaluation`.

        Exactly one of: ``groupby_time_feature``, ``groupby_sliding_window_size``,
        ``groupby_custom_column`` must be provided.

        which: `str`
            "train" or "test". Which dataset to evaluate.
        groupby_time_feature : `str` or None, optional
            If provided, groups by a column generated by
            `~greykite.common.features.timeseries_features.build_time_features_df`.
            See that function for valid values.
        groupby_sliding_window_size : `int` or None, optional
            If provided, sequentially partitions data into groups of size
            ``groupby_sliding_window_size``.
        groupby_custom_column : `pandas.Series` or None, optional
            If provided, groups by this column value. Should be same length as the DataFrame.
        map_func_dict : `dict` [`str`, `callable`] or None, default None
            Row-wise transformation functions to create new columns.
            If None, no new columns are added.

                - key: new column name
                - value: row-wise function to apply to ``df`` to generate the column value.
                         Signature (row: `pandas.DataFrame`) -> transformed value: `float`.

            For example::

                map_func_dict = {
                    "residual": lambda row: row["actual"] - row["forecast"],
                    "squared_error": lambda row: (row["actual"] - row["forecast"])**2
                }

            Some predefined functions are available in
            `~greykite.common.evaluation.ElementwiseEvaluationMetricEnum`. For example::

                map_func_dict = {
                    "residual": lambda row: ElementwiseEvaluationMetricEnum.Residual.get_metric_func()(
                        row["actual"],
                        row["forecast"]),
                    "squared_error": lambda row: ElementwiseEvaluationMetricEnum.SquaredError.get_metric_func()(
                        row["actual"],
                        row["forecast"]),
                    "q90_loss": lambda row: ElementwiseEvaluationMetricEnum.Quantile90.get_metric_func()(
                        row["actual"],
                        row["forecast"]),
                    "abs_percent_error": lambda row: ElementwiseEvaluationMetricEnum.AbsolutePercentError.get_metric_func()(
                        row["actual"],
                        row["forecast"]),
                    "coverage": lambda row: ElementwiseEvaluationMetricEnum.Coverage.get_metric_func()(
                        row["actual"],
                        row["forecast_lower"],
                        row["forecast_upper"]),
                }

            As shorthand, it is sufficient to provide the enum member name.  These are
            auto-expanded into the appropriate function.
            So the following is equivalent::

                map_func_dict = {
                    "residual": ElementwiseEvaluationMetricEnum.Residual.name,
                    "squared_error": ElementwiseEvaluationMetricEnum.SquaredError.name,
                    "q90_loss": ElementwiseEvaluationMetricEnum.Quantile90.name,
                    "abs_percent_error": ElementwiseEvaluationMetricEnum.AbsolutePercentError.name,
                    "coverage": ElementwiseEvaluationMetricEnum.Coverage.name,
                }

        agg_kwargs : `dict` or None, default None
            Passed as keyword args to `pandas.core.groupby.DataFrameGroupBy.aggregate` after creating
            new columns and grouping by ``groupby_col``.

            See `pandas.core.groupby.DataFrameGroupBy.aggregate` or
            `~greykite.common.viz.timeseries_plotting.flexible_grouping_evaluation`
            for details.

        extend_col_names : `bool` or None, default False
            How to flatten index after aggregation.
            In some cases, the column index after aggregation is a multi-index.
            This parameter controls how to flatten an index with 2 levels to 1 level.

                - If None, the index is not flattened.
                - If True, column name is a composite: ``{index0}_{index1}``
                  Use this option if index1 is not unique.
                - If False, column name is simply ``{index1}``

            Ignored if the ColumnIndex after aggregation has only one level (e.g.
            if named aggregation is used in ``agg_kwargs``).

        Returns
        -------
        df_transformed : `pandas.DataFrame`
            ``df`` after transformation and optional aggregation.

            If ``groupby_col`` is None, returns ``df`` with additional columns as the keys in ``map_func_dict``.
            Otherwise, ``df`` is grouped by ``groupby_col`` and this becomes the index. Columns
            are determined by ``agg_kwargs`` and ``extend_col_names``.

        See Also
        --------
        `~greykite.common.viz.timeseries_plotting.add_groupby_column` : called by this function
        `~greykite.common.viz.timeseries_plotting.flexible_grouping_evaluation` : called by this function
        """
        df = self.df_train if which.lower() == "train" else self.df_test
        df = df.copy()
        result = add_groupby_column(
            df=df,
            time_col=self.time_col,
            groupby_time_feature=groupby_time_feature,
            groupby_sliding_window_size=groupby_sliding_window_size,
            groupby_custom_column=groupby_custom_column)
        df = result["df"]

        map_func_dict = self.autocomplete_map_func_dict(map_func_dict)
        grouped_df = flexible_grouping_evaluation(
            df,
            map_func_dict=map_func_dict,
            groupby_col=result["groupby_col"],
            agg_kwargs=agg_kwargs,
            extend_col_names=extend_col_names)

        return grouped_df
Ejemplo n.º 6
0
    def get_grouping_evaluation(
            self,
            score_func=EvaluationMetricEnum.MeanAbsolutePercentError.get_metric_func(),
            score_func_name=EvaluationMetricEnum.MeanAbsolutePercentError.get_metric_name(),
            which="train",
            groupby_time_feature=None,
            groupby_sliding_window_size=None,
            groupby_custom_column=None):
        """Group-wise computation of forecasting error.
        Can be used to evaluate error/ aggregated value by a time feature,
        over time, or by a user-provided column.

        Exactly one of: ``groupby_time_feature``, ``groupby_sliding_window_size``,
        ``groupby_custom_column`` must be provided.

        Parameters
        ----------
        score_func : callable, optional
            Function that maps two arrays to a number.
            Signature (y_true: array, y_pred: array) -> error: float
        score_func_name : `str` or None, optional
            Name of the score function used to report results.
            If None, defaults to "metric".
        which: `str`
            "train" or "test". Which dataset to evaluate.
        groupby_time_feature : `str` or None, optional
            If provided, groups by a column generated by
            `~greykite.common.features.timeseries_features.build_time_features_df`.
            See that function for valid values.
        groupby_sliding_window_size : `int` or None, optional
            If provided, sequentially partitions data into groups of size
            ``groupby_sliding_window_size``.
        groupby_custom_column : `pandas.Series` or None, optional
            If provided, groups by this column value. Should be same length as the DataFrame.

        Returns
        -------
        grouped_df : `pandas.DataFrame` with two columns:

            (1) grouping_func_name:
                evaluation metric computing forecasting error of timeseries.
            (2) group name:
                group name depends on the grouping method:
                ``groupby_time_feature`` for ``groupby_time_feature``
                ``cst.TIME_COL`` for ``groupby_sliding_window_size``
                ``groupby_custom_column.name`` for ``groupby_custom_column``.
        """
        df = self.df_train.copy() if which.lower() == "train" else self.df_test.copy()
        score_func = add_finite_filter_to_scorer(score_func)  # in case it's not already added
        if score_func_name:
            grouping_func_name = f"{which} {score_func_name}"
        else:
            grouping_func_name = f"{which} metric"

        def grouping_func(grp):
            return score_func(grp[self.actual_col], grp[self.predicted_col])

        result = add_groupby_column(
            df=df,
            time_col=self.time_col,
            groupby_time_feature=groupby_time_feature,
            groupby_sliding_window_size=groupby_sliding_window_size,
            groupby_custom_column=groupby_custom_column)

        grouped_df = grouping_evaluation(
            df=result["df"],
            groupby_col=result["groupby_col"],
            grouping_func=grouping_func,
            grouping_func_name=grouping_func_name)
        return grouped_df