How to calculate and optimize with graphs

Create graphs for computations with the Q-CTRL Python package

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.

Worked 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 session with the API.
qctrl = Qctrl()
# Create graph.

graph = qctrl.create_graph()
# Add nodes and operations.

sigma_z = np.array([[1, 0], [0, -1]])
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.kron(sigma_z, sigma_z)

# 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="717273") 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. 0. 0.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]
 [0. 0. 0. 2.]]

Trace value:
 4.0

Worked 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"]
)
Your task calculate_optimization (action_id="717274") 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.578e-29

Optimization variables (x,y) = (0.9999999999999982, 3.0)