Operations#

All the data structures of PauliArray can be acted on or combined using a set of operations. We give some examples using some data structures but most operations are available for all data structures. Also, while it is possible to infer expected behaviour for operations between different kinds of data structures, at the moment most operations involving two objects can only be performed between objects of the same data structure. This might change in the future.

Indexing and Masking#

Indexing and masking in PauliArray works similarly as in Numpy. For example, the following code shows how to access the first two Pauli strings of the second column of a PauliArray.

from pauliarray import PauliArray

paulis = PauliArray.from_labels(
    [
        ["IIIX", "IIIY"],
        ["IIXZ", "IIYZ"],
        ["IXZZ", "IYZZ"],
        ["XZZZ", "YZZZ"],
    ]
)
new_paulis = paulis[:2, 1]

The result can be seen using the inspect method.

print(new_paulis.inspect())
PauliArray
IIIY
IIYZ

Composition#

The operation of acting on an operator with another operator is called composition. It is equivalent to a matrix product between the matrices representation of the operators. In PauliArray the composition of two arrays is element-wise. For example, the composition of two WeightedPauliArray yields a new WeightedPauliArray

\[w_i^{(1)}\hat{P}_i^{(1)} w_j^{(2)} \hat{P}_j^{(2)} = w_{ij}^{(3)} \hat{P}_{ij}^{(3)}.\]
from pauliarray import WeightedPauliArray

wpaulis_1 = WeightedPauliArray.from_labels_and_weights(["IZ", "ZI"], [1, 2])
wpaulis_2 = WeightedPauliArray.from_labels_and_weights(["ZZ", "XX"], [3, 4])

wpaulis_3 = wpaulis_1.compose(wpaulis_2)

print(wpaulis_3.inspect())
PauliArray
(+3.0000 +0.0000j) ZI
(+0.0000 +8.0000j) YX

The same two WeightedPauliArray can be composed in a outer product fashion such that all the elements from the first WeightedPauliArray are composed with all the elements of a second WeightedPauliArray

\[w_i^{(1)}\hat{P}_i^{(1)} w_j^{(2)} \hat{P}_j^{(2)} = w_{ij}^{(4)} \hat{P}_{ij}^{(4)}.\]

This results into a 2-dimensional WeightedPauliArray.

In PauliArray this can be achieved by making use of broadcasting by introducing new dimensions to the arrays with None. See Numpy’s documentation for more details.

wpaulis_4 = wpaulis_1[:, None].compose(wpaulis_2[None, :])

print(wpaulis_4.inspect())
PauliArray
(+3.0000 +0.0000j) ZI  (+0.0000 +4.0000j) XY
(+6.0000 +0.0000j) IZ  (+0.0000 +8.0000j) YX

The composition of two Operator \(\hat{O}^{(1)} = \sum_{i} w_i^{(1)} \hat{P}_i^{(1)}\) and \(\hat{O}^{(2)} = \sum_{j} w_j^{(2)} \hat{P}_j^{(2)}\) involves such a 2-dimensional WeightedPauliArray.

\[\hat{O}^{(1)} \hat{O}^{(2)} = \sum_{i,j} w_i^{(1)} \hat{P}_i^{(1)} w_j^{(2)} \hat{P}_j^{(2)} = \sum_{i,j} w_{ij}^{(3)} \hat{P}_{ij}^{(3)}\]

However, it needs to be flattened (\((i,j) \to k\)) to represent an Operator.

PauliArray handles compositions of Operator this way. It also combines the coefficients of repeated Pauli strings within the sum.

\[\hat{O}^{(1)} \hat{O}^{(2)} = \sum_k w_{k}^{(3)} \hat{P}_{k}^{(3)}\]
from pauliarray import Operator

operator_1 = Operator.from_labels_and_weights(["IZ", "XI"], [1, 2])
operator_2 = Operator.from_labels_and_weights(["II", "XZ"], [2, 1])

operator_3 = operator_1.compose(operator_2)

print(operator_3.inspect())
Operator Sum of
(+5.0000 +0.0000j) XI
(+4.0000 +0.0000j) IZ

Attention

Composition, and all other operations based on composition, behave a bit differently for the data structure PauliArray. The composition of two Pauli strings produces a new Pauli string as well as a possible factor

\[\hat{P}_1 \hat{P}_2 = (-i)^{f}\hat{P}_3 .\]

Therefore, the composition of two PauliArray returns a PauliArray and a numpy.ndarray[complex].

from pauliarray import PauliArray

paulis_1 = PauliArray.from_labels(["IZ", "ZI"])
paulis_2 = PauliArray.from_labels(["ZZ", "XX"])

paulis_3, factors = paulis_1.compose(paulis_2)

This behaviour extends to all PauliArray methods and functions were such factors are produced.

Commutation#

Based on the encoding of Pauli strings with bit strings \(\mathbf{z}\) and \(\mathbf{x}\), it’s easy to show that Pauli strings \(\hat{P}^{(1)}\) and \(\hat{P}^{(2)}\) commute if

(1)#\[c = \mathbf{z}^{(1)} \cdot \mathbf{x}^{(2)} + \mathbf{x}^{(1)} \cdot \mathbf{z}^{(2)} \pmod{2}\]

is equal to \(0\) and anticommute otherwise. Commutation can be assessed element-wise using the commute_with method.

from pauliarray import PauliArray

paulis_1 = PauliArray.from_labels(["IZ", "ZI"])
paulis_2 = PauliArray.from_labels(["ZZ", "XX"])

do_commute = paulis_1.commute_with(paulis_2)

print(do_commute)
[ True False]

Actual commutators can be computed element-wise between two arrays of Pauli strings

\[[\hat{P}^{(1)}_i, \hat{P}^{(2)}_i] = \hat{P}^{(1)}_i \hat{P}^{(2)}_i - \hat{P}^{(2)}_i \hat{P}^{(1)}_i .\]

For efficiency, this operation can be reduced to a single composition

\[[\hat{P}^{(1)}_i, \hat{P}^{(2)}_i] = 2c_i \hat{P}^{(1)}_i \hat{P}^{(2)}_i\]

where \(c_i\) is given by the equation (1). In practice, the result for commutating Pauli strings is set to \(0\hat{I}\).

from pauliarray.pauli.weighted_pauli_array import WeightedPauliArray, commutator

wpaulis_1 = WeightedPauliArray.from_labels_and_weights(["IZ", "ZI"], [1, 2])
wpaulis_2 = WeightedPauliArray.from_labels_and_weights(["ZZ", "XX"], [3, 4])

comm_wpaulis = commutator(wpaulis_1, wpaulis_2)

print(comm_wpaulis.inspect())