Tutorial - Introduction to Helmi with qiskit

Helmi is a 5 qubit Quantum Computer that is co-developed by VTT and IQM. It uses superconducting transmon qubits in a star shaped topology. Helmi’s natives gates consist of the phased-rx and controlled-z gates. This architecture is called Adonis by IQM.

In this tutorial running on Helmi is demonstrated using the Qiskit framework by utilising the qiskit-on-iqm adapter. You can also run on Helmi using Cirq with cirq-on-iqm adapter. These notebooks are intended to be run on notebooks.csc.fi which has access to run on Helmi. Additional documentation for notebooks.csc.fi can be found here.

Here is Helmi! It is located in Espoo, Finland.

VTT Helmi

Setup

This notebook uses the following requirements.

qiskit-iqm==10.10
iqm-client==13.2
qiskit[visualization]
PennyLane-qiskit @ git+https://github.com/NordIQuEst/pennylane-qiskit@support-num-qubits

Using Helmi with Qiskit

First we import qiskit-on-iqm which is needed to run on Helmi with qiskit. You can read the user guide here.

import matplotlib.pyplot as plt
from qiskit_iqm import IQMProvider
import networkx as nx
from qiskit import QuantumCircuit, QuantumRegister, execute, transpile
from qiskit.tools.monitor import job_monitor
from qiskit.visualization import plot_histogram

Then connection to the backend is simple! For this we point the IQMProvider at what is called the “cocos URL”. The cocos url to access Helmi is provided below.

provider = IQMProvider("https://qc.vtt.fi/cocos")
backend = provider.get_backend()

Now that we have the backend connected to Helmi, let’s print out some information about Helmi!

print(f'Native operations: {backend.operation_names}')
print(f'Number of qubits: {backend.num_qubits}')
print(f'Coupling map: {backend.coupling_map}')

We see that the native operations on Helmi are the r gate and the cz gate. In reality Helmi has native gates phased-rx and cz and here it is given in the context of Qiskit as Qiskit does not have the PRX operation. You will see when running circuits on Helmi multiple r gates are quite common. These represent PRX gates with the following relation:

The phased-rx (PRX) gate is an x-rotation conjugated by a z-rotation. It can be represented as a function of angle \(\theta\) and phase \(\phi\) in the computational basis with:

\[R(\theta, \phi) = e^{-i (X \cos \phi + Y \sin \phi) \: \theta/2} = R_z(\phi) R_x(\theta) R_z^\dagger(\phi)\]

Where \(X\) and \(Y\) are the Pauli matrices. Note that id gates are dropped when submitted to Helmi and Helmi does not currently allow for mid-circuit measurements.

The topology can be visualised with networkx:

G = nx.Graph()
G.add_edges_from(backend.coupling_map)
node_labels = {node: f"QB{node + 1}" for node in G.nodes}
nx.draw(G, labels=node_labels, node_color='skyblue', node_size=500, font_size=10)

The topology can also be displayed as an image

Helmi topology

Constructing and executing quantum circuits

Circuits are constructed and submitted to Helmi using the same methods as with IBM machines. First we construct a Bell pair circuit between 2 qubits. The circuit is then executed on the backend using the execute function.

circuit = QuantumCircuit(2, name='Bell pair circuit')
circuit.h(0)
circuit.cx(0, 1)
circuit.measure_all()
circuit.draw(output='mpl')

Executing the circuit on Helmi

When submitting a job to Helmi a unique identifier for your job is returned. This can be used to gather additional information about the circuit you just submitted and the results. You should save your job ids!

job = execute(circuit, backend, shots=100)
print(f"Job ID: {job.job_id()}.")
print("Tracking execution of job:")
job_monitor(job)

After submitting, the job is now running. The status of the job can be queried using job.status(). Using the job id, you can retrieve previous jobs.

status = job.status()
print(status)
#old_job = backend.retrieve_job(job_id)

Explicit Transpilation

Instead of using the execute function you can also be explicit about your transpilation.

