Aller au contenu principal

Noyaux quantiques

Introduction aux noyaux quantiques

La « méthode à noyau quantique » désigne toute méthode qui utilise des ordinateurs quantiques pour estimer un noyau. Dans ce contexte, « noyau » désignera la matrice noyau ou ses entrées individuelles. Rappelle-toi qu'un plongement de caractéristiques Φ(x)\Phi(\vec{x}) est une application de xRd\vec{x}\in \mathbb{R}^d vers Φ(x)Rd,\Phi(\vec{x})\in \mathbb{R}^{d'}, où généralement d>dd'>d, et dont le but est de rendre les catégories de données séparables par un hyperplan. La fonction noyau prend comme arguments des vecteurs dans l'espace des caractéristiques et renvoie leur produit scalaire, c'est-à-dire K:Rd×RdRK:\mathbb{R}^d\times\mathbb{R}^d\rightarrow \mathbb{R} avec K(x,y)=Φ(x)Φ(y)K(x,y) = \langle \Phi(x)|\Phi(y)\rangle. Du point de vue classique, on s'intéresse aux plongements de caractéristiques pour lesquels la fonction noyau est facile à évaluer. Cela revient souvent à trouver une fonction noyau pour laquelle le produit scalaire dans l'espace des caractéristiques peut s'exprimer en termes des vecteurs de données d'origine, sans avoir à construire explicitement Φ(x)\Phi(x) et Φ(y)\Phi(y). Dans la méthode des noyaux quantiques, le plongement de caractéristiques est réalisé par un circuit quantique, et le noyau est estimé à partir des mesures de ce circuit et des probabilités de mesure relatives.

Dans cette leçon, nous allons examiner la profondeur des circuits d'encodage prédéfinis qui utilisent un fort enchevêtrement et les comparer à la profondeur de circuits que nous codons nous-mêmes. Il ne s'agit pas ici de privilégier une méthode par rapport à une autre. Tu pourrais trouver que les circuits prédéfinis sont trop profonds, et que l'enchevêtrement du circuit personnalisé est insuffisant pour être utile. Encore une fois, ces exemples sont présentés uniquement pour te permettre d'explorer.

Avant de parcourir en détail l'estimation d'une matrice noyau, décrivons le flux de travail en utilisant le langage des patrons Qiskit.

Étape 1 : Mapper les entrées classiques vers un problème quantique

  • Entrée : jeu de données d'entraînement
  • Sortie : circuit abstrait pour calculer un élément de la matrice noyau

À partir du jeu de données, le point de départ consiste à encoder les données dans un circuit quantique. Autrement dit, il faut mapper nos données dans l'espace de Hilbert des états de notre ordinateur quantique. Pour cela, on construit un circuit dépendant des données. Il existe de nombreuses façons de procéder, et la leçon précédente en a présenté plusieurs. Tu peux construire ton propre circuit pour encoder tes données, ou utiliser un plongement de caractéristiques prédéfini comme zz_feature_map. Dans cette leçon, nous ferons les deux.

Note que pour calculer un seul élément de la matrice noyau, on voudra encoder deux points différents afin de pouvoir estimer leur produit scalaire. Un flux de travail complet de noyau quantique impliquera bien sûr de nombreux produits scalaires entre des vecteurs de données mappés, ainsi que des méthodes d'apprentissage automatique classiques. Mais l'étape de base répétée est l'estimation d'un seul élément de la matrice noyau. Pour cela, on sélectionne un circuit quantique dépendant des données et on mappe deux vecteurs de données dans l'espace des caractéristiques.

Classical_Review_background_kernel_circuit

Pour la tâche de génération d'une matrice noyau, on s'intéresse particulièrement à la probabilité de mesurer l'état 0N|0\rangle^{\otimes N}, dans lequel tous les NN qubits sont dans l'état 0|0\rangle. Pour comprendre pourquoi, considère que le circuit responsable de l'encodage et du mappage d'un vecteur de données xi\vec{x}_i peut s'écrire Φ(xi)\Phi(\vec{x}_i), et celui responsable de xj\vec{x}_j est Φ(xj)\Phi(\vec{x}_j), et notons les états mappés

ψ(xi)=Φ(xi)0N|\psi(\vec{x}_i)\rangle = \Phi(\vec{x}_i)|0\rangle^{\otimes N} ψ(xj)=Φ(xj)0N.|\psi(\vec{x}_j)\rangle = \Phi(\vec{x}_j)|0\rangle^{\otimes N}.

Ces états sont le mappage des données vers des dimensions supérieures, donc l'entrée de noyau souhaitée est le produit scalaire

ψ(xj)ψ(xi)=0NΦ(xj)Φ(xi)0N.\langle\psi(\vec{x}_j)|\psi(\vec{x}_i)\rangle = \langle 0 |^{\otimes N}\Phi^\dagger(\vec{x}_j)\Phi(\vec{x}_i)|0\rangle^{\otimes N}.

Si on applique au l'état initial par défaut 0N|0\rangle^{\otimes N} les deux circuits Φ(xj)\Phi^\dagger(\vec{x}_j) et Φ(xi)\Phi(\vec{x}_i), la probabilité de mesurer ensuite l'état 0N|0\rangle^{\otimes N} est

