QNN for the XOR Problem

Classiq has an available dataset for training a PQC (parameterized quantum circuit) to imitate the XOR gate, similar to how we trained a U-gate to act as a NOT gate. Design a QNN to solve the XOR problem. Read more on the dataset here.

Let's remember that the behavior of the XOR gate is as follows:

b0 b1 XOR
0 0 0
0 1 1
1 0 1
1 1 0

Where b0 and b1 are bits.

Now, we can get the same behavior in a quantum circuit using the CNOT quantum gate as follows:

$CNOT|00\rangle = |00\rangle$
$CNOT|01\rangle = |01\rangle$
$CNOT|10\rangle = |11\rangle$
$CNOT|11\rangle = |10\rangle$

The left qubit is the control, and the right qubit is the target. We can observe that we get the result of the XOR operator in the target qubit (the right one).

Knowing this, I adjusted a parameter within the CRX gate so that we would ultimately determine the CNOT gate; that is, the parameter would be equal to $$\pi$$.

It should be noted that the equivalent of gates can be used:

$CNOT = (I\otimes H) \, (CZ) \, (I\otimes H)$

With these gates, the desired behavior is also found, but it takes more iterations, and more oscillations were observed in the calculation of the parameter, which is why the code is left with the CRX gate.

First let's see what the Dataset for the XOR operation is like:

from classiq.applications.qnn.datasets import DATALOADER_XOR

print(f"--> Data for training:\n{data}")
print(f"--> Corresponding labels:\n{label}")

--> Data for training:
tensor([[1., 0.],
[0., 1.],
[0., 0.],
[1., 1.]])
--> Corresponding labels:
tensor([1., 1., 0., 0.])


We can observe an important difference with respect to the dataset for the NOT, that in this case, the inputs are indicated as states 0 and 1, instead of an angle that can be used in the CRX gate to embed the inputs in the quantum circuit.

Knowing that, we can still continue using the CRX gate, since we will only need to encode the states 0 and 1, so we take that information that the dataset will provide and multiply it by $$\pi$$, so that we will have the corresponding state after performing the encoding (encoding method).

And for what was previously mentioned, in the mixing method a CRX gate will be used, with a single parameter (angle) to be determined by the Quantum Neural Network.

import numpy as np

from classiq import (
CRX,
CRZ,
RX,
CInt,
CReal,
H,
Output,
QArray,
QBit,
allocate,
qfunc,
show,
synthesize,
)
from classiq.qmod.quantum_function import create_model

@qfunc
def encoding(state0: CInt, state1: CInt, q: QArray[QBit]) -> None:
RX(theta=state0 * np.pi, target=q[0])
RX(theta=state1 * np.pi, target=q[1])

@qfunc
def mixing(theta: CReal, q: QArray[QBit]) -> None:

# H(q[0])                   # these three gates are equivalent to CRX,
# CRZ(theta, q[1], q[0])    #      this option also works,
# H(q[0])                   #      but takes more iterations

CRX(theta, q[1], q[0])

@qfunc
def main(
input_0: CInt, input_1: CInt, weight_0: CReal, res: Output[QArray[QBit]]
) -> None:
allocate(2, res)
encoding(
state0=input_0, state1=input_1, q=res
)  # loading input (two values, four possible combinations): 00, 01, 10, 11
mixing(theta=weight_0, q=res)  # adjustable parameter (an angle)

model = create_model(main)

quantum_program = synthesize(model)
show(quantum_program)

Opening: https://platform.classiq.io/circuit/570ed46d-be34-442f-a2a5-1ee856e6606d?version=0.42.2


We define the method that will be responsible for calling execute_qnn, given the simplicity of this QNN we do not need more configuration.

Then the method that will do the post-processing is defined, so that comparisons of what is measured from the quantum circuit can be carried out against the expected labels according to what is specified by the XOR dataset provided.

Here we note that states 01 and 10 should give us a 1 as a result of the XOR, and since we are applying a gate that we want to behave like a CNOT, then the outputs must be 01 and 11 respectively, and for the other two states we want an output equal to 0 (the result of the XOR).

import torch

