Graph Optimization with QAOA in PennyLane on Braket
Overview
# --- Setup cell added by QCR (not part of the original tutorial) ---
# Source: amazon-braket/amazon-braket-examples @ 0c0818f315479aab9deebed7e7ed7533ac581923, Apache License 2.0.
# Installs the example's dependencies. If a later cell still reports a missing
# package, restart the runtime/kernel and run again from the top.
%pip install -q amazon-braket-sdk==1.117.3 pennylane==0.45.0 amazon-braket-pennylane-plugin==1.34.1 matplotlib pandas networkx
Graph optimization with QAOA
One application area where near-term quantum hardware is expected to shine is in graph optimization. Graph-based problems are interesting to explore because they have both strong links to practical use-cases (such as logistics and social networks) and are also often hard to solve.

Graphs are composed of a collection of interconnected nodes. For example, here is a four-node graph:
import networkx as nx
n_nodes = 4
p = 0.5 # probability of an edge
seed = 1979
g = nx.erdos_renyi_graph(n_nodes, p=p, seed=seed)
positions = nx.spring_layout(g, seed=seed)
nx.draw(g, with_labels=True, pos=positions, node_size=600)Many practical use-cases can be mapped to a graph structure. In a social network, the nodes of a graph can represent users and the edges can represent connections between the users.
We often need to solve optimization problems to identify important properties of the graph. These problems can include:
- finding large clusters of fully connected nodes (known as maximum clique)
- finding a minimum number of nodes that connect to every edge in the graph (known as minimum vertex cover)
- finding a partition of the nodes into two subsets so that the greatest number of edges are intersected (known as maximum cut)
This tutorial shows how a quantum algorithm called QAOA can be run using PennyLane and Braket to solve graph-based optimization problems. We begin with a small 4-node graph and then push the limits to run an 18-node graph using parallel executions on SV1.
QAOA
The quantum approximate optimization algorithm (QAOA) is an algorithm designed for near-term hardware. It can find approximate solutions to combinatorial optimization problems such as graph-based problems.
QAOA is covered in more depth in the QAOA_braket notebook as well as in PennyLane tutorials. The following is a short summary to refresh the key concepts.
QAOA begins by associating the optimization problem with a cost Hamiltonian

The algorithm then measures the cost Hamiltonian
Fixing the problem
Let's consider the graph above and aim to find the maximum clique, i.e., the largest set of nodes that are fully connected.
To solve this using QAOA in PennyLane and Braket, we first calculate the cost Hamiltonian
import pennylane as qml
from pennylane import numpy as np
cost_h, mixer_h = qml.qaoa.max_clique(g, constrained=False)
# constrained=True results in greater circuit depth but potentially better solutions
print("Cost Hamiltonian:\n", cost_h)
print("Mixer Hamiltonian:\n", mixer_h)Cost Hamiltonian: (-0.5) [Z2] + (0.25) [Z0] + (0.25) [Z3] + (1.0) [Z1] + (0.75) [Z0 Z2] + (0.75) [Z2 Z3] Mixer Hamiltonian: (1) [X0] + (1) [X1] + (1) [X2] + (1) [X3]
Setting up the algorithm
We begin by setting up a single QAOA layer

