Representing Pauli Operators¶
In this tutorial, we'll walk through the basic ways to represent Pauli operators and their linear combinations in Workbench Algorithms.
A lot of quantum computing routines take their inputs expressed in terms of Pauli operators. To unify handling Pauli operators, Workbench Algorithms offers two classes:
from psiqdk.workbench import QPU, Qubits
from psiqdk.algorithms.utils.paulimask import PauliMask, PauliSum, pauli_sum_to_numpy
import numpy as np
PauliMask¶
PauliMask represents a multi-qubit Pauli operator - a tensor product of several single-qubit Pauli operators $I, X, Y, Z$ acting on qubits of a multi-qubit register.
Internally, PauliMask uses a bit-wise representation to indicate the type of Pauli operator acting on each qubit of the register. This representation was chosen due to the unique algebra associated with multiplying Pauli matrices together: thanks to this bit-wise representation, manipulating PauliMask objects is especially efficient!
Let's look at some PauliMask objects, so we can get more comfortable with the notation and its internal representation!
Creating PauliMasks from integer bitmasks¶
The typical way to instantiate a PauliMask is to pass two integers: the bitmasks x_mask and z_mask specifying the types of single-qubit Pauli operators acting on each qubit of the register.
As a reminder, Workbench uses little-endian notation: the least significant bit of an integer corresponds to the topmost qubit in a circuit diagram and the qubit with index $0$ in a Qubits register. Binary notation of Python integers uses big-endian notation: the least significant bit is written in the rightmost position:
0b110 = 6in Python.
To understand how these masks are used to represent Pauli operators, imagine a system of three qubits. We can think of the Pauli X operator acting on the first qubit ("X0") as simply noting that the Pauli X operator is applied to the least significant qubit. Likewise, we can think of a Pauli Z operator acting on the third qubit ("Z2") as the Pauli Z operator applied to the most significant qubit, and a Pauli Y operator acting on the second qubit ("Y1") - as both Pauli X and Pauli operators applied to the middle qubit.
We can efficiently represent these actions as binary numbers "acting" on our register of qubits: x_mask represents the indices of all qubits to which the X operator is applied (either on its own or as part of the Y operator), and z_mask does the same for the Z operator. A mask represents qubits positions using its binary notation: an operator should be applied to qubits in positions in which the binary notation of the mask has a $1$ bit.
Workbench uses the same mask-based notation for inputs to Pauli product rotation gates; you can read more about that in the Non-standard Gates and Measurements tutorial ⧉.
In our example:
- The X operator should be applied to the two least significant bits. The bitmask for this is
0b011 = 3. - The Z operator should be applied to the two most significant bits. The bitmask for this is
0b110 = 6.
The following example shows how to create a PauliMask object from bitmasks represented as Python binary integers and, equivalently, regular integers.
xyz1 = PauliMask(0b011, 0b110)
xyz2 = PauliMask(3, 6)
print(f"{xyz1.get_pauli_string()}\n{xyz2.get_pauli_string()}")
X0 Y1 Z2 X0 Y1 Z2
Creating PauliMasks from PauliStrings¶
Sometimes it is more user-friendly to create PauliMask objects from strings representing our Pauli operators, such as the string returned by the get_pauli_string method. We can do this easily using the from_pauli_string method:
xy = PauliMask.from_pauli_string("X0 Y1 Z2")
xy_x_mask, xy_z_mask = xy.mask
print(xy.mask)
print(f"x_mask = {bin(xy_x_mask)}, z_mask = {bin(xy_z_mask)}")
print(xy.get_pauli_string(), "\n")
xyiiiy = PauliMask.from_pauli_string("X0 Y1 Y5")
xyiiiy_x_mask, xyiiiy_z_mask = xyiiiy.mask
print(xyiiiy.mask)
print(f"x_mask = {bin(xyiiiy_x_mask)}, z_mask = {bin(xyiiiy_z_mask)}")
print(xyiiiy.get_pauli_string())
assert xyiiiy.mask == PauliMask(35, 34).mask
# Any identity operators in Pauli string are ignored
xyiiiy2 = PauliMask.from_pauli_string("X0 Y1 I2 I3 I4 Y5")
assert xyiiiy2.mask == xyiiiy.mask
(3, 6) x_mask = 0b11, z_mask = 0b110 X0 Y1 Z2 (35, 34) x_mask = 0b100011, z_mask = 0b100010 X0 Y1 Y5
Manipulating PauliMasks¶
We can do some handy algebra using the PauliMask objects. These manipulations are where the efficiency of these bitmask representations shine. Let's check some basic Pauli algebra!
The
PauliMaskobject has no coefficient nor sign, so such algebraic rules are neglected here: we say that $Y = XZ$ without any coefficient.
We can multiply PauliMasks directly:
x = PauliMask(1, 0)
z = PauliMask(0, 1)
y = x * z
print(f"X0 * Z0 = {y.get_pauli_string()}")
print(f"X0 * X0 = {(x * x).get_pauli_string()}")
X0 * Z0 = Y0 X0 * X0 = I
import time
some_big_operator = PauliMask(122398295302235234624, 1232234634544352536)
another_big_operator = PauliMask(854347345634252822, 235197623462345645782)
start = time.time()
mult = (some_big_operator * another_big_operator).get_pauli_string()
time_to_compute = time.time() - start
print(mult)
print("Time to compute: {} sec".format(time_to_compute))
Y1 Y2 Z3 X4 Y6 Z7 Y8 Z9 X11 Y12 X13 X14 X15 X16 Y17 X19 Y22 Z23 Z25 Z26 Y27 X28 X29 Z31 Y32 Y33 Y34 Y36 Y37 X38 Z39 X40 Z41 Z42 X43 Y44 X46 X48 Y50 Z51 Z52 X54 Y56 X59 Z60 X61 Z62 Y63 X65 Y66 Z67 Time to compute: 6.723403930664062e-05 sec
That's wicked fast for some really big operators!!!
We can also check whether operators commute:
i = PauliMask(0, 0)
print(f"X0 commutes with Z0: {x.commute_check(z)}")
print(f"X0 commutes with X0: {x.commute_check(x)}")
print(f"Z0 commutes with I: {z.commute_check(i)}")
print(f"X0 commutes with Z1: {x.commute_check(PauliMask(0, 2))}")
X0 commutes with Z0: False X0 commutes with X0: True Z0 commutes with I: True X0 commutes with Z1: True
Finally, we can get the commutator of PauliMask, with the phase (returns a PauliSum) or without it (returns a PauliMask):
xxy = PauliMask(7, 4)
zyx = PauliMask(6, 3)
print(xxy.get_pauli_string())
print(zyx.get_pauli_string())
print(f"Without the phase: {xxy.commutator(zyx).get_pauli_string()}")
print(f"With the phase: {xxy.commutator(zyx, drop_phase=False)}")
X0 X1 Y2 Z0 Y1 X2 Without the phase: Y0 Z1 Z2 With the phase: -2j*Y0 Z1 Z2
Inspecting elements of a PauliMask¶
PauliMask also offers useful helper functions to access its particular elements:
get_indices()method returns the qubit indices on which the operator acts non-trivially.get_pauli()method returns the Pauli that acts on a specific qubit index.
print(xyiiiy.get_indices())
for index in range(6):
pauli = xyiiiy.get_pauli(index)
print(f"Operator acting on qubit {index}: {pauli}")
[0, 1, 5] Operator acting on qubit 0: X Operator acting on qubit 1: Y Operator acting on qubit 2: None Operator acting on qubit 3: None Operator acting on qubit 4: None Operator acting on qubit 5: Y
PauliSum¶
As the name suggests, PauliSum is Workbench Algorithm representation of linear combinations of PauliMasks.
Building PauliSums¶
Two common ways to construct a PauliSum are by adding several PauliMask objects together or by passing them into the constructor along with their coefficients.
Note that you cannot multiply a
PauliMaskby a number, so if you need a linear combination of Pauli operators with different coefficients, you need to use thePauliSumconstructor.
x_plus_y = x + y
print(x_plus_y)
print(pauli_sum_to_numpy(x_plus_y), "\n")
x_plus_2y = PauliSum(
[1, x],
[2, y],
)
print(x_plus_2y)
print(pauli_sum_to_numpy(x_plus_2y))
1*X0 + 1*Y0 [[0.+0.j 1.-1.j] [1.+1.j 0.+0.j]] 1*X0 + 2*Y0 [[0.+0.j 1.-2.j] [1.+2.j 0.+0.j]]
As you can see above, the function pauli_sum_to_numpy() allows to convert a PauliSum object to matrix format easily.
Getting information about a PauliSum¶
PauliSum offers helpful methods to get information about it:
wires()counts the number of qubits that thePauliSumspans across (including qubits only acted on by the identity operator).width()counts the number of qubits on which thePauliSumacts non-trivially.norm()calculates the norm (sum of the magnitudes of the coefficients) of thePauliSum.
pauli_sum = PauliSum(
[1, PauliMask.from_pauli_string("Z0 X5 Y11")],
[-15, PauliMask.from_pauli_string("Y0 Z4")],
[0.25, PauliMask.from_pauli_string("Z5 Z8 Y9 X12")],
[0.25, PauliMask.from_pauli_string("Z3 Z8")],
[0.33, PauliMask.from_pauli_string("X3 Z7 Z8 X9 Y11")],
)
print(pauli_sum)
print(f"Number of Wires: {pauli_sum.wires()}")
print(f"Width: {pauli_sum.width()}")
print(f"Norm: {pauli_sum.norm()}")
1*Z0 X5 Y11 + -15*Y0 Z4 + 0.25*Z5 Z8 Y9 X12 + 0.25*Z3 Z8 + 0.33*X3 Z7 Z8 X9 Y11 Number of Wires: 13 Width: 9 Norm: 16.83
We can also get a list of the coefficients using get_coefficients() and get the amplitudes associated with them using get_padded_amplitudes(). The last method returns the coefficients divided by the norm and also padded with zeroes so that the length of the list is an integer power of $2$. This can come in handy when using the amplitudes to build operations such as $\text{PREPARE}$ and $\text{SELECT}$.
print(pauli_sum.get_coefficients())
print(pauli_sum.get_padded_abs_amplitudes())
[1, -15, 0.25, 0.25, 0.33] [0.05941770647653001, 0.8912655971479502, 0.014854426619132503, 0.014854426619132503, 0.019607843137254905, 0, 0, 0]
Manipulating PauliSums¶
There are also lots of helper methods that perform basic manipulations of PauliSum objects.
The normalize() method normalizes the PauliSum, dividing each term by its norm.
print("Unnormalized:\n", pauli_sum)
print("Normalized:\n", pauli_sum.normalize())
Unnormalized: 1*Z0 X5 Y11 + -15*Y0 Z4 + 0.25*Z5 Z8 Y9 X12 + 0.25*Z3 Z8 + 0.33*X3 Z7 Z8 X9 Y11 Normalized: 0.05941770647653001*Z0 X5 Y11 + -0.8912655971479502*Y0 Z4 + 0.014854426619132503*Z5 Z8 Y9 X12 + 0.014854426619132503*Z3 Z8 + 0.019607843137254905*X3 Z7 Z8 X9 Y11
add_identity_offset() adds an identity offset - a term with identity Pauli operator and coefficient equal to the norm.
coefficient, shifted_sum = pauli_sum.add_identity_offset()
print(shifted_sum)
1*Z0 X5 Y11 + -15*Y0 Z4 + 0.25*Z5 Z8 Y9 X12 + 0.25*Z3 Z8 + 0.33*X3 Z7 Z8 X9 Y11 + 16.83*I
remove_below() removes all terms with coefficients below the given threshold.
print(shifted_sum.remove_below(1))
1*Z0 X5 Y11 + -15*Y0 Z4 + 16.83*I
commutator() returns the commutator of two PauliSum objects.
print(pauli_sum.commutator(xxy + zyx))
2j*Y0 X1 Y2 X5 Y11 + 0.0*I + 30j*Z0 X1 Y2 Z4 + -30j*X0 Y1 X2 Z4
Summary¶
In this tutorial, we went over the PauliMask and PauliSum objects used to represent Pauli operators and their linear combinations in Workbench.