Path: blob/main/qaoa-for-max-cut/01_Max-Cut-with-QAOA.ipynb
579 views
Divide-and-Conquer Implementation of QAOA
Lab 1 - Overview: Max cut with QAOA on a small graph
This lab introduces the CUDA Quantum (CUDA-Q) platform through an example of a max cut problem solved with the QAOA (Quantum Approximate Optimization Algorithm). By the end of this lab, you will have reviewed the QAOA approach to a max cut problem, written quantum kernels with CUDA-Q, and simulated the QAOA for a max cut problem. Additionally, in preparation for Lab 2, you will have previewed the divide-and-conquer implementation of QAOA.
We assume the reader has some familiarity already with quantum computation and is comfortable with the concepts of qubits, quantum circuits, measurement, Hamiltonians, expectation values, and circuit sampling. Comprehensive references include Nielsen and Chuang's Quantum Computation and Quantum Information, Aaronson's Intro to Quantum Information Science lecture notes, and Lecture notes from a Quantum Computing course taught at Chalmers University of Technology. Good video series are Nielsen's Quantum Computing for the Determined and Ekert's Introduction to Quantum Information Science which accompanies the work-in-progress Introduction to Quantum Information Science ebook.
The list below outlines what you'll be doing in each section of this lab:
1.1 Define the Max Cut problem
1.2 Experiment with a few NetworkX commands to visualize graphs
1.3 Identify a max cut solution of the
sampleGraph
through a classical brute-force computation1.4 Review QAOA for Max Cut
1.5 Implement QAOA using CUDA-Q
1.6 Preview how to scale QAOA for larger graphs using divide-and-conquer QAOA
Learning Objectives:
Apply CUDA-Q primitives such as
observe
,sample
, andvqe
to kernelsConstruct CUDA-Q kernels with and without parameters using function decoration
Visualize the divide, conquer, and merge stage of the divide-and-conquer QAOA as it is applied to a small graph
Execute the cells below to load all the necessary packages for this lab.
1.1 Max Cut
If you prefer a video introduction to the material, please execute the cell below to view the video. Otherwise, feel free to skip to the next markdown block to read through the introduction to the max cut problem.
Max Cut is the problem of finding a partition of a graph's nodes into two sets which maximizes the edges between the two sets. There is also a weighted version of this problem which we will explore in Lab 4. Although this problem is relatively easy to solve for graphs with few vertices, this problem is NP-hard. The max cut problem has a wide range of applications including machine learning, circuit design and statistical physics, among others. Furthermore, the QAOA algorithm presented in this tutorial can be adapted to other related optimization problems with an even wider application field including portfolio optimization and job shop scheduling, just to name a few.
Notation: We take the convention that represents a graph with vertex set and edge set . We use the terms vertex and node interchangeably. For this tutorial we assume that the graphs are undirected (that is, and represent the same edge). Our graphs contain no self loops (i.e., for every vertex , there is no edge ). A cut of the graph is a partition, , of the vertex set such that every vertex of is a member of exactly one of or (i.e., and ). The cut value for a partition is the sum of the edges with one node in and one node in .
In the images below, we illustrate two cuts of a graph with the dotted lines. Each of these cuts partitions the graph into two disjoint sets. The cut on the left is not optimal, and the cut on the right is the max cut. The cut on the left divides the graph into disjoint sets and , and that cut contains 3 edges. To more easily visualize the cut, we have colored the vertices in one set of the partition green and the vertices in the other set of the partition gray. The cut depicted in the diagram on the right divides the graph vertices into two disjoint sets , colored gray, and , colored green. The number of edges connecting vertices in the distinct sets is computed by For the graph on the right, the number of edges in the cut (in this case there are edges) is maximal, and this value is referred to as the max cut value. The partitioning — and sometimes the set of edges connecting vertices in and — is referred to as a max cut of a graph. Note that the max cut of a graph need not be unique; that is, two distinct partitions may produce the same cut value.
Just as it was helpful for visualizing the partitions above, it will be helpful in this tutorial to view a solution of the max cut problem as a -coloring (or a labeling) of the vertices of the graph where all nodes in are colored gray (or ) and all nodes in are colored green (or ). Note that we make no requirements other than all nodes receive a color. In particular we do not require that adjacent vertices have distinct colors. The coloring solely codes the partitioning of the set of vertices.
As we encode the max cut problem with CUDA-Q, we will eventually use bitstrings to represent the colors of the vertices in a cut. For example using the ordering of the vertices, the bitstring 01100
captures the partition in the image above on the left with vertices and in , and the bitstring 01011
codes the partition in the image on the right with vertices , , and in .
1.2 Defining a graph
For the remainder of this lab, we will work with the graph coded in the cell below and will refer to it as sampleGraph
. We will be using the NetworkX library for the graphs in this tutorial. Please read through the cell below to familiarize yourself with some of the commands in the NetworkX library used in this tutorial.
Notice that the coloring above induces a cut with edges , , and . This is not a maximal cut of this graph. The aim of this lab is to use QAOA to find a maximal cut.
1.3 Classical solutions to Max Cut
For our small graph with seven vertices, there are only different choices of the partition sets and . In fact, there are only distinct partitions since the partition , is equivalent to the partition , for any subset of . Our graph is small enough that we can, by brute force, check every single partition to find the max cut.
Exercise: Complete the code block below to find the max cut solution to the sampleGraph
via brute-force computation. We won't worry about double checking the cut values of partitions and . For this exercise, compute the cut values for each partition of vertices by replacing FIX_ME
with selections of the following:
sampleGraph.nodes()
sampleGraph.edges()
subset_cut_value > max_cut_value
u not in V0
If you get stuck, you can click on the triple dots below to view the solution.
1.4 Quantum Approximation Optimization Algorithm (QAOA) for Max Cut
1.4.1 Quadratic Unconstrained Binary Optimization (QUBO) Formualtion of Max Cut
Since there are many distinct -colorings of the graph, and distinct cuts of the graph, the brute-force approach to check every coloring of the graph is not scalable to large graphs. There are several classical algorithms that perform better than the brute-force approach on certain subclasses of graphs, and there are heuristic algorithms that give good approximations to the max cut rather quickly. For a review of many of the classical approaches to max cut, check out this paper and this one. In this tutorial, we will be exploring a quantum heuristic algorithm for identifying good (but perhaps not maximal) cuts of graphs. More specifically, we begin with QAOA (Quantum Approximate Optimization Algorithm) and then explore a divide-and-conquer implementation of QAOA, which is one of many adaptations of QAOA.
In order to employ quantum algorithms, it is helpful to reframe the max cut problem as a quadratic unconstrained binary optimization (QUBO) problem. Suppose that we have a graph . We can code a partition of by defining for each , a variable so that if vertex and if . (Another way of thinking about this is that represents the coloring of a vertex as 0 or 1 depending on whether is a member of or .) The max cut problem then can be restated as finding an assignment to the variables so that the function is maximized.
Let's walk through the formula above to observe that computes the cut value of the graph for the partition coded by . For example, notice that if were an edge and we assigned to and to , this edge would be in the cut given by the partition. Furthermore, the value of the term () associated with this edge in the equation would be . Take a minute to consider what happens to the term associated with in the equation for if and were both assigned 1 (and therefore, the edge between them would not be in the cut). Is the result what you expected?
QUBO problems have a natural translation to Ising Hamilitonian problems (see for instance this article). This translation is carried out by mapping the binary -variables to variables that take on the values and . Take a moment to verify that by assigning and to identify a cut of a graph, the equation below computes the cut value for that cut: Take for instance an edge with the assignments . This edge would not be in the cut determined by these variable assignments. The term in the equation above associated with this edge would be and would not contribute to the cut edge count. Verify that the assignment and and the assignment both produce the result you would expect for to compute the cut value.
Notice that if we multiply by -1, we can reframe the optimization problem from maximization to minimization. Furthermore, we can promote this equation to a matrix equation by replacing with the Pauli-Z operator acting on qubits associated with nodes and , respectively, and replacing 1 with the identity matrix. This leads to the reformulation of the problem from one of maximizing to one of minimizing the eigenvalues of
1.4.2 QAOA
In this section, we will describe the Quantum Approximate Optimization Algorithm (QAOA) for solving the Max Cut problem. Farhi et al. (2014) originally introduced QAOA to tackle optimization problems like Max Cut. More recent research by Farhi et al. (2025) have shown that QAOA can establish lower bounds on the maximum cut achievable in high girth 3-regular graphs, surpassing previously known bounds. This study indicates that QAOA can guarantee the existence of such cuts through classical numerical analysis, suggesting an exponential speedup over the best known classical algorithms for finding cuts of this size on graphs of this girth.
In essence, QAOA is a variational algorithm composed of a variational quantum circuit (a kernel dependent on a set of parameter values) and a classical optimizer. The goal of QAOA is to use the classical optimizer to identify parameter values that generate a quantum circuit whose expectation value for a given cost Hamiltonian is minimized. In our case, the cost Hamiltonian will be as defined in the previous section.
The diagram below depicts the subroutines in QAOA.
The two green process blocks in the image above represent the quantum subroutines that we'll execute with CUDA-Q primitives. The green process block on the left will use the observe
primitive to estimate the expectation value (this primitive is subsumed in a vqe
call) and the green process on the right will use the sample
primitive to identify the most probable outcome of the circuit. We'll describe how to program this entire flowchart using CUDA-Q in the following sections.
1.5 CUDA-Q Implementation of QAOA for Max Cut
This section starts with a brief review of CUDA-Q syntax. From there, we can define a function to carry out QAOA by creating several helper functions for the problem Hamiltonian, QAOA kernel, etc. If you'd like to treat the QAOA algorithm as a black box, you can jump to section 1.5.6 where we pull everything together and demonstrate how to simulate the kernel on a GPU. However, understanding the code below is necessary for the remainder of the labs.
1.5.1 CUDA-Q Basics
Before implementing QAOA in CUDA-Q, let's run through two quick examples highlighting most of the syntax that we will need. The first thing to note is that we will sometimes refer to quantum circuits as kernels. While every quantum circuit is a kernel, kernels need not be quantum circuits. CUDA-Q kernels allow for the creation of generic quantum programs that can be compiled and executed on various devices. They provide better interplatform compatability and control flow. We will see some more general quantum kernels towards the end of this lab, but for now you can think of a kernel as a quantum circuit.
There are two main primitives common in most quantum algorithms: sampling a circuit to approximate the probability distribution of the state and computing expectation values given a circuit and Hamiltonian. As an introduction to CUDA-Q, we provide two short examples of these routines below.
The first example in the code block below demonstrates how to
select a backend for kernel execution or simulation
define the kernel which involves initializing the quantum kernel and allocating qubits, applying gates to the kernel (in this case we'll create a 2-qubit GHZ state), and measuring the state
sample the outcomes.
We will be using CPUs to simulate quantum kernels throughout this tutorial. In other notebooks, we'll discuss how to change the target to access GPUs. While it is easy to switch to a quantum processor, by simply changing the target (refer to this guide for more details), there are some differences between how the code is implemented behind the scenes. In simulation mode, the quantum state is built once and then sampled shots_count
many times. In quantum hardware execution mode, the quantum state collapses upon measurement and hence needs to be rebuilt over and over again.
For the example below, we expect to see about 50% of the outcomes to be the 00
state and about 50% to be in the 11
state.
The next example shows how to use CUDA-Q to compute the expectation value , where is a Hamiltonian and is a quantum state defined by the kernel. We'll use the GHZ state, . Therefore, our kernel will be the same as the previous example, but we will remove the measurement. The Hamiltonian is encoded using the spin
operators in cudaq
and they can be combined together with scalers using +
and *
. The observe
primitive computes many statistics, but we will only need the .expectation()
value.
1.5.2 Problem Hamiltonian
Exercise: Using the syntax demonstrated in the previous section, edit the line commented as ###FIX_ME###
in the code block below to define the problem Hamiltonian. Recall that for max cut problems, the problem Hamiltonian is where is the set of edges in our graph and is shorthand for the spin operator applied to the qubit associated with vertex . In a similar manner can be thought of as . Hint: you will need to use the spin.z
and spin.i
operations. Click on the triple dots in the 2nd cell below to view the solution.
1.5.3 QAOA Circuit
In order to program QAOA for our max cut problem, we need to understand the structure of the variational QAOA quantum circuit. The general structure of the circuit is described and depicted below:
For each vertex in the graph, there is an associated qubit in the circuit.
The circuit is initialized in a superposition state, corresponding to a a state in which each possible coloring of vertices of the graph is equally likely. This is achieved with the Hadamard gates on the left of the diagram.
The QAOA circuit is made up of repeated blocks (referred to as layers) of a problem kernel and a mixer kernel. These layers are outlined in light gray.
The problem kernel is drawn in blue. It encodes our graph. We'll go into more details about the make up of the problem kernel later in this section.
The mixer kernel is composed of the green parameterized rotation gates in the diagram below.
Let's take a closer look at the problem kernel, which encodes the structure of the graph for the max cut problem. For each edge in the sampleGraph
a contolled- gate is applied with control qubit associated with and target qubit associated with . Then a parameterized -rotation is applied to the qubit associated with , and this is followed by another controlled- gate identical to the first one applied. The diagram below illustrates how the edge between node 1 and 2 in the graph drawn below would get encoded in the kernel. Here we have assigned vertex 1 to qubit 1 and vertex 2 to qubit 2. The encoding of edges is repeated for each edge of the graph. Within a given layer of the QAOA cicuit, the same parameter is used for all of the edge encodings in the problem kernel, but new parameters are introduced for each layer.
In the code block below, edit the FIX_ME commands to define a kernel function that takes as input 2 qubits corresponding to an edge in our graph and a parameter, then applies the parameterized-gate sequence depicted in the image above to those two qubits.
Now, we'll create a kernel function for the mixer. This kernel will take as input a qubit and a parameter, and applies a parameterized x-rotation gate to the qubit. Edit FIX_ME statements in the code block below.
We're ready to put these kernels together to build up the QAOA circuit. You are tasked in the exercise below to edit the FIX_MEs to carry out the first steps:
initialize the qubits - one for each vertex in our graph
place the qubits in superposition.
Then, the two kernels that we created in the previous blocks will be added to form the layers in the QAOA circuit.
Side Note: In this tutorial we defined kernels using the preferred PyKernel
decorator. An alternative method for defining kerels is to use the kernel builder syntax.
That completes the QAOA kernel. Before moving on to define the optimizer, let's examine how varying parameter values produce different graph colorings with corresponding cut values when executing the QAOA circuit. Execute the cell below to generate a widget. You don't need to worry about the code in that cell, just focus on the widget. In the widget, adjust the sliders to experiment with different parameter values and visualize the resulting graph coloring from the QAOA circuit's execution.
As you may notice, some parameter values yield better cuts than others. This is where the classical optimizer loop comes into play. Each iteration of the loop suggests a new set of parameters to improve the cut, aiming to converge on the maximum cut of the graph. We'll define the optimizer loop next.
1.5.4 Optimizer Loop
CUDA-Q has several built-in optimizers. You can find more information about the optimizers here.
The code block below defines our optimizer and sets the initial parameter values. We'll use the COBYLA optimizer. We'll set a seed and choose initial parameter values randomly. Recall that the number of parameters needed depends on the number of layers in our QAOA circuit. Each layer calls two parameters, one for the problem kernel and one for the mixer.
The vqe
function is built into CUDA-Q and carries out the optimization loop once the optimizer, initial parameters, kernel, and Hamiltonian cost function have been defined. Let's add the vqe
call to the code. The code block below implements the full optimizer loop. When you execute the code, you'll see the optimal parameter values identified for the max cut approximate solution to our sampleGraph
. In the next two sections, we'll walk through how to read out an optimal cut from this.
We can read out an approximation for the max cut value from this result. The optimal value reported is the average value () of our problem Hamiltonian when applied to the state generated by the quantum circuit with the optimal parameter values. What this means that at our max cut value for the sampleGraph
is at least 4.466. We'll use the sampling in the next section to identify the most probable measurements of the state generated by the QAOA kernel with these optimal parameter values.
Since we'll eventually be applying QAOA to several graphs, let's wrap all of this into a function that takes as input the graph, layer count, and seed and outputs the optimal parameters found after executing QAOA with the given layer count and seed for max cut on the given graph. We'll want to apply QAOA to graphs whose vertices may not be indexed by consecutive integers ranging from 0 to n, so we'll need to do some bookkeeping before passing the graph to the QAOA kernel by identifying the each vertex with its position in a sorted list of the vertices of the graph.
Let's verify that the find_optimal_parameters
function executes as expected.
1.5.5 Sampling the results of QAOA
The final step of the QAOA is to sample the QAOA circuit using the optimal parameters identified through the optimization loop. This is carried out with sample
for which we can specify the number of shots. The code block below adds the sampling instruction. For now, we'll set the layer_count
to be 1. Notice that there are many colorings of the 7 nodes. Therefore, we want to make sure that our shots_count
will be large enough to distinguish the most probable outcome among these 128 states.
1.5.6 Creating a function to carry out QAOA for max cut on a graph
Because we'll want to reuse this code later on and apply the QAOA algorithm to other graphs, we'll create a function that carries out QAOA for max cut on a given graph with specified layer count, number of shots in the sampling subroutine, and a random seed for reproducibility.
We are ready to run our code to find the max cut of sampleGraph
! Execute the code block below which calls on the qaoa_for_graph
function that we've defined in the previous sections, interprets the resulting bitstring as a coloring or partitioning of sample graph, and computes the cut.
Yay! Using QAOA, we found the max cut value which matches our brute-force computation earlier in this lab.
Let's visualize the result by highlighting the edges in the cut with green.
1.6 Scaling QAOA
In the remainder of this lab, we explore another approach to this problem that will allow us to scale QAOA to handle slightly larger graphs than otherwise would be possible.
In QAOA the number of qubits scales with the number of vertices in the graph. Additionally, the depth of the QAOA circuit grows with the number of edges and the number of layers. Circuit depth and number of qubits are two factors in determing the cost and feasibilty of executing or simulating a quantum algorithm. Before jumping into the divide-and-conquer QAOA algorithm applied to a larger graph in Lab 2, we apply part of this algorithm to the sampleGraph
as a warm up.
Divide-and-conquer QAOA can be thought of as 3 stages: divide, conquer, and merge. We preview the divide-and-conquer idea of breaking up a larger graph into smaller subgraphs in section 1.6.1, solving the max cut problem for the smaller subgraphs in section 1.6.2, and stitching the subgraph solutions back together to get a solution to the original graph in sections 1.6.3 and 1.6.4. In this example, we will be applying a brute-force algorithm to stitch together the subgraph solutions, but in the next lab we will improve upon this approach with an application of QAOA that identifies an approximately optimal way to merge together the solutions.
1.6.1 Divide: Dividing the sampleGraph into two smaller subgraphs
Our sampleGraph
is small and easy to visualize. A natural partition of the graph is to break the graph at the edge , leaving us with two smaller disjoint subgraphs: sampleSubgraph0
and sampleSubgraph1
which we will draw below with node colors blue and yellow, respectively. Notice that the edge between vertex and does not appear in either subgraph, and every vertex from sampleGraph
appears in exactly one of the subgraphs.
1.6.2 Conquer: Solving the subgraph problems using QAOA
Breaking sampleGraph
into two smaller graphs not only reduces the number of qubits required in each QAOA kernel, but it also affords us the ability to compute the max cut of these subgraphs in parallel. We'll execute these kernels sequentially here, but we'll learn how to parallelize this in Lab 2 and Lab 3.
Exercise edit the code block below to find the max cut of each subgraph by replacing the FIX_ME with a function that we've defined in the previous sections.
1.6.3 Merge: Determining a (perhaps not-optimal) cut of the sampleGraph from the subgraph solutions
Because no vertex lies in both subgraphs, we can use the subgraph solutions to generate a (possibly non-optimal) cut of the original sampleGraph
by coloring each node with the colors determined by the subgraph solutions. We'll find a better cut in the next subsection.
Notice that the cut value for this partition is 5. This is less than the optimal value of 6 that we found earlier. Therefore, we have not optimally combined the subgraph max cut solutions into a solution for the sampleGraph
. We can be more careful about how we merge together the subgraph solutions to generate a cut of the original graph. We'll explore this in the next section.
1.6.4 Merge: Finding a better merger of the subgraph solutions
Instead of directly merging the subgraph solutions together as we did above, we can inspect the edges that do not lie in either subgraph and make some adjustments to the colorings before stitching them together. This is a common procedure in both classical and quantum max cut algorithms and is sometimes referred to as local search.
In the example above, the edge is not included in the cut deduced from the merger of the subgraph solutions because both and were colored gray. Suppose we could change the coloring of sampleSubgraph0
without reducing the cut value of the subgraph, but while also ensuring that gets colored green. Then, we would be able to include edge in the merged cut, resulting in a larger cut value for the sampleGraph
. Our graphs are small enough that we can visually check to see if this would be possible.
Notice that the sampleSubgraph0
cut value resulting from the node coloring 100
is the same as the one coded by 011
. By simply swapping the colors of the sampleSubgraph0
result, we can find a better (optimal) cut for the sampleGraph
because the cut value for sampleSubgraph0
will remain the same, but we will capture the edge in our cut of the sampelGraph
with this new coloring. Let's see how that works out.
Hooray! We have found a max cut of the original graph using our subgraph solutions. It's a different solution from the one that we originally found, but it's just as good!
Next
In the above example, our graph was small enough that we could inspect the subgraph solutions and realize that the subgraph solutions needed to be altered (by flipping the colors of sampleSubgraph0
) in order to generate an optimal cut of the original sampleGraph
. In Lab 2, we will investigate non-brute-force methods for stitching together subgraph solutions to optimize the cut of the parent graph.