PauliStrings and Observables

View on QuantumAI Run in Google Colab View source on GitHub Download notebook
# @title Setup { vertical-output: true, display-mode: "form" }
try:
    import cirq
except ImportError:
    print("installing cirq...")
    !pip install --quiet cirq
    print("installed cirq.")
    import cirq

import cirq_google
import sympy
import numpy as np

What are Pauli observables?

Cirq provides the Pauli operators X, Y and Z as cirq.X, cirq.Y and cirq.Z respectively. Together with the identity operator cirq.I, these three operators form a complete basis for the set of all unitary transformations on a single qubit. That is, any quantum circuit on a single qubit can be represented by a linear combination (weighted sum) of the X,Y,Z and I operators applied to that qubit. This extends to quantum circuits of any number of qubits in the sense that any multi-qubit quantum circuit that doesn't entangle qubits can be represented by a linear combination of tensor products of Pauli operators.

Observables are, in general, some sort of measurable property of a circuit. At its very simplest, this could be whether a qubit measures to be \(|0\rangle\) or \(|1\rangle\) in the standard computational basis. In the Pauli basis, this corresponds to the Z observable. In general, this is roughly a way to measure qubit state in a basis other than the computational one, by applying basis-changing operations before measurement.

In Cirq, compositions, linear combinations, and tensor products of Pauli operators are represented with cirq.PauliString and cirq.PauliSum, which this tutorial will demonstrate next. Fundamentally, these objects are still Operations, and can be added to circuits like any other operation. The second half of this tutorial will cover the second use of PauliStrings, as observables in measurement.

Pauli Operator Representations

Before starting on building PauliStrings, define:

  1. A tiny function to print an object with its type, to make clear the types being used later
  2. Some Pauli operations that the PauliStrings and such will be built from.
# A small utility function to print the type and value of any number of arguments.
def typrint(*xs):
    for x in xs:
        print(type(x), x)


# A couple qubits.
a, b, c = cirq.LineQubit.range(3)
# A set of Pauli operations to build PauliStrings from.
Xa = cirq.X(a)
Xb = cirq.X(b)
Za = cirq.Z(a)
Zb = cirq.Z(b)

# Test the typrint function.
typrint(Xa, Xb, Za, Zb)
<class 'cirq.ops.pauli_string.SingleQubitPauliStringGateOperation'> X(q(0))
<class 'cirq.ops.pauli_string.SingleQubitPauliStringGateOperation'> X(q(1))
<class 'cirq.ops.pauli_string.SingleQubitPauliStringGateOperation'> Z(q(0))
<class 'cirq.ops.pauli_string.SingleQubitPauliStringGateOperation'> Z(q(1))

Note that all of these are operations applied to qubits of type cirq.SingleQubitPauliStringGateOperation. Even when you use cirq.X, cirq.Y and cirq.Z in other places in Cirq, they are still this type, which is representative of the simplest component of a PauliString, a single Pauli operation.

PauliString construction

An empty cirq.PauliString by itself is representative of the identity operation I, applied to any and all available qubits. A no-op, where no transformation of any qubits is occuring.

This also means that PauliStrings only represent combinations of the non-identity operations cirq.X, cirq.Y and cirq.Z. Any cirq.I operations added are dropped from a PauliString. Additionally, any qubits in the expression that have operations that cancel out to the identity are completely dropped. To reinforce this, cirq.I is not a SingleQubitPauliStringGateOperation, unlike X, Y and Z.

# An empty PauliString
typrint(cirq.PauliString())
# An equivalently empty PauliString built from an identity operation
Ia = cirq.I(a)
typrint(cirq.PauliString(Ia))
print(cirq.PauliString() == cirq.PauliString(Ia))
# cirq.I is a PauliString.
typrint(Ia)
print(issubclass(Ia.__class__, cirq.PauliString))
# cirq.I has qubits, but a PauliString drops qubits that are identity.
print(Ia.qubits)
print(cirq.PauliString(Ia).qubits)
# Two consecutive Xa cancel to the identity and are dropped.
print(cirq.PauliString(Xa, Xa).qubits)
<class 'cirq.ops.pauli_string.PauliString'> I
<class 'cirq.ops.pauli_string.PauliString'> I
True
<class 'cirq.ops.gate_operation.GateOperation'> I(q(0))
False
(cirq.LineQubit(0),)
()
()

