Repository: sktime/sktime

Issue:
[ENH] flexible `update` behaviour of forecasting tuners
Suggestion inferred from #3046.

There should be an `update_best_params: bool` parameter in the forecasting tuners, and a custom `_update` function.

The parameter `update_best_params` should determine whether the grid search is repeated in `update`, or whether only parameters of the wrapped estimator are updated.

The location to add this would be `BaseGridSearch._update`.

Update: what could even be nicer is an `update_param_grid` which allows to specify a tuning grid separate from the one used in fit. Only the parameters specified in there are re-tuned, on update.


Discussion:

Resolving patch:
diff --git a/sktime/forecasting/model_selection/_tune.py b/sktime/forecasting/model_selection/_tune.py
--- a/sktime/forecasting/model_selection/_tune.py
+++ b/sktime/forecasting/model_selection/_tune.py
@@ -41,6 +41,7 @@ def __init__(
         scoring=None,
         verbose=0,
         return_n_best_forecasters=1,
+        update_behaviour="full_refit",
     ):
 
         self.forecaster = forecaster
@@ -53,6 +54,7 @@ def __init__(
         self.scoring = scoring
         self.verbose = verbose
         self.return_n_best_forecasters = return_n_best_forecasters
+        self.update_behaviour = update_behaviour
         super(BaseGridSearch, self).__init__()
         tags_to_clone = [
             "requires-fh-in-fit",
@@ -220,6 +222,43 @@ def evaluate_candidates(candidate_params):
 
         return self
 
+    def _update(self, y, X=None, update_params=True):
+        """Update time series to incremental training data.
+
+        Parameters
+        ----------
+        y : guaranteed to be of a type in self.get_tag("y_inner_mtype")
+            Time series with which to update the forecaster.
+            if self.get_tag("scitype:y")=="univariate":
+                guaranteed to have a single column/variable
+            if self.get_tag("scitype:y")=="multivariate":
+                guaranteed to have 2 or more columns
+            if self.get_tag("scitype:y")=="both": no restrictions apply
+        X : optional (default=None)
+            guaranteed to be of a type in self.get_tag("X_inner_mtype")
+            Exogeneous time series for the forecast
+        update_params : bool, optional (default=True)
+            whether model parameters should be updated
+
+        Returns
+        -------
+        self : reference to self
+        """
+        update_behaviour = self.update_behaviour
+
+        if update_behaviour == "full_refit":
+            super()._update(y=y, X=X, update_params=update_params)
+        elif update_behaviour == "inner_only":
+            self.best_forecaster_.update(y=y, X=X, update_params=update_params)
+        elif update_behaviour == "no_update":
+            self.best_forecaster_.update(y=y, X=X, update_params=False)
+        else:
+            raise ValueError(
+                'update_behaviour must be one of "full_refit", "inner_only",'
+                f' or "no_update", but found {update_behaviour}'
+            )
+        return self
+
 
 class ForecastingGridSearchCV(BaseGridSearch):
     """Perform grid-search cross-validation to find optimal model parameters.
@@ -242,6 +281,18 @@ class ForecastingGridSearchCV(BaseGridSearch):
         or a scoring function must be passed.
     cv : cross-validation generator or an iterable
         e.g. SlidingWindowSplitter()
+    strategy : {"refit", "update", "no-update_params"}, optional, default="refit"
+        data ingestion strategy in fitting cv, passed to `evaluate` internally
+        defines the ingestion mode when the forecaster sees new data when window expands
+        "refit" = forecaster is refitted to each training window
+        "update" = forecaster is updated with training window data, in sequence provided
+        "no-update_params" = fit to first training window, re-used without fit or update
+    update_behaviour: str, optional, default = "full_refit"
+        one of {"full_refit", "inner_only", "no_update"}
+        behaviour of the forecaster when calling update
+        "full_refit" = both tuning parameters and inner estimator refit on all data seen
+        "inner_only" = tuning parameters are not re-tuned, inner estimator is updated
+        "no_update" = neither tuning parameters nor inner estimator are updated
     param_grid : dict or list of dictionaries
         Model tuning parameters of the forecaster to evaluate
     scoring: function, optional (default=None)
@@ -251,7 +302,8 @@ class ForecastingGridSearchCV(BaseGridSearch):
         None means 1 unless in a joblib.parallel_backend context.
         -1 means using all processors.
     refit: bool, optional (default=True)
-        Refit the forecaster with the best parameters on all the data
+        True = refit the forecaster with the best parameters on the entire data in fit
+        False = best forecaster remains fitted on the last fold in cv
     verbose: int, optional (default=0)
     return_n_best_forecasters: int, default=1
         In case the n best forecaster should be returned, this value can be set
@@ -367,6 +419,7 @@ def __init__(
         return_n_best_forecasters=1,
         pre_dispatch="2*n_jobs",
         backend="loky",
+        update_behaviour="full_refit",
     ):
         super(ForecastingGridSearchCV, self).__init__(
             forecaster=forecaster,
@@ -379,6 +432,7 @@ def __init__(
             return_n_best_forecasters=return_n_best_forecasters,
             pre_dispatch=pre_dispatch,
             backend=backend,
+            update_behaviour=update_behaviour,
         )
         self.param_grid = param_grid
 
@@ -441,6 +495,7 @@ def get_test_params(cls, parameter_set="default"):
             "cv": SingleWindowSplitter(fh=1),
             "param_grid": {"initialization_method": ["estimated", "heuristic"]},
             "scoring": MeanAbsolutePercentageError(symmetric=True),
+            "update_behaviour": "inner_only",
         }
         return [params, params2]
 
@@ -466,6 +521,18 @@ class ForecastingRandomizedSearchCV(BaseGridSearch):
         or a scoring function must be passed.
     cv : cross-validation generator or an iterable
         e.g. SlidingWindowSplitter()
+    strategy : {"refit", "update", "no-update_params"}, optional, default="refit"
+        data ingestion strategy in fitting cv, passed to `evaluate` internally
+        defines the ingestion mode when the forecaster sees new data when window expands
+        "refit" = forecaster is refitted to each training window
+        "update" = forecaster is updated with training window data, in sequence provided
+        "no-update_params" = fit to first training window, re-used without fit or update
+    update_behaviour: str, optional, default = "full_refit"
+        one of {"full_refit", "inner_only", "no_update"}
+        behaviour of the forecaster when calling update
+        "full_refit" = both tuning parameters and inner estimator refit on all data seen
+        "inner_only" = tuning parameters are not re-tuned, inner estimator is updated
+        "no_update" = neither tuning parameters nor inner estimator are updated
     param_distributions : dict or list of dicts
         Dictionary with parameters names (`str`) as keys and distributions
         or lists of parameters to try. Distributions must provide a ``rvs``
@@ -483,7 +550,8 @@ class ForecastingRandomizedSearchCV(BaseGridSearch):
         None means 1 unless in a joblib.parallel_backend context.
         -1 means using all processors.
     refit: bool, optional (default=True)
-        Refit the forecaster with the best parameters on all the data
+        True = refit the forecaster with the best parameters on the entire data in fit
+        False = best forecaster remains fitted on the last fold in cv
     verbose: int, optional (default=0)
     return_n_best_forecasters: int, default=1
         In case the n best forecaster should be returned, this value can be set
@@ -534,6 +602,7 @@ def __init__(
         random_state=None,
         pre_dispatch="2*n_jobs",
         backend="loky",
+        update_behaviour="full_refit",
     ):
         super(ForecastingRandomizedSearchCV, self).__init__(
             forecaster=forecaster,
@@ -546,6 +615,7 @@ def __init__(
             return_n_best_forecasters=return_n_best_forecasters,
             pre_dispatch=pre_dispatch,
             backend=backend,
+            update_behaviour=update_behaviour,
         )
         self.param_distributions = param_distributions
         self.n_iter = n_iter


Test patch: