4. The Qiskit/IBMQ interface

In this tutorial, we discuss the polyadicqml.qiskit module, which provides support for Qiskit simulators as well as IBMQ backends. It implements a specific circuitML, namely qkCircuitML, as well as the corresponding circuitBuilder.

Furthermore, the polyadicqml.qiskit.utility module provides the Backends class, which wraps the Qiskit/IBMQ backend interface and add a few functionalities.

Note

To access IBM Quantum systems, you need to configure your IBM Quantum account. Detailed instructions are provided on the Qiskit installation guide. You can verify your setup if the following runs without producing errors:

>>> from qiskit import IBMQ
>>> IBMQ.load_account()

If you do not have an IBM Quantum account, you can still use Qiskit Aer simulators.

4.1. Using qkCircuitML

Qiskit provides various backends on which to run circuits. Some are locally-run simulators, provided by the Aer API, while others are cloud-based IBM Quantum systems, or simulators.

To run a qkCircuitML, we need to provide such a backend to its constructor. This can be done in two ways: by manually loading a backend from qiskit; or by using a Backends instance. In this section we describe the first method and we explain the second in Section 4.2.

4.1.1. Basic simulation

Suppose we want to simulate a quantum circuit, using a shots-compatible simulator. We can retrieve the qasm_simulator with the following code:

from qiskit import Aer, IBMQ
backend = Aer.get_backend('qasm_simulator')

At this point, we can instantiate our qkCircuitML, by providing the backend as the fourth positional argument (the first three being common to all circuitML):

from polyadicqml.qiskit import qkCircuitML

def make_circuit(bdr, x, params):
    # We define the circuit operations
    ...

# We instantiate the circuit
circuit = qkCircuitML(
    make_circuit,
    nbqbits,
    nbparams,
    backend
)

4.1.2. Simulating Noise

We may want to use a noise model for the quantum operations, when running our circuit. To do so, we can provide a noise model to qkCircuitML using the noise_model keyword argument of the constructor.

For instance, let’s retrieve the noise model and the coupling map from a 5-qubit real device, namely ibmq_ourense, using the usual Qiskit syntax. These two properties can be provided to qkCircuitML by the corresponding keyword arguments:

from qiskit import IBMQ
from qiskit.providers.aer.noise import NoiseModel

# Choose a real device to simulate
IBMQ.load_account()
provider = IBMQ.get_provider(group='open')
device = provider.get_backend('ibmq_ourense')

# Retrieve the device coupling map
coupling_map = device.configuration().coupling_map
# Generate an Aer noise model for device
noise_model = NoiseModel.from_backend(device)


circuit = qkCircuitML(
    ...
    noise_model = noise_model,
    coupling_map = coupling_map
)

While this is the only way to use custom noise models and coupling maps, when simulating a real device it is more convenient to just provide the noise_backend to the circuit constructor, which infers by itself the necessary properties. The previous code thus becomes:

from qiskit import IBMQ

# Choose a real device to simulate
IBMQ.load_account()
provider = IBMQ.get_provider(group='open')
device = provider.get_backend('ibmq_ourense')

circuit = qkCircuitML(
    ...
    noise_backend = device
)

4.1.3. Distributing across backends

One of the functionalities provided by qkCircuitML consists of dispatching jobs over multiple backends at the same time. This behavior can be obtained by providing to the class builder a list of backends, instead of just one. The run method will then handle the backends, by iterating over them each time it dispatch a new job.

To leverage parallelism one should divide the input points across different jobs, so that in a single run call they are processed by multiple devices. This is easily done using the job_size keyword argument.

For instance, let’s run a circuit on two real devices:

from qiskit import IBMQ

# Choose TWO real devices
IBMQ.load_account()
provider = IBMQ.get_provider(group='open')
device1 = provider.get_backend('ibmq_ourense')
device2 = provider.get_backend('ibmq_vigo')

# Define the circuit operations
def make_circuit(bdr, x, params):
    ...

# instantiate the circuit
circuit = qkCircuitML(
    make_circuit, nbqbits, nbparams
    backend = [device1, device2],
)

# Run it by precising a `job_size`
circuit.run(
    X, params,
    nbshots = 300,    # Needed as the backend is a physical device
    job_size = 30     # We split X in jobs of 30 points/circuits
)

In the same way, one can provide multiple noise models and coupling maps, by passing lists to the corresponding keyword arguments.

As for the backend retrieval, multiple dispatching is supported, with a shorter syntax, by the Backends class, which we introduce next.

4.2. The Backends class

The Backends class provides a quick interface to load backends, which is well integrated with qkCircuitML.

4.2.1. Base use

To load an IBMQ device, or a Qiskit Aer simulator, we create a Backends instance providing the desired name:

from polyadicqml.qiskit.utility import Backends

# Load IBMQ device
backend = Backends("ibmq_ourense")

# Simulators are automatically recognised
backend_sim = Backends("statevector_simulator")

Then, we can directly provide it to a qkCircuitML though the backend argument:

from polyadicqml.qiskit import qkCircuitML

circuit = qkCircuitML(
    ...
    backend = backend
)

4.2.2. Simulate noise

