IBM Quantum Challenge 2021 — Part 2

Emilio Peláez
Quantum Untangled
Published in
17 min readJun 3, 2021

--

In part 1, we managed to solve the first two exercises. We did this quickly and had momentum going. Now, let’s get into the solutions for the next two exercises: quantum error correction and transmon qubits.

5 qubit computer by IBM, you can see the individual qubits

1995: Quantum error correction

After a rather easy time with the first two exercises, I arrived at quantum error correction. A few weeks ago, I had attended a couple of lectures on surface code, so I felt confident when I saw that was the theme of this challenge.

The purpose of quantum error correction is to detect and correct errors introduced during the execution of a circuit. Overall, all errors that can be introduced in a quantum circuit can be interpreted as a combination of two types of errors: bit and phase flip. We can do this since any error can be written as some matrix and this matrix in turn can be written as a combination of the two Pauli matrices X and Z. This is summarized in the following equation:

Error matrix as linear combination of X and Z

for some 𝛼,𝛽,𝛾,𝛿 where 𝐸 is the error matrix. For an explanation on surface code, check out this article that I read while I was doing the challenge. It’s actually quite easy to understand yet it still covers a lot about error correction. And if you want to go more in depth, check out this paper.

Let’s get into the challenge. They are already giving us a circuit implementing a surface code to detect bit and phase flip errors.

code = QuantumRegister(5,'code')
syn = QuantumRegister(4,'syn')
out = ClassicalRegister(4,'out')
qc_syn = QuantumCircuit(code,syn,out)# Left ZZZ
qc_syn.cx(code[0],syn[1])
qc_syn.cx(code[2],syn[1])
qc_syn.cx(code[3],syn[1])
qc_syn.barrier()
# Right ZZZ
qc_syn.cx(code[1],syn[2])
qc_syn.cx(code[2],syn[2])
qc_syn.cx(code[4],syn[2])
qc_syn.barrier()
# Top XXX
qc_syn.h(syn[0])
qc_syn.cx(syn[0],code[0])
qc_syn.cx(syn[0],code[1])
qc_syn.cx(syn[0],code[2])
qc_syn.h(syn[0])
qc_syn.barrier()
# Bottom XXX
qc_syn.h(syn[3])
qc_syn.cx(syn[3],code[2])
qc_syn.cx(syn[3],code[3])
qc_syn.cx(syn[3],code[4])
qc_syn.h(syn[3])
qc_syn.barrier()
# Measure the auxilliary qubits
qc_syn.measure(syn,out)
qc_syn.draw('mpl')
Inital syndrome circuit IBM gives us

Note: Imports needed to run this and all following code are given at the end of the article.

When this circuit works correctly, each qubit from the syndrome register will correspond to one type of error in the code register as follows:

  • Leftmost qubit for phase flip in error qubit 1; corresponds to third block of gates in the circuit above.
  • Second qubit from the left for bit flip in error qubit 1; corresponds to first block of gates.
  • Third qubit from the left for bit flip in error qubit 0; corresponds to second block of gates.
  • Rightmost output for phase flip in error qubit 0; corresponds to fourth block of gates.

We haven’t defined what are our error qubits, but just keep in mind that each qubit from the syndrome register detects one type of error.

They also give us the initialization circuit, which takes the state of our register to an eigenstate of the stabilizer, allowing us to detect possible errors and measure the |0000⟩ state with certainty in case there are none.

qc_init = QuantumCircuit(code,syn,out)qc_init.h(syn[0])
qc_init.cx(syn[0],code[0])
qc_init.cx(syn[0],code[1])
qc_init.cx(syn[0],code[2])
qc_init.cx(code[2],syn[0])
qc_init.h(syn[3])
qc_init.cx(syn[3],code[2])
qc_init.cx(syn[3],code[3])
qc_init.cx(syn[3],code[4])
qc_init.cx(code[4],syn[3])
qc_init.barrier()
qc_init.draw('mpl')
Initialization circuit that comes in the notebook

