Skip to main content

Numerical Pauli Strings

Pauli Operator Encoding

Pauli operators are central to the representation of spin 1/2 rotations, form a basis in the space of many-body spin 1/2 (or equivalently qubit) operators, and are mathematically simple to manipulate. A pauli string acting on N spin 1/2 particles PP is defined as the tensor product of the pauli operators:

P=n=1Nσn,σn{I,X,Y,Z}P = \bigotimes_{n=1}^{N} \sigma_n \quad , \quad \sigma_n \in \{ I, X, Y, Z \}

The dimension of the operator space they span is 4N4^N which makes generating and handling large collections of Pauli strings numerically difficult. However, there exists a faithful binary encoding that compresses the product into bits:

I=00,X=10,Y=11,Z=01I = 00 , \quad X = 10 , \quad Y = 11 , \quad Z = 01

The first bit is referred to as the "X" bit and the second as the "Z" bit. This reformulation does not solve the dimensionality problem, but ensures that PP can be efficiently represented and manipulated with 2N2N bits. In this form, we can also perform operations between Pauli strings very quickly like multiplication, commutation, and query efficiently for string properties.

PauliString

Making A Pauli String

In Aleph Pauli strings can be handled with the lightweight PauliString data type. It contains two integers for the X and Z bits, as well as a complex coefficient. An object can be created by passing a string with the name of pauli operators, their corresponding sites, and the total number of spins in the Hilbert space.

var ps1 = PauliString("X",[0],2) // 1.0 * Z(0)
var ps2 = 0.5 * PauliString("YX",[0,1],2) // 0.5 * Y(0)*X(1)

Alternatively the more verbose binary form of the integers can also be passed

var ps = PauliString(0B011,0B100,3) // 1.0 * X(0) * X(1) * Z(2)
warning

Some care is needed when constructing terms from their integer representations in either form. The bits are indexed from 00 to N1N-1 from right to left and thus the PauliString will treat the Pauli operator on the right most bits as the first one. Ensure that if individual bits are used that this access order is respected.

Two PauliStrings can be multiplied in and out of place with the multiply and multiplied methods

var ps1 = PauliString("Z",[0],1) // 1.0 * Z(0)
var ps2 = 2.0 * PauliString("X",[0],1) // 2.0 * X(0)
var ps3 = ps1.multiplied(ps2) // 2.0i * Y(0)
var ps4 = ps1 * ps2 // using the * operator.
ps1.multiply(ps2) // ps1 now contains the output of the multiplication.

Constructing many desired Pauli strings can become tedious with these constructors, but there exist functions that allow you to convert from a ComplexOperatorSum and sums of PauliStrings that we will discuss below. This workflow is the recommended one as the Operator framework provides a more intuitive handle on complex Operator expressions.

Extracting Properties

Two member variables can be accessed directly:

  • coefficient() : returns the complex coefficient.
  • num_spins : returns the number of spins in the Hilbert space of the Pauli string. Using the display() method, a more verbose output of the bit pattern of the pauli string can be seen, along with the information above.

A useful quantity to extract from a PauliString is its weight: the number of non identity operators contained within the string. This can be used in algorithm design to help in filtering Pauli strings or truncate sums if larger weight Pauli's are deemed irrelevant. The weight can be extracted simply as

var ps1 = PauliString("YZ",[0,1],2) // 1.0 * Y(0) * Z(1)
var weight1 = ps1.weight() // weight1 = 2

var ps2 = PauliString(0,0,2) // 1.0 * Id()
var weight2 = ps2.weight // weight2 = 0

A property of Pauli strings is that they either commute or anti commute with each other, i.e. for two Pauli strings S,PS,P

[S,P]=0 or {S,P}=0.[S,P] = 0 \quad \text{ or } \quad \{S,P \} = 0.

To determine if two strings commute or anti commute the does_commute_with() method can be used

var ps1 = PauliString(0,1,1) // 1.0 * Z(0)
var ps2 = PauliString(1,0,1) // 1.0 * X(0)
var comm1 = ps1.commutes_with(ps2) // false, Z and X on same site anti commute.

var ps3 = PauliString(2,2,1) // 1.0 * Y(1)
var comm2 = ps1.commutes_with(ps3) // true, Z and Y on different sites commute.

PauliStringSum