cirq.SingleQubitPauliStringGateOperations are themselves PauliStrings, and are representative of one of the Pauli gates applied to some qubit.

typrint(Xa)
print(issubclass(Xa.__class__, cirq.PauliString))
<class 'cirq.ops.pauli_string.SingleQubitPauliStringGateOperation'> X(q(0))
True

Larger PauliStrings can be built with the * operator. Be careful with this operator, as it can be used in three distinct ways:

  1. Scalar Multiplication: complex * PauliString produces a PauliString with a complex scalar coefficient attached.
  2. Composition: PauliString(q(a)) * PauliString(q(a)) takes two PauliStrings that are applied to the same qubit(s) and composes them together by standard matrix multiplication.
  3. Tensor: PauliString(q(a)) * PauliString(q(b)) takes two PauliStrings that are applied to different qubits and combines them with the tensor product operation (usually \(⨂\)).
# Complex scalar multiplication.
typrint((4 + 5j) * Xa)
# Composition
typrint(Xa * Xa)
typrint(Xa * Za)
# Tensor
typrint(Xa * Xb)
typrint(Xa * Zb)
<class 'cirq.ops.pauli_string.PauliString'> (4+5j)*X(q(0))
<class 'cirq.ops.pauli_string.PauliString'> I
<class 'cirq.ops.pauli_string.PauliString'> -1j*Y(q(0))
<class 'cirq.ops.pauli_string.PauliString'> X(q(0))*X(q(1))
<class 'cirq.ops.pauli_string.PauliString'> X(q(0))*Z(q(1))

In the composition examples, cancellation and anti-commutation occurred according to the properties of the Pauli operators. Any Pauli operator composed with itself cancels into the identity I, and any two distinct Pauli operators composed together are equivalent to the third, with a \(\pm 1\) coefficient.

These three uses of the * operator fluidly work together in larger expressions. Interestingly, due to the associativity of these operators, it doesn't matter where each term is as long as the operations on the same qubit are applied in the same order

# The two PauliStrings from before
typrint(Xa * Za)
typrint(Xa * Zb)
# Correct order of operations on qubit a, which merge to a single -Z operation.
typrint(Xa * Za * Xa)
# Combined together with a coefficient.
typrint((3 + 6j) * (Xa * Za) * (Xa * Zb))
# The same PauliString with different ordering and a split coefficient.
typrint(Xa * Zb * Za * (3 + 0j) * Xa * (1 + 2j))
# A different PauliString where the terms applied to qubit a have changed order.
typrint(Za * Zb * Xa * (3 + 0j) * Xa * (1 + 2j))
<class 'cirq.ops.pauli_string.PauliString'> -1j*Y(q(0))
<class 'cirq.ops.pauli_string.PauliString'> X(q(0))*Z(q(1))
<class 'cirq.ops.pauli_string.PauliString'> -Z(q(0))
<class 'cirq.ops.pauli_string.PauliString'> (-3-6j)*Z(q(0))*Z(q(1))
<class 'cirq.ops.pauli_string.PauliString'> (-3-6j)*Z(q(0))*Z(q(1))
<class 'cirq.ops.pauli_string.PauliString'> (3+6j)*Z(q(0))*Z(q(1))

It is also possible to build cirq.PauliStrings explicitly with its constructor. This may be useful in generative code, but is occasionally less readable. Each argument and each element in that argument (if it is iterable) is combined with the same * operator as before.