transpiled_circuit = transpile(circuit, backend=backend, layout_method='sabre', optimization_level=3)
transpiled_circuit.draw('mpl')

For the more control, you can also specify the initial layout in both transpile and execute. For example, Helmi’s topology only allows 2 qubit gates between the central and outer qubits. Therefore we can map the 2 qubit gate to QB3. For this we make use of the QuantumRegister.

qreg = QuantumRegister(2, "QB")
circuit = QuantumCircuit(qreg, name='Bell pair circuit')
circuit.h(qreg[0])
circuit.cx(qreg[0], qreg[1])
circuit.measure_all()


# Qubit numbers start at 0 index whereas the qubit names start at 1 index. 
qubit_mapping = {
    qreg[0]: 0,  # Map the first qubit to QB1
    qreg[1]: 2,  # Map the second qubit to QB3
    }


job = execute(circuit, backend, shots=100, initial_layout=qubit_mapping)

Qiskit refers to qubits using integer indices, whereas IQM uses strings. The backend class provides utility methods for mapping them to one another. Let’s see on which physical qubits the logical circuit qubits were mapped.

mapping = {}
for qubit in circuit.qubits:
    index = circuit.find_bit(qubit).index
    mapping[index] = backend.index_to_qubit_name(index)
 
print(mapping)

Results

Results can be printed once the job has completed. If results are queried before the job has completed then an error will be returned.

result = job.result()
print(result.job_id)  # The job id can be queried from the result
print(result.get_counts())
#print(result.get_memory())

plot_histogram(result.get_counts())

Additional metadata about the executed job can also be found.

exp_result = result._get_experiment(circuit)
print("Job ID: ", job.job_id())  # Retrieving the submitted job id
print(result.request.circuits)  # Retrieving the circuit request sent
print("Calibration Set ID: ", exp_result.calibration_set_id)  # Retrieving the current calibration set id. 
print(result.request.qubit_mapping)  # Retrieving the qubit mapping
print(result.request.shots)  # Retrieving the number of requested shots. 
print(exp_result.header)

Simulating circuits locally with noise

Qiskit on IQM provides an IQMFakeBackend with IQMFakeAdonis for simulating Helmi.

from qiskit_iqm import IQMFakeAdonis
fake_backend = IQMFakeAdonis()
job = execute(circuit, fake_backend, shots=1000)
job.result().get_counts()

The error profile of the noise can be queried, for example:

print(fake_backend.error_profile)

And the noise model can be updated. Let’s give it some parameters to make the noise model more accurate.

error_profile = fake_backend.error_profile
error_profile.t1s['QB1'] = 38940.0  # in ns
error_profile.t1s['QB3'] = 43322.0  # in ns

error_profile.t2s['QB1'] = 24785.0  # in ns
error_profile.t2s['QB3'] = 10050.0  # in ns

error_profile.single_qubit_gate_depolarizing_error_parameters['phased_rx']['QB1'] = 0.0043 
error_profile.single_qubit_gate_depolarizing_error_parameters['phased_rx']['QB3'] = 0.0022 

error_profile.two_qubit_gate_depolarizing_error_parameters['cz'][('QB1', 'QB3')] = 0.018

error_profile.single_qubit_gate_durations['phased_rx'] = 120  # in ns
error_profile.two_qubit_gate_durations['cz'] = 120  # in ns

error_profile.readout_errors['QB1']['0'] = 0.03375
error_profile.readout_errors['QB1']['1'] = 0.03865
error_profile.readout_errors['QB3']['0'] = 0.0365
error_profile.readout_errors['QB3']['1'] = 0.05885

error_profile.name = 'fake_helmi'


helmi_fake_backend = fake_backend.copy_with_error_profile(error_profile)

Let’s test to see how accurate our noise model is!

job_1 = execute(circuit, backend, shots=1000)
job_2 = execute(circuit, fake_backend, shots=1000)
job_3 = execute(circuit, helmi_fake_backend, shots=1000)

counts_1 = job_1.result().get_counts()
counts_2 = job_2.result().get_counts()
counts_3 = job_3.result().get_counts()

