Repository: sktime/sktime
Pull request: Multivariate Detrending
#### Reference Issues/PRs

Fixes #1030 . See also #1038 .

#### What does this implement/fix? Explain your changes.

This would be a quick fix to #1030 as it adds functionality for multivariate series to the detrender. As discussed with @mloning I took inspiration from how this was done for the imputer. But I also like the idea to have compositions for multivariate series as it is described in #1038


Problem statement:
Make transformer for detrending support multivariate series
**Is your feature request related to a problem? Please describe.**
For the EEG applications we need a transformer that detrends each channel. Currently the detrending transformer only supports univariate time series.

**Describe the solution you'd like**
Detrend each univariate series separately

Hints from issue discussion:
@SveaMeyer13, what do you think about the solution in #1038? That way, you can apply everything, not just the detrender, to multivariate EEG data.
@fkiraly, yes I like the idea in #1038 - for now, I just took inspiration by how it was done in the imputer.

Commit message: put forecasters into dict and check if all columns are known

Patch to review:
diff --git a/sktime/transformations/series/detrend/_detrend.py b/sktime/transformations/series/detrend/_detrend.py
index 7842969c6f4..09a2ffdfde2 100644
--- a/sktime/transformations/series/detrend/_detrend.py
+++ b/sktime/transformations/series/detrend/_detrend.py
@@ -6,6 +6,7 @@
 __author__ = ["Markus Löning"]
 
 from sklearn.base import clone
+import pandas as pd
 
 from sktime.forecasting.base._fh import ForecastingHorizon
 from sktime.transformations.base import _SeriesToSeriesTransformer
@@ -59,7 +60,7 @@ class Detrender(_SeriesToSeriesTransformer):
     """
 
     _required_parameters = ["forecaster"]
-    _tags = {"transform-returns-same-time-index": True, "univariate-only": True}
+    _tags = {"transform-returns-same-time-index": True}
 
     def __init__(self, forecaster=None):
         self.forecaster = forecaster
@@ -82,11 +83,20 @@ def fit(self, Z, X=None):
         self : an instance of self
         """
         self._is_fitted = False
-        z = check_series(Z, enforce_univariate=True)
+        z = check_series(Z)
         if self.forecaster is None:
             self.forecaster = PolynomialTrendForecaster(degree=1)
-        forecaster = clone(self.forecaster)
-        self.forecaster_ = forecaster.fit(z, X)
+
+        # multivariate
+        if isinstance(z, pd.DataFrame):
+            self.forecaster_ = {}
+            for colname in z.columns:
+                forecaster = clone(self.forecaster)
+                self.forecaster_[colname] = forecaster.fit(z[colname], X)
+        # univariate
+        else:
+            forecaster = clone(self.forecaster)
+            self.forecaster_ = forecaster.fit(z, X)
         self._is_fitted = True
         return self
 
@@ -107,10 +117,22 @@ def transform(self, Z, X=None):
             De-trended series
         """
         self.check_is_fitted()
-        z = check_series(Z, enforce_univariate=True)
+        z = check_series(Z)
         fh = ForecastingHorizon(z.index, is_relative=False)
-        z_pred = self.forecaster_.predict(fh, X)
-        return z - z_pred
+
+        # multivariate
+        if isinstance(z, pd.DataFrame):
+            # check if all columns are known
+            if not all(k in self.forecaster_ for k in z.columns):
+                raise KeyError("not all columns are known")
+            for colname in z.columns:
+                z_pred = self.forecaster_[colname].predict(fh, X)
+                z[colname] = z[colname] - z_pred
+            return z
+        # univariate
+        else:
+            z_pred = self.forecaster_.predict(fh, X)
+            return z - z_pred
 
     def inverse_transform(self, Z, X=None):
         """
@@ -129,10 +151,22 @@ def inverse_transform(self, Z, X=None):
             Series with the trend
         """
         self.check_is_fitted()
-        z = check_series(Z, enforce_univariate=True)
+        z = check_series(Z)
         fh = ForecastingHorizon(z.index, is_relative=False)
-        z_pred = self.forecaster_.predict(fh, X)
-        return z + z_pred
+
+        # multivariate
+        if isinstance(z, pd.DataFrame):
+            # check if all columns are known
+            if not all(k in self.forecaster_ for k in z.columns):
+                raise KeyError("not all columns are known")
+            for colname in z.columns:
+                z_pred = self.forecaster_[colname].predict(fh, X)
+                z[colname] = z[colname] + z_pred
+            return z
+        # univariate
+        else:
+            z_pred = self.forecaster_.predict(fh, X)
+            return z + z_pred
 
     def update(self, Z, X=None, update_params=True):
         """
@@ -149,6 +183,17 @@ def update(self, Z, X=None, update_params=True):
         -------
         self : an instance of self
         """
-        z = check_series(Z, enforce_univariate=True, allow_empty=True)
-        self.forecaster_.update(z, X, update_params=update_params)
+        z = check_series(Z, allow_empty=True)
+        # multivariate
+        if isinstance(z, pd.DataFrame):
+            # check if all columns are known
+            if not all(k in self.forecaster_ for k in z.columns):
+                raise KeyError("Not all columns are known")
+            for colname in z.columns:
+                self.forecaster_[colname].update(
+                    z[colname], X, update_params=update_params
+                )
+        # univariate
+        else:
+            self.forecaster_.update(z, X, update_params=update_params)
         return self


Code review comments:
diff_hunk: @@ -107,10 +117,22 @@ def transform(self, Z, X=None):
             De-trended series
         """
         self.check_is_fitted()
-        z = check_series(Z, enforce_univariate=True)
+        z = check_series(Z)
         fh = ForecastingHorizon(z.index, is_relative=False)
-        z_pred = self.forecaster_.predict(fh, X)
-        return z - z_pred
+
+        # multivariate
+        if isinstance(z, pd.DataFrame):
+            # check if all columns are known
+            if not all(k in self.forecaster_ for k in z.columns):
path: sktime/transformations/series/detrend/_detrend.py
text: @user1:
for the check or printing missing columns, you could do sth like `set(z.columns).difference(set(self.forecaster_.keys()))`; for readability you could first define variables `Z_fit_keys = set(self.forecaster_.keys())`, `Z_new_keys = ...`

@author:
Thanks! I implemented it as you suggested