I read this article on a Hybrid Quantum LSTM in Pennylane and I'm trying to replicate it in Qiskit. Nevertheless it doesn't seem to work very well. Here's my code
from typing import Optional, Union
import numpy as np
from qiskit import QuantumCircuit
from qiskit.circuit.library import ZZFeatureMap, RealAmplitudes
from qiskit.providers import Backend, BaseBackend
from qiskit.utils import QuantumInstance
from qiskit.opflow import StateFn, PauliSumOp, ListOp, AerPauliExpectation
from qiskit_machine_learning.neural_networks import OpflowQNN
import torch
import torch.nn as nn
from torch import Tensor
class QLongShortTermMemory(nn.Module):
def __init__(self,
input_size: int,
hidden_size: int,
n_layers: Optional[int] = 1,
n_qubits: Optional[int] = 4,
batch_first: Optional[bool] = True,
feature_map: QuantumCircuit = None,
ansatz: QuantumCircuit = None,
quantum_instance: Optional[Union[QuantumInstance, BaseBackend, Backend]] = None
):
super(QLongShortTermMemory, self).__init__()
self.input_size = input_size
self.hidden_size = hidden_size
self.n_layers = n_layers
self.batch_first = batch_first
self._qlayers = {}
self._set_quantum_instance(quantum_instance)
# layers preparation
if feature_map:
if feature_map.num_qubits == n_qubits:
_feature_map = feature_map
else:
raise ValueError(f"Incompatible parameter n_qubits={n_qubits} with "
f"feature_map of {feature_map.num_qubits} qubits")
else:
_feature_map = ZZFeatureMap(n_qubits)
_ansatz = ansatz if ansatz else \
RealAmplitudes(n_qubits, entanglement='linear', reps=n_layers)
# quantum layers
self._construct_quantum_layers(_feature_map, _ansatz)
# classical layers
self.clayer_in = nn.Linear(input_size + hidden_size, n_qubits)
self.clayer_out = nn.Linear(n_qubits, hidden_size)
def _construct_quantum_layers(self, feature_map, ansatz):
for layer_name in ['forget', 'input', 'update', 'output']:
# define the layer using OpflowQNN from qiskit ml
n_inputs = feature_map.num_qubits
qc = QuantumCircuit(n_inputs)
qc.append(feature_map, range(n_inputs))
qc.append(ansatz, range(n_inputs))
readout_op = ListOp([
~StateFn(PauliSumOp.from_list([('Z' * self.hidden_size, 1)])) @ StateFn(qc)
] * n_inputs)
input_params = list(feature_map.parameters)
weight_params = list(ansatz.parameters)
layer = OpflowQNN(operator=readout_op,
input_params=input_params,
weight_params=weight_params,
exp_val=AerPauliExpectation(),
quantum_instance=self.quantum_instance
)
initial_weights = Tensor(np.zeros(n_inputs * self.n_layers * 2))
self._qlayers[layer_name] = TorchConnector(layer, initial_weights=initial_weights)
def _set_quantum_instance(
self,
quantum_instance: Optional[Union[QuantumInstance, BaseBackend, Backend]]):
if isinstance(quantum_instance, (BaseBackend, Backend)):
quantum_instance = QuantumInstance(quantum_instance)
self._quantum_instance = quantum_instance
def forward(self,
x: Tensor,
input_data: Optional[Tensor] = None):
if self.batch_first:
batch, seq, _ = x.size()
else:
seq, batch, _ = x.size()
hidden_seq = []
if input_data is None:
h_t = torch.zeros(batch, self.hidden_size) # hidden state (output)
c_t = torch.zeros(batch, self.hidden_size) # cell state
else:
h_t, c_t = input_data.detach()
for t in range(seq):
# get features from the t-th element in seq, for all entries in the batch
x_t = x[:, t, :]
# concatenate input and hidden state
v_t = torch.cat((h_t, x_t), dim=1)
# match qubit dimension
y_t = self.clayer_in(v_t)
# for each time step `t` we compute the forget, input, update and output gates
# using 3 sigmoid layers and a hyperbolic tangent layer.
# forget
f_t = torch.sigmoid(self.clayer_out(self._qlayers['forget'](y_t)))
# input
i_t = torch.sigmoid(self.clayer_out(self._qlayers['input'](y_t)))
# update
g_t = torch.tanh(self.clayer_out(self._qlayers['update'](y_t)))
# output
o_t = torch.sigmoid(self.clayer_out(self._qlayers['output'](y_t)))
# eventually, the hidden state and the cell state are evaluated
# (see RNN architecture)
c_t = (f_t * c_t) + (i_t * g_t)
h_t = o_t * torch.tanh(c_t)
hidden_seq.append(h_t.unsqueeze(0))
# update hidden seq
hidden_seq = torch.cat(hidden_seq, dim=0)
hidden_seq = hidden_seq.transpose(0, 1).contiguous()
return hidden_seq, (h_t, c_t)
I'm using the same NLP example proposed in the article and taken from Pytorch documentation on LSTM.
# see tutorial: https://pytorch.org/tutorials/beginner/nlp/sequence_models_tutorial.html
import numpy as np
import qiskit
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from matplotlib import pyplot as plt
tag_to_ix = {"DET": 0, "NN": 1, "V": 2} # Assign each tag with a unique index
ix_to_tag = {i: k for k, i in tag_to_ix.items()}
def prepare_sequence(seq, to_ix):
idxs = [to_ix[w] for w in seq]
return torch.tensor(idxs, dtype=torch.long)
training_data = [
# Tags are: DET - determiner; NN - noun; V - verb
# For example, the word "The" is a determiner
("The dog ate the apple".split(), ["DET", "NN", "V", "DET", "NN"]),
("Everybody read that book".split(), ["NN", "V", "DET", "NN"])
]
word_to_ix = {}
# For each words-list (sentence) and tags-list in each tuple of training_data
for sent, tags in training_data:
for word in sent:
if word not in word_to_ix: # word has not been assigned an index yet
word_to_ix[word] = len(word_to_ix) # Assign each word with a unique index
print(f"Vocabulary: {word_to_ix}")
print(f"Entities: {ix_to_tag}")
class LSTMTagger(nn.Module):
def __init__(self, embedding_dim, hidden_dim, vocab_size, tagset_size, n_qubits=0, backend='default.qubit'):
super(LSTMTagger, self).__init__()
self.hidden_dim = hidden_dim
self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
# The LSTM takes word embeddings as inputs, and outputs hidden states
# with dimensionality hidden_dim.
if n_qubits > 0:
seed = 71
np.random.seed = seed
from qiskit.utils import QuantumInstance, algorithm_globals
algorithm_globals.random_seed = seed
quantum_instance = QuantumInstance(
backend=qiskit.Aer.get_backend("aer_simulator_statevector"), seed_transpiler=seed, seed_simulator=seed,
backend_options={"device": 'CPU', "max_parallel_experiments": 0}
)
print(f"Tagger will use Quantum LSTM running on backend {backend}")
self.lstm = QLongShortTermMemory(embedding_dim, hidden_dim, n_qubits=n_qubits, quantum_instance=quantum_instance)
else:
print("Tagger will use Classical LSTM")
self.lstm = nn.LSTM(embedding_dim, hidden_dim)
# The linear layer that maps from hidden state space to tag space
self.hidden2tag = nn.Linear(hidden_dim, tagset_size)
def forward(self, sentence):
embeds = self.word_embeddings(sentence)
lstm_out, _ = self.lstm(embeds.view(len(sentence), 1, -1))
tag_logits = self.hidden2tag(lstm_out.view(len(sentence), -1))
tag_scores = F.log_softmax(tag_logits, dim=1)
return tag_scores
if __name__ == '__main__':
###############################
# Change manually this params
###############################
embedding_dim = 8
hidden_dim = 4
n_qubits = 2
n_epochs = 300
print(f"Embedding dim: {embedding_dim}")
print(f"LSTM output size: {hidden_dim}")
print(f"Number of qubits: {n_qubits}")
print(f"Training epochs: {n_epochs}")
model = LSTMTagger(embedding_dim,
hidden_dim,
vocab_size=len(word_to_ix),
tagset_size=len(tag_to_ix),
n_qubits=n_qubits,
backend='foo')
loss_function = nn.NLLLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
history = {
'loss': [],
'acc': []
}
for epoch in range(n_epochs):
losses = []
preds = []
targets = []
for sentence, tags in training_data:
# Step 1. Remember that Pytorch accumulates gradients.
# We need to clear them out before each instance
model.zero_grad()
# Step 2. Get our inputs ready for the network, that is, turn them into
# Tensors of word indices.
sentence_in = prepare_sequence(sentence, word_to_ix)
labels = prepare_sequence(tags, tag_to_ix)
# Step 3. Run our forward pass.
tag_scores = model(sentence_in)
# Step 4. Compute the loss, gradients, and update the parameters by
# calling optimizer.step()
loss = loss_function(tag_scores, labels)
loss.backward()
optimizer.step()
losses.append(float(loss))
probs = torch.softmax(tag_scores, dim=-1)
preds.append(probs.argmax(dim=-1))
targets.append(labels)
avg_loss = np.mean(losses)
history['loss'].append(avg_loss)
# print("preds", preds)
preds = torch.cat(preds)
targets = torch.cat(targets)
corrects = (preds == targets)
accuracy = corrects.sum().float() / float(targets.size(0))
history['acc'].append(accuracy)
print(f"Epoch {epoch + 1} / {n_epochs}: Loss = {avg_loss:.3f} Acc = {accuracy:.2f}")
# See what the scores are after training
with torch.no_grad():
input_sentence = training_data[0][0]
labels = training_data[0][1]
inputs = prepare_sequence(input_sentence, word_to_ix)
tag_scores = model(inputs)
tag_ids = torch.argmax(tag_scores, dim=1).numpy()
tag_labels = [ix_to_tag[k] for k in tag_ids]
print(f"Sentence: {input_sentence}")
print(f"Labels: {labels}")
print(f"Predicted: {tag_labels}")
What happens is that the loss doesn't actually decrease as in the version with PennyLane and the accuracy never reaches 1. Also, it's way slower, but I suspect this is qiskit backend's fault.
I'm a CS Student who recently approached Quantum, then my apologies if my mistake is something very obvious. Thanks in advance=)
EDIT:
to make it easier to help me out, I show here the only parts of the code related to qiskit/pennylane which are very likely where the problem is
Pennylane version:
import pennylane as qml
def _circuit(inputs, weights):
qml.templates.AngleEmbedding(inputs, wires=range(n_qubits))
qml.templates.BasicEntanglerLayers(weights, wires=range(n_qubits))
return [qml.expval(qml.PauliZ(wires=i)) for i in range(n_qubits)]
Qiskit version:
have look at the method _construct_quantum_layers
, mainly this part
n_inputs = feature_map.num_qubits
qc = QuantumCircuit(n_inputs)
qc.append(feature_map, range(n_inputs))
qc.append(ansatz, range(n_inputs))
readout_op = ListOp([
~StateFn(PauliSumOp.from_list([('Z' * self.hidden_size, 1)])) @ StateFn(qc)
] * n_inputs)
input_params = list(feature_map.parameters)
weight_params = list(ansatz.parameters)
layer = OpflowQNN(operator=readout_op,
input_params=input_params,
weight_params=weight_params,
exp_val=AerPauliExpectation(),
quantum_instance=self.quantum_instance
)