So wait, if they are already giving us a working circuit, what are we supposed to do? Well, we do need to implement a quantum error correction code, but this has to be able to run in the `ibmq_tokyo` system. How do we make sure our circuit is able to run in this device? We have to pay special attention to qubit connectivity.

Problem statement, quite misleading again if you ask me

What is qubit connectivity? It’s how qubits are connected in a quantum device. In most quantum computers (except for trivial ones with very few qubits), qubits will not be directly connected to all of the others but just to a few. This restriction on connectivity makes it difficult to implement multi-qubit gates — in reality just the CX gate is implemented in hardware, the rest of them are built out of this and single-qubit gates — between certain qubits that don’t have direct connectivity. Let’s take a look at the connectivity map of ibmq_tokyo.

backend = FakeTokyo()
backend
Connectivity map for ibmq_tokyo

As you may see, most qubits are connected directly to two or three other qubits. Imagine you want to implement a CX gate between qubit 0 and qubit 1. That would be easy since they are directly connected. But now imagine you want to do the same between qubit 0 and qubit 15. That would involve doing something like the following.

circuit = QuantumCircuit(4)circuit.cx(0,1)
circuit.cx(1,2)
circuit.cx(2,3)
circuit.cx(1,2) # Reverse the effect on qubit 2
circuit.cx(0,1) # Reverse the effect on qubit 1
circuit.draw(‘mpl’)
CX ladder to implement CX between qubits 0 and 3

(Imagine qubit 1 as qubit 5, qubit 2 as qubit 10 and qubit 3 as qubit 15.) This isn’t very efficient. A single CX gate has turned into 5 CX gates. Now imagine implementing a CX gate between qubit 0 and qubit 19, the number of CX gates needed would be huge! That’s why we need to keep qubit connectivity in mind. What the challenge is asking us to do is to implement a circuit that has CX gates only between connected gates in ibmq_tokyo.

You may think that the ordering of qubits on the device is a bit messy and could cause some problems when designing our circuit. But we can decide how our circuit gets mapped into the device, so let’s do that. We can define an array called initial_layout that will do this mapping. Suppose we have the array [0,2,6,10,12,1,5,7,11]. This tells the transpiler to map qubit 0 from our circuit to qubit 0 in hardware, qubit 1 from our circuit to qubit 2 in hardware, qubit 3 from our circuit to qubit 6 in hardware, and so on.

One last note about this mapping step. Qubits from the circuits we are going to be building are not labeled 0 through 8. They are labeled code[0], code[1], …, code[4], syn[0], syn[1], …, syn[3]. However, the same order is applied. Therefore, code[0] is mapped to qubit 0, code[1] to qubit 2, …, code[4] to qubit 12, syn[0] to qubit 1, syn[1] to qubit 5, …, and syn[3] to qubit 11.

Quick and easy solution

Let’s consider the initial layout I defined in the last paragraph. (Which in reality is the one given in the exercise notebook.) Now, let’s look at the original circuits they give us and analyze their connectivity requirements. First, the initialization circuit.

From the first part of this circuit, we see that we need a direct connection between syn[0] and code[0], code[1], and code[2]. Which translates in hardware to a connection of qubit 1 with qubits 0, 2, and 6. Looking at the diagram of ibmq_tokyo, we see that this is satisfied. Now, from the second part of the circuit, we need a connection between syn[3] and code[2], code[3], and code[4]. Which translates to a connection of qubit 11 with qubits 6, 10, and 12 in hardware. Which are also connected! All right, everything’s fine with the initialization circuit, let’s move on to the syndrome circuit.

Let’s analyze it by blocks. The left ZZZ part needs a connection of syn[1] with code[0], code[2], and code[3]. This translates to a connection of qubit 5 with qubits 0, 6, and 10. Looking at the connectivity map, this is satisfied. Onto the next block.

The right ZZZ block requires a connection between syn[2] and code[1], code[2], and code[4]. Translating it, we need a connection of qubit 7 with qubits 2, 6, and 12. Analyzing the connectivity map, we see that this is not satisfied! Qubit 7 is not connected with qubit 2. At this point, you may want to go ahead and choose a different initial layout that satisfies every requirement— but there is no such layout! Therefore, we need to implement a CX gate between qubits 7 and 2 without them interacting directly.

We can use the thing I showed earlier! Let’s choose a qubit that both are connected to and use it as an ancilla. They have two qubits in common: 1 and 6. Qubit 1 corresponds to syn[0] and qubit 6 to code[2]. Because the code register is carrying the information we want to correct, it’s better if we don’t mess up with it. Thus, let’s use syn[0] as an ancilla qubit. Following what I described earlier, this is the edited circuit for the right ZZZ block.

qc_syn = QuantumCircuit(code,syn,out)# Right ZZZ
qc_syn.cx(code[1],syn[0])
qc_syn.cx(syn[0], syn[2])
qc_syn.cx(code[1],syn[0])
qc_syn.cx(code[2],syn[2])
qc_syn.cx(code[4],syn[2])
qc_syn.draw('mpl')
Right ZZZ block with corrected qubit connectivity

Now, all CX gates are between connected qubits! To not make this too repetitive, I’ll just tell you now that the next two blocks are fine as given in the notebook: all CX gates are between connected qubits. You can go ahead and follow what I did for these first two blocks to check for yourself.

Also let me give you the full connectivity map of our circuit. Using this, you can translate into the qubits from the chip and verify that the only connectivity issue is the one we already corrected. Each dotted line represents a required connection.

c0....s0....c1
: : :
: : :
s1....c2....s2
: : :
: : :
c3....s3....c4

After the changes we made to ensure all qubits that have a CX gate between them are connected, the circuit we end up with is the following.

qc_syn = QuantumCircuit(code,syn,out)# Left ZZZ
qc_syn.cx(code[0],syn[1])
qc_syn.cx(code[2],syn[1])
qc_syn.cx(code[3],syn[1])
qc_syn.barrier()
# Right ZZZ
qc_syn.cx(code[1],syn[0])
qc_syn.cx(syn[0], syn[2])
qc_syn.cx(code[1],syn[0])
qc_syn.cx(code[2],syn[2])
qc_syn.cx(code[4],syn[2])
qc_syn.barrier()
# Top XXX
qc_syn.h(syn[0])
qc_syn.cx(syn[0],code[0])
qc_syn.cx(syn[0],code[1])
qc_syn.cx(syn[0],code[2])
qc_syn.h(syn[0])
qc_syn.barrier()
# Bottom XXX
qc_syn.h(syn[3])
qc_syn.cx(syn[3],code[2])
qc_syn.cx(syn[3],code[3])
qc_syn.cx(syn[3],code[4])
qc_syn.h(syn[3])
qc_syn.barrier()
# Measure the auxilliary qubits
qc_syn.measure(syn,out)
qc_syn.draw('mpl')

Let’s try it out. First, we are going to define two qubits in which to introduce artificial errors. I chose qubits 0 and 4 (code[0] and code[4]).

error_qubits = [0,4]

Now, we are going to define the `insert` function that was included in the notebook. You can pass a string in the `errors` parameter that indicates the error you want to insert.

def insert(errors,error_qubits,code,syn,out):
qc_insert = QuantumCircuit(code,syn,out)
if 'x0' in errors:
qc_insert.x(error_qubits[0])
if 'x1' in errors:
qc_insert.x(error_qubits[1])
if 'z0' in errors:
qc_insert.z(error_qubits[0])
if 'z1' in errors:
qc_insert.z(error_qubits[1])

return qc_insert

We can use the following function IBM provided us to test our circuit.

for error in ['x0','x1','z0','z1']:

qc = qc_init.compose(insert([error],error_qubits,code,syn,out))
qc = qc.compose(qc_syn)
job = Aer.get_backend('qasm_simulator').run(qc)

