Repository: Project-MONAI/MONAI

Issue:
Support fixed model file name in CheckpointSaver handler
**Is your feature request related to a problem? Please describe.**
Currently, `CheckpointSaver` can save model into file with dynamic name, which contains metric number or epoch number, etc.
Need to add an option to support fixed file name, this can be very useful for further auto-loading programs, no need to manually modify the filename in the evaluation program.



Discussion:

Resolving patch:
diff --git a/monai/handlers/checkpoint_saver.py b/monai/handlers/checkpoint_saver.py
--- a/monai/handlers/checkpoint_saver.py
+++ b/monai/handlers/checkpoint_saver.py
@@ -15,11 +15,15 @@
 from monai.utils import exact_version, optional_import
 
 Events, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Events")
-ModelCheckpoint, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "ModelCheckpoint")
+Checkpoint, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "Checkpoint")
+BaseSaveHandler, _ = optional_import("ignite.handlers.checkpoint", "0.4.2", exact_version, "BaseSaveHandler")
+
 if TYPE_CHECKING:
     from ignite.engine import Engine
+    from ignite.handlers import DiskSaver
 else:
     Engine, _ = optional_import("ignite.engine", "0.4.2", exact_version, "Engine")
+    DiskSaver, _ = optional_import("ignite.handlers", "0.4.2", exact_version, "DiskSaver")
 
 
 class CheckpointSaver:
@@ -40,6 +44,8 @@ class CheckpointSaver:
             If checkpoints are to be saved when an exception is raised, put this handler before
             `StatsHandler` in the handler list, because the logic with Ignite can only trigger
             the first attached handler for `EXCEPTION_RAISED` event.
+        final_filename: set a fixed filename to save the final model if `save_final=True`.
+            If None, default to `checkpoint_final_iteration=N.pt`.
         save_key_metric: whether to save checkpoint or session when the value of key_metric is
             higher than all the previous values during training.keep 4 decimal places of metric,
             checkpoint name is: {file_prefix}_key_metric=0.XXXX.pth.
@@ -47,6 +53,8 @@ class CheckpointSaver:
             If None, use `engine.state.key_metric` instead.
         key_metric_n_saved: save top N checkpoints or sessions, sorted by the value of key
             metric in descending order.
+        key_metric_filename: set a fixed filename to set the best metric model, if not None,
+            `key_metric_n_saved` should be 1 and only keep the best metric model.
         epoch_level: save checkpoint during training for every N epochs or every N iterations.
             `True` is epoch level, `False` is iteration level.
         save_interval: save checkpoint every N epochs, default is 0 to save no checkpoint.
@@ -56,11 +64,11 @@ class CheckpointSaver:
         CheckpointHandler can be used during training, validation or evaluation.
         example of saved files:
 
