Przejdź do głównej treści

Kwantowe obwody wariacyjne i kwantowe sieci neuronowe

W tej lekcji zaimplementujemy kilka wariacyjnych obwodów kwantowych do zadania klasyfikacji danych, tzw. wariacyjnych klasyfikatorów kwantowych (VQC). W pewnym momencie było powszechne określanie podzbioru VQC mianem kwantowych sieci neuronowych (QNN) przez analogię do klasycznych sieci neuronowych. Istotnie, istnieją przypadki, w których struktury zaczerpnięte z klasycznych sieci neuronowych, takie jak warstwy splotowe, odgrywają ważną rolę w VQC. W takich przypadkach, gdy analogia jest silna, QNN może być użytecznym opisem. Jednak parametryzowane obwody kwantowe nie muszą podążać za ogólną strukturą sieci neuronowej; na przykład nie wszystkie dane muszą być ładowane w pierwszej (wejściowej) warstwie; możemy załadować część danych w pierwszej warstwie, zastosować pewne bramki, a następnie załadować dodatkowe dane (proces nazywany "ponownym ładowaniem" danych). Powinniśmy zatem myśleć o QNN jako o podzbiorze parametryzowanych obwodów kwantowych i nie powinniśmy ograniczać naszego poszukiwania użytecznych obwodów kwantowych analogią do klasycznych sieci neuronowych.

Zbiór danych omawiany w tej lekcji składa się z obrazów zawierających poziome i pionowe paski, a naszym celem jest oznaczenie niewidzianych obrazów jedną z dwóch kategorii w zależności od orientacji ich linii. Osiągniemy to za pomocą VQC. W trakcie pracy omówimy sposoby, w jakie obliczenia mogą być ulepszone i skalowane. Zbiór danych tutaj jest wyjątkowo łatwy do sklasyfikowania klasycznie. Został wybrany ze względu na swoją prostotę, abyśmy mogli skupić się na kwantowej części tego problemu i zobaczyć, jak atrybut zbioru danych może przekładać się na część obwodu kwantowego. Nie jest rozsądne oczekiwanie przyspieszenia kwantowego w tak prostych przypadkach, w których algorytmy klasyczne są tak wydajne.

Pod koniec tej lekcji powinieneś być w stanie:

  • Załadować dane z obrazu do obwodu kwantowego
  • Zbudować ansatz dla VQC (lub QNN) i dostosować go do swojego problemu
  • Wytrenować swój VQC/QNN i używać go do dokonywania dokładnych przewidywań na danych testowych
  • Skalować problem i rozpoznawać ograniczenia obecnych komputerów kwantowych

Generowanie danych

Zaczniemy od skonstruowania danych. Zbiory danych często nie są jawnie generowane w ramach frameworka Qiskit patterns. Jednak typ danych i ich przygotowanie mają kluczowe znaczenie dla skutecznego zastosowania obliczeń kwantowych do uczenia maszynowego. Poniższy kod definiuje zbiór danych obrazów o ustalonych wymiarach pikseli. Jednemu pełnemu wierszowi lub kolumnie obrazu przypisuje się wartość π/2\pi/2, a pozostałe piksele otrzymują losowe wartości z przedziału (0,π/4)(0,\pi/4). Losowe wartości stanowią szum w naszych danych. Przejrzyj kod, aby upewnić się, że rozumiesz, w jaki sposób generowane są obrazy. Później zwiększymy rozmiar obrazów.

# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-ibm-runtime scipy scikit-learn
# This code defines the images to be classified:

import numpy as np

# Total number of "pixels"/qubits
size = 8
# One dimension of the image (called vertical, but it doesn't matter). Must be a divisor of `size`
vert_size = 2
# The length of the line to be detected (yellow). Must be less than or equal to the smallest dimension of the image (`<=min(vert_size,size/vert_size)`
line_size = 2

def generate_dataset(num_images):
images = []
labels = []
hor_array = np.zeros((size - (line_size - 1) * vert_size, size))
ver_array = np.zeros((round(size / vert_size) * (vert_size - line_size + 1), size))

j = 0
for i in range(0, size - 1):
if i % (size / vert_size) <= (size / vert_size) - line_size:
for p in range(0, line_size):
hor_array[j][i + p] = np.pi / 2
j += 1

# Make two adjacent entries pi/2, then move down to the next row. Careful to avoid the "pixels" at size/vert_size - linesize, because we want to fold this list into a grid.

j = 0
for i in range(0, round(size / vert_size) * (vert_size - line_size + 1)):
for p in range(0, line_size):
ver_array[j][i + p * round(size / vert_size)] = np.pi / 2
j += 1

# Make entries pi/2, spaced by the length/rows, so that when folded, the entries appear on top of each other.

for n in range(num_images):
rng = np.random.randint(0, 2)
if rng == 0:
labels.append(-1)
random_image = np.random.randint(0, len(hor_array))
images.append(np.array(hor_array[random_image]))

elif rng == 1:
labels.append(1)
random_image = np.random.randint(0, len(ver_array))
images.append(np.array(ver_array[random_image]))
# Randomly select 0 or 1 for a horizontal or vertical array, assign the corresponding label.

# Create noise
for i in range(size):
if images[-1][i] == 0:
images[-1][i] = np.random.rand() * np.pi / 4
return images, labels

hor_size = round(size / vert_size)

Zauważ, że powyższy kod wygenerował również etykiety wskazujące, czy obrazy zawierają pionową (+1), czy poziomą (-1) linię. Użyjemy teraz sklearn do podziału zbioru danych 100 obrazów na zbiór treningowy i testowy (wraz z odpowiadającymi im etykietami). Tutaj wykorzystujemy 7070% zbioru danych do treningu, a pozostałe 3030% zatrzymujemy do testowania.

from sklearn.model_selection import train_test_split

np.random.seed(42)
images, labels = generate_dataset(200)

train_images, test_images, train_labels, test_labels = train_test_split(
images, labels, test_size=0.3, random_state=246
)

Narysujmy kilka elementów naszego zbioru danych, aby zobaczyć, jak wyglądają te linie:

import matplotlib.pyplot as plt

# Make subplot titles so we can identify categories
titles = []
for i in range(8):
title = "category: " + str(train_labels[i])
titles.append(title)

# Generate a figure with nested images using subplots.
fig, ax = plt.subplots(4, 2, figsize=(10, 6), subplot_kw={"xticks": [], "yticks": []})

for i in range(8):
ax[i // 2, i % 2].imshow(
train_images[i].reshape(vert_size, hor_size),
aspect="equal",
)
ax[i // 2, i % 2].set_title(titles[i])
plt.subplots_adjust(wspace=0.1, hspace=0.3)

Wynik poprzedniej komórki kodu

Każdy z tych obrazów jest nadal powiązany ze swoją etykietą w train_labels w prostej formie listy:

print(train_labels[:8])
[1, 1, 1, 1, -1, 1, 1, 1]

Wariacyjny klasyfikator kwantowy: pierwsze podejście

Qiskit patterns krok 1: Odwzoruj problem na obwód kwantowy

Celem jest znalezienie funkcji ff z parametrami θ\theta, która odwzorowuje wektor danych / obraz x\vec{x} na poprawną kategorię: fθ(x)±1f_\theta(\vec{x}) \rightarrow \pm1. Zostanie to osiągnięte za pomocą VQC z kilkoma warstwami, które można zidentyfikować po ich odrębnych celach:

fθ(x)=0U(x)W(θ)OW(θ)U(x)0f_\theta(\vec{x}) = \langle 0|U^{\dagger}(\vec{x})W^\dagger(\theta)OW(\theta)U(\vec{x})|0\rangle

Tutaj U(x)U(\vec{x}) jest obwodem kodującym, dla którego mamy wiele opcji, jak widzieliśmy w poprzednich lekcjach. W(θ)W(\theta) jest wariacyjnym lub trenowalnym blokiem obwodu, a θ\theta jest zbiorem parametrów do wytrenowania. Parametry te będą zmieniane przez klasyczne algorytmy optymalizacyjne, aby znaleźć zbiór parametrów dający najlepszą klasyfikację obrazów przez obwód kwantowy. Ten wariacyjny obwód jest czasem nazywany "ansatz". Wreszcie OO jest pewną obserwablą, która będzie estymowana za pomocą prymitywu Estimator. Nie ma ograniczenia wymuszającego, by warstwy występowały w tej kolejności, ani żeby były w pełni rozdzielone. Można mieć wiele warstw wariacyjnych i/lub kodujących w dowolnej kolejności, która jest technicznie uzasadniona.

Zaczynamy od wyboru mapy cech do zakodowania naszych danych. Użyjemy z_feature_map, ponieważ utrzymuje on niskie głębokości obwodu w porównaniu z niektórymi innymi mapowaniami cech.

from qiskit.circuit.library import z_feature_map

# One qubit per data feature
num_qubits = len(train_images[0])

# Data encoding
# Note that qiskit orders parameters alphabetically. We assign the parameter prefix "a" to ensure our data encoding goes to the first part of the circuit, the feature mapping.
feature_map = z_feature_map(num_qubits, parameter_prefix="a")

Musimy teraz zdecydować o ansatzu, który zostanie wytrenowany. Przy wyborze ansatzu należy wziąć pod uwagę wiele aspektów. Pełny opis wykracza poza zakres tego wprowadzenia; tutaj wskazujemy jedynie kilka kategorii rozważań.

  1. Sprzęt: Wszystkie nowoczesne komputery kwantowe są bardziej podatne na błędy i bardziej wrażliwe na szum niż ich klasyczne odpowiedniki. Użycie zbyt głębokiego ansatzu (szczególnie w głębokości dwukubitowej po transpilacji) nie przyniesie dobrych wyników. Powiązanym zagadnieniem jest to, że komputery kwantowe mają pewien układ kubitów, co oznacza, że niektóre fizyczne kubity sąsiadują ze sobą na komputerze kwantowym, a inne mogą być bardzo oddalone. Splątywanie sąsiednich kubitów nie zwiększa zbytnio głębokości, ale splątywanie bardzo odległych kubitów może znacznie zwiększyć głębokość, ponieważ musimy wstawić bramki swap, aby przenieść informację na kubity, które są sąsiadujące, aby można je było splątać.
  2. Problem: Ilekroć masz jakąś informację o swoim problemie, która mogłaby pokierować twoim ansatzem, skorzystaj z niej. Na przykład dane w tej lekcji składają się z obrazów poziomych i pionowych linii. Można by rozważyć, jaka korelacja między sąsiednimi kolorami/wartościami identyfikuje obraz poziomej lub pionowej linii. Jakie atrybuty ansatzu odpowiadałyby tej korelacji między sąsiadującymi pikselami? Wrócimy do tego punktu bardziej technicznie w dalszej części tej lekcji. Ale na razie powiedzmy po prostu, że uwzględnienie splątania i bramek CNOT między kubitami odpowiadającymi sąsiednim pikselom wydaje się dobrym pomysłem. W szerszej perspektywie rozważ, czy problem jest rzeczywiście najlepiej rozwiązywany za pomocą obwodu kwantowego, czy też mogą istnieć klasyczne algorytmy, które mogą wykonać równie dobrą pracę.
  3. Liczba parametrów: Każda niezależnie parametryzowana bramka kwantowa w obwodzie zwiększa przestrzeń do klasycznej optymalizacji, co skutkuje wolniejszą zbieżnością. Ale w miarę skalowania problemów możemy napotkać jałowe plateau (barren plateaus). Termin ten odnosi się do zjawiska, w którym krajobraz optymalizacji wariacyjnego algorytmu kwantowego staje się wykładniczo płaski i pozbawiony cech w miarę wzrostu rozmiaru problemu. Powoduje to zanikające gradienty, utrudniając skuteczne trenowanie algorytmu[1]. Jałowe plateau są istotne dla wariacyjnych algorytmów kwantowych, takich jak VQC/QNN. Należy zauważyć, że rosnąca liczba parametrów nie jest jedynym czynnikiem w unikaniu jałowych plateau; inne rozważania obejmują globalne funkcje kosztu i losową inicjalizację parametrów.

W tej lekcji zobaczymy kilka prostych przykładów dobrych praktyk w konstruowaniu ansatzu. Spróbujmy najpierw poniższego ansatzu. Wrócimy do niego, aby go zrewidować, później.

# Import the necessary packages
from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector

# Initialize the circuit using the same number of qubits as the image has pixels
qnn_circuit = QuantumCircuit(size)

# We choose to have two variational parameters for each qubit.
params = ParameterVector("θ", length=2 * size)

# A first variational layer:
for i in range(size):
qnn_circuit.ry(params[i], i)

# Here is a list of qubit pairs between which we want CNOT gates. The choice of these is not yet obvious.
qnn_cnot_list = [[0, 1], [1, 2], [2, 3]]

for i in range(len(qnn_cnot_list)):
qnn_circuit.cx(qnn_cnot_list[i][0], qnn_cnot_list[i][1])

# The second variational layer:
for i in range(size):
qnn_circuit.rx(params[size + i], i)

# Check the circuit depth, and the two-qubit gate depth
print(qnn_circuit.decompose().depth())
print(
f"2+ qubit depth: {qnn_circuit.decompose().depth(lambda instr: len(instr.qubits) > 1)}"
)

# Draw the circuit
qnn_circuit.draw("mpl")
5
2+ qubit depth: 3
┌──────────┐             ┌──────────┐                         
q_0: ┤ Ry(θ[0]) ├──────■──────┤ Rx(θ[8]) ├─────────────────────────
├──────────┤ ┌─┴─┐ └──────────┘┌──────────┐
q_1: ┤ Ry(θ[1]) ├────┤ X ├─────────■──────┤ Rx(θ[9]) ├─────────────
├──────────┤ └───┘ ┌─┴─┐ └──────────┘┌───────────┐
q_2: ┤ Ry(θ[2]) ├────────────────┤ X ├─────────■──────┤ Rx(θ[10]) ├
├──────────┤ └───┘ ┌─┴─┐ ├───────────┤
q_3: ┤ Ry(θ[3]) ├────────────────────────────┤ X ├────┤ Rx(θ[11]) ├
├──────────┤┌───────────┐ └───┘ └───────────┘
q_4: ┤ Ry(θ[4]) ├┤ Rx(θ[12]) ├─────────────────────────────────────
├──────────┤├───────────┤
q_5: ┤ Ry(θ[5]) ├┤ Rx(θ[13]) ├─────────────────────────────────────
├──────────┤├───────────┤
q_6: ┤ Ry(θ[6]) ├┤ Rx(θ[14]) ├─────────────────────────────────────
├──────────┤├───────────┤
q_7: ┤ Ry(θ[7]) ├┤ Rx(θ[15]) ├─────────────────────────────────────
└──────────┘└───────────┘

Mając przygotowane kodowanie danych oraz obwód wariacyjny, możemy je połączyć, tworząc nasz pełny ansatz. W tym przypadku elementy naszego obwodu kwantowego są dość analogiczne do tych w sieciach neuronowych: U(x)U(\vec{x}) najbardziej przypomina warstwę wczytującą wartości wejściowe z obrazu, a W(θ)W(\theta) jest jak warstwa zmiennych „wag”. Ponieważ ta analogia jest w tym przypadku trafna, w niektórych naszych konwencjach nazewniczych używamy skrótu „qnn”; analogia ta nie powinna jednak ograniczać eksploracji VQC.

QML_CR_background_QNN_circuit-2.png

# QNN ansatz
ansatz = qnn_circuit

# Combine the feature map with the ansatz
full_circuit = QuantumCircuit(num_qubits)
full_circuit.compose(feature_map, range(num_qubits), inplace=True)
full_circuit.compose(ansatz, range(num_qubits), inplace=True)

# Display the circuit
full_circuit.decompose().draw("mpl", style="clifford", fold=-1)

Wynik poprzedniej komórki kodu

Musimy teraz zdefiniować obserwablę, abyśmy mogli wykorzystać ją w naszej funkcji kosztu. Wartość oczekiwaną tej obserwabli uzyskamy za pomocą Estimatora. Jeśli wybraliśmy dobry, uzasadniony problemem ansatz, wówczas każdy kubit będzie zawierał informacje istotne dla klasyfikacji. Można dodać warstwy łączące informacje na mniejszej liczbie kubitów (zwane warstwą konwolucyjną), tak aby pomiary były konieczne tylko na podzbiorze kubitów w obwodzie (podobnie jak w konwolucyjnych sieciach neuronowych). Alternatywnie można mierzyć jakąś wielkość z każdego kubitu. Tutaj wybieramy to drugie podejście, więc uwzględniamy operator Z dla każdego kubitu. Wybór ZZ nie jest szczególnie unikalny, ale ma dobre uzasadnienie:

  • Jest to zadanie klasyfikacji binarnej, a pomiar ZZ może dać dwa możliwe wyniki.
  • Wartości własne ZZ (±1\pm 1) są dość dobrze rozseparowane, a w rezultacie wynik z Estimatora mieści się w przedziale [-1, +1], gdzie 0 może być po prostu wartością graniczną.
  • Pomiar w bazie Pauliego Z jest prosty i nie wymaga dodatkowych bramek.

Zatem Z jest bardzo naturalnym wyborem.

from qiskit.quantum_info import SparsePauliOp

observable = SparsePauliOp.from_list([("Z" * (num_qubits), 1)])

Mamy już nasz obwód kwantowy oraz obserwablę, której wartość chcemy estymować. Teraz potrzebujemy kilku rzeczy, aby uruchomić i zoptymalizować ten obwód. Po pierwsze, potrzebujemy funkcji do wykonania przejścia w przód (forward pass). Zauważ, że poniższa funkcja przyjmuje input_params i weight_params oddzielnie. Pierwsze to zbiór statycznych parametrów opisujących dane w obrazie, a drugie to zbiór parametrów zmiennych, które mają być optymalizowane.

from qiskit.primitives import BaseEstimatorV2
from qiskit.quantum_info.operators.base_operator import BaseOperator

def forward(
circuit: QuantumCircuit,
input_params: np.ndarray,
weight_params: np.ndarray,
estimator: BaseEstimatorV2,
observable: BaseOperator,
) -> np.ndarray:
"""
Forward pass of the neural network.

Args:
circuit: circuit consisting of data loader gates and the neural network ansatz.
input_params: data encoding parameters.
weight_params: neural network ansatz parameters.
estimator: EstimatorV2 primitive.
observable: a single observable to compute the expectation over.

Returns:
expectation_values: an array (for one observable) or a matrix (for a sequence of observables) of expectation values.
Rows correspond to observables and columns to data samples.
"""
num_samples = input_params.shape[0]
weights = np.broadcast_to(weight_params, (num_samples, len(weight_params)))
params = np.concatenate((input_params, weights), axis=1)
pub = (circuit, observable, params)
job = estimator.run([pub])
result = job.result()[0]
expectation_values = result.data.evs

return expectation_values

Funkcja straty

Następnie potrzebujemy funkcji straty, aby obliczyć różnicę między przewidywanymi a obliczonymi wartościami etykiet. Funkcja ta przyjmie etykiety przewidziane przez algorytm oraz etykiety poprawne i zwróci średnią różnicę podniesioną do kwadratu. Istnieje wiele różnych funkcji straty. Tutaj MSE to przykład, który wybraliśmy.

def mse_loss(predict: np.ndarray, target: np.ndarray) -> np.ndarray:
"""
Mean squared error (MSE).

prediction: predictions from the forward pass of neural network.
target: true labels.

output: MSE loss.
"""
if len(predict.shape) <= 1:
return ((predict - target) ** 2).mean()
else:
raise AssertionError("input should be 1d-array")

Zdefiniujmy także nieco inną funkcję straty, będącą funkcją zmiennych parametrów (wag), do użycia przez klasyczny optymalizator. Funkcja ta przyjmuje jako wejście jedynie parametry ansatzu; pozostałe zmienne dla przejścia w przód oraz funkcji straty są ustawione jako parametry globalne. Optymalizator będzie trenował model, próbkując różne wagi i starając się zmniejszyć wartość funkcji kosztu/straty.

def mse_loss_weights(weight_params: np.ndarray) -> np.ndarray:
"""
Cost function for the optimizer to update the ansatz parameters.

weight_params: ansatz parameters to be updated by the optimizer.

output: MSE loss.
"""
predictions = forward(
circuit=circuit,
input_params=input_params,
weight_params=weight_params,
estimator=estimator,
observable=observable,
)

cost = mse_loss(predict=predictions, target=target)
objective_func_vals.append(cost)

global iter
if iter % 50 == 0:
print(f"Iter: {iter}, loss: {cost}")
iter += 1

return cost

Powyżej odnieśliśmy się do użycia klasycznego optymalizatora. Gdy przejdziemy do przeszukiwania wag w celu zminimalizowania funkcji kosztu, użyjemy optymalizatora COBYLA:

from scipy.optimize import minimize

Ustawimy kilka początkowych zmiennych globalnych dla funkcji kosztu.

# Globals
circuit = full_circuit
observables = observable
# input_params = train_images_batch
# target = train_labels_batch
objective_func_vals = []
iter = 0

Qiskit Patterns krok 2: Optymalizacja problemu pod kątem wykonania kwantowego

Zaczynamy od wyboru zaplecza (backendu) do wykonania. W tym przypadku użyjemy najmniej obciążonego zaplecza.

from qiskit_ibm_runtime import QiskitRuntimeService

service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
print(backend.name)
ibm_brisbane

Tutaj optymalizujemy obwód pod kątem uruchomienia na rzeczywistym zapleczu, określając optimization_level i dodając dynamical decoupling. Poniższy kod generuje pass manager, korzystając z predefiniowanych pass managerów z qiskit.transpiler.

from qiskit.circuit.library import XGate
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import (
ALAPScheduleAnalysis,
ConstrainedReschedule,
PadDynamicalDecoupling,
)
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

target = backend.target

pm = generate_preset_pass_manager(target=target, optimization_level=3)
pm.scheduling = PassManager(
[
ALAPScheduleAnalysis(target=target),
ConstrainedReschedule(
acquire_alignment=target.acquire_alignment,
pulse_alignment=target.pulse_alignment,
target=target,
),
PadDynamicalDecoupling(
target=target,
dd_sequence=[XGate(), XGate()],
pulse_alignment=target.pulse_alignment,
),
]
)

Teraz stosujemy pass manager do obwodu. Powstałe zmiany rozkładu muszą zostać zastosowane także do obserwabli. W przypadku bardzo dużych obwodów heurystyki używane w optymalizacji obwodów nie zawsze dają najlepszy i najpłytszy obwód. W takich przypadkach sensowne jest kilkukrotne uruchomienie takich pass managerów i wybranie najlepszego obwodu. Zobaczymy to później, gdy będziemy skalować nasze obliczenia.

circuit_ibm = pm.run(full_circuit)
observable_ibm = observable.apply_layout(circuit_ibm.layout)

Qiskit Patterns krok 3: Wykonanie z użyciem Qiskit Primitives

Pętla po zbiorze danych w partiach i epokach

Najpierw implementujemy pełny algorytm przy użyciu symulatora do wstępnego debugowania oraz do oszacowania błędu. Teraz możemy przejść przez cały zbiór danych w partiach, w żądanej liczbie epok, aby wytrenować naszą kwantową sieć neuronową.

from qiskit.primitives import StatevectorEstimator as Estimator

batch_size = 140
num_epochs = 1
num_samples = len(train_images)

# Globals
circuit = full_circuit
estimator = Estimator() # simulator for debugging
observables = observable
objective_func_vals = []
iter = 0

# Random initial weights for the ansatz
np.random.seed(42)
weight_params = np.random.rand(len(ansatz.parameters)) * 2 * np.pi

for epoch in range(num_epochs):
for i in range((num_samples - 1) // batch_size + 1):
print(f"Epoch: {epoch}, batch: {i}")
start_i = i * batch_size
end_i = start_i + batch_size
train_images_batch = np.array(train_images[start_i:end_i])
train_labels_batch = np.array(train_labels[start_i:end_i])
input_params = train_images_batch
target = train_labels_batch
iter = 0
res = minimize(
mse_loss_weights, weight_params, method="COBYLA", options={"maxiter": 100}
)
weight_params = res["x"]
Epoch: 0, batch: 0
Iter: 0, loss: 1.0002309063537163
Iter: 50, loss: 0.9434121445008878

Qiskit Patterns krok 4: Przetwarzanie końcowe, zwrócenie wyniku w formacie klasycznym

Testowanie i dokładność

Teraz interpretujemy wyniki treningu. Najpierw sprawdzamy dokładność treningową na zbiorze treningowym.

import copy
from sklearn.metrics import accuracy_score
from qiskit.primitives import StatevectorEstimator as Estimator # simulator
# from qiskit_ibm_runtime import EstimatorV2 as Estimator # real quantum computer

estimator = Estimator()
# estimator = Estimator(backend=backend)

pred_train = forward(circuit, np.array(train_images), res["x"], estimator, observable)
# pred_train = forward(circuit_ibm, np.array(train_images), res['x'], estimator, observable_ibm)

print(pred_train)

pred_train_labels = copy.deepcopy(pred_train)
pred_train_labels[pred_train_labels >= 0] = 1
pred_train_labels[pred_train_labels < 0] = -1
print(pred_train_labels)
print(train_labels)

accuracy = accuracy_score(train_labels, pred_train_labels)
print(f"Train accuracy: {accuracy * 100}%")
[-2.27688499e-02 -1.46227204e-02 -1.73927452e-02  9.93331786e-02
-4.85553548e-01 1.43558565e-01 8.34567054e-02 -1.40133992e-02
1.52169596e-01 -1.95082515e-01 8.24373578e-03 -9.90696638e-02
-3.54268344e-02 -4.77017954e-01 1.38713848e-02 -2.99706215e-01
-5.78378029e-02 3.25528779e-02 -4.11354239e-02 -1.06483708e-01
1.53095800e-01 2.90110884e-02 1.25745450e-02 6.46323079e-02
-1.53538943e-01 -1.57694952e-02 -1.67800067e-02 -1.99820822e-01
1.70360075e-01 7.86148038e-03 -2.33373818e-02 6.64233020e-02
-1.14895445e-01 -1.11296215e-01 1.15120303e-01 -2.94096140e-01
-1.00531392e-03 -1.69209726e-01 -1.26120885e-01 3.26298176e-02
-1.33517383e-02 -5.86983444e-02 -4.32341361e-01 -4.36509551e-01
-4.17940102e-02 1.76935235e-03 8.14479984e-03 1.86985655e-01
-2.75525019e-01 -1.63229907e-03 -1.08571055e-01 -7.37452387e-04
-6.44440657e-02 6.72812834e-04 2.16785530e-03 1.41381850e-01
-9.82570410e-02 4.35973325e-01 -7.62261965e-02 -1.86193980e-01
-1.56971183e-02 -4.02757541e-01 -1.53869367e-01 2.29262129e-02
-7.02788246e-03 3.65719683e-02 4.68232163e-01 2.36434668e-02
-2.59520939e-02 3.70550137e-01 -1.19630110e-01 -5.79555318e-02
2.09554455e-01 5.04689780e-02 7.39494314e-02 -1.77647326e-02
-1.45407207e-01 -9.54908878e-02 7.56029640e-02 -2.74049696e-02
3.34885873e-01 1.58546171e-03 1.09339091e-01 -8.84693274e-02
-2.36450457e-02 1.41892239e-01 -2.34453218e-01 -7.50717757e-02
-1.13281310e-01 -1.66649414e-01 -3.17224197e-01 -6.38220597e-02
3.28916563e-02 3.04739203e-02 2.67720196e-02 -1.16485785e-01
-3.08115732e-02 -2.95372010e-02 -7.54669023e-02 6.20013872e-02
-3.85258710e-01 -1.16456443e-01 -7.38548075e-02 -3.20558243e-02
-4.22284741e-02 1.01285659e-01 -1.76949246e-01 -2.02767491e-01
-1.12407344e-01 -3.81408267e-02 -4.33345231e-01 -9.24507501e-02
-4.21765393e-02 -6.06533771e-02 -2.22257783e-01 -1.17312535e-01
-6.74132262e-02 -2.76206274e-01 -9.13971800e-02 -2.27653991e-01
1.66358563e-01 2.17230774e-04 5.76426304e-02 -2.82079169e-02
-1.15482051e-01 -3.46716009e-01 -3.21448755e-01 -5.20041405e-02
-2.16833625e-01 -1.06154654e-02 -7.74854811e-02 -3.28257935e-01
-7.83242410e-02 1.65547682e-01 -2.55294862e-01 -8.89085025e-02
4.47581491e-01 1.92351832e-02 2.74083885e-02 -3.61304571e-01]
[-1. -1. -1. 1. -1. 1. 1. -1. 1. -1. 1. -1. -1. -1. 1. -1. -1. 1.
-1. -1. 1. 1. 1. 1. -1. -1. -1. -1. 1. 1. -1. 1. -1. -1. 1. -1.
-1. -1. -1. 1. -1. -1. -1. -1. -1. 1. 1. 1. -1. -1. -1. -1. -1. 1.
1. 1. -1. 1. -1. -1. -1. -1. -1. 1. -1. 1. 1. 1. -1. 1. -1. -1.
1. 1. 1. -1. -1. -1. 1. -1. 1. 1. 1. -1. -1. 1. -1. -1. -1. -1.
-1. -1. 1. 1. 1. -1. -1. -1. -1. 1. -1. -1. -1. -1. -1. 1. -1. -1.
-1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. -1. 1. 1. 1. -1. -1. -1.
-1. -1. -1. -1. -1. -1. -1. 1. -1. -1. 1. 1. 1. -1.]
[1, 1, 1, 1, -1, 1, 1, 1, -1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, 1, -1, -1, -1, -1, -1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1, 1, 1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1]
Train accuracy: 60.0%

Dokładność na zbiorze uczącym wynosi tylko 6060%, co zdecydowanie nie jest dobrym wynikiem. Trudno sobie wyobrazić, by osiągi modelu na zbiorze testowym mogły być lepsze. Sprawdźmy to.

pred_test = forward(circuit, np.array(test_images), res["x"], estimator, observable)
# pred_test = forward(circuit_ibm, np.array(test_images), res['x'], estimator, observable_ibm)

print(pred_test)

pred_test_labels = copy.deepcopy(pred_test)
pred_test_labels[pred_test_labels >= 0] = 1
pred_test_labels[pred_test_labels < 0] = -1
print(pred_test_labels)
print(test_labels)

accuracy = accuracy_score(test_labels, pred_test_labels)
print(f"Test accuracy: {accuracy * 100}%")
[-2.77978120e-01 -2.62194862e-01  4.59636095e-02 -8.09344165e-02
-2.97362966e-01 9.22947242e-02 2.06693174e-01 3.31629460e-02
1.10971762e-03 -2.14602152e-01 -1.62671993e-01 -6.07179155e-04
-1.59948633e-01 -8.55722523e-02 -1.13057027e-01 -3.00187433e-01
-2.92832827e-01 7.38580629e-02 -6.03706270e-02 -8.57643552e-02
-1.52402062e-02 -3.57505447e-01 -3.54890597e-02 1.36534749e-01
-1.54688180e-01 -2.93714726e-01 1.89548513e-02 -6.15715564e-02
1.11042670e-01 -2.22861100e-02 -3.84230105e-02 1.67351034e-01
-8.38766333e-02 2.56348613e-01 -1.10653111e-01 -1.18989476e-01
-6.75723266e-05 -6.88580547e-02 1.02431393e-02 -2.42125353e-01
-1.09142367e-01 -1.22540757e-01 -1.63735850e-01 3.93334838e-01
2.36705685e-01 -2.34259814e-02 -3.91877756e-02 -1.95106746e-01
1.86707523e-01 4.74775215e-02 -4.24907432e-02 -2.06453265e-01
4.09184710e-02 -3.54762080e-02 -9.47513112e-02 2.97270112e-01
-2.99708696e-02 9.93941064e-03 -1.26760302e-01 -1.36183355e-01]
[-1. -1. 1. -1. -1. 1. 1. 1. 1. -1. -1. -1. -1. -1. -1. -1. -1. 1.
-1. -1. -1. -1. -1. 1. -1. -1. 1. -1. 1. -1. -1. 1. -1. 1. -1. -1.
-1. -1. 1. -1. -1. -1. -1. 1. 1. -1. -1. -1. 1. 1. -1. -1. 1. -1.
-1. 1. -1. 1. -1. -1.]
[-1, -1, 1, 1, -1, -1, 1, -1, 1, -1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1, 1, 1, 1, -1, 1, 1, -1, -1, -1, 1, 1, 1, -1, 1, 1, 1, 1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1]
Test accuracy: 60.0%

Model nie klasyfikuje tych danych dobrze. Powinniśmy zapytać, dlaczego tak jest, a w szczególności sprawdzić:

  • Czy zakończyliśmy uczenie zbyt wcześnie? Czy potrzeba było więcej kroków optymalizacji?
  • Czy skonstruowaliśmy zły ansatz? Może to oznaczać wiele rzeczy. Gdy pracujemy na prawdziwych komputerach kwantowych, głębokość obwodu będzie istotnym czynnikiem. Liczba parametrów jest również potencjalnie ważna, podobnie jak splątywanie między kubitami.
  • Łącząc oba powyższe, czy skonstruowaliśmy ansatz z tak dużą liczbą parametrów, że nie da się go uczyć?

Możemy zacząć od sprawdzenia zbieżności w optymalizacji:

obj_func_vals_first = objective_func_vals
# import matplotlib.pyplot as plt

plt.figure(figsize=(12, 6))
plt.plot(obj_func_vals_first, label="first ansatz")
plt.xlabel("iteration")
plt.ylabel("loss")
plt.legend()
plt.show()

Wynik poprzedniej komórki kodu

Moglibyśmy spróbować wydłużyć kroki optymalizacji, aby upewnić się, że optymalizator nie utknął po prostu w lokalnym minimum w przestrzeni parametrów. Jednak wygląda to na dość dobrze zbieżne. Przyjrzyjmy się bliżej obrazom, które nie zostały poprawnie sklasyfikowane, i zobaczmy, czy uda nam się zrozumieć, co się dzieje.

missed = []
for i in range(len(test_labels)):
if pred_test_labels[i] != test_labels[i]:
missed.append(test_images[i])
print(len(missed))
24
fig, ax = plt.subplots(12, 2, figsize=(6, 6), subplot_kw={"xticks": [], "yticks": []})
for i in range(len(missed)):
ax[i // 2, i % 2].imshow(
missed[i].reshape(vert_size, hor_size),
aspect="equal",
)
plt.subplots_adjust(wspace=0.02, hspace=0.025)

Wynik poprzedniej komórki kodu

Widać tutaj, że zdecydowana większość błędnie sklasyfikowanych obrazów zawiera linię pionową. Coś w naszym modelu nie potrafi uchwycić informacji o nich. Być może spodziewałaś/eś się tego, znając postać pierwszego obwodu wariacyjnego. Przyjrzyjmy mu się dokładniej.

Ulepszanie modelu

Powrót do kroku 1

Odwzorowując nasz problem na obwód kwantowy, powinniśmy byli jawnie przemyśleć, w jaki sposób informacja w sąsiednich pikselach decyduje o klasie. Aby zidentyfikować linie poziome, chcemy wiedzieć „jeśli piksel ii jest żółty, to czy piksel i+1i+1 jest żółty" dla wszystkich pikseli w każdym wierszu. Chcemy również wiedzieć o liniach pionowych. Ponieważ jednak klasyfikacja jest binarna, można sobie wyobrazić, że po prostu stwierdzamy: jeśli taka linia pozioma nie została wykryta, to jest to linia pionowa. Nasz poprzedni obwód wariacyjny zawierał bramki CNOT pomiędzy kubitami (a zatem pikselami) 0 i 1, 1 i 2 oraz 2 i 3. Pokrywa to wszelkie linie poziome w górnej części obrazu, ale nie wykrywa bezpośrednio linii pionowych, ani nie wykrywa w pełni linii poziomych, ponieważ ignoruje dolny wiersz. Aby w pełni wykryć wszystkie linie poziome, chcielibyśmy mieć podobny zestaw bramek CNOT pomiędzy kubitami (pikselami) 4 i 5, 5 i 6 oraz 6 i 7. Warto pamiętać, że dodanie bramek CNOT pomiędzy kubitami odpowiadającymi liniom pionowym (jak 0 i 4 lub 2 i 6) również może być użyteczne. Najpierw jednak sprawdzimy, czy wystarczy wykryć, że linia pozioma istnieje lub nie istnieje.

# Initialize the circuit using the same number of qubits as the image has pixels
qnn_circuit = QuantumCircuit(size)

# We choose to have two variational parameters for each qubit.
params = ParameterVector("θ", length=2 * size)

# A first variational layer:
for i in range(size):
qnn_circuit.ry(params[i], i)

# Here is an extended list of qubit pairs between which we want CNOT gates. This now covers all pixels connected by horizontal lines.
qnn_cnot_list = [[0, 1], [1, 2], [2, 3], [4, 5], [5, 6], [6, 7]]

for i in range(len(qnn_cnot_list)):
qnn_circuit.cx(qnn_cnot_list[i][0], qnn_cnot_list[i][1])

# The second variational layer:
for i in range(size):
qnn_circuit.rx(params[size + i], i)

# Check the circuit depth, and the two-qubit gate depth
print(qnn_circuit.decompose().depth())
print(
f"2+ qubit depth: {qnn_circuit.decompose().depth(lambda instr: len(instr.qubits) > 1)}"
)

# Combine the feature map and variational circuit
ansatz = qnn_circuit

# Combine the feature map with the ansatz
full_circuit = QuantumCircuit(num_qubits)
full_circuit.compose(feature_map, range(num_qubits), inplace=True)
full_circuit.compose(ansatz, range(num_qubits), inplace=True)

# Display the circuit
full_circuit.decompose().draw("mpl", style="clifford", fold=-1)
5
2+ qubit depth: 3

Wynik poprzedniej komórki kodu

Nie zwiększyliśmy głębokości obwodu. Zobaczmy, czy zwiększyliśmy jego zdolność do modelowania naszych obrazów.

Krok 2 ponownie

Będziemy musieli przetranspilować ten nowy obwód do uruchomienia na rzeczywistym kwantowym backendzie. Pominiemy na razie ten krok, aby zobaczyć, czy nasza rewizja obwodu wariacyjnego przyniosła pożądany efekt na symulatorach. Transpilacji przyjrzymy się bliżej w następnym podrozdziale.

Krok 3 ponownie

Teraz stosujemy zaktualizowany model do naszych danych treningowych.

from qiskit.primitives import StatevectorEstimator as Estimator

batch_size = 140
num_epochs = 1
num_samples = len(train_images)

# Globals
circuit = full_circuit
estimator = Estimator() # simulator for debugging
observables = observable
objective_func_vals = []
iter = 0

# Random initial weights for the ansatz
np.random.seed(42)
weight_params = np.random.rand(len(ansatz.parameters)) * 2 * np.pi

for epoch in range(num_epochs):
for i in range((num_samples - 1) // batch_size + 1):
print(f"Epoch: {epoch}, batch: {i}")
start_i = i * batch_size
end_i = start_i + batch_size
train_images_batch = np.array(train_images[start_i:end_i])
train_labels_batch = np.array(train_labels[start_i:end_i])
input_params = train_images_batch
target = train_labels_batch
iter = 0
res = minimize(
mse_loss_weights, weight_params, method="COBYLA", options={"maxiter": 100}
)
weight_params = res["x"]
Epoch: 0, batch: 0
Iter: 0, loss: 1.0049762969140237
Iter: 50, loss: 0.8274276543780351

Krok 4 ponownie

Zacznijmy od sprawdzenia, czy nasz optymalizator w pełni się zbiegł.

obj_func_vals_revised = objective_func_vals
# import matplotlib.pyplot as plt

plt.figure(figsize=(12, 6))
plt.plot(obj_func_vals_revised, label="revised ansatz")
plt.xlabel("iteration")
plt.ylabel("loss")
plt.legend()
plt.show()

Wynik poprzedniej komórki kodu

Nie wygląda to na pełną zbieżność, ponieważ funkcja straty nie utrzymywała się na mniej więcej stałym poziomie przez znaczną liczbę kroków. Jednak funkcja straty jest już ~60% niższa niż w przypadku użycia poprzedniego obwodu wariacyjnego. Gdyby był to projekt badawczy, chcielibyśmy zapewnić pełną zbieżność. Jednak do celów eksploracyjnych to wystarczy. Sprawdźmy dokładność na naszych danych treningowych i testowych.

from sklearn.metrics import accuracy_score
from qiskit.primitives import StatevectorEstimator as Estimator # simulator
# from qiskit_ibm_runtime import EstimatorV2 as Estimator # real quantum computer

estimator = Estimator()
# estimator = Estimator(backend=backend)

pred_train = forward(circuit, np.array(train_images), res["x"], estimator, observable)
# pred_train = forward(circuit_ibm, np.array(train_images), res['x'], estimator, observable_ibm)

print(pred_train)

pred_train_labels = copy.deepcopy(pred_train)
pred_train_labels[pred_train_labels >= 0] = 1
pred_train_labels[pred_train_labels < 0] = -1
print(pred_train_labels)
print(train_labels)

accuracy = accuracy_score(train_labels, pred_train_labels)
print(f"Train accuracy: {accuracy * 100}%")
[ 0.46144755  0.42579688  0.35255977  0.55207273 -0.48578418  0.50805845
0.44892649 0.6173847 -0.62428139 0.40405121 0.46862421 0.29503395
-0.5740469 -0.71794562 -0.45022095 -0.45330418 -0.19795258 -0.46821777
-0.5622049 -0.32114059 0.54947838 -0.4889812 0.28327445 0.58149728
-0.27026749 0.41328304 0.21119412 0.60108606 0.39204178 -0.24974605
0.38496469 0.39867586 -0.38946996 0.62616766 0.61212525 -0.49719567
0.30860002 0.68443904 -0.27505907 -0.41508947 -0.49666422 0.67716994
-0.54696613 -0.70058779 0.42711815 -0.5285338 0.37678572 0.43888249
-0.30844464 0.42347715 -0.4250844 0.67324132 0.59914067 -0.45184567
0.13604098 0.65336342 0.26099853 0.60316559 -0.38743183 -0.54784284
-0.29549031 -0.45592302 0.41613453 -0.38781528 0.56903087 0.54955451
0.55532336 -0.3931852 -0.57599675 0.61246236 0.42014135 -0.38171749
0.56760389 0.45383135 -0.50473943 -0.47551181 0.54221517 -0.64987023
0.28845851 0.54403865 0.53841148 0.64477078 0.71912049 -0.63178323
-0.50764757 0.50304637 -0.38099972 -0.27707127 -0.24353841 -0.52045267
-0.61500665 0.65443173 0.31902266 -0.64969037 -0.4814051 0.47980608
-0.649786 -0.43048551 0.34562588 0.308998 -0.32454238 0.29558168
-0.45410187 0.54600712 0.33204827 0.22627804 0.4283921 0.56191874
-0.25400294 -0.6493613 -0.47445293 0.42272138 -0.35472546 -0.52240474
-0.45207595 0.40292125 -0.3361856 -0.46620886 0.60202719 -0.56505744
0.47169796 -0.43577622 0.40689437 0.48869108 -0.39701189 -0.57698634
-0.39236332 0.31294648 0.41797597 0.63004836 -0.52884541 -0.43805812
-0.3193499 0.36860211 -0.49190995 0.65000193 0.50260077 -0.56737168
-0.29693083 -0.40956432]
[ 1. 1. 1. 1. -1. 1. 1. 1. -1. 1. 1. 1. -1. -1. -1. -1. -1. -1.
-1. -1. 1. -1. 1. 1. -1. 1. 1. 1. 1. -1. 1. 1. -1. 1. 1. -1.
1. 1. -1. -1. -1. 1. -1. -1. 1. -1. 1. 1. -1. 1. -1. 1. 1. -1.
1. 1. 1. 1. -1. -1. -1. -1. 1. -1. 1. 1. 1. -1. -1. 1. 1. -1.
1. 1. -1. -1. 1. -1. 1. 1. 1. 1. 1. -1. -1. 1. -1. -1. -1. -1.
-1. 1. 1. -1. -1. 1. -1. -1. 1. 1. -1. 1. -1. 1. 1. 1. 1. 1.
-1. -1. -1. 1. -1. -1. -1. 1. -1. -1. 1. -1. 1. -1. 1. 1. -1. -1.
-1. 1. 1. 1. -1. -1. -1. 1. -1. 1. 1. -1. -1. -1.]
[1, 1, 1, 1, -1, 1, 1, 1, -1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, 1, -1, 1, 1, -1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, 1, -1, -1, -1, -1, -1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1, 1, 1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1]
Train accuracy: 100.0%
pred_test = forward(circuit, np.array(test_images), res["x"], estimator, observable)
# pred_test = forward(circuit_ibm, np.array(test_images), res['x'], estimator, observable_ibm)

print(pred_test)

pred_test_labels = copy.deepcopy(pred_test)
pred_test_labels[pred_test_labels >= 0] = 1
pred_test_labels[pred_test_labels < 0] = -1
print(pred_test_labels)
print(test_labels)

accuracy = accuracy_score(test_labels, pred_test_labels)
print(f"Test accuracy: {accuracy * 100}%")
[-0.48396136 -0.57123828  0.28373249  0.38983869 -0.45799092 -0.63643031
0.69164877 -0.47749808 0.16965244 -0.39669469 0.39366915 0.44206948
0.69733951 0.40445979 -0.33663432 0.54511581 -0.49397081 0.55934553
0.69269512 0.38875983 0.39724004 -0.49635863 -0.19131387 0.38813936
0.39537369 -0.46262489 0.5307315 0.21783317 0.31949453 -0.49772087
0.56409526 -0.66254365 -0.57507262 0.37363552 0.35154205 0.69295687
-0.31205475 0.37787066 0.67903997 -0.29984861 -0.46435535 -0.32610974
0.4327188 0.64626537 0.37592731 -0.14328906 0.59694745 0.71880638
0.32414334 0.42119333 -0.60745236 -0.42520033 0.28334222 0.21699081
0.34837252 0.31538989 0.30754545 0.5995197 -0.34678026 -0.46587602]
[-1. -1. 1. 1. -1. -1. 1. -1. 1. -1. 1. 1. 1. 1. -1. 1. -1. 1.
1. 1. 1. -1. -1. 1. 1. -1. 1. 1. 1. -1. 1. -1. -1. 1. 1. 1.
-1. 1. 1. -1. -1. -1. 1. 1. 1. -1. 1. 1. 1. 1. -1. -1. 1. 1.
1. 1. 1. 1. -1. -1.]
[-1, -1, 1, 1, -1, -1, 1, -1, 1, -1, 1, 1, 1, 1, -1, 1, -1, 1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1, 1, 1, 1, -1, 1, 1, -1, -1, -1, 1, 1, 1, -1, 1, 1, 1, 1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1]
Test accuracy: 100.0%
```bash
$100\%$ dokładności na obu zbiorach! Nasze przypuszczenie, że dokładne wykrywanie poziomych linii jest wystarczające, okazało się słuszne! Ponadto nasze odwzorowanie wymaganych informacji o pikselach na bramki CNOT w obwodzie kwantowym było skuteczne. Przyjrzyjmy się teraz, jak ten proces skaluje się w przypadku uruchamiania na rzeczywistych komputerach kwantowych.
## Skalowanie i uruchamianie na rzeczywistych komputerach kwantowych \{#scaling-and-running-on-real-quantum-computers}

### Dane \{#data}

Zacznijmy od zwiększenia rozmiaru naszych obrazów. Wybór siatki 6x6 nie jest niczym szczególnym, poza tym, że przekracza liczbę kubitów (32), które możemy symulować dla obwodów z wykorzystaniem bramek innych niż Clifforda.

```python
# This code defines the images to be classified:

import numpy as np

# Total number of "pixels"/qubits
size = 36
# One dimension of the image (called vertical, but it doesn't matter). Must be a divisor of `size`
vert_size = 6
# The length of the line to be detected (yellow). Must be less than or equal to the smallest dimension of the image (`<=min(vert_size,size/vert_size)`
line_size = 6

def generate_dataset(num_images):
images = []
labels = []
hor_array = np.zeros((size - (line_size - 1) * vert_size, size))
ver_array = np.zeros((round(size / vert_size) * (vert_size - line_size + 1), size))

j = 0
for i in range(0, size - 1):
if i % (size / vert_size) <= (size / vert_size) - line_size:
for p in range(0, line_size):
hor_array[j][i + p] = np.pi / 2
j += 1

# Make two adjacent entries pi/2, then move down to the next row. Careful to avoid the "pixels" at size/vert_size - linesize, because we want to fold this list into a grid.

j = 0
for i in range(0, round(size / vert_size) * (vert_size - line_size + 1)):
for p in range(0, line_size):
ver_array[j][i + p * round(size / vert_size)] = np.pi / 2
j += 1

# Make entries pi/2, spaced by the length/rows, so that when folded, the entries appear on top of each other.

for n in range(num_images):
rng = np.random.randint(0, 2)
if rng == 0:
labels.append(-1)
random_image = np.random.randint(0, len(hor_array))
images.append(np.array(hor_array[random_image]))
# Randomly select one of the several rows you made above.
elif rng == 1:
labels.append(1)
random_image = np.random.randint(0, len(ver_array))
images.append(np.array(ver_array[random_image]))
# Randomly select one of the several rows you made above.

# Create noise
for i in range(size):
if images[-1][i] == 0:
images[-1][i] = np.random.rand() * np.pi / 4
return images, labels

hor_size = round(size / vert_size)

Ponieważ czas obliczeń kwantowych jest cennym zasobem, użyjemy bardzo małego zbioru treningowego i bardzo niewielu kroków optymalizacji. To wystarczy, aby zademonstrować przepływ pracy.

from sklearn.model_selection import train_test_split

np.random.seed(42)
# Here we specify a very small data set. Increase for realism, but monitor use of quantum computing time.
images, labels = generate_dataset(10)

train_images, test_images, train_labels, test_labels = train_test_split(
images, labels, test_size=0.3, random_state=246
)
import matplotlib.pyplot as plt

# Generate a figure with nested images using subplots.

fig, ax = plt.subplots(2, 2, figsize=(10, 6), subplot_kw={"xticks": [], "yticks": []})
for i in range(4):
ax[i // 2, i % 2].imshow(
train_images[i].reshape(vert_size, hor_size),
aspect="equal",
)
plt.subplots_adjust(wspace=0.1, hspace=0.025)

Wynik poprzedniej komórki kodu

Krok 1: Odwzorowanie problemu na obwód kwantowy

from qiskit.circuit.library import z_feature_map

# One qubit per data feature
num_qubits = len(train_images[0])

# Data encoding
# Note that qiskit orders parameters alphabetically. We assign the parameter prefix "a" to ensure our data encoding goes to the first part of the circuit, the feature mapping.
feature_map = z_feature_map(num_qubits, parameter_prefix="a")
# This creates a circuit with the cxs in the compressed order.

from qiskit import QuantumCircuit
from qiskit.circuit import ParameterVector

qnn_circuit = QuantumCircuit(size)
params = ParameterVector("θ", length=2 * size)
for i in range(size):
qnn_circuit.ry(params[i], i)

# CNOT gates between horizontally adjacent qubits.
for i in range(vert_size):
for j in range(hor_size):
if j < hor_size - 1:
qnn_circuit.cx((i * hor_size) + j, (i * hor_size) + j + 1)

# CNOT gates between vertically adjacent qubits, likely not necessary based on our preliminary simulation.
# if i<vert_size-1:
# qnn_circuit.cx((i*hor_size)+j,(i*hor_size)+j+hor_size)
for i in range(size):
qnn_circuit.rx(params[size + i], i)
qnn_circuit_large = qnn_circuit

print(qnn_circuit_large.decompose().depth())
print(
f"2+ qubit depth: {qnn_circuit_large.decompose().depth(lambda instr: len(instr.qubits) > 1)}"
)
# qnn_circuit_large.draw()
7
2+ qubit depth: 5

To jest rozsądna głębokość dwukubitowa. Powinniśmy być w stanie uzyskać wysokiej jakości wyniki z rzeczywistego komputera kwantowego.

# Combine the feature map and variational circuit
ansatz = qnn_circuit

# Combine the feature map with the ansatz
full_circuit = QuantumCircuit(num_qubits)
full_circuit.compose(feature_map, range(num_qubits), inplace=True)
full_circuit.compose(ansatz, range(num_qubits), inplace=True)

# Check the depth of the full circuit
print(full_circuit.decompose().depth())
print(
f"2+ qubit depth: {full_circuit.decompose().depth(lambda instr: len(instr.qubits) > 1)}"
)
11
2+ qubit depth: 5

Ponieważ używamy z_feature_map, który nie zawiera bramek CNOT, dodanie warstwy kodującej nie zwiększa naszej głębokości dwukubitowej. Możemy zwizualizować pełny obwód tutaj.

full_circuit.decompose().draw("mpl", style="clifford", idle_wires=False, fold=-1)

Wynik poprzedniej komórki z kodem

Można zauważyć, że gdyby minimalizacja głębokości dwukubitowej była sprawą pierwszorzędnego znaczenia, moglibyśmy ją nieco zmniejszyć przez zmianę kolejności bramek CNOT. Na przykład bramki CNOT na q35q_{35} i q34q_{34} można by przesunąć w lewo na powyższym diagramie obwodu i umieścić bezpośrednio pod bramkami CNOT na q30q_{30} i q31q_{31}. Przy dwukubitowej głębokości bramek równej 5 nie jest oczywiste, czy po transpilacji przyniesie to różnicę, ale warto o tym pamiętać. Jeśli kolejność bramek CNOT jest istotna dla logicznego dopasowania do rozważanego problemu, obecna głębokość jest w porządku. Jeśli zaś kolejność bramek CNOT nie jest kluczowa dla modelowania struktury danych w naszych obrazach, moglibyśmy napisać skrypt przestawiający te bramki CNOT w celu zminimalizowania głębokości.

Musimy również ponownie zdefiniować naszą obserwablę dla większych obrazów:

from qiskit.quantum_info import SparsePauliOp

observable = SparsePauliOp.from_list([("Z" * (num_qubits), 1)])

Qiskit Patterns krok 2: Optymalizacja problemu pod kątem wykonania kwantowego

Zaczynamy od wyboru backendu do wykonania. W tym przypadku użyjemy najmniej obciążonego backendu.

from qiskit_ibm_runtime import QiskitRuntimeService

# To run on hardware, select the least busy quantum computer or specify a particular one.
service = QiskitRuntimeService()
backend = service.least_busy(operational=True, simulator=False)
# backend = service.backend("ibm_brisbaneane")

print(backend.name)
ibm_brisbane

Ponownie definiujemy pass manager z poziomem optymalizacji ustawionym na 3.

from qiskit.circuit.library import XGate
from qiskit.transpiler import PassManager
from qiskit.transpiler.passes import (
ALAPScheduleAnalysis,
ConstrainedReschedule,
PadDynamicalDecoupling,
)
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager

target = backend.target

pm = generate_preset_pass_manager(target=target, optimization_level=3)
pm.scheduling = PassManager(
[
ALAPScheduleAnalysis(target=target),
ConstrainedReschedule(
acquire_alignment=target.acquire_alignment,
pulse_alignment=target.pulse_alignment,
target=target,
),
PadDynamicalDecoupling(
target=target,
dd_sequence=[XGate(), XGate()],
pulse_alignment=target.pulse_alignment,
),
]
)

Teraz zastosujemy pass manager kilkakrotnie. W przypadku bardzo szerokich lub bardzo głębokich obwodów może występować duża zmienność transpilowanych głębokości dwukubitowych. Dla takich obwodów ważne jest, aby wielokrotnie uruchomić pass manager i wykorzystać najlepszy (najpłytszy) wynik.

# Try pass manager several times, since heuristics can return various transpilations on large circuits, and we want the shallowest.

transpiled_qcs = []
transpiled_depths = []
transpiled_2q_depths = []
for i in range(1, 10):
circuit_ibm = pm.run(full_circuit)
transpiled_qcs.append(circuit_ibm)
transpiled_depths.append(circuit_ibm.decompose().depth())
transpiled_2q_depths.append(
circuit_ibm.decompose().depth(lambda instr: len(instr.qubits) > 1)
)
# print(i)

print(transpiled_depths)
print(transpiled_2q_depths)

# Use the shallowest

minpos = transpiled_2q_depths.index(min(transpiled_2q_depths))
[85, 85, 81, 89, 81, 81, 89, 85, 85]
[10, 10, 10, 10, 10, 10, 10, 10, 10]

Widzimy, że w tym przypadku transpilowana głębokość dwukubitowa zawsze wynosiła 10. Wystąpiła niewielka zmienność głębokości jednokubitowej i wykorzystamy najpłytszy wynik. Jednakże w tym 36-kubitowym obwodzie nie jest to krytyczne ulepszenie. Możemy zwizualizować ten transpilowany obwód, choć w tej skali staje się on coraz trudniejszy do wizualnego przeanalizowania.

circuit_ibm = transpiled_qcs[2]
observable_ibm = observable.apply_layout(circuit_ibm.layout)
print(circuit_ibm.decompose().depth())
print(
f"2+ qubit depth: {circuit_ibm.decompose().depth(lambda instr: len(instr.qubits) > 1)}"
)
81
2+ qubit depth: 10

Qiskit Patterns krok 3: Wykonanie przy użyciu Qiskit Primitives

Aby ograniczyć czas używany na rzeczywistych komputerach kwantowych, wykonamy tutaj tylko kilka kroków optymalizacji i robimy to na bardzo małym zbiorze treningowym. Jednak skalowanie tego do większej liczby kroków optymalizacji oraz większych zbiorów testowych powinno być jasne na podstawie instrukcji zawartych w całej lekcji.

# This was run on an Eagle r3 processor on 10-4-24, and took 7 min.

from qiskit_ibm_runtime import EstimatorV2 as Estimator, Session

batch_size = 7
num_epochs = 1
num_samples = len(train_images)

# Globals
circuit = circuit_ibm
observable = observable_ibm
objective_func_vals = []
iter = 0

# Random initial weights for the ansatz
np.random.seed(42)
# weight_params = np.random.rand(len(ansatz.parameters)) * 2 * np.pi
# Or re-load weights from a previous calculation
weight_params = np.array(
[
3.35330497,
5.97351416,
4.59925358,
3.76148219,
0.98029403,
0.98014248,
0.3649501,
6.44234523,
3.77691701,
4.44895122,
0.12933619,
6.09412333,
5.23039137,
1.33416598,
1.14243996,
1.15236452,
1.91161039,
3.2971419,
3.71399059,
1.82984665,
3.84438512,
0.87646578,
1.83559896,
2.30191935,
2.86557222,
4.93340606,
1.25458737,
3.23103027,
3.72225051,
0.29185655,
3.81731689,
1.07143467,
0.40873121,
5.96202367,
6.067245,
5.07931034,
1.91394476,
0.61369199,
4.2991629,
2.76555968,
0.76678884,
3.11128829,
0.21606945,
5.71342859,
1.62596258,
4.16275028,
1.95853845,
3.26768375,
3.43508199,
1.1614748,
6.09207989,
4.87030317,
5.90304595,
5.62236606,
3.75671636,
5.79230665,
0.55601479,
1.23139664,
0.28417144,
2.04411075,
2.44213144,
1.70493625,
5.20711134,
2.24154726,
1.76516358,
3.40986006,
0.88545302,
5.04035228,
0.46841551,
6.2007935,
4.85215699,
1.24856745,
]
)

# Running in a session avoids repeated queuing. This is available to Premium Plan, Flex Plan, and On-Prem (IBM Quantum Platform API) Plan users.

with Session(backend=backend) as session:
estimator = Estimator(mode=session, options={"resilience_level": 1})

for epoch in range(num_epochs):
for i in range((num_samples - 1) // batch_size + 1):
print(f"Epoch: {epoch}, batch: {i}")
start_i = i * batch_size
end_i = start_i + batch_size
train_images_batch = np.array(train_images[start_i:end_i])
train_labels_batch = np.array(train_labels[start_i:end_i])
input_params = train_images_batch
target = train_labels_batch
iter = 0
# We can increase maxiter to do a full optimization.
res = minimize(
mse_loss_weights,
weight_params,
method="COBYLA",
options={"maxiter": 20},
)
weight_params = res["x"]
session.close()

# Open users can carry out the same calculation using a batch, but repeated queuing is possible.
# from qiskit_ibm_runtime import Batch

# with Batch(backend=backend) as batch:
# estimator = Estimator(
# mode=batch, options={"resilience_level": 1}
# )
#
# for epoch in range(num_epochs):
# for i in range((num_samples - 1) // batch_size + 1):
# print(f"Epoch: {epoch}, batch: {i}")
# start_i = i * batch_size
# end_i = start_i + batch_size
# train_images_batch = np.array(train_images[start_i:end_i])
# train_labels_batch = np.array(train_labels[start_i:end_i])
# input_params = train_images_batch
# target = train_labels_batch
# iter = 0
# # We can increase maxiter to do a full optimization.
# res = minimize(
# mse_loss_weights,
# weight_params,
# method="COBYLA",
# options={"maxiter": 20},
# )
# weight_params = res["x"]
# batch.close()

Zaleca się zapisanie parametrów wag zwróconych z tego obliczenia, na wypadek gdybyś zdecydował się kontynuować iteracje.

weight_params
array([3.35330497, 6.97351416, 5.59925358, 3.76148219, 0.98029403,
0.98014248, 0.3649501 , 6.44234523, 3.77691701, 4.44895122,
1.12933619, 7.09412333, 5.23039137, 1.33416598, 1.14243996,
1.15236452, 1.91161039, 3.2971419 , 3.71399059, 1.82984665,
3.84438512, 0.87646578, 1.83559896, 2.30191935, 2.86557222,
4.93340606, 1.25458737, 3.23103027, 3.72225051, 0.29185655,
3.81731689, 1.07143467, 0.40873121, 5.96202367, 6.067245 ,
5.07931034, 1.91394476, 0.61369199, 4.2991629 , 2.76555968,
0.76678884, 3.11128829, 0.21606945, 5.71342859, 1.62596258,
4.16275028, 1.95853845, 3.26768375, 3.43508199, 1.1614748 ,
6.09207989, 4.87030317, 5.90304595, 5.62236606, 3.75671636,
5.79230665, 0.55601479, 1.23139664, 0.28417144, 2.04411075,
2.44213144, 1.70493625, 5.20711134, 2.24154726, 1.76516358,
3.40986006, 0.88545302, 5.04035228, 0.46841551, 6.2007935 ,
4.85215699, 1.24856745])

Możemy wykreślić te kilka pierwszych kroków optymalizacji, chociaż nie powinniśmy oczekiwać żadnej zbieżności po zaledwie kilku krokach optymalizacji. Krzywe te były stosunkowo płaskie przez kilka pierwszych kroków, nawet przy użyciu symulatorów. Należy jednak zauważyć, że optymalizacja ma obecnie 72 wolne parametry. Można to zredukować co najmniej 2-3-krotnie bez pogorszenia wyników, na przykład parametryzując kubity danymi odpowiadającymi podzbiorowi pełnych wierszy i kolumn. Istotnie, przestrzeń parametrów powinna zostać zredukowana przed poświęceniem większej ilości czasu obliczeń kwantowych na minimalizację funkcji straty.

obj_func_vals_qc = objective_func_vals
# import matplotlib.pyplot as plt

plt.figure(figsize=(12, 6))
plt.plot(obj_func_vals_qc, label="revised ansatz")
plt.xlabel("iteration")
plt.ylabel("loss")
plt.legend()
plt.show()

Wynik poprzedniej komórki kodu

Podsumowanie

Podsumowując, w tej lekcji poznaliśmy przepływ pracy dla klasyfikacji binarnej obrazów przy użyciu kwantowej sieci neuronowej. Oto kilka kluczowych kwestii w każdym z kroków wzorców Qiskit:

Krok 1: Odwzoruj problem na obwód kwantowy

  • Wczytaj dane treningowe. Można to zrobić „ręcznie" lub za pomocą gotowej mapy cech, takiej jak z_feature_map.
  • Skonstruuj ansatz zawierający warstwy rotacji i splątania odpowiednie dla Twojego problemu.
  • Monitoruj głębokość obwodu, aby zapewnić wyniki wysokiej jakości na komputerach kwantowych.

Krok 2: Zoptymalizuj problem pod kątem wykonania kwantowego

  • Wybierz backend, zazwyczaj najmniej obciążony.
  • Użyj menedżera przejść (pass manager) do transpilacji zarówno obwodu, jak i obserwabli do architektury wybranego backendu.
  • W przypadku bardzo głębokich lub szerokich obwodów dokonaj transpilacji wielokrotnie i wybierz najpłytszy obwód.

Krok 3: Wykonaj za pomocą prymitywów Qiskit (Runtime)

  • Przeprowadź wstępne próby na symulatorach w celu debugowania i optymalizacji swojego ansatzu.
  • Wykonaj na komputerze kwantowym IBM®.

Krok 4: Przetwórz wyniki, zwróć rezultat w formacie klasycznym

  • Oblicz dokładność modelu na danych treningowych oraz na danych testowych.
  • Monitoruj zbieżność klasycznej optymalizacji.

Bibliografia