Repository: vanderschaarlab/temporai (language: python)

Issue:
Fix the problem of accessing static/class methods on plugins

Fix the problem of accessing static/class methods on plugins



Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index dcb9af84..7b566376 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -3,6 +3,9 @@
 branch = True
 source = tempor
 # omit = bad_file.py
+disable_warnings =
+    # pytest-xdist occasionally raises rogue module-not-measured warnings, ignore:
+    module-not-measured
 
 [paths]
 source =
diff --git a/README.md b/README.md
index 5deb9228..639f85dc 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ assert dataset.static.dataframe().isna().sum().sum() != 0
 assert dataset.time_series.dataframe().isna().sum().sum() != 0
 
 # load the model
-model = plugin_loader.get("preprocessing.imputation.bfill")
+model = plugin_loader.get("preprocessing.imputation.temporal.bfill")
 
 # train
 model.fit(dataset)
@@ -86,7 +86,7 @@ from tempor.plugins import plugin_loader
 dataset = SineDataLoader().load()
 
 # load the model
-model = plugin_loader.get("classification.nn_classifier", n_iter=50)
+model = plugin_loader.get("prediction.one_off.classification.nn_classifier", n_iter=50)
 
 # train
 model.fit(dataset)
@@ -103,7 +103,7 @@ from tempor.plugins import plugin_loader
 dataset = SineDataLoader().load()
 
 # load the model
-model = plugin_loader.get("regression.nn_regressor", n_iter=50)
+model = plugin_loader.get("prediction.one_off.regression.nn_regressor", n_iter=50)
 
 # train
 model.fit(dataset)
@@ -129,14 +129,14 @@ testcases = [
         "pipeline1",
         Pipeline(
             [
-                "preprocessing.scaling.static_minmax_scaler",
-                "classification.nn_classifier",
+                "preprocessing.scaling.static.static_minmax_scaler",
+                "prediction.one_off.classification.nn_classifier",
             ]
         )({"nn_classifier": {"n_iter": 10}}),
     ),
     (
         "plugin1",
-        plugin_loader.get("classification.nn_classifier", n_iter=10),
+        plugin_loader.get("prediction.one_off.classification.nn_classifier", n_iter=10),
     ),
 ]
 dataset = SineDataLoader().load()
