Manipulation of Quantum States in QuAIRKit

A pure quantum state is a unit vector in a Hilbert space \(\mathcal{H}\). A mixed quantum state could be described by a density matrix \(\rho\), which is a Hermitian, positive semi-definite matrix with unit trace. Concretely, pure states are of the form \(|\psi \rangle \langle \psi |\) where \(|\psi \rangle \in \mathcal{H}\) is a normalized vector. Mixed state is the convex combination of the pure states \(\sum_{x \in \mathcal{X}}p(x)|\psi_x \rangle \langle \psi_x |\) for some set \(\{|\psi_x \rangle\}_{x \in \mathcal{X}}\) of state vectors defined with respect to a finite alphabet \(\mathcal{X}\), where \(p: \mathcal{X} \rightarrow [0, 1]\) is a probability distribution. The State class in QuairKit supports operations related to quantum states, mainly including the preparation of quantum states and operations on them.

Table of Contents

[1]:
import torch
import quairkit as qkit
from quairkit.qinfo import *
from quairkit import *
from quairkit.database import *

Preparation of states

In QuAIRKit, states are prepared in several ways. For some commonly-used states, they are available in QuAIRKit database:

[2]:
num_qubits = 2  # set the number of qubits

state = zero_state(num_qubits)  # |00>
print(f"zero states with 2 qubits: {state}")

state = bell_state(num_qubits)  # (|00> + |11>) / sqrt(2)
print(f"Bell states: {state}")

state = isotropic_state(num_qubits, prob=0.1)  # isotropic state
print(f"isotropic state: {state}")
zero states with 2 qubits:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [0, 1]
[1.+0.j 0.+0.j 0.+0.j 0.+0.j]
---------------------------------------------------

Bell states:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [0, 1]
[0.71+0.j 0.  +0.j 0.  +0.j 0.71+0.j]
---------------------------------------------------

isotropic state:
---------------------------------------------------
 Backend: density_matrix
 System dimension: [2, 2]
 System sequence: [0, 1]
[[0.27+0.j 0.  +0.j 0.  +0.j 0.05+0.j]
 [0.  +0.j 0.22+0.j 0.  +0.j 0.  +0.j]
 [0.  +0.j 0.  +0.j 0.22+0.j 0.  +0.j]
 [0.05+0.j 0.  +0.j 0.  +0.j 0.27+0.j]]
---------------------------------------------------

Users can also prepare a random state, with desirable size and rank. Additionally, by setting is_real=True one can restrict the random state within the real space.

[3]:
state = random_state(num_qubits, rank=1)  # random 2-qubit pure state
state = random_state(num_qubits, is_real=True)  # random 2-qubit real state
state = random_state(num_qubits, size=1000)  # 1000 random 2-qubit states

Another way of preparing a quantum state uses the function to_state. to_state converts a torch.Tensor or a numpy.ndarray data type to a State instance. Allowed shapes of the input tensor are listed in the following table. Based on the input data, the state will be represented differently. If the input is a vector, the state will be represented by a state vector, whereas if the input is a density matrix, the state will be adapted to a density matrix. The type can be checked by the state.backend.

single state

batch states

state vector

[d], [1, d], [d, 1]

[d1, …, dn, d, 1]

density matrix

[d, d]

[d1, …, dn, d, d]

[4]:
data = haar_state_vector(
    num_qubits
)  # randomly generate a state vector with 2 qubits following Haar random
state = to_state(data)
print(f"A state vector with 2 qubits following Haar random{state}")

data = random_density_matrix(num_qubits)  # random 2-qubit density matrix
state = to_state(data)
print(f"type of the state: {state.backend}")
A state vector with 2 qubits following Haar random
---------------------------------------------------
 Backend: state_vector
 System dimension: [2]
 System sequence: [0]
[0.23-0.11j 0.28-0.93j]
---------------------------------------------------

type of the state: density_matrix

Information of a State instance

The information of a State instance is provided as follows. Three random generated single-qubit states \(\{ |\psi_j\rangle \}_{j=1}^3\) are taken as examples.

[5]:
state = random_state(num_qubits=1, rank=1, size=3)  # 3 random single-qubit pure states
print(f"3 random single-qubit pure states: {state}")
3 random single-qubit pure states:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2]
 System sequence: [0]
 Batch size: [3]

 # 0:
[-0.46-0.j   -0.85-0.27j]
 # 1:
[ 0.39-0.11j -0.87-0.26j]
 # 2:
[ 0.82-0.32j -0.42+0.24j]
---------------------------------------------------

