Back in September, QOSF (The Quantum Open Source Foundation) were accepting applications for their second cohort of their Mentorship Program, where mentees are paired with a mentor and spend a few months working on a research project.

Sadly, I was not accepted onto the mentorship program. This is not down to the quality of the submission, but there simply wasn’t enough mentors for the number of applicants! I guess this means I have more time to actually focus on my unitary fund project (More info coming on that soon).

Before anyone was accepted onto the program, we had to complete one of four screening tasks. I chose the first. In this blog post I am going to outline my approach and solution to the problem!

Disclaimer – I know it’s been a few weeks and I’m a bit late, but Covid and University can be tough so cut me some slack!

## Outlining the Task

In this task, we needed to implement a 4 qubit-state consisting of a number of “Layers”. One layer is made up by an “odd” block and an “even” block.

An odd block is an Rx gate on each qubit, with an even block being Rz gates and a combination of Cz gates. The Rx and Rz gates rotate each qubit with an individual parameter theta. Therefore, for one layer, we have a combination of 8 different theta values.

The state vector of this circuit was our variable “psi” and we also calculated a random 4-qubit state “phi”.

We needed to calculate the minimum possible value of the distance between these two values, with-a certain combination of parameters(8 per layer). In essence, this is an optimization problem.

We wanted to investigate how the number of layers in the circuit impacted our end “minimum distance” result. The end deliverable is a graph showing this!

## Let’s get started!

In this task I chose to use Qiskit. Reppin’ the cause and all that.

To start with, we import all the libraries and functions we need, followed by setting up our IBMQ environment and our quantum computer simulator.

import numpy as np import random import matplotlib.pyplot as plt %matplotlib inline # Importing standard Qiskit libraries and configuring account from qiskit import QuantumCircuit, execute, Aer, IBMQ, QuantumRegister from qiskit.tools.jupyter import * from qiskit.visualization import * from qiskit.circuit import gate from qiskit.quantum_info import random_statevector from qiskit.aqua.components.optimizers import COBYLA, ADAM from qiskit.compiler import transpile IBMQ.load_account() provider = IBMQ.get_provider('ibm-q') simulator = Aer.get_backend('statevector_simulator')

Now with the setup out of the way, we move on to defining how to build our layers. As mentioned before, one layer is defined as a combination of two blocks – odd and even.

Odd Block – Four Rx Gates, one on each Qubit, each with an individual parameter theta i.e. the angle at which that qubit will be rotated about the X axis.

Even Block – Four Rz Gates, one on each Qubit, each with an individual parameter theta i.e. the angle at which that qubit will be rotated about the Z axis. This is followed by a combination of CZ gates for all qubit pairs.

def layer(circuit,theta_params): """ Function that constructs a layer within a circuit. Parameters: circuit: Qiskit Circuit Object thera_params (list): 8 Parameters, one for each gate in the layer. Returns: circuit: New Qiskit Circuit object with layer added """ #Odd Block circuit.rx(theta_params[0],0) circuit.rx(theta_params[1],1) circuit.rx(theta_params[2],2) circuit.rx(theta_params[3],3) #Even Block circuit.rz(theta_params[4],0) circuit.rz(theta_params[5],1) circuit.rz(theta_params[6],2) circuit.rz(theta_params[7],3) circuit.cz(0,1) circuit.cz(0,2) circuit.cz(0,3) circuit.cz(1,2) circuit.cz(1,3) circuit.cz(2,3) return circuit

When a layer is built and initialised with each theta having a value of Pi/2, the circuit looks like:

For building a circuit with more than one layer, we needed to handle a larger number of parameters, in batches of 8.

def layer_calculation(circuit,params): """ Calculates each layer in batches of 8 parameters, as there are 8 per layer """ for i in range(0,len(params), 8): circuit = layer(circuit,params[i:i+8]) return circuit def construct_circuit(params): """Builds the circuit with the necessary number of layers""" circuit = QuantumCircuit(4) circuit = layer_calculation(circuit,params) return circuit

Tying all of this together – We call the `construct_circuit`

function which accepts a list of parameters, whose length will be a multiple of 8.

Now that we have the core of our circuit building implemented, we need to create our random circuit from the original question and also be able to execute both circuits.

def random_circuit(): "Generates random circuit" state = random_statevector(4) # note: # the parameter you pass is the number of entries in the vector # not the number of qubits circuit = QuantumCircuit(4,4) circuit.initialize(state.data, [0,3]) return circuit def run_circuit(circuit,simulator= Aer.get_backend('statevector_simulator'), number_of_shots=100): """Runs the circuit to get the statevector""" job = execute(circuit, simulator, shots=number_of_shots) resultant_state = job.result().get_statevector() return resultant_state

Following this, we’ll need a function that computes the distance between the the statevector of our circuit comprised of our layers and the random circuit.

def distance_function(circuit_state,random_state): """ Calculates distance between two vectors """ state_difference = circuit_state-random_state distance = np.linalg.norm(state_difference,ord=2) return distance

As this is an optimisation problem, we require a cost_function that will need to be reduced as the optimisation is run. In this case, we are trying to reduce the distance between these vectors by varying our theta parameters for our rotation gates.

Here’s where I ran into a problem: our random statevector had to remain constant for every step of the optimisation, as stated in the question. Therefore, if I was to get a random_statevector from within my cost function, the optimisation would cause a new random circuit to be created each time. This will be more visible later when I show how to run the optimisation. I decided to implement this random circuit as just a global variable, which will be shown later. I know this is a very inelegant choice, however trying to solve this problem seemed to send me down a rabbit hole of mountains of code.

def cost_function(params): """ This is the cost function to be minimised. Parameters: params: a list containing all theta values needed to build the circuit. """ global random_state_global # Ugly method, but easiest way of passing in a new random circuit. circuit = construct_circuit(params) state = run_circuit(circuit, simulator) distance = distance_function(state,random_state_global) return distance # our end deliverable

Now I was able to implement my main function. It is messy and could probably be separated a bit more, i.e. for calling the optimisation operation, but I think for the purpose of showing the thought process it was useful to see how the pieces fit together.

def main(): #preparing optimizers cob = COBYLA(1000, tol=0.0001, rhobeg=2) # where our results are stored returned_values_cobyla = [] #new circuit for each number of layers for number_of_layers in range (1,11): # initialising between 0 and 2Pi params = [random.uniform(0,2*np.pi) for i in range(8 * number_of_layers)] bounds = [] for param in params: bounds.append((0, 2*np.pi)) # bounds for optimiser ret1 = cob.optimize(num_vars=8 * number_of_layers, objective_function=cost_function, initial_point=params, variable_bounds=bounds) returned_values_cobyla.append(ret1) cobyla_results = [i[1] for i in returned_values_cobyla] return (cobyla_results)

The process here is to prepare our optimiser(in this case COBYLA) and have an empty list for us to insert our results (just to keep it simple), construct a new circuit for each combination of layers (a circuit for one layer, a circuit for 2, etc), then run an optimisation for each time we wanted a new layer in our circuit, with parameters of length 8 * the number of layers.

This optimisation process pointed to our cost function to be optimised, with an initial point definited by our parameters. The variable bounds are present simply to ensure theta values stay in the range of 0 to 2Pi.

Putting these results together (after waiting a very long time for 11 optimisations to run): we achieved the following graph:

COBYLA did a good job of optimising the process. It’s clear from Figure 2 that the minimum distance should reduce with each new layer, provided the right angles are used.

Pleased with this outcome, I decided to try again with the Adam optimiser (the most popular in ML at the minute), which actually did much better:

Although starting off in a worse position than Cobyla, for each new layer added the Adam optimiser did a great job at keeping the Minimum Distance between the vectors below 0.1. It is also worth noting that Adam executed much faster.

## Closing Thoughts

In this article, we showed how to construct a circuit in Qiskit, run it and return the resulting statevector, along with how to optimise a circuit in Qiskit with variable parameters. I am pleased with the outcome, as this is the first optimisation task I have ever had to do in Qiskit. I’m excited to do more!

If you have any thoughts on how to overcome my problem with the global variable for the random circuit, please let me know. It’s been haunting my dreams at night and I’d really like to resolve it!

I would like to thank QOSF for the opportunity (especially Michał Stęchły, who offered great support) and I’m excited to work more with the organisation. I learned a lot about Qiskit and much more while doing this task. Also, we will be announcing in the next few days a new exciting learning opportunity that Michał, myself and a few others are putting together!

Hopefully you found this article useful or interesting and it has encouraged you to play around with Qiskit a bit more. I’d also encourage you to investigate the work done by QOSF!

Thank you for reading!