-            - checkpoint_iteration=400.pth
-            - checkpoint_iteration=800.pth
-            - checkpoint_epoch=1.pth
-            - checkpoint_final_iteration=1000.pth
-            - checkpoint_key_metric=0.9387.pth
+            - checkpoint_iteration=400.pt
+            - checkpoint_iteration=800.pt
+            - checkpoint_epoch=1.pt
+            - checkpoint_final_iteration=1000.pt
+            - checkpoint_key_metric=0.9387.pt
 
     """
 
@@ -71,9 +79,11 @@ def __init__(
         name: Optional[str] = None,
         file_prefix: str = "",
         save_final: bool = False,
+        final_filename: Optional[str] = None,
         save_key_metric: bool = False,
         key_metric_name: Optional[str] = None,
         key_metric_n_saved: int = 1,
+        key_metric_filename: Optional[str] = None,
         epoch_level: bool = True,
         save_interval: int = 0,
         n_saved: Optional[int] = None,
@@ -91,18 +101,39 @@ def __init__(
         self._final_checkpoint = self._key_metric_checkpoint = self._interval_checkpoint = None
         self._name = name
 
+        class _DiskSaver(DiskSaver):
+            """
+            Enhance the DiskSaver to support fixed filename.
+
+            """
+
+            def __init__(self, dirname: str, filename: Optional[str] = None):
+                super().__init__(dirname=dirname, require_empty=False)
+                self.filename = filename
+
+            def __call__(self, checkpoint: Dict, filename: str, metadata: Optional[Dict] = None) -> None:
+                if self.filename is not None:
+                    filename = self.filename
+                super().__call__(checkpoint=checkpoint, filename=filename, metadata=metadata)
+
+            def remove(self, filename: str) -> None:
+                if self.filename is not None:
+                    filename = self.filename
+                super().remove(filename=filename)
+
         if save_final:
 
             def _final_func(engine: Engine):
                 return engine.state.iteration
 
-            self._final_checkpoint = ModelCheckpoint(
-                self.save_dir,
-                file_prefix,
+            self._final_checkpoint = Checkpoint(
+                to_save=self.save_dict,
+                save_handler=_DiskSaver(dirname=self.save_dir, filename=final_filename),
+                filename_prefix=file_prefix,
                 score_function=_final_func,
                 score_name="final_iteration",
-                require_empty=False,
             )
+
         if save_key_metric:
 
             def _score_func(engine: Engine):
@@ -116,26 +147,30 @@ def _score_func(engine: Engine):
                     )
                 return round(engine.state.metrics[metric_name], 4)
 
-            self._key_metric_checkpoint = ModelCheckpoint(
-                self.save_dir,
-                file_prefix,
+            if key_metric_filename is not None and key_metric_n_saved > 1:
+                raise ValueError("if using fixed filename to save the best metric model, we should only save 1 model.")
+
+            self._key_metric_checkpoint = Checkpoint(
+                to_save=self.save_dict,
+                save_handler=_DiskSaver(dirname=self.save_dir, filename=key_metric_filename),
+                filename_prefix=file_prefix,
                 score_function=_score_func,
                 score_name="key_metric",
                 n_saved=key_metric_n_saved,
-                require_empty=False,
             )
+
         if save_interval > 0:
 
             def _interval_func(engine: Engine):
                 return engine.state.epoch if self.epoch_level else engine.state.iteration
 
-            self._interval_checkpoint = ModelCheckpoint(
-                self.save_dir,
-                file_prefix,
+            self._interval_checkpoint = Checkpoint(
+                to_save=self.save_dict,
+                save_handler=_DiskSaver(dirname=self.save_dir),
+                filename_prefix=file_prefix,
                 score_function=_interval_func,
                 score_name="epoch" if self.epoch_level else "iteration",
                 n_saved=n_saved,
-                require_empty=False,
             )
 
     def attach(self, engine: Engine) -> None:
@@ -164,7 +199,7 @@ def completed(self, engine: Engine) -> None:
             engine: Ignite Engine, it can be a trainer, validator or evaluator.
         """
         assert callable(self._final_checkpoint), "Error: _final_checkpoint function not specified."
-        self._final_checkpoint(engine, self.save_dict)
+        self._final_checkpoint(engine)
         assert self.logger is not None
         assert hasattr(self.logger, "info"), "Error, provided logger has not info attribute."
         self.logger.info(f"Train completed, saved final checkpoint: {self._final_checkpoint.last_checkpoint}")
@@ -179,7 +214,7 @@ def exception_raised(self, engine: Engine, e: Exception) -> None:
             e: the exception caught in Ignite during engine.run().
         """
         assert callable(self._final_checkpoint), "Error: _final_checkpoint function not specified."
-        self._final_checkpoint(engine, self.save_dict)
+        self._final_checkpoint(engine)
         assert self.logger is not None
         assert hasattr(self.logger, "info"), "Error, provided logger has not info attribute."
         self.logger.info(f"Exception_raised, saved exception checkpoint: {self._final_checkpoint.last_checkpoint}")
@@ -192,7 +227,7 @@ def metrics_completed(self, engine: Engine) -> None:
             engine: Ignite Engine, it can be a trainer, validator or evaluator.
         """
         assert callable(self._key_metric_checkpoint), "Error: _key_metric_checkpoint function not specified."
