Source code for tno.quantum.ml.classifiers.vc.variational_classifier

"""This module implements a scikit-learn compatible, variational quantum classifier.

Usage:

.. code-block::

    vc = VariationalClassifier()
    vc = vc.fit(X_training, y_training, n_iter=60)
    predictions = vc.predict(X_validation)
"""
from __future__ import annotations

import warnings
from typing import Any, Dict, Optional, Tuple

import numpy as np
import torch
from numpy.typing import ArrayLike, NDArray
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.exceptions import NotFittedError
from sklearn.preprocessing import LabelEncoder
from sklearn.utils import check_random_state
from sklearn.utils.multiclass import unique_labels
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y
from torch import nn
from tqdm import tqdm

from tno.quantum.ml.classifiers.vc import models as quantum_models
from tno.quantum.ml.classifiers.vc import optimizers as quantum_optimizers

# pylint: disable=attribute-defined-outside-init
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-arguments
# pylint: disable=invalid-name
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals


def get_default_if_none(
    backend: Optional[Dict[str, Any]] = None,
    model: Optional[Dict[str, Any]] = None,
    optimizer: Optional[Dict[str, Any]] = None,
) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
    """Set default value if the one provided is ``None``.

    Args:
        backend: see docstring of :py:func:`~vc.models.get_model`.
          default value ``{"name": "default.qubit", "options": {}}``.
        model: see docstring of :py:func:`~vc.models.get_model`
          default value ``{"name": "modulo_model",
          "options": {"n_layers": 2, "n_trainable_sublayers": 2, "scaling": 0.5}}``.
        optimizer: see docstring of :py:func:`~vc.optimizers.get_optimizer`
          default value ``{"name": "adam", "options": {}}``.

    Returns:
        Default backend, model, optimizer.
    """
    if backend is None:
        backend = {"name": "default.qubit", "options": {}}
    if model is None:
        model = {
            "name": "modulo_model",
            "options": {"n_layers": 2, "n_trainable_sublayers": 2, "scaling": 0.5},
        }
    if optimizer is None:
        optimizer = {"name": "adam", "options": {}}
    return backend, model, optimizer


