How to calculate with graphs

Create computational graphs for computations in the Q-CTRL Python package

Several functions in the Q-CTRL Python package use graphs to represent quantum systems. Graphs use collections of nodes and edges to represent computations that map inputs to outputs. Formally, these graphs are directed and acyclic, and are commonly known as data-flow graphs, but we refer to them simply as graphs. Each node in a graph describes a primitive computation performed on its input(s), for example the exponential of a number or the multiplication of two matrices. Different nodes are joined by directed edges, which describe how the output of one node is connected to the input of another node. By joining different types of nodes in different ways, you can build graphs representing computations as simple as basic arithmetic through to computations as complex as state propagation in high-dimensional quantum systems. The nodes provided by Q-CTRL are all available as graph operations.

The graph representation offers three key advantages over other approaches to representing generic computations:

  • Flexibility: no particular structure is enforced, so you can represent any computation that can be expressed in terms of the provided nodes.
  • Efficiency: graphs can be evaluated extremely efficiently.
  • Automatic differentiation: graphs can be automatically differentiated, enabling features like gradient-based optimization and calculation of Hessian matrices.

Below we present a brief introduction to working with graphs in the abstract sense, and two examples on how to use them for a simple calculation and a simple optimization.

Summary workflow

Creating graphs

The first step in creating a graph is to use the qctrl.create_graph function to create the Python object representing the graph.

import numpy as np
from qctrl import Qctrl

# Start a session with the API
qctrl = Qctrl()
# Create graph
graph = qctrl.create_graph()

Working with graphs

Graphs consists of nodes connected by edges. Each node represents a primitive computation, known as an operation, and has input edges and output edges. Each edge represents data flowing from one node to another.

You can create nodes in the graph by calling methods on the graph object. For instance, this will create a node in the graph that multiplies two numbers.

# Multiply two numbers
two_times_three = graph.multiply(2.0, 3.0)

Graphs provide a powerful and efficient framework for performing computations remotely, and have some important differences when compared to libraries like NumPy. You might expect that if we printed the two_times_three object, we would see a value of 6. However, this is not the case:

print(two_times_three)
<Tensor: name="multiply_#0", operation_name="multiply", shape=()>

You can pass the results of operations as inputs to other operations in order to build up graphs representing more complicated computations. For example, here is the syntax for calculating the sine of the previous product. We also assign a name to the result so that we can retrieve it when we execute the graph.

sin_six = graph.sin(two_times_three, name="sin_six")

The key point to understand is that the graph itself does not perform the computation; instead it is a recipe for performing the computation remotely. You can only get the computed values out of a graph after you have evaluated it, which you will see how to do later in this guide.

For now, we can explore some more consequences of the fact that graphs do not get evaluated immediately. Below, we add a node to the graph that represents an optimization variable. You'll see more of optimization variables later, but for now you only need to know that the graph.optimization_variable function returns an object representing count (in this case 10) values.

variables = graph.optimization_variable(count=10, lower_bound=0, upper_bound=1)
print(variables)
<Tensor: name="optimization_variable_#2", operation_name="optimization_variable", shape=(10,)>

As with the addition in the earlier code block, the variables object is not an array of numbers, but it is a representation of an array of numbers that will be computed in the cloud when you evaluate the graph. We call such an object a tensor. You can manipulate tensors in much the same way as NumPy arrays, although you need to use graph operations instead of NumPy functions. For certain basic arithmetic operations you can also use regular Python operators, such as +, -, *, /, // (floor division), ^ (exponentiation), and @ (matrix multiplication), which are convenient wrappers around the corresponding graph operations. Finally, when calling a graph operation that accepts tensors, you can usually also pass NumPy arrays.

first_variable = variables[0]
print(first_variable)

scaled_variables = variables * 5
print(scaled_variables)

added_variables = graph.add(scaled_variables, np.linspace(0, 1, 10))
# This is equivalent to added_variables = scaled_variables + np.linspace(0, 1, 10)
print(added_variables)

multiplied_variables = scaled_variables * variables
print(multiplied_variables)
<Tensor: name="getitem_#3", operation_name="getitem", shape=()>
<Tensor: name="multiply_#4", operation_name="multiply", shape=(10,)>
<Tensor: name="add_#5", operation_name="add", shape=(10,)>
<Tensor: name="multiply_#6", operation_name="multiply", shape=(10,)>

Tensors are not the only types that live in graphs, each node has a particular data type describing the type of data it produces. Some graph operations return types representing, for example, piecewise-constant functions of time (PWCs) or sampleable tensor-valued functions of time (STFs). While these other types do not represent simple arrays of numbers, they are still similar to tensors in the sense that they represent the result of a remote computation that will be performed in the cloud.

For example, here is a graph operation that creates a piecewise-constant function over ten segments, using the variables we created earlier.

pwc = graph.pwc_signal(values=multiplied_variables, duration=1.0)
print(pwc)
<Pwc: name="pwc_signal_#7", operation_name="pwc_signal", value_shape=(), batch_shape=()>

Evaluating graphs

The graph constructed generally represents both a quantum system and the desired computation on that quantum system—for instance simulation or optimization.

There are currently three ways to evaluate graphs:

  • Using qctrl.functions.calculate_graph, which simply calculates and returns the values of specific nodes in the graph.
  • Using qctrl.functions.calculate_optimization, which optimizes special optimization variable nodes in order to minimize a specified cost node, and then returns the values of specific nodes in the graph. All graphs used for optimizations contain at least one of the special optimization variable nodes (and such nodes cannot be used with qctrl.functions.calculate_graph).
  • Using qctrl.functions.calculate_stochastic_optimization, similar to qctrl.functions.calculate_optimization, but allows stochastic (random) cost functions.

When calling these functions, besides providing 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. Moreover, in the case of the optimization functions, you need to provide the cost_node_name of the node whose value needs to be minimized.

Below you can see examples of how to use qctrl.functions.calculate_graph and qctrl.functions.calculate_optimization.

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 the graph step by step, and define the matrices $A = \sigma_z \otimes \sigma_z$ and $B = A + \mathrm{Id}_4$. You can assign names to nodes by passing a name keyword argument to the graph operation that creates it, or by manually changing its name attribute (for instance if the node is created by applying regular Python arithmetic operators to nodes). You don't need to assign names to the nodes whose values we don't want to extract.

sigma_z = np.array([[1, 0], [0, -1]])
identity_4 = np.eye(4)

# Create graph
graph = qctrl.create_graph()

# 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="618063") has completed.

Now that the graph has been executed, the values of the output nodes can be obtained from result.output, a dictionary whose keys are the output_node_names.

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()

# 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"

# 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="618064") has completed.

Now that the optimization has been performed, the value reached of the cost function can be obtained from result.cost, and the values of the output nodes can be obtained from result.output.

print(f"Optimized cost = {result.cost:.3e}")

print(
    f"\nOptimization variables (x,y) = ({result.output['x']['value']}, {result.output['y']['value']})"
)
Optimized cost = 5.364e-29

Optimization variables (x,y) = (0.9999999999999964, 3.0000000000000053)