print('\nFor error ' + error + ':')
counts = job.result().get_counts()
for output in counts:
print('Output was',output,'for',counts[output],'shots.')

Running this code, we get the output below.

Our circuit detecting errors as intended

Which, looking back at the beginning of this exercise when I defined how we wanted to detect the errors, you’ll see it is exactly what we intended. Therefore, we can go ahead and grade our solution to find out it has cost 226. That’s pretty high for what we achieved in the previous two exercises, but this was a simple solution, so this was expected.

Insightful solution

Note: full credit for this solution goes to Luis Beltran Vasquez, who is a MSc student in Mathematical Physics at Hamburg University. He was kind enough to present his solution to us and let us use it in this article.

Last solution was pretty intuitive, but we paid for that with the cost. It’s also worthwhile to note that while this solution is pretty clever, it doesn’t work on the general case, It relies on knowing what qubits will the errors be introduced, something that is not possible in the general case. We will benefit from the knowledge that the error qubits in this case are code[0] and code[4].

Having this knowledge, we can forget about the other qubits since we are sure they will have no errors. I’ll show you the final circuit right now and then I’ll break it down for you.

qc_syn = QuantumCircuit(code,syn,out)
qc_init = QuantumCircuit(code,syn,out)
# Syn circuit
qc_syn.cx(code[0],syn[1])
qc_syn.cx(code[4],syn[2])
qc_syn.cx(syn[0],code[0])
qc_syn.h(syn[0])
qc_syn.cx(syn[3],code[4])
qc_syn.h(syn[3])
qc_syn.barrier()
qc_syn.measure(syn,out)
# Init circuit
qc_init.h(syn[3])
qc_init.cx(syn[3],code[4])
qc_init.h(syn[0])
qc_init.cx(syn[0],code[0])
qc_init.cx(code[4],syn[2])
qc_init.cx(code[0],syn[1])
qc_init.barrier()
qc = qc_init.compose(qc_syn)
qc.draw('mpl')
Way shorter circuit

This looks a bit confusing at first, but once you analyze it is pretty similar to the error detecting circuit shown on the tutorial part of the notebook. Another important thing that’s very clear is that the first block (initialization) is opposite to the second block (syndrome); this ensures that the output is 0000 if no errors are introduced in between. Let’s analyze the circuit for each qubit in the syndrome circuit.

Look at syn[0]. Going back some paragraphs, we see that this qubit needs to detect a phase flip error on code[0]. The circuit to accomplish this is the following.

qc = QuantumCircuit(code,syn,out)qc.h(syn[0])
qc.cx(syn[0], code[0])
qc.barrier()
qc.cx(syn[0], code[0])
qc.h(syn[0])
qc.draw('mpl')
Circuit with gates for syn[0]

The initialization part of the circuit entangles syn[0] with code[0]. And the syndrome part of the circuit simply disentangles them. Now imagine that a Z gate (phase error) is applied to code[0] in between. The state of the two qubits would go from |00⟩ + |11⟩ to |00⟩ — |11⟩. And now when the syndrome circuit is applied, syn[0] will be in state |1⟩ instead of |0⟩.

Now look at syn[1]. This qubit needs to detect a bit flip error in code]0]. It does this with the following circuit.

qc = QuantumCircuit(code,syn,out)qc.cx(code[0], syn[1])
qc.barrier()
qc.cx(code[0], syn[1])
qc.draw('mpl')
Circuit with gates for syn[1]

This circuit is pretty simple and self-explanatory. With the initialization part, since syn[1] is in the 0 state, the CX gate will make it take the same state that code[0]. Imagine that code[0] was in the 1 state, then syn[1] would also take the 1 state. Now, if there are no errors, the second part of the circuit will simply take syn[1] back to the 0 state, thus detecting no errors. Now imagine that in between code[0] is flipped to the 0 state by error. This results in the second CX having no effect on syn[1]. Then, we will measure it in the 1 state and know there was an error. The same thing applies if code[0] is initially in the 0 state; try it by yourself.