[docs]class VariationalClassifier(ClassifierMixin, BaseEstimator): # type:ignore[misc] """Variational classifier."""
[docs] def __init__( self, batch_size: int = 5, backend: Optional[Dict[str, Any]] = None, model: Optional[Dict[str, Any]] = None, optimizer: Optional[Dict[str, Any]] = None, use_bias: bool = False, random_init: bool = True, warm_init: bool = False, random_state: Optional[int] = None, ) -> None: """Init VariationalClassifier. The default values for ``backend``, ``model``, and ``optimizer`` are defined in :py:func:`vc.variational_classifier.get_default_if_none`. Args: batch_size: batch size to be used during fitting. backend: see docstring of :py:func:`~vc.models.get_model`. model: see docstring of :py:func:`~vc.models.get_model`. optimizer: see docstring of :py:func:`~vc.optimizers.get_optimizer` use_bias: set to ``True`` if a bias parameter should be optimized over. random_init: set to ``True`` if parameters to optimize over should be initialized randomly. warm_init: set to ``True`` if parameters from a previous call to fit should be used. random_state: random seed for repeatability. """ self.batch_size = batch_size self.backend = backend self.model = model self.optimizer = optimizer self.use_bias = use_bias self.random_init = random_init self.warm_init = warm_init self.random_state = random_state
[docs] def fit(self, X: ArrayLike, y: ArrayLike, n_iter: int = 1) -> VariationalClassifier: """Fit data using a quantum model. Args: X: training data with shape (`n_samples`, `n_features`). y: target values with shape (`n_samples`,). n_iter: number of training iterations. """ # Check X, y = check_X_y(X, y) X, y = np.array(X), np.array(y) if X.shape[0] == 1: raise ValueError("Cannot fit with only 1 sample.") # Get default settings if necessary backend, model, optimizer = get_default_if_none( self.backend, self.model, self.optimizer ) # Store the classes seen during fit self.classes_ = unique_labels(y) if self.classes_.size == 1: warnings.warn("Only one class found. Fitting anyway.") # Encode labels self.label_encoder_ = LabelEncoder() self.label_encoder_.fit(self.classes_) y = self.label_encoder_.transform(y) # Get numpy's random state random_state = check_random_state(self.random_state) # Get quantum model qmodel = quantum_models.get_model(model, backend, self.classes_.size) # Preprocess X and y self.min_max_ = (np.min(X, axis=0), np.max(X, axis=0)) X_preprocessed = torch.tensor( qmodel.preprocess(X, self.min_max_), requires_grad=False ) y = torch.tensor(y, requires_grad=False) # Train weights_init = qmodel.get_init_weights(self.random_init, random_state) bias_init = np.zeros(1 if self.classes_.size == 2 else (self.classes_.size,)) optimizer_state = None if self.warm_init: try: check_is_fitted(self, ["weights_", "bias_"]) except NotFittedError: warnings.warn( "The warm_init keyword is set to True, but the fit" " method has not been called before. Fitting will be" " performed for the first time." ) else: weights_init = self.weights_ # pylint: disable=E0203 bias_init = self.bias_ # pylint: disable=E0203 optimizer_state = self.optimizer_state_ # pylint: disable=E0203 weights = torch.tensor(weights_init, requires_grad=True) bias = torch.tensor(bias_init, requires_grad=self.use_bias) qfunc = qmodel.get_qfunc() opt_params = [weights] if self.use_bias: opt_params.append(bias) opt = quantum_optimizers.get_optimizer(opt_params, optimizer) if optimizer_state is not None: opt.load_state_dict(optimizer_state) for _ in tqdm(range(n_iter), desc="Training iteration: "): # Load batch batch_index = random_state.permutation(range(y.numel()))[: self.batch_size] X_preprocessed_batch = X_preprocessed[batch_index, :] y_batch = y[batch_index] # Compute predictions and loss predictions = torch.stack( [qfunc(weights, x) + bias for x in X_preprocessed_batch] ).squeeze() loss_func: torch.nn.modules.loss._WeightedLoss if self.classes_.size > 2: loss_func = nn.CrossEntropyLoss() loss = loss_func(predictions, y_batch.long()) else: loss_func = nn.BCELoss() loss = loss_func(nn.Sigmoid()(predictions), y_batch.double()) # Backpropagation step opt.zero_grad() loss.backward() opt.step() # Store fit parameters self.weights_: NDArray[np.float_] = weights.detach().numpy() self.bias_: NDArray[np.float_] = bias.detach().numpy() self.optimizer_state_: Dict[str, Any] = opt.state_dict() self.n_features_in_: int = X.shape[1] # Return the classifier return self
[docs] def predict(self, X: ArrayLike) -> NDArray[Any]: """Predict class using a quantum model. Args: X: input data with shape (`n_samples` `n_features`). Returns: The predicted classes with shape (`n_samples`, `n_classes`). """ # Check check_is_fitted(self, ["weights_", "bias_"]) X = np.array(check_array(X)) # Get default settings if necessary backend, model, _ = get_default_if_none( self.backend, self.model, self.optimizer ) # Get quantum model qmodel = quantum_models.get_model(model, backend, self.classes_.size) # Preprocess X X_preprocessed = torch.tensor( qmodel.preprocess(X, self.min_max_), requires_grad=False ) # Load weights and bias weights = torch.tensor(self.weights_, requires_grad=False) bias = torch.tensor(self.bias_, requires_grad=False) # Predict qfunc = qmodel.get_qfunc() predictions_tensor = torch.stack( [qfunc(weights, x) + bias for x in X_preprocessed] ).squeeze() if self.classes_.size > 2: predictions_tensor_2d = torch.atleast_2d( predictions_tensor ) # type: ignore[no-untyped-call] predicted_idx = torch.max(torch.softmax(predictions_tensor_2d, dim=1), 1)[ 1 ].numpy() else: predicted_idx = (torch.sigmoid(predictions_tensor) > 0.5).int().numpy() predictions: NDArray[Any] if self.classes_.size == 1: predictions = np.full(shape=X.shape[0], fill_value=self.classes_[0]) else: predictions = self.label_encoder_.inverse_transform(predicted_idx) return predictions
def _more_tags(self) -> Dict[str, bool]: """Return estimator tags for use in sklearn tests.""" return { "poor_score": True, # We have our own tests }