Repository: vanderschaarlab/temporai (language: python)

Issue:
[Feat] Prediction models: Add Dynamic DeepHit
## Feature Description
Dynamic Deephit prediciton model.

Reference implementation in PyTorch: https://github.com/vanderschaarlab/synthcity/blob/main/src/synthcity/plugins/core/models/time_series_survival/ts_surv_dynamic_deephit.py



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index cdad4543..77600611 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,7 +54,8 @@ MANIFEST
 .python-version
 
 ### Custom ###
-dev
-logs
-outputs
 *.csv
+dev/**
+logs/**
+outputs/**
+data/**
diff --git a/README.md b/README.md
index 223a25c3..b8d79c8d 100644
--- a/README.md
+++ b/README.md
@@ -59,10 +59,10 @@ print(plugin_loader.list())
 
 * Use an imputer
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader(with_missing=True).load()
+dataset = SineDataLoader(with_missing=True).load()
 assert dataset.static.dataframe().isna().sum().sum() != 0
 assert dataset.time_series.dataframe().isna().sum().sum() != 0
 
@@ -80,10 +80,10 @@ assert imputed.time_series.dataframe().isna().sum().sum() == 0
 
 * Use a classifier
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader().load()
+dataset = SineDataLoader().load()
 
 # load the model
 model = plugin_loader.get("classification.nn_classifier", n_iter=50)
@@ -97,10 +97,10 @@ assert model.predict(dataset).numpy().shape == (len(dataset), 1)
 
 * Use a regressor
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader().load()
+dataset = SineDataLoader().load()
 
 # load the model
 model = plugin_loader.get("regression.nn_regressor", n_iter=50)
@@ -114,7 +114,7 @@ assert model.predict(dataset).numpy().shape == (len(dataset), 1)
 
 * Benchmark models
 ```python
-TODO
+# TODO
 ```
 
 
@@ -172,7 +172,7 @@ TODO
 | [SyncTwin](https://github.com/vanderschaarlab/SyncTwin-NeurIPS-2021/) | [van der Schaar Lab] | [📄](https://proceedings.neurips.cc/paper/2021/hash/19485224d128528da1602ca47383f078-Abstract.html) | ➰ Experimental |
 | [TE-CDE](https://github.com/vanderschaarlab/TE-CDE/) | [van der Schaar Lab] | [📄](https://proceedings.mlr.press/v162/seedat22b/seedat22b.pdf) | 🔵 Planned |
 
-**Temporal Survival Analysis**
+**Temporal Time-to-event Analysis**
 
 | Model &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | Affiliation &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; | Paper | Status &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; |
 | ----- | ----------- | ----- | ------ |
diff --git a/docs/overview.md b/docs/overview.md
index 1d4f0a1f..e80f8ef3 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -46,10 +46,10 @@ print(plugin_loader.list())
 
 * Use an imputer
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader(with_missing=True).load()
+dataset = SineDataLoader(with_missing=True).load()
 assert dataset.static.dataframe().isna().sum().sum() != 0
 assert dataset.time_series.dataframe().isna().sum().sum() != 0
 
@@ -67,10 +67,10 @@ assert imputed.time_series.dataframe().isna().sum().sum() == 0
 
 * Use a classifier
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader().load()
+dataset = SineDataLoader().load()
 
 # load the model
 model = plugin_loader.get("classification.nn_classifier", n_iter=50)
@@ -84,10 +84,10 @@ assert model.predict(dataset).numpy().shape == (len(dataset), 1)
 
 * Use a regressor
 ```python
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 from tempor.plugins import plugin_loader
 
-dataset = SineDataloader().load()
+dataset = SineDataLoader().load()
 
 # load the model
 model = plugin_loader.get("regression.nn_regressor", n_iter=50)
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 92dfd0be..e544c368 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -10,7 +10,9 @@ pandas >=1
 pandera >=0.13
 pydantic >=1, <2
 rich
+scikit-learn
 scikit-learn >= 1.0
+scipy
 torch
 torchcde
 torchdiffeq
diff --git a/docs/user_guide/survival_analysis.md b/docs/user_guide/survival_analysis.md
index e20b727d..4764da41 100644
--- a/docs/user_guide/survival_analysis.md
+++ b/docs/user_guide/survival_analysis.md
@@ -1,4 +1,4 @@
-# Survival Analysis
+# Time-to-event Analysis
 
 > 💡 We are in process of migrating the modules from the [previous iteration of the project](https://github.com/vanderschaarlab/clairvoyance2).
 
diff --git a/setup.cfg b/setup.cfg
index 3e9c29c7..2abc605f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -63,6 +63,7 @@ install_requires =
     torchdiffeq
     tsai
     rich
+    scikit-learn
     typing-extensions
     torch
 
@@ -98,6 +99,7 @@ dev =
     setuptools-scm
     sphinx >=5, !=5.1.0  # Bug in version 5.1.0.
     tox
+    types-requests
     twine
 
 [options.entry_points]
diff --git a/src/tempor/benchmarks/__init__.py b/src/tempor/benchmarks/__init__.py
index 830734f7..526fe778 100644
--- a/src/tempor/benchmarks/__init__.py
+++ b/src/tempor/benchmarks/__init__.py
@@ -6,6 +6,8 @@
     classifier_supported_metrics,
     evaluate_classifier,
     evaluate_regressor,
+    evaluate_time_to_event,
     output_metrics,
     regression_supported_metrics,
+    time_to_event_supported_metrics,
 )
diff --git a/src/tempor/benchmarks/benchmark.py b/src/tempor/benchmarks/benchmark.py
index 1996b453..9f18476d 100644
--- a/src/tempor/benchmarks/benchmark.py
+++ b/src/tempor/benchmarks/benchmark.py
@@ -1,12 +1,12 @@
-from typing import Any, Dict, List, Tuple
+from typing import Any, Callable, Dict, List, Optional, Tuple
 
 import pandas as pd
 import pydantic
 
-from tempor.data import dataset
+from tempor.data import data_typing, dataset
 from tempor.log import logger as log
 
-from .evaluation import evaluate_classifier, evaluate_regressor
+from . import evaluation
 
 
 def print_score(mean: pd.Series, std: pd.Series) -> pd.Series:
@@ -27,13 +27,14 @@ def benchmark_models(
     data: dataset.Dataset,
     n_splits: int = 3,
     random_state: int = 0,
+    horizons: Optional[data_typing.TimeIndex] = None,
 ) -> Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
     """Benchmark the performance of several algorithms.
 
     Args:
         task_type (str):
             The type of problem. Relevant for evaluating the downstream models with the correct metrics.
-            Valid tasks are:  ``"classification"``, ``"regression"``.
+            Valid tasks are:  ``"classification"``, ``"regression"``, ``"time_to_event"``.
         tests (List[Tuple[str, Any]]):
             Tuples of form ``(test_name: str, plugin: BasePredictor/Pipeline)``
         data (dataset.Dataset):
@@ -42,31 +43,41 @@ def benchmark_models(
             Number of splits used for cross-validation. Defaults to ``3``.
         random_state (int, optional):
             Random seed. Defaults to ``0``.
+        horizons (data_typing.TimeIndex, optional):
+            Time horizons for making predictions, if applicable to the task.
 
     Returns:
         Tuple[pd.DataFrame, Dict[str, pd.DataFrame]]:
-            The benchmarking results given as ``(readable_dataframe: pd.DataFrame, results: Dict[str, pd.DataFrame]])``
-            where:
-                * ``readable_dataframe``: a dataframe with metric name as index and test names as columns, where the
-                values are readable string representations of the evaluation metric, like: ``MEAN +/- STDDEV``.
-                * ``results``: a dictionary mapping the test name to a dataframe with metric names as index and
-                ``["mean", "stddev"]`` columns, where the values are the ``float`` mean and standard deviation for
-                each metric.
+            The benchmarking results given as ``(readable_dataframe: pd.DataFrame, results: Dict[str, pd.DataFrame]])``\
+                where:
+                * ``readable_dataframe``: a dataframe with metric name as index and test names as columns, where the\
+                    values are readable string representations of the evaluation metric, like: ``MEAN +/- STDDEV``.
+                * ``results``: a dictionary mapping the test name to a dataframe with metric names as index and\
+                    ``["mean", "stddev"]`` columns, where the values are the ``float`` mean and standard deviation\
+                    for each metric.
     """
 
     results = {}
 
     if task_type == "classification":
-        evaluator = evaluate_classifier
+        evaluator: Callable = evaluation.evaluate_classifier
     elif task_type == "regression":
-        evaluator = evaluate_regressor
+        evaluator = evaluation.evaluate_regressor
+    elif task_type == "time_to_event":
+        evaluator = evaluation.evaluate_time_to_event
     else:
         raise ValueError(f"Unsupported task type {task_type}")
 
     for testcase, plugin in tests:
         log.info(f"Testcase : {testcase}")
 
-        scores = evaluator(plugin, data=data, n_splits=n_splits, random_state=random_state)
+        scores = evaluator(
+            plugin,
+            data=data,  # pyright: ignore
+            n_splits=n_splits,
+            random_state=random_state,
+            horizons=horizons,  # pyright: ignore
+        )
 
         mean_score = scores["mean"].to_dict()
         stddev_score = scores["stddev"].to_dict()
diff --git a/src/tempor/benchmarks/evaluation.py b/src/tempor/benchmarks/evaluation.py
index 676a1da3..47ed1065 100644
--- a/src/tempor/benchmarks/evaluation.py
+++ b/src/tempor/benchmarks/evaluation.py
@@ -1,31 +1,24 @@
 import copy
 from time import time
-from typing import Any, Dict, List, Sequence, Union, cast
+from typing import Any, Callable, Dict, List, Sequence, Union, cast
 
 import numpy as np
 import pandas as pd
 import pydantic
-from scipy.stats import iqr
-from sklearn.metrics import (
-    accuracy_score,
-    cohen_kappa_score,
-    f1_score,
-    matthews_corrcoef,
-    mean_absolute_error,
-    mean_squared_error,
-    precision_score,
-    r2_score,
-    recall_score,
-)
-from sklearn.model_selection import KFold, StratifiedKFold
+import scipy.stats
+import sklearn.metrics
+import sklearn.model_selection
 from typing_extensions import Literal, get_args
 
-from tempor.benchmarks.utils import generate_score, print_score
-from tempor.data import dataset
-from tempor.log import logger as log
+from tempor.data import data_typing, dataset, samples
+from tempor.log import logger
 from tempor.models.utils import enable_reproducibility
+from tempor.plugins.classification import BaseClassifier
+from tempor.plugins.regression import BaseRegressor
+from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
 
-from .utils import evaluate_auc_multiclass
+from . import metrics as tempor_metrics
+from . import utils
 
 ClassifierSupportedMetric = Literal[
     "aucroc",
@@ -109,6 +102,19 @@
         Mean absolute error regression loss.
 """
 
+TimeToEventSupportedMetric = Literal[
+    "c_index",
+    "brier_score",
+]
+"""Evaluation metrics supported in the time-to-event (survival) task setting.
+
+Possible values:
+    - ``"c_index"``:
+        Concordance index based on inverse probability of censoring weights.
+    - ``"brier_score"``:
+        The time-dependent Brier score.
+"""
+
 OutputMetric = Literal[
     "min",
     "max",
@@ -149,6 +155,9 @@
 regression_supported_metrics = get_args(RegressionSupportedMetric)
 """A tuple of all possible values of :obj:`~tempor.benchmarks.evaluation.RegressionSupportedMetric`."""
 
+time_to_event_supported_metrics = get_args(TimeToEventSupportedMetric)
+"""A tuple of all possible values of :obj:`~tempor.benchmarks.evaluation.TimeToEventSupportedMetric`."""
+
 output_metrics = get_args(OutputMetric)
 """A tuple of all possible values of :obj:`~tempor.benchmarks.evaluation.OutputMetric`."""
 
@@ -169,14 +178,14 @@ def _postprocess_results(results: _InternalScores) -> pd.DataFrame:
     for metric in results.metrics:
         values = results.metrics[metric]
         errors = np.sum(results.errors)
-        durations = print_score(generate_score(np.asarray(results.durations)))
+        durations = utils.print_score(utils.generate_score(np.asarray(results.durations)))
 
         score_min = np.min(values)
         score_max = np.max(values)
         score_mean = np.mean(values)
         score_median = np.median(values)
         score_stddev = np.std(values)
-        score_iqr = iqr(values)
+        score_iqr = scipy.stats.iqr(values)
         score_rounds = len(values)
 
         output = pd.concat(
@@ -240,88 +249,88 @@ def score_proba(self, y_test: np.ndarray, y_pred_proba: np.ndarray) -> Dict[str,
             elif metric == "aucroc":
                 results[metric] = self.roc_auc_score(y_test, y_pred_proba)
             elif metric == "accuracy":
-                results[metric] = accuracy_score(y_test, y_pred)
+                results[metric] = sklearn.metrics.accuracy_score(y_test, y_pred)
             elif metric == "f1_score_micro":
-                results[metric] = f1_score(
+                results[metric] = sklearn.metrics.f1_score(
                     y_test,
                     y_pred,
                     average="micro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "f1_score_macro":
-                results[metric] = f1_score(
+                results[metric] = sklearn.metrics.f1_score(
                     y_test,
                     y_pred,
                     average="macro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "f1_score_weighted":
-                results[metric] = f1_score(
+                results[metric] = sklearn.metrics.f1_score(
                     y_test,
                     y_pred,
                     average="weighted",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "kappa":
-                results[metric] = cohen_kappa_score(y_test, y_pred)
+                results[metric] = sklearn.metrics.cohen_kappa_score(y_test, y_pred)
             elif metric == "kappa_quadratic":
-                results[metric] = cohen_kappa_score(y_test, y_pred, weights="quadratic")
+                results[metric] = sklearn.metrics.cohen_kappa_score(y_test, y_pred, weights="quadratic")
             elif metric == "recall_micro":
-                results[metric] = recall_score(
+                results[metric] = sklearn.metrics.recall_score(
                     y_test,
                     y_pred,
                     average="micro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "recall_macro":
-                results[metric] = recall_score(
+                results[metric] = sklearn.metrics.recall_score(
                     y_test,
                     y_pred,
                     average="macro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "recall_weighted":
-                results[metric] = recall_score(
+                results[metric] = sklearn.metrics.recall_score(
                     y_test,
                     y_pred,
                     average="weighted",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "precision_micro":
-                results[metric] = precision_score(
+                results[metric] = sklearn.metrics.precision_score(
                     y_test,
                     y_pred,
                     average="micro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "precision_macro":
-                results[metric] = precision_score(
+                results[metric] = sklearn.metrics.precision_score(
                     y_test,
                     y_pred,
                     average="macro",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "precision_weighted":
-                results[metric] = precision_score(
+                results[metric] = sklearn.metrics.precision_score(
                     y_test,
                     y_pred,
                     average="weighted",
                     zero_division=0,  # pyright: ignore
                 )
             elif metric == "mcc":
-                results[metric] = matthews_corrcoef(y_test, y_pred)
+                results[metric] = sklearn.metrics.matthews_corrcoef(y_test, y_pred)
             else:
                 raise ValueError(f"invalid metric {metric}")
 
-        log.debug(f"evaluate_classifier: {results}")
+        logger.debug(f"evaluate_classifier: {results}")
         return results
 
     def roc_auc_score(self, y_test: np.ndarray, y_pred_proba: np.ndarray) -> float:
-        return evaluate_auc_multiclass(y_test, y_pred_proba)[0]
+        return utils.evaluate_auc_multiclass(y_test, y_pred_proba)[0]
 
     def average_precision_score(self, y_test: np.ndarray, y_pred_proba: np.ndarray) -> float:
 
-        return evaluate_auc_multiclass(y_test, y_pred_proba)[1]
+        return utils.evaluate_auc_multiclass(y_test, y_pred_proba)[1]
 
 
 @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
@@ -358,6 +367,7 @@ def evaluate_classifier(  # pylint: disable=unused-argument
 
     if n_splits < 2 or not isinstance(n_splits, int):
         raise ValueError("n_splits must be an integer >= 2")
+    estimator_: BaseClassifier = cast(BaseClassifier, estimator)
     enable_reproducibility(random_state)
 
     results = _InternalScores()
@@ -365,7 +375,7 @@ def evaluate_classifier(  # pylint: disable=unused-argument
     for metric in classifier_supported_metrics:
         results.metrics[metric] = np.zeros(n_splits)
 
-    splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=random_state)
+    splitter = sklearn.model_selection.StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=random_state)
     if data.predictive is None:
         raise ValueError("No targets to use for train/test")
 
@@ -375,7 +385,7 @@ def evaluate_classifier(  # pylint: disable=unused-argument
 
     indx = 0
     for train_data, test_data in data.split(splitter=splitter, y=labels):
-        model = copy.deepcopy(estimator)
+        model = copy.deepcopy(estimator_)
         start = time()
         try:
             model.fit(train_data)
@@ -391,7 +401,7 @@ def evaluate_classifier(  # pylint: disable=unused-argument
                 results.metrics[metric][indx] = scores[metric]
             results.errors.append(0)
         except BaseException as e:  # pylint: disable=broad-except
-            log.error(f"Evaluation failed: {e}")
+            logger.error(f"Evaluation failed: {e}")
             results.errors.append(1)
 
         results.durations.append(time() - start)
@@ -433,7 +443,7 @@ def evaluate_regressor(  # pylint: disable=unused-argument
     """
     if n_splits < 2 or not isinstance(n_splits, int):
         raise ValueError("n_splits must be an integer >= 2")
-
+    estimator_: BaseRegressor = cast(BaseRegressor, estimator)
     enable_reproducibility(random_state)
     metrics = regression_supported_metrics
 
@@ -441,12 +451,11 @@ def evaluate_regressor(  # pylint: disable=unused-argument
     for metric in metrics:
         results.metrics[metric] = np.zeros(n_splits)
 
-    indx = 0
-
-    splitter = KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
+    splitter = sklearn.model_selection.KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
 
+    indx = 0
     for train_data, test_data in data.split(splitter=splitter):
-        model = copy.deepcopy(estimator)
+        model = copy.deepcopy(estimator_)
         start = time()
         try:
             model.fit(train_data)
@@ -457,12 +466,170 @@ def evaluate_regressor(  # pylint: disable=unused-argument
             targets = test_data.predictive.targets.numpy().squeeze()
             preds = model.predict(test_data).numpy().squeeze()
 
-            results.metrics["mse"][indx] = mean_squared_error(targets, preds)
-            results.metrics["mae"][indx] = mean_absolute_error(targets, preds)
-            results.metrics["r2"][indx] = r2_score(targets, preds)
+            results.metrics["mse"][indx] = sklearn.metrics.mean_squared_error(targets, preds)
+            results.metrics["mae"][indx] = sklearn.metrics.mean_absolute_error(targets, preds)
+            results.metrics["r2"][indx] = sklearn.metrics.r2_score(targets, preds)
             results.errors.append(0)
         except BaseException as e:  # pylint: disable=broad-except
-            log.error(f"Regression evaluation failed: {e}")
+            logger.error(f"Regression evaluation failed: {e}")
+            results.errors.append(1)
+
+        results.durations.append(time() - start)
+        indx += 1
+
+    return _postprocess_results(results)
+
+
+TimeToEventMetricCallable = Callable[[np.ndarray, np.ndarray, np.ndarray, List[float]], List[float]]
+"""Standardized function for time-to-event metric.
+
+Inputs are:
+    * ``training_array_struct`` (np.ndarray)
+    * ``testing_array_struct`` (np.ndarray)
+    * ``predictions`` (np.ndarray)
+    * ``horizons`` (List[float])
+
+Output is:
+    A list with the metric values for each horizon.
+"""
+
+
+def compute_c_index(
+    training_array_struct: np.ndarray, testing_array_struct: np.ndarray, predictions: np.ndarray, horizons: List[float]
+) -> List[float]:
+    metrics: List[float] = []
+    for horizon_idx, horizon_time in enumerate(horizons):
+        predictions_at_horizon_time = predictions[:, horizon_idx, :].reshape((-1,))
+        out = tempor_metrics.concordance_index_ipcw(
+            training_array_struct, testing_array_struct, predictions_at_horizon_time, float(horizon_time)
+        )
+        metrics.append(out[0])
+    return metrics
+
+
+def compute_brier_score(
+    training_array_struct: np.ndarray, testing_array_struct: np.ndarray, predictions: np.ndarray, horizons: List[float]
+) -> List[float]:
+    predictions = predictions.reshape((predictions.shape[0], predictions.shape[1]))
+    times, scores = tempor_metrics.brier_score(  # pylint: disable=unused-variable
+        training_array_struct, testing_array_struct, predictions, horizons
+    )
+    return scores.tolist()
+
+
+def _compute_time_to_event_metric(
+    metric_func: TimeToEventMetricCallable,
+    train_data: dataset.TimeToEventAnalysisDataset,
+    test_data: dataset.TimeToEventAnalysisDataset,
+    horizons: data_typing.TimeIndex,
+    predictions: samples.TimeSeriesSamples,
+) -> float:
+    if not predictions.num_timesteps_equal():
+        raise ValueError(
+            f"Expected time to event prediction values for horizons {horizons} all to have equal number of time steps "
+            f"({len(horizons)} but different lengths found {predictions.num_timesteps()}"
+        )
+    for data, name in zip(
+        (predictions, train_data.predictive.targets, test_data.predictive.targets),
+        ("predictions", "training data targets", "testing data targets"),
+    ):
+        if data.num_features > 1:
+            raise ValueError(
+                "Currently time to event evaluation only supports one event "
+                f"but more than one event features found in {name}"
+            )
+    try:
+        float(horizons[0])  # type: ignore
+    except (TypeError, ValueError) as e:
+        raise ValueError("Currently only int or float time horizons supported.") from e
+    horizons = cast(List[float], horizons)
+
+    predictions_array = predictions.numpy()
+    t_train, y_train = (df.to_numpy().reshape((-1,)) for df in train_data.predictive.targets.split_as_two_dataframes())
+    y_train_struct = tempor_metrics.create_structured_array(y_train, t_train)
+    t_test, y_test = (df.to_numpy().reshape((-1,)) for df in test_data.predictive.targets.split_as_two_dataframes())
+    y_test_struct = tempor_metrics.create_structured_array(y_test, t_test)
+
+    metrics: List[float] = metric_func(y_train_struct, y_test_struct, predictions_array, horizons)
+    avg_metric = float(np.asarray(metrics).mean())
+
+    return avg_metric
+
+
+@pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
+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,
+    **kwargs: Any,
+) -> pd.DataFrame:
+    """Helper for evaluating time-to-event tasks.
+
+    Args:
+        estimator (Any):
+            Baseline model to evaluate - must be unfitted
+        data (dataset.TimeToEventAnalysisDataset):
+             The dataset.
+        horizons (data_typing.TimeIndex):
+            Time horizons for making predictions at.
+        n_splits (int, optional):
+            Cross-validation folds. Defaults to ``3``.
+        random_state (int, optional):
+            Random state. Defaults to ``0``.
+
+    Returns:
+        pd.DataFrame:
+            DataFrame containing the results.
+
+            The columns of the dataframe contain details about the cross-validation repeats: one column for each
+            :obj:`~tempor.benchmarks.evaluation.OutputMetric`.
+
+            The index of the dataframe contains all the metrics evaluated: all of
+            :obj:`~tempor.benchmarks.evaluation.TimeToEventSupportedMetric`.
+    """
+    if n_splits < 2 or not isinstance(n_splits, int):
+        raise ValueError("n_splits must be an integer >= 2")
+    estimator_: BaseTimeToEventAnalysis = cast(BaseTimeToEventAnalysis, estimator)
+    enable_reproducibility(random_state)
+    metrics = time_to_event_supported_metrics
+    metrics_map = {
+        "c_index": compute_c_index,
+        "brier_score": compute_brier_score,
+    }
+
+    results = _InternalScores()
+    for metric in metrics:
+        results.metrics[metric] = np.zeros(n_splits)
+
+    splitter = sklearn.model_selection.KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
+
+    indx = 0
+    for train_data, test_data in data.split(splitter=splitter):
+        model = copy.deepcopy(estimator_)
+        start = time()
+        try:
+            model.fit(train_data)
+
+            # targets = test_data.predictive.targets.numpy().squeeze()
+            preds = model.predict(test_data, horizons=horizons)
+
+            for metric_name in time_to_event_supported_metrics:
+                metric_func = metrics_map[metric_name]
+                results.metrics[metric_name][indx] = _compute_time_to_event_metric(
+                    metric_func,
+                    train_data=train_data,
+                    test_data=test_data,
+                    horizons=horizons,
+                    predictions=preds,
+                )
+
+            results.errors.append(0)
+
+        except BaseException as e:  # pylint: disable=broad-except
+            logger.error(f"Regression evaluation failed: {e}")
             results.errors.append(1)
 
         results.durations.append(time() - start)
diff --git a/src/tempor/benchmarks/metrics.py b/src/tempor/benchmarks/metrics.py
new file mode 100644
index 00000000..c38b7db6
--- /dev/null
+++ b/src/tempor/benchmarks/metrics.py
@@ -0,0 +1,749 @@
+from typing import Any, Optional, Tuple
+
+import numpy as np
+import sklearn.base
+import sklearn.utils
+import sklearn.utils.validation
+from typing_extensions import Self
+
+# --- Utilities. ---
+
+
+def check_y_survival(y_or_event: np.ndarray, *args, allow_all_censored=False) -> Tuple[np.ndarray, ...]:
+    """Check that array correctly represents an outcome for survival analysis.
+
+    Args:
+        y_or_event (np.ndarray):
+            Structured array with two fields, or boolean array. If a structured array, it must contain the binary
+            event indicator as first field, and time of event or time of censoring as second field. Otherwise, it is
+            assumed that a boolean array representing the event indicator is passed.
+        *args:
+            List of array-likes. Any number of array-like objects representing time information. Elements that are
+            `None` are passed along in the return value.
+        allow_all_censored (bool, optional):
+            Whether to allow all events to be censored. Defaults to `False.`
+
+    Returns:
+        Tuple[np.ndarray, ...]:
+            Tuple like ``(event, time)``.
+                * event: ``np.ndarray, shape=(n_samples,), dtype=bool``. Binary event indicator.
+                * time: ``np.ndarray, shape=(n_samples,), dtype=float``. Time of event or censoring.
+                * ...: additional time elements, depending on number of ``args``.
+    """
+    if len(args) == 0:
+        y = y_or_event
+
+        if not isinstance(y, np.ndarray) or y.dtype.fields is None or len(y.dtype.fields) != 2:
+            raise ValueError(
+                "y must be a structured array with the first field"
+                " being a binary class event indicator and the second field"
+                " the time of the event/censoring"
+            )
+
+        event_field, time_field = y.dtype.names
+        y_event = y[event_field]
+        time_args: Tuple = (y[time_field],)
+    else:
+        y_event = np.asanyarray(y_or_event)
+        time_args = args
+
+    event = sklearn.utils.check_array(y_event, ensure_2d=False)
+    if not np.issubdtype(event.dtype, np.bool_):
+        raise ValueError("elements of event indicator must be boolean, but found {0}".format(event.dtype))
+
+    if not (allow_all_censored or np.any(event)):
+        raise ValueError("all samples are censored")
+
+    return_val = [event]
+    for i, yt in enumerate(time_args):
+        if yt is None:
+            return_val.append(yt)
+            continue
+
+        yt = sklearn.utils.check_array(yt, ensure_2d=False)
+        if not np.issubdtype(yt.dtype, np.number):
+            raise ValueError("time must be numeric, but found {} for argument {}".format(yt.dtype, i + 2))
+
+        return_val.append(yt)
+
+    return tuple(return_val)
+
+
+def _check_estimate_1d(estimate, test_time):
+    estimate = sklearn.utils.check_array(
+        estimate,
+        ensure_2d=False,
+        input_name="estimate",  # pyright: ignore
+    )
+    if estimate.ndim != 1:
+        raise ValueError("Expected 1D array, got {:d}D array instead:\narray={}.\n".format(estimate.ndim, estimate))
+    sklearn.utils.check_consistent_length(test_time, estimate)
+    return estimate
+
+
+def _check_inputs(event_indicator, event_time, estimate):
+    sklearn.utils.check_consistent_length(event_indicator, event_time, estimate)
+    event_indicator = sklearn.utils.check_array(
+        event_indicator,
+        ensure_2d=False,
+        input_name="event_indicator",  # pyright: ignore
+    )
+    event_time = sklearn.utils.check_array(
+        event_time,
+        ensure_2d=False,
+        input_name="event_time",  # pyright: ignore
+    )
+    estimate = _check_estimate_1d(estimate, event_time)
+
+    if not np.issubdtype(event_indicator.dtype, np.bool_):
+        raise ValueError(
+            "only boolean arrays are supported as class labels for survival analysis, got {0}".format(
+                event_indicator.dtype
+            )
+        )
+
+    if len(event_time) < 2:
+        raise ValueError("Need a minimum of two samples")
+
+    if not event_indicator.any():
+        raise ValueError("All samples are censored")
+
+    return event_indicator, event_time, estimate
+
+
+def _check_times(test_time, times):
+    times = sklearn.utils.check_array(
+        np.atleast_1d(times),
+        ensure_2d=False,
+        dtype=test_time.dtype,
+        input_name="times",  # pyright: ignore
+    )
+    times = np.unique(times)
+
+    if times.max() >= test_time.max() or times.min() < test_time.min():
+        raise ValueError(
+            "all times must be within follow-up time of test data: [{}; {}[".format(test_time.min(), test_time.max())
+        )
+
+    return times
+
+
+def _check_estimate_2d(estimate, test_time, time_points, estimator):
+    estimate = sklearn.utils.check_array(
+        estimate,
+        ensure_2d=False,
+        allow_nd=False,
+        input_name="estimate",  # pyright: ignore
+        estimator=estimator,
+    )
+    time_points = _check_times(test_time, time_points)
+    sklearn.utils.check_consistent_length(test_time, estimate)
+
+    if estimate.ndim == 2 and estimate.shape[1] != time_points.shape[0]:
+        raise ValueError(
+            "expected estimate with {} columns, but got {}".format(time_points.shape[0], estimate.shape[1])
+        )
+
+    return estimate, time_points
+
+
+def _compute_counts(
+    event: np.ndarray, time: np.ndarray, order: Optional[np.ndarray] = None
+) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+    """Count right censored and uncensored samples at each unique time point.
+
+    Args:
+        event (np.ndarray):
+            Boolean event indicator.
+        time (np.ndarray):
+            Survival time or time of censoring.
+        order (Optional[np.ndarray]):
+            Indices to order time in ascending order.
+            If None, order will be computed.
+
+    Returns:
+        Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+            Tuple like ``(times, n_events, n_at_risk, n_censored)``
+                * times (np.ndarray): Unique time points.
+                * n_events (np.ndarray): Number of events at each time point.
+                * n_at_risk (np.ndarray): Number of samples that have not been censored or have not had an event at
+                each time point.
+                * n_censored (np.ndarray): Number of censored samples at each time point.
+    """
+    n_samples = event.shape[0]
+
+    if order is None:
+        order = np.argsort(time, kind="mergesort")
+
+    uniq_times = np.empty(n_samples, dtype=time.dtype)
+    uniq_events = np.empty(n_samples, dtype=int)
+    uniq_counts = np.empty(n_samples, dtype=int)
+
+    i = 0
+    prev_val = time[order[0]]
+    j = 0
+    while True:
+        count_event = 0
+        count = 0
+        while i < n_samples and prev_val == time[order[i]]:
+            if event[order[i]]:
+                count_event += 1
+
+            count += 1
+            i += 1
+
+        uniq_times[j] = prev_val
+        uniq_events[j] = count_event
+        uniq_counts[j] = count
+        j += 1
+
+        if i == n_samples:
+            break
+
+        prev_val = time[order[i]]
+
+    times = np.resize(uniq_times, j)
+    n_events = np.resize(uniq_events, j)
+    total_count = np.resize(uniq_counts, j)
+    n_censored = total_count - n_events
+
+    # Offset cumulative sum by one.
+    total_count = np.r_[0, total_count]
+    n_at_risk = n_samples - np.cumsum(total_count)
+
+    return times, n_events, n_at_risk[:-1], n_censored
+
+
+def _compute_counts_truncated(
+    event: np.ndarray, time_enter: np.ndarray, time_exit: np.ndarray
+) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """Compute counts for left truncated and right censored survival data.
+
+    Args:
+        event (np.ndarray):
+            Boolean event indicator.
+        time_start (np.ndarray):
+            Time when a subject entered the study.
+        time_exit (np.ndarray):
+            Time when a subject left the study due to an
+            event or censoring.
+
+    Returns:
+        Tuple[np.ndarray, np.ndarray, np.ndarray]:
+            Tuple like ``(times, n_events, n_at_risk)``.
+                * times (np.ndarray): Unique time points.
+                * n_events (np.ndarray): Number of events at each time point.
+                * n_at_risk (np.ndarray): Number of samples that are censored or have an event at each time point.
+    """
+    if (time_enter > time_exit).any():
+        raise ValueError("exit time must be larger start time for all samples")
+
+    n_samples = event.shape[0]
+
+    uniq_times = np.sort(np.unique(np.r_[time_enter, time_exit]), kind="mergesort")
+    total_counts = np.empty(len(uniq_times), dtype=int)
+    event_counts = np.empty(len(uniq_times), dtype=int)
+
+    order_enter = np.argsort(time_enter, kind="mergesort")
+    order_exit = np.argsort(time_exit, kind="mergesort")
+    s_time_enter = time_enter[order_enter]
+    s_time_exit = time_exit[order_exit]
+
+    t0 = uniq_times[0]
+    # Everything larger is included:
+    idx_enter = np.searchsorted(s_time_enter, t0, side="right")
+    # Everything smaller is excluded.
+    idx_exit = np.searchsorted(s_time_exit, t0, side="left")
+
+    total_counts[0] = idx_enter
+    # Except people die on the day they enter.
+    event_counts[0] = 0
+
+    for i in range(1, len(uniq_times)):
+        ti = uniq_times[i]
+
+        while idx_enter < n_samples and s_time_enter[idx_enter] <= ti:
+            idx_enter += 1
+
+        while idx_exit < n_samples and s_time_exit[idx_exit] < ti:
+            idx_exit += 1
+
+        risk_set = np.setdiff1d(order_enter[:idx_enter], order_exit[:idx_exit], assume_unique=True)
+        total_counts[i] = len(risk_set)
+
+        count_event = 0
+        k = idx_exit
+        while k < n_samples and s_time_exit[k] == ti:
+            if event[order_exit[k]]:
+                count_event += 1
+            k += 1
+        event_counts[i] = count_event
+
+    return uniq_times, event_counts, total_counts
+
+
+def kaplan_meier_estimator(
+    event: Any,
+    time_exit: Any,
+    time_enter: Any = None,
+    time_min: Optional[float] = None,
+    reverse: Optional[bool] = False,
+) -> Tuple[np.ndarray, np.ndarray]:
+    """Kaplan-Meier estimator of survival function. See [1] for further description.
+
+    Args:
+        event:
+            Array-like, ``shape = (n_samples,)``. Contains binary event indicators.
+        time_exit:
+            Array-like, ``shape = (n_samples,)``. Contains event/censoring times.
+        time_enter:
+            Array-like, ``shape = (n_samples,)``, optional. Contains time when each individual entered the study for
+            left truncated survival data. Defaults to `None`.
+        time_min (float, optional):
+            Compute estimator conditional on survival at least up to the specified time. Defaults to `None`.
+        reverse (bool, optional).
+            Whether to estimate the censoring distribution. When there are ties between times at which events are
+            observed, then events come first and are subtracted from the denominator. Only available for
+            right-censored data, i.e. ``time_enter`` must be None. Defaults to `False`.
+
+    Returns:
+        Tuple[np.ndarray, np.ndarray]:
+            Tuple like ``(time, prob_survival)``.
+                * time: ``np.ndarray, shape = (n_times,)`` Unique times.
+                * prob_survival:
+                    ``np.ndarray, shape = (n_times,)``. Survival probability at each unique time point.
+                    If ``time_enter`` is provided, estimates are conditional probabilities.
+
+    References:
+        [1] Kaplan, E. L. and Meier, P., "Nonparametric estimation from incomplete observations", Journal of The
+        American Statistical Association, vol. 53, pp. 457-481, 1958.
+    """
+    event, time_enter, time_exit = check_y_survival(  # pylint: disable=unbalanced-tuple-unpacking
+        event, time_enter, time_exit, allow_all_censored=True
+    )
+    sklearn.utils.check_consistent_length(event, time_enter, time_exit)
+
+    if time_enter is None:
+        uniq_times, n_events, n_at_risk, n_censored = _compute_counts(event, time_exit)
+
+        if reverse:
+            n_at_risk -= n_events
+            n_events = n_censored
+    else:
+        if reverse:
+            raise ValueError("The censoring distribution cannot be estimated from left truncated data")
+
+        uniq_times, n_events, n_at_risk = _compute_counts_truncated(event, time_enter, time_exit)
+
+    # account for 0/0 = nan
+    ratio = np.divide(
+        n_events,
+        n_at_risk,
+        out=np.zeros(uniq_times.shape[0], dtype=float),
+        where=n_events != 0,
+    )
+    values = 1.0 - ratio
+
+    if time_min is not None:
+        mask = uniq_times >= time_min
+        uniq_times = np.compress(mask, uniq_times)
+        values = np.compress(mask, values)
+
+    y = np.cumprod(values)
+    return uniq_times, y
+
+
+class SurvivalFunctionEstimator(sklearn.base.BaseEstimator):
+    """Kaplan-Meier estimate of the survival function."""
+
+    def __init__(self):
+        pass
+
+    def fit(self, y: np.ndarray) -> Self:
+        """Estimate survival distribution from training data.
+
+        Args:
+            y (np.ndarray):
+                Structured array, ``shape = (n_samples,)``. A structured array containing the binary event indicator
+                as first field, and time of event or time of censoring as second field.
+
+        Returns:
+            Self.
+        """
+        event, time = check_y_survival(y, allow_all_censored=True)  # pylint: disable=unbalanced-tuple-unpacking
+
+        unique_time, prob = kaplan_meier_estimator(event, time)
+        self.unique_time_ = np.r_[-np.infty, unique_time]  # pylint: disable=attribute-defined-outside-init
+        self.prob_ = np.r_[1.0, prob]  # pylint: disable=attribute-defined-outside-init
+
+        return self
+
+    def predict_proba(self, time: np.ndarray) -> np.ndarray:
+        r"""Return probability of an event after given time point.  :math:`\hat{S}(t) = P(T > t)`.
+
+        Args:
+            time (np.ndarray):
+                Array, ``shape = (n_samples,)``. Time to estimate probability at.
+
+        Returns:
+            np.ndarray: ``shape = (n_samples,)``. Probability of an event.
+        """
+        sklearn.utils.validation.check_is_fitted(self, "unique_time_")
+        time = sklearn.utils.check_array(
+            time,
+            ensure_2d=False,
+            estimator=self,
+            input_name="time",  # pyright: ignore
+        )
+
+        # K-M is undefined if estimate at last time point is non-zero.
+        extends = time > self.unique_time_[-1]
+        if self.prob_[-1] > 0 and extends.any():
+            raise ValueError(
+                "time must be smaller than largest " "observed time point: {}".format(self.unique_time_[-1])
+            )
+
+        # Beyond last time point is zero probability.
+        Shat = np.empty(time.shape, dtype=float)
+        Shat[extends] = 0.0
+
+        valid = ~extends
+        time = time[valid]
+        idx = np.searchsorted(self.unique_time_, time)
+        # For non-exact matches, we need to shift the index to left.
+        eps = np.finfo(self.unique_time_.dtype).eps
+        exact = np.absolute(self.unique_time_[idx] - time) < eps
+        idx[~exact] -= 1
+        Shat[valid] = self.prob_[idx]
+
+        return Shat
+
+
+class CensoringDistributionEstimator(SurvivalFunctionEstimator):
+    """Kaplan-Meier estimator for the censoring distribution."""
+
+    def fit(self, y: np.ndarray):
+        """Estimate censoring distribution from training data.
+
+        Args:
+            y (np.ndarray):
+                Structured array, ``shape = (n_samples,)``. A structured array containing the binary event indicator
+                as first field, and time of event or time of censoring as second field.
+
+        Returns:
+            Self.
+        """
+        event, time = check_y_survival(y)  # pylint: disable=unbalanced-tuple-unpacking
+        if event.all():
+            self.unique_time_ = np.unique(time)  # pylint: disable=attribute-defined-outside-init
+            self.prob_ = np.ones(self.unique_time_.shape[0])  # pylint: disable=attribute-defined-outside-init
+        else:
+            unique_time, prob = kaplan_meier_estimator(event, time, reverse=True)
+            self.unique_time_ = np.r_[-np.infty, unique_time]  # pylint: disable=attribute-defined-outside-init
+            self.prob_ = np.r_[1.0, prob]  # pylint: disable=attribute-defined-outside-init
+
+        return self
+
+    def predict_ipcw(self, y: np.ndarray) -> np.ndarray:
+        r"""Return inverse probability of censoring weights at given time points.
+        :math:`\omega_i = \delta_i / \hat{G}(y_i)`
+
+        Args:
+            y (np.ndarray):
+                Structured array, ``shape = (n_samples,)``. A structured array containing the binary event indicator
+                as first field, and time of event or time of censoring as second field.
+
+        Returns:
+            np.ndarray:
+                IPCW, ``array, shape = (n_samples,)``. Inverse probability of censoring weights.
+        """
+        event, time = check_y_survival(y)  # pylint: disable=unbalanced-tuple-unpacking
+        Ghat = self.predict_proba(time[event])
+
+        if (Ghat == 0.0).any():
+            raise ValueError("censoring survival function is zero at one or more time points")
+
+        weights = np.zeros(time.shape[0])
+        weights[event] = 1.0 / Ghat
+
+        return weights
+
+
+def _get_comparable(event_indicator, event_time, order):
+    n_samples = len(event_time)
+    tied_time = 0
+    comparable = {}
+    i = 0
+    while i < n_samples - 1:
+        time_i = event_time[order[i]]
+        start = i + 1
+        end = start
+        while end < n_samples and event_time[order[end]] == time_i:
+            end += 1
+
+        # check for tied event times
+        event_at_same_time = event_indicator[order[i:end]]
+        censored_at_same_time = ~event_at_same_time
+        for j in range(i, end):
+            if event_indicator[order[j]]:
+                mask = np.zeros(n_samples, dtype=bool)
+                mask[end:] = True
+                # an event is comparable to censored samples at same time point
+                mask[i:end] = censored_at_same_time
+                comparable[j] = mask
+                tied_time += censored_at_same_time.sum()
+        i = end
+
+    return comparable, tied_time
+
+
+def _estimate_concordance_index(
+    event_indicator, event_time, estimate, weights, tied_tol=1e-8
+) -> Tuple[float, int, int, int, int]:
+    order = np.argsort(event_time)
+
+    comparable, tied_time = _get_comparable(event_indicator, event_time, order)
+
+    if len(comparable) == 0:
+        raise ValueError("Data has no comparable pairs, cannot estimate concordance index.")
+
+    concordant = 0
+    discordant = 0
+    tied_risk = 0
+    numerator = 0.0
+    denominator = 0.0
+    for ind, mask in comparable.items():
+        est_i = estimate[order[ind]]
+        event_i = event_indicator[order[ind]]
+        w_i = weights[order[ind]]
+
+        est = estimate[order[mask]]
+
+        if not event_i:
+            raise RuntimeError("got censored sample at index %d, but expected uncensored" % order[ind])
+
+        ties = np.absolute(est - est_i) <= tied_tol
+        n_ties = ties.sum()
+        # an event should have a higher score
+        con = est < est_i
+        n_con = con[~ties].sum()
+
+        numerator += w_i * n_con + 0.5 * w_i * n_ties
+        denominator += w_i * mask.sum()
+
+        tied_risk += n_ties
+        concordant += n_con
+        discordant += est.size - n_con - n_ties
+
+    cindex = numerator / denominator
+    return cindex, concordant, discordant, tied_risk, tied_time
+
+
+def create_structured_array(
+    event: Any,
+    time: Any,
+    name_event: Optional[str] = None,
+    name_time: Optional[str] = None,
+) -> np.ndarray:
+    """Create structured array.
+
+    Args:
+        event (Any):
+            Array-like. Event indicator. A boolean array or array with values 0/1.
+        time (Any):
+            Array-like. Observed time.
+        name_event (Optional[str]):
+            Name of event, optional. Defaults to "event".
+        name_time (Optional[str]):
+            Name of observed time, optional. Defaults to "time".
+
+    Returns:
+        np.ndarray:
+            Structured array with two fields.
+    """
+    name_event = name_event or "event"
+    name_time = name_time or "time"
+    if name_time == name_event:
+        raise ValueError("name_time must be different from name_event")
+
+    time = np.asanyarray(time, dtype=float)
+    y = np.empty(time.shape[0], dtype=[(name_event, bool), (name_time, float)])
+    y[name_time] = time
+
+    event = np.asanyarray(event)
+    sklearn.utils.check_consistent_length(time, event)
+
+    if np.issubdtype(event.dtype, np.bool_):
+        y[name_event] = event
+    else:
+        events = np.unique(event)
+        events.sort()
+        if len(events) != 2:
+            raise ValueError("event indicator must be binary")
+
+        if np.all(events == np.array([0, 1], dtype=events.dtype)):
+            y[name_event] = event.astype(bool)
+        else:
+            raise ValueError("non-boolean event indicator must contain 0 and 1 only")
+
+    return y
+
+
+# --- Metrics. ---
+
+
+def brier_score(
+    survival_train: np.ndarray, survival_test: np.ndarray, estimate: Any, times: Any
+) -> Tuple[np.ndarray, np.ndarray]:
+    r"""Estimate the time-dependent Brier score for right censored data.
+
+    The time-dependent Brier score is the mean squared error at time point :math:`t`:
+
+    .. math::
+        \mathrm{BS}^c(t) = \frac{1}{n} \sum_{i=1}^n I(y_i \leq t \land \delta_i = 1)
+        \frac{(0 - \hat{\pi}(t | \mathbf{x}_i))^2}{\hat{G}(y_i)} + I(y_i > t)
+        \frac{(1 - \hat{\pi}(t | \mathbf{x}_i))^2}{\hat{G}(t)} ,
+
+    where :math:`\hat{\pi}(t | \mathbf{x})` is the predicted probability of remaining event-free up to time point
+    :math:`t` for a feature vector :math:`\mathbf{x}`, and :math:`1/\hat{G}(t)` is a inverse probability of
+    censoring weight, estimated by the Kaplan-Meier estimator.
+
+    See [1] for details.
+
+    Args:
+        survival_train (np.ndarray):
+            Structured array, ``shape = (n_train_samples,)``.
+            Survival times for training data to estimate the censoring distribution from. A structured array containing
+            the binary event indicator as first field, and time of event or time of censoring as second field.
+        survival_test (np.ndarray):
+            Structured array, ``shape = (n_samples,)``.
+            Survival times of test data. A structured array containing the binary event indicator as first field, and
+            time of event or time of censoring as second field.
+        estimate (Any):
+            Array-like, ``shape = (n_samples, n_times)``.
+            Estimated risk of experiencing an event for test data at `times`. The i-th column must contain the
+            estimated probability of remaining event-free up to the i-th time point.
+        times (Any):
+            Array-like, ``shape = (n_times,)``.
+            The time points for which to estimate the Brier score. Values must be within the range of follow-up times
+            of the test data `survival_test`.
+
+    Returns:
+        Tuple[np.ndarray, np.ndarray]:
+            Tuple like ``(times, bier_scores)``.
+                * times: ``np.ndarray, shape=(n_times,)``. Unique time points at which the brier scores was estimated.
+                * brier_scores: ``np.ndarray , shape=(n_times,)``. Values of the brier score.
+
+    References:
+        [1] E. Graf, C. Schmoor, W. Sauerbrei, and M. Schumacher, "Assessment and comparison of prognostic
+        classification schemes for survival data," Statistics in Medicine, vol. 18, no. 17-18, pp. 2529-2545, 1999.
+    """
+    test_event, test_time = check_y_survival(survival_test)  # pylint: disable=unbalanced-tuple-unpacking
+    estimate, times = _check_estimate_2d(estimate, test_time, times, estimator="brier_score")
+    if estimate.ndim == 1 and times.shape[0] == 1:
+        estimate = estimate.reshape(-1, 1)
+
+    # Fit IPCW estimator:
+    cens = CensoringDistributionEstimator().fit(survival_train)
+
+    # Calculate inverse probability of censoring weight at current time point t.
+    prob_cens_t = cens.predict_proba(times)
+    prob_cens_t[prob_cens_t == 0] = np.inf
+    # Calculate inverse probability of censoring weights at observed time point.
+    prob_cens_y = cens.predict_proba(test_time)
+    prob_cens_y[prob_cens_y == 0] = np.inf
+
+    # Calculating the brier scores at each time point:
+    brier_scores = np.empty(times.shape[0], dtype=float)
+    for i, t in enumerate(times):
+        est = estimate[:, i]
+        is_case = (test_time <= t) & test_event
+        is_control = test_time > t
+
+        brier_scores[i] = np.mean(
+            np.square(est) * is_case.astype(int) / prob_cens_y
+            + np.square(1.0 - est) * is_control.astype(int) / prob_cens_t[i]
+        )
+
+    return times, brier_scores
+
+
+def concordance_index_ipcw(
+    survival_train: np.ndarray,
+    survival_test: np.ndarray,
+    estimate: Any,
+    tau: Optional[float] = None,
+    tied_tol: float = 1e-8,
+) -> Tuple[float, int, int, int, int]:
+    r"""Concordance index for right-censored data based on inverse probability of censoring weights.
+
+    This is an alternative to the estimator in ``concordance_index_censored`` that does not depend on the distribution
+    of censoring times in the test data. Therefore, the estimate is unbiased and consistent for a population
+    concordance measure that is free of censoring.
+
+    It is based on inverse probability of censoring weights, thus requires access to survival times from the
+    training data to estimate the censoring distribution. Note that this requires that survival times ``survival_test``
+    lie within the range of survival times `survival_train`. This can be achieved by specifying the truncation time
+    ``tau``. The resulting ``cindex`` tells how well the given prediction model works in predicting events that occur in
+    the time range from 0 to ``tau``.
+
+    The estimator uses the Kaplan-Meier estimator to estimate the censoring survivor function. Therefore,
+    it is restricted to situations where the random censoring assumption holds and censoring is independent of the
+    features.
+
+    See [1] for further description.
+
+    Args:
+        survival_train (np.ndarray):
+            Structured array, ``shape = (n_train_samples,)``.
+            Survival times for training data to estimate the censoring distribution from a structured array containing
+            the binary event indicator as first field, and time of event or time of censoring as second field.
+        survival_test (np.ndarray):
+            Structured array, ``shape = (n_samples,)``.
+            Survival times of test data. A structured array containing the binary event indicator as first field,
+            and time of event or time of censoring as second field.
+        estimate:
+            Array-like, ``shape = (n_samples,)``. Estimated risk of experiencing an event of test data.
+        tau (float, optional):
+            Truncation time. The survival function for the underlying censoring time distribution :math:`D` needs to be
+            positive at ``tau``, i.e., ``tau`` should be chosen such that the probability of being censored after time
+            ``tau`` is non-zero: :math:`P(D > \tau) > 0`. If `None`, no truncation is performed.
+        tied_tol (float, optional).
+            The tolerance value for considering ties. If the absolute difference between risk scores is smaller or
+            equal than ``tied_tol``, risk scores are considered tied. Defaults to ``1e-8``.
+
+    Returns:
+        Tuple[float, int, int, int, int]:
+            Tuple like ``(cindex, concordant, discordant, tied_risk, tied_time)``.
+                * cindex (float): Concordance index.
+                * concordant (int): Number of concordant pairs.
+                * discordant (int): Number of discordant pairs.
+                * tied_risk (int): Number of pairs having tied estimated risks.
+                * tied_time (int): Number of comparable pairs sharing the same time.
+
+    References:
+        [1] Uno, H., Cai, T., Pencina, M. J., D'Agostino, R. B., & Wei, L. J. (2011). "On the C-statistics for
+        evaluating overall adequacy of risk prediction procedures with censored survival data". Statistics in Medicine,
+        30(10), 1105-1117.
+    """
+    test_event, test_time = check_y_survival(survival_test)  # pylint: disable=unbalanced-tuple-unpacking
+
+    if tau is not None:
+        mask = test_time < tau
+        survival_test = survival_test[mask]
+
+    estimate = _check_estimate_1d(estimate, test_time)
+
+    cens = CensoringDistributionEstimator()
+    cens.fit(survival_train)
+    ipcw_test = cens.predict_ipcw(survival_test)
+    if tau is None:
+        ipcw = ipcw_test
+    else:
+        ipcw = np.empty(estimate.shape[0], dtype=ipcw_test.dtype)
+        ipcw[mask] = ipcw_test  # pyright: ignore
+        ipcw[~mask] = 0  # pyright: ignore
+
+    w = np.square(ipcw)
+
+    return _estimate_concordance_index(test_event, test_time, estimate, w, tied_tol)
diff --git a/src/tempor/core/__init__.py b/src/tempor/core/__init__.py
index 8484ffbc..e69de29b 100644
--- a/src/tempor/core/__init__.py
+++ b/src/tempor/core/__init__.py
@@ -1,7 +0,0 @@
-from . import supports_impl
-from ._register_method_decorator import RegisterMethodDecorator
-
-__all__ = [
-    "RegisterMethodDecorator",
-    "supports_impl",
-]
diff --git a/src/tempor/core/_register_method_decorator.py b/src/tempor/core/_register_method_decorator.py
deleted file mode 100644
index 07c4193b..00000000
--- a/src/tempor/core/_register_method_decorator.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from typing import Any, Callable, Dict
-
-from tempor.log import logger
-
-
-class RegisterMethodDecorator:
-    # Inspired by: https://stackoverflow.com/a/54316392
-
-    _registering_key: Any = None
-    _registering_dict: Dict = {}
-
-    owner_class: type = None  # type: ignore
-    registration_dict_attribute_name: str = None  # type: ignore
-    key_type: type = None  # type: ignore
-    method_category_name: str = None  # type: ignore
-
-    class _Decorator:
-        # The following attributes are set by RegisterMethodDecorator.register() so that this class has access to them
-        # in the __set_name__() and __init__() methods.
-        registering_key: Any = None
-        registering_dict: Dict = {}
-        owner_class: type = None  # type: ignore
-        registration_dict_attribute_name: str = None  # type: ignore
-        key_type: type = None  # type: ignore
-        method_category_name: str = None  # type: ignore
-
-        def __init__(self, method: Callable):
-            # Called when each decorator is initialized.
-
-            try:
-                logger.trace(
-                    f"Recording {self.method_category_name} method {method} for {self.key_type} "
-                    f"{self.registering_key}"
-                )
-                if self.registering_key in self.registering_dict:
-                    raise TypeError(
-                        f"{self.method_category_name.capitalize()} method to handle {self.key_type} "
-                        f"{self.registering_dict} registered multiple times"
-                    )
-                self.registering_dict[self.registering_key] = method
-
-            except Exception as ex:
-                self.registering_dict.clear()
-                raise ex
-
-        def __set_name__(self, owner, _):
-            # Called only once per `ValidationImplementation` derived class. `owner` contains the class.
-
-            try:
-                logger.trace(f"Registering {self.method_category_name} methods for class {owner}")
-
-                if not issubclass(owner, self.owner_class):
-                    raise TypeError(f"Decorator expected to be used in a subclass of {self.owner_class.__name__}")
-
-                # Copy registration_dict_attribute from parent class if parent class is a owner_class type.
-                # Do not copy if a particular method has been defined (overridden) on the class.
-                relevant_bases = [
-                    b
-                    for b in owner.mro()
-                    if issubclass(b, self.owner_class)
-                    and b != owner
-                    and hasattr(b, self.registration_dict_attribute_name)
-                ]
-                if relevant_bases:
-                    base = relevant_bases[0]
-                    setattr(
-                        owner,
-                        self.registration_dict_attribute_name,
-                        {
-                            k: v
-                            for k, v in getattr(base, self.registration_dict_attribute_name).items()
-                            if k not in self.registering_dict
-                        },
-                    )
-                else:
-                    setattr(owner, self.registration_dict_attribute_name, dict())
-
-                for type_, method in self.registering_dict.items():
-                    logger.trace(f"Registering validation method {method}")
-                    reg_dict = getattr(owner, self.registration_dict_attribute_name)
-                    reg_dict[type_] = method
-
-            finally:
-                self.registering_dict.clear()
-
-    @classmethod
-    def register_method_for(cls, registering_key):
-        cls._registering_key = registering_key
-
-        if cls.owner_class is None:
-            raise TypeError("Must set `owner_class`")
-        if cls.registration_dict_attribute_name is None:
-            raise TypeError("Must set `registration_dict_attribute_name`")
-        if cls.key_type is None:
-            raise TypeError("Must set `key_type`")
-        if cls.method_category_name is None:
-            raise TypeError("Must set `method_category_name`")
-
-        cls._Decorator.registering_key = cls._registering_key
-        cls._Decorator.registering_dict = cls._registering_dict
-        cls._Decorator.owner_class = cls.owner_class
-        cls._Decorator.registration_dict_attribute_name = cls.registration_dict_attribute_name
-        cls._Decorator.key_type = cls.key_type
-        cls._Decorator.method_category_name = cls.method_category_name
-
-        return cls._Decorator
diff --git a/src/tempor/core/_supports_impl.py b/src/tempor/core/_supports_impl.py
deleted file mode 100644
index 47666dfb..00000000
--- a/src/tempor/core/_supports_impl.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import abc
-from typing import Dict, Generic, Tuple, TypeVar
-
-SupportsT = TypeVar("SupportsT")
-ImplementationT = TypeVar("ImplementationT")
-
-
-class SupportsImplementations(Generic[SupportsT, ImplementationT], abc.ABC):
-    def __init__(self) -> None:
-        super().__init__()
-        self._implementations: Dict[SupportsT, ImplementationT] = self._register_implementations()
-        registered = set(self._implementations.keys())
-        supported = set(self.supports_implementations_for)
-        if registered != supported:
-            raise TypeError(
-                f"{self.__class__.__name__}: Expected implementations to be registered for the following supported "
-                f"item(s) {list(supported)} but found registered item(s) {list(registered)}"
-            )
-
-    def dispatch_to_implementation(self, key: SupportsT) -> ImplementationT:
-        if key not in self._implementations:
-            raise TypeError(
-                f"Implementation for `{key}` has not been registered on `{self.__class__}`, "
-                f"only implementations the following have been registered {list(self._implementations.keys())}"
-            )
-        return self._implementations[key]
-
-    @property
-    @abc.abstractmethod
-    def supports_implementations_for(self) -> Tuple[SupportsT, ...]:  # pragma: no cover
-        ...
-
-    @abc.abstractmethod
-    def _register_implementations(self) -> Dict[SupportsT, ImplementationT]:  # pragma: no cover
-        ...
-
-    def __str__(self) -> str:
-        # For clarity.
-        return f"{self.__class__.__name__}(_implementations={self._implementations})"
diff --git a/src/tempor/core/requirements.py b/src/tempor/core/requirements.py
deleted file mode 100644
index fa9895c1..00000000
--- a/src/tempor/core/requirements.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import abc
-import enum
-from typing import Any, ClassVar, Sequence, get_type_hints
-
-import pydantic
-import rich.pretty
-
-
-class RequirementCategory(enum.Enum):
-    DATA_CONTAINER = enum.auto()
-    DATA_BUNDLE = enum.auto()
-    MODEL = enum.auto()
-
-
-class Requirement(abc.ABC):
-    definition: Any
-
-    name: ClassVar[str] = None  # type: ignore
-    validator_methods: ClassVar[Sequence[Any]] = tuple()
-
-    def __init__(self, definition) -> None:
-        validator = pydantic.create_model(
-            f"{self.__class__.__name__}Validator",
-            __validators__={clsmtd.__func__.__name__: clsmtd for clsmtd in self.validator_methods},  # type: ignore
-            name=(str, ...),
-            definition=(get_type_hints(self)["definition"], ...),
-        )
-        validator(name=self.name, definition=definition)
-        self.definition = definition
-
-    @property
-    @abc.abstractmethod
-    def requirement_category(self) -> RequirementCategory:  # pragma: no cover
-        ...
-
-    def __rich_repr__(self):
-        yield "definition", self.definition
-
-    def __repr__(self) -> str:
-        return rich.pretty.pretty_repr(self)
-
-
-class RequirementValidator(abc.ABC):
-    @property
-    @abc.abstractmethod
-    def supported_requirement_category(self) -> RequirementCategory:  # pragma: no cover
-        ...
-
-    def validate(self, target: Any, *, requirements: Sequence[Requirement], **kwargs):  # pragma: no cover
-        for r in requirements:
-            if r.requirement_category != self.supported_requirement_category:
-                raise ValueError(
-                    f"{self.__class__.__name__} does not support requirement category {r.requirement_category}"
-                )
-        self._validate(target=target, requirements=requirements, **kwargs)
-
-    @abc.abstractmethod
-    def _validate(self, *args, **kwargs):  # pragma: no cover
-        ...
diff --git a/src/tempor/core/supports_impl.py b/src/tempor/core/supports_impl.py
deleted file mode 100644
index af6f4237..00000000
--- a/src/tempor/core/supports_impl.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from ._supports_impl import *  # noqa  # pylint: disable=W
diff --git a/src/tempor/core/utils.py b/src/tempor/core/utils.py
index 9eac6acc..fc46a833 100644
--- a/src/tempor/core/utils.py
+++ b/src/tempor/core/utils.py
@@ -1,4 +1,5 @@
 import enum
+from typing import Tuple
 
 
 def get_class_full_name(o: object):
@@ -29,3 +30,19 @@ def is_iterable(o: object) -> bool:
     except TypeError:
         is_iterable_ = False
     return is_iterable_
+
+
+def get_version(version: str) -> Tuple[int, ...]:
+    """Get the semantic ``version`` as a tuple of ``int`` s.
+
+    Note:
+        Assumes that the ``version`` string is specified as ``.``-separated ``ints``; will throw exceptions in
+        case of more complex version semantics.
+
+    Args:
+        module (ModuleType): The module to get the version of.
+
+    Returns:
+        Tuple[int, ...]: Tuple of integers representing ``(major, minor, patch[, ...])``.
+    """
+    return tuple(int(v) for v in version.split("."))
diff --git a/src/tempor/data/data_typing.py b/src/tempor/data/data_typing.py
index 6f2b73d1..c685b066 100644
--- a/src/tempor/data/data_typing.py
+++ b/src/tempor/data/data_typing.py
@@ -55,7 +55,11 @@ class DataModality(enum.Enum):
         Tuple[str, pd.Timestamp],
     ]
 ]
-TimeIndexList = List[TimeIndex]
+TimeIndexList = Union[
+    List[List[float]],
+    List[List[int]],
+    List[List[pd.Timestamp]],
+]
 
 
 GetItemKey = Union[int, Iterable[int], slice]
diff --git a/src/tempor/data/dataloader.py b/src/tempor/data/dataloader.py
new file mode 100644
index 00000000..5e23cd7f
--- /dev/null
+++ b/src/tempor/data/dataloader.py
@@ -0,0 +1,144 @@
+import abc
+import os
+from typing import ClassVar, Optional, Type  # pylint: disable=unused-import
+
+import tempor
+
+from . import data_typing, dataset
+
+DATA_DIR = "data"
+"""The subdirectory on the user's system where all data source files will be stored.
+
+The full directory will be ``< tempor -> config -> working_directory > / DATA_DIR``.
+"""
+
+
+# TODO: Unit test.
+
+
+class DataLoader(abc.ABC):
+    """`DataLoader` class to ``load`` a `~tempor.data.dataset.DataSet`."""
+
+    data_root_dir: ClassVar[str] = os.path.join(tempor.get_config().get_working_dir(), DATA_DIR)
+    """The automatically determined root directory for data on the user's system.
+    It will be ``< tempor -> config -> working_directory > / data``.
+    """
+
+    def __init__(self, *args, **kwargs) -> None:  # pylint: disable=unused-argument
+        """Initializer for `DataLoader`.
+
+        Args:
+            dataset_dir (Optional[str]):
+                Pass in the subdirectory within ``data_root_dir`` where the data source files will be stored,
+                if relevant.
+        """
+        os.makedirs(self.data_root_dir, exist_ok=True)
+        dataset_dir = self.dataset_dir()
+        if dataset_dir is not None:
+            os.makedirs(dataset_dir, exist_ok=True)
+
+    @staticmethod
+    @abc.abstractmethod
+    def dataset_dir() -> Optional[str]:  # pragma: no cover
+        """The path to the directory where the data file(s) will be stored, if relevant.
+        If the data loader has no data files, return `None`
+
+        Note:
+            the path should correspond to a subdirectory within ``data_root_dir``.
+
+        Returns:
+            Optional[str]: The path of the directory where the data file(s) will be stored, if relevant, else `None`.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    @staticmethod
+    @abc.abstractmethod
+    def url() -> Optional[str]:  # pragma: no cover
+        """Define the URL of the data source, if relevant, otherwise return `None`.
+
+        Returns:
+            Optional[str]: The URL of the data source, if relevant, otherwise `None`.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    @classmethod
+    def requires_internet(cls: "Type[DataLoader]") -> bool:
+        """A `classmethod` that returns true if the `DataLoader` requires access to the Internet to `load`
+        (at least before it is saved locally).
+
+        Returns `True` if `cls.url()` is not `None`, else returns `False`.
+
+        Returns:
+            bool: Whether the `DataLoader` requires Internet access to `load`.
+        """
+        return cls.url() is not None
+
+    @property
+    @abc.abstractmethod
+    def predictive_task(self) -> data_typing.PredictiveTask:  # pragma: no cover
+        """The expected predictive task of the loaded `~tempor.data.dataset.DataSet`.
+
+        Returns:
+            data_typing.PredictiveTask: Predictive task of loaded `~tempor.data.dataset.DataSet`.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.Dataset:  # pragma: no cover
+        """The method that should return the loaded `~tempor.data.dataset.DataSet` for the appropriate
+        ``predictive_task``.
+
+        Returns:
+            dataset.Dataset: The loaded `~tempor.data.dataset.DataSet`.
+        """
+        ...  # pylint: disable=unnecessary-ellipsis
+
+
+class OneOffPredictionDataLoader(DataLoader):
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.ONE_OFF_PREDICTION
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.OneOffPredictionDataset:  # pragma: no cover
+        ...
+
+
+class TemporalPredictionDataLoader(DataLoader):
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TEMPORAL_PREDICTION
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.TemporalPredictionDataset:  # pragma: no cover
+        ...
+
+
+class TimeToEventAnalysisDataLoader(DataLoader):
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TIME_TO_EVENT_ANALYSIS
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.TimeToEventAnalysisDataset:  # pragma: no cover
+        ...
+
+
+class OneOffTreatmentEffectsDataLoader(DataLoader):
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.ONE_OFF_TREATMENT_EFFECTS
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.OneOffTreatmentEffectsDataset:  # pragma: no cover
+        ...
+
+
+class TemporalTreatmentEffectsDataLoader(DataLoader):
+    @property
+    def predictive_task(self) -> data_typing.PredictiveTask:
+        return data_typing.PredictiveTask.TEMPORAL_TREATMENT_EFFECTS
+
+    @abc.abstractmethod
+    def load(self, **kwargs) -> dataset.TemporalTreatmentEffectsDataset:  # pragma: no cover
+        ...
diff --git a/src/tempor/data/dataset.py b/src/tempor/data/dataset.py
index c0433fdb..fa75d194 100644
--- a/src/tempor/data/dataset.py
+++ b/src/tempor/data/dataset.py
@@ -224,8 +224,8 @@ def split(
 
         Example:
             >>> from sklearn.model_selection import KFold
-            >>> from tempor.utils.datasets.sine import SineDataloader
-            >>> data = SineDataloader().load()
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
+            >>> data = SineDataLoader().load()
             >>> kfold = KFold(n_splits=5)
             >>> len([(data_train, data_test) for (data_train, data_test) in data.split(splitter=kfold)])
             5
diff --git a/src/tempor/data/pandera_utils.py b/src/tempor/data/pandera_utils.py
index 96e1123e..7fcb41f1 100644
--- a/src/tempor/data/pandera_utils.py
+++ b/src/tempor/data/pandera_utils.py
@@ -4,11 +4,12 @@
 import pandas as pd
 import pandera as pa
 
+import tempor.core.utils
 from tempor.log import logger
 
 from . import data_typing
 
-pa_major, pa_minor, *_ = [int(v) for v in pa.__version__.split(".")]
+pa_major, pa_minor, *_ = tempor.core.utils.get_version(pa.__version__)
 
 
 _PA_DF_SCHEMA_INIT_PARAMETERS = [
diff --git a/src/tempor/data/samples.py b/src/tempor/data/samples.py
index dae3bbd3..eb6e0da9 100644
--- a/src/tempor/data/samples.py
+++ b/src/tempor/data/samples.py
@@ -3,7 +3,7 @@
 # pylint: disable=useless-super-delegation, unnecessary-ellipsis
 
 import abc
-from typing import TYPE_CHECKING, Any, List, Optional
+from typing import TYPE_CHECKING, Any, List, Optional, Tuple
 
 import numpy as np
 import pandas as pd
@@ -457,7 +457,7 @@ def dataframe(self, **kwargs) -> pd.DataFrame:
         return self._data
 
     def sample_index(self) -> data_typing.SampleIndex:
-        return list(self._data.index.get_level_values(0).unique())  # pyright: ignore
+        return list(utils.get_df_index_level0_unique(self._data))  # pyright: ignore
 
     def time_indexes(self) -> data_typing.TimeIndexList:
         """Get a list containing time indexes for each sample. Each time index is represented as a list of time step
@@ -466,7 +466,7 @@ def time_indexes(self) -> data_typing.TimeIndexList:
         Returns:
             List[List[<timestep element>]]: A list containing time indexes for each sample.
         """
-        return list(self.time_indexes_as_dict().values())
+        return list(self.time_indexes_as_dict().values())  # pyright: ignore
 
     def time_indexes_as_dict(self) -> data_typing.SampleToTimeIndexDict:
         """Get a dictionary mapping each sample index to its time index. Time index is represented as a list of time
@@ -485,7 +485,15 @@ def time_indexes_as_dict(self) -> data_typing.SampleToTimeIndexDict:
             d[s] = list(multiindex.get_level_values(1)[time_index_locs])
         return d  # type: ignore[return-value]
 
-    # TODO: time indexes sensibly converted to floats would be useful.
+    def time_indexes_float(self) -> List[np.ndarray]:
+        """Return time indexes but converting their elements to `float` values.
+
+        Date-time time index will be converted using :obj:`~tempor.data.utils.datetime_time_index_to_float`.
+
+        Returns:
+            List[np.ndarray]: List of 1D `numpy.ndarray` s of `float` values, corresponding to the time index.
+        """
+        return [utils.datetime_time_index_to_float(ti) for ti in self.time_indexes()]
 
     def num_timesteps(self) -> List[int]:
         """Get the number of timesteps for each sample.
@@ -512,6 +520,14 @@ def num_timesteps_equal(self) -> bool:
         timesteps = self.num_timesteps()
         return True if len(timesteps) == 0 else all([x == timesteps[0] for x in timesteps])
 
+    def list_of_dataframes(self) -> List[pd.DataFrame]:
+        """Returns a list of dataframes where each dataframe has the data for each sample.
+
+        Returns:
+            List[pd.DataFrame]: List of dataframes for each sample.
+        """
+        return utils.multiindex_timeseries_dataframe_to_list_of_dataframes(self._data)
+
     @property
     def num_samples(self) -> int:
         sample_ids = self._data.index.levels[0]  # pyright: ignore
@@ -526,7 +542,7 @@ def short_repr(self) -> str:
 
     def __getitem__(self, key: data_typing.GetItemKey) -> Self:
         key_ = utils.ensure_pd_iloc_key_returns_df(key)
-        sample_index = self._data.index.get_level_values(0).unique()
+        sample_index = utils.get_df_index_level0_unique(self._data)
         selected = list(sample_index[key_])  # pyright: ignore
         return TimeSeriesSamples(  # type: ignore[return-value]
             self._data.loc[(selected, slice(None)), :],  # pyright: ignore
@@ -534,6 +550,9 @@ def __getitem__(self, key: data_typing.GetItemKey) -> Self:
         )
 
 
+_DEFAULT_EVENTS_TIME_FEATURE_SUFFIX = "_time"
+
+
 class EventSamples(DataSamples):
     _data: pd.DataFrame
     _schema: pa.DataFrameSchema
@@ -601,7 +620,7 @@ def _validate(self) -> None:
         )
         self._data = schema.validate(self._data)
         # Validate event time and value components:
-        suffix = "_time"
+        suffix = _DEFAULT_EVENTS_TIME_FEATURE_SUFFIX
         data_split = self.split(time_feature_suffix=suffix)
         schema_split = pa.infer_schema(data_split)
         schema_split = pandera_utils.add_regex_column_checks(
@@ -683,7 +702,7 @@ def num_features(self) -> int:
         return self._data.shape[1]
 
     @pydantic.validate_arguments(config={"arbitrary_types_allowed": True})
-    def split(self, time_feature_suffix: str = "_time") -> pd.DataFrame:
+    def split(self, time_feature_suffix: str = _DEFAULT_EVENTS_TIME_FEATURE_SUFFIX) -> pd.DataFrame:
         """Return a `pandas.DataFrame` where the time component of each event feature has been split off to its own
         column. The new columns that contain the times will be named ``"<original column name><time_feature_suffix>"``
         and will be inserted before each corresponding ``<original column name>`` column. The ``<original column name>``
@@ -707,6 +726,26 @@ def split(self, time_feature_suffix: str = "_time") -> pd.DataFrame:
             df[f] = df[f].apply(lambda x: x[1])
         return df
 
+    def split_as_two_dataframes(
+        self, time_feature_suffix: str = _DEFAULT_EVENTS_TIME_FEATURE_SUFFIX
+    ) -> Tuple[pd.DataFrame, pd.DataFrame]:
+        """Analogous to :func:`~tempor.data.samples.EventSamples.split` but returns two `pandas.DataFrame` s:
+            - first dataframe contains the event times of each feature.
+            - second dataframe contains the event values (`True`/`False`) of each feature.
+
+        Args:
+            time_feature_suffix (str, optional):
+                A column name suffix string to identify the time columns that will be split off. Defaults to
+                ``"_time"``.
+
+        Returns:
+            Tuple[pd.DataFrame, pd.DataFrame]: Two `pandas.DataFrame` s containing event times and values respectively.
+        """
+        df_split = self.split(time_feature_suffix=time_feature_suffix)
+        df_event_times = df_split.loc[:, [c for c in df_split.columns if time_feature_suffix in c]]
+        df_event_values = df_split.loc[:, [c for c in df_split.columns if time_feature_suffix not in c]]
+        return df_event_times, df_event_values
+
     def short_repr(self) -> str:
         return f"{self.__class__.__name__}([{self.num_samples}, {self.num_features}])"
 
diff --git a/src/tempor/data/utils.py b/src/tempor/data/utils.py
index 6dca76e4..26d8e20a 100644
--- a/src/tempor/data/utils.py
+++ b/src/tempor/data/utils.py
@@ -1,6 +1,6 @@
 import dataclasses
 import itertools
-from typing import Any, ClassVar, List, Optional
+from typing import Any, ClassVar, List, Optional, Sequence, Tuple, Union
 
 import numpy as np
 import pandas as pd
@@ -8,7 +8,7 @@
 
 import tempor.core.utils
 
-from . import data_typing
+from . import data_typing, settings
 
 
 @dataclasses.dataclass(frozen=True)
@@ -24,14 +24,27 @@ class _ExceptionMessages:
 """Reusable error messages for the module."""
 
 
-# --- Multiindex timeseries dataframe --> 3D numpy array. ---
-
-
 def value_in_df(df: pd.DataFrame, *, value: Any) -> bool:
     """Check if ``value`` exists in dataframe ``df``, accounting for the case where ``value`` is `numpy.nan`."""
     return (pd.isnull(value) and df.isna().any().any()) or (df == value).any().any()
 
 
+def set_df_column_names_inplace(df: pd.DataFrame, names: Sequence) -> pd.DataFrame:
+    pd_major, pd_minor, *_ = tempor.core.utils.get_version(pd.__version__)
+    if pd_major <= 1 and pd_minor < 5:
+        df.set_axis(names, axis="columns", inplace=True)  # pyright: ignore
+        return df
+    else:
+        return df.set_axis(names, axis="columns", copy=False)
+
+
+def get_df_index_level0_unique(df: pd.DataFrame) -> pd.Index:
+    return df.index.get_level_values(level=0).unique()
+
+
+# --- Multiindex timeseries dataframe --> 3D numpy array. ---
+
+
 @pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
 def multiindex_timeseries_dataframe_to_array3d(
     df: pd.DataFrame, *, padding_indicator: Any, max_timesteps: Optional[int] = None
@@ -57,7 +70,7 @@ def multiindex_timeseries_dataframe_to_array3d(
     """
     if value_in_df(df, value=padding_indicator):
         raise ValueError(f"Value `{padding_indicator}` found in data frame, choose a different padding indicator")
-    samples = df.index.get_level_values(level=0).unique()
+    samples = get_df_index_level0_unique(df)
     num_samples = len(samples)
     num_features = len(df.columns)
     num_timesteps_per_sample = df.groupby(level=0).size()
@@ -66,6 +79,8 @@ def multiindex_timeseries_dataframe_to_array3d(
     array = np.full(shape=(num_samples, max_timesteps, num_features), fill_value=padding_indicator)
     for i_sample, idx_sample in enumerate(samples):
         set_vals = df.loc[idx_sample, :, :].to_numpy()[:max_timesteps, :]  # pyright: ignore
+        if i_sample == 0:
+            array = array.astype(set_vals.dtype)  # Need to cast to the type matching source data.
         array[i_sample, : num_timesteps_per_sample[idx_sample], :] = set_vals  # pyright: ignore
     return array
 
@@ -239,7 +254,7 @@ def make_sample_time_index_tuples(
     """
     if len(sample_index) != len(time_indexes):
         raise ValueError("Expected the same number of elements in `sample_index` and `time_indexes`")
-    sample_indexes_copied = [[si] * len(tis) for si, tis in zip(sample_index, time_indexes)]
+    sample_indexes_copied = [[si] * len(tis) for si, tis in zip(sample_index, time_indexes)]  # type: ignore[arg-type]
     sample_indexes_flattened = list(itertools.chain.from_iterable(sample_indexes_copied))
     time_indexes_flattened = list(itertools.chain.from_iterable(time_indexes))
     pairs = [(si, ti) for si, ti in zip(sample_indexes_flattened, time_indexes_flattened)]
@@ -291,6 +306,134 @@ def array3d_to_multiindex_timeseries_dataframe(
     )
 
 
+# --- List of dataframes --> Multiindex timeseries dataframe. ---
+
+
+@pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+def list_of_dataframes_to_multiindex_timeseries_dataframe(
+    list_of_dataframes: List[pd.DataFrame],
+    *,
+    sample_index: data_typing.SampleIndex,
+    time_indexes: Optional[data_typing.TimeIndexList] = None,
+    feature_index: Optional[data_typing.FeatureIndex] = None,
+) -> pd.DataFrame:
+    dfs: List[pd.DataFrame] = list_of_dataframes
+
+    if time_indexes is not None and len(list_of_dataframes) != len(time_indexes):
+        raise ValueError(
+            "Expected `list_of_dataframes` and `time_indexes` to be of same length but was "
+            f"{len(list_of_dataframes)} and {len(time_indexes)} respectively"
+        )
+
+    if time_indexes is not None:
+        for idx, df in enumerate(list_of_dataframes):
+            dfs[idx] = df.set_index(
+                keys=pd.Index(time_indexes[idx], name=df.index.name),  # pyright: ignore
+                drop=True,
+                verify_integrity=True,
+            )
+    else:
+        time_indexes = [list(df.index) for df in dfs]
+
+    df_concat = pd.concat(dfs, axis=0, ignore_index=False)
+
+    multiindex = pd.MultiIndex.from_tuples(make_sample_time_index_tuples(sample_index, time_indexes))
+    df_concat.set_index(
+        keys=multiindex,
+        drop=True,
+        verify_integrity=True,
+        inplace=True,  # pyright: ignore
+    )
+    df_concat.index.set_names(
+        [settings.DATA_SETTINGS.sample_index_name, settings.DATA_SETTINGS.time_index_name], inplace=True
+    )
+    if feature_index is not None:
+        df_concat = set_df_column_names_inplace(df_concat, feature_index)
+
+    return df_concat
+
+
+def multiindex_timeseries_dataframe_to_list_of_dataframes(df: pd.DataFrame) -> List[pd.DataFrame]:
+    """Returns a list of dataframes where each dataframe has the data for each sample. That is, each of the dataframes
+    has a unique level ``0`` index value.
+
+    Args:
+        df (pd.DataFrame): Input multiindex dataframe.
+
+    Returns:
+        List[pd.DataFrame]: Output list of dataframes.
+    """
+    sample_index = get_df_index_level0_unique(df)
+    return [df.loc[(si, slice(None)), :] for si in sample_index]
+
+
+# --- [(event_times, event_values), ...] --> DataFrame compatible with EventSamples. ---
+
+
+@pydantic.validate_arguments(config={"arbitrary_types_allowed": False, "smart_union": True})
+def event_time_value_pairs_to_event_dataframe(
+    event_time_value_pairs: Sequence[Tuple[data_typing.TimeIndex, List[bool]]],
+    sample_index: data_typing.SampleIndex,
+    feature_index: Optional[data_typing.FeatureIndex] = None,
+) -> pd.DataFrame:
+    """Convert a sequence like ``[(event_times, event_values), ...]`` to a `pandas.DataFrame` whose columns contain
+    elements like tuples ``(event_time, event_value)``.
+
+    Args:
+        event_time_value_pairs (Sequence[Tuple[data_typing.TimeIndex, List[bool]]]):
+            A sequence where each item corresponds to an event feature and is a tuple of form
+            ``(event_times, event_values)`` (e.g. ``([1.1, 1.2, 1.3], [True, True, False])``).
+        sample_index (data_typing.SampleIndex):
+            List of sample IDs, to be set as dataframe row index.
+        feature_index (Optional[data_typing.FeatureIndex]):
+            List of feature names, to be set as dataframe column names.
+
+    Example:
+        >>> sample_index = ["s1", "s2", "s3"]
+        >>> feature_names = ["feature_1", "feature_2"]
+        >>> event_feature_1 = ([1.1, 1.2, 1.3], [True, True, False])
+        >>> event_feature_2 = ([2.1, 2.2, 2.3], [False, True, False])
+        >>> event_time_value_pairs = [event_feature_1, event_feature_2]
+        >>> df = event_time_value_pairs_to_event_dataframe(
+        ...     event_time_value_pairs, sample_index=sample_index, feature_index=feature_names
+        ... )
+        >>> df.shape
+        (3, 2)
+
+    Returns:
+        pd.DataFrame: `pandas.DataFrame` compatible with `EventSamples`.
+    """
+    series_list = [pd.Series(zip(t, v)) for t, v in event_time_value_pairs]
+    data_input = pd.DataFrame(data=series_list).T.to_numpy()
+    df = pd.DataFrame(data=data_input, index=sample_index)
+    if feature_index is not None:
+        df = set_df_column_names_inplace(df, feature_index)
+    return df
+
+
+# --- Date-time time index -related ---
+
+
+@pydantic.validate_arguments(config={"arbitrary_types_allowed": True, "smart_union": True})
+def datetime_time_index_to_float(time_index: Union[data_typing.TimeIndex, pd.Index, pd.Series]) -> np.ndarray:
+    """Convert a date-time ``time_index`` to floats. The conversion is done by calling
+    ``<time_index as a numpy array>.astype(float)``.
+
+    Args:
+        time_index (Union[data_typing.TimeIndex, pd.Index, pd.Series]):
+            The input time index.
+
+    Returns:
+        np.ndarray:
+            NumPy array containing the time index converted to `float` s.
+    """
+    if isinstance(time_index, (pd.Index, pd.Series)):
+        time_index_pd_type = time_index
+    else:
+        time_index_pd_type = pd.Series(time_index)
+    return time_index_pd_type.to_numpy().astype(float)
+
+
 # --- Miscellaneous. ---
 
 
diff --git a/src/tempor/models/ddh.py b/src/tempor/models/ddh.py
new file mode 100644
index 00000000..b4d4e465
--- /dev/null
+++ b/src/tempor/models/ddh.py
@@ -0,0 +1,597 @@
+from copy import deepcopy
+from typing import Any, List, Optional, Tuple, Union
+
+import numpy as np
+import torch
+import torch.nn as nn
+from typing_extensions import Literal, Self, get_args
+
+from tempor.models import constants
+
+from .mlp import MLP
+from .transformer import TransformerModel
+from .ts_model import TimeSeriesLayer
+
+RnnMode = Literal[
+    "GRU",
+    "LSTM",
+    "RNN",
+    "Transformer",
+]
+OutputMode = Literal[
+    "MLP",
+    "LSTM",
+    "GRU",
+    "RNN",
+    "Transformer",
+    "TCN",
+    "InceptionTime",
+    "InceptionTimePlus",
+    "ResCNN",
+    "XCM",
+]
+
+rnn_modes = get_args(RnnMode)
+output_modes = get_args(OutputMode)
+
+
+def get_padded_features(
+    x: Union[np.ndarray, List[np.ndarray]], pad_size: Optional[int] = None, fill: float = np.nan
+) -> np.ndarray:
+    """Helper function to pad variable length RNN inputs with nans."""
+    if pad_size is None:
+        pad_size = max([len(x_) for x_ in x])
+
+    x_padded = []
+    for i in range(len(x)):
+        if pad_size == len(x[i]):
+            x_padded.append(x[i].astype(float))
+        elif pad_size > len(x[i]):
+            pads = fill * np.ones((pad_size - len(x[i]),) + x[i].shape[1:])
+            x_padded.append(np.concatenate([x[i], pads]).astype(float))
+        else:
+            x_padded.append(x[i][:pad_size].astype(float))
+
+    return np.asarray(x_padded)
+
+
+class DynamicDeepHitModel:
+    """This implementation considers that the last event happen at the same time for each patient.
+    The CIF is therefore simplified.
+    """
+
+    def __init__(
+        self,
+        split: int = 100,
+        n_layers_hidden: int = 2,
+        n_units_hidden: int = 100,
+        rnn_mode: str = "LSTM",
+        dropout: float = 0.1,
+        alpha: float = 0.1,
+        beta: float = 0.1,
+        sigma: float = 0.1,
+        patience: int = 20,
+        lr: float = 1e-3,
+        batch_size: int = 100,
+        n_iter: int = 1000,
+        device: Any = constants.DEVICE,
+        val_size: float = 0.1,
+        random_state: int = 0,
+        clipping_value: int = 1,
+        output_mode: str = "MLP",
+    ) -> None:
+
+        self.split = split
+        self.split_time = None
+
+        self.pad_size = 0
+
+        self.layers_rnn = n_layers_hidden
+        self.hidden_rnn = n_units_hidden
+        self.rnn_type = rnn_mode
+
+        self.alpha = alpha
+        self.beta = beta
+        self.sigma = sigma
+
+        self.device = torch.device(device) if isinstance(device, str) else device
+        self.dropout = dropout
+        self.lr = lr
+        self.n_iter = n_iter
+        self.batch_size = batch_size
+        self.val_size = val_size
+        self.clipping_value = clipping_value
+
+        self.patience = patience
+        self.random_state = random_state
+        self.output_type = output_mode
+
+        self.model: Optional[DynamicDeepHitLayers] = None
+
+    def _setup_model(self, inputdim: int, seqlen: int, risks: int) -> "DynamicDeepHitLayers":
+        return (
+            DynamicDeepHitLayers(
+                inputdim,
+                seqlen,
+                self.split,
+                self.layers_rnn,
+                self.hidden_rnn,
+                rnn_type=self.rnn_type,
+                dropout=self.dropout,
+                risks=risks,
+                device=self.device,
+                output_type=self.output_type,
+            )
+            .float()
+            .to(self.device)
+        )
+
+    def fit(
+        self,
+        x: np.ndarray,
+        t: np.ndarray,
+        e: np.ndarray,
+    ) -> Self:
+        discretized_t, self.split_time = self.discretize(t, self.split, self.split_time)
+        processed_data = self._preprocess_training_data(x, discretized_t, e)
+        x_train, t_train, e_train, x_val, t_val, e_val = processed_data
+        inputdim = x_train.shape[-1]
+        seqlen = x_train.shape[-2]
+
+        maxrisk = int(np.nanmax(e_train.cpu().numpy()))
+
+        self.model = self._setup_model(inputdim, seqlen, risks=maxrisk)
+        optimizer = torch.optim.Adam(self.model.parameters(), lr=self.lr)
+
+        patience, old_loss = 0, np.inf
+        nbatches = int(x_train.shape[0] / self.batch_size) + 1
+        valbatches = int(x_val.shape[0] / self.batch_size) + 1
+
+        best_param = deepcopy(self.model.state_dict())
+
+        for i in range(self.n_iter):  # pylint: disable=unused-variable
+            self.model.train()
+            for j in range(nbatches):
+                xb = x_train[j * self.batch_size : (j + 1) * self.batch_size]
+                tb = t_train[j * self.batch_size : (j + 1) * self.batch_size]
+                eb = e_train[j * self.batch_size : (j + 1) * self.batch_size]
+
+                if xb.shape[0] == 0:
+                    continue
+
+                optimizer.zero_grad()
+                loss = self.total_loss(xb, tb, eb)
+                loss.backward()
+
+                if self.clipping_value > 0:
+                    torch.nn.utils.clip_grad_norm_(  # pyright: ignore [reportPrivateImportUsage]
+                        self.model.parameters(),
+                        self.clipping_value,
+                    )
+
+                optimizer.step()
+
+            self.model.eval()
+            valid_loss: Any = 0.0
+            for j in range(valbatches):
+                xb = x_val[j * self.batch_size : (j + 1) * self.batch_size]
+                tb = t_val[j * self.batch_size : (j + 1) * self.batch_size]
+                eb = e_val[j * self.batch_size : (j + 1) * self.batch_size]
+
+                if xb.shape[0] == 0:
+                    continue
+
+                valid_loss += self.total_loss(xb, tb, eb)
+
+            if torch.isnan(valid_loss):
+                raise RuntimeError("NaNs detected in the total loss")
+
+            valid_loss = valid_loss.item()
+
+            if valid_loss < old_loss:
+                patience = 0
+                old_loss = valid_loss
+                best_param = deepcopy(self.model.state_dict())
+            else:
+                patience += 1
+
+            if patience == self.patience:
+                break
+
+        self.model.load_state_dict(best_param)
+        self.model.eval()
+
+        return self
+
+    def discretize(self, t: Union[np.ndarray, List[np.ndarray]], split: int, split_time: Optional[int] = None) -> Tuple:
+        """Discretize the survival horizon.
+
+        Args:
+            t (List of Array): Time of events
+            split (int): Number of bins
+            split_time (List, optional): List of bins (must be same length than split). Defaults to None.
+
+        Returns:
+            List of Array: Discretized events time
+        """
+        if split_time is None:
+            _, split_time = np.histogram(t, split - 1)  # type: ignore
+        t_discretized = np.array(
+            [np.digitize(t_, split_time, right=True) - 1 for t_ in t], dtype=object  # type: ignore
+        )
+        return t_discretized, split_time
+
+    def _preprocess_test_data(self, x: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
+        data = torch.from_numpy(get_padded_features(x, pad_size=self.pad_size)).float().to(self.device)
+        return data
+
+    def _preprocess_training_data(
+        self,
+        x: np.ndarray,
+        t: np.ndarray,
+        e: np.ndarray,
+    ) -> Tuple:
+        """RNNs require different preprocessing for variable length sequences."""
+
+        idx = list(range(x.shape[0]))
+        np.random.seed(self.random_state)
+        np.random.shuffle(idx)
+
+        x = get_padded_features(x)
+        self.pad_size = x.shape[1]
+        x_train_np, t_train_np, e_train_np = x[idx], t[idx], e[idx]
+
+        x_train = torch.from_numpy(x_train_np.astype(float)).float().to(self.device)
+        t_train = torch.from_numpy(t_train_np.astype(float)).float().to(self.device)
+        e_train = torch.from_numpy(e_train_np.astype(int)).float().to(self.device)
+
+        val_size = int(self.val_size * x_train.shape[0])
+
+        x_val, t_val, e_val = x_train[-val_size:], t_train[-val_size:], e_train[-val_size:]
+
+        x_train = x_train[:-val_size]
+        t_train = t_train[:-val_size]
+        e_train = e_train[:-val_size]
+
+        return (x_train, t_train, e_train, x_val, t_val, e_val)
+
+    def predict_emb(
+        self,
+        x: np.ndarray,
+    ) -> torch.Tensor:
+        if self.model is None:
+            raise Exception(
+                "The model has not been fitted yet. Please fit the "
+                + "model using the `fit` method on some training data "
+                + "before calling `predict_survival`."
+            )
+        x_in: torch.Tensor = self._preprocess_test_data(x)
+
+        _, emb = self.model.forward_emb(x_in)
+
+        return emb
+
+    def predict_survival(
+        self,
+        x: np.ndarray,
+        t: List,
+        risk: int = 1,
+        all_step: bool = False,
+        bs: int = 100,
+    ) -> np.ndarray:
+        if self.model is None:
+            raise Exception(
+                "The model has not been fitted yet. Please fit the "
+                + "model using the `fit` method on some training data "
+                + "before calling `predict_survival`."
+            )
+        lens = [len(x_) for x_ in x]
+
+        x_in: Any = x
+        if all_step:
+            new_x = []
+            for x_, l_ in zip(x, lens):
+                new_x += [x_[: li + 1] for li in range(l_)]
+            x_in = new_x
+
+        # TODO: The below [t] is messy, need to investigate...
+        t = self.discretize([t], self.split, self.split_time)[0][0]  # type: ignore
+
+        x_in_tensor: torch.Tensor = self._preprocess_test_data(x_in)
+        batches = int(len(x) / bs) + 1
+        scores: dict = {t_: [] for t_ in t}
+        for j in range(batches):
+            xb = x_in_tensor[j * self.batch_size : (j + 1) * self.batch_size]
+            _, f = self.model(xb)  # pylint: disable=not-callable
+            for t_ in t:
+                pred = torch.cumsum(f[int(risk) - 1], dim=1)[:, t_].squeeze().detach().cpu().numpy().tolist()
+
+                if isinstance(pred, list):
+                    scores[t_].extend(pred)
+                else:
+                    scores[t_].append(pred)
+
+        output = []
+        for t_ in t:
+            output.append(scores[t_])
+
+        return 1 - np.asarray(output).T
+
+    def predict_risk(self, x: np.ndarray, t: List, **kwargs: Any) -> np.ndarray:
+        return 1 - self.predict_survival(x, t, **kwargs)
+
+    def negative_log_likelihood(
+        self,
+        outcomes: torch.Tensor,
+        cif: List[torch.Tensor],
+        t: torch.Tensor,
+        e: torch.Tensor,
+    ) -> torch.Tensor:
+        """Compute the log likelihood loss.
+
+        This function is used to compute the survival loss.
+        """
+        loss: torch.Tensor = 0.0  # type: ignore
+        censored_cif: torch.Tensor = 0.0  # type: ignore
+        for k, ok in enumerate(outcomes):
+            # Censored cif
+            censored_cif += cif[k][e == 0][:, t[e == 0]]
+
+            # Uncensored
+            selection = e == (k + 1)
+            loss += torch.sum(torch.log(ok[selection][:, t[selection]] + constants.EPS))
+
+        # Censored loss
+        loss += torch.sum(torch.log(nn.ReLU()(1 - censored_cif) + constants.EPS))
+        return -loss / len(outcomes)
+
+    def ranking_loss(
+        self,
+        cif: List[torch.Tensor],
+        t: torch.Tensor,
+        e: torch.Tensor,
+    ) -> torch.Tensor:
+        """Penalize wrong ordering of probability.
+
+        Equivalent to a C Index. This function is used to penalize wrong ordering in the survival prediction.
+        """
+        loss: torch.Tensor = 0.0  # type: ignore
+        # Data ordered by time
+        for k, cif_k in enumerate(cif):
+            for ci, ti in zip(cif_k[e - 1 == k], t[e - 1 == k]):
+                # For all events: all patients that didn't experience event before
+                # must have a lower risk for that cause
+                if torch.sum(t > ti) > 0:
+                    # TODO: When data are sorted in time -> wan we make it even faster?
+                    loss += torch.mean(torch.exp((cif_k[t > ti][:, ti] - ci[ti])) / self.sigma)
+
+        return loss / len(cif)
+
+    def longitudinal_loss(self, longitudinal_prediction: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
+        """Penalize error in the longitudinal predictions. This function is used to compute the error made by the RNN.
+
+        NB: In the paper, they seem to use different losses for continuous and categorical,
+        but this was not reflected in the code associated (therefore we compute MSE for all).
+
+        NB: Original paper mentions possibility of different alphas for each risk,
+        but takes same for all (for ranking loss).
+        """
+        length = (~torch.isnan(x[:, :, 0])).sum(dim=1) - 1
+
+        # Create a grid of the column index
+        index = torch.arange(x.size(1)).repeat(x.size(0), 1).to(self.device)
+
+        # Select all predictions until the last observed
+        prediction_mask = index <= (length - 1).unsqueeze(1).repeat(1, x.size(1))
+
+        # Select all observations that can be predicted
+        observation_mask = index <= length.unsqueeze(1).repeat(1, x.size(1))
+        observation_mask[:, 0] = False  # Remove first observation
+
+        return torch.nn.MSELoss(reduction="mean")(longitudinal_prediction[prediction_mask], x[observation_mask])
+
+    def total_loss(
+        self,
+        x: torch.Tensor,
+        t: torch.Tensor,
+        e: torch.Tensor,
+    ) -> torch.Tensor:
+        if self.model is None:
+            raise RuntimeError("Invalid model for loss")
+
+        longitudinal_prediction, outcomes = self.model(x.float())  # pylint: disable=not-callable
+        if torch.isnan(longitudinal_prediction).sum() != 0:
+            raise RuntimeError("NaNs detected in the longitudinal_prediction")
+
+        t, e = t.long(), e.int()
+
+        # Compute cumulative function from predicted outcomes
+        cif = [torch.cumsum(ok, 1) for ok in outcomes]
+
+        return (
+            (1 - self.alpha - self.beta) * self.longitudinal_loss(longitudinal_prediction, x)
+            + self.alpha * self.ranking_loss(cif, t, e)
+            + self.beta * self.negative_log_likelihood(outcomes, cif, t, e)
+        )
+
+
+class DynamicDeepHitLayers(nn.Module):
+    def __init__(
+        self,
+        input_dim: int,
+        seq_len: int,
+        output_dim: int,
+        layers_rnn: int,
+        hidden_rnn: int,
+        rnn_type: str = "LSTM",
+        dropout: float = 0.1,
+        risks: int = 1,
+        output_type: str = "MLP",
+        device: Any = constants.DEVICE,
+    ) -> None:
+        super(DynamicDeepHitLayers, self).__init__()
+
+        self.input_dim = input_dim
+        self.seq_len = seq_len
+        self.output_dim = output_dim
+        self.risks = risks
+        self.rnn_type = rnn_type
+        self.device = device
+        self.dropout = dropout
+
+        # RNN model for longitudinal data
+        self.embedding: nn.Module
+        if self.rnn_type == "LSTM":
+            self.embedding = nn.LSTM(input_dim, hidden_rnn, layers_rnn, bias=False, batch_first=True)
+        elif self.rnn_type == "RNN":
+            self.embedding = nn.RNN(
+                input_dim,
+                hidden_rnn,
+                layers_rnn,
+                bias=False,
+                batch_first=True,
+                nonlinearity="relu",
+            )
+        elif self.rnn_type == "GRU":
+            self.embedding = nn.GRU(input_dim, hidden_rnn, layers_rnn, bias=False, batch_first=True)
+        elif self.rnn_type == "Transformer":
+            self.embedding = TransformerModel(input_dim, hidden_rnn, n_layers_hidden=layers_rnn, dropout=dropout)
+        else:
+            raise RuntimeError(f"Unknown rnn_type {rnn_type}")
+
+        # Longitudinal network
+        self.longitudinal = MLP(
+            task_type="regression",
+            n_units_in=hidden_rnn,
+            n_units_out=input_dim,
+            n_layers_hidden=layers_rnn,
+            n_units_hidden=hidden_rnn,
+            dropout=self.dropout,
+            device=device,
+        )
+
+        # Attention mechanism
+        self.attention: Union[MLP, TimeSeriesLayer]
+        if output_type == "MLP":
+            self.attention = MLP(
+                task_type="regression",
+                n_units_in=input_dim + hidden_rnn,
+                n_units_out=1,
+                dropout=self.dropout,
+                n_layers_hidden=layers_rnn,
+                n_units_hidden=hidden_rnn,
+                device=device,
+            )
+        else:
+            self.attention = TimeSeriesLayer(
+                n_static_units_in=0,
+                n_temporal_units_in=input_dim + hidden_rnn,
+                n_temporal_window=seq_len,
+                n_units_out=seq_len,
+                n_temporal_units_hidden=hidden_rnn,
+                n_temporal_layers_hidden=layers_rnn,
+                mode=output_type,
+                dropout=self.dropout,
+                device=device,
+            )
+        self.attention_soft = nn.Softmax(1)  # On temporal dimension
+        self.output_type = output_type
+
+        # Cause specific network
+        cause_specific = []
+        for r in range(self.risks):  # pylint: disable=unused-variable
+            cause_specific.append(
+                MLP(
+                    task_type="regression",
+                    n_units_in=input_dim + hidden_rnn,
+                    n_units_out=output_dim,
+                    dropout=self.dropout,
+                    n_layers_hidden=layers_rnn,
+                    n_units_hidden=hidden_rnn,
+                    device=device,
+                )
+            )
+        self.cause_specific = nn.ModuleList(cause_specific)
+
+        # Probability
+        self.soft = nn.Softmax(dim=-1)  # On all observed output
+
+        self.to(self.device)
+
+    def forward_attention(self, x: torch.Tensor, inputmask: torch.Tensor, hidden: torch.Tensor) -> torch.Tensor:
+        # Attention using last observation to predict weight of all previously observed
+        # Extract last observation (the one used for predictions)
+        last_observations = (~inputmask).sum(dim=1) - 1
+        last_observations_idx = last_observations.unsqueeze(1).repeat(1, x.size(1))
+        index = torch.arange(x.size(1)).repeat(x.size(0), 1).to(self.device)
+
+        last = index == last_observations_idx
+        x_last = x[last]
+
+        # Concatenate all previous with new to measure attention
+        concatenation = torch.cat([hidden, x_last.unsqueeze(1).repeat(1, x.size(1), 1)], -1)
+
+        # Compute attention and normalize
+        if self.output_type == "MLP":
+            attention = self.attention(concatenation).squeeze(-1)
+        else:
+            attention = self.attention(torch.zeros(len(concatenation), 0).to(self.device), concatenation).squeeze(-1)
+        attention[index >= last_observations_idx] = -1e10  # Want soft max to be zero as values not observed
+        attention[last_observations > 0] = self.attention_soft(
+            attention[last_observations > 0]
+        )  # Weight previous observation
+        attention[last_observations == 0] = 0  # No context for only one observation
+
+        # Risk networks
+        # The original paper is not clear on how the last observation is
+        # combined with the temporal sum, other code was concatenating them
+        attention = attention.unsqueeze(2).repeat(1, 1, hidden.size(2))
+        hidden_attentive = torch.sum(attention * hidden, dim=1)
+        return torch.cat([hidden_attentive, x_last], 1)
+
+    def forward_emb(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
+        """The forward function that is called when data is passed through DynamicDeepHit."""
+        # RNN representation - Nan values for not observed data
+        x = x.clone()
+        inputmask = torch.isnan(x[:, :, 0])
+        x[torch.isnan(x)] = -1
+
+        if torch.isnan(x).sum() != 0:
+            raise RuntimeError("NaNs detected in the input")
+
+        if self.rnn_type in ["GRU", "LSTM", "RNN"]:
+            hidden, _ = self.embedding(x)
+        else:
+            hidden = self.embedding(x)
+
+        if torch.isnan(hidden).sum() != 0:
+            raise RuntimeError("NaNs detected in the embeddings")
+
+        # Longitudinal modelling
+        longitudinal_prediction = self.longitudinal(hidden)
+        if torch.isnan(longitudinal_prediction).sum() != 0:
+            raise RuntimeError("NaNs detected in the longitudinal_prediction")
+
+        hidden_attentive = self.forward_attention(x, inputmask, hidden)
+
+        return longitudinal_prediction, hidden_attentive
+
+    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, List]:
+        """The forward function that is called when data is passed through DynamicDeepHit."""
+        # RNN representation - Nan values for not observed data
+        x = x.to(self.device)
+
+        longitudinal_prediction, hidden_attentive = self.forward_emb(x)
+
+        outcomes = []
+        for cs_nn in self.cause_specific:
+            outcomes.append(cs_nn(hidden_attentive))
+
+        # Soft max for probability distribution
+        outcomes_t = torch.cat(outcomes, dim=1)
+        outcomes_t = self.soft(outcomes_t)
+        if torch.isnan(outcomes_t).sum() != 0:
+            raise RuntimeError("NaNs detected in the outcome")
+
+        outcomes = [outcomes_t[:, i * self.output_dim : (i + 1) * self.output_dim] for i in range(self.risks)]
+        return longitudinal_prediction, outcomes
diff --git a/src/tempor/models/transformer.py b/src/tempor/models/transformer.py
new file mode 100644
index 00000000..552ef6f6
--- /dev/null
+++ b/src/tempor/models/transformer.py
@@ -0,0 +1,86 @@
+from typing import Any
+
+import torch
+from torch import nn
+from torch.nn.modules.transformer import TransformerEncoder, TransformerEncoderLayer
+
+from .constants import DEVICE
+
+
+class Permute(nn.Module):
+    def __init__(self, *dims: Any) -> None:
+        super().__init__()
+        self.dims = dims
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        return x.permute(self.dims)
+
+
+class Transpose(nn.Module):
+    def __init__(self, *dims: Any, contiguous: bool = False) -> None:
+        super().__init__()
+        self.dims, self.contiguous = dims, contiguous
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        if self.contiguous:
+            return x.transpose(*self.dims).contiguous()
+        else:
+            return x.transpose(*self.dims)
+
+
+class TransformerModel(nn.Module):
+    def __init__(
+        self,
+        n_units_in: int,
+        n_units_hidden: int = 64,
+        n_head: int = 1,
+        d_ffn: int = 128,
+        dropout: float = 0.1,
+        activation: str = "relu",
+        n_layers_hidden: int = 1,
+        device: Any = DEVICE,
+    ) -> None:
+        """Transformer model.
+
+        Args:
+            n_units_in (int):
+                The number of features (a.k.a. variables, dimensions, channels) in the time series dataset.
+            n_units_hidden (int, optional):
+                Total dimension of the model. Defaults to ``64``.
+            n_head (int, optional):
+                Parallel attention heads. Defaults to ``1``.
+            d_ffn (int, optional):
+                The dimension of the feedforward network model. Defaults to ``128``.
+            dropout (float, optional):
+                Dropout value passed to `~torch.nn.modules.transformer.TransformerEncoderLayer` s. Defaults to ``0.1``.
+            activation (Nonlin, optional):
+                The activation function of intermediate layer, ``"relu"`` or ``"gelu"``. Defaults to ``"relu"``.
+            n_layers_hidden (int, optional):
+                The number of sub-encoder-layers in the encoder. Defaults to ``1``.
+            device (Any, optional):
+                PyTorch device. Defaults to `~tempor.models.constants.DEVICE`.
+        """
+
+        super().__init__()
+
+        encoder_layer = TransformerEncoderLayer(
+            n_units_hidden,
+            n_head,
+            dim_feedforward=d_ffn,
+            dropout=dropout,
+            activation=activation,
+        )
+        encoder_norm = nn.LayerNorm(n_units_hidden)
+        self.transformer_encoder = TransformerEncoder(encoder_layer, n_layers_hidden, norm=encoder_norm)
+
+        self.model = nn.Sequential(
+            Permute(1, 0, 2),  # bs x seq_len x nvars -> seq_len x bs x nvars
+            nn.Linear(n_units_in, n_units_hidden),  # seq_len x bs x nvars -> seq_len x bs x n_units_hidden
+            nn.ReLU(),
+            self.transformer_encoder,
+            Transpose(1, 0),  # seq_len x bs x n_units_hidden -> bs x seq_len x n_units_hidden
+            nn.ReLU(),
+        ).to(device)
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        return self.model(x)  # pylint: disable=not-callable
diff --git a/src/tempor/models/utils.py b/src/tempor/models/utils.py
index 7d451448..bcf08c59 100644
--- a/src/tempor/models/utils.py
+++ b/src/tempor/models/utils.py
@@ -8,6 +8,8 @@
 import torch.version
 from torch import nn
 
+import tempor.core.utils
+
 
 def enable_reproducibility(
     random_seed: int = 0,
@@ -53,7 +55,7 @@ def enable_reproducibility(
     if torch_use_deterministic_algorithms:
         torch.use_deterministic_algorithms(True)
         if warn_cuda_env_vars and torch.version.cuda not in ("", None):
-            major, minor = [int(x) for x in torch.version.cuda.split(".")]
+            major, minor = tempor.core.utils.get_version(torch.version.cuda)
             if (major, minor) == (10, 1) and os.environ.get("CUDA_LAUNCH_BLOCKING", None) != "1":
                 warnings.warn(
                     "When setting torch.use_deterministic_algorithms and using CUDA 10.1, the environment variable "
diff --git a/src/tempor/plugins/_plugin_loader.py b/src/tempor/plugins/_plugin_loader.py
index 414f4573..e9fd3dc1 100644
--- a/src/tempor/plugins/_plugin_loader.py
+++ b/src/tempor/plugins/_plugin_loader.py
@@ -1,13 +1,13 @@
 import tempor.plugins.core as plugins
 
-from . import classification, preprocessing, regression, survival
+from . import classification, preprocessing, regression, time_to_event
 
 plugin_loader = plugins.PluginLoader()
 
 
 __all__ = [
     "preprocessing",
-    "survival",
+    "time_to_event",
     "classification",
     "regression",
 ]
diff --git a/src/tempor/plugins/classification/plugin_cde_classifier.py b/src/tempor/plugins/classification/plugin_cde_classifier.py
index a94f8b7a..d8318def 100644
--- a/src/tempor/plugins/classification/plugin_cde_classifier.py
+++ b/src/tempor/plugins/classification/plugin_cde_classifier.py
@@ -84,10 +84,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("classification.cde_classifier", n_iter=50)
diff --git a/src/tempor/plugins/classification/plugin_laplace_classifier.py b/src/tempor/plugins/classification/plugin_laplace_classifier.py
index 21386366..2a596ce0 100644
--- a/src/tempor/plugins/classification/plugin_laplace_classifier.py
+++ b/src/tempor/plugins/classification/plugin_laplace_classifier.py
@@ -85,10 +85,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> # dataset = SineDataloader().load()
+            >>> # dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("classification.laplace_ode_classifier", n_iter=50)
diff --git a/src/tempor/plugins/classification/plugin_nn_classifier.py b/src/tempor/plugins/classification/plugin_nn_classifier.py
index 7c227d7c..1e05a4af 100644
--- a/src/tempor/plugins/classification/plugin_nn_classifier.py
+++ b/src/tempor/plugins/classification/plugin_nn_classifier.py
@@ -82,10 +82,10 @@ def __init__(
                 Train/test split ratio. Defaults to ``0.8``.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("classification.nn_classifier", n_iter=50)
diff --git a/src/tempor/plugins/classification/plugin_ode_classifier.py b/src/tempor/plugins/classification/plugin_ode_classifier.py
index 1c23208c..38f2e0d1 100644
--- a/src/tempor/plugins/classification/plugin_ode_classifier.py
+++ b/src/tempor/plugins/classification/plugin_ode_classifier.py
@@ -80,10 +80,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("classification.ode_classifier", n_iter=50)
diff --git a/src/tempor/plugins/pipeline/__init__.py b/src/tempor/plugins/pipeline/__init__.py
index 041f0fd6..cf103a27 100644
--- a/src/tempor/plugins/pipeline/__init__.py
+++ b/src/tempor/plugins/pipeline/__init__.py
@@ -25,7 +25,7 @@ def __new__(cls: Type, name: str, plugins: Tuple[Type, ...], dct: dict) -> Any:
         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_params"] = _generate_sample_param_impl(plugins)
+        dct["sample_hyperparameters"] = _generate_sample_param_impl(plugins)
         dct["get_args"] = _generate_get_args()
 
         dct["plugin_types"] = list(plugins)
@@ -34,34 +34,34 @@ def __new__(cls: Type, name: str, plugins: Tuple[Type, ...], dct: dict) -> Any:
 
     @staticmethod
     def name(*args: Any) -> str:
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     @staticmethod
     def type(*args: Any) -> str:
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     @staticmethod
     def hyperparameter_space(*args: Any, **kwargs: Any) -> Dict:
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     @staticmethod
     def hyperparameter_space_for_layer(name: str, *args: Any, **kwargs: Any) -> Dict:
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
-    def sample_params(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=no-method-argument
-        raise NotImplementedError("not implemented")
+    def sample_hyperparameters(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=no-method-argument
+        raise NotImplementedError("Not implemented")
 
     def get_args(*args: Any, **kwargs: Any) -> Dict:  # pylint: disable=no-method-argument
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     def fit(self: Any, X: dataset.Dataset, *args: Any, **kwargs: Any) -> Any:
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     def predict(*args: Any, **kwargs: Any) -> dataset.Dataset:  # pylint: disable=no-method-argument
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
     def predict_proba(*args: Any, **kwargs: Any) -> dataset.Dataset:  # pylint: disable=no-method-argument
-        raise NotImplementedError("not implemented")
+        raise NotImplementedError("Not implemented")
 
 
 def PipelineGroup(names: List[str]) -> Tuple[Type, ...]:
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py b/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py
index 6dcb9211..45f58f5a 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py
+++ b/src/tempor/plugins/preprocessing/imputation/plugin_bfill.py
@@ -22,10 +22,10 @@ def __init__(self, static_imputer: str = "mean", random_state: int = 0, **params
                 Random seed. Defaults to ``0``.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader(with_missing = True).load()
+            >>> dataset = SineDataLoader(with_missing = True).load()
             >>> assert dataset.static.dataframe().isna().sum().sum() != 0
             >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
             >>>
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py b/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py
index 90647746..c38677d4 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py
+++ b/src/tempor/plugins/preprocessing/imputation/plugin_ffill.py
@@ -21,10 +21,10 @@ class FFillImputer(BaseImputer):
             Random seed. Defaults to ``0``.
 
     Example:
-        >>> from tempor.utils.datasets.sine import SineDataloader
+        >>> from tempor.utils.dataloaders.sine import SineDataLoader
         >>> from tempor.plugins import plugin_loader
         >>>
-        >>> dataset = SineDataloader(with_missing = True).load()
+        >>> dataset = SineDataLoader(with_missing = True).load()
         >>> assert dataset.static.dataframe().isna().sum().sum() != 0
         >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
         >>>
diff --git a/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py b/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py
index eb7f2ecb..4d9ef42c 100644
--- a/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py
+++ b/src/tempor/plugins/preprocessing/imputation/plugin_static_imputation.py
@@ -23,10 +23,10 @@ class StaticOnlyImputer(BaseImputer):
             Random seed. Defaults to ``0``.
 
     Example:
-        >>> from tempor.utils.datasets.sine import SineDataloader
+        >>> from tempor.utils.dataloaders.sine import SineDataLoader
         >>> from tempor.plugins import plugin_loader
         >>>
-        >>> dataset = SineDataloader(with_missing = True).load()
+        >>> dataset = SineDataLoader(with_missing = True).load()
         >>> assert dataset.static.dataframe().isna().sum().sum() != 0
         >>> assert dataset.time_series.dataframe().isna().sum().sum() != 0
         >>>
diff --git a/src/tempor/plugins/regression/plugin_cde_regressor.py b/src/tempor/plugins/regression/plugin_cde_regressor.py
index c942732b..d9028ecf 100644
--- a/src/tempor/plugins/regression/plugin_cde_regressor.py
+++ b/src/tempor/plugins/regression/plugin_cde_regressor.py
@@ -83,10 +83,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("regression.cde_regressor", n_iter=50)
diff --git a/src/tempor/plugins/regression/plugin_laplace_regressor.py b/src/tempor/plugins/regression/plugin_laplace_regressor.py
index d83b867f..ab99f121 100644
--- a/src/tempor/plugins/regression/plugin_laplace_regressor.py
+++ b/src/tempor/plugins/regression/plugin_laplace_regressor.py
@@ -84,10 +84,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> # dataset = SineDataloader().load()
+            >>> # dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("regression.laplace_ode_regressor", n_iter=50)
diff --git a/src/tempor/plugins/regression/plugin_nn_regressor.py b/src/tempor/plugins/regression/plugin_nn_regressor.py
index 84bf7d26..494e8c0a 100644
--- a/src/tempor/plugins/regression/plugin_nn_regressor.py
+++ b/src/tempor/plugins/regression/plugin_nn_regressor.py
@@ -85,10 +85,10 @@ def __init__(
                 Train/test split ratio. Defaults to ``0.8``.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("regression.nn_regressor", n_iter=50)
diff --git a/src/tempor/plugins/regression/plugin_ode_regressor.py b/src/tempor/plugins/regression/plugin_ode_regressor.py
index c27ba5b6..09515b81 100644
--- a/src/tempor/plugins/regression/plugin_ode_regressor.py
+++ b/src/tempor/plugins/regression/plugin_ode_regressor.py
@@ -79,10 +79,10 @@ def __init__(
                 Custom data sampler for training. Defaults to `None`.
 
         Example:
-            >>> from tempor.utils.datasets.sine import SineDataloader
+            >>> from tempor.utils.dataloaders.sine import SineDataLoader
             >>> from tempor.plugins import plugin_loader
             >>>
-            >>> dataset = SineDataloader().load()
+            >>> dataset = SineDataLoader().load()
             >>>
             >>> # Load the model:
             >>> model = plugin_loader.get("regression.ode_regressor", n_iter=50)
diff --git a/src/tempor/plugins/survival/plugin_ddh.py b/src/tempor/plugins/survival/plugin_ddh.py
deleted file mode 100644
index c1a830b5..00000000
--- a/src/tempor/plugins/survival/plugin_ddh.py
+++ /dev/null
@@ -1,117 +0,0 @@
-import dataclasses
-
-from typing_extensions import Literal
-
-import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
-from tempor.plugins.survival import BaseSurvivalAnalysis
-
-RnnModes = Literal[
-    "GRU",
-    "LSTM",
-    "RNN",
-    # "Transformer",
-]
-OutputModes = Literal[
-    "MLP",
-    "LSTM",
-    "GRU",
-    "RNN",
-    # "Transformer",
-    # "TCN",
-    # "InceptionTime",
-    # "InceptionTimePlus",
-    # "ResCNN",
-    # "XCM",
-]
-
-
-@dataclasses.dataclass
-class _Params:
-    n_iter: int
-    batch_size: int
-    lr: float
-    n_layers_hidden: int
-    n_units_hidden: int
-    split: int
-    rnn_type: RnnModes
-    alpha: float
-    beta: float
-    sigma: float
-    random_state: int
-    dropout: float
-    device: str
-    patience: int
-    output_type: OutputModes
-
-
-@plugins.register_plugin(name="dynamic_deephit", category="survival")
-class DynamicDeepHitSurvivalAnalysis(BaseSurvivalAnalysis):
-    ParamsDefinition = _Params
-
-    def __init__(
-        self,
-        *,
-        n_iter: int = 1000,
-        batch_size: int = 100,
-        lr: float = 1e-3,
-        n_layers_hidden: int = 1,
-        n_units_hidden: int = 40,
-        split: int = 100,
-        rnn_type: RnnModes = "GRU",
-        alpha: float = 0.34,
-        beta: float = 0.27,
-        sigma: float = 0.21,
-        random_state: int = 0,
-        dropout: float = 0.06,
-        device: str = "cpu",
-        patience: int = 20,
-        output_type: OutputModes = "MLP",
-    ) -> None:
-        super().__init__(
-            n_iter=n_iter,
-            batch_size=batch_size,
-            lr=lr,
-            n_layers_hidden=n_layers_hidden,
-            n_units_hidden=n_units_hidden,
-            split=split,
-            rnn_type=rnn_type,
-            alpha=alpha,
-            beta=beta,
-            sigma=sigma,
-            random_state=random_state,
-            dropout=dropout,
-            device=device,
-            patience=patience,
-            output_type=output_type,
-        )
-        print(self.params)
-        # WIP...
-
-    def fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "DynamicDeepHitSurvivalAnalysis":  # pyright: ignore
-        raise NotImplementedError
-
-    def _fit(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> "DynamicDeepHitSurvivalAnalysis":  # pyright: ignore
-        raise NotImplementedError
-
-    def _predict(
-        self,
-        data: dataset.Dataset,
-        *args,
-        **kwargs,
-    ) -> samples.TimeSeriesSamples:
-        raise NotImplementedError
-
-    @staticmethod
-    def hyperparameter_space(*args, **kwargs):
-        return []
diff --git a/src/tempor/plugins/survival/__init__.py b/src/tempor/plugins/time_to_event/__init__.py
similarity index 55%
rename from src/tempor/plugins/survival/__init__.py
rename to src/tempor/plugins/time_to_event/__init__.py
index 299eca5d..639edc0a 100644
--- a/src/tempor/plugins/survival/__init__.py
+++ b/src/tempor/plugins/time_to_event/__init__.py
@@ -1,10 +1,11 @@
 import abc
 
 import pydantic
-from typing_extensions import Self
+from typing_extensions import Any, Self
 
+import tempor.exc
 import tempor.plugins.core as plugins
-from tempor.data import dataset, samples
+from tempor.data import data_typing, dataset, samples
 
 
 def check_data_class(data):
@@ -15,7 +16,7 @@ def check_data_class(data):
         )
 
 
-class BaseSurvivalAnalysis(plugins.BasePredictor):
+class BaseTimeToEventAnalysis(plugins.BasePredictor):
     def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
         super().__init__(**params)
 
@@ -25,25 +26,33 @@ def fit(self, data: dataset.Dataset, *args, **kwargs) -> Self:
         return self
 
     @pydantic.validate_arguments(config=dict(arbitrary_types_allowed=True))
-    def predict(
+    def predict(  # type: ignore[override]  # pylint: disable=arguments-differ
         self,
         data: dataset.Dataset,
+        horizons: data_typing.TimeIndex,
         *args,
         **kwargs,
     ) -> samples.TimeSeriesSamples:  # Output is risk scores at time points, hence `samples.TimeSeriesSamples`.
         check_data_class(data)
-        return super().predict(data, *args, **kwargs)
+        return super().predict(data, horizons, *args, **kwargs)
+
+    def predict_proba(self, data: dataset.Dataset, *args, **kwargs) -> Any:
+        raise tempor.exc.UnsupportedSetupException(
+            "`predict_proba` method is not supported in the time-to-event analysis setting"
+        )
 
     @abc.abstractmethod
-    def _predict(self, data: dataset.Dataset, *args, **kwargs) -> samples.TimeSeriesSamples:
+    def _predict(  # type: ignore[override]  # pylint: disable=arguments-differ
+        self, data: dataset.Dataset, horizons: data_typing.TimeIndex, *args, **kwargs
+    ) -> samples.TimeSeriesSamples:  # pragma: no cover
         ...
 
 
-plugins.register_plugin_category("survival", BaseSurvivalAnalysis)
+plugins.register_plugin_category("time_to_event", BaseTimeToEventAnalysis)
 
 plugins.importing.import_plugins(__file__)
 
 __all__ = [  # pyright: ignore
     *plugins.importing.gather_modules_names(__file__),
-    "BaseSurvivalAnalysis",
+    "BaseTimeToEventAnalysis",
 ]
diff --git a/src/tempor/plugins/time_to_event/plugin_ddh.py b/src/tempor/plugins/time_to_event/plugin_ddh.py
new file mode 100644
index 00000000..96bf4e31
--- /dev/null
+++ b/src/tempor/plugins/time_to_event/plugin_ddh.py
@@ -0,0 +1,177 @@
+import dataclasses
+from typing import TYPE_CHECKING, List, Optional, cast
+
+import numpy as np
+from typing_extensions import Self
+
+import tempor.exc
+import tempor.plugins.core as plugins
+from tempor.data import data_typing, dataset, samples
+from tempor.models.ddh import (
+    DynamicDeepHitModel,
+    OutputMode,
+    RnnMode,
+    output_modes,
+    rnn_modes,
+)
+from tempor.plugins.core._params import CategoricalParams, FloatParams, IntegerParams
+from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
+
+
+@dataclasses.dataclass
+class DynamicDeepHitTimeToEventAnalysisParams:
+    # TODO: Docstring.
+    n_iter: int = 1000
+    batch_size: int = 100
+    lr: float = 1e-3
+    n_layers_hidden: int = 1
+    n_units_hidden: int = 40
+    split: int = 100
+    rnn_mode: RnnMode = "GRU"
+    alpha: float = 0.34
+    beta: float = 0.27
+    sigma: float = 0.21
+    dropout: float = 0.06
+    device: str = "cpu"
+    patience: int = 20
+    output_mode: OutputMode = "MLP"
+    random_state: int = 0
+
+
+# TODO: Docstring.
+@plugins.register_plugin(name="dynamic_deephit", category="time_to_event")
+class DynamicDeepHitTimeToEventAnalysis(BaseTimeToEventAnalysis):
+    ParamsDefinition = DynamicDeepHitTimeToEventAnalysisParams
+    params: DynamicDeepHitTimeToEventAnalysisParams  # type: ignore
+
+    def __init__(self, **params) -> None:  # pylint: disable=useless-super-delegation
+        """Dynamic DeepHit survival analysis model.
+
+        Args:
+            **params:
+                Parameters and defaults as defined in :class:`DynamicDeepHitTimeToEventAnalysisParams`.
+        """
+        super().__init__(**params)
+
+        self.model = DynamicDeepHitModel(
+            split=self.params.split,
+            n_layers_hidden=self.params.n_layers_hidden,
+            n_units_hidden=self.params.n_units_hidden,
+            rnn_mode=self.params.rnn_mode,
+            alpha=self.params.alpha,
+            beta=self.params.beta,
+            sigma=self.params.sigma,
+            dropout=self.params.dropout,
+            patience=self.params.patience,
+            lr=self.params.lr,
+            batch_size=self.params.batch_size,
+            n_iter=self.params.n_iter,
+            output_mode=self.params.output_mode,
+            device=self.params.device,
+        )
+
+    def _merge_data(
+        self,
+        static: Optional[np.ndarray],
+        temporal: List[np.ndarray],
+        observation_times: List[np.ndarray],
+    ) -> np.ndarray:
+        if static is None:
+            static = np.zeros((len(temporal), 0))
+
+        merged = []
+        for idx, item in enumerate(temporal):  # pylint: disable=unused-variable
+            local_static = static[idx].reshape(1, -1)
+            local_static = np.repeat(local_static, len(temporal[idx]), axis=0)
+            tst = np.concatenate(
+                [
+                    temporal[idx],
+                    local_static,
+                    np.asarray(observation_times[idx]).reshape(-1, 1),
+                ],
+                axis=1,
+            )
+            merged.append(tst)
+
+        return np.array(merged, dtype=object)
+
+    def _validate_data(self, data: dataset.TimeToEventAnalysisDataset) -> None:
+        if data.predictive.targets.num_features > 1:
+            raise tempor.exc.UnsupportedSetupException(
+                f"{self.__class__.__name__} does not currently support more than one event feature, "
+                f"but features found were: {data.predictive.targets.dataframe().columns}"
+            )
+        # TODO: This needs investigating - likely different length sequences aren't handled properly.
+        # if not data.time_series.num_timesteps_equal():
+        #     raise tempor.exc.UnsupportedSetupException(
+        #         f"{self.__class__.__name__} currently requires all samples to have the same number of timesteps, "
+        #         f"but found timesteps of varying lengths {np.unique(data.time_series.num_timesteps()).tolist()}"
+        #     )
+
+    def _convert_data(self, data: dataset.TimeToEventAnalysisDataset):
+        if data.has_static:
+            static = data.static.numpy() if data.static is not None else None
+        else:
+            static = np.zeros((data.time_series.num_samples, 0))
+        temporal = [df.to_numpy() for df in data.time_series.list_of_dataframes()]
+        observation_times = data.time_series.time_indexes_float()
+        if data.predictive is not None:
+            event_times, event_values = (
+                df.to_numpy().reshape((-1,)) for df in data.predictive.targets.split_as_two_dataframes()
+            )
+        else:
+            event_times, event_values = None, None
+        return (static, temporal, observation_times, event_times, event_values)
+
+    def _fit(
+        self,
+        data: dataset.Dataset,
+        *args,
+        **kwargs,
+    ) -> Self:
+        data = cast(dataset.TimeToEventAnalysisDataset, data)
+        self._validate_data(data)
+        (static, temporal, observation_times, event_times, event_values) = self._convert_data(data)
+        processed_data = self._merge_data(static, temporal, observation_times)
+        if TYPE_CHECKING:  # pragma: no cover
+            assert event_times is not None and event_values is not None  # nosec B101
+
+        self.model.fit(processed_data, event_times, event_values)
+
+        return self
+
+    def _predict(  # type: ignore[override]
+        self,
+        data: dataset.Dataset,
+        horizons: data_typing.TimeIndex,
+        *args,
+        **kwargs,
+    ) -> samples.TimeSeriesSamples:
+        # NOTE: kwargs will be passed to DynamicDeepHitModel.predict_risk().
+        # E.g. `bs` batch size parameter can be provided this way.
+        data = cast(dataset.TimeToEventAnalysisDataset, data)
+        self._validate_data(data)
+        (static, temporal, observation_times, _, _) = self._convert_data(data)
+        processed_data = self._merge_data(static, temporal, observation_times)
+        risk = self.model.predict_risk(processed_data, horizons, **kwargs)
+        return samples.TimeSeriesSamples(
+            risk.reshape((risk.shape[0], risk.shape[1], 1)),
+            sample_index=data.time_series.sample_index(),
+            time_indexes=[horizons] * data.time_series.num_samples,  # pyright: ignore
+            feature_index=["risk_score"],
+        )
+
+    @staticmethod
+    def hyperparameter_space(*args, **kwargs):
+        return [
+            IntegerParams(name="n_units_hidden", low=10, high=100, step=10),
+            IntegerParams(name="n_layers_hidden", low=1, high=4),
+            CategoricalParams(name="batch_size", choices=[100, 200, 500]),
+            CategoricalParams(name="lr", choices=[1e-2, 1e-3, 1e-4]),
+            CategoricalParams(name="rnn_mode", choices=list(rnn_modes)),
+            CategoricalParams(name="output_mode", choices=list(output_modes)),
+            FloatParams(name="alpha", low=0.0, high=0.5),
+            FloatParams(name="sigma", low=0.0, high=0.5),
+            FloatParams(name="beta", low=0.0, high=0.5),
+            FloatParams(name="dropout", low=0.0, high=0.2),
+        ]
diff --git a/src/tempor/utils/dataloaders/__init__.py b/src/tempor/utils/dataloaders/__init__.py
new file mode 100644
index 00000000..978ab2a8
--- /dev/null
+++ b/src/tempor/utils/dataloaders/__init__.py
@@ -0,0 +1,9 @@
+from .google_stocks import GoogleStocksDataLoader
+from .pbc import PBCDataLoader
+from .sine import SineDataLoader
+
+__all__ = [
+    "GoogleStocksDataLoader",
+    "PBCDataLoader",
+    "SineDataLoader",
+]
diff --git a/src/tempor/utils/datasets/google_stocks.py b/src/tempor/utils/dataloaders/google_stocks.py
similarity index 71%
rename from src/tempor/utils/datasets/google_stocks.py
rename to src/tempor/utils/dataloaders/google_stocks.py
index 2462dbef..39af9b85 100644
--- a/src/tempor/utils/datasets/google_stocks.py
+++ b/src/tempor/utils/dataloaders/google_stocks.py
@@ -6,27 +6,29 @@
 import requests
 from sklearn.preprocessing import MinMaxScaler
 
-from tempor.data.dataset import OneOffPredictionDataset
+from tempor.data import dataloader, dataset
 
-URL = "https://raw.githubusercontent.com/PacktPublishing/Learning-Pandas-Second-Edition/master/data/goog.csv"
 
+# TODO: Docstring to explain the dataset.
+class GoogleStocksDataLoader(dataloader.OneOffPredictionDataLoader):
+    def __init__(self, *args, seq_len: int = 10, **kwargs) -> None:
+        super().__init__(*args, **kwargs)
 
-class GoogleStocksDataloader:
-    def __init__(self, seq_len: int = 10) -> None:
         self.seq_len = seq_len
+        self.df_path = Path(self.dataset_dir()) / "goog.csv"
 
-        df_folder = Path(__file__).parent / "data"
-        df_folder.mkdir(parents=True, exist_ok=True)
-        df_path = "goog.csv"
+    @staticmethod
+    def url() -> str:
+        return "https://raw.githubusercontent.com/PacktPublishing/Learning-Pandas-Second-Edition/master/data/goog.csv"
 
-        self.df_path = df_folder / df_path
+    @staticmethod
+    def dataset_dir() -> str:
+        return str(Path(GoogleStocksDataLoader.data_root_dir) / "google_stocks")
 
-    def load(
-        self,
-    ) -> OneOffPredictionDataset:
+    def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
         # Load Google Data
         if not self.df_path.exists():
-            s = requests.get(URL, timeout=5).content
+            s = requests.get(self.url(), timeout=5).content
             df = pd.read_csv(io.StringIO(s.decode("utf-8")))
 
             df.to_csv(self.df_path, index=False)
@@ -69,7 +71,7 @@ def load(
         time_series_df.sort_index(level=[0, 1], inplace=True)
         outcome_df.sort_index(inplace=True)
 
-        return OneOffPredictionDataset(
+        return dataset.OneOffPredictionDataset(
             time_series=time_series_df,
             targets=outcome_df,
             sample_index="sample_idx",
diff --git a/src/tempor/utils/dataloaders/pbc.py b/src/tempor/utils/dataloaders/pbc.py
new file mode 100644
index 00000000..b98a5c7b
--- /dev/null
+++ b/src/tempor/utils/dataloaders/pbc.py
@@ -0,0 +1,134 @@
+import io
+import os
+
+import numpy as np
+import pandas as pd
+import requests
+from sklearn.impute import SimpleImputer
+from sklearn.preprocessing import LabelEncoder, StandardScaler
+
+from tempor.data import dataloader, dataset, utils
+
+
+# TODO: Docstring to explain the dataset.
+class PBCDataLoader(dataloader.TimeToEventAnalysisDataLoader):
+    def __init__(self, *args, **kwargs) -> None:
+        self.datafile_path = os.path.join(self.dataset_dir(), "pbc2.csv")
+        super().__init__(*args, **kwargs)
+
+    @staticmethod
+    def dataset_dir() -> str:
+        return os.path.join(PBCDataLoader.data_root_dir, "pbc/")
+
+    @staticmethod
+    def url() -> str:
+        return (
+            "https://raw.githubusercontent.com/autonlab/auton-survival/"
+            "cf583e598ec9ab92fa5d510a0ca72d46dfe0706f/dsm/datasets/pbc2.csv"
+        )
+
+    def load(self, **kwargs) -> dataset.TimeToEventAnalysisDataset:
+        if os.path.exists(self.datafile_path):
+            data = pd.read_csv(self.datafile_path)
+        else:
+            request = requests.get(self.url(), timeout=5).content
+            data = pd.read_csv(io.StringIO(request.decode("utf-8")))
+            data.to_csv(self.datafile_path, index=False)
+
+        data["time"] = data["years"] - data["year"]
+        data = data.sort_values(by=["id", "time"], ignore_index=True)
+        data["histologic"] = data["histologic"].astype(str)
+        dat_cat = data[["drug", "sex", "ascites", "hepatomegaly", "spiders", "edema", "histologic"]].copy()
+        dat_num = data[["serBilir", "serChol", "albumin", "alkaline", "SGOT", "platelets", "prothrombin"]].copy()
+        age = data["age"] + data["years"]
+
+        for col in dat_cat.columns:
+            dat_cat[col] = LabelEncoder().fit_transform(dat_cat[col])
+
+        x = pd.concat([dat_cat, dat_num, pd.Series(age, name="age")], axis=1)
+
+        time = data["time"]
+        event = data["status2"]
+
+        x = pd.DataFrame(
+            SimpleImputer(missing_values=np.nan, strategy="mean").fit_transform(x),
+            columns=x.columns,
+        )
+        scaled_cols = list(dat_num.columns) + ["age"]
+
+        x_scaled = x.copy()
+        x_scaled[scaled_cols] = pd.DataFrame(
+            StandardScaler().fit_transform(x[scaled_cols]),
+            columns=scaled_cols,
+            index=data.index,
+        )
+
+        x_static, x_temporal, t, e = [], [], [], []
+        time_indexes, event_feat_full = [], []
+
+        temporal_cols = [
+            "drug",
+            "ascites",
+            "hepatomegaly",
+            "spiders",
+            "edema",
+            "histologic",
+            "serBilir",
+            "serChol",
+            "albumin",
+            "alkaline",
+            "SGOT",
+            "platelets",
+            "prothrombin",
+            "age",
+        ]
+        static_cols = ["sex"]
+        sample_index = []
+
+        for id_ in sorted(list(set(data["id"]))):
+            sample_index.append(id_)
+            patient = x_scaled[data["id"] == id_]
+
+            patient_static = patient[static_cols]
+            if not (patient_static.iloc[0] == patient_static).all().all():  # pragma: no cover
+                # This is a sanity check.
+                raise RuntimeError(
+                    f"Found patient with static data that are not actually fixed:\nid: {id_}\n{patient_static}"
+                )
+            x_static.append(patient_static.values[0].tolist())
+
+            patient_temporal = patient[temporal_cols]
+            patient_temporal.index = time[data["id"] == id_].values
+
+            x_temporal.append(patient_temporal)
+
+            events = event[data["id"] == id_].values
+            times = time[data["id"] == id_].values
+            evt = np.amax(events)  # pyright: ignore
+            if evt == 0:
+                pos = np.max(np.where(events == evt))  # Last censored
+            else:
+                pos = np.min(np.where(events == evt))  # First event
+
+            t.append(times[pos])
+            e.append({0: False, 1: True}[evt])
+
+            time_indexes.append(list(times))
+            event_feat_full.append(events)
+
+        df_static = pd.DataFrame(x_static, columns=static_cols, index=sample_index)
+        df_time_series = utils.list_of_dataframes_to_multiindex_timeseries_dataframe(
+            x_temporal,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=temporal_cols,
+        )
+        df_event = utils.event_time_value_pairs_to_event_dataframe(
+            [(t, e)], sample_index=sample_index, feature_index=["status"]
+        )
+
+        return dataset.TimeToEventAnalysisDataset(
+            time_series=df_time_series,
+            static=df_static,
+            targets=df_event,
+        )
diff --git a/src/tempor/utils/datasets/sine.py b/src/tempor/utils/dataloaders/sine.py
similarity index 64%
rename from src/tempor/utils/datasets/sine.py
rename to src/tempor/utils/dataloaders/sine.py
index 7951c198..625cf22a 100644
--- a/src/tempor/utils/datasets/sine.py
+++ b/src/tempor/utils/dataloaders/sine.py
@@ -1,25 +1,13 @@
 import numpy as np
 import pandas as pd
 
-from tempor.data.dataset import OneOffPredictionDataset
+from tempor.data import dataloader, dataset
 
 
-class SineDataloader:
-    """Sine data generation.
-
-    Args:
-
-    - no: the number of samples
-    - seq_len: sequence length of the time-series
-    - dim: feature dimensions
-
-    Returns:
-    - data: generated data
-
-    """
-
+class SineDataLoader(dataloader.OneOffPredictionDataLoader):
     def __init__(
         self,
+        *args,
         no: int = 100,
         seq_len: int = 10,
         temporal_dim: int = 5,
@@ -27,7 +15,29 @@ def __init__(
         freq_scale: float = 1,
         with_missing: bool = False,
         miss_ratio: float = 0.1,
+        **kwargs,
     ) -> None:
+        """Sine data generation.
+
+        Args:
+            no (int, optional):
+                The number of samples. Defaults to ``100``.
+            seq_len (int, optional):
+                Sequence length of the time-series. Defaults to ``10``.
+            temporal_dim (int, optional):
+                Time-series feature dimensions. Defaults to ``5``.
+            static_dim (int, optional):
+                Static feature dimensions. Defaults to ``4``.
+            freq_scale (float, optional):
+                The frequency scaling multiplier for the signal (``sin(freq_scale * random_drawn_freq * x + phase)``).
+                Defaults to ``1``.
+            with_missing (bool, optional):
+                Whether to generate missing data points (`np.nan`). Defaults to `False`.
+            miss_ratio (float, optional):
+                The ration of missing data points. Defaults to ``0.1``.
+        """
+        super().__init__(*args, **kwargs)
+
         self.no = no
         self.seq_len = seq_len
         self.temporal_dim = temporal_dim
@@ -36,10 +46,16 @@ def __init__(
         self.with_missing = with_missing
         self.miss_ratio = miss_ratio
 
-    def load(
-        self,
-    ) -> OneOffPredictionDataset:
-        # Initialize the output
+    @staticmethod
+    def url() -> None:
+        return None
+
+    @staticmethod
+    def dataset_dir() -> None:
+        return None
+
+    def load(self, **kwargs) -> dataset.OneOffPredictionDataset:
+        # Initialize the output.
 
         static_data = pd.DataFrame(np.random.rand(self.no, self.static_dim))
         static_data["sample_idx"] = [str(i) for i in range(self.no)]
@@ -58,7 +74,7 @@ def load(
 
         outcome.set_index(keys=["sample_idx"], drop=True, inplace=True)
 
-        # Generate sine data
+        # Generate sine data.
 
         for i in range(self.no):
 
@@ -70,17 +86,17 @@ def load(
 
             for k in range(self.temporal_dim):  # pylint: disable=unused-variable
 
-                # Randomly drawn frequency and phase
+                # Randomly drawn frequency and phase:
                 freq = np.random.beta(2, 2)
                 phase = np.random.normal()
 
-                # Generate sine signal based on the drawn frequency and phase
+                # Generate sine signal based on the drawn frequency and phase:
                 temp_data = [np.sin(self.freq_scale * freq * j + phase) for j in range(seq_len)]
 
                 local.append(temp_data)
 
-            # Align row/column
-            # DataFrame with index - time, and columns - temporal features
+            # Align row/column.
+            # DataFrame with index - time, and columns - temporal features.
             local_data = pd.DataFrame(np.transpose(np.asarray(local)))
             local_data.columns = local_data.columns.astype(str)
 
@@ -88,7 +104,7 @@ def load(
                 for col in local_data.columns:
                     local_data.loc[local_data.sample(frac=self.miss_ratio).index, col] = np.nan
 
-            # Stack the generated data
+            # Stack the generated data:
             local_data["sample_idx"] = str(i)
             local_data["time_idx"] = list(range(seq_len))
             temporal_data.append(local_data)
@@ -100,7 +116,7 @@ def load(
         static_data.sort_index(inplace=True)
         outcome.sort_index(inplace=True)
 
-        return OneOffPredictionDataset(
+        return dataset.OneOffPredictionDataset(
             time_series=time_series_df,
             targets=outcome,
             static=static_data,
diff --git a/src/tempor/utils/datasets/__init__.py b/src/tempor/utils/datasets/__init__.py
deleted file mode 100644
index e69de29b..00000000


Test patch:
diff --git a/tests/benchmarks/test_benchmark.py b/tests/benchmarks/test_benchmark.py
index bbd4eeca..485ebd87 100644
--- a/tests/benchmarks/test_benchmark.py
+++ b/tests/benchmarks/test_benchmark.py
@@ -1,12 +1,18 @@
+from typing import Callable
+
 from tempor.benchmarks import (
     benchmark_models,
     classifier_supported_metrics,
     regression_supported_metrics,
+    time_to_event_supported_metrics,
 )
 from tempor.plugins import plugin_loader
 from tempor.plugins.pipeline import Pipeline
-from tempor.utils.datasets.google_stocks import GoogleStocksDataloader
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders import (
+    GoogleStocksDataLoader,
+    PBCDataLoader,
+    SineDataLoader,
+)
 
 
 def test_classifier_benchmark() -> None:
@@ -25,10 +31,14 @@ def test_classifier_benchmark() -> None:
             plugin_loader.get("classification.nn_classifier", n_iter=10),
         ),
     ]
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     aggr_score, per_test_score = benchmark_models(
-        task_type="classification", tests=testcases, data=dataset, n_splits=2, random_state=0
+        task_type="classification",
+        tests=testcases,
+        data=dataset,
+        n_splits=2,
+        random_state=0,
     )
 
     for testcase, _ in testcases:
@@ -58,10 +68,14 @@ def test_regressor_benchmark() -> None:
             plugin_loader.get("regression.nn_regressor", n_iter=10),
         ),
     ]
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     aggr_score, per_test_score = benchmark_models(
-        task_type="regression", tests=testcases, data=dataset, n_splits=2, random_state=0
+        task_type="regression",
+        tests=testcases,
+        data=dataset,
+        n_splits=2,
+        random_state=0,
     )
 
     for testcase, _ in testcases:
@@ -73,3 +87,43 @@ def test_regressor_benchmark() -> None:
 
         for testcase, _ in testcases:
             assert metric in per_test_score[testcase].index
+
+
+def test_time_to_event_benchmark(get_event0_time_percentiles: Callable) -> None:
+    testcases = [
+        (
+            "pipeline1",
+            Pipeline(
+                [
+                    "preprocessing.imputation.ffill",
+                    "time_to_event.dynamic_deephit",
+                ]
+            )({"dynamic_deephit": {"n_iter": 10}}),
+        ),
+        (
+            "plugin1",
+            plugin_loader.get("time_to_event.dynamic_deephit", n_iter=10),
+        ),
+    ]
+    dataset = PBCDataLoader().load()
+
+    horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+
+    aggr_score, per_test_score = benchmark_models(
+        task_type="time_to_event",
+        tests=testcases,
+        data=dataset,
+        n_splits=2,
+        random_state=0,
+        horizons=horizons,
+    )
+
+    for testcase, _ in testcases:
+        assert testcase in aggr_score.columns
+        assert testcase in per_test_score
+
+    for metric in time_to_event_supported_metrics:
+        assert metric in aggr_score.index
+
+        for testcase, _ in testcases:
+            assert metric in per_test_score[testcase].index
diff --git a/tests/benchmarks/test_evaluation.py b/tests/benchmarks/test_evaluation.py
index c9c93a82..f144b3cc 100644
--- a/tests/benchmarks/test_evaluation.py
+++ b/tests/benchmarks/test_evaluation.py
@@ -1,4 +1,4 @@
-from typing import Any
+from typing import Any, Callable
 
 import pytest
 
@@ -6,13 +6,18 @@
     classifier_supported_metrics,
     evaluate_classifier,
     evaluate_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.utils.datasets.google_stocks import GoogleStocksDataloader
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders import (
+    GoogleStocksDataLoader,
+    PBCDataLoader,
+    SineDataLoader,
+)
 
 
 @pytest.mark.parametrize(
@@ -34,7 +39,7 @@
 )
 @pytest.mark.parametrize("n_splits", [2])
 def test_classifier_evaluation(model_template: Any, n_splits: int) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     scores = evaluate_classifier(model_template, dataset, n_splits=n_splits, seed=0)
 
@@ -47,7 +52,7 @@ def test_classifier_evaluation(model_template: Any, n_splits: int) -> None:
 
 @pytest.mark.parametrize("n_splits", [-1, 0, 1])
 def test_classifier_evaluation_fail(n_splits: int) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     with pytest.raises(ValueError):
         evaluate_classifier(
@@ -74,7 +79,7 @@ def test_classifier_evaluation_fail(n_splits: int) -> None:
 )
 @pytest.mark.parametrize("n_splits", [2])
 def test_regressor_evaluation(model_template: Any, n_splits: int) -> None:
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     scores = evaluate_regressor(model_template, dataset, n_splits=n_splits, seed=0)
 
@@ -87,7 +92,55 @@ def test_regressor_evaluation(model_template: Any, n_splits: int) -> None:
 
 @pytest.mark.parametrize("n_splits", [-1, 0, 1])
 def test_regressor_evaluation_fail(n_splits: int) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     with pytest.raises(ValueError):
         evaluate_regressor(plugin_loader.get("regression.nn_regressor", n_iter=50), dataset, n_splits=n_splits, seed=0)
+
+
+@pytest.mark.parametrize(
+    "model_template",
+    [
+        Pipeline(
+            [
+                "preprocessing.imputation.ffill",
+                "time_to_event.dynamic_deephit",
+            ]
+        )({"dynamic_deephit": {"n_iter": 10}}),
+        Pipeline(
+            [
+                "time_to_event.dynamic_deephit",
+            ]
+        )({"dynamic_deephit": {"n_iter": 10}}),
+        plugin_loader.get("time_to_event.dynamic_deephit", n_iter=10),
+    ],
+)
+@pytest.mark.parametrize("n_splits", [2])
+def test_time_to_event_evaluation(model_template: Any, n_splits: int, get_event0_time_percentiles: Callable) -> None:
+    dataset = PBCDataLoader().load()
+
+    horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+
+    scores = evaluate_time_to_event(model_template, dataset, n_splits=n_splits, seed=0, horizons=horizons)
+
+    for out_metric in output_metrics:
+        assert out_metric in scores
+
+    for metric in time_to_event_supported_metrics:
+        assert metric in scores.index
+
+
+@pytest.mark.parametrize("n_splits", [-1, 0, 1])
+def test_time_to_event_evaluation_fail(n_splits: int, get_event0_time_percentiles: Callable) -> None:
+    dataset = PBCDataLoader().load()
+
+    horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+
+    with pytest.raises(ValueError):
+        evaluate_time_to_event(
+            plugin_loader.get("time_to_event.nn_regressor", n_iter=50),
+            dataset,
+            n_splits=n_splits,
+            seed=0,
+            horizons=horizons,
+        )
diff --git a/tests/conftest.py b/tests/conftest.py
index 0faadca7..478cc008 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,25 @@
 import copy
 from types import ModuleType
-from typing import Any, List, Tuple
+from typing import TYPE_CHECKING, Any, List, Tuple
 
+if TYPE_CHECKING:
+    from tempor.data.dataset import TimeToEventAnalysisDataset
+
+import numpy as np
 import pytest
 
+# --- Reusable functions. ---
+
+
+@pytest.fixture
+def get_event0_time_percentiles():
+    def func(dataset: "TimeToEventAnalysisDataset", horizon_percentiles: List):
+        event0_times = dataset.predictive.targets.split_as_two_dataframes()[0].to_numpy().reshape((-1,))
+        return np.quantile(event0_times, horizon_percentiles).tolist()
+
+    return func
+
+
 # --- Test utilities. ---
 
 
@@ -13,8 +29,8 @@ def patch_module(monkeypatch, request):
     and optionally refresh pydantic.
 
     For reference:
-    - https://github.com/streamlit/streamlit/issues/3218#issuecomment-1050647471
-    - https://docs.pytest.org/en/6.2.x/fixture.html#adding-finalizers-directly
+        - https://github.com/streamlit/streamlit/issues/3218#issuecomment-1050647471
+        - https://docs.pytest.org/en/6.2.x/fixture.html#adding-finalizers-directly
 
     Args:
         monkeypatch: pytest monkeypatch object.
diff --git a/tests/core/test_core_utils.py b/tests/core/test_core_utils.py
new file mode 100644
index 00000000..bb642711
--- /dev/null
+++ b/tests/core/test_core_utils.py
@@ -0,0 +1,15 @@
+# pylint: disable=redefined-outer-name
+
+import pytest
+
+from tempor.core import utils
+
+
+@pytest.fixture
+def dummy_version() -> str:
+    return "1.5.3"
+
+
+def test_get_version(dummy_version: str):
+    major, minor, patch = utils.get_version(dummy_version)
+    assert (major, minor, patch) == (1, 5, 3)
diff --git a/tests/core/test_register_methods_decorator.py b/tests/core/test_register_methods_decorator.py
deleted file mode 100644
index 8d8b8285..00000000
--- a/tests/core/test_register_methods_decorator.py
+++ /dev/null
@@ -1,211 +0,0 @@
-# pylint: disable=redefined-outer-name, unused-argument
-
-from typing import Callable, Dict
-from unittest.mock import Mock
-
-import pytest
-
-import tempor.core
-
-
-def test_register_methods_success():
-    mock_case_a_method = Mock()
-    mock_case_b_method = Mock()
-
-    class DummyOwnerClass:
-        methods_for_stuff: Dict[str, Callable]
-
-    class RegisterMethodDecoratorToTest(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    class DummyInheredClass(DummyOwnerClass):
-        @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_case_a_method(dummy=dummy, args=args)
-            return "dummy return"
-
-        @RegisterMethodDecoratorToTest.register_method_for("case_b")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_case_b_method(dummy=dummy, args=args)
-            return "dummy return"
-
-    to_test = DummyInheredClass()
-
-    assert "case_a" in to_test.methods_for_stuff
-    assert "case_b" in to_test.methods_for_stuff
-
-    to_test.methods_for_stuff["case_a"](self=to_test, dummy="dummy_a", args="args_a")
-    to_test.methods_for_stuff["case_b"](self=to_test, dummy="dummy_b", args="args_b")
-
-    mock_case_a_method.assert_called_once_with(dummy="dummy_a", args="args_a")
-    mock_case_b_method.assert_called_once_with(dummy="dummy_b", args="args_b")
-
-
-def test_fail_registered_multiple():
-    class DummyOwnerClass:
-        methods_for_stuff: Dict[str, Callable]
-
-    class RegisterMethodDecoratorToTest(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    with pytest.raises(TypeError, match=".*registered multiple.*"):
-
-        class DummyInheredClass(DummyOwnerClass):
-            @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-            @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass()
-
-
-def test_fail_wrong_owner_class():
-    # Note, error raised in decorator won't be captured by pytest.raises, so using a workaround.
-
-    import traceback
-
-    class DummyOwnerClass:
-        methods_for_stuff: Dict[str, Callable]
-
-    class RegisterMethodDecoratorToTest(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    class WrongClass:
-        pass
-
-    try:
-
-        class DummyInheredClass(WrongClass):
-            @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass()
-
-    except Exception:  # pylint: disable=broad-except
-        ex_trace = traceback.format_exc()
-        assert TypeError.__name__ in ex_trace
-        assert "subclass of" in ex_trace
-
-
-def test_inheritance_works():
-    mock_parent_case_a_method = Mock()
-    mock_parent_case_b_method = Mock()
-    mock_child_case_a_method = Mock()
-    mock_child_case_c_method = Mock()
-
-    class DummyOwnerClass:
-        methods_for_stuff: Dict[str, Callable]
-
-    class RegisterMethodDecoratorToTest(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    class DummyInheredClass(DummyOwnerClass):
-        @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_parent_case_a_method(dummy=dummy, args=args)
-            return "dummy return"
-
-        @RegisterMethodDecoratorToTest.register_method_for("case_b")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_parent_case_b_method(dummy=dummy, args=args)
-            return "dummy return"
-
-    class DummyInheredAgainClass(DummyInheredClass):
-        @RegisterMethodDecoratorToTest.register_method_for("case_a")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_child_case_a_method(dummy=dummy, args=args)
-            return "dummy return"
-
-        @RegisterMethodDecoratorToTest.register_method_for("case_c")  # pyright: ignore
-        def _(self, dummy, args):
-            mock_child_case_c_method(dummy=dummy, args=args)
-            return "dummy return"
-
-    to_test = DummyInheredAgainClass()
-
-    to_test.methods_for_stuff["case_a"](self=to_test, dummy="dummy_a", args="args_a")
-    to_test.methods_for_stuff["case_b"](self=to_test, dummy="dummy_b", args="args_b")
-    to_test.methods_for_stuff["case_c"](self=to_test, dummy="dummy_c", args="args_c")
-
-    mock_parent_case_a_method.assert_not_called()
-    mock_parent_case_b_method.assert_called_once_with(dummy="dummy_b", args="args_b")
-    mock_child_case_a_method.assert_called_once_with(dummy="dummy_a", args="args_a")
-    mock_child_case_a_method.assert_called_once_with(dummy="dummy_a", args="args_a")
-    mock_child_case_c_method.assert_called_once_with(dummy="dummy_c", args="args_c")
-
-
-def test_missing_attributes():
-    class DummyOwnerClass:
-        methods_for_stuff: Dict[str, Callable]
-
-    class RegisterMethodDecoratorToTest_MissingOwner(tempor.core.RegisterMethodDecorator):
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    class RegisterMethodDecoratorToTest_MissingDict(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        key_type: type = str
-        method_category_name: str = "stuff"
-
-    class RegisterMethodDecoratorToTest_MissingKey(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        method_category_name: str = "stuff"
-
-    class RegisterMethodDecoratorToTest_MissingName(tempor.core.RegisterMethodDecorator):
-        owner_class: type = DummyOwnerClass
-        registration_dict_attribute_name: str = "methods_for_stuff"
-        key_type: type = str
-
-    with pytest.raises(TypeError, match=".*owner_class.*"):
-
-        class DummyInheredClass1(DummyOwnerClass):
-            @RegisterMethodDecoratorToTest_MissingOwner.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass1()
-
-    with pytest.raises(TypeError, match=".*registration_dict_attribute_name.*"):
-
-        class DummyInheredClass2(DummyOwnerClass):
-            @RegisterMethodDecoratorToTest_MissingDict.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass2()
-
-    with pytest.raises(TypeError, match=".*key_type.*"):
-
-        class DummyInheredClass3(DummyOwnerClass):
-            @RegisterMethodDecoratorToTest_MissingKey.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass3()
-
-    with pytest.raises(TypeError, match=".*method_category_name.*"):
-
-        class DummyInheredClass4(DummyOwnerClass):
-            @RegisterMethodDecoratorToTest_MissingName.register_method_for("case_a")  # pyright: ignore
-            def _(self, dummy, args):
-                return "dummy return"
-
-        _ = DummyInheredClass4()
diff --git a/tests/core/test_requirements.py b/tests/core/test_requirements.py
deleted file mode 100644
index 9c5c2a31..00000000
--- a/tests/core/test_requirements.py
+++ /dev/null
@@ -1,153 +0,0 @@
-from unittest.mock import Mock
-
-import pydantic
-import pytest
-
-from tempor.core import requirements as r
-
-MyReqCategory = Mock()
-
-
-def test_init_success():
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    _ = MyRequirement(definition="some value")
-
-
-def test_init_fails_no_name():
-    class MyRequirement(r.Requirement):
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    with pytest.raises(ValueError, match=".*name.*"):
-        _ = MyRequirement(definition="some value")
-
-
-def test_init_definition_type_success():
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-        definition: float
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    _ = MyRequirement(definition=1.5)
-
-
-def test_init_definition_type_fails():
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-        definition: float
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    with pytest.raises(ValueError, match=".*definition.*"):
-        _ = MyRequirement(definition="incompatible")
-
-
-def test_init_extra_validator_success():
-    mock_call = Mock()
-
-    @pydantic.validator("definition")
-    def extra_validator(cls, v):  # pylint: disable=unused-argument
-        mock_call(value=v)
-        return v
-
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-        definition: float
-        validator_methods = [extra_validator]
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    _ = MyRequirement(definition=1.5)
-
-    mock_call.assert_called_once_with(value=1.5)
-
-
-def test_init_extra_validator_fail():
-    @pydantic.validator("definition")
-    def extra_validator(cls, v):  # pylint: disable=unused-argument
-        if v != "foo":
-            raise ValueError("Not foo")
-        return v
-
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-        definition: str
-        validator_methods = [extra_validator]
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    with pytest.raises(ValueError, match=".*Not foo.*"):
-        _ = MyRequirement(definition="bar")
-
-
-def test_validator_validate_success():
-    mock_validate = Mock()
-    mock_target = Mock()
-
-    class MyValidator(r.RequirementValidator):
-        @property
-        def supported_requirement_category(self):
-            return MyReqCategory
-
-        def _validate(self, *args, **kwargs):
-            mock_validate(*args, **kwargs)
-
-    class MyRequirement(r.Requirement):
-        name = "my_requirement"
-
-        @property
-        def requirement_category(self):
-            return MyReqCategory
-
-    requirements = (MyRequirement("A"), MyRequirement("B"))
-    validator = MyValidator()
-
-    validator.validate(target=mock_target, requirements=requirements)
-
-    mock_validate.assert_called_once_with(target=mock_target, requirements=requirements)
-
-
-def test_validator_validate_fails_not_supported_category():
-    mock_validate = Mock()
-    mock_target = Mock()
-
-    MyOtherCategory = Mock()
-
-    class MyValidator(r.RequirementValidator):
-        @property
-        def supported_requirement_category(self):
-            return MyReqCategory
-
-        def _validate(self, *args, **kwargs):
-            mock_validate(*args, **kwargs)
-
-    class MyOtherRequirement(r.Requirement):
-        name = "my_requirement"
-
-        @property
-        def requirement_category(self):
-            return MyOtherCategory
-
-    requirements = (MyOtherRequirement("A"), MyOtherRequirement("B"))
-    validator = MyValidator()
-
-    with pytest.raises(ValueError):
-        validator.validate(target=mock_target, requirements=requirements)
-
-    mock_validate.assert_not_called()
diff --git a/tests/core/test_supports_impl.py b/tests/core/test_supports_impl.py
deleted file mode 100644
index f376c6ab..00000000
--- a/tests/core/test_supports_impl.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from typing import Dict, Tuple
-
-import pytest
-
-from tempor.core import supports_impl
-
-
-class DummySupported:
-    pass
-
-
-dummy_supported_a = DummySupported()
-dummy_supported_b = DummySupported()
-
-
-class DummyImpl:
-    pass
-
-
-dummy_impl = DummyImpl()
-
-
-def test_init_success():
-    class TestSupportsImplementations(supports_impl.SupportsImplementations[DummySupported, DummyImpl]):
-        @property
-        def supports_implementations_for(self) -> Tuple[DummySupported, ...]:
-            return (dummy_supported_a, dummy_supported_b)
-
-        def _register_implementations(self) -> Dict[DummySupported, DummyImpl]:
-            return {
-                dummy_supported_a: dummy_impl,
-                dummy_supported_b: dummy_impl,
-            }
-
-    _ = TestSupportsImplementations()
-
-
-@pytest.mark.parametrize(
-    "registered_implementations_for", [[dummy_supported_a], [dummy_supported_a, dummy_supported_b]]
-)
-def test_init_fails_wrong_implementations_registered(registered_implementations_for):
-    class TestSupportsImplementations(supports_impl.SupportsImplementations[DummySupported, DummyImpl]):
-        @property
-        def supports_implementations_for(self) -> Tuple[DummySupported, ...]:
-            return (dummy_supported_b,)
-
-        def _register_implementations(self) -> Dict[DummySupported, DummyImpl]:
-            return {k: dummy_impl for k in registered_implementations_for}
-
-    with pytest.raises(TypeError, match=".*supported.*"):
-        _ = TestSupportsImplementations()
-
-
-def test_dispatch_success():
-    class TestSupportsImplementations(supports_impl.SupportsImplementations[DummySupported, DummyImpl]):
-        @property
-        def supports_implementations_for(self) -> Tuple[DummySupported, ...]:
-            return (dummy_supported_a,)
-
-        def _register_implementations(self) -> Dict[DummySupported, DummyImpl]:
-            return {
-                dummy_supported_a: dummy_impl,
-            }
-
-    to_test = TestSupportsImplementations()
-    impl = to_test.dispatch_to_implementation(dummy_supported_a)
-    assert impl == dummy_impl
-
-
-def test_dispatch_fails_not_registered():
-    class TestSupportsImplementations(supports_impl.SupportsImplementations[DummySupported, DummyImpl]):
-        @property
-        def supports_implementations_for(self) -> Tuple[DummySupported, ...]:
-            return (dummy_supported_a,)
-
-        def _register_implementations(self) -> Dict[DummySupported, DummyImpl]:
-            return {
-                dummy_supported_a: dummy_impl,
-            }
-
-    to_test = TestSupportsImplementations()
-
-    with pytest.raises(TypeError, match=".*implementation.*") as excinfo:
-        to_test.dispatch_to_implementation(dummy_supported_b)
-    assert "supported" not in str(excinfo.value)  # To differentiate with exception form the other test.
-
-
-def test_str():
-    impl_dict = {
-        dummy_supported_a: dummy_impl,
-        dummy_supported_b: dummy_impl,
-    }
-
-    class TestSupportsImplementations(supports_impl.SupportsImplementations[DummySupported, DummyImpl]):
-        @property
-        def supports_implementations_for(self) -> Tuple[DummySupported, ...]:
-            return (dummy_supported_a, dummy_supported_b)
-
-        def _register_implementations(self) -> Dict[DummySupported, DummyImpl]:
-            return impl_dict
-
-    to_test = TestSupportsImplementations()
-
-    assert "_implementations" in str(to_test)
-    assert f"{impl_dict}" in str(to_test)
diff --git a/tests/data/test_samples.py b/tests/data/test_samples.py
index 52a809f1..1064fdec 100644
--- a/tests/data/test_samples.py
+++ b/tests/data/test_samples.py
@@ -576,6 +576,21 @@ def test_time_indexes_as_dict(self, df_time_series: pd.DataFrame):
         s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
         assert s.time_indexes_as_dict() == {"a": [1, 2, 3, 4], "b": [2, 4], "c": [9]}
 
+    def test_time_indexes_float(self):
+        df = pd.DataFrame(
+            {
+                "sample_idx": ["s1", "s1", "s2"],
+                "time_idx": pd.to_datetime(["2000-01-02 15:31", "2000-02-03 23:11", "2020-01-01 00:15"]),
+                "feat_1": [11, 12, 21],
+            }
+        )
+        df.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+        s = samples.TimeSeriesSamples.from_dataframe(df)
+        float_time_index = s.time_indexes_float()
+        assert len(float_time_index) == 2
+        assert all(issubclass(type(x), float) for x in float_time_index[0])
+        assert all(issubclass(type(x), float) for x in float_time_index[1])
+
     def test_num_timesteps(self, df_time_series: pd.DataFrame):
         s = samples.TimeSeriesSamples.from_dataframe(df_time_series)
         assert s.num_timesteps() == [4, 2, 1]
@@ -727,6 +742,37 @@ def test_split(self, df_event: pd.DataFrame):
 
         assert s_split.equals(expected_df)
 
+    def test_split_as_two_dataframes(self, df_event: pd.DataFrame):
+        s = samples.EventSamples(data=df_event)
+        s_split_df_event_time, s_split_df_event_value = s.split_as_two_dataframes(time_feature_suffix="_time")
+
+        expected_df_event_time = pd.DataFrame(
+            {
+                "sample_idx": [f"sample_{x}" for x in range(1, 3 + 1)],
+                "feat_1_time": [5, 6, 3],
+                "feat_2_time": [1, 8, 8],
+                "feat_3_time": [
+                    pd.to_datetime("2000-01-02"),
+                    pd.to_datetime("2000-01-03"),
+                    pd.to_datetime("2000-01-01"),
+                ],
+            },
+        )
+        expected_df_event_time.set_index("sample_idx", drop=True, inplace=True)
+
+        expected_df_event_value = pd.DataFrame(
+            {
+                "sample_idx": [f"sample_{x}" for x in range(1, 3 + 1)],
+                "feat_1": [True, False, True],
+                "feat_2": [False, False, True],
+                "feat_3": [False, True, True],
+            },
+        )
+        expected_df_event_value.set_index("sample_idx", drop=True, inplace=True)
+
+        assert s_split_df_event_time.equals(expected_df_event_time)
+        assert s_split_df_event_value.equals(expected_df_event_value)
+
     def test_split_fails_column_naming_conflict(self):
         df = pd.DataFrame({"feat_1_time": [(5, True), (6, False), (3, True)]})
 
diff --git a/tests/data/test_utils.py b/tests/data/test_utils.py
index 47aef75e..91ee3765 100644
--- a/tests/data/test_utils.py
+++ b/tests/data/test_utils.py
@@ -941,3 +941,191 @@ def test_success_with_padding(
 
         mock_validate_timeseries_array3d.assert_called()
         assert df.equals(expected)
+
+
+@pytest.fixture
+def list_of_dfs_ts() -> Tuple[List[pd.DataFrame], List[str]]:
+    df_0 = pd.DataFrame(
+        {
+            "time_idx": [1, 2, 3],
+            "feat_1": [11, 12, 13],
+            "feat_2": [1.1, 1.2, 1.3],
+        }
+    )
+    df_0.set_index("time_idx", drop=True, inplace=True)
+    df_1 = pd.DataFrame(
+        {
+            "time_idx": [10, 20],
+            "feat_1": [21, 22],
+            "feat_2": [2.1, 2.2],
+        }
+    )
+    df_1.set_index("time_idx", drop=True, inplace=True)
+    df_2 = pd.DataFrame(
+        {
+            "time_idx": [100],
+            "feat_1": [31],
+            "feat_2": [3.1],
+        }
+    )
+    df_2.set_index("time_idx", drop=True, inplace=True)
+    dfs = [df_0, df_1, df_2]
+    sample_index = ["s1", "s2", "s3"]
+    return dfs, sample_index
+
+
+class TestListOfDataframesToMultiindexTimeseriesDataframe:
+    def test_base_case(self, list_of_dfs_ts):
+        dfs, sample_index = list_of_dfs_ts
+
+        multiindex_df = utils.list_of_dataframes_to_multiindex_timeseries_dataframe(
+            list_of_dataframes=dfs,
+            sample_index=sample_index,
+            time_indexes=None,
+            feature_index=None,
+        )
+
+        multiindex_df_expected = pd.DataFrame(
+            {
+                "sample_idx": ["s1", "s1", "s1", "s2", "s2", "s3"],
+                "time_idx": [1, 2, 3, 10, 20, 100],
+                "feat_1": [11, 12, 13, 21, 22, 31],
+                "feat_2": [1.1, 1.2, 1.3, 2.1, 2.2, 3.1],
+            }
+        )
+        multiindex_df_expected.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+        assert multiindex_df.equals(multiindex_df_expected)
+
+    def test_case_time_indexes_provided(self, list_of_dfs_ts):
+        dfs, sample_index = list_of_dfs_ts
+        time_indexes = [[1.5, 2.5, 3.5], [10.5, 20.5], [100.5]]
+
+        multiindex_df = utils.list_of_dataframes_to_multiindex_timeseries_dataframe(
+            list_of_dataframes=dfs,
+            sample_index=sample_index,
+            time_indexes=time_indexes,
+            feature_index=None,
+        )
+
+        multiindex_df_expected = pd.DataFrame(
+            {
+                "sample_idx": ["s1", "s1", "s1", "s2", "s2", "s3"],
+                "time_idx": [1.5, 2.5, 3.5, 10.5, 20.5, 100.5],
+                "feat_1": [11, 12, 13, 21, 22, 31],
+                "feat_2": [1.1, 1.2, 1.3, 2.1, 2.2, 3.1],
+            }
+        )
+        multiindex_df_expected.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+        assert multiindex_df.equals(multiindex_df_expected)
+
+    def test_case_feature_index_provided(self, list_of_dfs_ts):
+        dfs, sample_index = list_of_dfs_ts
+
+        multiindex_df = utils.list_of_dataframes_to_multiindex_timeseries_dataframe(
+            list_of_dataframes=dfs,
+            sample_index=sample_index,
+            time_indexes=None,
+            feature_index=["f1", "f2"],
+        )
+
+        multiindex_df_expected = pd.DataFrame(
+            {
+                "sample_idx": ["s1", "s1", "s1", "s2", "s2", "s3"],
+                "time_idx": [1, 2, 3, 10, 20, 100],
+                "f1": [11, 12, 13, 21, 22, 31],
+                "f2": [1.1, 1.2, 1.3, 2.1, 2.2, 3.1],
+            }
+        )
+        multiindex_df_expected.set_index(keys=["sample_idx", "time_idx"], drop=True, inplace=True)
+
+        assert list(multiindex_df.columns) == ["f1", "f2"]
+        assert multiindex_df.equals(multiindex_df_expected)
+
+    def test_case_time_indexes_provided_fails_length_mismatch(self, list_of_dfs_ts):
+        dfs, sample_index = list_of_dfs_ts
+        time_indexes = [[1.5, 2.5, 3.5], [10.5, 20.5]]
+
+        with pytest.raises(ValueError, match=".*same length.*"):
+            utils.list_of_dataframes_to_multiindex_timeseries_dataframe(
+                list_of_dataframes=dfs,
+                sample_index=sample_index,
+                time_indexes=time_indexes,
+                feature_index=None,
+            )
+
+
+class TestEventTimeValuePairsToEventDataframe:
+    @pytest.mark.parametrize(
+        "sample_index",
+        [
+            ["s1", "s2", "s3"],
+            [1, 2, 3],
+        ],
+    )
+    @pytest.mark.parametrize(
+        "event_time_value_pairs",
+        [
+            [([11, 12, 13], [True, True, False]), ([21, 22, 23], [False, True, False])],
+            [([1.1, 1.2, 1.3], [True, True, False]), ([2.1, 2.2, 2.3], [False, True, False])],
+            [
+                (list(pd.to_datetime(["2000-01-01", "2000-01-02", "2000-01-03"])), [True, True, False]),
+                (list(pd.to_datetime(["2000-02-01", "2000-02-02", "2000-02-03"])), [False, True, False]),
+            ],
+        ],
+    )
+    def test_success(self, sample_index, event_time_value_pairs):
+        feature_index = ["f1", "f2"]
+        df = utils.event_time_value_pairs_to_event_dataframe(
+            event_time_value_pairs, sample_index=sample_index, feature_index=feature_index
+        )
+
+        assert list(df.index) == sample_index
+        assert list(df.columns) == feature_index
+        assert df.shape == (3, 2)
+        assert isinstance(df.iloc[0, 0], tuple)
+
+    @pytest.mark.parametrize(
+        "event_time_value_pairs",
+        [
+            [([11, 12, 13], [4, 5, 6]), ([21, 22, 23], [5, 9, 9])],
+        ],
+    )
+    def test_fails_input_validation(self, event_time_value_pairs):
+        sample_index = ["s1", "s2", "s3"]
+        feature_index = ["f1", "f2"]
+        with pytest.raises(ValueError, match=".*validation.*error.*"):
+            utils.event_time_value_pairs_to_event_dataframe(
+                event_time_value_pairs, sample_index=sample_index, feature_index=feature_index
+            )
+
+
+time_index_under_test = pd.to_datetime(["2000-01-02 15:31", "2000-02-03 23:11", "2000-07-11 00:15"]).to_list()
+
+
+class TestDatetimeTimeIndexToFloat:
+    @pytest.mark.parametrize(
+        "time_index",
+        [
+            time_index_under_test,
+            pd.Series(time_index_under_test),
+            pd.Index(time_index_under_test),
+        ],
+    )
+    def test_convert(self, time_index):
+        as_float = utils.datetime_time_index_to_float(time_index=time_index)
+        assert all(issubclass(type(x), float) for x in as_float)
+        assert np.isclose(as_float, np.asarray([9.4682706e17, 9.4961946e17, 9.6327450e17], dtype=float)).all()
+
+    @pytest.mark.parametrize(
+        "time_index",
+        [
+            [1.4, 1.7, 1.9],
+            [1, 2, 3],
+        ],
+    )
+    def test_numeric_case(self, time_index):
+        as_float = utils.datetime_time_index_to_float(time_index=time_index)
+        assert all(issubclass(type(x), float) for x in as_float)
+        assert np.isclose(as_float, np.asarray(time_index, dtype=float)).all()
diff --git a/tests/models/test_ddh.py b/tests/models/test_ddh.py
new file mode 100644
index 00000000..de648dd3
--- /dev/null
+++ b/tests/models/test_ddh.py
@@ -0,0 +1,86 @@
+# pylint: disable=redefined-outer-name
+
+from typing import Any
+
+import numpy as np
+import pytest
+
+from tempor.models.ddh import DynamicDeepHitModel
+from tempor.utils.dataloaders import PBCDataLoader
+
+
+@pytest.fixture(scope="module")
+def get_test_data():
+    # This is module-scoped such that tests can run quicker.
+
+    data = PBCDataLoader().load()
+    x: Any = [df.to_numpy() for df in data.time_series.list_of_dataframes()]
+    x = np.array(x, dtype=object)
+    t, e = (df.to_numpy().reshape((-1,)) for df in data.predictive.targets.split_as_two_dataframes())
+
+    event0_times = data.predictive.targets.split_as_two_dataframes()[0].to_numpy().reshape((-1,))
+    horizons = np.quantile(event0_times, [0.25, 0.5, 0.75]).tolist()
+
+    return x, t, e, horizons
+
+
+@pytest.mark.parametrize(
+    "rnn_mode",
+    [
+        "GRU",
+        "LSTM",
+        "RNN",
+        "Transformer",
+    ],
+)
+@pytest.mark.parametrize(
+    "output_mode",
+    [
+        "MLP",
+    ],
+)
+def test_ddh_predict_rnn_modes(
+    get_test_data,
+    rnn_mode,
+    output_mode,
+) -> None:
+    x, t, e, horizons = get_test_data
+
+    model = DynamicDeepHitModel(n_iter=10, rnn_mode=rnn_mode, output_mode=output_mode)
+    output = model.fit(x=x, t=t, e=e).predict_risk(x=x, t=horizons)
+
+    assert output.shape == (len(x), len(horizons))
+
+
+@pytest.mark.parametrize(
+    "rnn_mode",
+    [
+        "GRU",
+    ],
+)
+@pytest.mark.parametrize(
+    "output_mode",
+    [
+        "MLP",
+        "LSTM",
+        "GRU",
+        "RNN",
+        "Transformer",
+        "TCN",
+        "InceptionTime",
+        "InceptionTimePlus",
+        "ResCNN",
+        "XCM",
+    ],
+)
+def test_ddh_predict_output_modes(
+    get_test_data,
+    rnn_mode,
+    output_mode,
+) -> None:
+    x, t, e, horizons = get_test_data
+
+    model = DynamicDeepHitModel(n_iter=10, rnn_mode=rnn_mode, output_mode=output_mode)
+    output = model.fit(x=x, t=t, e=e).predict_risk(x=x, t=horizons)
+
+    assert output.shape == (len(x), len(horizons))
diff --git a/tests/models/test_transformer.py b/tests/models/test_transformer.py
new file mode 100644
index 00000000..1e523a75
--- /dev/null
+++ b/tests/models/test_transformer.py
@@ -0,0 +1,33 @@
+import numpy as np
+import pytest
+import torch
+
+from tempor.models.constants import DEVICE
+from tempor.models.transformer import TransformerModel
+from tempor.utils.dataloaders import GoogleStocksDataLoader, SineDataLoader
+
+
+def unpack_dataset(source):
+    dataset = source().load()
+    temporal = dataset.time_series.numpy()
+    observation_times = dataset.time_series.time_indexes()
+    outcome = dataset.predictive.targets.numpy()
+
+    if dataset.static is not None:
+        static = dataset.static.numpy()
+    else:
+        static = np.zeros((len(temporal), 0))
+
+    return static, temporal, observation_times, outcome
+
+
+@pytest.mark.parametrize("source", [GoogleStocksDataLoader, SineDataLoader])
+def test_sanity(source) -> None:
+    _, temporal, _, _ = unpack_dataset(source)  # pylint: disable=unused-variable
+
+    n_hidden = 10
+    model = TransformerModel(n_units_in=temporal[0].shape[-1], n_units_hidden=n_hidden)
+    temporal = torch.from_numpy(temporal).to(device=DEVICE, dtype=torch.float)
+    out = model.forward(temporal)
+
+    assert out.shape == (len(temporal), temporal[0].shape[0], n_hidden)
diff --git a/tests/models/test_ts_model.py b/tests/models/test_ts_model.py
index 82a0cb33..e978fcd6 100644
--- a/tests/models/test_ts_model.py
+++ b/tests/models/test_ts_model.py
@@ -5,8 +5,8 @@
 from typing_extensions import get_args
 
 from tempor.models.ts_model import ModelTaskType, TimeSeriesModel, TSModelMode
-from tempor.utils.datasets.google_stocks import GoogleStocksDataloader
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def unpack_dataset(source):
@@ -59,7 +59,7 @@ def test_rnn_sanity(mode: TSModelMode, task_type: ModelTaskType) -> None:
 
 
 @pytest.mark.parametrize("mode", get_args(TSModelMode))
-@pytest.mark.parametrize("source", [GoogleStocksDataloader, SineDataloader])
+@pytest.mark.parametrize("source", [GoogleStocksDataLoader, SineDataLoader])
 @pytest.mark.parametrize("use_horizon_condition", [True, False])
 def test_rnn_regression_fit_predict(mode: TSModelMode, source: Any, use_horizon_condition: bool) -> None:
     static, temporal, observation_times, outcome = unpack_dataset(source)
@@ -90,7 +90,7 @@ def test_rnn_regression_fit_predict(mode: TSModelMode, source: Any, use_horizon_
 
 
 @pytest.mark.parametrize("mode", get_args(TSModelMode))
-@pytest.mark.parametrize("source", [SineDataloader, GoogleStocksDataloader])
+@pytest.mark.parametrize("source", [SineDataLoader, GoogleStocksDataLoader])
 def test_rnn_classification_fit_predict(mode: TSModelMode, source: Any) -> None:
     static, temporal, observation_times, outcome = unpack_dataset(source)  # pylint: disable=unused-variable
     static_fake, temporal_fake = np.random.randn(*static.shape), np.random.randn(*temporal.shape)
diff --git a/tests/models/test_ts_ode.py b/tests/models/test_ts_ode.py
index 9dc7033c..7c929f5d 100644
--- a/tests/models/test_ts_ode.py
+++ b/tests/models/test_ts_ode.py
@@ -5,8 +5,8 @@
 
 from tempor.models.constants import ODEBackend
 from tempor.models.ts_ode import NeuralODE
-from tempor.utils.datasets.google_stocks import GoogleStocksDataloader
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def unpack_dataset(source):
@@ -41,10 +41,10 @@ def test_ode_sanity(backend: ODEBackend):
     assert hasattr(model, "initial_static")
 
 
-@pytest.mark.parametrize("source", [GoogleStocksDataloader, SineDataloader])
+@pytest.mark.parametrize("source", [GoogleStocksDataLoader, SineDataLoader])
 @pytest.mark.parametrize("backend", ["laplace", "cde", "ode"])
 def test_ode_regression_fit_predict(source: Any, backend: ODEBackend) -> None:
-    if source == SineDataloader and backend == "laplace":
+    if source == SineDataLoader and backend == "laplace":
         # NOTE: Test with this setup fails, laplace implementation is not yet stable,
         # this needs to be debugged with the author.
         return
@@ -74,10 +74,10 @@ def test_ode_regression_fit_predict(source: Any, backend: ODEBackend) -> None:
     assert model.score(static, temporal, observation_times, outcome) < 2
 
 
-@pytest.mark.parametrize("source", [SineDataloader, GoogleStocksDataloader])
+@pytest.mark.parametrize("source", [SineDataLoader, GoogleStocksDataLoader])
 @pytest.mark.parametrize("backend", ["laplace", "cde", "ode"])
 def test_ode_classification_fit_predict(source: Any, backend: ODEBackend) -> None:
-    if source == SineDataloader and backend == "laplace":
+    if source == SineDataLoader and backend == "laplace":
         # NOTE: Test with this setup fails, laplace implementation is not yet stable,
         # this needs to be debugged with the author.
         return
diff --git a/tests/plugins/classification/test_cde_classifier.py b/tests/plugins/classification/test_cde_classifier.py
index 68fa742c..643e6890 100644
--- a/tests/plugins/classification/test_cde_classifier.py
+++ b/tests/plugins/classification/test_cde_classifier.py
@@ -3,7 +3,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.classification import BaseClassifier
 from tempor.plugins.classification.plugin_cde_classifier import CDEClassifier as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -26,14 +26,14 @@ def test_cde_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_cde_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_cde_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
@@ -41,7 +41,7 @@ def test_cde_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_cde_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict_proba(dataset)
 
diff --git a/tests/plugins/classification/test_laplace_classifier.py b/tests/plugins/classification/test_laplace_classifier.py
index 34b2d812..4cdab4d9 100644
--- a/tests/plugins/classification/test_laplace_classifier.py
+++ b/tests/plugins/classification/test_laplace_classifier.py
@@ -5,7 +5,7 @@
 from tempor.plugins.classification.plugin_laplace_classifier import (
     LaplaceODEClassifier as plugin,
 )
-from tempor.utils.datasets.google_stocks import GoogleStocksDataloader
+from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -28,14 +28,14 @@ def test_laplace_ode_classifier_plugin_sanity(test_plugin: BaseClassifier) -> No
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_laplace_ode_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_laplace_ode_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/classification/test_nn_classifier.py b/tests/plugins/classification/test_nn_classifier.py
index a3ed67d1..4cdb4418 100644
--- a/tests/plugins/classification/test_nn_classifier.py
+++ b/tests/plugins/classification/test_nn_classifier.py
@@ -5,7 +5,7 @@
 from tempor.plugins.classification.plugin_nn_classifier import (
     NeuralNetClassifier as plugin,
 )
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def from_api() -> BaseClassifier:
@@ -26,14 +26,14 @@ def test_nn_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_nn_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_nn_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
@@ -41,7 +41,7 @@ def test_nn_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_nn_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict_proba(dataset)
 
diff --git a/tests/plugins/classification/test_ode_classifier.py b/tests/plugins/classification/test_ode_classifier.py
index 726cc59d..6a256874 100644
--- a/tests/plugins/classification/test_ode_classifier.py
+++ b/tests/plugins/classification/test_ode_classifier.py
@@ -3,7 +3,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.classification import BaseClassifier
 from tempor.plugins.classification.plugin_ode_classifier import ODEClassifier as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -26,14 +26,14 @@ def test_ode_classifier_plugin_sanity(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ode_classifier_plugin_fit(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ode_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
@@ -41,7 +41,7 @@ def test_ode_classifier_plugin_predict(test_plugin: BaseClassifier) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ode_classifier_plugin_predict_proba(test_plugin: BaseClassifier) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict_proba(dataset)
 
diff --git a/tests/plugins/pipelines/test_pipeline.py b/tests/plugins/pipelines/test_pipeline.py
index b5720e7c..115a37bf 100644
--- a/tests/plugins/pipelines/test_pipeline.py
+++ b/tests/plugins/pipelines/test_pipeline.py
@@ -5,7 +5,7 @@
 import pytest
 
 from tempor.plugins.pipeline import Pipeline, PipelineGroup, PipelineMeta
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 @pytest.mark.parametrize(
@@ -97,9 +97,9 @@ def test_pipeline_fails(plugins_str: List[Any]) -> None:
 )
 def test_pipeline_end2end(plugins_str) -> None:
     if len(plugins_str) > 1:
-        dataset = SineDataloader(with_missing=True).load()
+        dataset = SineDataLoader(with_missing=True).load()
     else:
-        dataset = SineDataloader(with_missing=False).load()
+        dataset = SineDataLoader(with_missing=False).load()
 
     template: PipelineMeta = Pipeline(plugins_str)
     pipeline = template()
diff --git a/tests/plugins/preprocessing/imputation/test_bfill.py b/tests/plugins/preprocessing/imputation/test_bfill.py
index 33efb7b9..3859f1eb 100644
--- a/tests/plugins/preprocessing/imputation/test_bfill.py
+++ b/tests/plugins/preprocessing/imputation/test_bfill.py
@@ -5,7 +5,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.preprocessing.imputation import BaseImputer
 from tempor.plugins.preprocessing.imputation.plugin_bfill import BFillImputer as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def from_api() -> BaseImputer:
@@ -25,7 +25,7 @@ def test_bfill_plugin_sanity(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_bfill_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
@@ -36,7 +36,7 @@ def test_bfill_plugin_fit(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_bfill_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
diff --git a/tests/plugins/preprocessing/imputation/test_ffill.py b/tests/plugins/preprocessing/imputation/test_ffill.py
index a735a349..f75adf81 100644
--- a/tests/plugins/preprocessing/imputation/test_ffill.py
+++ b/tests/plugins/preprocessing/imputation/test_ffill.py
@@ -5,7 +5,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.preprocessing.imputation import BaseImputer
 from tempor.plugins.preprocessing.imputation.plugin_ffill import FFillImputer as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def from_api() -> BaseImputer:
@@ -25,7 +25,7 @@ def test_ffill_plugin_sanity(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ffill_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
@@ -36,7 +36,7 @@ def test_ffill_plugin_fit(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ffill_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
diff --git a/tests/plugins/preprocessing/imputation/test_static_imputation.py b/tests/plugins/preprocessing/imputation/test_static_imputation.py
index 27ef345d..d4eed620 100644
--- a/tests/plugins/preprocessing/imputation/test_static_imputation.py
+++ b/tests/plugins/preprocessing/imputation/test_static_imputation.py
@@ -7,7 +7,7 @@
 from tempor.plugins.preprocessing.imputation.plugin_static_imputation import (
     StaticOnlyImputer as plugin,
 )
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def from_api() -> BaseImputer:
@@ -27,7 +27,7 @@ def test_static_imputation_plugin_sanity(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_static_imputation_plugin_fit(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
@@ -38,7 +38,7 @@ def test_static_imputation_plugin_fit(test_plugin: BaseImputer) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_static_imputation_plugin_transform(test_plugin: BaseImputer) -> None:
-    dataset = SineDataloader(with_missing=True).load()
+    dataset = SineDataLoader(with_missing=True).load()
     if TYPE_CHECKING:  # pragma: no cover
         assert dataset.static is not None  # nosec B101
 
diff --git a/tests/plugins/regression/test_cde_regressor.py b/tests/plugins/regression/test_cde_regressor.py
index e521054f..75dc3da1 100644
--- a/tests/plugins/regression/test_cde_regressor.py
+++ b/tests/plugins/regression/test_cde_regressor.py
@@ -3,7 +3,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.regression import BaseRegressor
 from tempor.plugins.regression.plugin_cde_regressor import CDERegressor as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -25,14 +25,14 @@ def test_cde_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_cde_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_cde_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/regression/test_laplace_regressor.py b/tests/plugins/regression/test_laplace_regressor.py
index 2af6f630..b6fb5e7e 100644
--- a/tests/plugins/regression/test_laplace_regressor.py
+++ b/tests/plugins/regression/test_laplace_regressor.py
@@ -5,7 +5,7 @@
 from tempor.plugins.regression.plugin_laplace_regressor import (
     LaplaceODERegressor as plugin,
 )
-from tempor.utils.datasets.google_stocks import GoogleStocksDataloader
+from tempor.utils.dataloaders.google_stocks import GoogleStocksDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -27,14 +27,14 @@ def test_laplace_ode_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_laplace_ode_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_laplace_ode_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = GoogleStocksDataloader().load()
+    dataset = GoogleStocksDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/regression/test_nn_regressor.py b/tests/plugins/regression/test_nn_regressor.py
index 7c07cd27..7602b7f2 100644
--- a/tests/plugins/regression/test_nn_regressor.py
+++ b/tests/plugins/regression/test_nn_regressor.py
@@ -3,7 +3,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.regression import BaseRegressor
 from tempor.plugins.regression.plugin_nn_regressor import NeuralNetRegressor as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 
 def from_api() -> BaseRegressor:
@@ -23,14 +23,14 @@ def test_nn_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_nn_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_nn_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/regression/test_ode_regressor.py b/tests/plugins/regression/test_ode_regressor.py
index 4dd38fae..f867f4fa 100644
--- a/tests/plugins/regression/test_ode_regressor.py
+++ b/tests/plugins/regression/test_ode_regressor.py
@@ -3,7 +3,7 @@
 from tempor.plugins import plugin_loader
 from tempor.plugins.regression import BaseRegressor
 from tempor.plugins.regression.plugin_ode_regressor import ODERegressor as plugin
-from tempor.utils.datasets.sine import SineDataloader
+from tempor.utils.dataloaders.sine import SineDataLoader
 
 train_kwargs = {"random_state": 123, "n_iter": 50}
 
@@ -25,14 +25,14 @@ def test_ode_regressor_plugin_sanity(test_plugin: BaseRegressor) -> None:
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ode_regressor_plugin_fit(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     test_plugin.fit(dataset)
 
 
 @pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
 def test_ode_regressor_plugin_predict(test_plugin: BaseRegressor) -> None:
-    dataset = SineDataloader().load()
+    dataset = SineDataLoader().load()
 
     output = test_plugin.fit(dataset).predict(dataset)
     assert output.numpy().shape == (len(dataset.time_series), 1)
diff --git a/tests/plugins/test_plugin_loader.py b/tests/plugins/test_plugin_loader.py
index 3c144932..04f80313 100644
--- a/tests/plugins/test_plugin_loader.py
+++ b/tests/plugins/test_plugin_loader.py
@@ -7,12 +7,12 @@ def test_tempor_plugin_loader_contents():
     # Do some checks that expected plugins have been registered.
     # Update as plugins get added / reorganized.
     assert "preprocessing" in all_plugins
-    assert "survival" in all_plugins
+    assert "time_to_event" in all_plugins
     assert "imputation" in all_plugins["preprocessing"]
     assert "scaling" in all_plugins["preprocessing"]
     assert "nop_imputer" in all_plugins["preprocessing"]["imputation"]
     assert "nop_scaler" in all_plugins["preprocessing"]["scaling"]
-    assert "dynamic_deephit" in all_plugins["survival"]
+    assert "dynamic_deephit" in all_plugins["time_to_event"]
 
 
 def test_tempor_plugins_all_init_success():
diff --git a/tests/plugins/time_to_event/test_ddh_plugin.py b/tests/plugins/time_to_event/test_ddh_plugin.py
new file mode 100644
index 00000000..dba1319b
--- /dev/null
+++ b/tests/plugins/time_to_event/test_ddh_plugin.py
@@ -0,0 +1,55 @@
+from typing import TYPE_CHECKING, Callable
+
+import pytest
+
+from tempor.plugins import plugin_loader
+from tempor.plugins.time_to_event.plugin_ddh import (
+    DynamicDeepHitTimeToEventAnalysis as plugin,
+)
+from tempor.utils.dataloaders import PBCDataLoader
+
+if TYPE_CHECKING:  # pragma: no cover
+    from tempor.plugins.time_to_event import BaseTimeToEventAnalysis
+
+train_kwargs = {"random_state": 123, "n_iter": 10}
+
+
+def from_api() -> "BaseTimeToEventAnalysis":
+    return plugin_loader.get("time_to_event.dynamic_deephit", **train_kwargs)
+
+
+def from_module() -> "BaseTimeToEventAnalysis":
+    return plugin(**train_kwargs)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_dynamic_deephit_plugin_sanity(test_plugin: "BaseTimeToEventAnalysis") -> None:
+    assert test_plugin is not None
+    assert test_plugin.name == "dynamic_deephit"
+    assert test_plugin.fqn() == "time_to_event.dynamic_deephit"
+    assert len(test_plugin.hyperparameter_space()) == 10
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_dynamic_deephit_plugin_fit(test_plugin: "BaseTimeToEventAnalysis") -> None:
+    dataset = PBCDataLoader().load()
+    test_plugin.fit(dataset)
+
+
+@pytest.mark.parametrize("test_plugin", [from_api(), from_module()])
+def test_dynamic_deephit_plugin_predict(
+    test_plugin: "BaseTimeToEventAnalysis", get_event0_time_percentiles: Callable
+) -> None:
+    dataset = PBCDataLoader().load()
+
+    horizons = get_event0_time_percentiles(dataset, [0.25, 0.5, 0.75])
+
+    output = test_plugin.fit(dataset).predict(dataset, horizons=horizons)
+
+    assert output.numpy().shape == (len(dataset.time_series), len(horizons), 1)
+
+
+def test_hyperparam_sample():
+    for repeat in range(100):  # pylint: disable=unused-variable
+        args = plugin._cls.sample_hyperparameters()  # pylint: disable=no-member, protected-access
+        plugin(**args)
diff --git a/tests/utils/dataloaders/test_dataloaders.py b/tests/utils/dataloaders/test_dataloaders.py
new file mode 100644
index 00000000..ef7ab647
--- /dev/null
+++ b/tests/utils/dataloaders/test_dataloaders.py
@@ -0,0 +1,23 @@
+from typing import Type
+
+import pytest
+
+from tempor.data.dataloader import DataLoader
+from tempor.data.dataset import Dataset
+from tempor.utils import dataloaders
+
+
+@pytest.mark.parametrize(
+    "dataloader_cls",
+    [dataloaders.SineDataLoader, dataloaders.GoogleStocksDataLoader, dataloaders.GoogleStocksDataLoader],
+)
+def test_init_load_and_basic_methods(dataloader_cls: Type[DataLoader]):
+    dataloader = dataloader_cls()  # Test __ini__ with no (all default) parameters.
+
+    url = dataloader.url()
+    dataset_dir = dataloader.dataset_dir()
+    dataset = dataloader.load()
+
+    assert isinstance(url, (str, type(None)))
+    assert isinstance(dataset_dir, (str, type(None)))
+    assert isinstance(dataset, Dataset)