User guide#

This page goes into more detail about how to use the features of pyQuEST, some common design principles, and gives hints on how to write efficient code and what to avoid when using the package.

Absolute basics#

The pyQuEST project focuses on being a thin layer between Python and the QuEST backend. It provides some convenience features, but its primary focus is to provide fast access to a powerful state vector and density matrix simulator. In particular, it does not try to be a replacement for elaborate quantum algorithm development and deployment frameworks.

The high-level working principle of this interface is that users can create quantum registers that hold their quantum state as a state vector or a density matrix. This state is then modified using operators, which are provided as regular Python classes. From these classes, specific instances of these operators can be created using all the necessary parameters (e.g. what qubit(s) should it be applied to, rotation angles, control qubits, etc.). Once such an operator is created, it can be applied to the register, thereby modifying it. Multiple such operators can also be collected into a quantum circuit and then applied at once. To extract information out of the stored quantum state, one can either use regular measurement gates, or evaluate expectation values of sums of Pauli strings directly using QuEST functionality. The rest of this document will elaborate on each of these points.

Package layout#

The pyQuEST package comes as multiple modules which somewhat mimic the organisation of the parent project QuEST.

  • core contains the most central data structures used by pyQuEST, namely Register and Circuit. This module rarely needs to be addressed directly, as its most important contents are also available from the top-level pyquest module.

  • unitaries contains unitary operators most users will be familiar with, such as the Hadamard (H), Pauli (X, Y, Z), and Swap gates, as well as a generic Unitary (U) operator for which an arbitrary unitary matrix can be supplied.

  • gates contains non-unitary but norm-preserving operators, i.e. projective measurements.

  • operators contains non-unitary, possibly non-norm-preserving operators, and those that are not typically part of a “standard” gate set, like PhaseFunc.

  • initialisations contains operators that set the register to a defined state, like the all-zero computational basis state, or the plus-state.

  • decoherence contains operators that represent noise channels for density matrices; well-known predefined ones like Depolarising and Dephasing, but also arbitrary Kraus maps can be specified.

Usage details#

System information#

First, pyQuEST is imported like any other package.

import pyquest

Note

It is important to not start your Python interpreter from within the pyquest directory, as the source code is also contained in a directory called pyquest, which then takes precedence over the installed pyquest package. If your import fails, change to a different directory and try again.

After successful import, an important tool to check whether everything is configured as intended is to look at the pyquest.env object. It lists in its arguments how the QuEST backend is configured.

In  [2]: pyquest.env
Out [2]: QuESTEnvironment(gpu_accelerated=False, multithreaded=False, distributed=False, num_threads=1, rank=0, num_ranks=1, precision=2)

The variable gpu_accelearted is only true if the GPU is used, multithreaded means QuEST was compiled with OpenMP to use multiple threads, where num_threads tells us how many exactly are used. If distributed is true, QuEST can use multiple instances on different nodes that communicate with each other to perform bigger calculations. In this case, num_ranks is set to how many instances there are in total, and rank will be different for every instance, ranging from 0 to (num_ranks - 1). Finally, precision is either 1, 2, or 4, and represents the size of the floating point data type used internally by QuEST to represent amplitudes; they correspond to float, double, and long double C data types.

Register and Circuit#

The most important classes are Register and Circuit. A Register holds all the memory required to store the amplitudes of the quantum state on as many qubits as it represents (either as a pure state or a density matrix). A Circuit is a collection of operators that can be applied to a Register, which modifies the amplitudes in the Register.

As they have such an important role in the package, they are available straight from the top level.

from pyquest import Register, Circuit

Pure states#

This section will specifially address registers holding pure states. Much of what is discussed also applies to density matrices; the exact differences are discussed in a separate section below.

Creating registers#

Creating a quantum register capable of holding amplitudes of a pure state for a number of e.g. 10 qubits is then as simple as

reg = Register(10)

If you already have a Register (let’s call it reg again) and would like to get an identical copy, you can either call

copied_reg = Register(copy_reg=reg)

or use

copied_reg = reg.copy()
General useful properties and functions#

We can find out some general useful information about a Register object, like the number of qubits, whether it is a density matrix, and the number of stored amplitudes, by looking at the following properties:

In [3]: reg.num_qubits
Out[3]: 10

In [4]: reg.is_density_matrix
Out[4]: False

In [5]: reg.num_amps
Out[5]: 1024
Manually manipulating amplitudes#

These two snippets are functionally indentical in that they both create a new Register that is identical to the existing one. If you have already created a second register with identical parameters (i.e. the same number of qubits and matching state vector/density matrix type), and only would like to copy over the amplitudes from orig_reg to copied_reg both of the following lines achieve this.

orig_reg.copy_to(copied_reg)
copied_reg.copy_from(orig_reg)

On the contrary, if you would like to create a Register with the same properties as an existing one, but not copy over the state,

like_reg = Register.zero_like(orig_reg)

provides this functionality. The newly created register will then be in the all-zero computational basis state. To completely erase all amplitudes and set them to zero, you can call

reg.init_blank_state()

