Skip to content

Specify IO for QSVC #950

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .pylintdict
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ datasets
deepcopy
denoising
deriv
deserialize
deterministically
diag
dicts
Expand Down Expand Up @@ -398,6 +399,8 @@ pearson
pedro
pegasos
peruzzo
picklable
pkl
pixelated
platt
polyfit
Expand Down Expand Up @@ -585,6 +588,7 @@ uncompute
unitaries
univariate
uno
unpickling
unscaled
unsymmetric
utf
Expand Down
103 changes: 99 additions & 4 deletions qiskit_machine_learning/algorithms/classifiers/qsvc.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 = (
Expand All @@ -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:
Expand All @@ -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)
114 changes: 88 additions & 26 deletions test/algorithms/classifiers/test_qsvc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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__":
Expand Down
Loading