Get an introduction to graphs in Boulder Opal

An overview of 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

Boulder Opal graphs include 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 element-wise 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, Boulder Opal 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 Boulder Opal graph (a Python object) using the boulderopal.Graph function (for instance, as graph = boulderopal.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

Executing a graph

You can execute a graph, carrying out the computation it represents, and evaluate the values of its nodes by using the graph execution function. This is the typical case when you want to perform the simulation of a quantum system.

The boulderopal.execute_graph function calculates and returns the values of specific nodes in the graph. See our documentation on simulation tutorial and our various simulation user guides for examples.

Here we show a simple example that execute a simple graph that calculates 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.

import numpy as np
import boulderopal as bo


# Create graph.
graph = bo.Graph()

# Add nodes and operations.
identity_4 = np.eye(4)

# Create node with the matrix we want to trace.
kron = graph.pauli_kronecker_product([("Z", 0), ("Z", 1)], 2)
matrix = kron + identity_4
matrix.name = "matrix"

# Create node calculating the trace.
trace = graph.trace(matrix, name="trace")

# Execute the graph.
result = bo.execute_graph(graph=graph, output_node_names=["matrix", "trace"])

# 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']:
 ['matrix', 'trace']

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)

Optimizing a graph

If you want to optimize the values of some graph nodes in order to minimize a cost function, you should call one of the graph optimization functions:

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.

Below we show a simple graph optimization that minimizes 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 object.
graph = bo.Graph()

# Add optimization variables.
x = graph.optimizable_scalar(lower_bound=-10, upper_bound=10, name="x")
y = graph.optimizable_scalar(lower_bound=-10, upper_bound=10, name="y")

# Create cost node.
cost = (x + 2 * y - 7) ** 2 + (2 * x + y - 5) ** 2
cost.name = "cost"

# Optimize the graph, miniizing the value of the cost node.
result = bo.run_optimization(
    graph=graph,
    cost_node_name="cost",
    output_node_names=["x", "y"],
    optimization_count=4,
)

# Extract the values of the final cost function and the output nodes.
print(f"Optimized cost = {result['cost']:.3e}")
print(
    f"Variables (x, y) = {(result['output']['x']['value'], result['output']['y']['value'])}"
)
Optimized cost = 2.840e-29
Variables (x, y) = (0.9999999999999982, 3.0000000000000036)

Next steps

Our documentation has tutorials and user guides with 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.

Was this useful?

cta background

New to Boulder Opal?

Get access to everything you need to automate and optimize quantum hardware performance at scale.