# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2021, 2024.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""An abstract objective function definition and common objective functions suitable
for classifiers/regressors."""
from abc import abstractmethod
from typing import Optional, Union
import numpy as np
from .. import optionals as _optionals
from ..neural_networks import NeuralNetwork
from ..utils.loss_functions import Loss
if _optionals.HAS_SPARSE:
# pylint: disable=import-error
from sparse import SparseArray
else:
class SparseArray: # type: ignore
"""Empty SparseArray class
Replacement if sparse.SparseArray is not present.
"""
pass
[docs]
class ObjectiveFunction:
"""An abstract objective function. Provides methods for computing objective value and
gradients for forward and backward passes."""
# pylint: disable=invalid-name
def __init__(
self, X: np.ndarray, y: np.ndarray, neural_network: NeuralNetwork, loss: Loss
) -> None:
"""
Args:
X: The input data.
y: The target values.
neural_network: An instance of an quantum neural network to be used by this
objective function.
loss: A target loss function to be used in training.
"""
super().__init__()
self._X = X
self._num_samples = X.shape[0]
self._y = y
self._neural_network = neural_network
self._loss = loss
self._last_forward_weights: Optional[np.ndarray] = None
self._last_forward: Optional[Union[np.ndarray, SparseArray]] = None
[docs]
@abstractmethod
def objective(self, weights: np.ndarray) -> float:
"""Computes the value of this objective function given weights.
Args:
weights: an array of weights to be used in the objective function.
Returns:
Value of the function.
"""
raise NotImplementedError
[docs]
@abstractmethod
def gradient(self, weights: np.ndarray) -> np.ndarray:
"""Computes gradients of this objective function given weights.
Args:
weights: an array of weights to be used in the objective function.
Returns:
Gradients of the function.
"""
raise NotImplementedError
def _neural_network_forward(self, weights: np.ndarray) -> Union[np.ndarray, SparseArray]:
"""
Computes and caches the results of the forward pass. Cached values may be re-used in
gradient computation.
Args:
weights: an array of weights to be used in the forward pass.
Returns:
The result of the neural network.
"""
# if we get the same weights, we don't compute the forward pass again.
if self._last_forward_weights is None or (
not np.all(np.isclose(weights, self._last_forward_weights))
):
# compute forward and cache the results for re-use in backward
self._last_forward = self._neural_network.forward(self._X, weights)
# a copy avoids keeping a reference to the same array, so we are sure we have
# different arrays on the next iteration.
self._last_forward_weights = np.copy(weights)
return self._last_forward
[docs]
class BinaryObjectiveFunction(ObjectiveFunction):
"""An objective function for binary representation of the output. For instance, classes of
``-1`` and ``+1``."""
[docs]
def objective(self, weights: np.ndarray) -> float:
# predict is of shape (N, 1), where N is a number of samples
predict = self._neural_network_forward(weights)
target = np.array(self._y).reshape(predict.shape)
# float(...) is for mypy compliance
return float(np.sum(self._loss(predict, target)) / self._num_samples)
[docs]
def gradient(self, weights: np.ndarray) -> np.ndarray:
# check that we have supported output shape
num_outputs = self._neural_network.output_shape[0]
if num_outputs != 1:
raise ValueError(f"Number of outputs is expected to be 1, got {num_outputs}")
# output must be of shape (N, 1), where N is a number of samples
output = self._neural_network_forward(weights)
# weight grad is of shape (N, 1, num_weights)
_, weight_grad = self._neural_network.backward(self._X, weights)
# we reshape _y since the output has the shape (N, 1) and _y has (N,)
# loss_gradient is of shape (N, 1)
loss_gradient = self._loss.gradient(output, self._y.reshape(-1, 1))
# for the output we compute a dot product(matmul) of loss gradient for this output
# and weights for this output.
grad = loss_gradient[:, 0] @ weight_grad[:, 0, :]
# we keep the shape of (1, num_weights)
grad = grad.reshape(1, -1) / self._num_samples
return grad
[docs]
class MultiClassObjectiveFunction(ObjectiveFunction):
"""
An objective function for multiclass representation of the output. For instance, classes of
``0``, ``1``, ``2``, etc.
"""
[docs]
def objective(self, weights: np.ndarray) -> float:
# probabilities is of shape (N, num_outputs)
probs = self._neural_network_forward(weights)
num_outputs = self._neural_network.output_shape[0]
val = 0.0
num_samples = self._X.shape[0]
for i in range(num_outputs):
# for each output we compute a dot product of probabilities of this output and a loss
# vector.
# loss vector is a loss of a particular output value(value of i) versus true labels.
# we do this across all samples.
val += probs[:, i] @ self._loss(np.full(num_samples, i), self._y)
val = val / self._num_samples
return val
[docs]
def gradient(self, weights: np.ndarray) -> np.ndarray:
# weight probability gradient is of shape (N, num_outputs, num_weights)
_, weight_prob_grad = self._neural_network.backward(self._X, weights)
grad = np.zeros((1, self._neural_network.num_weights))
num_samples = self._X.shape[0]
num_outputs = self._neural_network.output_shape[0]
for i in range(num_outputs):
# similar to what is in the objective, but we compute a matrix multiplication of
# weight probability gradients and a loss vector.
grad += weight_prob_grad[:, i, :].T @ self._loss(np.full(num_samples, i), self._y)
grad = grad / self._num_samples
return grad
[docs]
class OneHotObjectiveFunction(ObjectiveFunction):
"""
An objective function for one hot encoding representation of the output. For instance, classes
like ``[1, 0, 0]``, ``[0, 1, 0]``, ``[0, 0, 1]``.
"""
[docs]
def objective(self, weights: np.ndarray) -> float:
# probabilities is of shape (N, num_outputs)
probs = self._neural_network_forward(weights)
# float(...) is for mypy compliance
value = float(np.sum(self._loss(probs, self._y)) / self._num_samples)
return value
[docs]
def gradient(self, weights: np.ndarray) -> np.ndarray:
# predict is of shape (N, num_outputs)
y_predict = self._neural_network_forward(weights)
# weight probability gradient is of shape (N, num_outputs, num_weights)
_, weight_prob_grad = self._neural_network.backward(self._X, weights)
grad = np.zeros(self._neural_network.num_weights)
num_outputs = self._neural_network.output_shape[0]
# loss gradient is of shape (N, num_output)
loss_gradient = self._loss.gradient(y_predict, self._y)
for i in range(num_outputs):
# a dot product(matmul) of loss gradient and weight probability gradient across all
# samples for an output.
grad += loss_gradient[:, i] @ weight_prob_grad[:, i, :]
grad = grad / self._num_samples
return grad