P0=0NΦ(xj)Φ(xi)0N2.P_0 = |\langle0|^{\otimes N}\Phi^\dagger(\vec{x}_j)\Phi(\vec{x}_i)|0\rangle^{\otimes N}|^2.

C'est exactement la valeur que l'on cherche (à 2||^2 près). La couche de mesure de notre circuit renverra des probabilités de mesure (ou des « quasi-probabilités », si certaines méthodes de mitigation d'erreurs sont utilisées). La probabilité qui nous intéresse est celle de l'état zéro, 0N|0\rangle^{\otimes N}.

Étape 2 : Optimiser le problème pour l'exécution quantique

  • Entrée : circuit abstrait, non optimisé pour un backend particulier
  • Sortie : circuit cible et observable, optimisés pour le QPU sélectionné

Dans cette étape, on utilisera la fonction generate_preset_pass_manager de Qiskit pour spécifier une routine d'optimisation de notre circuit par rapport à l'ordinateur quantique réel sur lequel on prévoit de mener l'expérience. On définit optimization_level=3, ce qui signifie qu'on utilisera le gestionnaire de passes prédéfini offrant le niveau d'optimisation le plus élevé. Dans ce contexte, « optimisation » désigne l'optimisation de l'implémentation du circuit sur un vrai ordinateur quantique. Cela inclut des considérations telles que le choix des qubits physiques correspondant aux qubits du circuit quantique abstrait afin de minimiser la profondeur de portes, ou la sélection des qubits physiques avec les taux d'erreur disponibles les plus bas. Ce n'est pas directement lié à l'optimisation du problème d'apprentissage automatique (comme avec les optimiseurs classiques tels que COBYLA).

Selon la façon dont tu implémentes l'étape 2, tu devras peut-être optimiser le circuit plusieurs fois, car chaque paire de points impliquée dans un élément de matrice produit un circuit différent à mesurer.

Étape 3 : Exécuter avec les primitives Qiskit Runtime

  • Entrée : circuit cible
  • Sortie : distribution de probabilité

Utilise la primitive Sampler de Qiskit Runtime pour reconstruire une distribution de probabilité des états produits par l'échantillonnage du circuit. Note que tu pourrais voir cela qualifié de « distribution de quasi-probabilité », un terme applicable lorsque le bruit est un problème et que des étapes supplémentaires sont introduites, comme dans la mitigation d'erreurs. Dans ces cas, la somme de toutes les probabilités peut ne pas être exactement égale à 1 ; d'où « quasi-probabilité ».

Étape 4 : Post-traiter, renvoyer le résultat au format classique

  • Entrée : distribution de probabilité
  • Sortie : un seul élément de la matrice noyau, ou une matrice noyau complète si l'opération est répétée

Calcule la probabilité de mesurer 0N|0\rangle^{\otimes N} sur le circuit quantique et remplis la matrice noyau à la position correspondant aux deux vecteurs de données utilisés. Pour remplir toute la matrice noyau, il faut lancer une expérience quantique pour chaque entrée. Une fois que tu as une matrice noyau, tu peux l'utiliser dans de nombreux algorithmes d'apprentissage automatique classiques qui acceptent des noyaux pré-calculés. Par exemple : qml_svc = SVC(kernel="precomputed"). On peut ensuite utiliser des flux de travail classiques pour appliquer notre modèle aux données de test et obtenir un score de précision. Selon notre satisfaction vis-à-vis de ce score, il peut être nécessaire de revoir certains aspects du calcul, comme notre plongement de caractéristiques.

Plan de la leçon

Dans cette leçon, nous allons réaliser ces étapes de plusieurs façons afin d'optimiser l'utilisation de ton temps sur les vrais ordinateurs quantiques. Nous appliquerons une méthode à noyau quantique à :

  • Un seul élément de la matrice noyau pour des données avec relativement peu de caractéristiques, en utilisant un vrai backend, afin de pouvoir suivre facilement ce qui se passe à chaque étape.
  • Un jeu de données complet avec relativement peu de caractéristiques, en utilisant un backend simulé, afin de voir comment le flux de travail quantique s'articule avec les méthodes d'apprentissage automatique classiques.
  • Un seul élément de la matrice noyau pour des données avec de nombreuses caractéristiques, en utilisant un vrai ordinateur quantique. Nous n'estimerons pas une matrice noyau complète pour un grand jeu de données, afin de respecter le temps disponible sur les ordinateurs quantiques IBM®.
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy pandas qiskit qiskit-ibm-runtime scikit-learn
# If you have not already, install scikit learn
#!pip install scikit-learn

Élément unique de la matrice noyau

Étape 1 : Mapper les entrées classiques vers un problème quantique

Commençons par considérer un jeu de données avec seulement quelques caractéristiques, disons 10. Le jeu de données peut être aussi grand que tu le souhaites, puisqu'on calcule les éléments de la matrice noyau un par un. Il nous faut au moins deux points, donc on commencera avec ça (dans l'exemple suivant, on importera un jeu de données complet). Importons quelques paquets nécessaires :

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Two mock data points, including category labels, as in training
small_data = [
[-0.194, 0.114, -0.006, 0.301, -0.359, -0.088, -0.156, 0.342, -0.016, 0.143, 1],
[-0.1, 0.002, 0.244, 0.127, -0.064, -0.086, 0.072, 0.043, -0.053, 0.02, -1],
]

