Quantum Computing: The Qiskit Framework

Can I perform the simplest operation using a simulated Quantum Computer?
Published

December 26, 2021

Quantum Computing has been talked about for some time and it may well become the next iteration of computer programming. To keep my skills up to date I should try out this approach to computation. I want to try out the Qiskit and QuTiP frameworks, both of which use python code.

This post is an evaluation of the Qiskit framework (ANIS et al. 2021).

ANIS, MD SAJID, Héctor Abraham, AduOffei, Rochisha Agarwal, Gabriele Agliardi, Merav Aharoni, Ismail Yunus Akhalwaya, et al. 2021. “Qiskit: An Open-Source Framework for Quantum Computing.” https://doi.org/10.5281/zenodo.2573505.

Introduction

Qiskit is the framework that was introduced to me by a colleague last year. They tried to use this to solve a simple problem and described it as “not even at the level of assembly programming”. I have low expectations for the usability of this framework.

I’ve installed it with poetry add qiskit[visualization] and the framework version is:

qiskit-terra: 0.19.1
qiskit-aer: 0.10.1
qiskit-ignis: 0.7.0
qiskit-ibmq-provider: 0.18.3
qiskit-aqua: None
qiskit: 0.34.0
qiskit-nature: None
qiskit-finance: None
qiskit-optimization: None
qiskit-machine-learning: None

Reviewing the repository quickly this looks like an active project - the last commit was hours ago and it has a lot of stars.

Logic Gates

Since this is below the level of assembly, the first examples I can find with code are for logic gates. Let’s run them here and see how it works.

The sections following here have quite a lot of code and show the circuit diagram and results for each discrete input, making it quite long.

NOT Gate

This is the fully defined example from the page.

Code
from qiskit import QuantumCircuit, Aer, execute

def NOT(inp):
    """An NOT gate.
    
    Parameters:
        inp (str): Input, encoded in qubit 0.
        
    Returns:
        QuantumCircuit: Output NOT circuit.
        str: Output value measured from qubit 0.
    """

    qc = QuantumCircuit(1, 1) # A quantum circuit with a single qubit and a single classical bit
    qc.reset(0)
    
    # We encode '0' as the qubit state |0⟩, and '1' as |1⟩
    # Since the qubit is initially |0⟩, we don't need to do anything for an input of '0'
    # For an input of '1', we do an x to rotate the |0⟩ to |1⟩
    if inp=='1':
        qc.x(0)
        
    # barrier between input state and gate operation 
    qc.barrier()
    
    # Now we've encoded the input, we can do a NOT on it using x
    qc.x(0)
    
    #barrier between gate operation and measurement
    qc.barrier()
    
    # Finally, we extract the |0⟩/|1⟩ output of the qubit and encode it in the bit c[0]
    qc.measure(0,0)
    qc.draw('mpl')
    
    # We'll run the program on a simulator
    backend = Aer.get_backend('qasm_simulator')
    # Since the output will be deterministic, we can use just a single shot to get it
    job = execute(qc, backend, shots=1, memory=True)
    output = job.result().get_memory()[0]
    
    return qc, output

We can see the quantum circuit as well as the results:

Code
for inp in ['0', '1']:
    qc, out = NOT(inp)
    print('NOT with input',inp,'gives output',out)
    display(qc.draw(output="mpl"))
NOT with input 0 gives output 1

NOT with input 1 gives output 0

This is very pretty. I’m not really sure why the x operation on the qubit does a rotation. Is there an associated y and z (yes, there is)?

I also dislike the way that everything is string encoded. The response is string encoded too.

To extend this I want to try performing a NOT operation over all three dimensions.

Code
from qiskit import QuantumCircuit, Aer, execute

def NOT_all(x_in: bool, y_in: bool, z_in: bool):
    qc = QuantumCircuit(1, 1)
    qc.reset(0)

    if x_in:
        qc.x(0)
    if y_in:
        qc.y(0)
    if z_in:
        qc.z(0)

    qc.barrier()
    qc.x(0)
    qc.y(0)
    qc.z(0)
    qc.barrier()

    qc.measure(0,0)
    qc.draw('mpl')

    backend = Aer.get_backend('qasm_simulator')

    # Since the output will be deterministic, we can use just a single shot to get it
    job = execute(qc, backend, shots=1, memory=True)
    output = job.result().get_memory()[0]

    return qc, output, job
Code
inp = (True, True, False)
qc, out, job = NOT_all(*inp)
print('NOT with input',inp,'gives output',out)
display(qc.draw(output="mpl"))
NOT with input (True, True, False) gives output 0