Often, operations on Pauli strings can return sums of Pauli strings. The PauliStrings introduced above are useful for looking at individual strings but not at manipulating the collection or performing operations that generate an increasing number of strings. The PauliStringSum class does precisely this: it handles sums of Pauli strings for you and provides simple access to operations that increase the term count.

Making and Modifying a PauliStringSum

A PauliStringSum object can be constructed in couple ways:

var Sum1 = PauliStringSum(10) // Empty Sum
var ps1 = PauliString("Z",[0],10) // 1.0 * Z(0)
var Sum2 = PauliStringSum(ps1) // Initializes the sum with the first term being ps

The number of spins in the PauliString and PauliStringSum must match as they may act on different Hilbert spaces. If you have two PauliStrings you can add them with + to make a PauliStringSum object:

var ps1 = PauliString(0,1,2) // 1.0 * Z(0)
var ps2 = PauliString(1,0,2) // 1.0 * X(0)
var Sum = ps1 + ps2

and the Hilbert space information is automatically deduced in the sum.

Sums follow basic algebraic rules such that they can be scaled and added together to return a new sum:

var ps1 = PauliString(0,1,10)
var Sum1 = PauliStringSum(ps1)

var ps2 = PauliString(1,0,10)
var Sum2 = PauliStringSum(ps2)

var Sum3 = 2.0 * Sum1 + -1.0i * Sum2

The coefficients of the PauliStringSum can be modified with a function that acts on the coefficient cnc_n of the Pauli string PnP_n and returns a transformed version of the coefficient

def double(Complex val) {
return 2.0 * val
}

var sum = PauliStringSum(10)
sum += 2.0 * PauliString(0B10,0,10) // 2.0 * X(1)
sum.transform_coefficients(double) // sum = 4.0 * X(1)

The function supplied to transform_coefficients must work for complex numbers and cannot couple different coefficients together. Another useful operation is taking the adjoint of a Pauli string:

P=(cn=1Nσn)=ck=1NσNk=ck=1NσkP^{\dag} = (c \prod_{n=1}^{N} \sigma_n)^{\dag} = c^{*} \prod_{k = 1}^{N} \sigma_{N-k}^{\dag} = c^{*} \prod_{k = 1}^{N} \sigma_k

where in the last equality we use the fact that Pauli operators on different sites commute, they are Hermitian, and that in our representation theres only a single Pauli per site. Both in and out of place adjoints can be taken

var ps1 = 2.0 * PauliString(0,1,2)
var ps2 = 1.0i * PauliString(1,0,2)
var sum = PauliStringSum(ps1)
sum += ps2

var sum2 = sum.adjointed() // out of place, sum2 = 2.0 * Z(0) - 1.0 * X(0)
sum.adjoint() // in place sum = 2.0 * Z(0) - 1.0 * X(0)

Growing the Sum

The PauliStringSum class is designed to make the addition of terms to the sum simple and offer high level control over complicated calculations. Once a sum object has been created, PauliString objects can be added to it:

var Sum = PauliStringSum(4)
// use the += operator to add a term
Sum += 2.0 * PauliString(1,1,4)
// call the append method that does the same thing
Sum.append(-3.0i * PauliString(0,1,4))

Two sums can be multiplied into each other using the * operator

var ps1 = PauliString(0,1,2) // 1.0 * Z(0)
var ps2 = PauliString(1,0,2) // 1.0 * X(0)
var Sum1 = PauliStringSum(ps1)
var Sum2 = PauliStringSum(ps2)
var Sum3 = Sum1 * Sum2 // out of place multiplication.

Another useful operation is evaluating the commutator between two sums. This is useful when expressing many-body Hamiltonian's in PauliStringSum form and solving the Heisenberg equations of motion. It can be calculated explicitly with the commutator method

ar ps1 = PauliString(0,1,1) // 1.0 * Z(0)
var ps2 = PauliString(1,0,1) // 1.0 * X(0)
var Sum1 = PauliStringSum(ps1)
var Sum2 = PauliStringSum(ps2)
var comm = ps1.commutator(ps2) // comm = [ps1,ps2]

Lastly, Pauli strings have well defined behaviour under conjugation by a unitary UU

PUPUP \rightarrow U^{\dag} P U

when U=eiθQU = e^{i \theta Q} and QQ is a Pauli string. The conjugation can be performed efficiently when the unitary is of this form, regardless of the number of spins in the support of QQ. If QQ and PP commute, i.e. [Q,P]=0[Q,P] = 0, then PP is unchanged under conjugation. Otherwise QQ and PP anticommute and we find that

