Source code for psipose.estimators.vqc_regressor

"""Variational Quantum Regressor (VQC)."""

from collections.abc import Callable

import numpy as np
import pennylane as qml
import pennylane.numpy as pnp
from sklearn.base import RegressorMixin
from sklearn.preprocessing import StandardScaler
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 VQCRegressor(QuantumEstimator, RegressorMixin): """Variational Quantum Regressor. A quantum regressor that uses a parameterized quantum circuit to make continuous predictions. Follows the scikit-learn API. This implements the variational quantum regressor from: - Mitarai et al. (2018), "Quantum circuit learning" (arXiv:1803.00745). Introduces parameterized quantum circuits for continuous prediction using Pauli expectation values and MSE loss. - Farhi & Neven (2018), "Classification with Quantum Neural Networks on Near Term Processors" (arXiv:1802.06002). Framework for variational quantum circuits with Pauli-Z measurements. - Schuld, Bocharov, Svore & Wiebe (2018), "Circuit-centric quantum classifiers" (arXiv:1804.00633). Circuit-centric approach applicable to regression with MSE loss. Mathematically, for input :math:`\\mathbf{x}`: .. math:: f(\\mathbf{x}) = \\langle \\phi(\\mathbf{x}) | U_{\\text{ansatz}}^{\\dagger}(\\mathbf{w}) Z_0 U_{\\text{ansatz}}(\\mathbf{w}) |\\phi(\\mathbf{x}) \\rangle The target values are scaled to [-1, 1] and training minimizes MSE: .. math:: \\mathcal{L} = \\frac{1}{N} \\sum_{i=1}^N (y_i^{\\text{scaled}} - f(\\mathbf{x}_i))^2 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. 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 ---------- n_features_in_ : int Number of features seen during fit. weights_ : ndarray Trained circuit parameters. encoder_ : BaseEncoder Fitted encoder. ansatz_ : BaseAnsatz Fitted ansatz. scaler_ : StandardScaler Fitted scaler for y values. loss_history_ : list[float] Training MSE at each iteration. qnode_ : callable Cached QNode for inference, 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 _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 _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_scaled): """Compute MSE loss between scaled targets and predictions.""" qnode = self._build_qnode() total_loss = pnp.array(0.0) for x, y_true_scaled in zip(X_batch, y_batch_scaled, strict=True): prediction = qnode(x, weights) loss = (y_true_scaled - prediction) ** 2 total_loss += loss return total_loss / len(X_batch) def _train_loop(self, X, y_scaled, opt, rng): """Run the training loop. Parameters ---------- X : array-like, shape (n_samples, n_features) Training data. y_scaled : array-like, shape (n_samples,) Scaled target values in [-1, 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_scaled = y_scaled[indices] else: X_batch = X y_batch_scaled = y_scaled self.weights_, loss = opt.step_and_cost( lambda w, Xb=X_batch, yb=y_batch_scaled: self._compute_loss(w, Xb, yb), self.weights_, ) loss_history.append(float(loss)) return loss_history
[docs] def fit(self, X, y): """Fit the quantum regressor. Parameters ---------- X : array-like, shape (n_samples, n_features) Training data. y : array-like, shape (n_samples,) Target values. Returns ------- self : VQCRegressor Returns the fitted estimator. """ X, y = check_X_y(X, y, multi_output=False) self.n_features_in_ = X.shape[1] # 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) self.weights_ = pnp.array( rng.uniform(0, 2 * np.pi, weight_shape), requires_grad=True ) # Scale y to [-1, 1] using StandardScaler self.scaler_ = StandardScaler() y_scaled = self.scaler_.fit_transform(y.reshape(-1, 1)).flatten() # Setup optimizer opt = self._get_optimizer() # Training loop self.loss_history_ = self._train_loop(X, y_scaled, opt, rng) return self
[docs] def predict(self, X): """Predict continuous target values for samples. Parameters ---------- X : array-like, shape (n_samples, n_features) Input samples. Returns ------- y : ndarray, shape (n_samples,) Predicted target values. """ check_is_fitted(self, ["weights_", "encoder_", "ansatz_", "scaler_"]) X = check_array(X) # Validate n_features_in_ matches if X.shape[1] != self.n_features_in_: raise ValueError( f"X has {X.shape[1]} features, but VQCRegressor is expecting " f"{self.n_features_in_} features as input." ) qnode = self.qnode_ # Get predictions in [-1, 1] range predictions_scaled = np.array([qnode(x, self.weights_) for x in X]) # Inverse transform back to original scale predictions = self.scaler_.inverse_transform( predictions_scaled.reshape(-1, 1) ).flatten() return predictions