One can obtain the ket \(\{ |\psi_j\rangle \}_{j=1}^3\), bra \(\{ \langle\psi_j| \}_{j=1}^3\) and the density matrix \(\{ |\psi_j\rangle\langle\psi_j| \}_{j=1}^3\) of these states. (Note that ket and bra forms are only available for pure states.)

[6]:
print("Its density matrix is :\n", state.density_matrix)
print("\nIts ket is :\n", state.ket)
print("\nIts bra is :\n", state.bra)
Its density matrix is :
 tensor([[[ 0.2082+0.0000j,  0.3876-0.1210j],
         [ 0.3876+0.1210j,  0.7918+0.0000j]],

        [[ 0.1690+0.0000j, -0.3152+0.2027j],
         [-0.3152-0.2027j,  0.8310+0.0000j]],

        [[ 0.7664+0.0000j, -0.4182-0.0642j],
         [-0.4182+0.0642j,  0.2336+0.0000j]]])

Its ket is :
 tensor([[[-0.4563-0.0035j],
         [-0.8473-0.2717j]],

        [[ 0.3947-0.1150j],
         [-0.8741-0.2588j]],

        [[ 0.8157-0.3179j],
         [-0.4185+0.2418j]]])

Its bra is :
 tensor([[[-0.4563+0.0035j, -0.8473+0.2717j]],

        [[ 0.3947+0.1150j, -0.8741+0.2588j]],

        [[ 0.8157+0.3179j, -0.4185-0.2418j]]])

We can also use the method numpy() to output numpy.ndarray type data for State.

[7]:
print("\nThe state is :\n", state.numpy())

The state is :
 [[-0.45629326-0.00350509j -0.8473218 -0.2717167j ]
 [ 0.39468753-0.1149976j  -0.8740804 -0.25880632j]
 [ 0.8156662 -0.31794474j -0.41849422+0.24178998j]]

Other useful information can be read via State object.

[8]:
print("The trace of these states are", state.trace())
print("The rank of these states are", state.rank)
print("The size of these states are", state.dim)
print("The shape of vectorization of these states are", state.vec.shape)
The trace of these states are tensor([1.+0.j, 1.+0.j, 1.+0.j])
The rank of these states are 1
The size of these states are 2
The shape of vectorization of these states are torch.Size([3, 4, 1])
[9]:
print("The number of systems in these states are", state.num_systems)
print("Are these states qubits?", state.are_qubits())
print("Are these states qutrits?", state.are_qutrits())
The number of systems in these states are 1
Are these states qubits? True
Are these states qutrits? False

State instance supports direct indexing, returning the \(i\)-th state in the batch.

[10]:
print(f"the second and third state in the batch: {state[1:]}")
the second and third state in the batch:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2]
 System sequence: [0]
 Batch size: [2]

 # 0:
[ 0.39-0.11j -0.87-0.26j]
 # 1:
[ 0.82-0.32j -0.42+0.24j]
---------------------------------------------------

Or if the length of the batch dimension is larger than 1, one can index elements in a batch dimension. See torch.index_select for more understanding.

[11]:
print(f"The index of batch dimension is 0: {state.index_select(0, torch.tensor([1, 2]))}")
The index of batch dimension is 0:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2]
 System sequence: [0]
 Batch size: [2]

 # 0:
[ 0.39-0.11j -0.87-0.26j]
 # 1:
[ 0.82-0.32j -0.42+0.24j]
---------------------------------------------------

Manipulation of states

Matrix multiplication is implemented via @, which makes calculating the inner product of two pure states \(\langle \psi | \phi \rangle\) easily.

[12]:
state_1 = zero_state(num_qubits=1).density_matrix
data = random_density_matrix(num_qubits=1)
state_2 = to_state(data).density_matrix
print(f"matrix multiplication:\n{state_1 @ state_2}")
print("The overlap of state_1 and state_2 is :", trace(state_1 @ state_2))
matrix multiplication:
tensor([[0.6244+0.0000j, 0.1924+0.4444j],
        [0.0000+0.0000j, 0.0000+0.0000j]])
The overlap of state_1 and state_2 is : tensor(0.6244+0.j)

User can also use NKron to obtain the tensor product \(\rho \otimes \sigma\) of the two quantum states \(\rho\) and \(\sigma\).

[13]:
product_state = NKron(state_1, state_2)
print(f"tensor product:\n{product_state}")
tensor product:
tensor([[0.6244+0.0000j, 0.1924+0.4444j, 0.0000+0.0000j, 0.0000+0.0000j],
        [0.1924-0.4444j, 0.3756+0.0000j, 0.0000+0.0000j, 0.0000+0.0000j],
        [0.0000+0.0000j, 0.0000+0.0000j, 0.0000+0.0000j, 0.0000+0.0000j],
        [0.0000+0.0000j, 0.0000+0.0000j, 0.0000+0.0000j, 0.0000+0.0000j]])