Now, we will not get into syn[2] and syn[3] because they do the same as the two circuits above but with code[4] (error_qubits[1]). As you can see, this error correction code is pretty simple and useful — as long as you know what the error qubits are. But since we know, it works.

Just before submitting, we need to check the qubit connectivity requirement. Remember that the only error on the last circuit was between code[1] and syn[2]? Well, we don’t need that connection here, so we’re good to go!

We run the grader on this circuit and see that it is cost 2. This may seem a little strange since we definitely have more gates than that. Our guess is that this happens because the transpiler doesn’t know errors can be introduced between the initialization and syndrome parts, thus it thinks that is the whole circuit and does a bunch of cancelling. There’s nothing we can do about this, so we just go ahead and submit.

2007: Transmon qubits

Moving on from fairly familiar terrain with QEC. We got into Transmon qubits. This exercise uses Qiskit pulse, something I was not familiar with before this challenge. But this only made it more interesting, despite me taking a longer time to complete it. In the end, I was really proud of myself for spending the time to really understand this practical aspect of quantum computing. So here I will try to explain to you, hopefully in less time than it takes for me to learn it.

The world around you and me is one big classical system. This means that the kinetic energy and potential energy of objects here takes on a continuous range of values.

For example, a 2-ton car travelling on the highway can go at speed of 75 km, but it can also go at 90, or 75.5, or any non-negative speed that is smaller than the speed of light. And since classical kinetic energy = ½ mv², this energy can take on any non-negative value.

Another example would be people standing at different elevations at different places on earth. Gravitational potential energy on the surface of the Earth is defined as mgh. And since h, the elevation can again be any non-negative value, the PE also takes on a continuous range of non-negative numbers.

Elevation map

These “classical phenomena” are so intuitive and obvious that they might as well be trivial. Yet they serve as good points of reference that can help us better understand the behaviour of quantum systems.

Quantum systems are a little different in that quantum objects can only take on discrete energy levels. A popular depiction of this is the Bohr’s model of the atom.

Photo by nagwa on this post.

We can see in the illustrations that the electrons take on two different orbitals circling round and round the nucleus. We can think about the total energy of the electron as the sum of its kinetic energy (determined by its speed) and potential energy (determined by its distance from the nucleus). Since the radius of the electron orbits is discrete, the total energy takes on different discrete levels as well. Now with this information in mind, let’s motivate our own Transmon qubits.

As we have seen from working with quantum circuits, qubits can take up states between |0⟩ and |1⟩ inclusively. Therefore we want whatever device we’re building to be able to oscillate between these two states as well. Let’s introduce the concept of an LC circuit.

LC circuit

This is quite a simple circuit, made up of a single capacitor and inductor. If we define the total energy of the circuit in terms as charge and magnetic flux, we have the following expression.

Hamiltonian of LC circuit

The following steps of derivations is already covered in this chapter of the Qiskit textbook. However, understanding these would not be necessary for the completion of this challenge, so I will just go through the main points.

After a few steps of quantization and substitution, we have

As you can see, we have discrete and equally spaced energy levels

To get from one energy level to another, we simply apply the correct transition frequency. Doing this, we can continually climb up the energy ladder to higher and higher energy levels.

However, the fact that all levels have the same transition energy means that if we can’t control which energy level our qubits are on. In other words, in order to isolate let’s say the first two energy levels (n=0 and n=1), we would want the transition energy from n=0 to n=1 to be different from that of n=1 to n=2. This is where Transmon qubits enter the picture.

The Transmon qubits take advantage of the Josephson effect through the Josephson junction to add some anharmonicity to the circuit. Let’s compare side by side the energy levels graph of the before and after circuit.

Notice how the transition frequency from |1⟩ to |2⟩ is slightly shorter than that of |0⟩ to |1⟩ for our Transmon qubit?