# Data points with labels removed, for inner product
train_data = [small_data[0][:-1], small_data[1][:-1]]

On peut essayer d'utiliser z_feature_map.

# from qiskit.circuit.library import zz_feature_map
# fm = zz_feature_map(feature_dimension=np.shape(train_data)[1], entanglement='linear', reps=1)

from qiskit.circuit.library import z_feature_map

fm = z_feature_map(feature_dimension=np.shape(train_data)[1])

unitary1 = fm.assign_parameters(train_data[0])
unitary2 = fm.assign_parameters(train_data[1])

Les deux unitaires ci-dessus correspondent exactement à U1U_1 et U2U_2 décrits dans l'introduction. On peut les combiner à l'aide de unitary_overlap. Comme toujours, on garde un œil sur la profondeur du circuit.

from qiskit.circuit.library import unitary_overlap

overlap_circ = unitary_overlap(unitary1, unitary2)
overlap_circ.measure_all()

print("circuit depth = ", overlap_circ.decompose().depth())
overlap_circ.decompose().draw("mpl", scale=0.6, style="iqp")
circuit depth =  9

Output of the previous code cell

Étape 2 : Optimiser le problème pour l'exécution quantique

On commence par sélectionner le backend le moins occupé, puis on optimise notre circuit pour l'exécuter sur ce backend.

# Import needed packages
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService

# Get the least busy backend
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=fm.num_qubits
)
print(backend)
<IBMBackend('ibm_brisbane')>
# Apply level 3 optimization to our overlap circuit
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
overlap_ibm = pm.run(overlap_circ)

Pour les circuits complexes, cette étape augmentera considérablement la profondeur du circuit lors du mappage vers les portes natives des vrais ordinateurs quantiques, car les informations peuvent devoir être déplacées de qubit en qubit. Dans ce cas simple, la profondeur est à peine affectée.

print("circuit depth = ", overlap_ibm.decompose().depth())
overlap_ibm.decompose().depth(lambda instr: len(instr.qubits) > 1)
circuit depth =  10
1

Étape 3 : Exécuter avec les primitives Qiskit Runtime

La syntaxe pour l'exécution sur un simulateur est commentée ci-dessous. Pour ce jeu de données, avec un petit nombre de caractéristiques, l'exécution sur un simulateur reste une option. Pour les calculs à l'échelle utilitaire, la simulation n'est généralement pas faisable. Les simulateurs ne devraient être utilisés que pour déboguer du code à échelle réduite.

# Run this for a simulator
# from qiskit.primitives import StatevectorSampler

# from qiskit_ibm_runtime import Options, Session, Sampler

# num_shots = 10000

# Evaluate the problem using state vector-based primitives from Qiskit
# sampler = StatevectorSampler()
# results = sampler.run([overlap_circ], shots=num_shots).result()
# .get_counts() returns counts associated with a state labeled by bit results such as |001101...01>.
# counts_bit = results[0].data.meas.get_counts()
# .get_int_counts returns the same counts, but labeled by integer equivalent of the above bit string.
# counts = results[0].data.meas.get_int_counts()
# Benchmarked on an Eagle processor, 7-11-24, took 4 sec.

# Import our runtime primitive
from qiskit_ibm_runtime import Session, SamplerV2 as Sampler

num_shots = 10000

# Use sampler and get the counts

sampler = Sampler(mode=backend)
results = sampler.run([overlap_ibm], shots=num_shots).result()
# .get_counts() returns counts associated with a state labeled by bit results such as |001101...01>.
counts_bit = results[0].data.meas.get_counts()
# .get_int_counts returns the same counts, but labeled by integer equivalent of the above bit string.
counts = results[0].data.meas.get_int_counts()

Étape 4 : Post-traiter, renvoyer le résultat au format classique

Comme décrit dans l'introduction, la mesure la plus utile ici est la probabilité de mesurer l'état zéro 00000|00000\rangle.

counts.get(0, 0.0) / num_shots
0.6525

C'est le résultat que l'on voulait : une estimation du produit scalaire (au module au carré près) des vecteurs correspondant à deux points de données. Si on veut regarder la distribution complète des probabilités de mesure (ou quasi-probabilités), on peut le faire en utilisant la fonction plot_distribution comme montré ci-dessous. On constate que pour un grand nombre de qubits, de telles visualisations deviennent rapidement inexploitables.

from qiskit.visualization import plot_distribution

plot_distribution(counts_bit)

Output of the previous code cell

On peut aussi définir une visualisation comme celle ci-dessous pour ne regarder que les 10 mesures les plus probables. Cela peut être utile pour le débogage ou pour mieux comprendre les données. Mais la probabilité de mesure de l'état zéro reste notre élément de matrice noyau.

def visualize_counts(probs, num_qubits):
"""Visualize the outputs from the Qiskit Sampler primitive."""
zero_prob = probs.get(0, 0.0)
top_10 = dict(sorted(probs.items(), key=lambda item: item[1], reverse=True)[:10])
top_10.update({0: zero_prob})
by_key = dict(sorted(top_10.items(), key=lambda item: item[0]))
xvals, yvals = list(zip(*by_key.items()))
xvals = [bin(xval)[2:].zfill(num_qubits) for xval in xvals]
plt.bar(xvals, yvals)
plt.xticks(rotation=75)
plt.title("Results of sampling")
plt.xlabel("Measured bitstring")
plt.ylabel("Counts")
plt.show()