legend = ["Helmi", "FakeAdonis", "FakeHelmi"]
plot_histogram([counts_1, counts_2, counts_3], legend=legend)

Batch execution

Helmi also allows for batches of circuits to be submitted with 1 call to the quantum computer. A batch is simply a list of QuantumCircuits. This is often faster than executing circuits individually, however, circuits will still be executed sequentially. On Helmi currently you can only place a maximum of 20 circuits in one batch. All circuits in a batch are executed with the same number of shots. The maximum number of shots per circuit is 100,000.

A batch of circuits has an additional restriction that all circuits in the batch must measure the same qubits. In this case, batch execution is useful for parameterized circuits to be executed using the qiskit.circuit.Parameter class.

circuits_list = []

circuit_1 = QuantumCircuit(2, name='Bell pair circuit')
circuit_1.h(0)
circuit_1.cx(0, 1)
circuit_1.measure_all()
circuits_list.append(circuit_1)

circuit_1.draw(output='mpl')
circuit_2 = QuantumCircuit(2, name='Reverse Bell pair circuit')
circuit_2.h(1)
circuit_2.cx(1, 0)
circuit_2.measure_all()
circuits_list.append(circuit_2)

circuit_2.draw(output='mpl')
# Execute and monitor job
job = execute(circuits_list, backend, shots=10, optimization_level=0, initial_layout=[0, 2])
print("Tracking execution of job:")
job_monitor(job)
# Get results
result = job.result()

# Plot histograms
plot_histogram(result.get_counts(), legend=['Circuit 1', 'Circuit 2'])

Pennylane Qiskit

You can also run Pennylane code on Helmi with the PennyLane-Qiskit Plugin. The IQMBackend object uses BackendV2 currently which Pennylane-Qiskit does not support, therefore we use a forked-version that fixes this.

import pennylane as qml

provider = IQMProvider("https://qc.vtt.fi/cocos")
backend = provider.get_backend()


dev = qml.device('qiskit.remote', wires=5, backend=backend, shots=10)

print(dev.capabilities())

@qml.qnode(dev, interface="autograd")
def circuit(params):
    qml.RX(params[0], wires=0)
    qml.RY(params[1], wires=0)
    return qml.expval(qml.PauliZ(0))


print(circuit([0.54, 0.12]))

Summary

In this notebook we have demonstrated how to connect and run circuits on Helmi with Qiskit, qiskit-on-iqm and the PennyLane-Qiskit plugin. You are encouraged to use Helmi and apply what you have learnt to the exercise from day 1! Below are some tasks!

Tasks

Here are some tasks to demonstrate usage of Helmi and how to improve the results. Do you notice any differences compared to when you run with the simulator?

Task 1

In this task we create an entangled state on a real quantum computer! This follows the second practice session from yesterday.

Here is a demonstration of creating a Bell pair (Entanglement!) between qubits 1 and 3. Create an entanglement between the other qubit pairs according to the topology of Helmi! Which are the best qubit pairs today?

# create quantum circuit
qreg = QuantumRegister(2, "QB")
qc = QuantumCircuit(qreg, name='Bell pair circuit')

qc.h(0)
qc.cx(0, 1)
qc.measure_all()

qc.draw('mpl')
# Transpile the circuit 

qubit_mapping = {
    qreg[0]: 0,  # Map the first qubit to QB1
    qreg[1]: 2,  # Map the second qubit to QB3
}
transpiled_circuit = transpile(qc, backend=backend, initial_layout=qubit_mapping)
transpiled_circuit.draw('mpl')
# Execute the circuit

job = backend.run(transpiled_circuit)

job_monitor(job)
counts = job.result().get_counts()
print(counts)

plot_histogram(counts)

Task 1 - Solution