This layer contains the controllable parameters
def qaoa_layer(gamma, alpha):
qml.qaoa.cost_layer(gamma, cost_h)
qml.qaoa.mixer_layer(alpha, mixer_h)The full QAOA circuit is then given by:
n_layers = 2
wires = n_nodes
def circuit(params, **kwargs):
for i in range(wires): # Prepare an equal superposition over all qubits
qml.Hadamard(wires=i)
qml.layer(qaoa_layer, n_layers, params[0], params[1])There are overall four controllable parameters: the first two are for
np.random.seed(1985)
params = np.random.uniform(size=[2, n_layers])
paramstensor([[0.0053323 , 0.06300618],
[0.56089065, 0.10018836]], requires_grad=True)For this part of the tutorial, we will use the local Braket simulator (see the introduction tutorial for further details):
dev = qml.device("braket.local.qubit", wires=wires)The final step is to define the cost function. In QAOA, the output cost function is given by the expectation value of the cost Hamiltonian
@qml.qnode(dev, diff_method="parameter-shift")
def cost_function(params, **kwargs):
circuit(params)
return qml.expval(cost_h)Running the algorithm
Now that we have set up the cost function, we just need to pick an optimizer and run the standard optimization loop.
optimizer = qml.GradientDescentOptimizer()%%time
print("Initial cost:", cost_function(params))
for i in range(10):
params = optimizer.step(cost_function, params)
cost_eval = cost_function(params)
print(f"Completed iteration {i + 1}, cost function:", cost_eval)Initial cost: 0.10901335346620078 Completed iteration 1, cost function: -0.055924072910973115 Completed iteration 2, cost function: -0.22761069369561948 Completed iteration 3, cost function: -0.4039653421158007 Completed iteration 4, cost function: -0.5814217439494995 Completed iteration 5, cost function: -0.7551479094131212 Completed iteration 6, cost function: -0.9198393933758282 Completed iteration 7, cost function: -1.0708307386802136 Completed iteration 8, cost function: -1.2050204372450495 Completed iteration 9, cost function: -1.321209023705309 Completed iteration 10, cost function: -1.4198324693330084 CPU times: total: 46.9 s Wall time: 47.3 s
Investigating the result
How do we know how well the algorithm has performed? To do this, we can sample from the circuit using the optimized parameters. This will give us binary samples that allow us to select which nodes of the graph to use as part of our clique, e.g., either by simply selecting the most common sample or selecting the sample with the lowest corresponding energy.
Let's take some samples and see which ones occur most frequently. To start, we'll create a QNode designed for sampling.
shots = 10_000
dev = qml.device("braket.local.qubit", wires=wires, shots=shots)
@qml.qnode(dev, diff_method="parameter-shift")
def samples(params):
circuit(params)
return np.array([qml.sample(qml.PauliZ(i)) for i in range(wires)])Samples can now be generated and converted into probabilities:
from collections import Counter
s = samples(params).T
s = (1 - s.numpy()) / 2
s = map(tuple, s)
counts = Counter(s)
indx = np.ndindex(*[2] * wires)
probs = {p: counts.get(p, 0) / shots for p in indx}We can now plot the probability distribution over all possible samples:
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use("seaborn-v0_8-colorblind")
labels = ["{0:{fill}{length}b}".format(i, fill="0", length=wires) for i in range(len(probs))]
plt.bar(range(2**wires), probs.values())
plt.xticks([i for i in range(len(probs))], labels, rotation="vertical", size=12)
plt.yticks(size=12)
plt.xlabel("Sample", size=16)
plt.ylabel("Probability", size=16)
fig = plt.gcf()
fig.set_size_inches(6, 4)
plt.show()From the plot, it is clear that the sample 1101 has the greatest probability. Since each qubit corresponds to a node, this sample selects the nodes [0, 1, 3] to form a subgraph. Let's check if this is a clique, i.e., if all of the nodes are connected:
sub = g.subgraph([0, 1, 3])
nx.draw(g, pos=positions, with_labels=True)
nx.draw(sub, pos=positions, node_color="r", edge_color="r")Great, this is a clique! Moreover, it is the largest clique in this four-node graph. QAOA, using PennyLane and Braket, has helped us to solve the maximum clique problem!
Scaling up QAOA for larger graphs with hybrid jobs
We have seen how we can use PennyLane on Braket to solve graph optimization problems with QAOA. However, we have so far restricted to a simple six-node graph and used the local Braket device. Let's now be more ambitious and try to solve an optimization problem on a 18-node graph! We will use Amazon Braket Hybrid Jobs to scale up the classical resources, and run the entire algorithm asynchronously.
# Use Braket SDK Cost Tracking to estimate the cost to run this example
from braket.tracking import Tracker
task_tracker = Tracker().start() # track Braket tasks costsimport networkx as nx
nodes = wires = 18
edges = 60
seed = 1967
g = nx.gnm_random_graph(nodes, edges, seed=seed)
positions = nx.spring_layout(g, seed=seed)
nx.draw(g, with_labels=True, pos=positions)An 18-node graph (which maps to the same number of qubits) definitely puts us in a regime where the local simulator will be slow to execute. As we have discussed in the parallelization tutorial, this slowness will be compounded when it comes to training the circuit, with each optimization step resulting in multiple device executions due to calculation of the gradient. Thankfully, the remote SV1 simulator is highly suited to speeding up gradient calculations through parallelization or adjoint differentiation. We now show that this makes training the circuit for QAOA solvable within a reasonable time.
Let's first load a new device:
from braket.devices import Devices
device_arn = Devices.Amazon.SV1
# device_arn = "arn:aws:braket:::device/quantum-simulator/amazon/sv1" # alternatively use the device ARNdev = qml.device("lightning.qubit", wires=wires)We now just need to set up the QAOA circuit and optimization problem in the same way as before. However, we will switch to a new optimization problem to keep things interesting: aiming to solve maximum cut, with the objective of partitioning the graph's nodes into two groups so that the greatest number of edges are shared between the groups (see the image below). This problem is NP-hard, so we expect it to be tough as we increase the number of graph nodes.