visualize_counts(counts, overlap_circ.num_qubits)

Output of the previous code cell

À partir de cette information sur un seul produit scalaire entre deux points de données dans l'espace des caractéristiques de plus haute dimension, tout ce qu'on peut dire, c'est que leur chevauchement est assez grand comparé au chevauchement maximal (qui serait 1,0). Cela pourrait indiquer que ces deux points de données sont d'une certaine façon similaires et seront classifiés dans la même catégorie. Ou cela pourrait indiquer que notre plongement de caractéristiques n'est pas efficace pour mapper vers un espace où les données similaires ont un fort chevauchement et les données dissemblables un faible chevauchement. Pour savoir lequel est vrai, il faut appliquer notre plongement de caractéristiques à l'ensemble des données et voir si la matrice noyau résultante peut être utilisée pour séparer efficacement les classes avec une grande précision.

Il vaut la peine de noter qu'on a utilisé z_feature_map, ce qui a donné une faible profondeur transpilée à deux qubits (profondeur 1, en fait). Si tes circuits deviennent trop profonds, cela entraînera inévitablement beaucoup de bruit, et la probabilité de mesurer l'état zéro sera très faible, même si ton plongement de caractéristiques est bien adapté à tes données. Par exemple, une répétition du processus ci-dessus avec zz_feature_map et , entanglement='linear', reps=1 a donné dist.get(0,0.0) = 0.0015 pour les mêmes points de données. Cela est dû aux profondeurs de circuit et de profondeur à deux qubits bien plus grandes de zz_feature_map. La figure ci-dessous montre la distribution de probabilité pour ce calcul.

Bad results from a zz feature map.

Il vaut la peine de jouer avec quelques points de données de la même catégorie pour voir à quel point la profondeur doit être faible pour obtenir de bons résultats. Ce qui suit est un conseil approximatif qui aura certainement des exceptions. En général, une profondeur transpilée à deux qubits de 10 ou moins ne devrait poser aucun problème. Une profondeur transpilée à deux qubits de 50 à 60 est à l'état de l'art et nécessitera une mitigation d'erreurs avancée ainsi que d'autres outils. Entre les deux, les résultats varieront selon la similarité des données, l'expressivité du plongement de caractéristiques, la largeur du circuit et d'autres facteurs. En général, l'étape de post-traitement inclurait également des processus d'apprentissage automatique classiques. Dans la section suivante, nous allons étendre ce processus à un jeu de données complet et présenter le flux de travail d'apprentissage automatique classique.

Vérifie ta compréhension

Lis les questions ci-dessous, réfléchis à tes réponses, puis clique sur les triangles pour révéler les solutions.

Dans un circuit quantique à 10 qubits, combien d'états différents peuvent en général être mesurés ?

Réponse :

2102^{10} soit 1024.

Supposons qu'une personne débutant en informatique quantique tente d'utiliser un circuit quantique avec une très grande profondeur à deux qubits, sans utiliser de mitigation d'erreurs. Supposons de plus que cela entraîne un taux d'erreur de 10 % sur chaque qubit. Si le vrai élément de la matrice noyau (sans erreur) correspondant à ce circuit est très grand, disons 1,0, quelle serait la probabilité de mesurer les 10 qubits dans l'état où chaque qubit est |0> ?

Réponse :

La probabilité que chaque qubit soit correctement trouvé dans l'état |0> est 0,90. La probabilité que les 10 qubits se trouvent tous dans le bon état est 0.90100.90^{10}, soit environ 35 %.

Explique dans tes propres mots pourquoi il est si important de surveiller la profondeur des circuits. C'est vrai en général, mais explique-le dans le contexte de l'estimation de noyaux quantiques.

Réponse :

Dans ce flux de travail d'estimation de noyau quantique (QKE), nos estimations sont basées sur les mesures de l'état zéro, c'est-à-dire l'état dans lequel chaque qubit est trouvé dans l'état 0|0\rangle. Des circuits très profonds introduiront des taux d'erreur élevés. Lorsque ce taux d'erreur se cumule sur de nombreux qubits, cela réduira considérablement la probabilité de mesurer l'état zéro.

Matrice noyau complète

Dans cette section, nous allons étendre le processus ci-dessus à la classification binaire d'un jeu de données complet. Cela introduira deux composantes importantes : (1) on peut maintenant implémenter l'apprentissage automatique classique en post-traitement, et (2) on peut obtenir des scores de précision pour notre entraînement.

Étape 1 : Mapper les entrées classiques vers un problème quantique

Maintenant, on va importer un jeu de données existant pour notre classification. Ce jeu de données se compose de 128 lignes (points de données) et de 14 caractéristiques par point. Il y a un 15e élément qui indique la catégorie binaire de chaque point (±1\pm 1). Le jeu de données est importé ci-dessous, ou tu peux y accéder et voir sa structure ici.

Nous utiliserons les 90 premiers points de données pour l'entraînement, et les 30 suivants pour le test.

!wget https://raw.githubusercontent.com/qiskit-community/prototype-quantum-kernel-training/main/data/dataset_graph7.csv

df = pd.read_csv("dataset_graph7.csv", sep=",", header=None)