This can be useful when you want to create a state with only a few non-zero amplitudes in a register that currently holds many amplitudes. Zeroing the register this way is faster than setting all amplitudes to zero manually, as shown below.

The amplitudes stored in a Register can be inspected manually by regular indexing using either integers or slices. The indices follow the convention of QuEST (and almost every other quantum cirucit simulator): Each computational basis state is interpreted as a binary number, where we call “qubit 0” the least significant bit in the number, increasing the qubit index as we go to higher significance. For example, the bit string 01001 has qubit 0 and qubit 3 in the 1 state, and qubits 1, 2, and 4 in the 0 state.

With this indexing, e.g. the first 5 amplitudes are retrieved via

In [3]: reg[:5]
Out[3]: array([1.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j])

More examples are getting all amplitudes with qubit 0 in the 0-state by calling reg[::2], retrieving the amplitudes of the specific indices 3, 6, and 9 via reg[3, 6, 9], and fetching all amplitudes with reg[:].

You can also assign arbitrary amplitudes to any point in the state vector using the same kind of indexing.

In [4]: reg[:5] = [.3, .1j, .7, .4 + .2j, .02]

In [5]: reg[:5]
Out[5]: array([0.3 +0.j , 0.  +0.1j, 0.7 +0.j , 0.4 +0.2j, 0.02+0.j ])

Warning

Avoid reading from and writing to quantum registers as much as possible. The QuEST backend holds and manages its own memory, and when accessing it via Python, all data must be copied into a numpy array, which can slow down your code significantly.

Be careful when writing individual amplitudes. The state is not checked for normalisation after writing and you might therefore, like in the above example, end up with an invalid non-normalised state. The normalisation can easily be checked by looking at the property reg.total_prob. For the above case this yields

In [6]: reg.total_prob
Out[6]: 0.7904

so in this case the amplitudes do not form a valid quantum state. It is not recommended to continue with such a state, unless you know exactly what you are doing, as some algorithms for operations might now perform as expected on invalid states.

Arithmetic with quantum registers#

To fix such a state, we can perform arithmetic on a register; in this case division by a scalar is useful.

In [7]: reg /= np.sqrt(reg.total_prob)

In [8]: reg.total_prob
Out[8]: 1.0

The following arithmetic operations are currently supported by Register:

  • Adding reg1 + reg2 and subtracting reg1 - reg2 two registers while creating a new one.

  • Adding reg1 += reg2 and subtracting reg1 -= reg2 one register from the other in-place.

  • Multiplication with reg * c and division by reg / c a (complex) scalar c and creating a new register.

  • Multiplication with reg *= c and division by reg /= c a (complex) scalar in-place.

Note

Because of how the QuEST backend works, these arithmetic operations are usually cached and only evaluated when actually needed. This makes expressions like reg3 = a * reg1 + b * reg2 more efficient, but also means that your code might be slow or run out of memory in places where you would not expect it, i.e. when pyQuEST decides it now needs to actually perform the arithmetic you asked for earlier.

Calculated properties of quantum states#

Some properties that involve calculations with the amplitudes of a state are also available directly from the corresponding Register object. As already mentioned above, the total probability of a state can be determined by

In [9]: reg.total_prob
Out[9]: 1.0

Of course, this should always be 1 for valid quantum state, as they must be normalised. It can, however, serve as a sanity check or be used to normalise a previously unnormalised state as shown in the previous subsection.

Another useful function is reg.prob_of_all_outcomes(qubits), which – as the name suggests – calculates the probabilities of all possible bitstrings (in the computational basis) on the qubits specified in the qubits parameter.

In [10]: reg.prob_of_all_outcomes([2,3])
Out[10]: array([9.99493927e-01, 5.06072874e-04, 0.00000000e+00, 0.00000000e+00])

The output is a single array that can be interpreted as follows. Each linear index k of the result array, when converted to a bitstring, means the measurement outcomes of the subset of qubits given in the qubits parameter. Somewhat unintuitively, the qubit index qubits[0] here is the least-significant bit in k. In the above example, the following table illustrates the principle.

k

qubit 2

qubit 3

probability

0

0

0

0.9995

1

0

1

5e-4

2

1

0

0

3

1

1

0

Registers can also calculate properties that are a function of another register. For example, the function

In [11]: reg.fidelity(other_reg)
Out[11]: 0.7852327627890966

calculates the fidelity \(\vert \langle \psi_\mathrm{reg} \vert \psi_\mathrm{other\_reg} \rangle \rvert^2\) if reg is a pure-state register, and \(\langle \psi_\mathrm{other\_reg} \rvert \rho_\mathrm{reg} \lvert \psi_\mathrm{other\_reg}\rangle\) if reg is a density matrix. Note, however, that the argument other_reg must be a pure state in any case.

Inner products also work slightly different between pure states and density matrices. In the function call

In [12]: reg.inner_product(other_reg)
Out[12]: (0.8861336032388664+0j)

the registers reg and other_reg must either be both state vectors or both density matrices. If they are state vectors, inner_product returns the regular scalar product between the states \(\langle \psi_\mathrm{reg} \vert \psi_\mathrm{other\_reg} \rangle\). If they are both density matrices, it returns the Hilbert-Schmidt inner product between them.