cost_h, mixer_h = qml.qaoa.maxcut(g)def qaoa_layer(gamma, alpha): # noqa: F811
qml.qaoa.cost_layer(gamma, cost_h)
qml.qaoa.mixer_layer(alpha, mixer_h)n_layers = 2
@qml.qnode(dev)
def cost_function(params, **kwargs):
for i in range(wires): # Prepare an equal superposition over all qubits
qml.Hadamard(wires=i)
qml.layer(qaoa_layer, n_layers, params[0], params[1])
return qml.expval(cost_h)np.random.seed(1967)A variety of optimizers are available in PennyLane. Let's choose AdagradOptimizer:
optimizer = qml.AdagradOptimizer(stepsize=0.1)We're now set up to train the circuit! Note, if you are training this circuit yourself, you will likely want to increase the number of iterations in the optimization loop and also investigate changing the number of QAOA layers.
We create a hybrid job by annotating our main function with @hybrid_job.
This allows us to choose a target QPU for priority queueing, and additional arguments such as the type of classical instances to use.
In this example, we use an "ml.c5.xlarge" instance.
Note that creating hybrid jobs is only supported on Python 3.12. For other versions, you may use scripts or a custom container image.
from braket.jobs import InstanceConfig, hybrid_job
from braket.jobs.metrics import log_metric
large_instance = InstanceConfig(instanceType="ml.c5.xlarge")
@hybrid_job(device="local:pennylane/lightning.qubit", instance_config=large_instance, data_format="pickle")
def qaoa_training(iterations, n_layers=2):
task_tracker = Tracker().start() # track Braket tasks costs
dev = qml.device("lightning.qubit", wires=wires)
@qml.qnode(dev)
def cost_function(params, **kwargs):
for i in range(wires): # Prepare an equal superposition over all qubits
qml.Hadamard(wires=i)
qml.layer(qaoa_layer, n_layers, params[0], params[1])
return qml.expval(cost_h)
params = 0.01 * np.random.uniform(size=[2, n_layers])
for i in range(iterations):
params, cost = optimizer.step_and_cost(cost_function, params)
# Record the value of the cost function with each iteration
log_metric(metric_name="cost", value=cost, iteration_number=i)
# Additionally, keep track of cost in USD for Braket tasks
braket_task_cost = float(
task_tracker.qpu_tasks_cost() + task_tracker.simulator_tasks_cost(),
)
log_metric(metric_name="braket_cost", value=braket_task_cost, iteration_number=i)
return {
"parameters": params,
"final_cost": cost_function(params),
"braket_tasks_cost": task_tracker.qpu_tasks_cost() + task_tracker.simulator_tasks_cost(),
}In the next cell, we create the hybrid job by calling the function as usual. The function arguments are logged as hyperparameters for the hybrid job.
job = qaoa_training(iterations=5, n_layers=2)
print(job)AwsQuantumJob('arn':'arn:aws:braket:<region>:<account_id>:job/qaoa-training-1697562110107')
The hybrid job will be scheduled to run and will appear in the "QUEUED" state. If the target is a QPU, the hybrid job will be queued with other hybrid jobs. If the target device is not a QPU, the hybrid job should start immediately.
Note that since the algorithm code is run in a containerized environment, it takes approximately 1 minute to start running your algorithm.
job.state()'QUEUED'
Once the state is "COMPLETED", we retrieve the results with
%%time
job.result(allow_pickle=True)CPU times: total: 562 ms Wall time: 7min 48s
{'parameters': tensor([[-0.01008145, -0.00484703],
[ 0.0112027 , 0.00618034]], requires_grad=True),
'final_cost': array(-30.02458225),
'braket_tasks_cost': Decimal('0')}The results included the three values from the return statement of our function.
Additionally, we can retrieve the metrics recorded during the training with:
import pandas as pd
metrics = job.metrics()
df = pd.DataFrame(metrics)
df = df.groupby("iteration_number").sum().reset_index()
df| iteration_number | timestamp | cost | braket_cost | |
|---|---|---|---|---|
| 0 | 0.0 | 3.395125e+09 | -29.980853 | 0.0 |
| 1 | 1.0 | 3.395125e+09 | -27.158835 | 0.0 |
| 2 | 2.0 | 3.395125e+09 | -29.983195 | 0.0 |
| 3 | 3.0 | 3.395125e+09 | -29.999724 | 0.0 |
| 4 | 4.0 | 3.395125e+09 | -30.004880 | 0.0 |
The metrics are plotted below.
# Plotting the convergence of the loss function metric
import matplotlib.pyplot as plt
%matplotlib inline
df.sort_values(by=["iteration_number"]).plot(x="iteration_number", y="cost", marker="o")
plt.xlim(1, 5)
plt.xlabel("iteration number")
plt.ylabel("cost function");This example shows us that an 18-qubit QAOA problem can be trained using the "lightning.qubit" with adjoint gradients by using hybrid jobs.
job_cost = job.result(allow_pickle=True)["braket_tasks_cost"]
print(
"Note: Charges shown are estimates based on your Amazon Braket simulator and quantum processing unit (QPU) task usage. Estimated charges shown may differ from your actual charges. Estimated charges do not factor in any discounts or credits, and you may experience additional charges based on your use of other services such as Amazon Elastic Compute Cloud (Amazon EC2).",
)
print(f"Estimated cost to run this example: {job_cost:.3f} USD")Note: Charges shown are estimates based on your Amazon Braket simulator and quantum processing unit (QPU) task usage. Estimated charges shown may differ from your actual charges. Estimated charges do not factor in any discounts or credits, and you may experience additional charges based on your use of other services such as Amazon Elastic Compute Cloud (Amazon EC2). Estimated cost to run this example: 0.000 USD
This entry was created automatically from publicly available records. QCR links to public sources and only stores repository content where the license permits redistribution.
Versions
Cite all versions? Use the base QCR ID to always reference the latest version of this entry.
Join the Discussion
Comments (0)
No comments yet. Be the first to share your thoughts!