# Compose two Xa and a coefficient, as a list.
typrint(cirq.PauliString([4, Xa, 5j * Za]))
# Compose Xa and Za and a coefficient, as arguments.
typrint(cirq.PauliString(4, Xa, 5j * Za))
# Compose Xa and Za and a coefficient, as dictionary arguments.
typrint(cirq.PauliString(20j, {a: cirq.X}, {a: cirq.Z}))
<class 'cirq.ops.pauli_string.PauliString'> (20+0j)*Y(q(0))
<class 'cirq.ops.pauli_string.PauliString'> (20+0j)*Y(q(0))
<class 'cirq.ops.pauli_string.PauliString'> (20+0j)*Y(q(0))
  • PauliStrings are immutable and should be treated as such at all times. cirq.MutablePauliString exists, but there are few use cases where this should be necessary.
  • The qubits in a PauliString are kept track of in the qubits property, since it is an Operation, and the with_qubits function can re-map the PauliString to new qubits.
  • As an operation, PauliString has a cirq.Gate object (cirq.DensePauliString) to represent the operation when not applied to any particular qubits.
pauli_string = -1 * cirq.X(a) * cirq.Y(b) * cirq.Z(c)
typrint(pauli_string)
# The PauliString's qubits.
print(pauli_string.qubits)
# Remap the PauliString to new qubits.
new_qubits = cirq.LineQubit.range(3, 6)
new_pauli_string = pauli_string.with_qubits(*new_qubits)
typrint(new_pauli_string)
print(new_pauli_string.qubits)
# The PauliString's gate.
typrint(pauli_string.gate)
<class 'cirq.ops.pauli_string.PauliString'> -X(q(0))*Y(q(1))*Z(q(2))
(cirq.LineQubit(0), cirq.LineQubit(1), cirq.LineQubit(2))
<class 'cirq.ops.pauli_string.PauliString'> -X(q(3))*Y(q(4))*Z(q(5))
(cirq.LineQubit(3), cirq.LineQubit(4), cirq.LineQubit(5))
<class 'cirq.ops.dense_pauli_string.DensePauliString'> -XYZ

Linear combinations of Pauli operators as cirq.PauliSums

Only scalar multiplication, composition and tensor product are possible with the * operator notation used so far. The final ingredient necessary is the sum +, which fittingly produces an object of type cirq.PauliSum. This is a lower precedence operator than *.

# Numbers are treated as coefficients on the identity I (on a unique bias qubit)
typrint(Xa + 4 + 5j)
# Sums of single qubit PauliStrings
typrint(Xa + Xa)
typrint(Xa + Za)
typrint(Xa + Zb)
# Sums of more complex PauliStrings
typrint(-2 * Xa + 3 * Za)
typrint(-2 * Xa * Xa + 3 * Za * Zb)
<class 'cirq.ops.linear_combinations.PauliSum'> 1.000*X(q(0))+(4.000+5.000j)*I
<class 'cirq.ops.linear_combinations.PauliSum'> 2.000*X(q(0))
<class 'cirq.ops.linear_combinations.PauliSum'> 1.000*X(q(0))+1.000*Z(q(0))
<class 'cirq.ops.linear_combinations.PauliSum'> 1.000*X(q(0))+1.000*Z(q(1))
<class 'cirq.ops.linear_combinations.PauliSum'> -2.000*X(q(0))+3.000*Z(q(0))
<class 'cirq.ops.linear_combinations.PauliSum'> -2.000*I+3.000*Z(q(0))*Z(q(1))

The PauliString terms will be simplified in the final version of the sum, and sums of the same term will combine together by adding their exponents.

Arbitrary combinations and parenthesizations of + and * are supported as you would expect with distribution.

