# 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)
```

```
print(result)
```

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
)
print(variables)
```

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]
print(first_variable)
scaled_variables = variables * 5
print(scaled_variables)
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)
print(added_variables)
multiplied_variables = scaled_variables * variables
print(multiplied_variables)
```

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_b.name = "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"]
)
```

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']}")
```

```
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.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"]
)
```

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}")
print(
f"\nOptimization variables (x,y) = ({result.output['x']['value']}, {result.output['y']['value']})"
)
```