Getting started

The following code snippet creates and simulates a simple Bell pair circuit:

images/bell_pair.png
from qat.lang.AQASM import Program, H, CNOT

# Create a Program
qprog = Program()
# Number of qbits
nbqbits = 2
# Allocate some qbits
qbits = qprog.qalloc(nbqbits)

# Apply some quantum Gates
H(qbits[0])
CNOT(qbits[0], qbits[1])

# Export this program into a quantum circuit
circuit = qprog.to_circ()

# Import a Quantum Processor Unit Factory (the default one)
from qat.qpus import get_default_qpu

# Create a Quantum Processor Unit
qpu = get_default_qpu()

# Create a job
job = circuit.to_job()

# Submit the job to the QPU
result = qpu.submit(job)

# Iterate over the final state vector to get all final components
for sample in result:
    print("State %s amplitude %s" % (sample.state, sample.amplitude))
State |00> amplitude (0.7071067811865475+0j)
State |11> amplitude (0.7071067811865475+0j)

The first few lines of code are dedicated to the generation of a quantum circuit and a job, an atomic computation task in the QLM language. A detailed description of the quantum circuit generation tools can be found in the programming section.

Then the remaining lines instantiate a simulator, submit the job, and print the simulation results. More information about this process can be found in the simulating section.

QLM also comes with a collection of powerful tools, called Plugins, to manipulate quantum circuits and execution results. Information about these tools can be found in the manipulating section.

Finally, the QLM provides powerful simulators, called Quantum Processing Units (QPUs). The example above used the default one. You will find more information about QPUs here.

The rest of this section is dedicated to some basic examples of quantum algorithms from the standard literature (such as Grover and a variational algorithm).

Let’s write a simple Grover algorithm. Grover is a quantum search algorithm that can find an element in an unstructured search space quadratically faster than a randomized classical algorithm. In this search model, the problem is specified by an oracle, i.e a function \(\mathcal{X}\rightarrow \{0, 1\}\), and we are looking for an element \(x \in \mathcal{X}\) such that \(f(x) = 1\).

The algorithm consists in alternating two operations \(\pi \sqrt{\frac{1}{a}}/4\) times where \(a = \frac{|f^{-1}(\{1\})|}{|\mathcal{X}|}\) is the probability of finding the searched element in the uniform distribution.

These operations are:

  • an oracle \(U_f: |x \rangle \mapsto (-1)^{f(x)}|x\rangle\)

  • a diffusion \(U_D = I - 2|s\rangle\langle s|\) where \(|s\rangle = \frac{1}{\sqrt{|\mathcal{X}|}} \sum_{x\in\mathcal{X}} |x\rangle\)

Let’s dive in the details of their implementation for a simple search!

The diffusion

To keep things simple we will consider the following search space: \(\mathcal{X} = \{0, 1\}^{2k}\). In this setting, a diffusion can be implemented as follows:

  • First, we will put all qubits in the diagonal basis by applying a wall of H gates.

  • We can then flip the amplitude of the \(|0..0\rangle\) state by flipping all qubits using a wall of \(X\) gates and applying a controlled \(Z\) gate on all qubits.

  • Finally, we can undo our basis changes by applying a wall of \(X\) followed by a wall of \(H\)

We will write a python function that given a number \(k\) returns a diffusion routine over \(2k\) qubits:

import numpy as np
# everything we need to write a quantum circuit
from qat.lang.AQASM import *
# a default qpu (here a simulator)
from qat.qpus import get_default_qpu

# This is a standard implementation of Grover's diffusion
def diffusion(k):
     routine = QRoutine()
     wires = routine.new_wires(2 * k)
     for wire in wires:
         H(wire)
         X(wire)
     Z.ctrl(2 * k - 1)(wires)
     for wire in wires:
         X(wire)
         H(wire)
     return routine

As you can see, we repeat a lot of code to do basis change and revert them. We can simplify a bit the code by using a compute/uncompute:

def diffusion(k):
     routine = QRoutine()
     wires = routine.new_wires(2 * k)
     with routine.compute():
         for wire in wires:
             H(wire)
             X(wire)
     Z.ctrl(2 * k - 1)(wires)
     routine.uncompute()
     return routine

This is a bit clearer now: We have our walls of \(H\) and \(X\) gates, our controlled \(Z\) gate, and we undo our walls using the uncomputation.

The oracle

In this space, we will look for palindromes: bit strings that are their own mirrors. We will implement our oracle as follows (remember that we need to flip the sign of the amplitude of all palindromes):

  • First we will compute the xor of \(b_1\) and \(b_{2k}\), \(b_2\) and \(b_{2k-1}\), etc. We will do these operations in place in the second half of the bit string.

  • Then we will flip the amplitude of our state if and only if its second half is \(0...0\). To do so, we will flip all bits in the second half (using \(X\) gates) and perform a controlled \(Z\) gate on the second half, thus flipping the amplitude if and only if all qubits are set to \(1\).

  • Finally, we can revert to the original state by uncomputing the bit flips and xors (once again we will use a compute/uncompute block)

def is_palindrome(k):
    routine = QRoutine()
    first_half = routine.new_wires(k)
    second_half = routine.new_wires(k)
    with routine.compute():
        for w1, w2 in zip(first_half, reversed(second_half)):
            CNOT(w1, w2)
        for w2 in second_half:
            X(w2)
    Z.ctrl(k - 1)(second_half)
    routine.uncompute()
    return routine

And this is it. We can now run a Grover algorithm to find palindromes! There are exactly \(2^k\) palindromes over \(2k\) bits, hence we will need to perform \(\approx \pi\sqrt{2^k}/4\) iterations to find a palindrome with good probability.

k = 2
grover = Program()
qbits = grover.qalloc(2 * k)
diff = diffusion(k)
oracle = is_palindrome(k)

# We start by a uniform superposition of bit strings:
for qbit in qbits:
    H(qbit)

nsteps = int(np.pi * np.sqrt(2 ** k) / 4)
for _ in range(nsteps):
    oracle(qbits)
    diff(qbits)

# Build a circuit
circuit = grover.to_circ()

# Build a job
job = circuit.to_job()

# Evaluate the job and print the output probabilities
result = get_default_qpu().submit(job)
for sample in result:
    print(sample.state, sample.probability)
|0000> 0.24999999999999956
|0110> 0.24999999999999956
|1001> 0.24999999999999956
|1111> 0.24999999999999956

As you can see, all the bit strings we can sample (with decently high probability) are palindromes!

Of course, this example is not particularly helpful to solve practical problems, but the QLM comes with high level constructs that can help you write more advanced oracles. If you are curious, you can have a look at this section of the documentation to see how to write complicated oracles relying on custom data structures.

Variational algorithms are believed to be well suited to Noisy, Intermediate-Scale Quantum (NISQ) processors as they do not necessarily require long circuits to nevertheless prepare powerful ansatz states.

In the code snippet below, we illustrate how the QLM can be used to write such variational algorithms in a few lines of code: we first define the Hamiltonian \(H\) (here the antiferromagnetic Heisenberg Hamiltonian) whose ground-state energy we want to approximate. We then define the ansatz circuit, i.e a parametric circuit with parameters \(\theta_i\) to be optimized. Finally, our quantum stack is composed of a QPU (here a simulator) and a so-called “plugin” that is going to perform the iterative optimization of the parameters given the ansatz circuit and the observable to be minimized.

from qat.core import Observable, Term
from qat.lang.AQASM import Program, RY, CNOT
from qat.qpus import get_default_qpu
from qat.plugins import ScipyMinimizePlugin

# we instantiate the Hamiltonian we want to approximate the ground state energy of
hamiltonian = Observable(nqbits=2, pauli_terms=[Term(1, op, [0, 1]) for op in ["XX", "YY", "ZZ"]])

# we construct the variational circuit (ansatz)
prog = Program()
reg = prog.qalloc(2)
theta = [prog.new_var(float, '\\theta_%s'%i) for i in range(2)]
RY(theta[0])(reg[0])
RY(theta[1])(reg[1])
CNOT(reg[0], reg[1])
circ = prog.to_circ()

# construct a (variational) job with the variational circuit and the observable
job = circ.to_job(observable=hamiltonian)

# we now build a stack that can handle variational jobs
qpu = get_default_qpu()
optimizer_scipy = ScipyMinimizePlugin(method="COBYLA",
                                      tol=1e-6,
                                      options={"maxiter": 200},
                                      x0=[0, 0])
stack = optimizer_scipy | qpu

# we submit the job and print the optimized variational energy (the exact GS energy is -3)
result = stack.submit(job)
print(f"Minimum VQE energy ={result.value}")
Minimum VQE energy =-2.9999999999988276

You can learn more about QLM jobs, observables, circuits here and there. You can learn more about parametric circuits and variational plugins here.