"""Classification estimators using quantum models."""
import warnings
import numpy as np
from sklearn.base import ClassifierMixin
from sklearn.svm import SVC
from sklearn.utils.multiclass import unique_labels
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y
from psipose.ansatze import StronglyEntanglingAnsatz
from psipose.estimators.base import QuantumEstimator
from psipose.feature_maps import AngleFeatureMap
from psipose.feature_maps import FeatureMap as FMProtocol
from psipose.measurements import PauliZExpectation
from psipose.models import QuantumKernel, VariationalModel
[docs]
class VQCClassifier(QuantumEstimator, ClassifierMixin):
"""Variational Quantum Classifier.
A variational quantum classifier (VQC) is a quantum machine learning
model that combines data encoding (feature map), a parameterized quantum
circuit (ansatz), and measurement to perform binary or multi-class
classification. The model is trained using gradient-based optimization.
This implementation follows the circuit-centric quantum classifier
approach of Schuld et al. (2018, arXiv:1804.00633) and the broader
variational quantum algorithm (VQA) paradigm. For multi-class problems,
it uses a one-vs-rest (OvR) strategy.
The training process:
1. Encode classical data into quantum states using the feature map
2. Apply parameterized ansatz circuit
3. Measure expectation value (typically Pauli-Z on first qubit)
4. Minimize binary cross-entropy loss via gradient descent
The gradients are computed using the parameter shift rule, which
provides exact gradients on quantum hardware (Mitarai et al., 2018,
arXiv:1803.00745).
Parameters
----------
n_qubits : int, default=4
Number of qubits.
feature_map : FeatureMap, optional
Data encoding. Default: AngleFeatureMap()
ansatz : Ansatz, optional
Parameterized circuit. Default: StronglyEntanglingAnsatz(layers=2)
measurement : Measurement, optional
Measurement operator. Default: PauliZExpectation()
optimizer : str or callable, default="adam"
Optimizer (adam or sgd from PennyLane).
learning_rate : float, default=0.01
Learning rate for the optimizer.
n_iter : int, default=100
Number of optimization iterations.
batch_size : int, optional
Batch size for training. If None, uses full batch.
random_state : int, optional
Random seed for weight initialization.
device : str, default="default.qubit"
PennyLane device for simulation.
Attributes
----------
classes_ : ndarray
Class labels.
n_features_in_ : int
Number of features seen during fit.
n_classes_ : int
Number of classes.
model_ : VariationalModel, optional
The trained quantum model (binary case) or None (multiclass OvR).
estimators_ : list[VQCClassifier]
One-vs-rest classifiers for multi-class.
loss_history_ : list[float]
Training loss per iteration.
qnode_ : callable, optional
Cached QNode for inference (binary case only).
weights_ : ndarray, optional
Trained weights (binary case).
References
----------
.. [1] M. Schuld, A. Bocharov, K. Svore, and N. Wiebe,
"Circuit-centric quantum classifiers," arXiv:1804.00633, 2018.
.. [2] K. Mitarai et al., "Quantum circuit learning," Phys. Rev. A 98,
032309, 2018. arXiv:1803.00745.
"""
def __init__(
self,
n_qubits: int = 4,
feature_map: FMProtocol | None = None,
ansatz=None,
measurement=None,
optimizer="adam",
learning_rate=0.01,
n_iter=100,
batch_size=None,
random_state=None,
device="default.qubit",
):
self.n_qubits = n_qubits
self.feature_map = feature_map
self.ansatz = ansatz
self.measurement = measurement
self.optimizer = optimizer
self.learning_rate = learning_rate
self.n_iter = n_iter
self.batch_size = batch_size
self.random_state = random_state
self.device = device
def _make_binary_classifier(self, random_state_offset=0):
"""Create binary classifier with independent random state."""
rs = self.random_state
if rs is not None:
rs = rs + random_state_offset
return VQCClassifier(
n_qubits=self.n_qubits,
feature_map=self.feature_map,
ansatz=self.ansatz,
measurement=self.measurement,
optimizer=self.optimizer,
learning_rate=self.learning_rate,
n_iter=self.n_iter,
batch_size=self.batch_size,
random_state=rs,
device=self.device,
)
[docs]
def fit(self, X, y):
"""Fit the classifier."""
X, y = check_X_y(X, y)
self.classes_ = unique_labels(y)
self.n_features_in_ = X.shape[1]
self.n_classes_ = len(self.classes_)
# Binary case
if self.n_classes_ == 2:
y_encoded = np.array([0 if label == self.classes_[0] else 1 for label in y])
feature_map = self.feature_map or AngleFeatureMap(n_qubits=self.n_qubits)
ansatz = self.ansatz or StronglyEntanglingAnsatz(
n_qubits=self.n_qubits, layers=2
)
measurement = self.measurement or PauliZExpectation()
self.model_ = VariationalModel(
feature_map=feature_map,
ansatz=ansatz,
measurement=measurement,
optimizer=self.optimizer,
learning_rate=self.learning_rate,
n_iter=self.n_iter,
batch_size=self.batch_size,
device=self.device,
random_state=self.random_state,
loss_fn="bce",
)
self.model_.fit(X, y_encoded)
# Expose attributes at classifier level for backward compatibility
self.encoder_ = feature_map
self.ansatz_ = ansatz
self.weights_ = self.model_.weights_
self.qnode_ = self.model_.qnode_
self.loss_history_ = self.model_.loss_history_
return self
# Multi-class: one-vs-rest
self.estimators_ = []
loss_histories = []
for i, class_label in enumerate(self.classes_):
y_binary = (y == class_label).astype(int)
clf = self._make_binary_classifier(random_state_offset=i)
clf.fit(X, y_binary)
self.estimators_.append(clf)
loss_histories.append(clf.loss_history_)
self.loss_history_ = [
float(np.mean([h[i] for h in loss_histories])) for i in range(self.n_iter)
]
return self
[docs]
def predict(self, X):
"""Predict class labels."""
check_is_fitted(self, self._check_fitted_attributes())
X = check_array(X)
if self.n_classes_ == 2:
proba = self.predict_proba(X)
indices = (proba[:, 1] > 0.5).astype(int)
else:
proba = self.predict_proba(X)
indices = np.argmax(proba, axis=1)
return self.classes_[indices]
def _check_fitted_attributes(self):
if self.n_classes_ > 2:
return ["estimators_", "classes_", "n_classes_"]
return ["model_", "classes_", "weights_"]
[docs]
def predict_proba(self, X):
"""Predict class probabilities."""
check_is_fitted(self, self._check_fitted_attributes())
X = check_array(X)
if self.n_classes_ == 2:
predictions = self.model_.predict(X)
# Map from [-1, 1] to [0, 1]
probs_class1 = (predictions + 1) / 2
probs = np.vstack([1 - probs_class1, probs_class1]).T
return probs
proba_positive = np.array(
[est.predict_proba(X)[:, 1] for est in self.estimators_]
).T
row_sums = proba_positive.sum(axis=1, keepdims=True)
zero_mask = (row_sums == 0).flatten()
if np.any(zero_mask):
n_classes = proba_positive.shape[1]
proba_positive[zero_mask] = 1.0 / n_classes
row_sums[zero_mask] = 1.0
return proba_positive / row_sums
[docs]
def decision_function(self, X):
"""Compute decision function (raw QNode output)."""
check_is_fitted(self, self._check_fitted_attributes())
if self.n_classes_ == 2:
return self.model_.predict(X)
decisions = np.array([est.model_.predict(X) for est in self.estimators_]).T
return decisions
[docs]
def score(self, X, y):
"""Return classification accuracy."""
return super().score(X, y)
[docs]
class QSVC(QuantumEstimator, ClassifierMixin):
"""Quantum Support Vector Classifier.
A support vector machine that uses a quantum kernel to implicitly
map data into a high-dimensional quantum feature space. The kernel
is computed as the fidelity (squared inner product) between quantum-
encoded states, following the quantum kernel method of Havlíček et al.
(2019, arXiv:1904.01567).
The implementation:
1. Encodes data using a quantum feature map
2. Computes the quantum kernel matrix (Gram matrix)
3. Trains a classical SVM with precomputed kernel
This hybrid approach leverages quantum computers to generate kernel
matrices that may be classically intractable to compute, while using
well-established classical SVM training.
Parameters
----------
n_qubits : int, default=4
Number of qubits for the quantum circuit.
feature_map : FeatureMap, optional
Quantum feature map for data encoding. Default: AngleFeatureMap()
device : str, default="default.qubit"
PennyLane device for simulation.
C : float, default=1.0
Regularization parameter. The strength of regularization is
inversely proportional to C.
shrinking : bool, default=True
Whether to use the shrinking heuristic.
probability : bool, default=False
Whether to enable probability estimates.
tol : float, default=1e-3
Tolerance for stopping criterion.
cache_size : float, default=200
Kernel cache size in MB.
class_weight : dict or "balanced", optional
Class weights for imbalanced datasets.
verbose : bool, default=False
Enable verbose output.
max_iter : int, default=-1
Maximum number of iterations (-1 for no limit).
decision_function_shape : {"ovr", "ovo"}, default="ovr"
Decision function shape (one-vs-rest or one-vs-one).
break_ties : bool, default=False
Whether to break ties according to confidence.
random_state : int, optional
Random seed.
Attributes
----------
classes_ : ndarray
Class labels.
n_features_in_ : int
Number of features seen during fit.
n_classes_ : int
Number of classes.
kernel_ : QuantumKernel
The fitted quantum kernel.
X_train_ : ndarray
Training data.
K_train_ : ndarray
Training kernel matrix.
svc_ : SVC
The underlying scikit-learn SVM classifier.
References
----------
.. [1] V. Havlíček et al., "Supervised learning with quantum-inspired
kernel," arXiv:1904.01567, 2019.
.. [2] B. Schölkopf and A. Smola, "Learning with Kernels," MIT Press,
2002. (Standard SVM reference)
"""
def __init__(
self,
*,
n_qubits: int = 4,
feature_map: FMProtocol | None = None,
device: str = "default.qubit",
C: float = 1.0,
shrinking: bool = True,
probability: bool = False,
tol: float = 1e-3,
cache_size: float = 200,
class_weight=None,
verbose: bool = False,
max_iter: int = -1,
decision_function_shape: str = "ovr",
break_ties: bool = False,
random_state: int | None = None,
kernel: str = "precomputed", # Accepted for sklearn compatibility, ignored
):
self.n_qubits = n_qubits
self.feature_map = feature_map
self.device = device
self.C = C
self.shrinking = shrinking
self.probability = probability
self.tol = tol
self.cache_size = cache_size
self.class_weight = class_weight
self.verbose = verbose
self.max_iter = max_iter
self.decision_function_shape = decision_function_shape
self.break_ties = break_ties
self.random_state = random_state
self.kernel = (
kernel # Store for sklearn compatibility, but always use precomputed
)
if kernel != "precomputed":
warnings.warn(
f"QSVC always uses a quantum (precomputed) kernel; "
f"ignoring kernel='{kernel}'",
UserWarning,
stacklevel=2,
)
[docs]
def fit(self, X, y, sample_weight=None):
"""Fit the quantum SVM."""
X, y = check_X_y(X, y)
self.n_features_in_ = X.shape[1]
self.classes_ = unique_labels(y)
self.n_classes_ = len(self.classes_)
self.kernel_ = QuantumKernel(
feature_map=self.feature_map,
device=self.device,
)
self.kernel_.fit(X)
self.X_train_ = X.copy()
K_train = self.kernel_.compute_matrix(X)
self.K_train_ = K_train
self.svc_ = SVC(
C=self.C,
kernel="precomputed",
shrinking=self.shrinking,
probability=self.probability,
tol=self.tol,
cache_size=self.cache_size,
class_weight=self.class_weight,
verbose=self.verbose,
max_iter=self.max_iter,
decision_function_shape=self.decision_function_shape,
break_ties=self.break_ties,
random_state=self.random_state,
)
self.svc_.fit(K_train, y, sample_weight=sample_weight)
return self
[docs]
def predict(self, X):
"""Predict class labels."""
check_is_fitted(self, ["svc_", "kernel_", "X_train_"])
X = check_array(X, ensure_2d=True)
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but QSVC is expecting "
f"{self.n_features_in_} features as input."
)
K_test = self.kernel_.compute_matrix(X, self.X_train_)
return self.svc_.predict(K_test)
[docs]
def predict_proba(self, X):
"""Predict class probabilities."""
check_is_fitted(self, ["svc_", "kernel_", "X_train_"])
if not self.probability:
raise AttributeError(
"predict_proba requires probability=True in QSVC constructor"
)
X = check_array(X, ensure_2d=True)
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but QSVC is expecting "
f"{self.n_features_in_} features as input."
)
K_test = self.kernel_.compute_matrix(X, self.X_train_)
return self.svc_.predict_proba(K_test)
[docs]
def decision_function(self, X):
"""Compute decision function."""
check_is_fitted(self, ["svc_", "kernel_", "X_train_"])
X = check_array(X, ensure_2d=True)
if X.shape[1] != self.n_features_in_:
raise ValueError(
f"X has {X.shape[1]} features, but QSVC is expecting "
f"{self.n_features_in_} features as input."
)
K_test = self.kernel_.compute_matrix(X, self.X_train_)
return self.svc_.decision_function(K_test)
[docs]
def score(self, X, y):
"""Return classification accuracy."""
return super().score(X, y)
def __getattr__(self, name):
"""Delegate attribute access to the underlying SVC for fitted attributes."""
if name == "svc_" or not hasattr(self, "svc_"):
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
return getattr(self.svc_, name)