Repository: vanderschaarlab/temporai (language: python)

Issue:
[AutoML] Create pipeline from hyperparameters
## Feature Description
For AutoML search, is important to be able to sample hyperparameters, and to recreate the pipeline from those hyperparameters.

AutoPrognosis implements the following strategy:
 - For each search task, the user can select imputation, preprocessing and prediction plugins.
 - For each pipeline, the prediction plugin "drives" the whole pipeline selection.
 - To that end, we artificially extend the predictor hyperparameters to include the imputation and preprocessing plugins. In other words, the user samples from predictor's `[imputation plugin 1, 2, 3, ...] + [preprocessing plugin 1, 2, 3, ...] + hyperparam_space`. This simplifies the sampling process.
 - Given a sampled preprocessing/imputed plugin, and a set of hyperparameters, the user must be able to create the complete pipeline

AP reference: https://github.com/vanderschaarlab/autoprognosis/blob/main/src/autoprognosis/explorers/core/selector.py

Blocked by:  https://github.com/vanderschaarlab/temporai/issues/27



Discussion / hints:

Fix patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3d683b24..426fa308 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -16,7 +16,8 @@ considerate, reasonable, and respectful**. When in doubt,
 [Python Software Foundation's Code of Conduct] is a good reference in terms of
 behavior guidelines.
 
-> 🕮 Please also make sure to first familiarize yourself with the [`vanderschaarlab` Code of Conduct](https://github.com/vanderschaarlab/.github/blob/main/CODE_OF_CONDUCT.md).
+> 🕮 Please also make sure to first familiarize yourself with the
+[`vanderschaarlab` Code of Conduct](https://github.com/vanderschaarlab/.github/blob/main/CODE_OF_CONDUCT.md).
 
 ## Issue Reports
 
@@ -43,7 +44,8 @@ by adding missing information and correcting mistakes.
 This means that the docs are kept in the same repository as the project code, and
 that any documentation update is done in the same way was a code contribution.
 
-We use [CommonMark] as the markup language for the documentation, facilitated by the [MyST] extension. This also allows the use of [reStructuredText] where necessary.
+We use [CommonMark] as the markup language for the documentation, facilitated by the [MyST] extension.
+This also allows the use of [reStructuredText] where necessary.
 
 > Please notice that the [GitHub web interface] provides a quick way of
 propose changes in *TemporAI*'s files. While this mechanism can
@@ -173,14 +175,16 @@ conda activate temporai
 
    to look for recurring communication patterns.
 
-1. Please locally check that your changes don't break any tests. The same set of tests will be executed via [GitHub workflows] when you submit your PR.
+1. Please locally check that your changes don't break any tests. The same set of tests will be executed via
+[GitHub workflows] when you submit your PR.
 
    ```sh
    # Run unit tests on your current environment.
    pytest
 
-   # Run unit tests on different tox python environments (this may be slow).
+   # [Optional] Run unit tests on different tox python environments (this may be slow).
    # The tests will also include doctests, and a check that all notebooks execute without errors.
+   # The different environment cases will be tested by automated GitHub workflows, so doing this locally is optional.
    tox
    # If your code introduces new dependencies, use `tox -r`.
    ```
@@ -190,7 +194,34 @@ conda activate temporai
    You can also use [tox] to run several other pre-configured tasks in the
    repository. Try `tox -av` to see a list of the available checks.
 
-1. It is also useful to check that documentation generation succeeds after your changes, run this with the following command, and make sure you do not see any [Sphinx] `WARNING`s or errors.
+   **Note on running tests faster:**
+
+   You may wish to first run a minimal set of tests, as this is faster:
+   ```sh
+   pytest -m "not slow and not extra"
+   ```
+
+   You may also with to use [pytest-xdist] (pre installed with the `[dev]` extra) to distribute test across multiple
+   CPUs to make the test run faster, this requires the following parameters:
+   ```sh
+   # Split tests across all available CPUs (likely not optimal, may also cause running out of memory errors):
+   pytest -n auto
+
+   # Split tests across <INT> max number of CPUs. Optimal value varies by machine / system.
+   pytest -n <INT>
+   # Note that some code is multithreaded under the hood, which means more than <INT> CPUs will be utilized.
+   # From our testing, on a machine with ~20 CPUs (including hyperthreading), value of around 3-6 appears optimal.
+   # Try out different values and monitor your CPUs utilization -
+   # aim to keep high utilization *but below* 100% across all CPUs/threads.
+   ```
+
+   If your system has GPU that supports CUDA, you may wish to skip CPU-only tests for speed:
+   ```sh
+   pytest -m "not cpu"
+   ```
+
+1. It is also useful to check that documentation generation succeeds after your changes, run this with the following
+command, and make sure you do not see any [Sphinx] `WARNING`s or errors.
    ```sh
    tox -e docs
    tox -e doctests
@@ -277,7 +308,8 @@ package:
 
 ### Releases
 
-We now use [GitHub workflows] for releases, see [`release.yml`](https://github.com/vanderschaarlab/temporai/blob/main/.github/workflows/release.yml).
+We now use [GitHub workflows] for releases, see
+[`release.yml`](https://github.com/vanderschaarlab/temporai/blob/main/.github/workflows/release.yml).
 
 
 
@@ -306,6 +338,7 @@ We now use [GitHub workflows] for releases, see [`release.yml`](https://github.c
 [PyScaffold contributor's guide template]: https://github.com/pyscaffold/pyscaffold/blob/835eb4f986e37409d33fdb3f4d150e41ee07a111/src/pyscaffold/templates/contributing.template
 [pyscaffold]: https://pyscaffold.org/
 [pytest can drop you]: https://docs.pytest.org/en/stable/usage.html#dropping-to-pdb-python-debugger-at-the-start-of-a-test
+[pytest-xdist]: https://pytest-xdist.readthedocs.io/en/stable/
 [python software foundation's code of conduct]: https://www.python.org/psf/conduct/
 [restructuredtext]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/
 [sphinx]: https://www.sphinx-doc.org/en/master/
diff --git a/README.md b/README.md
index 62cf0d59..4d11c031 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,8 @@
 
 *TemporAI* is a Machine Learning-centric time-series library for medicine.  The tasks that are currently of focus in TemporAI are: time-series prediction, time-to-event (a.k.a. survival) analysis with time-series data, and counterfactual inference (i.e. \[individualized\] treatment effects).
 
+*TemporAI* provides data preprocessing methods (including scaling and imputation methods for static and temporal covariates). AutoML tools for hyperparameter tuning and pipeline selection are available.
+
 In future versions, the library also aims to provide the user with understanding of their data, model, and problem, through e.g. integration with interpretability methods.
 
 Key concepts:
@@ -83,11 +85,9 @@ model.fit(dataset)
 
 # Impute:
 imputed = model.transform(dataset)
-static_data_n_missing = imputed.static.dataframe().isna().sum().sum()
 temporal_data_n_missing = imputed.time_series.dataframe().isna().sum().sum()
 
 print(static_data_n_missing, temporal_data_n_missing)
-assert static_data_n_missing == 0
 assert temporal_data_n_missing == 0
 ```
 
@@ -130,13 +130,13 @@ Classification task
 ```python
 from tempor.benchmarks import benchmark_models
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 from tempor.utils.dataloaders import SineDataLoader
 
 testcases = [
     (
         "pipeline1",
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.scaling.static.static_minmax_scaler",
                 "prediction.one_off.classification.nn_classifier",
@@ -151,7 +151,7 @@ testcases = [
 dataset = SineDataLoader().load()
 
 aggr_score, per_test_score = benchmark_models(
-    task_type="classification",
+    task_type="prediction.one_off.classification",
     tests=testcases,
     data=dataset,
     n_splits=2,
@@ -175,6 +175,38 @@ reloaded = load(buff)  # Reload model.
 # `save_to_file`, `load_from_file` also available in the serialization module.
 ```
 
+* AutoML
+```python
+from tempor.automl.seeker import PipelineSeeker
+from tempor.utils.dataloaders import SineDataLoader
+
+dataset = SineDataLoader().load()
+
+# Specify the AutoML pipeline seeker for the task of your choice, providing candidate methods, metric,
+# preprocessing steps etc.
+seeker = PipelineSeeker(
+    study_name="my_automl_study",
+    task_type="prediction.one_off.classification",
+    estimator_names=[
+        "cde_classifier",
+        "ode_classifier",
+        "nn_classifier",
+    ],
+    metric="aucroc",
+    dataset=dataset,
+    return_top_k=3,
+    num_iter=100,
+    tuner_type="bayesian",
+    static_imputers=["static_tabular_imputer"],
+    static_scalers=[],
+    temporal_imputers=["ffill", "bfill"],
+    temporal_scalers=["ts_minmax_scaler"],
+)
+
+# The search will return the best pipelines.
+best_pipelines, best_scores = seeker.search()  # doctest: +SKIP
+```
+
 ## 🔑 Methods
 
 
@@ -318,6 +350,7 @@ Treatment effects estimation where treatments are temporal (time series).
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial06_treatments.ipynb) - [Treatment Effects](./tutorials/user_guide/tutorial06_treatments.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial07_pipeline.ipynb) - [Pipeline](./tutorials/user_guide/tutorial07_pipeline.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial08_benchmarks.ipynb) - [Benchmarks](./tutorials/user_guide/tutorial08_benchmarks.ipynb)
+- [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial09_automl.ipynb) - [AutoML](./tutorials/user_guide/tutorial09_automl.ipynb)
 
 ### Extending TemporAI
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/extending/tutorial01_custom_plugin.ipynb) - [Writing a Custom Plugin](./tutorials/extending/tutorial01_custom_plugin.ipynb)
diff --git a/docs/conf.py b/docs/conf.py
index 6b8e4fda..c835650d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -332,6 +332,7 @@
     "hyperimpute": ("https://hyperimpute.readthedocs.io/en/latest/", None),
     "xgbse": ("https://loft-br.github.io/xgboost-survival-embeddings/", None),
     "lifelines": ("https://lifelines.readthedocs.io/en/stable/", None),
+    "optuna": ("https://optuna.readthedocs.io/en/stable/", None),
 }
 
 print(f"loading configurations for {project} {version} ...", file=sys.stderr)
diff --git a/docs/overview.md b/docs/overview.md
index ad43f305..d2af131b 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -12,6 +12,8 @@
 
 *TemporAI* is a Machine Learning-centric time-series library for medicine.  The tasks that are currently of focus in TemporAI are: time-series prediction, time-to-event (a.k.a. survival) analysis with time-series data, and counterfactual inference (i.e. \[individualized\] treatment effects).
 
+*TemporAI* provides data preprocessing methods (including scaling and imputation methods for static and temporal covariates). AutoML tools for hyperparameter tuning and pipeline selection are available.
+
 In future versions, the library also aims to provide the user with understanding of their data, model, and problem, through e.g. integration with interpretability methods.
 
 Key concepts:
@@ -74,11 +76,9 @@ model.fit(dataset)
 
 # Impute:
 imputed = model.transform(dataset)
-static_data_n_missing = imputed.static.dataframe().isna().sum().sum()
 temporal_data_n_missing = imputed.time_series.dataframe().isna().sum().sum()
 
 print(static_data_n_missing, temporal_data_n_missing)
-assert static_data_n_missing == 0
 assert temporal_data_n_missing == 0
 ```
 ```python
@@ -101,11 +101,9 @@ model.fit(dataset)
 
 # Impute:
 imputed = model.transform(dataset)
-static_data_n_missing = imputed.static.dataframe().isna().sum().sum()
 temporal_data_n_missing = imputed.time_series.dataframe().isna().sum().sum()
 
 print(static_data_n_missing, temporal_data_n_missing)