def create_bell_pair_circuit(outer_qubit):
    """For a given outer qubit, create a bell pair between the outer qubit and QB3"""
    qreg = QuantumRegister(2, "QB")
    qc = QuantumCircuit(qreg, name='Bell pair circuit')
    qc.h(0)
    qc.cx(0, 1)
    qc.measure_all()
    
    # Create the qubit mapping
    qubit_mapping = {
        qreg[0]: outer_qubit,
        qreg[1]: 2,  # Map the second qubit to QB3
    }
    
    # Transpile the circuit with the qubit mapping
    transpiled_circuit = transpile(qc, backend=backend,  initial_layout=qubit_mapping)
    
    return transpiled_circuit

outer_qubits = [0, 1, 3, 4]  # Qubits 1, 2, 4, 5
bell_pair_circuits = [create_bell_pair_circuit(q) for q in outer_qubits]

# uncomment to draw the circuits
# for circuit in bell_pair_circuits:
#     display(circuit.draw('mpl'))

jobs = [execute(qc, backend, shots=100) for qc in bell_pair_circuits]

for i, job in enumerate(jobs):
    job_monitor(job)
    counts = job.result().get_counts()
    print(f"Counts for outer qubit {outer_qubits[i]}: {counts}")
def success_probability(counts):
    total_shots = sum(counts.values())
    success_count = counts.get('00', 0) + counts.get('11', 0)
    return success_count / total_shots

outcome_order = ['00', '01', '10', '11']
success_probabilities = [success_probability(counts) for counts in [job.result().get_counts() for job in jobs]]


# Plot histograms for counts
plt.figure(figsize=(12, 6))

for i, counts in enumerate([job.result().get_counts() for job in jobs]):
    plt.subplot(2, 2, i + 1)
    sorted_counts = {outcome: counts.get(outcome, 0) for outcome in outcome_order}
    plt.bar(sorted_counts.keys(), sorted_counts.values())
    plt.title(f'Counts for outer qubit {outer_qubits[i]+1}')
    plt.xlabel('Measurement Outcomes')
    plt.ylabel('Counts')

plt.tight_layout()

# Plot histograms for success probabilities
plt.figure(figsize=(12, 6))
plt.bar(outer_qubits, success_probabilities)
plt.title('Success Probabilities for Each Outer Qubit')
plt.xlabel('Outer Qubit')
plt.ylabel('Success Probability')

plt.show()

Task 2 - Entangling more qubits: GHZ

We’ve now seen that we can create a Bell pair circuit where 2 qubits are entangled. What about entangling more than 2 qubits? The Greenberger-Horne-Zeilinger (GHZ) State does precisely this by creating an n-qubit entangled state. Running a GHZ experiment is useful for assessing the multi-qubit interactions in a quantum computer.

Here we demonstrate a 5 qubit GHZ circuit on Helmi.

Creating the GHZ circuit

# create quantum circuit

shots = 1000

qreg = QuantumRegister(5, "QB")
qc = QuantumCircuit(qreg, name='GHZ circuit')

qc.h(0)
qc.cx(0, 1)                      # apply CNOT, control=0, target=1
qc.cx(1, 2)
qc.cx(2, 3)
qc.cx(3, 4)
qc.measure_all()

qc.draw('mpl')

Let’s see what the simulator gives

from qiskit import Aer

simulator = Aer.get_backend('aer_simulator')
result = simulator.run(qc, shots=shots).result()
counts = result.get_counts()   # extract statistics from results
print(counts)

plot_histogram(counts)

In this approach, the circuit is created in a ‘textbook’ fashion. Due to the topology of Helmi, after transpiling the circuit it becomes much longer because SWAP gates are needed.

transpiled_circuit = transpile(qc, backend=backend, layout_method='sabre', optimization_level=3)
transpiled_circuit.draw('mpl')

This can be shown by only displaying the routed circuit, without decomposition into native gates.

transpiled_circuit_simple = transpile(qc, coupling_map=backend.coupling_map, layout_method='sabre', optimization_level=3)
transpiled_circuit_simple.draw('mpl')

Let’s run this on Helmi!

job = backend.run(transpiled_circuit)

job_monitor(job)
counts = job.result().get_counts()   # extract statistics from results

plot_histogram(counts)

