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 in the operations namespace of the Q-CTRL Python package.

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.

Before explaining how graphs are used to represent quantum systems, the next sections present a brief introduction to working with them in the abstract sense.

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. Technically this object is a Python context manager, which you enter using the with keyword. With the graph object created, you can then create graph nodes by calling functions in the qctrl.operations namespace. All calls to qctrl.operations functions must be made inside the context manager that you created, to ensure that nodes are added to the correct graph. Below we show how to create a simple graph that adds two numbers.

import numpy as np
from qctrl import Qctrl

# Start a session with the API
qctrl = Qctrl()
with qctrl.create_graph() as graph:
    result = qctrl.operations.add(1.5, 2.5)

Working with graphs

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 result object, we would see a value of 4. However, this is not the case:

<TensorNode: name="397499a0612d474b9cba35a5a93b0b74", operation_name="add", shape=()>

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 qctrl.operations.optimization_variable function returns an object representing count (in this case 10) values.

with graph:
    variables = qctrl.operations.optimization_variable(
        count=10, lower_bound=0, upper_bound=1
<TensorNode: name="3abfea8ae70c40b7a5edd0256cfa0325", 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 qctrl.operations functions 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 qctrl.operations functions. Finally, when calling a qctrl.operations function that accepts tensors, you can usually also pass NumPy arrays.

with graph:
    first_variable = variables[0]

    scaled_variables = variables * 5

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

    multiplied_variables = scaled_variables * variables
<TensorNode: name="342f4f5f345948b79010c7724acac79b", operation_name="getitem", shape=()>
<TensorNode: name="9e963edc60244964953d713a8c3c61c6", operation_name="multiply", shape=(10,)>
<TensorNode: name="9a6ce4847626462f835c7e24c9d1d16f", operation_name="add", shape=(10,)>
<TensorNode: name="9b7f453d71e946e29fc7ac4a484f1a07", operation_name="multiply", shape=(10,)>

Tensors are not the only types that live in graphs. Some qctrl.operations functions return other types of data too, 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. You will see examples of these types later in this guide, and you can also see the reference documentation for more details.

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 two 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).

When calling these functions, 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 qctrl.functions.calculate_optimization, you need to provide the cost_node_name of the node whose value needs to be minimized.

You will see both of these functions used later in this guide.

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 qctrl.operations function 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)

with qctrl.create_graph() as 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 = qctrl.operations.kron(sigma_z, sigma_z)

    # Create node with matrix B
    matrix_b = matrix_a + identity_4 = "matrix"

    # Create node calculating the trace
    trace = qctrl.operations.trace(matrix_b, name="trace")

# Execute the graph
result = qctrl.functions.calculate_graph(
    graph=graph, output_node_names=["matrix", "trace"]
Your task calculate_graph 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:

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$.

with qctrl.create_graph() as graph:

    # Create optimization variables
    optimization_variables = qctrl.operations.optimization_variable(
        2, lower_bound=-10, upper_bound=10
    x = optimization_variables[0] = "x"
    y = optimization_variables[1] = "y"

    # Create cost node
    cost = (x + 2 * y - 7) ** 2 + (2 * x + y - 5) ** 2 = "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 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}")

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

Optimization variables (x,y) = (1.0000000000000036, 2.9999999999999964)