from classiq.applications.qnn.types import (
MultipleArguments,
ResultsCollection,
SavedResult,
)
from classiq.execution import execute_qnn
from classiq.synthesis import SerializedQuantumProgram

def execute(
quantum_program: SerializedQuantumProgram, arguments: MultipleArguments
) -> ResultsCollection:
return execute_qnn(quantum_program, arguments)

# Post-process the result
# This function validates if output is 01 or 11 then the result
#   of the XOR operation is '1', and since we don't want the
#   other two (00, 10) we substract their probailities
# The returning value will correspond to the XOR result, therefore
#   if we calculate a negative value, then we simply return a zero
def post_process(result: SavedResult) -> torch.Tensor:
"""
Take in a SavedResult with ExecutionDetails value type, and return the
probability of measuring |01> + |11> - |00> - |10>
"""
counts: dict = result.value.counts
xor_result: float = (
counts.get("01", 0.0) / sum(counts.values())
+ counts.get("11", 0.0) / sum(counts.values())
- counts.get("00", 0.0) / sum(counts.values())
- counts.get("10", 0.0) / sum(counts.values())
)



Now we create a neural network, to which we add a QLayer, which is responsible for executing the quantum circuit.

This process is the same as creating a NN with PyTorch, only we use a special layer provided by the Classiq SDK.

import torch

from classiq.applications.qnn import QLayer

class Net(torch.nn.Module):
def __init__(self, *args, **kwargs) -> None:
super().__init__()
self.qlayer = QLayer(
quantum_program,  # the quantum program, the result of synthesize()
execute,  # a callable that takes
# - a quantum program
# - parameters to that program (a tuple of dictionaries)
# and returns a ResultsCollection
post_process,  # a callable that takes
# - a single SavedResult
# and returns a torch.Tensor
*args,
**kwargs
)
# self.qlayer.weight.data.

def forward(self, x: torch.Tensor) -> torch.Tensor:
# return the new parameter
return self.qlayer(x)

model = Net()


We indicate the dataset for the XOR operation. We continue using the Mean Absolute Error to calculate how different the output of the labels is, this function works because we are calculating it that way within post_process. And finally we continue using Stochastic Gradient Descent as an optimizer, since it is good enough to determine the desired parameter.

import torch.nn as nn
import torch.optim as optim

_LEARNING_RATE = 1

# choosing our data
# choosing our loss function
loss_func = nn.L1Loss()  # Mean Absolute Error (MAE)
# choosing our optimizer
optimizer = optim.SGD(model.parameters(), lr=_LEARNING_RATE)


We train the QNN. We print the labels right next to the model output to see the progress, as well as the current parameter in the corresponding iteration (the epoch).

import torch.nn as nn
import torch.optim as optim

def train(
model: nn.Module,
loss_func: nn.modules.loss._Loss,
optimizer: optim.Optimizer,
epoch: int = 1,  # To achieve reasonable results, change the value to 20 or more
) -> None:
for index in range(epoch):
print(index, model.qlayer.weight)

output = model(data)

print(
"label:", label
)  # print the expected values along side with the calculated ones
print("output:", output)

loss = loss_func(output, label)
loss.backward()

optimizer.step()