-assert static_data_n_missing == 0
 assert temporal_data_n_missing == 0
 ```
 
@@ -187,13 +185,13 @@ import os; import sys; f = open(os.devnull, 'w'); sys.stdout = f
 
 from tempor.benchmarks import benchmark_models
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 from tempor.utils.dataloaders import SineDataLoader
 
 testcases = [
     (
         "pipeline1",
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.scaling.static.static_minmax_scaler",
                 "prediction.one_off.classification.nn_classifier",
@@ -208,7 +206,7 @@ testcases = [
 dataset = SineDataLoader().load()
 
 aggr_score, per_test_score = benchmark_models(
-    task_type="classification",
+    task_type="prediction.one_off.classification",
     tests=testcases,
     data=dataset,
     n_splits=2,
@@ -220,13 +218,13 @@ print(aggr_score)
 ```python
 from tempor.benchmarks import benchmark_models
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 from tempor.utils.dataloaders import SineDataLoader
 
 testcases = [
     (
         "pipeline1",
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.scaling.static.static_minmax_scaler",
                 "prediction.one_off.classification.nn_classifier",
@@ -241,7 +239,7 @@ testcases = [
 dataset = SineDataLoader().load()
 
 aggr_score, per_test_score = benchmark_models(
-    task_type="classification",
+    task_type="prediction.one_off.classification",
     tests=testcases,
     data=dataset,
     n_splits=2,
@@ -280,6 +278,71 @@ reloaded = load(buff)  # Reload model.
 # `save_to_file`, `load_from_file` also available in the serialization module.
 ```
 
+* AutoML
+```{testcode}
+:hide:
+import os; import sys; f = open(os.devnull, 'w'); sys.stdout = f
+
+from tempor.automl.seeker import PipelineSeeker
+from tempor.utils.dataloaders import SineDataLoader
+
+dataset = SineDataLoader().load()
+
+# Specify the AutoML pipeline seeker for the task of your choice, providing candidate methods, metric,
+# preprocessing steps etc.
+seeker = PipelineSeeker(
+    study_name="my_automl_study",
+    task_type="prediction.one_off.classification",
+    estimator_names=[
+        "cde_classifier",
+        "ode_classifier",
+        "nn_classifier",
+    ],
+    metric="aucroc",
+    dataset=dataset,
+    return_top_k=3,
+    num_iter=100,
+    tuner_type="bayesian",
+    static_imputers=["static_tabular_imputer"],
+    static_scalers=[],
+    temporal_imputers=["ffill", "bfill"],
+    temporal_scalers=["ts_minmax_scaler"],
+)
+
+# The search will return the best pipelines.
+#best_pipelines, best_scores = seeker.search()  
+```
+```python
+from tempor.automl.seeker import PipelineSeeker
+from tempor.utils.dataloaders import SineDataLoader
+
+dataset = SineDataLoader().load()
+
+# Specify the AutoML pipeline seeker for the task of your choice, providing candidate methods, metric,
+# preprocessing steps etc.
+seeker = PipelineSeeker(
+    study_name="my_automl_study",
+    task_type="prediction.one_off.classification",
+    estimator_names=[
+        "cde_classifier",
+        "ode_classifier",
+        "nn_classifier",
+    ],
+    metric="aucroc",
+    dataset=dataset,
+    return_top_k=3,
+    num_iter=100,
+    tuner_type="bayesian",
+    static_imputers=["static_tabular_imputer"],
+    static_scalers=[],
+    temporal_imputers=["ffill", "bfill"],
+    temporal_scalers=["ts_minmax_scaler"],
+)
+
+# The search will return the best pipelines.
+best_pipelines, best_scores = seeker.search()  # doctest: +SKIP
+```
+
 ## 🔑 Methods
 
 
@@ -423,6 +486,7 @@ Treatment effects estimation where treatments are temporal (time series).
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial06_treatments.ipynb) - [Treatment Effects](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial06_treatments.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial07_pipeline.ipynb) - [Pipeline](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial07_pipeline.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial08_benchmarks.ipynb) - [Benchmarks](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial08_benchmarks.ipynb)
+- [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial09_automl.ipynb) - [AutoML](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial09_automl.ipynb)
 
 ### Extending TemporAI
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/extending/tutorial01_custom_plugin.ipynb) - [Writing a Custom Plugin](https://github.com/vanderschaarlab/temporai/tutorials/extending/tutorial01_custom_plugin.ipynb)
diff --git a/docs/pre_build.py b/docs/pre_build.py
index 9dbf4074..2ce217cb 100644
--- a/docs/pre_build.py
+++ b/docs/pre_build.py
@@ -69,6 +69,13 @@
     flags=re.DOTALL,
 )
 # ^ Add a hidden copy of the ```python ... ``` blocks as testcode blocks with output check skipped.
+readme_content = re.sub(
+    r"```{testcode}(.*)\n(.*?)# doctest: \+SKIP(.*?)```python",
+    r"```{testcode}\1\n#\2\3```python",
+    readme_content,
+    flags=re.DOTALL,
+)
+# ^ Comment out any lines that include "# doctest: +SKIP" (in {testcode}```...``` only).
 
 # Make emoji representations compatible with sphinxemoji, e.g. :key: --> |:key:|
 # readme_content = re.sub(r"\:[a-z1-9+\-_]{0,100}\:", (lambda x: f"|{x.group(0)}|"), readme_content)
diff --git a/docs/requirements.txt b/docs/requirements.txt
index e020ed17..26433963 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -12,6 +12,7 @@ importlib-metadata; python_version<"3.8"
 lifelines != 0.27.5
 loguru
 numpy >=1
+optuna >= 2.8.0
 packaging
 pandas >=1
 pandera >=0.13
diff --git a/pypi.md b/pypi.md
index 22beef63..975f8dd4 100644
--- a/pypi.md
+++ b/pypi.md
@@ -29,6 +29,8 @@
 
 *TemporAI* is a Machine Learning-centric time-series library for medicine.  The tasks that are currently of focus in TemporAI are: time-series prediction, time-to-event (a.k.a. survival) analysis with time-series data, and counterfactual inference (i.e. \[individualized\] treatment effects).
 
+*TemporAI* provides data preprocessing methods (including scaling and imputation methods for static and temporal covariates). AutoML tools for hyperparameter tuning and pipeline selection are available.
+
 In future versions, the library also aims to provide the user with understanding of their data, model, and problem, through e.g. integration with interpretability methods.
 
 Key concepts:
@@ -83,11 +85,9 @@ model.fit(dataset)
 
 # Impute:
 imputed = model.transform(dataset)
-static_data_n_missing = imputed.static.dataframe().isna().sum().sum()
 temporal_data_n_missing = imputed.time_series.dataframe().isna().sum().sum()
 
 print(static_data_n_missing, temporal_data_n_missing)
-assert static_data_n_missing == 0
 assert temporal_data_n_missing == 0
 ```
 
@@ -130,13 +130,13 @@ Classification task
 ```python
 from tempor.benchmarks import benchmark_models
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 from tempor.utils.dataloaders import SineDataLoader
 
 testcases = [
     (
         "pipeline1",
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.scaling.static.static_minmax_scaler",
                 "prediction.one_off.classification.nn_classifier",
@@ -151,7 +151,7 @@ testcases = [
 dataset = SineDataLoader().load()
 
 aggr_score, per_test_score = benchmark_models(
-    task_type="classification",
+    task_type="prediction.one_off.classification",
     tests=testcases,
     data=dataset,
     n_splits=2,
@@ -175,6 +175,38 @@ reloaded = load(buff)  # Reload model.
 # `save_to_file`, `load_from_file` also available in the serialization module.
 ```
 
+* AutoML
+```python
+from tempor.automl.seeker import PipelineSeeker
+from tempor.utils.dataloaders import SineDataLoader
+
+dataset = SineDataLoader().load()
+
+# Specify the AutoML pipeline seeker for the task of your choice, providing candidate methods, metric,
+# preprocessing steps etc.
+seeker = PipelineSeeker(
+    study_name="my_automl_study",
+    task_type="prediction.one_off.classification",
+    estimator_names=[
+        "cde_classifier",
+        "ode_classifier",
+        "nn_classifier",
+    ],
+    metric="aucroc",
+    dataset=dataset,
+    return_top_k=3,
+    num_iter=100,
+    tuner_type="bayesian",
+    static_imputers=["static_tabular_imputer"],
+    static_scalers=[],
+    temporal_imputers=["ffill", "bfill"],
+    temporal_scalers=["ts_minmax_scaler"],
+)
+
+# The search will return the best pipelines.
+best_pipelines, best_scores = seeker.search()  # doctest: +SKIP
+```
+
 ## 🔑 Methods
 
 
@@ -318,6 +350,7 @@ Treatment effects estimation where treatments are temporal (time series).
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial06_treatments.ipynb) - [Treatment Effects](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial06_treatments.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial07_pipeline.ipynb) - [Pipeline](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial07_pipeline.ipynb)
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial08_benchmarks.ipynb) - [Benchmarks](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial08_benchmarks.ipynb)
+- [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/user_guide/tutorial09_automl.ipynb) - [AutoML](https://github.com/vanderschaarlab/temporai/tutorials/user_guide/tutorial09_automl.ipynb)
 
 ### Extending TemporAI
 - [![Test In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/vanderschaarlab/temporai/blob/main/tutorials/extending/tutorial01_custom_plugin.ipynb) - [Writing a Custom Plugin](https://github.com/vanderschaarlab/temporai/tutorials/extending/tutorial01_custom_plugin.ipynb)
diff --git a/setup.cfg b/setup.cfg
index a3bd39b7..b098c68b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -68,6 +68,7 @@ install_requires =
     lifelines != 0.27.5
     loguru
     numpy >=1
+    optuna >= 2.8.0
     packaging
     pandas >=1
     pandera >=0.13
diff --git a/src/tempor/automl/__init__.py b/src/tempor/automl/__init__.py
new file mode 100644
index 00000000..ec08afb5
--- /dev/null
+++ b/src/tempor/automl/__init__.py
@@ -0,0 +1,5 @@
+from ._types import OptimDirection
+
+__all__ = [
+    "OptimDirection",
+]
diff --git a/src/tempor/automl/_types.py b/src/tempor/automl/_types.py
new file mode 100644
index 00000000..e0f0b688
--- /dev/null
+++ b/src/tempor/automl/_types.py
@@ -0,0 +1,15 @@
+from typing import Type, Union
+
+from typing_extensions import Literal
+
+from tempor.plugins.core import BasePredictor
+
+from .pipeline_selector import PipelineSelector
+
+OptimDirection = Literal["minimize", "maximize"]
+"""Optimization direction for AutoML (with respect to a metric)"""
+
+AutoMLCompatibleEstimator = Union[Type[BasePredictor], PipelineSelector]
+"""Estimator types supported by AutoML. Either a predictor class,
+or a special `PipelineSelector` object for pipeline search.
+"""
diff --git a/src/tempor/automl/params.py b/src/tempor/automl/params.py
new file mode 100644
index 00000000..aeb12431
--- /dev/null
+++ b/src/tempor/automl/params.py
@@ -0,0 +1,5 @@
+# TODO: Reorganize things so that this pass-through is not necessary.
+
+# pylint: disable=wildcard-import,unused-wildcard-import
+
+from tempor.plugins.core._params import *  # noqa: F401,F403
diff --git a/src/tempor/automl/pipeline_selector.py b/src/tempor/automl/pipeline_selector.py
new file mode 100644
index 00000000..777a2d3b
--- /dev/null
+++ b/src/tempor/automl/pipeline_selector.py
@@ -0,0 +1,250 @@
+from typing import Any, Dict, List, Optional, Tuple, Type
+
+import optuna
+
+from tempor.core import utils
+from tempor.core.types import PredictiveTaskType
+from tempor.plugins import plugin_loader
+from tempor.plugins.core import BaseEstimator, BasePredictor
+from tempor.plugins.core._params import CategoricalParams, Params
+from tempor.plugins.pipeline import PipelineBase, pipeline
+from tempor.plugins.preprocessing.imputation import BaseImputer
+from tempor.plugins.preprocessing.scaling import BaseScaler
+
+PREFIX_STATIC_IMPUTERS = "preprocessing.imputation.static"
+PREFIX_STATIC_SCALERS = "preprocessing.scaling.static"
+PREFIX_TEMPORAL_IMPUTERS = "preprocessing.imputation.temporal"
+PREFIX_TEMPORAL_SCALERS = "preprocessing.scaling.temporal"
+
+DEFAULT_STATIC_IMPUTERS = plugin_loader.list()["preprocessing"]["imputation"]["static"]
+DEFAULT_STATIC_SCALERS = plugin_loader.list()["preprocessing"]["scaling"]["static"]
+DEFAULT_TEMPORAL_IMPUTERS = plugin_loader.list()["preprocessing"]["imputation"]["temporal"]
+DEFAULT_TEMPORAL_SCALERS = plugin_loader.list()["preprocessing"]["scaling"]["temporal"]
+
+
+def get_fqn(prefix: str, name: str) -> str:
+    """Return a full plugin name from category / task type prefix and plugin name, that is ``{prefix}.{name}``."""
+    return f"{prefix}.{name}"
+
+
+class PipelineSelector:
+    def __init__(
+        self,
+        task_type: PredictiveTaskType,
+        predictor: str,
+        static_imputers: List[str] = DEFAULT_STATIC_IMPUTERS,
+        static_scalers: List[str] = DEFAULT_STATIC_SCALERS,
+        temporal_imputers: List[str] = DEFAULT_TEMPORAL_IMPUTERS,
+        temporal_scalers: List[str] = DEFAULT_TEMPORAL_SCALERS,
+    ) -> None:
+        """A helper class for AutoML pipeline selection.
+
+        Defines custom version of methods:
+            - ``hyperparameter_space``,
+            - ``sample_hyperparameters``.
+
+        Adds methods to create a pipeline from sampled hyperparameters:
+            - ``pipeline_class_from_hps``,
+            - ``pipeline_from_hps``.
+
+        Provides the tools to create candidate pipelines where the imputers / scalers are exposed as a categorical
+        hyperparameter in the hyperparameter space. The hyperparameter space of these, and the final predictor step
+        are also sampled.
+
+        Args:
+            task_type (PredictiveTaskType):
+                The task type of the predictors.
+            predictor (str):
+                The predictor estimator to be used as the last stage of the pipelines.
+            static_imputers (List[str], optional):
+                A list of candidate static imputers. Defaults to `DEFAULT_STATIC_IMPUTERS`.
+            static_scalers (List[str], optional):
+                A list of candidate static scalers. Defaults to `DEFAULT_STATIC_SCALERS`.
+            temporal_imputers (List[str], optional):
+                A list of candidate temporal imputers. Defaults to `DEFAULT_TEMPORAL_IMPUTERS`.
+            temporal_scalers (List[str], optional):
+                A list of candidate temporal scalers. Defaults to `DEFAULT_TEMPORAL_SCALERS`.
+        """
+        self.task_type: PredictiveTaskType = task_type
+
+        self.static_imputers: List[Type[BaseImputer]] = [
+            plugin_loader.get_class(get_fqn(PREFIX_STATIC_IMPUTERS, p)) for p in static_imputers
+        ]
+        self.static_scalers: List[Type[BaseScaler]] = [
+            plugin_loader.get_class(get_fqn(PREFIX_STATIC_SCALERS, p)) for p in static_scalers
+        ]
+        self.temporal_imputers: List[Type[BaseImputer]] = [
+            plugin_loader.get_class(get_fqn(PREFIX_TEMPORAL_IMPUTERS, p)) for p in temporal_imputers
+        ]
+        self.temporal_scalers: List[Type[BaseScaler]] = [
+            plugin_loader.get_class(get_fqn(PREFIX_TEMPORAL_SCALERS, p)) for p in temporal_scalers
+        ]
+
+        self.predictor: Type[BasePredictor] = plugin_loader.get_class(get_fqn(str(self.task_type), predictor))
+
+    def _preproc_candidate_lists(self) -> List[List[Type[BaseEstimator]]]:
+        list_ = [self.static_imputers, self.static_scalers, self.temporal_imputers, self.temporal_scalers]
+        return list_  # type: ignore
+
+    def _preproc_prefixes(self) -> List[str]:
+        return [PREFIX_STATIC_IMPUTERS, PREFIX_STATIC_SCALERS, PREFIX_TEMPORAL_IMPUTERS, PREFIX_TEMPORAL_SCALERS]
+
+    @staticmethod
+    def format_hps_names(plugin: Any, hps: List[Params]) -> List[Params]:
+        """Format hyperparameter space of a plugin by giving each `Param` name as
+        ``[plugin.name](hyperparameter.name)``. Necessary for differentiating hyperparameters of different stages in
+        the pipeline.
+
+        Args:
+            plugin (Any):
+                Plugin (method).
+            hps (List[Params]):
+                Hyperparameter space definition.
+
+        Returns:
+            List[Params]: Updated hyperparameter space definition.
+        """
+        for hp in hps:
+            hp.name = f"[{plugin.name}]({hp.name})"
+        return hps
+
+    @staticmethod
+    def _generate_candidates_param(candidates: List[Type[BaseEstimator]]) -> CategoricalParams:
+        """Creates a `CategoricalParams` for choosing between the ``candidates`` models (used for the preprocessing
+        steps of the pipeline).
+
+        Args:
+            candidates (List[Type[BaseEstimator]]):
+                List of candidate plugins (methods).
+
+        Returns:
+            CategoricalParams: The `CategoricalParams` with the candidate names as ``choices`` and\
+                named ``"<candidates>(category)"`` where ``category`` is plugin category (task type).
+        """
+        if candidates:
+            category = candidates[0].category
+            return CategoricalParams(name=f"<candidates>({category})", choices=[p.name for p in candidates])
+        else:  # pragma: no cover
+            # Should not reach here in normal use.
+            raise RuntimeError("Must pass a list of candidates with at least one item")
+
+    def hyperparameter_space(
+        self,
+        *args,
+        override: Optional[List[Params]] = None,  # NOTE: Hyperparameter override applies to predictor only.
+        **kwargs,
+    ) -> List[Params]:
+        """The customized ``hyperparameter_space`` implementation. The hyperparameter space is built up of the
+        hyperparameters of each pipeline steps (the preprocessors, and the final predictive step). ``CategoricalParams``
+        for choosing which preprocessor to use are also added (if at least one ``static_imputer``, ... are provided).
+        Parameter names are customized to be able to differentiate pipeline stages (see ``format_hps_names``).
+
+        Args:
+            override (Optional[List[Params]], optional):
+                Hyperparameter space override for the final predictive step of the pipeline. Defaults to `None`.
+
+        Returns:
+            List[Params]: Returned hyperparameter space.
+        """
+        hps: List[Params] = []
+
+        for plugin_cls_list in self._preproc_candidate_lists():
+            if plugin_cls_list:
+                hps.append(self._generate_candidates_param(plugin_cls_list))
+            for plugin in plugin_cls_list:
+                hps.extend(self.format_hps_names(plugin, plugin.hyperparameter_space(*args, **kwargs)))
+
+        if override is not None:
+            hps.extend(self.format_hps_names(self.predictor, override))
+        else:
+            hps.extend(self.format_hps_names(self.predictor, self.predictor.hyperparameter_space(*args, **kwargs)))
+
+        return hps
+
+    def sample_hyperparameters(
+        self,
+        *args: Any,
+        override: Optional[List[Params]] = None,  # NOTE: Hyperparameter override applies to predictor only.
+        **kwargs: Any,
+    ) -> Dict[str, Any]:
+        """The customized ``sample_hyperparameters`` implementation. Uses the customized ``hyperparameter_space``.
+
+        Args:
+            override (Optional[List[Params]], optional):
+                Hyperparameter space override for the final predictive step of the pipeline. Defaults to `None`.
+
+        Returns:
+            Dict[str, Any]: Sampled hyperparameters dictionary.
+        """
+        # NOTE: The inner workings of this method need to stay roughly consistent with
+        # `BaseEstimator.sample_hyperparameters`.
+
+        # Pop the `trial` argument for optuna if such is found (if not, `trial` will be `None`).
+        trial, args, kwargs = utils.get_from_args_or_kwargs(
+            args, kwargs, argument_name="trial", argument_type=optuna.Trial, position_if_args=0
+        )
+
+        param_space = self.hyperparameter_space(*args, override=override, **kwargs)
+
+        results = dict()
+
+        for hp in param_space:
+            results[hp.name] = hp.sample(trial)
+
+        return results
+
+    @staticmethod
+    def _get_relevant_hps(plugin_name: str, hps: Dict[str, Any]) -> Dict[str, Any]:
+        """Return a dictionary of sampled hyperparameters for a particular pipeline step (the step for ``plugin_name``),
+        from the dictionary of hyperparameters for the whole pipeline (``hps``). The customized hyperparameter names
+        (see ``format_hps_names``) are turned back to normal hyperparameter names.
+
+        Args:
+            plugin_name (str):
+                The plugin name corresponding to one of the stages in the pipeline.
+            hps (Dict[str, Any]):
+                The sampled hyperparameters for the entire pipeline.
+
+        Returns:
+            Dict[str, Any]: Returned hyperparameters for ``plugin_name``.
+        """
+        return {k.split("(")[-1][:-1]: v for k, v in hps.items() if f"[{plugin_name}]" in k}
+
+    def pipeline_class_from_hps(self, hps: Dict[str, Any]) -> Tuple[Type[PipelineBase], Dict[str, Dict]]:
+        """Return a pipeline class from the sampled hyperparameters ``hps``.
+
+        Args:
+            hps (Dict[str, Any]): The sampled hyperparameters for the pipeline.
+
+        Returns:
+            Tuple[Type[PipelineBase], Dict[str, Dict]]:
+                ``(pipeline_cls, pipeline_params)``, the pipeline class and the params to initialize the pipeline with.
+        """
+        pipeline_def: List[str] = []
+        pipeline_init_params: Dict[str, Dict] = dict()
+
+        for candidates_list, prefix in zip(self._preproc_candidate_lists(), self._preproc_prefixes()):
+            if candidates_list:
+                selected = hps[f"<candidates>({prefix})"]
+                selected_hps = self._get_relevant_hps(selected, hps)
+                pipeline_def.append(get_fqn(prefix, selected))
+                pipeline_init_params[selected] = selected_hps
+
+        pipeline_def.append(self.predictor.fqn())
+        predictor_hps = self._get_relevant_hps(self.predictor.name, hps)
+        pipeline_init_params[self.predictor.name] = predictor_hps
+
+        return pipeline(pipeline_def), pipeline_init_params
+
+    def pipeline_from_hps(self, hps: Dict[str, Any]) -> PipelineBase:
+        """Return a pipeline instance from the sampled hyperparameters ``hps``.
+
+        Args:
+            hps (Dict[str, Any]): The sampled hyperparameters for the pipeline.
+
+        Returns:
+            PipelineBase: The pipeline.
+        """
+        PipelineCls, pipeline_init_params = self.pipeline_class_from_hps(hps)
+
+        return PipelineCls(pipeline_init_params)
diff --git a/src/tempor/automl/seeker.py b/src/tempor/automl/seeker.py
new file mode 100644
index 00000000..1ee129da
--- /dev/null
+++ b/src/tempor/automl/seeker.py
@@ -0,0 +1,755 @@
+"""Module containing the interface for, and the implemented hyperparameter seekers."""
+
+import abc
+import copy
+import functools
+import warnings
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
+
+import numpy as np
+import optuna
+import pydantic
+from typing_extensions import Literal, Type, get_args
+
+from tempor.benchmarks import evaluation
+from tempor.core.types import PredictiveTaskType
+from tempor.data import data_typing
+from tempor.data.dataset import PredictiveDataset, TimeToEventAnalysisDataset
+from tempor.log import logger
+from tempor.plugins import plugin_loader
+from tempor.plugins.core import BasePredictor
+from tempor.plugins.core._params import Params
+
+from ._types import AutoMLCompatibleEstimator, OptimDirection
+from .pipeline_selector import (
+    DEFAULT_STATIC_IMPUTERS,
+    DEFAULT_STATIC_SCALERS,
+    DEFAULT_TEMPORAL_IMPUTERS,
+    DEFAULT_TEMPORAL_SCALERS,
+    PipelineSelector,
+)
+from .tuner import BaseTuner, OptunaTuner
+
+TunerType = Literal[
+    "bayesian",
+    "random",
+    "cmaes",
+    "qmc",
+    "grid",
+]
+"""Hyperparameter tuner to use.
+
+Available options:
+  - `"bayesian"`: Use a tuner based on `optuna.samplers.TPESampler`.
+  - `"random"`: Use a tuner based on `optuna.samplers.RandomSampler`.
+  - `"cmaes"`: Use a tuner based on `optuna.samplers.CmaEsSampler`.
+  - `"qmc"`: Use a tuner based on `optuna.samplers.QMCSampler`.
+  - `"grid"`: Use a tuner based on `optuna.samplers.GridSampler`.
+"""
+
+SupportedMetric = Union[
+    evaluation.ClassifierSupportedMetric,
+    evaluation.RegressionSupportedMetric,
+    evaluation.TimeToEventSupportedMetric,
+]
+"""The type denoting all metrics supported by ``Seeker`` classes"""
+
+TUNER_OPTUNA_SAMPLER_MAP: Dict[TunerType, Any] = {
+    "bayesian": optuna.samplers.TPESampler,
+    "random": optuna.samplers.RandomSampler,
+    "cmaes": optuna.samplers.CmaEsSampler,
+    "qmc": optuna.samplers.QMCSampler,
+    "grid": optuna.samplers.GridSampler,
+}
+"""A map from `TunerType` to the corresponding `optuna` sampler class"""
+
+METRIC_DIRECTION_MAP: Dict[SupportedMetric, OptimDirection] = {
+    # ClassifierSupportedMetric:
+    "aucroc": "maximize",
+    "aucprc": "maximize",
+    "accuracy": "maximize",
+    "f1_score_micro": "maximize",
+    "f1_score_macro": "maximize",
+    "f1_score_weighted": "maximize",
+    "kappa": "maximize",
+    "kappa_quadratic": "maximize",
+    "precision_micro": "maximize",
+    "precision_macro": "maximize",
+    "precision_weighted": "maximize",
+    "recall_micro": "maximize",
+    "recall_macro": "maximize",
+    "recall_weighted": "maximize",
+    "mcc": "maximize",
+    # RegressionSupportedMetric:
+    "mse": "minimize",
+    "mae": "minimize",
+    "r2": "maximize",
+    # TimeToEventMetricCallable:
+    "c_index": "maximize",
+    "brier_score": "minimize",
+}
+"""A map from metric (`SupportedMetric`) to its optimization direction (`OptimDirection`)"""
+
+# TODO: Parallelism support (per-estimator).
+
+
+def evaluation_callback_dispatch(
+    estimator: Type[BasePredictor],
+    dataset: PredictiveDataset,
+    task_type: PredictiveTaskType,
+    metric: SupportedMetric,
+    n_cv_folds: int,
+    random_state: int,
+    horizon: Optional[data_typing.TimeIndex],
+    raise_exceptions: bool,
+    *args,
+    **kwargs,
+) -> float:
+    """Perform evaluation of ``estimator`` (of task type ``task_type``) on ``dataset``, using the appropriate
+    evaluation function from the `tempor.benchmarks.evaluation` module.
+
+    Args:
+        estimator (Type[BasePredictor]):
+            The predictor estimator class to use.
+        dataset (PredictiveDataset):
+            The dataset to use.
+        task_type (PredictiveTaskType):
+            The task type of the predictor.
+        metric (SupportedMetric):
+            The metric to be used for evaluation.
+        n_cv_folds (int):
+            Number of cross-validation folds to use.
+        random_state (int):
+            Random state used for data splitting.
+        horizon (Optional[data_typing.TimeIndex]):
+            The prediction horizon. Applicable to the "time_to_event" task case.
+        raise_exceptions (bool):
+            If set to `True`, if an exception is raised during evaluation, this will be raised and execution will be
+            terminated. Otherwise the exception will be ignored and a dummy value returned.
+
+    Returns:
+        float: The mean evaluation metric across the cross-validation folds.
+    """
+    model = estimator(*args, **kwargs)
+    # TODO: Handle missing cases.
+    if task_type == "prediction.one_off.classification":
+        metrics = evaluation.evaluate_prediction_oneoff_classifier(
+            model,
+            dataset,
+            n_splits=n_cv_folds,
+            random_state=random_state,
+            raise_exceptions=raise_exceptions,
+        )
+    elif task_type == "prediction.one_off.regression":
+        metrics = evaluation.evaluate_prediction_oneoff_regressor(
+            model,
+            dataset,
+            n_splits=n_cv_folds,
+            random_state=random_state,
+            raise_exceptions=raise_exceptions,
+        )
+    elif task_type == "prediction.temporal.classification":  # pragma: no cover
+        raise NotImplementedError
+    elif task_type == "prediction.temporal.regression":  # pragma: no cover
+        raise NotImplementedError
+    elif task_type == "time_to_event":
+        if not isinstance(dataset, TimeToEventAnalysisDataset):
+            raise ValueError(
+                f"`dataset` must be of type {TimeToEventAnalysisDataset.__name__} for the '{task_type}' task"
+            )
+        if horizon is None:
+            raise ValueError(f"`horizon` must not be None for the '{task_type}' task")
+        metrics = evaluation.evaluate_time_to_event(
+            model,
+            dataset,
+            horizons=horizon,
+            n_splits=n_cv_folds,
+            random_state=random_state,
+            raise_exceptions=raise_exceptions,
+        )
+    elif task_type == "treatments.one_off.classification":  # pragma: no cover
+        raise NotImplementedError
+    elif task_type == "treatments.one_off.regression":  # pragma: no cover
+        raise NotImplementedError
+    elif task_type == "treatments.temporal.classification":  # pragma: no cover
+        raise NotImplementedError
+    elif task_type == "treatments.temporal.regression":  # pragma: no cover
+        raise NotImplementedError
+    else:  # pragma: no cover
+        raise ValueError(f"Unknown evaluation case: {task_type}")
+    return metrics.loc[metric, "mean"]  # pyright: ignore
+
+
+class BaseSeeker(abc.ABC):
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def __init__(
+        self,
+        study_name: str,
+        task_type: PredictiveTaskType,
+        estimator_names: List[str],
+        estimator_defs: List[Any],
+        metric: SupportedMetric,
+        dataset: PredictiveDataset,
+        *,
+        return_top_k: int = 3,
+        num_cv_folds: int = 5,
+        num_iter: int = 100,
+        tuner_patience: int = 5,
+        tuner_type: TunerType = "bayesian",
+        timeout: int = 360,
+        random_state: int = 0,
+        override_hp_space: Optional[Dict[str, List[Params]]] = None,
+        horizon: Optional[data_typing.TimeIndex] = None,
+        compute_baseline_score: bool = False,
+        grid: Optional[Dict[str, Dict[str, Any]]] = None,
+        custom_tuner: Optional[BaseTuner] = None,
+        raise_exceptions: bool = True,
+        **kwargs,  # pylint: disable=unused-argument
+    ) -> None:
+        """The base class for an AutoML Seeker, to be derived from by concrete implementations. Provides an AutoML
+        interface, in particular, the ``search`` method.
+
+        Args:
+            study_name (str):
+                The name of the AutoML study (that is, the set of all individual AutoML trials).
+            task_type (PredictiveTaskType):
+                The task type of the predictor estimators to be searched.
+            estimator_names (List[str]):
+                Friendly names of estimators. Will be passed one-by-one to ``_init_estimator`` method calls.
+            estimator_defs (List[Any]):
+                Definition of estimators. Will be passed one-by-one to ``_init_estimator`` method calls.
+            metric (SupportedMetric):
+                The metric to use for evaluation.
+            dataset (PredictiveDataset):
+                The dataset to use for evaluation.
+            return_top_k (int, optional):
+                How many best estimators to return. Defaults to ``3``.
+            num_cv_folds (int, optional):
+                How many cross-validation folds to use. Defaults to ``5``.
+            num_iter (int, optional):
+                Number of AutoML iterations. Defaults to ``100``.
+            tuner_patience (int, optional):
+                Patience of the AutoML tuner (for early-stopping). Defaults to ``5``.
+            tuner_type (TunerType, optional):
+                The type of AutoML tuner to use. Defaults to ``"bayesian"``.
+            timeout (int, optional):
+                AutoML optimization run time out (seconds). Defaults to ``360``.
+            random_state (int, optional):
+                Random state to use. Defaults to ``0``.
+            override_hp_space (Optional[Dict[str, List[Params]]], optional):
+                A dictionary with ``estimator_names`` keys and the hyperparameter space overrides values, e.g.
+                ``{"my_estimator_A": [IntegerParams("some_param", low=1, high=100), ...], "my_estimator_B": ....}``.
+                Defaults to `None`.
+            horizon (Optional[data_typing.TimeIndex], optional):
+                The prediction horizon for evaluation. Applicable to the "time_to_event" task case. Defaults to `None`.
+            compute_baseline_score (bool, optional):
+                (If supported by the ``Seeker`` implementation.) Whether to run a baseline trial and compute its score.
+                A baseline trial is a trial with all the default parameters. Defaults to `False`.
+            grid (Optional[Dict[str, Dict[str, Any]]], optional):
+                (If supported by the ``Seeker`` implementation; only relevant to `"grid"` tuner type) The grid for the
+                grid search tuner type. Keys are ``estimator_names`, values are
+                ``(param_name: str -> List[param_value_candidate: Any])``. Defaults to `None`.
+            custom_tuner (Optional[BaseTuner], optional):
+                Pass a ``custom_tuner`` to override the default AutoML tuner for ``tuner_type``. Defaults to `None`.
+            raise_exceptions (bool, optional):
+                 If set to `True`, if an exception is raised during AutoML study run, this will be raised and
+                 execution will be terminated. Otherwise the exception will be ignored. Defaults to `True`.
+
+        Raises:
+            ValueError: If incompatible / invalid input arguments have been passed.
+        """
+        if override_hp_space is None:
+            override_hp_space = dict()
+
+        self.study_name = study_name
+        self.task_type: PredictiveTaskType = task_type
+        self.metric: SupportedMetric = metric
+        self.dataset = dataset
+        self.return_top_k = return_top_k
+        self.num_cv_folds = num_cv_folds
+        self.num_iter = num_iter
+        self.tuner_patience = tuner_patience
+        self.tuner_type: TunerType = tuner_type
+        self.timeout = timeout
+        self.random_state = random_state
+        self.horizon = horizon
+        self.compute_baseline_score = compute_baseline_score
+        self.grid = grid
+        self.raise_exceptions = raise_exceptions
+
+        if len(estimator_defs) != len(estimator_names):
+            raise ValueError("`estimator_defs` and `estimator_names` must be the same length.")
+        self.estimator_names = estimator_names
+        self.estimator_defs = estimator_defs
+
+        # Validate "grid" case:
+        if self.tuner_type == "grid":
+            if self.grid is None:
+                raise ValueError("Must provide `grid` argument when using the 'grid' search method")
+            if self.estimator_names != list(self.grid.keys()):
+                raise ValueError(
+                    f"`grid` keys ({list(self.grid.keys())}) did not match " f"estimator names ({self.estimator_names})"
+                )
+
+        # Validate and set override_hp_space:
+        for key in override_hp_space.keys():
+            if key not in self.estimator_names:
+                raise ValueError(
+                    f"Estimator name '{key}' in `override_hp_space` was found that did not "
+                    f"correspond to any of the estimators provided: {self.estimator_names}"
+                )
+        self.override_hp_space = override_hp_space
+
+        # Validate custom tuner.
+        if isinstance(custom_tuner, OptunaTuner):
+            if isinstance(custom_tuner.sampler, optuna.samplers.GridSampler):
+                raise ValueError("Passing a custom tuner with `optuna.samplers.GridSampler` is not supported")
+        self.custom_tuner = custom_tuner
+
+        self.direction: OptimDirection = METRIC_DIRECTION_MAP[self.metric]
+        self.estimators: List[AutoMLCompatibleEstimator] = []
+        self.tuners: List[BaseTuner] = []
+
+        self._set_up_tuners()
+
+    @abc.abstractmethod
+    def _init_estimator(self, estimator_name: str, estimator_def: Any) -> AutoMLCompatibleEstimator:  # pragma: no cover
+        """Abstract method for overriding by concrete ``Seeker`` implementations. Create an AutoML estimator based on
+        its name (``estimator_name``) and any other information provided in its definition (``estimator_def``).
+
+        Args:
+            estimator_name (str): Estimator friendly name.
+            estimator_def (Any): Estimator definition - can be any data needed for creating the estimator.
+
+        Returns:
+            AutoMLCompatibleEstimator: The created estimator.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    @abc.abstractmethod
+    def _create_estimator_with_hps(
+        self,
+        estimator_cls: AutoMLCompatibleEstimator,
+        hps: Dict[str, Any],
+        score: float,
+    ) -> BasePredictor:  # pragma: no cover
+        """Abstract method for overriding by concrete ``Seeker`` implementations. Create a concrete `BasePredictor`
+        instance based on the hyperparameters provided. Used when a trial is completed and a set of parameters has
+        been sampled.
+
+        Args:
+            estimator_cls (AutoMLCompatibleEstimator):
+                The AutoML-compatible estimator to facilitate the creation of the instance.
+            hps (Dict[str, Any]):
+                Hyperparameter dictionary.
+            score (float):
+                The score corresponding to this trail (for information).
+
+        Returns:
+            BasePredictor: Predictor instance to return.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    def _set_up_tuners(self):
+        logger.info(f"Setting up estimators and tuners for study {self.study_name}.")
+        for estimator_name, estimator_def in zip(self.estimator_names, self.estimator_defs):
+            # Set up estimator.
+            EstimatorCls = self._init_estimator(estimator_name=estimator_name, estimator_def=estimator_def)
+            self.estimators.append(EstimatorCls)
+
+            # Set up tuner.
+            if self.custom_tuner is None:
+                # Case: Default tuners based on tuner_type etc.
+                # Pruner:
+                with warnings.catch_warnings():
+                    warnings.filterwarnings("ignore", category=optuna.exceptions.ExperimentalWarning)
+                    pruner = optuna.pruners.PatientPruner(None, self.tuner_patience)
+                # Sampler:
+                with warnings.catch_warnings():
+                    warnings.filterwarnings("ignore", category=optuna.exceptions.ExperimentalWarning)
+                    SamplerCls = TUNER_OPTUNA_SAMPLER_MAP[self.tuner_type]
+                    if self.tuner_type != "grid":
+                        sampler = SamplerCls(seed=self.random_state)
+                    else:
+                        if TYPE_CHECKING:  # pragma: no cover
+                            assert self.grid is not None  # nosec B101
+                        sampler = SamplerCls(seed=self.random_state, search_space=self.grid[estimator_name])
+                # Instantiate:
+                if self.tuner_type in TUNER_OPTUNA_SAMPLER_MAP:
+                    tuner = OptunaTuner(
+                        study_name=f"{self.study_name}_{estimator_name}",
+                        direction=self.direction,
+                        study_sampler=sampler,
+                        study_storage=None,
+                        study_pruner=pruner,
+                        study_load_if_exists=False,
+                    )
+                    self.tuners.append(tuner)
+                else:  # pragma: no cover
+                    # Should not get here as there is pydantic validation.
+                    raise ValueError(f"Unsupported tuner type: {self.tuner_type}. Available: {get_args(TunerType)}.")
+            else:
+                # Case: Custom tuner.
+                # Copy the tuner for each estimator.
+                if TYPE_CHECKING:  # pragma: no cover
+                    assert isinstance(self.custom_tuner, OptunaTuner)  # nosec B101
+                study_name = f"{self.study_name}_{estimator_name}"
+                custom_tuner = copy.deepcopy(self.custom_tuner)
+                custom_tuner.study_name = study_name  # Update the study name to be unique.
+                custom_tuner.create_study()
+                self.tuners.append(custom_tuner)
+
+    def search(self) -> Tuple[List[BasePredictor], List[float]]:
+        """Perform AutoML search.
+
+        Returns:
+            Tuple[List[BasePredictor], List[float]]:
+                ``(best_estimators, best_scores)``, the best estimators and the corresponding base scores returned.
+        """
+        search_results: List[Tuple[List[float], List[Dict]]] = []
+        for idx, (estimator_name, estimator_cls, tuner) in enumerate(
+            zip(self.estimator_names, self.estimators, self.tuners)
+        ):
+            logger.info(f"Running  search for estimator '{estimator_name}' {idx+1}/{len(self.estimators)}.")
+
+            if estimator_name in self.override_hp_space:
+                override = self.override_hp_space[estimator_name]
+            else:
+                override = None
+
+            estimator_results = tuner.tune(
+                estimator=estimator_cls,
+                dataset=self.dataset,
+                evaluation_callback=functools.partial(
+                    evaluation_callback_dispatch,
+                    task_type=self.task_type,
+                    metric=self.metric,
+                    n_cv_folds=self.num_cv_folds,
+                    random_state=self.random_state,
+                    horizon=self.horizon,
+                    raise_exceptions=self.raise_exceptions,
+                ),
+                override_hp_space=override,
+                compute_baseline_score=self.compute_baseline_score,
+                # NOTE: The below is OptunaTuner-only kwarg:
+                optimize_kwargs=dict(n_trials=self.num_iter, timeout=self.timeout),
+            )
+            search_results.append(estimator_results)
+
+        all_estimators = []
+        all_scores = []
+        all_hps = []
+        for idx, (scores, hps) in enumerate(search_results):
+            best_idx = np.argmin(scores) if self.direction == "minimize" else np.argmax(scores)
+            all_scores.append(scores[best_idx])
+            all_hps.append(hps[best_idx])
+            all_estimators.append(self.estimators[idx])
+            logger.info(f"Evaluation for {self.estimator_names[idx]} scores:\n{scores}.")
+        logger.info(f"All estimator definitions searched:\n{self.estimator_names}")
+        logger.info(f"Best scores for each estimator searched:\n{all_scores}")
+        logger.info(f"Best hyperparameters for each estimator searched:\n{all_hps}")
+
+        all_scores_np = np.array(all_scores)
+        top_k = min(self.return_top_k, len(all_scores))
+        unique_sorted = np.sort(np.unique(all_scores_np.ravel()))
+        if self.direction == "minimize":
+            unique_sorted = unique_sorted[::-1]
+        top_k_scores = unique_sorted[-top_k:]
+
+        result: List[Any] = []
+        result_scores: List[float] = []
+        for score in reversed(top_k_scores):
+            result_scores.append(score)
+            idx = np.argwhere(all_scores_np == score)[0][0]
+            estimator_top_k = self._create_estimator_with_hps(all_estimators[idx], all_hps[idx], score)
+            result.append(estimator_top_k)
+
+        return result, result_scores
+
+
+class MethodSeeker(BaseSeeker):
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def __init__(
+        self,
+        study_name: str,
+        task_type: PredictiveTaskType,
+        estimator_names: List[str],
+        metric: SupportedMetric,
+        dataset: PredictiveDataset,
+        *,
+        return_top_k: int = 3,
+        num_cv_folds: int = 5,
+        num_iter: int = 100,
+        tuner_patience: int = 5,
+        tuner_type: TunerType = "bayesian",
+        timeout: int = 360,
+        random_state: int = 0,
+        override_hp_space: Optional[Dict[str, List[Params]]] = None,
+        horizon: Optional[data_typing.TimeIndex] = None,
+        compute_baseline_score: bool = False,
+        grid: Optional[Dict[str, Dict[str, Any]]] = None,
+        custom_tuner: Optional[BaseTuner] = None,
+        raise_exceptions: bool = True,
+        **kwargs,
+    ) -> None:
+        """An AutoML seeker which will search the hyperparameter space of each of the predictor estimators defined in
+        ``estimator_names`` for the ``task_type`` task setting.
+
+        Args:
+            study_name (str):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            task_type (PredictiveTaskType):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            estimator_names (List[str]):
+                The candidate predictors. Provide plugin names (without category qualification), e.g. like
+                ``["nn_classifier", "cde_classifier"]``.
+            metric (SupportedMetric):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            dataset (PredictiveDataset):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            return_top_k (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            num_cv_folds (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            num_iter (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            tuner_patience (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            tuner_type (TunerType, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            timeout (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            random_state (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            override_hp_space (Optional[Dict[str, List[Params]]], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            horizon (Optional[data_typing.TimeIndex], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            compute_baseline_score (bool, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            grid (Optional[Dict[str, Dict[str, Any]]], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            custom_tuner (Optional[BaseTuner], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            raise_exceptions (bool, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+        """
+        estimator_defs = estimator_names
+
+        super().__init__(
+            study_name,
+            task_type,
+            estimator_names,
+            estimator_defs,
+            metric,
+            dataset,
+            return_top_k=return_top_k,
+            num_cv_folds=num_cv_folds,
+            num_iter=num_iter,
+            tuner_patience=tuner_patience,
+            tuner_type=tuner_type,
+            timeout=timeout,
+            random_state=random_state,
+            override_hp_space=override_hp_space,
+            horizon=horizon,
+            compute_baseline_score=compute_baseline_score,
+            grid=grid,
+            custom_tuner=custom_tuner,
+            raise_exceptions=raise_exceptions,
+            **kwargs,
+        )
+
+    def _init_estimator(self, estimator_name: str, estimator_def: Any) -> AutoMLCompatibleEstimator:
+        logger.info(f"Creating estimator {estimator_name}.")
+        estimator_fqn = f"{self.task_type}.{estimator_def}"
+        return plugin_loader.get_class(estimator_fqn)
+
+    def _create_estimator_with_hps(
+        self, estimator_cls: AutoMLCompatibleEstimator, hps: Dict[str, Any], score: float
+    ) -> BasePredictor:
+        estimator_cls = cast(Type[BasePredictor], estimator_cls)
+        logger.info(f"Selected score {score} for {estimator_cls.name} with hyperparameters:\n{hps}")
+        return estimator_cls(**hps)
+
+
+class PipelineSeeker(BaseSeeker):
+    @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def __init__(
+        self,
+        study_name: str,
+        task_type: PredictiveTaskType,
+        estimator_names: List[str],
+        metric: SupportedMetric,
+        dataset: PredictiveDataset,
+        *,
+        static_imputers: List[str] = DEFAULT_STATIC_IMPUTERS,
+        static_scalers: List[str] = DEFAULT_STATIC_SCALERS,
+        temporal_imputers: List[str] = DEFAULT_TEMPORAL_IMPUTERS,
+        temporal_scalers: List[str] = DEFAULT_TEMPORAL_SCALERS,
+        return_top_k: int = 3,
+        num_cv_folds: int = 5,
+        num_iter: int = 100,
+        tuner_patience: int = 5,
+        tuner_type: TunerType = "bayesian",
+        timeout: int = 360,
+        random_state: int = 0,
+        override_hp_space: Optional[Dict[str, List[Params]]] = None,
+        horizon: Optional[data_typing.TimeIndex] = None,
+        compute_baseline_score: bool = False,
+        grid: Optional[Dict[str, Dict[str, Any]]] = None,
+        custom_tuner: Optional[BaseTuner] = None,
+        raise_exceptions: bool = True,
+        **kwargs,
+    ) -> None:
+        """An AutoML seeker which will sample pipelines comprised of:
+            - A static imputer (if at lease one candidate in ``static_imputers`` provided)
+            - A static scaler (if at lease one candidate in ``static_scalers`` provided)
+            - A temporal imputer (if at lease one candidate in ``temporal_imputers`` provided)
+            - A temporal scaler (if at lease one candidate in ``temporal_scalers`` provided)
+            - The final predictor, from the ``estimator_names`` options.
+
+        The imputer/scaler candidates will be sampled as a categorical hyperparameter. The hyperparameter spaces of
+        these, and of the final predictor, will be sampled.
+
+        Note:
+            - ``compute_baseline_score=True`` is not supported, as the pipeline is dynamic and there is no defined\
+                baseline case.
+            - ``tuner_type="grid"`` is not currently supported.
+
+        Args:
+            study_name (str):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            task_type (PredictiveTaskType):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            estimator_names (List[str]):
+                The candidate predictors that will be the last step of the pipeline. Provide plugin names
+                (without category qualification), e.g. like ``["nn_classifier", "cde_classifier"]``.
+            metric (SupportedMetric):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            dataset (PredictiveDataset):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            static_imputers (List[str], optional):
+                A list of candidate static imputers. Defaults to `DEFAULT_STATIC_IMPUTERS`.
+            static_scalers (List[str], optional):
+                A list of candidate static scalers. Defaults to `DEFAULT_STATIC_SCALERS`.
+            temporal_imputers (List[str], optional):
+                A list of candidate temporal imputers. Defaults to `DEFAULT_TEMPORAL_IMPUTERS`.
+            temporal_scalers (List[str], optional):
+                A list of candidate temporal scalers. Defaults to `DEFAULT_TEMPORAL_SCALERS`.
+            return_top_k (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            num_cv_folds (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            num_iter (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            tuner_patience (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            tuner_type (TunerType, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            timeout (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            random_state (int, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            override_hp_space (Optional[Dict[str, List[Params]]], optional):
+                See `~tempor.automl.seeker.BaseSeeker`. Note that currently the hyperparameter space override in this
+                case can only be specified for the last pipeline step (the predictive estimator), not the preceding
+                data transformer steps. The default hyperparameter space will always be used for those.
+            horizon (Optional[data_typing.TimeIndex], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            compute_baseline_score (bool, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            grid (Optional[Dict[str, Dict[str, Any]]], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            custom_tuner (Optional[BaseTuner], optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+            raise_exceptions (bool, optional):
+                See `~tempor.automl.seeker.BaseSeeker`.
+        """
+        # Define estimator definitions:
+        estimator_defs = []
+        for predictor in estimator_names:
+            estimator_defs.append(
+                dict(
+                    predictor=predictor,
+                    static_imputers=static_imputers,
+                    static_scalers=static_scalers,
+                    temporal_imputers=temporal_imputers,
+                    temporal_scalers=temporal_scalers,
+                )
+            )
+        # Define human-friendly names for pipelines (pre-predictor):
+        estimator_human_names = []
+        for predictor in estimator_names:
+            estimator_human_names.append(self._pipe_human_name(predictor))
+
+        # Validate override:
+        if override_hp_space is not None:
+            for key in override_hp_space.keys():
+                if key not in estimator_names:
+                    raise ValueError(
+                        f"Estimator name '{key}' in `override_hp_space` was found that did not "
+                        f"correspond to any of the estimators provided: {estimator_names}"
+                    )
+        # Set override keys:
+        if override_hp_space is not None:
+            override_hp_space_keys_renamed = dict()
+            for predictor in estimator_names:
+                if predictor in override_hp_space:
+                    override_hp_space_keys_renamed[self._pipe_human_name(predictor)] = override_hp_space[predictor]
+        else:
+            override_hp_space_keys_renamed = None
+
+        # Grid search not supported.
+        if tuner_type == "grid":
+            raise ValueError(f"The 'grid' search method not supported with {self.__class__.__name__}")
+
+        super().__init__(
+            study_name,
+            task_type,
+            estimator_human_names,
+            estimator_defs,
+            metric,
+            dataset,
+            return_top_k=return_top_k,
+            num_cv_folds=num_cv_folds,
+            num_iter=num_iter,
+            tuner_patience=tuner_patience,
+            tuner_type=tuner_type,
+            timeout=timeout,
+            random_state=random_state,
+            override_hp_space=override_hp_space_keys_renamed,
+            horizon=horizon,
+            compute_baseline_score=compute_baseline_score,
+            grid=grid,
+            custom_tuner=custom_tuner,
+            raise_exceptions=raise_exceptions,
+            **kwargs,
+        )
+
+    def _pipe_human_name(self, predictor_name: str) -> str:
+        return f"<Pipeline with {predictor_name}>"
+
+    def _init_estimator(self, estimator_name: str, estimator_def: Any) -> AutoMLCompatibleEstimator:
+        logger.info(f"Creating estimator {estimator_name}.")
+        return PipelineSelector(
+            task_type=self.task_type,
+            predictor=estimator_def["predictor"],
+            static_imputers=estimator_def["static_imputers"],
+            static_scalers=estimator_def["static_scalers"],
+            temporal_imputers=estimator_def["temporal_imputers"],
+            temporal_scalers=estimator_def["temporal_scalers"],
+        )
+
+    def _create_estimator_with_hps(
+        self, estimator_cls: AutoMLCompatibleEstimator, hps: Dict[str, Any], score: float
+    ) -> BasePredictor:
+        estimator_cls = cast(PipelineSelector, estimator_cls)
+        name = self._pipe_human_name(estimator_cls.predictor.name)
+        logger.info(f"Selected score {score} for {name} with hyperparameters:\n{hps}")
+
+        pipe = estimator_cls.pipeline_from_hps(hps)
+
+        if not isinstance(pipe, BasePredictor):  # pragma: no cover
+            # Should not end up here.
+            raise RuntimeError(f"Pipeline was not a subclass of {BasePredictor.__name__}")
+        return pipe
diff --git a/src/tempor/automl/tuner.py b/src/tempor/automl/tuner.py
new file mode 100644
index 00000000..7a2c529d
--- /dev/null
+++ b/src/tempor/automl/tuner.py
@@ -0,0 +1,243 @@
+"""Module containing the interface for, and the implemented hyperparameter tuners."""
+
+import abc
+import copy
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union
+
+import optuna
+from pydantic import validate_arguments
+from typing_extensions import Protocol, runtime_checkable
+
+from tempor.data.dataset import PredictiveDataset
+from tempor.log import logger
+from tempor.plugins.core._base_predictor import BasePredictor
+from tempor.plugins.core._params import Params
+
+from ._types import AutoMLCompatibleEstimator, OptimDirection
+from .pipeline_selector import PipelineSelector
+
+# TODO: Handle other hyperparameter tuning frameworks, e.g. hyperband.
+# TODO: Explicitly handle other storage types, e.g. redis.
+# TODO: Possibly add a repeated parameter pruner.
+# TODO: Support ensembles.
+
+
+@runtime_checkable
+class EvaluationCallback(Protocol):
+    """Evaluation callback callable.
+
+    Should take in `Type[BasePredictor]` as first argument, then the dataset (`PredictiveDataset`),
+    hyperparameters, returning a `float` score.
+
+    Signature like:\
+        ``(estimator: Type[BasePredictor], dataset: PredictiveDataset, *args: Any, **kwargs: Any) -> float``.
+    """
+
+    def __call__(
+        self, estimator: Type[BasePredictor], dataset: PredictiveDataset, *args: Any, **kwargs: Any
+    ) -> float:  # pragma: no cover
+        ...
+
+
+class BaseTuner(abc.ABC):
+    @validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def __init__(
+        self,
+        study_name: str,
+        direction: OptimDirection,
+        **kwargs,  # pylint: disable=unused-argument
+    ):
+        """Base hyperparameter tuner from which tuner implementations should derive. Defines the initializer and the
+        `tune` method.
+
+        Args:
+            study_name (str):
+                Study name.
+            direction (OptimDirection):
+                Optimization direction (`"minimize"` or `"maximize"`).
+        """
+        self.study_name = study_name
+        self.direction = direction
+
+    @abc.abstractmethod
+    def tune(
+        self,
+        estimator: AutoMLCompatibleEstimator,
+        dataset: PredictiveDataset,
+        evaluation_callback: EvaluationCallback,
+        override_hp_space: Optional[List[Params]] = None,
+        compute_baseline_score: bool = True,
+        **kwargs,
+    ) -> Tuple[List[float], List[Dict]]:
+        """Run the hyperparameter tuner and return scores and chosen hyperparameters.
+
+        Args:
+            estimator (AutoMLCompatibleEstimator):
+                Estimator class, or `PipelineSelector`, whose hyperparameters will be tuned.
+            dataset (PredictiveDataset):
+                Dataset to use.
+            evaluation_callback (EvaluationCallback):
+                Evaluation callback which will take in the estimator class, hyperparameters, and return a score.
+            override_hp_space (Optional[List[Params]]):
+                If this is not `None`, hyperparameters will be sampled from this list, rather than from those defined
+                in the ``hyperparameter_space`` method of the estimator. Defaults to `None`.
+            compute_baseline_score (bool, optional):
+                If `True`, a trial will be run with default parameters (hyperparameters passed to ``__init__`` as an
+                empty dictionary). This will be returned as the zeroth item in ``scores`` and ``params``. If `False`,
+                this will be skipped. Defaults to `True`.
+
+        Returns:
+            Tuple[List[float], List[Dict]]:
+                ``(scores, params)`` tuple, containing a list of scores for the tuning runs and a list of dictionaries\
+                containing the parameters for each corresponding tuning run.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+
+class OptunaTuner(BaseTuner):
+    @validate_arguments(config=dict(arbitrary_types_allowed=True))
+    def __init__(
+        self,
+        study_name: str,
+        direction: OptimDirection,
+        *,
+        study_sampler: optuna.samplers.BaseSampler,
+        study_storage: Optional[Union[str, optuna.storages.BaseStorage]] = None,
+        study_pruner: Optional[optuna.pruners.BasePruner] = None,
+        study_load_if_exists: bool = False,
+        **kwargs,
+    ):
+        """Hyper parameter tuning (optimization) helper for an `optuna.study.Study` using any
+        `optuna.sampler.BaseSampler`.
+
+        Args:
+            study_name (str):
+                Study name.
+            direction (OptimDirection):
+                Optimization direction (`"minimize"` or `"maximize"`).
+            study_sampler (optuna.samplers.BaseSampler):
+                An `optuna` sampler (passed to `optuna.create_study`).
+            study_storage (Optional[Union[str, optuna.storages.BaseStorage]], optional):
+                An `optuna` storage object (passed to `optuna.create_study`). Defaults to `None`.
+            study_pruner (Optional[optuna.pruners.BasePruner], optional):
+                An `optuna` pruner (passed to `optuna.create_study`). Defaults to `None`.
+            study_load_if_exists (bool, optional):
+                The `load_if_exists` parameter (passed to `optuna.create_study`). Defaults to `False`.
+        """
+        super().__init__(
+            study_name=study_name,
+            direction=direction,
+        )
+
+        self.sampler = study_sampler
+        self.study_storage = study_storage
+        self.study_pruner = study_pruner
+        self.study_load_if_exists = study_load_if_exists
+
+        self.create_study()
+
+    def create_study(self) -> optuna.Study:
+        """Create an `optuna.Study` to be used for tuning. Sets the ``self.study`` attribute.
+
+        Returns:
+            optuna.Study: The created study.
+        """
+        self.study = optuna.create_study(
+            storage=self.study_storage,
+            sampler=self.sampler,
+            pruner=self.study_pruner,
+            study_name=self.study_name,
+            direction=self.direction,
+            load_if_exists=self.study_load_if_exists,
+            directions=None,  # Do not support multi-objective.
+        )
+        return self.study
+
+    def tune(
+        self,
+        estimator: AutoMLCompatibleEstimator,
+        dataset: PredictiveDataset,
+        evaluation_callback: EvaluationCallback,
+        override_hp_space: Optional[List[Params]] = None,
+        compute_baseline_score: bool = True,
+        optimize_kwargs: Optional[Dict[str, Any]] = None,
+        **kwargs,
+    ) -> Tuple[List[float], List[Dict]]:
+        """Run the hyperparameter tuner and return scores and chosen hyperparameters.
+
+        Args:
+            estimator (AutoMLCompatibleEstimator):
+                Estimator class, or `PipelineSelector`, whose hyperparameters will be tuned.
+            dataset (PredictiveDataset):
+                Dataset to use.
+            evaluation_callback (EvaluationCallback):
+                Evaluation callback which will take in the estimator class, hyperparameters, and return a score.
+            override_hp_space (Optional[List[Params]]):
+                If this is not `None`, hyperparameters will be sampled from this list, rather than from those defined
+                in the ``hyperparameter_space`` method of the estimator. Defaults to `None`.
+            compute_baseline_score (bool, optional):
+                If `True`, a trial will be run with default parameters (hyperparameters passed to ``__init__`` as an
+                empty dictionary). This will be returned as the zeroth item in ``scores`` and ``params``. If `False`,
+                this will be skipped. Defaults to `True`.
+            optimize_kwargs (Optional[Dict[str, Any]], optional):
+                Keyword arguments to pass to ``study.optimize``. Defaults to `None`.
+
+        Tuple[List[float], List[Dict]]:
+                ``(scores, params)`` tuple, containing a list of scores for the tuning runs and a list of dictionaries\
+                containing the parameters for each corresponding tuning run.
+        """
+
+        if optimize_kwargs is None:
+            optimize_kwargs = dict()
+
+        scores = []
+        params: List[Dict[str, Any]] = []
+
+        # TODO: Handle dataset passing through transformation such that there is no need to copy.
+
+        if compute_baseline_score and not isinstance(estimator, PipelineSelector):
+            # NOTE: in case of PipelineSelector base case is not defined, so this is ignored.
+            baseline_score = evaluation_callback(estimator, copy.deepcopy(dataset))
+            scores.append(baseline_score)
+            params.append(dict())
+            logger.info(f"Baseline score for {estimator.name}: {baseline_score}")
+        else:
+            logger.info("Baseline score computation skipped")
+
+        if len(estimator.hyperparameter_space()) == 0:
+            return scores, params
+
+        def objective(trial: optuna.Trial) -> float:
+            # Ensure the override variable doesn't get mutated unintentionally by copying.
+            override_copy = copy.deepcopy(override_hp_space)
+            hps = estimator.sample_hyperparameters(trial, override=override_copy)
+
+            estimator_for_eval: Type[BasePredictor]
+            if isinstance(estimator, PipelineSelector):
+                pipe_cls, pipe_hp_dict = estimator.pipeline_class_from_hps(hps)
+                hps = dict(plugin_params=pipe_hp_dict)
+                name = pipe_cls.pipeline_seq()
+                if TYPE_CHECKING:  # pragma: no cover
+                    assert issubclass(pipe_cls, BasePredictor)  # nosec B101
+                estimator_for_eval = pipe_cls
+            else:
+                estimator_for_eval = estimator
+                name = estimator_for_eval.__name__
+
+            logger.info(f"Hyperparameters sampled from {name}:\n{hps}")
+            score = evaluation_callback(estimator_for_eval, copy.deepcopy(dataset), **hps)
+
+            return score
+
+        self.study.optimize(objective, **optimize_kwargs)
+
+        for trial_idx, trial_info in enumerate(self.study.get_trials(states=[optuna.trial.TrialState.COMPLETE])):
+            score_trial = trial_info.values[0]
+            params_trial = trial_info.params
+            logger.trace(f"Got trial {trial_idx}.")
+            logger.trace(f"Trial score: {score_trial}.")
+            logger.trace(f"Trial params:\n{params_trial}.")
+            scores.append(score_trial)
+            params.append(params_trial)
+
+        return scores, params
diff --git a/src/tempor/benchmarks/__init__.py b/src/tempor/benchmarks/__init__.py
index 526fe778..45cadef7 100644
--- a/src/tempor/benchmarks/__init__.py
+++ b/src/tempor/benchmarks/__init__.py
@@ -4,8 +4,8 @@
     OutputMetric,
     RegressionSupportedMetric,
     classifier_supported_metrics,
-    evaluate_classifier,
-    evaluate_regressor,
+    evaluate_prediction_oneoff_classifier,
+    evaluate_prediction_oneoff_regressor,
     evaluate_time_to_event,
     output_metrics,
     regression_supported_metrics,
diff --git a/src/tempor/benchmarks/benchmark.py b/src/tempor/benchmarks/benchmark.py
index 5f9dd8fb..5f2b2ba5 100644
--- a/src/tempor/benchmarks/benchmark.py
+++ b/src/tempor/benchmarks/benchmark.py
@@ -3,6 +3,7 @@
 import pandas as pd
 import pydantic
 
+from tempor.core.types import PredictiveTaskType
 from tempor.data import data_typing, dataset
 from tempor.log import logger as log
 
@@ -22,7 +23,7 @@ def print_score(mean: pd.Series, std: pd.Series) -> pd.Series:
 
 @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
 def benchmark_models(
-    task_type: str,
+    task_type: PredictiveTaskType,
     tests: List[Tuple[str, Any]],  # [ ( Test name, Model to evaluate (unfitted) ), ... ]
     data: dataset.PredictiveDataset,
     n_splits: int = 3,
@@ -32,9 +33,9 @@ def benchmark_models(
     """Benchmark the performance of several algorithms.
 
     Args:
-        task_type (str):
+        task_type (PredictiveTaskType):
             The type of problem. Relevant for evaluating the downstream models with the correct metrics.
-            Valid tasks are:  ``"classification"``, ``"regression"``, ``"time_to_event"``.
+            The options are any of `PredictiveTaskType`.
         tests (List[Tuple[str, Any]]):
             Tuples of form ``(test_name: str, plugin: BasePredictor/Pipeline)``
         data (dataset.Dataset):
@@ -59,24 +60,42 @@ def benchmark_models(
 
     results = {}
 
-    if task_type == "classification":
-        evaluator: Callable = evaluation.evaluate_classifier
-    elif task_type == "regression":
-        evaluator = evaluation.evaluate_regressor
+    if task_type == "prediction.one_off.classification":
+        evaluator: Callable = evaluation.evaluate_prediction_oneoff_classifier
+    elif task_type == "prediction.one_off.regression":
+        evaluator = evaluation.evaluate_prediction_oneoff_regressor
     elif task_type == "time_to_event":
         evaluator = evaluation.evaluate_time_to_event
+    elif task_type == "prediction.temporal.classification":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
+    elif task_type == "prediction.temporal.regression":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
+    elif task_type == "treatments.one_off.classification":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
+    elif task_type == "treatments.one_off.regression":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
+    elif task_type == "treatments.temporal.classification":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
+    elif task_type == "treatments.temporal.regression":
+        # TODO: This case needs to be handled in `tempor.benchmarks.evaluation`.
+        raise NotImplementedError
     else:
-        raise ValueError(f"Unsupported task type {task_type}")
+        raise ValueError(f"Unsupported task type: {task_type}")
 
     for testcase, plugin in tests:
-        log.info(f"Testcase : {testcase}")
+        log.info(f"Test case: {testcase}")
 
         scores = evaluator(
             plugin,
-            data=data,  # pyright: ignore
+            data=data,  # type: ignore
             n_splits=n_splits,
             random_state=random_state,
-            horizons=horizons,  # pyright: ignore
+            horizons=horizons,  # type: ignore
         )
 
         mean_score = scores["mean"].to_dict()
diff --git a/src/tempor/benchmarks/evaluation.py b/src/tempor/benchmarks/evaluation.py
index 255faa0e..90132bce 100644
--- a/src/tempor/benchmarks/evaluation.py
+++ b/src/tempor/benchmarks/evaluation.py
@@ -22,7 +22,7 @@
     from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
     from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
-# TODO: Benchmarking workflow for prediction.temporal case.
+# TODO: Benchmarking workflow for missing cases.
 
 ClassifierSupportedMetric = Literal[
     "aucroc",
@@ -338,12 +338,12 @@ def average_precision_score(self, y_test: np.ndarray, y_pred_proba: np.ndarray)
 
 
 @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
-def evaluate_classifier(  # pylint: disable=unused-argument
+def evaluate_prediction_oneoff_classifier(  # pylint: disable=unused-argument
     estimator: Any,
     data: dataset.PredictiveDataset,
-    *args: Any,
     n_splits: int = 3,
     random_state: int = 0,
+    raise_exceptions: bool = False,
     **kwargs: Any,
 ) -> pd.DataFrame:
     """Helper for evaluating classifiers.
@@ -357,6 +357,10 @@ def evaluate_classifier(  # pylint: disable=unused-argument
             Cross-validation folds. Defaults to ``3``.
         random_state (int, optional):
             Random state. Defaults to ``0``.
+        raise_exceptions (bool, optional):
+            Whether to raise exceptions during evaluation. If `False`, the exceptions will be swallowed and the
+            evaluation will continue - exception count will be reported in the `"errors"` column of the resultant
+            dataframe. Defaults to `False`.
 
     Returns:
         pd.DataFrame:
@@ -406,6 +410,8 @@ def evaluate_classifier(  # pylint: disable=unused-argument
         except BaseException as e:  # pylint: disable=broad-except
             logger.error(f"Evaluation failed: {e}")
             results.errors.append(1)
+            if raise_exceptions:
+                raise
 
         results.durations.append(time() - start)
         indx += 1
@@ -414,12 +420,12 @@ def evaluate_classifier(  # pylint: disable=unused-argument
 
 
 @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
-def evaluate_regressor(  # pylint: disable=unused-argument
+def evaluate_prediction_oneoff_regressor(  # pylint: disable=unused-argument
     estimator: Any,
     data: dataset.PredictiveDataset,
-    *args: Any,
     n_splits: int = 3,
     random_state: int = 0,
+    raise_exceptions: bool = False,
     **kwargs: Any,
 ) -> pd.DataFrame:
     """Helper for evaluating regression tasks.
@@ -433,6 +439,10 @@ def evaluate_regressor(  # pylint: disable=unused-argument
             Cross-validation folds. Defaults to ``3``.
         random_state (int, optional):
             Random state. Defaults to ``0``.
+        raise_exceptions (bool, optional):
+            Whether to raise exceptions during evaluation. If `False`, the exceptions will be swallowed and the
+            evaluation will continue - exception count will be reported in the `"errors"` column of the resultant
+            dataframe. Defaults to `False`.
 
     Returns:
         pd.DataFrame:
@@ -475,6 +485,8 @@ def evaluate_regressor(  # pylint: disable=unused-argument
         except BaseException as e:  # pylint: disable=broad-except
             logger.error(f"Regression evaluation failed: {e}")
             results.errors.append(1)
+            if raise_exceptions:
+                raise
 
         results.durations.append(time() - start)
         indx += 1
@@ -565,9 +577,9 @@ def evaluate_time_to_event(  # pylint: disable=unused-argument
     estimator: Any,
     data: dataset.TimeToEventAnalysisDataset,
     horizons: data_typing.TimeIndex,
-    *args: Any,
     n_splits: int = 3,
     random_state: int = 0,
+    raise_exceptions: bool = False,
     **kwargs: Any,
 ) -> pd.DataFrame:
     """Helper for evaluating time-to-event tasks.
@@ -583,6 +595,10 @@ def evaluate_time_to_event(  # pylint: disable=unused-argument
             Cross-validation folds. Defaults to ``3``.
         random_state (int, optional):
             Random state. Defaults to ``0``.
+        raise_exceptions (bool, optional):
+            Whether to raise exceptions during evaluation. If `False`, the exceptions will be swallowed and the
+            evaluation will continue - exception count will be reported in the `"errors"` column of the resultant
+            dataframe. Defaults to `False`.
 
     Returns:
         pd.DataFrame:
@@ -635,6 +651,8 @@ def evaluate_time_to_event(  # pylint: disable=unused-argument
         except BaseException as e:  # pylint: disable=broad-except
             logger.error(f"Regression evaluation failed: {e}")
             results.errors.append(1)
+            if raise_exceptions:
+                raise
 
         results.durations.append(time() - start)
         indx += 1
diff --git a/src/tempor/core/types.py b/src/tempor/core/types.py
new file mode 100644
index 00000000..0f89c706
--- /dev/null
+++ b/src/tempor/core/types.py
@@ -0,0 +1,13 @@
+from typing_extensions import Literal
+
+PredictiveTaskType = Literal[
+    "prediction.one_off.classification",
+    "prediction.one_off.regression",
+    "prediction.temporal.classification",
+    "prediction.temporal.regression",
+    "time_to_event",
+    "treatments.one_off.classification",
+    "treatments.one_off.regression",
+    "treatments.temporal.classification",
+    "treatments.temporal.regression",
+]
diff --git a/src/tempor/core/utils.py b/src/tempor/core/utils.py
index b5fed5c4..b6569473 100644
--- a/src/tempor/core/utils.py
+++ b/src/tempor/core/utils.py
@@ -1,5 +1,5 @@
 import enum
-from typing import Any, Dict, Tuple
+from typing import Any, Dict, Tuple, Type
 
 from typing_extensions import get_args
 
@@ -72,3 +72,59 @@ def ensure_literal_matches_dict_keys(
             f"There was a mismatch between the literal '{literal_name}' and the the dictionary "
             f"'{dict_name}' keys: {list(lits.symmetric_difference(keys))}"
         )
+
+
+def get_from_args_or_kwargs(
+    args: Tuple, kwargs: Dict, argument_name: str, argument_type: Type, position_if_args: int
+) -> Tuple[Any, Tuple, Dict]:
+    """Will attempt to get the function argument as defined by ``argument_name``, ``argument_type``, and
+    ``position_if_args`` from ``args`` and ``kwargs``. Will return `None` if no such argument found.
+    Will raise an exception if ``args`` and ``kwargs`` have a problem with the definition given.
+
+    Algorithm:
+        1. Check if an ``arg`` of type ``argument_type`` is found at index ``position_if_args`` in ``args``.
+        2. Check if a ``kwarg`` by key ``argument_name`` is found in ``kwargs``.
+        3. If both 1 and 2 are found raise `RuntimeError`.
+        4. If ``kwarg`` from 2 is not of type ``argument_type`` raise `TypeError`.
+        5. Return 1 or 2 if argument is found, else return `None`. Also return ``args`` and ``kwargs``\
+            with the argument "popped".
+
+    Args:
+        args (Tuple):
+            ``args`` to check.
+        kwargs (Dict):
+            ``kwargs`` to check.
+        argument_name (str):
+            The name of the argument to look for.
+        argument_type (Type):
+            The type of the argument to confirm.
+        position_if_args (int):
+            The index in ``args`` at which the argument should be found, if it is provided by ``args``.
+
+    Raises:
+        RuntimeError: Error in case the argument appears to have been provided by both ``args`` and ``kwargs``.
+        TypeError: Error in case the ``kwarg`` provided by key ``argument_name`` is not of the expected type.
+
+    Returns:
+        Tuple[Any, Tuple, Dict]:
+            ``(found_argument_or_None, args, kwargs)``.
+            If argument found, it will be removed from the ``args``/``kwargs`` returned.
+    """
+    from_args = None
+    if len(args) >= (position_if_args + 1):
+        arg_at_position = args[position_if_args]
+        if isinstance(arg_at_position, argument_type):
+            from_args = arg_at_position
+            args = tuple([x for i, x in enumerate(args) if i != position_if_args])
+    from_kwargs = kwargs.pop(argument_name, None)
+    if from_args is not None and from_kwargs is not None:
+        raise RuntimeError(
+            f"Argument `{argument_name}` appears to have been passed as `kwargs` (by key '{argument_name}') "
+            f"and as `args` (at position {position_if_args}), but it should be passed in only one of these ways"
+        )
+    if from_kwargs is not None and not isinstance(from_kwargs, argument_type):
+        raise TypeError(
+            f"Argument `{argument_name}` was passed as `kwargs` (by key '{argument_name}') but was not of "
+            f"expected type `{argument_type}`"
+        )
+    return from_args if from_args is not None else from_kwargs, args, kwargs
diff --git a/src/tempor/plugins/core/_base_estimator.py b/src/tempor/plugins/core/_base_estimator.py
index 9df53c71..dd6139ae 100644
--- a/src/tempor/plugins/core/_base_estimator.py
+++ b/src/tempor/plugins/core/_base_estimator.py
@@ -3,6 +3,7 @@
 from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Type
 
 import omegaconf
+import optuna
 import pydantic
 import rich.pretty
 from typing_extensions import Self
@@ -108,13 +109,41 @@ def hyperparameter_space(*args: Any, **kwargs: Any) -> List[Params]:  # pragma:
         ...  # pylint: disable=unnecessary-ellipsis
 
     @classmethod
-    def sample_hyperparameters(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
-        """Sample hyperparameters."""
-        param_space = cls.hyperparameter_space(*args, **kwargs)
-
-        results = {}
+    def sample_hyperparameters(
+        cls, *args: Any, override: Optional[List[Params]] = None, **kwargs: Any
+    ) -> Dict[str, Any]:
+        """Sample hyperparameters. Hyperparameters will be sampled as defined in the ``hyperparameter_space`` static
+        method, unless ``override`` is provided, in which case, they will be sampled from the ``override`` definition.
+
+        Can provide variadics ``*args`` and ``**kwargs``, these will be passed on to the ``hyperparameter_space``
+        method.
+
+        Note:
+            If using `optuna` as the hyperparameter optimizer, an additional argument, ``trial`` (`optuna.Trial`) must
+            be passed either as an argument or keyword argument to this method, i.e.
+            ``.sample_hyperparameters(trial, ...)`` or ``.sample_hyperparameters(..., trial=trial, ...)``.
+
+        Args:
+            override (Optional[List[Params]], optional):
+                If this is not `None`, hyperparameters will be sampled from this list, rather than from those defined\
+                    in the ``hyperparameter_space`` method. Defaults to `None`.
+
+        Returns:
+            Dict[str, Any]: _description_
+        """
+        # Pop the `trial` argument for optuna if such is found (if not, `trial` will be `None`).
+        trial, args, kwargs = utils.get_from_args_or_kwargs(
+            args, kwargs, argument_name="trial", argument_type=optuna.Trial, position_if_args=0
+        )
+
+        if override is None:
+            param_space = cls.hyperparameter_space(*args, **kwargs)
+        else:
+            param_space = override
+
+        results = dict()
 
         for hp in param_space:
-            results[hp.name] = hp.sample()
+            results[hp.name] = hp.sample(trial)
 
         return results
diff --git a/src/tempor/plugins/core/_params.py b/src/tempor/plugins/core/_params.py
index 317d034d..67f43025 100644
--- a/src/tempor/plugins/core/_params.py
+++ b/src/tempor/plugins/core/_params.py
@@ -1,23 +1,48 @@
 import abc
 import random
-from typing import Any, List, Tuple
+from typing import Any, List, Optional, Tuple
+
+import rich.pretty
+from optuna.trial import Trial
+
+RESERVED_ARG_NAMES = ("trail", "override")
 
 
 class Params(abc.ABC):
     """Helper for describing the hyperparameters for each estimator."""
 
     def __init__(self, name: str, bounds: Tuple[Any, Any]) -> None:
+        if name in RESERVED_ARG_NAMES:
+            raise ValueError(f"The hyperparameter name '{name}' is not allowed, as it is a special argument")
         self.name = name
         self.bounds = bounds
 
     @abc.abstractmethod
-    def get(self) -> List[Any]:
+    def get(self) -> List[Any]:  # pragma: no cover
+        ...
+
+    def sample(self, trial: Optional[Trial] = None) -> Any:
+        if trial is not None:
+            return self._sample_optuna_trial(trial)
+        else:
+            return self._sample_default()
+        # NOTE: Could support more parameter sampling implementations.
+
+    @abc.abstractmethod
+    def _sample_optuna_trial(self, trial: Trial) -> Any:  # pragma: no cover
         ...
 
     @abc.abstractmethod
-    def sample(self) -> Any:
+    def _sample_default(self) -> Any:  # pragma: no cover
         ...
 
+    def __rich_repr__(self):
+        yield "name", self.name
+        yield "bounds", self.bounds
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
 
 class CategoricalParams(Params):
     """Sample from a categorical distribution."""
@@ -30,9 +55,16 @@ def __init__(self, name: str, choices: List[Any]) -> None:
     def get(self) -> List[Any]:
         return [self.name, self.choices]
 
-    def sample(self) -> Any:
+    def _sample_optuna_trial(self, trial: Trial) -> Any:
+        return trial.suggest_categorical(self.name, self.choices)
+
+    def _sample_default(self) -> Any:
         return random.SystemRandom().choice(self.choices)
 
+    def __rich_repr__(self):
+        yield "name", self.name
+        yield "choices", self.choices
+
 
 class FloatParams(Params):
     """Sample from a float distribution."""
@@ -49,9 +81,17 @@ def __init__(self, name: str, low: float, high: float) -> None:
     def get(self) -> List[Any]:
         return [self.name, self.low, self.high]
 
-    def sample(self) -> Any:
+    def _sample_optuna_trial(self, trial: Trial) -> Any:
+        return trial.suggest_float(self.name, self.low, self.high)
+
+    def _sample_default(self) -> Any:
         return random.uniform(self.low, self.high)  # nosec
 
+    def __rich_repr__(self):
+        yield "name", self.name
+        yield "low", self.low
+        yield "high", self.high
+
 
 class IntegerParams(Params):
     """Sample from an integer distribution."""
@@ -71,7 +111,14 @@ def __init__(self, name: str, low: int, high: int, step: int = 1) -> None:
     def get(self) -> List[Any]:
         return [self.name, self.low, self.high, self.step]
 
-    def sample(self) -> Any:
-        return random.SystemRandom().choice(
-            self.choices,
-        )
+    def _sample_optuna_trial(self, trial: Trial) -> Any:
+        return trial.suggest_int(self.name, self.low, self.high, self.step)
+
+    def _sample_default(self) -> Any:
+        return random.SystemRandom().choice(self.choices)
+
+    def __rich_repr__(self):
+        yield "name", self.name
+        yield "low", self.low
+        yield "high", self.high
+        yield "step", self.step
diff --git a/src/tempor/plugins/pipeline/__init__.py b/src/tempor/plugins/pipeline/__init__.py
index 07c700ca..a2844757 100644
--- a/src/tempor/plugins/pipeline/__init__.py
+++ b/src/tempor/plugins/pipeline/__init__.py
@@ -1,84 +1,298 @@
-from typing import Any, Dict, List, Tuple, Type
+import abc
+from typing import Any, Dict, List, NoReturn, Optional, Tuple, Type
+
+import omegaconf
+import rich.pretty
+from typing_extensions import Self
 
 from tempor.data import dataset
+from tempor.log import logger
 from tempor.plugins import plugin_loader
+from tempor.plugins.core._params import Params
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.temporal.classification import BaseTemporalClassifier
+from tempor.plugins.prediction.temporal.regression import BaseTemporalRegressor
+from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
+from tempor.plugins.treatments.one_off import BaseOneOffTreatmentEffects
+from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
 
 from .generators import (
     _generate_constructor,
     _generate_fit,
-    _generate_get_args,
-    _generate_hyperparameter_space_for_layer_impl,
+    _generate_hyperparameter_space_for_step_impl,
     _generate_hyperparameter_space_impl,
-    _generate_name_impl,
+    _generate_pipeline_seq_impl,
     _generate_predict,
+    _generate_predict_counterfactuals,
     _generate_predict_proba,
-    _generate_sample_param_impl,
+    _generate_sample_hyperparameters_impl,
+)
+
+BASE_CLASS_CANDIDATES = (
+    BaseOneOffClassifier,
+    BaseOneOffRegressor,
+    BaseTemporalClassifier,
+    BaseTemporalRegressor,
+    BaseTimeToEventAnalysis,
+    BaseOneOffTreatmentEffects,
+    BaseTemporalTreatmentEffects,
 )
 
 
-class PipelineMeta(type):
-    def __new__(cls: Type, name: str, plugins: Tuple[Type, ...], dct: dict) -> Any:
-        dct["__init__"] = _generate_constructor()
-        dct["fit"] = _generate_fit()
-        dct["predict"] = _generate_predict()
-        dct["predict_proba"] = _generate_predict_proba()
-        dct["name"] = _generate_name_impl(plugins)
-        dct["hyperparameter_space"] = _generate_hyperparameter_space_impl(plugins)
-        dct["hyperparameter_space_for_layer"] = _generate_hyperparameter_space_for_layer_impl(plugins)
-        dct["sample_hyperparameters"] = _generate_sample_param_impl(plugins)
-        dct["get_args"] = _generate_get_args()
+# TODO: Consider allowing transform-only pipelines.
 
-        dct["plugin_types"] = list(plugins)
 
-        return super().__new__(cls, name, tuple(), dct)
+class PipelineBase:
+    stages: List
+    """A list of method plugin instances corresponding to each step in the pipeline."""
+    plugin_types: List[Type]
+    """A list of types denoting the class of each step in the pipeline."""
 
-    @staticmethod
-    def name(*args: Any) -> str:
+    def __init__(self, plugin_params: Optional[Dict[str, Dict]] = None, **kwargs) -> None:  # pragma: no cover
+        """Instantiate the pipeline, (optionally) providing initialization parameters for constituent step plugins.
+
+        Note:
+            The implementations of the methods on this class (``fit``, ``sample_hyperparameters``, etc.) are
+            auto-generated by the :class:`PipelineMeta` metaclass.
+
+        Args:
+            plugin_params (Optional[Dict[str, Dict]], optional):
+                A dictionary like ``{"plugin_1_name": {"init_param_1": value, ...}, ...}``. Defaults to None.
+        """
         raise NotImplementedError("Not implemented")
 
     @staticmethod
-    def type(*args: Any) -> str:
+    def pipeline_seq(*args: Any) -> str:  # pragma: no cover
+        """Get a string representation of the pipeline, stating each stage plugin, e.g. like:
+        ``'preprocessing.imputation.temporal.bfill->...->prediction.one_off.classification.nn_classifier'``
+
+        Returns:
+            str: String representation of the pipeline.
+        """
         raise NotImplementedError("Not implemented")
 
     @staticmethod
-    def hyperparameter_space(*args: Any, **kwargs: Any) -> Dict:
+    def hyperparameter_space(*args: Any, **kwargs: Any) -> Dict[str, List[Params]]:  # pragma: no cover
+        """The pipeline version of the estimator static method of the same name. All the hyperparameters of the
+        different stages will be returned.
+
+        Returns:
+            Dict[str, List[Params]]: A dictionary with each stage plugin names as keys and corresponding hyperparameter\
+                space (``List[Params]``) as values.
+        """
         raise NotImplementedError("Not implemented")
 
     @staticmethod
-    def hyperparameter_space_for_layer(name: str, *args: Any, **kwargs: Any) -> Dict:
+    def hyperparameter_space_for_step(name: str, *args: Any, **kwargs: Any) -> List[Params]:  # pragma: no cover
+        """Return the hyperparameter space (``List[Params]``) for the step of the pipeline as specified by ``name``.
+
+        Args:
+            name (str): Name of the pipeline step (i.e. the name of the underlying plugin).
+
+        Returns:
+            List[Params]: the hyperparameter space for the step of the pipeline.
+        """
         raise NotImplementedError("Not implemented")
 
-    def sample_hyperparameters(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=no-method-argument
+    @classmethod
+    def sample_hyperparameters(
+        cls, *args: Any, override: Optional[List[Params]] = None, **kwargs: Any
+    ) -> Dict[str, Any]:  # pragma: no cover
+        """The pipeline version of the estimator method of the same name. Returns a hyperparameter sample.
+
+        Returns:
+            Dict[str, Any]: a dictionary with hyperparameter names as keys and corresponding hyperparameter samples\
+                as values.
+        """
         raise NotImplementedError("Not implemented")
 
-    def get_args(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=no-method-argument
+    def fit(self, data: dataset.BaseDataset, *args: Any, **kwargs: Any) -> Self:  # pragma: no cover
+        """The pipeline version of the estimator ``fit`` method.
+
+        By analogy to `sklearn`, under the hood, ``fit_transform`` will be called on all the pipeline steps except
+        for the last one (the transformer steps of the pipeline), and `fit` will be called on the last step
+        (the predictive step of the pipeline).
+
+        Args:
+            data (dataset.BaseDataset): Input dataset.
+
+        Returns:
+            Self: Returns the fitted pipeline itself.
+        """
         raise NotImplementedError("Not implemented")
 
-    def fit(self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:
+    def predict(self, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:  # pragma: no cover
+        """The pipeline version of the estimator ``predict`` method. Applicable if the final step of the pipeline has
+        a ``predict`` method implemented.
+
+        Args:
+            data (dataset.PredictiveDataset): Input dataset.
+
+        Returns:
+            Any: the same return type as the final step of the pipeline.
+        """
         raise NotImplementedError("Not implemented")
 
-    def predict(*args: Any, **kwargs: Any) -> dataset.PredictiveDataset:  # pylint: disable=no-method-argument
+    def predict_proba(self, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:  # pragma: no cover
+        """The pipeline version of the estimator ``predict_proba`` method. Applicable if the final step of the pipeline
+        has a ``predict_proba`` method implemented.
+
+        Args:
+            data (dataset.PredictiveDataset): Input dataset.
+
+        Returns:
+            Any: the same return type as the final step of the pipeline.
+        """
         raise NotImplementedError("Not implemented")
 
-    def predict_proba(*args: Any, **kwargs: Any) -> dataset.PredictiveDataset:  # pylint: disable=no-method-argument
+    def predict_counterfactuals(
+        self, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any
+    ) -> Any:  # pragma: no cover
+        """The pipeline version of the estimator ``predict_counterfactuals`` method. Applicable if the final step of
+        the pipeline has a ``predict_counterfactuals`` method implemented.
+
+        Args:
+            data (dataset.PredictiveDataset): Input dataset.
+
+        Returns:
+            Any: the same return type as the final step of the pipeline.
+        """
         raise NotImplementedError("Not implemented")
 
+    @property
+    def predictor_category(self) -> str:
+        return self.plugin_types[-1].category
+
+    @property
+    def params(self) -> Dict[str, omegaconf.DictConfig]:
+        out = dict()
+        for p in self.stages:
+            out[p.name] = p.params
+        return out
+
+    def __rich_repr__(self):
+        yield "pipeline_seq", self.pipeline_seq()
+        yield "predictor_category", self.predictor_category
+        yield "params", {k: omegaconf.OmegaConf.to_container(v) for k, v in self.params.items()}
+
+    def __repr__(self) -> str:
+        return rich.pretty.pretty_repr(self)
+
+
+def prepend_base(base: Type, bases: List[Type]) -> List[Type]:
+    if base in bases:
+        bases_final = bases
+    else:
+        bases_final = [base] + bases
+    return bases_final
 
-def PipelineGroup(names: List[str]) -> Tuple[Type, ...]:
+
+def raise_not_implemented(*args, **kwargs) -> NoReturn:
+    raise NotImplementedError("The `{_fit/predict/...}` methods are not implemented for the pipelines")
+
+
+class PipelineMeta(abc.ABCMeta):
+    def __new__(
+        cls: Any,
+        __name: str,
+        __bases: Tuple[type, ...],
+        __namespace: Dict[str, Any],
+        plugins: Tuple[Type, ...] = tuple(),
+        **kwds: Any,
+    ) -> Any:
+        logger.debug(f"Creating pipeline defined by steps:\n{plugins}")
+
+        # Constructor:
+        __namespace["__init__"] = _generate_constructor()
+
+        # Pipeline-specific:
+        __namespace["pipeline_seq"] = _generate_pipeline_seq_impl(plugins)
+
+        # sklearn style methods:
+        __namespace["fit"] = _generate_fit()
+        __namespace["predict"] = _generate_predict()
+        __namespace["predict_proba"] = _generate_predict_proba()
+        __namespace["predict_counterfactuals"] = _generate_predict_counterfactuals()
+
+        # Hyperparameter methods:
+        __namespace["hyperparameter_space"] = _generate_hyperparameter_space_impl(plugins)
+        __namespace["hyperparameter_space_for_step"] = _generate_hyperparameter_space_for_step_impl(plugins)
+        __namespace["sample_hyperparameters"] = _generate_sample_hyperparameters_impl(plugins)
+
+        # Non-method attributes:
+        __namespace["plugin_types"] = list(plugins)
+
+        # Process base classes appropriately.
+        bases = PipelineMeta.parse_bases(__bases, plugins)
+        logger.debug(f"Pipeline base classes identified as:\n{bases}")
+
+        # Avoid ABC error from the lack of _sk* method implementations.
+        __namespace["_fit"] = raise_not_implemented
+        __namespace["_predict"] = raise_not_implemented
+        __namespace["_predict_proba"] = raise_not_implemented
+        __namespace["_predict_counterfactuals"] = raise_not_implemented
+
+        return super().__new__(cls, __name, bases, __namespace, **kwds)
+
+    @staticmethod
+    def parse_bases(bases: Tuple[type, ...], plugins: Tuple[Type, ...]):
+        bases_final: List[Type] = list(bases)
+
+        if len(plugins) > 0:
+            predictive_step = plugins[-1]
+            for base_class in BASE_CLASS_CANDIDATES:
+                if issubclass(predictive_step, base_class):
+                    bases_final = prepend_base(base_class, bases_final)
+
+        bases_final = prepend_base(PipelineBase, bases_final)
+
+        return tuple(bases_final)
+
+
+def pipeline_classes(names: List[str]) -> Tuple[Type, ...]:
+    """Return a list sequence of method plugin classes based on a sequence of fully-qualified ``names`` provided.
+
+    Args:
+        names (List[str]): A sequence of fully-qualified names of method plugins, corresponding to pipeline steps.
+
+    Returns:
+        Tuple[Type, ...]: The corresponding sequence of method plugin classes.
+    """
     res = []
 
     for fqn in names:
         if "." not in fqn:
-            raise RuntimeError("invalid fqn")
+            raise RuntimeError(f"Invalid fqn: {fqn}")
 
         res.append(plugin_loader.get_class(fqn))
 
     return tuple(res)
 
 
-def Pipeline(plugins_str: List[str]) -> Any:
-    plugins = PipelineGroup(plugins_str)
+def pipeline(plugins_str: List[str]) -> Type[PipelineBase]:
+    """Use this method to create pipelines.
+
+    Generates a pipeline (:class:`PipelineBase`) class with an implementation of the necessary methods
+    (``fit``, ``sample_hyperparameters`` etc.), based on a sequence of steps defined by ``plugins_str``.
+
+    All but the last steps must be data transformer plugins, and the last step must be a predictive method plugin.
+
+    This method will return a pipeline class (``Type[PipelineBase]``), which should be instantiated. At time of
+    instantiation, ``__init__`` input parameters for each step's method plugin can be provided. See
+    :class:`PipelineBase` for details.
+
+    Args:
+        plugins_str (List[str]):
+            A sequence of method plugins' fully-qualified names (e.g.
+            ``"prediction.one_off.classification.nn_classifier"``).
+
+    Returns:
+        Type[PipelineBase]: The pipeline class (not instance) is returned.
+    """
+    plugins = pipeline_classes(plugins_str)
 
-    name = "_".join(p.name for p in plugins)
+    class Pipeline(metaclass=PipelineMeta, plugins=plugins):
+        pass
 
-    return PipelineMeta(name, plugins, {})
+    return Pipeline  # type: ignore
diff --git a/src/tempor/plugins/pipeline/generators.py b/src/tempor/plugins/pipeline/generators.py
index 8490421f..ec5992a8 100644
--- a/src/tempor/plugins/pipeline/generators.py
+++ b/src/tempor/plugins/pipeline/generators.py
@@ -1,13 +1,16 @@
-from typing import Any, Callable, Dict, Optional, Tuple, Type
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type
 
 from tempor.data import dataset
 
+if TYPE_CHECKING:
+    from tempor.plugins.core._params import Params
 
-def _generate_name_impl(plugins: Tuple[Type, ...]) -> Callable:
-    def name_impl(*args: Any) -> str:  # pylint: disable=unused-argument
+
+def _generate_pipeline_seq_impl(plugins: Tuple[Type, ...]) -> Callable:
+    def pipeline_seq_impl(*args: Any) -> str:  # pylint: disable=unused-argument
         return "->".join(p.fqn() for p in plugins)
 
-    return name_impl
+    return pipeline_seq_impl
 
 
 def _generate_hyperparameter_space_impl(plugins: Tuple[Type, ...]) -> Callable:
@@ -20,64 +23,63 @@ def hyperparameter_space_impl(*args: Any, **kwargs: Any) -> Dict:
     return hyperparameter_space_impl
 
 
-def _generate_hyperparameter_space_for_layer_impl(plugins: Tuple[Type, ...]) -> Callable:
-    def hyperparameter_space_for_layer_impl(layer: str, *args: Any, **kwargs: Any) -> Dict:
+def _generate_hyperparameter_space_for_step_impl(plugins: Tuple[Type, ...]) -> Callable:
+    def hyperparameter_space_for_step_impl(step: str, *args: Any, **kwargs: Any) -> Dict:
         for p in plugins:
-            if p.name == layer:
+            if p.name == step:
                 return p.hyperparameter_space(*args, **kwargs)
-        raise ValueError(f"invalid layer {layer}")
+        raise ValueError(f"Invalid layer: {step}")
 
-    return hyperparameter_space_for_layer_impl
+    return hyperparameter_space_for_step_impl
 
 
-def _generate_sample_param_impl(plugins: Tuple[Type, ...]) -> Callable:
-    def sample_param_impl(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=unused-argument
+def _generate_sample_hyperparameters_impl(plugins: Tuple[Type, ...]) -> Callable:
+    def sample_hyperparameters_impl(*args: Any, override: Optional[List["Params"]] = None, **kwargs: Any) -> Dict:
         sample: dict = {}
         for p in plugins:
-            sample[p.name] = p.sample_hyperparameters()
+            sample[p.name] = p.sample_hyperparameters(*args, override=override, **kwargs)
 
         return sample
 
-    return sample_param_impl
+    return sample_hyperparameters_impl
 
 
 def _generate_constructor() -> Callable:
     def _sanity_checks(plugins: Tuple[Type, ...]) -> None:
         if len(plugins) == 0:
-            raise RuntimeError("invalid empty pipeline.")
+            raise RuntimeError("Invalid empty pipeline.")
 
         for plugin in plugins[:-1]:
             if not hasattr(plugin, "transform"):
-                raise RuntimeError(f"invalid preprocessing plugin in the pipeline. {plugin}")
+                raise RuntimeError(f"Invalid preprocessing plugin in the pipeline. {plugin}")
 
-        if not hasattr(plugins[-1], "predict"):
-            raise RuntimeError(f"invalid output plugin in the pipeline. {plugins[-1]}")
+        if not hasattr(plugins[-1], "predict") and not hasattr(plugins[-1], "predict_counterfactuals"):
+            raise RuntimeError(f"Invalid output plugin in the pipeline. {plugins[-1]}")
 
-    def init_impl(self: Any, args: Optional[Dict] = None) -> None:
+    def init_impl(self: Any, plugin_params: Optional[Dict[str, Dict]] = None) -> None:
         _sanity_checks(self.plugin_types)
 
         self.stages = []
-        self.args = args if args is not None else dict()
+        self.plugin_params = plugin_params if plugin_params is not None else dict()
 
         for plugin_type in self.plugin_types:
             plugin_args = {}
-            if plugin_type.name in self.args:
-                plugin_args = self.args[plugin_type.name]
+            if plugin_type.name in self.plugin_params:
+                plugin_args = self.plugin_params[plugin_type.name]
             self.stages.append(plugin_type(**plugin_args))
 
     return init_impl
 
 
-def _generate_get_args() -> Callable:
-    def get_args_impl(self: Any) -> Dict:
-        return self.args
-
-    return get_args_impl
+# def _generate_get_args() -> Callable:
+#     def get_args_impl(self: Any) -> Dict:
+#         return self.plugin_params
+#     return get_args_impl
 
 
 def _generate_fit() -> Callable:
-    def fit_impl(self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> dataset.PredictiveDataset:
-        local_X = X
+    def fit_impl(self: Any, data: dataset.BaseDataset, *args: Any, **kwargs: Any) -> Any:
+        local_X = data
         for stage in self.stages[:-1]:
             local_X = stage.fit_transform(local_X)
 
@@ -89,8 +91,8 @@ def fit_impl(self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs: Any)
 
 
 def _generate_predict() -> Callable:
-    def predict_impl(self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> dataset.PredictiveDataset:
-        local_X = X
+    def predict_impl(self: Any, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:
+        local_X = data
         for stage in self.stages[:-1]:
             local_X = stage.transform(local_X)
 
@@ -100,10 +102,8 @@ def predict_impl(self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs:
 
 
 def _generate_predict_proba() -> Callable:
-    def predict_proba_impl(
-        self: Any, X: dataset.PredictiveDataset, *args: Any, **kwargs: Any
-    ) -> dataset.PredictiveDataset:
-        local_X = X
+    def predict_proba_impl(self: Any, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:
+        local_X = data
         for stage in self.stages[:-1]:
             local_X = stage.transform(local_X, *args, **kwargs)
 
@@ -112,14 +112,25 @@ def predict_proba_impl(
     return predict_proba_impl
 
 
+def _generate_predict_counterfactuals() -> Callable:
+    def predict_counterfactuals_impl(self: Any, data: dataset.PredictiveDataset, *args: Any, **kwargs: Any) -> Any:
+        local_X = data
+        for stage in self.stages[:-1]:
+            local_X = stage.transform(local_X, *args, **kwargs)
+
+        return self.stages[-1].predict_counterfactuals(local_X, *args, **kwargs)
+
+    return predict_counterfactuals_impl
+
+
 __all__ = [
-    "_generate_name_impl",
-    "_generate_hyperparameter_space_impl",
-    "_generate_hyperparameter_space_for_layer_impl",
-    "_generate_sample_param_impl",
     "_generate_constructor",
     "_generate_fit",
-    "_generate_predict",
+    "_generate_hyperparameter_space_for_step_impl",
+    "_generate_hyperparameter_space_impl",
+    "_generate_pipeline_seq_impl",
+    "_generate_predict_counterfactuals",
     "_generate_predict_proba",
-    "_generate_get_args",
+    "_generate_predict",
+    "_generate_sample_hyperparameters_impl",
 ]
diff --git a/src/tempor/plugins/preprocessing/imputation/hyperimpute_utils.py b/src/tempor/plugins/preprocessing/imputation/hyperimpute_utils.py
new file mode 100644
index 00000000..95a03b19
--- /dev/null
+++ b/src/tempor/plugins/preprocessing/imputation/hyperimpute_utils.py
@@ -0,0 +1,30 @@
+import contextlib
+
+
+@contextlib.contextmanager
+def monkeypatch_hyperimpute_logger():
+    """In `hyperimpute`, at least as of version ``0.1.17``, the following call in `hyperimpute.logger` causes a
+    conflict with `loguru` logger as used in TemporAI. To circumvent this problem, this context manager monkeypatches
+    `loguru` ``logger.remove`` call with a no-op. To be used around `hyperimpute` imports.
+
+    ```
+    from loguru import logger
+    ...
+    logger.remove()
+    ```
+
+    """
+    from loguru import logger
+
+    original_remove = logger.remove
+
+    def monkeypatched_remove():
+        pass
+
+    logger.remove = monkeypatched_remove  # type: ignore
+
+    try:
+        yield
+
+    finally:
+        logger.remove = original_remove  # type: ignore
diff --git a/src/tempor/plugins/preprocessing/imputation/static/plugin_static_tabular_imputer.py b/src/tempor/plugins/preprocessing/imputation/static/plugin_static_tabular_imputer.py
index acaf0d1c..3a524862 100644
--- a/src/tempor/plugins/preprocessing/imputation/static/plugin_static_tabular_imputer.py
+++ b/src/tempor/plugins/preprocessing/imputation/static/plugin_static_tabular_imputer.py
@@ -1,7 +1,6 @@
 import dataclasses
 from typing import Any, Dict, List
 
-from hyperimpute.plugins.imputers import Imputers
 from typing_extensions import Self, get_args
 
 import tempor.plugins.core as plugins
@@ -10,6 +9,11 @@
 from tempor.plugins.core._params import CategoricalParams, Params
 from tempor.plugins.preprocessing.imputation._base import BaseImputer, TabularImputerType
 
+from ..hyperimpute_utils import monkeypatch_hyperimpute_logger
+
+with monkeypatch_hyperimpute_logger():
+    from hyperimpute.plugins.imputers import Imputers
+
 
 @dataclasses.dataclass
 class StaticTabularImputerParams:
diff --git a/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ts_tabular_imputer.py b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ts_tabular_imputer.py
index e5a4691b..13c72117 100644
--- a/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ts_tabular_imputer.py
+++ b/src/tempor/plugins/preprocessing/imputation/temporal/plugin_ts_tabular_imputer.py
@@ -1,7 +1,6 @@
 import dataclasses
 from typing import Any, Dict, List
 
-from hyperimpute.plugins.imputers import Imputers
 from typing_extensions import Self, get_args
 
 import tempor.plugins.core as plugins
@@ -10,6 +9,11 @@
 from tempor.plugins.core._params import CategoricalParams, Params
 from tempor.plugins.preprocessing.imputation._base import BaseImputer, TabularImputerType
 
+from ..hyperimpute_utils import monkeypatch_hyperimpute_logger
+
+with monkeypatch_hyperimpute_logger():
+    from hyperimpute.plugins.imputers import Imputers
+
 
 @dataclasses.dataclass
 class TemporalTabularImputerParams:
diff --git a/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
index 42a98825..20a816f0 100644
--- a/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/static/plugin_static_minmax_scaler.py
@@ -8,6 +8,7 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import StaticSamples
+from tempor.plugins.core._params import CategoricalParams
 from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
@@ -86,4 +87,6 @@ def _transform(self, data: dataset.BaseDataset, *args, **kwargs) -> dataset.Base
 
     @staticmethod
     def hyperparameter_space(*args, **kwargs):
-        return []
+        return [
+            CategoricalParams("clip", [True, False]),
+        ]
diff --git a/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
index 66a2155a..cb5ec453 100644
--- a/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
+++ b/src/tempor/plugins/preprocessing/scaling/temporal/plugin_ts_minmax_scaler.py
@@ -8,6 +8,7 @@
 import tempor.plugins.core as plugins
 from tempor.data import dataset
 from tempor.data.samples import TimeSeriesSamples
+from tempor.plugins.core._params import CategoricalParams
 from tempor.plugins.preprocessing.scaling._base import BaseScaler
 
 
@@ -83,4 +84,6 @@ def _transform(self, data: dataset.BaseDataset, *args, **kwargs) -> dataset.Base
 
     @staticmethod
     def hyperparameter_space(*args, **kwargs):
-        return []
+        return [
+            CategoricalParams("clip", [True, False]),
+        ]
diff --git a/tutorials/user_guide/tutorial07_pipeline.ipynb b/tutorials/user_guide/tutorial07_pipeline.ipynb
index f63f3fe8..01b7aaf6 100644
--- a/tutorials/user_guide/tutorial07_pipeline.ipynb
+++ b/tutorials/user_guide/tutorial07_pipeline.ipynb
@@ -19,7 +19,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 4,
+   "execution_count": 1,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -40,7 +40,17 @@
     "inspired by be [scikit-learn pipeline](https://scikit-learn.org/stable/modules/generated/sklearn.pipeline.Pipeline.html).\n",
     "\n",
     "* All but the final plugin in the pipeline need to be data transformers (the `preprocessing` plugin category),\n",
-    "* The final one must be a predictive plugin (any of the `prediction`, `time_to_event`, `treatments` plugin categories)."
+    "* The final one must be a predictive plugin (any of the `prediction`, `time_to_event`, `treatments` plugin categories).\n",
+    "\n",
+    "When fitting, all the stages will be fitted, and the data will be sequentially transformed by all the preprocessing\n",
+    "steps before fitting the final predictive method plugin.\n",
+    "\n",
+    "When predicting, the data will be again transformed by the preprocessing steps, and prediction carried out using the\n",
+    "final predictive method plugin.\n",
+    "\n",
+    "**Note:**\n",
+    "\n",
+    "All pipelines follow `PipelineBase` interface, see API reference for details."
    ]
   },
   {
@@ -52,27 +62,153 @@
     "\n",
     "Below is an example of a pipeline ending with `prediction.one_off.nn_classifier`.\n",
     "\n",
-    "Initializing the `Pipeline`:"
+    "Initializing the `Pipeline` follows the following steps.\n",
+    "1. Use the `pipeline()` function to create a *pipeline class* from a list of strings denoting its steps.\n",
+    "1. Instantiate the pipeline class. The initialization arguments to each component plugin can be passed as a dictionary at this step.\n",
+    "1. Use the pipeline like any other TemporAI estimator (call `.fit(...)`, `.predict(...)` and so on)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from rich.pretty import pprint  # For fancy printing only."
    ]
   },
   {
    "cell_type": "code",
-   "execution_count": 5,
+   "execution_count": 3,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "Pipeline class: <class 'tempor.plugins.pipeline.bfill_static_tabular_imputer_ts_tabular_imputer_ts_minmax_scaler_nn_classifier'>\n",
-      "Pipeline instance: <tempor.plugins.pipeline.bfill_static_tabular_imputer_ts_tabular_imputer_ts_minmax_scaler_nn_classifier object at 0x7ff0ec8e6100>\n"
+      "Pipeline class:\n",
+      "<class 'tempor.plugins.pipeline.pipeline.<locals>.Pipeline'>\n",
+      "\n",
+      "Pipeline base classes (note `PipelineBase`):\n"
+     ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"font-weight: bold\">[</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"font-weight: bold\">&lt;</span><span style=\"color: #ff00ff; text-decoration-color: #ff00ff; font-weight: bold\">class</span><span style=\"color: #000000; text-decoration-color: #000000\"> </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.pipeline.pipeline.&lt;locals&gt;.Pipeline'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.pipeline.PipelineBase'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.prediction.one_off.classification.BaseOneOffClassifier'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.core._base_predictor.BasePredictor'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.core._base_estimator.BaseEstimator'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'tempor.plugins.core._plugin.Plugin'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'abc.ABC'</span><span style=\"color: #000000; text-decoration-color: #000000\">&gt;,</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #000000; text-decoration-color: #000000\">&lt;class </span><span style=\"color: #008000; text-decoration-color: #008000\">'object'</span><span style=\"font-weight: bold\">&gt;</span>\n",
+       "<span style=\"font-weight: bold\">]</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1m[\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[1m<\u001b[0m\u001b[1;95mclass\u001b[0m\u001b[39m \u001b[0m\u001b[32m'tempor.plugins.pipeline.pipeline.<locals>.Pipeline'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'tempor.plugins.pipeline.PipelineBase'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'tempor.plugins.prediction.one_off.classification.BaseOneOffClassifier'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'tempor.plugins.core._base_predictor.BasePredictor'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'tempor.plugins.core._base_estimator.BaseEstimator'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'tempor.plugins.core._plugin.Plugin'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'abc.ABC'\u001b[0m\u001b[39m>,\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[39m<class \u001b[0m\u001b[32m'object'\u001b[0m\u001b[1m>\u001b[0m\n",
+       "\u001b[1m]\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Pipeline instance:\n"
      ]
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">Pipeline</span><span style=\"font-weight: bold\">(</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #808000; text-decoration-color: #808000\">pipeline_seq</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'preprocessing.imputation.temporal.bfill-&gt;preprocessing.imputation.static.static_tabular_imputer-&gt;preprocessing.imputation.temporal.ts_tabular_imputer-&gt;preprocessing.scaling.temporal.ts_minmax_scaler-&gt;prediction.one_off.classification.nn_classifier'</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #808000; text-decoration-color: #808000\">predictor_category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'bfill'</span>: <span style=\"font-weight: bold\">{}</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'static_tabular_imputer'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'imputer'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'ice'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'imputer_params'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span><span style=\"font-weight: bold\">}}</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'ts_tabular_imputer'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'imputer'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'ice'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'imputer_params'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span><span style=\"font-weight: bold\">}}</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'ts_minmax_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'feature_range'</span>: <span style=\"font-weight: bold\">[</span><span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span><span style=\"font-weight: bold\">]</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'clip'</span>: <span style=\"color: #ff0000; text-decoration-color: #ff0000; font-style: italic\">False</span><span style=\"font-weight: bold\">}</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'nn_classifier'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_static_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_static_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">102</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'mode'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'RNN'</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'window_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">20</span>,\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   │   </span><span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   │   </span><span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"color: #7fbf7f; text-decoration-color: #7fbf7f\">│   </span><span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mPipeline\u001b[0m\u001b[1m(\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[33mpipeline_seq\u001b[0m=\u001b[32m'preprocessing.imputation.temporal.bfill->preprocessing.imputation.static.static_tabular_imputer->preprocessing.imputation.temporal.ts_tabular_imputer->preprocessing.scaling.temporal.ts_minmax_scaler->prediction.one_off.classification.nn_classifier'\u001b[0m,\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[33mpredictor_category\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[32m'bfill'\u001b[0m: \u001b[1m{\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[32m'static_tabular_imputer'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'imputer'\u001b[0m: \u001b[32m'ice'\u001b[0m, \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m, \u001b[32m'imputer_params'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[32m'ts_tabular_imputer'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'imputer'\u001b[0m: \u001b[32m'ice'\u001b[0m, \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m, \u001b[32m'imputer_params'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[32m'ts_minmax_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'feature_range'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m1\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'clip'\u001b[0m: \u001b[3;91mFalse\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[32m'nn_classifier'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_static_units_hidden'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_static_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_temporal_units_hidden'\u001b[0m: \u001b[1;36m102\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_temporal_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_iter'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'mode'\u001b[0m: \u001b[32m'RNN'\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'batch_size'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'lr'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'window_size'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'patience'\u001b[0m: \u001b[1;36m20\u001b[0m,\n",
+       "\u001b[2;32m│   │   │   \u001b[0m\u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m\n",
+       "\u001b[2;32m│   │   \u001b[0m\u001b[1m}\u001b[0m\n",
+       "\u001b[2;32m│   \u001b[0m\u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
     }
    ],
    "source": [
-    "from tempor.plugins.pipeline import Pipeline\n",
+    "from tempor.plugins.pipeline import pipeline\n",
     "\n",
-    "pipeline_class = Pipeline(\n",
+    "# 1. Create a pipeline class based on your desired definition of the pipeline.\n",
+    "PipelineClass = pipeline(\n",
     "    # Provide plugin names for the pipeline, in order.\n",
     "    [\n",
     "        # Preprocessing (data transformer) plugins:\n",
@@ -84,16 +220,22 @@
     "        \"prediction.one_off.classification.nn_classifier\",\n",
     "    ],\n",
     ")\n",
-    "print(\"Pipeline class:\", pipeline_class)\n",
+    "print(\"Pipeline class:\")\n",
+    "print(PipelineClass)\n",
+    "\n",
+    "print(\"\\nPipeline base classes (note `PipelineBase`):\")\n",
+    "pprint(PipelineClass.mro())\n",
     "\n",
-    "pipeline = pipeline_class(\n",
+    "pipe = PipelineClass(\n",
     "    # You can provide initialization arguments to each plugin comprising the pipeline as a dictionary, as follows:\n",
     "    {\n",
     "        \"static_imputer\": {\"static_imputer\": \"ice\", \"random_state\": 42},\n",
     "        \"nn_classifier\": {\"n_iter\": 100},\n",
     "    }\n",
     ")\n",
-    "print(\"Pipeline instance:\", pipeline)"
+    "\n",
+    "print(\"Pipeline instance:\")\n",
+    "pprint(pipe)"
    ]
   },
   {
@@ -106,7 +248,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 6,
+   "execution_count": 4,
    "metadata": {},
    "outputs": [
     {
@@ -206,7 +348,7 @@
        "[100 rows x 1 columns]"
       ]
      },
-     "execution_count": 6,
+     "execution_count": 4,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -217,10 +359,10 @@
     "dataset = SineDataLoader(random_state=42).load()\n",
     "\n",
     "# Fit:\n",
-    "pipeline.fit(dataset)\n",
+    "pipe.fit(dataset)\n",
     "\n",
     "# Predict:\n",
-    "pipeline.predict(dataset)  # This will transform the data ant then predict."
+    "pipe.predict(dataset)  # This will transform the data ant then predict."
    ]
   },
   {
diff --git a/tutorials/user_guide/tutorial08_benchmarks.ipynb b/tutorials/user_guide/tutorial08_benchmarks.ipynb
index 59d8cb79..c4881086 100644
--- a/tutorials/user_guide/tutorial08_benchmarks.ipynb
+++ b/tutorials/user_guide/tutorial08_benchmarks.ipynb
@@ -19,7 +19,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": null,
+   "execution_count": 1,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -50,6 +50,20 @@
    "execution_count": 2,
    "metadata": {},
    "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 19:18:40 | INFO     | tempor.benchmarks.benchmark:benchmark_models:91 | Test case: model_1\n",
+      "2023-05-14 19:18:42 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.68637615442276, validation loss: 0.6680578589439392\n",
+      "2023-05-14 19:18:43 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6887814402580261, validation loss: 0.6939960718154907\n",
+      "2023-05-14 19:18:43 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6906945109367371, validation loss: 0.6966598033905029\n",
+      "2023-05-14 19:18:43 | INFO     | tempor.benchmarks.benchmark:benchmark_models:91 | Test case: model_2\n",
+      "2023-05-14 19:18:48 | INFO     | tempor.models.ts_ode:_train:608 | Epoch:99| train loss: 0.6465951204299927, validation loss: 0.5632617473602295\n",
+      "2023-05-14 19:18:52 | INFO     | tempor.models.ts_ode:_train:608 | Epoch:99| train loss: 0.913261890411377, validation loss: 0.8132617473602295\n",
+      "2023-05-14 19:18:57 | INFO     | tempor.models.ts_ode:_train:608 | Epoch:99| train loss: 0.7132617831230164, validation loss: 0.8132617473602295\n"
+     ]
+    },
     {
      "name": "stdout",
      "output_type": "stream",
@@ -85,77 +99,77 @@
        "  <tbody>\n",
        "    <tr>\n",
        "      <th>aucroc</th>\n",
-       "      <td>0.333 +/- 0.109</td>\n",
-       "      <td>0.422 +/- 0.11</td>\n",
+       "      <td>0.311 +/- 0.083</td>\n",
+       "      <td>0.583 +/- 0.312</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>aucprc</th>\n",
-       "      <td>0.453 +/- 0.099</td>\n",
-       "      <td>0.456 +/- 0.107</td>\n",
+       "      <td>0.385 +/- 0.028</td>\n",
+       "      <td>0.588 +/- 0.292</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>accuracy</th>\n",
-       "      <td>0.528 +/- 0.137</td>\n",
+       "      <td>0.602 +/- 0.033</td>\n",
        "      <td>0.519 +/- 0.105</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_micro</th>\n",
-       "      <td>0.528 +/- 0.137</td>\n",
+       "      <td>0.602 +/- 0.033</td>\n",
        "      <td>0.519 +/- 0.105</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_macro</th>\n",
-       "      <td>0.484 +/- 0.112</td>\n",
+       "      <td>0.375 +/- 0.013</td>\n",
        "      <td>0.338 +/- 0.048</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_weighted</th>\n",
-       "      <td>0.514 +/- 0.128</td>\n",
+       "      <td>0.453 +/- 0.04</td>\n",
        "      <td>0.361 +/- 0.116</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>kappa</th>\n",
-       "      <td>0.001 +/- 0.232</td>\n",
+       "      <td>0.0 +/- 0.0</td>\n",
        "      <td>0.0 +/- 0.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>kappa_quadratic</th>\n",
-       "      <td>0.001 +/- 0.232</td>\n",
+       "      <td>0.0 +/- 0.0</td>\n",
        "      <td>0.0 +/- 0.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_micro</th>\n",
-       "      <td>0.528 +/- 0.137</td>\n",
+       "      <td>0.602 +/- 0.033</td>\n",
        "      <td>0.519 +/- 0.105</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_macro</th>\n",
-       "      <td>0.497 +/- 0.122</td>\n",
+       "      <td>0.301 +/- 0.016</td>\n",
        "      <td>0.259 +/- 0.053</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_weighted</th>\n",
-       "      <td>0.514 +/- 0.128</td>\n",
+       "      <td>0.363 +/- 0.039</td>\n",
        "      <td>0.28 +/- 0.104</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_micro</th>\n",
-       "      <td>0.528 +/- 0.137</td>\n",
+       "      <td>0.602 +/- 0.033</td>\n",
        "      <td>0.519 +/- 0.105</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_macro</th>\n",
-       "      <td>0.486 +/- 0.114</td>\n",
+       "      <td>0.5 +/- 0.0</td>\n",
        "      <td>0.5 +/- 0.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_weighted</th>\n",
-       "      <td>0.528 +/- 0.137</td>\n",
+       "      <td>0.602 +/- 0.033</td>\n",
        "      <td>0.519 +/- 0.105</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>mcc</th>\n",
-       "      <td>0.001 +/- 0.235</td>\n",
+       "      <td>0.0 +/- 0.0</td>\n",
        "      <td>0.0 +/- 0.0</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
@@ -164,21 +178,21 @@
       ],
       "text/plain": [
        "                            model_1          model_2\n",
-       "aucroc              0.333 +/- 0.109   0.422 +/- 0.11\n",
-       "aucprc              0.453 +/- 0.099  0.456 +/- 0.107\n",
-       "accuracy            0.528 +/- 0.137  0.519 +/- 0.105\n",
-       "f1_score_micro      0.528 +/- 0.137  0.519 +/- 0.105\n",
-       "f1_score_macro      0.484 +/- 0.112  0.338 +/- 0.048\n",
-       "f1_score_weighted   0.514 +/- 0.128  0.361 +/- 0.116\n",
-       "kappa               0.001 +/- 0.232      0.0 +/- 0.0\n",
-       "kappa_quadratic     0.001 +/- 0.232      0.0 +/- 0.0\n",
-       "precision_micro     0.528 +/- 0.137  0.519 +/- 0.105\n",
-       "precision_macro     0.497 +/- 0.122  0.259 +/- 0.053\n",
-       "precision_weighted  0.514 +/- 0.128   0.28 +/- 0.104\n",
-       "recall_micro        0.528 +/- 0.137  0.519 +/- 0.105\n",
-       "recall_macro        0.486 +/- 0.114      0.5 +/- 0.0\n",
-       "recall_weighted     0.528 +/- 0.137  0.519 +/- 0.105\n",
-       "mcc                 0.001 +/- 0.235      0.0 +/- 0.0"
+       "aucroc              0.311 +/- 0.083  0.583 +/- 0.312\n",
+       "aucprc              0.385 +/- 0.028  0.588 +/- 0.292\n",
+       "accuracy            0.602 +/- 0.033  0.519 +/- 0.105\n",
+       "f1_score_micro      0.602 +/- 0.033  0.519 +/- 0.105\n",
+       "f1_score_macro      0.375 +/- 0.013  0.338 +/- 0.048\n",
+       "f1_score_weighted    0.453 +/- 0.04  0.361 +/- 0.116\n",
+       "kappa                   0.0 +/- 0.0      0.0 +/- 0.0\n",
+       "kappa_quadratic         0.0 +/- 0.0      0.0 +/- 0.0\n",
+       "precision_micro     0.602 +/- 0.033  0.519 +/- 0.105\n",
+       "precision_macro     0.301 +/- 0.016  0.259 +/- 0.053\n",
+       "precision_weighted  0.363 +/- 0.039   0.28 +/- 0.104\n",
+       "recall_micro        0.602 +/- 0.033  0.519 +/- 0.105\n",
+       "recall_macro            0.5 +/- 0.0      0.5 +/- 0.0\n",
+       "recall_weighted     0.602 +/- 0.033  0.519 +/- 0.105\n",
+       "mcc                     0.0 +/- 0.0      0.0 +/- 0.0"
       ]
      },
      "metadata": {},
@@ -221,78 +235,78 @@
        "  <tbody>\n",
        "    <tr>\n",
        "      <th>aucroc</th>\n",
-       "      <td>0.333333</td>\n",
-       "      <td>0.108866</td>\n",
+       "      <td>0.311111</td>\n",
+       "      <td>0.083148</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>aucprc</th>\n",
-       "      <td>0.452844</td>\n",
-       "      <td>0.099425</td>\n",
+       "      <td>0.385185</td>\n",
+       "      <td>0.028154</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>accuracy</th>\n",
-       "      <td>0.527778</td>\n",
-       "      <td>0.137493</td>\n",
+       "      <td>0.601852</td>\n",
+       "      <td>0.032736</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_micro</th>\n",
-       "      <td>0.527778</td>\n",
-       "      <td>0.137493</td>\n",
+       "      <td>0.601852</td>\n",
+       "      <td>0.032736</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_macro</th>\n",
-       "      <td>0.484091</td>\n",
-       "      <td>0.112494</td>\n",
+       "      <td>0.375458</td>\n",
+       "      <td>0.012951</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>f1_score_weighted</th>\n",
-       "      <td>0.514141</td>\n",
-       "      <td>0.127851</td>\n",
+       "      <td>0.452788</td>\n",
+       "      <td>0.039572</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>kappa</th>\n",
-       "      <td>0.001000</td>\n",
-       "      <td>0.232335</td>\n",
+       "      <td>0.000000</td>\n",
+       "      <td>0.000000</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>kappa_quadratic</th>\n",
-       "      <td>0.001000</td>\n",
-       "      <td>0.232335</td>\n",
+       "      <td>0.000000</td>\n",
+       "      <td>0.000000</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_micro</th>\n",
-       "      <td>0.527778</td>\n",
-       "      <td>0.137493</td>\n",
+       "      <td>0.601852</td>\n",
+       "      <td>0.032736</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_macro</th>\n",
-       "      <td>0.497222</td>\n",
-       "      <td>0.121780</td>\n",
+       "      <td>0.300926</td>\n",
+       "      <td>0.016368</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>precision_weighted</th>\n",
-       "      <td>0.513889</td>\n",
-       "      <td>0.127672</td>\n",
+       "      <td>0.363297</td>\n",
+       "      <td>0.038647</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_micro</th>\n",
-       "      <td>0.527778</td>\n",
-       "      <td>0.137493</td>\n",
+       "      <td>0.601852</td>\n",
+       "      <td>0.032736</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_macro</th>\n",
-       "      <td>0.486111</td>\n",
-       "      <td>0.113923</td>\n",
+       "      <td>0.500000</td>\n",
+       "      <td>0.000000</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>recall_weighted</th>\n",
-       "      <td>0.527778</td>\n",
-       "      <td>0.137493</td>\n",
+       "      <td>0.601852</td>\n",
+       "      <td>0.032736</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>mcc</th>\n",
-       "      <td>0.001000</td>\n",
-       "      <td>0.235264</td>\n",
+       "      <td>0.000000</td>\n",
+       "      <td>0.000000</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
@@ -300,21 +314,21 @@
       ],
       "text/plain": [
        "                        mean    stddev\n",
-       "aucroc              0.333333  0.108866\n",
-       "aucprc              0.452844  0.099425\n",
-       "accuracy            0.527778  0.137493\n",
-       "f1_score_micro      0.527778  0.137493\n",
-       "f1_score_macro      0.484091  0.112494\n",
-       "f1_score_weighted   0.514141  0.127851\n",
-       "kappa               0.001000  0.232335\n",
-       "kappa_quadratic     0.001000  0.232335\n",
-       "precision_micro     0.527778  0.137493\n",
-       "precision_macro     0.497222  0.121780\n",
-       "precision_weighted  0.513889  0.127672\n",
-       "recall_micro        0.527778  0.137493\n",
-       "recall_macro        0.486111  0.113923\n",
-       "recall_weighted     0.527778  0.137493\n",
-       "mcc                 0.001000  0.235264"
+       "aucroc              0.311111  0.083148\n",
+       "aucprc              0.385185  0.028154\n",
+       "accuracy            0.601852  0.032736\n",
+       "f1_score_micro      0.601852  0.032736\n",
+       "f1_score_macro      0.375458  0.012951\n",
+       "f1_score_weighted   0.452788  0.039572\n",
+       "kappa               0.000000  0.000000\n",
+       "kappa_quadratic     0.000000  0.000000\n",
+       "precision_micro     0.601852  0.032736\n",
+       "precision_macro     0.300926  0.016368\n",
+       "precision_weighted  0.363297  0.038647\n",
+       "recall_micro        0.601852  0.032736\n",
+       "recall_macro        0.500000  0.000000\n",
+       "recall_weighted     0.601852  0.032736\n",
+       "mcc                 0.000000  0.000000"
       ]
      },
      "metadata": {},
@@ -355,13 +369,13 @@
        "  <tbody>\n",
        "    <tr>\n",
        "      <th>aucroc</th>\n",
-       "      <td>0.422222</td>\n",
-       "      <td>0.109994</td>\n",
+       "      <td>0.583333</td>\n",
+       "      <td>0.311805</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>aucprc</th>\n",
-       "      <td>0.456349</td>\n",
-       "      <td>0.106738</td>\n",
+       "      <td>0.587731</td>\n",
+       "      <td>0.291568</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>accuracy</th>\n",
@@ -434,8 +448,8 @@
       ],
       "text/plain": [
        "                        mean    stddev\n",
-       "aucroc              0.422222  0.109994\n",
-       "aucprc              0.456349  0.106738\n",
+       "aucroc              0.583333  0.311805\n",
+       "aucprc              0.587731  0.291568\n",
        "accuracy            0.518519  0.105369\n",
        "f1_score_micro      0.518519  0.105369\n",
        "f1_score_macro      0.338162  0.047609\n",
@@ -465,10 +479,10 @@
     "dataset = SineDataLoader(random_state=42, no=25).load()\n",
     "\n",
     "results_readable, results = benchmark_models(\n",
-    "    task_type=\"classification\",\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
     "    tests=[\n",
-    "        (\"model_1\", plugin_loader.get(\"prediction.one_off.classification.nn_classifier\", n_iter=50)),\n",
-    "        (\"model_2\", plugin_loader.get(\"prediction.one_off.classification.ode_classifier\", n_iter=50)),\n",
+    "        (\"model_1\", plugin_loader.get(\"prediction.one_off.classification.nn_classifier\", n_iter=10)),\n",
+    "        (\"model_2\", plugin_loader.get(\"prediction.one_off.classification.ode_classifier\", n_iter=100)),\n",
     "    ],\n",
     "    data=dataset,\n",
     "    n_splits=3,\n",
@@ -493,9 +507,9 @@
     "> ⚠️ Not all task types are supported by `benchmark_models` yet.\n",
     "\n",
     "Supported tasks (for each `task_type` argument):\n",
-    "* `task_type=\"classification\"`: `prediction.one_off.classification` models.\n",
-    "* `task_type=\"regression\"`: `prediction.one_off.regression` models.\n",
-    "* `task_type=\"time_to_event\"`: `time_to_event` models.\n"
+    "* `task_type=\"prediction.one_off.classification\"`.\n",
+    "* `task_type=\"prediction.one_off.regression\"`.\n",
+    "* `task_type=\"time_to_event\"`.\n"
    ]
   },
   {
diff --git a/tutorials/user_guide/tutorial09_automl.ipynb b/tutorials/user_guide/tutorial09_automl.ipynb
new file mode 100644
index 00000000..9dc1758d
--- /dev/null
+++ b/tutorials/user_guide/tutorial09_automl.ipynb
@@ -0,0 +1,1094 @@
+{
+ "cells": [
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# User Guide Tutorial 09: AutoML\n",
+    "\n",
+    "TemporAI provides AutoML tools for finding the best model for your use case in `tempor.automl`, these are demonstrated here."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "*Skip the below cell if you are not on Google Colab / already have TemporAI installed:*"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%pip install temporai\n",
+    "\n",
+    "# Or from the repo, for the latest version:\n",
+    "# %pip install git+https://github.com/vanderschaarlab/temporai.git"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## AutoML in TemporAI Overview\n",
+    "\n",
+    "TemporAI provides two AutoML approaches (\"seekers\") under the `tempor.automl.seekers` module.\n",
+    "\n",
+    "1. `MethodSeeker`: Search the hyperparameter space of a particular predictive method.\n",
+    "2. `PipelineSeeker`: Search the hyperparameter space of a pipeline like `preprocessing steps -> predictive step`.\n",
+    "\n",
+    "The optimization strategies are facilitated by [`optuna`]() and the currently supported strategies are:\n",
+    "* [Bayesian, specifically Tree-structured Parzen estimator](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.TPESampler.html) (`\"bayesian\"`),\n",
+    "* [Random](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.RandomSampler.html) (`\"random\"`),\n",
+    "* [CMA-ES](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.CmaEsSampler.html) (`\"cmaes\"`),\n",
+    "* [QMC](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.QMCSampler.html) (`\"qmc\"`),\n",
+    "* [Grid](https://optuna.readthedocs.io/en/stable/reference/samplers/generated/optuna.samplers.GridSampler.html) (`\"grid\"`)."
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using `MethodSeeker`\n",
+    "\n",
+    "Use a `MethodSeeker` to search for best algorithm and hyperparameters parameters for a particular task.\n",
+    "No preprocessing (data transformation) steps are carried out in this approach, so preprocess the data using\n",
+    "`tempor.plugins.preprocessing` first, as needed.\n",
+    "\n",
+    "A `MethodSeeker` can be initialized as follows."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_set_up_tuners:354 | Setting up estimators and tuners for study my_automl_study.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator cde_classifier.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator ode_classifier.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator nn_classifier.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from tempor.automl.seeker import MethodSeeker\n",
+    "from tempor.utils.dataloaders import SineDataLoader\n",
+    "\n",
+    "# Load your dataset.\n",
+    "dataset = SineDataLoader().load()\n",
+    "\n",
+    "seeker = MethodSeeker(\n",
+    "    # Name your AutoML study:\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    # Select the type of task:\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
+    "    # Choose which predictive methods to use in the search:\n",
+    "    estimator_names=[\n",
+    "        \"cde_classifier\",\n",
+    "        \"ode_classifier\",\n",
+    "        \"nn_classifier\",\n",
+    "    ],\n",
+    "    # Choose a metric. Metric maximization/minimization will be determined automatically.\n",
+    "    metric=\"aucroc\",\n",
+    "    # Pass in your dataset.\n",
+    "    dataset=dataset,\n",
+    "    # How many best models to return:\n",
+    "    return_top_k=3,\n",
+    "    # Number of AutoML iterations:\n",
+    "    num_iter=100,\n",
+    "    # Type of AutoML tuner to use:\n",
+    "    tuner_type=\"bayesian\",\n",
+    "    # You can also provide some other options like early stopping patience, number of cross-validation folds etc.\n",
+    ")"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can then run the AutoML search as below.\n",
+    "\n",
+    "The below example also shows how you can provide a custom hyperparameter space (*override* the default hyperparameter\n",
+    "space for a model)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_set_up_tuners:354 | Setting up estimators and tuners for study my_automl_study.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator cde_classifier.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator ode_classifier.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator nn_classifier.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from tempor.automl.params import IntegerParams, CategoricalParams\n",
+    "\n",
+    "# Provide a custom hyperparameter space to search for each type of model.\n",
+    "# NOTE: For the sake of speed of this example, we limit epochs to 2.\n",
+    "hp_space = {\n",
+    "    \"cde_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_temporal_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "    \"ode_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "    \"nn_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "}\n",
+    "\n",
+    "# Initialize a `MethodSeeker` and provide `override_hp_space`.\n",
+    "seeker = MethodSeeker(\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
+    "    estimator_names=[\n",
+    "        \"cde_classifier\",\n",
+    "        \"ode_classifier\",\n",
+    "        \"nn_classifier\",\n",
+    "    ],\n",
+    "    metric=\"aucroc\",\n",
+    "    dataset=dataset,\n",
+    "    return_top_k=3,\n",
+    "    num_iter=3,  # For the sake of speed of this example, only 3 AutoML iterations.\n",
+    "    tuner_type=\"bayesian\",\n",
+    "    # Override hyperparameter space:\n",
+    "    override_hp_space=hp_space,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator 'cde_classifier' 1/3.\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:48:44 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from CDEClassifier:\n",
+      "{'n_iter': 2, 'n_temporal_units_hidden': 13, 'lr': 0.01}\n",
+      "2023-05-14 21:48:49 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from CDEClassifier:\n",
+      "{'n_iter': 2, 'n_temporal_units_hidden': 11, 'lr': 0.0001}\n",
+      "2023-05-14 21:48:53 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from CDEClassifier:\n",
+      "{'n_iter': 2, 'n_temporal_units_hidden': 20, 'lr': 0.001}\n",
+      "2023-05-14 21:48:56 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator 'ode_classifier' 2/3.\n",
+      "2023-05-14 21:48:56 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:48:56 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from ODEClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 13, 'lr': 0.01}\n",
+      "2023-05-14 21:48:59 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from ODEClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}\n",
+      "2023-05-14 21:49:02 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from ODEClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 20, 'lr': 0.001}\n",
+      "2023-05-14 21:49:05 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator 'nn_classifier' 3/3.\n",
+      "2023-05-14 21:49:05 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:49:05 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from NeuralNetClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 13, 'lr': 0.01}\n",
+      "2023-05-14 21:49:06 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919505596160889, validation loss: 0.6882655620574951\n",
+      "2023-05-14 21:49:07 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.691156268119812, validation loss: 0.6865323185920715\n",
+      "2023-05-14 21:49:07 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.692247211933136, validation loss: 0.6861071586608887\n",
+      "2023-05-14 21:49:08 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6928325891494751, validation loss: 0.6825112104415894\n",
+      "2023-05-14 21:49:08 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919053792953491, validation loss: 0.6819757223129272\n",
+      "2023-05-14 21:49:08 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from NeuralNetClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}\n",
+      "2023-05-14 21:49:09 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919505596160889, validation loss: 0.691270649433136\n",
+      "2023-05-14 21:49:09 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.691156268119812, validation loss: 0.6910805106163025\n",
+      "2023-05-14 21:49:10 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.692247211933136, validation loss: 0.6900344491004944\n",
+      "2023-05-14 21:49:10 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6928325891494751, validation loss: 0.6906693577766418\n",
+      "2023-05-14 21:49:11 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919053792953491, validation loss: 0.6910874843597412\n",
+      "2023-05-14 21:49:11 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from NeuralNetClassifier:\n",
+      "{'n_iter': 2, 'n_units_hidden': 20, 'lr': 0.001}\n",
+      "2023-05-14 21:49:11 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919505596160889, validation loss: 0.6896670460700989\n",
+      "2023-05-14 21:49:12 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.691156268119812, validation loss: 0.6886401772499084\n",
+      "2023-05-14 21:49:12 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.692247211933136, validation loss: 0.6884845495223999\n",
+      "2023-05-14 21:49:13 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6928325891494751, validation loss: 0.6887120604515076\n",
+      "2023-05-14 21:49:13 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6919053792953491, validation loss: 0.6893669962882996\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for cde_classifier scores:\n",
+      "[0.47310606060606064, 0.48396464646464643, 0.4840277777777778].\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for ode_classifier scores:\n",
+      "[0.47992424242424236, 0.48409090909090907, 0.484090909090909].\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for nn_classifier scores:\n",
+      "[0.37064393939393936, 0.5370580808080809, 0.4579545454545454].\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:448 | \n",
+      "All estimator definitions searched:\n",
+      "['cde_classifier', 'ode_classifier', 'nn_classifier']\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:449 | \n",
+      "Best scores for each estimator searched:\n",
+      "[0.4840277777777778, 0.48409090909090907, 0.5370580808080809]\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:450 | \n",
+      "Best hyperparameters for each estimator searched:\n",
+      "[{'n_iter': 2, 'n_temporal_units_hidden': 20, 'lr': 0.001}, {'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}, {'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}]\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:571 | \n",
+      "Selected score 0.5370580808080809 for nn_classifier with hyperparameters:\n",
+      "{'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:571 | \n",
+      "Selected score 0.48409090909090907 for ode_classifier with hyperparameters:\n",
+      "{'n_iter': 2, 'n_units_hidden': 11, 'lr': 0.0001}\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:571 | \n",
+      "Selected score 0.4840277777777778 for cde_classifier with hyperparameters:\n",
+      "{'n_iter': 2, 'n_temporal_units_hidden': 20, 'lr': 0.001}\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Execute the search.\n",
+    "\n",
+    "best_methods, best_scores = seeker.search()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">NeuralNetClassifier</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">name</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'nn_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_static_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_static_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">102</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'mode'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'RNN'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'window_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">20</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mNeuralNetClassifier\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mname\u001b[0m=\u001b[32m'nn_classifier'\u001b[0m,\n",
+       "    \u001b[33mcategory\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'n_static_units_hidden'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "        \u001b[32m'n_static_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "        \u001b[32m'n_temporal_units_hidden'\u001b[0m: \u001b[1;36m102\u001b[0m,\n",
+       "        \u001b[32m'n_temporal_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "        \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "        \u001b[32m'mode'\u001b[0m: \u001b[32m'RNN'\u001b[0m,\n",
+       "        \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "        \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "        \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.0001\u001b[0m,\n",
+       "        \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "        \u001b[32m'window_size'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "        \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "        \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "        \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "        \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "        \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'patience'\u001b[0m: \u001b[1;36m20\u001b[0m,\n",
+       "        \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">ODEClassifier</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">name</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'ode_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">11</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'atol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'rtol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'interpolation'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'cubic'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">500</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mODEClassifier\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mname\u001b[0m=\u001b[32m'ode_classifier'\u001b[0m,\n",
+       "    \u001b[33mcategory\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'n_units_hidden'\u001b[0m: \u001b[1;36m11\u001b[0m,\n",
+       "        \u001b[32m'n_layers_hidden'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "        \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "        \u001b[32m'atol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "        \u001b[32m'rtol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "        \u001b[32m'interpolation'\u001b[0m: \u001b[32m'cubic'\u001b[0m,\n",
+       "        \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.0001\u001b[0m,\n",
+       "        \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "        \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "        \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m500\u001b[0m,\n",
+       "        \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "        \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "        \u001b[32m'patience'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "        \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m,\n",
+       "        \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "        \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">CDEClassifier</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">name</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'cde_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'atol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'rtol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'interpolation'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'cubic'</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">500</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mCDEClassifier\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mname\u001b[0m=\u001b[32m'cde_classifier'\u001b[0m,\n",
+       "    \u001b[33mcategory\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'n_units_hidden'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "        \u001b[32m'n_layers_hidden'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "        \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "        \u001b[32m'atol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "        \u001b[32m'rtol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "        \u001b[32m'interpolation'\u001b[0m: \u001b[32m'cubic'\u001b[0m,\n",
+       "        \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "        \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "        \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "        \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m500\u001b[0m,\n",
+       "        \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "        \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "        \u001b[32m'patience'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "        \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "        \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m,\n",
+       "        \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "        \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "# The best methods are returned, and can be used by calling .predict() and so on.\n",
+    "\n",
+    "import rich.pretty  # For pretty printing only.\n",
+    "\n",
+    "for method in best_methods:\n",
+    "    rich.pretty.pprint(method, indent_guides=False)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using `PipelineSeeker`\n",
+    "\n",
+    "Use a `PipelineSeeker` to search for best *pipeline* (`preprocessing steps -> prediction step`) for a particular task.\n",
+    "\n",
+    "This seeker will create pipelines comprised of:\n",
+    "- A static imputer (if at lease one candidate in ``static_imputers`` provided),\n",
+    "- A static scaler (if at lease one candidate in ``static_scalers`` provided),\n",
+    "- A temporal imputer (if at lease one candidate in ``temporal_imputers`` provided),\n",
+    "- A temporal scaler (if at lease one candidate in ``temporal_scalers`` provided),\n",
+    "- The final predictor, from the ``estimator_names`` options.\n",
+    "\n",
+    "The imputer/scaler candidates will be sampled as a categorical hyperparameter. The hyperparameter spaces of these,\n",
+    "and of the final predictor, will be sampled.\n",
+    "\n",
+    "A `PipelineSeeker` uses a very similar interface to `MethodSeeker`, and can be initialized as follows."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_set_up_tuners:354 | Setting up estimators and tuners for study my_automl_study.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with cde_classifier>.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with ode_classifier>.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with nn_classifier>.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from tempor.automl.seeker import PipelineSeeker\n",
+    "\n",
+    "seeker = PipelineSeeker(\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
+    "    # The estimators here will be the final step of the pipeline:\n",
+    "    estimator_names=[\n",
+    "        \"cde_classifier\",\n",
+    "        \"ode_classifier\",\n",
+    "        \"nn_classifier\",\n",
+    "    ],\n",
+    "    metric=\"aucroc\",\n",
+    "    dataset=dataset,\n",
+    "    return_top_k=3,\n",
+    "    num_iter=100,\n",
+    "    tuner_type=\"bayesian\",\n",
+    "    # The following arguments specify the candidates of the different preprocessing steps, e.g.:\n",
+    "    static_imputers=[\"static_tabular_imputer\"],\n",
+    "    static_scalers=[],\n",
+    "    temporal_imputers=[\"ffill\", \"bfill\"],\n",
+    "    temporal_scalers=[\"ts_minmax_scaler\"],\n",
+    ")"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "By default, the following preprocessing candidates will be used, if you do not specify the argument:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Static imputer candidates: ['static_tabular_imputer']\n",
+      "Static scaler candidates: ['static_minmax_scaler', 'static_standard_scaler']\n",
+      "Temporal imputer candidates: ['ffill', 'ts_tabular_imputer', 'bfill']\n",
+      "Temporal scaler candidates: ['ts_minmax_scaler', 'ts_standard_scaler']\n"
+     ]
+    }
+   ],
+   "source": [
+    "from tempor.automl.seeker import (\n",
+    "    DEFAULT_STATIC_IMPUTERS,\n",
+    "    DEFAULT_STATIC_SCALERS,\n",
+    "    DEFAULT_TEMPORAL_IMPUTERS,\n",
+    "    DEFAULT_TEMPORAL_SCALERS,\n",
+    ")\n",
+    "\n",
+    "print(\"Static imputer candidates:\", DEFAULT_STATIC_IMPUTERS)\n",
+    "print(\"Static scaler candidates:\", DEFAULT_STATIC_SCALERS)\n",
+    "print(\"Temporal imputer candidates:\", DEFAULT_TEMPORAL_IMPUTERS)\n",
+    "print(\"Temporal scaler candidates:\", DEFAULT_TEMPORAL_SCALERS)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can execute the search as follows."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_set_up_tuners:354 | Setting up estimators and tuners for study my_automl_study.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with cde_classifier>.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with ode_classifier>.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:_init_estimator:733 | Creating estimator <Pipeline with nn_classifier>.\n"
+     ]
+    }
+   ],
+   "source": [
+    "from tempor.automl.params import IntegerParams, CategoricalParams\n",
+    "\n",
+    "# Provide a custom hyperparameter space to search for each type of model.\n",
+    "# These can be provided for the final (predictive) step of the pipeline.\n",
+    "# Default hyperparameter space will be sampled for the preprocessing steps.\n",
+    "# NOTE: For the sake of speed of this example, we limit epochs to 2.\n",
+    "hp_space = {\n",
+    "    \"cde_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_temporal_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "    \"ode_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "    \"nn_classifier\": [\n",
+    "        IntegerParams(name=\"n_iter\", low=2, high=2),\n",
+    "        IntegerParams(name=\"n_units_hidden\", low=5, high=20),\n",
+    "        CategoricalParams(name=\"lr\", choices=[1e-2, 1e-3, 1e-4]),\n",
+    "    ],\n",
+    "}\n",
+    "\n",
+    "# Initialize a `PipelineSeeker` and provide `override_hp_space`.\n",
+    "seeker = PipelineSeeker(\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
+    "    estimator_names=[\n",
+    "        \"cde_classifier\",\n",
+    "        \"ode_classifier\",\n",
+    "        \"nn_classifier\",\n",
+    "    ],\n",
+    "    metric=\"aucroc\",\n",
+    "    dataset=dataset,\n",
+    "    return_top_k=3,\n",
+    "    num_iter=3,  # For the sake of speed of this example, only 3 AutoML iterations.\n",
+    "    tuner_type=\"bayesian\",\n",
+    "    # Override hyperparameter space:\n",
+    "    override_hp_space=hp_space,\n",
+    "    # Specify preprocessing candidates:\n",
+    "    static_imputers=[\"static_tabular_imputer\"],\n",
+    "    static_scalers=[\"static_minmax_scaler\", \"static_standard_scaler\"],\n",
+    "    temporal_imputers=[],\n",
+    "    temporal_scalers=[\"ts_minmax_scaler\", \"ts_standard_scaler\"],\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator '<Pipeline with cde_classifier>' 1/3.\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:49:14 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.cde_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'softimpute'}, 'static_minmax_scaler': {'clip': True}, 'ts_standard_scaler': {}, 'cde_classifier': {'n_iter': 2, 'n_temporal_units_hidden': 17, 'lr': 0.001}}}\n",
+      "2023-05-14 21:49:30 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_minmax_scaler->prediction.one_off.classification.cde_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'mean'}, 'static_minmax_scaler': {'clip': False}, 'ts_minmax_scaler': {'clip': False}, 'cde_classifier': {'n_iter': 2, 'n_temporal_units_hidden': 14, 'lr': 0.001}}}\n",
+      "2023-05-14 21:49:36 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_standard_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.cde_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'most_frequent'}, 'static_standard_scaler': {}, 'ts_standard_scaler': {}, 'cde_classifier': {'n_iter': 2, 'n_temporal_units_hidden': 6, 'lr': 0.0001}}}\n",
+      "2023-05-14 21:49:42 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator '<Pipeline with ode_classifier>' 2/3.\n",
+      "2023-05-14 21:49:42 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:49:42 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.ode_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'softimpute'}, 'static_minmax_scaler': {'clip': True}, 'ts_standard_scaler': {}, 'ode_classifier': {'n_iter': 2, 'n_units_hidden': 17, 'lr': 0.001}}}\n",
+      "2023-05-14 21:50:00 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_minmax_scaler->prediction.one_off.classification.ode_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'mean'}, 'static_minmax_scaler': {'clip': False}, 'ts_minmax_scaler': {'clip': False}, 'ode_classifier': {'n_iter': 2, 'n_units_hidden': 14, 'lr': 0.001}}}\n",
+      "2023-05-14 21:50:06 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_standard_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.ode_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'most_frequent'}, 'static_standard_scaler': {}, 'ts_standard_scaler': {}, 'ode_classifier': {'n_iter': 2, 'n_units_hidden': 6, 'lr': 0.0001}}}\n",
+      "2023-05-14 21:50:11 | INFO     | tempor.automl.seeker:search:413 | Running  search for estimator '<Pipeline with nn_classifier>' 3/3.\n",
+      "2023-05-14 21:50:11 | INFO     | tempor.automl.tuner:tune:205 | Baseline score computation skipped\n",
+      "2023-05-14 21:50:11 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.nn_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'softimpute'}, 'static_minmax_scaler': {'clip': True}, 'ts_standard_scaler': {}, 'nn_classifier': {'n_iter': 2, 'n_units_hidden': 17, 'lr': 0.001}}}\n",
+      "2023-05-14 21:50:14 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6918608546257019, validation loss: 0.6910383105278015\n",
+      "2023-05-14 21:50:17 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6909429430961609, validation loss: 0.6872987151145935\n",
+      "2023-05-14 21:50:21 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.692150354385376, validation loss: 0.6879752278327942\n",
+      "2023-05-14 21:50:24 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6926363706588745, validation loss: 0.6881052851676941\n",
+      "2023-05-14 21:50:27 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6915659308433533, validation loss: 0.6888220310211182\n",
+      "2023-05-14 21:50:28 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_minmax_scaler->prediction.one_off.classification.nn_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'mean'}, 'static_minmax_scaler': {'clip': False}, 'ts_minmax_scaler': {'clip': False}, 'nn_classifier': {'n_iter': 2, 'n_units_hidden': 14, 'lr': 0.001}}}\n",
+      "2023-05-14 21:50:28 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6920319199562073, validation loss: 0.6892786622047424\n",
+      "2023-05-14 21:50:29 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6913070678710938, validation loss: 0.6893746852874756\n",
+      "2023-05-14 21:50:30 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6924894452095032, validation loss: 0.6886722445487976\n",
+      "2023-05-14 21:50:31 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6930248141288757, validation loss: 0.6894596815109253\n",
+      "2023-05-14 21:50:32 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6921922564506531, validation loss: 0.6893032789230347\n",
+      "2023-05-14 21:50:33 | INFO     | tempor.automl.tuner:objective:227 | \n",
+      "Hyperparameters sampled from preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_standard_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.nn_classifier:\n",
+      "{'plugin_params': {'static_tabular_imputer': {'imputer': 'most_frequent'}, 'static_standard_scaler': {}, 'ts_standard_scaler': {}, 'nn_classifier': {'n_iter': 2, 'n_units_hidden': 6, 'lr': 0.0001}}}\n",
+      "2023-05-14 21:50:33 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6914154291152954, validation loss: 0.6911771297454834\n",
+      "2023-05-14 21:50:34 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.690178394317627, validation loss: 0.6934390664100647\n",
+      "2023-05-14 21:50:35 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6933540105819702, validation loss: 0.6896734833717346\n",
+      "2023-05-14 21:50:36 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.694333553314209, validation loss: 0.690070390701294\n",
+      "2023-05-14 21:50:37 | INFO     | tempor.models.ts_model:_train:379 | Epoch:0| train loss: 0.6921719312667847, validation loss: 0.6909489631652832\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for <Pipeline with cde_classifier> scores:\n",
+      "[0.4716540404040404, 0.47575757575757577, 0.4657828282828283].\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for <Pipeline with ode_classifier> scores:\n",
+      "[0.47575757575757577, 0.4779040404040404, 0.4779671717171716].\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:447 | \n",
+      "Evaluation for <Pipeline with nn_classifier> scores:\n",
+      "[0.48478535353535346, 0.4361742424242424, 0.5071338383838383].\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:448 | \n",
+      "All estimator definitions searched:\n",
+      "['<Pipeline with cde_classifier>', '<Pipeline with ode_classifier>', '<Pipeline with nn_classifier>']\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:449 | \n",
+      "Best scores for each estimator searched:\n",
+      "[0.47575757575757577, 0.4779671717171716, 0.5071338383838383]\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:search:450 | \n",
+      "Best hyperparameters for each estimator searched:\n",
+      "[{'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'mean', '<candidates>(preprocessing.scaling.static)': 'static_minmax_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_minmax_scaler', '[ts_minmax_scaler](clip)': False, '[cde_classifier](n_iter)': 2, '[cde_classifier](n_temporal_units_hidden)': 14, '[cde_classifier](lr)': 0.001}, {'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'most_frequent', '<candidates>(preprocessing.scaling.static)': 'static_standard_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_standard_scaler', '[ts_minmax_scaler](clip)': False, '[ode_classifier](n_iter)': 2, '[ode_classifier](n_units_hidden)': 6, '[ode_classifier](lr)': 0.0001}, {'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'most_frequent', '<candidates>(preprocessing.scaling.static)': 'static_standard_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_standard_scaler', '[ts_minmax_scaler](clip)': False, '[nn_classifier](n_iter)': 2, '[nn_classifier](n_units_hidden)': 6, '[nn_classifier](lr)': 0.0001}]\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:748 | \n",
+      "Selected score 0.5071338383838383 for <Pipeline with nn_classifier> with hyperparameters:\n",
+      "{'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'most_frequent', '<candidates>(preprocessing.scaling.static)': 'static_standard_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_standard_scaler', '[ts_minmax_scaler](clip)': False, '[nn_classifier](n_iter)': 2, '[nn_classifier](n_units_hidden)': 6, '[nn_classifier](lr)': 0.0001}\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:748 | \n",
+      "Selected score 0.4779671717171716 for <Pipeline with ode_classifier> with hyperparameters:\n",
+      "{'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'most_frequent', '<candidates>(preprocessing.scaling.static)': 'static_standard_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_standard_scaler', '[ts_minmax_scaler](clip)': False, '[ode_classifier](n_iter)': 2, '[ode_classifier](n_units_hidden)': 6, '[ode_classifier](lr)': 0.0001}\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_create_estimator_with_hps:748 | \n",
+      "Selected score 0.47575757575757577 for <Pipeline with cde_classifier> with hyperparameters:\n",
+      "{'<candidates>(preprocessing.imputation.static)': 'static_tabular_imputer', '[static_tabular_imputer](imputer)': 'mean', '<candidates>(preprocessing.scaling.static)': 'static_minmax_scaler', '[static_minmax_scaler](clip)': False, '<candidates>(preprocessing.scaling.temporal)': 'ts_minmax_scaler', '[ts_minmax_scaler](clip)': False, '[cde_classifier](n_iter)': 2, '[cde_classifier](n_temporal_units_hidden)': 14, '[cde_classifier](lr)': 0.001}\n"
+     ]
+    }
+   ],
+   "source": [
+    "best_pipelines, best_scores = seeker.search()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">Pipeline</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">pipeline_seq</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'preprocessing.imputation.static.static_tabular_imputer-&gt;preprocessing.scaling.static.static_standard_scaler-&gt;preprocessing.scaling.temporal.ts_standard_scaler-&gt;prediction.one_off.classification.nn_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">predictor_category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_tabular_imputer'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'imputer'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'most_frequent'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'imputer_params'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span><span style=\"font-weight: bold\">}</span>\n",
+       "        <span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_standard_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'with_mean'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'with_std'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'ts_standard_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'with_mean'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'with_std'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'nn_classifier'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_static_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_static_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">102</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_temporal_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'mode'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'RNN'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'window_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">20</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>\n",
+       "        <span style=\"font-weight: bold\">}</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mPipeline\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mpipeline_seq\u001b[0m=\u001b[32m'preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_standard_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.nn_classifier'\u001b[0m,\n",
+       "    \u001b[33mpredictor_category\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'static_tabular_imputer'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "            \u001b[32m'imputer'\u001b[0m: \u001b[32m'most_frequent'\u001b[0m,\n",
+       "            \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "            \u001b[32m'imputer_params'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m\n",
+       "        \u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'static_standard_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'with_mean'\u001b[0m: \u001b[3;92mTrue\u001b[0m, \u001b[32m'with_std'\u001b[0m: \u001b[3;92mTrue\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'ts_standard_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'with_mean'\u001b[0m: \u001b[3;92mTrue\u001b[0m, \u001b[32m'with_std'\u001b[0m: \u001b[3;92mTrue\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'nn_classifier'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "            \u001b[32m'n_static_units_hidden'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "            \u001b[32m'n_static_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "            \u001b[32m'n_temporal_units_hidden'\u001b[0m: \u001b[1;36m102\u001b[0m,\n",
+       "            \u001b[32m'n_temporal_layers_hidden'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "            \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "            \u001b[32m'mode'\u001b[0m: \u001b[32m'RNN'\u001b[0m,\n",
+       "            \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "            \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "            \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.0001\u001b[0m,\n",
+       "            \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "            \u001b[32m'window_size'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "            \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "            \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "            \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "            \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "            \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'patience'\u001b[0m: \u001b[1;36m20\u001b[0m,\n",
+       "            \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m\n",
+       "        \u001b[1m}\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">Pipeline</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">pipeline_seq</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'preprocessing.imputation.static.static_tabular_imputer-&gt;preprocessing.scaling.static.static_standard_scaler-&gt;preprocessing.scaling.temporal.ts_standard_scaler-&gt;prediction.one_off.classification.ode_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">predictor_category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_tabular_imputer'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'imputer'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'most_frequent'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'imputer_params'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span><span style=\"font-weight: bold\">}</span>\n",
+       "        <span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_standard_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'with_mean'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'with_std'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'ts_standard_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'with_mean'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'with_std'</span>: <span style=\"color: #00ff00; text-decoration-color: #00ff00; font-style: italic\">True</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'ode_classifier'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">6</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'atol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'rtol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'interpolation'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'cubic'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">500</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>\n",
+       "        <span style=\"font-weight: bold\">}</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mPipeline\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mpipeline_seq\u001b[0m=\u001b[32m'preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_standard_scaler->preprocessing.scaling.temporal.ts_standard_scaler->prediction.one_off.classification.ode_classifier'\u001b[0m,\n",
+       "    \u001b[33mpredictor_category\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'static_tabular_imputer'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "            \u001b[32m'imputer'\u001b[0m: \u001b[32m'most_frequent'\u001b[0m,\n",
+       "            \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "            \u001b[32m'imputer_params'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m\n",
+       "        \u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'static_standard_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'with_mean'\u001b[0m: \u001b[3;92mTrue\u001b[0m, \u001b[32m'with_std'\u001b[0m: \u001b[3;92mTrue\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'ts_standard_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'with_mean'\u001b[0m: \u001b[3;92mTrue\u001b[0m, \u001b[32m'with_std'\u001b[0m: \u001b[3;92mTrue\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'ode_classifier'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "            \u001b[32m'n_units_hidden'\u001b[0m: \u001b[1;36m6\u001b[0m,\n",
+       "            \u001b[32m'n_layers_hidden'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "            \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "            \u001b[32m'atol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "            \u001b[32m'rtol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "            \u001b[32m'interpolation'\u001b[0m: \u001b[32m'cubic'\u001b[0m,\n",
+       "            \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.0001\u001b[0m,\n",
+       "            \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "            \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "            \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m500\u001b[0m,\n",
+       "            \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "            \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "            \u001b[32m'patience'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "            \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m,\n",
+       "            \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "            \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m\n",
+       "        \u001b[1m}\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/html": [
+       "<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #800080; text-decoration-color: #800080; font-weight: bold\">Pipeline</span><span style=\"font-weight: bold\">(</span>\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">pipeline_seq</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'preprocessing.imputation.static.static_tabular_imputer-&gt;preprocessing.scaling.static.static_minmax_scaler-&gt;preprocessing.scaling.temporal.ts_minmax_scaler-&gt;prediction.one_off.classification.cde_classifier'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">predictor_category</span>=<span style=\"color: #008000; text-decoration-color: #008000\">'prediction.one_off.classification'</span>,\n",
+       "    <span style=\"color: #808000; text-decoration-color: #808000\">params</span>=<span style=\"font-weight: bold\">{</span>\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_tabular_imputer'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'imputer'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'mean'</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'imputer_params'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span><span style=\"font-weight: bold\">}}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'static_minmax_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'feature_range'</span>: <span style=\"font-weight: bold\">[</span><span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span><span style=\"font-weight: bold\">]</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'clip'</span>: <span style=\"color: #ff0000; text-decoration-color: #ff0000; font-style: italic\">False</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'ts_minmax_scaler'</span>: <span style=\"font-weight: bold\">{</span><span style=\"color: #008000; text-decoration-color: #008000\">'feature_range'</span>: <span style=\"font-weight: bold\">[</span><span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>, <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span><span style=\"font-weight: bold\">]</span>, <span style=\"color: #008000; text-decoration-color: #008000\">'clip'</span>: <span style=\"color: #ff0000; text-decoration-color: #ff0000; font-style: italic\">False</span><span style=\"font-weight: bold\">}</span>,\n",
+       "        <span style=\"color: #008000; text-decoration-color: #008000\">'cde_classifier'</span>: <span style=\"font-weight: bold\">{</span>\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_units_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_layers_hidden'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'nonlin'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'relu'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dropout'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'atol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'rtol'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.01</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'interpolation'</span>: <span style=\"color: #008000; text-decoration-color: #008000\">'cubic'</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'lr'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'weight_decay'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.001</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">2</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'batch_size'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">500</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'n_iter_print'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">100</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'random_state'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'patience'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">10</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'clipping_value'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">1</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'train_ratio'</span>: <span style=\"color: #008080; text-decoration-color: #008080; font-weight: bold\">0.8</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'device'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>,\n",
+       "            <span style=\"color: #008000; text-decoration-color: #008000\">'dataloader_sampler'</span>: <span style=\"color: #800080; text-decoration-color: #800080; font-style: italic\">None</span>\n",
+       "        <span style=\"font-weight: bold\">}</span>\n",
+       "    <span style=\"font-weight: bold\">}</span>\n",
+       "<span style=\"font-weight: bold\">)</span>\n",
+       "</pre>\n"
+      ],
+      "text/plain": [
+       "\u001b[1;35mPipeline\u001b[0m\u001b[1m(\u001b[0m\n",
+       "    \u001b[33mpipeline_seq\u001b[0m=\u001b[32m'preprocessing.imputation.static.static_tabular_imputer->preprocessing.scaling.static.static_minmax_scaler->preprocessing.scaling.temporal.ts_minmax_scaler->prediction.one_off.classification.cde_classifier'\u001b[0m,\n",
+       "    \u001b[33mpredictor_category\u001b[0m=\u001b[32m'prediction.one_off.classification'\u001b[0m,\n",
+       "    \u001b[33mparams\u001b[0m=\u001b[1m{\u001b[0m\n",
+       "        \u001b[32m'static_tabular_imputer'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'imputer'\u001b[0m: \u001b[32m'mean'\u001b[0m, \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m, \u001b[32m'imputer_params'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'static_minmax_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'feature_range'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m1\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'clip'\u001b[0m: \u001b[3;91mFalse\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'ts_minmax_scaler'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'feature_range'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m, \u001b[1;36m1\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'clip'\u001b[0m: \u001b[3;91mFalse\u001b[0m\u001b[1m}\u001b[0m,\n",
+       "        \u001b[32m'cde_classifier'\u001b[0m: \u001b[1m{\u001b[0m\n",
+       "            \u001b[32m'n_units_hidden'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "            \u001b[32m'n_layers_hidden'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'nonlin'\u001b[0m: \u001b[32m'relu'\u001b[0m,\n",
+       "            \u001b[32m'dropout'\u001b[0m: \u001b[1;36m0.0\u001b[0m,\n",
+       "            \u001b[32m'atol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "            \u001b[32m'rtol'\u001b[0m: \u001b[1;36m0.01\u001b[0m,\n",
+       "            \u001b[32m'interpolation'\u001b[0m: \u001b[32m'cubic'\u001b[0m,\n",
+       "            \u001b[32m'lr'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "            \u001b[32m'weight_decay'\u001b[0m: \u001b[1;36m0.001\u001b[0m,\n",
+       "            \u001b[32m'n_iter'\u001b[0m: \u001b[1;36m2\u001b[0m,\n",
+       "            \u001b[32m'batch_size'\u001b[0m: \u001b[1;36m500\u001b[0m,\n",
+       "            \u001b[32m'n_iter_print'\u001b[0m: \u001b[1;36m100\u001b[0m,\n",
+       "            \u001b[32m'random_state'\u001b[0m: \u001b[1;36m0\u001b[0m,\n",
+       "            \u001b[32m'patience'\u001b[0m: \u001b[1;36m10\u001b[0m,\n",
+       "            \u001b[32m'clipping_value'\u001b[0m: \u001b[1;36m1\u001b[0m,\n",
+       "            \u001b[32m'train_ratio'\u001b[0m: \u001b[1;36m0.8\u001b[0m,\n",
+       "            \u001b[32m'device'\u001b[0m: \u001b[3;35mNone\u001b[0m,\n",
+       "            \u001b[32m'dataloader_sampler'\u001b[0m: \u001b[3;35mNone\u001b[0m\n",
+       "        \u001b[1m}\u001b[0m\n",
+       "    \u001b[1m}\u001b[0m\n",
+       "\u001b[1m)\u001b[0m\n"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "# The best performing pipelines are returned, and can be used by calling .predict() and so on.\n",
+    "\n",
+    "for method in best_pipelines:\n",
+    "    rich.pretty.pprint(method, indent_guides=False)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Advanced customization\n",
+    "\n",
+    "You may further customize the AutoML tuning behavior by specifying the sampler an pruner, if desired.\n",
+    "\n",
+    "See the below example."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_set_up_tuners:354 | Setting up estimators and tuners for study my_automl_study.\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator cde_classifier.\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator ode_classifier.\n",
+      "2023-05-14 21:50:38 | INFO     | tempor.automl.seeker:_init_estimator:563 | Creating estimator nn_classifier.\n"
+     ]
+    }
+   ],
+   "source": [
+    "# 1. Import a Tuner:\n",
+    "from tempor.automl.tuner import OptunaTuner\n",
+    "\n",
+    "# 2. Customize this as needed:\n",
+    "import optuna\n",
+    "\n",
+    "custom_tuner = OptunaTuner(\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    direction=\"maximize\",\n",
+    "    # Customized sampler:\n",
+    "    study_sampler=optuna.samplers.TPESampler(seed=12345, n_startup_trials=3),\n",
+    "    # Customized pruner:\n",
+    "    study_pruner=optuna.pruners.MedianPruner(interval_steps=2),\n",
+    "    # Using default optuna storage object here, but may a provide custom one, e.g. redis.\n",
+    "    study_storage=None,\n",
+    ")\n",
+    "\n",
+    "# 3. Pass the Tuner to the {Method/Pipeline}Seeker:\n",
+    "seeker = MethodSeeker(\n",
+    "    study_name=\"my_automl_study\",\n",
+    "    task_type=\"prediction.one_off.classification\",\n",
+    "    estimator_names=[\n",
+    "        \"cde_classifier\",\n",
+    "        \"ode_classifier\",\n",
+    "        \"nn_classifier\",\n",
+    "    ],\n",
+    "    metric=\"aucroc\",\n",
+    "    dataset=dataset,\n",
+    "    # Like so:\n",
+    "    custom_tuner=custom_tuner,\n",
+    ")\n",
+    "\n",
+    "# 4. Execute search:\n",
+    "# results = seeker.search() ..."
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Supported tasks\n",
+    "\n",
+    "> ⚠️ The tasks for which benchmarking is supported are supported by AutoML. See the benchmarking tutorial.\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 🎉 Congratulations!\n",
+    "\n",
+    "Congratulations on completing this notebook tutorial! If you enjoyed this and would like to join the movement towards *Machine learning and AI for Medicine*, you can do so in the following ways!\n",
+    "\n",
+    "\n",
+    "\n",
+    "### ⭐ Star [TemporAI](https://github.com/vanderschaarlab/temporai) on GitHub\n",
+    "\n",
+    "- The easiest way to help our community is by just starring the repos! This helps raise awareness of the tools we're building.\n",
+    "\n",
+    "\n",
+    "\n",
+    "### Check out other projects from [vanderschaarlab](https://github.com/vanderschaarlab)\n",
+    "- 📝 [HyperImpute](https://github.com/vanderschaarlab/hyperimpute)\n",
+    "- 📊 [AutoPrognosis](https://github.com/vanderschaarlab/autoprognosis)\n",
+    "- 🤖 [SynthCity](https://github.com/vanderschaarlab/synthcity)\n",
+    " "
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "RE_py38_temporai",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.15"
+  },
+  "orig_nbformat": 4
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3c138f2f..e4a3e2f2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -61,11 +61,11 @@ jobs:
           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 --cov
+        run: pytest -vvvx -n auto -m "not slow and not extra and not skipci" --durations=50 --cov
       - name: Test with pytest
         # 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 --cov
+        run: pytest -vvvx -m "not slow and not extra and not skipci" --durations=50 --cov
       - name: Upload coverage report to codecov
         uses: codecov/codecov-action@v3
         # Currently we only upload in one of the matrix cases: ubuntu + py38:
diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml
index b28f5146..47703c5e 100644
--- a/.github/workflows/test_full.yml
+++ b/.github/workflows/test_full.yml
@@ -57,11 +57,11 @@ jobs:
           pip install .[dev]
       - name: Test with pytest (xdist)
         if: matrix.os != 'ubuntu-latest'
-        run: pytest -vvvx -n auto --durations=50 --cov
+        run: pytest -vvvx -n auto -m "not skipci" --durations=50 --cov
       - name: Test with pytest
         # Avoid xdist on 'ubuntu-latest', as this has worse performance.
         if: matrix.os == 'ubuntu-latest'
-        run: pytest -vvvx --durations=50 --cov
+        run: pytest -vvvx -m "not skipci" --durations=50 --cov
       - name: Upload coverage report to codecov
         uses: codecov/codecov-action@v3
         # Currently we only upload in one of the matrix cases: ubuntu + py38:
diff --git a/pytest.ini b/pytest.ini
index ef933aef..0945830c 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -29,3 +29,4 @@ markers =
     extra: additional test which may be skipped for minimal testing
     cpu: test uses CPU device, apply this mark if test can also be run for other devices (e.g. CUDA)
     cuda: test requires CUDA device support (auto-skipped if no CUDA support found)
+    skipci: a mark to indicate to skip this test in CI runs
diff --git a/tests/automl/test_pipeline_selector.py b/tests/automl/test_pipeline_selector.py
new file mode 100644
index 00000000..793223c1
--- /dev/null
+++ b/tests/automl/test_pipeline_selector.py
@@ -0,0 +1,254 @@
+from typing import List
+
+import pytest
+
+from tempor.automl import pipeline_selector
+from tempor.plugins.pipeline import PipelineBase
+
+TEST_PREDICTOR_CASES = [
+    ("prediction.one_off.classification", "nn_classifier"),
+    ("prediction.one_off.regression", "nn_regressor"),
+    ("time_to_event", "dynamic_deephit"),
+    ("treatments.one_off.regression", "synctwin_regressor"),
+    ("treatments.temporal.classification", "crn_classifier"),
+    ("treatments.temporal.regression", "crn_regressor"),
+]
+TEST_STATIC_IMPUTERS_CASES = [
+    [],
+    pipeline_selector.DEFAULT_STATIC_IMPUTERS,
+]
+TEST_STATIC_SCALERS_CASES = [
+    [],
+    pipeline_selector.DEFAULT_STATIC_SCALERS,
+]
+TEST_TEMPORAL_IMPUTERS_CASES = [
+    [],
+    pipeline_selector.DEFAULT_TEMPORAL_IMPUTERS,
+]
+TEST_TEMPORAL_SCALERS_CASES = [
+    [],
+    pipeline_selector.DEFAULT_TEMPORAL_SCALERS,
+]
+
+
+@pytest.mark.parametrize("task_type,predictor", TEST_PREDICTOR_CASES)
+@pytest.mark.parametrize("static_imputers", TEST_STATIC_IMPUTERS_CASES)
+@pytest.mark.parametrize("static_scalers", TEST_STATIC_SCALERS_CASES)
+@pytest.mark.parametrize("temporal_imputers", TEST_TEMPORAL_IMPUTERS_CASES)
+@pytest.mark.parametrize("temporal_scalers", TEST_TEMPORAL_SCALERS_CASES)
+def test_init(task_type, predictor, static_imputers, static_scalers, temporal_imputers, temporal_scalers):
+    ps = pipeline_selector.PipelineSelector(
+        task_type=task_type,
+        predictor=predictor,
+        static_imputers=static_imputers,
+        static_scalers=static_scalers,
+        temporal_imputers=temporal_imputers,
+        temporal_scalers=temporal_scalers,
+    )
+
+    assert len(ps.static_imputers) == len(static_imputers)
+    assert len(ps.static_scalers) == len(static_scalers)
+    assert len(ps.temporal_imputers) == len(temporal_imputers)
+    assert len(ps.temporal_scalers) == len(temporal_scalers)
+    assert ps.task_type == task_type
+    assert ps.predictor.name == predictor
+
+
+@pytest.mark.parametrize("task_type,predictor", TEST_PREDICTOR_CASES)
+@pytest.mark.parametrize("static_imputers", TEST_STATIC_IMPUTERS_CASES)
+@pytest.mark.parametrize("static_scalers", TEST_STATIC_SCALERS_CASES)
+@pytest.mark.parametrize("temporal_imputers", TEST_TEMPORAL_IMPUTERS_CASES)
+@pytest.mark.parametrize("temporal_scalers", TEST_TEMPORAL_SCALERS_CASES)
+def test_hyperparameter_space(
+    task_type, predictor, static_imputers, static_scalers, temporal_imputers, temporal_scalers
+):
+    ps = pipeline_selector.PipelineSelector(
+        task_type=task_type,
+        predictor=predictor,
+        static_imputers=static_imputers,
+        static_scalers=static_scalers,
+        temporal_imputers=temporal_imputers,
+        temporal_scalers=temporal_scalers,
+    )
+
+    hps = ps.hyperparameter_space()
+    hps_names = [hp.name for hp in hps]
+
+    for hp in ps.predictor.hyperparameter_space():
+        assert f"[{predictor}]({hp.name})" in hps_names
+
+    if static_imputers:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_IMPUTERS})" in hps_names
+        for candidate in ps.static_imputers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in hps_names
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_IMPUTERS})" not in hps_names
+    if static_scalers:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_SCALERS})" in hps_names
+        for candidate in ps.static_scalers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in hps_names
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_SCALERS})" not in hps_names
+    if temporal_imputers:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_IMPUTERS})" in hps_names
+        for candidate in ps.temporal_imputers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in hps_names
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_IMPUTERS})" not in hps_names
+    if temporal_scalers:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_SCALERS})" in hps_names
+        for candidate in ps.temporal_scalers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in hps_names
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_SCALERS})" not in hps_names
+
+
+@pytest.mark.parametrize("task_type,predictor", TEST_PREDICTOR_CASES)
+@pytest.mark.parametrize("static_imputers", TEST_STATIC_IMPUTERS_CASES)
+@pytest.mark.parametrize("static_scalers", TEST_STATIC_SCALERS_CASES)
+@pytest.mark.parametrize("temporal_imputers", TEST_TEMPORAL_IMPUTERS_CASES)
+@pytest.mark.parametrize("temporal_scalers", TEST_TEMPORAL_SCALERS_CASES)
+def test_sample_hyperparameters(
+    task_type, predictor, static_imputers, static_scalers, temporal_imputers, temporal_scalers
+):
+    ps = pipeline_selector.PipelineSelector(
+        task_type=task_type,
+        predictor=predictor,
+        static_imputers=static_imputers,
+        static_scalers=static_scalers,
+        temporal_imputers=temporal_imputers,
+        temporal_scalers=temporal_scalers,
+    )
+
+    sample = ps.sample_hyperparameters()
+
+    for hp in ps.predictor.hyperparameter_space():
+        assert f"[{predictor}]({hp.name})" in sample
+
+    if static_imputers:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_IMPUTERS})" in sample
+        for candidate in ps.static_imputers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in sample
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_IMPUTERS})" not in sample
+    if static_scalers:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_SCALERS})" in sample
+        for candidate in ps.static_scalers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in sample
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_STATIC_SCALERS})" not in sample
+    if temporal_imputers:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_IMPUTERS})" in sample
+        for candidate in ps.temporal_imputers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in sample
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_IMPUTERS})" not in sample
+    if temporal_scalers:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_SCALERS})" in sample
+        for candidate in ps.temporal_scalers:
+            for hp in candidate.hyperparameter_space():
+                assert f"[{candidate.name}]({hp.name})" in sample
+    else:
+        assert f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_SCALERS})" not in sample
+
+    for hp in ps.predictor.hyperparameter_space():
+        assert f"[{predictor}]({hp.name})" in sample
+
+
+def test_sample_hyperparameters_predictor_override():
+    from tempor.plugins.core._params import IntegerParams, Params
+
+    task_type = "prediction.one_off.classification"
+    predictor = "nn_classifier"
+    ps = pipeline_selector.PipelineSelector(task_type=task_type, predictor=predictor)
+
+    predictor_hps_override: List[Params] = [IntegerParams("n_iter", low=1, high=1)]
+
+    sample = ps.sample_hyperparameters(override=predictor_hps_override)  # type: ignore
+
+    assert len([s for s in sample if "[nn_classifier]" in s]) == 1
+    assert "[nn_classifier](n_iter)" in sample
+    assert sample["[nn_classifier](n_iter)"] == 1
+
+
+def assert_actual_params(pipe, sample, method):
+    estimator = [p for p in pipe.stages if p.name == method][0]
+    relevant = [(k.split("(")[-1][:-1], v) for k, v in sample.items() if f"[{method}]" in k]
+    for k, v in relevant:
+        assert getattr(estimator.params, k) == v
+
+
+@pytest.mark.filterwarnings("ignore:.*bool8.*:DeprecationWarning")  # Expected.
+@pytest.mark.parametrize("task_type,predictor", TEST_PREDICTOR_CASES)
+@pytest.mark.parametrize("static_imputers", TEST_STATIC_IMPUTERS_CASES)
+@pytest.mark.parametrize("static_scalers", TEST_STATIC_SCALERS_CASES)
+@pytest.mark.parametrize("temporal_imputers", TEST_TEMPORAL_IMPUTERS_CASES)
+@pytest.mark.parametrize("temporal_scalers", TEST_TEMPORAL_SCALERS_CASES)
+def test_pipeline_from_hps(task_type, predictor, static_imputers, static_scalers, temporal_imputers, temporal_scalers):
+    ps = pipeline_selector.PipelineSelector(
+        task_type=task_type,
+        predictor=predictor,
+        static_imputers=static_imputers,
+        static_scalers=static_scalers,
+        temporal_imputers=temporal_imputers,
+        temporal_scalers=temporal_scalers,
+    )
+
+    sample = ps.sample_hyperparameters()
+    pipe = ps.pipeline_from_hps(sample)
+    actual_estimator_names = [p.name for p in pipe.stages]  # pylint: disable=no-member
+
+    assert isinstance(pipe, PipelineBase)
+
+    if static_imputers:
+        chosen = sample[f"<candidates>({pipeline_selector.PREFIX_STATIC_IMPUTERS})"]
+        assert chosen in actual_estimator_names
+        assert_actual_params(pipe, sample, chosen)
+    if static_scalers:
+        chosen = sample[f"<candidates>({pipeline_selector.PREFIX_STATIC_SCALERS})"]
+        assert chosen in actual_estimator_names
+        assert_actual_params(pipe, sample, chosen)
+    if temporal_imputers:
+        chosen = sample[f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_IMPUTERS})"]
+        assert chosen in actual_estimator_names
+        assert_actual_params(pipe, sample, chosen)
+    if temporal_scalers:
+        chosen = sample[f"<candidates>({pipeline_selector.PREFIX_TEMPORAL_SCALERS})"]
+        assert chosen in actual_estimator_names
+        assert_actual_params(pipe, sample, chosen)
+
+    assert_actual_params(pipe, sample, predictor)
+
+
+def test_pipeline_from_hps_predictor_override():
+    from tempor.plugins.core._params import IntegerParams, Params
+
+    task_type = "prediction.one_off.classification"
+    predictor = "nn_classifier"
+    ps = pipeline_selector.PipelineSelector(task_type=task_type, predictor=predictor)
+
+    predictor_hps_override: List[Params] = [IntegerParams("n_iter", low=1, high=1)]
+
+    ps = pipeline_selector.PipelineSelector(
+        task_type=task_type,
+        predictor=predictor,
+        static_imputers=[],
+        static_scalers=[],
+        temporal_imputers=[],
+        temporal_scalers=[],
+    )
+
+    sample = ps.sample_hyperparameters(override=predictor_hps_override)
+    pipe = ps.pipeline_from_hps(sample)
+
+    assert isinstance(pipe, PipelineBase)
+
+    assert_actual_params(pipe, sample, predictor)
+    assert pipe.stages[-1].params.n_iter == 1  # pylint: disable=no-member
diff --git a/tests/automl/test_seeker.py b/tests/automl/test_seeker.py
new file mode 100644
index 00000000..fba68969
--- /dev/null
+++ b/tests/automl/test_seeker.py
@@ -0,0 +1,712 @@
+# pylint: disable=redefined-outer-name,unused-argument
+
+from typing import Callable
+from unittest.mock import MagicMock
+
+import pytest
+
+from tempor.automl.seeker import (
+    DEFAULT_STATIC_SCALERS,
+    DEFAULT_TEMPORAL_SCALERS,
+    METRIC_DIRECTION_MAP,
+    MethodSeeker,
+    PipelineSeeker,
+    TunerType,
+)
+from tempor.data.dataset import PredictiveDataset, TimeToEventAnalysisDataset
+from tempor.plugins.core._params import CategoricalParams, IntegerParams
+
+
+@pytest.fixture
+def patch_slow(monkeypatch, request):
+    # Monkeypatch all the slow things for ease of unit testing, specifically:
+    #   - Evaluation functions.
+    #   - Estimator methods fit/predict etc.
+    #   - Pipeline method generation fit/predict etc.
+
+    import numpy as np
+    import pandas as pd
+
+    from tempor.benchmarks import evaluation
+    from tempor.plugins.core import BasePredictor, BaseTransformer
+
+    def patched_fit(self, *args, **kwargs):  # pylint: disable=unused-argument
+        return self
+
+    def patched_predict(self, *args, **kwargs):  # pylint: disable=unused-argument
+        return MagicMock()
+
+    def patched_transform(self, *args, **kwargs):  # pylint: disable=unused-argument
+        return MagicMock()
+
+    monkeypatch.setattr(BasePredictor, "fit", patched_fit)
+    monkeypatch.setattr(BasePredictor, "predict", patched_predict)
+    monkeypatch.setattr(BaseTransformer, "transform", patched_transform)
+
+    np.random.seed(12345)
+
+    def patched_evaluate(*args, **kwargs):
+
+        # Since "ensure reproducibility" may affect seeding, and we want this function to return different values,
+        # seed it from input hash manually as below.
+        seed_from = str(args[0])
+        hash_ = hash(seed_from)
+        # Ensure fits with random seed requirements
+        hash_ = -1 * hash_ if hash_ < 0 else hash_
+        hash_ = int(str(hash_)[-9:])
+        np.random.seed(hash_)
+        # --- --- ---
+
+        return pd.DataFrame(
+            data=np.random.rand(len(METRIC_DIRECTION_MAP), 1),
+            index=list(METRIC_DIRECTION_MAP.keys()),
+            columns=["mean"],
+        )
+
+    monkeypatch.setattr(evaluation, "evaluate_prediction_oneoff_classifier", patched_evaluate)
+    monkeypatch.setattr(evaluation, "evaluate_prediction_oneoff_regressor", patched_evaluate)
+    monkeypatch.setattr(evaluation, "evaluate_time_to_event", patched_evaluate)
+
+    from tempor.plugins.pipeline import generators
+
+    def patched_generate_fit():
+        return patched_fit
+
+    def patched_generate_predict():
+        return patched_predict
+
+    monkeypatch.setattr(generators, "_generate_fit", patched_generate_fit)
+    monkeypatch.setattr(generators, "_generate_predict", patched_generate_predict)
+
+
+class TestMethodSeeker:
+    @pytest.mark.parametrize("tuner_type", ["bayesian", "random", "cmaes", "qmc"])
+    @pytest.mark.parametrize(
+        "override_hp_space",
+        [
+            None,
+            {
+                "cde_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+                "ode_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+                "nn_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+            },
+        ],
+    )
+    def test_init_success(self, tuner_type: TunerType, override_hp_space, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+        MethodSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            tuner_type=tuner_type,
+            override_hp_space=override_hp_space,
+        )
+
+    def test_init_success_custom_tuner(self, get_dataset: Callable):
+        import optuna
+
+        from tempor.automl.tuner import OptunaTuner
+
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+
+        custom_tuner = OptunaTuner(
+            study_name="test_study",
+            direction="maximize",
+            study_sampler=optuna.samplers.TPESampler(seed=12345),
+            study_pruner=optuna.pruners.PercentilePruner(0.1),
+        )
+
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            override_hp_space=None,
+            custom_tuner=custom_tuner,
+        )
+        assert len(seeker.tuners) == len(estimator_names)
+        for tuner in seeker.tuners:
+            assert isinstance(tuner, OptunaTuner)
+            assert "test_study_" in tuner.study_name
+            isinstance(tuner.study_pruner, optuna.pruners.PercentilePruner)
+
+    def test_init_fails_custom_tuner_grid_sampler_unsupported(self, get_dataset: Callable):
+        import optuna
+
+        from tempor.automl.tuner import OptunaTuner
+
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+
+        custom_tuner = OptunaTuner(
+            study_name="test_study",
+            direction="maximize",
+            study_sampler=optuna.samplers.GridSampler(search_space={}),
+            study_pruner=optuna.pruners.PercentilePruner(0.1),
+        )
+
+        with pytest.raises(ValueError, match=".*[Gg]rid.*not supported.*"):
+            MethodSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                override_hp_space=None,
+                custom_tuner=custom_tuner,
+            )
+
+    def test_init_fails_wrong_overrides(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        override_hp_space = {
+            "cde_classifier": [
+                IntegerParams(name="n_iter", low=2, high=100),
+            ],
+            "nonexistent_classifier": [
+                IntegerParams(name="n_iter", low=2, high=100),
+            ],
+        }
+        dataset = get_dataset("sine_data_full")
+
+        with pytest.raises(ValueError, match=".*override.*did not correspond.*"):
+            MethodSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                tuner_type="bayesian",
+                override_hp_space=override_hp_space,  # type: ignore
+            )
+
+    def test_init_success_grid(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        grid = {
+            "cde_classifier": {"n_iter": [10, 100], "lr": [0.01, 0.001]},
+            "ode_classifier": {"n_iter": [10, 100], "lr": [0.01, 0.001]},
+            "nn_classifier": {"n_iter": [10, 100], "lr": [0.01, 0.001]},
+        }
+        dataset = get_dataset("sine_data_full")
+        MethodSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            tuner_type="grid",
+            grid=grid,
+        )
+
+    def test_init_fails_grid(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        wrong_grid = {
+            "ode_classifier": {"n_iter": [10, 100], "lr": [0.01, 0.001]},
+            "mm_classifier": {"n_iter": [10, 100], "lr": [0.01, 0.001]},
+        }
+        dataset = get_dataset("sine_data_full")
+        with pytest.raises(ValueError, match=".*[Mm]ust provide.*grid.*"):
+            MethodSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                tuner_type="grid",
+                grid=None,
+            )
+        with pytest.raises(ValueError, match=".*grid.*did not match.*"):
+            MethodSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                tuner_type="grid",
+                grid=wrong_grid,
+            )
+
+    @pytest.mark.parametrize(
+        "task_type,estimator_names,metric",
+        [
+            (
+                "prediction.one_off.classification",
+                [
+                    "cde_classifier",
+                    "ode_classifier",
+                    "nn_classifier",
+                    "laplace_ode_classifier",
+                ],
+                "aucroc",
+            ),
+            (
+                "prediction.one_off.regression",
+                [
+                    "cde_regressor",
+                    "ode_regressor",
+                    "nn_regressor",
+                    "laplace_ode_regressor",
+                ],
+                "mse",
+            ),
+            (
+                "time_to_event",
+                [
+                    "dynamic_deephit",
+                    "ts_coxph",
+                    "ts_xgb",
+                ],
+                "c_index",
+            ),
+        ],
+    )
+    @pytest.mark.parametrize("override", [False, True])
+    def test_search(self, task_type, estimator_names, metric, override, patch_slow):
+        if task_type != "time_to_event":
+            dataset = MagicMock(PredictiveDataset)
+        else:
+            dataset = MagicMock(TimeToEventAnalysisDataset)
+
+        horizon = None
+        if task_type == "time_to_event":
+            horizon = [0.1, 0.5, 0.7]
+
+        override_hp_space = None
+        if override:
+            override_hp_space = {estimator_names[0]: [CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4])]}
+
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type=task_type,
+            estimator_names=estimator_names,
+            metric=metric,
+            dataset=dataset,
+            return_top_k=3,
+            num_iter=10,
+            override_hp_space=override_hp_space,  # type: ignore
+            horizon=horizon,
+        )
+
+        estimators, scores = seeker.search()
+
+        assert len(estimators) == len(scores) == 3
+        for est in estimators:
+            assert est.name in estimator_names
+        if seeker.direction == "maximize":
+            assert sorted(scores, reverse=True) == scores
+        else:
+            assert sorted(scores, reverse=False) == scores
+
+    def test_search_fail_time_to_event(self, patch_slow):
+        task_type = "time_to_event"
+        estimator_names = [
+            "dynamic_deephit",
+            "ts_coxph",
+            "ts_xgb",
+        ]
+
+        # No horizon.
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type=task_type,
+            estimator_names=estimator_names,
+            metric="c_index",
+            dataset=MagicMock(TimeToEventAnalysisDataset),
+            return_top_k=3,
+            num_iter=10,
+            horizon=None,
+        )
+        with pytest.raises(ValueError, match=".*horizon.*"):
+            seeker.search()
+
+        # Wrong dataset.
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type=task_type,
+            estimator_names=estimator_names,
+            metric="c_index",
+            dataset=MagicMock(PredictiveDataset),
+            return_top_k=3,
+            num_iter=10,
+            horizon=[0.1, 0.4, 0.9],
+        )
+        with pytest.raises(ValueError, match=".*dataset.*"):
+            seeker.search()
+
+    # TODO: May wish to add more cases of slow tests, mark them as extra.
+    @pytest.mark.slow
+    def test_search_end2end(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        override_hp_space = {
+            "cde_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_temporal_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "ode_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "nn_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+        }
+        dataset = get_dataset("sine_data_full")
+
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            return_top_k=2,
+            num_iter=3,
+            override_hp_space=override_hp_space,  # type: ignore
+        )
+
+        estimators, scores = seeker.search()
+
+        assert len(estimators) == len(scores) == 2
+        for est in estimators:
+            assert est.name in estimator_names
+
+    @pytest.mark.slow
+    def test_search_end2end_custom_tuner(self, get_dataset: Callable):
+        import optuna
+
+        from tempor.automl.tuner import OptunaTuner
+
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        override_hp_space = {
+            "cde_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_temporal_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "ode_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "nn_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+        }
+        dataset = get_dataset("sine_data_full")
+
+        custom_tuner = OptunaTuner(
+            study_name="test_study",
+            direction="maximize",
+            study_sampler=optuna.samplers.TPESampler(seed=12345),
+            study_pruner=optuna.pruners.PercentilePruner(0.1),
+        )
+
+        seeker = MethodSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            return_top_k=2,
+            num_iter=3,
+            override_hp_space=override_hp_space,  # type: ignore
+            custom_tuner=custom_tuner,
+        )
+
+        estimators, scores = seeker.search()
+
+        assert len(estimators) == len(scores) == 2
+        for est in estimators:
+            assert est.name in estimator_names
+
+
+class TestPipelineSeeker:
+    @pytest.mark.parametrize("tuner_type", ["bayesian", "random", "cmaes", "qmc"])
+    @pytest.mark.parametrize(
+        "override_hp_space",
+        [
+            None,
+            {
+                "cde_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+                "ode_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+                "nn_classifier": [
+                    IntegerParams(name="n_iter", low=2, high=100),
+                ],
+            },
+        ],
+    )
+    def test_init_success(self, tuner_type: TunerType, override_hp_space, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+        PipelineSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            tuner_type=tuner_type,
+            override_hp_space=override_hp_space,
+        )
+
+    def test_init_success_custom_tuner(self, get_dataset: Callable):
+        import optuna
+
+        from tempor.automl.tuner import OptunaTuner
+
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+
+        custom_tuner = OptunaTuner(
+            study_name="test_study",
+            direction="maximize",
+            study_sampler=optuna.samplers.TPESampler(seed=12345),
+            study_pruner=optuna.pruners.PercentilePruner(0.1),
+        )
+
+        seeker = PipelineSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            override_hp_space=None,
+            custom_tuner=custom_tuner,
+        )
+        assert len(seeker.tuners) == len(estimator_names)
+        for tuner in seeker.tuners:
+            assert isinstance(tuner, OptunaTuner)
+            assert "test_study_" in tuner.study_name
+            isinstance(tuner.study_pruner, optuna.pruners.PercentilePruner)
+
+    def test_init_fails_grid_unsupported(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        dataset = get_dataset("sine_data_full")
+
+        with pytest.raises(ValueError, match=".*[Gg]rid.*not supported.*"):
+            PipelineSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                override_hp_space=None,
+                tuner_type="grid",
+            )
+
+    def test_init_fails_wrong_overrides(self, get_dataset: Callable):
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        override_hp_space = {
+            "cde_classifier": [
+                IntegerParams(name="n_iter", low=2, high=100),
+            ],
+            "nonexistent_classifier": [
+                IntegerParams(name="n_iter", low=2, high=100),
+            ],
+        }
+        dataset = get_dataset("sine_data_full")
+
+        with pytest.raises(ValueError, match=".*override.*did not correspond.*"):
+            PipelineSeeker(
+                study_name="test_study",
+                task_type="prediction.one_off.classification",
+                estimator_names=estimator_names,
+                metric="aucroc",
+                dataset=dataset,
+                tuner_type="bayesian",
+                override_hp_space=override_hp_space,  # type: ignore
+            )
+
+    @pytest.mark.filterwarnings("ignore:.*bool8.*:DeprecationWarning")  # Expected.
+    @pytest.mark.parametrize(
+        "task_type,estimator_names,metric",
+        [
+            (
+                "prediction.one_off.classification",
+                [
+                    "cde_classifier",
+                    "ode_classifier",
+                    "nn_classifier",
+                    "laplace_ode_classifier",
+                ],
+                "aucroc",
+            ),
+            (
+                "prediction.one_off.regression",
+                [
+                    "cde_regressor",
+                    "ode_regressor",
+                    "nn_regressor",
+                    "laplace_ode_regressor",
+                ],
+                "mse",
+            ),
+            (
+                "time_to_event",
+                [
+                    "dynamic_deephit",
+                    "ts_coxph",
+                    "ts_xgb",
+                ],
+                "c_index",
+            ),
+        ],
+    )
+    @pytest.mark.parametrize("override", [False, True])
+    def test_search(self, task_type, estimator_names, metric, override, patch_slow):
+        if task_type != "time_to_event":
+            dataset = MagicMock(PredictiveDataset)
+        else:
+            dataset = MagicMock(TimeToEventAnalysisDataset)
+
+        horizon = None
+        if task_type == "time_to_event":
+            horizon = [0.1, 0.5, 0.7]
+
+        override_hp_space = None
+        if override:
+            override_hp_space = {estimator_names[0]: [CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4])]}
+
+        seeker = PipelineSeeker(
+            study_name="test_study",
+            task_type=task_type,
+            estimator_names=estimator_names,
+            metric=metric,
+            dataset=dataset,
+            return_top_k=3,
+            num_iter=10,
+            override_hp_space=override_hp_space,  # type: ignore
+            horizon=horizon,
+        )
+
+        pipelines, scores = seeker.search()
+
+        assert len(pipelines) == len(scores) == 3
+        for pipe in pipelines:
+            assert pipe.stages[-1].name in estimator_names  # type: ignore
+        if seeker.direction == "maximize":
+            assert sorted(scores, reverse=True) == scores
+        else:
+            assert sorted(scores, reverse=False) == scores
+
+    @pytest.mark.slow
+    def test_search_end2end(self, get_dataset: Callable):
+        from tempor.plugins.pipeline import PipelineBase
+
+        estimator_names = [
+            "cde_classifier",
+            "ode_classifier",
+            "nn_classifier",
+        ]
+        override_hp_space = {
+            "cde_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_temporal_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "ode_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+            "nn_classifier": [
+                IntegerParams(name="n_iter", low=2, high=2),
+                IntegerParams(name="n_units_hidden", low=5, high=20),
+                CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            ],
+        }
+        dataset = get_dataset("sine_data_full")
+
+        seeker = PipelineSeeker(
+            study_name="test_study",
+            task_type="prediction.one_off.classification",
+            estimator_names=estimator_names,
+            metric="aucroc",
+            dataset=dataset,
+            return_top_k=2,
+            num_iter=3,
+            override_hp_space=override_hp_space,  # type: ignore
+            static_imputers=[],  # Skip, slow.
+            static_scalers=DEFAULT_STATIC_SCALERS,
+            temporal_imputers=[],  # Skip, slow.
+            temporal_scalers=DEFAULT_TEMPORAL_SCALERS,
+        )
+
+        pipelines, scores = seeker.search()
+
+        assert len(pipelines) == len(scores) == 2
+        for pipe in pipelines:
+            assert isinstance(pipe, PipelineBase)
+            assert len(pipe.stages) == 3
+            assert pipe.stages[-1].name in estimator_names
+            assert pipe.stages[0].name in DEFAULT_STATIC_SCALERS
+            assert pipe.stages[1].name in DEFAULT_TEMPORAL_SCALERS
+
+            # Assert override workedL
+            assert pipe.stages[-1].params.n_iter == 2
diff --git a/tests/automl/test_tuner.py b/tests/automl/test_tuner.py
new file mode 100644
index 00000000..93946dd2
--- /dev/null
+++ b/tests/automl/test_tuner.py
@@ -0,0 +1,612 @@
+# pylint: disable=redefined-outer-name
+
+import functools
+import warnings
+from typing import Any, Callable, Dict, Type
+
+import optuna
+import pytest
+from packaging.version import Version
+
+from tempor.automl import OptimDirection, tuner
+from tempor.benchmarks import evaluation
+from tempor.data.dataset import PredictiveDataset, TimeToEventAnalysisDataset
+from tempor.plugins import plugin_loader
+from tempor.plugins.core._base_predictor import BasePredictor
+from tempor.plugins.core._params import IntegerParams
+
+# To ignore warnings in parametrization:
+warnings.filterwarnings("ignore", category=optuna.exceptions.ExperimentalWarning)
+
+
+OPTIMIZE_KWARGS = {"n_trials": 2}
+
+# When testing optuna.samplers.GridSampler, build the grid from this many calls to sample_hyperparameters:
+GRID_SAMPLER_GRID_N = 2
+
+# The number or iters/epochs of underlying models is limited in the tests for the sake of speed.
+# See `limit_int_param()`.
+MIN_MODEL_EPOCHS = 2
+MAX_MODEL_EPOCHS = 3
+
+SEED = 123
+SETTINGS: Dict = dict()
+
+TEST_SAMPLER_SET = [
+    optuna.samplers.TPESampler(seed=SEED),
+    pytest.param(optuna.samplers.RandomSampler(seed=SEED), marks=pytest.mark.extra),
+    pytest.param(optuna.samplers.CmaEsSampler(seed=SEED), marks=pytest.mark.extra),
+    pytest.param(
+        optuna.samplers.QMCSampler(seed=SEED),
+        marks=[
+            pytest.mark.skipif(
+                Version(optuna.__version__) < Version("3.0.0"),
+                reason="optuna.samplers.QMCSampler is only available in optuna v3.0.0+",
+            ),
+            pytest.mark.extra,
+        ],
+    ),
+    pytest.param("GridSampler", marks=pytest.mark.extra),
+    # ^ Needs get_grid_by_sampling() call in test to define the grid.
+    # NOTE: `optuna.samplers.BruteForceSampler` not currently compatible due to FloatParams
+    # infinite search space; MO samplers are not applicable.
+]
+TEST_PRUNER_SET = [
+    optuna.pruners.MedianPruner(),
+    pytest.param(optuna.pruners.PatientPruner(None, 3), marks=pytest.mark.extra),
+    pytest.param(optuna.pruners.PercentilePruner(0.25), marks=pytest.mark.extra),
+    pytest.param(optuna.pruners.SuccessiveHalvingPruner(), marks=pytest.mark.extra),
+    pytest.param(optuna.pruners.ThresholdPruner(lower=0.5), marks=pytest.mark.extra),
+    pytest.param(optuna.pruners.HyperbandPruner(), marks=pytest.mark.extra),
+]
+
+
+# Test settings for different evaluation cases. ---
+_category_prefix = "prediction.one_off.classification"
+SETTINGS["prediction.one_off.classification"] = dict(
+    TEST_ON_DATASETS=["sine_data_small"],
+    TEST_WITH_PLUGINS=[
+        f"{_category_prefix}.nn_classifier",
+        pytest.param(f"{_category_prefix}.cde_classifier", marks=pytest.mark.extra),
+        pytest.param(f"{_category_prefix}.ode_classifier", marks=pytest.mark.extra),
+    ],
+    METRICS=[
+        ("aucroc", "maximize"),
+        ("accuracy", "maximize"),
+        ("precision_micro", "maximize"),
+    ],
+    TEST_SAMPLER_SET=TEST_SAMPLER_SET,
+)
+_category_prefix = "prediction.one_off.regression"
+SETTINGS["prediction.one_off.regression"] = dict(
+    TEST_ON_DATASETS=["sine_data_small"],
+    TEST_WITH_PLUGINS=[
+        f"{_category_prefix}.nn_regressor",
+        pytest.param(f"{_category_prefix}.cde_regressor", marks=pytest.mark.extra),
+        pytest.param(f"{_category_prefix}.ode_regressor", marks=pytest.mark.extra),
+    ],
+    METRICS=[
+        ("mse", "minimize"),
+        ("mae", "minimize"),
+        ("r2", "maximize"),
+    ],
+    TEST_SAMPLER_SET=TEST_SAMPLER_SET,
+)
+_category_prefix = "time_to_event"
+SETTINGS["time_to_event"] = dict(
+    TEST_ON_DATASETS=["pbc_data_small"],
+    TEST_WITH_PLUGINS=[
+        f"{_category_prefix}.dynamic_deephit",
+        pytest.param(f"{_category_prefix}.ts_coxph", marks=pytest.mark.extra),
+        pytest.param(
+            f"{_category_prefix}.ts_xgb",
+            marks=[
+                pytest.mark.extra,
+                pytest.mark.skipci,  # Too memory intensive for CI workers, skip.
+            ],
+        ),
+    ],
+    METRICS=[
+        ("c_index", "maximize"),
+        ("brier_score", "minimize"),
+    ],
+    TEST_SAMPLER_SET=[s for s in TEST_SAMPLER_SET if s != "GridSampler"],  # GridSampler too resource intensive.
+)
+# Test settings for different evaluation cases. (end) ---
+
+
+def get_grid_by_sampling(plugin_cls: Any, n: int) -> Dict:
+    # Get a parameter grid for `plugin_cls` like {"param1": [val1, val2, val3], "param2": [val1, val2, val3]}
+    # by calling `plugin_cls.sample_hyperparameters()` `n` times.
+    out: Dict = dict()
+    for i in range(n):
+        hps = plugin_cls.sample_hyperparameters()
+        for k, v in hps.items():
+            if i == 0:
+                out[k] = [v]
+            else:
+                out[k].append(v)
+    return out
+
+
+@pytest.fixture
+def limit_int_param(monkeypatch):
+    # A fixture that overrides `hyperparameter_space` method for a plugin to limit the `param_name` parameter space.
+    # This is used to speed up tests by limiting epoch params, like: ``n_iter``, ``epochs``.
+    def func(plugin: Any, param_name: str, low: int, high: int):
+        original = plugin.hyperparameter_space
+
+        def patched_hyperparameter_space(*args, **kwargs):
+            hps = original(*args, **kwargs)
+            hps = [hp for hp in hps if hp.name != param_name]
+            hps.append(
+                IntegerParams(name=param_name, low=low, high=high),
+            )
+            return hps
+
+        monkeypatch.setattr(plugin, "hyperparameter_space", patched_hyperparameter_space)
+        return plugin
+
+    return func
+
+
+@pytest.fixture
+def tune_objective(get_event0_time_percentiles: Callable):
+    # The tuning objective function.
+    # We take the appropriate function from the tempor.benchmarks.evaluation module.
+
+    def func(
+        estimator: Type[BasePredictor],
+        dataset: PredictiveDataset,
+        evaluation_case: str,
+        metric: str,
+        *args,
+        **kwargs,
+    ) -> float:
+        model = estimator(*args, **kwargs)
+        # TODO: Handle missing cases.
+        if evaluation_case == "prediction.one_off.classification":
+            metrics = evaluation.evaluate_prediction_oneoff_classifier(model, dataset)
+        elif evaluation_case == "prediction.one_off.regression":
+            metrics = evaluation.evaluate_prediction_oneoff_regressor(model, dataset)
+        elif evaluation_case == "prediction.temporal.classification":
+            raise NotImplementedError
+        elif evaluation_case == "prediction.temporal.regression":
+            raise NotImplementedError
+        elif evaluation_case == "time_to_event":
+            assert isinstance(dataset, TimeToEventAnalysisDataset)
+            metrics = evaluation.evaluate_time_to_event(
+                model, dataset, horizons=get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+            )
+        elif evaluation_case == "treatments.one_off.classification":
+            raise NotImplementedError
+        elif evaluation_case == "treatments.one_off.regression":
+            raise NotImplementedError
+        elif evaluation_case == "treatments.temporal.classification":
+            raise NotImplementedError
+        elif evaluation_case == "treatments.temporal.regression":
+            raise NotImplementedError
+        else:
+            raise ValueError(f"Unknown evaluation case: {evaluation_case}")
+        return metrics.loc[metric, "mean"]  # pyright: ignore
+
+    return func
+
+
+# TestOptunaTuner helper functions. ---
+
+
+@pytest.fixture
+def helper_initialize(limit_int_param: Callable, get_dataset: Callable):
+    def func(data: str, plugin: str):
+        # Initialize the dataset and plugin class from the strings provided.
+        # In addition, limit the `n_iter`, `epochs` hyperparameter sampling, such that tests run in reasonable time.
+
+        dataset = get_dataset(data)
+        p = plugin_loader.get_class(plugin)
+
+        # NOTE: Limit the hyperparameters like "n_iter" and "epochs", so that tests do not take a very long time.
+        for param_name in ("n_iter", "epochs"):
+            if hasattr(p.ParamsDefinition(), param_name):
+                p = limit_int_param(p, param_name, MIN_MODEL_EPOCHS, MAX_MODEL_EPOCHS)
+
+        return dataset, p
+
+    return func
+
+
+@pytest.fixture
+def helper_tune(tune_objective: Callable):
+    def func(
+        dataset, plugin, evaluation_case, metric, direction, sampler, pruner, override_hp_space, compute_baseline_score
+    ):
+        # Initialize and tune the tuner.OptunaTuner.
+        hp_tuner = tuner.OptunaTuner(
+            study_name="my_study",
+            direction=direction,
+            study_sampler=sampler,
+            study_pruner=pruner,
+        )
+        return hp_tuner.tune(
+            estimator=plugin,
+            dataset=dataset,
+            evaluation_callback=functools.partial(
+                tune_objective,
+                evaluation_case=evaluation_case,
+                metric=metric,
+            ),
+            override_hp_space=override_hp_space,
+            compute_baseline_score=compute_baseline_score,
+            optimize_kwargs=OPTIMIZE_KWARGS,
+        )
+
+    return func
+
+
+@pytest.fixture
+def helper_asserts():
+    def func(plugin, scores, params, compute_baseline_score: bool, pruner_enabled: bool):
+        # Do the necessary asserts for the tests.
+        hp_names = sorted(list(get_grid_by_sampling(plugin, n=1).keys()))
+        assert len(scores) == len(params)
+        if not pruner_enabled:
+            assert len(params) == OPTIMIZE_KWARGS["n_trials"] + int(compute_baseline_score)
+        else:
+            assert len(params) > int(compute_baseline_score)
+        if compute_baseline_score:
+            assert params[0] == dict()  # Baseline score.
+        for param in params[int(compute_baseline_score) :]:
+            assert sorted(list(param.keys())) == hp_names
+
+    return func
+
+
+@pytest.fixture
+def helper_test_optuna_tuner(
+    helper_initialize: Callable,
+    helper_tune: Callable,
+    helper_asserts: Callable,
+):
+    def func(
+        data: str,
+        plugin: str,
+        sampler: Any,
+        pruner: Any,
+        evaluation_case: str,
+        metric: str,
+        direction: OptimDirection,
+        compute_baseline_score: bool,
+    ):
+        # A general test function for testing `tuner.OptunaTuner`.
+
+        dataset, p = helper_initialize(
+            data=data,
+            plugin=plugin,
+        )
+
+        if sampler is None:
+            sampler = optuna.samplers.TPESampler(seed=SEED)
+
+        if sampler == "GridSampler":
+            sampler = optuna.samplers.GridSampler(search_space=get_grid_by_sampling(p, n=GRID_SAMPLER_GRID_N))
+
+        scores, params = helper_tune(
+            dataset=dataset,
+            plugin=p,
+            evaluation_case=evaluation_case,
+            metric=metric,
+            direction=direction,
+            sampler=sampler,
+            pruner=pruner,
+            override_hp_space=None,
+            compute_baseline_score=compute_baseline_score,
+        )
+        helper_asserts(
+            plugin=p,
+            compute_baseline_score=compute_baseline_score,
+            scores=scores,
+            params=params,
+            pruner_enabled=pruner is not None,
+        )
+
+    return func
+
+
+@pytest.fixture
+def helper_test_optuna_tuner_pipeline_selector(
+    get_dataset: Callable,
+    helper_tune: Callable,
+    helper_asserts: Callable,
+):
+    def func(
+        data: str,
+        pipeline_selector,
+        sampler: Any,
+        pruner: Any,
+        evaluation_case: str,
+        metric: str,
+        direction: OptimDirection,
+    ):
+        # A general test function for testing `tuner.OptunaTuner` - pipeline selector case.
+
+        dataset = get_dataset(data)
+
+        if sampler is None:
+            sampler = optuna.samplers.TPESampler(seed=SEED)
+
+        if sampler == "GridSampler":
+            sampler = optuna.samplers.GridSampler(
+                search_space=get_grid_by_sampling(pipeline_selector, n=GRID_SAMPLER_GRID_N)
+            )
+
+        scores, params = helper_tune(
+            dataset=dataset,
+            plugin=pipeline_selector,
+            evaluation_case=evaluation_case,
+            metric=metric,
+            direction=direction,
+            sampler=sampler,
+            pruner=pruner,
+            override_hp_space=None,
+            compute_baseline_score=False,
+        )
+        helper_asserts(
+            plugin=pipeline_selector,
+            compute_baseline_score=False,
+            scores=scores,
+            params=params,
+            pruner_enabled=pruner is not None,
+        )
+
+    return func
+
+
+# TestOptunaTuner helper functions (end) ---
+
+
+class TestOptunaTuner:
+    # Evaluation case: prediction.one_off.classification.
+
+    @pytest.mark.parametrize("compute_baseline_score", [True, False])
+    def test_compute_baseline_score(
+        self,
+        compute_baseline_score: bool,
+        helper_test_optuna_tuner: Callable,
+    ):
+        # Check compute_baseline_score = True, False cases.
+        # helper_asserts() helper has appropriate asserts for each of these two cases.
+        helper_test_optuna_tuner(
+            data="sine_data_small",
+            plugin="prediction.one_off.classification.nn_classifier",
+            sampler=None,
+            pruner=None,
+            evaluation_case="prediction.one_off.classification",
+            metric="aucroc",
+            direction="maximize",
+            compute_baseline_score=compute_baseline_score,
+        )
+
+    def test_override_hp_space(
+        self,
+        helper_initialize: Callable,
+        helper_tune: Callable,
+    ):
+        # Can only be sampled as:
+        # {'n_temporal_layers_hidden': 1, 'n_iter': 2}
+        override = [
+            IntegerParams(name="n_temporal_layers_hidden", low=1, high=1),
+            IntegerParams(name="n_iter", low=2, high=2),
+        ]
+
+        dataset, p = helper_initialize(
+            data="sine_data_small",
+            plugin="prediction.one_off.classification.nn_classifier",
+        )
+
+        sampler = optuna.samplers.TPESampler(seed=SEED)
+
+        scores, params = helper_tune(
+            dataset=dataset,
+            plugin=p,
+            evaluation_case="prediction.one_off.classification",
+            metric="aucroc",
+            direction="maximize",
+            sampler=sampler,
+            pruner=None,
+            override_hp_space=override,
+            compute_baseline_score=False,
+        )
+
+        assert len(params) == len(scores) == OPTIMIZE_KWARGS["n_trials"]
+        # Check that indeed our override params are being used. These can only be sampled as:
+        # {'n_temporal_layers_hidden': 1, 'n_iter': 2}
+        assert params[0] == params[1] == {"n_temporal_layers_hidden": 1, "n_iter": 2}
+
+    class TestPredictionOneOffClassification:
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.classification"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.classification"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("sampler", SETTINGS["prediction.one_off.classification"]["TEST_SAMPLER_SET"])
+        def test_tune_vary_samplers(self, data: str, plugin: str, sampler: Any, helper_test_optuna_tuner: Callable):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=sampler,
+                pruner=None,
+                evaluation_case="prediction.one_off.classification",
+                metric="aucroc",
+                direction="maximize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.classification"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.classification"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("pruner", TEST_PRUNER_SET)
+        def test_tune_vary_pruners(self, data: str, plugin: str, pruner: Any, helper_test_optuna_tuner: Callable):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=pruner,
+                evaluation_case="prediction.one_off.classification",
+                metric="aucroc",
+                direction="maximize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.classification"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.classification"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("metric,direction", SETTINGS["prediction.one_off.classification"]["METRICS"])
+        def test_tune_vary_metrics(
+            self,
+            data: str,
+            plugin: str,
+            metric: str,
+            direction: OptimDirection,
+            helper_test_optuna_tuner: Callable,
+        ):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=None,
+                evaluation_case="prediction.one_off.classification",
+                metric=metric,
+                direction=direction,
+                compute_baseline_score=False,
+            )
+
+    # Evaluation case: prediction.one_off.regression.
+
+    class TestPredictionOneOffRegression:
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.regression"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.regression"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("sampler", SETTINGS["prediction.one_off.regression"]["TEST_SAMPLER_SET"])
+        def test_tune_vary_samplers(
+            self,
+            data: str,
+            plugin: str,
+            sampler: Any,
+            helper_test_optuna_tuner: Callable,
+        ):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=sampler,
+                pruner=None,
+                evaluation_case="prediction.one_off.regression",
+                metric="mse",
+                direction="minimize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.regression"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.regression"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("pruner", TEST_PRUNER_SET)
+        def test_tune_vary_pruners(self, data: str, plugin: str, pruner: Any, helper_test_optuna_tuner: Callable):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=pruner,
+                evaluation_case="prediction.one_off.regression",
+                metric="mse",
+                direction="minimize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.parametrize("data", SETTINGS["prediction.one_off.regression"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["prediction.one_off.regression"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("metric,direction", SETTINGS["prediction.one_off.regression"]["METRICS"])
+        def test_tune_vary_metrics(
+            self, data: str, plugin: str, metric: str, direction: OptimDirection, helper_test_optuna_tuner: Callable
+        ):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=None,
+                evaluation_case="prediction.one_off.regression",
+                metric=metric,
+                direction=direction,
+                compute_baseline_score=False,
+            )
+
+    # Evaluation case: time_to_event.
+
+    @pytest.mark.filterwarnings("ignore")  # Multiple warnings re. convergence etc.
+    class TestTimeToEvent:
+        @pytest.mark.parametrize("data", SETTINGS["time_to_event"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["time_to_event"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("sampler", SETTINGS["time_to_event"]["TEST_SAMPLER_SET"])
+        def test_tune_vary_samplers(
+            self,
+            data: str,
+            plugin: str,
+            sampler: Any,
+            helper_test_optuna_tuner: Callable,
+        ):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=sampler,
+                pruner=None,
+                evaluation_case="time_to_event",
+                metric="c_index",
+                direction="maximize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.filterwarnings("ignore")  # Multiple warnings re. convergence etc.
+        @pytest.mark.parametrize("data", SETTINGS["time_to_event"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["time_to_event"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("pruner", TEST_PRUNER_SET)
+        def test_tune_vary_pruners(self, data: str, plugin: str, pruner: Any, helper_test_optuna_tuner: Callable):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=pruner,
+                evaluation_case="time_to_event",
+                metric="c_index",
+                direction="maximize",
+                compute_baseline_score=False,
+            )
+
+        @pytest.mark.filterwarnings("ignore")  # Multiple warnings re. convergence etc.
+        @pytest.mark.parametrize("data", SETTINGS["time_to_event"]["TEST_ON_DATASETS"])
+        @pytest.mark.parametrize("plugin", SETTINGS["time_to_event"]["TEST_WITH_PLUGINS"])
+        @pytest.mark.parametrize("metric,direction", SETTINGS["time_to_event"]["METRICS"])
+        def test_tune_vary_metrics(
+            self, data: str, plugin: str, metric: str, direction: OptimDirection, helper_test_optuna_tuner: Callable
+        ):
+            helper_test_optuna_tuner(
+                data=data,
+                plugin=plugin,
+                sampler=None,
+                pruner=None,
+                evaluation_case="time_to_event",
+                metric=metric,
+                direction=direction,
+                compute_baseline_score=False,
+            )
+
+    # Test PipelineSelector case.
+
+    @pytest.mark.slow
+    @pytest.mark.filterwarnings("ignore")  # In some HyperImpute cases.
+    def test_pipeline_selector_case(self, helper_test_optuna_tuner_pipeline_selector: Callable):
+        from tempor.automl.pipeline_selector import PipelineSelector
+
+        ps = PipelineSelector(task_type="prediction.one_off.classification", predictor="nn_classifier")
+
+        data = "sine_data_full"
+
+        helper_test_optuna_tuner_pipeline_selector(
+            data=data,
+            pipeline_selector=ps,
+            sampler=None,
+            pruner=None,
+            evaluation_case="prediction.one_off.classification",
+            metric="aucroc",
+            direction="maximize",
+        )
diff --git a/tests/benchmarks/test_benchmark.py b/tests/benchmarks/test_benchmark.py
index efc0e31c..d2565c65 100644
--- a/tests/benchmarks/test_benchmark.py
+++ b/tests/benchmarks/test_benchmark.py
@@ -9,7 +9,7 @@
     time_to_event_supported_metrics,
 )
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 
 N_ITER = 5
 
@@ -27,7 +27,7 @@ def test_classifier_benchmark(data: str, request: pytest.FixtureRequest) -> None
     testcases = [
         (
             "pipeline1",
-            Pipeline(
+            pipeline(
                 [
                     "preprocessing.imputation.static.static_tabular_imputer",
                     "preprocessing.imputation.temporal.ffill",
@@ -43,7 +43,7 @@ def test_classifier_benchmark(data: str, request: pytest.FixtureRequest) -> None
     dataset = request.getfixturevalue(data)
 
     aggr_score, per_test_score = benchmark_models(
-        task_type="classification",
+        task_type="prediction.one_off.classification",
         tests=testcases,
         data=dataset,
         n_splits=2,
@@ -66,7 +66,7 @@ def test_regressor_benchmark(data: str, request: pytest.FixtureRequest) -> None:
     testcases = [
         (
             "pipeline1",
-            Pipeline(
+            pipeline(
                 [
                     "preprocessing.imputation.static.static_tabular_imputer",
                     "preprocessing.imputation.temporal.ffill",
@@ -82,7 +82,7 @@ def test_regressor_benchmark(data: str, request: pytest.FixtureRequest) -> None:
     dataset = request.getfixturevalue(data)
 
     aggr_score, per_test_score = benchmark_models(
-        task_type="regression",
+        task_type="prediction.one_off.regression",
         tests=testcases,
         data=dataset,
         n_splits=2,
@@ -107,7 +107,7 @@ def test_time_to_event_benchmark(
     testcases = [
         (
             "pipeline1",
-            Pipeline(
+            pipeline(
                 [
                     "preprocessing.imputation.static.static_tabular_imputer",
                     "preprocessing.imputation.temporal.ffill",
diff --git a/tests/benchmarks/test_evaluation.py b/tests/benchmarks/test_evaluation.py
index 2f6c1942..b3248079 100644
--- a/tests/benchmarks/test_evaluation.py
+++ b/tests/benchmarks/test_evaluation.py
@@ -4,15 +4,15 @@
 
 from tempor.benchmarks import (
     classifier_supported_metrics,
-    evaluate_classifier,
-    evaluate_regressor,
+    evaluate_prediction_oneoff_classifier,
+    evaluate_prediction_oneoff_regressor,
     evaluate_time_to_event,
     output_metrics,
     regression_supported_metrics,
     time_to_event_supported_metrics,
 )
 from tempor.plugins import plugin_loader
-from tempor.plugins.pipeline import Pipeline
+from tempor.plugins.pipeline import pipeline
 
 N_ITER = 5
 
@@ -29,13 +29,13 @@
 @pytest.mark.parametrize(
     "model_template",
     [
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.imputation.temporal.ffill",
                 PREDICTOR_CLASSIFICATION,
             ]
         )({"nn_classifier": {"n_iter": N_ITER}}),
-        Pipeline(
+        pipeline(
             [
                 PREDICTOR_CLASSIFICATION,
             ]
@@ -47,7 +47,7 @@
 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)
+    scores = evaluate_prediction_oneoff_classifier(model_template, dataset, n_splits=n_splits, seed=0)
 
     for out_metric in output_metrics:
         assert out_metric in scores
@@ -62,7 +62,7 @@ def test_classifier_evaluation_fail(n_splits: int, data: str, request: pytest.Fi
     dataset = request.getfixturevalue(data)
 
     with pytest.raises(ValueError):
-        evaluate_classifier(
+        evaluate_prediction_oneoff_classifier(
             plugin_loader.get(PREDICTOR_CLASSIFICATION, n_iter=N_ITER), dataset, n_splits=n_splits, seed=0
         )
 
@@ -71,13 +71,13 @@ def test_classifier_evaluation_fail(n_splits: int, data: str, request: pytest.Fi
 @pytest.mark.parametrize(
     "model_template",
     [
-        Pipeline(
+        pipeline(
             [
                 "preprocessing.imputation.temporal.ffill",
                 PREDICTOR_REGRESSION,
             ]
         )({"nn_regressor": {"n_iter": N_ITER}}),
-        Pipeline(
+        pipeline(
             [
                 PREDICTOR_REGRESSION,
             ]
@@ -89,7 +89,7 @@ def test_classifier_evaluation_fail(n_splits: int, data: str, request: pytest.Fi
 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)
+    scores = evaluate_prediction_oneoff_regressor(model_template, dataset, n_splits=n_splits, seed=0)
 
     for out_metric in output_metrics:
         assert out_metric in scores
@@ -104,20 +104,22 @@ def test_regressor_evaluation_fail(n_splits: int, data: str, request: pytest.Fix
     dataset = request.getfixturevalue(data)
 
     with pytest.raises(ValueError):
-        evaluate_regressor(plugin_loader.get(PREDICTOR_REGRESSION, n_iter=N_ITER), dataset, n_splits=n_splits, seed=0)
+        evaluate_prediction_oneoff_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(
+        pipeline(
             [
                 "preprocessing.imputation.temporal.ffill",
                 PREDICTOR_TIME_TO_EVENT,
             ]
         )({"dynamic_deephit": {"n_iter": N_ITER}}),
-        Pipeline(
+        pipeline(
             [
                 PREDICTOR_TIME_TO_EVENT,
             ]
diff --git a/tests/conftest.py b/tests/conftest.py
index a61ed773..d10e75e1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,8 @@
+# pylint: disable=redefined-outer-name
+
 import copy
 from types import ModuleType
-from typing import TYPE_CHECKING, Any, List, Tuple, Type
+from typing import TYPE_CHECKING, Any, Callable, List, Tuple, Type
 
 import numpy as np
 import pytest
@@ -139,6 +141,54 @@ def func(data: "dataset.TimeToEventAnalysisDataset", horizon_percentiles: List):
     return func
 
 
+@pytest.fixture
+def simulate_horizons() -> Callable:
+    def func(data: "dataset.BaseDataset") -> List:
+        return [tc.time_indexes()[0][len(tc.time_indexes()[0]) // 2 :] for tc in data.time_series]
+
+    return func
+
+
+@pytest.fixture
+def simulate_treatments_scenarios(simulate_horizons: Callable) -> Callable:
+    def func(
+        data: "dataset.TemporalTreatmentEffectsDataset", n_counterfactuals_per_sample: int = 2
+    ) -> Tuple[List, List]:
+        horizons = simulate_horizons(data)
+
+        treatment_scenarios = []
+        for idx, sample_idx in enumerate(data.time_series.sample_index()):
+            sample_scenarios = []
+            treat = data.predictive.treatments[sample_idx].dataframe()  # pyright: ignore
+            horizon_counterfactuals_sample = horizons[idx]
+
+            for treat_sc_idx in range(n_counterfactuals_per_sample):
+                np.random.seed(12345 + treat_sc_idx)
+                treat_sc = np.random.randint(
+                    low=0, high=1 + 1, size=(len(horizon_counterfactuals_sample), treat.shape[1])
+                )
+                sample_scenarios.append(treat_sc)
+            treatment_scenarios.append(sample_scenarios)
+
+        return horizons, treatment_scenarios
+
+    return func
+
+
+@pytest.fixture
+def as_covariates_dataset() -> Callable:
+    from tempor.data.dataset import CovariatesDataset
+
+    def func(ds: "dataset.PredictiveDataset") -> "dataset.CovariatesDataset":
+        data = CovariatesDataset(
+            time_series=ds.time_series.dataframe(),
+            static=ds.static.dataframe() if ds.static is not None else None,
+        )
+        return data
+
+    return func
+
+
 # --- Reusable datasets. ---
 
 # Sine data: full.
diff --git a/tests/core/test_core_utils.py b/tests/core/test_core_utils.py
index 7a07d2cb..ed3f4c8a 100644
--- a/tests/core/test_core_utils.py
+++ b/tests/core/test_core_utils.py
@@ -41,3 +41,111 @@ def test_fails(self, lit, d):
     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)
+
+
+# Test get_from_args_or_kwargs ----------
+
+
+class MyArgument:
+    pass
+
+
+def my_function(*args, **kwargs):
+    value, args, kwargs = utils.get_from_args_or_kwargs(
+        args, kwargs, argument_name="my_arg", argument_type=MyArgument, position_if_args=0
+    )
+    return value, args, kwargs
+
+
+class TestGetFromArgsOrKwargs:
+    def test_get_from_args(self):
+        my_arg = MyArgument()
+
+        value, args, kwargs = my_function(my_arg)
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple()
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(my_arg, "bar", "baz")
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple(["bar", "baz"])
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(my_arg, foobar="foobar", foobaz="foobaz")
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple()
+        assert kwargs == dict(foobar="foobar", foobaz="foobaz")
+
+        value, args, kwargs = my_function(my_arg, "bar", "baz", foobar="foobar", foobaz="foobaz")
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple(["bar", "baz"])
+        assert kwargs == dict(foobar="foobar", foobaz="foobaz")
+
+    def test_get_from_kwargs(self):
+        my_arg = MyArgument()
+
+        value, args, kwargs = my_function(my_arg=my_arg)
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple()
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function("bar", "baz", my_arg=my_arg)
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple(["bar", "baz"])
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(my_arg=my_arg, foobar="foobar", foobaz="foobaz")
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple()
+        assert kwargs == dict(foobar="foobar", foobaz="foobaz")
+
+        value, args, kwargs = my_function("bar", "baz", my_arg=my_arg, foobar="foobar", foobaz="foobaz")
+        assert value is my_arg
+        assert value not in args and "my_arg" not in kwargs
+        assert args == tuple(["bar", "baz"])
+        assert kwargs == dict(foobar="foobar", foobaz="foobaz")
+
+    def test_no_argument(self):
+        value, args, kwargs = my_function()
+        assert value is None
+        assert args == tuple()
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(12345)
+        assert value is None
+        assert args == tuple([12345])
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(12345, "abc")
+        assert value is None
+        assert args == tuple([12345, "abc"])
+        assert kwargs == dict()
+
+        value, args, kwargs = my_function(foobar=999, foobaz="foobaz")
+        assert args == tuple()
+        assert kwargs == dict(foobar=999, foobaz="foobaz")
+
+        value, args, kwargs = my_function("abc", -12345, foobar=999, foobaz="foobaz")
+        assert value is None
+        assert args == tuple(["abc", -12345])
+        assert kwargs == dict(foobar=999, foobaz="foobaz")
+
+    def test_exception_passed_as_both(self):
+        my_arg = MyArgument()
+
+        with pytest.raises(RuntimeError, match=".*as `kwargs`.*as `args`.*"):
+            my_function(my_arg, "bar", my_arg=my_arg, foobaz=1234)
+
+    def test_exception_kwargs_wrong_type(self):
+        with pytest.raises(TypeError, match=".*`kwargs`.*type.*"):
+            my_function("bar", "baz", my_arg="str", foobaz=1234)
+
+
+# Test get_from_args_or_kwargs (end) -----
diff --git a/tests/plugins/core/test_base_estimator.py b/tests/plugins/core/test_base_estimator.py
index 24f5c5d2..72d599c4 100644
--- a/tests/plugins/core/test_base_estimator.py
+++ b/tests/plugins/core/test_base_estimator.py
@@ -203,3 +203,37 @@ def _fit(self, data, *args, **kwargs):
 
         with pytest.raises(ValueError, match=".*not fit-ready.*"):
             my_model.fit(data)
+
+    @pytest.mark.parametrize("override", [False, True])
+    def test_sample_hyperparameters(self, override):
+        hp = Mock()
+        hp.name = "my_param"
+        hp.sample = Mock(return_value="value")
+        hps = [hp]
+
+        class MyModel(tempor.plugins.core.BaseEstimator):
+            name = "my_model"
+            category = "my_category"
+            ParamsDefinition = self.MyModelParams
+
+            @staticmethod
+            def hyperparameter_space(*args: Any, **kwargs: Any):
+                return hps
+
+            def _fit(self, data, *args, **kwargs):
+                pass
+
+        my_model = MyModel()
+        if not override:
+            sampled = my_model.sample_hyperparameters()
+        else:
+            override_hp = Mock()
+            override_hp.name = "my_override_param"
+            override_hp.sample = Mock(return_value="override_value")
+            override_hps = [override_hp]
+            sampled = my_model.sample_hyperparameters(override=override_hps)  # type: ignore
+
+        if not override:
+            assert sampled == {"my_param": "value"}
+        else:
+            assert sampled == {"my_override_param": "override_value"}
diff --git a/tests/plugins/pipelines/test_pipeline.py b/tests/plugins/pipelines/test_pipeline.py
index 47acade3..85d0a3cb 100644
--- a/tests/plugins/pipelines/test_pipeline.py
+++ b/tests/plugins/pipelines/test_pipeline.py
@@ -1,16 +1,11 @@
-# pylint: disable=redefined-outer-name,no-member
+# pylint: disable=no-member
 
-from typing import Any, List
+import re
+from typing import Any, List, Type
 
 import pytest
 
-from tempor.plugins.pipeline import Pipeline, PipelineGroup, PipelineMeta
-from tempor.utils.serialization import load, save
-
-TEST_ON_DATASETS = [
-    "sine_data_small",
-    pytest.param("sine_data_full", marks=pytest.mark.extra),
-]
+from tempor.plugins.pipeline import PipelineBase, pipeline, pipeline_classes
 
 
 @pytest.mark.parametrize(
@@ -32,15 +27,29 @@
         ],
     ],
 )
-def test_sanity(plugins_str: List[Any]) -> None:
-    dtype: PipelineMeta = Pipeline(plugins_str)
-    plugins = PipelineGroup(plugins_str)
+def test_sanity(plugins_str: List[Any]):
+    PipelineCls: Type[PipelineBase] = pipeline(plugins_str)
+    plugin_classes = pipeline_classes(plugins_str)
 
-    assert dtype.name() == "->".join(p for p in plugins_str)
+    assert issubclass(PipelineCls, PipelineBase)
+    assert PipelineCls.pipeline_seq() == "->".join(p for p in plugins_str)
+    assert list(PipelineCls.plugin_types) == list(plugin_classes)
 
     args = {"features_count": 10}
-    for act, pl in zip(dtype.hyperparameter_space(**args), plugins):
-        assert len(dtype.hyperparameter_space(**args)[act]) == len(pl.hyperparameter_space(**args))
+    for act, pl in zip(PipelineCls.hyperparameter_space(**args), plugin_classes):
+        assert len(PipelineCls.hyperparameter_space(**args)[act]) == len(pl.hyperparameter_space(**args))
+
+    pipe = PipelineCls()
+    hps = pipe.sample_hyperparameters()
+
+    assert len(pipe.stages) == len(plugin_classes)
+    assert pipe.predictor_category == plugin_classes[-1].category
+    assert len(pipe.params) == len(plugin_classes)
+    for stage in pipe.stages:
+        assert pipe.params[stage.name] == stage.params
+    assert len(hps) == len(plugin_classes)
+    for stage in pipe.stages:
+        assert stage.name in hps
 
 
 @pytest.mark.parametrize(
@@ -72,70 +81,55 @@ def test_sanity(plugins_str: List[Any]) -> None:
             "preprocessing.imputation.temporal.bfill",
             "preprocessing.scaling.temporal.ts_minmax_scaler",
         ],
+        ["invalid_fqn"],
         [],
     ],
 )
-def test_fails(plugins_str: List[Any]) -> None:
+def test_fails(plugins_str: List[Any]):
     with pytest.raises(RuntimeError):
-        Pipeline(plugins_str)()
+        pipeline(plugins_str)()
 
 
-@pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")  # Expected: problem with current serialization.
-@pytest.mark.parametrize(
-    "plugins_str",
-    [
-        [
-            "preprocessing.imputation.static.static_tabular_imputer",
-            "preprocessing.imputation.temporal.ts_tabular_imputer",
-            "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.static.static_tabular_imputer",
-            "preprocessing.imputation.temporal.bfill",
-            "preprocessing.scaling.temporal.ts_minmax_scaler",
-            "prediction.one_off.regression.nn_regressor",
-        ],
-        [
-            "preprocessing.imputation.static.static_tabular_imputer",
-            "preprocessing.imputation.temporal.ffill",
-            "preprocessing.scaling.static.static_minmax_scaler",
-            "preprocessing.scaling.temporal.ts_minmax_scaler",
-            "prediction.one_off.regression.nn_regressor",
-        ],
-        [
-            "preprocessing.imputation.static.static_tabular_imputer",
-            "preprocessing.imputation.temporal.ffill",
-            "prediction.one_off.regression.nn_regressor",
-        ],
-        [
-            "prediction.one_off.classification.nn_classifier",
-        ],
-    ],
-)
-@pytest.mark.parametrize("serialize", [True, False])
-def test_end2end(plugins_str, serialize: bool, sine_data_small, sine_data_missing_small) -> None:
-    if len(plugins_str) > 1:
-        dataset = sine_data_missing_small
-    else:
-        dataset = sine_data_small
+def test_hyperparameter_space_for_step():
+    plugins_str = [
+        "preprocessing.imputation.temporal.bfill",
+        "prediction.one_off.classification.nn_classifier",
+    ]
+    PipelineCls = pipeline(plugins_str)
+
+    hps = PipelineCls.hyperparameter_space_for_step("nn_classifier")
+
+    assert len(hps) > 0
+
+
+def test_hyperparameter_space_for_step_fails():
+    plugins_str = [
+        "preprocessing.imputation.temporal.bfill",
+        "prediction.one_off.classification.nn_classifier",
+    ]
+    PipelineCls = pipeline(plugins_str)
 
-    template: PipelineMeta = Pipeline(plugins_str)
-    pipeline = template()
+    with pytest.raises(ValueError, match="Invalid layer.*"):
+        PipelineCls.hyperparameter_space_for_step("non_existent_step")
 
-    if serialize:
-        dump = save(pipeline)
-        pipeline = load(dump)
 
-    pipeline.fit(dataset)
+def test_repr():
+    plugins_str = [
+        "preprocessing.imputation.temporal.bfill",
+        "preprocessing.scaling.static.static_minmax_scaler",
+        "preprocessing.scaling.temporal.ts_minmax_scaler",
+        "prediction.one_off.classification.nn_classifier",
+    ]
 
-    if serialize:
-        dump = save(pipeline)
-        pipeline = load(dump)
+    PipelineCls = pipeline(plugins_str)
 
-    y_pred = pipeline.predict(dataset)
+    pipe = PipelineCls()
+    repr_ = repr(pipe)
 
-    assert y_pred.dataframe().shape == (len(dataset.predictive.targets.dataframe()), 1)
+    seq = pipe.pipeline_seq()
+    assert re.search(
+        r"^Pipeline\(.*pipeline_seq='" + seq + r"'.*predictor_category='prediction.one_off.classification'"
+        r".*params=.?\{.*'bfill':.*'static_minmax_scaler':.*'ts_minmax_scaler':.*'nn_classifier':.*\}.*\)",
+        repr_,
+        re.S,
+    )
diff --git a/tests/plugins/pipelines/test_pipeline_end2end.py b/tests/plugins/pipelines/test_pipeline_end2end.py
new file mode 100644
index 00000000..489091cc
--- /dev/null
+++ b/tests/plugins/pipelines/test_pipeline_end2end.py
@@ -0,0 +1,632 @@
+"""Test pipeline end-t-end for different predictive categories"""
+
+from typing import Callable, Dict, List
+
+import pytest
+
+from tempor.plugins.pipeline import PipelineBase, pipeline
+from tempor.plugins.prediction.one_off.classification import BaseOneOffClassifier
+from tempor.plugins.prediction.one_off.regression import BaseOneOffRegressor
+from tempor.plugins.prediction.temporal.classification import BaseTemporalClassifier
+from tempor.plugins.prediction.temporal.regression import BaseTemporalRegressor
+from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
+from tempor.plugins.treatments.one_off import BaseOneOffTreatmentEffects
+from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
+from tempor.utils.serialization import load, save
+
+# TODO: It would be useful to create automated tests where all existing plugins available in each category are
+# used as the last step of a pipeline.
+
+
+TEST_TRANSFORM_STEPS: Dict[str, List] = {
+    "CASE_A": [
+        "preprocessing.imputation.static.static_tabular_imputer",
+        "preprocessing.imputation.temporal.ts_tabular_imputer",
+        "preprocessing.nop.nop_transformer",
+        "preprocessing.imputation.temporal.bfill",
+        "preprocessing.scaling.static.static_minmax_scaler",
+        "preprocessing.scaling.temporal.ts_minmax_scaler",
+    ],
+    "CASE_B": [
+        "preprocessing.imputation.static.static_tabular_imputer",
+        "preprocessing.imputation.temporal.bfill",
+        "preprocessing.scaling.temporal.ts_minmax_scaler",
+    ],
+    "CASE_C": [
+        "preprocessing.imputation.static.static_tabular_imputer",
+        "preprocessing.imputation.temporal.ffill",
+        "preprocessing.scaling.static.static_minmax_scaler",
+        "preprocessing.scaling.temporal.ts_minmax_scaler",
+    ],
+    "CASE_D": [
+        "preprocessing.imputation.static.static_tabular_imputer",
+        "preprocessing.imputation.temporal.ffill",
+    ],
+    "CASE_E": [],  # No transformer steps case.
+}
+
+
+def init_pipeline_and_fit(plugins_str, data_missing, data_not_missing, serialize, init_params=None):
+    if len(plugins_str) > 1:
+        dataset = data_missing
+    else:
+        dataset = data_not_missing
+
+    PipelineCls = pipeline(plugins_str)
+    pipe = PipelineCls(init_params)
+
+    if serialize:
+        dump = save(pipe)
+        pipe = load(dump)
+
+    pipe.fit(dataset)
+
+    if serialize:
+        dump = save(pipe)
+        pipe = load(dump)
+
+    return dataset, pipe
+
+
+# Category: prediction.one_off.classification:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "prediction.one_off.classification.nn_classifier",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "prediction.one_off.classification.nn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "prediction.one_off.classification.nn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "prediction.one_off.classification.nn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "prediction.one_off.classification.nn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_prediction_oneoff_classification(
+    plugins_str, serialize: bool, sine_data_small, sine_data_missing_small
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=sine_data_missing_small,
+        data_not_missing=sine_data_small,
+        serialize=serialize,
+    )
+
+    y_pred = pipe.predict(dataset)
+    y_proba = pipe.predict_proba(dataset)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseOneOffClassifier)
+    assert y_pred.dataframe().shape == (len(dataset.predictive.targets.dataframe()), 1)
+    assert y_proba.numpy().shape == (len(dataset.time_series), 2)
+
+
+# Category: prediction.one_off.regression:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "prediction.one_off.regression.nn_regressor",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "prediction.one_off.regression.nn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "prediction.one_off.regression.nn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "prediction.one_off.regression.nn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "prediction.one_off.regression.nn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_prediction_oneoff_regression(
+    plugins_str, serialize: bool, sine_data_small, sine_data_missing_small
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=sine_data_missing_small,
+        data_not_missing=sine_data_small,
+        serialize=serialize,
+    )
+
+    y_pred = pipe.predict(dataset)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseOneOffRegressor)
+    assert y_pred.dataframe().shape == (len(dataset.predictive.targets.dataframe()), 1)
+
+
+# Category: prediction.temporal.classification:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "prediction.temporal.classification.seq2seq_classifier",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "prediction.temporal.classification.seq2seq_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "prediction.temporal.classification.seq2seq_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "prediction.temporal.classification.seq2seq_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "prediction.temporal.classification.seq2seq_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_prediction_temporal_classification(
+    plugins_str,
+    serialize: bool,
+    sine_data_temporal_small,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=sine_data_temporal_small,
+        data_not_missing=sine_data_temporal_small,
+        serialize=serialize,
+        init_params={"seq2seq_classifier": {"epochs": 2}},
+    )
+
+    y_pred = pipe.predict(dataset, n_future_steps=10)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseTemporalClassifier)
+    assert y_pred.numpy().shape == (dataset.predictive.targets.num_samples, 10, 5)
+
+
+# Category: prediction.temporal.regression:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "prediction.temporal.regression.seq2seq_regressor",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "prediction.temporal.regression.seq2seq_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "prediction.temporal.regression.seq2seq_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "prediction.temporal.regression.seq2seq_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "prediction.temporal.regression.seq2seq_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_prediction_temporal_regression(
+    plugins_str,
+    serialize: bool,
+    sine_data_temporal_small,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=sine_data_temporal_small,
+        data_not_missing=sine_data_temporal_small,
+        serialize=serialize,
+        init_params={"seq2seq_regressor": {"epochs": 2}},
+    )
+
+    y_pred = pipe.predict(dataset, n_future_steps=10)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseTemporalRegressor)
+    assert y_pred.numpy().shape == (dataset.predictive.targets.num_samples, 10, 5)
+
+
+# Category: time_to_event:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "time_to_event.dynamic_deephit",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "time_to_event.dynamic_deephit",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "time_to_event.dynamic_deephit",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "time_to_event.dynamic_deephit",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "time_to_event.dynamic_deephit",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_time_to_event(
+    plugins_str,
+    serialize: bool,
+    pbc_data_small,
+    get_event0_time_percentiles,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=pbc_data_small,
+        data_not_missing=pbc_data_small,
+        serialize=serialize,
+        init_params={"dynamic_deephit": {"n_iter": 2}},
+    )
+
+    horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+    y_pred = pipe.predict(dataset, horizons=horizons)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseTimeToEventAnalysis)
+    assert y_pred.numpy().shape == (len(dataset.time_series), len(horizons), 1)
+
+
+# Category: treatments.one_off.classification:
+# NOTE: None.
+
+
+# Category: treatments.one_off.regression:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "treatments.one_off.regression.synctwin_regressor",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "treatments.one_off.regression.synctwin_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "treatments.one_off.regression.synctwin_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "treatments.one_off.regression.synctwin_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "treatments.one_off.regression.synctwin_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_treatments_oneoff_regression(
+    plugins_str,
+    serialize: bool,
+    pkpd_data_small,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=pkpd_data_small,
+        data_not_missing=pkpd_data_small,
+        serialize=serialize,
+        init_params={
+            "synctwin_regressor": {
+                "pretraining_iterations": 3,
+                "matching_iterations": 3,
+                "inference_iterations": 3,
+            }
+        },
+    )
+
+    output = pipe.predict_counterfactuals(dataset)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseOneOffTreatmentEffects)
+    assert len(output) == len(dataset)
+
+
+# Category: treatments.temporal.classification:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "treatments.temporal.classification.crn_classifier",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "treatments.temporal.classification.crn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "treatments.temporal.classification.crn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "treatments.temporal.classification.crn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "treatments.temporal.classification.crn_classifier",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_treatments_temporal_classification(
+    plugins_str,
+    serialize: bool,
+    clv_data_small,
+    simulate_treatments_scenarios: Callable,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=clv_data_small,
+        data_not_missing=clv_data_small,
+        serialize=serialize,
+        init_params={"crn_classifier": {"n_iter": 2}},
+    )
+
+    n_counterfactuals_per_sample = 2
+    horizons, treatment_scenarios = simulate_treatments_scenarios(
+        dataset, n_counterfactuals_per_sample=n_counterfactuals_per_sample
+    )
+    output = pipe.predict_counterfactuals(dataset, horizons=horizons, treatment_scenarios=treatment_scenarios)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseTemporalTreatmentEffects)
+    assert len(output) == len(dataset)
+    assert len(output[0]) == n_counterfactuals_per_sample
+
+
+# Category: treatments.temporal.regression:
+
+
+@pytest.mark.parametrize(
+    "plugins_str",
+    [
+        [
+            *TEST_TRANSFORM_STEPS["CASE_A"],
+            "treatments.temporal.regression.crn_regressor",
+        ],
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_B"],
+                "treatments.temporal.regression.crn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_C"],
+                "treatments.temporal.regression.crn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_D"],
+                "treatments.temporal.regression.crn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+        pytest.param(
+            [
+                *TEST_TRANSFORM_STEPS["CASE_E"],
+                "treatments.temporal.regression.crn_regressor",
+            ],
+            marks=pytest.mark.extra,
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "serialize",
+    [
+        pytest.param(True, marks=pytest.mark.filterwarnings("ignore:RNN.*contiguous.*:UserWarning")),
+        # ^ Expected: problem with current serialization.
+        False,
+    ],
+)
+def test_end2end_treatments_temporal_regression(
+    plugins_str,
+    serialize: bool,
+    clv_data_small,
+    simulate_treatments_scenarios: Callable,
+) -> None:
+    dataset, pipe = init_pipeline_and_fit(
+        plugins_str=plugins_str,
+        data_missing=clv_data_small,
+        data_not_missing=clv_data_small,
+        serialize=serialize,
+        init_params={"crn_regressor": {"n_iter": 2}},
+    )
+
+    n_counterfactuals_per_sample = 2
+    horizons, treatment_scenarios = simulate_treatments_scenarios(
+        dataset, n_counterfactuals_per_sample=n_counterfactuals_per_sample
+    )
+    output = pipe.predict_counterfactuals(dataset, horizons=horizons, treatment_scenarios=treatment_scenarios)
+
+    assert isinstance(pipe, PipelineBase)
+    assert isinstance(pipe, BaseTemporalTreatmentEffects)
+    assert len(output) == len(dataset)
+    assert len(output[0]) == n_counterfactuals_per_sample
diff --git a/tests/plugins/preprocessing/helpers_preprocessing.py b/tests/plugins/preprocessing/helpers_preprocessing.py
deleted file mode 100644
index 6c60e227..00000000
--- a/tests/plugins/preprocessing/helpers_preprocessing.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from tempor.data import dataset
-
-
-def as_covariates_dataset(ds: dataset.PredictiveDataset) -> dataset.CovariatesDataset:
-    data = dataset.CovariatesDataset(
-        time_series=ds.time_series.dataframe(),
-        static=ds.static.dataframe() if ds.static is not None else None,
-    )
-    return data
diff --git a/tests/plugins/preprocessing/imputation/__init__.py b/tests/plugins/preprocessing/imputation/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/imputation/static/__init__.py b/tests/plugins/preprocessing/imputation/static/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/imputation/static/test_static_tabular_imputer.py b/tests/plugins/preprocessing/imputation/static/test_static_tabular_imputer.py
index 84fb1dfc..6e48aadf 100644
--- a/tests/plugins/preprocessing/imputation/static/test_static_tabular_imputer.py
+++ b/tests/plugins/preprocessing/imputation/static/test_static_tabular_imputer.py
@@ -10,8 +10,6 @@
 from tempor.plugins.preprocessing.imputation.static.plugin_static_tabular_imputer import StaticTabularImputer
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -64,7 +62,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseImputer = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
@@ -88,6 +91,8 @@ def test_transform(
     assert output.static.dataframe().isna().sum().sum() == 0
 
 
+@pytest.mark.filterwarnings("ignore:.*Jupyter.*platformdirs.*:DeprecationWarning")  # Expected.
+@pytest.mark.filterwarnings("ignore:.*bool8.*:DeprecationWarning")  # Expected.
 @pytest.mark.filterwarnings("ignore:.*nonzero.*0d.*:DeprecationWarning")  # Expected for EM imputer.
 @pytest.mark.filterwarnings("ignore::RuntimeWarning")  # Expected for EM imputer.
 @pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning")  # May happen in some cases.
diff --git a/tests/plugins/preprocessing/imputation/temporal/__init__.py b/tests/plugins/preprocessing/imputation/temporal/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/imputation/temporal/test_bfill.py b/tests/plugins/preprocessing/imputation/temporal/test_bfill.py
index b0a50151..6c599a3f 100644
--- a/tests/plugins/preprocessing/imputation/temporal/test_bfill.py
+++ b/tests/plugins/preprocessing/imputation/temporal/test_bfill.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.imputation.temporal.plugin_bfill import BFillImputer
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS: Any = dict()
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseImputer = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/preprocessing/imputation/temporal/test_ffill.py b/tests/plugins/preprocessing/imputation/temporal/test_ffill.py
index 3c42a06a..aed84348 100644
--- a/tests/plugins/preprocessing/imputation/temporal/test_ffill.py
+++ b/tests/plugins/preprocessing/imputation/temporal/test_ffill.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.imputation.temporal.plugin_ffill import FFillImputer
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS: Any = dict()
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseImputer = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/preprocessing/imputation/temporal/test_ts_tabular_imputer.py b/tests/plugins/preprocessing/imputation/temporal/test_ts_tabular_imputer.py
index 4efdcc87..d78fc9af 100644
--- a/tests/plugins/preprocessing/imputation/temporal/test_ts_tabular_imputer.py
+++ b/tests/plugins/preprocessing/imputation/temporal/test_ts_tabular_imputer.py
@@ -10,8 +10,6 @@
 from tempor.plugins.preprocessing.imputation.temporal.plugin_ts_tabular_imputer import TemporalTabularImputer
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -60,7 +58,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseImputer = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
@@ -81,6 +84,8 @@ def test_transform(
     assert output.time_series.dataframe().isna().sum().sum() == 0
 
 
+@pytest.mark.filterwarnings("ignore:.*Jupyter.*platformdirs.*:DeprecationWarning")  # Expected.
+@pytest.mark.filterwarnings("ignore:.*bool8.*:DeprecationWarning")  # Expected.
 @pytest.mark.filterwarnings("ignore:.*nonzero.*0d.*:DeprecationWarning")  # Expected for EM imputer.
 @pytest.mark.filterwarnings("ignore::RuntimeWarning")  # Expected for EM imputer.
 @pytest.mark.filterwarnings("ignore::sklearn.exceptions.ConvergenceWarning")  # May happen in some cases.
diff --git a/tests/plugins/preprocessing/scaling/__init__.py b/tests/plugins/preprocessing/scaling/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/scaling/static/__init__.py b/tests/plugins/preprocessing/scaling/static/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py b/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py
index 5a657c9b..27774a2d 100644
--- a/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py
+++ b/tests/plugins/preprocessing/scaling/static/test_static_minmax_scaler.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.scaling.static.plugin_static_minmax_scaler import StaticMinMaxScaler
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -36,7 +34,7 @@ def test_sanity(get_test_plugin: Callable, plugin_from: str) -> None:
     test_plugin = get_test_plugin(plugin_from, INIT_KWARGS)
     assert test_plugin is not None
     assert test_plugin.name == "static_minmax_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
+    assert len(test_plugin.hyperparameter_space()) == 1
 
 
 @pytest.mark.parametrize("plugin_from", PLUGIN_FROM_OPTIONS)
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseScaler = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py b/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py
index 60cc0c08..6986de74 100644
--- a/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py
+++ b/tests/plugins/preprocessing/scaling/static/test_static_standard_scaler.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.scaling.static.plugin_static_standard_scaler import StaticStandardScaler
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseScaler = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/preprocessing/scaling/temporal/__init__.py b/tests/plugins/preprocessing/scaling/temporal/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py b/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py
index 3f179ad4..734f91f4 100644
--- a/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py
+++ b/tests/plugins/preprocessing/scaling/temporal/test_ts_minmax_scaler.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.scaling.temporal.plugin_ts_minmax_scaler import TimeSeriesMinMaxScaler
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -36,7 +34,7 @@ def test_sanity(get_test_plugin: Callable, plugin_from: str) -> None:
     test_plugin = get_test_plugin(plugin_from, INIT_KWARGS)
     assert test_plugin is not None
     assert test_plugin.name == "ts_minmax_scaler"
-    assert len(test_plugin.hyperparameter_space()) == 0
+    assert len(test_plugin.hyperparameter_space()) == 1
 
 
 @pytest.mark.parametrize("plugin_from", PLUGIN_FROM_OPTIONS)
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseScaler = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py b/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py
index 3eec003e..4e16eb9b 100644
--- a/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py
+++ b/tests/plugins/preprocessing/scaling/temporal/test_ts_standard_scaler.py
@@ -8,8 +8,6 @@
 from tempor.plugins.preprocessing.scaling.temporal.plugin_ts_standard_scaler import TimeSeriesStandardScaler
 from tempor.utils.serialization import load, save
 
-from ...helpers_preprocessing import as_covariates_dataset
-
 INIT_KWARGS = {"random_state": 123}
 PLUGIN_FROM_OPTIONS = ["from_api", pytest.param("from_module", marks=pytest.mark.extra)]
 TEST_ON_DATASETS = [
@@ -58,7 +56,12 @@ def test_fit(plugin_from: str, data: str, get_test_plugin: Callable, get_dataset
     ],
 )
 def test_transform(
-    plugin_from: str, data: str, covariates_dataset: bool, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    covariates_dataset: bool,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    as_covariates_dataset: Callable,
 ) -> None:
     test_plugin: BaseScaler = get_test_plugin(plugin_from, INIT_KWARGS)
     dataset = get_dataset(data)
diff --git a/tests/plugins/test_all_plugins.py b/tests/plugins/test_all_plugins.py
index 48ad6f49..b804da23 100644
--- a/tests/plugins/test_all_plugins.py
+++ b/tests/plugins/test_all_plugins.py
@@ -15,6 +15,8 @@ def test_init_success(plugin_fqn):
     PluginCls()  # Should successfully initialize with all default params.
 
 
+# DeprecationWarning expected for preprocessing.imputation.temporal.ts_tabular_imputer:
+@pytest.mark.filterwarnings("ignore:.*bool8.*:DeprecationWarning")
 @pytest.mark.parametrize("plugin_fqn", PLUGIN_FQNS)
 def test_sample_hyperparameters(plugin_fqn):
     PluginCls = plugin_loader.get_class(plugin_fqn)
diff --git a/tests/plugins/treatments/__init__.py b/tests/plugins/treatments/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/helpers_treatments.py b/tests/plugins/treatments/helpers_treatments.py
deleted file mode 100644
index 57ef3ba4..00000000
--- a/tests/plugins/treatments/helpers_treatments.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import numpy as np
-
-
-def simulate_horizons(data):
-    return [tc.time_indexes()[0][len(tc.time_indexes()[0]) // 2 :] for tc in data.time_series]
-
-
-def simulate_treatments_scenarios(data, n_counterfactuals_per_sample: int = 2):
-    horizons = simulate_horizons(data)
-
-    treatment_scenarios = []
-    for idx, sample_idx in enumerate(data.time_series.sample_index()):
-        sample_scenarios = []
-        treat = data.predictive.treatments[sample_idx].dataframe()
-        horizon_counterfactuals_sample = horizons[idx]
-
-        for treat_sc_idx in range(n_counterfactuals_per_sample):
-            np.random.seed(12345 + treat_sc_idx)
-            treat_sc = np.random.randint(low=0, high=1 + 1, size=(len(horizon_counterfactuals_sample), treat.shape[1]))
-            sample_scenarios.append(treat_sc)
-        treatment_scenarios.append(sample_scenarios)
-
-    return horizons, treatment_scenarios
diff --git a/tests/plugins/treatments/one_off/__init__.py b/tests/plugins/treatments/one_off/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/preprocessing/__init__.py b/tests/plugins/treatments/one_off/classification/.gitkeep
similarity index 100%
rename from tests/plugins/preprocessing/__init__.py
rename to tests/plugins/treatments/one_off/classification/.gitkeep
diff --git a/tests/plugins/treatments/one_off/classification/__init__.py b/tests/plugins/treatments/one_off/classification/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/one_off/regression/__init__.py b/tests/plugins/treatments/one_off/regression/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/temporal/__init__.py b/tests/plugins/treatments/temporal/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/temporal/classification/__init__.py b/tests/plugins/treatments/temporal/classification/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/temporal/classification/test_crn_classifier.py b/tests/plugins/treatments/temporal/classification/test_crn_classifier.py
index bcba0441..9ab05779 100644
--- a/tests/plugins/treatments/temporal/classification/test_crn_classifier.py
+++ b/tests/plugins/treatments/temporal/classification/test_crn_classifier.py
@@ -7,8 +7,6 @@
 from tempor.plugins.treatments.temporal.classification.plugin_crn_classifier import CRNTreatmentsClassifier
 from tempor.utils.serialization import load, save
 
-from ...helpers_treatments import simulate_horizons, simulate_treatments_scenarios
-
 if TYPE_CHECKING:  # pragma: no cover
     from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
 
@@ -56,7 +54,14 @@ def test_fit(plugin_from: str, data: str, device: str, get_test_plugin: Callable
 @pytest.mark.parametrize("plugin_from", PLUGIN_FROM_OPTIONS)
 @pytest.mark.parametrize("data", TEST_ON_DATASETS)
 @pytest.mark.parametrize("device", DEVICES)
-def test_predict(plugin_from: str, data: str, device: str, get_test_plugin: Callable, get_dataset: Callable) -> None:
+def test_predict(
+    plugin_from: str,
+    data: str,
+    device: str,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    simulate_horizons: Callable,
+) -> None:
     test_plugin: "BaseTemporalTreatmentEffects" = get_test_plugin(plugin_from, INIT_KWARGS, device=device)
     dataset = get_dataset(data)
     test_plugin.fit(dataset)
@@ -79,7 +84,12 @@ def test_predict(plugin_from: str, data: str, device: str, get_test_plugin: Call
 @pytest.mark.parametrize("data", TEST_ON_DATASETS)
 @pytest.mark.parametrize("device", DEVICES)
 def test_predict_counterfactuals(
-    plugin_from: str, data: str, device: str, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    device: str,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    simulate_treatments_scenarios: Callable,
 ) -> None:
     test_plugin: "BaseTemporalTreatmentEffects" = get_test_plugin(plugin_from, INIT_KWARGS, device=device)
     dataset = get_dataset(data)
diff --git a/tests/plugins/treatments/temporal/regression/__init__.py b/tests/plugins/treatments/temporal/regression/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/plugins/treatments/temporal/regression/test_crn_regressor.py b/tests/plugins/treatments/temporal/regression/test_crn_regressor.py
index 6b2e8ae8..85cc7715 100644
--- a/tests/plugins/treatments/temporal/regression/test_crn_regressor.py
+++ b/tests/plugins/treatments/temporal/regression/test_crn_regressor.py
@@ -7,8 +7,6 @@
 from tempor.plugins.treatments.temporal.regression.plugin_crn_regressor import CRNTreatmentsRegressor
 from tempor.utils.serialization import load, save
 
-from ...helpers_treatments import simulate_horizons, simulate_treatments_scenarios
-
 if TYPE_CHECKING:  # pragma: no cover
     from tempor.plugins.treatments.temporal import BaseTemporalTreatmentEffects
 
@@ -56,7 +54,14 @@ def test_fit(plugin_from: str, data: str, device: str, get_test_plugin: Callable
 @pytest.mark.parametrize("plugin_from", PLUGIN_FROM_OPTIONS)
 @pytest.mark.parametrize("data", TEST_ON_DATASETS)
 @pytest.mark.parametrize("device", DEVICES)
-def test_predict(plugin_from: str, data: str, device: str, get_test_plugin: Callable, get_dataset: Callable) -> None:
+def test_predict(
+    plugin_from: str,
+    data: str,
+    device: str,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    simulate_horizons: Callable,
+) -> None:
     test_plugin: "BaseTemporalTreatmentEffects" = get_test_plugin(plugin_from, INIT_KWARGS, device=device)
     dataset = get_dataset(data)
     test_plugin.fit(dataset)
@@ -79,7 +84,12 @@ def test_predict(plugin_from: str, data: str, device: str, get_test_plugin: Call
 @pytest.mark.parametrize("data", TEST_ON_DATASETS)
 @pytest.mark.parametrize("device", DEVICES)
 def test_predict_counterfactuals(
-    plugin_from: str, data: str, device: str, get_test_plugin: Callable, get_dataset: Callable
+    plugin_from: str,
+    data: str,
+    device: str,
+    get_test_plugin: Callable,
+    get_dataset: Callable,
+    simulate_treatments_scenarios: Callable,
 ) -> None:
     test_plugin: "BaseTemporalTreatmentEffects" = get_test_plugin(plugin_from, INIT_KWARGS, device=device)
     dataset = get_dataset(data)