Repository: sktime/sktime

Issue:
[ENH] conditional forecaster multiplexing by instance property, e.g., slow or fast moving goods
**Is your feature request related to a problem? Please describe.**
Here is the logic that I would like to use with sktime:
* I have some panel data, where the data is grouped into instances by 'store' and 'product', and the target value is 'Sales'
* I have a special rule that does a different type of forecast based on the product and past sales (it predicts all zeros based on a heuristic that predicts if a store no longer stocks a product)

Now, I have my custom forecaster working well, and its `fit` method is called with `X` for each instance, but there's no way for me to tell which instance I'm currently looking at (that is, which store/product I'm looking at) so I can't apply any product-specific logic.

**Describe the solution you'd like**
I've looked at the code and can't see how it could be done easily. In this code, `group_name` would need to be passed through to the estimator method, but of course that would error if it's not expected. You could `inspect` the func to see if it's expected.
https://github.com/sktime/sktime/blob/f94a4bf4ed6e7ec1520496ec00ae987c57cc9703/sktime/datatypes/_vectorize.py#L572-L584

**Describe alternatives you've considered**
A few workarounds that work just fine:
* Add a column to X `IsProductSomething` so the information is in X
* Put all the logic for whether or not to do a particular type of forecast outside the sktime pipeline, so have a boolean column for `JustPredictZero` and read that in the forecaster.

These both spread out the logic of conditional forecasting which isn't ideal.

I could also put the Product column in X (in addition to being in the index), but it's a string, so then I need to mess around with categories/integer representations, or drop the column before passing to `fit`, and it needs a different name (else it clashes with the index) so that approach is a bit of a mess.

**Additional context**
This is probably quite a niche problem, I'd be perfectly happy for you to close this. But if at some point the opportunity arises to make this happen that would be neat.



Discussion:
Have you seen `HierarchyEnsembleForecaster` (in `sktime.forecasting.compose`) - this might solve your problem, it allows to apply different forecasters per product or product group.

The `VectorizedDF` that you are looking at just does a naive loop over hierarchy levels if the estimator itself is not genuinely hierarchical (broadcast aka apply-by) - you wouldn't expect (or want) any estimator logic there.
FYI @VyomkeshVyas
I hadn't seen `HierarchyEnsembleForecaster`. It's close to what I want, but I've made my own `SwitchForecaster` that takes a dictionary of forecasters, and a selector function that decides which of several forecasters to use (returns the appropriate key):
```py
SwitchForecaster(
    estimators={
        "PredictZero": PredictZero(),
        "LagYIntoX": LagYIntoX(
            estimator=HistGradientBoostingRegressor(),
            num_lags=2,
        ),
    },
    selector=lambda X, y: "PredictZero" if y.squeeze().tail(30).sum() < 4 and not X.IsSchoolSupplies[0] else "LagYIntoX",
)
```
`SwitchForecaster` = `MultiplexForecaster` with lambda expressions as condition?

Interesting, your selector can take lambda expressions and is hence more elaborate, it's a conditional switch!

Mind contributing this? This is a very exciting component!

I've tried sth similar with the `TransformIf`, but the condition is on parameters rather than on data, and currently afaik there is no generic data-dependent switch.

Small note: lambda expressions are not pickleable and hence are non-compliant with the save/load part of the extender contract (you might get into trouble if you try to serialize for deployment).

A workaround that I've used before is allow strings and then use `eval` internally if it's a string - strings are pickleable.
I figured I'd run with it for a while and see what bugs shake out. I think it might be fiddly to use with something like grid search but I haven't got that far yet.

Here's the code though:
```py
class SwitchForecaster(BaseForecaster):
    _tags = {
        "y_inner_mtype": "pd.DataFrame",
        "X_inner_mtype": "pd.DataFrame",
    }

    def __init__(self, estimators: Mapping, selector: Callable):
        super().__init__()
        self.estimators = estimators
        self.estimator = None
        self.selector = selector

    def _fit(self, y, X=None, fh=None):
        est_key = self.selector(X, y)
        self.estimator = self.estimators[est_key]
        self.estimator.fit(y=y, X=X, fh=fh)
        return self

    def _predict(self, fh, X=None):
        return self.estimator.predict(fh=fh, X=X)
```

Yep I'm aware of the lambda/pickle issue (thanks for the heads up though), in reality I'd use a function to keep the pipeline definition neat. Are you aware of cloudpickle? It handles lambdas and I think is a drop-in replacement for pickle.
Got it working with grid search by making the estimator options arguments:
```py
SwitchForecaster(
    predict_zero=PredictZero(),
    lag_y_into_x=LagYIntoX(
        estimator=HistGradientBoostingRegressor(
            categorical_features=cat_cols,
            early_stopping=True,
        ),
        num_lags=2,
    ),
    selector=lambda X, y: "predict_zero" if y.squeeze().tail(30).sum() < 4 and not X.IsSchoolSupplies[0] else "lag_y_into_x",
)
```

And some faffing about to make the clone process work by adding to `get_param_names`:
```py
class SwitchForecaster(BaseForecaster):
    _tags = {
        "y_inner_mtype": "pd.DataFrame",
        "X_inner_mtype": "pd.DataFrame",
    }

    def __init__(self, selector: Callable, **kwargs):
        super().__init__()
        for key, val in kwargs.items():
            setattr(self, key, val)
        self.kwargs = kwargs
        self.estimator = None
        self.selector = selector

    def _fit(self, y, X=None, fh=None):
        est_key = self.selector(X, y)
        self.estimator = getattr(self, est_key)
        self.estimator.fit(y=y, X=X, fh=fh)
        return self

    def _predict(self, fh, X=None):
        return self.estimator.predict(fh=fh, X=X)

    def get_param_names(self):
        param_names = super().get_param_names()
        param_names.extend(self.kwargs.keys())
        return param_names
```
Looks nice, although you should avoid overrides of public methods like `get_param_names`.

The clean way would be in line with the `scikit-learn` interface conventions, where the set of parameters cannot change on the top level.

If you are interested in an interface compliant implementation, have a look at `MultiplexForecaster`.
I think I achieved something similar using the `MultiplexForecaster` and `PluginParamsForecaster` primitives, and defining a `ForecasterSelector` parameter fitter:

```python
class ForecasterSelector(BaseParamFitter):
    """Forecaster selector base class.

    This class is used to select a forecaster based on the input data.
    The selected forecaster is stored in the `selected_forecaster_` 
    attribute, which can be then passed down to the 
    ``MultiplexForecaster`` via ``PluginParamsForecaster``.

    Args:
        selector:
            A callable that takes the input time series and returns the 
            name of the forecaster to select.

    """

    def __init__(self, selector: Callable[[pd.DataFrame], str]):
        super().__init__()
        self.selector = selector

    def _fit(self, X: pd.DataFrame) -> BaseParamFitter:
        self.selected_forecaster_ = self.selector(X)
        return self
```

So the equivalent to @davidgilbertson's example would be:

```python
PluginParamsForecaster(
    param_est=ForecasterSelector(
        lambda X: "PredictZero" if X.squeeze().tail(30).sum() < 4 and not X.IsSchoolSupplies[0] else "LagYIntoX"
    ),
    forecaster=MultiplexForecaster(
        [
            ("PredictZero", PredictZero()),
            ("LagYIntoX", LagYIntoX(...)),
        ]
    ),
)
```
Interesting!

Would you like to contribute this, @tpvasconcelos?
I was not thinking about using parameter fitters/setters, very interesting.

It also seems like a common enough use case, you are already the second person posting about this feature gap, which typically means there are hundreds of others who may like to have it (but are too shy to post 😄 )
comments on `ForecasterSelector` - that's basically a conditional parameter selector, nothing requires the selected parameter to be a forecaster, no? So `ParamSelector` or similar, it could be a very general pattern for the `param_est.compose` submodule?

Also relevant: `TransformIf`, which, looking at this, could be probably decomposed as a "transformer parameter plugin" (similar to `PluginParamsForecaster`, plus the `ParamSelector`?
@fkiraly Happy to contribute this. I think I like your `ParamSelector` suggestion. Do you mean something like this:

```python
class ParamSelector(BaseParamFitter):

    def __init__(self, param: str, selector: Callable[[pd.DataFrame], str]):
        super().__init__()
        self.param = param
        self.selector = selector

    def _fit(self, X: pd.DataFrame) -> BaseParamFitter:
        setattr(self, self.param, self.selector(X))
        return self
```

and then:

```python
PluginParamsForecaster(
    param_est=ParamSelector(
        param="selected_forecaster_",
        selector=lambda X: "PredictZero" if X.squeeze().tail(30).sum() < 4 and not X.IsSchoolSupplies[0] else "LagYIntoX",
    ),
    forecaster=MultiplexForecaster(
        [
            ("PredictZero", PredictZero()),
            ("LagYIntoX", LagYIntoX(...)),
        ]
    ),
)
```

Looks nice and pretty generalisable to me! 🚀 
yes, exactly!

In addition, nice syntactic sugar would be to make the `*` dunder on param estimator plus forecaster to default to `PluginParamsForecaster`, but that's a minor thing imo (and a separate PR/issue).

Resolving patch:
diff --git a/sktime/param_est/compose/__init__.py b/sktime/param_est/compose/__init__.py
new file mode 100644
--- /dev/null
+++ b/sktime/param_est/compose/__init__.py
@@ -0,0 +1,7 @@
+"""Composition involving parameter estimators."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+from sktime.param_est.compose._func_fitter import FunctionParamFitter
+from sktime.param_est.compose._pipeline import ParamFitterPipeline
+
+__author__ = ["fkiraly", "tpvasconcelos"]
+__all__ = ["ParamFitterPipeline", "FunctionParamFitter"]
diff --git a/sktime/param_est/compose/_func_fitter.py b/sktime/param_est/compose/_func_fitter.py
new file mode 100644
--- /dev/null
+++ b/sktime/param_est/compose/_func_fitter.py
@@ -0,0 +1,162 @@
+"""Implements FunctionParamFitter, a class to create custom parameter fitters."""
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+from sktime.datatypes import ALL_TIME_SERIES_MTYPES
+from sktime.param_est.base import BaseParamFitter
+
+__author__ = ["tpvasconcelos"]
+__all__ = ["FunctionParamFitter"]
+
+
+class FunctionParamFitter(BaseParamFitter):
+    r"""Constructs a parameter fitter from an arbitrary callable.
+
+    A FunctionParamFitter forwards its X argument to a user-defined
+    function (or callable object) and sets the result of this function
+    to the ``param`` attribute. This can be useful for stateless
+    estimators such as simple conditional parameter selectors.
+
+    Note: If a lambda function is used as the ``func``, then the
+    resulting estimator will not be pickleable.
+
+    Parameters
+    ----------
+    param : str
+        The name of the parameter to set.
+    func : callable (X: X_type, **kwargs) -> Any
+        The callable to use for the parameter estimation. This will be
+        passed the same arguments as estimator, with args and kwargs
+        forwarded.
+    kw_args : dict, default=None
+        Dictionary of additional keyword arguments to pass to func.
+    X_type : str, one of "pd.DataFrame, pd.Series, np.ndarray", or list thereof
+        default = ["pd.DataFrame", "pd.Series", "np.ndarray"]
+        list of types that func is assumed to allow for X (see signature above)
+        if X passed to transform/inverse_transform is not on the list,
+            it will be converted to the first list element before passed to funcs
+
+    See Also
+    --------
+    sktime.param_est.plugin.PluginParamsForecaster :
+        Plugs parameters from a parameter estimator into a forecaster.
+    sktime.forecasting.compose.MultiplexForecaster :
+        MultiplexForecaster for selecting among different models.
+
+    Examples
+    --------
+    This class could be used to contruct a parameter estimator that
+    selects a forecaster based on the input data's length. The
+    selected forecaster can be stored in the ``selected_forecaster_``
+    attribute, which can be then passed down to a
+    :class:`~sktime.forecasting.compose.MultiplexForecaster` via a
+    :class:`~sktime.param_est.plugin.PluginParamsForecaster`.
+
+    >>> import numpy as np
+    >>> param_est = FunctionParamFitter(
+    ...     param="selected_forecaster",
+    ...     func=(
+    ...         lambda X, threshold: "naive-seasonal"
+    ...         if len(X) >= threshold
+    ...         else "naive-last"
+    ...     ),
+    ...     kw_args={"threshold": 7},
+    ... )
+    >>> param_est.fit(np.asarray([1, 2, 3, 4]))
+    FunctionParamFitter(...)
+    >>> param_est.get_fitted_params()
+    {'selected_forecaster': 'naive-last'}
+    >>> param_est.fit(np.asarray([1, 2, 3, 4, 5, 6, 7]))
+    FunctionParamFitter(...)
+    >>> param_est.get_fitted_params()
+    {'selected_forecaster': 'naive-seasonal'}
+
+    The full conditional forecaster selection pipeline could look
+    like this:
+
+    >>> from sktime.forecasting.compose import MultiplexForecaster
+    >>> from sktime.forecasting.naive import NaiveForecaster
+    >>> from sktime.param_est.plugin import PluginParamsForecaster
+    >>> forecaster = PluginParamsForecaster(
+    ...     param_est=param_est,
+    ...     forecaster=MultiplexForecaster(
+    ...         forecasters=[
+    ...             ("naive-last", NaiveForecaster()),
+    ...             ("naive-seasonal", NaiveForecaster(sp=7)),
+    ...         ]
+    ...     ),
+    ... )
+    >>> forecaster.fit(np.asarray([1, 2, 3, 4]))
+    PluginParamsForecaster(...)
+    >>> forecaster.predict(fh=[1,2,3])
+    array([[4.],
+           [4.],
+           [4.]])
+    >>> forecaster.fit(np.asarray([1, 2, 3, 4, 5, 6, 7]))
+    PluginParamsForecaster(...)
+    >>> forecaster.predict(fh=[1,2,3])
+    array([[1.],
+           [2.],
+           [3.]])
+    """
+
+    _tags = {
+        "X_inner_mtype": ALL_TIME_SERIES_MTYPES,
+        "scitype:X": ["Series", "Panel", "Hierarchical"],
+        "capability:missing_values": True,
+        "capability:multivariate": False,
+    }
+
+    def __init__(self, param, func, kw_args=None, X_type=None):
+        self.param = param
+        self.func = func
+        self.kw_args = kw_args
+        self.X_type = X_type
+        super().__init__()
+
+        if X_type is not None:
+            self.set_tags(X_inner_mtype=X_type)
+
+    def _fit(self, X):
+        """Fit estimator and estimate parameters.
+
+        private _fit containing the core logic, called from fit
+
+        Writes to self:
+            Sets fitted model attributes ending in "_".
+
+        Parameters
+        ----------
+        X : guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            Time series to which to fit the estimator.
+
+        Returns
+        -------
+        self : reference to self
+        """
+        param = self.param.rstrip("_") + "_"
+        setattr(self, param, self.func(X, **(self.kw_args or {})))
+        return self
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the estimator.
+
+        Parameters
+        ----------
+        parameter_set : str, default="default"
+            Name of the set of test parameters to return, for use in tests. If no
+            special parameters are defined for a value, will return `"default"` set.
+            There are no reserved values for parameter estimators.
+
+        Returns
+        -------
+        params : dict or list of dict, default = {}
+            Parameters to create testing instances of the class
+            Each dict are parameters to construct an "interesting" test instance, i.e.,
+            `MyClass(**params)` or `MyClass(**params[i])` creates a valid test instance.
+            `create_test_instance` uses the first (or only) dictionary in `params`
+        """
+        params = [
+            {"param": "param", "func": lambda X: "foo"},
+            {"param": "param", "func": lambda X, kwarg: "foo", "kw_args": {"kwarg": 1}},
+        ]
+        return params
diff --git a/sktime/param_est/compose.py b/sktime/param_est/compose/_pipeline.py
similarity index 99%
rename from sktime/param_est/compose.py
rename to sktime/param_est/compose/_pipeline.py
--- a/sktime/param_est/compose.py
+++ b/sktime/param_est/compose/_pipeline.py
@@ -1,4 +1,7 @@
-"""Composition involving parameter estimators."""
+"""Implements ParamFitterPipeline.
+
+A class to create a pipeline of transformers and a parameter estimator.
+"""
 # copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
 from sktime.base import _HeterogenousMetaEstimator
 from sktime.param_est.base import BaseParamFitter
@@ -8,7 +11,6 @@
 __author__ = ["fkiraly"]
 __all__ = ["ParamFitterPipeline"]
 
-
 # we ensure that internally we convert to pandas for now
 SUPPORTED_MTYPES = ["pd.DataFrame", "pd.Series", "pd-multiindex", "pd_multiindex_hier"]
 


Test patch: