From 59d7a44e26301d6d672974f7117543caab8246dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Wed, 25 Mar 2026 10:33:47 +0100 Subject: [PATCH 1/3] fix(security): replace pickle-based torch.save/load with safetensors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all torch.save/torch.load(weights_only=False) usage with the safetensors library to prevent arbitrary code execution via pickle deserialization of untrusted model files. New checkpoint format stores tensors in safetensors binary format and non-tensor metadata as JSON in the safetensors header. No legacy .pth/.pkl loading is retained — all torch.load calls are removed. - Add anomaly_match/data_io/checkpoint_io.py with save_checkpoint() and load_checkpoint() functions - Convert test_model.pth fixture to test_model.safetensors - Update all file extension references from .pth to .safetensors - Add safetensors to dependencies - Add unit tests for checkpoint_io (round-trip, security) --- anomaly_match/data_io/SessionIOHandler.py | 39 +-- anomaly_match/data_io/checkpoint_io.py | 326 ++++++++++++++++++ anomaly_match/utils/get_default_cfg.py | 2 +- environment.yml | 1 + environment_CI.yml | 1 + prediction_utils.py | 7 +- pyproject.toml | 1 + tests/e2e/test_prediction_process.py | 2 +- .../test_fitsbolt_config_persistence.py | 214 +++++------- .../integration/test_model_io_integration.py | 41 ++- tests/integration/test_run_label_migration.py | 17 +- ...{test_model.pth => test_model.safetensors} | Bin 13802918 -> 27386904 bytes tests/unit/test_checkpoint_io.py | 245 +++++++++++++ tests/unit/test_session_io_handler.py | 77 ++++- 14 files changed, 784 insertions(+), 189 deletions(-) create mode 100644 anomaly_match/data_io/checkpoint_io.py rename tests/test_data/{test_model.pth => test_model.safetensors} (50%) create mode 100644 tests/unit/test_checkpoint_io.py diff --git a/anomaly_match/data_io/SessionIOHandler.py b/anomaly_match/data_io/SessionIOHandler.py index 86fc0c3..8cd2a05 100644 --- a/anomaly_match/data_io/SessionIOHandler.py +++ b/anomaly_match/data_io/SessionIOHandler.py @@ -7,14 +7,13 @@ import json import os -import pickle from pathlib import Path from typing import Any, Dict, List, Optional import pandas as pd -import torch from loguru import logger +from anomaly_match.data_io.checkpoint_io import load_checkpoint, save_checkpoint from anomaly_match.data_io.save_config import save_config_toml from anomaly_match.pipeline.SessionTracker import IterationInfo, SessionTracker @@ -209,12 +208,12 @@ def save_model_checkpoint( checkpoints_dir.mkdir(exist_ok=True) if checkpoint_name is None: - checkpoint_name = f"model_iter_{session_tracker.total_model_iterations}.pkl" + checkpoint_name = f"model_iter_{session_tracker.total_model_iterations}.safetensors" checkpoint_path = checkpoints_dir / checkpoint_name - - with open(checkpoint_path, "wb") as f: - pickle.dump(model_state, f) + save_checkpoint(model_state, checkpoint_path) + # save_checkpoint forces .safetensors extension + checkpoint_path = checkpoint_path.with_suffix(".safetensors") # Update the session tracker with the checkpoint path session_tracker.update_model_state_path(str(checkpoint_path)) @@ -246,7 +245,7 @@ def save_model(self, model, cfg, session_tracker: SessionTracker = None) -> str: if session_tracker.session_iterations else 0 ) - model_filename = f"model_iteration_{iteration_num}.pth" + model_filename = f"model_iteration_{iteration_num}.safetensors" model_path = save_path / model_filename else: if cfg.model_path is None: @@ -287,8 +286,9 @@ def save_model(self, model, cfg, session_tracker: SessionTracker = None) -> str: "fitsbolt_cfg": fitsbolt_cfg, } - # Save model - torch.save(save_state, model_path) + # Save model (save_checkpoint forces .safetensors extension) + save_checkpoint(save_state, model_path) + model_path = Path(model_path).with_suffix(".safetensors") if session_tracker is not None: # Ensure there's an active session iteration @@ -331,7 +331,7 @@ def load_model(self, model, cfg, model_path: str = None) -> bool: try: # Load checkpoint - checkpoint = torch.load(load_path, weights_only=False) + checkpoint = load_checkpoint(load_path) # Handle distributed training case train_model = ( @@ -441,15 +441,8 @@ def load_model_checkpoint(self, checkpoint_path: str) -> Optional[Dict[str, Any] logger.error(f"Checkpoint path does not exist: {checkpoint_path}") return None - # Try loading as pickle first (new format), then as torch (legacy) - try: - with open(checkpoint_path, "rb") as f: - checkpoint = pickle.load(f) - logger.debug(f"Loaded checkpoint from pickle format: {checkpoint_path}") - except (pickle.UnpicklingError, EOFError): - # Fall back to torch format - checkpoint = torch.load(checkpoint_path, weights_only=False, map_location="cpu") - logger.debug(f"Loaded checkpoint from torch format: {checkpoint_path}") + checkpoint = load_checkpoint(checkpoint_path) + logger.debug(f"Loaded checkpoint: {checkpoint_path}") return checkpoint @@ -611,7 +604,9 @@ def save_run( "fitsbolt_cfg": fitsbolt_cfg, } - torch.save(save_state, save_filename) + save_checkpoint(save_state, save_filename) + # save_checkpoint forces .safetensors extension; update save_filename to match + save_filename = str(Path(save_filename).with_suffix(".safetensors")) # Update session tracker if provided if session_tracker is not None: @@ -706,7 +701,7 @@ def update_config_paths_for_session(self, cfg, session_tracker: SessionTracker) # Update model path to session directory only if not already set by user if cfg.model_path is None: - cfg.model_path = str(session_path / "model.pth") + cfg.model_path = str(session_path / "model.safetensors") # Update output directory to session directory cfg.output_dir = str(session_path) @@ -805,7 +800,7 @@ def print_session(filepath: str) -> None: checkpoints_dir = session_path / "checkpoints" if checkpoints_dir.exists(): - checkpoints = list(checkpoints_dir.glob("*.pkl")) + checkpoints = list(checkpoints_dir.glob("*.safetensors")) print(f"✓ {len(checkpoints)} model checkpoint(s)") print("=" * 60) diff --git a/anomaly_match/data_io/checkpoint_io.py b/anomaly_match/data_io/checkpoint_io.py new file mode 100644 index 0000000..191b63c --- /dev/null +++ b/anomaly_match/data_io/checkpoint_io.py @@ -0,0 +1,326 @@ +# Copyright (c) European Space Agency, 2025. +# +# This file is subject to the terms and conditions defined in file 'LICENCE.txt', which +# is part of this source code package. No part of the package, including +# this file, may be copied, modified, propagated, or distributed except according to +# the terms contained in the file 'LICENCE.txt'. + +"""Checkpoint I/O using safetensors for secure model serialization. + +Replaces pickle-based ``torch.save`` / ``torch.load`` with safetensors to +prevent arbitrary code execution when loading untrusted model files. + +Checkpoint layout inside a single ``.safetensors`` file: + +* **Binary section** — all ``torch.Tensor`` values (model weights, optimizer + momentum buffers, …) stored under namespaced keys + (``train_model.``, ``optimizer.state..``, …). +* **Metadata header** — every non-tensor value is JSON-encoded into the + ``Dict[str, str]`` metadata that safetensors carries in its header. +""" + +from __future__ import annotations + +import json +from enum import Enum +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from loguru import logger + +# --------------------------------------------------------------------------- +# JSON helpers for types that appear in checkpoint metadata +# --------------------------------------------------------------------------- + + +def _nullify_empty_dicts(obj: Any) -> Any: + """Recursively replace empty dicts with ``None``. + + DotMap auto-creates empty child maps when accessing missing keys. After + ``toDict()`` these become ``{}``, which breaks fitsbolt's + ``validate_config`` on reload (e.g. ``channel_combination`` is expected to + be ``None`` or ``np.ndarray``, not ``{}``). + """ + if isinstance(obj, dict): + if len(obj) == 0: + return None + return {k: _nullify_empty_dicts(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_nullify_empty_dicts(v) for v in obj] + return obj + + +def _prepare_for_json(obj: Any) -> Any: + """Recursively convert non-JSON-native types to tagged representations. + + This is needed because ``IntEnum`` (which ``NormalisationMethod`` inherits + from) is a subclass of ``int`` — the standard JSON encoder serializes it + as a plain integer and never calls ``default()``. By walking the + structure up-front we ensure *all* special types are tagged. + + """ + # Enum check MUST come before int/float because IntEnum is also an int + if isinstance(obj, Enum): + return {"__enum__": type(obj).__name__, "name": obj.name} + if isinstance(obj, np.dtype): + return {"__numpy_dtype__": str(obj)} + if isinstance(obj, type) and issubclass(obj, np.generic): + return {"__numpy_dtype_type__": np.dtype(obj).str} + if isinstance(obj, np.ndarray): + return {"__numpy_array__": obj.tolist(), "dtype": str(obj.dtype)} + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.bool_): + return bool(obj) + if isinstance(obj, dict): + return {k: _prepare_for_json(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_prepare_for_json(v) for v in obj] + return obj + + +class _CheckpointEncoder(json.JSONEncoder): + """JSON encoder that handles checkpoint-specific types. + + Note: ``IntEnum`` values bypass ``default()`` because they *are* ints. + Use :func:`_prepare_for_json` on the data **before** calling + ``json.dumps`` to ensure those types are correctly tagged. + """ + + def default(self, obj: Any) -> Any: + if isinstance(obj, Enum): + return {"__enum__": type(obj).__name__, "name": obj.name} + if isinstance(obj, np.dtype): + return {"__numpy_dtype__": str(obj)} + if isinstance(obj, type) and issubclass(obj, np.generic): + return {"__numpy_dtype_type__": np.dtype(obj).str} + if isinstance(obj, np.ndarray): + return {"__numpy_array__": obj.tolist(), "dtype": str(obj.dtype)} + if isinstance(obj, np.integer): + return int(obj) + if isinstance(obj, np.floating): + return float(obj) + if isinstance(obj, np.bool_): + return bool(obj) + return super().default(obj) + + +def _checkpoint_object_hook(obj: dict) -> Any: + """JSON object-hook that restores checkpoint-specific types.""" + if "__enum__" in obj: + enum_name = obj["__enum__"] + if enum_name == "NormalisationMethod": + from fitsbolt.normalisation.NormalisationMethod import NormalisationMethod + + return NormalisationMethod[obj["name"]] + return f"{enum_name}.{obj['name']}" + if "__numpy_dtype__" in obj: + return np.dtype(obj["__numpy_dtype__"]) + if "__numpy_dtype_type__" in obj: + return np.dtype(obj["__numpy_dtype_type__"]).type + if "__numpy_array__" in obj: + return np.array(obj["__numpy_array__"], dtype=obj["dtype"]) + return obj + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def save_checkpoint(save_state: dict[str, Any], path: str | Path) -> Path: + """Save a model checkpoint in safetensors format. + + Tensors are stored in the safetensors binary section; everything else is + JSON-encoded into the safetensors metadata header. + + Args: + save_state: Checkpoint dictionary (same keys as previously passed to + ``torch.save``). + path: Destination file path. The extension is forced to + ``.safetensors``. + + Returns: + The actual path written (with ``.safetensors`` extension). + """ + from safetensors.torch import save_file + + path = Path(path).with_suffix(".safetensors") + + tensors: dict[str, torch.Tensor] = {} + metadata: dict[str, str] = {} + + # ---- model state-dicts ------------------------------------------------ + for model_key in ("train_model", "eval_model"): + state_dict = save_state.get(model_key) + if state_dict is None: + continue + for param_name, tensor in state_dict.items(): + tensors[f"{model_key}.{param_name}"] = tensor.detach().clone().contiguous() + + # ---- optimizer state -------------------------------------------------- + opt_state = save_state.get("optimizer") + if opt_state is not None: + opt_skeleton: dict[str, Any] = { + "state": {}, + "param_groups": opt_state.get("param_groups", []), + } + for param_idx, state in opt_state.get("state", {}).items(): + idx_str = str(param_idx) + opt_skeleton["state"][idx_str] = {} + for key, val in state.items(): + if isinstance(val, torch.Tensor): + tensors[f"optimizer.state.{param_idx}.{key}"] = ( + val.detach().clone().contiguous() + ) + opt_skeleton["state"][idx_str][key] = "__tensor__" + else: + opt_skeleton["state"][idx_str][key] = val + metadata["optimizer"] = json.dumps(_prepare_for_json(opt_skeleton), cls=_CheckpointEncoder) + else: + metadata["optimizer"] = "null" + + # ---- scheduler state -------------------------------------------------- + sched_state = save_state.get("scheduler") + metadata["scheduler"] = ( + json.dumps(_prepare_for_json(sched_state), cls=_CheckpointEncoder) + if sched_state is not None + else "null" + ) + + # ---- scalar / enum metadata ------------------------------------------- + for key in ( + "it", + "total_it", + "best_eval_acc", + "best_it", + "num_channels", + "net", + "normalisation_method", + "last_normalisation_method", + ): + metadata[key] = json.dumps(_prepare_for_json(save_state.get(key)), cls=_CheckpointEncoder) + + # ---- fitsbolt config (DotMap → dict → JSON) --------------------------- + fb_cfg = save_state.get("fitsbolt_cfg") + if fb_cfg is not None: + cfg_dict = fb_cfg.toDict() if hasattr(fb_cfg, "toDict") else fb_cfg + # DotMap auto-creates empty child maps on missing-key access (e.g. + # channel_combination). After toDict() these become empty dicts {}, + # which break fitsbolt's validate_config on reload. Normalize + # leaf-level empty dicts to None. + cfg_dict = _nullify_empty_dicts(cfg_dict) + metadata["fitsbolt_cfg"] = json.dumps(_prepare_for_json(cfg_dict), cls=_CheckpointEncoder) + else: + metadata["fitsbolt_cfg"] = "null" + + # ---- labeled-data CSV ------------------------------------------------- + csv_str = save_state.get("labeled_data_csv") + if csv_str is not None: + metadata["labeled_data_csv"] = csv_str + + # safetensors requires at least one tensor + if not tensors: + tensors["__placeholder__"] = torch.zeros(1) + + save_file(tensors, str(path), metadata=metadata) + logger.debug(f"Saved checkpoint in safetensors format: {path}") + return path + + +def load_checkpoint(path: str | Path, device: str = "cpu") -> dict[str, Any]: + """Load a model checkpoint from a ``.safetensors`` file. + + Args: + path: Path to the ``.safetensors`` checkpoint file. + device: Device to map tensors to (default ``"cpu"``). + + Returns: + Checkpoint dictionary with the same structure as originally saved. + + Raises: + FileNotFoundError: If *path* does not exist. + """ + from safetensors import safe_open + from safetensors.torch import load_file + + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Checkpoint not found: {path}") + + all_tensors = load_file(str(path), device=device) + + with safe_open(str(path), framework="pt", device=device) as f: + raw_metadata = f.metadata() or {} + + checkpoint: dict[str, Any] = {} + + # ---- model state-dicts ------------------------------------------------ + for model_key in ("train_model", "eval_model"): + prefix = f"{model_key}." + state_dict = {k[len(prefix) :]: v for k, v in all_tensors.items() if k.startswith(prefix)} + if state_dict: + checkpoint[model_key] = state_dict + + # ---- optimizer state -------------------------------------------------- + opt_skeleton = json.loads( + raw_metadata.get("optimizer", "null"), object_hook=_checkpoint_object_hook + ) + if opt_skeleton is not None: + new_state: dict[int, dict] = {} + for idx_str, state in opt_skeleton.get("state", {}).items(): + restored: dict[str, Any] = {} + for key, val in state.items(): + if val == "__tensor__": + restored[key] = all_tensors[f"optimizer.state.{idx_str}.{key}"] + else: + restored[key] = val + new_state[int(idx_str)] = restored + opt_skeleton["state"] = new_state + checkpoint["optimizer"] = opt_skeleton + else: + checkpoint["optimizer"] = None + + # ---- scheduler state -------------------------------------------------- + checkpoint["scheduler"] = json.loads( + raw_metadata.get("scheduler", "null"), object_hook=_checkpoint_object_hook + ) + + # ---- scalar / enum metadata ------------------------------------------- + for key in ( + "it", + "total_it", + "best_eval_acc", + "best_it", + "num_channels", + "net", + "normalisation_method", + "last_normalisation_method", + ): + checkpoint[key] = json.loads( + raw_metadata.get(key, "null"), object_hook=_checkpoint_object_hook + ) + + # ---- fitsbolt config -------------------------------------------------- + fb_data = json.loads( + raw_metadata.get("fitsbolt_cfg", "null"), object_hook=_checkpoint_object_hook + ) + if fb_data is not None: + from dotmap import DotMap + + # _dynamic=False prevents DotMap from auto-creating empty child maps + # on missing-key access, which would break fitsbolt's validate_config + # (e.g. channel_combination should stay absent, not become DotMap()). + checkpoint["fitsbolt_cfg"] = DotMap(fb_data, _dynamic=False) + else: + checkpoint["fitsbolt_cfg"] = None + + # ---- labeled-data CSV ------------------------------------------------- + if "labeled_data_csv" in raw_metadata: + checkpoint["labeled_data_csv"] = raw_metadata["labeled_data_csv"] + + return checkpoint diff --git a/anomaly_match/utils/get_default_cfg.py b/anomaly_match/utils/get_default_cfg.py index 5ac0bc9..969efbc 100644 --- a/anomaly_match/utils/get_default_cfg.py +++ b/anomaly_match/utils/get_default_cfg.py @@ -32,7 +32,7 @@ def get_default_cfg(): cfg.metadata_file = None # Path to the metadata CSV file cfg.prediction_search_dir = None cfg.save_path = os.path.join(cfg.save_dir) - cfg.save_file = create_model_string(cfg) + ".pth" + cfg.save_file = create_model_string(cfg) + ".safetensors" cfg.model_path = None # Will be set by SessionIOHandler when session is active cfg.N_batch_prediction = None # User specified batch size for evaluating a directory, if None: determined automatically cfg.subprocess_buffer_size = ( diff --git a/environment.yml b/environment.yml index 94b3ce4..209c452 100644 --- a/environment.yml +++ b/environment.yml @@ -38,4 +38,5 @@ dependencies: - cutana>=0.2.1 - fitsbolt>=0.2 - opencv-python-headless + - safetensors - timm diff --git a/environment_CI.yml b/environment_CI.yml index d815bc1..6c52521 100644 --- a/environment_CI.yml +++ b/environment_CI.yml @@ -35,6 +35,7 @@ dependencies: - pip: - opencv-python-headless - albumentations + - safetensors - timm - fitsbolt>=0.2 - cutana>=0.2.1 diff --git a/prediction_utils.py b/prediction_utils.py index 6ce6348..e47e810 100644 --- a/prediction_utils.py +++ b/prediction_utils.py @@ -24,6 +24,7 @@ from loguru import logger from turbojpeg import TurboJPEG +from anomaly_match.data_io.checkpoint_io import load_checkpoint from anomaly_match.data_io.load_images import get_fitsbolt_config, process_single_wrapper from anomaly_match.utils.get_default_cfg import get_default_cfg @@ -189,10 +190,8 @@ def load_model(cfg): else: logger.info("Using CPU for inference") - if torch.cuda.is_available(): - checkpoint = torch.load(model_path, weights_only=False) - else: - checkpoint = torch.load(model_path, weights_only=False, map_location=torch.device("cpu")) + device = "cuda" if torch.cuda.is_available() else "cpu" + checkpoint = load_checkpoint(model_path, device=device) if "eval_model" not in checkpoint: raise KeyError( diff --git a/pyproject.toml b/pyproject.toml index 3e25184..71703fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "psutil", "pyarrow", "pyturbojpeg", + "safetensors", "scikit-image", "scikit-learn", "scipy", diff --git a/tests/e2e/test_prediction_process.py b/tests/e2e/test_prediction_process.py index 799d366..e8bfad1 100644 --- a/tests/e2e/test_prediction_process.py +++ b/tests/e2e/test_prediction_process.py @@ -40,7 +40,7 @@ def test_config(): cfg.net = "efficientnet-lite0" cfg.pretrained = True cfg.num_channels = 3 - cfg.model_path = "tests/test_data/test_model.pth" + cfg.model_path = "tests/test_data/test_model.safetensors" cfg.gpu = 0 cfg.output_dir = tempfile.mkdtemp() cfg.normalisation.normalisation_method = NormalisationMethod.CONVERSION_ONLY diff --git a/tests/integration/test_fitsbolt_config_persistence.py b/tests/integration/test_fitsbolt_config_persistence.py index ee58584..c8d80c9 100644 --- a/tests/integration/test_fitsbolt_config_persistence.py +++ b/tests/integration/test_fitsbolt_config_persistence.py @@ -7,8 +7,8 @@ """Tests for fitsbolt configuration persistence in model checkpoints. -The fitsbolt DotMap configuration can be pickled directly via torch.save/load -without explicit serialization. +The fitsbolt DotMap configuration is serialized via safetensors metadata +(JSON-encoded) through save_checkpoint/load_checkpoint. """ import shutil @@ -22,14 +22,36 @@ from fitsbolt.cfg.create_config import validate_config from fitsbolt.normalisation.NormalisationMethod import NormalisationMethod +from anomaly_match.data_io.checkpoint_io import load_checkpoint, save_checkpoint from anomaly_match.data_io.load_images import get_fitsbolt_config -class TestFitsboltConfigPickling: - """Test cases for fitsbolt config pickling via torch.save/load.""" - - def test_pickle_roundtrip_basic(self): - """Test basic pickle roundtrip via torch checkpoint.""" +def _make_checkpoint(fitsbolt_cfg=None, **extra): + """Create a minimal checkpoint dict suitable for save_checkpoint.""" + checkpoint = { + "train_model": {"dummy.weight": torch.randn(2, 2)}, + "eval_model": {"dummy.weight": torch.randn(2, 2)}, + "optimizer": None, + "scheduler": None, + "it": 0, + "total_it": 0, + "best_eval_acc": None, + "best_it": None, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": None, + "last_normalisation_method": None, + "fitsbolt_cfg": fitsbolt_cfg, + } + checkpoint.update(extra) + return checkpoint + + +class TestFitsboltConfigSafetensors: + """Test cases for fitsbolt config persistence via safetensors.""" + + def test_roundtrip_basic(self): + """Test basic roundtrip via safetensors checkpoint.""" original_cfg = fb_create_cfg( output_dtype=np.uint8, size=[64, 64], @@ -38,44 +60,34 @@ def test_pickle_roundtrip_basic(self): num_workers=4, ) - # Save via torch - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] assert loaded_cfg.size == original_cfg.size assert loaded_cfg.n_output_channels == original_cfg.n_output_channels - assert loaded_cfg.num_workers == original_cfg.num_workers assert loaded_cfg.normalisation_method == original_cfg.normalisation_method - finally: - Path(checkpoint_path).unlink(missing_ok=True) - def test_pickle_numpy_dtype(self): - """Test pickling of numpy dtypes.""" + def test_numpy_dtype(self): + """Test persistence of numpy dtypes.""" original_cfg = fb_create_cfg( output_dtype=np.float32, size=[128, 128], n_output_channels=3, ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] assert loaded_cfg.output_dtype == np.float32 - finally: - Path(checkpoint_path).unlink(missing_ok=True) - def test_pickle_all_normalisation_methods(self): - """Test pickling with all normalisation methods.""" + def test_all_normalisation_methods(self): + """Test persistence with all normalisation methods.""" for method in NormalisationMethod: original_cfg = fb_create_cfg( output_dtype=np.uint8, @@ -84,20 +96,16 @@ def test_pickle_all_normalisation_methods(self): normalisation_method=method, ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] assert loaded_cfg.normalisation_method == method - finally: - Path(checkpoint_path).unlink(missing_ok=True) - def test_pickle_channel_combination(self): - """Test pickling of numpy array channel_combination.""" + def test_channel_combination(self): + """Test persistence of numpy array channel_combination.""" original_cfg = fb_create_cfg( output_dtype=np.uint8, size=[64, 64], @@ -106,22 +114,18 @@ def test_pickle_channel_combination(self): channel_combination=np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]), ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] np.testing.assert_array_equal( loaded_cfg.channel_combination, original_cfg.channel_combination ) - finally: - Path(checkpoint_path).unlink(missing_ok=True) - def test_pickle_asinh_settings(self): - """Test pickling of asinh normalisation settings.""" + def test_asinh_settings(self): + """Test persistence of asinh normalisation settings.""" original_cfg = fb_create_cfg( output_dtype=np.uint8, size=[64, 64], @@ -131,25 +135,21 @@ def test_pickle_asinh_settings(self): norm_asinh_clip=[99.0, 99.5, 99.8], ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] assert loaded_cfg.normalisation.asinh_scale == original_cfg.normalisation.asinh_scale assert loaded_cfg.normalisation.asinh_clip == original_cfg.normalisation.asinh_clip - finally: - Path(checkpoint_path).unlink(missing_ok=True) class TestFitsboltConfigValidation: - """Test cases for fitsbolt config validation after pickling.""" + """Test cases for fitsbolt config validation after safetensors roundtrip.""" - def test_validate_pickled_config(self): - """Test that pickled config passes fitsbolt validation.""" + def test_validate_roundtripped_config(self): + """Test that roundtripped config passes fitsbolt validation.""" original_cfg = fb_create_cfg( output_dtype=np.uint8, size=[64, 64], @@ -158,25 +158,21 @@ def test_validate_pickled_config(self): num_workers=4, ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": original_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=original_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] # Validate using fitsbolt's validate_config validate_config(loaded_cfg) - finally: - Path(checkpoint_path).unlink(missing_ok=True) class TestFitsboltConfigCompatibility: """Test compatibility with fitsbolt's create_config function.""" def test_compatibility_with_fits_extension_settings(self): - """Test pickling with various fits_extension configurations.""" + """Test persistence with various fits_extension configurations.""" # Single integer extension cfg1 = fb_create_cfg( output_dtype=np.uint8, @@ -185,15 +181,11 @@ def test_compatibility_with_fits_extension_settings(self): fits_extension=0, ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": cfg1}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=cfg1), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) validate_config(loaded["fitsbolt_cfg"]) - finally: - Path(checkpoint_path).unlink(missing_ok=True) # List of extensions cfg2 = fb_create_cfg( @@ -203,22 +195,18 @@ def test_compatibility_with_fits_extension_settings(self): fits_extension=[0, 1, 2], ) - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": cfg2}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=cfg2), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) validate_config(loaded["fitsbolt_cfg"]) - finally: - Path(checkpoint_path).unlink(missing_ok=True) class TestGetFitsboltConfigIntegration: - """Test get_fitsbolt_config integration with pickling.""" + """Test get_fitsbolt_config integration with safetensors persistence.""" - def test_get_fitsbolt_config_pickling(self): - """Test that config from get_fitsbolt_config can be pickled.""" + def test_get_fitsbolt_config_roundtrip(self): + """Test that config from get_fitsbolt_config survives safetensors roundtrip.""" # Create an AnomalyMatch-style config cfg = DotMap() cfg.normalisation = DotMap() @@ -240,13 +228,10 @@ def test_get_fitsbolt_config_pickling(self): # Get fitsbolt config cfg = get_fitsbolt_config(cfg) - # Save and load via torch - with tempfile.NamedTemporaryFile(suffix=".pth", delete=False) as f: - checkpoint_path = f.name - - try: - torch.save({"fitsbolt_cfg": cfg.fitsbolt_cfg}, checkpoint_path) - loaded = torch.load(checkpoint_path, weights_only=False) + with tempfile.TemporaryDirectory() as tmp: + checkpoint_path = Path(tmp) / "model.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=cfg.fitsbolt_cfg), checkpoint_path) + loaded = load_checkpoint(checkpoint_path) loaded_cfg = loaded["fitsbolt_cfg"] # Validate @@ -256,8 +241,6 @@ def test_get_fitsbolt_config_pickling(self): assert loaded_cfg.size == [64, 64] assert loaded_cfg.n_output_channels == 3 assert loaded_cfg.normalisation_method == NormalisationMethod.CONVERSION_ONLY - finally: - Path(checkpoint_path).unlink(missing_ok=True) class TestFitsboltConfigE2EWithCheckpoint: @@ -272,7 +255,7 @@ def teardown_method(self): shutil.rmtree(self.temp_dir) def test_fitsbolt_config_in_checkpoint_dict(self): - """Test that fitsbolt config can be saved and loaded in a checkpoint-like dict.""" + """Test that fitsbolt config can be saved and loaded in a checkpoint dict.""" # Create a fitsbolt config fitsbolt_cfg = fb_create_cfg( output_dtype=np.uint8, @@ -283,19 +266,12 @@ def test_fitsbolt_config_in_checkpoint_dict(self): norm_asinh_clip=[99.0, 99.5, 99.8], ) - # Create a mock checkpoint - checkpoint = { - "model_state": {"dummy": "data"}, - "optimizer_state": None, - "fitsbolt_cfg": fitsbolt_cfg, - } - # Save checkpoint - checkpoint_path = Path(self.temp_dir) / "test_checkpoint.pth" - torch.save(checkpoint, checkpoint_path) + checkpoint_path = Path(self.temp_dir) / "test_checkpoint.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=fitsbolt_cfg), checkpoint_path) # Load checkpoint - loaded_checkpoint = torch.load(checkpoint_path, weights_only=False) + loaded_checkpoint = load_checkpoint(checkpoint_path) loaded_fitsbolt_cfg = loaded_checkpoint["fitsbolt_cfg"] # Verify @@ -310,22 +286,12 @@ def test_fitsbolt_config_in_checkpoint_dict(self): def test_backward_compatibility_checkpoint_without_fitsbolt(self): """Test loading checkpoints that don't have fitsbolt_cfg.""" - # Create a mock checkpoint without fitsbolt_cfg (legacy format) - checkpoint = { - "model_state": {"dummy": "data"}, - "optimizer_state": None, - } - - # Save checkpoint - checkpoint_path = Path(self.temp_dir) / "legacy_checkpoint.pth" - torch.save(checkpoint, checkpoint_path) + # Save checkpoint without fitsbolt_cfg + checkpoint_path = Path(self.temp_dir) / "legacy_checkpoint.safetensors" + save_checkpoint(_make_checkpoint(fitsbolt_cfg=None), checkpoint_path) # Load checkpoint - loaded_checkpoint = torch.load(checkpoint_path, weights_only=False) - - # Check that fitsbolt_cfg is not present - assert "fitsbolt_cfg" not in loaded_checkpoint + loaded_checkpoint = load_checkpoint(checkpoint_path) - # Accessing non-existent key should return None via .get() - result = loaded_checkpoint.get("fitsbolt_cfg") - assert result is None + # fitsbolt_cfg should be None (not missing) + assert loaded_checkpoint["fitsbolt_cfg"] is None diff --git a/tests/integration/test_model_io_integration.py b/tests/integration/test_model_io_integration.py index eb8e0e3..d376f37 100644 --- a/tests/integration/test_model_io_integration.py +++ b/tests/integration/test_model_io_integration.py @@ -15,6 +15,7 @@ from dotmap import DotMap from fitsbolt.normalisation.NormalisationMethod import NormalisationMethod +from anomaly_match.data_io.checkpoint_io import load_checkpoint from anomaly_match.data_io.SessionIOHandler import SessionIOHandler from anomaly_match.pipeline.SessionTracker import SessionTracker from anomaly_match.utils.get_net_builder import get_net_builder @@ -59,7 +60,7 @@ def setup_method(self): from anomaly_match.utils.get_default_cfg import get_default_cfg self.cfg = get_default_cfg() - self.cfg.model_path = str(self.temp_dir / "test_model.pth") + self.cfg.model_path = str(self.temp_dir / "test_model.safetensors") def teardown_method(self): """Clean up test fixtures.""" @@ -135,23 +136,33 @@ def test_load_model_with_normalisation_update(self): def test_load_model_nonexistent_file(self): """Test loading from nonexistent file.""" - self.cfg.model_path = str(self.temp_dir / "nonexistent.pth") + self.cfg.model_path = str(self.temp_dir / "nonexistent.safetensors") success = self.session_io.load_model(self.mock_model, self.cfg) assert not success def test_load_model_checkpoint(self): - """Test loading model checkpoint.""" - # Create and save a checkpoint + """Test loading model checkpoint via save_model_checkpoint.""" + # Create and save a checkpoint with standard checkpoint structure model_state = { - "train_model_state_dict": self.mock_model.train_model.state_dict(), - "eval_model_state_dict": self.mock_model.eval_model.state_dict(), + "train_model": self.mock_model.train_model.state_dict(), + "eval_model": self.mock_model.eval_model.state_dict(), + "optimizer": None, + "scheduler": None, + "it": 0, "total_it": self.mock_model.total_it, + "best_eval_acc": None, + "best_it": None, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": None, + "last_normalisation_method": None, + "fitsbolt_cfg": None, } checkpoint_path = self.session_io.save_model_checkpoint( - model_state, self.session_tracker, "test_checkpoint.pkl" + model_state, self.session_tracker, "test_checkpoint.safetensors" ) # Load checkpoint @@ -159,23 +170,25 @@ def test_load_model_checkpoint(self): # Verify checkpoint was loaded assert loaded_checkpoint is not None - assert "train_model_state_dict" in loaded_checkpoint + assert "train_model" in loaded_checkpoint assert "total_it" in loaded_checkpoint assert loaded_checkpoint["total_it"] == self.mock_model.total_it def test_load_model_checkpoint_nonexistent(self): """Test loading nonexistent checkpoint.""" - checkpoint = self.session_io.load_model_checkpoint(str(self.temp_dir / "nonexistent.pkl")) + checkpoint = self.session_io.load_model_checkpoint( + str(self.temp_dir / "nonexistent.safetensors") + ) assert checkpoint is None -TEST_MODEL_PATH = Path(__file__).parent.parent / "test_data" / "test_model.pth" +TEST_MODEL_PATH = Path(__file__).parent.parent / "test_data" / "test_model.safetensors" -@pytest.mark.skipif(not TEST_MODEL_PATH.exists(), reason="test_model.pth not available") +@pytest.mark.skipif(not TEST_MODEL_PATH.exists(), reason="test_model.safetensors not available") class TestStoredModelLoading: - """Regression tests for loading the stored test_model.pth checkpoint. + """Regression tests for loading the stored test_model.safetensors checkpoint. These tests verify that the checked-in test model remains compatible with the current model architecture (timm-based EfficientNet). @@ -183,7 +196,7 @@ class TestStoredModelLoading: def test_stored_model_has_expected_keys(self): """Verify the stored checkpoint contains expected top-level keys.""" - checkpoint = torch.load(str(TEST_MODEL_PATH), weights_only=False, map_location="cpu") + checkpoint = load_checkpoint(TEST_MODEL_PATH) assert "eval_model" in checkpoint, ( f"Checkpoint missing 'eval_model' key. Found: {list(checkpoint.keys())}" @@ -192,7 +205,7 @@ def test_stored_model_has_expected_keys(self): def test_stored_model_loads_into_efficientnet_lite0(self): """Verify stored model state_dict is compatible with the current architecture.""" - checkpoint = torch.load(str(TEST_MODEL_PATH), weights_only=False, map_location="cpu") + checkpoint = load_checkpoint(TEST_MODEL_PATH) net_builder = get_net_builder("efficientnet-lite0", pretrained=False, in_channels=3) model = net_builder(num_classes=2, in_channels=3) diff --git a/tests/integration/test_run_label_migration.py b/tests/integration/test_run_label_migration.py index a484cf7..de913f6 100644 --- a/tests/integration/test_run_label_migration.py +++ b/tests/integration/test_run_label_migration.py @@ -13,6 +13,7 @@ import pytest import torch +from anomaly_match.data_io.checkpoint_io import load_checkpoint from anomaly_match.data_io.SessionIOHandler import SessionIOHandler from anomaly_match.pipeline.SessionTracker import SessionTracker @@ -67,25 +68,25 @@ def mock_config(self): """Create a mock configuration.""" config = Mock() config.normalisation_method = "min_max" - config.model_path = "test_model.pth" + config.model_path = "test_model.safetensors" # Explicitly set fitsbolt_cfg to None to avoid pickling issues with Mock config.fitsbolt_cfg = None return config def test_save_run_basic(self, session_io, mock_model, temp_dir): """Test basic save_run functionality.""" - save_name = "test_model.pth" + save_name = "test_model.safetensors" save_path = temp_dir result_path = session_io.save_run(mock_model, save_name, save_path) - # Check that the model was saved + # Check that the model was saved (save_checkpoint forces .safetensors extension) expected_path = os.path.join(save_path, save_name) assert result_path == expected_path assert os.path.exists(expected_path) # Verify the saved model can be loaded - checkpoint = torch.load(expected_path, weights_only=False) + checkpoint = load_checkpoint(expected_path) assert "train_model" in checkpoint assert "eval_model" in checkpoint assert "optimizer" in checkpoint @@ -95,7 +96,7 @@ def test_save_run_basic(self, session_io, mock_model, temp_dir): def test_save_run_with_session_tracker(self, session_io, mock_model, session_tracker, temp_dir): """Test save_run with session tracker integration.""" - save_name = "test_model.pth" + save_name = "test_model.safetensors" save_path = temp_dir # Start a session iteration @@ -111,7 +112,7 @@ def test_save_run_with_session_tracker(self, session_io, mock_model, session_tra def test_save_run_with_config(self, session_io, mock_model, mock_config, temp_dir): """Test save_run with configuration saving.""" - save_name = "test_model.pth" + save_name = "test_model.safetensors" save_path = temp_dir # Mock the config saving function @@ -182,7 +183,7 @@ def test_integration_training_run_flow( self, session_io, session_tracker, mock_model, mock_config, temp_dir ): """Test the complete integration flow for training run saving.""" - save_name = "final_model.pth" + save_name = "final_model.safetensors" save_path = temp_dir # Simulate a training session @@ -199,7 +200,7 @@ def test_integration_training_run_flow( assert session_tracker.session_iterations[0].model_state_path == model_path # Verify model checkpoint structure - checkpoint = torch.load(model_path, weights_only=False) + checkpoint = load_checkpoint(model_path) assert all(key in checkpoint for key in ["train_model", "eval_model", "optimizer", "it"]) def test_integration_label_saving_flow(self, session_io, session_tracker, temp_dir): diff --git a/tests/test_data/test_model.pth b/tests/test_data/test_model.safetensors similarity index 50% rename from tests/test_data/test_model.pth rename to tests/test_data/test_model.safetensors index 1ffb3f46eb161b8184ea3c72a941212dd54b584e..53e7f4511f769d4e0e2530bcdd19a2c045de84b9 100644 GIT binary patch delta 57110 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 KYX5e;-v0-2`vw32 delta 135449 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 diff --git a/tests/unit/test_checkpoint_io.py b/tests/unit/test_checkpoint_io.py new file mode 100644 index 0000000..2a96bce --- /dev/null +++ b/tests/unit/test_checkpoint_io.py @@ -0,0 +1,245 @@ +# Copyright (c) European Space Agency, 2025. +# +# This file is subject to the terms and conditions defined in file 'LICENCE.txt', which +# is part of this source code package. No part of the package, including +# this file, may be copied, modified, propagated, or distributed except according to +# the terms contained in the file 'LICENCE.txt'. + +"""Unit tests for checkpoint_io: safetensors-based model checkpoint serialization.""" + +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import torch +from dotmap import DotMap +from fitsbolt.normalisation.NormalisationMethod import NormalisationMethod + +from anomaly_match.data_io.checkpoint_io import load_checkpoint, save_checkpoint + + +def _make_state_dict(seed=0): + """Create a small deterministic state_dict for testing.""" + torch.manual_seed(seed) + return { + "layer.weight": torch.randn(4, 3), + "layer.bias": torch.randn(4), + "bn.running_mean": torch.zeros(4), + "bn.running_var": torch.ones(4), + "bn.num_batches_tracked": torch.tensor(0, dtype=torch.long), + } + + +def _make_full_checkpoint(**overrides): + """Create a complete checkpoint dict with sensible defaults.""" + checkpoint = { + "train_model": _make_state_dict(seed=0), + "eval_model": _make_state_dict(seed=1), + "optimizer": None, + "scheduler": None, + "it": 42, + "total_it": 100, + "best_eval_acc": 0.95, + "best_it": 80, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": NormalisationMethod.CONVERSION_ONLY, + "last_normalisation_method": NormalisationMethod.LOG, + "fitsbolt_cfg": None, + } + checkpoint.update(overrides) + return checkpoint + + +class TestSaveLoadRoundTrip: + """Test that save_checkpoint → load_checkpoint round-trips all data correctly.""" + + def test_model_weights_roundtrip(self, tmp_path): + """Verify train_model and eval_model state_dicts survive round-trip.""" + original = _make_full_checkpoint() + path = save_checkpoint(original, tmp_path / "model") + + loaded = load_checkpoint(path) + + for key in ("train_model", "eval_model"): + for param_name in original[key]: + assert torch.equal(original[key][param_name], loaded[key][param_name]), ( + f"{key}.{param_name} mismatch after round-trip" + ) + + def test_scalar_metadata_roundtrip(self, tmp_path): + """Verify scalar metadata (it, total_it, etc.) survives round-trip.""" + original = _make_full_checkpoint() + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + assert loaded["it"] == 42 + assert loaded["total_it"] == 100 + assert loaded["best_eval_acc"] == 0.95 + assert loaded["best_it"] == 80 + assert loaded["num_channels"] == 3 + assert loaded["net"] == "efficientnet-lite0" + + def test_normalisation_enum_roundtrip(self, tmp_path): + """Verify NormalisationMethod enum values survive round-trip.""" + original = _make_full_checkpoint() + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + assert loaded["normalisation_method"] == NormalisationMethod.CONVERSION_ONLY + assert loaded["last_normalisation_method"] == NormalisationMethod.LOG + assert isinstance(loaded["normalisation_method"], NormalisationMethod) + + def test_optimizer_state_roundtrip(self, tmp_path): + """Verify optimizer state (including momentum tensors) survives round-trip.""" + # Build a real optimizer state + model = torch.nn.Linear(3, 2) + optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9) + # Step once to create momentum buffers + loss = model(torch.randn(1, 3)).sum() + loss.backward() + optimizer.step() + + opt_state = optimizer.state_dict() + original = _make_full_checkpoint(optimizer=opt_state) + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + # Check param_groups + assert loaded["optimizer"]["param_groups"][0]["lr"] == 0.01 + assert loaded["optimizer"]["param_groups"][0]["momentum"] == 0.9 + + # Check state tensors + for param_idx in opt_state["state"]: + for key in opt_state["state"][param_idx]: + orig_val = opt_state["state"][param_idx][key] + loaded_val = loaded["optimizer"]["state"][param_idx][key] + if isinstance(orig_val, torch.Tensor): + assert torch.equal(orig_val, loaded_val) + + def test_scheduler_state_roundtrip(self, tmp_path): + """Verify scheduler state survives round-trip.""" + sched_state = { + "T_max": 200, + "eta_min": 0, + "last_epoch": 50, + "_step_count": 51, + "base_lrs": [0.01], + "_last_lr": [0.005], + } + original = _make_full_checkpoint(scheduler=sched_state) + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + assert loaded["scheduler"]["T_max"] == 200 + assert loaded["scheduler"]["last_epoch"] == 50 + + def test_fitsbolt_cfg_roundtrip(self, tmp_path): + """Verify fitsbolt DotMap config survives round-trip.""" + fb_cfg = DotMap( + { + "output_dtype": np.uint8, + "size": [64, 64], + "normalisation_method": NormalisationMethod.CONVERSION_ONLY, + "n_output_channels": 3, + "channel_combination": np.array([[1, 0], [0, 1], [0.5, 0.5]]), + } + ) + original = _make_full_checkpoint(fitsbolt_cfg=fb_cfg) + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + loaded_fb = loaded["fitsbolt_cfg"] + assert isinstance(loaded_fb, DotMap) + assert loaded_fb.normalisation_method == NormalisationMethod.CONVERSION_ONLY + assert loaded_fb.output_dtype == np.uint8 + assert np.array_equal(loaded_fb.channel_combination, fb_cfg.channel_combination) + + def test_labeled_data_csv_roundtrip(self, tmp_path): + """Verify labeled_data_csv string survives round-trip.""" + csv = "filename,label\nimg1.jpg,anomaly\nimg2.jpg,normal\n" + original = _make_full_checkpoint(labeled_data_csv=csv) + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + assert loaded["labeled_data_csv"] == csv + + def test_none_values_roundtrip(self, tmp_path): + """Verify None values survive round-trip correctly.""" + original = _make_full_checkpoint( + optimizer=None, + scheduler=None, + fitsbolt_cfg=None, + best_eval_acc=None, + normalisation_method=None, + ) + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + + assert loaded["optimizer"] is None + assert loaded["scheduler"] is None + assert loaded["fitsbolt_cfg"] is None + assert loaded["best_eval_acc"] is None + assert loaded["normalisation_method"] is None + + +class TestFileFormat: + """Test file format details.""" + + def test_extension_forced_to_safetensors(self, tmp_path): + """save_checkpoint forces .safetensors extension.""" + path = save_checkpoint(_make_full_checkpoint(), tmp_path / "model.pth") + assert path.suffix == ".safetensors" + assert path.exists() + + def test_safetensors_extension_preserved(self, tmp_path): + """If .safetensors extension is already correct, it's preserved.""" + path = save_checkpoint(_make_full_checkpoint(), tmp_path / "model.safetensors") + assert path.suffix == ".safetensors" + + def test_load_nonexistent_raises(self, tmp_path): + """Loading a nonexistent file raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + load_checkpoint(tmp_path / "nonexistent.safetensors") + + def test_shared_memory_tensors(self, tmp_path): + """Tensors that share memory (e.g. EMA copy) are saved without error.""" + shared = _make_state_dict(seed=0) + original = _make_full_checkpoint( + train_model=shared, + eval_model=shared, # same object, shares memory + ) + # Should not raise RuntimeError about shared tensors + path = save_checkpoint(original, tmp_path / "model") + loaded = load_checkpoint(path) + assert "train_model" in loaded + assert "eval_model" in loaded + + +class TestSecurity: + """Verify the format is safe against code execution attacks.""" + + def test_no_pickle_in_file(self, tmp_path): + """The saved file must not contain pickle opcodes.""" + path = save_checkpoint(_make_full_checkpoint(), tmp_path / "model") + data = path.read_bytes() + # Pickle protocol markers (0x80 = protocol 2+, 'cos\n' = protocol 0) + # safetensors files start with a little-endian u64 header size + assert not data[8:].startswith(b"\x80\x02") # not pickle protocol 2 + assert not data[8:].startswith(b"cos\n") # not pickle protocol 0 + + def test_metadata_is_plain_json(self, tmp_path): + """All metadata in the safetensors header is valid JSON strings.""" + import json + + from safetensors import safe_open + + path = save_checkpoint(_make_full_checkpoint(), tmp_path / "model") + with safe_open(str(path), framework="pt") as f: + metadata = f.metadata() + + for key, value in metadata.items(): + # Every metadata value must be a valid JSON string + parsed = json.loads(value) + assert parsed is not None or value == "null" diff --git a/tests/unit/test_session_io_handler.py b/tests/unit/test_session_io_handler.py index f0f4e1e..fc322e1 100644 --- a/tests/unit/test_session_io_handler.py +++ b/tests/unit/test_session_io_handler.py @@ -6,7 +6,6 @@ # the terms contained in the file 'LICENCE.txt'. import json -import pickle import shutil import tempfile from pathlib import Path @@ -15,6 +14,7 @@ import pandas as pd import pytest +from anomaly_match.data_io.checkpoint_io import load_checkpoint from anomaly_match.data_io.SessionIOHandler import SessionIOHandler, print_session from anomaly_match.pipeline.SessionTracker import SessionTracker @@ -103,19 +103,35 @@ def test_save_session_custom_path(self): def test_save_model_checkpoint(self): """Test saving model checkpoint.""" - model_state = {"weights": [1, 2, 3], "epoch": 10} + import torch + + model_state = { + "train_model": {"layer.weight": torch.randn(2, 2)}, + "eval_model": {"layer.weight": torch.randn(2, 2)}, + "optimizer": None, + "scheduler": None, + "it": 10, + "total_it": 10, + "best_eval_acc": None, + "best_it": None, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": None, + "last_normalisation_method": None, + "fitsbolt_cfg": None, + } checkpoint_path = self.io_handler.save_model_checkpoint(model_state, self.session_tracker) # Check checkpoint was saved assert Path(checkpoint_path).exists() assert "checkpoints" in checkpoint_path - assert checkpoint_path.endswith(".pkl") + assert checkpoint_path.endswith(".safetensors") # Verify checkpoint content - with open(checkpoint_path, "rb") as f: - loaded_state = pickle.load(f) - assert loaded_state == model_state + loaded_state = load_checkpoint(checkpoint_path) + assert loaded_state["it"] == 10 + assert "train_model" in loaded_state # Verify that session tracker was updated - check the last iteration assert len(self.session_tracker.session_iterations) > 0 @@ -124,8 +140,24 @@ def test_save_model_checkpoint(self): def test_save_model_checkpoint_custom_name(self): """Test saving model checkpoint with custom name.""" - model_state = {"test": "data"} - custom_name = "custom_checkpoint.pkl" + import torch + + model_state = { + "train_model": {"layer.weight": torch.randn(2, 2)}, + "eval_model": {"layer.weight": torch.randn(2, 2)}, + "optimizer": None, + "scheduler": None, + "it": 0, + "total_it": 0, + "best_eval_acc": None, + "best_it": None, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": None, + "last_normalisation_method": None, + "fitsbolt_cfg": None, + } + custom_name = "custom_checkpoint.safetensors" checkpoint_path = self.io_handler.save_model_checkpoint( model_state, self.session_tracker, checkpoint_name=custom_name @@ -221,7 +253,7 @@ def setup_method(self): session_tracker.add_labeled_sample("img1.jpg", "anomaly") session_tracker.add_labeled_sample("img2.jpg", "normal") session_tracker.update_test_performance({"AUROC": 0.92, "AUPRC": 0.88}) - session_tracker.update_model_state_path("models/final_model.pth") + session_tracker.update_model_state_path("models/final_model.safetensors") # Start second iteration session_tracker.start_new_session_iteration() @@ -347,13 +379,29 @@ def test_full_workflow_integration(self): tracker.update_model_iteration(0.5) tracker.add_labeled_sample("img4.jpg", "anomaly") tracker.update_test_performance({"AUROC": 0.93, "AUPRC": 0.89}) - tracker.update_model_state_path("models/best_model.pth") + tracker.update_model_state_path("models/best_model.safetensors") # Save session saved_path = self.io_handler.save_session(tracker) # Save model checkpoint - model_state = {"epoch": 50, "weights": [1, 2, 3, 4]} + import torch + + model_state = { + "train_model": {"layer.weight": torch.randn(2, 2)}, + "eval_model": {"layer.weight": torch.randn(2, 2)}, + "optimizer": None, + "scheduler": None, + "it": 50, + "total_it": 50, + "best_eval_acc": None, + "best_it": None, + "num_channels": 3, + "net": "efficientnet-lite0", + "normalisation_method": None, + "last_normalisation_method": None, + "fitsbolt_cfg": None, + } checkpoint_path = self.io_handler.save_model_checkpoint(model_state, tracker) # Load session back @@ -365,11 +413,10 @@ def test_full_workflow_integration(self): assert len(loaded_tracker.get_labeled_data_df()) == 4 assert len(loaded_tracker.session_iterations) == 2 - # Check model checkpoint exists + # Check model checkpoint exists and can be loaded assert Path(checkpoint_path).exists() - with open(checkpoint_path, "rb") as f: - loaded_model = pickle.load(f) - assert loaded_model == model_state + loaded_model = load_checkpoint(checkpoint_path) + assert loaded_model["it"] == 50 def test_multiple_sessions_management(self): """Test managing multiple sessions.""" From 614ba34d298b6455e7a21b134d78a32cf14fc1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20G=C3=B3mez?= Date: Wed, 25 Mar 2026 10:37:20 +0100 Subject: [PATCH 2/3] fix(test): remove unused imports in test_checkpoint_io --- tests/unit/test_checkpoint_io.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/test_checkpoint_io.py b/tests/unit/test_checkpoint_io.py index 2a96bce..d87bfc8 100644 --- a/tests/unit/test_checkpoint_io.py +++ b/tests/unit/test_checkpoint_io.py @@ -7,9 +7,6 @@ """Unit tests for checkpoint_io: safetensors-based model checkpoint serialization.""" -import tempfile -from pathlib import Path - import numpy as np import pytest import torch From adcf233d1bd663e70fc8ecb241c6cd2b64ee7492 Mon Sep 17 00:00:00 2001 From: Pablo Gomez Date: Thu, 26 Mar 2026 09:21:56 +0100 Subject: [PATCH 3/3] refactor: remove dead save/load_model_checkpoint methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove save_model_checkpoint and load_model_checkpoint from SessionIOHandler — neither is called from production code (session.py only uses save_model/load_model). Remove their tests and vulture whitelist entries. --- .vulture_whitelist.py | 2 - anomaly_match/data_io/SessionIOHandler.py | 61 ------------- .../integration/test_model_io_integration.py | 40 -------- tests/unit/test_session_io_handler.py | 91 ------------------- 4 files changed, 194 deletions(-) diff --git a/.vulture_whitelist.py b/.vulture_whitelist.py index af316bf..4940fba 100644 --- a/.vulture_whitelist.py +++ b/.vulture_whitelist.py @@ -12,8 +12,6 @@ """ # SessionIOHandler methods - public API used in tests -save_model_checkpoint # noqa - Used in test_session_io_handler.py, test_model_io_integration.py -load_model_checkpoint # noqa - Used in test_model_io_integration.py list_sessions # noqa - Used in test_session_io_handler.py save_run # noqa - Used in test_run_label_migration.py save_labels_to_output_dir # noqa - Used in test_run_label_migration.py diff --git a/anomaly_match/data_io/SessionIOHandler.py b/anomaly_match/data_io/SessionIOHandler.py index 8cd2a05..724cf97 100644 --- a/anomaly_match/data_io/SessionIOHandler.py +++ b/anomaly_match/data_io/SessionIOHandler.py @@ -184,43 +184,6 @@ def save_iteration_scores( except Exception as e: logger.warning(f"Failed to save test scores: {e}") - def save_model_checkpoint( - self, - model_state: Dict[str, Any], - session_tracker: SessionTracker, - checkpoint_name: str = None, - ) -> str: - """ - Save a model checkpoint within the session directory. - - Args: - model_state: Model state dictionary to save. - session_tracker: Associated session tracker. - checkpoint_name: Optional custom checkpoint name. - - Returns: - Path to saved checkpoint. - """ - save_path = self.get_session_save_path(session_tracker) - save_path.mkdir(parents=True, exist_ok=True) - - checkpoints_dir = save_path / "checkpoints" - checkpoints_dir.mkdir(exist_ok=True) - - if checkpoint_name is None: - checkpoint_name = f"model_iter_{session_tracker.total_model_iterations}.safetensors" - - checkpoint_path = checkpoints_dir / checkpoint_name - save_checkpoint(model_state, checkpoint_path) - # save_checkpoint forces .safetensors extension - checkpoint_path = checkpoint_path.with_suffix(".safetensors") - - # Update the session tracker with the checkpoint path - session_tracker.update_model_state_path(str(checkpoint_path)) - - logger.debug(f"Saved model checkpoint to: {checkpoint_path}") - return str(checkpoint_path) - def save_model(self, model, cfg, session_tracker: SessionTracker = None) -> str: """ Save the model to the session directory if session_tracker is available, @@ -426,30 +389,6 @@ def load_model(self, model, cfg, model_path: str = None) -> bool: logger.error(f"Failed to load model from {load_path}: {e}") return False - def load_model_checkpoint(self, checkpoint_path: str) -> Optional[Dict[str, Any]]: - """ - Load a model checkpoint from the specified path. - - Args: - checkpoint_path: Path to the checkpoint file - - Returns: - Dictionary containing the checkpoint data, or None if loading failed - """ - try: - if not os.path.exists(checkpoint_path): - logger.error(f"Checkpoint path does not exist: {checkpoint_path}") - return None - - checkpoint = load_checkpoint(checkpoint_path) - logger.debug(f"Loaded checkpoint: {checkpoint_path}") - - return checkpoint - - except Exception as e: - logger.error(f"Failed to load checkpoint from {checkpoint_path}: {e}") - return None - def load_session(self, session_path: Path) -> SessionTracker: """ Load a session from disk. diff --git a/tests/integration/test_model_io_integration.py b/tests/integration/test_model_io_integration.py index d376f37..9fbb7a2 100644 --- a/tests/integration/test_model_io_integration.py +++ b/tests/integration/test_model_io_integration.py @@ -142,46 +142,6 @@ def test_load_model_nonexistent_file(self): assert not success - def test_load_model_checkpoint(self): - """Test loading model checkpoint via save_model_checkpoint.""" - # Create and save a checkpoint with standard checkpoint structure - model_state = { - "train_model": self.mock_model.train_model.state_dict(), - "eval_model": self.mock_model.eval_model.state_dict(), - "optimizer": None, - "scheduler": None, - "it": 0, - "total_it": self.mock_model.total_it, - "best_eval_acc": None, - "best_it": None, - "num_channels": 3, - "net": "efficientnet-lite0", - "normalisation_method": None, - "last_normalisation_method": None, - "fitsbolt_cfg": None, - } - - checkpoint_path = self.session_io.save_model_checkpoint( - model_state, self.session_tracker, "test_checkpoint.safetensors" - ) - - # Load checkpoint - loaded_checkpoint = self.session_io.load_model_checkpoint(checkpoint_path) - - # Verify checkpoint was loaded - assert loaded_checkpoint is not None - assert "train_model" in loaded_checkpoint - assert "total_it" in loaded_checkpoint - assert loaded_checkpoint["total_it"] == self.mock_model.total_it - - def test_load_model_checkpoint_nonexistent(self): - """Test loading nonexistent checkpoint.""" - checkpoint = self.session_io.load_model_checkpoint( - str(self.temp_dir / "nonexistent.safetensors") - ) - - assert checkpoint is None - TEST_MODEL_PATH = Path(__file__).parent.parent / "test_data" / "test_model.safetensors" diff --git a/tests/unit/test_session_io_handler.py b/tests/unit/test_session_io_handler.py index fc322e1..b9433fd 100644 --- a/tests/unit/test_session_io_handler.py +++ b/tests/unit/test_session_io_handler.py @@ -14,7 +14,6 @@ import pandas as pd import pytest -from anomaly_match.data_io.checkpoint_io import load_checkpoint from anomaly_match.data_io.SessionIOHandler import SessionIOHandler, print_session from anomaly_match.pipeline.SessionTracker import SessionTracker @@ -101,71 +100,6 @@ def test_save_session_custom_path(self): assert save_path.exists() assert (save_path / "session_metadata.json").exists() - def test_save_model_checkpoint(self): - """Test saving model checkpoint.""" - import torch - - model_state = { - "train_model": {"layer.weight": torch.randn(2, 2)}, - "eval_model": {"layer.weight": torch.randn(2, 2)}, - "optimizer": None, - "scheduler": None, - "it": 10, - "total_it": 10, - "best_eval_acc": None, - "best_it": None, - "num_channels": 3, - "net": "efficientnet-lite0", - "normalisation_method": None, - "last_normalisation_method": None, - "fitsbolt_cfg": None, - } - - checkpoint_path = self.io_handler.save_model_checkpoint(model_state, self.session_tracker) - - # Check checkpoint was saved - assert Path(checkpoint_path).exists() - assert "checkpoints" in checkpoint_path - assert checkpoint_path.endswith(".safetensors") - - # Verify checkpoint content - loaded_state = load_checkpoint(checkpoint_path) - assert loaded_state["it"] == 10 - assert "train_model" in loaded_state - - # Verify that session tracker was updated - check the last iteration - assert len(self.session_tracker.session_iterations) > 0 - last_iter = self.session_tracker.session_iterations[-1] - assert last_iter.model_state_path == checkpoint_path - - def test_save_model_checkpoint_custom_name(self): - """Test saving model checkpoint with custom name.""" - import torch - - model_state = { - "train_model": {"layer.weight": torch.randn(2, 2)}, - "eval_model": {"layer.weight": torch.randn(2, 2)}, - "optimizer": None, - "scheduler": None, - "it": 0, - "total_it": 0, - "best_eval_acc": None, - "best_it": None, - "num_channels": 3, - "net": "efficientnet-lite0", - "normalisation_method": None, - "last_normalisation_method": None, - "fitsbolt_cfg": None, - } - custom_name = "custom_checkpoint.safetensors" - - checkpoint_path = self.io_handler.save_model_checkpoint( - model_state, self.session_tracker, checkpoint_name=custom_name - ) - - assert checkpoint_path.endswith(custom_name) - assert Path(checkpoint_path).exists() - def test_load_session_complete_cycle(self): """Test complete save/load cycle.""" # First save a session @@ -384,26 +318,6 @@ def test_full_workflow_integration(self): # Save session saved_path = self.io_handler.save_session(tracker) - # Save model checkpoint - import torch - - model_state = { - "train_model": {"layer.weight": torch.randn(2, 2)}, - "eval_model": {"layer.weight": torch.randn(2, 2)}, - "optimizer": None, - "scheduler": None, - "it": 50, - "total_it": 50, - "best_eval_acc": None, - "best_it": None, - "num_channels": 3, - "net": "efficientnet-lite0", - "normalisation_method": None, - "last_normalisation_method": None, - "fitsbolt_cfg": None, - } - checkpoint_path = self.io_handler.save_model_checkpoint(model_state, tracker) - # Load session back loaded_tracker = self.io_handler.load_session(saved_path) @@ -413,11 +327,6 @@ def test_full_workflow_integration(self): assert len(loaded_tracker.get_labeled_data_df()) == 4 assert len(loaded_tracker.session_iterations) == 2 - # Check model checkpoint exists and can be loaded - assert Path(checkpoint_path).exists() - loaded_model = load_checkpoint(checkpoint_path) - assert loaded_model["it"] == 50 - def test_multiple_sessions_management(self): """Test managing multiple sessions.""" # Create multiple sessions