2. Basics

2.1. Basic Representation

States and operators in quimb are simply dense numpy arrays or sparse scipy matrices. All functions should directly work with these but the class qarray is also provided as a very thin subclass of numpy.ndarray with a few helpful methods and attributes. The quimbify() function (aliased to qu()) can convert between the various representations.

[1]:
from quimb import *
data = [1, 2j, -3]

Kets are column vectors, i.e. with shape (d, 1):

[2]:
qu(data, qtype='ket')
[2]:
qarray([[ 1.+0.j],
        [ 0.+2.j],
        [-3.+0.j]])

The normalized=True option can be used to ensure a normalized output.

Bras are row vectors, i.e. with shape (1, d):

[3]:
qu(data, qtype='bra')  # also conjugates the data
[3]:
qarray([[ 1.-0.j,  0.-2.j, -3.-0.j]])

And operators are square matrices, i.e. have shape (d, d):

[4]:
qu(data, qtype='dop')
[4]:
qarray([[ 1.+0.j,  0.-2.j, -3.-0.j],
        [ 0.+2.j,  4.+0.j,  0.-6.j],
        [-3.+0.j,  0.+6.j,  9.+0.j]])

Which can also be sparse:

[5]:
qu(data, qtype='dop', sparse=True)
[5]:
<3x3 sparse matrix of type '<class 'numpy.complex128'>'
        with 9 stored elements in Compressed Sparse Row format>

The sparse format can be specified with the stype keyword. The partial function versions of each of the above are also available:

Note

If a simple 1d-list is supplied and no qtype is given, 'ket' is assumed.

2.2. Basic Operations

The ‘dagger’, or hermitian conjugate, operation is performed with the .H attribute:

[6]:
psi = 1.0j * bell_state('psi-')
psi
[6]:
qarray([[ 0.+0.j      ],
        [ 0.+0.707107j],
        [-0.-0.707107j],
        [ 0.+0.j      ]])
[7]:
psi.H
[7]:
qarray([[ 0.-0.j      ,  0.-0.707107j, -0.+0.707107j,  0.-0.j      ]])

This is just the combination of .conj() and .T, but only available for scipy.sparse matrices and qarray s (not numpy.ndarray s).

The product of two quantum objects is the dot or matrix product, which, since python 3.5, has been overloaded with the @ symbol. Using it is recommended:

[8]:
psi = up()
psi
[8]:
qarray([[1.+0.j],
        [0.+0.j]])
[9]:
psi.H @ psi  # inner product
[9]:
qarray([[1.+0.j]])
[10]:
X = pauli('X')
X @ psi  # act as gate
[10]:
qarray([[0.+0.j],
        [1.+0.j]])
[11]:
psi.H @ X @ psi  # operator expectation
[11]:
qarray([[0.+0.j]])

Scalar expectation values might best be computed using the expectation() function (aliased to expec()) which dispatches to accelerated methods:

[12]:
expec(psi, psi)
[12]:
1.0
[13]:
expec(psi, X)
[13]:
0j

Here’s an example for a much larger (20 qubit), sparse operator expecation, which will be automatically parallelized:

[14]:
psi = rand_ket(2**20)
A = rand_herm(2**20, sparse=True) + speye(2**20)
A
[14]:
<1048576x1048576 sparse matrix of type '<class 'numpy.complex128'>'
        with 11534284 stored elements in Compressed Sparse Row format>
[15]:
expec(A, psi)  # should be ~ 1
[15]:
0.9999672709199712
[16]:
%%timeit
expec(A, psi)
117 ms ± 10.9 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

2.3. Combining Objects - Tensoring

There are a number of ways to combine states and operators, i.e. tensoring them together.

Functional form using kron():

>>> kron(psi1, psi2, psi3, ...)
...

This can also be done using the & overload on qarray and scipy matrices:

>>> psi1 & psi2 & psi3
...

Warning

When quimb is imported, it monkey patches the otherwise unused method of &/__and__ of scipy sparse matrices to kron().

Often one wants to sandwich an operator with many identities, ikron() can be used for this:

[17]:
dims = [2] * 10  # overall space of 10 qubits
X = pauli('X')
IIIXXIIIII = ikron(X, dims, inds=[3, 4])  # act on 4th and 5th spin only
IIIXXIIIII.shape
[17]:
(1024, 1024)

For more advanced tensor constructions, such as reversing and interleaving identities within operators pkron() can be used:

[18]:
dims = [2] * 3
XZ = pauli('X') & pauli('Z')
ZIX = pkron(XZ, dims, inds=[2, 0])
ZIX.real.astype(int)
[18]:
qarray([[ 0,  1,  0,  0,  0,  0,  0,  0],
        [ 1,  0,  0,  0,  0,  0,  0,  0],
        [ 0,  0,  0,  1,  0,  0,  0,  0],
        [ 0,  0,  1,  0,  0,  0,  0,  0],
        [ 0,  0,  0,  0,  0, -1,  0,  0],
        [ 0,  0,  0,  0, -1,  0,  0,  0],
        [ 0,  0,  0,  0,  0,  0,  0, -1],
        [ 0,  0,  0,  0,  0,  0, -1,  0]])

ZIX would then act with Z on first spin, and X on 3rd.

2.4. Removing Objects - Partial Trace

To remove, or ignore, certain parts of a quantum state the partial trace function partial_trace() (aliased to ptr()) is used. Here, the internal dimensions of a state must be supplied as well as the indicies of which of these subsystems to keep.

For example, if we have a random system of 10 qubits (hilbert space of dimension 2**10), and we want just the reduced density matrix describing the first and last spins:

[19]:
dims = [2] * 10
D = prod(dims)
psi = rand_ket(D)
rho_ab = ptr(psi, dims, [0, 9])
rho_ab.round(3)  # probably pretty close to identity
[19]:
qarray([[ 0.252+0.j   , -0.002+0.005j,  0.006+0.014j, -0.014+0.004j],
        [-0.002-0.005j,  0.246+0.j   ,  0.001+0.003j,  0.013+0.029j],
        [ 0.006-0.014j,  0.001-0.003j,  0.247+0.j   ,  0.008+0.01j ],
        [-0.014-0.004j,  0.013-0.029j,  0.008-0.01j ,  0.254+0.j   ]])

partial_trace() accepts dense or sparse, operators or vectors.