# Prepare training data

train_size = 90
X_train = df.values[0:train_size, :-1]
train_labels = df.values[0:train_size, -1]

# Prepare testing data
test_size = 30
X_test = df.values[train_size : train_size + test_size, :-1]
test_labels = df.values[train_size : train_size + test_size, -1]
--2024-07-11 23:05:22--  https://raw.githubusercontent.com/qiskit-community/prototype-quantum-kernel-training/main/data/dataset_graph7.csv
Resolving raw.githubusercontent.com (raw.githubusercontent.com)... 185.199.110.133, 185.199.111.133, 185.199.109.133, ...
Connecting to raw.githubusercontent.com (raw.githubusercontent.com)|185.199.110.133|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 49405 (48K) [text/plain]
Saving to: ‘dataset_graph7.csv.15’

dataset_graph7.csv. 100%[===================>] 48.25K --.-KB/s in 0.02s

2024-07-11 23:05:23 (2.11 MB/s) - ‘dataset_graph7.csv.15’ saved [49405/49405]

Nous allons déjà nous préparer à stocker plusieurs sorties en construisant une matrice noyau et une matrice de test aux dimensions appropriées.

# Empty kernel matrix
num_samples = np.shape(X_train)[0]
kernel_matrix = np.full((num_samples, num_samples), np.nan)
test_matrix = np.full((test_size, num_samples), np.nan)

Maintenant, on crée un plongement de caractéristiques pour encoder et mapper nos données classiques dans un circuit quantique. On est libre de construire notre propre plongement ou d'en utiliser un préfabriqué. N'hésite pas à modifier le plongement ci-dessous ou à revenir à ZFeatureMap. Mais prête toujours attention à la profondeur du circuit. Rappelle-toi que dans l'exemple précédent à 6 qubits, la profondeur transpilée du circuit était intraitable lors de l'utilisation de zz_feature_map. À mesure que l'échelle et la complexité du circuit augmentent, la profondeur peut rapidement atteindre un niveau où le bruit submerge nos résultats. Chaque fois que tu connais quelque chose sur la structure de tes données qui pourrait informer la structure du plongement de caractéristiques la plus utile, il est conseillé de créer ton propre plongement personnalisé qui exploite cette connaissance.

from qiskit.circuit import Parameter, ParameterVector, QuantumCircuit

# Prepare feature map for computing overlap
num_features = np.shape(X_train)[1]
num_qubits = int(num_features / 2)

# To use a custom feature map use the lines below.
entangler_map = [[0, 2], [3, 4], [2, 5], [1, 4], [2, 3], [4, 6]]

fm = QuantumCircuit(num_qubits)
training_param = Parameter("θ")
feature_params = ParameterVector("x", num_qubits * 2)
fm.ry(training_param, fm.qubits)
for cz in entangler_map:
fm.cz(cz[0], cz[1])
for i in range(num_qubits):
fm.rz(-2 * feature_params[2 * i + 1], i)
fm.rx(-2 * feature_params[2 * i], i)

Étapes 2 et 3 : Optimiser le problème et exécuter avec les primitives

Nous allons construire un circuit de chevauchement, et si on exécutait sur un vrai ordinateur quantique dans cet exemple, on l'optimiserait pour l'exécution comme avant. Mais dans ce cas, on prévoit de parcourir tous les points de données et de calculer la matrice noyau complète. Pour chaque paire de vecteurs de données xi\vec{x}_i et xj\vec{x}_j, on crée un circuit de chevauchement différent. Ainsi, les étapes 2 et 3 seraient réalisées ensemble dans les multiples itérations.

La cellule de code ci-dessous effectue exactement le même processus que précédemment pour une seule paire de points de données. Cette fois, elle est simplement exécutée à l'intérieur de deux boucles for, et il y a la ligne supplémentaire à la fin kernel_matrix[x_1,x_2] = ... pour stocker les résultats de chaque calcul. Note qu'on a exploité la symétrie de la matrice noyau pour réduire le nombre de calculs de moitié. On a également simplement fixé les éléments diagonaux à 1, comme ils devraient l'être en l'absence de bruit. Selon ton implémentation et la précision requise, tu pourrais également utiliser les éléments diagonaux pour estimer le bruit ou en apprendre davantage pour la mitigation d'erreurs.

Une fois la matrice noyau entièrement remplie, on répète le processus pour les données de test et on remplit la test_matrix. C'est aussi une matrice noyau ; on lui donne simplement un nom différent pour les distinguer.

# To use a simulator
from qiskit.primitives import StatevectorSampler

# Remember to insert your token in the QiskitRuntimeService constructor to use real quantum computers
# service = QiskitRuntimeService()
# backend = service.least_busy(
# operational=True, simulator=False, min_num_qubits=fm.num_qubits
# )

num_shots = 10000

# Evaluate the problem using state vector-based primitives from Qiskit.
sampler = StatevectorSampler()

for x1 in range(0, train_size):
for x2 in range(x1 + 1, train_size):
unitary1 = fm.assign_parameters(list(X_train[x1]) + [np.pi / 2])
unitary2 = fm.assign_parameters(list(X_train[x2]) + [np.pi / 2])

# Create the overlap circuit
overlap_circ = unitary_overlap(unitary1, unitary2)
overlap_circ.measure_all()

# These lines run the qiskit sampler primitive.
counts = (
sampler.run([overlap_circ], shots=num_shots)
.result()[0]
.data.meas.get_int_counts()
)

# Assign the probability of the 0 state to the kernel matrix, and the transposed element (since this is an inner product)
kernel_matrix[x1, x2] = counts.get(0, 0.0) / num_shots
kernel_matrix[x2, x1] = counts.get(0, 0.0) / num_shots
# Fill in on-diagonal elements with 1, again, since this is an inner-product corresponding to probability (or alter the code to check these entries and verify they yield 1)
kernel_matrix[x1, x1] = 1

print("training done")

# Similar process to above, but for testing data.
for x1 in range(0, test_size):
for x2 in range(0, train_size):
unitary1 = fm.assign_parameters(list(X_test[x1]) + [np.pi / 2])
unitary2 = fm.assign_parameters(list(X_train[x2]) + [np.pi / 2])

# Create the overlap circuit
overlap_circ = unitary_overlap(unitary1, unitary2)
overlap_circ.measure_all()

counts = (
sampler.run([overlap_circ], shots=num_shots)
.result()[0]
.data.meas.get_int_counts()
)

test_matrix[x1, x2] = counts.get(0, 0.0) / num_shots

print("test matrix done")
training done
test matrix done

Étape 4 : Post-traiter, renvoyer le résultat au format classique

Maintenant qu'on dispose d'une matrice noyau et d'une test_matrix de format similaire issues des méthodes à noyau quantique, on peut appliquer des algorithmes d'apprentissage automatique classiques pour faire des prédictions sur nos données de test et vérifier leur précision. On commencera par importer sklearn.svc de Scikit-Learn, un classificateur à vecteurs de support (SVC). On doit spécifier qu'on veut que le SVC utilise notre noyau précalculé avec kernel = precomputed.

# import a support vector classifier from a classical ML package.
from sklearn.svm import SVC

# Specify that you want to use a pre-computed kernel matrix
qml_svc = SVC(kernel="precomputed")

En utilisant SVC.fit, on peut maintenant transmettre la matrice noyau et les étiquettes d'entraînement pour obtenir un ajustement. SVC.score notera ensuite nos données de test par rapport à cet ajustement en utilisant notre test_matrix, et renverra notre précision.

# Feed in the pre-computed matrix and the labels of the training data. The classical algorithm gives you a fit.
qml_svc.fit(kernel_matrix, train_labels)

# Now use the .score to test your data, using the matrix of test data, and test labels as your inputs.
qml_score_precomputed_kernel = qml_svc.score(test_matrix, test_labels)
print(f"Precomputed kernel classification test score: {qml_score_precomputed_kernel}")
Precomputed kernel classification test score: 1.0

On voit que la précision de notre modèle entraîné était de 100 %. C'est excellent, et cela montre que le QKE peut fonctionner. Mais c'est très différent d'un avantage quantique. Les noyaux classiques auraient probablement aussi pu résoudre ce problème de classification avec 100 % de précision. Il reste beaucoup de travail à faire pour caractériser les différents types de données et leurs relations afin de déterminer où les noyaux quantiques seront les plus utiles à l'ère utilitaire actuelle. Nous laissons à l'apprenant le soin de modifier certaines parties de ce flux de travail et d'étudier l'efficacité de divers plongements de caractéristiques quantiques. Voici quelques points à considérer :

  • À quel point la précision est-elle robuste ? Se maintient-elle pour de nombreux types de données ou seulement pour ces données d'entraînement spécifiques ?
  • Quelle structure dans tes données te fait penser qu'un plongement de caractéristiques quantique est utile ?
  • Comment la précision est-elle affectée en augmentant/diminuant la quantité de données d'entraînement ?
  • Quels plongements de caractéristiques peux-tu utiliser et comment les résultats varient-ils avec les plongements ?
  • Comment la précision et le temps d'exécution sont-ils affectés en augmentant le nombre de caractéristiques ?
  • Quelles tendances, le cas échéant, t'attends-tu à voir se confirmer sur de vrais ordinateurs quantiques ?

Mise à l'échelle vers plus de caractéristiques et de qubits

Dans cette section, nous allons répéter le calcul d'un seul élément de matrice, mais pour un nombre de caractéristiques bien plus grand, esquissant la voie vers l'utilité à grande échelle. La restriction à un seul élément de matrice est faite pour que le processus puisse être montré sans consommer trop de ton temps alloué sur les ordinateurs quantiques.

Étape 1 : Mapper les entrées classiques vers un problème quantique

On supposera comme point de départ un jeu de données dans lequel chaque point de données a 42 caractéristiques. Comme dans le premier exemple, on calculera un seul élément de la matrice noyau, ce qui nécessite deux points de données. Les deux points ci-dessous ont 42 caractéristiques et une seule variable de catégorie (±1\pm 1).

# Two mock data points, including category labels, as in training

large_data = [
[
-0.028,
-1.49,
-1.698,
0.107,
-1.536,
-1.538,
-1.356,
-1.514,
-0.109,
-1.8,
-0.122,
-1.651,
-1.955,
-0.123,
-1.732,
0.091,
-0.048,
-0.128,
-0.026,
0.082,
-1.263,
0.065,
0.004,
-0.055,
-0.08,
-0.173,
-1.734,
-0.39,
-1.451,
0.078,
-1.578,
-0.025,
-0.184,
-0.119,
-1.336,
0.055,
-0.204,
-1.578,
0.132,
-0.121,
-1.599,
-0.187,
-1,
],
[
-1.414,
-1.439,
-1.606,
0.246,
-1.673,
0.002,
-1.317,
-1.262,
-0.178,
-1.814,
0.013,
-1.619,
-1.86,
-0.25,
-0.212,
-0.214,
-0.033,
0.071,
-0.11,
-1.607,
0.441,
-0.143,
-0.009,
-1.655,
-1.579,
0.381,
-1.86,
-0.079,
-0.088,
-0.058,
-1.481,
-0.064,
-0.065,
-1.507,
0.177,
-0.131,
-0.153,
0.07,
-1.627,
0.593,
-1.547,
-0.16,
-1,
],
]
train_data = [large_data[0][:-1], large_data[1][:-1]]

Rappelle-toi que zz_feature_map a produit des circuits assez profonds dans le cas d'un nombre relativement faible de caractéristiques (14 caractéristiques). À mesure qu'on augmente le nombre de caractéristiques, on doit surveiller de près la profondeur du circuit. Pour illustrer cela, on va d'abord essayer d'utiliser zz_feature_map et vérifier la profondeur du circuit résultant.

from qiskit.circuit.library import zz_feature_map

fm = zz_feature_map(
feature_dimension=np.shape(train_data)[1], entanglement="linear", reps=1
)

unitary1 = fm.assign_parameters(train_data[0])
unitary2 = fm.assign_parameters(train_data[1])
from qiskit.circuit.library import unitary_overlap

overlap_circ = unitary_overlap(unitary1, unitary2)
overlap_circ.measure_all()

print("circuit depth = ", overlap_circ.decompose(reps=2).depth())
print(
"two-qubit depth",
overlap_circ.decompose().depth(lambda instr: len(instr.qubits) > 1),
)
# overlap_circ.draw("mpl", scale=0.6, style="iqp")
circuit depth =  251
two-qubit depth 165

Comme décrit précédemment, déterminer exactement à partir de quelle profondeur c'est trop profond est une question nuancée. Mais une profondeur à deux qubits supérieure à 100, même avant la transpilation, est rédhibitoire. C'est pourquoi les plongements de caractéristiques personnalisés ont été mis en avant tout au long de cette leçon. Si tu connais quelque chose sur la structure de ton jeu de données complet, tu devrais concevoir une carte d'enchevêtrement en tenant compte de cette structure. Ici, puisqu'on ne calcule que le produit scalaire entre deux tels points de données, on a privilégié une faible profondeur de circuit par rapport à toute considération détaillée de la structure des données.

from qiskit.circuit import Parameter, ParameterVector, QuantumCircuit

# Prepare feature map for computing overlap

entangler_map = [
[3, 4],
[2, 5],
[1, 4],
[2, 3],
[4, 6],
[7, 9],
[10, 11],
[9, 12],
[8, 11],
[9, 10],
[11, 13],
[14, 16],
[17, 18],
[16, 19],
[15, 18],
[16, 17],
[18, 20],
]
# Use the entangler map above to build a feature map

num_features = np.shape(train_data)[1]
num_qubits = int(num_features / 2)

fm = QuantumCircuit(num_qubits)
training_param = Parameter("θ")
feature_params = ParameterVector("x", num_qubits * 2)
fm.ry(training_param, fm.qubits)
for cz in entangler_map:
fm.cz(cz[0], cz[1])
for i in range(num_qubits):
fm.rz(-2 * feature_params[2 * i + 1], i)
fm.rx(-2 * feature_params[2 * i], i)
from qiskit.circuit.library import unitary_overlap

# Assign features of each data point to a unitary, an instance of the general feature map.

unitary1 = fm.assign_parameters(list(train_data[0]) + [np.pi / 2])
unitary2 = fm.assign_parameters(list(train_data[1]) + [np.pi / 2])

# Create the overlap circuit

overlap_circ = unitary_overlap(unitary1, unitary2)
overlap_circ.measure_all()

On ne s'attardera pas à vérifier les profondeurs pour l'instant, puisque ce qui compte vraiment est la profondeur transpilée à deux qubits.

Étape 2 : Optimiser le problème pour l'exécution quantique

On commence par sélectionner le backend le moins occupé, puis on optimise notre circuit pour l'exécuter sur ce backend.

# Import needed packages
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService

# Get the least busy backend
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=fm.num_qubits
)
print(backend)
<IBMBackend('ibm_brisbane')>

Pour les petits jobs, un gestionnaire de passes prédéfini retournera souvent le même circuit avec la même profondeur, de façon fiable. Mais pour des circuits très grands et complexes, le gestionnaire de passes peut retourner des circuits transpilés différents à chaque exécution. Cela est dû à l'utilisation d'heuristiques, et parce que les circuits très grands auront un paysage complexe d'optimisations possibles. Il est souvent utile de transpiler plusieurs fois et de prendre le circuit le moins profond. Cela n'introduit qu'une surcharge classique et peut améliorer considérablement les résultats de l'ordinateur quantique.

Ici, on transpose le circuit de chevauchement unitaire 20 fois et on examine les profondeurs des circuits obtenus.