Permuting subsystems can be implemented by adapting the value of system_seq. However, such a change does not affect its output on ket, bra, and density matrix, in which case system_seq would be reset to the default sequence. Here, three randomly prepared 2-qubit states \(\{ |\phi_j\rangle \}_{j=1}^3\) are permuted as examples.

[14]:
state = random_state(num_qubits=2, rank=1, size=1)  # random 2-qubit pure states
print(f"1 random 2-qubit pure states: {state}")

state.system_seq = [1, 0]  # permutation
print(f"state after permutation: {state}")
1 random 2-qubit pure states:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [0, 1]
[-0.56+0.12j -0.28+0.56j -0.3 +0.26j -0.24+0.24j]
---------------------------------------------------

state after permutation:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [1, 0]
[-0.56+0.12j -0.3 +0.26j -0.28+0.56j -0.24+0.24j]
---------------------------------------------------

User can also clone a state, or change its data type and device.

[15]:
print("The dtype of these states are", state.dtype)
print("The device of these states are", state.device)

new_state = state.clone().to(
    dtype=torch.complex128, device="cpu"
)  # change to "cuda" if gpu is available
print("The dtype of new states are", new_state.dtype)
print("The device of new states are", new_state.device)
The dtype of these states are torch.complex64
The device of these states are cpu
The dtype of new states are torch.complex128
The device of new states are cpu

Interaction with environments

State instances can be sent to a quantum environment for further processing. For example, let the state evolve under a unitary operator.

[16]:
unitary = random_unitary(num_qubits=1)
state_evo = state.evolve(unitary, sys_idx=[1])
print(f"state after evolving with unitary: {state_evo}")
state after evolving with unitary:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [1, 0]
[0.24+0.56j 0.22+0.15j 0.35+0.48j 0.21+0.4j ]
---------------------------------------------------

Then, apply a random quantum channel to the state. Note that when pure states are sent to noisy environments, they will be automatically converted to mixed states, in which case their ket and bra properties will be lost.

[17]:
kraus = random_channel(num_qubits=1)
state_kra = state.transform(kraus, sys_idx=[0], repr_type="kraus")
print(f"state after transformation: {state}")
state after transformation:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [0, 1]
[-0.56+0.12j -0.28+0.56j -0.3 +0.26j -0.24+0.24j]
---------------------------------------------------

[18]:
choi = random_channel(num_qubits=1, target="choi")
state_cho = state.transform(choi, sys_idx=[1], repr_type="choi")
print(f"state after transformation: {state}")
state after transformation:
---------------------------------------------------
 Backend: state_vector
 System dimension: [2, 2]
 System sequence: [0, 1]
[-0.56+0.12j -0.28+0.56j -0.3 +0.26j -0.24+0.24j]
---------------------------------------------------

State.expec_val() calculates the expectation value of a state under a given observable. Here we set the observable to Pauli \(Z\) operator on the first qubit.

[19]:
observable = hamiltonian.Hamiltonian([(2, "Z0")])
print(
    "the expectation value under the given observable: ",
    state.expec_val(hamiltonian=observable),
)
the expectation value under the given observable:  tensor(0.9029)

State.measure() returns the measurement result in the computational basis.

[20]:
print("Theoretical value is :", state.measure())  # theoretical value
Theoretical value is : tensor([0.3325, 0.3932, 0.1569, 0.1173])

Table: A reference of notation conventions in this tutorial.

Symbol

Variant

Description

\(\mathcal{H}\)

\(\mathcal{H}_A\)

a Hilbert space (of quantum system \(A\))

\(X\)

\(\sigma_x\)

Pauli X

\(\vert \psi \rangle\)

\(\vert \psi_j \rangle\)

the \(j\)-th pure state

\(\langle \psi \vert\)

conjugate transpose of \(\vert \psi \rangle\)

\(\rho\)

general quantum state

\(\langle \psi \vert \phi \rangle\)

inner product of two pure states

\(\rho \otimes \sigma\)

tensor product of two states

[21]:
qkit.print_info()

---------VERSION---------
quairkit: 0.2.0
torch: 2.4.1+cpu
numpy: 1.26.0
scipy: 1.14.1
matplotlib: 3.9.2
---------SYSTEM---------
Python version: 3.10.15
OS: Windows
OS version: 10.0.26100
---------DEVICE---------
CPU: ARMv8 (64-bit) Family 8 Model 1 Revision 201, Qualcomm Technologies Inc