typrint(-2 * Xa * (Xa + Xb))
typrint(-2 * Xa * (Za + Zb))
typrint(-2 * Xa * (Za + Xb * Zb))
<class 'cirq.ops.linear_combinations.PauliSum'> -2.000*I-2.000*X(q(0))*X(q(1))
<class 'cirq.ops.linear_combinations.PauliSum'> 2.000j*Y(q(0))-2.000*X(q(0))*Z(q(1))
<class 'cirq.ops.linear_combinations.PauliSum'> 2.000j*Y(q(0))+2.000j*X(q(0))*Y(q(1))

It may be useful for you to think of this as normal algebra where each Pauli operator applied to each distinct qubit is a different variable, but with the Pauli anti-commutation relations between the three varaibles for each qubit.

Exponentials of Pauli operators as cirq.PauliStringPhasors

Cirq also supports exponentials of Pauli strings with the cirq.PauliStringPhasor class. Any number can be exponentiated with a PauliString, but most typically it will be Euler's constant \(e\) as np.exp. Critically, only PauliStrings are supported by PauliStringPhasor, not PauliSums.

# When the PauliString simplifies to a single Pauli term, produce GateOperations
typrint(np.exp(1j * Xa))
typrint(np.exp(Xa * Za))  # XZ = -1j*Y
# When the PauliString doesn't simplify to a single Pauli term, produce PauliStringPhasors
typrint(np.exp(1j * Xa * Xa))  # I doesn't count as a Pauli term
typrint(np.exp(1j * Xa * Zb))
# All integer/float bases are supported with an imaginary-coefficient PauliString.
typrint(3 ** (1j * Xa * Zb))
# Powers of unitary PauliStrings work...
typrint((Xa * Zb) ** 3)
# but non-unitary PauliStrings don't.
try:
    typrint((3j * Xa * Zb) ** 3)
except TypeError as e:
    print(e)
<class 'cirq.ops.gate_operation.GateOperation'> XPowGate(exponent=-0.6366197723675814, global_shift=-0.5)(q(0))
<class 'cirq.ops.gate_operation.GateOperation'> YPowGate(exponent=0.6366197723675814, global_shift=-0.5)(q(0))
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (I)**-0.6366197723675815
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*Z(q(1)))**-0.6366197723675815
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*Z(q(1)))**-0.6993983051321195
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*Z(q(1)))**1.0
unsupported operand type(s) for ** or pow(): 'PauliString' and 'int'
typrint(np.exp(1j * Xa * Xb))
typrint(np.exp(1j * Xa * Xb) ** 5)
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*X(q(1)))**-0.6366197723675815
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*X(q(1)))**0.8169011381620928
typrint(np.exp(2j * Xa * Zb))
typrint(np.exp(3j * Xa * Zb))
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> (X(q(0))*Z(q(1)))**0.726760455264837
<class 'cirq.ops.pauli_string_phasor.PauliStringPhasor'> exp(iπ0.954929658551372*X(q(0))*Z(q(1)))

PauliStringPhasor has additional, more general use patterns than just those presented here. See the reference page for cirq.PauliStringPhasor for specifics about the class. The docstrings for it discuss "phasing an eigenstate", which is a strategy for efficiently exponentiating an alternative but equivalent representation of Pauli strings. See this post for more details.

Exponentials of commuting Pauli operators as cirq.PauliSumExponentials.

cirq.PauliStringPhasor only supports the exponentiation of PauliStrings, but doesn't work for sums with PauliSum. The reason for this is that only some Pauli sum expressions can be exponentiated: expressions where the operators commute.

Cirq expresses this type of expression with cirq.PauliSumExponential. For the sake of clarity, these expressions are only ever created with the class initializer, instead of with the exponentiation operator ** or with np.exp. The initializer takes:

  • A PauliSum object or something that can be instantiated into one.
  • An (optional) exponent.

The result is an expression that represents exp(1j * exponent * pauli_sum).

