File size: 10,749 Bytes
b7d9967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# This code is part of a Qiskit project.
#
# (C) Copyright IBM 2022, 2023.
#
# 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.
"""Fidelity Quantum Kernel"""

from __future__ import annotations

from collections.abc import Sequence
from typing import List, Tuple

import numpy as np
from qiskit import QuantumCircuit
from qiskit.primitives import Sampler
from qiskit_algorithms.state_fidelities import BaseStateFidelity, ComputeUncompute

from .base_kernel import BaseKernel

KernelIndices = List[Tuple[int, int]]


class FidelityQuantumKernel(BaseKernel):
    r"""

    An implementation of the quantum kernel interface based on the

    :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` algorithm.



    Here, the kernel function is defined as the overlap of two quantum states defined by a

    parametrized quantum circuit (called feature map):



    .. math::



        K(x,y) = |\langle \phi(x) | \phi(y) \rangle|^2

    """

    def __init__(

        self,

        *,

        feature_map: QuantumCircuit | None = None,

        fidelity: BaseStateFidelity | None = None,

        enforce_psd: bool = True,

        evaluate_duplicates: str = "off_diagonal",

    ) -> None:
        """

        Args:

            feature_map: Parameterized circuit to be used as the feature map. If ``None`` is given,

                :class:`~qiskit.circuit.library.ZZFeatureMap` is used with two qubits. If there's

                a mismatch in the number of qubits of the feature map and the number of features

                in the dataset, then the kernel will try to adjust the feature map to reflect the

                number of features.

            fidelity: An instance of the

                :class:`~qiskit_algorithms.state_fidelities.BaseStateFidelity` primitive to be used

                to compute fidelity between states. Default is

                :class:`~qiskit_algorithms.state_fidelities.ComputeUncompute` which is created on

                top of the reference sampler defined by :class:`~qiskit.primitives.Sampler`.

            enforce_psd: Project to the closest positive semidefinite matrix if ``x = y``.

                Default ``True``.

            evaluate_duplicates: Defines a strategy how kernel matrix elements are evaluated if

               duplicate samples are found. Possible values are:



                    - ``all`` means that all kernel matrix elements are evaluated, even the diagonal

                      ones when training. This may introduce additional noise in the matrix.

                    - ``off_diagonal`` when training the matrix diagonal is set to `1`, the rest

                      elements are fully evaluated, e.g., for two identical samples in the

                      dataset. When inferring, all elements are evaluated. This is the default

                      value.

                    - ``none`` when training the diagonal is set to `1` and if two identical samples

                      are found in the dataset the corresponding matrix element is set to `1`.

                      When inferring, matrix elements for identical samples are set to `1`.

        Raises:

            ValueError: When unsupported value is passed to `evaluate_duplicates`.

        """
        super().__init__(feature_map=feature_map, enforce_psd=enforce_psd)

        eval_duplicates = evaluate_duplicates.lower()
        if eval_duplicates not in ("all", "off_diagonal", "none"):
            raise ValueError(
                f"Unsupported value passed as evaluate_duplicates: {evaluate_duplicates}"
            )
        self._evaluate_duplicates = eval_duplicates

        if fidelity is None:
            fidelity = ComputeUncompute(sampler=Sampler())
        self._fidelity = fidelity

    def evaluate(self, x_vec: np.ndarray, y_vec: np.ndarray | None = None) -> np.ndarray:
        x_vec, y_vec = self._validate_input(x_vec, y_vec)

        # determine if calculating self inner product
        is_symmetric = True
        if y_vec is None:
            y_vec = x_vec
        elif not np.array_equal(x_vec, y_vec):
            is_symmetric = False

        kernel_shape = (x_vec.shape[0], y_vec.shape[0])

        if is_symmetric:
            left_parameters, right_parameters, indices = self._get_symmetric_parameterization(x_vec)
            kernel_matrix = self._get_symmetric_kernel_matrix(
                kernel_shape, left_parameters, right_parameters, indices
            )
        else:
            left_parameters, right_parameters, indices = self._get_parameterization(x_vec, y_vec)
            kernel_matrix = self._get_kernel_matrix(
                kernel_shape, left_parameters, right_parameters, indices
            )

        if is_symmetric and self._enforce_psd:
            kernel_matrix = self._make_psd(kernel_matrix)

        return kernel_matrix

    def _get_parameterization(

        self, x_vec: np.ndarray, y_vec: np.ndarray

    ) -> tuple[np.ndarray, np.ndarray, KernelIndices]:
        """

        Combines x_vec and y_vec to get all the combinations needed to evaluate the kernel entries.

        """
        num_features = x_vec.shape[1]
        left_parameters = np.zeros((0, num_features))
        right_parameters = np.zeros((0, num_features))

        indices = []
        for i, x_i in enumerate(x_vec):
            for j, y_j in enumerate(y_vec):
                if self._is_trivial(i, j, x_i, y_j, False):
                    continue

                left_parameters = np.vstack((left_parameters, x_i))
                right_parameters = np.vstack((right_parameters, y_j))
                indices.append((i, j))

        return left_parameters, right_parameters, indices

    def _get_symmetric_parameterization(

        self, x_vec: np.ndarray

    ) -> tuple[np.ndarray, np.ndarray, KernelIndices]:
        """

        Combines two copies of x_vec to get all the combinations needed to evaluate the kernel entries.

        """
        num_features = x_vec.shape[1]
        left_parameters = np.zeros((0, num_features))
        right_parameters = np.zeros((0, num_features))

        indices = []
        for i, x_i in enumerate(x_vec):
            for j, x_j in enumerate(x_vec[i:]):
                if self._is_trivial(i, i + j, x_i, x_j, True):
                    continue

                left_parameters = np.vstack((left_parameters, x_i))
                right_parameters = np.vstack((right_parameters, x_j))
                indices.append((i, i + j))

        return left_parameters, right_parameters, indices

    def _get_kernel_matrix(

        self,

        kernel_shape: tuple[int, int],

        left_parameters: np.ndarray,

        right_parameters: np.ndarray,

        indices: KernelIndices,

    ) -> np.ndarray:
        """

        Given a parameterization, this computes the symmetric kernel matrix.

        """
        kernel_entries = self._get_kernel_entries(left_parameters, right_parameters)

        # fill in trivial entries and then update with fidelity values
        kernel_matrix = np.ones(kernel_shape)

        for i, (col, row) in enumerate(indices):
            kernel_matrix[col, row] = kernel_entries[i]

        return kernel_matrix

    def _get_symmetric_kernel_matrix(

        self,

        kernel_shape: tuple[int, int],

        left_parameters: np.ndarray,

        right_parameters: np.ndarray,

        indices: KernelIndices,

    ) -> np.ndarray:
        """

        Given a set of parameterization, this computes the kernel matrix.

        """
        kernel_entries = self._get_kernel_entries(left_parameters, right_parameters)
        kernel_matrix = np.ones(kernel_shape)

        for i, (col, row) in enumerate(indices):
            kernel_matrix[col, row] = kernel_entries[i]
            kernel_matrix[row, col] = kernel_entries[i]

        return kernel_matrix

    def _get_kernel_entries(

        self, left_parameters: np.ndarray, right_parameters: np.ndarray

    ) -> Sequence[float]:
        """

        Gets kernel entries by executing the underlying fidelity instance and getting the results

        back from the async job.

        """
        num_circuits = left_parameters.shape[0]
        if num_circuits != 0:
            job = self._fidelity.run(
                [self._feature_map] * num_circuits,
                [self._feature_map] * num_circuits,
                left_parameters,
                right_parameters,
            )
            kernel_entries = job.result().fidelities
        else:
            # trivial case, only identical samples
            kernel_entries = []
        return kernel_entries

    def _is_trivial(

        self, i: int, j: int, x_i: np.ndarray, y_j: np.ndarray, symmetric: bool

    ) -> bool:
        """

        Verifies if the kernel entry is trivial (to be set to `1.0`) or not.



        Args:

            i: row index of the entry in the kernel matrix.

            j: column index of the entry in the kernel matrix.

            x_i: a sample from the dataset that corresponds to the row in the kernel matrix.

            y_j: a sample from the dataset that corresponds to the column in the kernel matrix.

            symmetric: whether it is a symmetric case or not.



        Returns:

            `True` if the entry is trivial, `False` otherwise.

        """
        # if we evaluate all combinations, then it is non-trivial
        if self._evaluate_duplicates == "all":
            return False

        # if we are on the diagonal and we don't evaluate it, it is trivial
        if symmetric and i == j and self._evaluate_duplicates == "off_diagonal":
            return True

        # if don't evaluate any duplicates
        if np.array_equal(x_i, y_j) and self._evaluate_duplicates == "none":
            return True

        # otherwise evaluate
        return False

    @property
    def fidelity(self):
        """Returns the fidelity primitive used by this kernel."""
        return self._fidelity

    @property
    def evaluate_duplicates(self):
        """Returns the strategy used by this kernel to evaluate kernel matrix elements if duplicate

        samples are found."""
        return self._evaluate_duplicates