This is annoying, it isn’t returning the data in the y or z dimensions. It turns out that the qiskit framework can only ever measure the x base, so changes in y and z are invisible to measurement.

It would be nice to move away from string encoding the output - I know that numpy is used in some part of this. It looks like the output is determined based on the meas_level (measurement level?) or the memory flag. Since the memory flag is set this is the greatest level of precision for measurement which then results in string output. Presumably the use of meas_level then returns more noisy output from the underlying circuits.

Removing Boilerplate

I want to practice using this quite a bit. Currently there is quite a bit of boilerplate around setting up the circuit and running it. If I can reduce my methods to just the core operations it will be easier to iterate.

This would be an example of the template method pattern, however using OOP to define this seems like overkill. I can use a decorator to define the repeated behaviour and leave the decorated function as the concrete operation to perform.

Code
from typing import List, Tuple
from qiskit import QuantumCircuit
from qiskit.providers.aer.jobs.aerjob import AerJob

QuantumResult = Tuple[QuantumCircuit, List[bool], AerJob]

def setup_circuit(qubits: int, cbits: int, state: List[bool]) -> QuantumCircuit:
    qc = QuantumCircuit(qubits, cbits)
    qc.reset(range(qubits))

    for index, is_set in enumerate(state):
        if is_set:
            qc.x(index)

    qc.barrier()

    return qc

def run_circuit(qc: QuantumCircuit) -> QuantumResult:
    # execute is a super generic name, import it here to avoid conflicts
    from qiskit import Aer, execute

    backend = Aer.get_backend('qasm_simulator')

    # Since the output will be deterministic, we can use just a single shot to get it
    job = execute(qc, backend, shots=1, memory=True)
    output = [
        cbit == "1"
        for cbit in job.result().get_memory()
    ]

    return qc, output, job

With these two methods I can define a decorator around any method to make it execute on a quantum circuit.

Code
from typing import Callable, List, Tuple
from qiskit import QuantumCircuit
from qiskit.providers.aer.jobs.aerjob import AerJob

# the result of the decorator application, pass in the initial state and get out the result
QuantumResult = Tuple[QuantumCircuit, List[bool], AerJob]
StateCallable = Callable[[bool, ...], QuantumResult]

# the underlying method signature that is invoked by the decorator
QuantumCallable = Callable[[QuantumCircuit], QuantumCircuit]

# This decorator requires configuration so it is double layered
def with_circuit(qubits: int, cbits: int) -> Callable[QuantumCallable, StateCallable]:
    def decorator(function: QuantumCallable) -> StateCallable:
        def wrapper(*state: bool) -> QuantumResult:
            qc = setup_circuit(qubits=qubits, cbits=cbits, state=state)
            qc = function(qc)
            return run_circuit(qc)
        return wrapper
    return decorator

Now we can try it out for the NOT gate. The following code is identical to the original NOT gate defined above:

Code
from qiskit import QuantumCircuit

@with_circuit(qubits=1, cbits=1)
def NOT_decorated(qc: QuantumCircuit):
    qc.x(0)
    qc.barrier()
    qc.measure(0,0)

    return qc
Code
for inp in [True, False]:
    qc, out, job = NOT_decorated(inp)
    print('NOT with input',inp,'gives output',out[0])
    display(qc.draw(output="mpl"))
NOT with input True gives output False

NOT with input False gives output True

This is far more compact!

XOR Gate

The next gate to define is the XOR gate. This time they have left the implementation blank.

The quantum circuit does define AND, OR, XOR … methods directly however I think it would be better to try implementing this myself. When looking at code that they have written for various things I see things like cx and such which take multiple qubits. Looking at that method it uses a CXGate which the documentation describes as like the classical XOR gate.

Let’s run through this. Given the documentation:

In the computational basis, this gate flips the target qubit if the control qubit is in the \(\left| 1 \right\rangle\) state

We can define the truth table given these instructions:

target qubit control qubit result
0 0 0
1 0 1
0 1 1
1 1 0

And that looks like XOR to me!

Code
from qiskit import QuantumCircuit

@with_circuit(qubits=2, cbits=1)
def XOR(qc: QuantumCircuit):
    qc.cx(control_qubit=0, target_qubit=1)
    qc.barrier()
    qc.measure(1,0)

    return qc
Code
for x_in in [True, False]:
    for y_in in [True, False]:
        qc, out, job = XOR(x_in, y_in)
        print(f"XOR with input {x_in}, {y_in} gives output {out[0]}")
        display(qc.draw(output="mpl"))
XOR with input True, True gives output False

XOR with input True, False gives output True

XOR with input False, True gives output True

XOR with input False, False gives output False

AND Gate

The documentation for the CCXGate is basically missing, however I am inferring that it is a CXGate that has two control qubits. If both control qubits are set then it should flip the target qubit. That means that if I ensure that the target qubit starts out false I can get it to flip to true if both control qubits are set.

When writing this using my decorator there is a problem - the output qubit is not one of the two inputs. I use the partial function from functools to provide that starting qubit value. This is really a problem with the decorator.

Code
from functools import partial
from qiskit import QuantumCircuit

@with_circuit(qubits=3, cbits=1)
def AND(qc: QuantumCircuit):
    qc.ccx(control_qubit1=1, control_qubit2=2, target_qubit=0)
    qc.barrier()
    qc.measure(0,0)

    return qc

# I use `partial` here to hide the initial state of the output qubit
AND = partial(AND, False)
Code
for x_in in [True, False]:
    for y_in in [True, False]:
        qc, out, job = AND(x_in, y_in)
        print(f"AND with input {x_in}, {y_in} gives output {out[0]}")
        display(qc.draw(output="mpl"))
AND with input True, True gives output True

AND with input True, False gives output False

AND with input False, True gives output False

AND with input False, False gives output False

This looks great, and I did it relatively quickly too! At some point I should actually read about this stuff.

NAND Gate

I can do this one by flipping the AND gate.

Code
from functools import partial
from qiskit import QuantumCircuit

@with_circuit(qubits=3, cbits=1)
def NAND(qc: QuantumCircuit):
    qc.ccx(control_qubit1=1, control_qubit2=2, target_qubit=0)
    qc.x(qubit=0)
    qc.barrier()
    qc.measure(0,0)

    return qc

# I use `partial` here to hide the initial state of the output qubit
NAND = partial(NAND, False)
Code
for x_in in [True, False]:
    for y_in in [True, False]:
        qc, out, job = NAND(x_in, y_in)
        print(f"NAND with input {x_in}, {y_in} gives output {out[0]}")
        display(qc.draw(output="mpl"))
NAND with input True, True gives output False

NAND with input True, False gives output True

NAND with input False, True gives output True

NAND with input False, False gives output True

However given that the target qubit is flipped if both control qubits are set then I can make this more compact by making the target qubit initially true.

Code
from functools import partial
from qiskit import QuantumCircuit

@with_circuit(qubits=3, cbits=1)
def NAND(qc: QuantumCircuit):
    qc.ccx(control_qubit1=1, control_qubit2=2, target_qubit=0)
    # qc.x(qubit=0) # CHANGED
    qc.barrier()
    qc.measure(0,0)

    return qc

# I use `partial` here to hide the initial state of the output qubit
NAND = partial(NAND, True) # CHANGED
Code
for x_in in [True, False]:
    for y_in in [True, False]:
        qc, out, job = NAND(x_in, y_in)
        print(f"NAND with input {x_in}, {y_in} gives output {out[0]}")
        display(qc.draw(output="mpl"))
NAND with input True, True gives output False

NAND with input True, False gives output True

NAND with input False, True gives output True

NAND with input False, False gives output True

Woo!

OR Gate

Given that we have the NAND gate we can now define any logical operation in terms of NAND. I’m trying to understand the behaviour of quantum circuits though. Let’s solve it using the operators that I have encountered so far. The simplest thing I can think of is NOT ((NOT A) AND (NOT B)) so lets start with that.

Given we are dealing with flipping again we can start with the qubit set and flip it only if neither input is set.

Code
from functools import partial
from qiskit import QuantumCircuit

@with_circuit(qubits=3, cbits=1)
def OR(qc: QuantumCircuit):
    qc.x(qubit=1)
    qc.x(qubit=2)
    qc.ccx(control_qubit1=1, control_qubit2=2, target_qubit=0)
    qc.barrier()
    qc.measure(0,0)

    return qc

# I use `partial` here to hide the initial state of the output qubit
OR = partial(OR, True)
Code
for x_in in [True, False]:
    for y_in in [True, False]:
        qc, out, job = OR(x_in, y_in)
        print(f"OR with input {x_in}, {y_in} gives output {out[0]}")
        display(qc.draw(output="mpl"))
OR with input True, True gives output True

OR with input True, False gives output True

OR with input False, True gives output True

OR with input False, False gives output False

That’s all the operations that the quiz asks for. It then goes on to actually running this stuff in a real (noisy) quantum computer. I’m going to stop here as this is already quite long. The investigation of QuTiP will have to come in a different post.