In this case we have an additional swap gates due to the central qubit (QB3) being the only available qubit to make 2 qubit gates.

We can reduce the number of swap gates needed and improve our GHZ 5 result by placing the Hadamard gate on the central qubit and CNOTs on all the neighbours.

# create quantum circuit
qreg = QuantumRegister(5, "QB")
qc = QuantumCircuit(qreg, name='GHZ circuit')

qc.h(2)
qc.cx(2, 0)
qc.cx(2, 1)
qc.cx(2, 3)
qc.cx(2, 4)

qc.measure_all()

qc.draw('mpl')
transpiled_circuit = transpile(qc, backend=backend, layout_method='sabre', optimization_level=3)
transpiled_circuit.draw('mpl')

Now we run the code on Helmi and look at the histogram.

job = backend.run(transpiled_circuit, shots=1000)

job_monitor(job)

counts = job.result().get_counts()
plot_histogram(counts)

Simple readout error mitigation

Error mitigation is a class of techniques aimed at reducing the error from submitting to the current generation of noisy devices. This exercise demonstrates how to apply simple readout error mitigation to improve the results from our GHZ circuit.

This follows Qiskit’s tutorial: Readout Mitigation, however alternatives such as Mitiq can be used. Mitiq provides an open-source library to learn about and implement error mitigation.

For this brief example, readout error mitigation is applied using the LocalReadoutError mitigator from qiskit.experiments. Readout error mitigation refers to errors related to “reading out” of the quantum state into classical information which occurs during measurement.

With the LocalReadoutError, a \(2^n \times 2^n\) assignment matrix \(A\) is created, containing the probabilities to observe \(y\), given \(x\). That is to say that the individual elements of the matrix will contain the probabilities that a qubit prepared in state \(|0 \rangle\) or \(|1 \rangle\) and was measured in either state \(|0 \rangle\) or \(|1 \rangle\).

Here we demonstrate the LocalReadoutMitigator example, which assumes the readout errors of the qubits are uncorrelated. In this case \(n 2 \times 2\) mitigation matrices are generated, 1 for each qubit.

First we generate 2 circuits for all of Helmi’s qubits. The first circuit has no gates applied with the ideal outcome of all zeros: 00000, the second circuit applied an \(X\) gate to our circuit with the ideal outcome of all ones: 11111. After running the experiment we get the Mitigator which returns the mitigated qasi-probabilities.

from qiskit_experiments.library import LocalReadoutError

qubits = [0, 1, 2, 3, 4]

# The qiskit experiment class generates the "Calibration Circuits" based off the experiment and the qubits input. 
exp = LocalReadoutError(qubits)
for c in exp.circuits():
    print(c)

The experiment can simple be run. Qiskit’s experiments library takes take of the circuit transpilation and execution in addition to analysis. In this case the above circuits are run and then analysed.

provider = IQMProvider("https://qc.vtt.fi/cocos")
backend = provider.get_backend()

# from qiskit import Aer
# backend = Aer.get_backend('aer_simulator')

exp.analysis.set_options(plot=True)

result = exp.run(backend)
mitigator = result.analysis_results(0).value
result.figure(0)

Here \(A\) is the assignment matrix, with \(I\) being the identity matrix. The individual components of the assignment matrix represent the probabilities to, for example prepare a \(|0 \rangle\) state and get a \(|1 \rangle\) state or \(|1\rangle\) state and get a \(|0\rangle\) state. This is compared against the identity matrix because in the ideal case we would expect \(P(X|X) = 1\) and \(P(X|Y) = 0\) (\(P(X|X)\) means the probability of \(X\) given \(X\)) The plot shows the absolute value of these two matrices.

The automatic scale given by Qiskit experiments can be slightly misleading, as demonstrated when you run this with the simulator.

The assignment matrix can be printed.

mitigator.assignment_matrix()
print(len(mitigator.assignment_matrix()))

If, for example we used the simulator here the assignment matrix would look like the following:

