Repository: sktime/sktime

Issue:
[ENH] Adding MCDNN DL model for Classification
**Is your feature request related to a problem? Please describe.**
Migrating MCDNN  model from sktime-dl to sktime for classification. 

Discussion:
achieveordie: @fkiraly could you please review this too?
achieveordie: Currently,  Ubuntu-3.11 is failing due to:
> FAILED sktime/forecasting/base/tests/test_fh.py::test_auto_arima - ValueError: Could not successfully fit a viable ARIMA model to input data.
See http://alkaline-ml.com/pmdarima/no-successful-model.html for more information on why this can happen.

Is this a known issue? @yarnabrina @fkiraly
yarnabrina: Not known to me, and cannot reproduce locally.

Command used:

> pytest -k "test_auto_arima" sktime/forecasting/base/tests/test_fh.py

Versions:

```pycon
>>> from sktime import show_versions; show_versions()

System:
    python: 3.11.3 (main, Apr 19 2023, 23:54:32) [GCC 11.2.0]
executable: /path/to/venv/bin/python
   machine: Linux-5.15.90.1-microsoft-standard-WSL2-x86_64-with-glibc2.35

Python dependencies:
          pip: 22.3.1
       sktime: 0.19.1
      sklearn: 1.2.2
        numpy: 1.24.3
        scipy: 1.10.1
       pandas: 2.0.2
   matplotlib: None
       joblib: 1.2.0
  statsmodels: 0.14.0
        numba: None
     pmdarima: 2.0.3
      tsfresh: None
   tensorflow: None
tensorflow_probability: None
```

Patch:
diff --git a/docs/source/api_reference/classification.rst b/docs/source/api_reference/classification.rst
index 788828b42..4b3a29f8e 100644
--- a/docs/source/api_reference/classification.rst
+++ b/docs/source/api_reference/classification.rst
@@ -50,6 +50,8 @@ Deep learning
     InceptionTimeClassifier
     MACNNClassifier
     MLPClassifier
+    MCDCNNClassifier
+    SimpleRNNClassifier
     TapNetClassifier
 
 Dictionary-based
diff --git a/docs/source/api_reference/regression.rst b/docs/source/api_reference/regression.rst
index 45f98ea9a..d539c13e2 100644
--- a/docs/source/api_reference/regression.rst
+++ b/docs/source/api_reference/regression.rst
@@ -30,6 +30,8 @@ Deep learning
     :template: class.rst
 
     CNNRegressor
+    MCDCNNRegressor
+    SimpleRNNRegressor
     ResNetRegressor
     TapNetRegressor
 