0 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.5645, 0.0000], grad_fn=<QLayerFunctionBackward>)
1 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([1.0000, 0.0000, 0.9404, 0.0000], grad_fn=<QLayerFunctionBackward>)
2 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.9824, 0.0000], grad_fn=<QLayerFunctionBackward>)
3 Parameter containing:
label: tensor([0., 0., 1., 1.])
output: tensor([0.0000, 0.9922, 1.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)
4 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.9961, 0.0000], grad_fn=<QLayerFunctionBackward>)
5 Parameter containing:
label: tensor([0., 1., 1., 0.])
output: tensor([0.0000, 0.0000, 1.0000, 0.9951], grad_fn=<QLayerFunctionBackward>)
6 Parameter containing:
label: tensor([1., 0., 1., 0.])
output: tensor([0.0000, 0.0000, 1.0000, 0.9922], grad_fn=<QLayerFunctionBackward>)
7 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([0.0000, 0.9775, 0.0000, 1.0000], grad_fn=<QLayerFunctionBackward>)
8 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([1.0000, 0.9600, 0.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)
9 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([1.0000, 0.0000, 0.8643, 0.0000], grad_fn=<QLayerFunctionBackward>)
10 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([1.0000, 0.0000, 0.9766, 0.0000], grad_fn=<QLayerFunctionBackward>)
11 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.0000, 0.9922], grad_fn=<QLayerFunctionBackward>)
12 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.0000, 0.9834], grad_fn=<QLayerFunctionBackward>)
13 Parameter containing:
label: tensor([1., 0., 1., 0.])
output: tensor([0.0000, 0.0000, 1.0000, 0.9639], grad_fn=<QLayerFunctionBackward>)
14 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([0.0000, 1.0000, 0.0000, 0.9043], grad_fn=<QLayerFunctionBackward>)
15 Parameter containing:
label: tensor([0., 0., 1., 1.])
output: tensor([0.0000, 0.7734, 1.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)
16 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([0.0000, 0.0000, 0.3936, 1.0000], grad_fn=<QLayerFunctionBackward>)
17 Parameter containing:
label: tensor([0., 1., 0., 1.])
output: tensor([0.8896, 1.0000, 0.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)
18 Parameter containing:
label: tensor([0., 1., 0., 1.])
output: tensor([0.8496, 0.0000, 0.0000, 1.0000], grad_fn=<QLayerFunctionBackward>)
19 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([0.0000, 1.0000, 0.0000, 0.8682], grad_fn=<QLayerFunctionBackward>)
20 Parameter containing:
label: tensor([0., 1., 0., 1.])
output: tensor([0.0000, 1.0000, 0.7402, 0.0000], grad_fn=<QLayerFunctionBackward>)
21 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.9336, 0.0000], grad_fn=<QLayerFunctionBackward>)
22 Parameter containing:
label: tensor([1., 0., 1., 0.])
output: tensor([0.0000, 0.9775, 1.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)
23 Parameter containing:
label: tensor([1., 0., 0., 1.])
output: tensor([0.0000, 0.9600, 0.0000, 1.0000], grad_fn=<QLayerFunctionBackward>)
24 Parameter containing:
label: tensor([1., 0., 1., 0.])
output: tensor([1.0000, 0.0000, 0.0000, 0.8779], grad_fn=<QLayerFunctionBackward>)
25 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.0000, 0.8018], grad_fn=<QLayerFunctionBackward>)
26 Parameter containing:
label: tensor([0., 1., 1., 0.])
output: tensor([0.0000, 0.0000, 1.0000, 0.6113], grad_fn=<QLayerFunctionBackward>)
27 Parameter containing:
label: tensor([0., 1., 0., 1.])
output: tensor([0.0000, 0.0156, 0.0000, 1.0000], grad_fn=<QLayerFunctionBackward>)
28 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([1.0000, 0.0000, 0.0000, 0.4404], grad_fn=<QLayerFunctionBackward>)
29 Parameter containing:
label: tensor([1., 1., 0., 0.])
output: tensor([0.2246, 1.0000, 0.0000, 0.0000], grad_fn=<QLayerFunctionBackward>)


Finally we check the accuracy, in order to know how good the model is, how well the QNN has found the desired parameter.

We observe that the accuary is 100%, so we can have the complete example for the XOR operation.

def check_accuracy(model: nn.Module, data_loader: DataLoader, atol=1e-4) -> float:
num_correct = 0
total = 0
model.eval()

# let the model predict
predictions = model(data)
print("predictions:", predictions)
print("labels:     ", labels)

# get a tensor of booleans, indicating if each label is close to the real label
is_prediction_correct = predictions.isclose(labels, atol=atol)

# count the amount of True predictions
num_correct += is_prediction_correct.sum().item()
# count the total evaluations, the first dimension of labels is batch_size
total += labels.size(0)

accuracy = float(num_correct) / float(total)
return accuracy


predictions: tensor([0.0000, 0.0000, 1.0000, 0.6318], requires_grad=True)