diff --git a/.pylintdict b/.pylintdict index 7e77dafdf..264598f1a 100644 --- a/.pylintdict +++ b/.pylintdict @@ -105,6 +105,7 @@ datasets deepcopy denoising deriv +deserialize deterministically diag dicts @@ -398,6 +399,8 @@ pearson pedro pegasos peruzzo +picklable +pkl pixelated platt polyfit @@ -585,6 +588,7 @@ uncompute unitaries univariate uno +unpickling unscaled unsymmetric utf diff --git a/qiskit_machine_learning/algorithms/classifiers/qsvc.py b/qiskit_machine_learning/algorithms/classifiers/qsvc.py index 292316965..ecb5e2428 100644 --- a/qiskit_machine_learning/algorithms/classifiers/qsvc.py +++ b/qiskit_machine_learning/algorithms/classifiers/qsvc.py @@ -1,6 +1,6 @@ # This code is part of a Qiskit project. # -# (C) Copyright IBM 2021, 2024. +# (C) Copyright IBM 2021, 2025. # # This code is licensed under the Apache License, Version 2.0. You may # obtain a copy of this license in the LICENSE.txt file in the root directory @@ -13,7 +13,9 @@ """Quantum Support Vector Classifier""" import warnings -from typing import Optional +from typing import Optional, Type +import pickle +from pathlib import Path from sklearn.svm import SVC @@ -51,6 +53,9 @@ def __init__(self, *, quantum_kernel: Optional[BaseKernel] = None, **kwargs): default to :class:`~qiskit_machine_learning.kernels.FidelityQuantumKernel`. *args: Variable length argument list to pass to SVC constructor. **kwargs: Arbitrary keyword arguments to pass to SVC constructor. + + Raises: + QiskitMachineLearningWarning: If the deprecated 'kernel' kwarg is used. """ if "kernel" in kwargs: msg = ( @@ -60,9 +65,11 @@ def __init__(self, *, quantum_kernel: Optional[BaseKernel] = None, **kwargs): warnings.warn(msg, QiskitMachineLearningWarning, stacklevel=2) # if we don't delete, then this value clashes with our quantum kernel del kwargs["kernel"] + if quantum_kernel is None: msg = "No quantum kernel is provided, SamplerV1 based quantum kernel will be used." warnings.warn(msg, QiskitMachineLearningWarning, stacklevel=2) + self._quantum_kernel = quantum_kernel if quantum_kernel else FidelityQuantumKernel() if "random_state" not in kwargs: @@ -83,7 +90,95 @@ def quantum_kernel(self, quantum_kernel: BaseKernel): # we override this method to be able to pretty print this instance @classmethod - def _get_param_names(cls): + def _get_param_names(cls) -> list[str]: + """ + Include 'quantum_kernel' in the list of SVC parameters for cloning. + + Returns: + list[str]: Sorted list of parameter names. + """ names = SVC._get_param_names() - names.remove("kernel") + # Remove the base 'kernel' parameter; we override it + if "kernel" in names: + names.remove("kernel") return sorted(names + ["quantum_kernel"]) + + def save(self, folder: str, filename: str = "qsvc.pkl") -> None: + """ + Serialize the entire QSVC object to disk, including fitted parameters and + the quantum kernel state. + + Args: + folder (str): Directory path where the model file will be saved. + filename (str): Name of the pickle file (default: 'qsvc.pkl'). + + Raises: + OSError: If the directory cannot be created. + IOError: If the file cannot be written. + """ + folder_path = Path(folder) + try: + folder_path.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise IOError(f"Failed to create directory '{folder}': {error}") from error + + file_path = folder_path / filename + try: + with file_path.open("wb") as file: + pickle.dump(self, file, protocol=pickle.HIGHEST_PROTOCOL) + except OSError as error: + raise IOError(f"Failed to save QSVC to '{file_path}': {error}") from error + + @classmethod + def load( + cls: Type["QSVC"], + folder: str, + filename: str = "qsvc.pkl", + ) -> "QSVC": + """ + Deserialize a QSVC object previously saved with `save()`. + + Args: + folder (str): Directory path where the model file is located. + filename (str): Name of the pickle file (default: 'qsvc.pkl'). + + Returns: + QSVC: Restored QSVC instance with its quantum kernel. + + Raises: + FileNotFoundError: If the specified file does not exist. + TypeError: If the loaded object is not a QSVC instance. + OSError: For other I/O or unpickling errors. + """ + file_path = Path(folder) / filename + if not file_path.is_file(): + raise FileNotFoundError(f"Model file not found at '{file_path}'.") + + try: + with file_path.open("rb") as file: + obj = pickle.load(file) + except OSError as error: + raise IOError(f"Failed to load QSVC from '{file_path}': {error}") from error + + if not isinstance(obj, cls): + raise TypeError(f"Loaded object is not a QSVC (got {type(obj).__name__}).") + + return obj + + def __getstate__(self): # pragma: no cover + """ + Define picklable state for the QSVC instance. + + Returns: + dict: A shallow copy of the instance __dict__. + """ + return self.__dict__.copy() + + def __setstate__(self, state): # pragma: no cover + """ + Restore QSVC state from unpickling. + + Args: + state (dict): State dictionary previously returned by __getstate__. + """ + self.__dict__.update(state) diff --git a/test/algorithms/classifiers/test_qsvc.py b/test/algorithms/classifiers/test_qsvc.py index 5d7489873..83f91e706 100644 --- a/test/algorithms/classifiers/test_qsvc.py +++ b/test/algorithms/classifiers/test_qsvc.py @@ -11,9 +11,9 @@ # that they have been altered from the originals. """ Test QSVC """ -import os import tempfile import unittest +from pathlib import Path from test import QiskitMachineLearningTestCase @@ -92,39 +92,101 @@ def test_with_kernel_parameter(self): with self.assertWarns(QiskitMachineLearningWarning): QSVC(kernel=1) - def test_save_load(self): - """Tests save and load models.""" - features = np.array([[0, 0], [0.1, 0.2], [1, 1], [0.9, 0.8]]) - labels = np.array([0, 0, 1, 1]) + def test_save_load_default_filename(self): + """Saving and loading with default 'qsvc.pkl' should preserve predictions.""" + qkernel = FidelityQuantumKernel(feature_map=self.feature_map) + + # temporary working directory + tmpdir = Path(tempfile.mkdtemp()) + + qsvc = QSVC(quantum_kernel=qkernel) + qsvc.fit(self.sample_train, self.label_train) + orig_preds = qsvc.predict(self.sample_test) + + # save into subdirectory (should be auto-created) + out_dir = tmpdir / "subfolder" + qsvc.save(str(out_dir)) + + # verify file exists + pkl = out_dir / "qsvc.pkl" + self.assertTrue(pkl.is_file(), f"{pkl} was not created") + + # load and compare + loaded = QSVC.load(str(out_dir)) + load_preds = loaded.predict(self.sample_test) + np.testing.assert_array_equal(orig_preds, load_preds) + + def test_save_load_custom_filename(self): + """Saving and loading with a custom filename works identically.""" + qkernel = FidelityQuantumKernel(feature_map=self.feature_map) - quantum_kernel = FidelityQuantumKernel(feature_map=zz_feature_map(2)) - classifier = QSVC(quantum_kernel=quantum_kernel) - classifier.fit(features, labels) + # temporary working directory + tmpdir = Path(tempfile.mkdtemp()) - # predicted labels from the newly trained model - test_features = np.array([[0.2, 0.1], [0.8, 0.9]]) - original_predicts = classifier.predict(test_features) + qsvc = QSVC(quantum_kernel=qkernel) + qsvc.fit(self.sample_train, self.label_train) + orig_preds = qsvc.predict(self.sample_test) + + # use a custom filename + fname = "my_model.bin" + out_dir = tmpdir / "custom" + qsvc.save(str(out_dir), filename=fname) + + full_path = out_dir / fname + self.assertTrue(full_path.is_file()) + + loaded = QSVC.load(str(out_dir), filename=fname) + np.testing.assert_array_equal(orig_preds, loaded.predict(self.sample_test)) + + def test_load_missing_file_raises(self): + """Attempting to load from a missing file should raise FileNotFoundError.""" + tmpdir = Path(tempfile.mkdtemp()) + missing_dir = tmpdir / "nope" + with self.assertRaises(FileNotFoundError): + QSVC.load(str(missing_dir)) + + # also if filename changed + valid_dir = tmpdir / "empty" + valid_dir.mkdir() + with self.assertRaises(FileNotFoundError): + QSVC.load(str(valid_dir), filename="does_not_exist.pkl") + + def test_load_wrong_type_raises(self): + """Loading via SerializableModelMixin.load on a non-QSVC class should TypeError.""" + # first save a valid QSVC + qkernel = FidelityQuantumKernel(feature_map=self.feature_map) + + # temporary working directory + tmpdir = Path(tempfile.mkdtemp()) + + qsvc = QSVC(quantum_kernel=qkernel) + qsvc.fit(self.sample_train, self.label_train) + qsvc.save(str(tmpdir)) - # save/load, change the quantum instance and check if predicted values are the same - file_name = os.path.join(tempfile.gettempdir(), "qsvc.model") - classifier.save(file_name) - try: - classifier_load = QSVC.load(file_name) - loaded_model_predicts = classifier_load.predict(test_features) + # define a dummy class using the mixin + class FakeModel(SerializableModelMixin): + """Class that pretends to be QSVC""" - np.testing.assert_array_almost_equal(original_predicts, loaded_model_predicts) + pass - # test loading warning - class FakeModel(SerializableModelMixin): - """Fake model class for test purposes.""" + # FakeModel.load should raise TypeError when unpickling a QSVC + with self.assertRaises(TypeError): + FakeModel.load(str(tmpdir / "qsvc.pkl")) - pass + def test_io_error_on_save_bad_folder(self): + """If the folder cannot be created (e.g. file in its place), save should IOError.""" + # temporary working directory + tmpdir = Path(tempfile.mkdtemp()) + # create a file where a folder should be + bad = tmpdir / "not_a_folder" + bad.write_text("I'm a file, not a directory") - with self.assertRaises(TypeError): - FakeModel.load(file_name) + qkernel = FidelityQuantumKernel(feature_map=self.feature_map) + qsvc = QSVC(quantum_kernel=qkernel) + qsvc.fit(self.sample_train, self.label_train) - finally: - os.remove(file_name) + with self.assertRaises(IOError): + qsvc.save(str(bad / "sub"), filename="model.pkl") if __name__ == "__main__":