@@ -152,13 +152,13 @@ aggr_score, per_test_score = benchmark_models(
 print(aggr_score)
 ```
 
-^ Serialization
+* Serialization
 ```python
 from tempor.utils.serialization import load, save
 from tempor.plugins import plugin_loader
 
 # load the model
-model = plugin_loader.get("classification.nn_classifier", n_iter=50)
+model = plugin_loader.get("prediction.one_off.classification.nn_classifier", n_iter=50)
 
 buff = save(model)  # save model to bytes
 reloaded = load(buff)  # reload model
@@ -168,28 +168,51 @@ reloaded = load(buff)  # reload model
 
 ## 🔑 Methods
 
+
+
 ### Prediction
-* Classification
+
+#### One-off
+Prediction where targets are static.
+
+* Classification (category: `prediction.one_off.classification`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
-| `classification.nn_classifier` | Neural-net based classifier. Supports multiple recurrent models, like RNN, LSTM, Transformer etc.  | --- |
-| `classification.ode_classifier` | Classifier based on ordinary differential equation (ODE) solvers.  | --- |
-| `classification.cde_classifier` | Classifier based Neural Controlled Differential Equations for Irregular Time Series.  | [Paper](https://arxiv.org/abs/2005.08926) |
-| `classification.laplace_ode_classifier` | Classifier based Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.  | [Paper](https://arxiv.org/abs/2206.04843) |
-| `classification.seq2seq_classifier` | Seq2Seq prediction  | --- |
+| `nn_classifier` | Neural-net based classifier. Supports multiple recurrent models, like RNN, LSTM, Transformer etc.  | --- |
+| `ode_classifier` | Classifier based on ordinary differential equation (ODE) solvers.  | --- |
+| `cde_classifier` | Classifier based Neural Controlled Differential Equations for Irregular Time Series.  | [Paper](https://arxiv.org/abs/2005.08926) |
+| `laplace_ode_classifier` | Classifier based Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.  | [Paper](https://arxiv.org/abs/2206.04843) |
 
-* Regression
+* Regression (category: `prediction.one_off.regression`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
-| `regression.nn_regressor` | Neural-net based regressor. Supports multiple recurrent models, like RNN, LSTM, Transformer etc.  | --- |
-| `regression.ode_regressor` | Regressor based on ordinary differential equation (ODE) solvers.  | --- |
-| `regression.cde_regressor` | Regressor based Neural Controlled Differential Equations for Irregular Time Series.  | [Paper](https://arxiv.org/abs/2005.08926)
-| `regression.laplace_ode_regressor` | Regressor based Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.  | [Paper](https://arxiv.org/abs/2206.04843) |
-| `classification.seq2seq_regressor` | Seq2Seq prediction  | --- |
+| `nn_regressor` | Neural-net based regressor. Supports multiple recurrent models, like RNN, LSTM, Transformer etc.  | --- |
+| `ode_regressor` | Regressor based on ordinary differential equation (ODE) solvers.  | --- |
+| `cde_regressor` | Regressor based Neural Controlled Differential Equations for Irregular Time Series.  | [Paper](https://arxiv.org/abs/2005.08926)
+| `laplace_ode_regressor` | Regressor based Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.  | [Paper](https://arxiv.org/abs/2206.04843) |
+
+#### Temporal
+Prediction where targets are temporal (time series).
 
-* Time-to-Event
+* Classification (category: `prediction.temporal.classification`)
+
+| Name | Description| Reference |
+| --- | --- | --- |
+| `seq2seq_classifier` | Seq2Seq prediction, classification | --- |
+
+* Regression (category: `prediction.temporal.regression`)
+
+| Name | Description| Reference |
+| --- | --- | --- |
+| `seq2seq_regressor` | Seq2Seq prediction, regression | --- |
+
+
+
+### Time-to-Event
+
+Risk estimation given event data (category: `time_to_event`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
@@ -197,82 +220,74 @@ reloaded = load(buff)  # reload model
 | `ts_coxph` | Create embeddings from the time series and use a CoxPH model for predicting the survival function| --- |
 | `ts_xgb` | Create embeddings from the time series and use a SurvivalXGBoost model for predicting the survival function| --- |
 
-* Treatment effects
+
+
+### Treatment effects
+
+#### One-off
+Treatment effects estimation where treatments are a one-off event.
+
+<!--
+* Classification on the outcomes (category: `treatments.one_off.classification`)
+-->
+
+* Regression on the outcomes (category: `treatments.one_off.regression`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
-| `crn_regressor` | The Counterfactual Recurrent Network (CRN), a sequence-to-sequence model that leverages the available patient observational data to estimate treatment effects over time. | [Paper](https://arxiv.org/abs/2002.04083) |
-| `crn_classifier` | The Counterfactual Recurrent Network (CRN), a sequence-to-sequence model that leverages the available patient observational data to estimate treatment effects over time. | [Paper](https://arxiv.org/abs/2002.04083) |
 | `synctwin_regressor` | SyncTwin is a treatment effect estimation method tailored for observational studies with longitudinal data, applied to the LIP setting: Longitudinal, Irregular and Point treatment.  | [Paper](https://proceedings.neurips.cc/paper/2021/hash/19485224d128528da1602ca47383f078-Abstract.html) |
 
+#### Temporal
+Treatment effects estimation where treatments are temporal (time series).
 
-
-### Preprocessing
-* Imputation
+* Classification on the outcomes (category: `treatments.temporal.classification`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
-| `preprocessing.imputation.ffill` | Propagate last valid observation forward to next valid  | --- |
-| `preprocessing.imputation.bfill` | Use next valid observation to fill gap | --- |
-| `preprocessing.imputation.static_imputation` | Use HyperImpute to impute both the static and temporal data | [Paper](https://arxiv.org/abs/2206.07769) |
+| `crn_classifier` | The Counterfactual Recurrent Network (CRN), a sequence-to-sequence model that leverages the available patient observational data to estimate treatment effects over time. | [Paper](https://arxiv.org/abs/2002.04083) |
 
-* Scalers
+* Regression on the outcomes (category: `treatments.temporal.regression`)
 
 | Name | Description| Reference |
 | --- | --- | --- |
-| `preprocessing.scaling.static_standard_scaler` | Scale the static features using a StandardScaler | --- |
-| `preprocessing.scaling.static_minmax_scaler` | Scale the static features using a MinMaxScaler | --- |
-| `preprocessing.scaling.ts_standard_scaler` | Scale the temporal features using a StandardScaler | --- |
-| `preprocessing.scaling.ts_minmax_scaler` | Scale the temporal features using a MinMaxScaler | --- |
+| `crn_regressor` | The Counterfactual Recurrent Network (CRN), a sequence-to-sequence model that leverages the available patient observational data to estimate treatment effects over time. | [Paper](https://arxiv.org/abs/2002.04083) |
+
 
-<!--
-## Models
 
-**Time Series Prediction (Forecasting)**
+### Preprocessing
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| A version of LSTM | Classic method | [📄](https://ieeexplore.ieee.org/abstract/document/6795963) | ✔️ Available |
-| A version of GRU | Classic method | [📄](https://arxiv.org/abs/1409.1259) | ✔️ Available |
-| A version of Seq2Seq | Classic method | [📄](https://proceedings.neurips.cc/paper/2014/hash/a14ac55a4f27472c5d894ec1c3c743d2-Abstract.html) | ✔️ Available |
-| [NeuralLaplace](https://github.com/samholt/NeuralLaplace) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v162/holt22a.html) | 🔵 Planned |
+#### Imputation
 
-**Time Series Imputation**
+* Static data (category: `preprocessing.imputation.static`)
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| `{f,b}fill` & Mean | Classic method(s) | N/A | ✔️ Available |
-| [HyperImpute](https://github.com/vanderschaarlab/HyperImpute) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v162/jarrett22a/jarrett22a.pdf) | 🔵 Planned
+| Name | Description| Reference |
+| --- | --- | --- |
+| `static_imputation` | Use HyperImpute to impute both the static and temporal data | [Paper](https://arxiv.org/abs/2206.07769) |
 
-**Temporal Treatment Effects**
+* Temporal data (category: `preprocessing.imputation.temporal`)
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| [CRN](https://github.com/vanderschaarlab/mlforhealthlabpub/tree/main/alg/counterfactual_recurrent_network) | [van der Schaar Lab] | [📄](https://openreview.net/forum?id=BJg866NFvB) | ✔️ Available |
-| [SyncTwin](https://github.com/vanderschaarlab/SyncTwin-NeurIPS-2021/) | [van der Schaar Lab] | [📄](https://proceedings.neurips.cc/paper/2021/hash/19485224d128528da1602ca47383f078-Abstract.html) | ➰ Experimental |
-| [TE-CDE](https://github.com/vanderschaarlab/TE-CDE/) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v162/seedat22b/seedat22b.pdf) | 🔵 Planned |
+| Name | Description| Reference |
+| --- | --- | --- |
+| `ffill` | Propagate last valid observation forward to next valid  | --- |
+| `bfill` | Use next valid observation to fill gap | --- |
 
-**Temporal Time-to-event Analysis**
+### Scaling
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| [Dynamic DeepHit Lite](https://github.com/chl8856/prostate_temporal) | [van der Schaar Lab] | [📄](https://www.nature.com/articles/s41746-022-00659-w) | ➰ Experimental |
-| [Dynamic DeepHit](https://github.com/vanderschaarlab/mlforhealthlabpub/tree/main/alg/dynamic_deephit) | [van der Schaar Lab] | [📄](https://pubmed.ncbi.nlm.nih.gov/30951460/) | 🔵 Planned |
+* Static data (category: `preprocessing.scaling.static`)
 
-**Interpretability**
+| Name | Description| Reference |
+| --- | --- | --- |
+| `static_standard_scaler` | Scale the static features using a StandardScaler | --- |
+| `static_minmax_scaler` | Scale the static features using a MinMaxScaler | --- |
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| [DynaMask](https://github.com/vanderschaarlab/Dynamask) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v139/crabbe21a.html) | 🔵 Planned |
+* Temporal data (category: `preprocessing.scaling.temporal`)
 
-**Temporal Clustering**
+| Name | Description| Reference |
+| --- | --- | --- |
+| `ts_standard_scaler` | Scale the temporal features using a StandardScaler | --- |
+| `ts_minmax_scaler` | Scale the temporal features using a MinMaxScaler | --- |
 
-| Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
-| ----- | ----------- | ----- | ------ |
-| [AC-TPC](https://github.com/vanderschaarlab/mlforhealthlabpub/tree/main/alg/ac_tpc) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v119/lee20h.html) | 🔵 Planned |
 
-\* `✔️ Available` & `➰ Experimental` may include some items still to be migrated from the [previous iteration of the project](https://github.com/vanderschaarlab/clairvoyance2).
--->
 
 ## TemporAI Pipeline
 The diagram below illustrates the structure of a *TemporAI* pipeline:
diff --git a/docs/conf.py b/docs/conf.py
index 917ebe23..ef0bee38 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -171,46 +171,7 @@
 
 autoclass_content = "both"
 
-from sphinx.ext.autodoc import ClassDocumenter
-
-
-class TemporPluginDocumenter(ClassDocumenter):
-    # Make sphinx/autodoc correctly document the TemporAI plugin classes by pointing it to the wrapped class,
-    # which is found in the `._cls` attribute.
-    # See:
-    #     - This StackOverflow answer: https://stackoverflow.com/a/33060970
-    #     - Sphinx autodoc extension, `ClassDocumenter` implementation: sphinx/ext/autodoc/__init__py
-
-    objtype = "tempor_plugin"
-    directivetype = "class"
-
-    _inner_class_attr = "_cls"
-    _tempor_plugin_indicator_attr = "_tempor_plugin_"
-
-    def import_object(self, raiseerror: bool = False) -> bool:
-        ret = super().import_object(raiseerror)
-
-        # The below does the trick.
-        self.object = getattr(self.object, self._inner_class_attr)
-        self.objpath.append(self._inner_class_attr)
-
-        return ret
-
-    def generate(self, more_content=None, real_modname=None, check_module=False, all_members=False) -> None:
-        # Remove the "_cls" from the actual generated directive.
-        super().generate(more_content, real_modname, check_module, all_members)
-        for i, s in enumerate(self.directive.result):
-            self.directive.result[i] = s.replace(f".{self._inner_class_attr}", "")
-
-    @classmethod
-    def can_document_member(cls, member, membername, isattr, parent):
-        # Trigger this custom Documenter if the object is a tempor plugin (has `_tempor_plugin_` attribute).
-        return hasattr(member, TemporPluginDocumenter._tempor_plugin_indicator_attr)
-
-
-def setup(app):
-    app.add_autodocumenter(TemporPluginDocumenter)
-
+autodoc_member_order = "bysource"
 
 # autodoc_mock_imports = ["sklearn"]  # Update as needed.
 
diff --git a/setup.cfg b/setup.cfg
index aa77c673..1da89caa 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -48,28 +48,29 @@ python_requires = >=3.7
 # new major versions. This works if the required packages follow Semantic Versioning.
 # For more information, check out https://semver.org/.
 install_requires =
-    clairvoyance2
+    clairvoyance2 >=0.0.2
+    cloudpickle
     hydra-core >=1.3
+    hyperimpute >= 0.1.17
     importlib-metadata; python_version<"3.8"
+    lifelines
     loguru
     numpy >=1
+    packaging
     pandas >=1
     pandera >=0.13
     pydantic >=1, <2
-    hyperimpute >= 0.1.17
+    rich
+    scikit-learn
     scikit-learn >= 1.0
     scipy
-    torchlaplace >= 0.0.4
+    torch
     torchcde
     torchdiffeq
+    torchlaplace >= 0.0.4
     tsai
-    rich
-    scikit-learn
-    lifelines
-    xgbse
-    cloudpickle
     typing-extensions
-    torch
+    xgbse
 
 
 [options.packages.find]
@@ -89,7 +90,6 @@ dev =
     blacken-docs
     build
     flake8
-    sphinx-material
     isort
     jupyter
     mypy >= 1.0.0
@@ -99,12 +99,14 @@ dev =
     pyscaffoldext-markdown
     pytest
     pytest-cov
+    pytest-xdist
     setuptools
     setuptools-scm
     sphinx >=5, !=5.1.0  # Bug in version 5.1.0.
+    sphinx-material
     tox
-    types-requests
     twine
+    types-requests
 
 [options.entry_points]
 # Add here console scripts like:
diff --git a/src/tempor/benchmarks/evaluation.py b/src/tempor/benchmarks/evaluation.py
index 47ed1065..7936de37 100644
--- a/src/tempor/benchmarks/evaluation.py
+++ b/src/tempor/benchmarks/evaluation.py
@@ -1,6 +1,6 @@
 import copy
 from time import time
-from typing import Any, Callable, Dict, List, Sequence, Union, cast
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Sequence, Union, cast
 
 import numpy as np
 import pandas as pd
@@ -13,13 +13,17 @@
 from tempor.data import data_typing, dataset, samples
 from tempor.log import logger
 from tempor.models.utils import enable_reproducibility
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
 from . import metrics as tempor_metrics
 from . import utils
 
+if TYPE_CHECKING:  # pragma: no cover
+    from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+    from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+    from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
+
+# TODO: Benchmarking workflow for prediction.temporal case.
+
 ClassifierSupportedMetric = Literal[
     "aucroc",
     "aucprc",
@@ -367,7 +371,7 @@ def evaluate_classifier(  # pylint: disable=unused-argument
 
     if n_splits < 2 or not isinstance(n_splits, int):
         raise ValueError("n_splits must be an integer >= 2")
-    estimator_: BaseClassifier = cast(BaseClassifier, estimator)
+    estimator_ = cast("BaseOneOffClassifier", estimator)
     enable_reproducibility(random_state)
 
     results = _InternalScores()
@@ -443,7 +447,7 @@ def evaluate_regressor(  # pylint: disable=unused-argument
     """
     if n_splits < 2 or not isinstance(n_splits, int):
         raise ValueError("n_splits must be an integer >= 2")
-    estimator_: BaseRegressor = cast(BaseRegressor, estimator)
+    estimator_ = cast("BaseOneOffRegressor", estimator)
     enable_reproducibility(random_state)
     metrics = regression_supported_metrics
 
@@ -592,7 +596,7 @@ def evaluate_time_to_event(  # pylint: disable=unused-argument
     """
     if n_splits < 2 or not isinstance(n_splits, int):
         raise ValueError("n_splits must be an integer >= 2")
-    estimator_: BaseTimeToEventAnalysis = cast(BaseTimeToEventAnalysis, estimator)
+    estimator_ = cast("BaseTimeToEventAnalysis", estimator)
     enable_reproducibility(random_state)
     metrics = time_to_event_supported_metrics
     metrics_map = {
diff --git a/src/tempor/benchmarks/metrics.py b/src/tempor/benchmarks/metrics.py
index 9a95b9d5..66b7d7c1 100644
--- a/src/tempor/benchmarks/metrics.py
+++ b/src/tempor/benchmarks/metrics.py
@@ -5,15 +5,12 @@
 import sklearn.base
 import sklearn.utils
 import sklearn.utils.validation
+from packaging.version import Version
 from typing_extensions import Self
 
-import tempor.core.utils
-
 # --- Utilities. ---
 
-sklearn_major, sklearn_minor, *_ = tempor.core.utils.get_version(sklearn.__version__)
-
-if tempor.core.utils.version_above_incl(version=(sklearn_major, sklearn_minor), above_incl=(1, 1)):
+if Version(sklearn.__version__) >= Version("1.1"):
     _has_input_name = True
 else:
     _has_input_name = False
diff --git a/src/tempor/benchmarks/utils.py b/src/tempor/benchmarks/utils.py
index ec8b475a..f8664601 100644
--- a/src/tempor/benchmarks/utils.py
+++ b/src/tempor/benchmarks/utils.py
@@ -40,9 +40,6 @@ def evaluate_auc_multiclass(
 
     n_classes = len(set(np.ravel(y_test)))
     classes = sorted(set(np.ravel(y_test)))
-    log.debug(
-        "Warning: classes is none and more than two " f"(#{n_classes}), classes assumed to be an ordered set:{classes}"
-    )
 
     y_pred_proba_tmp = get_y_pred_proba_hlpr(y_pred_proba, n_classes)
 
diff --git a/src/tempor/config/__init__.py b/src/tempor/config/__init__.py
index 52803bdf..f525b78f 100644
--- a/src/tempor/config/__init__.py
+++ b/src/tempor/config/__init__.py
@@ -46,10 +46,10 @@ class TemporConfig:
     working_directory: str = omegaconf.MISSING
 
     def get_working_dir(self):
-        if self.working_directory == "$PWD":
-            return os.getcwd()
-        elif self.working_directory == "~":
-            return os.path.expanduser("~")
+        if self.working_directory.startswith("$PWD"):
+            return self.working_directory.replace("$PWD", os.getcwd(), 1)
+        elif self.working_directory.startswith("~"):
+            return self.working_directory.replace("~", os.path.expanduser("~"), 1)
         else:
             return self.working_directory
 
diff --git a/src/tempor/core/utils.py b/src/tempor/core/utils.py
index 168b9fa9..b5fed5c4 100644
--- a/src/tempor/core/utils.py
+++ b/src/tempor/core/utils.py
@@ -1,5 +1,7 @@
 import enum
-from typing import Tuple
+from typing import Any, Dict, Tuple
+
+from typing_extensions import get_args
 
 
 def get_class_full_name(o: object):
@@ -58,3 +60,15 @@ def version_below_excl(version: Tuple[int, int], below_excl: Tuple[int, int]) ->
     v_major, v_minor = version
     compare_major, compare_minor = below_excl
     return (v_major == compare_major and v_minor < compare_minor) or v_major < compare_major
+
+
+def ensure_literal_matches_dict_keys(
+    literal: Any, d: Dict[str, Any], literal_name: str = "literal", dict_name: str = "dictionary"
+):
+    lits = set(get_args(literal))
+    keys = set(d.keys())
+    if lits != keys:
+        raise TypeError(
+            f"There was a mismatch between the literal '{literal_name}' and the the dictionary "
+            f"'{dict_name}' keys: {list(lits.symmetric_difference(keys))}"
+        )
diff --git a/src/tempor/data/dataloader.py b/src/tempor/data/dataloader.py
index 5e23cd7f..b660b556 100644
--- a/src/tempor/data/dataloader.py
+++ b/src/tempor/data/dataloader.py
@@ -24,13 +24,15 @@ class DataLoader(abc.ABC):
     It will be ``< tempor -> config -> working_directory > / data``.
     """
 
-    def __init__(self, *args, **kwargs) -> None:  # pylint: disable=unused-argument
+    def __init__(self, **kwargs) -> None:  # pylint: disable=unused-argument
         """Initializer for `DataLoader`.
 
         Args:
             dataset_dir (Optional[str]):
                 Pass in the subdirectory within ``data_root_dir`` where the data source files will be stored,
                 if relevant.
+            **kwargs:
+                Any additional keyword arguments for the :class:`DataLoader`.
         """
         os.makedirs(self.data_root_dir, exist_ok=True)
         dataset_dir = self.dataset_dir()
diff --git a/src/tempor/data/dataset.py b/src/tempor/data/dataset.py
index 7f4b5a94..4043b5d8 100644
--- a/src/tempor/data/dataset.py
+++ b/src/tempor/data/dataset.py
@@ -2,7 +2,7 @@
 
 import abc
 import dataclasses
-from typing import ClassVar, Generator, Optional, Tuple, Union
+from typing import Any, ClassVar, Generator, Optional, Tuple, Union
 
 import rich.pretty
 import sklearn.model_selection
@@ -166,7 +166,7 @@ def static(self, value: Optional[samples.StaticSamples]) -> None:
         self.validate()
 
     def __len__(self) -> int:
-        return len(self.time_series)
+        return self.time_series.num_samples
 
     def __getitem__(self, key: data_typing.GetItemKey) -> Self:
         key_ = utils.ensure_pd_iloc_key_returns_df(key)
@@ -224,7 +224,7 @@ def split(
 
         Example:
             >>> from sklearn.model_selection import KFold
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
+            >>> from tempor.utils.dataloaders import SineDataLoader
             >>> data = SineDataLoader().load()
             >>> kfold = KFold(n_splits=5)
             >>> len([(data_train, data_test) for (data_train, data_test) in data.split(splitter=kfold)])
@@ -236,7 +236,7 @@ def split(
         Yields:
             Generator[Tuple[Self, Self], None, None]: ``(dataset_train, dataset_test)`` for each split.
         """
-        sample_ilocs = list(range(len(self)))
+        sample_ilocs: Any = list(range(len(self)))
         for sample_ilocs_train, sample_ilocs_test in splitter.split(X=sample_ilocs, **kwargs):
             yield self[sample_ilocs_train], self[sample_ilocs_test]
 
diff --git a/src/tempor/data/pandera_utils.py b/src/tempor/data/pandera_utils.py
index 46b6b6ad..2bfc32bf 100644
--- a/src/tempor/data/pandera_utils.py
+++ b/src/tempor/data/pandera_utils.py
@@ -1,17 +1,17 @@
 import sys
-from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Tuple
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union
 
+import numpy as np
 import pandas as pd
 import pandera as pa
+import pandera.dtypes as pa_dtypes
+import pandera.engines.pandas_engine as pd_engine
+from packaging.version import Version
 
 import tempor.core.utils
-from tempor.log import logger
 
 from . import data_typing
 
-pa_major, pa_minor, *_ = tempor.core.utils.get_version(pa.__version__)
-
-
 _PA_DF_SCHEMA_INIT_PARAMETERS = [
     "columns",
     "checks",
@@ -51,7 +51,7 @@
     "unique",
 ]
 
-if tempor.core.utils.version_below_excl(version=(pa_major, pa_minor), below_excl=(0, 14)):
+if Version(pa.__version__) < Version("0.14"):
     # Before v0.14, pandera API had an extra parameter `report_duplicates`.
     _PA_MULTI_INDEX_INIT_PARAMETERS.append("report_duplicates")
 
@@ -117,43 +117,124 @@ def update_multiindex(multi_index: pa.MultiIndex, **kwargs) -> pa.MultiIndex:
 
 def get_pa_dtypes(dtypes: Iterable[data_typing.Dtype]) -> List[pa.DataType]:
     """Return a list of `pandera.DataType` corresponding to ``dtypes``. Raises `KeyError` If not found."""
-    pa_dtypes = []
+    pa_dtypes_ = []
     for dt in dtypes:
         if isinstance(dt, pa.DataType):
-            # If item in `dtypes` already a `pandera.Dtype`, pass it through.
+            # If item in `dtypes` already an instance of `pandera.DataType`, pass it through.
             dt_add = dt
+        elif hasattr(dt, "__mro__") and issubclass(dt, pa.DataType):  # type: ignore
+            # If item in `dtypes` a `pandera.DataType` class, pass it through as an instance.
+            dt_add = dt()  # type: ignore
         else:
             try:
                 dt_add = PA_DTYPE_MAP[dt]
             except KeyError as ex:
                 raise KeyError(f"Mapping from `{dt}` to a pandera DataType not found") from ex
-        pa_dtypes.append(dt_add)
+        pa_dtypes_.append(dt_add)
         if sys.platform == "win32":
             # Pandera .Int()/.Float() do not appear to correctly validate on Windows, unless one specifically
             # provides the bytes.
             if dt_add == pa.Int():
-                pa_dtypes.extend([pa.Int8(), pa.Int16(), pa.Int32(), pa.Int64()])
+                pa_dtypes_.extend([pa.Int8(), pa.Int16(), pa.Int32(), pa.Int64()])
             if dt_add == pa.Float():
-                pa_dtypes.extend([pa.Float16(), pa.Float32(), pa.Float64()])
-    return list(set(pa_dtypes))
+                pa_dtypes_.extend([pa.Float16(), pa.Float32(), pa.Float64()])
+    return list(set(pa_dtypes_))
+
 
+class UnionDtype(pd_engine.DataType):
+    """Extend `pandera` ``DataType`` s with a custom ``UnionDtype``, which will function similarly to ``Union``.
 
-def check_by_series_schema(series: pd.Series, series_name: str, dtypes: List[pa.DataType], **kwargs) -> bool:
-    """Will check that ``series`` satisfies a `pandera.SeriesSchema` with at least one dtype from ``dtypes``.
-    May pass additional `pandera.SeriesSchema` kwargs via ``kwargs``.
+    See `pandera` ``DataType`` [guide](https://pandera.readthedocs.io/en/stable/dtypes.html) for details.
+
+    In this case, rather than wrapping the extension ``DataType`` with ``register_dtype`` and ``immutable`` decorators,
+    we apply these directly to the class returned by ``__class_getitem__``, which dynamically creates the union
+    specified with its dtypes. In this way, `pandera`'s ``pandas`` engine correctly registers each new kind of union
+    as a different dtype.
     """
-    logger.trace(f"Doing {series_name} dtype validation.")
-    validated: List[bool] = []
-    for type_ in set(dtypes):
-        try:
-            pa.SeriesSchema(type_, **kwargs).validate(series)
-            logger.trace(f"{series_name} validated? Yes: {type_}")
-            validated.append(True)
-            break
-        except (pa.errors.SchemaError, pa.errors.SchemaErrors):  # pyright: ignore
-            logger.trace(f"{series_name} validated?  No: {type_}")
-            validated.append(False)
-    return any(validated)
+
+    union_dtypes: List
+    """The list of types in the union."""
+    type: Any
+    """The string representation of the data type, which will be, e.g., shown in exceptions."""
+
+    name: str
+    """The string representation of the data type used for `repr`."""
+
+    @classmethod
+    def __class_getitem__(cls, item):
+        """Allows for setting union types like ``UnionDtype[dtype, ...]``.
+
+        Acceptable ``dtype`` s are: `pandera.DataType` (as a class or instance) or the keys of
+        `~tempor.data.pandera_utils.PA_DTYPE_MAP`.
+        """
+        if not tempor.core.utils.is_iterable(item):
+            item = [item]
+        union_dtypes = get_pa_dtypes(item)
+        union_dtypes = sorted(union_dtypes, key=str)  # For consistency: `item` can get captured in random order.
+        repr_union_dtypes = str([str(t) for t in union_dtypes]).replace("'", "")
+        name = f"{cls.__name__}{repr_union_dtypes}"
+
+        cls_ = type(name, (UnionDtype,), dict())
+        cls_.union_dtypes = union_dtypes  # type: ignore
+        cls_.type = name  # type: ignore
+        cls_.name = name  # type: ignore
+
+        return pd_engine.Engine.register_dtype(pa_dtypes.immutable(cls_))  # type: ignore
+
+    def __repr__(self) -> str:
+        return self.name
+
+    def check(
+        self,
+        pandera_dtype: pa_dtypes.DataType,
+        data_container=None,
+    ) -> Union[bool, Iterable[bool]]:
+        """Checks whether the ``pandera_dtype`` and optionally ``data_container`` satisfy at least one the union's
+        ``union_dtypes``.
+
+        Args:
+            pandera_dtype (pa_dtypes.DataType):
+                The data type received as part of the check/validation.
+            data_container (PandasObject, optional):
+                The data container received as part of the check/validation. Defaults to `None`.
+
+        Returns:
+            Union[bool, Iterable[bool]]:
+                A `bool` stating whether the data type is satisfied, or an iterable thereof\
+                (for each item in the ``data_container``).
+        """
+        for union_dtype in self.union_dtypes:
+            validated = pd_engine.Engine.dtype(union_dtype).check(pandera_dtype, data_container)
+            if data_container is None:
+                # Only in case of direct type comparison, we also need to check via the union_dtype.check(pandera_dtype)
+                # method, making sure that pandera_dtype is an DataType instance not class.
+                if hasattr(pandera_dtype, "__mro__"):
+                    pandera_dtype = pandera_dtype()  # type: ignore
+                validated = validated or union_dtype.check(pandera_dtype)
+            if tempor.core.utils.is_iterable(validated):
+                validated = all(validated)  # type: ignore
+            if validated:
+                if data_container is None:
+                    return True
+                else:
+                    return np.full_like(data_container, True, dtype=bool)
+
+        if data_container is None:
+            return False
+        else:
+            return np.full_like(data_container, False, dtype=bool)
+
+    def coerce(self, data_container):
+        """The ``coerce`` method is not supported and will throw a `NotImplementedError`."""
+        raise NotImplementedError(f"`coerce` not supported by {self.__class__.__name__}")
+
+
+def init_schema(data: pd.DataFrame, **kwargs) -> pa.DataFrameSchema:
+    schema = pa.infer_schema(data)
+    if TYPE_CHECKING:  # pragma: no cover
+        assert isinstance(schema, pa.DataFrameSchema)  # nosec B101
+    schema = update_schema(schema, **kwargs)
+    return schema
 
 
 def add_df_checks(schema: pa.DataFrameSchema, *, checks_list: List[pa.Check]) -> pa.DataFrameSchema:
@@ -162,7 +243,12 @@ def add_df_checks(schema: pa.DataFrameSchema, *, checks_list: List[pa.Check]) ->
 
 
 def add_regex_column_checks(
-    schema: pa.DataFrameSchema, *, regex: str = ".*", dtype: Any, nullable: bool, checks_list: List[pa.Check]
+    schema: pa.DataFrameSchema,
+    *,
+    regex: str = ".*",
+    dtype: Any,
+    nullable: bool,
+    checks_list: Optional[List[pa.Check]] = None,
 ) -> pa.DataFrameSchema:
     """Update ``schema`` with checks specified in ``checks_list``, applied to all columns specified by ``regex``.
     ``dtype`` and ``nullable`` can also be specified and will apply to all columns.
@@ -186,12 +272,14 @@ def set_up_index(
     schema: pa.DataFrameSchema,
     data: pd.DataFrame,
     *,
+    dtype: Any,
     name: str,
     nullable: bool,
     unique: bool,
-    checks_list: List[pa.Check],
+    coerce: bool,
+    checks_list: Optional[List[pa.Check]] = None,
 ) -> Tuple[pa.DataFrameSchema, pd.DataFrame]:
-    """Update ``schema.index`` (`pandera.Index`) with ``name``, ``nullable``, ... schema settings.
+    """Update ``schema.index`` (`pandera.Index`) with ``dtype``, ``name``, ``nullable``, ... schema settings.
 
     In addition, set the index name of ``data`` (`pandas.DataFrame`) to ``name``.
 
@@ -201,10 +289,12 @@ def set_up_index(
         raise ValueError("Expected DataFrameSchema Index to not be None")
     index = update_index(
         schema.index,
+        dtype=dtype,
         nullable=nullable,
         unique=unique,
         name=name,
         checks=checks_list,
+        coerce=coerce,
     )
     schema = update_schema(schema, index=index)
     data.index.set_names(name, inplace=True)  # Name the index.
@@ -215,12 +305,14 @@ def set_up_2level_multiindex(
     schema: pa.DataFrameSchema,
     data: pd.DataFrame,
     *,
+    dtypes: Tuple[Any, Any],
     names: Tuple[str, str],
     nullable: Tuple[bool, bool],
+    coerce: bool,
     unique: Tuple[str, ...],
-    checks_list: Tuple[List[pa.Check], List[pa.Check]],
+    checks_list: Optional[Tuple[List[pa.Check], List[pa.Check]]] = None,
 ) -> Tuple[pa.DataFrameSchema, pd.DataFrame]:
-    """Update ``schema.index`` (`pandera.MultiIndex`), which is expected to have 2 levels, with ``name``,
+    """Update ``schema.index`` (`pandera.MultiIndex`), which is expected to have 2 levels, with `dtypes```, ``names``,
     ``nullable``, ... schema settings.
 
     In addition, set the index name of ``data`` (`pandas.DataFrame`) to ``name``.
@@ -236,14 +328,18 @@ def set_up_2level_multiindex(
 
     index_0 = update_index(
         schema.index.indexes[0],
+        dtype=dtypes[0],
         name=names[0],
         nullable=nullable[0],
-        checks=checks_list[0],
+        coerce=coerce,
+        checks=checks_list[0] if checks_list is not None else None,
     )
     index_1 = update_index(
         schema.index.indexes[1],
+        dtype=dtypes[1],
         name=names[1],
-        checks=checks_list[1],
+        coerce=coerce,
+        checks=checks_list[1] if checks_list is not None else None,
     )
 
     index = update_multiindex(schema.index, indexes=[index_0, index_1], unique=unique)
@@ -282,46 +378,17 @@ class configurable:
         """Namespace containing functions to get configurable `pandera.Check` s."""
 
         @staticmethod
-        def values_satisfy_dtypes(dtypes: List[data_typing.Dtype]) -> pa.Check:
-            series_name = "Values"
-            error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
-            return pa.Check(
-                lambda col: check_by_series_schema(
-                    pd.Series(col),
-                    series_name=series_name,
-                    dtypes=get_pa_dtypes(dtypes),
-                    nullable=True,
-                    coerce=False,  # NOTE: For simplicity "coerce-able" values are not accepted.
-                ),
-                error=error,
-            )
-
-        @staticmethod
-        def index_satisfies_dtypes(dtypes: List[data_typing.Dtype]) -> pa.Check:
-            series_name = "Index"
-            error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
-            return pa.Check(
-                lambda idx: check_by_series_schema(
-                    pd.Series(idx),
-                    series_name=series_name,
-                    dtypes=get_pa_dtypes(dtypes),
-                    nullable=False,
-                    coerce=False,
-                ),
-                error=error,
-            )
-
-        @staticmethod
-        def column_index_satisfies_dtypes(dtypes: List[data_typing.Dtype], *, nullable: bool) -> pa.Check:
+        def column_index_satisfies_dtype(dtype: Any, *, nullable: bool) -> pa.Check:
             series_name = "Column Index"
-            error = str(f"DataFrame {series_name} dtype validation failed, must be one of: {dtypes}")
-            return pa.Check(
-                lambda df: check_by_series_schema(
-                    pd.Series(df.columns),
-                    series_name=series_name,
-                    dtypes=get_pa_dtypes(dtypes),
+            error = str(f"DataFrame {series_name} dtype validation failed, must be of type: {dtype}")
+
+            def _check(df: pd.DataFrame) -> bool:
+                pa.SeriesSchema(
+                    dtype,
+                    name=series_name,
                     nullable=nullable,
                     coerce=False,
-                ),
-                error=error,
-            )
+                ).validate(pd.Series(df.columns, name=series_name))
+                return True
+
+            return pa.Check(_check, error=error)
diff --git a/src/tempor/data/samples.py b/src/tempor/data/samples.py
index eb6e0da9..522b722d 100644
--- a/src/tempor/data/samples.py
+++ b/src/tempor/data/samples.py
@@ -185,7 +185,7 @@ def modality(self) -> data_typing.DataModality:
         return data_typing.DataModality.STATIC
 
     def _validate(self) -> None:
-        schema = pa.infer_schema(self._data)
+        schema = pandera_utils.init_schema(self._data, coerce=False)
         if TYPE_CHECKING:  # pragma: no cover
             assert isinstance(schema, pa.DataFrameSchema)  # nosec B101
         logger.debug(f"Inferred schema:\n{schema}")
@@ -196,8 +196,9 @@ def _validate(self) -> None:
             checks_list=[
                 pandera_utils.checks.forbid_multiindex_index,
                 pandera_utils.checks.forbid_multiindex_columns,
-                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
-                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                pandera_utils.checks.configurable.column_index_satisfies_dtype(
+                    pandera_utils.UnionDtype[DATA_SETTINGS.feature_index_dtypes],  # type: ignore
+                    nullable=DATA_SETTINGS.feature_index_nullable,
                 ),
             ],
         )
@@ -207,9 +208,8 @@ def _validate(self) -> None:
         schema = pandera_utils.add_regex_column_checks(
             schema,
             regex=".*",
-            dtype=None,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.static_value_dtypes],  # type: ignore
             nullable=DATA_SETTINGS.static_values_nullable,
-            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.static_value_dtypes)],
         )
         self._data = schema.validate(self._data)
 
@@ -217,10 +217,11 @@ def _validate(self) -> None:
         schema, data = pandera_utils.set_up_index(
             schema,
             self._data,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.sample_index_dtypes],  # type: ignore
             name=DATA_SETTINGS.sample_index_name,
             nullable=DATA_SETTINGS.sample_index_nullable,
+            coerce=False,
             unique=DATA_SETTINGS.sample_index_unique,
-            checks_list=[pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
         )
         self._data = schema.validate(data)
 
@@ -344,7 +345,7 @@ def __init__(
         super().__init__(data, **kwargs)
 
     def _validate(self) -> None:
-        schema = pa.infer_schema(self._data)
+        schema = pandera_utils.init_schema(self._data, coerce=False)
         if TYPE_CHECKING:  # pragma: no cover
             assert isinstance(schema, pa.DataFrameSchema)  # nosec B101
         logger.debug(f"Inferred schema:\n{schema}")
@@ -355,8 +356,9 @@ def _validate(self) -> None:
             checks_list=[
                 pandera_utils.checks.forbid_multiindex_columns,
                 pandera_utils.checks.require_2level_multiindex_index,
-                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
-                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                pandera_utils.checks.configurable.column_index_satisfies_dtype(
+                    pandera_utils.UnionDtype[DATA_SETTINGS.feature_index_dtypes],  # type: ignore
+                    nullable=DATA_SETTINGS.feature_index_nullable,
                 ),
             ],
         )
@@ -366,11 +368,8 @@ def _validate(self) -> None:
         schema = pandera_utils.add_regex_column_checks(
             schema,
             regex=".*",
-            dtype=None,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.time_series_value_dtypes],  # type: ignore
             nullable=DATA_SETTINGS.time_series_values_nullable,
-            checks_list=[
-                pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.time_series_value_dtypes)
-            ],
         )
         self._data = schema.validate(self._data)
 
@@ -381,13 +380,14 @@ def _validate(self) -> None:
         schema, data = pandera_utils.set_up_2level_multiindex(
             schema,
             self._data,
+            dtypes=(
+                pandera_utils.UnionDtype[DATA_SETTINGS.sample_index_dtypes],  # type: ignore
+                pandera_utils.UnionDtype[DATA_SETTINGS.time_index_dtypes],  # type: ignore
+            ),
             names=(DATA_SETTINGS.sample_index_name, DATA_SETTINGS.time_index_name),
             nullable=(DATA_SETTINGS.sample_index_nullable, DATA_SETTINGS.time_index_nullable),
+            coerce=False,
             unique=multiindex_unique_def,
-            checks_list=(
-                [pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
-                [pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.time_index_dtypes)],
-            ),
         )
         self._data = schema.validate(data)
 
@@ -530,7 +530,7 @@ def list_of_dataframes(self) -> List[pd.DataFrame]:
 
     @property
     def num_samples(self) -> int:
-        sample_ids = self._data.index.levels[0]  # pyright: ignore
+        sample_ids = utils.get_df_index_level0_unique(self._data)
         return len(sample_ids)
 
     @property
@@ -592,7 +592,7 @@ def __init__(
         super().__init__(data, **kwargs)
 
     def _validate(self) -> None:
-        schema = pa.infer_schema(self._data)
+        schema = pandera_utils.init_schema(self._data, coerce=False)
         if TYPE_CHECKING:  # pragma: no cover
             assert isinstance(schema, pa.DataFrameSchema)  # nosec B101
         logger.debug(f"Inferred schema:\n{schema}")
@@ -603,8 +603,9 @@ def _validate(self) -> None:
             checks_list=[
                 pandera_utils.checks.forbid_multiindex_index,
                 pandera_utils.checks.forbid_multiindex_columns,
-                pandera_utils.checks.configurable.column_index_satisfies_dtypes(
-                    DATA_SETTINGS.feature_index_dtypes, nullable=DATA_SETTINGS.feature_index_nullable
+                pandera_utils.checks.configurable.column_index_satisfies_dtype(
+                    pandera_utils.UnionDtype[DATA_SETTINGS.feature_index_dtypes],  # type: ignore
+                    nullable=DATA_SETTINGS.feature_index_nullable,
                 ),
             ],
         )
@@ -622,20 +623,18 @@ def _validate(self) -> None:
         # Validate event time and value components:
         suffix = _DEFAULT_EVENTS_TIME_FEATURE_SUFFIX
         data_split = self.split(time_feature_suffix=suffix)
-        schema_split = pa.infer_schema(data_split)
+        schema_split = pandera_utils.init_schema(data_split, coerce=False)
         schema_split = pandera_utils.add_regex_column_checks(
             schema_split,
             regex=f".*{suffix}$",  # Event time columns, end in "_time".
-            dtype=None,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.time_index_dtypes],  # type: ignore
             nullable=DATA_SETTINGS.time_index_nullable,
-            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.time_index_dtypes)],
         )
         schema_split = pandera_utils.add_regex_column_checks(
             schema_split,
             regex=f"^((?!{suffix}$).)*$",  # Event value columns, do not end in "_time".
-            dtype=None,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.event_value_dtypes],  # type: ignore
             nullable=DATA_SETTINGS.event_values_nullable,
-            checks_list=[pandera_utils.checks.configurable.values_satisfy_dtypes(DATA_SETTINGS.event_value_dtypes)],
         )
         logger.debug(f"Time split-off schema (checks event time and values separately):\n{schema_split}")
         schema_split.validate(data_split)
@@ -645,10 +644,11 @@ def _validate(self) -> None:
         schema, data = pandera_utils.set_up_index(
             schema,
             self._data,
+            dtype=pandera_utils.UnionDtype[DATA_SETTINGS.sample_index_dtypes],  # type: ignore
             name=DATA_SETTINGS.sample_index_name,
             nullable=DATA_SETTINGS.sample_index_nullable,
+            coerce=False,
             unique=DATA_SETTINGS.sample_index_unique,
-            checks_list=[pandera_utils.checks.configurable.index_satisfies_dtypes(DATA_SETTINGS.sample_index_dtypes)],
         )
         self._data = schema.validate(data)
 
diff --git a/src/tempor/data/utils.py b/src/tempor/data/utils.py
index adedc76b..77e490eb 100644
--- a/src/tempor/data/utils.py
+++ b/src/tempor/data/utils.py
@@ -5,6 +5,7 @@
 import numpy as np
 import pandas as pd
 import pydantic
+from packaging.version import Version
 
 import tempor.core.utils
 
@@ -30,8 +31,7 @@ def value_in_df(df: pd.DataFrame, *, value: Any) -> bool:
 
 
 def set_df_column_names_inplace(df: pd.DataFrame, names: Sequence) -> pd.DataFrame:
-    pd_major, pd_minor, *_ = tempor.core.utils.get_version(pd.__version__)
-    if tempor.core.utils.version_below_excl(version=(pd_major, pd_minor), below_excl=(1, 5)):
+    if Version(pd.__version__) < Version("1.5"):
         df.set_axis(names, axis="columns", inplace=True)  # pyright: ignore
         return df
     else:
diff --git a/src/tempor/models/constants.py b/src/tempor/models/constants.py
index cb15e481..fc81e633 100644
--- a/src/tempor/models/constants.py
+++ b/src/tempor/models/constants.py
@@ -8,4 +8,25 @@
 ModelTaskType = Literal["classification", "regression"]
 ODEBackend = Literal["ode", "cde", "laplace"]
 
-Nonlin = Literal["none", "elu", "relu", "leaky_relu", "selu", "tanh", "sigmoid", "softmax"]
+Nonlin = Literal[
+    "none",
+    "elu",
+    "relu",
+    "leaky_relu",
+    "selu",
+    "tanh",
+    "sigmoid",
+    "softmax",
+    "gumbel_softmax",
+]
+"""Possible values specifying a nonlinearity."""
+
+Samp = Literal[
+    "BatchSampler",
+    "RandomSampler",
+    "Sampler",
+    "SequentialSampler",
+    "SubsetRandomSampler",
+    "WeightedRandomSampler",
+]
+"""Possible values specifying a Sampler."""
diff --git a/src/tempor/models/mlp.py b/src/tempor/models/mlp.py
index ecdc52b9..591e85b6 100644
--- a/src/tempor/models/mlp.py
+++ b/src/tempor/models/mlp.py
@@ -21,7 +21,7 @@ def __init__(
         n_units_out: int,
         dropout: float = 0,
         batch_norm: bool = False,
-        nonlin: Optional[str] = "relu",
+        nonlin: Optional[Nonlin] = "relu",
         device: Any = constants.DEVICE,
     ) -> None:
         super(LinearLayer, self).__init__()
@@ -54,7 +54,7 @@ def __init__(
         n_units_out: int,
         dropout: float = 0,
         batch_norm: bool = False,
-        nonlin: Optional[str] = "relu",
+        nonlin: Optional[Nonlin] = "relu",
         device: Any = constants.DEVICE,
     ) -> None:
         super(ResidualLayer, self).__init__(
diff --git a/src/tempor/models/utils.py b/src/tempor/models/utils.py
index 090ff08c..89066ad7 100644
--- a/src/tempor/models/utils.py
+++ b/src/tempor/models/utils.py
@@ -1,15 +1,20 @@
 import os
 import random
 import warnings
+from typing import Dict, Type, Union
 
 import numpy as np
 import torch
 import torch.backends.cudnn
+import torch.utils.data.sampler
 import torch.version
+from packaging.version import Version
 from torch import nn
 
 import tempor.core.utils
 
+from .constants import DEVICE, Nonlin, Samp
+
 
 def enable_reproducibility(
     random_seed: int = 0,
@@ -55,14 +60,16 @@ def enable_reproducibility(
     if torch_use_deterministic_algorithms:
         torch.use_deterministic_algorithms(True)
         if warn_cuda_env_vars and torch.version.cuda not in ("", None):
-            major, minor = tempor.core.utils.get_version(torch.version.cuda)
-            if (major, minor) == (10, 1) and os.environ.get("CUDA_LAUNCH_BLOCKING", None) != "1":
+            cuda_version = Version(torch.version.cuda)
+            if (cuda_version.major, cuda_version.minor) == (10, 1) and os.environ.get(
+                "CUDA_LAUNCH_BLOCKING", None
+            ) != "1":
                 warnings.warn(
                     "When setting torch.use_deterministic_algorithms and using CUDA 10.1, the environment variable "
                     "CUDA_LAUNCH_BLOCKING must be set to 1, else RNN/LSTM algorithms will not be deterministic.",
                     UserWarning,
                 )
-            if tempor.core.utils.version_above_incl(version=(major, minor), above_incl=(10, 2)) and (
+            if cuda_version >= Version("10.2") and (
                 os.environ.get("CUBLAS_WORKSPACE_CONFIG", None) not in (":4096:2", ":16:8")
             ):
                 warnings.warn(
@@ -92,22 +99,47 @@ def forward(self, logits: torch.Tensor) -> torch.Tensor:
         return nn.functional.gumbel_softmax(logits, tau=self.tau, hard=self.hard, dim=self.dim)
 
 
-def get_nonlin(name: str) -> nn.Module:
-    if name == "none":
-        return nn.Identity()
-    elif name == "elu":
-        return nn.ELU()
-    elif name == "relu":
-        return nn.ReLU()
-    elif name == "leaky_relu":
-        return nn.LeakyReLU()
-    elif name == "selu":
-        return nn.SELU()
-    elif name == "tanh":
-        return nn.Tanh()
-    elif name == "sigmoid":
-        return nn.Sigmoid()
-    elif name == "softmax":
-        return GumbelSoftmax(dim=-1)
+NONLIN_MAP: Dict[str, nn.Module] = {
+    "none": nn.Identity(),
+    "elu": nn.ELU(),
+    "relu": nn.ReLU(),
+    "leaky_relu": nn.LeakyReLU(),
+    "selu": nn.SELU(),
+    "tanh": nn.Tanh(),
+    "sigmoid": nn.Sigmoid(),
+    "softmax": nn.Softmax(dim=-1),
+    "gumbel_softmax": GumbelSoftmax(dim=-1),
+}
+tempor.core.utils.ensure_literal_matches_dict_keys(Nonlin, NONLIN_MAP, "Nonlin", "NONLIN_MAP")
+
+
+def get_nonlin(name: Nonlin) -> nn.Module:
+    try:
+        return NONLIN_MAP[name]
+    except KeyError as e:
+        raise ValueError(f"Unknown nonlinearity {name}") from e
+
+
+SAMPLER_MAP: Dict[str, Type[torch.utils.data.sampler.Sampler]] = {
+    "BatchSampler": torch.utils.data.sampler.BatchSampler,
+    "RandomSampler": torch.utils.data.sampler.RandomSampler,
+    "Sampler": torch.utils.data.sampler.Sampler,
+    "SequentialSampler": torch.utils.data.sampler.SequentialSampler,
+    "SubsetRandomSampler": torch.utils.data.sampler.SubsetRandomSampler,
+    "WeightedRandomSampler": torch.utils.data.sampler.WeightedRandomSampler,
+}
+tempor.core.utils.ensure_literal_matches_dict_keys(Samp, SAMPLER_MAP, "Samp", "SAMPLER_MAP")
+
+
+def get_sampler(name: Union[Samp, None], **kwargs) -> Union[torch.utils.data.sampler.Sampler, None]:
+    try:
+        return SAMPLER_MAP[name](**kwargs) if name is not None else None
+    except KeyError as e:
+        raise ValueError(f"Unknown sampler {name}") from e
+
+
+def get_device(device: Union[None, int, str, torch.device]) -> torch.device:
+    if device is None:
+        return DEVICE
     else:
-        raise ValueError(f"Unknown nonlinearity {name}")
+        return torch.device(device)
diff --git a/src/tempor/plugins/_plugin_loader.py b/src/tempor/plugins/_plugin_loader.py
index e9fd3dc1..256c4421 100644
--- a/src/tempor/plugins/_plugin_loader.py
+++ b/src/tempor/plugins/_plugin_loader.py
@@ -1,13 +1,13 @@
 import tempor.plugins.core as plugins
 
-from . import classification, preprocessing, regression, time_to_event
+from . import prediction, preprocessing, time_to_event, treatments
 
 plugin_loader = plugins.PluginLoader()
 
 
 __all__ = [
+    "prediction",
     "preprocessing",
     "time_to_event",
-    "classification",
-    "regression",
+    "treatments",
 ]
diff --git a/src/tempor/plugins/classification/plugin_cde_classifier.py b/src/tempor/plugins/classification/plugin_cde_classifier.py
deleted file mode 100644
index d8318def..00000000
--- a/src/tempor/plugins/classification/plugin_cde_classifier.py
+++ /dev/null
@@ -1,211 +0,0 @@
-from typing import Any, Optional
-
-import numpy as np
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import Interpolation, NeuralODE, Nonlin
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-
-
-@plugins.register_plugin(name="cde_classifier", category="classification")
-class CDEClassifier(BaseClassifier):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # CDE specific:
-        atol: float = 1e-2,
-        rtol: float = 1e-2,
-        interpolation: Interpolation = "cubic",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Neural Controlled Differential Equations for Irregular Time Series.
-
-        Paper:
-            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
-            James Foster, Terry Lyons.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            atol (float, optional):
-                Absolute tolerance for solution. Defaults to ``1e-2``.
-            rtol (float, optional):
-                Relative tolerance for solution. Defaults to ``1e-2``.
-            interpolation (Interpolation, optional):
-                ``"cubic"`` or ``"linear"``. Defaults to ``"cubic"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("classification.cde_classifier", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            CDEClassifier(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # CDE
-        self.atol = atol
-        self.rtol = rtol
-        self.interpolation: Interpolation = interpolation
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "CDEClassifier":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        n_classes = len(np.unique(outcome))
-
-        self.model = NeuralODE(
-            task_type="classification",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[n_classes],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # CDE
-            backend="cde",
-            atol=self.atol,
-            rtol=self.rtol,
-            interpolation=self.interpolation,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            FloatParams(name="atol", low=0, high=1),
-            FloatParams(name="rtol", low=0, high=1),
-            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/classification/plugin_laplace_classifier.py b/src/tempor/plugins/classification/plugin_laplace_classifier.py
deleted file mode 100644
index 2a596ce0..00000000
--- a/src/tempor/plugins/classification/plugin_laplace_classifier.py
+++ /dev/null
@@ -1,210 +0,0 @@
-from typing import Any, Optional
-
-import numpy as np
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import ILTAlgorithm, NeuralODE, Nonlin
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-
-
-@plugins.register_plugin(name="laplace_ode_classifier", category="classification")
-class LaplaceODEClassifier(BaseClassifier):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # Laplace specific:
-        ilt_reconstruction_terms: int = 33,
-        ilt_algorithm: ILTAlgorithm = "fourier",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.
-        Backpropagation through differential equation (DE) solutions in the Laplace domain is supported using the
-        Riemann stereographic projection for better global representation of the complex Laplace domain.
-
-        Paper:
-            "Neural Laplace: Learning diverse classes of differential equations in the Laplace domain",
-            Holt, Samuel I and Qian, Zhaozhi and van der Schaar, Mihaela.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            ilt_reconstruction_terms (int, optional):
-                Specific to ``"laplace"`` backend. Number of ILT reconstruction terms, i.e. the number of complex
-                :math:`s` points in ``laplace_rep_func`` to reconstruct a single time point. Defaults to ``33``.
-            ilt_algorithm (ILTAlgorithm, optional):
-                Specific to ``"laplace"`` backend. Inverse Laplace transform algorithm to use. Available are
-                {``fourier``, ``dehoog``, ``cme``, ``fixed_tablot``, ``stehfest``}. Defaults to ``"fourier"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> # dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("classification.laplace_ode_classifier", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> # model.fit(dataset)
-            >>> # LaplaceODEClassifier(...)
-            >>>
-            >>> # Predict:
-            >>> # assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        # TODO: Model currently fails to run with SineDataLoader data. Investigate and resolve.
-
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # Laplace
-        self.ilt_algorithm: ILTAlgorithm = ilt_algorithm
-        self.ilt_reconstruction_terms = ilt_reconstruction_terms
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "LaplaceODEClassifier":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        n_classes = len(np.unique(outcome))
-
-        self.model = NeuralODE(
-            task_type="classification",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[n_classes],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # Laplace
-            backend="laplace",
-            ilt_algorithm=self.ilt_algorithm,
-            ilt_reconstruction_terms=self.ilt_reconstruction_terms,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            CategoricalParams(name="ilt_algorithm", choices=["fourier", "dehoog", "cme", "fixed_tablot", "stehfest"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/classification/plugin_nn_classifier.py b/src/tempor/plugins/classification/plugin_nn_classifier.py
deleted file mode 100644
index 1e05a4af..00000000
--- a/src/tempor/plugins/classification/plugin_nn_classifier.py
+++ /dev/null
@@ -1,207 +0,0 @@
-from typing import Any, Optional
-
-import numpy as np
-from torch.utils.data import sampler
-from typing_extensions import get_args
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_model import Nonlin, TimeSeriesModel, TSModelMode
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-
-
-@plugins.register_plugin(name="nn_classifier", category="classification")
-class NeuralNetClassifier(BaseClassifier):
-    def __init__(
-        self,
-        *,
-        n_static_units_hidden: int = 100,
-        n_static_layers_hidden: int = 2,
-        n_temporal_units_hidden: int = 102,
-        n_temporal_layers_hidden: int = 2,
-        n_iter: int = 500,
-        mode: TSModelMode = "RNN",
-        n_iter_print: int = 10,
-        batch_size: int = 100,
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        window_size: int = 1,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-        dropout: float = 0,
-        nonlin: Nonlin = "relu",
-        random_state: int = 0,
-        clipping_value: int = 1,
-        patience: int = 20,
-        train_ratio: float = 0.8,
-    ) -> None:
-        """Neural-net classifier.
-
-        Args:
-            n_static_units_hidden (int, optional):
-                Number of hidden units for the static features. Defaults to ``100``.
-            n_static_layers_hidden (int, optional):
-                Number of hidden layers for the static features. Defaults to ``2``.
-            n_temporal_units_hidden (int, optional):
-                Number of hidden units for the temporal features. Defaults to ``102``.
-            n_temporal_layers_hidden (int, optional):
-                Number of hidden layers for the temporal features. Defaults to ``2``.
-            n_iter (int, optional):
-                Number of epochs. Defaults to ``500``.
-            mode (TSModelMode, optional):
-                Core neural net architecture. Available options: :obj:`~tempor.models.ts_model.TSModelMode`.
-                Defaults to ``"RNN"``.
-            n_iter_print (int, optional):
-                Number of epochs to print the loss. Defaults to ``10``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``100``.
-            lr (float, optional):
-                Learning rate. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            window_size (int, optional):
-                How many hidden states to use for the outcome. Defaults to ``1``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            random_state (int, optional):
-                Random seed. Defaults to ``0``.
-            clipping_value (int, optional):
-                Gradients clipping value. Zero disables the feature. Defaults to ``1``.
-            patience (int, optional):
-                How many epoch * n_iter_print to wait without loss improvement. Defaults to ``20``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("classification.nn_classifier", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            NeuralNetClassifier(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_static_units_hidden = n_static_units_hidden
-        self.n_static_layers_hidden = n_static_layers_hidden
-        self.n_temporal_units_hidden = n_temporal_units_hidden
-        self.n_temporal_layers_hidden = n_temporal_layers_hidden
-        self.n_iter = n_iter
-        self.mode: TSModelMode = mode
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.window_size = window_size
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        self.model: Optional[TimeSeriesModel] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "NeuralNetClassifier":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        n_classes = len(np.unique(outcome))
-
-        self.model = TimeSeriesModel(
-            task_type="classification",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            n_temporal_window=temporal.shape[1],
-            output_shape=[n_classes],
-            n_static_units_hidden=self.n_static_units_hidden,
-            n_static_layers_hidden=self.n_static_layers_hidden,
-            n_temporal_units_hidden=self.n_temporal_units_hidden,
-            n_temporal_layers_hidden=self.n_temporal_layers_hidden,
-            n_iter=self.n_iter,
-            mode=self.mode,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            window_size=self.window_size,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_static_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_static_layers_hidden", low=1, high=5),
-            IntegerParams(name="n_temporal_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_temporal_layers_hidden", low=1, high=5),
-            CategoricalParams(name="mode", choices=list(get_args(TSModelMode))),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/classification/plugin_ode_classifier.py b/src/tempor/plugins/classification/plugin_ode_classifier.py
deleted file mode 100644
index 38f2e0d1..00000000
--- a/src/tempor/plugins/classification/plugin_ode_classifier.py
+++ /dev/null
@@ -1,207 +0,0 @@
-from typing import Any, Optional
-
-import numpy as np
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import Interpolation, NeuralODE, Nonlin
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-
-
-@plugins.register_plugin(name="ode_classifier", category="classification")
-class ODEClassifier(BaseClassifier):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # ODE specific:
-        atol: float = 1e-2,
-        rtol: float = 1e-2,
-        interpolation: Interpolation = "cubic",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Classifier based on ordinary differential equation (ODE) solvers.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            atol (float, optional):
-                Absolute tolerance for solution. Defaults to ``1e-2``.
-            rtol (float, optional):
-                Relative tolerance for solution. Defaults to ``1e-2``.
-            interpolation (Interpolation, optional):
-                ``"cubic"`` or ``"linear"``. Defaults to ``"cubic"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("classification.ode_classifier", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            ODEClassifier(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # ODE
-        self.atol = atol
-        self.rtol = rtol
-        self.interpolation: Interpolation = interpolation
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "ODEClassifier":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        n_classes = len(np.unique(outcome))
-
-        self.model = NeuralODE(
-            task_type="classification",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[n_classes],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # ODE
-            backend="ode",
-            atol=self.atol,
-            rtol=self.rtol,
-            interpolation=self.interpolation,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            FloatParams(name="atol", low=0, high=1),
-            FloatParams(name="rtol", low=0, high=1),
-            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/core/_base_transformer.py b/src/tempor/plugins/core/_base_transformer.py
index 9d8d6e55..8047575c 100644
--- a/src/tempor/plugins/core/_base_transformer.py
+++ b/src/tempor/plugins/core/_base_transformer.py
@@ -30,10 +30,10 @@ def fit_transform(
         data: dataset.Dataset,
         *args,
         **kwargs,
-    ) -> Any:
+    ) -> dataset.Dataset:
         self.fit(data, *args, **kwargs)
         return self.transform(data, *args, **kwargs)
 
     @abc.abstractmethod
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:  # pragma: no cover
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:  # pragma: no cover
         ...
diff --git a/src/tempor/plugins/core/_plugin.py b/src/tempor/plugins/core/_plugin.py
index e1fbfc70..14cc348a 100644
--- a/src/tempor/plugins/core/_plugin.py
+++ b/src/tempor/plugins/core/_plugin.py
@@ -1,9 +1,7 @@
-import functools
 import glob
 import importlib
 import importlib.abc
 import importlib.util
-import inspect
 import os
 import os.path
 import sys
@@ -63,7 +61,7 @@ def _check_same_class(class_1, class_2) -> bool:
 
 
 def register_plugin(name: str, category: str):
-    def inner(cls: Callable[P, T]) -> Callable[P, T]:
+    def class_decorator(cls: Callable[P, T]) -> Callable[P, T]:
         # NOTE:
         # The Callable[<ParamSpec>, <TypeVar>] approach allows to preserve the type annotation of the parameters of the
         # wrapped class (its __init__ method, specifically). See resources:
@@ -71,6 +69,8 @@ def inner(cls: Callable[P, T]) -> Callable[P, T]:
         #     * https://docs.python.org/3/library/typing.html#typing.ParamSpec
 
         if TYPE_CHECKING:  # pragma: no cover
+            # Note that cls is in fact `Type[Plugin]`, but this allows to
+            # silence static type-checker warnings inside this function.
             assert isinstance(cls, Plugin)  # nosec B101
 
         logger.debug(f"Registering plugin of class {cls}")
@@ -98,23 +98,19 @@ def inner(cls: Callable[P, T]) -> Callable[P, T]:
             else:
                 # The same class is being reimported, do not raise error.
                 pass
-        PLUGIN_REGISTRY[cls.fqn()] = cls
-
-        @functools.wraps(cls)
-        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:  # See NOTE above.
-            return cls(*args, **kwargs)
 
-        # Set attributes.
-        setattr(wrapper, "_cls", cls)  # To access the class directly if required.
-        setattr(wrapper, "_tempor_plugin_", True)
+        PLUGIN_REGISTRY[cls.fqn()] = cls
 
-        return wrapper
+        return cls
 
-    return inner
+    return class_decorator
 
 
 class PluginLoader:
     def __init__(self) -> None:
+        self._refresh()
+
+    def _refresh(self):
         self._plugin_registry: Dict[str, Type[Plugin]] = PLUGIN_REGISTRY
 
         name_by_category: Dict = dict()
@@ -132,12 +128,15 @@ def __init__(self) -> None:
         self._plugin_class_by_category = class_by_category
 
     def list(self) -> Dict:
+        self._refresh()
         return self._plugin_name_by_category
 
     def list_fqns(self) -> List[str]:
+        self._refresh()
         return list(self._plugin_registry.keys())
 
     def list_classes(self) -> Dict:
+        self._refresh()
         return self._plugin_class_by_category
 
     def _raise_plugin_does_not_exist_error(self, name: str):
@@ -145,10 +144,12 @@ def _raise_plugin_does_not_exist_error(self, name: str):
             raise ValueError(f"Plugin {name} does not exist.")
 
     def get(self, name: str, *args, **kwargs) -> Any:
+        self._refresh()
         self._raise_plugin_does_not_exist_error(name)
         return self._plugin_registry[name](*args, **kwargs)
 
     def get_class(self, name: str) -> Type:
+        self._refresh()
         self._raise_plugin_does_not_exist_error(name)
         return self._plugin_registry[name]
 
@@ -162,7 +163,7 @@ def _glob_plugin_paths(package_dir: str) -> List[str]:
 
 def _module_name_from_path(path: str) -> str:
     path = os.path.normpath(path)
-    split = path[path.rfind(tempor.import_name) :].split(os.sep)
+    split = path[path.rfind(f"{tempor.import_name}{os.sep}") :].split(os.sep)
     if split[-1][-3:] != ".py":
         raise ValueError(f"Path `{path}` is not a python file.")
     split[-1] = split[-1].replace(".py", "")
@@ -186,14 +187,6 @@ def import_plugins(init_file: str) -> None:
             sys.modules[module_name] = module
             spec.loader.exec_module(module)
 
-            # Ensure doctests of plugins are included.
-            # See: https://docs.python.org/3.8/library/doctest.html#which-docstrings-are-examined
-            for name, obj in inspect.getmembers(module):
-                if hasattr(obj, "_tempor_plugin_"):
-                    test_dict = getattr(module, "__test__", dict())
-                    test_dict[name] = getattr(obj, "_cls")
-                    setattr(module, "__test__", test_dict)
-
     @staticmethod
     def gather_modules_names(package_init_file: str) -> List[str]:
         package_dir = os.path.dirname(package_init_file)
diff --git a/src/tempor/plugins/pipeline/generators.py b/src/tempor/plugins/pipeline/generators.py
index c9564b40..1c0a1244 100644
--- a/src/tempor/plugins/pipeline/generators.py
+++ b/src/tempor/plugins/pipeline/generators.py
@@ -76,7 +76,7 @@ def get_args_impl(self: Any) -> Dict:
 
 
 def _generate_fit() -> Callable:
-    def fit_impl(self: Any, X: dataset.Dataset, *args: Any, **kwargs: Any) -> Any:
+    def fit_impl(self: Any, X: dataset.Dataset, *args: Any, **kwargs: Any) -> dataset.Dataset:
         local_X = X
         for stage in self.stages[:-1]:
             local_X = stage.fit_transform(local_X)
diff --git a/src/tempor/plugins/prediction/__init__.py b/src/tempor/plugins/prediction/__init__.py
new file mode 100644
index 00000000..eef95d67
--- /dev/null
+++ b/src/tempor/plugins/prediction/__init__.py
@@ -0,0 +1,6 @@
+from . import one_off, temporal
+
+__all__ = [
+    "one_off",
+    "temporal",
+]
diff --git a/src/tempor/plugins/prediction/one_off/__init__.py b/src/tempor/plugins/prediction/one_off/__init__.py
new file mode 100644
index 00000000..ec2cd3ba
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/__init__.py
@@ -0,0 +1,6 @@
+from . import classification, regression
+
+__all__ = [
+    "classification",
+    "regression",
+]
diff --git a/src/tempor/plugins/classification/__init__.py b/src/tempor/plugins/prediction/one_off/classification/__init__.py
similarity index 87%
rename from src/tempor/plugins/classification/__init__.py
rename to src/tempor/plugins/prediction/one_off/classification/__init__.py
index a0b783cc..5db73ed4 100644
--- a/src/tempor/plugins/classification/__init__.py
+++ b/src/tempor/plugins/prediction/one_off/classification/__init__.py
@@ -10,14 +10,14 @@
 
 
 def check_data_class(data):
-    if not isinstance(data, (dataset.OneOffPredictionDataset, dataset.TemporalPredictionDataset)):
+    if not isinstance(data, dataset.OneOffPredictionDataset):
         raise TypeError(
-            "Expected `data` passed to a classification estimator to be "
+            "Expected `data` passed to a one-off classification estimator to be "
             f"`{dataset.OneOffPredictionDataset.__name__}` but was {type(data)}"
         )
 
 
-class BaseClassifier(plugins.BasePredictor):
+class BaseOneOffClassifier(plugins.BasePredictor):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         super().__init__(**params)
 
@@ -72,11 +72,11 @@ def _unpack_dataset(self, data: dataset.Dataset) -> Tuple:
         return static, temporal, observation_times, outcome
 
 
-plugins.register_plugin_category("classification", BaseClassifier)
+plugins.register_plugin_category("prediction.one_off.classification", BaseOneOffClassifier)
 
 plugins.importing.import_plugins(__file__)
 
 __all__ = [  # pyright: ignore
     *plugins.importing.gather_modules_names(__file__),
-    "BaseClassifier",
+    "BaseOneOffClassifier",
 ]
diff --git a/src/tempor/plugins/prediction/one_off/classification/plugin_cde_classifier.py b/src/tempor/plugins/prediction/one_off/classification/plugin_cde_classifier.py
new file mode 100644
index 00000000..98c9b32b
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/classification/plugin_cde_classifier.py
@@ -0,0 +1,186 @@
+import dataclasses
+from typing import Optional
+
+import numpy as np
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import Interpolation, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+
+
+@dataclasses.dataclass
+class CDEClassifierParams:
+    """Initialization parameters for :class:`CDEClassifier`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    atol: float = 1e-2
+    """Absolute tolerance for solution."""
+    rtol: float = 1e-2
+    """Relative tolerance for solution."""
+    interpolation: Interpolation = "cubic"
+    """``"cubic"`` or ``"linear"``."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="cde_classifier", category="prediction.one_off.classification")
+class CDEClassifier(BaseOneOffClassifier):
+    ParamsDefinition = CDEClassifierParams
+    params: CDEClassifierParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Neural Controlled Differential Equations for Irregular Time Series.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`CDEClassifierParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.classification.cde_classifier", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            CDEClassifier(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
+            James Foster, Terry Lyons.
+        """
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        n_classes = len(np.unique(outcome))
+
+        self.model = NeuralODE(
+            task_type="classification",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[n_classes],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # CDE
+            backend="cde",
+            atol=self.params.atol,
+            rtol=self.params.rtol,
+            interpolation=self.params.interpolation,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            FloatParams(name="atol", low=0, high=1),
+            FloatParams(name="rtol", low=0, high=1),
+            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/classification/plugin_laplace_classifier.py b/src/tempor/plugins/prediction/one_off/classification/plugin_laplace_classifier.py
new file mode 100644
index 00000000..2d5bd814
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/classification/plugin_laplace_classifier.py
@@ -0,0 +1,187 @@
+import dataclasses
+from typing import Optional
+
+import numpy as np
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import ILTAlgorithm, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+
+
+@dataclasses.dataclass
+class LaplaceODEClassifierParams:
+    """Initialization parameters for :class:`LaplaceODEClassifier`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    ilt_reconstruction_terms: int = 33
+    """Number of ILT reconstruction terms, i.e. the number of complex :math:`s` points in
+    ``laplace_rep_func`` to reconstruct a single time point."""
+    ilt_algorithm: ILTAlgorithm = "fourier"
+    """Inverse Laplace transform algorithm to use. Available are {``fourier``, ``dehoog``, ``cme``,
+    ``fixed_tablot``, ``stehfest``}."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="laplace_ode_classifier", category="prediction.one_off.classification")
+class LaplaceODEClassifier(BaseOneOffClassifier):
+    ParamsDefinition = LaplaceODEClassifierParams
+    params: LaplaceODEClassifierParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.
+        Backpropagation through differential equation (DE) solutions in the Laplace domain is supported using the
+        Riemann stereographic projection for better global representation of the complex Laplace domain.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`LaplaceODEClassifierParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import GoogleStocksDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = GoogleStocksDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.classification.laplace_ode_classifier", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            LaplaceODEClassifier(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Laplace: Learning diverse classes of differential equations in the Laplace domain",
+            Holt, Samuel I and Qian, Zhaozhi and van der Schaar, Mihaela.
+        """
+        # TODO: Model currently fails to run with SineDataLoader data. Investigate and resolve.
+
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        n_classes = len(np.unique(outcome))
+
+        self.model = NeuralODE(
+            task_type="classification",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[n_classes],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # Laplace
+            backend="laplace",
+            ilt_algorithm=self.params.ilt_algorithm,
+            ilt_reconstruction_terms=self.params.ilt_reconstruction_terms,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            CategoricalParams(name="ilt_algorithm", choices=["fourier", "dehoog", "cme", "fixed_tablot", "stehfest"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/classification/plugin_nn_classifier.py b/src/tempor/plugins/prediction/one_off/classification/plugin_nn_classifier.py
new file mode 100644
index 00000000..026d7e04
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/classification/plugin_nn_classifier.py
@@ -0,0 +1,179 @@
+import dataclasses
+from typing import Optional
+
+import numpy as np
+from typing_extensions import Self, get_args
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_model import TimeSeriesModel, TSModelMode
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+
+
+@dataclasses.dataclass
+class NeuralNetClassifierParams:
+    """Initialization parameters for :class:`NeuralNetClassifier`."""
+
+    n_static_units_hidden: int = 100
+    """Number of hidden units for the static features."""
+    n_static_layers_hidden: int = 2
+    """Number of hidden layers for the static features."""
+    n_temporal_units_hidden: int = 102
+    """Number of hidden units for the temporal features."""
+    n_temporal_layers_hidden: int = 2
+    """Number of hidden layers for the temporal features."""
+    n_iter: int = 500
+    """Number of epochs."""
+    mode: TSModelMode = "RNN"
+    """Core neural net architecture. Available options: :obj:`~tempor.models.ts_model.TSModelMode`."""
+    n_iter_print: int = 10
+    """Number of epochs to print the loss."""
+    batch_size: int = 100
+    """Batch size."""
+    lr: float = 1e-3
+    """Learning rate."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    window_size: int = 1
+    """How many hidden states to use for the outcome."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+    dropout: float = 0
+    """Dropout value."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    random_state: int = 0
+    """Random seed."""
+    clipping_value: int = 1
+    """Gradients clipping value. Zero disables the feature."""
+    patience: int = 20
+    """How many ``epoch * n_iter_print`` to wait without loss improvement."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+
+
+@plugins.register_plugin(name="nn_classifier", category="prediction.one_off.classification")
+class NeuralNetClassifier(BaseOneOffClassifier):
+    ParamsDefinition = NeuralNetClassifierParams
+    params: NeuralNetClassifierParams  # type: ignore
+
+    def __init__(self, **param) -> None:
+        """Neural-net classifier.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`NeuralNetClassifierParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.classification.nn_classifier", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            NeuralNetClassifier(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+        """
+        super().__init__(**param)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[TimeSeriesModel] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        n_classes = len(np.unique(outcome))
+
+        self.model = TimeSeriesModel(
+            task_type="classification",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            n_temporal_window=temporal.shape[1],
+            output_shape=[n_classes],
+            n_static_units_hidden=self.params.n_static_units_hidden,
+            n_static_layers_hidden=self.params.n_static_layers_hidden,
+            n_temporal_units_hidden=self.params.n_temporal_units_hidden,
+            n_temporal_layers_hidden=self.params.n_temporal_layers_hidden,
+            n_iter=self.params.n_iter,
+            mode=self.params.mode,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            window_size=self.params.window_size,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_static_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_static_layers_hidden", low=1, high=5),
+            IntegerParams(name="n_temporal_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_temporal_layers_hidden", low=1, high=5),
+            CategoricalParams(name="mode", choices=list(get_args(TSModelMode))),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/classification/plugin_ode_classifier.py b/src/tempor/plugins/prediction/one_off/classification/plugin_ode_classifier.py
new file mode 100644
index 00000000..214385fb
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/classification/plugin_ode_classifier.py
@@ -0,0 +1,186 @@
+import dataclasses
+from typing import Optional
+
+import numpy as np
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import Interpolation, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+
+
+@dataclasses.dataclass
+class ODEClassifierParams:
+    """Initialization parameters for :class:`ODEClassifier`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    atol: float = 1e-2
+    """Absolute tolerance for solution."""
+    rtol: float = 1e-2
+    """Relative tolerance for solution."""
+    interpolation: Interpolation = "cubic"
+    """``"cubic"`` or ``"linear"``."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="ode_classifier", category="prediction.one_off.classification")
+class ODEClassifier(BaseOneOffClassifier):
+    ParamsDefinition = ODEClassifierParams
+    params: ODEClassifierParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Classifier based on ordinary differential equation (ODE) solvers.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`ODEClassifierParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.classification.ode_classifier", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            ODEClassifier(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
+            James Foster, Terry Lyons.
+        """
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        n_classes = len(np.unique(outcome))
+
+        self.model = NeuralODE(
+            task_type="classification",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[n_classes],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # ODE
+            backend="ode",
+            atol=self.params.atol,
+            rtol=self.params.rtol,
+            interpolation=self.params.interpolation,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            FloatParams(name="atol", low=0, high=1),
+            FloatParams(name="rtol", low=0, high=1),
+            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/regression/__init__.py b/src/tempor/plugins/prediction/one_off/regression/__init__.py
similarity index 79%
rename from src/tempor/plugins/regression/__init__.py
rename to src/tempor/plugins/prediction/one_off/regression/__init__.py
index bfbff3df..222afbc3 100644
--- a/src/tempor/plugins/regression/__init__.py
+++ b/src/tempor/plugins/prediction/one_off/regression/__init__.py
@@ -10,14 +10,14 @@
 
 
 def check_data_class(data):
-    if not isinstance(data, (dataset.OneOffPredictionDataset, dataset.TemporalPredictionDataset)):
+    if not isinstance(data, dataset.OneOffPredictionDataset):
         raise TypeError(
-            "Expected `data` passed to a regression estimator to be "
-            f"`{dataset.OneOffPredictionDataset.__name__}` or `{dataset.TemporalPredictionDataset.__name__}` but was {type(data)}"
+            "Expected `data` passed to a one-off regression estimator to be "
+            f"`{dataset.OneOffPredictionDataset.__name__}` but was {type(data)}"
         )
 
 
-class BaseRegressor(plugins.BasePredictor):
+class BaseOneOffRegressor(plugins.BasePredictor):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         super().__init__(**params)
 
@@ -56,11 +56,11 @@ def _unpack_dataset(self, data: dataset.Dataset) -> Tuple:
         return static, temporal, observation_times, outcome
 
 
-plugins.register_plugin_category("regression", BaseRegressor)
+plugins.register_plugin_category("prediction.one_off.regression", BaseOneOffRegressor)
 
 plugins.importing.import_plugins(__file__)
 
 __all__ = [  # pyright: ignore
     *plugins.importing.gather_modules_names(__file__),
-    "BaseRegressor",
+    "BaseOneOffRegressor",
 ]
diff --git a/src/tempor/plugins/prediction/one_off/regression/plugin_cde_regressor.py b/src/tempor/plugins/prediction/one_off/regression/plugin_cde_regressor.py
new file mode 100644
index 00000000..e569ac75
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/regression/plugin_cde_regressor.py
@@ -0,0 +1,183 @@
+import dataclasses
+from typing import Optional
+
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import Interpolation, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+
+
+@dataclasses.dataclass
+class CDERegressorParams:
+    """Initialization parameters for :class:`CDERegressor`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    atol: float = 1e-2
+    """Absolute tolerance for solution."""
+    rtol: float = 1e-2
+    """Relative tolerance for solution."""
+    interpolation: Interpolation = "cubic"
+    """``"cubic"`` or ``"linear"``."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="cde_regressor", category="prediction.one_off.regression")
+class CDERegressor(BaseOneOffRegressor):
+    ParamsDefinition = CDERegressorParams
+    params: CDERegressorParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Neural Controlled Differential Equations for Irregular Time Series.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`CDERegressorParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.regression.cde_regressor", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            CDERegressor(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
+            James Foster, Terry Lyons.
+        """
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        self.model = NeuralODE(
+            task_type="regression",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[1],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # CDE
+            backend="cde",
+            atol=self.params.atol,
+            rtol=self.params.rtol,
+            interpolation=self.params.interpolation,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            FloatParams(name="atol", low=0, high=1),
+            FloatParams(name="rtol", low=0, high=1),
+            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/regression/plugin_laplace_regressor.py b/src/tempor/plugins/prediction/one_off/regression/plugin_laplace_regressor.py
new file mode 100644
index 00000000..f0f13b19
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/regression/plugin_laplace_regressor.py
@@ -0,0 +1,184 @@
+import dataclasses
+from typing import Optional
+
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import ILTAlgorithm, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+
+
+@dataclasses.dataclass
+class LaplaceODERegressorParams:
+    """Initialization parameters for :class:`LaplaceODERegressor`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    ilt_reconstruction_terms: int = 33
+    """Number of ILT reconstruction terms, i.e. the number of complex :math:`s` points in
+    ``laplace_rep_func`` to reconstruct a single time point."""
+    ilt_algorithm: ILTAlgorithm = "fourier"
+    """Inverse Laplace transform algorithm to use. Available are {``fourier``, ``dehoog``, ``cme``,
+    ``fixed_tablot``, ``stehfest``}."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="laplace_ode_regressor", category="prediction.one_off.regression")
+class LaplaceODERegressor(BaseOneOffRegressor):
+    ParamsDefinition = LaplaceODERegressorParams
+    params: LaplaceODERegressorParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.
+        Backpropagation through differential equation (DE) solutions in the Laplace domain is supported using the
+        Riemann stereographic projection for better global representation of the complex Laplace domain.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`LaplaceODERegressorParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import GoogleStocksDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = GoogleStocksDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.regression.laplace_ode_regressor", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            LaplaceODERegressor(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Laplace: Learning diverse classes of differential equations in the Laplace domain",
+            Holt, Samuel I and Qian, Zhaozhi and van der Schaar, Mihaela.
+        """
+        # TODO: Model currently fails to run with SineDataLoader data. Investigate and resolve.
+
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        self.model = NeuralODE(
+            task_type="regression",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[1],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # Laplace
+            backend="laplace",
+            ilt_algorithm=self.params.ilt_algorithm,
+            ilt_reconstruction_terms=self.params.ilt_reconstruction_terms,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            CategoricalParams(name="ilt_algorithm", choices=["fourier", "dehoog", "cme", "fixed_tablot", "stehfest"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/regression/plugin_nn_regressor.py b/src/tempor/plugins/prediction/one_off/regression/plugin_nn_regressor.py
new file mode 100644
index 00000000..c59ffc89
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/regression/plugin_nn_regressor.py
@@ -0,0 +1,160 @@
+import dataclasses
+from typing import Optional
+
+from typing_extensions import Self, get_args
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_model import TimeSeriesModel, TSModelMode
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+
+
+@dataclasses.dataclass
+class NeuralNetRegressorParams:
+    """Initialization parameters for :class:`NeuralNetRegressor`."""
+
+    n_static_units_hidden: int = 100
+    """Number of hidden units for the static features."""
+    n_static_layers_hidden: int = 2
+    """Number of hidden layers for the static features."""
+    n_temporal_units_hidden: int = 102
+    """Number of hidden units for the temporal features."""
+    n_temporal_layers_hidden: int = 2
+    """Number of hidden layers for the temporal features."""
+    n_iter: int = 500
+    """Number of epochs."""
+    mode: TSModelMode = "RNN"
+    """Core neural net architecture. Available options: :obj:`~tempor.models.ts_model.TSModelMode`."""
+    n_iter_print: int = 10
+    """Number of epochs to print the loss."""
+    batch_size: int = 100
+    """Batch size."""
+    lr: float = 1e-3
+    """Learning rate."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    window_size: int = 1
+    """How many hidden states to use for the outcome."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+    dropout: float = 0
+    """Dropout value."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    random_state: int = 0
+    """Random seed."""
+    clipping_value: int = 1
+    """Gradients clipping value. Zero disables the feature."""
+    patience: int = 20
+    """How many ``epoch * n_iter_print`` to wait without loss improvement."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+
+
+@plugins.register_plugin(name="nn_regressor", category="prediction.one_off.regression")
+class NeuralNetRegressor(BaseOneOffRegressor):
+    ParamsDefinition = NeuralNetRegressorParams
+    params: NeuralNetRegressorParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Neural-net regressor.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`NeuralNetRegressorParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.regression.nn_regressor", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            NeuralNetRegressor(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+        """
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[TimeSeriesModel] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        self.model = TimeSeriesModel(
+            task_type="regression",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            n_temporal_window=temporal.shape[1],
+            output_shape=[1],
+            n_static_units_hidden=self.params.n_static_units_hidden,
+            n_static_layers_hidden=self.params.n_static_layers_hidden,
+            n_temporal_units_hidden=self.params.n_temporal_units_hidden,
+            n_temporal_layers_hidden=self.params.n_temporal_layers_hidden,
+            n_iter=self.params.n_iter,
+            mode=self.params.mode,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            window_size=self.params.window_size,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_static_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_static_layers_hidden", low=1, high=5),
+            IntegerParams(name="n_temporal_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_temporal_layers_hidden", low=1, high=5),
+            CategoricalParams(name="mode", choices=list(get_args(TSModelMode))),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/one_off/regression/plugin_ode_regressor.py b/src/tempor/plugins/prediction/one_off/regression/plugin_ode_regressor.py
new file mode 100644
index 00000000..4f739c27
--- /dev/null
+++ b/src/tempor/plugins/prediction/one_off/regression/plugin_ode_regressor.py
@@ -0,0 +1,183 @@
+import dataclasses
+from typing import Optional
+
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+from tempor.models import utils as model_utils
+from tempor.models.constants import Nonlin, Samp
+from tempor.models.ts_ode import Interpolation, NeuralODE
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+
+
+@dataclasses.dataclass
+class ODERegressorParams:
+    """Initialization parameters for :class:`ODERegressor`."""
+
+    n_units_hidden: int = 100
+    """Number of hidden units."""
+    n_layers_hidden: int = 1
+    """Number of hidden layers."""
+    nonlin: Nonlin = "relu"
+    """Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`."""
+    dropout: float = 0
+    """Dropout value."""
+
+    # CDE specific:
+    atol: float = 1e-2
+    """Absolute tolerance for solution."""
+    rtol: float = 1e-2
+    """Relative tolerance for solution."""
+    interpolation: Interpolation = "cubic"
+    """``"cubic"`` or ``"linear"``."""
+
+    # Training:
+    lr: float = 1e-3
+    """Learning rate for optimizer."""
+    weight_decay: float = 1e-3
+    """l2 (ridge) penalty for the weights."""
+    n_iter: int = 1000
+    """Maximum number of iterations."""
+    batch_size: int = 500
+    """Batch size."""
+    n_iter_print: int = 100
+    """Number of iterations after which to print updates and check the validation loss."""
+    random_state: int = 0
+    """Random_state used."""
+    patience: int = 10
+    """Number of iterations to wait before early stopping after decrease in validation loss."""
+    clipping_value: int = 1
+    """Gradients clipping value."""
+    train_ratio: float = 0.8
+    """Train/test split ratio."""
+    device: Optional[str] = None
+    """String representing PyTorch device. If `None`, `~tempor.models.constants.DEVICE`."""
+    dataloader_sampler: Optional[Samp] = None
+    """Custom data sampler for training."""
+
+
+@plugins.register_plugin(name="ode_regressor", category="prediction.one_off.regression")
+class ODERegressor(BaseOneOffRegressor):
+    ParamsDefinition = ODERegressorParams
+    params: ODERegressorParams  # type: ignore
+
+    def __init__(self, **params) -> None:
+        """Regressor based on ordinary differential equation (ODE) solvers.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`ODERegressorParams`.
+
+        Example:
+            >>> from tempor.utils.dataloaders import SineDataLoader
+            >>> from tempor.plugins import plugin_loader
+            >>>
+            >>> dataset = SineDataLoader().load()
+            >>>
+            >>> # Load the model:
+            >>> model = plugin_loader.get("prediction.one_off.regression.ode_regressor", n_iter=50)
+            >>>
+            >>> # Train:
+            >>> model.fit(dataset)
+            ODERegressor(...)
+            >>>
+            >>> # Predict:
+            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
+
+        References:
+            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
+            James Foster, Terry Lyons.
+        """
+        super().__init__(**params)
+
+        self.device = model_utils.get_device(self.params.device)
+        self.dataloader_sampler = model_utils.get_sampler(self.params.dataloader_sampler)
+
+        self.model: Optional[NeuralODE] = None
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        static, temporal, observation_times, outcome = self._unpack_dataset(data)
+        outcome = outcome.squeeze()
+
+        self.model = NeuralODE(
+            task_type="regression",
+            n_static_units_in=static.shape[-1],
+            n_temporal_units_in=temporal.shape[-1],
+            output_shape=[1],
+            n_units_hidden=self.params.n_units_hidden,
+            n_layers_hidden=self.params.n_layers_hidden,
+            # ODE
+            backend="ode",
+            atol=self.params.atol,
+            rtol=self.params.rtol,
+            interpolation=self.params.interpolation,
+            # training
+            n_iter=self.params.n_iter,
+            n_iter_print=self.params.n_iter_print,
+            batch_size=self.params.batch_size,
+            lr=self.params.lr,
+            weight_decay=self.params.weight_decay,
+            device=self.device,
+            dataloader_sampler=self.dataloader_sampler,
+            dropout=self.params.dropout,
+            nonlin=self.params.nonlin,
+            random_state=self.params.random_state,
+            clipping_value=self.params.clipping_value,
+            patience=self.params.patience,
+            train_ratio=self.params.train_ratio,
+        )
+
+        self.model.fit(static, temporal, observation_times, outcome)
+        return self
+
+    def _predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        preds = preds.reshape(-1, 1)
+        return samples.StaticSamples.from_numpy(preds)
+
+    def _predict_proba(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        if self.model is None:
+            raise RuntimeError("Fit the model first")
+        static, temporal, observation_times, _ = self._unpack_dataset(data)
+
+        preds = self.model.predict_proba(static, temporal, observation_times)
+        preds = preds.astype(float)
+
+        return samples.StaticSamples.from_numpy(preds)
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=100, high=1000),
+            IntegerParams(name="n_layers_hidden", low=1, high=5),
+            FloatParams(name="atol", low=0, high=1),
+            FloatParams(name="rtol", low=0, high=1),
+            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
+            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
+            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
+            FloatParams(name="dropout", low=0, high=0.2),
+            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
+        ]
diff --git a/src/tempor/plugins/prediction/temporal/__init__.py b/src/tempor/plugins/prediction/temporal/__init__.py
new file mode 100644
index 00000000..ec2cd3ba
--- /dev/null
+++ b/src/tempor/plugins/prediction/temporal/__init__.py
@@ -0,0 +1,6 @@
+from . import classification, regression
+
+__all__ = [
+    "classification",
+    "regression",
+]
diff --git a/src/tempor/plugins/prediction/temporal/classification/__init__.py b/src/tempor/plugins/prediction/temporal/classification/__init__.py
new file mode 100644
index 00000000..52f9c2eb
--- /dev/null
+++ b/src/tempor/plugins/prediction/temporal/classification/__init__.py
@@ -0,0 +1,73 @@
+# pylint: disable=unused-argument
+
+import abc
+
+import pydantic
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+
+
+def check_data_class(data):
+    if not isinstance(data, dataset.TemporalPredictionDataset):
+        raise TypeError(
+            "Expected `data` passed to a temporal classification estimator to be "
+            f"`{dataset.TemporalPredictionDataset.__name__}` but was {type(data)}"
+        )
+
+
+class BaseTemporalClassifier(plugins.BasePredictor):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
+        check_data_class(data)
+        super().fit(data, *args, **kwargs)
+        return self
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self,
+        data: dataset.Dataset,
+        n_future_steps: int,
+        *args,
+        time_delta: int = 1,
+        **kwargs,
+    ) -> samples.TimeSeriesSamples:
+        check_data_class(data)
+        return super().predict(data, n_future_steps, *args, time_delta=time_delta, **kwargs)
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict_proba(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self,
+        data: dataset.Dataset,
+        n_future_steps: int,
+        *args,
+        time_delta: int = 1,
+        **kwargs,
+    ) -> samples.TimeSeriesSamples:
+        check_data_class(data)
+        return super().predict_proba(data, n_future_steps, *args, time_delta=time_delta, **kwargs)
+
+    @abc.abstractmethod
+    def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self, data: dataset.Dataset, n_future_steps: int, *args, time_delta: int = 1, **kwargs
+    ) -> samples.TimeSeriesSamples:
+        ...
+
+    @abc.abstractmethod
+    def _predict_proba(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self, data: dataset.Dataset, n_future_steps: int, *args, time_delta: int = 1, **kwargs
+    ) -> samples.TimeSeriesSamples:
+        ...
+
+
+plugins.register_plugin_category("prediction.temporal.classification", BaseTemporalClassifier)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+    "BaseTemporalClassifier",
+]
diff --git a/src/tempor/plugins/classification/plugin_seq2seq_classifier.py b/src/tempor/plugins/prediction/temporal/classification/plugin_seq2seq_classifier.py
similarity index 85%
rename from src/tempor/plugins/classification/plugin_seq2seq_classifier.py
rename to src/tempor/plugins/prediction/temporal/classification/plugin_seq2seq_classifier.py
index 9e376ebe..47dedeee 100644
--- a/src/tempor/plugins/classification/plugin_seq2seq_classifier.py
+++ b/src/tempor/plugins/prediction/temporal/classification/plugin_seq2seq_classifier.py
@@ -12,12 +12,13 @@
     _from_clv2_time_series,
     tempor_dataset_to_clairvoyance2_dataset,
 )
-from tempor.plugins.classification import BaseClassifier
 from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.prediction.temporal.classification import BaseTemporalClassifier
 
 
 @dataclasses.dataclass
-class seq2seqParams:
+class Seq2seqClassifierParams:
+    # TODO: Docstrings.
     # Encoder:
     encoder_rnn_type: str = "LSTM"
     encoder_hidden_size: int = 100
@@ -51,10 +52,10 @@ class seq2seqParams:
     padding_indicator: float = DEFAULT_PADDING_INDICATOR
 
 
-@plugins.register_plugin(name="seq2seq_classifier", category="classification")
-class Seq2seqClassifier(BaseClassifier):
-    ParamsDefinition = seq2seqParams
-    params: seq2seqParams  # type: ignore
+@plugins.register_plugin(name="seq2seq_classifier", category="prediction.temporal.classification")
+class Seq2seqClassifier(BaseTemporalClassifier):
+    ParamsDefinition = Seq2seqClassifierParams
+    params: Seq2seqClassifierParams  # type: ignore
 
     def __init__(
         self,
@@ -62,10 +63,14 @@ def __init__(
     ) -> None:
         """Seq2seq classifier.
 
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`Seq2seqClassifierParams`.
+
         Example:
             >>> import doctest; doctest.ELLIPSIS_MARKER = "[...]"  # Doctest config, ignore.
             >>>
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
+            >>> from tempor.utils.dataloaders import SineDataLoader
             >>> from tempor.data import dataset
             >>> from tempor.plugins import plugin_loader
             >>>
@@ -73,11 +78,11 @@ def __init__(
             >>> data = dataset.TemporalPredictionDataset(
             ...    time_series=raw_data.time_series.dataframe(),
             ...    static=raw_data.static.dataframe(),
-            ...    targets=raw_data.time_series.dataframe()
+            ...    targets=raw_data.time_series.dataframe(),
             ... )
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("classification.seq2seq_classifier", epochs=10)
+            >>> model = plugin_loader.get("prediction.temporal.classification.seq2seq_classifier", epochs=10)
             >>>
             >>> # Train:
             >>> model.fit(data)
@@ -103,7 +108,7 @@ def _fit(
         self.model.fit(cl_dataset)
         return self
 
-    def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+    def _predict(  # type: ignore[override]
         self,
         data: dataset.Dataset,
         n_future_steps: int,
@@ -125,7 +130,7 @@ def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
         preds = _from_clv2_time_series(preds_cl.to_multi_index_dataframe())
         return samples.TimeSeriesSamples.from_dataframe(preds)
 
-    def _predict_proba(  # type: ignore[override]
+    def _predict_proba(
         self,
         data: dataset.Dataset,
         *args,
diff --git a/src/tempor/plugins/prediction/temporal/regression/__init__.py b/src/tempor/plugins/prediction/temporal/regression/__init__.py
new file mode 100644
index 00000000..b1454efc
--- /dev/null
+++ b/src/tempor/plugins/prediction/temporal/regression/__init__.py
@@ -0,0 +1,55 @@
+# pylint: disable=unused-argument
+
+import abc
+
+import pydantic
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+
+
+def check_data_class(data):
+    if not isinstance(data, dataset.TemporalPredictionDataset):
+        raise TypeError(
+            "Expected `data` passed to a temporal regression estimator to be "
+            f"`{dataset.TemporalPredictionDataset.__name__}` but was {type(data)}"
+        )
+
+
+class BaseTemporalRegressor(plugins.BasePredictor):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
+        check_data_class(data)
+        super().fit(data, *args, **kwargs)
+        return self
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self,
+        data: dataset.Dataset,
+        n_future_steps: int,
+        *args,
+        time_delta: int = 1,
+        **kwargs,
+    ) -> samples.TimeSeriesSamples:
+        check_data_class(data)
+        return super().predict(data, n_future_steps, *args, time_delta=time_delta, **kwargs)
+
+    @abc.abstractmethod
+    def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self, data: dataset.Dataset, n_future_steps: int, *args, time_delta: int = 1, **kwargs
+    ) -> samples.TimeSeriesSamples:
+        ...
+
+
+plugins.register_plugin_category("prediction.temporal.regression", BaseTemporalRegressor)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+    "BaseTemporalRegressor",
+]
diff --git a/src/tempor/plugins/regression/plugin_seq2seq_regressor.py b/src/tempor/plugins/prediction/temporal/regression/plugin_seq2seq_regressor.py
similarity index 86%
rename from src/tempor/plugins/regression/plugin_seq2seq_regressor.py
rename to src/tempor/plugins/prediction/temporal/regression/plugin_seq2seq_regressor.py
index 5b30217d..05740b2b 100644
--- a/src/tempor/plugins/regression/plugin_seq2seq_regressor.py
+++ b/src/tempor/plugins/prediction/temporal/regression/plugin_seq2seq_regressor.py
@@ -13,11 +13,12 @@
     tempor_dataset_to_clairvoyance2_dataset,
 )
 from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.regression import BaseRegressor
+from tempor.plugins.prediction.temporal.regression import BaseTemporalRegressor
 
 
 @dataclasses.dataclass
-class seq2seqParams:
+class Seq2seqRegressorParams:
+    # TODO: Docstrings.
     # Encoder:
     encoder_rnn_type: str = "LSTM"
     encoder_hidden_size: int = 100
@@ -51,10 +52,10 @@ class seq2seqParams:
     padding_indicator: float = DEFAULT_PADDING_INDICATOR
 
 
-@plugins.register_plugin(name="seq2seq_regressor", category="regression")
-class Seq2seqRegressor(BaseRegressor):
-    ParamsDefinition = seq2seqParams
-    params: seq2seqParams  # type: ignore
+@plugins.register_plugin(name="seq2seq_regressor", category="prediction.temporal.regression")
+class Seq2seqRegressor(BaseTemporalRegressor):
+    ParamsDefinition = Seq2seqRegressorParams
+    params: Seq2seqRegressorParams  # type: ignore
 
     def __init__(
         self,
@@ -62,10 +63,14 @@ def __init__(
     ) -> None:
         """Seq2seq regressor.
 
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`Seq2seqClassifierParams`.
+
         Example:
             >>> import doctest; doctest.ELLIPSIS_MARKER = "[...]"  # Doctest config, ignore.
             >>>
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
+            >>> from tempor.utils.dataloaders import SineDataLoader
             >>> from tempor.data import dataset
             >>> from tempor.plugins import plugin_loader
             >>>
@@ -77,7 +82,7 @@ def __init__(
             ... )
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("regression.seq2seq_regressor", epochs=50)
+            >>> model = plugin_loader.get("prediction.temporal.regression.seq2seq_regressor", epochs=50)
             >>>
             >>> # Train:
             >>> model.fit(data)
@@ -103,7 +108,7 @@ def _fit(
         self.model.fit(cl_dataset)
         return self
 
-    def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+    def _predict(  # type: ignore[override]
         self,
         data: dataset.Dataset,
         n_future_steps: int,
diff --git a/src/tempor/plugins/preprocessing/__init__.py b/src/tempor/plugins/preprocessing/__init__.py
index ac7c4856..7d2bb6c6 100644
--- a/src/tempor/plugins/preprocessing/__init__.py
+++ b/src/tempor/plugins/preprocessing/__init__.py
@@ -1,6 +1,7 @@
-from . import imputation, scaling
+from . import imputation, nop, scaling
 
 __all__ = [
     "imputation",
+    "nop",
     "scaling",
 ]
diff --git a/src/tempor/plugins/preprocessing/imputation/__init__.py b/src/tempor/plugins/preprocessing/imputation/__init__.py
index bc958c27..4525c840 100644
--- a/src/tempor/plugins/preprocessing/imputation/__init__.py
+++ b/src/tempor/plugins/preprocessing/imputation/__init__.py
@@ -1,16 +1,8 @@
-import tempor.plugins.core as plugins
+from . import static, temporal
+from ._base import BaseImputer
 
-
-class BaseImputer(plugins.BaseTransformer):
-    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
-        super().__init__(**params)
-
-
-plugins.register_plugin_category("preprocessing.imputation", BaseImputer)
-
-plugins.importing.import_plugins(__file__)
-
-__all__ = [  # pyright: ignore
-    *plugins.importing.gather_modules_names(__file__),
+__all__ = [
     "BaseImputer",
+    "static",
+    "temporal",
 ]
diff --git a/src/tempor/plugins/preprocessing/imputation/_base.py b/src/tempor/plugins/preprocessing/imputation/_base.py
new file mode 100644
index 00000000..cf250a7b
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/_base.py
@@ -0,0 +1,6 @@
+import tempor.plugins.core as plugins
+
+
+class BaseImputer(plugins.BaseTransformer):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
diff --git a/src/tempor/plugins/preprocessing/imputation/static/__init__.py b/src/tempor/plugins/preprocessing/imputation/static/__init__.py
new file mode 100644
index 00000000..2aaf1cb0
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/static/__init__.py
@@ -0,0 +1,11 @@
+import tempor.plugins.core as plugins
+
+from .._base import BaseImputer
+
+plugins.register_plugin_category("preprocessing.imputation.static", BaseImputer)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py b/src/tempor/plugins/preprocessing/imputation/static/plugin_static_imputation.py
similarity index 94%
rename from src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py
rename to src/tempor/plugins/preprocessing/imputation/static/plugin_static_imputation.py
index 4d9ef42c..5db1081e 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py
+++ b/src/tempor/plugins/preprocessing/imputation/static/plugin_static_imputation.py
@@ -7,10 +7,10 @@
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples, TimeSeriesSamples
 from tempor.plugins.core._params import CategoricalParams, Params
-from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.imputation._base import BaseImputer
 
 
-@plugins.register_plugin(name="static_imputation", category="preprocessing.imputation")
+@plugins.register_plugin(name="static_imputation", category="preprocessing.imputation.static")
 class StaticOnlyImputer(BaseImputer):
     """Impute the time-series using a static imputer.
 
@@ -23,7 +23,7 @@ class StaticOnlyImputer(BaseImputer):
             Random seed. Defaults to ``0``.
 
     Example:
-        >>> from tempor.utils.dataloaders.sine import SineDataLoader
+        >>> from tempor.utils.dataloaders import SineDataLoader
         >>> from tempor.plugins import plugin_loader
         >>>
         >>> dataset = SineDataLoader(with_missing = True).load()
@@ -31,7 +31,7 @@ class StaticOnlyImputer(BaseImputer):
         >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
         >>>
         >>> # Load the model:
-        >>> model = plugin_loader.get("preprocessing.imputation.static_imputation")
+        >>> model = plugin_loader.get("preprocessing.imputation.static.static_imputation")
         >>>
         >>> # Train:
         >>> model.fit(dataset)
@@ -59,7 +59,7 @@ def _fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
 
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         # impute static data
         if data.static is not None:
             static_data = data.static.dataframe()
diff --git a/src/tempor/plugins/preprocessing/imputation/temporal/__init__.py b/src/tempor/plugins/preprocessing/imputation/temporal/__init__.py
new file mode 100644
index 00000000..7dda3fb5
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/temporal/__init__.py
@@ -0,0 +1,11 @@
+import tempor.plugins.core as plugins
+
+from .._base import BaseImputer
+
+plugins.register_plugin_category("preprocessing.imputation.temporal", BaseImputer)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_bfill.py
similarity index 94%
rename from src/tempor/plugins/preprocessing/imputation/plugin_bfill.py
rename to src/tempor/plugins/preprocessing/imputation/temporal/plugin_bfill.py
index 45f58f5a..771c9999 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py
+++ b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_bfill.py
@@ -7,10 +7,10 @@
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples, TimeSeriesSamples
 from tempor.plugins.core._params import CategoricalParams, Params
-from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.imputation._base import BaseImputer
 
 
-@plugins.register_plugin(name="bfill", category="preprocessing.imputation")
+@plugins.register_plugin(name="bfill", category="preprocessing.imputation.temporal")
 class BFillImputer(BaseImputer):
     def __init__(self, static_imputer: str = "mean", random_state: int = 0, **params) -> None:
         """Backward-first Time-Series Imputation.
@@ -22,7 +22,7 @@ def __init__(self, static_imputer: str = "mean", random_state: int = 0, **params
                 Random seed. Defaults to ``0``.
 
         Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
+            >>> from tempor.utils.dataloaders import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
             >>> dataset = SineDataLoader(with_missing = True).load()
@@ -30,7 +30,7 @@ def __init__(self, static_imputer: str = "mean", random_state: int = 0, **params
             >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("preprocessing.imputation.bfill")
+            >>> model = plugin_loader.get("preprocessing.imputation.temporal.bfill")
             >>>
             >>> # Train:
             >>> model.fit(dataset)
@@ -51,7 +51,7 @@ def _fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
 
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         # impute static data
         if data.static is not None:
             static_data = data.static.dataframe()
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ffill.py
similarity index 94%
rename from src/tempor/plugins/preprocessing/imputation/plugin_ffill.py
rename to src/tempor/plugins/preprocessing/imputation/temporal/plugin_ffill.py
index c38677d4..eb8ad388 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py
+++ b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ffill.py
@@ -7,10 +7,10 @@
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples, TimeSeriesSamples
 from tempor.plugins.core._params import CategoricalParams, Params
-from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.imputation._base import BaseImputer
 
 
-@plugins.register_plugin(name="ffill", category="preprocessing.imputation")
+@plugins.register_plugin(name="ffill", category="preprocessing.imputation.temporal")
 class FFillImputer(BaseImputer):
     """Forward-first Time-Series Imputation.
 
@@ -21,7 +21,7 @@ class FFillImputer(BaseImputer):
             Random seed. Defaults to ``0``.
 
     Example:
-        >>> from tempor.utils.dataloaders.sine import SineDataLoader
+        >>> from tempor.utils.dataloaders import SineDataLoader
         >>> from tempor.plugins import plugin_loader
         >>>
         >>> dataset = SineDataLoader(with_missing = True).load()
@@ -29,7 +29,7 @@ class FFillImputer(BaseImputer):
         >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
         >>>
         >>> # Load the model:
-        >>> model = plugin_loader.get("preprocessing.imputation.ffill")
+        >>> model = plugin_loader.get("preprocessing.imputation.temporal.ffill")
         >>>
         >>> # Train:
         >>> model.fit(dataset)
@@ -54,7 +54,7 @@ def _fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
 
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         # impute static data
         if data.static is not None:
             static_data = data.static.dataframe()
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_nop.py b/src/tempor/plugins/preprocessing/nop/__init__.py
similarity index 66%
rename from src/tempor/plugins/preprocessing/imputation/plugin_nop.py
rename to src/tempor/plugins/preprocessing/nop/__init__.py
index 5815b2c8..15ef7182 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_nop.py
+++ b/src/tempor/plugins/preprocessing/nop/__init__.py
@@ -1,14 +1,13 @@
-from typing import Any
-
 from typing_extensions import Self
 
 import tempor.plugins.core as plugins
 from tempor.data import dataset
-from tempor.plugins.preprocessing.imputation import BaseImputer
+
+plugins.register_plugin_category("preprocessing.nop", plugins.BaseTransformer)
 
 
-@plugins.register_plugin(name="nop_imputer", category="preprocessing.imputation")
-class NopImputer(BaseImputer):
+@plugins.register_plugin(name="nop_transformer", category="preprocessing.nop")
+class NopTransformer(plugins.BaseTransformer):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         super().__init__(**params)
 
@@ -20,9 +19,14 @@ def _fit(
     ) -> Self:
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         return data
 
     @staticmethod
     def hyperparameter_space(*args, **kwargs):
         return []
+
+
+__all__ = [
+    "NopTransformer",
+]
diff --git a/src/tempor/plugins/preprocessing/scaling/__init__.py b/src/tempor/plugins/preprocessing/scaling/__init__.py
index df5f15d5..26fbd5ba 100644
--- a/src/tempor/plugins/preprocessing/scaling/__init__.py
+++ b/src/tempor/plugins/preprocessing/scaling/__init__.py
@@ -1,16 +1,8 @@
-import tempor.plugins.core as plugins
+from . import static, temporal
+from ._base import BaseScaler
 
-
-class BaseScaler(plugins.BaseTransformer):
-    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
-        super().__init__(**params)
-
-
-plugins.register_plugin_category("preprocessing.scaling", BaseScaler)
-
-plugins.importing.import_plugins(__file__)
-
-__all__ = [  # pyright: ignore
-    *plugins.importing.gather_modules_names(__file__),
+__all__ = [
     "BaseScaler",
+    "static",
+    "temporal",
 ]
diff --git a/src/tempor/plugins/preprocessing/scaling/_base.py b/src/tempor/plugins/preprocessing/scaling/_base.py
new file mode 100644
index 00000000..ee39acba
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/scaling/_base.py
@@ -0,0 +1,6 @@
+import tempor.plugins.core as plugins
+
+
+class BaseScaler(plugins.BaseTransformer):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_nop.py b/src/tempor/plugins/preprocessing/scaling/plugin_nop.py
deleted file mode 100644
index 8c07fbb9..00000000
--- a/src/tempor/plugins/preprocessing/scaling/plugin_nop.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from typing import Any
-
-from typing_extensions import Self
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset
-from tempor.plugins.preprocessing.scaling import BaseScaler
-
-
-@plugins.register_plugin(name="nop_scaler", category="preprocessing.scaling")
-class NopScaler(BaseScaler):
-    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
-        super().__init__(**params)
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> Self:
-        return self
-
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
-        return data
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return []
diff --git a/src/tempor/plugins/preprocessing/scaling/static/__init__.py b/src/tempor/plugins/preprocessing/scaling/static/__init__.py
new file mode 100644
index 00000000..2547194d
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/scaling/static/__init__.py
@@ -0,0 +1,11 @@
+import tempor.plugins.core as plugins
+
+from .._base import BaseScaler
+
+plugins.register_plugin_category("preprocessing.scaling.static", BaseScaler)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_static_minmax_scaler.py b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
similarity index 93%
rename from src/tempor/plugins/preprocessing/scaling/plugin_static_minmax_scaler.py
rename to src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
index a64b3e49..20133698 100644
--- a/src/tempor/plugins/preprocessing/scaling/plugin_static_minmax_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
@@ -1,5 +1,3 @@
-from typing import Any
-
 import pandas as pd
 from sklearn.preprocessing import MinMaxScaler
 from typing_extensions import Self
@@ -7,10 +5,10 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples
-from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
-@plugins.register_plugin(name="static_minmax_scaler", category="preprocessing.scaling")
+@plugins.register_plugin(name="static_minmax_scaler", category="preprocessing.scaling.static")
 class StaticMinMaxScaler(BaseScaler):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         """MinMax scaling for the static data.
@@ -26,7 +24,7 @@ def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegatio
             >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("preprocessing.scaling.static_minmax_scaler")
+            >>> model = plugin_loader.get("preprocessing.scaling.static.static_minmax_scaler")
             >>>
             >>> # Train:
             >>> model.fit(dataset)
@@ -51,7 +49,7 @@ def _fit(
         self.model.fit(data.static.dataframe())
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         if data.static is None:
             return data
 
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_static_standard_scaler.py b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_standard_scaler.py
similarity index 92%
rename from src/tempor/plugins/preprocessing/scaling/plugin_static_standard_scaler.py
rename to src/tempor/plugins/preprocessing/scaling/static/plugin_static_standard_scaler.py
index 4ed036b1..0ae32acb 100644
--- a/src/tempor/plugins/preprocessing/scaling/plugin_static_standard_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_standard_scaler.py
@@ -1,5 +1,3 @@
-from typing import Any
-
 import pandas as pd
 from sklearn.preprocessing import StandardScaler
 from typing_extensions import Self
@@ -7,10 +5,10 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples
-from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
-@plugins.register_plugin(name="static_standard_scaler", category="preprocessing.scaling")
+@plugins.register_plugin(name="static_standard_scaler", category="preprocessing.scaling.static")
 class StaticStandardScaler(BaseScaler):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         """Standard scaling for the static data.
@@ -24,7 +22,7 @@ def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegatio
             >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("preprocessing.scaling.static_standard_scaler")
+            >>> model = plugin_loader.get("preprocessing.scaling.static.static_standard_scaler")
             >>>
             >>> # Train:
             >>> model.fit(dataset)
@@ -49,7 +47,7 @@ def _fit(
         self.model.fit(data.static.dataframe())
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         if data.static is None:
             return data
 
diff --git a/src/tempor/plugins/preprocessing/scaling/temporal/__init__.py b/src/tempor/plugins/preprocessing/scaling/temporal/__init__.py
new file mode 100644
index 00000000..f345d254
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/scaling/temporal/__init__.py
@@ -0,0 +1,11 @@
+import tempor.plugins.core as plugins
+
+from .._base import BaseScaler
+
+plugins.register_plugin_category("preprocessing.scaling.temporal", BaseScaler)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_ts_minmax_scaler.py b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
similarity index 93%
rename from src/tempor/plugins/preprocessing/scaling/plugin_ts_minmax_scaler.py
rename to src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
index ceea59f6..d7e39d2b 100644
--- a/src/tempor/plugins/preprocessing/scaling/plugin_ts_minmax_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
@@ -1,5 +1,3 @@
-from typing import Any
-
 import pandas as pd
 from sklearn.preprocessing import MinMaxScaler
 from typing_extensions import Self
@@ -7,10 +5,10 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import TimeSeriesSamples
-from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
-@plugins.register_plugin(name="ts_minmax_scaler", category="preprocessing.scaling")
+@plugins.register_plugin(name="ts_minmax_scaler", category="preprocessing.scaling.temporal")
 class TimeSeriesMinMaxScaler(BaseScaler):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         """MinMax scaling for the time-series data.
@@ -26,7 +24,7 @@ def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegatio
             >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("preprocessing.scaling.ts_minmax_scaler")
+            >>> model = plugin_loader.get("preprocessing.scaling.temporal.ts_minmax_scaler")
             >>>
             >>> # Train:
             >>> model.fit(dataset)
@@ -48,7 +46,7 @@ def _fit(
         self.model.fit(data.time_series.dataframe())
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         temporal_data = data.time_series.dataframe()
         scaled = pd.DataFrame(self.model.transform(temporal_data))
         scaled.columns = temporal_data.columns
diff --git a/src/tempor/plugins/preprocessing/scaling/plugin_ts_standard_scaler.py b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_standard_scaler.py
similarity index 92%
rename from src/tempor/plugins/preprocessing/scaling/plugin_ts_standard_scaler.py
rename to src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_standard_scaler.py
index 9e06aa66..037ae9e7 100644
--- a/src/tempor/plugins/preprocessing/scaling/plugin_ts_standard_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_standard_scaler.py
@@ -1,5 +1,3 @@
-from typing import Any
-
 import pandas as pd
 from sklearn.preprocessing import StandardScaler
 from typing_extensions import Self
@@ -7,10 +5,10 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import TimeSeriesSamples
-from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
-@plugins.register_plugin(name="ts_standard_scaler", category="preprocessing.scaling")
+@plugins.register_plugin(name="ts_standard_scaler", category="preprocessing.scaling.temporal")
 class TimeSeriesStandardScaler(BaseScaler):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         """Standard scaling for the time-series data.
@@ -24,7 +22,7 @@ def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegatio
             >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("preprocessing.scaling.ts_standard_scaler")
+            >>> model = plugin_loader.get("preprocessing.scaling.temporal.ts_standard_scaler")
             >>>
             >>> # Train:
             >>> model.fit(dataset)
@@ -46,7 +44,7 @@ def _fit(
         self.model.fit(data.time_series.dataframe())
         return self
 
-    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+    def _transform(self, data: dataset.Dataset, *args, **kwargs) -> dataset.Dataset:
         temporal_data = data.time_series.dataframe()
         scaled = pd.DataFrame(self.model.transform(temporal_data))
         scaled.columns = temporal_data.columns
diff --git a/src/tempor/plugins/regression/plugin_cde_regressor.py b/src/tempor/plugins/regression/plugin_cde_regressor.py
deleted file mode 100644
index d9028ecf..00000000
--- a/src/tempor/plugins/regression/plugin_cde_regressor.py
+++ /dev/null
@@ -1,208 +0,0 @@
-from typing import Any, Optional
-
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import Interpolation, NeuralODE, Nonlin
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.regression import BaseRegressor
-
-
-@plugins.register_plugin(name="cde_regressor", category="regression")
-class CDERegressor(BaseRegressor):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # CDE specific:
-        atol: float = 1e-2,
-        rtol: float = 1e-2,
-        interpolation: Interpolation = "cubic",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Neural Controlled Differential Equations for Irregular Time Series.
-
-        Paper:
-            "Neural Controlled Differential Equations for Irregular Time Series", Patrick Kidger, James Morrill,
-            James Foster, Terry Lyons.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            atol (float, optional):
-                Absolute tolerance for solution. Defaults to ``1e-2``.
-            rtol (float, optional):
-                Relative tolerance for solution. Defaults to ``1e-2``.
-            interpolation (Interpolation, optional):
-                ``"cubic"`` or ``"linear"``. Defaults to ``"cubic"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("regression.cde_regressor", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            CDERegressor(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # CDE
-        self.atol = atol
-        self.rtol = rtol
-        self.interpolation: Interpolation = interpolation
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "CDERegressor":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        self.model = NeuralODE(
-            task_type="regression",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[1],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # CDE
-            backend="cde",
-            atol=self.atol,
-            rtol=self.rtol,
-            interpolation=self.interpolation,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            FloatParams(name="atol", low=0, high=1),
-            FloatParams(name="rtol", low=0, high=1),
-            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/regression/plugin_laplace_regressor.py b/src/tempor/plugins/regression/plugin_laplace_regressor.py
deleted file mode 100644
index ab99f121..00000000
--- a/src/tempor/plugins/regression/plugin_laplace_regressor.py
+++ /dev/null
@@ -1,207 +0,0 @@
-from typing import Any, Optional
-
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import ILTAlgorithm, NeuralODE, Nonlin
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.regression import BaseRegressor
-
-
-@plugins.register_plugin(name="laplace_ode_regressor", category="regression")
-class LaplaceODERegressor(BaseRegressor):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # Laplace specific:
-        ilt_reconstruction_terms: int = 33,
-        ilt_algorithm: ILTAlgorithm = "fourier",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Inverse Laplace Transform (ILT) algorithms implemented in PyTorch.
-        Backpropagation through differential equation (DE) solutions in the Laplace domain is supported using the
-        Riemann stereographic projection for better global representation of the complex Laplace domain.
-
-        Paper:
-            "Neural Laplace: Learning diverse classes of differential equations in the Laplace domain",
-            Holt, Samuel I and Qian, Zhaozhi and van der Schaar, Mihaela.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            ilt_reconstruction_terms (int, optional):
-                Specific to ``"laplace"`` backend. Number of ILT reconstruction terms, i.e. the number of complex
-                :math:`s` points in ``laplace_rep_func`` to reconstruct a single time point. Defaults to ``33``.
-            ilt_algorithm (ILTAlgorithm, optional):
-                Specific to ``"laplace"`` backend. Inverse Laplace transform algorithm to use. Available are
-                {``fourier``, ``dehoog``, ``cme``, ``fixed_tablot``, ``stehfest``}. Defaults to ``"fourier"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> # dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("regression.laplace_ode_regressor", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> # model.fit(dataset)
-            >>> # LaplaceODERegressor(...)
-            >>>
-            >>> # Predict:
-            >>> # assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        # TODO: Model currently fails to run with SineDataLoader data. Investigate and resolve.
-
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # Laplace
-        self.ilt_algorithm: ILTAlgorithm = ilt_algorithm
-        self.ilt_reconstruction_terms = ilt_reconstruction_terms
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "LaplaceODERegressor":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        self.model = NeuralODE(
-            task_type="regression",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[1],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # Laplace
-            backend="laplace",
-            ilt_algorithm=self.ilt_algorithm,
-            ilt_reconstruction_terms=self.ilt_reconstruction_terms,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            CategoricalParams(name="ilt_algorithm", choices=["fourier", "dehoog", "cme", "fixed_tablot", "stehfest"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/regression/plugin_nn_regressor.py b/src/tempor/plugins/regression/plugin_nn_regressor.py
deleted file mode 100644
index 494e8c0a..00000000
--- a/src/tempor/plugins/regression/plugin_nn_regressor.py
+++ /dev/null
@@ -1,194 +0,0 @@
-from typing import Any, List, Optional, Tuple
-
-from torch.utils.data import sampler
-from typing_extensions import get_args
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_model import Nonlin, TimeSeriesModel, TSModelMode
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.regression import BaseRegressor
-
-
-@plugins.register_plugin(name="nn_regressor", category="regression")
-class NeuralNetRegressor(BaseRegressor):
-    def __init__(
-        self,
-        n_static_units_hidden: int = 100,
-        n_static_layers_hidden: int = 2,
-        n_temporal_units_hidden: int = 102,
-        n_temporal_layers_hidden: int = 2,
-        n_iter: int = 500,
-        mode: TSModelMode = "RNN",
-        n_iter_print: int = 10,
-        batch_size: int = 100,
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        window_size: int = 1,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-        nonlin_out: Optional[List[Tuple[Nonlin, int]]] = None,
-        dropout: float = 0,
-        nonlin: Nonlin = "relu",
-        random_state: int = 0,
-        clipping_value: int = 1,
-        patience: int = 20,
-        train_ratio: float = 0.8,
-    ) -> None:
-        """Neural-net regressor.
-
-        Args:
-            n_static_units_hidden (int, optional):
-                Number of hidden units for the static features. Defaults to ``100``.
-            n_static_layers_hidden (int, optional):
-                Number of hidden layers for the static features. Defaults to ``2``.
-            n_temporal_units_hidden (int, optional):
-                Number of hidden units for the temporal features. Defaults to ``102``.
-            n_temporal_layers_hidden (int, optional):
-                Number of hidden layers for the temporal features. Defaults to ``2``.
-            n_iter (int, optional):
-                Number of epochs. Defaults to ``500``.
-            mode (TSModelMode, optional):
-                Core neural net architecture. Available options: :obj:`~tempor.models.ts_model.TSModelMode`.
-                Defaults to ``"RNN"``.
-            n_iter_print (int, optional):
-                Number of epochs to print the loss. Defaults to ``10``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``100``.
-            lr (float, optional):
-                Learning rate. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            window_size (int, optional):
-                How many hidden states to use for the outcome. Defaults to ``1``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-            nonlin_out (Optional[List[Tuple[Nonlin, int]]], optional):
-                List of activations for the output. Example ``[("tanh", 1), ("softmax", 3)]`` - means the output layer
-                will apply ``"tanh"`` for the first unit, and ``"softmax"`` for the following 3 units in the output.
-                Defaults to `None`.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            random_state (int, optional):
-                Random seed. Defaults to ``0``.
-            clipping_value (int, optional):
-                Gradients clipping value. Zero disables the feature. Defaults to ``1``.
-            patience (int, optional):
-                How many epoch * n_iter_print to wait without loss improvement. Defaults to ``20``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("regression.nn_regressor", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            NeuralNetRegressor(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_static_units_hidden = n_static_units_hidden
-        self.n_static_layers_hidden = n_static_layers_hidden
-        self.n_temporal_units_hidden = n_temporal_units_hidden
-        self.n_temporal_layers_hidden = n_temporal_layers_hidden
-        self.n_iter = n_iter
-        self.mode: TSModelMode = mode
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.window_size = window_size
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.nonlin_out = nonlin_out
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        self.model: Optional[TimeSeriesModel] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "NeuralNetRegressor":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        self.model = TimeSeriesModel(
-            task_type="regression",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            n_temporal_window=temporal.shape[1],
-            output_shape=[1],
-            n_static_units_hidden=self.n_static_units_hidden,
-            n_static_layers_hidden=self.n_static_layers_hidden,
-            n_temporal_units_hidden=self.n_temporal_units_hidden,
-            n_temporal_layers_hidden=self.n_temporal_layers_hidden,
-            n_iter=self.n_iter,
-            mode=self.mode,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            window_size=self.window_size,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            nonlin_out=self.nonlin_out,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_static_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_static_layers_hidden", low=1, high=5),
-            IntegerParams(name="n_temporal_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_temporal_layers_hidden", low=1, high=5),
-            CategoricalParams(name="mode", choices=list(get_args(TSModelMode))),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/regression/plugin_ode_regressor.py b/src/tempor/plugins/regression/plugin_ode_regressor.py
deleted file mode 100644
index 09515b81..00000000
--- a/src/tempor/plugins/regression/plugin_ode_regressor.py
+++ /dev/null
@@ -1,204 +0,0 @@
-from typing import Any, Optional
-
-from torch.utils.data import sampler
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.models.constants import DEVICE
-from tempor.models.ts_ode import Interpolation, NeuralODE, Nonlin
-from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.regression import BaseRegressor
-
-
-@plugins.register_plugin(name="ode_regressor", category="regression")
-class ODERegressor(BaseRegressor):
-    def __init__(
-        self,
-        *,
-        n_units_hidden: int = 100,
-        n_layers_hidden: int = 1,
-        nonlin: Nonlin = "relu",
-        dropout: float = 0,
-        # ODE specific:
-        atol: float = 1e-2,
-        rtol: float = 1e-2,
-        interpolation: Interpolation = "cubic",
-        # Training:
-        lr: float = 1e-3,
-        weight_decay: float = 1e-3,
-        n_iter: int = 1000,
-        batch_size: int = 500,
-        n_iter_print: int = 100,
-        random_state: int = 0,
-        patience: int = 10,
-        clipping_value: int = 1,
-        train_ratio: float = 0.8,
-        device: Any = DEVICE,
-        dataloader_sampler: Optional[sampler.Sampler] = None,
-    ) -> None:
-        """Regressor based on ordinary differential equation (ODE) solvers.
-
-        Args:
-            n_units_hidden (int, optional):
-                Number of hidden units. Defaults to ``100``.
-            n_layers_hidden (int, optional):
-                Number of hidden layers. Defaults to ``2``.
-            nonlin (Nonlin, optional):
-                Activation for hidden layers. Available options: :obj:`~tempor.models.constants.Nonlin`.
-                Defaults to ``"relu"``.
-            dropout (float, optional):
-                Dropout value. Defaults to ``0``.
-            atol (float, optional):
-                Absolute tolerance for solution. Defaults to ``1e-2``.
-            rtol (float, optional):
-                Relative tolerance for solution. Defaults to ``1e-2``.
-            interpolation (Interpolation, optional):
-                ``"cubic"`` or ``"linear"``. Defaults to ``"cubic"``.
-            lr (float, optional):
-                Learning rate for optimizer. Defaults to ``1e-3``.
-            weight_decay (float, optional):
-                l2 (ridge) penalty for the weights. Defaults to ``1e-3``.
-            n_iter (int, optional):
-                Maximum number of iterations. Defaults to ``1000``.
-            batch_size (int, optional):
-                Batch size. Defaults to ``500``.
-            n_iter_print (int, optional):
-                Number of iterations after which to print updates and check the validation loss. Defaults to ``100``.
-            random_state (int, optional):
-                Random_state used. Defaults to ``0``.
-            patience (int, optional):
-                Number of iterations to wait before early stopping after decrease in validation loss.
-                Defaults to ``10``.
-            clipping_value (int, optional):
-                Gradients clipping value. Defaults to ``1``.
-            train_ratio (float, optional):
-                Train/test split ratio. Defaults to ``0.8``.
-            device (Any, optional):
-                PyTorch device to use. Defaults to `~tempor.models.constants.DEVICE`.
-            dataloader_sampler (Optional[sampler.Sampler], optional):
-                Custom data sampler for training. Defaults to `None`.
-
-        Example:
-            >>> from tempor.utils.dataloaders.sine import SineDataLoader
-            >>> from tempor.plugins import plugin_loader
-            >>>
-            >>> dataset = SineDataLoader().load()
-            >>>
-            >>> # Load the model:
-            >>> model = plugin_loader.get("regression.ode_regressor", n_iter=50)
-            >>>
-            >>> # Train:
-            >>> model.fit(dataset)
-            ODERegressor(...)
-            >>>
-            >>> # Predict:
-            >>> assert model.predict(dataset).numpy().shape == (len(dataset), 1)
-        """
-        super().__init__()
-        self.n_units_hidden = n_units_hidden
-        self.n_layers_hidden = n_layers_hidden
-        self.n_iter = n_iter
-        self.n_iter_print = n_iter_print
-        self.batch_size = batch_size
-        self.lr = lr
-        self.weight_decay = weight_decay
-        self.device = device
-        self.dataloader_sampler = dataloader_sampler
-        self.dropout = dropout
-        self.nonlin: Nonlin = nonlin
-        self.random_state = random_state
-        self.clipping_value = clipping_value
-        self.patience = patience
-        self.train_ratio = train_ratio
-
-        # ODE
-        self.atol = atol
-        self.rtol = rtol
-        self.interpolation: Interpolation = interpolation
-
-        self.model: Optional[NeuralODE] = None
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "ODERegressor":  # pyright: ignore
-        static, temporal, observation_times, outcome = self._unpack_dataset(data)
-        outcome = outcome.squeeze()
-
-        self.model = NeuralODE(
-            task_type="regression",
-            n_static_units_in=static.shape[-1],
-            n_temporal_units_in=temporal.shape[-1],
-            output_shape=[1],
-            n_units_hidden=self.n_units_hidden,
-            n_layers_hidden=self.n_layers_hidden,
-            # ODE
-            backend="ode",
-            atol=self.atol,
-            rtol=self.rtol,
-            interpolation=self.interpolation,
-            # training
-            n_iter=self.n_iter,
-            n_iter_print=self.n_iter_print,
-            batch_size=self.batch_size,
-            lr=self.lr,
-            weight_decay=self.weight_decay,
-            device=self.device,
-            dataloader_sampler=self.dataloader_sampler,
-            dropout=self.dropout,
-            nonlin=self.nonlin,
-            random_state=self.random_state,
-            clipping_value=self.clipping_value,
-            patience=self.patience,
-            train_ratio=self.train_ratio,
-        )
-
-        self.model.fit(static, temporal, observation_times, outcome)
-        return self
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        preds = preds.reshape(-1, 1)
-        return samples.StaticSamples.from_numpy(preds)
-
-    def _predict_proba(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        if self.model is None:
-            raise RuntimeError("Fit the model first")
-        static, temporal, observation_times, _ = self._unpack_dataset(data)
-
-        preds = self.model.predict_proba(static, temporal, observation_times)
-        preds = preds.astype(float)
-
-        return samples.StaticSamples.from_numpy(preds)
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return [
-            IntegerParams(name="n_units_hidden", low=100, high=1000),
-            IntegerParams(name="n_layers_hidden", low=1, high=5),
-            FloatParams(name="atol", low=0, high=1),
-            FloatParams(name="rtol", low=0, high=1),
-            CategoricalParams(name="interpolation", choices=["cubic", "linear"]),
-            CategoricalParams(name="batch_size", choices=[64, 128, 256, 512]),
-            CategoricalParams(name="lr", choices=[1e-3, 1e-4, 2e-4]),
-            FloatParams(name="dropout", low=0, high=0.2),
-            CategoricalParams(name="nonlin", choices=["relu", "elu", "leaky_relu", "selu"]),
-        ]
diff --git a/src/tempor/plugins/time_to_event/plugin_ddh.py b/src/tempor/plugins/time_to_event/plugin_ddh.py
index e5a26b82..d161c712 100644
--- a/src/tempor/plugins/time_to_event/plugin_ddh.py
+++ b/src/tempor/plugins/time_to_event/plugin_ddh.py
@@ -39,7 +39,6 @@ class DynamicDeepHitTimeToEventAnalysisParams:
     random_state: int = 0
 
 
-# TODO: Docstring.
 @plugins.register_plugin(name="dynamic_deephit", category="time_to_event")
 class DynamicDeepHitTimeToEventAnalysis(BaseTimeToEventAnalysis):
     ParamsDefinition = DynamicDeepHitTimeToEventAnalysisParams
@@ -51,6 +50,10 @@ def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegatio
         Args:
             **params:
                 Parameters and defaults as defined in :class:`DynamicDeepHitTimeToEventAnalysisParams`.
+
+        References:
+            "Dynamic-DeepHit: A Deep Learning Approach for Dynamic Survival Analysis With Competing Risks Based on
+            Longitudinal Data", Changhee Lee, Jinsung Yoon, Mihaela van der Schaar.
         """
         super().__init__(**params)
 
diff --git a/src/tempor/plugins/time_to_event/plugin_ts_coxph.py b/src/tempor/plugins/time_to_event/plugin_ts_coxph.py
index 8a126252..d5506b97 100644
--- a/src/tempor/plugins/time_to_event/plugin_ts_coxph.py
+++ b/src/tempor/plugins/time_to_event/plugin_ts_coxph.py
@@ -1,9 +1,12 @@
+import contextlib
 import dataclasses
 from typing import Any, Dict, List, Optional
 
+import lifelines
 import numpy as np
 import pandas as pd
 from lifelines import CoxPHFitter
+from packaging.version import Version
 from typing_extensions import Self
 
 import tempor.plugins.core as plugins
@@ -15,6 +18,42 @@
 from .helper_embedding import EmbTimeToEventAnalysis, OutputTimeToEventAnalysis
 
 
+@contextlib.contextmanager
+def monkeypatch_lifelines_pd2_compatibility():
+    """lifelines (at least as of 0.27.4) is not compatible with pandas 2.0.0+, due to
+    ``TypeError: describe() got an unexpected keyword argument 'datetime_is_numeric'`` thrown by pandas in e.g.
+    ``CoxPHFitter.fit``. This monkeypatch fixes this compatibility issue, until the problem is addressed by
+    `lifelines`.
+    """
+
+    def problem_versions() -> bool:
+        return (
+            Version(pd.__version__) >= Version("2.0.0rc0")
+            and Version(lifelines.__version__) <= Version("1.0")
+            # TODO: ^ Update here with the `lifelines` version which becomes `pandas` 2 compatible.
+        )
+
+    if problem_versions():
+        # Monkeypatch `pandas.DataFrame.describe`.
+
+        original_pd_df_describe = pd.DataFrame.describe
+
+        def monkeypatched_describe(*args, **kwargs):
+            # Remove the offending keyword argument (it is no longer needed to pass in).
+            kwargs.pop("datetime_is_numeric", None)
+            return original_pd_df_describe(*args, **kwargs)
+
+        pd.DataFrame.describe = monkeypatched_describe
+
+    try:
+        yield
+
+    finally:
+        if problem_versions():
+            # Restore `pandas.DataFrame.describe`.
+            pd.DataFrame.describe = original_pd_df_describe  # pyright: ignore
+
+
 @dataclasses.dataclass
 class CoxPHTimeToEventAnalysisParams:
     # TODO: Docstring.
@@ -79,7 +118,8 @@ def fit(self, X: pd.DataFrame, T: pd.Series, Y: pd.Series) -> Self:
         df["event"] = Y
         df["time"] = T
 
-        self.model.fit(df, "time", "event", fit_options=self.fit_options)
+        with monkeypatch_lifelines_pd2_compatibility():
+            self.model.fit(df, "time", "event", fit_options=self.fit_options)
 
         return self
 
diff --git a/src/tempor/plugins/treatments/__init__.py b/src/tempor/plugins/treatments/__init__.py
index b53920f4..eef95d67 100644
--- a/src/tempor/plugins/treatments/__init__.py
+++ b/src/tempor/plugins/treatments/__init__.py
@@ -1,64 +1,6 @@
-import abc
+from . import one_off, temporal
 
-import pydantic
-from typing_extensions import Self
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-
-
-def check_data_class(data):
-    if not isinstance(data, (dataset.OneOffTreatmentEffectsDataset, dataset.TemporalTreatmentEffectsDataset)):
-        raise TypeError(
-            "Expected `data` passed to a treatments estimator to be "
-            f"`{dataset.OneOffTreatmentEffectsDataset.__name__}` or "
-            f"`{dataset.TemporalTreatmentEffectsDataset.__name__}` but was {type(data)}"
-        )
-
-
-class BaseTreatments(plugins.BasePredictor):
-    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
-        super().__init__(**params)
-
-    def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
-        check_data_class(data)
-        super().fit(data, *args, **kwargs)
-        return self
-
-    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
-    def predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.StaticSamples:
-        check_data_class(data)
-        return super().predict(data, *args, **kwargs)
-
-    @abc.abstractmethod
-    def _predict(self, data: dataset.Dataset, *args, **kwargs) -> samples.StaticSamples:
-        ...
-
-    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
-    def predict_counterfactuals(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.TimeSeriesSamples:
-        check_data_class(data)
-        return super().predict_counterfactuals(data, *args, **kwargs)
-
-    @abc.abstractmethod
-    def _predict_counterfactuals(self, data: dataset.Dataset, *args, **kwargs) -> samples.TimeSeriesSamples:
-        ...
-
-
-plugins.register_plugin_category("treatments", BaseTreatments)
-
-plugins.importing.import_plugins(__file__)
-
-__all__ = [  # pyright: ignore
-    *plugins.importing.gather_modules_names(__file__),
-    "BaseTreatments",
+__all__ = [
+    "one_off",
+    "temporal",
 ]
diff --git a/src/tempor/plugins/treatments/one_off/__init__.py b/src/tempor/plugins/treatments/one_off/__init__.py
new file mode 100644
index 00000000..6abad0bb
--- /dev/null
+++ b/src/tempor/plugins/treatments/one_off/__init__.py
@@ -0,0 +1,8 @@
+from . import classification, regression
+from ._base import BaseOneOffTreatmentEffects
+
+__all__ = [
+    "BaseOneOffTreatmentEffects",
+    "classification",
+    "regression",
+]
diff --git a/src/tempor/plugins/treatments/one_off/_base.py b/src/tempor/plugins/treatments/one_off/_base.py
new file mode 100644
index 00000000..8cf2b2e0
--- /dev/null
+++ b/src/tempor/plugins/treatments/one_off/_base.py
@@ -0,0 +1,54 @@
+import abc
+from typing import List
+
+import pydantic
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+
+
+def check_data_class(data):
+    if not isinstance(data, dataset.OneOffTreatmentEffectsDataset):
+        raise TypeError(
+            "Expected `data` passed to a one-off treatment effects estimator to be "
+            f"`{dataset.OneOffTreatmentEffectsDataset.__name__}` but was {type(data)}"
+        )
+
+
+class BaseOneOffTreatmentEffects(plugins.BasePredictor):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
+        check_data_class(data)
+        super().fit(data, *args, **kwargs)
+        return self
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        check_data_class(data)
+        return super().predict(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _predict(self, data: dataset.Dataset, *args, **kwargs) -> samples.StaticSamples:
+        ...
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict_counterfactuals(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> List:
+        check_data_class(data)
+        return super().predict_counterfactuals(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _predict_counterfactuals(self, data: dataset.Dataset, *args, **kwargs) -> List:
+        ...
diff --git a/src/tempor/plugins/treatments/one_off/classification/__init__.py b/src/tempor/plugins/treatments/one_off/classification/__init__.py
new file mode 100644
index 00000000..53529741
--- /dev/null
+++ b/src/tempor/plugins/treatments/one_off/classification/__init__.py
@@ -0,0 +1,12 @@
+# NOTE: Uncomment once have at least one treatments.one_off.classification plugin.
+
+# import tempor.plugins.core as plugins
+# from tempor.plugins.treatments.one_off import BaseOneOffTreatmentEffects
+
+# plugins.register_plugin_category("treatments.one_off.classification", BaseOneOffTreatmentEffects)
+
+# plugins.importing.import_plugins(__file__)
+
+# __all__ = [  # pyright: ignore
+#     *plugins.importing.gather_modules_names(__file__),
+# ]
diff --git a/src/tempor/plugins/treatments/one_off/regression/__init__.py b/src/tempor/plugins/treatments/one_off/regression/__init__.py
new file mode 100644
index 00000000..608c573f
--- /dev/null
+++ b/src/tempor/plugins/treatments/one_off/regression/__init__.py
@@ -0,0 +1,10 @@
+import tempor.plugins.core as plugins
+from tempor.plugins.treatments.one_off._base import BaseOneOffTreatmentEffects
+
+plugins.register_plugin_category("treatments.one_off.regression", BaseOneOffTreatmentEffects)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/treatments/plugin_synctwin_regressor.py b/src/tempor/plugins/treatments/one_off/regression/plugin_synctwin_regressor.py
similarity index 94%
rename from src/tempor/plugins/treatments/plugin_synctwin_regressor.py
rename to src/tempor/plugins/treatments/one_off/regression/plugin_synctwin_regressor.py
index 06197346..bf339857 100644
--- a/src/tempor/plugins/treatments/plugin_synctwin_regressor.py
+++ b/src/tempor/plugins/treatments/one_off/regression/plugin_synctwin_regressor.py
@@ -7,7 +7,7 @@
 from tempor.data import dataset, samples
 from tempor.data.clv2conv import tempor_dataset_to_clairvoyance2_dataset
 from tempor.plugins.core._params import FloatParams, IntegerParams
-from tempor.plugins.treatments import BaseTreatments
+from tempor.plugins.treatments.one_off._base import BaseOneOffTreatmentEffects
 
 
 @dataclasses.dataclass
@@ -26,8 +26,8 @@ class SyncTwinParams:
     treatment_status_is_treated: int = 1
 
 
-@plugins.register_plugin(name="synctwin_regressor", category="treatments")
-class SyncTwinTreatmentsRegressor(BaseTreatments):
+@plugins.register_plugin(name="synctwin_regressor", category="treatments.one_off.regression")
+class SyncTwinTreatmentsRegressor(BaseOneOffTreatmentEffects):
     ParamsDefinition = SyncTwinParams
     params: SyncTwinParams  # type: ignore
 
@@ -37,21 +37,21 @@ def __init__(
     ) -> None:
         """SyncTwin treatment effects estimation.
 
-        Paper:
-            SyncTwin: Treatment Effect Estimation with Longitudinal Outcomes,
-            Zhaozhi Qian, Yao Zhang, Ioana Bica, Angela Wood, Mihaela van der Schaar.
-
         Example:
             >>> from tempor.plugins import plugin_loader
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("treatments.synctwin_regressor", n_iter=50)
+            >>> model = plugin_loader.get("treatments.one_off.regression.synctwin_regressor", n_iter=50)
             >>>
             >>> # Train:
             >>> # model.fit(dataset)
             >>>
             >>> # Predict:
             >>> # assert model.predict(dataset, n_future_steps = 10).numpy().shape == (len(dataset), 10, 5)
+
+        References:
+            SyncTwin: Treatment Effect Estimation with Longitudinal Outcomes,
+            Zhaozhi Qian, Yao Zhang, Ioana Bica, Angela Wood, Mihaela van der Schaar.
         """
         super().__init__(**params)
         self.model = SyncTwinRegressor(
diff --git a/src/tempor/plugins/treatments/temporal/__init__.py b/src/tempor/plugins/treatments/temporal/__init__.py
new file mode 100644
index 00000000..0e11e43b
--- /dev/null
+++ b/src/tempor/plugins/treatments/temporal/__init__.py
@@ -0,0 +1,8 @@
+from . import classification, regression
+from ._base import BaseTemporalTreatmentEffects
+
+__all__ = [
+    "BaseTemporalTreatmentEffects",
+    "classification",
+    "regression",
+]
diff --git a/src/tempor/plugins/treatments/temporal/_base.py b/src/tempor/plugins/treatments/temporal/_base.py
new file mode 100644
index 00000000..32b85c46
--- /dev/null
+++ b/src/tempor/plugins/treatments/temporal/_base.py
@@ -0,0 +1,54 @@
+import abc
+from typing import List
+
+import pydantic
+from typing_extensions import Self
+
+import tempor.plugins.core as plugins
+from tempor.data import dataset, samples
+
+
+def check_data_class(data):
+    if not isinstance(data, dataset.TemporalTreatmentEffectsDataset):
+        raise TypeError(
+            "Expected `data` passed to a temporal treatment effects estimator to be "
+            f"`{dataset.TemporalTreatmentEffectsDataset.__name__}` but was {type(data)}"
+        )
+
+
+class BaseTemporalTreatmentEffects(plugins.BasePredictor):
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        super().__init__(**params)
+
+    def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
+        check_data_class(data)
+        super().fit(data, *args, **kwargs)
+        return self
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> samples.StaticSamples:
+        check_data_class(data)
+        return super().predict(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _predict(self, data: dataset.Dataset, *args, **kwargs) -> samples.StaticSamples:
+        ...
+
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def predict_counterfactuals(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> List:
+        check_data_class(data)
+        return super().predict_counterfactuals(data, *args, **kwargs)
+
+    @abc.abstractmethod
+    def _predict_counterfactuals(self, data: dataset.Dataset, *args, **kwargs) -> List:
+        ...
diff --git a/src/tempor/plugins/treatments/temporal/classification/__init__.py b/src/tempor/plugins/treatments/temporal/classification/__init__.py
new file mode 100644
index 00000000..5f9fff46
--- /dev/null
+++ b/src/tempor/plugins/treatments/temporal/classification/__init__.py
@@ -0,0 +1,10 @@
+import tempor.plugins.core as plugins
+from tempor.plugins.treatments.temporal._base import BaseTemporalTreatmentEffects
+
+plugins.register_plugin_category("treatments.temporal.classification", BaseTemporalTreatmentEffects)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/treatments/plugin_crn_classifier.py b/src/tempor/plugins/treatments/temporal/classification/plugin_crn_classifier.py
similarity index 95%
rename from src/tempor/plugins/treatments/plugin_crn_classifier.py
rename to src/tempor/plugins/treatments/temporal/classification/plugin_crn_classifier.py
index 0612fc21..25cf6a93 100644
--- a/src/tempor/plugins/treatments/plugin_crn_classifier.py
+++ b/src/tempor/plugins/treatments/temporal/classification/plugin_crn_classifier.py
@@ -14,7 +14,7 @@
     tempor_dataset_to_clairvoyance2_dataset,
 )
 from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.treatments import BaseTreatments
+from tempor.plugins.treatments.temporal._base import BaseTemporalTreatmentEffects
 
 
 @dataclasses.dataclass
@@ -52,8 +52,8 @@ class CRNParams:
     padding_indicator: float = DEFAULT_PADDING_INDICATOR
 
 
-@plugins.register_plugin(name="crn_classifier", category="treatments")
-class CRNTreatmentsClassifier(BaseTreatments):
+@plugins.register_plugin(name="crn_classifier", category="treatments.temporal.classification")
+class CRNTreatmentsClassifier(BaseTemporalTreatmentEffects):
     ParamsDefinition = CRNParams
     params: CRNParams  # type: ignore
 
@@ -63,21 +63,21 @@ def __init__(
     ) -> None:
         """Counterfactual Recurrent Network treatment effects model for classification on the outcomes (targets).
 
-        Paper:
-            Estimating counterfactual treatment outcomes over time through adversarially balanced representations,
-            Ioana Bica, Ahmed M. Alaa, James Jordon, Mihaela van der Schaar.
-
         Example:
             >>> from tempor.plugins import plugin_loader
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("treatments.crn_classifier", n_iter=50)
+            >>> model = plugin_loader.get("treatments.temporal.classification.crn_classifier", n_iter=50)
             >>>
             >>> # Train:
             >>> # model.fit(dataset)
             >>>
             >>> # Predict:
             >>> # assert model.predict(dataset, n_future_steps = 10).numpy().shape == (len(dataset), 10, 5)
+
+        References:
+            Estimating counterfactual treatment outcomes over time through adversarially balanced representations,
+            Ioana Bica, Ahmed M. Alaa, James Jordon, Mihaela van der Schaar.
         """
         super().__init__(**params)
         self.model: Optional[CRNClassifier] = None
diff --git a/src/tempor/plugins/treatments/temporal/regression/__init__.py b/src/tempor/plugins/treatments/temporal/regression/__init__.py
new file mode 100644
index 00000000..4c669fc0
--- /dev/null
+++ b/src/tempor/plugins/treatments/temporal/regression/__init__.py
@@ -0,0 +1,10 @@
+import tempor.plugins.core as plugins
+from tempor.plugins.treatments.temporal._base import BaseTemporalTreatmentEffects
+
+plugins.register_plugin_category("treatments.temporal.regression", BaseTemporalTreatmentEffects)
+
+plugins.importing.import_plugins(__file__)
+
+__all__ = [  # pyright: ignore
+    *plugins.importing.gather_modules_names(__file__),
+]
diff --git a/src/tempor/plugins/treatments/plugin_crn_regressor.py b/src/tempor/plugins/treatments/temporal/regression/plugin_crn_regressor.py
similarity index 92%
rename from src/tempor/plugins/treatments/plugin_crn_regressor.py
rename to src/tempor/plugins/treatments/temporal/regression/plugin_crn_regressor.py
index 1b93c05a..7f1c95a3 100644
--- a/src/tempor/plugins/treatments/plugin_crn_regressor.py
+++ b/src/tempor/plugins/treatments/temporal/regression/plugin_crn_regressor.py
@@ -14,7 +14,7 @@
     tempor_dataset_to_clairvoyance2_dataset,
 )
 from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
-from tempor.plugins.treatments import BaseTreatments
+from tempor.plugins.treatments.temporal._base import BaseTemporalTreatmentEffects
 
 
 @dataclasses.dataclass
@@ -52,8 +52,8 @@ class CRNParams:
     padding_indicator: float = DEFAULT_PADDING_INDICATOR
 
 
-@plugins.register_plugin(name="crn_regressor", category="treatments")
-class CRNTreatmentsRegressor(BaseTreatments):
+@plugins.register_plugin(name="crn_regressor", category="treatments.temporal.regression")
+class CRNTreatmentsRegressor(BaseTemporalTreatmentEffects):
     ParamsDefinition = CRNParams
     params: CRNParams  # type: ignore
 
@@ -63,7 +63,7 @@ def __init__(
     ) -> None:
         """Counterfactual Recurrent Network treatment effects model for regression on the outcomes (targets).
 
-        Paper:
+        References:
             Estimating counterfactual treatment outcomes over time through adversarially balanced representations,
             Ioana Bica, Ahmed M. Alaa, James Jordon, Mihaela van der Schaar.
 
@@ -71,13 +71,17 @@ def __init__(
             >>> from tempor.plugins import plugin_loader
             >>>
             >>> # Load the model:
-            >>> model = plugin_loader.get("treatments.crn_regressor", n_iter=50)
+            >>> model = plugin_loader.get("treatments.temporal.regression.crn_regressor", n_iter=50)
             >>>
             >>> # Train:
             >>> # model.fit(dataset)
             >>>
             >>> # Predict:
             >>> # assert model.predict(dataset, n_future_steps = 10).numpy().shape == (len(dataset), 10, 5)
+
+        References:
+            Estimating counterfactual treatment outcomes over time through adversarially balanced representations,
+            Ioana Bica, Ahmed M. Alaa, James Jordon, Mihaela van der Schaar.
         """
         super().__init__(**params)
         self.model: Optional[CRNRegressor] = None
diff --git a/src/tempor/utils/dataloaders/__init__.py b/src/tempor/utils/dataloaders/__init__.py
index 978ab2a8..e55f4ff3 100644
--- a/src/tempor/utils/dataloaders/__init__.py
+++ b/src/tempor/utils/dataloaders/__init__.py
@@ -1,9 +1,17 @@
+from .dummy import (
+    DummyTemporalPredictionDataLoader,
+    DummyTemporalTreatmentEffectsDataLoader,
+)
 from .google_stocks import GoogleStocksDataLoader
 from .pbc import PBCDataLoader
+from .pkpd import PKPDDataLoader
 from .sine import SineDataLoader
 
 __all__ = [
+    "DummyTemporalPredictionDataLoader",
+    "DummyTemporalTreatmentEffectsDataLoader",
     "GoogleStocksDataLoader",
     "PBCDataLoader",
+    "PKPDDataLoader",
     "SineDataLoader",
 ]
diff --git a/src/tempor/utils/dataloaders/dummy.py b/src/tempor/utils/dataloaders/dummy.py
new file mode 100644
index 00000000..33068b31
--- /dev/null
+++ b/src/tempor/utils/dataloaders/dummy.py
@@ -0,0 +1,118 @@
+from typing import Optional, cast
+
+from clairvoyance2.datasets.dummy import dummy_dataset
+
+from tempor.data import dataloader, dataset
+from tempor.data.clv2conv import clairvoyance2_dataset_to_tempor_dataset
+
+
+# TODO: Docstring.
+class DummyTemporalPredictionDataLoader(dataloader.TemporalPredictionDataLoader):
+    def __init__(
+        self,
+        n_samples: int = 100,
+        temporal_covariates_n_features: int = 5,
+        temporal_covariates_max_len: int = 20,
+        temporal_covariates_missing_prob: float = 0.1,
+        static_covariates_n_features: int = 3,
+        static_covariates_missing_prob: float = 0.0,
+        temporal_targets_n_features: int = 2,
+        temporal_targets_n_categories: Optional[int] = None,
+        random_state: int = 12345,
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+
+        self.n_samples = n_samples
+        self.temporal_covariates_n_features = temporal_covariates_n_features
+        self.temporal_covariates_max_len = temporal_covariates_max_len
+        self.temporal_covariates_missing_prob = temporal_covariates_missing_prob
+        self.static_covariates_n_features = static_covariates_n_features
+        self.static_covariates_missing_prob = static_covariates_missing_prob
+        self.temporal_targets_n_features = temporal_targets_n_features
+        self.temporal_targets_n_categories = temporal_targets_n_categories
+        self.random_state = random_state
+
+    @staticmethod
+    def url() -> None:
+        return None
+
+    @staticmethod
+    def dataset_dir() -> None:
+        return None
+
+    def load(self, **kwargs) -> dataset.TemporalPredictionDataset:
+        clv_dataset = dummy_dataset(
+            n_samples=self.n_samples,
+            temporal_covariates_n_features=self.temporal_covariates_n_features,
+            temporal_covariates_max_len=self.temporal_covariates_max_len,
+            temporal_covariates_missing_prob=self.temporal_covariates_missing_prob,
+            static_covariates_n_features=self.static_covariates_n_features,
+            temporal_targets_n_features=self.temporal_targets_n_features,
+            temporal_targets_n_categories=self.temporal_targets_n_categories,
+            random_seed=self.random_state,
+            # No temporal treatments features in this case:
+            temporal_treatments_n_features=0,
+            temporal_treatments_n_categories=None,
+        )
+        data = clairvoyance2_dataset_to_tempor_dataset(clv_dataset)
+        data = cast(dataset.TemporalPredictionDataset, data)
+        return data
+
+
+# TODO: Docstring.
+class DummyTemporalTreatmentEffectsDataLoader(dataloader.TemporalTreatmentEffectsDataLoader):
+    def __init__(
+        self,
+        n_samples: int = 100,
+        temporal_covariates_n_features: int = 5,
+        temporal_covariates_max_len: int = 20,
+        temporal_covariates_missing_prob: float = 0.1,
+        static_covariates_n_features: int = 3,
+        static_covariates_missing_prob: float = 0.0,
+        temporal_targets_n_features: int = 2,
+        temporal_targets_n_categories: Optional[int] = None,
+        temporal_treatments_n_features: int = 2,
+        temporal_treatments_n_categories: Optional[int] = None,
+        random_state: int = 12345,
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+
+        self.n_samples = n_samples
+        self.temporal_covariates_n_features = temporal_covariates_n_features
+        self.temporal_covariates_max_len = temporal_covariates_max_len
+        self.temporal_covariates_missing_prob = temporal_covariates_missing_prob
+        self.static_covariates_n_features = static_covariates_n_features
+        self.static_covariates_missing_prob = static_covariates_missing_prob
+        self.temporal_targets_n_features = temporal_targets_n_features
+        self.temporal_targets_n_categories = temporal_targets_n_categories
+        self.temporal_treatments_n_features = temporal_treatments_n_features
+        self.temporal_treatments_n_categories = temporal_treatments_n_categories
+        self.random_state = random_state
+
+    @staticmethod
+    def url() -> None:
+        return None
+
+    @staticmethod
+    def dataset_dir() -> None:
+        return None
+
+    def load(self, **kwargs) -> dataset.TemporalTreatmentEffectsDataset:
+        clv_dataset = dummy_dataset(
+            n_samples=self.n_samples,
+            temporal_covariates_n_features=self.temporal_covariates_n_features,
+            temporal_covariates_max_len=self.temporal_covariates_max_len,
+            temporal_covariates_missing_prob=self.temporal_covariates_missing_prob,
+            static_covariates_n_features=self.static_covariates_n_features,
+            temporal_targets_n_features=self.temporal_targets_n_features,
+            temporal_targets_n_categories=self.temporal_targets_n_categories,
+            random_seed=self.random_state,
+            # There are treatments features in this case:
+            temporal_treatments_n_features=self.temporal_treatments_n_features,
+            temporal_treatments_n_categories=self.temporal_treatments_n_categories,
+        )
+        data = clairvoyance2_dataset_to_tempor_dataset(clv_dataset)
+        data = cast(dataset.TemporalTreatmentEffectsDataset, data)
+        return data
diff --git a/src/tempor/utils/dataloaders/google_stocks.py b/src/tempor/utils/dataloaders/google_stocks.py
index 39af9b85..21b99187 100644
--- a/src/tempor/utils/dataloaders/google_stocks.py
+++ b/src/tempor/utils/dataloaders/google_stocks.py
@@ -11,8 +11,8 @@
 
 # TODO: Docstring to explain the dataset.
 class GoogleStocksDataLoader(dataloader.OneOffPredictionDataLoader):
-    def __init__(self, *args, seq_len: int = 10, **kwargs) -> None:
-        super().__init__(*args, **kwargs)
+    def __init__(self, seq_len: int = 10, **kwargs) -> None:
+        super().__init__(**kwargs)
 
         self.seq_len = seq_len
         self.df_path = Path(self.dataset_dir()) / "goog.csv"
@@ -37,7 +37,7 @@ def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
 
         # Flip the data to make chronological data
         df = pd.DataFrame(df.values[::-1], columns=df.columns)
-        T = pd.to_datetime(df["Date"], infer_datetime_format=True).astype(np.int64).astype(np.float64) / 10**9
+        T = pd.to_datetime(df["Date"]).astype(np.int64).astype(np.float64) / 10**9
         T = pd.Series(MinMaxScaler().fit_transform(T.values.reshape(-1, 1)).squeeze())  # pyright: ignore
 
         df = df.drop(columns=["Date"])
diff --git a/src/tempor/utils/dataloaders/pbc.py b/src/tempor/utils/dataloaders/pbc.py
index b98a5c7b..28ce8b10 100644
--- a/src/tempor/utils/dataloaders/pbc.py
+++ b/src/tempor/utils/dataloaders/pbc.py
@@ -12,9 +12,9 @@
 
 # TODO: Docstring to explain the dataset.
 class PBCDataLoader(dataloader.TimeToEventAnalysisDataLoader):
-    def __init__(self, *args, **kwargs) -> None:
+    def __init__(self, **kwargs) -> None:
         self.datafile_path = os.path.join(self.dataset_dir(), "pbc2.csv")
-        super().__init__(*args, **kwargs)
+        super().__init__(**kwargs)
 
     @staticmethod
     def dataset_dir() -> str:
diff --git a/src/tempor/utils/dataloaders/pkpd.py b/src/tempor/utils/dataloaders/pkpd.py
new file mode 100644
index 00000000..e74c56cc
--- /dev/null
+++ b/src/tempor/utils/dataloaders/pkpd.py
@@ -0,0 +1,46 @@
+from typing import cast
+
+from clairvoyance2.datasets.simulated.simple_pkpd import simple_pkpd_dataset
+
+from tempor.data import dataloader, dataset
+from tempor.data.clv2conv import clairvoyance2_dataset_to_tempor_dataset
+
+
+# TODO: Docstring.
+class PKPDDataLoader(dataloader.OneOffTreatmentEffectsDataLoader):
+    def __init__(
+        self,
+        n_timesteps: int = 10,
+        time_index_treatment_event: int = 7,
+        n_control_samples: int = 20,
+        n_treated_samples: int = 20,
+        random_state: int = 100,
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+
+        self.n_timesteps = n_timesteps
+        self.time_index_treatment_event = time_index_treatment_event
+        self.n_control_samples = n_control_samples
+        self.n_treated_samples = n_treated_samples
+        self.random_state = random_state
+
+    @staticmethod
+    def url() -> None:
+        return None
+
+    @staticmethod
+    def dataset_dir() -> None:
+        return None
+
+    def load(self, **kwargs) -> dataset.OneOffTreatmentEffectsDataset:
+        clv_dataset = simple_pkpd_dataset(
+            n_timesteps=self.n_timesteps,
+            time_index_treatment_event=self.time_index_treatment_event,
+            n_control_samples=self.n_control_samples,
+            n_treated_samples=self.n_treated_samples,
+            seed=self.random_state,
+        )
+        data = clairvoyance2_dataset_to_tempor_dataset(clv_dataset)
+        data = cast(dataset.OneOffTreatmentEffectsDataset, data)
+        return data
diff --git a/src/tempor/utils/dataloaders/sine.py b/src/tempor/utils/dataloaders/sine.py
index e94cd9f8..e6c50103 100644
--- a/src/tempor/utils/dataloaders/sine.py
+++ b/src/tempor/utils/dataloaders/sine.py
@@ -7,7 +7,6 @@
 class SineDataLoader(dataloader.OneOffPredictionDataLoader):
     def __init__(
         self,
-        *args,
         no: int = 100,
         seq_len: int = 10,
         temporal_dim: int = 5,
@@ -17,6 +16,7 @@ def __init__(
         miss_ratio: float = 0.1,
         static_scale: float = 1.0,
         ts_scale: float = 1.0,
+        random_state: int = 42,
         **kwargs,
     ) -> None:
         """Sine data generation.
@@ -41,8 +41,10 @@ def __init__(
                 The scaling factor to apply to the static data. Defaults to ``1.0``.
             ts_scale (float, optional):
                 The scaling factor to apply to the time series data. Defaults to ``1.0``.
+            random_state (int, optional):
+                The random seed to set for `numpy.random.seed`. Defaults to ``42``.
         """
-        super().__init__(*args, **kwargs)
+        super().__init__(**kwargs)
 
         self.no = no
         self.seq_len = seq_len
@@ -53,6 +55,7 @@ def __init__(
         self.miss_ratio = miss_ratio
         self.static_scale = static_scale
         self.ts_scale = ts_scale
+        self.random_state = random_state
 
     @staticmethod
     def url() -> None:
@@ -64,9 +67,10 @@ def dataset_dir() -> None:
 
     def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
         # Initialize the output.
+        np.random.seed(self.random_state)
 
         static_data = pd.DataFrame(np.random.rand(self.no, self.static_dim) * self.static_scale)
-        static_data["sample_idx"] = [str(i) for i in range(self.no)]
+        static_data["sample_idx"] = [i for i in range(self.no)]
         static_data.set_index(keys=["sample_idx"], drop=True, inplace=True)
 
         static_data.columns = static_data.columns.astype(str)
@@ -77,7 +81,7 @@ def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
         temporal_data = []
 
         outcome = pd.DataFrame(np.random.randint(0, 2, self.no))
-        outcome["sample_idx"] = [str(i) for i in range(self.no)]
+        outcome["sample_idx"] = [i for i in range(self.no)]
         outcome.columns = outcome.columns.astype(str)
 
         outcome.set_index(keys=["sample_idx"], drop=True, inplace=True)
@@ -113,7 +117,7 @@ def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
                     local_data.loc[local_data.sample(frac=self.miss_ratio).index, col] = np.nan
 
             # Stack the generated data:
-            local_data["sample_idx"] = str(i)
+            local_data["sample_idx"] = i
             local_data["time_idx"] = list(range(seq_len))
             temporal_data.append(local_data)
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2f570923..6a428cc1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -57,9 +57,19 @@ jobs:
           pip install --upgrade pip
           pip install -r prereq.txt
           pip install .[dev]
+      - name: Test with pytest (xdist)
+        if: matrix.os != 'ubuntu-latest'
+        run: pytest -vvvx -n auto -m "not slow and not extra" --durations=50
       - name: Test with pytest
-        run: pytest -vvvx -m "not slow" --durations=50
+        # Avoid xdist on 'ubuntu-latest', as this has worse performance.
+        if: matrix.os == 'ubuntu-latest'
+        run: pytest -vvvx -m "not slow and not extra" --durations=50
+      - name: Run doctests with pytest (xdist)
+        if: matrix.os != 'ubuntu-latest'
+        run: pytest -vvvx -n auto src/ --doctest-modules --durations=50
       - name: Run doctests with pytest
+        # Avoid xdist on 'ubuntu-latest', as this has worse performance.
+        if: matrix.os == 'ubuntu-latest'
         run: pytest -vvvx src/ --doctest-modules --durations=50
       - name: Test notebooks run without error
         run: python tests/nb_eval.py --nb_dir ./
diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml
new file mode 100644
index 00000000..22140292
--- /dev/null
+++ b/.github/workflows/test_full.yml
@@ -0,0 +1,71 @@
+name: Run full set of tests (slow)
+
+on:
+  schedule:
+    - cron: "17 4 * * 3"
+  workflow_dispatch:
+
+jobs:
+  # Lint code with pre-commit.
+  Linter:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: [3.8]
+        os: [ubuntu-latest]
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          pip install --upgrade pip
+          pip install pre-commit
+      - name: Run pre-commit validation
+        run: pre-commit run --all-files
+
+  # Run tests with pytest.
+  Tests:
+    needs: [Linter]
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
+        os: [macos-latest, ubuntu-latest, windows-latest]
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+      # Prevents scheduled run being disabled after no commit
+      # for some time (creates a dummy commit):
+      - uses: gautamkrishnar/keepalive-workflow@v1
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: "pip" # Caching pip dependencies.
+      - name: Install dependencies
+        run: |
+          pip install --upgrade pip
+          pip install -r prereq.txt
+          pip install .[dev]
+      - name: Test with pytest (xdist)
+        if: matrix.os != 'ubuntu-latest'
+        run: pytest -vvvx -n auto --durations=50
+      - name: Test with pytest
+        # Avoid xdist on 'ubuntu-latest', as this has worse performance.
+        if: matrix.os == 'ubuntu-latest'
+        run: pytest -vvvx -m --durations=50
+      - name: Run doctests with pytest (xdist)
+        if: matrix.os != 'ubuntu-latest'
+        run: pytest -vvvx -n auto src/ --doctest-modules --durations=50
+      - name: Run doctests with pytest
+        # Avoid xdist on 'ubuntu-latest', as this has worse performance.
+        if: matrix.os == 'ubuntu-latest'
+        run: pytest -vvvx src/ --doctest-modules --durations=50
+      - name: Test notebooks run without error
+        run: python tests/nb_eval.py --nb_dir ./
diff --git a/pytest.ini b/pytest.ini
index bf3896a5..aff23131 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -19,7 +19,11 @@ addopts =
 norecursedirs = *.egg .* _darcs build CVS dist node_modules venv dev
 testpaths =
     tests
+filterwarnings =
+    # pytest-xdist -related warning, ignore:
+    ignore:.*rsyncdir.*:DeprecationWarning
 # Use pytest markers to select/deselect specific tests
 markers =
     internet: requires internet access
-    slow: tests is slow (~1s+)
+    slow: tests is slow
+    extra: additional test which may be skipped for minimal testing
diff --git a/tests/benchmarks/test_benchmark.py b/tests/benchmarks/test_benchmark.py
index 485ebd87..5375e6f3 100644
--- a/tests/benchmarks/test_benchmark.py
+++ b/tests/benchmarks/test_benchmark.py
@@ -1,5 +1,7 @@
 from typing import Callable
 
+import pytest
+
 from tempor.benchmarks import (
     benchmark_models,
     classifier_supported_metrics,
@@ -8,30 +10,36 @@
 )
 from tempor.plugins import plugin_loader
 from tempor.plugins.pipeline import Pipeline
-from tempor.utils.dataloaders import (
-    GoogleStocksDataLoader,
-    PBCDataLoader,
-    SineDataLoader,
-)
+
+N_ITER = 5
+
+TEST_ON_DATASETS_CLASSIFIER = ["sine_data_small"]
+TEST_ON_DATASETS_REGRESSOR = ["google_stocks_data_small"]
+TEST_ON_DATASETS_TIME_TO_EVENT = ["pbc_data_small"]
+
+PREDICTOR_CLASSIFICATION = "prediction.one_off.classification.nn_classifier"
+PREDICTOR_REGRESSION = "prediction.one_off.regression.nn_regressor"
+PREDICTOR_TIME_TO_EVENT = "time_to_event.dynamic_deephit"
 
 
-def test_classifier_benchmark() -> None:
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_CLASSIFIER)
+def test_classifier_benchmark(data: str, request: pytest.FixtureRequest) -> None:
     testcases = [
         (
             "pipeline1",
             Pipeline(
                 [
-                    "preprocessing.imputation.ffill",
-                    "classification.nn_classifier",
+                    "preprocessing.imputation.temporal.ffill",
+                    PREDICTOR_CLASSIFICATION,
                 ]
-            )({"nn_classifier": {"n_iter": 10}}),
+            )({"nn_classifier": {"n_iter": N_ITER}}),
         ),
         (
             "plugin1",
-            plugin_loader.get("classification.nn_classifier", n_iter=10),
+            plugin_loader.get(PREDICTOR_CLASSIFICATION, n_iter=N_ITER),
         ),
     ]
-    dataset = SineDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     aggr_score, per_test_score = benchmark_models(
         task_type="classification",
@@ -52,23 +60,24 @@ def test_classifier_benchmark() -> None:
             assert metric in per_test_score[testcase].index
 
 
-def test_regressor_benchmark() -> None:
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_REGRESSOR)
+def test_regressor_benchmark(data: str, request: pytest.FixtureRequest) -> None:
     testcases = [
         (
             "pipeline1",
             Pipeline(
                 [
-                    "preprocessing.imputation.ffill",
-                    "regression.nn_regressor",
+                    "preprocessing.imputation.temporal.ffill",
+                    PREDICTOR_REGRESSION,
                 ]
-            )({"nn_regressor": {"n_iter": 10}}),
+            )({"nn_regressor": {"n_iter": N_ITER}}),
         ),
         (
             "plugin1",
-            plugin_loader.get("regression.nn_regressor", n_iter=10),
+            plugin_loader.get(PREDICTOR_REGRESSION, n_iter=N_ITER),
         ),
     ]
-    dataset = GoogleStocksDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     aggr_score, per_test_score = benchmark_models(
         task_type="regression",
@@ -89,23 +98,26 @@ def test_regressor_benchmark() -> None:
             assert metric in per_test_score[testcase].index
 
 
-def test_time_to_event_benchmark(get_event0_time_percentiles: Callable) -> None:
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_TIME_TO_EVENT)
+def test_time_to_event_benchmark(
+    get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest
+) -> None:
     testcases = [
         (
             "pipeline1",
             Pipeline(
                 [
-                    "preprocessing.imputation.ffill",
-                    "time_to_event.dynamic_deephit",
+                    "preprocessing.imputation.temporal.ffill",
+                    PREDICTOR_TIME_TO_EVENT,
                 ]
-            )({"dynamic_deephit": {"n_iter": 10}}),
+            )({"dynamic_deephit": {"n_iter": N_ITER}}),
         ),
         (
             "plugin1",
-            plugin_loader.get("time_to_event.dynamic_deephit", n_iter=10),
+            plugin_loader.get(PREDICTOR_TIME_TO_EVENT, n_iter=N_ITER),
         ),
     ]
-    dataset = PBCDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
diff --git a/tests/benchmarks/test_evaluation.py b/tests/benchmarks/test_evaluation.py
index f144b3cc..2f6c1942 100644
--- a/tests/benchmarks/test_evaluation.py
+++ b/tests/benchmarks/test_evaluation.py
@@ -13,33 +13,39 @@
 )
 from tempor.plugins import plugin_loader
 from tempor.plugins.pipeline import Pipeline
-from tempor.utils.dataloaders import (
-    GoogleStocksDataLoader,
-    PBCDataLoader,
-    SineDataLoader,
-)
+
+N_ITER = 5
+
+TEST_ON_DATASETS_CLASSIFIER = ["sine_data_small"]
+TEST_ON_DATASETS_REGRESSOR = ["google_stocks_data_small"]
+TEST_ON_DATASETS_TIME_TO_EVENT = ["pbc_data_small"]
+
+PREDICTOR_CLASSIFICATION = "prediction.one_off.classification.nn_classifier"
+PREDICTOR_REGRESSION = "prediction.one_off.regression.nn_regressor"
+PREDICTOR_TIME_TO_EVENT = "time_to_event.dynamic_deephit"
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_CLASSIFIER)
 @pytest.mark.parametrize(
     "model_template",
     [
         Pipeline(
             [
-                "preprocessing.imputation.ffill",
-                "classification.nn_classifier",
+                "preprocessing.imputation.temporal.ffill",
+                PREDICTOR_CLASSIFICATION,
             ]
-        )({"nn_classifier": {"n_iter": 50}}),
+        )({"nn_classifier": {"n_iter": N_ITER}}),
         Pipeline(
             [
-                "classification.nn_classifier",
+                PREDICTOR_CLASSIFICATION,
             ]
-        )({"nn_classifier": {"n_iter": 50}}),
-        plugin_loader.get("classification.nn_classifier", n_iter=50),
+        )({"nn_classifier": {"n_iter": N_ITER}}),
+        plugin_loader.get(PREDICTOR_CLASSIFICATION, n_iter=N_ITER),
     ],
 )
 @pytest.mark.parametrize("n_splits", [2])
-def test_classifier_evaluation(model_template: Any, n_splits: int) -> None:
-    dataset = SineDataLoader().load()
+def test_classifier_evaluation(model_template: Any, n_splits: int, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
 
     scores = evaluate_classifier(model_template, dataset, n_splits=n_splits, seed=0)
 
@@ -50,36 +56,38 @@ def test_classifier_evaluation(model_template: Any, n_splits: int) -> None:
         assert metric in scores.index
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_CLASSIFIER)
 @pytest.mark.parametrize("n_splits", [-1, 0, 1])
-def test_classifier_evaluation_fail(n_splits: int) -> None:
-    dataset = SineDataLoader().load()
+def test_classifier_evaluation_fail(n_splits: int, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
 
     with pytest.raises(ValueError):
         evaluate_classifier(
-            plugin_loader.get("classification.nn_classifier", n_iter=50), dataset, n_splits=n_splits, seed=0
+            plugin_loader.get(PREDICTOR_CLASSIFICATION, n_iter=N_ITER), dataset, n_splits=n_splits, seed=0
         )
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_REGRESSOR)
 @pytest.mark.parametrize(
     "model_template",
     [
         Pipeline(
             [
-                "preprocessing.imputation.ffill",
-                "regression.nn_regressor",
+                "preprocessing.imputation.temporal.ffill",
+                PREDICTOR_REGRESSION,
             ]
-        )({"nn_regressor": {"n_iter": 50}}),
+        )({"nn_regressor": {"n_iter": N_ITER}}),
         Pipeline(
             [
-                "regression.nn_regressor",
+                PREDICTOR_REGRESSION,
             ]
-        )({"nn_regressor": {"n_iter": 50}}),
-        plugin_loader.get("regression.nn_regressor", n_iter=50),
+        )({"nn_regressor": {"n_iter": N_ITER}}),
+        plugin_loader.get(PREDICTOR_REGRESSION, n_iter=N_ITER),
     ],
 )
 @pytest.mark.parametrize("n_splits", [2])
-def test_regressor_evaluation(model_template: Any, n_splits: int) -> None:
-    dataset = GoogleStocksDataLoader().load()
+def test_regressor_evaluation(model_template: Any, n_splits: int, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
 
     scores = evaluate_regressor(model_template, dataset, n_splits=n_splits, seed=0)
 
@@ -90,34 +98,38 @@ def test_regressor_evaluation(model_template: Any, n_splits: int) -> None:
         assert metric in scores.index
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_REGRESSOR)
 @pytest.mark.parametrize("n_splits", [-1, 0, 1])
-def test_regressor_evaluation_fail(n_splits: int) -> None:
-    dataset = SineDataLoader().load()
+def test_regressor_evaluation_fail(n_splits: int, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
 
     with pytest.raises(ValueError):
-        evaluate_regressor(plugin_loader.get("regression.nn_regressor", n_iter=50), dataset, n_splits=n_splits, seed=0)
+        evaluate_regressor(plugin_loader.get(PREDICTOR_REGRESSION, n_iter=N_ITER), dataset, n_splits=n_splits, seed=0)
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_TIME_TO_EVENT)
 @pytest.mark.parametrize(
     "model_template",
     [
         Pipeline(
             [
-                "preprocessing.imputation.ffill",
-                "time_to_event.dynamic_deephit",
+                "preprocessing.imputation.temporal.ffill",
+                PREDICTOR_TIME_TO_EVENT,
             ]
-        )({"dynamic_deephit": {"n_iter": 10}}),
+        )({"dynamic_deephit": {"n_iter": N_ITER}}),
         Pipeline(
             [
-                "time_to_event.dynamic_deephit",
+                PREDICTOR_TIME_TO_EVENT,
             ]
-        )({"dynamic_deephit": {"n_iter": 10}}),
-        plugin_loader.get("time_to_event.dynamic_deephit", n_iter=10),
+        )({"dynamic_deephit": {"n_iter": N_ITER}}),
+        plugin_loader.get(PREDICTOR_TIME_TO_EVENT, n_iter=N_ITER),
     ],
 )
 @pytest.mark.parametrize("n_splits", [2])
-def test_time_to_event_evaluation(model_template: Any, n_splits: int, get_event0_time_percentiles: Callable) -> None:
-    dataset = PBCDataLoader().load()
+def test_time_to_event_evaluation(
+    model_template: Any, n_splits: int, get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest
+) -> None:
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -130,15 +142,18 @@ def test_time_to_event_evaluation(model_template: Any, n_splits: int, get_event0
         assert metric in scores.index
 
 
+@pytest.mark.parametrize("data", TEST_ON_DATASETS_TIME_TO_EVENT)
 @pytest.mark.parametrize("n_splits", [-1, 0, 1])
-def test_time_to_event_evaluation_fail(n_splits: int, get_event0_time_percentiles: Callable) -> None:
-    dataset = PBCDataLoader().load()
+def test_time_to_event_evaluation_fail(
+    n_splits: int, get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest
+) -> None:
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
     with pytest.raises(ValueError):
         evaluate_time_to_event(
-            plugin_loader.get("time_to_event.nn_regressor", n_iter=50),
+            plugin_loader.get("time_to_event.nn_regressor", n_iter=N_ITER),
             dataset,
             n_splits=n_splits,
             seed=0,
diff --git a/tests/conftest.py b/tests/conftest.py
index 478cc008..6eac9548 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,23 +2,11 @@
 from types import ModuleType
 from typing import TYPE_CHECKING, Any, List, Tuple
 
-if TYPE_CHECKING:
-    from tempor.data.dataset import TimeToEventAnalysisDataset
-
 import numpy as np
 import pytest
 
-# --- Reusable functions. ---
-
-
-@pytest.fixture
-def get_event0_time_percentiles():
-    def func(dataset: "TimeToEventAnalysisDataset", horizon_percentiles: List):
-        event0_times = dataset.predictive.targets.split_as_two_dataframes()[0].to_numpy().reshape((-1,))
-        return np.quantile(event0_times, horizon_percentiles).tolist()
-
-    return func
-
+if TYPE_CHECKING:
+    from tempor.data import dataset  # nosec B101
 
 # --- Test utilities. ---
 
@@ -93,3 +81,257 @@ def teardown():
         request.addfinalizer(teardown)
 
     return _patch_module
+
+
+# --- Reusable utility functions. ---
+
+
+@pytest.fixture
+def get_event0_time_percentiles():
+    def func(data: "dataset.TimeToEventAnalysisDataset", horizon_percentiles: List):
+        event0_times = data.predictive.targets.split_as_two_dataframes()[0].to_numpy().reshape((-1,))
+        return np.quantile(event0_times, horizon_percentiles).tolist()
+
+    return func
+
+
+# --- Reusable datasets. ---
+
+# Sine data: full.
+@pytest.fixture(scope="session")
+def _sine_data_full():
+    from tempor.utils import dataloaders
+
+    return dataloaders.SineDataLoader(no=100, temporal_dim=5, random_state=42).load()
+
+
+@pytest.fixture(scope="function")
+def sine_data_full(_sine_data_full: "dataset.OneOffPredictionDataset") -> "dataset.OneOffPredictionDataset":
+    # Give each test a copy, just in case.
+    return copy.deepcopy(_sine_data_full)
+
+
+# Sine data: full, missing.
+@pytest.fixture(scope="session")
+def _sine_data_missing_full():
+    from tempor.utils import dataloaders
+
+    return dataloaders.SineDataLoader(no=100, with_missing=True, temporal_dim=5, random_state=42).load()
+
+
+@pytest.fixture(scope="function")
+def sine_data_missing_full(
+    _sine_data_missing_full: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    return copy.deepcopy(_sine_data_missing_full)
+
+
+# Sine data: small.
+@pytest.fixture(scope="session")
+def _sine_data_small(_sine_data_full: "dataset.OneOffPredictionDataset") -> "dataset.OneOffPredictionDataset":
+    data, _ = copy.deepcopy(_sine_data_full).train_test_split(
+        train_size=6,
+        stratify=_sine_data_full.predictive.targets.numpy(),
+        random_state=42,
+    )
+    return data
+
+
+@pytest.fixture(scope="function")
+def sine_data_small(_sine_data_small: "dataset.OneOffPredictionDataset") -> "dataset.OneOffPredictionDataset":
+    return copy.deepcopy(_sine_data_small)
+
+
+# Sine data: small, missing.
+@pytest.fixture(scope="session")
+def _sine_data_missing_small(
+    _sine_data_missing_full: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    data, _ = copy.deepcopy(_sine_data_missing_full).train_test_split(
+        train_size=6,
+        stratify=_sine_data_missing_full.predictive.targets.numpy(),
+        random_state=42,
+    )
+    return data
+
+
+@pytest.fixture(scope="function")
+def sine_data_missing_small(
+    _sine_data_missing_small: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    return copy.deepcopy(_sine_data_missing_small)
+
+
+# Sine data: temporal, full.
+@pytest.fixture(scope="session")
+def _sine_data_temporal(_sine_data_full: "dataset.OneOffPredictionDataset") -> "dataset.TemporalPredictionDataset":
+    from tempor.data import dataset
+
+    raw_data, _ = copy.deepcopy(_sine_data_full).train_test_split(
+        train_size=6,
+        stratify=_sine_data_full.predictive.targets.numpy(),
+        random_state=42,
+    )
+    data = dataset.TemporalPredictionDataset(
+        time_series=raw_data.time_series.dataframe(),
+        static=raw_data.static.dataframe(),  # type: ignore
+        targets=raw_data.time_series.dataframe().copy(),
+    )
+    return data
+
+
+# Sine data: temporal, small.
+@pytest.fixture(scope="function")
+def sine_data_temporal(_sine_data_temporal: "dataset.TemporalPredictionDataset") -> "dataset.TemporalPredictionDataset":
+    return copy.deepcopy(_sine_data_temporal)
+
+
+@pytest.fixture(scope="session")
+def _sine_data_temporal_small(
+    _sine_data_temporal: "dataset.TemporalPredictionDataset",
+) -> "dataset.TemporalPredictionDataset":
+    data = copy.deepcopy(_sine_data_temporal)[:6]
+    return data
+
+
+@pytest.fixture(scope="function")
+def sine_data_temporal_small(
+    _sine_data_temporal_small: "dataset.TemporalPredictionDataset",
+) -> "dataset.TemporalPredictionDataset":
+    return copy.deepcopy(_sine_data_temporal_small)
+
+
+# Google stocks data: full.
+@pytest.fixture(scope="session")
+def _google_stocks_data_full() -> "dataset.OneOffPredictionDataset":
+    from tempor.utils import dataloaders
+
+    data = dataloaders.GoogleStocksDataLoader(seq_len=50).load()
+    return data
+
+
+@pytest.fixture(scope="function")
+def google_stocks_data_full(
+    _google_stocks_data_full: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    return copy.deepcopy(_google_stocks_data_full)
+
+
+# Google stocks data: small.
+@pytest.fixture(scope="session")
+def _google_stocks_data_small(
+    _google_stocks_data_full: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    data = copy.deepcopy(_google_stocks_data_full)[:6]
+    return data
+
+
+@pytest.fixture(scope="function")
+def google_stocks_data_small(
+    _google_stocks_data_small: "dataset.OneOffPredictionDataset",
+) -> "dataset.OneOffPredictionDataset":
+    return copy.deepcopy(_google_stocks_data_small)
+
+
+# PBC data: full.
+@pytest.fixture(scope="session")
+def _pbc_data_full() -> "dataset.TimeToEventAnalysisDataset":
+    from tempor.utils import dataloaders
+
+    data = dataloaders.PBCDataLoader().load()
+    return data
+
+
+@pytest.fixture(scope="function")
+def pbc_data_full(_pbc_data_full: "dataset.TimeToEventAnalysisDataset") -> "dataset.TimeToEventAnalysisDataset":
+    return copy.deepcopy(_pbc_data_full)
+
+
+# PBC data: small.
+@pytest.fixture(scope="session")
+def _pbc_data_small(_pbc_data_full: "dataset.TimeToEventAnalysisDataset") -> "dataset.TimeToEventAnalysisDataset":
+    si = list(range(len(_pbc_data_full.time_series)))
+    np.random.seed(42)
+    np.random.shuffle(si)
+    _pbc_data_full = copy.deepcopy(_pbc_data_full)[si]
+    data = _pbc_data_full[:90]  # As small as feasible without causing convergence errors.
+    return data
+
+
+@pytest.fixture(scope="function")
+def pbc_data_small(_pbc_data_small: "dataset.TimeToEventAnalysisDataset") -> "dataset.TimeToEventAnalysisDataset":
+    return copy.deepcopy(_pbc_data_small)
+
+
+# Clairvoyance dummy data: full.
+@pytest.fixture(scope="session")
+def _clv_data_full() -> "dataset.TemporalTreatmentEffectsDataset":
+    from tempor.utils.dataloaders import DummyTemporalTreatmentEffectsDataLoader
+
+    return DummyTemporalTreatmentEffectsDataLoader(
+        n_samples=100,
+        temporal_covariates_n_features=5,
+        temporal_covariates_max_len=11,
+        temporal_covariates_missing_prob=0.0,
+        static_covariates_n_features=13,
+        temporal_treatments_n_features=5,
+        temporal_treatments_n_categories=2,
+        temporal_targets_n_features=3,
+        temporal_targets_n_categories=4,
+        random_state=12345,
+    ).load()
+
+
+@pytest.fixture(scope="function")
+def clv_data_full(
+    _clv_data_full: "dataset.TemporalTreatmentEffectsDataset",
+) -> "dataset.TemporalTreatmentEffectsDataset":
+    return copy.deepcopy(_clv_data_full)
+
+
+# Clairvoyance dummy data: small.
+@pytest.fixture(scope="session")
+def _clv_data_small(
+    _clv_data_full: "dataset.TemporalTreatmentEffectsDataset",
+) -> "dataset.TemporalTreatmentEffectsDataset":
+    data = copy.deepcopy(_clv_data_full)[:10]
+    return data
+
+
+@pytest.fixture(scope="function")
+def clv_data_small(
+    _clv_data_small: "dataset.TemporalTreatmentEffectsDataset",
+) -> "dataset.TemporalTreatmentEffectsDataset":
+    return copy.deepcopy(_clv_data_small)
+
+
+# PKPD data: full.
+@pytest.fixture(scope="session")
+def _pkpd_data_full() -> "dataset.OneOffTreatmentEffectsDataset":
+    from tempor.utils.dataloaders import PKPDDataLoader
+
+    return PKPDDataLoader(
+        n_timesteps=30, time_index_treatment_event=25, n_control_samples=50, n_treated_samples=50, random_state=123
+    ).load()
+
+
+@pytest.fixture(scope="function")
+def pkpd_data_full(_pkpd_data_full: "dataset.OneOffTreatmentEffectsDataset") -> "dataset.OneOffTreatmentEffectsDataset":
+    return copy.deepcopy(_pkpd_data_full)
+
+
+# PKPD data: small.
+@pytest.fixture(scope="session")
+def _pkpd_data_small() -> "dataset.OneOffTreatmentEffectsDataset":
+    from tempor.utils.dataloaders import PKPDDataLoader
+
+    return PKPDDataLoader(
+        n_timesteps=6, time_index_treatment_event=3, n_control_samples=4, n_treated_samples=4, random_state=123
+    ).load()
+
+
+@pytest.fixture(scope="function")
+def pkpd_data_small(
+    _pkpd_data_small: "dataset.OneOffTreatmentEffectsDataset",
+) -> "dataset.OneOffTreatmentEffectsDataset":
+    return copy.deepcopy(_pkpd_data_small)
diff --git a/tests/core/test_core_utils.py b/tests/core/test_core_utils.py
index b9c44fab..7a07d2cb 100644
--- a/tests/core/test_core_utils.py
+++ b/tests/core/test_core_utils.py
@@ -1,51 +1,43 @@
-# pylint: disable=redefined-outer-name
-
 import pytest
+from typing_extensions import Literal
 
 from tempor.core import utils
 
 
-@pytest.fixture
-def dummy_version() -> str:
-    return "1.5.3"
-
-
-def test_get_version(dummy_version: str):
-    major, minor, patch = utils.get_version(dummy_version)
-    assert (major, minor, patch) == (1, 5, 3)
-
-
-@pytest.mark.parametrize(
-    "version, compare, expected",
-    [
-        ((1, 6), (1, 5), True),
-        ((1, 5), (1, 5), True),
-        ((1, 4), (1, 5), False),
-        ((2, 4), (1, 5), True),
-        ((2, 5), (1, 5), True),
-        ((2, 6), (1, 5), True),
-        ((0, 6), (1, 5), False),
-        ((0, 5), (1, 5), False),
-        ((0, 4), (1, 5), False),
-    ],
-)
-def test_version_above_incl(version, compare, expected):
-    assert utils.version_above_incl(version=version, above_incl=compare) is expected
-
-
-@pytest.mark.parametrize(
-    "version, compare, expected",
-    [
-        ((1, 6), (1, 5), False),
-        ((1, 5), (1, 5), False),
-        ((1, 4), (1, 5), True),
-        ((2, 4), (1, 5), False),
-        ((2, 5), (1, 5), False),
-        ((2, 6), (1, 5), False),
-        ((0, 6), (1, 5), True),
-        ((0, 5), (1, 5), True),
-        ((0, 4), (1, 5), True),
-    ],
-)
-def test_version_below_excl(version, compare, expected):
-    assert utils.version_below_excl(version=version, below_excl=compare) is expected
+class TestEnsureLiteralMatchesDictKeys:
+    @pytest.mark.parametrize(
+        "lit, d",
+        [
+            (Literal["A", "B", "C"], {"A": None, "B": None, "C": None}),
+            (Literal["A", "B", "C"], {"A": None, "B": None, "C": None}),
+            (Literal["A"], {"A": None}),
+        ],
+    )
+    def test_passes(self, lit, d):
+        utils.ensure_literal_matches_dict_keys(lit, d)
+
+    @pytest.mark.parametrize(
+        "lit, d",
+        [
+            (Literal["A", "B", "C", "D"], {"A": None, "B": None, "C": None}),
+            (Literal["A", "B", "X"], {"A": None, "B": None, "C": None}),
+            (Literal["A", "B"], {"A": None, "B": None, "C": None}),
+            (Literal["A"], {"B": None}),
+            (Literal["A"], dict()),
+        ],
+    )
+    def test_fails(self, lit, d):
+        with pytest.raises(TypeError, match=".*MyLit.*MyDict.*"):
+            utils.ensure_literal_matches_dict_keys(lit, d, literal_name="MyLit", dict_name="MyDict")
+
+    @pytest.mark.parametrize(
+        "lit, d, expect",
+        [
+            (Literal["A", "B", "C", "D"], {"A": None, "B": None, "C": None}, set(["D"])),
+            (Literal["A", "C", "D"], {"A": None, "B": None, "C": None, "D": None}, set(["D"])),
+            (Literal["A", "C", "D", "X"], {"A": None, "B": None, "C": None, "D": None}, set(["D", "X"])),
+        ],
+    )
+    def test_fails_difference(self, lit, d, expect):
+        with pytest.raises(TypeError, match=f".*{list(str(expect))}.*"):
+            utils.ensure_literal_matches_dict_keys(lit, d)
diff --git a/tests/data/test_pandera_utils.py b/tests/data/test_pandera_utils.py
new file mode 100644
index 00000000..a3299528
--- /dev/null
+++ b/tests/data/test_pandera_utils.py
@@ -0,0 +1,164 @@
+import pandas as pd
+import pandera as pa
+import pytest
+
+from tempor.data.pandera_utils import UnionDtype
+
+
+class TestUnionDtype:
+    @pytest.mark.parametrize(
+        "union, df",
+        [
+            (
+                UnionDtype[pa.Int, pa.String],  # type: ignore
+                pd.DataFrame(
+                    {
+                        "a": [1, 2, 3, 4],
+                        "b": ["aa", "bb", "cc", "dd"],
+                    }
+                ),
+            ),
+            (
+                UnionDtype[pa.Int, pa.Float],  # type: ignore
+                pd.DataFrame(
+                    {
+                        "a": [1, 2, 3, 4],
+                        "b": [1.1, 2.2, 3.3, 4.4],
+                    }
+                ),
+            ),
+            (
+                UnionDtype[pa.Category],  # type: ignore
+                pd.DataFrame(
+                    {
+                        "a": pd.Series(["x", "y", "x"], dtype="category"),
+                        "b": pd.Series(["a", "a", "b"], dtype="category"),
+                    }
+                ),
+            ),
+        ],
+    )
+    def test_validation_passes_dataframe(self, union, df):
+        pa.DataFrameSchema(
+            columns={
+                # Using regex here to apply the same union dtype to all dataframe columns.
+                ".*": pa.Column(
+                    dtype=union,
+                    coerce=False,
+                    regex=True,
+                )
+            }
+        ).validate(df)
+
+    @pytest.mark.parametrize(
+        "union, df",
+        [
+            (
+                UnionDtype[pa.Int, pa.Float],  # type: ignore
+                pd.DataFrame(
+                    {
+                        "a": [1, 2, 3, 4],
+                        "b": ["aa", "bb", "cc", "dd"],
+                    }
+                ),
+            ),
+            (
+                UnionDtype[pa.Category],  # type: ignore
+                pd.DataFrame(
+                    {
+                        "a": [1, 2, 3, 4],
+                        "b": [1.1, 2.2, 3.3, 4.4],
+                    }
+                ),
+            ),
+        ],
+    )
+    def test_validation_fails_dataframe(self, union, df):
+        with pytest.raises(
+            pa.errors.SchemaError,  # pyright: ignore
+            match=".*" + union.name.replace("[", r"\[").replace("]", r"\]") + ".*",
+        ):
+            pa.DataFrameSchema(
+                columns={
+                    # Using regex here to apply the same union dtype to all dataframe columns.
+                    ".*": pa.Column(
+                        dtype=union,
+                        coerce=False,
+                        regex=True,
+                    )
+                }
+            ).validate(df)
+
+    @pytest.mark.parametrize(
+        "union, series",
+        [
+            (
+                UnionDtype[pa.Int, pa.String],  # type: ignore
+                pd.Series(["aa", "bb", "cc"]),
+            ),
+            (
+                UnionDtype[pa.Int, pa.String],  # type: ignore
+                pd.Series([1, 2, 3]),
+            ),
+            (
+                UnionDtype[pa.Int],  # type: ignore
+                pd.Series([1, 2, 3]),
+            ),
+            (
+                UnionDtype[pa.Float],  # type: ignore
+                pd.Series([1.0, 2.0, 3.0]),
+            ),
+            (
+                UnionDtype[pa.Category],  # type: ignore
+                pd.Series(["a", "a", "b"], dtype="category"),
+            ),
+        ],
+    )
+    def test_validation_passes_series(self, union, series):
+        pa.SeriesSchema(union, coerce=False).validate(series)
+
+    @pytest.mark.parametrize(
+        "union, series",
+        [
+            (
+                UnionDtype[pa.Int],  # type: ignore
+                pd.Series(["aa", "bb", "cc"]),
+            ),
+        ],
+    )
+    def test_validation_fails_series(self, union, series):
+        with pytest.raises(
+            pa.errors.SchemaError,  # pyright: ignore
+            match=".*" + union.name.replace("[", r"\[").replace("]", r"\]") + ".*",
+        ):
+            pa.SeriesSchema(union, coerce=False).validate(series)
+
+    def test_fails_types_not_specified(self):
+        with pytest.raises(TypeError):
+            pa.SeriesSchema(UnionDtype, coerce=False).validate(pd.Series([1, 2, 3]))
+
+    def test_fails_coerce_not_supported(self):
+        with pytest.raises((TypeError, pa.errors.SchemaError)):  # pyright: ignore
+            pa.SeriesSchema(UnionDtype[pa.Int], coerce=True).validate(pd.Series(["1", "2", "3"]))
+
+    @pytest.mark.parametrize(
+        "union, dtype",
+        [
+            (UnionDtype[pa.Int, pa.String], pa.String()),  # type: ignore
+            (UnionDtype[pa.Int, pa.String], pa.Int()),  # type: ignore
+            (UnionDtype[pa.Category], pa.Category()),  # type: ignore
+        ],
+    )
+    def test_validation_passes_dtype_directly(self, union, dtype):
+        assert union().check(pandera_dtype=dtype, data_container=None) is True
+
+    @pytest.mark.parametrize(
+        "union, dtype",
+        [
+            (UnionDtype[pa.Int, pa.String], pa.Category()),  # type: ignore
+            (UnionDtype[pa.Int, pa.Float], pa.String()),  # type: ignore
+            (UnionDtype[pa.Category], pa.Int()),  # type: ignore
+        ],
+    )
+    def test_validation_fails_dtype_directly(self, union, dtype):
+        assert union().check(pandera_dtype=dtype, data_container=None) is False
diff --git a/tests/data/test_samples.py b/tests/data/test_samples.py
index 1064fdec..5b28dc83 100644
--- a/tests/data/test_samples.py
+++ b/tests/data/test_samples.py
@@ -61,9 +61,8 @@ def set_up_dfs_test():
     df_s_fail_index_dtype_float["sample_idx"] = list([x + 0.5 for x in range(len(df_s_success_nonan))])
     df_s_fail_index_dtype_float.set_index(keys=["sample_idx"], drop=True, inplace=True)
 
-    df_s_fail_columns_multiindex = pd.DataFrame(
-        data=df_s_success_nonan.to_numpy(),
-        columns=pd.MultiIndex.from_tuples([("something", x) for x in df_s_success_nonan.columns]),
+    df_s_fail_columns_multiindex = df_s_fail_index_dtype_float.set_axis(
+        pd.MultiIndex.from_tuples([("something", x) for x in df_s_success_nonan.columns]), axis="columns"
     )
 
     df_s_fail_values_dtype_string = df_s_success_nonan.copy()
@@ -190,8 +189,8 @@ def set_up_dfs_test():
             (df_e_fail_index_dtype_float, r".*DataFrame.*index.*dtype.*"),
             (df_e_fail_columns_multiindex, r".*MultiIndex.*columns.*not allowed.*"),
             (df_e_fail_values_non_len2_seq, r".*sequence.*length 2.*"),
-            (df_e_fail_values_dtype_unexp_event_value, r".*feat_2.*DataFrame.*dtype.*"),
-            (df_e_fail_values_dtype_unexp_event_time, r".*feat_2_time.*DataFrame.*dtype.*"),
+            (df_e_fail_values_dtype_unexp_event_value, r".*feat_2.*type.*UnionDtype.*"),
+            (df_e_fail_values_dtype_unexp_event_time, r".*feat_2_time.*type.*UnionDtype.*"),
         ],
     )
 
@@ -271,6 +270,7 @@ def test_from_numpy(
     def test_sample_index(self, df_static: pd.DataFrame):
         s = samples.StaticSamples.from_dataframe(df_static)
         assert s.sample_index() == [f"sample_{x}" for x in range(1, 10 + 1)]
+        assert s.sample_index() == [x.dataframe().index[0] for x in s]
 
     def test_repr(self, df_static: pd.DataFrame):
         s = samples.StaticSamples(data=df_static)
@@ -567,6 +567,7 @@ def test_from_numpy(
     def test_sample_index(self, df_time_series: pd.DataFrame):
         s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
         assert s.sample_index() == ["a", "b", "c"]
+        assert s.sample_index() == [x.dataframe().index.get_level_values(0)[0] for x in s]
 
     def test_time_indexes(self, df_time_series: pd.DataFrame):
         s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
@@ -700,6 +701,7 @@ def test_from_numpy(
     def test_sample_index(self, df_event: pd.DataFrame):
         s = samples.EventSamples.from_dataframe(df_event)
         assert s.sample_index() == [f"sample_{x}" for x in range(1, 3 + 1)]
+        assert s.sample_index() == [x.dataframe().index[0] for x in s]
 
     def test_repr(self, df_event: pd.DataFrame):
         s = samples.EventSamples.from_dataframe(df_event)
diff --git a/tests/models/test_ts_model.py b/tests/models/test_ts_model.py
index e978fcd6..a343fa38 100644
--- a/tests/models/test_ts_model.py
+++ b/tests/models/test_ts_model.py
@@ -5,8 +5,7 @@
 from typing_extensions import get_args
 
 from tempor.models.ts_model import ModelTaskType, TimeSeriesModel, TSModelMode
-from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
-from tempor.utils.dataloaders.sine import SineDataLoader
+from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
 
 
 def unpack_dataset(source):
diff --git a/tests/models/test_ts_ode.py b/tests/models/test_ts_ode.py
index 7c929f5d..ccd72d05 100644
--- a/tests/models/test_ts_ode.py
+++ b/tests/models/test_ts_ode.py
@@ -5,8 +5,7 @@
 
 from tempor.models.constants import ODEBackend
 from tempor.models.ts_ode import NeuralODE
-from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
-from tempor.utils.dataloaders.sine import SineDataLoader
+from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
 
 
 def unpack_dataset(source):
diff --git a/tests/plugins/classification/test_cde_classifier.py b/tests/plugins/classification/test_cde_classifier.py
deleted file mode 100644
index 49d2090f..00000000
--- a/tests/plugins/classification/test_cde_classifier.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.classification.plugin_cde_classifier import CDEClassifier as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseClassifier:
-    return plugin_loader.get("classification.cde_classifier", **train_kwargs)
-
-
-def from_module() -> BaseClassifier:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "cde_classifier"
-    assert test_plugin.fqn() == "classification.cde_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict(dataset)
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict_proba(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 2)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
-
-
-def test_cde_classifier_serde() -> None:
-    test_plugin = from_api()
-
-    data = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded1 = load(dump)
-
-    reloaded1.fit(data)
-
-    dump = save(reloaded1)
-    reloaded2 = load(dump)
-
-    reloaded2.predict(data)
diff --git a/tests/plugins/classification/test_laplace_classifier.py b/tests/plugins/classification/test_laplace_classifier.py
deleted file mode 100644
index aee0620e..00000000
--- a/tests/plugins/classification/test_laplace_classifier.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.classification.plugin_laplace_classifier import (
-    LaplaceODEClassifier as plugin,
-)
-from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseClassifier:
-    return plugin_loader.get("classification.laplace_ode_classifier", **train_kwargs)
-
-
-def from_module() -> BaseClassifier:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "laplace_ode_classifier"
-    assert test_plugin.fqn() == "classification.laplace_ode_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 7
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = GoogleStocksDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = GoogleStocksDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict(dataset)
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
-
-
-def test_laplace_classifier_serde() -> None:
-    test_plugin = from_api()
-
-    data = GoogleStocksDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded1 = load(dump)
-
-    reloaded1.fit(data)
-
-    dump = save(reloaded1)
-    reloaded2 = load(dump)
-
-    reloaded2.predict(data)
diff --git a/tests/plugins/classification/test_nn_classifier.py b/tests/plugins/classification/test_nn_classifier.py
deleted file mode 100644
index e555a298..00000000
--- a/tests/plugins/classification/test_nn_classifier.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.classification.plugin_nn_classifier import (
-    NeuralNetClassifier as plugin,
-)
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseClassifier:
-    return plugin_loader.get("classification.nn_classifier", random_state=123)
-
-
-def from_module() -> BaseClassifier:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "nn_classifier"
-    assert test_plugin.fqn() == "classification.nn_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict(dataset)
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict_proba(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 2)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
-
-
-def test_nn_classifier_serde() -> None:
-    test_plugin = from_api()
-
-    data = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded1 = load(dump)
-
-    reloaded1.fit(data)
-
-    dump = save(reloaded1)
-    reloaded2 = load(dump)
-
-    reloaded2.predict(data)
diff --git a/tests/plugins/classification/test_ode_classifier.py b/tests/plugins/classification/test_ode_classifier.py
deleted file mode 100644
index 014ad308..00000000
--- a/tests/plugins/classification/test_ode_classifier.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.classification.plugin_ode_classifier import ODEClassifier as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseClassifier:
-    return plugin_loader.get("classification.ode_classifier", **train_kwargs)
-
-
-def from_module() -> BaseClassifier:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "ode_classifier"
-    assert test_plugin.fqn() == "classification.ode_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict(dataset)
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataLoader().load()
-
-    output = test_plugin.fit(dataset).predict_proba(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 2)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
-
-
-def test_ode_classifier_serde() -> None:
-    test_plugin = from_api()
-
-    data = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded1 = load(dump)
-
-    reloaded1.fit(data)
-
-    dump = save(reloaded1)
-    reloaded2 = load(dump)
-
-    reloaded2.predict(data)
diff --git a/tests/plugins/classification/test_seq2seq_classifier.py b/tests/plugins/classification/test_seq2seq_classifier.py
deleted file mode 100644
index 2a8bf821..00000000
--- a/tests/plugins/classification/test_seq2seq_classifier.py
+++ /dev/null
@@ -1,79 +0,0 @@
-import pytest
-
-from tempor.data import dataset
-from tempor.plugins import plugin_loader
-from tempor.plugins.classification import BaseClassifier
-from tempor.plugins.classification.plugin_seq2seq_classifier import (
-    Seq2seqClassifier as plugin,
-)
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseClassifier:
-    return plugin_loader.get("classification.seq2seq_classifier", random_state=123, epochs=10)
-
-
-def from_module() -> BaseClassifier:
-    return plugin(random_state=123, epochs=10)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "seq2seq_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 8
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    raw_data = SineDataLoader().load()
-    data = dataset.TemporalPredictionDataset(
-        time_series=raw_data.time_series.dataframe(),
-        static=raw_data.static.dataframe(),  # type: ignore
-        targets=raw_data.time_series.dataframe().copy(),
-    )
-
-    test_plugin.fit(data)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    temporal_dim = 11
-    raw_data = SineDataLoader(temporal_dim=temporal_dim).load()
-    data = dataset.TemporalPredictionDataset(
-        time_series=raw_data.time_series.dataframe(),
-        static=raw_data.static.dataframe(),  # type: ignore
-        targets=raw_data.time_series.dataframe().copy(),
-    )
-    test_plugin.fit(data)
-    output = test_plugin.predict(data, n_future_steps=10)
-
-    assert output.numpy().shape == (len(raw_data.time_series), 10, temporal_dim)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
-
-
-def test_seq2seq_classifier_serde() -> None:
-    test_plugin = from_api()
-
-    raw_data = SineDataLoader().load()
-    data = dataset.TemporalPredictionDataset(
-        time_series=raw_data.time_series.dataframe(),
-        static=raw_data.static.dataframe(),  # type: ignore
-        targets=raw_data.time_series.dataframe().copy(),
-    )
-
-    dump = save(test_plugin)
-    reloaded1 = load(dump)
-
-    reloaded1.fit(data)
-
-    dump = save(reloaded1)
-    reloaded2 = load(dump)
-
-    reloaded2.predict(data, n_future_steps=10)
diff --git a/tests/plugins/core/conftest.py b/tests/plugins/core/conftest.py
new file mode 100644
index 00000000..5dccadf4
--- /dev/null
+++ b/tests/plugins/core/conftest.py
@@ -0,0 +1,30 @@
+from typing import Dict, Type
+
+import pytest
+
+import tempor.plugins.core._plugin as plugin_core
+
+DUMMY_PLUGIN_CATEGORY_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
+DUMMY_PLUGIN_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
+
+
+@pytest.fixture
+def patch_plugins_core_module(patch_module):
+    patch_module(
+        main_module=plugin_core,
+        module_vars=[
+            (
+                plugin_core,
+                plugin_core.PLUGIN_CATEGORY_REGISTRY,
+                "PLUGIN_CATEGORY_REGISTRY",
+                DUMMY_PLUGIN_CATEGORY_REGISTRY,
+            ),
+            (
+                plugin_core,
+                plugin_core.PLUGIN_REGISTRY,
+                "PLUGIN_REGISTRY",
+                DUMMY_PLUGIN_REGISTRY,
+            ),
+        ],
+        refresh_pydantic=False,
+    )
diff --git a/tests/plugins/core/test_importing.py b/tests/plugins/core/test_importing.py
new file mode 100644
index 00000000..9e7ed17b
--- /dev/null
+++ b/tests/plugins/core/test_importing.py
@@ -0,0 +1,76 @@
+# pylint: disable=unused-argument
+
+import os
+import pathlib
+
+import tempor
+import tempor.plugins.core._plugin as plugin_core
+
+plugin_file_1 = """
+import tempor.plugins.core._plugin as plugin_core
+
+
+@plugin_core.register_plugin(name="dummy_plugin_a", category="dummy_category")
+class DummyPluginA(plugin_core.Plugin):
+    pass
+
+
+@plugin_core.register_plugin(name="dummy_plugin_b", category="dummy_category")
+class DummyPluginB(plugin_core.Plugin):
+    pass
+
+"""
+
+plugin_file_2 = """
+import tempor.plugins.core._plugin as plugin_core
+
+
+@plugin_core.register_plugin(name="dummy_plugin_c", category="dummy_category")
+class DummyPluginC(plugin_core.Plugin):
+    pass
+
+"""
+
+plugin_tempor_file = """
+import tempor.plugins.core._plugin as plugin_core
+
+
+@plugin_core.register_plugin(name="dummy_tempor_plugin", category="dummy_category")
+class DummyPluginTempor(plugin_core.Plugin):
+    pass
+
+"""
+
+
+def test_import_plugins(tmp_path: pathlib.Path, patch_plugins_core_module):
+    test_plugins_dir = tmp_path / tempor.import_name / "dummy_plugins"
+    test_plugins_dir.mkdir(parents=True, exist_ok=True)
+    with open(test_plugins_dir / "plugin_file_1.py", "w", encoding="utf8") as f:
+        f.write(plugin_file_1)
+    with open(test_plugins_dir / "plugin_file_2.py", "w", encoding="utf8") as f:
+        f.write(plugin_file_2)
+    with open(test_plugins_dir / "plugin_tempor_file.py", "w", encoding="utf8") as f:
+        f.write(plugin_tempor_file)
+
+    plugin_core.register_plugin_category("dummy_category", expected_class=plugin_core.Plugin)
+
+    init_file = os.path.join(str(test_plugins_dir), "__init__.py")
+    plugin_core.importing.import_plugins(init_file)
+    names = plugin_core.importing.gather_modules_names(init_file)
+
+    assert sorted(
+        [
+            "dummy_category.dummy_plugin_a",
+            "dummy_category.dummy_plugin_b",
+            "dummy_category.dummy_plugin_c",
+            "dummy_category.dummy_tempor_plugin",
+        ]
+    ) == sorted(plugin_core.PLUGIN_REGISTRY.keys())
+
+    assert sorted(
+        [
+            "tempor.dummy_plugins.plugin_tempor_file",
+            "tempor.dummy_plugins.plugin_file_2",
+            "tempor.dummy_plugins.plugin_file_1",
+        ]
+    ) == sorted(names)
diff --git a/tests/plugins/core/test_plugins.py b/tests/plugins/core/test_plugins.py
index 8d962c7b..7d6c61f6 100644
--- a/tests/plugins/core/test_plugins.py
+++ b/tests/plugins/core/test_plugins.py
@@ -1,14 +1,11 @@
-# pylint: disable=redefined-outer-name, unused-argument
+# pylint: disable=unused-argument
 
-from typing import Dict, Type
 from unittest.mock import Mock
 
 import pytest
 
 import tempor.plugins.core._plugin as plugin_core
-
-DUMMY_PLUGIN_CATEGORY_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
-DUMMY_PLUGIN_REGISTRY: Dict[str, Type[plugin_core.Plugin]] = dict()
+from tempor.plugins import plugin_loader
 
 
 class TestPluginClass:
@@ -27,28 +24,6 @@ class CategorylessPlugin(plugin_core.Plugin):
             CategorylessPlugin()
 
 
-@pytest.fixture
-def patch_plugins_core_module(patch_module):
-    patch_module(
-        main_module=plugin_core,
-        module_vars=[
-            (
-                plugin_core,
-                plugin_core.PLUGIN_CATEGORY_REGISTRY,
-                "PLUGIN_CATEGORY_REGISTRY",
-                DUMMY_PLUGIN_CATEGORY_REGISTRY,
-            ),
-            (
-                plugin_core,
-                plugin_core.PLUGIN_REGISTRY,
-                "PLUGIN_REGISTRY",
-                DUMMY_PLUGIN_REGISTRY,
-            ),
-        ],
-        refresh_pydantic=False,
-    )
-
-
 class TestRegistration:
     @pytest.fixture(autouse=True)
     def always_patch_module_in_this_test_class(self, patch_plugins_core_module):
@@ -75,7 +50,7 @@ class DummyPlugin(plugin_core.Plugin):
         assert fqn in plugin_core.PLUGIN_REGISTRY
 
         assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category"] == plugin_core.Plugin
-        assert plugin_core.PLUGIN_REGISTRY[fqn] == DummyPlugin._cls  # type: ignore  # pylint: disable=E,W
+        assert plugin_core.PLUGIN_REGISTRY[fqn] == DummyPlugin
 
     def test_register_multiple_plugins_and_categories_success(self):
         plugin_core.register_plugin_category("dummy_category_A", expected_class=plugin_core.Plugin)
@@ -100,9 +75,9 @@ class DummyPluginB1(plugin_core.Plugin):
 
         assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category_A"] == plugin_core.Plugin
         assert plugin_core.PLUGIN_CATEGORY_REGISTRY["dummy_category_B"] == plugin_core.Plugin
-        assert plugin_core.PLUGIN_REGISTRY[p_a1.fqn()] == DummyPluginA1._cls  # type: ignore  # pylint: disable=E,W
-        assert plugin_core.PLUGIN_REGISTRY[p_a2.fqn()] == DummyPluginA2._cls  # type: ignore  # pylint: disable=E,W
-        assert plugin_core.PLUGIN_REGISTRY[p_b1.fqn()] == DummyPluginB1._cls  # type: ignore  # pylint: disable=E,W
+        assert plugin_core.PLUGIN_REGISTRY[p_a1.fqn()] == DummyPluginA1
+        assert plugin_core.PLUGIN_REGISTRY[p_a2.fqn()] == DummyPluginA2
+        assert plugin_core.PLUGIN_REGISTRY[p_b1.fqn()] == DummyPluginB1
 
     def test_category_registration_fails_duplicate(self):
         plugin_core.register_plugin_category("same_category", expected_class=plugin_core.Plugin)
@@ -202,8 +177,8 @@ def pre_fill_registry(self, always_patch_module_in_this_test_class):
             f"{PluginBY2.category}.{PluginBY2.name}": PluginBY2,
         }
 
-    def test_list(self):
-        loader = plugin_core.PluginLoader()
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_list(self, loader):
         listed = loader.list()
 
         assert listed == {
@@ -214,8 +189,8 @@ def test_list(self):
             },
         }
 
-    def test_list_classes(self):
-        loader = plugin_core.PluginLoader()
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_list_classes(self, loader):
         listed = loader.list_classes()
 
         assert listed == {
@@ -226,8 +201,8 @@ def test_list_classes(self):
             },
         }
 
-    def test_list_fqns(self):
-        loader = plugin_core.PluginLoader()
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_list_fqns(self, loader):
         listed = loader.list_fqns()
 
         assert listed == [
@@ -239,30 +214,53 @@ def test_list_fqns(self):
             "category_b.y.plugin_by2",
         ]
 
-    def test_get(self):
-        loader = plugin_core.PluginLoader()
-
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_get(self, loader):
         plugin_a2_instance = loader.get(  # pylint: disable=unused-variable  # noqa: F841
             "category_a.plugin_a2", "arg", kwarg="kwarg"
         )
         PluginA2.assert_called_once_with("arg", kwarg="kwarg")
+        PluginA2.reset_mock()
 
         plugin_by2_instance = loader.get(  # pylint: disable=unused-variable  # noqa: F841
             name="category_b.y.plugin_by2", kwarg="kwarg"
         )
         PluginBY2.assert_called_once_with(kwarg="kwarg")
+        PluginBY2.reset_mock()
 
-    def test_get_class(self):
-        loader = plugin_core.PluginLoader()
-
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_get_class(self, loader):
         plugin_a2_class = loader.get_class("category_a.plugin_a2")
         assert plugin_a2_class == PluginA2
 
         plugin_bx1_class = loader.get_class("category_b.x.plugin_bx1")
         assert plugin_bx1_class == PluginBX1
 
-    def test_get_fails_no_such_plugin(self):
-        loader = plugin_core.PluginLoader()
-
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_get_fails_no_such_plugin(self, loader):
         with pytest.raises(ValueError, match=".*[Pp]lugin.*not.*exist.*"):
             loader.get_class("category_a.no_such_plugin")
+
+    @pytest.mark.parametrize("loader", [plugin_core.PluginLoader(), plugin_loader])
+    def test_plugin_added_live(self, loader: plugin_core.PluginLoader):
+        plugin_core.register_plugin_category("category_c", expected_class=plugin_core.Plugin)
+
+        @plugin_core.register_plugin(name="plugin_c1", category="category_c")
+        class PluginC1(plugin_core.Plugin):  # pylint: disable=unused-variable
+            pass
+
+        listed = loader.list()
+        assert "category_c" in listed
+        assert "plugin_c1" in listed["category_c"]
+
+        listed_classes = loader.list_classes()
+        assert "category_c" in listed
+        assert PluginC1 in listed_classes["category_c"]
+
+        plugin_c1_class = loader.get_class("category_c.plugin_c1")
+        assert plugin_c1_class == PluginC1
+
+        plugin_c1_instance = loader.get(
+            "category_c.plugin_c1",
+        )
+        assert isinstance(plugin_c1_instance, PluginC1)  # type: ignore
diff --git a/tests/plugins/pipelines/test_pipeline.py b/tests/plugins/pipelines/test_pipeline.py
index 82bc1c62..8dd54cd7 100644
--- a/tests/plugins/pipelines/test_pipeline.py
+++ b/tests/plugins/pipelines/test_pipeline.py
@@ -5,29 +5,30 @@
 import pytest
 
 from tempor.plugins.pipeline import Pipeline, PipelineGroup, PipelineMeta
-from tempor.utils.dataloaders.sine import SineDataLoader
 from tempor.utils.serialization import load, save
 
+TEST_ON_DATASETS = ["sine_data_small"]
+
 
 @pytest.mark.parametrize(
     "plugins_str",
     [
         [
-            "preprocessing.imputation.bfill",
-            "preprocessing.scaling.static_minmax_scaler",
-            "preprocessing.scaling.ts_minmax_scaler",
-            "classification.nn_classifier",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.scaling.static.static_minmax_scaler",
+            "preprocessing.scaling.temporal.ts_minmax_scaler",
+            "prediction.one_off.classification.nn_classifier",
         ],
         [
-            "preprocessing.imputation.static_imputation",
-            "regression.nn_regressor",
+            "preprocessing.imputation.static.static_imputation",
+            "prediction.one_off.regression.nn_regressor",
         ],
         [
-            "classification.nn_classifier",
+            "prediction.one_off.classification.nn_classifier",
         ],
     ],
 )
-def test_pipeline_sanity(plugins_str: List[Any]) -> None:
+def test_sanity(plugins_str: List[Any]) -> None:
     dtype: PipelineMeta = Pipeline(plugins_str)
     plugins = PipelineGroup(plugins_str)
 
@@ -42,76 +43,77 @@ def test_pipeline_sanity(plugins_str: List[Any]) -> None:
     "plugins_str",
     [
         [
-            "classification.nn_classifier",
-            "preprocessing.imputation.bfill",
+            "prediction.one_off.classification.nn_classifier",
+            "preprocessing.imputation.temporal.bfill",
         ],
         [
-            "preprocessing.imputation.bfill",
-            "preprocessing.imputation.bfill",
-            "preprocessing.imputation.bfill",
-            "preprocessing.imputation.bfill",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.imputation.temporal.bfill",
         ],
         [
-            "regression.nn_regressor",
-            "regression.nn_regressor",
+            "prediction.one_off.regression.nn_regressor",
+            "prediction.one_off.regression.nn_regressor",
         ],
         [
-            "regression.nn_regressor",
-            "regression.nn_regressor",
-            "preprocessing.imputation.bfill",
+            "prediction.one_off.regression.nn_regressor",
+            "prediction.one_off.regression.nn_regressor",
+            "preprocessing.imputation.temporal.bfill",
         ],
         [
-            "regression.nn_regressor",
-            "preprocessing.imputation.bfill",
-            "preprocessing.imputation.bfill",
-            "preprocessing.imputation.bfill",
-            "preprocessing.scaling.ts_minmax_scaler",
+            "prediction.one_off.regression.nn_regressor",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.scaling.temporal.ts_minmax_scaler",
         ],
         [],
     ],
 )
-def test_pipeline_fails(plugins_str: List[Any]) -> None:
+def test_fails(plugins_str: List[Any]) -> None:
     with pytest.raises(RuntimeError):
         Pipeline(plugins_str)()
 
 
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
 @pytest.mark.parametrize(
     "plugins_str",
     [
         [
-            "preprocessing.imputation.static_imputation",
-            "preprocessing.imputation.nop_imputer",
-            "preprocessing.imputation.bfill",
-            "preprocessing.scaling.static_minmax_scaler",
-            "preprocessing.scaling.ts_minmax_scaler",
-            "classification.nn_classifier",
+            "preprocessing.imputation.static.static_imputation",
+            "preprocessing.nop.nop_transformer",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.scaling.static.static_minmax_scaler",
+            "preprocessing.scaling.temporal.ts_minmax_scaler",
+            "prediction.one_off.classification.nn_classifier",
         ],
         [
-            "preprocessing.imputation.bfill",
-            "preprocessing.scaling.ts_minmax_scaler",
-            "regression.nn_regressor",
+            "preprocessing.imputation.temporal.bfill",
+            "preprocessing.scaling.temporal.ts_minmax_scaler",
+            "prediction.one_off.regression.nn_regressor",
         ],
         [
-            "preprocessing.imputation.ffill",
-            "preprocessing.scaling.static_minmax_scaler",
-            "preprocessing.scaling.ts_minmax_scaler",
-            "regression.nn_regressor",
+            "preprocessing.imputation.temporal.ffill",
+            "preprocessing.scaling.static.static_minmax_scaler",
+            "preprocessing.scaling.temporal.ts_minmax_scaler",
+            "prediction.one_off.regression.nn_regressor",
         ],
         [
-            "preprocessing.imputation.ffill",
-            "regression.nn_regressor",
+            "preprocessing.imputation.temporal.ffill",
+            "prediction.one_off.regression.nn_regressor",
         ],
         [
-            "classification.nn_classifier",
+            "prediction.one_off.classification.nn_classifier",
         ],
     ],
 )
 @pytest.mark.parametrize("serialize", [True, False])
-def test_pipeline_end2end(plugins_str, serialize: bool) -> None:
+def test_end2end(plugins_str, serialize: bool, sine_data_small, sine_data_missing_small) -> None:
     if len(plugins_str) > 1:
-        dataset = SineDataLoader(with_missing=True).load()
+        dataset = sine_data_missing_small
     else:
-        dataset = SineDataLoader(with_missing=False).load()
+        dataset = sine_data_small
 
     template: PipelineMeta = Pipeline(plugins_str)
     pipeline = template()
diff --git a/tests/plugins/prediction/one_off/classification/test_cde_classifier.py b/tests/plugins/prediction/one_off/classification/test_cde_classifier.py
new file mode 100644
index 00000000..062097eb
--- /dev/null
+++ b/tests/plugins/prediction/one_off/classification/test_cde_classifier.py
@@ -0,0 +1,89 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.classification.plugin_cde_classifier import (
+    CDEClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffClassifier:
+    return plugin_loader.get("prediction.one_off.classification.cde_classifier", **train_kwargs)
+
+
+def from_module() -> BaseOneOffClassifier:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffClassifier) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "cde_classifier"
+    assert test_plugin.fqn() == "prediction.one_off.classification.cde_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    output = test_plugin.fit(dataset).predict(dataset)
+    assert output.numpy().shape == (len(dataset.time_series), 1)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict_proba(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    output = test_plugin.fit(dataset).predict_proba(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 2)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_serde(data: str, request: pytest.FixtureRequest) -> None:
+    test_plugin = from_api()
+
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded1 = load(dump)
+
+    reloaded1.fit(dataset)
+
+    dump = save(reloaded1)
+    reloaded2 = load(dump)
+
+    reloaded2.predict(dataset)
diff --git a/tests/plugins/prediction/one_off/classification/test_laplace_classifier.py b/tests/plugins/prediction/one_off/classification/test_laplace_classifier.py
new file mode 100644
index 00000000..7830fa6a
--- /dev/null
+++ b/tests/plugins/prediction/one_off/classification/test_laplace_classifier.py
@@ -0,0 +1,73 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.classification.plugin_laplace_classifier import (
+    LaplaceODEClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["google_stocks_data_small"]
+
+
+def from_api() -> BaseOneOffClassifier:
+    return plugin_loader.get("prediction.one_off.classification.laplace_ode_classifier", **train_kwargs)
+
+
+def from_module() -> BaseOneOffClassifier:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffClassifier) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "laplace_ode_classifier"
+    assert test_plugin.fqn() == "prediction.one_off.classification.laplace_ode_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 7
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    output = test_plugin.fit(dataset).predict(dataset)
+    assert output.numpy().shape == (len(dataset.time_series), 1)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_serde(data: str, request: pytest.FixtureRequest) -> None:
+    test_plugin = from_api()
+
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded1 = load(dump)
+
+    reloaded1.fit(dataset)
+
+    dump = save(reloaded1)
+    reloaded2 = load(dump)
+
+    reloaded2.predict(dataset)
diff --git a/tests/plugins/prediction/one_off/classification/test_nn_classifier.py b/tests/plugins/prediction/one_off/classification/test_nn_classifier.py
new file mode 100644
index 00000000..219d4ac7
--- /dev/null
+++ b/tests/plugins/prediction/one_off/classification/test_nn_classifier.py
@@ -0,0 +1,89 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.classification.plugin_nn_classifier import (
+    NeuralNetClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffClassifier:
+    return plugin_loader.get("prediction.one_off.classification.nn_classifier", **train_kwargs)
+
+
+def from_module() -> BaseOneOffClassifier:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffClassifier) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "nn_classifier"
+    assert test_plugin.fqn() == "prediction.one_off.classification.nn_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    output = test_plugin.fit(dataset).predict(dataset)
+    assert output.numpy().shape == (len(dataset.time_series), 1)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict_proba(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    output = test_plugin.fit(dataset).predict_proba(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 2)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_serde(data: str, request: pytest.FixtureRequest) -> None:
+    test_plugin = from_api()
+
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded1 = load(dump)
+
+    reloaded1.fit(dataset)
+
+    dump = save(reloaded1)
+    reloaded2 = load(dump)
+
+    reloaded2.predict(dataset)
diff --git a/tests/plugins/prediction/one_off/classification/test_ode_classifier.py b/tests/plugins/prediction/one_off/classification/test_ode_classifier.py
new file mode 100644
index 00000000..2f3e3080
--- /dev/null
+++ b/tests/plugins/prediction/one_off/classification/test_ode_classifier.py
@@ -0,0 +1,87 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.classification.plugin_ode_classifier import (
+    ODEClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffClassifier:
+    return plugin_loader.get("prediction.one_off.classification.ode_classifier", **train_kwargs)
+
+
+def from_module() -> BaseOneOffClassifier:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffClassifier) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "ode_classifier"
+    assert test_plugin.fqn() == "prediction.one_off.classification.ode_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    output = test_plugin.fit(dataset).predict(dataset)
+    assert output.numpy().shape == (len(dataset.time_series), 1)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict_proba(test_plugin: BaseOneOffClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    output = test_plugin.fit(dataset).predict_proba(dataset)
+    assert output.numpy().shape == (len(dataset.time_series), 2)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_serde(data: str, request: pytest.FixtureRequest) -> None:
+    test_plugin = from_api()
+
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded1 = load(dump)
+
+    reloaded1.fit(dataset)
+
+    dump = save(reloaded1)
+    reloaded2 = load(dump)
+
+    reloaded2.predict(dataset)
diff --git a/tests/plugins/prediction/one_off/regression/test_cde_regressor.py b/tests/plugins/prediction/one_off/regression/test_cde_regressor.py
new file mode 100644
index 00000000..30c866c9
--- /dev/null
+++ b/tests/plugins/prediction/one_off/regression/test_cde_regressor.py
@@ -0,0 +1,65 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.one_off.regression.plugin_cde_regressor import (
+    CDERegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffRegressor:
+    return plugin_loader.get("prediction.one_off.regression.cde_regressor", **train_kwargs)
+
+
+def from_module() -> BaseOneOffRegressor:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffRegressor) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "cde_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.predict(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/prediction/one_off/regression/test_laplace_regressor.py b/tests/plugins/prediction/one_off/regression/test_laplace_regressor.py
new file mode 100644
index 00000000..b6c8ea6c
--- /dev/null
+++ b/tests/plugins/prediction/one_off/regression/test_laplace_regressor.py
@@ -0,0 +1,65 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.one_off.regression.plugin_laplace_regressor import (
+    LaplaceODERegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["google_stocks_data_small"]
+
+
+def from_api() -> BaseOneOffRegressor:
+    return plugin_loader.get("prediction.one_off.regression.laplace_ode_regressor", **train_kwargs)
+
+
+def from_module() -> BaseOneOffRegressor:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffRegressor) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "laplace_ode_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 7
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.predict(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/prediction/one_off/regression/test_nn_regressor.py b/tests/plugins/prediction/one_off/regression/test_nn_regressor.py
new file mode 100644
index 00000000..b4d42889
--- /dev/null
+++ b/tests/plugins/prediction/one_off/regression/test_nn_regressor.py
@@ -0,0 +1,65 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.one_off.regression.plugin_nn_regressor import (
+    NeuralNetRegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffRegressor:
+    return plugin_loader.get("prediction.one_off.regression.nn_regressor", **train_kwargs)
+
+
+def from_module() -> BaseOneOffRegressor:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffRegressor) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "nn_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.predict(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/prediction/one_off/regression/test_ode_regressor.py b/tests/plugins/prediction/one_off/regression/test_ode_regressor.py
new file mode 100644
index 00000000..7609ed29
--- /dev/null
+++ b/tests/plugins/prediction/one_off/regression/test_ode_regressor.py
@@ -0,0 +1,65 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.one_off.regression.plugin_ode_regressor import (
+    ODERegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["sine_data_small"]
+
+
+def from_api() -> BaseOneOffRegressor:
+    return plugin_loader.get("prediction.one_off.regression.ode_regressor", **train_kwargs)
+
+
+def from_module() -> BaseOneOffRegressor:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffRegressor) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "ode_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 9
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseOneOffRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.predict(dataset)
+
+    assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/prediction/temporal/classification/test_seq2seq_classifier.py b/tests/plugins/prediction/temporal/classification/test_seq2seq_classifier.py
new file mode 100644
index 00000000..0dee3da6
--- /dev/null
+++ b/tests/plugins/prediction/temporal/classification/test_seq2seq_classifier.py
@@ -0,0 +1,74 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.temporal.classification import BaseTemporalClassifier
+from tempor.plugins.prediction.temporal.classification.plugin_seq2seq_classifier import (
+    Seq2seqClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "epochs": 5}
+
+TEST_ON_DATASETS = ["sine_data_temporal_small"]
+
+
+def from_api() -> BaseTemporalClassifier:
+    return plugin_loader.get("prediction.temporal.classification.seq2seq_classifier", **train_kwargs)
+
+
+def from_module() -> BaseTemporalClassifier:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseTemporalClassifier) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "seq2seq_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 8
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseTemporalClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseTemporalClassifier, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+    output = test_plugin.predict(dataset, n_future_steps=10)
+
+    assert output.numpy().shape == (len(dataset.time_series), 10, 5)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_serde(data: str, request: pytest.FixtureRequest) -> None:
+    test_plugin = from_api()
+
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded1 = load(dump)
+
+    reloaded1.fit(dataset)
+
+    dump = save(reloaded1)
+    reloaded2 = load(dump)
+
+    reloaded2.predict(dataset, n_future_steps=10)
diff --git a/tests/plugins/prediction/temporal/regression/test_seq2seq_regressor.py b/tests/plugins/prediction/temporal/regression/test_seq2seq_regressor.py
new file mode 100644
index 00000000..22682823
--- /dev/null
+++ b/tests/plugins/prediction/temporal/regression/test_seq2seq_regressor.py
@@ -0,0 +1,65 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.prediction.temporal.regression import BaseTemporalRegressor
+from tempor.plugins.prediction.temporal.regression.plugin_seq2seq_regressor import (
+    Seq2seqRegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123, "epochs": 5}
+
+TEST_ON_DATASETS = ["sine_data_temporal_small"]
+
+
+def from_api() -> BaseTemporalRegressor:
+    return plugin_loader.get("prediction.temporal.regression.seq2seq_regressor", **train_kwargs)
+
+
+def from_module() -> BaseTemporalRegressor:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseTemporalRegressor) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "seq2seq_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 8
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseTemporalRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseTemporalRegressor, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.predict(dataset, n_future_steps=10)
+
+    assert output.numpy().shape == (len(dataset.time_series), 10, 5)
diff --git a/tests/plugins/preprocessing/imputation/test_static_imputation.py b/tests/plugins/preprocessing/imputation/static/test_static_imputation.py
similarity index 51%
rename from tests/plugins/preprocessing/imputation/test_static_imputation.py
rename to tests/plugins/preprocessing/imputation/static/test_static_imputation.py
index 0c003268..bb78ab38 100644
--- a/tests/plugins/preprocessing/imputation/test_static_imputation.py
+++ b/tests/plugins/preprocessing/imputation/static/test_static_imputation.py
@@ -4,31 +4,41 @@
 
 from tempor.plugins import plugin_loader
 from tempor.plugins.preprocessing.imputation import BaseImputer
-from tempor.plugins.preprocessing.imputation.plugin_static_imputation import (
+from tempor.plugins.preprocessing.imputation.static.plugin_static_imputation import (
     StaticOnlyImputer as plugin,
 )
-from tempor.utils.dataloaders.sine import SineDataLoader
 from tempor.utils.serialization import load, save
 
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["sine_data_missing_small"]
+
 
 def from_api() -> BaseImputer:
-    return plugin_loader.get("preprocessing.imputation.static_imputation", random_state=123)
+    return plugin_loader.get("preprocessing.imputation.static.static_imputation", **train_kwargs)
 
 
 def from_module() -> BaseImputer:
-    return plugin(random_state=123)
+    return plugin(**train_kwargs)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_imputation_plugin_sanity(test_plugin: BaseImputer) -> None:
+def test_sanity(test_plugin: BaseImputer) -> None:
     assert test_plugin is not None
     assert test_plugin.name == "static_imputation"
     assert len(test_plugin.hyperparameter_space()) == 2
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_imputation_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
@@ -37,9 +47,17 @@ def test_static_imputation_plugin_fit(test_plugin: BaseImputer) -> None:
     test_plugin.fit(dataset)
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_imputation_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_transform(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
@@ -57,9 +75,3 @@ def test_static_imputation_plugin_transform(test_plugin: BaseImputer) -> None:
 
     assert output.static.dataframe().isna().sum().sum() == 0
     assert output.time_series.dataframe().isna().sum().sum() == 0
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/imputation/temporal/test_bfill.py b/tests/plugins/preprocessing/imputation/temporal/test_bfill.py
new file mode 100644
index 00000000..44dc61ea
--- /dev/null
+++ b/tests/plugins/preprocessing/imputation/temporal/test_bfill.py
@@ -0,0 +1,77 @@
+from typing import TYPE_CHECKING
+
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.imputation.temporal.plugin_bfill import (
+    BFillImputer as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["sine_data_missing_small"]
+
+
+def from_api() -> BaseImputer:
+    return plugin_loader.get("preprocessing.imputation.temporal.bfill", **train_kwargs)
+
+
+def from_module() -> BaseImputer:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseImputer) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "bfill"
+    assert len(test_plugin.hyperparameter_space()) == 1
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    if TYPE_CHECKING:  # pragma: no cover
+        assert dataset.static is not None  # nosec B101
+
+    assert dataset.static.dataframe().isna().sum().sum() != 0
+
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_transform(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    if TYPE_CHECKING:  # pragma: no cover
+        assert dataset.static is not None  # nosec B101
+
+    assert dataset.static.dataframe().isna().sum().sum() != 0
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert output.static.dataframe().isna().sum().sum() == 0
+    assert output.time_series.dataframe().isna().sum().sum() == 0
diff --git a/tests/plugins/preprocessing/imputation/temporal/test_ffill.py b/tests/plugins/preprocessing/imputation/temporal/test_ffill.py
new file mode 100644
index 00000000..8d08e083
--- /dev/null
+++ b/tests/plugins/preprocessing/imputation/temporal/test_ffill.py
@@ -0,0 +1,77 @@
+from typing import TYPE_CHECKING
+
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.imputation.temporal.plugin_ffill import (
+    FFillImputer as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["sine_data_missing_small"]
+
+
+def from_api() -> BaseImputer:
+    return plugin_loader.get("preprocessing.imputation.temporal.ffill", **train_kwargs)
+
+
+def from_module() -> BaseImputer:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseImputer) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "ffill"
+    assert len(test_plugin.hyperparameter_space()) == 1
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    if TYPE_CHECKING:  # pragma: no cover
+        assert dataset.static is not None  # nosec B101
+
+    assert dataset.static.dataframe().isna().sum().sum() != 0
+
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_transform(test_plugin: BaseImputer, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    if TYPE_CHECKING:  # pragma: no cover
+        assert dataset.static is not None  # nosec B101
+
+    assert dataset.static.dataframe().isna().sum().sum() != 0
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert output.static.dataframe().isna().sum().sum() == 0
+    assert output.time_series.dataframe().isna().sum().sum() == 0
diff --git a/tests/plugins/preprocessing/imputation/test_bfill.py b/tests/plugins/preprocessing/imputation/test_bfill.py
deleted file mode 100644
index 3ea97e25..00000000
--- a/tests/plugins/preprocessing/imputation/test_bfill.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from typing import TYPE_CHECKING
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.imputation import BaseImputer
-from tempor.plugins.preprocessing.imputation.plugin_bfill import BFillImputer as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseImputer:
-    return plugin_loader.get("preprocessing.imputation.bfill", random_state=123)
-
-
-def from_module() -> BaseImputer:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_bfill_plugin_sanity(test_plugin: BaseImputer) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "bfill"
-    assert len(test_plugin.hyperparameter_space()) == 1
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_bfill_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
-    if TYPE_CHECKING:  # pragma: no cover
-        assert dataset.static is not None  # nosec B101
-
-    assert dataset.static.dataframe().isna().sum().sum() != 0
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_bfill_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
-    if TYPE_CHECKING:  # pragma: no cover
-        assert dataset.static is not None  # nosec B101
-
-    assert dataset.static.dataframe().isna().sum().sum() != 0
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert output.static.dataframe().isna().sum().sum() == 0
-    assert output.time_series.dataframe().isna().sum().sum() == 0
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/imputation/test_ffill.py b/tests/plugins/preprocessing/imputation/test_ffill.py
deleted file mode 100644
index 3a82fbae..00000000
--- a/tests/plugins/preprocessing/imputation/test_ffill.py
+++ /dev/null
@@ -1,63 +0,0 @@
-from typing import TYPE_CHECKING
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.imputation import BaseImputer
-from tempor.plugins.preprocessing.imputation.plugin_ffill import FFillImputer as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseImputer:
-    return plugin_loader.get("preprocessing.imputation.ffill", random_state=123)
-
-
-def from_module() -> BaseImputer:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ffill_plugin_sanity(test_plugin: BaseImputer) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "ffill"
-    assert len(test_plugin.hyperparameter_space()) == 1
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ffill_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
-    if TYPE_CHECKING:  # pragma: no cover
-        assert dataset.static is not None  # nosec B101
-
-    assert dataset.static.dataframe().isna().sum().sum() != 0
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ffill_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataLoader(with_missing=True).load()
-    if TYPE_CHECKING:  # pragma: no cover
-        assert dataset.static is not None  # nosec B101
-
-    assert dataset.static.dataframe().isna().sum().sum() != 0
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert output.static.dataframe().isna().sum().sum() == 0
-    assert output.time_series.dataframe().isna().sum().sum() == 0
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/scaling/conftest.py b/tests/plugins/preprocessing/scaling/conftest.py
new file mode 100644
index 00000000..916e9b99
--- /dev/null
+++ b/tests/plugins/preprocessing/scaling/conftest.py
@@ -0,0 +1,32 @@
+import copy
+
+import pytest
+
+from tempor.data import dataset
+from tempor.utils import dataloaders
+
+# --- Reusable datasets. ---
+
+
+# Sine data: full, scaled.
+@pytest.fixture(scope="session")
+def _sine_data_scaled_full():
+    return dataloaders.SineDataLoader(no=100, temporal_dim=5, random_state=42, static_scale=100, ts_scale=100).load()
+
+
+@pytest.fixture(scope="function")
+def sine_data_scaled_full(_sine_data_full: dataset.OneOffPredictionDataset) -> dataset.OneOffPredictionDataset:
+    # Give each test a copy, just in case.
+    return copy.deepcopy(_sine_data_full)
+
+
+# Sine data: small, scaled.
+@pytest.fixture(scope="session")
+def _sine_data_scaled_small(_sine_data_scaled_full: dataset.OneOffPredictionDataset) -> dataset.OneOffPredictionDataset:
+    data = _sine_data_scaled_full[:6]
+    return data
+
+
+@pytest.fixture(scope="function")
+def sine_data_scaled_small(_sine_data_scaled_small: dataset.OneOffPredictionDataset) -> dataset.OneOffPredictionDataset:
+    return copy.deepcopy(_sine_data_scaled_small)
diff --git a/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py b/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py
new file mode 100644
index 00000000..b913469c
--- /dev/null
+++ b/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py
@@ -0,0 +1,69 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling.static.plugin_static_minmax_scaler import (
+    StaticMinMaxScaler as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["google_stocks_data_full", "sine_data_scaled_small"]
+TEST_TRANSFORM_ON_DATASETS = ["sine_data_scaled_small"]
+
+
+def from_api() -> BaseScaler:
+    return plugin_loader.get("preprocessing.scaling.static.static_minmax_scaler", **train_kwargs)
+
+
+def from_module() -> BaseScaler:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseScaler) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "static_minmax_scaler"
+    assert len(test_plugin.hyperparameter_space()) == 0
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_TRANSFORM_ON_DATASETS)
+def test_transform(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    assert dataset.static is not None  # nosec B101
+    assert (dataset.static.numpy() > 1.1).any()
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert (output.static.numpy() < 1 + 1e-1).all()
+    assert (output.static.numpy() >= 0).all()
diff --git a/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py b/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py
new file mode 100644
index 00000000..1797f95f
--- /dev/null
+++ b/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py
@@ -0,0 +1,68 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling.static.plugin_static_standard_scaler import (
+    StaticStandardScaler as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["google_stocks_data_full", "sine_data_scaled_small"]
+TEST_TRANSFORM_ON_DATASETS = ["sine_data_scaled_small"]
+
+
+def from_api() -> BaseScaler:
+    return plugin_loader.get("preprocessing.scaling.static.static_standard_scaler", **train_kwargs)
+
+
+def from_module() -> BaseScaler:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseScaler) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "static_standard_scaler"
+    assert len(test_plugin.hyperparameter_space()) == 0
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_TRANSFORM_ON_DATASETS)
+def test_transform(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    assert dataset.static is not None  # nosec B101
+    assert (dataset.static.numpy() > 50).any()
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert (output.static.numpy() < 50).all()
diff --git a/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py b/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py
new file mode 100644
index 00000000..25df965d
--- /dev/null
+++ b/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py
@@ -0,0 +1,69 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling.temporal.plugin_ts_minmax_scaler import (
+    TimeSeriesMinMaxScaler as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["google_stocks_data_full", "sine_data_scaled_small"]
+TEST_TRANSFORM_ON_DATASETS = ["sine_data_scaled_small"]
+
+
+def from_api() -> BaseScaler:
+    return plugin_loader.get("preprocessing.scaling.temporal.ts_minmax_scaler", **train_kwargs)
+
+
+def from_module() -> BaseScaler:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseScaler) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "ts_minmax_scaler"
+    assert len(test_plugin.hyperparameter_space()) == 0
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_TRANSFORM_ON_DATASETS)
+def test_transform(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    assert dataset.time_series is not None  # nosec B101
+    assert (dataset.time_series.numpy() > 1.1).any()
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert (output.time_series.numpy() < 1 + 1e-1).all()
+    assert (output.time_series.numpy() >= 0).all()
diff --git a/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py b/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py
new file mode 100644
index 00000000..be98d5e9
--- /dev/null
+++ b/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py
@@ -0,0 +1,68 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.preprocessing.scaling import BaseScaler
+from tempor.plugins.preprocessing.scaling.temporal.plugin_ts_standard_scaler import (
+    TimeSeriesStandardScaler as plugin,
+)
+from tempor.utils.serialization import load, save
+
+train_kwargs = {"random_state": 123}
+
+TEST_ON_DATASETS = ["google_stocks_data_full", "sine_data_scaled_small"]
+TEST_TRANSFORM_ON_DATASETS = ["sine_data_scaled_small"]
+
+
+def from_api() -> BaseScaler:
+    return plugin_loader.get("preprocessing.scaling.temporal.ts_standard_scaler", **train_kwargs)
+
+
+def from_module() -> BaseScaler:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseScaler) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "ts_standard_scaler"
+    assert len(test_plugin.hyperparameter_space()) == 0
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_TRANSFORM_ON_DATASETS)
+def test_transform(test_plugin: BaseScaler, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    assert dataset.time_series is not None  # nosec B101
+    assert (dataset.time_series.numpy() > 50).any()
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    output = reloaded.transform(dataset)
+
+    assert (output.time_series.numpy() < 50).all()
diff --git a/tests/plugins/preprocessing/scaling/test_static_minmax_scaler.py b/tests/plugins/preprocessing/scaling/test_static_minmax_scaler.py
deleted file mode 100644
index 05e7c0a7..00000000
--- a/tests/plugins/preprocessing/scaling/test_static_minmax_scaler.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from typing import Any
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.scaling import BaseScaler
-from tempor.plugins.preprocessing.scaling.plugin_static_minmax_scaler import (
-    StaticMinMaxScaler as plugin,
-)
-from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseScaler:
-    return plugin_loader.get("preprocessing.scaling.static_minmax_scaler", random_state=123)
-
-
-def from_module() -> BaseScaler:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_minmax_scaler_plugin_sanity(test_plugin: BaseScaler) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "static_minmax_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-@pytest.mark.parametrize("dataloader", [GoogleStocksDataLoader(), SineDataLoader(static_scale=5)])
-def test_static_minmax_scaler_plugin_fit(test_plugin: BaseScaler, dataloader: Any) -> None:
-    dataset = dataloader.load()
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_minmax_scaler_plugin_transform(test_plugin: BaseScaler) -> None:
-    dataset = SineDataLoader(static_scale=100).load()
-    assert dataset.static is not None  # nosec B101
-    assert (dataset.static.numpy() > 1.1).any()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert (output.static.numpy() < 1 + 1e-1).all()
-    assert (output.static.numpy() >= 0).all()
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/scaling/test_static_standard_scaler.py b/tests/plugins/preprocessing/scaling/test_static_standard_scaler.py
deleted file mode 100644
index bbbd8593..00000000
--- a/tests/plugins/preprocessing/scaling/test_static_standard_scaler.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from typing import Any
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.scaling import BaseScaler
-from tempor.plugins.preprocessing.scaling.plugin_static_standard_scaler import (
-    StaticStandardScaler as plugin,
-)
-from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseScaler:
-    return plugin_loader.get("preprocessing.scaling.static_standard_scaler", random_state=123)
-
-
-def from_module() -> BaseScaler:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_scaler_plugin_sanity(test_plugin: BaseScaler) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "static_standard_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-@pytest.mark.parametrize("dataloader", [GoogleStocksDataLoader(), SineDataLoader(static_scale=5)])
-def test_static_scaler_plugin_fit(test_plugin: BaseScaler, dataloader: Any) -> None:
-    dataset = dataloader.load()
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_static_scaler_plugin_transform(test_plugin: BaseScaler) -> None:
-    dataset = SineDataLoader(static_scale=100).load()
-    assert dataset.static is not None  # nosec B101
-    assert (dataset.static.numpy() > 50).any()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert (output.static.numpy() < 50).all()
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/scaling/test_ts_minmax_scaler.py b/tests/plugins/preprocessing/scaling/test_ts_minmax_scaler.py
deleted file mode 100644
index 1ac4bf58..00000000
--- a/tests/plugins/preprocessing/scaling/test_ts_minmax_scaler.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from typing import Any
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.scaling import BaseScaler
-from tempor.plugins.preprocessing.scaling.plugin_ts_minmax_scaler import (
-    TimeSeriesMinMaxScaler as plugin,
-)
-from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseScaler:
-    return plugin_loader.get("preprocessing.scaling.ts_minmax_scaler", random_state=123)
-
-
-def from_module() -> BaseScaler:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_minmax_scaler_plugin_sanity(test_plugin: BaseScaler) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "ts_minmax_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-@pytest.mark.parametrize("dataloader", [GoogleStocksDataLoader(), SineDataLoader(ts_scale=5)])
-def test_ts_minmax_scaler_plugin_fit(test_plugin: BaseScaler, dataloader: Any) -> None:
-    dataset = dataloader.load()
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_minmax_scaler_plugin_transform(test_plugin: BaseScaler) -> None:
-    dataset = SineDataLoader(ts_scale=100).load()
-    assert dataset.time_series is not None  # nosec B101
-    assert (dataset.time_series.numpy() > 1.1).any()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert (output.time_series.numpy() < 1 + 1e-1).all()
-    assert (output.time_series.numpy() >= 0).all()
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/preprocessing/scaling/test_ts_standard_scaler.py b/tests/plugins/preprocessing/scaling/test_ts_standard_scaler.py
deleted file mode 100644
index f6f84b05..00000000
--- a/tests/plugins/preprocessing/scaling/test_ts_standard_scaler.py
+++ /dev/null
@@ -1,58 +0,0 @@
-from typing import Any
-
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.preprocessing.scaling import BaseScaler
-from tempor.plugins.preprocessing.scaling.plugin_ts_standard_scaler import (
-    TimeSeriesStandardScaler as plugin,
-)
-from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseScaler:
-    return plugin_loader.get("preprocessing.scaling.ts_standard_scaler", random_state=123)
-
-
-def from_module() -> BaseScaler:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_standard_scaler_plugin_sanity(test_plugin: BaseScaler) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "ts_standard_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-@pytest.mark.parametrize("dataloader", [GoogleStocksDataLoader(), SineDataLoader(ts_scale=5)])
-def test_ts_standard_scaler_plugin_fit(test_plugin: BaseScaler, dataloader: Any) -> None:
-    dataset = dataloader.load()
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_standard_scaler_plugin_transform(test_plugin: BaseScaler) -> None:
-    dataset = SineDataLoader(ts_scale=100).load()
-    assert dataset.time_series is not None  # nosec B101
-    assert (dataset.time_series.numpy() > 50).any()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.transform(dataset)
-
-    assert (output.time_series.numpy() < 50).all()
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/regression/test_cde_regressor.py b/tests/plugins/regression/test_cde_regressor.py
deleted file mode 100644
index 9e394f9e..00000000
--- a/tests/plugins/regression/test_cde_regressor.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.regression.plugin_cde_regressor import CDERegressor as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseRegressor:
-    return plugin_loader.get("regression.cde_regressor", **train_kwargs)
-
-
-def from_module() -> BaseRegressor:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "cde_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_cde_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.predict(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/regression/test_laplace_regressor.py b/tests/plugins/regression/test_laplace_regressor.py
deleted file mode 100644
index 0483a01d..00000000
--- a/tests/plugins/regression/test_laplace_regressor.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.regression.plugin_laplace_regressor import (
-    LaplaceODERegressor as plugin,
-)
-from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseRegressor:
-    return plugin_loader.get("regression.laplace_ode_regressor", **train_kwargs)
-
-
-def from_module() -> BaseRegressor:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "laplace_ode_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 7
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = GoogleStocksDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_laplace_ode_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = GoogleStocksDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.predict(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/regression/test_nn_regressor.py b/tests/plugins/regression/test_nn_regressor.py
deleted file mode 100644
index ee3388d2..00000000
--- a/tests/plugins/regression/test_nn_regressor.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.regression.plugin_nn_regressor import NeuralNetRegressor as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseRegressor:
-    return plugin_loader.get("regression.nn_regressor", random_state=123)
-
-
-def from_module() -> BaseRegressor:
-    return plugin(random_state=123)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "nn_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_nn_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.predict(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/regression/test_ode_regressor.py b/tests/plugins/regression/test_ode_regressor.py
deleted file mode 100644
index 9bf7952a..00000000
--- a/tests/plugins/regression/test_ode_regressor.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import pytest
-
-from tempor.plugins import plugin_loader
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.regression.plugin_ode_regressor import ODERegressor as plugin
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-train_kwargs = {"random_state": 123, "n_iter": 50}
-
-
-def from_api() -> BaseRegressor:
-    return plugin_loader.get("regression.ode_regressor", **train_kwargs)
-
-
-def from_module() -> BaseRegressor:
-    return plugin(**train_kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "ode_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 9
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    test_plugin.fit(dataset)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ode_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataLoader().load()
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(dataset)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.predict(dataset)
-
-    assert output.numpy().shape == (len(dataset.time_series), 1)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/regression/test_seq2seq_regressor.py b/tests/plugins/regression/test_seq2seq_regressor.py
deleted file mode 100644
index 8be34965..00000000
--- a/tests/plugins/regression/test_seq2seq_regressor.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import pytest
-
-from tempor.data import dataset
-from tempor.plugins import plugin_loader
-from tempor.plugins.regression import BaseRegressor
-from tempor.plugins.regression.plugin_seq2seq_regressor import (
-    Seq2seqRegressor as plugin,
-)
-from tempor.utils.dataloaders.sine import SineDataLoader
-from tempor.utils.serialization import load, save
-
-
-def from_api() -> BaseRegressor:
-    return plugin_loader.get("regression.seq2seq_regressor", random_state=123, epochs=10)
-
-
-def from_module() -> BaseRegressor:
-    return plugin(random_state=123, epochs=10)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "seq2seq_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 8
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    raw_data = SineDataLoader().load()
-    data = dataset.TemporalPredictionDataset(
-        time_series=raw_data.time_series.dataframe(),
-        static=raw_data.static.dataframe(),  # type: ignore
-        targets=raw_data.time_series.dataframe().copy(),
-    )
-
-    test_plugin.fit(data)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_seq2seq_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    temporal_dim = 11
-    raw_data = SineDataLoader(temporal_dim=temporal_dim).load()
-    data = dataset.TemporalPredictionDataset(
-        time_series=raw_data.time_series.dataframe(),
-        static=raw_data.static.dataframe(),  # type: ignore
-        targets=raw_data.time_series.dataframe().copy(),
-    )
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(data)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    output = reloaded.predict(data, n_future_steps=10)
-
-    assert output.numpy().shape == (len(raw_data.time_series), 10, temporal_dim)
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/test_all_plugins.py b/tests/plugins/test_all_plugins.py
new file mode 100644
index 00000000..c0879c19
--- /dev/null
+++ b/tests/plugins/test_all_plugins.py
@@ -0,0 +1,21 @@
+"""Tests that will be automatically run for all plugins."""
+
+import pytest
+
+from tempor.plugins import plugin_loader
+
+PLUGIN_FQNS = plugin_loader.list_fqns()
+
+
+@pytest.mark.parametrize("plugin_fqn", PLUGIN_FQNS)
+def test_init_success(plugin_fqn):
+    PluginCls = plugin_loader.get_class(plugin_fqn)
+    PluginCls()  # Should successfully initialize with all default params.
+
+
+@pytest.mark.parametrize("plugin_fqn", PLUGIN_FQNS)
+def test_sample_hyperparameters(plugin_fqn):
+    PluginCls = plugin_loader.get_class(plugin_fqn)
+    for repeat in range(10):  # pylint: disable=unused-variable
+        args = PluginCls.sample_hyperparameters()
+        PluginCls(**args)
diff --git a/tests/plugins/test_plugin_loader.py b/tests/plugins/test_plugin_loader.py
index 04f80313..9951c555 100644
--- a/tests/plugins/test_plugin_loader.py
+++ b/tests/plugins/test_plugin_loader.py
@@ -6,18 +6,66 @@ def test_tempor_plugin_loader_contents():
 
     # Do some checks that expected plugins have been registered.
     # Update as plugins get added / reorganized.
+
+    # Check categories:
+    assert "prediction" in all_plugins
     assert "preprocessing" in all_plugins
     assert "time_to_event" in all_plugins
+    assert "treatments" in all_plugins
+
+    # Check subcategories:
     assert "imputation" in all_plugins["preprocessing"]
     assert "scaling" in all_plugins["preprocessing"]
-    assert "nop_imputer" in all_plugins["preprocessing"]["imputation"]
-    assert "nop_scaler" in all_plugins["preprocessing"]["scaling"]
-    assert "dynamic_deephit" in all_plugins["time_to_event"]
+    assert "nop" in all_plugins["preprocessing"]
+    assert "one_off" in all_plugins["prediction"]
+    assert "temporal" in all_plugins["prediction"]
+    assert "one_off" in all_plugins["treatments"]
+    assert "temporal" in all_plugins["treatments"]
 
+    # Check sub-subcategories:
+    assert "classification" in all_plugins["prediction"]["one_off"]
+    assert "regression" in all_plugins["prediction"]["one_off"]
+    assert "classification" in all_plugins["prediction"]["temporal"]
+    assert "regression" in all_plugins["prediction"]["temporal"]
+    assert "static" in all_plugins["preprocessing"]["imputation"]
+    assert "temporal" in all_plugins["preprocessing"]["imputation"]
+    assert "static" in all_plugins["preprocessing"]["scaling"]
+    assert "temporal" in all_plugins["preprocessing"]["scaling"]
+    # assert "classification" in all_plugins["treatments"]["one_off"]
+    assert "regression" in all_plugins["treatments"]["one_off"]
+    assert "classification" in all_plugins["treatments"]["temporal"]
+    assert "regression" in all_plugins["treatments"]["temporal"]
 
-def test_tempor_plugins_all_init_success():
-    plugin_fqns = plugin_loader.list_fqns()
-
-    for plugin_fqn in plugin_fqns:
-        PluginCls = plugin_loader.get_class(plugin_fqn)
-        PluginCls()  # Should successfully initialize with all default params.
+    # Check plugins:
+    assert "cde_classifier" in all_plugins["prediction"]["one_off"]["classification"]
+    assert "ode_classifier" in all_plugins["prediction"]["one_off"]["classification"]
+    assert "nn_classifier" in all_plugins["prediction"]["one_off"]["classification"]
+    assert "laplace_ode_classifier" in all_plugins["prediction"]["one_off"]["classification"]
+    # ---
+    assert "seq2seq_classifier" in all_plugins["prediction"]["temporal"]["classification"]
+    # ---
+    assert "laplace_ode_regressor" in all_plugins["prediction"]["one_off"]["regression"]
+    assert "nn_regressor" in all_plugins["prediction"]["one_off"]["regression"]
+    assert "ode_regressor" in all_plugins["prediction"]["one_off"]["regression"]
+    assert "cde_regressor" in all_plugins["prediction"]["one_off"]["regression"]
+    # ---
+    assert "seq2seq_regressor" in all_plugins["prediction"]["temporal"]["regression"]
+    # ---
+    assert "nop_transformer" in all_plugins["preprocessing"]["nop"]
+    # ---
+    assert "static_imputation" in all_plugins["preprocessing"]["imputation"]["static"]
+    assert "ffill" in all_plugins["preprocessing"]["imputation"]["temporal"]
+    assert "bfill" in all_plugins["preprocessing"]["imputation"]["temporal"]
+    # ---
+    assert "static_minmax_scaler" in all_plugins["preprocessing"]["scaling"]["static"]
+    assert "static_standard_scaler" in all_plugins["preprocessing"]["scaling"]["static"]
+    assert "ts_minmax_scaler" in all_plugins["preprocessing"]["scaling"]["temporal"]
+    assert "ts_standard_scaler" in all_plugins["preprocessing"]["scaling"]["temporal"]
+    # ---
+    assert "ts_coxph" in all_plugins["time_to_event"]
+    assert "ts_xgb" in all_plugins["time_to_event"]
+    assert "dynamic_deephit" in all_plugins["time_to_event"]
+    # ---
+    assert "crn_classifier" in all_plugins["treatments"]["temporal"]["classification"]
+    assert "crn_regressor" in all_plugins["treatments"]["temporal"]["regression"]
+    assert "synctwin_regressor" in all_plugins["treatments"]["one_off"]["regression"]
diff --git a/tests/plugins/time_to_event/test_ddh_plugin.py b/tests/plugins/time_to_event/test_ddh_plugin.py
index 668bea7a..ad8f51ab 100644
--- a/tests/plugins/time_to_event/test_ddh_plugin.py
+++ b/tests/plugins/time_to_event/test_ddh_plugin.py
@@ -7,13 +7,14 @@
 from tempor.plugins.time_to_event.plugin_ddh import (
     DynamicDeepHitTimeToEventAnalysis as plugin,
 )
-from tempor.utils.dataloaders import PBCDataLoader
 from tempor.utils.serialization import load, save
 
 if TYPE_CHECKING:  # pragma: no cover
     from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
-train_kwargs = {"random_state": 123, "n_iter": 10}
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["pbc_data_small"]
 
 
 def from_api() -> "BaseTimeToEventAnalysis":
@@ -25,24 +26,42 @@ def from_module() -> "BaseTimeToEventAnalysis":
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_dynamic_deephit_plugin_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
+def test_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
     assert test_plugin is not None
     assert test_plugin.name == "dynamic_deephit"
     assert test_plugin.fqn() == "time_to_event.dynamic_deephit"
     assert len(test_plugin.hyperparameter_space()) == 10
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_dynamic_deephit_plugin_fit(test_plugin: "BaseTimeToEventAnalysis") -> None:
-    dataset = PBCDataLoader().load()
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: "BaseTimeToEventAnalysis", data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
     test_plugin.fit(dataset)
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_dynamic_deephit_plugin_predict(
-    test_plugin: "BaseTimeToEventAnalysis", get_event0_time_percentiles: Callable
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(
+    test_plugin: "BaseTimeToEventAnalysis",
+    get_event0_time_percentiles: Callable,
+    data: str,
+    request: pytest.FixtureRequest,
 ) -> None:
-    dataset = PBCDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -59,9 +78,10 @@ def test_dynamic_deephit_plugin_predict(
     assert output.numpy().shape == (len(dataset.time_series), len(horizons), 1)
 
 
-def test_dynamic_deephit_plugin_benchmark(get_event0_time_percentiles: Callable) -> None:
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_benchmark(get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest) -> None:
     test_plugin = from_api()
-    dataset = PBCDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -69,9 +89,3 @@ def test_dynamic_deephit_plugin_benchmark(get_event0_time_percentiles: Callable)
 
     assert (score.loc[:, "errors"] == 0).all()
     assert score.loc["c_index", "mean"] > 0.5  # pyright: ignore
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/time_to_event/test_ts_coxph_plugin.py b/tests/plugins/time_to_event/test_ts_coxph_plugin.py
index 241f1aea..d534dc31 100644
--- a/tests/plugins/time_to_event/test_ts_coxph_plugin.py
+++ b/tests/plugins/time_to_event/test_ts_coxph_plugin.py
@@ -7,13 +7,14 @@
 from tempor.plugins.time_to_event.plugin_ts_coxph import (
     CoxPHTimeToEventAnalysis as plugin,
 )
-from tempor.utils.dataloaders import PBCDataLoader
 from tempor.utils.serialization import load, save
 
 if TYPE_CHECKING:  # pragma: no cover
     from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
-train_kwargs = {"random_state": 123, "n_iter": 10}
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["pbc_data_small"]
 
 
 def from_api() -> "BaseTimeToEventAnalysis":
@@ -25,22 +26,46 @@ def from_module() -> "BaseTimeToEventAnalysis":
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_coxph_plugin_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
+def test_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
     assert test_plugin is not None
     assert test_plugin.name == "ts_coxph"
     assert test_plugin.fqn() == "time_to_event.ts_coxph"
     assert len(test_plugin.hyperparameter_space()) == 12
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_coxph_plugin_fit(test_plugin: "BaseTimeToEventAnalysis") -> None:
-    dataset = PBCDataLoader().load()
+@pytest.mark.filterwarnings("ignore::lifelines.utils.ConvergenceWarning")  # Expected.
+@pytest.mark.filterwarnings("ignore:.*Index constructor.*:FutureWarning")
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: "BaseTimeToEventAnalysis", data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
     test_plugin.fit(dataset)
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_coxph_plugin_predict(test_plugin: "BaseTimeToEventAnalysis", get_event0_time_percentiles: Callable) -> None:
-    dataset = PBCDataLoader().load()
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.filterwarnings("ignore::lifelines.utils.ConvergenceWarning")  # Expected.
+@pytest.mark.filterwarnings("ignore:.*Index constructor.*:FutureWarning")
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(
+    test_plugin: "BaseTimeToEventAnalysis",
+    get_event0_time_percentiles: Callable,
+    data: str,
+    request: pytest.FixtureRequest,
+) -> None:
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -57,9 +82,12 @@ def test_ts_coxph_plugin_predict(test_plugin: "BaseTimeToEventAnalysis", get_eve
     assert output.numpy().shape == (len(dataset.time_series), len(horizons), 1)
 
 
-def test_ts_coxph_plugin_benchmark(get_event0_time_percentiles: Callable) -> None:
+@pytest.mark.filterwarnings("ignore::lifelines.utils.ConvergenceWarning")  # Expected.
+@pytest.mark.filterwarnings("ignore:.*Index constructor.*:FutureWarning")
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_benchmark(get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest) -> None:
     test_plugin = from_api()
-    dataset = PBCDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -67,9 +95,3 @@ def test_ts_coxph_plugin_benchmark(get_event0_time_percentiles: Callable) -> Non
 
     assert (score.loc[:, "errors"] == 0).all()
     assert score.loc["c_index", "mean"] > 0.5  # pyright: ignore
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/time_to_event/test_ts_xgb_plugin.py b/tests/plugins/time_to_event/test_ts_xgb_plugin.py
index 6aafc1d7..fc860fba 100644
--- a/tests/plugins/time_to_event/test_ts_xgb_plugin.py
+++ b/tests/plugins/time_to_event/test_ts_xgb_plugin.py
@@ -5,13 +5,14 @@
 from tempor.benchmarks.evaluation import evaluate_time_to_event
 from tempor.plugins import plugin_loader
 from tempor.plugins.time_to_event.plugin_ts_xgb import XGBTimeToEventAnalysis as plugin
-from tempor.utils.dataloaders import PBCDataLoader
 from tempor.utils.serialization import load, save
 
 if TYPE_CHECKING:  # pragma: no cover
     from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
-train_kwargs = {"random_state": 123, "n_iter": 10}
+train_kwargs = {"random_state": 123, "n_iter": 5}
+
+TEST_ON_DATASETS = ["pbc_data_full"]
 
 
 def from_api() -> "BaseTimeToEventAnalysis":
@@ -23,22 +24,42 @@ def from_module() -> "BaseTimeToEventAnalysis":
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_xgb_plugin_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
+def test_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
     assert test_plugin is not None
     assert test_plugin.name == "ts_xgb"
     assert test_plugin.fqn() == "time_to_event.ts_xgb"
     assert len(test_plugin.hyperparameter_space()) == 23
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_xgb_plugin_fit(test_plugin: "BaseTimeToEventAnalysis") -> None:
-    dataset = PBCDataLoader().load()
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: "BaseTimeToEventAnalysis", data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
     test_plugin.fit(dataset)
 
 
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_ts_xgb_plugin_predict(test_plugin: "BaseTimeToEventAnalysis", get_event0_time_percentiles: Callable) -> None:
-    dataset = PBCDataLoader().load()
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(
+    test_plugin: "BaseTimeToEventAnalysis",
+    get_event0_time_percentiles: Callable,
+    data: str,
+    request: pytest.FixtureRequest,
+) -> None:
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -55,9 +76,10 @@ def test_ts_xgb_plugin_predict(test_plugin: "BaseTimeToEventAnalysis", get_event
     assert output.numpy().shape == (len(dataset.time_series), len(horizons), 1)
 
 
-def test_ts_xgb_plugin_benchmark(get_event0_time_percentiles: Callable) -> None:
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_benchmark(get_event0_time_percentiles: Callable, data: str, request: pytest.FixtureRequest) -> None:
     test_plugin = from_api()
-    dataset = PBCDataLoader().load()
+    dataset = request.getfixturevalue(data)
 
     horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
 
@@ -65,9 +87,3 @@ def test_ts_xgb_plugin_benchmark(get_event0_time_percentiles: Callable) -> None:
 
     assert (score.loc[:, "errors"] == 0).all()
     assert score.loc["c_index", "mean"] > 0.5  # pyright: ignore
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/treatments/one_off/__init__.py b/tests/plugins/treatments/one_off/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/one_off/classification/__init__.py b/tests/plugins/treatments/one_off/classification/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/one_off/regression/__init__.py b/tests/plugins/treatments/one_off/regression/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/one_off/regression/test_synctwin_regressor.py b/tests/plugins/treatments/one_off/regression/test_synctwin_regressor.py
new file mode 100644
index 00000000..5c626102
--- /dev/null
+++ b/tests/plugins/treatments/one_off/regression/test_synctwin_regressor.py
@@ -0,0 +1,70 @@
+import pandas as pd
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.treatments.one_off import BaseOneOffTreatmentEffects
+from tempor.plugins.treatments.one_off.regression.plugin_synctwin_regressor import (
+    SyncTwinTreatmentsRegressor as plugin,
+)
+
+train_kwargs = {
+    "pretraining_iterations": 3,
+    "matching_iterations": 3,
+    "inference_iterations": 3,
+}
+
+TEST_ON_DATASETS = ["pkpd_data_small"]
+
+
+def from_api() -> BaseOneOffTreatmentEffects:
+    return plugin_loader.get("treatments.one_off.regression.synctwin_regressor", **train_kwargs)
+
+
+def from_module() -> BaseOneOffTreatmentEffects:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseOneOffTreatmentEffects) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "synctwin_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 5
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseOneOffTreatmentEffects, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict_counterfactuals(
+    test_plugin: BaseOneOffTreatmentEffects, data: str, request: pytest.FixtureRequest
+) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+    output = test_plugin.predict_counterfactuals(dataset)
+
+    assert len(output) == len(dataset)
+    for o in output:
+        assert isinstance(o, (list, str))
+        if isinstance(o, list):
+            assert len(o) == 1
+            assert isinstance(o[0], pd.DataFrame)
+        else:
+            assert "SyncTwin implementation can currently only predict counterfactuals for treated samples" in o
diff --git a/tests/plugins/treatments/temporal/__init__.py b/tests/plugins/treatments/temporal/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/temporal/classification/__init__.py b/tests/plugins/treatments/temporal/classification/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/temporal/classification/test_crn_classifier.py b/tests/plugins/treatments/temporal/classification/test_crn_classifier.py
new file mode 100644
index 00000000..f7167c00
--- /dev/null
+++ b/tests/plugins/treatments/temporal/classification/test_crn_classifier.py
@@ -0,0 +1,93 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
+from tempor.plugins.treatments.temporal.classification.plugin_crn_classifier import (
+    CRNTreatmentsClassifier as plugin,
+)
+from tempor.utils.serialization import load, save
+
+from ...helpers_treatments import simulate_horizons, simulate_treatments_scenarios
+
+train_kwargs = {"random_state": 123, "n_iter": 3}
+
+TEST_ON_DATASETS = ["clv_data_small"]
+
+
+def from_api() -> BaseTemporalTreatmentEffects:
+    return plugin_loader.get("treatments.temporal.classification.crn_classifier", **train_kwargs)
+
+
+def from_module() -> BaseTemporalTreatmentEffects:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseTemporalTreatmentEffects) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "crn_classifier"
+    assert len(test_plugin.hyperparameter_space()) == 8
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    horizons = simulate_horizons(dataset)
+    output = reloaded.predict(dataset, horizons=horizons)
+
+    assert output.numpy().shape == (len(dataset.time_series), 6, 3)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_crn_classifier_plugin_predict_counterfactuals(
+    test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest
+) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+    n_counterfactuals_per_sample = 2
+    horizons, treatment_scenarios = simulate_treatments_scenarios(
+        dataset, n_counterfactuals_per_sample=n_counterfactuals_per_sample
+    )
+
+    output = test_plugin.predict_counterfactuals(dataset, horizons=horizons, treatment_scenarios=treatment_scenarios)
+
+    assert len(output) == len(dataset)
+    assert len(output[0]) == n_counterfactuals_per_sample
diff --git a/tests/plugins/treatments/temporal/regression/__init__.py b/tests/plugins/treatments/temporal/regression/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/plugins/treatments/temporal/regression/test_crn_regressor.py b/tests/plugins/treatments/temporal/regression/test_crn_regressor.py
new file mode 100644
index 00000000..5f1643f7
--- /dev/null
+++ b/tests/plugins/treatments/temporal/regression/test_crn_regressor.py
@@ -0,0 +1,93 @@
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
+from tempor.plugins.treatments.temporal.regression.plugin_crn_regressor import (
+    CRNTreatmentsRegressor as plugin,
+)
+from tempor.utils.serialization import load, save
+
+from ...helpers_treatments import simulate_horizons, simulate_treatments_scenarios
+
+train_kwargs = {"random_state": 123, "n_iter": 3}
+
+TEST_ON_DATASETS = ["clv_data_small"]
+
+
+def from_api() -> BaseTemporalTreatmentEffects:
+    return plugin_loader.get("treatments.temporal.regression.crn_regressor", **train_kwargs)
+
+
+def from_module() -> BaseTemporalTreatmentEffects:
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_sanity(test_plugin: BaseTemporalTreatmentEffects) -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "crn_regressor"
+    assert len(test_plugin.hyperparameter_space()) == 8
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_fit(test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict(test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest) -> None:
+    dataset = request.getfixturevalue(data)
+
+    dump = save(test_plugin)
+    reloaded = load(dump)
+
+    reloaded.fit(dataset)
+
+    dump = save(reloaded)
+    reloaded = load(dump)
+
+    horizons = simulate_horizons(dataset)
+    output = reloaded.predict(dataset, horizons=horizons)
+
+    assert output.numpy().shape == (len(dataset.time_series), 6, 3)
+
+
+@pytest.mark.parametrize(
+    "test_plugin",
+    [
+        from_api(),
+        pytest.param(from_module(), marks=pytest.mark.extra),
+    ],
+)
+@pytest.mark.parametrize("data", TEST_ON_DATASETS)
+def test_predict_counterfactuals(
+    test_plugin: BaseTemporalTreatmentEffects, data: str, request: pytest.FixtureRequest
+) -> None:
+    dataset = request.getfixturevalue(data)
+    test_plugin.fit(dataset)
+
+    n_counterfactuals_per_sample = 2
+    horizons, treatment_scenarios = simulate_treatments_scenarios(
+        dataset, n_counterfactuals_per_sample=n_counterfactuals_per_sample
+    )
+
+    output = test_plugin.predict_counterfactuals(dataset, horizons=horizons, treatment_scenarios=treatment_scenarios)
+
+    assert len(output) == len(dataset)
+    assert len(output[0]) == n_counterfactuals_per_sample
diff --git a/tests/plugins/treatments/test_crn_classifier.py b/tests/plugins/treatments/test_crn_classifier.py
deleted file mode 100644
index cc323393..00000000
--- a/tests/plugins/treatments/test_crn_classifier.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import pytest
-from clairvoyance2.datasets.dummy import dummy_dataset
-
-from tempor.data.clv2conv import clairvoyance2_dataset_to_tempor_dataset
-from tempor.plugins import plugin_loader
-from tempor.plugins.treatments import BaseTreatments
-from tempor.plugins.treatments.plugin_crn_classifier import (
-    CRNTreatmentsClassifier as plugin,
-)
-from tempor.utils.serialization import load, save
-
-from .helpers_treatments import simulate_horizons, simulate_treatments_scenarios
-
-
-def get_dummy_data(
-    n_samples: int = 100,
-    temporal_covariates_n_features: int = 5,
-    temporal_covariates_max_len: int = 11,
-    temporal_covariates_missing_prob: float = 0.0,
-    static_covariates_n_features: int = 13,
-    temporal_treatments_n_features: int = 5,
-    temporal_treatments_n_categories: int = 2,
-    temporal_targets_n_features: int = 7,
-    temporal_targets_n_categories: int = 4,
-):
-    local_dataset = dummy_dataset(
-        n_samples=n_samples,
-        temporal_covariates_n_features=temporal_covariates_n_features,
-        temporal_covariates_max_len=temporal_covariates_max_len,
-        temporal_covariates_missing_prob=temporal_covariates_missing_prob,
-        static_covariates_n_features=static_covariates_n_features,
-        temporal_treatments_n_features=temporal_treatments_n_features,
-        temporal_treatments_n_categories=temporal_treatments_n_categories,
-        temporal_targets_n_features=temporal_targets_n_features,
-        temporal_targets_n_categories=temporal_targets_n_categories,
-        random_seed=12345,
-    )
-    return clairvoyance2_dataset_to_tempor_dataset(local_dataset)
-
-
-def from_api() -> BaseTreatments:
-    return plugin_loader.get("treatments.crn_classifier", random_state=123, epochs=10)
-
-
-def from_module() -> BaseTreatments:
-    return plugin(random_state=123, epochs=10)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_CRN_classifier_plugin_sanity(test_plugin: BaseTreatments) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "crn_classifier"
-    assert len(test_plugin.hyperparameter_space()) == 8
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_CRN_classifier_plugin_fit(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data()
-    test_plugin.fit(data)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_CRN_classifier_plugin_predict(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data(temporal_targets_n_features=3)
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(data)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    horizons = simulate_horizons(data)
-    output = reloaded.predict(data, horizons=horizons)
-
-    assert output.numpy().shape == (len(data.time_series), 6, 3)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_crn_classifier_plugin_predict_counterfactuals(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data(temporal_targets_n_features=3)
-    test_plugin.fit(data)
-
-    n_counterfactuals_per_sample = 2
-    horizons, treatment_scenarios = simulate_treatments_scenarios(
-        data, n_counterfactuals_per_sample=n_counterfactuals_per_sample
-    )
-
-    output = test_plugin.predict_counterfactuals(data, horizons=horizons, treatment_scenarios=treatment_scenarios)
-
-    assert len(output) == len(data)
-    assert len(output[0]) == n_counterfactuals_per_sample
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/treatments/test_crn_regressor.py b/tests/plugins/treatments/test_crn_regressor.py
deleted file mode 100644
index 479b4c7c..00000000
--- a/tests/plugins/treatments/test_crn_regressor.py
+++ /dev/null
@@ -1,99 +0,0 @@
-import pytest
-from clairvoyance2.datasets.dummy import dummy_dataset
-
-from tempor.data.clv2conv import clairvoyance2_dataset_to_tempor_dataset
-from tempor.plugins import plugin_loader
-from tempor.plugins.treatments import BaseTreatments
-from tempor.plugins.treatments.plugin_crn_regressor import (
-    CRNTreatmentsRegressor as plugin,
-)
-from tempor.utils.serialization import load, save
-
-from .helpers_treatments import simulate_horizons, simulate_treatments_scenarios
-
-
-def get_dummy_data(
-    n_samples: int = 100,
-    temporal_covariates_n_features: int = 5,
-    temporal_covariates_max_len: int = 11,
-    temporal_covariates_missing_prob: float = 0.0,
-    static_covariates_n_features: int = 13,
-    temporal_treatments_n_features: int = 5,
-    temporal_treatments_n_categories: int = 2,
-    temporal_targets_n_features: int = 7,
-    temporal_targets_n_categories: int = 4,
-):
-    local_dataset = dummy_dataset(
-        n_samples=n_samples,
-        temporal_covariates_n_features=temporal_covariates_n_features,
-        temporal_covariates_max_len=temporal_covariates_max_len,
-        temporal_covariates_missing_prob=temporal_covariates_missing_prob,
-        static_covariates_n_features=static_covariates_n_features,
-        temporal_treatments_n_features=temporal_treatments_n_features,
-        temporal_treatments_n_categories=temporal_treatments_n_categories,
-        temporal_targets_n_features=temporal_targets_n_features,
-        temporal_targets_n_categories=temporal_targets_n_categories,
-        random_seed=12345,
-    )
-    return clairvoyance2_dataset_to_tempor_dataset(local_dataset)
-
-
-def from_api() -> BaseTreatments:
-    return plugin_loader.get("treatments.crn_regressor", random_state=123, epochs=10)
-
-
-def from_module() -> BaseTreatments:
-    return plugin(random_state=123, epochs=10)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_crn_regressor_plugin_sanity(test_plugin: BaseTreatments) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "crn_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 8
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_crn_regressor_plugin_fit(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data()
-    test_plugin.fit(data)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_crn_regressor_plugin_predict(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data(temporal_targets_n_features=3)
-
-    dump = save(test_plugin)
-    reloaded = load(dump)
-
-    reloaded.fit(data)
-
-    dump = save(reloaded)
-    reloaded = load(dump)
-
-    horizons = simulate_horizons(data)
-    output = reloaded.predict(data, horizons=horizons)
-
-    assert output.numpy().shape == (len(data.time_series), 6, 3)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_crn_regressor_plugin_predict_counterfactuals(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data(temporal_targets_n_features=3)
-    test_plugin.fit(data)
-
-    n_counterfactuals_per_sample = 2
-    horizons, treatment_scenarios = simulate_treatments_scenarios(
-        data, n_counterfactuals_per_sample=n_counterfactuals_per_sample
-    )
-
-    output = test_plugin.predict_counterfactuals(data, horizons=horizons, treatment_scenarios=treatment_scenarios)
-
-    assert len(output) == len(data)
-    assert len(output[0]) == n_counterfactuals_per_sample
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/plugins/treatments/test_synctwin_regressor.py b/tests/plugins/treatments/test_synctwin_regressor.py
deleted file mode 100644
index 7e76fa12..00000000
--- a/tests/plugins/treatments/test_synctwin_regressor.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import pandas as pd
-import pytest
-from clairvoyance2.datasets import simple_pkpd_dataset
-
-from tempor.data.clv2conv import clairvoyance2_dataset_to_tempor_dataset
-from tempor.plugins import plugin_loader
-from tempor.plugins.treatments import BaseTreatments
-from tempor.plugins.treatments.plugin_synctwin_regressor import (
-    SyncTwinTreatmentsRegressor as plugin,
-)
-
-
-def get_dummy_data(
-    n_timesteps: int = 10,
-    time_index_treatment_event: int = 7,
-    n_control_samples: int = 10,
-    n_treated_samples: int = 10,
-    seed: int = 123,
-):
-    local_dataset = simple_pkpd_dataset(
-        n_timesteps=n_timesteps,
-        time_index_treatment_event=time_index_treatment_event,
-        n_control_samples=n_control_samples,
-        n_treated_samples=n_treated_samples,
-        seed=seed,
-    )
-    return clairvoyance2_dataset_to_tempor_dataset(local_dataset)
-
-
-kwargs = {
-    "pretraining_iterations": 10,
-    "matching_iterations": 10,
-    "inference_iterations": 10,
-}
-
-
-def from_api() -> BaseTreatments:
-    return plugin_loader.get("treatments.synctwin_regressor", **kwargs)
-
-
-def from_module() -> BaseTreatments:
-    return plugin(**kwargs)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_synctwin_regressor_plugin_sanity(test_plugin: BaseTreatments) -> None:
-    assert test_plugin is not None
-    assert test_plugin.name == "synctwin_regressor"
-    assert len(test_plugin.hyperparameter_space()) == 5
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_synctwin_regressor_plugin_fit(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data()
-    test_plugin.fit(data)
-
-
-@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
-def test_synctwin_regressor_plugin_predict_counterfactuals(test_plugin: BaseTreatments) -> None:
-    data = get_dummy_data()
-    test_plugin.fit(data)
-
-    output = test_plugin.predict_counterfactuals(data)
-
-    assert len(output) == len(data)
-    for o in output:
-        assert isinstance(o, (list, str))
-        if isinstance(o, list):
-            assert len(o) == 1
-            assert isinstance(o[0], pd.DataFrame)
-        else:
-            assert "SyncTwin implementation can currently only predict counterfactuals for treated samples" in o
-
-
-def test_hyperparam_sample():
-    for repeat in range(10):  # pylint: disable=unused-variable
-        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
-        plugin(**args)
diff --git a/tests/utils/dataloaders/test_dataloaders.py b/tests/utils/dataloaders/test_dataloaders.py
index ef7ab647..a7388063 100644
--- a/tests/utils/dataloaders/test_dataloaders.py
+++ b/tests/utils/dataloaders/test_dataloaders.py
@@ -9,7 +9,14 @@
 
 @pytest.mark.parametrize(
     "dataloader_cls",
-    [dataloaders.SineDataLoader, dataloaders.GoogleStocksDataLoader, dataloaders.GoogleStocksDataLoader],
+    [
+        dataloaders.SineDataLoader,
+        dataloaders.GoogleStocksDataLoader,
+        dataloaders.GoogleStocksDataLoader,
+        dataloaders.PKPDDataLoader,
+        dataloaders.DummyTemporalPredictionDataLoader,
+        dataloaders.DummyTemporalTreatmentEffectsDataLoader,
+    ],
 )
 def test_init_load_and_basic_methods(dataloader_cls: Type[DataLoader]):
     dataloader = dataloader_cls()  # Test __ini__ with no (all default) parameters.
@@ -21,3 +28,7 @@ def test_init_load_and_basic_methods(dataloader_cls: Type[DataLoader]):
     assert isinstance(url, (str, type(None)))
     assert isinstance(dataset_dir, (str, type(None)))
     assert isinstance(dataset, Dataset)
+
+    # Assert the right type of dataset for the dataloader has been returned.
+    return_type = dataloader_cls.load.__annotations__["return"]
+    assert isinstance(dataset, return_type)