-        self._key_metric_checkpoint(engine, self.save_dict)
+        self._key_metric_checkpoint(engine)
 
     def interval_completed(self, engine: Engine) -> None:
         """Callback for train epoch/iteration completed Event.
@@ -202,7 +237,7 @@ def interval_completed(self, engine: Engine) -> None:
             engine: Ignite Engine, it can be a trainer, validator or evaluator.
         """
         assert callable(self._interval_checkpoint), "Error: _interval_checkpoint function not specified."
-        self._interval_checkpoint(engine, self.save_dict)
+        self._interval_checkpoint(engine)
         assert self.logger is not None
         assert hasattr(self.logger, "info"), "Error, provided logger has not info attribute."
         if self.epoch_level:


Test patch:
diff --git a/tests/test_handler_checkpoint_saver.py b/tests/test_handler_checkpoint_saver.py
--- a/tests/test_handler_checkpoint_saver.py
+++ b/tests/test_handler_checkpoint_saver.py
@@ -22,43 +22,64 @@
 
 from monai.handlers import CheckpointSaver
 
-TEST_CASE_1 = [True, False, None, 1, True, 0, None, ["test_checkpoint_final_iteration=40.pt"]]
+TEST_CASE_1 = [True, None, False, None, 1, None, True, 0, None, ["test_checkpoint_final_iteration=40.pt"]]
 
 TEST_CASE_2 = [
     False,
+    None,
     True,
     "val_loss",
     2,
+    None,
     True,
     0,
     None,
     ["test_checkpoint_key_metric=32.pt", "test_checkpoint_key_metric=40.pt"],
 ]
 
-TEST_CASE_3 = [False, False, None, 1, True, 2, 2, ["test_checkpoint_epoch=2.pt", "test_checkpoint_epoch=4.pt"]]
+TEST_CASE_3 = [
+    False,
+    None,
+    False,
+    None,
+    1,
+    None,
+    True,
+    2,
+    2,
+    ["test_checkpoint_epoch=2.pt", "test_checkpoint_epoch=4.pt"],
+]
 
 TEST_CASE_4 = [
     False,
+    None,
     False,
     None,
     1,
+    None,
     False,
     10,
     2,
     ["test_checkpoint_iteration=30.pt", "test_checkpoint_iteration=40.pt"],
 ]
 
-TEST_CASE_5 = [True, False, None, 1, True, 0, None, ["test_checkpoint_final_iteration=40.pt"], True]
+TEST_CASE_5 = [True, None, False, None, 1, None, True, 0, None, ["test_checkpoint_final_iteration=40.pt"], True]
+
+TEST_CASE_6 = [True, "final_model.pt", False, None, 1, None, True, 0, None, ["final_model.pt"]]
+
+TEST_CASE_7 = [False, None, True, "val_loss", 1, "model.pt", True, 0, None, ["model.pt"]]
 
 
 class TestHandlerCheckpointSaver(unittest.TestCase):
-    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5])
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4, TEST_CASE_5, TEST_CASE_6, TEST_CASE_7])
     def test_file(
         self,
         save_final,
+        final_filename,
         save_key_metric,
         key_metric_name,
         key_metric_n_saved,
+        key_metric_filename,
         epoch_level,
         save_interval,
         n_saved,
@@ -86,9 +107,11 @@ def _train_func(engine, batch):
                 "CheckpointSaver",
                 "test",
                 save_final,
+                final_filename,
                 save_key_metric,
                 key_metric_name,
                 key_metric_n_saved,
+                key_metric_filename,
                 epoch_level,
                 save_interval,
                 n_saved,