Repository: mlcommons/gandlf
Issue: Per-label and per-class metrics should be formatted properly for CSV output

**Describe the bug**
The per class/label metrics get saved as lists in the CSV, which makes parsing difficult.

**To Reproduce**
Steps to reproduce the behavior:
1. Run any training.
2. See the following in logs (example shown for a simple classification unit test):
```csv
epoch_no,train_loss,train_accuracy_global,train_f1_global,
0,0.445031365,[1.0, 0.0],[0.936170220375061, 0.0]
```

**Expected behavior**
This creates an issue when trying to parse the CSV, since the location of the actual results get moved. There are 2 potential solutions:
1. Either the class value needs to be appended in the metric, such as:
    ```csv
    epoch_no,train_loss,train_accuracy_global_0,train_accuracy_global_1,train_f1_global_0,train_f1_global_1
    0,0.445031365,1.0,0.0,0.936170220375061,0.0
    ```
2. Concatenate the metrics that give lists into a single string:
    ```csv
    epoch_no,train_loss,train_accuracy_globa,train_f1_global
    0,0.445031365,1.0_0.0,0.936170220375061_0.0
    ```
My expectation is that solution `2` is easier to implement and maintain, and `1` needs a wrappers per metric.

**Screenshots**
N.A.

**GaNDLF Version**
<!-- Put the output of the following command:
python -c 'import GANDLF as g;print(g.__version__)'
-->
0.0.17-dev

**Desktop (please complete the following information):**
N.A.

**Additional context**
Related to #680


**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**GaNDLF Version**
<!-- Put the output of the following command:
python -c 'import GANDLF as g;print(g.__version__)'
-->
Version information of the GaNDLF package in the virtual environment.

**Desktop (please complete the following information):**
 - OS: [e.g. Windows/Linux (include detailed distro information)/macOS]
 - Version (including Build information, if any): [e.g. Fedora 22 or Windows 10.1803]

**Additional context**
Add any other context about the problem here.


Changed files:
GANDLF/utils/__init__.py, GANDLF/utils/generic.py, GANDLF/compute/training_loop.py, GANDLF/compute/forward_pass.py

diff --git a/GANDLF/compute/forward_pass.py b/GANDLF/compute/forward_pass.py
index 4a098d56..0adcc5fb 100644
--- a/GANDLF/compute/forward_pass.py
+++ b/GANDLF/compute/forward_pass.py
@@ -16,6 +16,7 @@ from GANDLF.utils import (
     resample_image,
     reverse_one_hot,
     get_ground_truths_and_predictions_tensor,
+    print_and_format_metrics,
 )
 from GANDLF.metrics import overall_stats
 from tqdm import tqdm
