Understanding graphs in Boulder Opal

An overview on how Boulder Opal uses computational graphs to represent systems and perform operations

Computational graphs are used in Boulder Opal to represent quantum systems and desired computations on them, from simple arithmetic calculations to complex operations on high-dimensional quantum systems, including simulations and optimizations.

Our simulation tutorial can then teach you how to perform a simple simulation with Boulder Opal using graphs.

What are graphs?

Graphs are a way of describing simple and complex computations using a combination of nodes and edges.

Each node in a graph describes a primitive computation, known as an operation, which is performed on its input(s) and results in its output(s). Boulder Opal offers a wide variety of nodes, some representing standard mathematical operations and others specific to quantum systems. The operations can be simple, like the multiplication of two matrices, or complex, such as calculating the unitary evolution operators due to a Hamiltonian. You can see all available graph operations in our reference.

Edges in the graph connect nodes together, representing the data flowing from one node to another. An edge going into a node represents an input to that node's operation, and an edge going out of a node represents an output. A node can have multiple input edges and multiple output edges, although they usually have a single output.

More formally, we use directed and acyclic graphs, known as data-flow graphs. This means that each edge has a given direction between two nodes (indicating the flow of information), and no closed loops are created following those directions.

Note that the computation is not performed while constructing the graph. Instead you can think of it as defining a recipe for executing the computation remotely. You can only retrieve the computed values out of graphs when they are evaluated.

Why does Boulder Opal use graphs?

Graphs offer some key advantages when it comes to representing generic computations:

  • Flexibility: no particular structure is enforced, so you can represent any computation, as long as it is expressed in terms of the available 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.

Graph nodes

Each node has a particular data type describing the type of data it produces.

Some commonly-used nodes are:

Graph operations

Basic operations

The Q-CTRL Python package includes many nodes representing operations you can apply to Tensors, PWCs, or STFs. These include basic mathematical functions (like graph.sqrt, graph.exp, graph.log), trigonometric functions (graph.sin, graph.cosh, graph.arctan), functions for complex objects (graph.real, graph.angle, graph.conjugate), and even matrix functions (graph.trace, graph.adjoint). You can search the full list of available operations to learn more about them.

Arithmetic operations

You can directly sum PWC operators to yield the overall system Hamiltonian, as long as all the operators have compatible shapes. In general, you can apply basic elementwise arithmetic operations between arrays, Tensors, PWCs, or STFs using regular Python operators, such as + (instead of graph.add), -, *, /, // (floor division), ^ (exponentiation), and @ (matrix multiplication). We have enabled this for convenience of use by constructing optional wrappers around the corresponding graph operations.

Note that operations between PWCs/STFs and NumPy arrays or Tensors correspond to applying the operation to every value that the PWC/STF assumes in time, while operations between two PWCs/STFs correspond to the operation between the values that the objects assume in each time window. Operations between a PWC and an STF are not allowed.

Besides basic arithmetic, you can also calculate other operations between objects, such as matrix multiplication (using the @ operator or graph.matmul), the Kronecker product (with graph.kron), or create a complex object from its real and imaginary parts (via graph.complex_value). In these cases the shape compatibility depends on the particular operation.

If the two objects don't have the same shape, the Q-CTRL Python package attempts to broadcast them into a compatible shape. Broadcasting is an important tool to improve the efficiency of your code, and you can learn more about it in the topic Batching and broadcasting in Boulder Opal.

Quantum-specific operations

We offer specific graph operations for working with quantum systems. These include operations to calculate the time evolution of your open or closed quantum system, operations for optimal and robust control, and operations to design Mølmer–Sørensen gates, among several others.

Using graphs

Creating a graph

You instantiate a Q-CTRL graph (a Python object) using the qctrl.create_graph function (for instance, as graph = qctrl.create_graph()). This will create an empty graph, without any nodes or edges.

Adding nodes

You should then add nodes representing the computation or describing the quantum system you are aiming to construct, by calling the methods of our Python graph object. For instance, nodes that describe the Hamiltonian of your quantum system, and/or nodes representing the computation that you want to perform on it (for example, calculating the time evolution operators for the system).

You can pass the results of operations as inputs to other operations in order to build up graphs representing more complicated computations.

When defining each node, you give a name to those whose values you want to retrieve after the computation has been executed. You do this by passing a name= argument to its constructor, or by assigning a string to the name attribute of the node after defining it. For a simulation you likely want to retrieve the values of nodes representing the time evolution operators or evolved states of your system, while for an optimization you might be interested, for example, in the nodes representing the control pulses that create the optimized gates.

Evaluating a graph

The constructed computation is executed when you evaluate the graph.

There are currently three ways of evaluating graphs:

When calling these functions, besides providing the graph, you need to provide a list of output_node_names with strings corresponding to the nodes whose values you want to retrieve from the graph. If you haven't explicitly named those nodes while defining them, you can check their automatically-assigned names by calling the appropriate attribute, <node_variable_name>.name, and use those instead. In the case of the optimization functions, you also need to provide the cost_node_name of the node whose value you want to minimize.

Next steps

Please refer to our tutorials and user guides for step-by-step examples of graphs being used for representing and solving various relevant problems with Boulder Opal. For instance, you can see how to use graph representations for optimal 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). Our topic on improving calculation performance can give you further tips to consider when working with graphs to solve your unique and interesting problems.