Repository: sktime/sktime

Issue:
[BUG] - TransformedTargetForecaster with Arima can't fit a transformer taking series as input and returning pd.Dataframe with multiple series
**Describe the bug**
<!--

-->
When using a custom decomposition transformer which takes a single time series input and produces multiple in a Pandas dataframe format, used in conjunction with the TransformedTargetForecaster as a pipeline and the Arima model as the forecaster, will produce the following error when making predictions:
    raise ValueError("input must be univariate pd.DataFrame, with one column")
ValueError: input must be univariate pd.DataFrame, with one column

**To Reproduce**
<!--
Run the code snippet below.
-->

```python
import pandas as pd
import numpy as np
from sktime.forecasting.arima import ARIMA
from sktime.forecasting.compose import TransformedTargetForecaster
from sktime.forecasting.model_evaluation import evaluate
from sktime.forecasting.model_selection import ExpandingWindowSplitter
from sktime.performance_metrics.forecasting import MeanSquaredError

from vmd_specifc.VmdTransformer import VmdTransformer
#. Time Domain 0 to T
T = 1000
fs = 1/T
t = np.arange(1,T+1)/T
freqs = 2*np.pi*(t-0.5-fs)/(fs)

#. center frequencies of components
f_1 = 2
f_2 = 24
f_3 = 288

#. modes
v_1 = (np.cos(2*np.pi*f_1*t))
v_2 = 1/4*(np.cos(2*np.pi*f_2*t))
v_3 = 1/16*(np.cos(2*np.pi*f_3*t))

f = v_1 + v_2 + v_3 + 0.1*np.random.randn(v_1.size)
data = pd.Series(f)

pipe = TransformedTargetForecaster(steps=[
    ("vmd transformer", VmdTransformer()),
    ("forecaster", ARIMA())
])
# the below commented section also show the same problem
# y = data[:(round((len(data) / 100) * 70))]
# pipe.fit(y)
# preds = pipe.predict(fh=[1,2,3,4,5,6])
cv = ExpandingWindowSplitter(step_length=2, fh=2,
                             initial_window=(round((len(data) / 100) * 70)) )
cv_df = evaluate(error_score='raise', forecaster=pipe, y=data, cv=cv, strategy="refit",
                 return_data=True,
                 scoring=MeanSquaredError(square_root=True))

```

**Expected behavior**
<!--
-->
A separate Arima model should be fit on each column of the returned pandas data frame, and the model should not throw an error.
**Additional context**
<!--
-->
The link to the custom VmdTransformer pull request will be given here when created, or mentioned in the comments.
**Versions**
<details>

<!--
-->
System:
    python: 3.8.10 (tags/v3.8.10:3d8993a, May  3 2021, 11:48:03) [MSC v.1928 64 bit (AMD64)]
executable: C:\Users\Dane\AppData\Local\Microsoft\WindowsApps\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\python.exe
   machine: Windows-10-10.0.19045-SP0

Python dependencies:
          pip: 23.0
       sktime: 0.21.0
      sklearn: 1.2.1
       skbase: 0.4.6
        numpy: 1.22.4
        scipy: 1.10.0
       pandas: 1.5.3
   matplotlib: 3.3.4
       joblib: 1.2.0
  statsmodels: 0.13.2
        numba: 0.55.2
     pmdarima: 2.0.2
      tsfresh: 0.20.0
   tensorflow: None
tensorflow_probability: 0.19.0
</details>

<!-- Thanks for contributing! -->



Discussion:

Resolving patch:
diff --git a/sktime/transformations/series/vmd/__init__.py b/sktime/transformations/series/vmd/__init__.py
new file mode 100644
--- /dev/null
+++ b/sktime/transformations/series/vmd/__init__.py
@@ -0,0 +1,9 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""VMD transformer and VMD mini-package based on pyvmd."""
+
+__author__ = ["DaneLyttinen", "vcarvo"]
+
+
+from sktime.transformations.series.vmd._vmd import VmdTransformer
+
+__all__ = ["VmdTransformer"]
diff --git a/sktime/transformations/series/vmd/_vmd.py b/sktime/transformations/series/vmd/_vmd.py
new file mode 100644
--- /dev/null
+++ b/sktime/transformations/series/vmd/_vmd.py
@@ -0,0 +1,183 @@
+"""Variational Mode Decomposition transformer."""
+
+import numpy as np
+import pandas as pd
+
+from sktime.transformations.base import BaseTransformer
+from sktime.transformations.series.vmd._vmdpy import VMD
+
+__author__ = ["DaneLyttinen", "vcarvo"]
+
+
+class VmdTransformer(BaseTransformer):
+    """Variational Mode Decomposition transformer.
+
+    An implementation of the Variational Mode Decomposition method (2014) [1]_,
+    based on the ``vmdpy`` package [3]_ by ``vcarvo``, which in turn is based
+    on the original MATLAB implementation by Dragomiretskiy and Zosso [1]_.
+
+    This transformer is the official continuation of the ``vmdpy`` package,
+    maintained in ``sktime``.
+
+    VMD is an decomposition (series-to-series) transformer which uses the Variational
+    Mode Decomposition method to decompose an original time series into
+    multiple Intrinsic Mode Functions.
+    The number of Intrinsic Mode Functions created depend on the K parameter and should
+    be optimally defined if known.
+    If the K parameter is unknown, this transformer will attempt to find
+    a good estimate of it by comparing the
+    original time series against the reconstruction of the signals using
+    the energy loss coefficient, default of 0.01.
+
+    This is useful if you have a complex series and want to decompose it
+    into easier-to-learn IMF's,
+    which when summed together make up an estimate of the original time series with
+    some loss of information.
+
+    References
+    ----------
+    .. [1] K. Dragomiretskiy and D. Zosso, - Variational Mode Decomposition:
+        IEEE Transactions on Signal Processing, vol. 62, no. 3, pp. 531-544, Feb.1,
+        2014, doi: 10.1109/TSP.2013.2288675.
+    .. [2] Vinícius R. Carvalho, Márcio F.D. Moraes, Antônio P. Braga,
+        Eduardo M.A.M. Mendes -
+        Evaluating five different adaptive decomposition methods for EEG signal
+        seizure detection and classification,
+        Biomedical Signal Processing and Control, Volume 62, 2020,
+        102073, ISSN 1746-8094
+        https://doi.org/10.1016/j.bspc.2020.102073.
+    .. [3] https://github.com/vrcarva/vmdpy
+
+    Parameters
+    ----------
+    K : int, optional (default='None')
+        the number of Intrinsic Mode Functions to decompose original series to.
+        If None, will decompose the series iteratively until kMax is reached or the sum
+        of the decomposed modes against the original series is less than
+        the ``energy_loss_coefficient`` parameter.
+    kMax : int, optional (default=30)
+        the limit on the number of Intrinsic Mode Functions to
+        decompose the original series to
+        if the ``energy_loss_coefficient`` hasn't been reached.
+        Only used if ``K`` is ``None``, ignored otherwise.
+    energy_loss_coefficient : int, optional (default=0.01)
+        decides the acceptable loss of information from the
+        original series when the decomposed modes are summed together
+        as calculated by the energy loss coefficient.
+        Only used if ``K`` is ``None``, ignored otherwise.
+    alpha : int, optional (default=2000)
+        bandwidth constraint for the generated Intrinsic Mode Functions,
+        balancing parameter of the data-fidelity constraint
+    tau : int, optional (default=0.)
+        noise tolerance of the generated modes, time step of dual ascent
+    DC : int, optional (default=0)
+        Imposed DC parts
+    init : int, optional (default=1)
+        parameter for omegas, default of one will initialize the omegas uniformly,
+        1 = all omegas initialized uniformly
+        0 = all omegas start at 0,
+        2 = all omegas are initialized at random
+    tol : int, optional (default=1e-7)
+        convergence tolerance criterion
+
+    Examples
+    --------
+    >>> from sktime.transformations.series.vmd import VmdTransformer  # doctest: +SKIP
+    >>> from sktime.datasets import load_solar  # doctest: +SKIP
+    >>> y = load_solar()  # doctest: +SKIP
+    >>> transformer = VmdTransformer()  # doctest: +SKIP
+    >>> modes = transformer.fit_transform(y)  # doctest: +SKIP
+    """
+
+    _tags = {
+        "scitype:transform-input": "Series",
+        "scitype:transform-output": "Series",
+        "scitype:instancewise": True,
+        "scitype:transform-labels": "None",
+        "X_inner_mtype": "pd.DataFrame",
+        "y_inner_mtype": "None",
+        "univariate-only": False,
+        "requires_y": False,
+        "remember_data": False,
+        "fit_is_empty": False,
+        "X-y-must-have-same-index": False,
+        "enforce_index_type": None,
+        "transform-returns-same-time-index": False,
+        "capability:inverse_transform": True,
+        "capability:inverse_transform:exact": False,
+        "skip-inverse-transform": False,
+        "capability:unequal_length": False,
+        "capability:unequal_length:removes": False,
+        "handles-missing-data": False,
+        "capability:missing_values:removes": False,
+    }
+
+    def __init__(
+        self,
+        K=None,
+        kMax=30,
+        alpha=2000,
+        tau=0.0,
+        DC=0,
+        init=1,
+        tol=1e-7,
+        energy_loss_coefficient=0.01,
+    ):
+        super().__init__()
+        self.K = K
+        self.alpha = alpha
+        self.tau = tau
+        self.DC = DC
+        self.init = init
+        self.tol = tol
+        self.kMax = kMax
+        self.energy_loss_coefficient = energy_loss_coefficient
+        self.fit_column_names = None
+
+    def _inverse_transform(self, X, y=None):
+        row_sums = X.sum(axis=1)
+        row_sums.columns = self.fit_column_names
+        return row_sums
+
+    def _fit(self, X, y=None):
+        if self.K is None:
+            self._K = self.__runVMDUntilCoefficientThreshold(X.to_numpy())
+        else:
+            self._K = self.K
+        self.fit_column_names = X.columns
+        return self
+
+    def _transform(self, X, y=None):
+        # Package truncates last if odd, so make even
+        # through duplication then remove duplicate
+        values = X.values
+        if len(values) % 2 == 1:
+            values = np.append(values, values[-1])
+        u, u_hat, omega = VMD(
+            values, self.alpha, self.tau, self._K, self.DC, self.init, self.tol
+        )
+        transposed = u.T
+        if len(transposed) != len(X.values):
+            transposed = transposed[:-1]
+        Y = pd.DataFrame(transposed)
+        return Y
+
+    def __runVMDUntilCoefficientThreshold(self, data):
+        K = 1
+        data = data.flatten()
+        if len(data) % 2 == 1:
+            data = np.append(data, data[-1])
+        while K < self.kMax:
+            u, u_hat, omega = VMD(
+                data, self.alpha, self.tau, K, self.DC, self.init, self.tol
+            )
+            reconstruct = sum(u)
+            energy_loss_coef = np.linalg.norm(
+                (data - reconstruct), 2
+            ) ** 2 / np.linalg.norm(data, 2)
+            if energy_loss_coef > self.energy_loss_coefficient:
+                K += 1
+                continue
+            else:
+                break
+        return K
diff --git a/sktime/transformations/series/vmd/_vmdpy.py b/sktime/transformations/series/vmd/_vmdpy.py
new file mode 100644
--- /dev/null
+++ b/sktime/transformations/series/vmd/_vmdpy.py
@@ -0,0 +1,213 @@
+"""Python implementation of the Variational Mode Decomposition method.
+
+Official fork of the ``vmdpy`` package, maintained in ``sktime``.
+
+sktime migration: 2023, August
+Version 0.2 release: 2020, Aug 11
+Version 0.1 release: 2019, Apr 9
+First version created on Wed Feb 20 19:24:58 2019
+
+Original author: Vinícius Rezende Carvalho
+
+2019 and 2020 releases subject to following license:
+
+Copyright (c) 2019 Vinícius Carvalho & Eduardo Mazoni
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+import numpy as np
+
+__author__ = ["vcarvo"]
+
+
+def VMD(f, alpha, tau, K, DC, init, tol):
+    """Variational mode decomposition.
+
+    Python implementation by Vinícius Rezende Carvalho - vrcarva
+    code based on Dominique Zosso's MATLAB code, available at:
+    https://www.mathworks.com/matlabcentral/fileexchange/44765-variational-mode-decomposition  # noqa E501
+
+    Original paper: Dragomiretskiy, K., & Zosso, D. (2014) [1]_.
+
+    Parameters
+    ----------
+    f : array_like
+        the time domain signal (1D) to be decomposed
+    alpha : float
+        the balancing parameter of the data-fidelity constraint
+    tau : float
+        time-step of the dual ascent ( pick 0 for noise-slack )
+    K : int
+        the number of modes to be recovered
+    DC : bool
+        true if the first mode is put and kept at DC (0-freq)
+    init : int
+        0 = all omegas start at 0
+        1 = all omegas start uniformly distributed
+        2 = all omegas initialized randomly
+    tol : float
+        tolerance of convergence criterion; typically around 1e-6
+
+    Returns
+    -------
+    u : array_like
+        the collection of decomposed modes
+    u_hat : array_like
+        spectra of the modes
+    omega : array_like
+        estimated mode center-frequencies
+
+    References
+    ----------
+    .. [1] K. Dragomiretskiy and D. Zosso, - Variational Mode Decomposition:
+        IEEE Transactions on Signal Processing, vol. 62, no. 3, pp. 531-544, Feb.1,
+        2014, doi: 10.1109/TSP.2013.2288675.
+    """
+    if len(f) % 2:
+        f = f[:-1]
+
+    # Period and sampling frequency of input signal
+    fs = 1.0 / len(f)
+
+    ltemp = len(f) // 2
+    fMirr = np.append(np.flip(f[:ltemp], axis=0), f)
+    fMirr = np.append(fMirr, np.flip(f[-ltemp:], axis=0))
+
+    # Time Domain 0 to T (of mirrored signal)
+    T = len(fMirr)
+    t = np.arange(1, T + 1) / T
+
+    # Spectral Domain discretization
+    freqs = t - 0.5 - (1 / T)
+
+    # Maximum number of iterations (if not converged yet, then it won't anyway)
+    Niter = 500
+    # For future generalizations: individual alpha for each mode
+    Alpha = alpha * np.ones(K)
+
+    # Construct and center f_hat
+    f_hat = np.fft.fftshift(np.fft.fft(fMirr))
+    f_hat_plus = np.copy(f_hat)  # copy f_hat
+    f_hat_plus[: T // 2] = 0
+
+    # Initialization of omega_k
+    omega_plus = np.zeros([Niter, K])
+
+    if init == 1:
+        for i in range(K):
+            omega_plus[0, i] = (0.5 / K) * i
+    elif init == 2:
+        omega_plus[0, :] = np.sort(
+            np.exp(np.log(fs) + (np.log(0.5) - np.log(fs)) * np.random.rand(1, K))
+        )
+    else:
+        omega_plus[0, :] = 0
+
+    # if DC mode imposed, set its omega to 0
+    if DC:
+        omega_plus[0, 0] = 0
+
+    # start with empty dual variables
+    lambda_hat = np.zeros([Niter, len(freqs)], dtype=complex)
+
+    # other inits
+    uDiff = tol + np.spacing(1)  # update step
+    n = 0  # loop counter
+    sum_uk = 0  # accumulator
+    # matrix keeping track of every iterant // could be discarded for mem
+    u_hat_plus = np.zeros([Niter, len(freqs), K], dtype=complex)
+
+    # *** Main loop for iterative updates***
+
+    while uDiff > tol and n < Niter - 1:  # not converged and below iterations limit
+        # update first mode accumulator
+        k = 0
+        sum_uk = u_hat_plus[n, :, K - 1] + sum_uk - u_hat_plus[n, :, 0]
+
+        # update spectrum of first mode through Wiener filter of residuals
+        u_hat_plus_enumerator = f_hat_plus - sum_uk - lambda_hat[n, :] / 2
+        u_hat_plus_denominator = 1.0 + Alpha[k] * (freqs - omega_plus[n, k]) ** 2
+        u_hat_plus[n + 1, :, k] = u_hat_plus_enumerator / u_hat_plus_denominator
+
+        # update first omega if not held at 0
+        if not DC:
+            o_pl_enum = np.dot(
+                freqs[T // 2 : T], abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
+            )
+            o_pl_denom = np.sum(abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2)
+            omega_plus[n + 1, k] = o_pl_enum / o_pl_denom
+
+        # update of any other mode
+        for k in np.arange(1, K):
+            # accumulator
+            sum_uk = u_hat_plus[n + 1, :, k - 1] + sum_uk - u_hat_plus[n, :, k]
+            # mode spectrum
+            u_hat_plus_enumerator = f_hat_plus - sum_uk - lambda_hat[n, :] / 2
+            u_hat_plus_denominator = 1.0 + Alpha[k] * (freqs - omega_plus[n, k]) ** 2
+            u_hat_plus[n + 1, :, k] = u_hat_plus_enumerator / u_hat_plus_denominator
+            # center frequencies
+            o_pl_enum = np.dot(
+                freqs[T // 2 : T], abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2
+            )
+            o_pl_denom = np.sum(abs(u_hat_plus[n + 1, T // 2 : T, k]) ** 2)
+            omega_plus[n + 1, k] = o_pl_enum / o_pl_denom
+
+        # Dual ascent
+        lambda_update = tau * (np.sum(u_hat_plus[n + 1, :, :], axis=1) - f_hat_plus)
+        lambda_hat[n + 1, :] = lambda_hat[n, :] + lambda_update
+
+        # loop counter
+        n = n + 1
+
+        # converged yet?
+        uDiff = np.spacing(1)
+        for i in range(K):
+            dot_left = u_hat_plus[n, :, i] - u_hat_plus[n - 1, :, i]
+            dot_right = np.conj(u_hat_plus[n, :, i] - u_hat_plus[n - 1, :, i])
+            uDiff = uDiff + (1 / T) * np.dot(dot_left, dot_right)
+
+        uDiff = np.abs(uDiff)
+
+    # Postprocessing and cleanup
+
+    # discard empty space if converged early
+    Niter = np.min([Niter, n])
+    omega = omega_plus[:Niter, :]
+
+    idxs = np.flip(np.arange(1, T // 2 + 1), axis=0)
+
+    # Signal reconstruction
+    u_hat = np.zeros([T, K], dtype=complex)
+    u_hat[T // 2 : T, :] = u_hat_plus[Niter - 1, T // 2 : T, :]
+    u_hat[idxs, :] = np.conj(u_hat_plus[Niter - 1, T // 2 : T, :])
+    u_hat[0, :] = np.conj(u_hat[-1, :])
+
+    u = np.zeros([K, len(t)])
+    for k in range(K):
+        u[k, :] = np.real(np.fft.ifft(np.fft.ifftshift(u_hat[:, k])))
+
+    # remove mirror part
+    u = u[:, T // 4 : 3 * T // 4]
+
+    # recompute spectrum
+    u_hat = np.zeros([u.shape[1], K], dtype=complex)
+    for k in range(K):
+        u_hat[:, k] = np.fft.fftshift(np.fft.fft(u[k, :]))
+
+    return u, u_hat, omega


Test patch: