VQE and QAOA to solve MAXCUT¶
To hand out¶
- A report with your answers in a PDF FILE (made out of LaTeX, libreoffice, ...)
- Math and text answers
- The code for the circuits
- Screenshot of figures/circuits
- python answers and results of runs
- etc
- This notebook
- as a runnable script
Do not hesitate to create one new cell for each run
1 Some prerequisites¶
1.1 - Making sure we have qiskit installed¶
import numpy as np
import matplotlib.pyplot as plt
from math import pi, gcd
from qiskit import *
from qiskit.circuit import *
from qiskit.circuit.library import *
from qiskit.quantum_info.operators import Operator
from qiskit_aer import AerSimulator, StatevectorSimulator
from scipy import optimize
%matplotlib inline
%config InlineBackend.figure_formats = ['svg']
1.2 - Some helpers¶
def plotDistrib(d):
sorted_items = sorted(d.items())
keys = [k for k, _ in sorted_items]
values = [v for _, v in sorted_items]
plt.figure()
plt.bar(keys, values)
plt.xticks(rotation=90)
plt.show()
def getSample(qc,howmany):
simulator = AerSimulator()
job = simulator.run(qc.decompose(reps=20), shots=howmany)
res = dict(job.result().get_counts(qc))
return res
def plotSample(qc,howmany):
d = getSample(qc,howmany)
ld = len(list(d.keys())[0])
for i in range(2 ** ld):
s = nat2bs(ld,i)
if s not in d: d[s] = 0
plotDistrib(d)
1.3 - Finding the minimum of a function in Python¶
I suggest to use the the operator optimize.minimize. Below an example of usage.
# The function f takes as input a list of 10 variables
def f(xs):
res = 0
for i in range(10):
res = res+(xs[i]-i)*(xs[i]-i)
return res
The function $f$ computes $\vec{x}\mapsto x_0^2+(x_1-1)^2+(x_2-2)^2+(x_3-3)^2+(x_4-4)^2...$
Its minimum is the vector $(0,1,2,3,4,5,6,7,8,9,10)$
# Let us infer this minimum.
# The operator minimize takes as input the function f, a starting value as a guess, and a method.
# Powell is supposed to be well-suited for the case where we have plenty of variables...
# The manual : https://docs.scipy.org/doc/scipy/reference/optimize.minimize-powell.html
o = optimize.minimize(f, [1 for i in range(10)], method="Powell")
o
message: Optimization terminated successfully.
success: True
status: 0
fun: 5.018264692853602e-28
x: [ 1.443e-15 1.000e+00 2.000e+00 3.000e+00 4.000e+00
5.000e+00 6.000e+00 7.000e+00 8.000e+00 9.000e+00]
nit: 3
direc: [[ 1.000e+00 0.000e+00 ... 0.000e+00 0.000e+00]
[ 0.000e+00 1.000e+00 ... 0.000e+00 0.000e+00]
...
[ 0.000e+00 0.000e+00 ... 1.000e+00 0.000e+00]
[ 0.000e+00 0.000e+00 ... 0.000e+00 1.000e+00]]
nfev: 254
# The inferred minimum vector is stored at the key `x`:
list(o.x)
[1.4432899320127035e-15, 1.0, 2.0, 2.9999999999999987, 3.9999999999999916, 5.000000000000003, 5.999999999999991, 6.9999999999999964, 7.9999999999999964, 9.000000000000018]
2 - Coding graphs¶
In this lab session, we code graphs as adjacency lists:
- nodes are numbers
0,1,2,3... - edges are stored with dictionary: to a node one associates a set of its neighbourgs.
The graphs are non-directed: we take as convention that if g is a graph and if $j \in g[i]$, then $i<j$.
Some examples are as follows.
g1 = {0: set([1,2]), 1:set([2,3]), 2:set([3])}
g2 = {0: set([1,2,3])}
g3 = {0: set([1]), 1: set([2]), 2: set([3])}
Let us draw the graphs!
NOTE The graph drawing routine is using networkx that you can install with pip. if it does not work, just comment the cell: it is not used later on.
import networkx as nx
def drawGraph(g,title):
G = nx.Graph()
G.add_nodes_from(g.keys())
G.add_edges_from([(i,j) for i in g for j in g[i]])
plt.figure(figsize=(2, 2))
nx.draw(G, with_labels=True, node_color='lightblue', node_size=800)
plt.title(title)
plt.show()
drawGraph(g1,"g1")
drawGraph(g2,"g2")
drawGraph(g3,"g3")
2.1 - Cuts in a graph¶
for a graph with nodes $0,1,2,3,...n$, a cut is written as a string $b_n...b_2b_1b_0$ with $b_i$ characters 0 or 1. The node $i$ belongs to the set $V_{b_i}$. For instance, 1010 corresponds to the two sets $V_0=\{0,2\}$ and $V_1=\{1,3\}$.
Note how we read the nodes backward in the string: this is due to the way the measure is handled in Qiskit.
Given a cut s for a graph g, the cost of the cut is the number of edges crossing it.
TODO Implement the function computing the cost of a cut¶
def costOfCut(g,s): # g is a graph, s is a cut
r = 0
# TODO : UPDATE r
return r
# Tests
print(costOfCut(g1,"0110")) # Should be 4
print(costOfCut(g2,"0111")) # Should be 1
print(costOfCut(g2,"1110")) # Should be 3
0 0 0
2.2 - Scalar product¶
In both VQE and QAOA, we are interested in finding the eigenvector of minimum eigenvalue for the Hamiltonian
$$
H_C = -\sum_x C(x)|x\rangle\langle x|
$$
where $C$ is the cost function costOfCut for the graph.
For this, we discussed how it is sufficient to look for the vector $|\phi\rangle$ minimizing $$ |\phi\rangle \mapsto \langle \phi|H_C|\phi\rangle $$
We also discussed how to estimate $\langle \phi|H_C|\phi\rangle$ from a probabilistic distribution of measurements of $|\phi\rangle$.
TODO Implement the scalar product¶
The input d is assumed to be the probabilistic distribution corresponding to the result of a measurement, for instance d could be
{ "00" : 0.5, "11" : 0.5" }
if it were the result of measuring a Bell state.
Recall that we are in the case where the Hamiltonian is diagonal, so the computation is very simple. Remember also that we are looking to MAXIMIZE a value but that VQE will perform a minimization...
def scalprod(g,d):
r = 0
# TODO : COMPUTE r
return -r # I added the minus sign for you...
2 - MAXCUT with VQE¶
We need to be able to build parameterized gates. As discussed in the course, a general gate on one qubit can always be written using 3 angles. In QisKit, this is the gate u. Here is a small self-contained example of building circuit with this gate, and running the circuit
# A very simple circuit
q = QuantumRegister(1, name="q")
qc = QuantumCircuit(q)
# To apply a generic gate on qubit 0:
angle1 = 1.23
angle2 = 2.34
angle3 = 4.56
qc.u(angle1,angle2,angle3,q[0])
qc.draw()
┌───────────────────┐ q: ┤ U(1.23,2.34,4.56) ├ └───────────────────┘
2.1 - The problem¶
We shall be coding VQE for the Hamiltonian steaming from MAXCUT, exactly the way we saw it on the board. As discussed, the objective is to find the maximal cut of the graph g1.
The problem can be splitted in two parts :
- Generate a probability distribution
- Infer the scalar product out of this distribution
2.2 - Implementation¶
We already have the function for computing the scalar product: remains generating the probabilistic distribution, as with the circuit we saw in class.
TODO fill in the following function¶
It inputs a list of angles to be used to make columns of CNOTS and u gates. The u-gates rotates the state while the CNOTs are there to create some entanglement. The way the CNOTs are placed is not very important, as long as all wires are connected. For instance, we can use the circuit discussed in class (and shown in 7.2.8 of the lecture notes), with a measurement at the end.
Each 1-qubit gate is the generic u-gate defined with 3 angles, so each tower of U's requires 12 angles. A series of 3 layers would therefore needs an array of 36 angles. One can use more, or less layers: the goal is to be able to attain as much states as possible.
Note This is an example of ansatz, a "circuit with holes". It is not necessarily the only one, nor the best one. And in fact, the QAOA proposes another ansatz, more suitable.
We then want to return an estimate of the probabilistic distribution steaming from the measurement of the memory.
def probDistVQE(a): # The variable a contains a list of angles (real numbers).
q = QuantumRegister(4)
c = ClassicalRegister(4)
qc = QuantumCircuit(q,c)
# TODO : circuit for VQE
# At this stage, the circuit should have "computed" the candidate vector |phi> needed
# for the computation <phi|Hamiltonien|phi>. We cannot do this here, but we can measure and
# do it offline, with the probability distribution.
# We then add a measure at the end
qc.measure(q, c)
# Run a thousand times and collect samples
res = getSample(qc,1000)
# Renormalize so that the gradients are not awfull
for i in res:
res[i] = res[i] / 1000
# Return the final distribution
return res
The function probDistVQE is the one we want to minimize. It is enough to call optimize.minimize. As input table, we can take whatever we want ([0.0, 0,0,.... 0.0] for instance).
Then, if o contains the answer to the call to optimize.minimize:
list(o.x)is a table of angles which answers the problem- It is enough to give it as argument to
probDistVQEto get the distribution maximizing the probability of the "good cuts".
BEWARE the variable initAngles should only contains floats, not integers...
# The graph
g = g1
# The function to minimize
def toMinimizeVQE(a):
return scalprod(g,probDistVQE(a))
# Initial guess for the angle
initAngles=[0.0 for i in range(12*3)]
# Let us minimize it
o = optimize.minimize(toMinimizeVQE, initAngles, method="Powell")
# Let us retrieve the probability distribution
probDistVQE(list(o.x))
# Slowww but normal
{'0000': 1.0}
2.3 - Questions and discussion¶
What are the various proposed cuts $(V_{0},V_{1})$ ?
Change the graph and check that it does not work "just by chance" (for instance, use
g2,g3, or your own).Are all possible cuts there ?
The problem is symmetric, in the sense that if '0110' is an answer, so is '1001'. Is this reflected in the resulting probabilities ?
If we had access to a real quantum co-processor, how would the code change ?
Report your code and answers in the separate report !¶
3 - MAXCUT with QAOA¶
Instead of using a completely generic circuit, we are here going to use QAOA technique, as discussed in class and in 7.3.3 of the lecture notes. The components are
- $|s\rangle = \frac1{\sqrt{2}^n}\sum_{i=0}^{2^n-1}|i\rangle$
- $U_\lambda = e^{i\lambda B}$ where $B = \sum_{i=0}^n \sigma_X^i$ and $\sigma_X^i$ is the doot $X$ applied on qubit $i$ and the identity everywhere else
- $V_\lambda = e^{i\lambda H_C}$
- two tables of angles $\gamma$ et $\beta$ of size $p$
In QAOA, we fix $p$ and we look for the optimal tables of angles $\beta$ and $\gamma$ that make the scalar product $\langle\psi|H_C|\psi\rangle$ minimal.
3.1 - An important subcircuit¶
We derived in class that the structure of $V_\lambda$: for g1 it is
TODO Implement $V_\lambda$. The arguments are¶
qcis the quantum circuit in constructionqis the quantum register to be acted uponangleis the angle for the rotationgis the graph structure
def V(qc,q,angle,g):
# TODO : add the subcircuit V on the register q in the circuit qc
return
# Test : drawing the circuit V for g1
angle = 1.234
q = QuantumRegister(4, name="q")
qc = QuantumCircuit(q)
V(qc,q,angle,g1)
qc.draw()
q_0:
q_1:
q_2:
q_3:
def probDistQAOA(a,g): # For optimize.minimize, we store beta and gamma in a single array
p = int(len(a)/2) # of course, a should be of even length
beta = a[:p] # say that beta is at the end
gamma = a[p:] # and gamma is at the front
q = QuantumRegister(4)
c = ClassicalRegister(4)
qc = QuantumCircuit(q,c)
# TODO : initialize |s>
# TODO Place the Us and Vs (p layers)
# At this stage, the circuit should have "computed" the candidate vector |phi> needed
# for the computation <phi|Hamiltonien|phi>. We cannot do this here, but we can measure and
# do it offline, with the probability distribution.
# We then add a measure at the end
qc.measure(q, c)
# Run and collect the results
res = getSample(qc,1000)
# Renormalize so that the gradients are not awfull
for i in res:
res[i] = res[i] / 1000
# Return the final distribution
return res
We can then use it exactly as for VQE.
# TODO !
3.3 - Discussion¶
- Does it still work with other graphs ?
- Does it find all of the possibilities ? How about the symmetry of the results ?
- Play with the length of the array
a($p=2,4,...$ -- make sure to keep it even). Do you see any loss/increase in precision ? - Remark how the number of necessary parameters is way smaller than for VQE.
Report your code and answers in the separate report !¶