# Apply level 3 optimization to our overlap circuit
transpiled_qcs = []
transpiled_depths = []
transpiled_twoqubit_depths = []
for i in range(1, 20):
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
overlap_ibm = pm.run(overlap_circ)
transpiled_qcs.append(overlap_ibm)
transpiled_depths.append(overlap_ibm.decompose().depth())
transpiled_twoqubit_depths.append(
overlap_ibm.decompose().depth(lambda instr: len(instr.qubits) > 1)
)

print("circuit depth = ", overlap_ibm.decompose().depth())
circuit depth =  61
print(transpiled_depths)
print(transpiled_twoqubit_depths)
[61, 60, 60, 69, 60, 60, 60, 65, 60, 60, 69, 61, 77, 77, 65, 60, 60, 77, 61]
[13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13]

Ici, on peut voir qu'il y a une certaine variation dans la profondeur totale des portes avec différentes passes de transpilation. Notre circuit n'est pas encore assez profond/large pour observer une variation des profondeurs transpilées à deux qubits. On utilisera transpiled_qcs[1], qui a une profondeur de 60, légèrement inférieure à la profondeur du circuit le plus profond obtenu, qui était de 77.

overlap_ibm = transpiled_qcs[1]

Étape 3 : Exécuter avec les primitives Qiskit Runtime

À mesure qu'on se rapproche de l'utilité, les simulateurs ne seront plus utiles. Seule la syntaxe pour les vrais ordinateurs quantiques est présentée ici.

# Run on ibm_osaka, 7-12-24, required 22 sec.

# Import our runtime primitive
from qiskit_ibm_runtime import SamplerV2 as Sampler

# Open a Runtime session:
session = Session(backend=backend)
num_shots = 10000
# Use sampler and get the counts

sampler = Sampler(mode=session)
options = sampler.options
options.dynamical_decoupling.enable = True
options.twirling.enable_gates = True
counts = (
sampler.run([overlap_ibm], shots=num_shots).result()[0].data.meas.get_int_counts()
)

# Close session after done
session.close()

Étape 4 : Post-traiter, renvoyer le résultat au format classique

Comme décrit dans l'introduction, la mesure la plus utile ici est la probabilité de mesurer l'état zéro 00000|00000\rangle.

counts.get(0, 0.0) / num_shots
0.0138

Ce processus pour l'élément unique de la matrice noyau pourrait être répété entre d'autres paires de données de ton jeu pour obtenir la matrice noyau complète. La dimension de la matrice noyau est dictée par le nombre de points dans les données d'entraînement, et non par le nombre de caractéristiques. Le coût informatique de la manipulation de la matrice noyau pour en faire un modèle prédictif n'augmente donc pas comme le nombre de caractéristiques ou de qubits. Même pour des jeux de données relativement petits avec un grand nombre de caractéristiques, les données devraient quand même être associées à un plongement de caractéristiques qui permet une classification efficace.

Mise à l'échelle et travaux futurs

La méthode à noyau exige qu'on mesure 0|0\rangle aussi précisément que possible. Mais les erreurs de portes et les erreurs de lecture signifient qu'il y a une probabilité non nulle pp qu'un qubit donné soit mesuré par erreur dans l'état 1|1\rangle. Même avec la simplification excessive que la probabilité de 0|0\rangle devrait être de 100 %, pour de nombreuses caractéristiques encodées sur, disons, NN bits, la probabilité de mesurer correctement tous les bits à 0|0\rangle est réduite à (1p)N(1-p)^N. Plus NN devient grand, moins cette méthode devient fiable. Surmonter cette difficulté et mettre à l'échelle l'estimation des noyaux vers de plus en plus de caractéristiques est un domaine de recherche actif. Pour en savoir plus sur cette question, consulte ce travail de Thanasilp, Wang, Cerezo et Holmes. Nous t'encourageons à explorer ce qui est possible avec les ordinateurs quantiques actuels, et à envisager également ce qui sera possible à l'ère de la correction d'erreurs.

Révision

Le calcul d'un noyau quantique implique de :

  • calculer les entrées de la matrice noyau, en utilisant des paires de points de données d'entraînement
  • encoder les données et les mapper via un plongement de caractéristiques
  • optimiser ton circuit pour l'exécution sur de vrais ordinateurs quantiques / backends

Le noyau quantique peut ensuite être utilisé dans des algorithmes d'apprentissage automatique classiques, comme dans cette leçon.

Quelques points clés à garder en tête lors de l'utilisation de noyaux quantiques :

  • Le jeu de données est-il susceptible de bénéficier des méthodes à noyau quantique ?
  • Essaie différents plongements de caractéristiques et schémas d'enchevêtrement.
  • La profondeur du circuit est-elle acceptable ?
  • Essaie d'exécuter un gestionnaire de passes plusieurs fois et utilise le circuit de plus faible profondeur que tu peux obtenir.

Les méthodes à noyau quantique sont des outils potentiellement puissants, à condition qu'il y ait une bonne correspondance entre des jeux de données aux caractéristiques adaptées au quantique et un plongement de caractéristiques quantique approprié. Pour mieux comprendre où les noyaux quantiques sont susceptibles d'être utiles, nous recommandons la lecture de Liu, Arunachalam & Temme (2021).