Skip to content

Intro

In this example, we will show a simple example of parametric quantum circuit (PQC).

We will take 1 input from the user, and consider 1 weight, while utilizing 1 qubit in the PQC. During this example, the goal of the learning process is to assess the right angle for a Rx gate for performing a "NOT" operation (spoiler, the correct answer is \(\pi\)).

General flow

In section 1 we will see the code required for defining a quantum layer. This will include:

  • section 1.1: defining the quantum circuit, in the OpenQASM3 format
  • section 1.2: a callable which handles execution of parametric quantum circuits
  • section 1.3: defining a torch.nn.Module network

In section 2 we will choose our dataset, loss function, and optimizer. Section 3 will demostrate how to handle the learning process, and section 4 will test our network's performance.

Section 1.1 requires some familiarity with the OpenQASM3 format. Section 1.2 uses Classiq's executor, and formalizes the output to a torch.Tensor object. Sections 1.3 onwards requires familiarity with PyTorch.

If you're not familiar with PyTorch, it is highly recommended that you'll check out the following pages from their documentation:

Step 1 - Create our torch.nn.Module

Step 1.1 - Create our parametric quantum circuit

Our OpenQASM3 code will be defined as:

from typing import Dict
from classiq import ModelDesigner
from classiq.builtin_functions import HardwareEfficientAnsatz
from classiq.model_designer.wire import Wire  # for type hint

_NUM_QUBITS = 1
_REPS = 1
_CONNECTIVITY_MAP = "circular"


def add_rx(md: ModelDesigner, prefix: str, in_wire=None) -> Dict[str, Wire]:
    if in_wire is not None:
        kwargs = {"in_wires": {"IN": in_wire["OUT"]}}
    else:
        kwargs = {}

    hwea_params = HardwareEfficientAnsatz(
        num_qubits=_NUM_QUBITS,
        connectivity_map=_CONNECTIVITY_MAP,
        reps=_REPS,
        one_qubit_gates="rx",
        two_qubit_gates=[],
        parameter_prefix=prefix,
    )
    return md.HardwareEfficientAnsatz(hwea_params, **kwargs)


model_designer = ModelDesigner()
out1 = add_rx(model_designer, "input_")
out2 = add_rx(model_designer, "weight_", out1)

circuit = model_designer.synthesize()

The input (input_0), logically indicating either the state |0> or |1>, will be trasformed into an angle, either 0 or pi.

Step 1.2 - Create our execution and post-processing

Here, we wish to define a function that takes in a parametric quantum circuit + parameters, execute the circuit, and return the result. There are 2 things to note:

  1. The execution may happen on a physical computer, or on a simulator. In any way, we will implement the execution using Classiq's executor
  2. We will take the result of the execution, and will apply some post processing into a single number (float), and then into a 1D Tensor.
import torch
from torch import Tensor

from classiq import Executor
from classiq.applications.qnn.types import (
    Circuit,
    MultipleArguments,
    ExecutionDetails,
    MultipleExecutionDetails,
)

# You may edit this line with your own Executor preferences
_MY_EXECUTOR = Executor()


# Here we wrap the call to Classiq's Executor.execute
def execute(circuit: Circuit, arguments: MultipleArguments) -> MultipleExecutionDetails:
    """
    Wrap the call to Classiq's Executor.execute.
    """
    return _MY_EXECUTOR.execute_quantum_program_batch(circuit.to_program(), arguments)


# Here we post process the result, returning a dict
# Note: this function assumes that we only care about
#   differentiating a single state (|0>)
#   from all the rest of the states.
#   In case of a different differentiation, this function should change.
def post_process(result: ExecutionDetails) -> Tensor:
    """
    Take in an `ExecutionDetails`, and return the probability of measuring |0>
    Which equals the amount of `|0>` measurements divided by the total amount of measurements.
    """
    counts: dict = result.counts
    # The probability of measuring |0>
    p_zero: float = counts.get("0", 0.0) / sum(counts.values())

    # convert to tensor
    return torch.tensor(p_zero)

Step 1.3 - Create a network

Now we're going to define a network, just like any other PyTorch network, only that this time, we will have only 1 layer, and it will be a quantum layer.

from classiq.applications.qnn.qlayer import QLayer
import torch.nn as nn


class Net(nn.Module):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__()
        self.qlayer = QLayer(
            circuit,  # the quantum circuit, the result of `ModelDesiger.synthesize()`
            execute,  # a callable that takes
            # - a quantum circuit
            # - parameters to that circuit (a tuple of dictionaries)
            # and returns a `MultipleExecutionDetails` of the result
            post_process,  # a callable that takes
            # - a single `ExecutionDetails`
            # and returns a `torch.Tensor` of the result
            *args,
            **kwargs
        )

    def forward(self, x: Tensor) -> Tensor:
        x = self.qlayer(x)
        return x


model = Net()

Step 2 - Choose a dataset, loss function, and optimizer

We will use the DATALOADER_NOT dataset, defined here, as well as L1Loss and SGD

from classiq.applications.qnn.data_sets import DATALOADER_NOT
import torch.nn as nn
import torch.optim as optim

_LEARNING_RATE = 1.0

# choosing our data
data_loader = DATALOADER_NOT
# choosing our loss function
loss_func = nn.L1Loss()
# choosing our optimizer
optimizer = optim.SGD(model.parameters(), lr=_LEARNING_RATE)

Step 3 - Train

For the training process, we will use a loop similar to the one recommended by PyTorch

import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader


def train(
    model: nn.Module,
    data_loader: DataLoader,
    loss_func: nn.modules.loss._Loss,
    optimizer: optim.Optimizer,
    epoch: int = 20,
) -> None:
    for index in range(epoch):
        print(index, model.qlayer.weight)
        for data, label in data_loader:
            optimizer.zero_grad()

            output = model(data)

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

            optimizer.step()


train(model, data_loader, loss_func, optimizer)

Step 4 - Test

Lastly, we will test our network accuracy, using the following answer

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

    with torch.no_grad():
        for data, labels in data_loader:
            # Let the model predict
            predictions = model(data)

            # 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)
    print(f"Test Accuracy of the model: {accuracy*100:.2f}")
    return accuracy


check_accuracy(model, data_loader)

The results show that the accuracy is \(1\), meaning a 100% success rate at performing the required transformation (i.e. the network learned to perform a X-gate). We may further test it by printing the value of model.qlayer.weight, which is a tensor of shape (1,1), which should, after training, be close to \(\pi\)

Summary

In this example, we wrote a fully working Quantum Neural Network from scratch, trained it, and saw its success at learning the requested transformation.

In section 1 we defined our parametric quantum circuit, as well as our execution function. Together, these two are sent as arguments to the QLayer object. In section 2 we set some hyperparameters, and in section 3 we trained our model. Section 4 helped us verify that our network is working as intended.