The transition energy between levels in the after circuit is only slightly different from one another, but that’s all we need. Now let’s get into the challenge.

Problem statement, kind of general for what we actually need to do

The challenge question itself is quite straightforward.

Goal included in notebook, much better

So in this challenge, we are interested in accessing the higher energy level of our Transmon qubits. More specifically, we want to promote our qubits to the |2⟩ state. Here’s the plan of how we are going to do this.

Step 1: Find drive frequency of |0⟩ → |1⟩ transition

We want to bring our qubits from the |0⟩ to the |1⟩ state. This is shown to us in part 1 of the tutorial. After we have set up all the dependency, these are the main code

with pulse.align_sequential():
# Pay attention to this part to solve the problem at the end
pulse.set_frequency(freq*GHz, DriveChannel(qubit))
pulse.play(spec_pulse, DriveChannel(qubit))
pulse.call(meas)

Essentially, we are trying out a set of frequencies over an interval of 5.05 to 5.35 GHz, and measuring the response signals. The responses will peak when we have hit the correct transition frequency. In this case, the value I got is 5.237220 GHz.

Results from step 1

Step 2: Find drive amplitude of |0⟩ → |1⟩ transition

Next thing, we will find out how to calibrate our pi pulse. Also called the X or X180, the purpose of this step is to find the appropriate drive amplitude that will take the qubits from |0⟩ to |1⟩.

After the set up, we have

with pulse.align_sequential():
pulse.set_frequency(f01*GHz, DriveChannel(qubit))
rabi_pulse = Gaussian(duration=drive_duration, amp=amp, sigma=drive_sigma, name=f"Rabi drive amplitude = {amp}")
pulse.play(rabi_pulse, DriveChannel(qubit))
pulse.call(meas)

We will perform a similar procedure as step 1, only now we increment our amplitude bit by bit until we hit the optimal value once again by looking at our measured signal.

Results for step 2

In this case, our amplitude, conveniently shown by the black dashed line, is 0.205.

Step 3: Repeat Step 1, find drive frequency but for |1⟩ → |2⟩ transition

To even begin step 3, we need to get the qubit on the |1⟩. We do this by just applying the actions of step 1 and 2. After this, we are once again sweeping the frequency interval incrementally, to find the correct |1⟩ to |2⟩ drive frequency. We will have to repeat step 1 and 2 for every frequency we try out, but it will all be worth it in the end.

# Promote qubit from |0⟩ to |1⟩
pulse.play(x_pulse, DriveChannel(qubit))
# Incrementally sweeping the frequency interval to find |1⟩ -> |2⟩ transition frequency
pulse.set_frequency((freq+anharm_guess_GHz)*GHz, DriveChannel(qubit))
pulse.play(spec_pulse, DriveChannel(qubit))
pulse.call(meas)

This is all we have to do. 4 measly lines of code. In the end, we would find that the value we are looking for is 4.899345 GHz.

Results for final step

And there we have it, quantum computing on another level. There’s nothing much we can do to our solution to make it more efficient or simple. This exercise doesn’t even have cost, which indicates that there’s probably only one correct solution.

We will have to leave it right here for now. This article has a lot of information to digest, so we’ll give you some time to implement and experiment with what you’ve learned. If you missed the first part which covers exercises 1 and 2, check it out here. On the next, and final, part we will go through exercise 5: the Variational Quantum Eigensolver. Check it out here!

Once again, special thanks to Minh Pham for his help co-authoring this article.

Imports

As promised, here are the imports you need to run the code presented in this article. I added them here to not clutter up the code cells throughout the article.

Imports for exercise 3:

from qiskit import QuantumCircuit, QuantumRegister, ClassicalRegister, Aer, transpile
from qiskit.test.mock import FakeTokyo

For exercise 4, providing the imports is not enough since we omitted big chunks of code and just presented the essential parts. Thus, to see a full working solution see this file. And find the code for all five exercises here.

--

--