Repository: zurutech/ashpy

Issue:
[BUG] - Unable to do model selection on a decreasing metric
**Describe the bug**
I expect to be able to do model selecting, using the classifier loss as metric. Actually, I find only a JSON with this content:

> cat ~/log/on/best/loss/loss.json 

```
{
    "loss": "-inf",
    "step": "0"
}
```

**Expected behavior**

I should find in the logdir, the best folder containing the JSON + the checkpoint files of the best model, wrt the chosen metric.

**Code to reproduce the issue**
Provide a reproducible test case that is the bare minimum necessary to generate the problem.

```

def get_model():
    """Create a new autoencoder tf.keras.Model."""
    autoencoder = Autoencoder(
        (64, 64),
        (4, 4),
        kernel_size=3,
        initial_filters=16,
        filters_cap=64,
        encoding_dimension=50,
        channels=3,
    )

    # encoding, representation = autoencoder(input)
    inputs = tf.keras.layers.Input(shape=(64, 64, 3))
    _, reconstruction = autoencoder(inputs)
    model = tf.keras.Model(inputs=inputs, outputs=reconstruction)
    return model


def _train(dataset: tf.data.Dataset, logdir: Path):
    reconstruction_error = ClassifierLoss(tf.keras.losses.MeanSquaredError())
    autoencoder = get_model()

    ClassifierTrainer(
        model=autoencoder,
        optimizer=tf.optimizers.Adam(1e-4),
        loss=reconstruction_error,
        metrics=[ashpy.metrics.ClassifierLoss(model_selection_operator=operator.lt)],
        logdir=str(logdir),
        epochs=100,
    )(dataset, dataset)

```

In short, the initialization of the JSON is wrong when I want to select a model whose metric is decreasing.


Discussion:

Patch:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index cbe50ad..45c89dc 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -19,7 +19,7 @@ Fixes # (issue)
 
 Please delete options that are not relevant.
 
-- [ ] Bug fix (i.e., a non-breaking change which fixes an issue)
+- [ ] Bugfix (i.e., a non-breaking change which fixes an issue)
 - [ ] New feature (i.e., a non-breaking change which adds functionality)
 - [ ] Breaking change (i.e., a fix or feature that would cause existing functionality to not work as expected)
 - [ ] This change requires a documentation update
@@ -46,7 +46,7 @@ List any dependencies that are required for this change.
 
 - [ ] My code follows the style guidelines of this project
 - [ ] I have performed a self-review of my own code
-- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have commented on my code, particularly in hard-to-understand areas
 - [ ] I have made corresponding changes to the documentation
 - [ ] My changes generate no new warnings
 - [ ] I have added tests that prove my fix is effective or that my feature works
diff --git a/src/ashpy/metrics/classifier.py b/src/ashpy/metrics/classifier.py
index 7df9ce0..972207d 100644
--- a/src/ashpy/metrics/classifier.py
+++ b/src/ashpy/metrics/classifier.py
@@ -34,6 +34,7 @@ class ClassifierLoss(Metric):
 
     def __init__(
         self,
+        name: str = "loss",
         model_selection_operator: Callable = None,
         logdir: str = os.path.join(os.getcwd(), "log"),
     ) -> None:
