Source code for psipose.estimators.vqc

"""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)