@@ -498,19 +499,14 @@ def validate_network(
             average_epoch_valid_metric = overall_stats(
                 predictions_array, ground_truth_array, params
             )
-        for metric in params["metrics"]:
-            if isinstance(total_epoch_valid_metric[metric], np.ndarray):
-                to_print = (
-                    total_epoch_valid_metric[metric] / len(valid_dataloader)
-                ).tolist()
-            else:
-                to_print = total_epoch_valid_metric[metric] / len(valid_dataloader)
-            average_epoch_valid_metric[metric] = to_print
-        for metric in average_epoch_valid_metric.keys():
-            print(
-                "     Epoch Final   " + mode + " " + metric + " : ",
-                average_epoch_valid_metric[metric],
-            )
+        average_epoch_valid_metric = print_and_format_metrics(
+            average_epoch_valid_metric,
+            total_epoch_valid_metric,
+            params["metrics"],
+            mode,
+            len(valid_dataloader),
+        )
+
     else:
         average_epoch_valid_loss, average_epoch_valid_metric = 0, {}
 
diff --git a/GANDLF/compute/training_loop.py b/GANDLF/compute/training_loop.py
index d28c8e2e..078cf86f 100644
--- a/GANDLF/compute/training_loop.py
+++ b/GANDLF/compute/training_loop.py
@@ -21,6 +21,7 @@ from GANDLF.utils import (
     print_model_summary,
     get_ground_truths_and_predictions_tensor,
     get_model_dict,
+    print_and_format_metrics,
 )
 from GANDLF.metrics import overall_stats
 from GANDLF.logger import Logger
@@ -204,19 +205,13 @@ def train_network(model, train_dataloader, optimizer, params):
         average_epoch_train_metric = overall_stats(
             predictions_array, ground_truth_array, params
         )
-    for metric in params["metrics"]:
-        if isinstance(total_epoch_train_metric[metric], np.ndarray):
-            to_print = (
-                total_epoch_train_metric[metric] / len(train_dataloader)
-            ).tolist()
-        else:
-            to_print = total_epoch_train_metric[metric] / len(train_dataloader)
-        average_epoch_train_metric[metric] = to_print
-    for metric in average_epoch_train_metric.keys():
-        print(
-            "     Epoch Final   train " + metric + " : ",
-            average_epoch_train_metric[metric],
-        )
+    average_epoch_train_metric = print_and_format_metrics(
+        average_epoch_train_metric,
+        total_epoch_train_metric,
+        params["metrics"],
+        "train",
+        len(train_dataloader),
+    )
 
     return average_epoch_train_loss, average_epoch_train_metric
 
@@ -348,15 +343,12 @@ def training_loop(
         overall_metrics = overall_stats(torch.Tensor([1]), torch.Tensor([1]), params)
     elif params["problem_type"] == "classification":
         # this is just used to generate the headers for the overall stats
-        org_num_classes = params["model"]["num_classes"]
-        params["model"]["num_classes"] = 3
+        temp_tensor = torch.randint(0, params["model"]["num_classes"], (5,))
         overall_metrics = overall_stats(
-            torch.Tensor([0, 0, 2, 2, 1, 2]).to(dtype=torch.int32),
-            torch.Tensor([0, 0, 2, 2, 1, 2]).to(dtype=torch.int32),
+            temp_tensor.to(dtype=torch.int32),
+            temp_tensor.to(dtype=torch.int32),
             params,
         )
-        # original number of classes are restored
-        params["model"]["num_classes"] = org_num_classes
 
     metrics_log = params["metrics"].copy()
     if calculate_overall_metrics:
@@ -373,13 +365,15 @@ def training_loop(
         logger_csv_filename=os.path.join(output_dir, "logs_validation.csv"),
         metrics=metrics_log,
     )
-    test_logger = Logger(
-        logger_csv_filename=os.path.join(output_dir, "logs_testing.csv"),
-        metrics=metrics_log,
-    )
+    if testingDataDefined:
+        test_logger = Logger(
+            logger_csv_filename=os.path.join(output_dir, "logs_testing.csv"),
+            metrics=metrics_log,
+        )
     train_logger.write_header(mode="train")
     valid_logger.write_header(mode="valid")
-    test_logger.write_header(mode="test")
+    if testingDataDefined:
+        test_logger.write_header(mode="test")
 
     if "medcam" in params:
         model = medcam.inject(
diff --git a/GANDLF/utils/__init__.py b/GANDLF/utils/__init__.py
index e8b6bab8..97c8face 100644
--- a/GANDLF/utils/__init__.py
+++ b/GANDLF/utils/__init__.py
@@ -49,6 +49,7 @@ from .generic import (
     get_array_from_image_or_tensor,
     suppress_stdout_stderr,
     set_determinism,
+    print_and_format_metrics,
 )
 
 from .modelio import (
diff --git a/GANDLF/utils/generic.py b/GANDLF/utils/generic.py
index fe672b6d..15970822 100644
--- a/GANDLF/utils/generic.py
+++ b/GANDLF/utils/generic.py
@@ -1,4 +1,5 @@
 import os, datetime, sys
+from copy import deepcopy
 import random
 import numpy as np
 import torch
@@ -201,3 +202,67 @@ def set_determinism(seed=42):
         torch.cuda.manual_seed_all(seed)
         torch.backends.cudnn.deterministic = True
         torch.backends.cudnn.benchmark = True
+
+
+def print_and_format_metrics(
+    cohort_level_metrics,
+    sample_level_metrics,
+    metrics_dict_from_parameters,
+    mode,
+    length_of_dataloader,
+):
+    """
+    This function prints and formats the metrics.
+
+    Args:
+        cohort_level_metrics (dict): The cohort level metrics calculated from the GANDLF.metrics.overall_stats function.
+        sample_level_metrics (dict): The sample level metrics calculated from separate samples from the dataloader(s).
+        metrics_dict_from_parameters (dict): The metrics dictionary to populate.
+        mode (str): The mode of the metrics (train, val, test).
+        length_of_dataloader (int): The length of the dataloader.
+
+    Returns:
+        dict: The metrics dictionary populated with the metrics.
+    """
+
+    def __update_metric_from_list_to_single_string(input_metrics_dict) -> dict:
+        """
+        Helper function updates the metrics dictionary to have a single string for each metric.
+
+        Args:
+            input_metrics_dict (dict): The input metrics dictionary.
+        Returns:
+            dict: The updated metrics dictionary.
+        """
+        print(input_metrics_dict)
+        output_metrics_dict = deepcopy(input_metrics_dict)
+        for metric in input_metrics_dict.keys():
+            if isinstance(input_metrics_dict[metric], list):
+                output_metrics_dict[metric] = ("_").join(
+                    str(input_metrics_dict[metric])
+                    .replace("[", "")
+                    .replace("]", "")
+                    .replace(" ", "")
+                    .split(",")
+                )
+
+        print(output_metrics_dict)
+        return output_metrics_dict
+
+    output_metrics_dict = deepcopy(cohort_level_metrics)
+    for metric in metrics_dict_from_parameters:
+        if isinstance(sample_level_metrics[metric], np.ndarray):
+            to_print = (sample_level_metrics[metric] / length_of_dataloader).tolist()
+        else:
+            to_print = sample_level_metrics[metric] / length_of_dataloader
+        output_metrics_dict[metric] = to_print
+    for metric in output_metrics_dict.keys():
+        print(
+            "     Epoch Final   " + mode + " " + metric + " : ",
+            output_metrics_dict[metric],
+        )
+    output_metrics_dict = __update_metric_from_list_to_single_string(
+        output_metrics_dict
+    )
+
+    return output_metrics_dict