Source code for vc.models

"""This module is used to define quantum models.

To add a new model, you should implement the :py:class:`~vc.models.QModel`
interface and update :py:func:`~vc.models.get_model`.
"""
import copy
from abc import ABC, abstractmethod
from functools import partial
from typing import Any, Callable, Dict, Tuple, Type, Union

import numpy as np
import pennylane
import pennylane.measurements
import torch
from numpy.random import RandomState
from numpy.typing import ArrayLike, NDArray
from torchtyping import TensorType

# pylint: disable=invalid-name
# pylint: disable=too-many-arguments
# pylint: disable=attribute-defined-outside-init


[docs]class ModelError(Exception): """Module exception."""
[docs] def __init__(self, message: str): """Init ModelError.""" super().__init__(message)
def _get_device(backend: Dict[str, Any], n_qubits: int) -> pennylane.QubitDevice: """Get qubit device using PennyLane. Args: backend: see docstring of :py:class:`~vc.models.QModel`. n_qubits: number of qubits. Returns: PennyLane device. """ return pennylane.device( backend["name"], wires=n_qubits, **backend.get("options", {}) )
[docs]class QModel(ABC): """Abstract base class for quantum models."""
[docs] def __init__(self, backend: Dict[str, Any], n_classes: int) -> None: """ Init QModel. Args: backend: dictionary of the form ``{"name": str, "options": dict}``, where the value for ``"name"`` is the name of a PennyLane device and the value for ``"options"`` is a dict to be passed as kwargs to PennyLane when creating the device. Example: ``{"name": "default.qubit", "options": {}}`` n_classes: number of classes. """ self.backend = backend self.n_classes = n_classes
[docs] @abstractmethod def preprocess( self, X: ArrayLike, min_max: Tuple[float, float] ) -> NDArray[np.float_]: """Convert ``X`` to features. This function should set ``self.n_qubits``."""
[docs] @abstractmethod def get_init_weights( self, random: bool, random_state: RandomState ) -> NDArray[np.float_]: """Generate weights to be used as initial trainable parameters."""
[docs] @abstractmethod def get_qfunc(self) -> Callable[[TensorType, TensorType], TensorType]: """Generate and return a quantum function."""
[docs]class ExpectedValuesModel(QModel): """Model that uses angle encoding and expected values."""
[docs] def __init__( self, backend: Dict[str, Union[str, Any]], n_classes: int, n_layers: int = 2, n_trainable_sublayers: int = 2, scaling: float = 0.5, ) -> None: r"""Init ExpectedValuesModel. This model implements a unitary $U(x)$ of the form: .. math:: U(x) = W_{L + 1} \cdot S(x) \cdot W_{L} \cdot \ldots \cdot W_{2} \cdot S(x) \cdot W_{1} where: - $x$ is the data to encode, - $S$ is an encoding circuit, - $W$ is a trainable circuit. Args: backend: see docstring of :py:class:`~vc.models.QModel`. n_classes: see docstring of :py:class:`~vc.models.QModel`. n_layers: number of layers in $U(x)$ (equal to $L$). n_trainable_sublayers: number of layers for each $W$. scaling: scaling to apply to the preprocessed data, see :py:meth:`~vc.models.ExpectedValuesModel.preprocess`. """ super().__init__(backend, n_classes) self.n_layers = n_layers self.n_trainable_sublayers = n_trainable_sublayers self.scaling = scaling
[docs] def preprocess( self, X: ArrayLike, min_max: Tuple[float, float] ) -> NDArray[np.float_]: r"""Maps input ``X`` in the range ``min_max`` to $(-\pi, \pi]$. Args: X: input data with shape (`n_samples`, `n_features`). min_max: minimum value and maximum value. """ # Coerce into an ndarray X = np.asarray(X) # Convert to angles between -pi and pi angles = 2 * np.pi * (X - min_max[0]) / (min_max[1] - min_max[0]) - np.pi # Set number of qubits required by model and validate self.n_qubits = angles.shape[1] if self.n_classes > self.n_qubits: raise ModelError( "The number of classes should be less than or equal to the number " "of features." ) return angles
def _S(self, x: TensorType) -> None: """Define encoding circuit.""" pennylane.AngleEmbedding( features=self.scaling * x, wires=range(self.n_qubits), rotation="X" ) def _W(self, w: TensorType) -> None: """Define trainable circuit.""" pennylane.StronglyEntanglingLayers(w, wires=range(self.n_qubits))
[docs] def get_init_weights( self, random: bool, random_state: RandomState ) -> NDArray[np.float_]: r"""Get init weights between 0 and $2\pi$.""" array_shape = self.n_layers + 1, self.n_trainable_sublayers, self.n_qubits, 3 if random: return 2 * np.pi * random_state.random(size=array_shape) return np.zeros(array_shape)
def _circuit( self, weights: TensorType, x: TensorType, ) -> Tuple[pennylane.measurements.MeasurementProcess, ...]: """Create modelled circuit. Args: weights: weights for trainable circuits x: data for the encoding circuit Returns: Measurements (expected values). """ # Define circuit for w in weights[:-1, :, :, :]: self._W(w) self._S(x) self._W(weights[-1, :, :, :]) # Measure the expected value for as many qubits as classes we have if self.n_classes > 2: wires = range(self.n_classes) else: wires = range(1) return tuple(pennylane.expval(pennylane.PauliZ(wire)) for wire in wires)
[docs] def get_qfunc(self) -> Callable[[TensorType, TensorType], TensorType]: """Define callable based on circuit.""" dev = _get_device(self.backend, self.n_qubits) qnode = pennylane.QNode(self._circuit, dev, interface="torch") def _process_measurement( weights: TensorType, x: TensorType, qnode: pennylane.QNode = qnode ) -> TensorType: return qnode(weights, x) return _process_measurement
[docs]class ProbabilitiesModel(QModel, ABC): """Generic model that uses probabilities."""
[docs] def __init__( self, backend: Dict[str, Any], n_classes: int, n_layers: int = 2, n_trainable_sublayers: int = 2, scaling: float = 0.5, ) -> None: r"""Init ProbabilitiesModel. This model implements a unitary $U(x)$ of the form: .. math:: U(x) = W_{L + 1} \cdot S(x) \cdot W_{L} \cdot \ldots \cdot W_{2} \cdot S(x) \cdot W_{1} where: - $x$ is the data to encode, - $S$ is an encoding circuit, - $W$ is a trainable circuit. Args: backend: see docstring of :py:class:`~vc.models.QModel`. n_classes: see docstring of :py:class:`~vc.models.QModel`. n_layers: number of layers in $U(x)$ (equal to $L$). n_trainable_sublayers: number of layers for each $W$. scaling: scaling to apply to the preprocessed data, see :py:meth:`~vc.models.ProbabilitiesModel.preprocess`. """ super().__init__(backend, n_classes) self.n_layers = n_layers self.n_trainable_sublayers = n_trainable_sublayers self.scaling = scaling
[docs] def preprocess( self, X: ArrayLike, min_max: Tuple[float, float] ) -> NDArray[np.float_]: r"""Maps input ``X`` in the range ``min_max`` to $(-\pi, \pi]$. Args: X: input data with shape (`n_samples`, `n_features`). min_max: minimum value and maximum value. """ # Coerce into an ndarray X = np.asarray(X) # Convert to angles between -pi and pi angles = 2 * np.pi * (X - min_max[0]) / (min_max[1] - min_max[0]) - np.pi # Set number of qubits required by model and validate self.n_qubits = angles.shape[1] if self.n_classes > 2**self.n_qubits: raise ModelError( "The number of classes should be less than or equal to 2 to the power" "of the number of features." ) return angles
def _S(self, x: TensorType) -> None: """Define encoding circuit.""" pennylane.AngleEmbedding( features=self.scaling * x, wires=range(self.n_qubits), rotation="X" ) def _W(self, w: TensorType) -> None: """Define trainable circuit.""" pennylane.StronglyEntanglingLayers(w, wires=range(self.n_qubits))
[docs] def get_init_weights( self, random: bool, random_state: RandomState ) -> NDArray[np.float_]: r"""Get init weights between $0$ and $2\pi$.""" array_shape = self.n_layers + 1, self.n_trainable_sublayers, self.n_qubits, 3 if random: return 2 * np.pi * random_state.random(size=array_shape) return np.zeros(array_shape)
def _circuit( self, weights: TensorType, x: TensorType, ) -> pennylane.measurements.MeasurementProcess: """Create modelled circuit. Args: weights: weights for trainable circuits x: data for the encoding circuit Returns: Measurements (probabilities) if the number of classes is greater than 2; otherwise measurements (expected values) are returned. """ # Define circuit for w in weights[:-1, :, :, :]: self._W(w) self._S(x) self._W(weights[-1, :, :, :]) # If there are more than 2 classes, measure probabilities if self.n_classes > 2: return pennylane.probs(wires=range(self.n_qubits)) # Else measure the expected value for the first qubit return pennylane.expval(pennylane.PauliZ(0))
[docs] def get_qfunc(self) -> Callable[[TensorType, TensorType], TensorType]: """Get callable based on circuit.""" dev = _get_device(self.backend, self.n_qubits) qnode = pennylane.QNode(self._circuit, dev, interface="torch") return partial(self._process_measurement, qnode=qnode, n_classes=self.n_classes)
@staticmethod @abstractmethod def _process_measurement( weights: TensorType, x: TensorType, qnode: pennylane.QNode, n_classes: int, ) -> TensorType: """Define post-processing strategy."""
[docs]class ModuloModel(ProbabilitiesModel): r"""Model that uses post-processing with modulo. See docstring of :py:class:`.vc.models.ProbabilitiesModel`. The post-processing strategy assigns a class to an $n$-bit string $b$ according to the following formula: .. math:: f(b) = \left[b\right]_{10} \mod M where: - $M$ is the number of classes, - $[\cdot]_{10}$ is the decimal representation of the argument. """ @staticmethod def _process_measurement( weights: TensorType, x: TensorType, qnode: pennylane.QNode, n_classes: int, ) -> TensorType: """Define post-processing strategy.""" measurement: TensorType = qnode(weights, x) if n_classes > 2: aggregated_measurement = [] for class_id in range(n_classes): aggregated_measurement.append( measurement[class_id::n_classes][ : measurement.numel() // n_classes ].sum() ) return torch.stack(aggregated_measurement).squeeze() return measurement
[docs]class ParityModel(ProbabilitiesModel): r"""Model that uses parity post-processing. See docstring of :py:class:`.vc.models.ProbabilitiesModel`. The post-processing strategy assigns a class to an $n$-bit string $b$ according to the following formula: .. math:: f(b) = \left[b_0 ... b_{m-2}\left(\bigoplus_{i=m-1}^{n-1} b_i\right) \right]_{10} where: - $m=\lceil \log_2(M) \rceil$ with $M$ being the number of classes, - $n$ is the number of bits, - $[\cdot]_{10}$ is the decimal representation of the argument. Reference: `"Quantum Policy Gradient Algorithm with Optimized Action Decoding" by Meyer et al. <https://arxiv.org/abs/2212.06663v1>`_ """ @staticmethod def _f(idx: int, n_bits_in: int, n_bits_out: int) -> int: """Post-processing function. Assigns a class index (an integer) to an arbitrary integer (corresponding to a measured bit string). Args: idx: state index. n_bits_in: number of bits used for the input index. n_bits_out: number of bits to be used for the output class index. Returns: Class index assigned. """ idx_bit_array = np.array( list(map(int, [*np.binary_repr(idx, width=n_bits_in)])) ) class_bit_array = idx_bit_array[-n_bits_out:] if n_bits_out < n_bits_in: class_bit_array[0] = idx_bit_array[: -(n_bits_out - 1)].sum() % 2 return int("".join([str(elem) for elem in np.flip(class_bit_array)]), 2) @staticmethod def _process_measurement( weights: TensorType, x: TensorType, qnode: pennylane.QNode, n_classes: int, ) -> TensorType: """Define post-processing strategy.""" measurement: TensorType = qnode(weights, x) if n_classes > 2: n_bits_in = int(np.log2(measurement.numel())) n_bits_out = int(np.ceil(np.log2(n_classes))) aggregated_measurement = [ torch.zeros(1, requires_grad=False) for _ in range(n_classes) ] for idx, prob in enumerate(measurement): class_id = ParityModel._f(idx, n_bits_in, n_bits_out) if class_id >= n_classes: continue aggregated_measurement[class_id] = ( aggregated_measurement[class_id] + prob ) return torch.stack(aggregated_measurement).squeeze() return measurement
[docs]def get_model( model: Dict[str, Any], backend: Dict[str, Any], n_classes: int, ) -> QModel: """Create instance of a quantum model. Args: model: dictionary of the form ``{"name": str, "options": dict}``. The value for ``"name"`` is used to determine the model class. The value for ``"options"`` should be a ``dict`` and is passed as kwargs to the constructor of the model class. Note: if there's a ``"backend"`` key in the value for ``"options"``, it will be ignored. backend: see docstring of :py:class:`~vc.models.QModel`. n_classes: number of classes. Returns: An instance of a quantum model. """ # Make sure there's no conflicting backend key model = copy.deepcopy(model) model.pop("backend", None) # Instantiate model models: Dict[str, Type[QModel]] models = { "expected_values_model": ExpectedValuesModel, "modulo_model": ModuloModel, "parity_model": ParityModel, } if model["name"] not in models: raise ValueError("Invalid model name.") model_class = models[model["name"]] model_instance = model_class( backend, n_classes, **model["options"], ) return model_instance