Modifying states via operators and circuits#

The arguably most important feature of quantum registers in this package is the ability to modify the contained state using a wide variety of operators described later in this guide. Say we have already created such an operator and stored it in the object op. Then we can apply it to an existing register reg by simply calling

reg.apply_operator(op)

If we have collected a series of operators into a Circuit object (described later in this guide), let’s call it circ, then all operators contained in circ can be applied sequentially by executing

reg.apply_circuit(circ)

The advantage of this code over calling apply_operator(op) in a loop over all operators contained in circ is that applying the whole circuit at once is potentially much faster, because the code does not need to return control to Python in between applying operators; it can stay in the compiled backend for the whole execution.

Quantum circuits#

As mentioned above, sequences of operators can be collected into a circuit. The Circuit class behaves very much like a regular list in Python, but keeps an extra data structure which the QuEST backen can iterate more quickly. As such, from a user perspective, one can simply use this class like a list and pass it to apply_circuit. To create a Circuit, simply pass any iterable (e.g. a list) of pyQuEST operators (here called op_iter) to its constructor.

circ = Circuit(op_iter)

The Circuit class may get some extra features in the future that decompose or otherwise modify its contents, but for now it is solely a faster container for gates.

Quantum operators#

Within the pyquest package, the available quantum operators are logically grouped into several modules. Each operator (group) is discussed in more detail below. Here we provide some general notes on common features among many or most of the operators.

Apart from the separation into their respective modules, the operators can also be separated into three types: single qubit operators (acting on only one qubit), multi-qubit operators (acting on one or more qubits), and global operators (potentially, but not necessarily acting on all qubits).

The following properties usually apply, but may change depending on the specific requirements of the operator. Refer to the API documentation for the constructor signature of any specific operator.

Inverse operators#

Most operators also provide a simple way to get their inverse with the inverse property. This also applies to Circuit, if the operators in it all have an inverse.

Matrix representation#

Because even local (e.g. single-qubit) gates act on a specific qubit in a Hilbert space spanned by more qubits, we must be careful when using matrix representations. Some operators implement the matrix property, which always returns the local matrix representation restricted to the targets. However, there is also the generic as_matrix(num_qb) method that almost all operators (for which it makes sense) [1] have. This method returns a matrix in the full Hilbert space of num_qb qubits. If num_qb is not explicitly given, it defaults to the largest number in the operator’s targets. For global operators, num_qb must be given.

Warning

Be careful when retrieving matrices for operators with a large value of num_qb. The generated matrix will have dimensions \(2^\mathrm{num\_qb} \times 2^\mathrm{num\_qb}\) and potentially consume a lot of memory.

Single-qubit operators#

Single-qubit operators generally take the target qubit as their first argument (a single integer), followed by potential optional parameters. Once the operator is created, its target can be retrieved through the target property. For consistency with multi-qubit operators (see below), there is also a targets property, which returns a list with the target as its only item. A very simple example using the Pauli-Y gate acting on qubit 3 illustrates this behaviour below.

In [13]: from pyquest.unitaries import Y

In [14]: y_gate = Y(3)

In [15]: y_gate.target
Out[15]: 3

In [16]: y_gate.targets
Out[16]: [3]
Multi-qubit operators#

Multi-qubit operators usually take the target qubits first (either a single integer or an iterable of integers), followed by other parameters of the gate. Instead of as the first positional parameter, the targets can also be provided via the keyword parameter targets (which also has the alias target). The targets of a multi-qubit operator can be inspected via the targets property, which always returns a list of targets, or via the target property, which returns an integer for a single target, and a list of integers if there is more than one target.

In [17]: from pyquest.unitaries import X

In [18]: x_gate_24 = X([2,4])

In [19]: x_gate_24.target
Out[19]: [2, 4]

In [20]: x_gate_24.targets
Out[20]: [2, 4]

In [21]: x_gate_1 = X(1)

In [22]: x_gate_1.target
Out[22]: 1

In [23]: x_gate_1.targets
Out[23]: [1]
Controlled operators#

Many gates also have the option to have control qubits. This is specified in the constructor via the keyword-only argument controls (note the plural; unlike for the targets, there is no singular control keyword). It accepts an integer (for single controls) or a list (for single- and multi-control). Every potentially controlled gate has the property controls, which always returns a list of integers. Uncontrolled gates return an empty list. The following example uses the x_gate_1 from above.

In [24]: x_gate_1.controls
Out[24]: []

In [25]: cx_gate_2_1 = X(2, controls=1)

In [26]: cx_gate_2_1.controls
Out[26]: [1]

In [27]: cx_gate_2_01 = X(2, controls=[0, 1])

In [28]: cx_gate_2_01.controls
Out[28]: [0, 1]
Noteworthy operators#

Most operators are fairly standard, and behave in a way you could reasonably expect them to. Some, however, have caveats that you should be aware of when using them. The individual operators are described in more detail in the API documentation. The following list of gates takes you to the appropriate pages.