# Instantiated with PauliStrings.
typrint(cirq.PauliSumExponential(Xa))
typrint(cirq.PauliSumExponential(Xa * Zb, exponent=3))
# Instantiated with PauliSums.
typrint(cirq.PauliSumExponential(Xa + Zb, exponent=3 + 5j))
typrint(cirq.PauliSumExponential(2 * (3 * Xa + 4 * Zb), exponent=3))
# Doesn't work with other bases than e.
try:
    typrint(6 ** (Xa + Xb))
except TypeError as e:
    print(e)
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * 1 * (1.000*X(q(0))))
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * 3 * (1.000*X(q(0))*Z(q(1))))
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * (3+5j) * (1.000*X(q(0))+1.000*Z(q(1))))
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * 3 * (6.000*X(q(0))+8.000*Z(q(1))))
unsupported operand type(s) for ** or pow(): 'int' and 'PauliSum'
typrint(cirq.PauliSumExponential(Xa * Zb, exponent=3))
typrint(cirq.PauliSumExponential(Xa * Zb, exponent=3) ** 5)
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * 3 * (1.000*X(q(0))*Z(q(1))))
<class 'cirq.ops.pauli_sum_exponential.PauliSumExponential'> exp(j * 15 * (1.000*X(q(0))*Z(q(1))))

Using PauliString as observables

All of the PauliStrings and compositions thereof are still cirq.Operations, meaning they can be used in circuits like any other Operation. However, they have unique ability to be used as observables during measurement. Observables are typically some sort of measurable property (of a quantum state).

"Measuring an observable" usually amounts to applying the observable to the final quantum state and measuring in the standard computational basis, but is representative of measuring whether that observable's property holds. It is equivalent to (in many cases) or conceptually similar to a change of basis, meaning "measuring an observable" is roughly the same as measuring in some basis other than the computational one.

Measure a single observable

cirq.measure_single_paulistring serves to package the observable into a cirq.MeasurementGate-line object, a cirq.PauliMeasurementgate, which applies the observable as if it were an operation, and then measures all of the qubits that appear in the observable.

There is one critical, additional step that measure_single_paulistring performs beyond simply applying the observable and measuring. It identifies the eigenstates of the observable and returns a single bit of information, whether the final state of the qubits in question is in one of those eigenstates (0) or not (1). For example, the eigenstates of the ZZ observable (aka. cirq.Z(a) * cirq.Z(b)), are \(|00\rangle\) and \(|11\rangle\) for the two qubits a and b.

sim = cirq.Simulator()
observable = Za * Zb
# A PauliMeasurementGate.
typrint(cirq.measure_single_paulistring(observable, key='m'))
# Measure the observable on the bell state.
circuit = cirq.Circuit(
    cirq.H(a), cirq.CNOT(a, b), cirq.measure_single_paulistring(observable, key='m')
)
print(f"dirac notation: {sim.simulate(circuit).dirac_notation()}")
print(f"measurements: {sim.run(circuit, repetitions=100).histogram(key='m')}")
<class 'cirq.ops.gate_operation.GateOperation'> cirq.PauliMeasurementGate(cirq.DensePauliString('ZZ', coefficient=(1+0j)), cirq.MeasurementKey(name='m'))(q(0), q(1))
dirac notation: 0.71|00⟩ + 0.71|11⟩
measurements: Counter({0: 100})

A single simulation of the circuit produces a result that is not in the computational basis, as seen in the dirac notation printout. This also shows that the qubits can only be in one of the two mentioned eigenstates of ZZ, \(|00\rangle\) or \(|11\rangle\).

The value of the measurement itself is always 0, because the states that are really measured under the hood, \(|00\rangle\) and \(|11\rangle\), are eigenstates of the observable.

The difference in behavior can be seen by appending the observable and measuring separately:

# The same circuit, but applying the observable as an operator and measuring separately.
circuit = cirq.Circuit(cirq.H(a), cirq.CNOT(a, b), observable, cirq.measure([a, b], key='m'))
print(f"dirac notation: {sim.simulate(circuit).dirac_notation()}")
print(f"measurements: {sim.run(circuit, repetitions=100).histogram(key='m')}")
dirac notation: |00⟩
measurements: Counter({3: 51, 0: 49})