@@ -41,6 +42,7 @@ class ClassifierLoss(Metric):
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -54,8 +56,8 @@ class ClassifierLoss(Metric):
 
         """
         super().__init__(
-            name="loss",
-            metric=tf.metrics.Mean(name="loss", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
diff --git a/src/ashpy/metrics/gan.py b/src/ashpy/metrics/gan.py
index cf32613..c12190c 100644
--- a/src/ashpy/metrics/gan.py
+++ b/src/ashpy/metrics/gan.py
@@ -37,6 +37,7 @@ class DiscriminatorLoss(Metric):
 
     def __init__(
         self,
+        name: str = "d_loss",
         model_selection_operator: Callable = None,
         logdir: str = os.path.join(os.getcwd(), "log"),
     ) -> None:
@@ -44,6 +45,7 @@ class DiscriminatorLoss(Metric):
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -57,8 +59,8 @@ class DiscriminatorLoss(Metric):
 
         """
         super().__init__(
-            name="d_loss",
-            metric=tf.metrics.Mean(name="d_loss", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
@@ -100,6 +102,7 @@ class GeneratorLoss(Metric):
 
     def __init__(
         self,
+        name: str = "g_loss",
         model_selection_operator: Callable = None,
         logdir: str = os.path.join(os.getcwd(), "log"),
     ):
@@ -107,6 +110,7 @@ class GeneratorLoss(Metric):
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -120,8 +124,8 @@ class GeneratorLoss(Metric):
 
         """
         super().__init__(
-            name="g_loss",
-            metric=tf.metrics.Mean(name="g_loss", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
@@ -162,6 +166,7 @@ class EncoderLoss(Metric):
 
     def __init__(
         self,
+        name: str = "e_loss",
         model_selection_operator: Callable = None,
         logdir: str = os.path.join(os.getcwd(), "log"),
     ) -> None:
@@ -169,6 +174,7 @@ class EncoderLoss(Metric):
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -182,8 +188,8 @@ class EncoderLoss(Metric):
 
         """
         super().__init__(
-            name="e_loss",
-            metric=tf.metrics.Mean(name="e_loss", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
@@ -233,6 +239,7 @@ class InceptionScore(Metric):
     def __init__(
         self,
         inception: tf.keras.Model,
+        name: str = "inception_score",
         model_selection_operator=operator.gt,
         logdir=os.path.join(os.getcwd(), "log"),
     ):
@@ -241,6 +248,7 @@ class InceptionScore(Metric):
 
         Args:
             inception (:py:class:`tf.keras.Model`): Keras Inception model.
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -254,8 +262,8 @@ class InceptionScore(Metric):
 
         """
         super().__init__(
-            name="inception_score",
-            metric=tf.metrics.Mean("inception_score"),
+            name=name,
+            metric=tf.metrics.Mean(name),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
@@ -418,6 +426,7 @@ class EncodingAccuracy(ClassifierMetric):
     def __init__(
         self,
         classifier: tf.keras.Model,
+        name: str = "encoding_accuracy",
         model_selection_operator: Callable = None,
         logdir=os.path.join(os.getcwd(), "log"),
     ) -> None:
@@ -430,6 +439,7 @@ class EncodingAccuracy(ClassifierMetric):
         Args:
             classifier (:py:class:`tf.keras.Model`): Keras Model to use as a Classifier to
                 measure the accuracy. Generally assumed to be the Inception Model.
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -443,7 +453,7 @@ class EncodingAccuracy(ClassifierMetric):
 
         """
         super().__init__(
-            metric=tf.metrics.Accuracy("encoding_accuracy"),
+            metric=tf.metrics.Accuracy(name),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
diff --git a/src/ashpy/metrics/metric.py b/src/ashpy/metrics/metric.py
index d99c55c..e577da9 100644
--- a/src/ashpy/metrics/metric.py
+++ b/src/ashpy/metrics/metric.py
@@ -48,7 +48,7 @@ class Metric(ABC):
         Initialize the Metric object.
 
         Args:
-            name (str): The name of the metric.
+            name (str): Name of the metric.
             metric (:py:class:`tf.keras.metrics.Metric`): The Keras metric to use.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
@@ -81,17 +81,22 @@ class Metric(ABC):
 
         """
         current_value = self.result()
-        previous_value = float(self.json_read(self.best_model_sel_file)[self._name])
+        previous_value = float(
+            self.json_read(self.best_model_sel_file)[self._name.replace("/", "_")]
+        )
         # Model selection is done ONLY if an operator was passed at __init__
         if self._model_selection_operator and self._model_selection_operator(
             current_value, previous_value
         ):
             tf.print(
-                f"{self.name}: validation value: {previous_value} → {current_value}"
+                f"{self._name.replace('/', '_')}: validation value: {previous_value} → {current_value}"
             )
             Metric.json_write(
                 self.best_model_sel_file,
-                {self._name: str(current_value), "step": int(global_step.numpy())},
+                {
+                    self._name.replace("/", "_"): str(current_value),
+                    "step": int(global_step.numpy()),
+                },
             )
             manager = tf.train.CheckpointManager(
                 checkpoint, os.path.join(self.best_folder, "ckpts"), max_to_keep=1
@@ -99,6 +104,8 @@ class Metric(ABC):
             manager.save()
 
     def _update_logdir(self):
+        if not self._model_selection_operator:
+            pass
         # write the initial value of the best metric
         if not os.path.exists(self.best_model_sel_file):
             os.makedirs(os.path.dirname(self.best_model_sel_file))
@@ -106,7 +113,8 @@ class Metric(ABC):
             np.inf if self._model_selection_operator is operator.lt else -np.inf
         )
         self.json_write(
-            self.best_model_sel_file, {self._name: str(initial_value), "step": 0}
+            self.best_model_sel_file,
+            {self._name.replace("/", "_"): str(initial_value), "step": 0},
         )
 
     @property
@@ -138,12 +146,12 @@ class Metric(ABC):
     @property
     def best_folder(self) -> str:
         """Retrieve the folder used to save the best model when doing model selection."""
-        return os.path.join(self.logdir, "best", self._name)
+        return os.path.join(self.logdir, "best", self._name.replace("/", "_"))
 
     @property
     def best_model_sel_file(self) -> str:
         """Retrieve the path to JSON file containing the measured performance of the best model."""
-        return os.path.join(self.best_folder, self._name + ".json")
+        return os.path.join(self.best_folder, self._name.replace("/", "_") + ".json")
 
     @staticmethod
     def json_read(filename: str) -> Dict[str, Any]:
diff --git a/src/ashpy/metrics/sliced_wasserstein_metric.py b/src/ashpy/metrics/sliced_wasserstein_metric.py
index 9df15e6..15d94d5 100644
--- a/src/ashpy/metrics/sliced_wasserstein_metric.py
+++ b/src/ashpy/metrics/sliced_wasserstein_metric.py
@@ -92,6 +92,7 @@ class SlicedWassersteinDistance(Metric):
 
     def __init__(
         self,
+        name: str = "SWD",
         model_selection_operator: Callable = operator.lt,
         logdir: str = os.path.join(os.getcwd(), "log"),
         resolution: int = 128,
@@ -106,6 +107,7 @@ class SlicedWassersteinDistance(Metric):
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -126,8 +128,8 @@ class SlicedWassersteinDistance(Metric):
 
         """
         super().__init__(
-            name="SWD",
-            metric=tf.metrics.Mean(name="SWD", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
diff --git a/src/ashpy/metrics/ssim_multiscale.py b/src/ashpy/metrics/ssim_multiscale.py
index eee12f2..1b68c90 100644
--- a/src/ashpy/metrics/ssim_multiscale.py
+++ b/src/ashpy/metrics/ssim_multiscale.py
@@ -41,19 +41,21 @@ class SSIM_Multiscale(Metric):  # pylint: disable=invalid-name
 
     def __init__(
         self,
+        name: str = "SSIM_Multiscale",
         model_selection_operator: Callable = operator.lt,
         logdir: str = os.path.join(os.getcwd(), "log"),
         max_val: float = 2.0,
         power_factors=None,
         filter_size: int = 11,
         filter_sigma: int = 1.5,
-        k1: int = 0.01,
-        k2: int = 0.03,
+        k1: float = 0.01,
+        k2: float = 0.03,
     ) -> None:
         """
         Initialize the Metric.
 
         Args:
+            name (str): Name of the metric.
             model_selection_operator (:py:obj:`typing.Callable`): The operation that will
                 be used when `model_selection` is triggered to compare the metrics,
                 used by the `update_state`.
@@ -74,14 +76,14 @@ class SSIM_Multiscale(Metric):  # pylint: disable=invalid-name
                 0.1333), which are the values obtained in the original paper.
             filter_size (int): Default value 11 (size of gaussian filter).
             filter_sigma (int): Default value 1.5 (width of gaussian filter).
-            k1 (int): Default value 0.01.
-            k2 (int): Default value 0.03 (SSIM is less sensitivity to K2 for lower values, so
+            k1 (float): Default value 0.01.
+            k2 (float): Default value 0.03 (SSIM is less sensitivity to K2 for lower values, so
                 it would be better if we take the values in range of 0< K2 <0.4).
 
         """
         super().__init__(
-            name="SSIM_Multiscale",
-            metric=tf.metrics.Mean(name="SSIM_Multiscale", dtype=tf.float32),
+            name=name,
+            metric=tf.metrics.Mean(name=name, dtype=tf.float32),
             model_selection_operator=model_selection_operator,
             logdir=logdir,
         )
diff --git a/src/ashpy/trainers/classifier.py b/src/ashpy/trainers/classifier.py
index e209551..97ebf18 100644
--- a/src/ashpy/trainers/classifier.py
+++ b/src/ashpy/trainers/classifier.py
@@ -124,13 +124,14 @@ class ClassifierTrainer(Trainer):
         self._loss = loss
         self._loss.reduction = tf.keras.losses.Reduction.NONE
 
-        self._avg_loss = ClassifierLoss()
+        self._avg_loss = ClassifierLoss(name="ashpy/avg_loss")
         if metrics:
             metrics.append(self._avg_loss)
         else:
             metrics = [self._avg_loss]
 
         super()._update_metrics(metrics)
+        super()._validate_metrics()
 
         self._checkpoint.objects.extend([self._optimizer, self._model])
         self._restore_or_init()
diff --git a/src/ashpy/trainers/gan.py b/src/ashpy/trainers/gan.py
index 7da4860..8b48527 100644
--- a/src/ashpy/trainers/gan.py
+++ b/src/ashpy/trainers/gan.py
@@ -185,8 +185,8 @@ class AdversarialTrainer(Trainer):
         self._discriminator_loss.reduction = tf.losses.Reduction.NONE
 
         losses_metrics = [
-            DiscriminatorLoss(logdir=logdir),
-            GeneratorLoss(logdir=logdir),
+            DiscriminatorLoss(name="ashpy/d_loss", logdir=logdir),
+            GeneratorLoss(name="ashpy/g_loss", logdir=logdir),
         ]
         if metrics:
             metrics.extend(losses_metrics)
@@ -194,6 +194,7 @@ class AdversarialTrainer(Trainer):
             metrics = losses_metrics
 
         super()._update_metrics(metrics)
+        super()._validate_metrics()
 
         self._generator_optimizer = generator_optimizer
         self._discriminator_optimizer = discriminator_optimizer
@@ -425,12 +426,7 @@ class EncoderTrainer(AdversarialTrainer):
             if os.path.exists(logdir):
                 shutil.rmtree(logdir)
 
-            metrics = [
-                metrics.gan.EncodingAccuracy(
-                    classifier,
-                    # model_selection_operator=operator.gt,
-                )
-            ]
+            metrics = [metrics.gan.EncodingAccuracy(classifier)]
 
             trainer = trainers.gan.EncoderTrainer(
                 generator=generator,
@@ -526,7 +522,7 @@ class EncoderTrainer(AdversarialTrainer):
         """
         if not metrics:
             metrics = []
-        metrics.append(EncoderLoss(logdir=logdir))
+        metrics.append(EncoderLoss(name="ashpy/e_loss", logdir=logdir))
         super().__init__(
             generator=generator,
             discriminator=discriminator,
diff --git a/src/ashpy/trainers/trainer.py b/src/ashpy/trainers/trainer.py
index fc6991c..bc23041 100644
--- a/src/ashpy/trainers/trainer.py
+++ b/src/ashpy/trainers/trainer.py
@@ -140,6 +140,11 @@ class Trainer(ABC):
     def _validate_metrics(self):
         """Check if every metric is an :py:class:`ashpy.metrics.Metric`."""
         validate_objects(self._metrics, Metric)
+        buffer = []
+        for metric in self._metrics:
+            if metric._name in buffer:
+                raise ValueError("Metric should have unique names.")
+            buffer.append(metric._name)
 
     def _validate_callbacks(self):
         """Check if every callback is an :py:class:`ashpy.callbacks.Callback`."""


Test patch:
diff --git a/tests/callbacks/test_custom_callback.py b/tests/callbacks/test_custom_callback.py
index 6a1a29d..91b6b34 100644
--- a/tests/callbacks/test_custom_callback.py
+++ b/tests/callbacks/test_custom_callback.py
@@ -28,7 +28,8 @@ class MCallback(Callback):
     Check the number of times the on_event is triggered.
     """
 
-    def __init__(self, event):
+    def __init__(self, event) -> None:
+        """Initialize Callback."""
         super(MCallback, self).__init__()
         self._event = event
         self.counter = 0
diff --git a/tests/callbacks/test_save_callback.py b/tests/callbacks/test_save_callback.py
index 88b1521..c282371 100644
--- a/tests/callbacks/test_save_callback.py
+++ b/tests/callbacks/test_save_callback.py
@@ -108,7 +108,8 @@ def _test_save_callback_helper(tmpdir, save_format, save_sub_format, save_dir):
 
 
 def test_save_callback_type_error(save_dir: str,):
-    """Test that the SaveCallback raises a TypeError.
+    """
+    Test that the SaveCallback raises a TypeError.
 
     Test that the SaveCallback raises a TypeError when wrong save_format
     or save sub-format is passed.
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index ef1c08d..28408b7 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -17,15 +17,19 @@ Test Metrics with the various trainers.
 
 TODO: Adversarial Encoder
 """
-
 import json
 import operator
 import pathlib
+import shutil
+from typing import List
 
 import pytest
+import tensorflow as tf
 from ashpy.metrics import (
     ClassifierLoss,
+    ClassifierMetric,
     InceptionScore,
+    Metric,
     SlicedWassersteinDistance,
     SSIM_Multiscale,
 )
@@ -37,7 +41,7 @@ from tests.utils.fake_training_loop import (
 )
 
 DEFAULT_LOGDIR = "log"
-TEST_MATRIX = {
+TEST_MATRIX_METRICS_LOG = {
     "adversarial_trainer": [
         fake_adversarial_training_loop,
         {
@@ -69,8 +73,8 @@ TEST_MATRIX = {
     ],
 }
 
-TEST_PARAMS = [TEST_MATRIX[k] for k in TEST_MATRIX]
-TEST_IDS = [k for k in TEST_MATRIX]
+TEST_PARAMS_METRICS_LOG = [TEST_MATRIX_METRICS_LOG[k] for k in TEST_MATRIX_METRICS_LOG]
+TEST_IDS_METRICS_LOG = [k for k in TEST_MATRIX_METRICS_LOG]
 
 
 @pytest.fixture(scope="module")
@@ -85,11 +89,13 @@ def cleanup():
 
 
 @pytest.mark.parametrize(
-    ["training_loop", "loop_args", "metrics"], TEST_PARAMS, ids=TEST_IDS
+    ["training_loop", "loop_args", "metrics"],
+    TEST_PARAMS_METRICS_LOG,
+    ids=TEST_IDS_METRICS_LOG,
 )
-def test_metrics(training_loop, loop_args, metrics, tmpdir, cleanup):
+def test_metrics_log(training_loop, loop_args, metrics, tmpdir, cleanup):
     """
-    Test the integration between metrics and trainer.
+    Test that trainers correctly create metrics log files.
 
     GIVEN a correctly instantiated trainer
     GIVEN some training has been done
@@ -99,19 +105,92 @@ def test_metrics(training_loop, loop_args, metrics, tmpdir, cleanup):
         THEN in the file there are the correct keys
 
     """
-    training_completed = training_loop(logdir=tmpdir, metrics=metrics, **loop_args)
+    training_completed, trainer = training_loop(
+        logdir=tmpdir, metrics=metrics, **loop_args
+    )
 
     assert training_completed
     assert not pathlib.Path(DEFAULT_LOGDIR).exists()  # Assert absence of side effects
     # Assert there exists folder for each metric
-    for metric in metrics:
-        metric_dir = pathlib.Path(tmpdir).joinpath("best", metric.name)
+    for metric in trainer._metrics:
+        metric_dir = pathlib.Path(tmpdir).joinpath(
+            "best", metric.name.replace("/", "_")
+        )
         assert metric_dir.exists()
-        json_path = metric_dir.joinpath(f"{metric.name}.json")
+        json_path = metric_dir.joinpath(f"{metric.name.replace('/', '_')}.json")
         assert json_path.exists()
         with open(json_path, "r") as fp:
             metric_data = json.load(fp)
 
             # Assert the metric data contains the expected keys
-            assert metric.name in metric_data
+            assert metric.name.replace("/", "_") in metric_data
             assert "step" in metric_data
+
+
+# -------------------------------------------------------------------------------------
+
+
+TEST_MATRIX_MODEL_SELECTION = {
+    # TODO: Add test for a metric with operator.gt
+    "classifier_trainer_lt": [
+        fake_classifier_taining_loop,
+        {},
+        [ClassifierLoss(model_selection_operator=operator.lt)],
+        operator.lt,
+    ],
+}
+TEST_PARAMS_MODEL_SELECTION = [
+    TEST_MATRIX_MODEL_SELECTION[k] for k in TEST_MATRIX_MODEL_SELECTION
+]
+TEST_IDS_MODEL_SELECTION = [k for k in TEST_MATRIX_MODEL_SELECTION]
+
+
+@pytest.mark.parametrize(
+    ["training_loop", "loop_args", "metrics", "operator_check"],
+    TEST_PARAMS_MODEL_SELECTION,
+    ids=TEST_IDS_MODEL_SELECTION,
+)
+def test_model_selection(
+    training_loop, loop_args, metrics, operator_check: List[Metric]
+):
+    """
+    Test the correct model selection behaviour of metrics.
+
+    Model selection is handled by the Metric when triggered by a trainer.
+
+    GIVEN a correctly instantiated trainer
+    GIVEN some training has been done
+        THEN there should be a metric log file containing two values
+        GIVEN all metrics log get initialized at -inf at step 0
+        GIVEN a new data point is added to the log
+        WHEN performing model solection this should be the value used
+
+    """
+    number_of_metrics = len(metrics)
+    for metric in metrics:
+        assert metric._model_selection_operator == operator_check
+
+    training_completed, trainer = training_loop(
+        logdir="testlog", metrics=metrics, **loop_args
+    )
+
+    # Maybe make it explicit when a trainer popultate metrics autonomously
+    assert training_completed
+    # Manually have to check this in the log. Find a better way.
+    # loss: validation value: inf → 0.0
+
+
+# -------------------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("training_loop", [fake_classifier_taining_loop])
+def test_metrics_names_collision(training_loop, tmpdir):
+    """
+    Test that an exception is correctly raised when two metrics have the same name.
+
+    WHEN two or more metrics passed to a trainer have the same name
+        THEN raise a ValueError
+    """
+    metrics = [ClassifierLoss(name="test_loss"), ClassifierLoss(name="test_loss")]
+    with pytest.raises(ValueError):
+        training_completed, trainer = training_loop(metrics=metrics, logdir=tmpdir)
diff --git a/tests/utils/fake_training_loop.py b/tests/utils/fake_training_loop.py
index f845163..8c52733 100644
--- a/tests/utils/fake_training_loop.py
+++ b/tests/utils/fake_training_loop.py
@@ -31,7 +31,7 @@ def fake_classifier_taining_loop(
     logdir: Path,
     optimizer=tf.optimizers.Adam(1e-4),
     metrics=[ashpy.metrics.ClassifierLoss(model_selection_operator=operator.lt)],
-    epochs=5,
+    epochs=2,
     # Dataset
     dataset_size=10,
     image_resolution=(64, 64),
@@ -46,6 +46,7 @@ def fake_classifier_taining_loop(
     channels=3,
 ):
     """Fake Classifier training loop implementation using an autoencoder as a base model."""
+    # Model
     model = conv_autoencoder(
         layer_spec_input_res,
         layer_spec_target_res,
@@ -55,21 +56,29 @@ def fake_classifier_taining_loop(
         encoding_dimension,
         channels,
     )
+
+    # Dataset
     dataset = fake_autoencoder_datasest(
         dataset_size, image_resolution, channels, batch_size
     )
 
+    # Loss
     reconstruction_error = ashpy.losses.ClassifierLoss(
         tf.keras.losses.MeanSquaredError()
     )
+
+    # Trainer
     trainer = ashpy.trainers.ClassifierTrainer(
         model=model,
         optimizer=optimizer,
         loss=reconstruction_error,
         logdir=str(logdir),
         epochs=epochs,
-    )(dataset, dataset)
-    return 1
+        metrics=metrics,
+    )
+
+    trainer(dataset, dataset)
+    return 1, trainer
 
 
 def fake_adversarial_training_loop(
@@ -154,4 +163,4 @@ def fake_adversarial_training_loop(
     )
 
     trainer(dataset)
-    return 1
+    return 1, trainer