"""Variational Quantum Classifier (VQC)."""
from collections.abc import Callable
import numpy as np
import pennylane as qml
import pennylane.numpy as pnp
from sklearn.base import ClassifierMixin
from sklearn.utils.multiclass import unique_labels
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y
from psipose.ansatze import BaseAnsatz, StronglyEntanglingAnsatz
from psipose.base import QuantumEstimator
from psipose.encoders import AngleEncoder, BaseEncoder
[docs]
class VQCClassifier(QuantumEstimator, ClassifierMixin):
"""Variational Quantum Classifier.
A quantum classifier that uses a parameterized quantum circuit
to make predictions. Follows the scikit-learn API.
For binary classification, a single quantum circuit is trained.
For multi-class classification (3+ classes), uses a one-vs-rest
strategy: trains one binary classifier per class.
This implements the variational quantum classifier from:
- Farhi & Neven (2018), "Classification with Quantum Neural
Networks on Near Term Processors" (arXiv:1802.06002).
Introduces QNNs with parametrized unitaries and Pauli-Z
measurements for binary classification.
- Schuld, Bocharov, Svore & Wiebe (2018), "Circuit-centric
quantum classifiers" (arXiv:1804.00633). Uses amplitude
encoding and variational circuits with analytical gradient
estimation for near-term hardware.
Mathematically, for binary classification:
.. math::
f(\\mathbf{x}) = \\langle 0^{\\otimes n} |
U_{\\text{ansatz}}^{\\dagger}(\\mathbf{w})
U_{\\text{enc}}(\\mathbf{x})^{\\dagger}
Z_0
U_{\\text{enc}}(\\mathbf{x})
U_{\\text{ansatz}}(\\mathbf{w})
|0^{\\otimes n} \\rangle
The prediction probability for class 1 is :math:`(f(\\mathbf{x}) + 1)/2`.
Training minimizes the binary cross-entropy loss:
.. math::
\\mathcal{L} = -\\frac{1}{N} \\sum_{i=1}^N
[y_i \\log(p_i) + (1-y_i) \\log(1-p_i)]
Parameters
----------
n_qubits : int, default=4
Number of qubits in the quantum circuit.
encoder : BaseEncoder, optional
Data encoding circuit. If None, defaults to AngleEncoder().
ansatz : BaseAnsatz, optional
Parameterized circuit. If None, defaults to StronglyEntanglingAnsatz(layers=2).
optimizer : {"adam", "sgd"} or callable, default="adam"
Optimization algorithm. If a string, uses PennyLane's built-in
optimizer. If callable, should return an optimizer instance with
step() and reset() methods.
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 reproducibility.
device : str, default="default.qubit"
PennyLane device to use.
Attributes
----------
classes_ : ndarray
Class labels.
n_features_in_ : int
Number of features seen during fit.
n_classes_ : int
Number of classes (binary: 2, multi-class: >2).
weights_ : ndarray
Trained circuit parameters (binary classification only).
encoder_ : BaseEncoder
Fitted encoder (binary classification only).
ansatz_ : BaseAnsatz
Fitted ansatz (binary classification only).
estimators_ : list[VQCClassifier]
List of fitted binary classifiers for multi-class classification.
Each is a trained VQCClassifier for one class vs rest.
loss_history_ : list[float]
Training loss at each iteration. For binary: direct loss history.
For multi-class: average loss across all binary classifiers.
qnode_ : callable
Cached QNode for inference (binary classification only).
Set after :meth:`fit`. Advanced users can call this directly
for debugging or custom measurements.
"""
def __init__(
self,
n_qubits: int = 4,
encoder: BaseEncoder | None = None,
ansatz: BaseAnsatz | None = None,
optimizer: str | Callable = "adam",
learning_rate: float = 0.01,
n_iter: int = 100,
batch_size: int | None = None,
random_state: int | None = None,
device: str = "default.qubit",
):
self.n_qubits = n_qubits
self.encoder = encoder
self.ansatz = ansatz
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 _create_binary_classifier(self, random_state_offset=0):
"""Create a fresh binary VQCClassifier instance for OvR training.
Parameters
----------
random_state_offset : int
Offset added to random_state to ensure different initializations.
Returns
-------
VQCClassifier
A new binary classifier with the same hyperparameters.
"""
# Use a different random state for each binary classifier
rs = self.random_state
if rs is not None:
rs = rs + random_state_offset
return VQCClassifier(
n_qubits=self.n_qubits,
encoder=self.encoder,
ansatz=self.ansatz,
optimizer=self.optimizer,
learning_rate=self.learning_rate,
n_iter=self.n_iter,
batch_size=self.batch_size,
random_state=rs,
device=self.device,
)
def _train_binary_classifier(self, X, y_binary, random_state_offset=0):
"""Train a binary VQC classifier.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Training data.
y_binary : array-like, shape (n_samples,)
Binary labels (0 or 1).
random_state_offset : int
Offset for random state to ensure different initializations.
Returns
-------
VQCClassifier
Fitted binary classifier.
"""
clf = self._create_binary_classifier(random_state_offset)
clf.fit(X, y_binary)
return clf
def _get_optimizer(self):
"""Get optimizer instance based on self.optimizer."""
if isinstance(self.optimizer, str):
if self.optimizer.lower() == "adam":
return qml.AdamOptimizer(self.learning_rate)
elif self.optimizer.lower() == "sgd":
return qml.GradientDescentOptimizer(self.learning_rate)
else:
raise ValueError(f"Unknown optimizer: {self.optimizer}")
else:
return self.optimizer
def _train_loop(self, X, y_encoded, opt, rng):
"""Run the training loop for binary classification.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Training data.
y_encoded : array-like, shape (n_samples,)
Encoded binary labels (0 or 1).
opt : pennylane.Optimizer
Optimizer instance.
rng : np.random.Generator
Random number generator.
Returns
-------
list[float]
Loss history.
"""
loss_history = []
for _ in range(self.n_iter):
if self.batch_size is not None and self.batch_size < len(X):
indices = rng.choice(len(X), size=self.batch_size, replace=False)
X_batch = X[indices]
y_batch = y_encoded[indices]
else:
X_batch = X
y_batch = y_encoded
self.weights_, loss = opt.step_and_cost(
lambda w, Xb=X_batch, yb=y_batch: self._compute_loss(w, Xb, yb),
self.weights_,
)
loss_history.append(float(loss))
return loss_history
def _build_qnode(self):
"""Build and cache the QNode for inference.
The QNode is cached as ``self.qnode_`` after the first call so
it does not need to be rebuilt for every prediction.
"""
if hasattr(self, "qnode_") and self.qnode_ is not None:
return self.qnode_
if not hasattr(self, "_qnode_dev"):
self._qnode_dev = qml.device(self.device, wires=self.n_qubits)
dev = self._qnode_dev
@qml.qnode(dev, interface="autograd")
def circuit(x, weights):
self.encoder_.encode(x, wires=range(self.n_qubits))
self.ansatz_.circuit(weights, wires=range(self.n_qubits))
return qml.expval(qml.PauliZ(0))
self.qnode_ = circuit
return circuit
def _compute_loss(self, weights, X_batch, y_batch):
"""Compute binary cross-entropy loss."""
qnode = self._build_qnode()
total_loss = pnp.array(0.0)
for x, y_true in zip(X_batch, y_batch, strict=True):
prediction = qnode(x, weights)
prob = (prediction + 1) / 2
prob = pnp.clip(prob, 1e-15, 1 - 1e-15)
y_t = pnp.array(y_true)
loss = -(y_t * pnp.log(prob) + (1 - y_t) * pnp.log(1 - prob))
total_loss += loss
return total_loss / len(X_batch)
[docs]
def fit(self, X, y):
"""Fit the quantum classifier.
Supports both binary and multi-class classification using one-vs-rest strategy.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Training data.
y : array-like, shape (n_samples,)
Target labels.
Returns
-------
self : VQCClassifier
Returns the fitted estimator.
"""
# Validate inputs
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 classification (n_classes == 2) - efficient path
if self.n_classes_ == 2:
# Encode labels to {0, 1}
y_encoded = np.array([0 if label == self.classes_[0] else 1 for label in y])
# Initialize encoder
if self.encoder is None:
self.encoder_ = AngleEncoder(n_qubits=self.n_qubits)
else:
self.encoder_ = self.encoder
self.encoder_.fit(X)
# Initialize ansatz
if self.ansatz is None:
self.ansatz_ = StronglyEntanglingAnsatz(
n_qubits=self.n_qubits, layers=2
)
else:
self.ansatz_ = self.ansatz
# Initialize weights
rng = np.random.default_rng(self.random_state)
weight_shape = self.ansatz_.weight_shape(self.n_qubits)
if isinstance(weight_shape, tuple):
self.weights_ = pnp.array(
rng.uniform(0, 2 * np.pi, weight_shape), requires_grad=True
)
else:
self.weights_ = pnp.array(
rng.uniform(0, 2 * np.pi, weight_shape), requires_grad=True
)
# Setup optimizer
opt = self._get_optimizer()
# Training loop
self.loss_history_ = self._train_loop(X, y_encoded, opt, rng)
return self
# Multi-class classification (n_classes > 2) - one-vs-rest
self.estimators_ = []
loss_histories = []
for i, class_label in enumerate(self.classes_):
# Create binary labels: current class = 1, others = 0
y_binary = (y == class_label).astype(int)
# Train binary classifier
clf = self._train_binary_classifier(X, y_binary, random_state_offset=i)
self.estimators_.append(clf)
loss_histories.append(clf.loss_history_)
# Average loss across all classifiers for each iteration
# All loss histories should have same length (n_iter)
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 for samples.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Input samples.
Returns
-------
y : ndarray, shape (n_samples,)
Predicted class labels.
"""
check_is_fitted(self, self._check_fitted_attributes())
X = check_array(X)
proba = self.predict_proba(X)
indices = np.argmax(proba, axis=1)
return self.classes_[indices]
def _check_fitted_attributes(self):
"""Return list of attributes that must be present for fitted estimator."""
if hasattr(self, "n_classes_") and self.n_classes_ > 2:
return ["estimators_", "classes_", "n_classes_"]
else:
return ["weights_", "encoder_", "ansatz_", "classes_"]
[docs]
def predict_proba(self, X):
"""Predict class probabilities.
For binary classification, returns probabilities for both classes.
For multi-class, returns normalized probabilities across all classes
from the one-vs-rest classifiers.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Input samples.
Returns
-------
proba : ndarray, shape (n_samples, n_classes)
Class probabilities.
"""
check_is_fitted(self, self._check_fitted_attributes())
X = check_array(X)
# Binary classification - batch evaluation
if self.n_classes_ == 2:
qnode = self.qnode_
# Batch: compute all predictions at once instead of
# rebuilding QNode per sample
predictions = np.array([qnode(x, self.weights_) for x in X])
# Map from [-1, 1] to [0, 1]
probs_class1 = (predictions + 1) / 2
probs = np.vstack([1 - probs_class1, probs_class1]).T
return probs
# Multi-class: get positive class probability from each binary classifier
proba_positive = np.array(
[est.predict_proba(X)[:, 1] for est in self.estimators_]
).T # shape (n_samples, n_classes)
# Normalize rows to sum to 1
row_sums = proba_positive.sum(axis=1, keepdims=True)
# Handle rows where all classifiers predict 0 (sum = 0)
# Replace with uniform distribution to ensure probabilities sum to 1
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
proba = proba_positive / row_sums
return proba
[docs]
def score(self, X, y):
"""Return classification accuracy."""
return super().score(X, y)