eiθQPeiθQ=cos(2θ)P+isin(2θ)QPe^{i \theta Q} P e^{-i \theta Q} = \cos(2\theta)P + i \sin(2\theta)QP

Unitaries UU that can be expressed as a product of such rotations U=neiθnQnU = \prod_n e^{i \theta_n Q_n} can chain conjugations together to perform the conjugation by UU. PauliStringSum offers in place and out of place methods to this with pauli_rotate

var ps = PauliString(1,0) // 1.0 * X(0)
var Q = PauliString(0,1) // 1.0 * Z(0)
var sum = PauliStringSum(ps)
var out = sum.pauli_rotated(pi/2,Q) // out = -1.0 * X(0)
sum.pauli_rotate(pi/2,Q) // sum = -1.0 * X(0)
warning

The rotation of the PauliStringSum is done with respect to θ\theta not θ2\frac{\theta}{2} like is done for the rotation operations. If comparing expectation values with standard spin 1/2 rotation operators ensure the angles are indeed the same.

Managing The Growth of Terms

While multiplying, commuting, and conjugating two PauliStringSums, the number of terms may grow very fast. The sum can be truncated via the prune method. It takes two arguments:

  • ϵ\epsilon : a minimum cutoff value for the norm cn|c_n| of the coefficient of the term cnc_n.
  • wmaxw_{max} : a maximum value for the weight of a Pauli string.

This should be called at appropriate places where the number of terms is expected to grow. The values chosen for ϵ,wmax\epsilon, w_{max} will depend on the physics of the system studied, the numerical accuracy desired, and the memory constraints.

Expectation Values

After undergoing some process that grows the number of terms, it is useful at time to get the expectation value of the PauliStringSum. One example where this arises naturally is taking the expectation value of Heisenberg evolved an Operator with an initial basis state. This can be done easily with the expval method

var ps = PauliStringSum(1,0,4) // 1.0 * X(0)
var Sum = PauliStringSum(ps)
var state = Qbit(4) // 1.0 * |0000>
var val = Sum.expval(state) // val = 0.0
Sum += PauliString(0,1) // 1.0 * Z(0)
val = Sum.expval(state) // val = 1.0;
note

Qbit objects are supported for N64N \leq 64 spins but PauliStringSums can up to N=256N=256 spins in their Hilbert space. Some care is needed to ensure that when taking expectation value, the support of the terms in the sum does not exceed the number of spins in the Qbit.

Converting to and From Operator Sums.

Often when trying to construct particular Pauli strings passing integers to the constructors can get complicated and tedious. Instead, one can use the Operator framework to first build your initial observable/Hamiltonian and then transform it to a PauliStringSum.

var N = 24
var J = 1.0
var h = 0.3
var H = operator_sum()
for(var s = 0 ; s < N-1 ; ++s)
{
H += J * (Z(s) * Z(s+1))
}
for(var s = 0 ; s < N ; ++s)
{
H += h * X(s)
}
var sum = convert_to_pauli_sum(H)

In the example, the transverse field Ising model is built in terms of a ComplexOperatorSum and then cast to PauliStringSum form using the convert_to_pauli_sum function. Heavy calculations can be done at this step, utilizing the power of the bit representation of the Pauli strings. Once completed, the transformed sum can be converted back to a ComplexOperatorSum via the convert_to_operator_sum function giving you back your symbolic expression.

The recommended workflow is then:

  1. Define your operator expression as a ComplexOperatorSum
  2. Perform some initial transformations on it.
  3. Convert to PauliStringSum form.
  4. Perform multiplication and conjugation heavy operations.
  5. Convert back to the symbolic form.

This pipeline allows for high level symbolic control while also turning complex expressions into numerically efficient ones.

Key Takeaways

  • PauliStrings represent Pauli strings in bit form.
  • In this bit form key operations like multiplication of Pauli strings can be done efficiently.
  • PauliStringSums represent sums of Pauli strings, allowing you to perform calculations like multiplying, commuting, and conjugating sums.
  • Operators can be used to define your expressions and serve as an entry point to numerically intensive Pauli calculations.
  • PauliStringSums and ComplexOperatorSums can be converted between each other.

Contributors

Sebastien J. Avakian