diff --git a/sktime/classification/deep_learning/mcdcnn.py b/sktime/classification/deep_learning/mcdcnn.py
new file mode 100644
index 000000000..a3c14a6a7
--- /dev/null
+++ b/sktime/classification/deep_learning/mcdcnn.py
@@ -0,0 +1,246 @@
+"""Multi Channel Deep Convolutional Neural Classifier (MCDCNN)."""
+
+__author__ = [
+    "JamesLarge",
+]
+
+from copy import deepcopy
+
+import numpy as np
+from sklearn.utils import check_random_state
+
+from sktime.classification.deep_learning.base import BaseDeepClassifier
+from sktime.networks.mcdcnn import MCDCNNNetwork
+from sktime.utils.validation._dependencies import _check_dl_dependencies
+
+
+class MCDCNNClassifier(BaseDeepClassifier):
+    """Multi Channel Deep Convolutional Neural Classifier, as described in [1]_.
+
+    Parameters
+    ----------
+    n_epochs : int, optional (default=120)
+        The number of epochs to train the model.
+    batch_size : int, optional (default=16)
+        The number of samples per gradient update.
+    kernel_size : int, optional (default=5)
+        The size of kernel in Conv1D layer.
+    pool_size : int, optional (default=2)
+        The size of kernel in (Max) Pool layer.
+    filter_sizes : tuple, optional (default=(8, 8))
+        The sizes of filter for Conv1D layer corresponding
+        to each Conv1D in the block.
+    dense_units : int, optional (default=732)
+        The number of output units of the final Dense
+        layer of this Network. This is NOT the final layer
+        but the penultimate layer.
+    conv_padding : str or None, optional (default="same")
+        The type of padding to be applied to convolutional
+        layers.
+    pool_padding : str or None, optional (default="same")
+        The type of padding to be applied to pooling layers.
+    loss : str, optional (default="categorical_crossentropy")
+        The name of the loss function to be used during training,
+        should be supported by keras.
+    activation : str, optional (default="sigmoid")
+        The activation function to apply at the output. It should be
+        "software" if response variable has more than two types.
+    use_bias : bool, optional (default=True)
+        Whether bias should be included in the output layer.
+    metrics : None or string, optional (default=None)
+        The string which will be used during model compilation. If left as None,
+        then "accuracy" is passed to `model.compile()`.
+    optimizer: None or keras.optimizers.Optimizer instance, optional (default=None)
+        The optimizer that is used for model compiltation. If left as None,
+        then `keras.optimizers.SGD` is used with the following parameters -
+        `learning_rate=0.01, momentum=0.9, weight_decay=0.0005`.
+    callbacks : None or list of keras.callbacks.Callback, optinal (default=None)
+        The callback(s) to use during training.
+    random_state : int, optional (default=0)
+        The seed to any random action.
+
+    Notes
+    -----
+    Adapted from the implementation of Fawaz et. al
+    https://github.com/hfawaz/dl-4-tsc/blob/master/classifiers/mcdcnn.py
+
+    References
+    ----------
+    .. [1] Zheng et. al, Time series classification using multi-channels deep
+      convolutional neural networks, International Conference on
+      Web-Age Information Management, Pages 298-310, year 2014, organization: Springer.
+
+    Examples
+    --------
+    >>> from sktime.classification.deep_learning.mcdcnn import MCDCNNClassifier
+    >>> from sktime.datasets import load_unit_test
+    >>> X_train, y_tain = load_unit_test(split="train")
+    >>> mcdcnn = MCDCNNClassifier()     # doctest: +SKIP
+    >>> mcdcnn.fit(X_train, y_train)    # doctest: +SKIP
+    MCDCNNClassifier(...)
+    """
+
+    _tags = {"python_dependencies": "tensorflow"}
+
+    def __init__(
+        self,
+        n_epochs=120,
+        batch_size=16,
+        kernel_size=5,
+        pool_size=2,
+        filter_sizes=(8, 8),
+        dense_units=732,
+        conv_padding="same",
+        pool_padding="same",
+        loss="categorical_crossentropy",
+        activation="sigmoid",
+        use_bias=True,
+        callbacks=None,
+        metrics=None,
+        optimizer=None,
+        verbose=False,
+        random_state=0,
+    ):
+        _check_dl_dependencies(severity="error")
+        super().__init__()
+
+        self.n_epochs = n_epochs
+        self.batch_size = batch_size
+        self.kernel_size = kernel_size
+        self.pool_size = pool_size
+        self.filter_sizes = filter_sizes
+        self.dense_units = dense_units
+        self.conv_padding = conv_padding
+        self.pool_padding = pool_padding
+        self.loss = loss
+        self.activation = activation
+        self.use_bias = use_bias
+        self.callbacks = callbacks
+        self.metrics = metrics
+        self.optimizer = optimizer
+        self.verbose = verbose
+        self.random_state = random_state
+        self.history = None
+        self._network = MCDCNNNetwork(
+            kernel_size=self.kernel_size,
+            pool_size=self.pool_size,
+            filter_sizes=self.filter_sizes,
+            dense_units=self.dense_units,
+            conv_padding=self.conv_padding,
+            pool_padding=self.pool_padding,
+            random_state=self.random_state,
+        )
+
+    def build_model(self, input_shape, n_classes, **kwargs):
+        """Construct a compiled, un-trained, keras model that is ready for training.
+
+        In sktime, time series are stored in numpy arrays of shape (d,m), where d
+        is the number of dimensions, m is the series length. Keras/tensorflow assume
+        data is in shape (m,d). This method also assumes (m,d). Transpose should
+        happen in fit.
+
+        Parameters
+        ----------
+        input_shape : tuple
+            The shape of the data fed into the input layer, should be (m,d)
+        n_classes: int
+            The number of classes, which becomes the size of the output layer
+
+        Returns
+        -------
+        output : a compiled Keras Model
+        """
+        import tensorflow as tf
+        from tensorflow import keras
+
+        tf.random.set_seed(self.random_state)
+
+        metrics = ["accuracy"] if self.metrics is None else self.metrics
+
+        input_layers, output_layer = self._network.build_network(input_shape, **kwargs)
+
+        output_layer = keras.layers.Dense(
+            units=n_classes,
+            activation=self.activation,
+            use_bias=self.use_bias,
+        )(output_layer)
+
+        self.optimizer_ = (
+            keras.optimizers.SGD(
+                learning_rate=0.01,
+                momentum=0.9,
+                weight_decay=0.0005,
+            )
+            if self.optimizer is None
+            else self.optimizer
+        )
+
+        model = keras.models.Model(inputs=input_layers, outputs=output_layer)
+
+        model.compile(
+            loss=self.loss,
+            optimizer=self.optimizer_,
+            metrics=metrics,
+        )
+
+        return model
+
+    def _fit(self, X, y):
+        """Fit the classifier on the training set (X, y).
+
+        Parameters
+        ----------
+        X : np.ndarray of shape = (n_instances (n), n_dimensions (d), series_length (m))
+            The training input samples.
+        y : np.ndarray of shape n
+            The training data class labels.
+
+        Returns
+        -------
+        self : object
+        """
+        y_onehot = self.convert_y_to_keras(y)
+        X = X.transpose(0, 2, 1)
+        self.input_shape = X.shape[1:]
+        X = self._network._prepare_input(X)
+
+        check_random_state(self.random_state)
+
+        self.model_ = self.build_model(self.input_shape, self.n_classes_)
+        self.callbacks_ = deepcopy(self.callbacks)
+
+        if self.verbose:
+            self.model_.summary()
+
+        self.history = self.model_.fit(
+            X,
+            y_onehot,
+            batch_size=self.batch_size,
+            verbose=self.verbose,
+            callbacks=self.callbacks_,
+        )
+
+        return self
+
+    def _predict_proba(self, X, **kwargs):
+        """Find probability estimates for each class for all cases in X.
+
+        Parameters
+        ----------
+        X : an np.ndarray of shape = (n_instances, n_dimensions, series_length)
+            The training input samples.
+
+        Returns
+        -------
+        output : array of shape = [n_instances, n_classes] of probabilities
+        """
+        self.check_is_fitted()
+        X = X.transpose([0, 2, 1])
+        X = self._network._prepare_input(X)
+
+        probs = self.model_.predict(X, self.batch_size, **kwargs)
+
+        if probs.shape[1] == 1:
+            probs = np.hstack([1 - probs, probs])
+        probs = probs / probs.sum(axis=1, keepdims=1)
+        return probs
diff --git a/sktime/forecasting/model_selection/__init__.py b/sktime/forecasting/model_selection/__init__.py
index 3e9874274..c092f1759 100644
--- a/sktime/forecasting/model_selection/__init__.py
+++ b/sktime/forecasting/model_selection/__init__.py
@@ -9,6 +9,7 @@ __all__ = [
     "SlidingWindowSplitter",
     "temporal_train_test_split",
     "ExpandingWindowSplitter",
+    "TestPlusTrainSplitter",
     "ForecastingGridSearchCV",
     "ForecastingRandomizedSearchCV",
     "ForecastingSkoptSearchCV",
@@ -19,6 +20,7 @@ from sktime.forecasting.model_selection._split import (
     ExpandingWindowSplitter,
     SingleWindowSplitter,
     SlidingWindowSplitter,
+    TestPlusTrainSplitter,
     temporal_train_test_split,
 )
 from sktime.forecasting.model_selection._tune import (
diff --git a/sktime/forecasting/model_selection/_split.py b/sktime/forecasting/model_selection/_split.py
index 27462a6c7..20a5a767c 100644
--- a/sktime/forecasting/model_selection/_split.py
+++ b/sktime/forecasting/model_selection/_split.py
@@ -8,6 +8,7 @@ __all__ = [
     "CutoffSplitter",
     "SingleWindowSplitter",
     "temporal_train_test_split",
+    "TestPlusTrainSplitter",
 ]
 __author__ = ["mloning", "kkoralturk", "khrapovs", "chillerobscuro"]
 
@@ -1301,6 +1302,103 @@ class SingleWindowSplitter(BaseSplitter):
         return params
 
 
+class TestPlusTrainSplitter(BaseSplitter):
+    r"""Splitter that adds the train sets to the test sets.
+
+    Takes a splitter ``cv`` and modifies it in the following way:
+    The i-th train sets is identical to the i-th train set of ``cv``.
+    The i-th test set is the union of the i-th train set and i-th test set of ``cv``.
+
+    Parameters
+    ----------
+    cv : BaseSplitter
+        splitter to modify as above
+
+    Examples
+    --------
+    >>> from sktime.datasets import load_airline
+    >>> from sktime.forecasting.model_selection import ExpandingWindowSplitter
+
+    >>> y = load_airline()
+    >>> y_template = y[:60]
+    >>> cv_tpl = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
+
+    >>> splitter = TestPlusTrainSplitter(cv_tpl)
+    """
+
+    def __init__(self, cv):
+        self.cv = cv
+        super().__init__()
+
+    def _split(self, y: pd.Index) -> SPLIT_GENERATOR_TYPE:
+        """Get iloc references to train/test splits of `y`.
+
+        private _split containing the core logic, called from split
+
+        Parameters
+        ----------
+        y : pd.Index or time series in sktime compatible time series format
+            Time series to split, or index of time series to split
+
+        Yields
+        ------
+        train : 1D np.ndarray of dtype int
+            Training window indices, iloc references to training indices in y
+        test : 1D np.ndarray of dtype int
+            Test window indices, iloc references to test indices in y
+        """
+        cv = self.cv
+
+        for y_train_inner, y_test_inner in cv.split(y):
+            y_train_self = y_train_inner
+            y_test_self = np.union1d(y_train_inner, y_test_inner)
+            yield y_train_self, y_test_self
+
+    def get_n_splits(self, y: Optional[ACCEPTED_Y_TYPES] = None) -> int:
+        """Return the number of splits.
+
+        This will always be equal to the number of splits
+        of ``self.cv`` on ``y``.
+
+        Parameters
+        ----------
+        y : pd.Series or pd.Index, optional (default=None)
+            Time series to split
+
+        Returns
+        -------
+        n_splits : int
+            The number of splits.
+        """
+        return self.cv.get_n_splits(y)
+
+    @classmethod
+    def get_test_params(cls, parameter_set="default"):
+        """Return testing parameter settings for the splitter.
+
+        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.
+
+        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`
+        """
+        from sktime.forecasting.model_selection import ExpandingWindowSplitter
+
+        cv_tpl = ExpandingWindowSplitter(fh=[2, 4], initial_window=24, step_length=12)
+
+        params = {"cv": cv_tpl}
+
+        return params
+
+
 def temporal_train_test_split(
     y: ACCEPTED_Y_TYPES,
     X: Optional[pd.DataFrame] = None,
diff --git a/sktime/networks/mcdcnn.py b/sktime/networks/mcdcnn.py
new file mode 100644
index 000000000..22ec496c4
--- /dev/null
+++ b/sktime/networks/mcdcnn.py
@@ -0,0 +1,136 @@
+"""Multi Channel Deep Convolution Neural Network (MCDCNN)."""
+
+__author__ = ["James Large", "Withington"]
+
+from sktime.networks.base import BaseDeepNetwork
+from sktime.utils.validation._dependencies import _check_dl_dependencies
+
+
+class MCDCNNNetwork(BaseDeepNetwork):
+    """
+    Multi Channel Deep Convolutional Neural Network (MCDCNN).
+
+    Adapted from the implementation from Fawaz et. al:
+    https://github.com/hfawaz/dl-4-tsc/blob/master/classifiers/mcdcnn.py
+
+    Parameters
+    ----------
+    kernel_size : int, optional (default=5)
+        The size of kernel in Conv1D layer.
+    pool_size : int, optional (default=2)
+        The size of kernel in (Max) Pool layer.
+    filter_sizes : tuple, optional (default=(8, 8))
+        The sizes of filter for Conv1D layer corresponding
+        to each Conv1D in the block.
+    dense_units : int, optional (default=732)
+        The number of output units of the final Dense
+        layer of this Network. This is NOT the final layer
+        but the penultimate layer.
+    conv_padding : str or None, optional (default="same")
+        The type of padding to be applied to convolutional
+        layers.
+    pool_padding : str or None, optional (default="same")
+        The type of padding to be applied to pooling layers.
+    random_state : int, optional (default=0)
+        The seed to any random action.
+    """
+
+    _tags = {"python_dependencies": "tensorflow"}
+
+    def __init__(
+        self,
+        kernel_size=5,
+        pool_size=2,
+        filter_sizes=(8, 8),
+        dense_units=732,
+        conv_padding="same",
+        pool_padding="same",
+        random_state=0,
+    ):
+        _check_dl_dependencies(severity="error")
+        super().__init__()
+
+        self.kernel_size = kernel_size
+        self.pool_size = pool_size
+        self.filter_sizes = filter_sizes
+        self.dense_units = dense_units
+        self.conv_padding = conv_padding
+        self.pool_padding = pool_padding
+        self.random_state = random_state
+
+    def build_network(self, input_shape, **kwargs):
+        """
+        Construct a network and return its input and output layers.
+
+        Parameters
+        ----------
+        input_shape : tuple
+            The shape of the data(without batch) fed into the input layer
+
+        Returns
+        -------
+        input_layers : List of keras.layers.Input of length input_shape[1]
+            The input layers of this Network. Equal to number of variables
+            in the independent variable (input_shape[1]).
+        output_layer: And instance of Keras.layers.Layer
+            The output layer of this Network.
+        """
+        from tensorflow import keras
+
+        n_t = input_shape[0]  # corresponding to the number of time steps (m)
+        n_vars = input_shape[1]  # corresponding to the number of variables (d)
+
+        input_layers, conv2_layers = [], []
+
+        for _ in range(n_vars):
+            input_layer = keras.layers.Input((n_t, 1))
+            input_layers.append(input_layer)
+
+            conv1 = keras.layers.Conv1D(
+                self.filter_sizes[0],
+                kernel_size=self.kernel_size,
+                activation="relu",
+                padding=self.conv_padding,
+            )(input_layer)
+            conv1 = keras.layers.MaxPooling1D(
+                pool_size=self.pool_size,
+                padding=self.pool_padding,
+            )(conv1)
+
+            conv2 = keras.layers.Conv1D(
+                self.filter_sizes[1],
+                kernel_size=self.kernel_size,
+                activation="relu",
+                padding=self.conv_padding,
+            )(conv1)
+            conv2 = keras.layers.MaxPooling1D(
+                pool_size=self.pool_size,
+                padding=self.pool_padding,
+            )(conv2)
+            conv2 = keras.layers.Flatten()(conv2)
+
+            conv2_layers.append(conv2)
+
+        # In univariate cases, legacy tf loaders returns just the
+        # layer and not a list of layers with one element,
+        # therefore simply use that layer, bypassing concat layer.
+        if n_vars == 1:
+            output_layer = conv2_layers[0]
+        else:
+            output_layer = keras.layers.Concatenate(axis=-1)(conv2_layers)
+
+        output_layer = keras.layers.Dense(units=self.dense_units, activation="relu")(
+            output_layer
+        )
+
+        return input_layers, output_layer
+
+    def _prepare_input(self, X):
+        # helper function to change X to conform to expected format.
+        new_X = []
+        n_vars = X.shape[2]
+
+        for i in range(n_vars):
+            new_X.append(X[:, :, i : i + 1])
+
+        return new_X
diff --git a/sktime/registry/__init__.py b/sktime/registry/__init__.py
index f66951d26..8a0d38b2c 100644
--- a/sktime/registry/__init__.py
+++ b/sktime/registry/__init__.py
@@ -10,6 +10,7 @@ from sktime.registry._base_classes import (
     TRANSFORMER_MIXIN_REGISTER,
     TRANSFORMER_MIXIN_SCITYPE_LIST,
 )
+from sktime.registry._craft import craft, deps, imports
 from sktime.registry._lookup import all_estimators, all_tags
 from sktime.registry._scitype import scitype
 from sktime.registry._tags import (
@@ -22,6 +23,9 @@ __all__ = [
     "all_estimators",
     "all_tags",
     "check_tag_is_valid",
+    "craft",
+    "deps",
+    "imports",
     "scitype",
     "ESTIMATOR_TAG_LIST",
     "ESTIMATOR_TAG_REGISTER",
diff --git a/sktime/registry/_craft.py b/sktime/registry/_craft.py
new file mode 100644
index 000000000..6e1e626a6
--- /dev/null
+++ b/sktime/registry/_craft.py
@@ -0,0 +1,167 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Quick crafting methods to build an object from string and registry.
+
+craft(spec)
+    craft an object or estimator from string
+
+deps(spec)
+    retrieves all dependencies required to craft str, in PEP440 format
+
+The ``craft`` function is a pair to ``str`` coercion, the two can be seen as
+deserialization/serialization counterparts to each other.
+
+That is,
+spec = str(my_est)
+new_est = craft(spec)
+
+will have the same effect as new_est = spec.clone()
+"""
+
+__author__ = ["fkiraly"]
+
+import re
+
+from sktime.registry._lookup import all_estimators
+
+
+def _extract_class_names(spec):
+    """Get all maximal alphanumeric substrings that start with a capital letter.
+
+    Parameters
+    ----------
+    spec : str (any)
+
+    Returns
+    -------
+    cls_name_list : list of str
+        list of all maximal alphanumeric substrings starting with a capital in ``spec``
+    """
+    pattern = r"\b([A-Z][A-Za-z0-9_]*)\b"
+    cls_name_list = re.findall(pattern, spec)
+    return cls_name_list
+
+
+def craft(spec):
+    """Instantiate an object from the specification string.
+
+    Parameters
+    ----------
+    spec : str, sktime/skbase compatible object specification
+        i.e., a string that executes to construct an object if all imports were present
+        imports inferred are of any classes in the scope of ``all_estimators``
+        option 1: a string that evaluates to an estimator
+        option 2: a sequence of assignments in valid python code,
+            with the object to be defined preceded by a "return"
+            assignments can use names of classes as if all imports were present
+
+    Returns
+    -------
+    obj : skbase BaseObject descendant, constructed from ``spec``
+        this will have the property that ``spec == str(obj)`` (up to formatting)
+    """
+    register = dict(all_estimators())  # noqa: F841
+
+    for x in _extract_class_names(spec):
+        exec(f"{x} = register['{x}']")
+
+    try:
+        obj = eval(spec)
+    except Exception:
+        from textwrap import indent
+
+        spec_fun = indent(spec, "    ")
+        spec_fun = (
+            """
+def build_obj():
+        """
+            + spec_fun
+        )
+        exec(spec_fun, locals())
+        obj = eval("build_obj()")
+
+    return obj
+
+
+def deps(spec):
+    """Get PEP 440 dependency requirements for a craft spec.
+
+    Parameters
+    ----------
+    spec : str, sktime/skbase compatible object specification
+        i.e., a string that executes to construct an object if all imports were present
+        imports inferred are of any classes in the scope of ``all_estimators``
+        option 1: a string that evaluates to an estimator
+        option 2: a sequence of assignments in valid python code,
+            with the object to be defined preceded by a "return"
+            assignments can use names of classes as if all imports were present
+
+    Returns
+    -------
+    reqs : list of str
+        each str is PEP 440 compatible requirement string for craft(spec)
+        if spec has no requirements, return is [], the length 0 list
+    """
+    register = dict(all_estimators())
+
+    dep_strs = []
+
+    for x in _extract_class_names(spec):
+        if x not in register.keys():
+            raise RuntimeError(
+                f"class {x} is required to build spec, but was not found "
+                "in all_estimators scope"
+            )
+        cls = register[x]
+
+        new_deps = cls.get_class_tag("python_dependencies")
+
+        if isinstance(new_deps, list):
+            dep_strs += new_deps
+        elif isinstance(new_deps, str) and len(new_deps) > 0:
+            dep_strs += [new_deps]
+
+        reqs = list(set(dep_strs))
+
+    return reqs
+
+
+def imports(spec):
+    """Get import code block for a craft spec.
+
+    Parameters
+    ----------
+    spec : str, sktime/skbase compatible object specification
+        i.e., a string that executes to construct an object if all imports were present
+        imports inferred are of any classes in the scope of ``all_estimators``
+        option 1: a string that evaluates to an estimator
+        option 2: a sequence of assignments in valid python code,
+            with the object to be defined preceded by a "return"
+            assignments can use names of classes as if all imports were present
+
+    Returns
+    -------
+    import_str : str
+        python code consisting of all import statements required for spec
+        imports cover object/estimator classes found as sub-strings of spec
+    """
+    register = dict(all_estimators())
+
+    import_strs = []
+
+    for x in _extract_class_names(spec):
+        if x not in register.keys():
+            raise RuntimeError(
+                f"class {x} is required to build spec, but was not found "
+                "in all_estimators scope"
+            )
+        cls = register[x]
+
+        import_str = f"from {cls.__module__} import {x}"
+        import_strs += [import_str]
+
+    if len(import_strs) == 0:
+        imports_str = ""
+    else:
+        imports_str = "\n".join(sorted(import_strs))
+
+    return imports_str
diff --git a/sktime/regression/deep_learning/mcdcnn.py b/sktime/regression/deep_learning/mcdcnn.py
new file mode 100644
index 000000000..da75302b9
--- /dev/null
+++ b/sktime/regression/deep_learning/mcdcnn.py
@@ -0,0 +1,239 @@
+"""Multi Channel Deep Convolutional Neural Regressor (MCDCNN)."""
+
+__author__ = [
+    "JamesLarge",
+]
+
+from copy import deepcopy
+
+from numpy import squeeze
+from sklearn.utils import check_random_state
+
+from sktime.networks.mcdcnn import MCDCNNNetwork
+from sktime.regression.deep_learning.base import BaseDeepRegressor
+from sktime.utils.validation._dependencies import _check_dl_dependencies
+
+
+class MCDCNNRegressor(BaseDeepRegressor):
+    """Multi Channel Deep Convolutional Neural Regressor, adopted from [1]_.
+
+    Parameters
+    ----------
+    n_epochs : int, optional (default=120)
+        The number of epochs to train the model.
+    batch_size : int, optional (default=16)
+        The number of samples per gradient update.
+    kernel_size : int, optional (default=5)
+        The size of kernel in Conv1D layer.
+    pool_size : int, optional (default=2)
+        The size of kernel in (Max) Pool layer.
+    filter_sizes : tuple, optional (default=(8, 8))
+        The sizes of filter for Conv1D layer corresponding
+        to each Conv1D in the block.
+    dense_units : int, optional (default=732)
+        The number of output units of the final Dense
+        layer of this Network. This is NOT the final layer
+        but the penultimate layer.
+    conv_padding : str or None, optional (default="same")
+        The type of padding to be applied to convolutional
+        layers.
+    pool_padding : str or None, optional (default="same")
+        The type of padding to be applied to pooling layers.
+    loss : str, optional (default="mean_squared_error")
+        The name of the loss function to be used during training,
+        should be supported by keras.
+    activation : str, optional (default="linear")
+        The activation function to apply at the output.
+    use_bias : bool, optional (default=True)
+        Whether bias should be included in the output layer.
+    metrics : None or string, optional (default=None)
+        The string which will be used during model compilation. If left as None,
+        then "mean_squared_error" is passed to `model.compile()`.
+    optimizer: None or keras.optimizers.Optimizer instance, optional (default=None)
+        The optimizer that is used for model compiltation. If left as None,
+        then `keras.optimizers.SGD` is used with the following parameters -
+        `learning_rate=0.01, momentum=0.9, weight_decay=0.0005`.
+    callbacks : None or list of keras.callbacks.Callback, optinal (default=None)
+        The callback(s) to use during training.
+    random_state : int, optional (default=0)
+        The seed to any random action.
+
+    Notes
+    -----
+    Adapted from the implementation of Fawaz et. al
+    https://github.com/hfawaz/dl-4-tsc/blob/master/classifiers/mcdcnn.py
+
+    References
+    ----------
+    .. [1] Zheng et. al, Time series classification using multi-channels deep
+      convolutional neural networks, International Conference on
+      Web-Age Information Management, Pages 298-310, year 2014, organization: Springer.
+
+    Examples
+    --------
+    >>> from sktime.regression.deep_learning.mcdcnn import MCDCNNRegressor
+    >>> from sktime.datasets import load_unit_test
+    >>> X_train, y_train = load_unit_test(split="train")
+    >>> mcdcnn = MCDCNNRegressor(n_epochs=1, kernel_size=4) # doctest: +SKIP
+    >>> mcdcnn.fit(X_train, y_train) # doctest: +SKIP
+    MCDCNRegressor(...)
+    """
+
+    _tags = {"python_dependencies": "tensorflow"}
+
+    def __init__(
+        self,
+        n_epochs=120,
+        batch_size=16,
+        kernel_size=5,
+        pool_size=2,
+        filter_sizes=(8, 8),
+        dense_units=732,
+        conv_padding="same",
+        pool_padding="same",
+        loss="mean_squared_error",
+        activation="linear",
+        use_bias=True,
+        callbacks=None,
+        metrics=None,
+        optimizer=None,
+        verbose=False,
+        random_state=0,
+    ):
+        _check_dl_dependencies(severity="error")
+        super().__init__()
+
+        self.n_epochs = n_epochs
+        self.batch_size = batch_size
+        self.kernel_size = kernel_size
+        self.pool_size = pool_size
+        self.filter_sizes = filter_sizes
+        self.dense_units = dense_units
+        self.conv_padding = conv_padding
+        self.pool_padding = pool_padding
+        self.loss = loss
+        self.activation = activation
+        self.use_bias = use_bias
+        self.callbacks = callbacks
+        self.metrics = metrics
+        self.optimizer = optimizer
+        self.verbose = verbose
+        self.random_state = random_state
+        self.history = None
+        self._network = MCDCNNNetwork(
+            kernel_size=self.kernel_size,
+            pool_size=self.pool_size,
+            filter_sizes=self.filter_sizes,
+            dense_units=self.dense_units,
+            conv_padding=self.conv_padding,
+            pool_padding=self.pool_padding,
+            random_state=self.random_state,
+        )
+
+    def build_model(self, input_shape, **kwargs):
+        """Construct a compiled, un-trained, keras model that is ready for training.
+
+        In sktime, time series are stored in numpy arrays of shape (d,m), where d
+        is the number of dimensions, m is the series length. Keras/tensorflow assume
+        data is in shape (m,d). This method also assumes (m,d). Transpose should
+        happen in fit.
+
+        Parameters
+        ----------
+        input_shape : tuple
+            The shape of the data fed into the input layer, should be (m,d)
+
+        Returns
+        -------
+        output : a compiled Keras Model
+        """
+        import tensorflow as tf
+        from tensorflow import keras
+
+        tf.random.set_seed(self.random_state)
+        metrics = ["mean_squared_error"] if self.metrics is None else self.metrics
+
+        input_layers, output_layer = self._network.build_network(input_shape, **kwargs)
+
+        output_layer = keras.layers.Dense(
+            units=1,
+            activation=self.activation,
+            use_bias=self.use_bias,
+        )(output_layer)
+
+        self.optimizer_ = (
+            keras.optimizers.SGD(
+                learning_rate=0.01,
+                momentum=0.9,
+                weight_decay=0.0005,
+            )
+            if self.optimizer is None
+            else self.optimizer
+        )
+
+        model = keras.models.Model(inputs=input_layers, outputs=output_layer)
+
+        model.compile(
+            loss=self.loss,
+            optimizer=self.optimizer_,
+            metrics=metrics,
+        )
+
+        return model
+
+    def _fit(self, X, y):
+        """Fit the regressor on the training set (X, y).
+
+        Parameters
+        ----------
+        X : np.ndarray of shape = (n_instances (n), n_dimensions (d), series_length (m))
+            The training input samples.
+        y : np.ndarray of shape n
+            The training data class labels.
+
+        Returns
+        -------
+        self : object
+        """
+        X = X.transpose(0, 2, 1)
+        self.input_shape = X.shape[1:]
+        X = self._network._prepare_input(X)
+
+        check_random_state(self.random_state)
+
+        self.model_ = self.build_model(self.input_shape)
+        self.callbacks_ = deepcopy(self.callbacks)
+
+        if self.verbose:
+            self.model_.summary()
+
+        self.history = self.model_.fit(
+            X,
+            y,
+            batch_size=self.batch_size,
+            verbose=self.verbose,
+            callbacks=self.callbacks_,
+        )
+
+        return self
+
+    def _predict(self, X, **kwargs):
+        """Find regression estimates, for a given independent sample X.
+
+        Parameters
+        ----------
+        X : an np.ndarray of shape = (n_instances, n_dimensions, series_length)
+            The testing input samples.
+
+        Returns
+        -------
+        output : array of shape = [n_instances,]
+            Representing the estimates for all instances in X.
+        """
+        X = X.transpose([0, 2, 1])
+        X = self._network._prepare_input(X)
+
+        probs = self.model_.predict(X, self.batch_size, **kwargs)
+
+        probs = squeeze(probs, axis=-1)
+        return probs


Test patch:
diff --git a/sktime/registry/tests/test_craft.py b/sktime/registry/tests/test_craft.py
new file mode 100644
index 000000000..fc65c563f
--- /dev/null
+++ b/sktime/registry/tests/test_craft.py
@@ -0,0 +1,95 @@
+# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
+"""Testing of crafting functionality."""
+
+__author__ = ["fkiraly"]
+
+import pytest
+
+from sktime.registry._craft import craft, deps, imports
+from sktime.utils.validation._dependencies import _check_soft_dependencies
+
+simple_spec = "NaiveForecaster()"
+simple_spec_with_dep = "VAR(trend='ct')"
+
+pipe_spec = """
+pipe = TransformedTargetForecaster(steps=[
+    ("imputer", Imputer()),
+    ("forecaster", NaiveForecaster())])
+cv = ExpandingWindowSplitter(
+    initial_window=24,
+    step_length=12,
+    fh=[1, 2, 3])
+
+return ForecastingGridSearchCV(
+    forecaster=pipe,
+    param_grid=[{
+        "forecaster": [NaiveForecaster(sp=12)],
+        "forecaster__strategy": ["drift", "last", "mean"],
+    },
+    {
+        "imputer__method": ["mean", "drift"],
+        "forecaster": [ThetaForecaster(sp=12)],
+    },
+    {
+        "imputer__method": ["mean", "median"],
+        "forecaster": [ExponentialSmoothing(sp=12)],
+        "forecaster__trend": ["add", "mul"],
+    },
+    ],
+    cv=cv,
+    n_jobs=-1)
+"""
+
+dunder_spec = "Detrender(ExponentialSmoothing(sp=12)) * ARIMA()"
+
+specs = [simple_spec, simple_spec_with_dep, pipe_spec, dunder_spec]
+
+
+@pytest.mark.skipif(
+    not _check_soft_dependencies(["statsmodels", "pmdarima"], severity="none"),
+    reason="skip test if required soft dependencies not available",
+)
+@pytest.mark.parametrize("spec", specs)
+def test_craft(spec):
+    """Check that crafting works and is inverse to str coercion."""
+    crafted_obj = craft(spec)
+
+    new_spec = str(crafted_obj)
+
+    crafted_again = craft(new_spec)
+
+    assert crafted_again == crafted_obj
+
+
+@pytest.mark.parametrize("spec", specs)
+def test_deps(spec):
+    """Check that deps retrieves the correct requirement sets."""
+    # should return length 0 list since has no deps
+    assert deps(simple_spec) == []
+
+    # should correctly find the single dependency
+    assert deps(simple_spec_with_dep) == ["statsmodels"]
+
+    # has multiple estimators with "statsmodels",
+    # this should be returned like this and not as ["statsmodels", "statsmodels"]
+    assert deps(pipe_spec) == ["statsmodels"]
+
+    # example with two dependencies, should be identified, order does not matter
+    assert set(deps(dunder_spec)) == {"statsmodels", "pmdarima"}
+
+
+def test_imports():
+    """Check that imports produces the correct import blocks."""
+    simple_spec_imports = "from sktime.forecasting.naive import NaiveForecaster"
+    assert imports(simple_spec) == simple_spec_imports
+
+    pipe_imports = (
+        "from sktime.forecasting.compose._pipeline import TransformedTargetForecast"
+        "er\nfrom sktime.forecasting.exp_smoothing import ExponentialSmoothing\nfrom"
+        " sktime.forecasting.model_selection._split import ExpandingWindowSplitter\nf"
+        "rom sktime.forecasting.model_selection._tune import ForecastingGridSearch"
+        "CV\nfrom sktime.forecasting.naive import NaiveForecaster\nfrom sktime.fore"
+        "casting.naive import NaiveForecaster\nfrom sktime.forecasting.theta impor"
+        "t ThetaForecaster\nfrom sktime.transformations.series.impute import Imputer"
+    )
+    assert imports(pipe_spec) == pipe_imports
diff --git a/sktime/tests/_config.py b/sktime/tests/_config.py
index fb2f8e3d0..a2f4ae195 100644
--- a/sktime/tests/_config.py
+++ b/sktime/tests/_config.py
@@ -129,6 +129,12 @@ EXCLUDED_TESTS = {
         "test_persistence_via_pickle",
         "test_save_estimators_to_file",
     ],
+    "MCDCNNClassifier": [
+        "test_fit_idempotent",
+    ],
+    "MCDCNNRegressor": [
+        "test_fit_idempotent",
+    ],
     "MACNNClassifier": [
         "test_fit_idempotent",
     ],