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