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 PauliString
s, as observables in measurement.
Pauli Operator Representations
Before starting on building PauliString
s, define:
- A tiny function to print an object with its type, to make clear the types being used later
- Some Pauli operations that the
PauliString
s 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 occurring.
This also means that PauliString
s 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.SingleQubitPauliStringGateOperation
s are themselves PauliString
s, 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 PauliString
s can be built with the *
operator. Be careful with this operator, as it can be used in three distinct ways:
- Scalar Multiplication:
complex * PauliString
produces aPauliString
with a complex scalar coefficient attached. - Composition:
PauliString(q(a)) * PauliString(q(a))
takes twoPauliString
s that are applied to the same qubit(s) and composes them together by standard matrix multiplication. - Tensor:
PauliString(q(a)) * PauliString(q(b))
takes twoPauliString
s 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.PauliString
s 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))
PauliString
s 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 thequbits
property, since it is anOperation
, and thewith_qubits
function can re-map thePauliString
to new qubits. - As an operation,
PauliString
has acirq.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.PauliSum
s
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 variables for each qubit.
Exponentials of Pauli operators as cirq.PauliStringPhasor
s
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 PauliString
s are supported by PauliStringPhasor
, not PauliSum
s.
# 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.PauliSumExponential
s.
cirq.PauliStringPhasor
only supports the exponentiation of PauliString
s, 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 PauliString
s and compositions thereof are still cirq.Operation
s, 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: |11⟩ measurements: Counter({3: 50, 0: 50})
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 PauliString
s. 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.24 <class 'float'> 1.24
Measure a sequence of observables in a circuit
If you need to measure many different PauliString
s (not PauliSum
s), 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.72 0.15637979797979795
Summary
Building PauliStrings
and more:
- The Pauli operatiors
cirq.X
,cirq.Y
andcirq.Z
can be combined intocirq.PauliString
s with*
andcirq.PauliSum
s 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. PauliString
s can be exponentiated withint**PauliString
ornp.exp(PauliString)
to produce acirq.PauliStringPhasor
, andPauliSums
can be exponentiated when they commute withPauliSumExponential(PauliSum, exponent)
to produce acirq.PauliSumExponential
.
Measuring observables:
- Measure a single
PauliString
term withcirq.measure_single_paulistring
, which takes care of determining eigenstates for you. - Estimate
PauliSum
expressions by calculating the weighted average of each term withcirq.PauliSumCollector
- Efficiently estimate the mean and variance of many different
PauliString
observables for a single circuit with the flexible and powerfulcirq.measure_observables
.