A single simulation of the circuit now produces a dirac notation state in the computational basis, but it is representative of only one of the possible two states to measure. Additionally, but \(|00\rangle\) and \(|11\rangle\) are recorded in the measurements (as 0 and 3). It would take you an extra step to determine which of those are eigenstates of ZZ, and see that the observable holds in all cases. cirq.measure_single_paulistring takes care of this for you.

For more information on eigenstates, see Quantum Theory, Groups and Representations:An Introduction, by Peter Woit.

Estimate expectation values of a PauliSum observable

As mentioned, cirq.measure_single_paulistring only works for PauliStrings. In order to "measure" a linear combination of Pauli operators, Cirq provides the cirq.PauliSumCollector class to estimate a PauliSum. This class provides a utility feature to sample a circuit in parallel, measure each PauliString observable term in the sum, and add them back together in a weighted sum based on their coefficients. Note that there may be more efficient case-specific ways to do this.

# A helper function to create a collector, collet, and estimate energy.
def show_energy(circuit, observable):
    collector = cirq.PauliSumCollector(circuit=circuit, observable=observable, samples_per_term=100)
    collector.collect(sampler=cirq.Simulator())
    energy = collector.estimated_energy()
    typrint(energy)


circuit = cirq.Circuit(cirq.H(a), cirq.CNOT(a, b))
observable = Za * Zb
show_energy(circuit, observable)
observable = 4 * Xa
show_energy(circuit, observable)
observable = Za * Zb + 4 * Xa
show_energy(circuit, observable)
<class 'float'> 1.0
<class 'float'> -0.16
<class 'float'> 0.84

Measure a sequence of observables in a circuit

If you need to measure many different PauliStrings (not PauliSums), for a circuit, cirq.measure_observables may fit your needs. It serves to estimate each observable in a provided iterable by computing the mean and variance over a number of repetitions defined by the stopping_criteria argument. In the example below, this stopping criteria is fixed at 50,000 repetitions.

The function also supports the following optional arguments, which expand its functionality:

  • circuit_sweep: A parameter sweep as in Parameter Sweeps
  • readout_calibrations: An input to make use of previously-collected readout error data.
  • grouper: A strategy to group the observables so multiple observables can be measured in the same run (uses default greedy strategy).
  • readout_symmetrization: Applies a bit flip after half of the runs to make readout error seem symmetric
from cirq.work.observable_measurement import measure_observables, RepetitionsStoppingCriteria

observables = [Za * Zb, 4 * Xa]
results = measure_observables(
    circuit, observables, cirq.Simulator(), stopping_criteria=RepetitionsStoppingCriteria(100)
)
# Print the mean and variance measured for each observable
for result in results:
    print(result.observable, result.mean, result.variance)
Z(q(0))*Z(q(1)) 1.0 0.0
(4+0j)*X(q(0)) 0.24 0.16103434343434336

Summary

Building PauliStrings and more:

  • The Pauli operatiors cirq.X, cirq.Y and cirq.Z can be combined into cirq.PauliStrings with * and cirq.PauliSums with +.
  • The * operator is used simultaneously for scalar multiplication, composition and tensor product, but only the order of operators applied to the same qubit matters.
  • PauliStrings can be exponentiated with int**PauliString or np.exp(PauliString) to produce a cirq.PauliStringPhasor, and PauliSums can be exponentiated when they commute with PauliSumExponential(PauliSum, exponent) to produce a cirq.PauliSumExponential.

Measuring observables:

  • Measure a single PauliString term with cirq.measure_single_paulistring, which takes care of determining eigenstates for you.
  • Estimate PauliSum expressions by calculating the weighted average of each term with cirq.PauliSumCollector
  • Efficiently estimate the mean and variance of many different PauliString observables for a single circuit with the flexible and powerful cirq.measure_observables.