How to calculate and optimize with graphs
Create graphs for computations with Boulder Opal
Graphs are a very efficient way of describing computations using a combination of nodes and edges. Please refer to our topic Understanding graphs in Boulder Opal for an introduction to what graphs are used for and why. You can also review our tutorials and user guides with examples on how to use graph representations for robust control (for calculating optimized control pulses), simulation (to understand the dynamics of the system in the presence of specific controls and noises), and system identification (to estimate the values of unknown system parameters based on measurements of the system). You can find a list of all predefined graph operations in Boulder Opal in our reference.
Here we will show how to use graphs to perform simple calculations and optimizations.
Summary workflow
When working with graphs, we typically follow the following steps.
1. Create an empty graph
You always start by creating an empty Boulder Opal Python graph object:
graph = qctrl.create_graph()
2. Add the appropriate nodes and operations
Depending on the task at hand, you can add the appropriate nodes and operations to your graph.
For optimization problems, this includes using the graph.optimization_variable
operation to define the variables whose values you want to optimize.
3. Evaluate the graph
To execute the sequence of operations represented by the graph, you use one of our graph evaluation functions, such as qctrl.functions.calculate_graph
or qctrl.functions.calculate_optimization
.
When calling these functions, besides passing in the graph, you need to provide a list of output_node_names
with the strings corresponding to the nodes whose values you want to extract from the graph.
For optimization problems, you also use the cost_node_name
parameter to point to the optimization function previously defined.
4. Extract the results
You can now access the node results that you requested in the previous step. They are given in the form of a dictionary with entries for each output node name you provided.
For optimization problems, the best value achieved by the cost function is returned separately in the cost
or best_cost
attribute of the result.
Example: Calculating a simple graph
In this example, we will execute a simple graph to calculate the trace of a matrix: \begin{equation} \mathrm{tr} \left[ \sigma_z \otimes \sigma_z + \mathrm{Id}_4 \right] , \end{equation} where $\sigma_z$ is the Pauli Z operator and $\mathrm{Id}_4$ the $4\times 4$ identity matrix.
We will build a graph that carries out this computation, defining matrices $A = \sigma_z \otimes \sigma_z$ and $B = A + \mathrm{Id}_4$.
For each node whose values we want to extract, we will assign a name to it by passing a name
keyword argument to the operation that creates it, or by manually changing their name
attribute (for instance if the node is created by applying regular Python arithmetic operators to other nodes).
import numpy as np
from qctrl import Qctrl
# Start a Boulder Opal session.
qctrl = Qctrl()
# Create graph.
graph = qctrl.create_graph()
# Add nodes and operations.
identity_4 = np.eye(4)
# Create node with matrix A.
# (We don't need to assign a name to it as we don't want to extract its value).
matrix_a = graph.pauli_kronecker_product([("Z", 0), ("Z", 1)], 2)
# Create node with matrix B.
matrix_b = matrix_a + identity_4
matrix_b.name = "matrix"
# Create node calculating the trace.
trace = graph.trace(matrix_b, name="trace")
# Execute the graph.
result = qctrl.functions.calculate_graph(
graph=graph, output_node_names=["matrix", "trace"]
)
Your task calculate_graph (action_id="1605130") has completed.
# Extract results.
print(f"Keys of result.output:\n {list(result.output.keys())}\n")
print(f"Matrix value:\n {result.output['matrix']['value']}\n")
print(f"Trace value:\n {result.output['trace']['value']}")
Keys of result.output:
['trace', 'matrix']
Matrix value:
[[2.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 0.+0.j]
[0.+0.j 0.+0.j 0.+0.j 2.+0.j]]
Trace value:
(4+0j)
Example: Performing a simple optimization
In this example, we will execute a simple graph to find the minimum of the Booth function, \begin{equation} f(x,y) = (x + 2 y - 7)^2 + (2 x + y - 5)^2 , \end{equation} which has a global minimum at $f(x=1,y=3) = 0$.
# Create graph.
graph = qctrl.create_graph()
# Add nodes and operations.
# Create optimization variables.
optimization_variables = graph.optimization_variable(2, lower_bound=-10, upper_bound=10)
x = optimization_variables[0]
x.name = "x"
y = optimization_variables[1]
y.name = "y"
# Create cost node.
cost = (x + 2 * y - 7) ** 2 + (2 * x + y - 5) ** 2
cost.name = "cost"
# Execute the graph.
# Minimize the value of the cost node.
result = qctrl.functions.calculate_optimization(
graph=graph,
cost_node_name="cost",
output_node_names=["x", "y"],
optimization_count=4,
)
Your task calculate_optimization (action_id="1605131") has completed.
# Extract results.
# Inspect the value reached by the cost function.
print(f"Optimized cost = {result.cost:.3e}")
# Inspect the values of the output nodes.
print(
f"\nOptimization variables (x,y) = ({result.output['x']['value']}, {result.output['y']['value']})"
)
Optimized cost = 1.136e-28
Optimization variables (x,y) = (1.000000000000007, 2.9999999999999964)
This notebook was run using the following package versions. It should also be compatible with newer versions of the Q-CTRL Python package.
Package | Version |
---|---|
Python | 3.10.8 |
numpy | 1.24.1 |
scipy | 1.10.0 |
qctrl | 20.1.1 |
qctrl-commons | 17.7.0 |
boulder-opal-toolkits | 2.0.0-beta.3 |