To use the qasm_simulator and add noise from a real device, it is enough to provide the corresponding name(s) as the second argument:

# Simulate "ibmq_ourense" device
backend = Backends("qasm_simulator", "ibmq_ourense")

4.2.3. Distributing across backends

As we already mentioned, qkCircuitML can dispatch jobs across multiple backends. This can be handled through a Backends instance, providing the list of device we want to iterate on.

# We precise multiple IBMQ devices
backend = Backends(["ibmq_ourense", "ibmq_vigo"])

# Or we can simulate them
backend = Backends(
    "qasm_simulator",
    ["ibmq_ourense", "ibmq_vigo"]
)

4.2.4. Specifying IBMQ provider

One can precise the hub/group/project combination for the IBMQ provider using the corresponding keyword arguments:

backend = Backends(
    ...
    hub="ibm-q",
    group="open",
    project="main"
)

4.3. Parallel computation on a single device

PolyadicQML provides the tools to run two circuits with the same architecture, but different inputs, on parallel on a single device, without changing our syntax.

For this purpose, we use the qkParallelML class, which comes with its circuit builder qkParallelBuilder. The syntax is almost the same as before, we describe a parametric circuit in make_circuit, then we instantiate the qkParallelML and we can run it on given design matrix and parameters. The only difference is the tot_nbqbits argument in the constructor. This specifies the total number of qubits in the device; not to be confused with nbqbits, the number of qubits used by a single circuit.

For instance, we can define a two-qubit circuit, and parallelize its execution on one of the 5-qubit devices from IBMQ:

import numpy as np

from polyadicqml.qiskit import qkParallelML
from polyadicqml.qiskit.utility import Backends

# Define the circuit structure
def make_circuit(bdr, x, params):
    bdr.allin(x[[0,1]])

    bdr.cz(0, 1)
    bdr.allin(params[[0,1]])

    bdr.cz(0, 1)
    bdr.allin(params[[2,3]])

    return bdr

# Load a backend
backend = Backends("ibmq_ourense", hub="ibm-q")

# instantiate the circuit
qc = qkParallelML(
    make_circuit=make_circuit,
    nbqbits=1, nbparams=4,
    backend=backend,
    tot_nbqbits=5, # We specify that the backend has 5 qubits
)

# Define the design matrix and parameters
X = np.array([[1,2], [3,4]]
params = np.array([.1,.2,.3,.4])

# Run the ciruit, the two datapoints will be processed
# at the same time
qc.run(X, params, nbshots=300)

We can verify that it is in fact the builder which parallelize the execution on the QPU, by creating a single circuit made of two independetent parts, each of which corresponds to a different datapoint. For instance, by running the following with the previous make_circuit, we obtain:

>>> from polyadicqml.qiskit import qkParallelBuilder

>>> # Note that using the circuit builder we tranpose X
>>> # as we want the first index to be that of the features
>>> print(make_circuit(qkParallelBuilder(2, 5), X.T, params).measure_all().circuit())

      ┌──────────┐┌───────┐┌──────────┐   ┌──────────┐┌─────────┐┌──────────┐   ┌──────────┐┌─────────┐┌──────────┐ ░ ┌─┐
qr_0: ┤ RX(pi/2) ├┤ RZ(1) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.1) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.3) ├┤ RX(pi/2) ├─░─┤M├────────────
      ├──────────┤├───────┤├──────────┤ │ ├──────────┤├─────────┤├──────────┤ │ ├──────────┤├─────────┤├──────────┤ ░ └╥┘┌─┐
qr_1: ┤ RX(pi/2) ├┤ RZ(2) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.2) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.4) ├┤ RX(pi/2) ├─░──╫─┤M├─────────
      └──────────┘└───────┘└──────────┘   └──────────┘└─────────┘└──────────┘   └──────────┘└─────────┘└──────────┘ ░  ║ └╥┘┌─┐
qr_2: ──────────────────────────────────────────────────────────────────────────────────────────────────────────────░──╫──╫─┤M├──────
      ┌──────────┐┌───────┐┌──────────┐   ┌──────────┐┌─────────┐┌──────────┐   ┌──────────┐┌─────────┐┌──────────┐ ░  ║  ║ └╥┘┌─┐
qr_3: ┤ RX(pi/2) ├┤ RZ(3) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.1) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.3) ├┤ RX(pi/2) ├─░──╫──╫──╫─┤M├───
      ├──────────┤├───────┤├──────────┤ │ ├──────────┤├─────────┤├──────────┤ │ ├──────────┤├─────────┤├──────────┤ ░  ║  ║  ║ └╥┘┌─┐
qr_4: ┤ RX(pi/2) ├┤ RZ(4) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.2) ├┤ RX(pi/2) ├─■─┤ RX(pi/2) ├┤ RZ(0.4) ├┤ RX(pi/2) ├─░──╫──╫──╫──╫─┤M├
      └──────────┘└───────┘└──────────┘   └──────────┘└─────────┘└──────────┘   └──────────┘└─────────┘└──────────┘ ░  ║  ║  ║  ║ └╥┘
meas: ═════════════════════════════════════════════════════════════════════════════════════════════════════════════════╩══╩══╩══╩══╩═