array([[1., 0., 0., ..., 0., 0., 0.],
       [0., 1., 0., ..., 0., 0., 0.],
       [0., 0., 1., ..., 0., 0., 0.],
       ...,
       [0., 0., 0., ..., 1., 0., 0.],
       [0., 0., 0., ..., 0., 1., 0.],
       [0., 0., 0., ..., 0., 0., 1.]])

With the simulator the \(n\) mitigation matrices will look like:

[1. 0.]
 [0. 1.]

When using the Qiskit experiment library the analysis is hidden from the user.

Here is the code snippet from Qiskit experiments LocalReadoutErrorAnalysis if you wish to see what it’s doing under the hood.

Click to reveal

Taken from LocalReadoutErrorAnalysis.


from qiskit.result import LocalReadoutMitigator, marginal_distribution
import numpy as np

def generate_matrices(data):
        num_qubits = len(data[0]["metadata"]["state_label"])
        counts = [None, None]
        for result in data:
            for i in range(2):
                if result["metadata"]["state_label"] == str(i) * num_qubits:
                    counts[i] = result["counts"]
        matrices = []
        for k in range(num_qubits):
            matrix = np.zeros([2, 2], dtype=float)
            marginalized_counts = []
            shots = []
            for i in range(2):
                marginal_cts = marginal_distribution(counts[i], [k])
                marginalized_counts.append(marginal_cts)
                shots.append(sum(marginal_cts.values()))

            # matrix[i][j] is the probability of counting i for expected j
            for i in range(2):
                for j in range(2):
                    matrix[i][j] = marginalized_counts[j].get(str(i), 0) / shots[j]
            matrices.append(matrix)
        return matrices

data = result.data()
physical_qubits = result.metadata["physical_qubits"]
matrices = generate_matrices(data)
result_mitigator = LocalReadoutMitigator(matrices, qubits=qubits)

result_mitigator.assignment_matrix()


And then plotting is done with


def assignment_matrix_visualization(assignment_matrix, ax=None):
    """Displays a visualization of the assignment matrix compared to the identity"""
    if ax is None:
        ax = get_non_gui_ax()
    figure = ax.get_figure()
    n = len(assignment_matrix)
    diff = np.abs(assignment_matrix - np.eye(n))
    im2 = ax.matshow(diff, cmap=plt.cm.Reds, vmin=0, vmax=0.2)
    ax.set_yticks(np.arange(n))
    ax.set_xticks(np.arange(n))
    ax.set_yticklabels(n * [""])
    ax.set_xticklabels(n * [""])
    ax.set_title(r"$|A - I  |$", fontsize=16)
    ax.set_xlabel("Prepared State")
    ax.xaxis.set_label_position("top")
    ax.set_ylabel("Measured State")
    figure.colorbar(im2, ax=ax)
    return figure

The mitigation matrices can then be printed

for m in mitigator._mitigation_mats:
    print(m)
    print()
print(len(mitigator._mitigation_mats))

Then a circuit can be run on Helmi and our error mitigation applied! In this case we apply the readout error mitigation to the GHZ circuit.

shots = 10000
counts = backend.run(transpiled_circuit, shots=shots).result().get_counts()
unmitigated_probs = {label: count / shots for label, count in counts.items()}
mitigated_quasi_probs = mitigator.quasi_probabilities(counts)
mitigated_probs = (mitigated_quasi_probs.nearest_probability_distribution().binary_probabilities())
legend = ['Mitigated Probabilities', 'Unmitigated Probabilities']
plot_histogram([mitigated_probs, unmitigated_probs], legend=legend, sort="value_desc", bar_labels=False)

We can quickly see how the total success probability has increased by counting the number of all 0’s and all 1’s states.

print((unmitigated_probs['00000']+unmitigated_probs['11111']))
print((mitigated_probs['00000']+mitigated_probs['11111']))

This is just 1 example of error mitigation for mitigating the “Local” (when you assume the readout erros for each qubit are independent) readout error. You could also apply what the Correlated readout error mitigation as described in Qiskit’s tutorial or other forms of error mitigation which are described in Mitiq’s documentation.

Additional Reading