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

- Tensors, representing multidimensional arrays of data. You can manipulate tensors in much the same way as NumPy arrays, although you need to use graph operations instead of NumPy functions. When calling a graph operation that accepts tensors, you can usually pass NumPy arrays instead as well.
- PWCs, representing piecewise-constant functions of time. We offer several graph operations for creating and manipulating PWCs.
- STFs, representing sampleable time-dependent functions, with various STF-specific graph operations available.

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

`boulderopal.run_optimization`

, which optimizes special optimization nodes in order to minimize a specified*cost*node, and then returns the values of specific nodes in the graph. Note that optimization variables cannot be used with`boulderopal.execute_graph`

. Check out our robust control optimization tutorial or our various control optimization and system identification user guides to see this function in action.`boulderopal.run_stochastic_optimization`

, which is similar to`boulderopal.run_optimization`

, but allows stochastic (random) cost functions. You can see examples in our stochastic optimization and system identification user guides.`boulderopal.run_gradient_free_optimization`

, which is an alternative to the gradient-based optimizers, and is useful when the gradient is either very costly to compute or inaccessible. You can see an example in our gradient-free optimization user guide.

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.