Repository: Project-MONAI/MONAI

Issue:
Optional split mark for CSV files
**Is your feature request related to a problem? Please describe.**
Feature request from @holgerroth :  the CSV output of in the metrics summary reports are split by `tap` instead of comma.
Would be nice to change the default to comma to be consistent with `CSVSaver` and better to add support to set the split mark for CSV writing logic.




Discussion:

Patch:
diff --git a/monai/data/csv_saver.py b/monai/data/csv_saver.py
--- a/monai/data/csv_saver.py
+++ b/monai/data/csv_saver.py
@@ -39,6 +39,7 @@ def __init__(
         filename: str = "predictions.csv",
         overwrite: bool = True,
         flush: bool = False,
+        delimiter: str = ",",
     ) -> None:
         """
         Args:
@@ -48,6 +49,8 @@ def __init__(
                 otherwise, will append new content to the CSV file.
             flush: whether to write the cache data to CSV file immediately when `save_batch` and clear the cache.
                 default to False.
+            delimiter: the delimiter character in the saved file, default to "," as the default output type is `csv`.
+                to be consistent with: https://docs.python.org/3/library/csv.html#csv.Dialect.delimiter.
 
         """
         self.output_dir = Path(output_dir)
@@ -59,6 +62,7 @@ def __init__(
             os.remove(self._filepath)
 
         self.flush = flush
+        self.delimiter = delimiter
         self._data_index = 0
 
     def finalize(self) -> None:
@@ -72,7 +76,7 @@ def finalize(self) -> None:
             for k, v in self._cache_dict.items():
                 f.write(k)
                 for result in v.flatten():
-                    f.write("," + str(result))
+                    f.write(self.delimiter + str(result))
                 f.write("\n")
         # clear cache content after writing
         self.reset_cache()
diff --git a/monai/handlers/classification_saver.py b/monai/handlers/classification_saver.py
--- a/monai/handlers/classification_saver.py
+++ b/monai/handlers/classification_saver.py
@@ -39,6 +39,7 @@ def __init__(
         self,
         output_dir: str = "./",
         filename: str = "predictions.csv",
+        delimiter: str = ",",
         overwrite: bool = True,
         batch_transform: Callable = lambda x: x,
         output_transform: Callable = lambda x: x,
@@ -50,6 +51,8 @@ def __init__(
         Args:
             output_dir: if `saver=None`, output CSV file directory.
             filename: if `saver=None`, name of the saved CSV file name.
+            delimiter: the delimiter character in the saved file, default to "," as the default output type is `csv`.
+                to be consistent with: https://docs.python.org/3/library/csv.html#csv.Dialect.delimiter.
             overwrite: if `saver=None`, whether to overwriting existing file content, if True,
                 will clear the file before saving. otherwise, will append new content to the file.
             batch_transform: a callable that is used to extract the `meta_data` dictionary of
@@ -74,6 +77,7 @@ def __init__(
         self.save_rank = save_rank
         self.output_dir = output_dir
         self.filename = filename
+        self.delimiter = delimiter
         self.overwrite = overwrite
         self.batch_transform = batch_transform
         self.output_transform = output_transform
@@ -153,6 +157,8 @@ def _finalize(self, _engine: Engine) -> None:
 
         # save to CSV file only in the expected rank
         if idist.get_rank() == self.save_rank:
-            saver = self.saver or CSVSaver(self.output_dir, self.filename, self.overwrite)
+            saver = self.saver or CSVSaver(
+                output_dir=self.output_dir, filename=self.filename, overwrite=self.overwrite, delimiter=self.delimiter
+            )
             saver.save_batch(outputs, meta_dict)
             saver.finalize()
diff --git a/monai/handlers/metrics_saver.py b/monai/handlers/metrics_saver.py
--- a/monai/handlers/metrics_saver.py
+++ b/monai/handlers/metrics_saver.py
@@ -70,7 +70,8 @@ class    mean    median    max    5percentile 95percentile  notnans
                 mean    6.2500   6.2500   7.0000   5.5750      6.9250       2.0000
 
         save_rank: only the handler on specified rank will save to files in multi-gpus validation, default to 0.
-        delimiter: the delimiter character in CSV file, default to "\t".
+        delimiter: the delimiter character in the saved file, default to "," as the default output type is `csv`.
+            to be consistent with: https://docs.python.org/3/library/csv.html#csv.Dialect.delimiter.
         output_type: expected output file type, supported types: ["csv"], default to "csv".
 
     """
@@ -83,7 +84,7 @@ def __init__(
         batch_transform: Callable = lambda x: x,
         summary_ops: Optional[Union[str, Sequence[str]]] = None,
         save_rank: int = 0,
-        delimiter: str = "\t",
+        delimiter: str = ",",
         output_type: str = "csv",
     ) -> None:
         self.save_dir = save_dir
diff --git a/monai/handlers/utils.py b/monai/handlers/utils.py
--- a/monai/handlers/utils.py
+++ b/monai/handlers/utils.py
@@ -56,7 +56,7 @@ def write_metrics_reports(
     metrics: Optional[Dict[str, Union[torch.Tensor, np.ndarray]]],
     metric_details: Optional[Dict[str, Union[torch.Tensor, np.ndarray]]],
     summary_ops: Optional[Union[str, Sequence[str]]],
-    deli: str = "\t",
+    deli: str = ",",
     output_type: str = "csv",
 ):
     """
@@ -88,7 +88,8 @@ class    mean    median    max    5percentile 95percentile  notnans
                 class1  6.0000   6.0000   6.0000   6.0000      6.0000       1.0000
                 mean    6.2500   6.2500   7.0000   5.5750      6.9250       2.0000
 
-        deli: the delimiter character in the file, default to "\t".
+        deli: the delimiter character in the saved file, default to "," as the default output type is `csv`.
+            to be consistent with: https://docs.python.org/3/library/csv.html#csv.Dialect.delimiter.
         output_type: expected output file type, supported types: ["csv"], default to "csv".
 
     """
diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py
--- a/monai/transforms/post/dictionary.py
+++ b/monai/transforms/post/dictionary.py
@@ -676,6 +676,7 @@ def __init__(
         saver: Optional[CSVSaver] = None,
         output_dir: PathLike = "./",
         filename: str = "predictions.csv",
+        delimiter: str = ",",
         overwrite: bool = True,
         flush: bool = True,
         allow_missing_keys: bool = False,
@@ -699,6 +700,8 @@ def __init__(
                 the saver must provide `save(data, meta_data)` and `finalize()` APIs.
             output_dir: if `saver=None`, specify the directory to save the CSV file.
             filename: if `saver=None`, specify the name of the saved CSV file.
+            delimiter: the delimiter character in the saved file, default to "," as the default output type is `csv`.
+                to be consistent with: https://docs.python.org/3/library/csv.html#csv.Dialect.delimiter.
             overwrite: if `saver=None`, indicate whether to overwriting existing CSV file content, if True,
                 will clear the file before saving. otherwise, will append new content to the CSV file.
             flush: if `saver=None`, indicate whether to write the cache data to CSV file immediately
@@ -710,7 +713,9 @@ def __init__(
         super().__init__(keys, allow_missing_keys)
         if len(self.keys) != 1:
             raise ValueError("only 1 key is allowed when saving the classification result.")
-        self.saver = saver or CSVSaver(output_dir, filename, overwrite, flush)
+        self.saver = saver or CSVSaver(
+            output_dir=output_dir, filename=filename, overwrite=overwrite, flush=flush, delimiter=delimiter
+        )
         self.flush = flush
         self.meta_keys = ensure_tuple_rep(meta_keys, len(self.keys))
         self.meta_key_postfix = ensure_tuple_rep(meta_key_postfix, len(self.keys))


Test patch:
diff --git a/tests/test_csv_saver.py b/tests/test_csv_saver.py
--- a/tests/test_csv_saver.py
+++ b/tests/test_csv_saver.py
@@ -23,14 +23,14 @@
 class TestCSVSaver(unittest.TestCase):
     def test_saved_content(self):
         with tempfile.TemporaryDirectory() as tempdir:
-            saver = CSVSaver(output_dir=tempdir, filename="predictions.csv")
+            saver = CSVSaver(output_dir=tempdir, filename="predictions.csv", delimiter="\t")
             meta_data = {"filename_or_obj": ["testfile" + str(i) for i in range(8)]}
             saver.save_batch(torch.zeros(8), meta_data)
             saver.finalize()
             filepath = os.path.join(tempdir, "predictions.csv")
             self.assertTrue(os.path.exists(filepath))
             with open(filepath) as f:
-                reader = csv.reader(f)
+                reader = csv.reader(f, delimiter="\t")
                 i = 0
                 for row in reader:
                     self.assertEqual(row[0], "testfile" + str(i))
diff --git a/tests/test_handler_classification_saver.py b/tests/test_handler_classification_saver.py
--- a/tests/test_handler_classification_saver.py
+++ b/tests/test_handler_classification_saver.py
@@ -35,8 +35,8 @@ def _train_func(engine, batch):
             engine = Engine(_train_func)
 
             # set up testing handler
-            saver = CSVSaver(output_dir=tempdir, filename="predictions2.csv")
-            ClassificationSaver(output_dir=tempdir, filename="predictions1.csv").attach(engine)
+            saver = CSVSaver(output_dir=tempdir, filename="predictions2.csv", delimiter="\t")
+            ClassificationSaver(output_dir=tempdir, filename="predictions1.csv", delimiter="\t").attach(engine)
             ClassificationSaver(saver=saver).attach(engine)
 
             data = [{"filename_or_obj": ["testfile" + str(i) for i in range(8)]}]
@@ -46,7 +46,7 @@ def _test_file(filename):
                 filepath = os.path.join(tempdir, filename)
                 self.assertTrue(os.path.exists(filepath))
                 with open(filepath) as f:
-                    reader = csv.reader(f)
+                    reader = csv.reader(f, delimiter="\t")
                     i = 0
                     for row in reader:
                         self.assertEqual(row[0], "testfile" + str(i))
diff --git a/tests/test_handler_metrics_saver.py b/tests/test_handler_metrics_saver.py
--- a/tests/test_handler_metrics_saver.py
+++ b/tests/test_handler_metrics_saver.py
@@ -30,6 +30,7 @@ def test_content(self):
                 metric_details=["metric3", "metric4"],
                 batch_transform=lambda x: x[PostFix.meta("image")],
                 summary_ops=["mean", "median", "max", "5percentile", "95percentile", "notnans"],
+                delimiter="\t",
             )
             # set up engine
             data = [
diff --git a/tests/test_handler_metrics_saver_dist.py b/tests/test_handler_metrics_saver_dist.py
--- a/tests/test_handler_metrics_saver_dist.py
+++ b/tests/test_handler_metrics_saver_dist.py
@@ -41,6 +41,7 @@ def _run(self, tempdir):
             metric_details=["metric3", "metric4"],
             batch_transform=lambda x: x[PostFix.meta("image")],
             summary_ops="*",
+            delimiter="\t",
         )
 
         def _val_func(engine, batch):
diff --git a/tests/test_save_classificationd.py b/tests/test_save_classificationd.py
--- a/tests/test_save_classificationd.py
+++ b/tests/test_save_classificationd.py
@@ -41,7 +41,9 @@ def test_saved_content(self):
                 },
             ]
 
-            saver = CSVSaver(output_dir=Path(tempdir), filename="predictions2.csv", overwrite=False, flush=False)
+            saver = CSVSaver(
+                output_dir=Path(tempdir), filename="predictions2.csv", overwrite=False, flush=False, delimiter="\t"
+            )
             # set up test transforms
             post_trans = Compose(
                 [
@@ -53,6 +55,7 @@ def test_saved_content(self):
                         meta_keys=None,
                         output_dir=Path(tempdir),
                         filename="predictions1.csv",
+                        delimiter="\t",
                         overwrite=True,
                     ),
                     # 2rd saver only saves data into the cache, manually finalize later
@@ -76,6 +79,7 @@ def test_saved_content(self):
                 meta_keys=PostFix.meta("image"),  # specify meta key, so no need to copy anymore
                 output_dir=tempdir,
                 filename="predictions1.csv",
+                delimiter="\t",
                 overwrite=False,
             )
             d = decollate_batch(data[2])
@@ -86,7 +90,7 @@ def _test_file(filename, count):
                 filepath = os.path.join(tempdir, filename)
                 self.assertTrue(os.path.exists(filepath))
                 with open(filepath) as f:
-                    reader = csv.reader(f)
+                    reader = csv.reader(f, delimiter="\t")
                     i = 0
                     for row in reader:
                         self.assertEqual(row[0], "testfile" + str(i))