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, namelyRegister
andCircuit
. This module rarely needs to be addressed directly, as its most important contents are also available from the top-levelpyquest
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, likePhaseFunc
.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 likeDepolarising
andDephasing
, 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 subtractingreg1 - reg2
two registers while creating a new one.Adding
reg1 += reg2
and subtractingreg1 -= reg2
one register from the other in-place.Multiplication with
reg * c
and division byreg / c
a (complex) scalarc
and creating a new register.Multiplication with
reg *= c
and division byreg /= 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.