Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Measurements

Product states

A propagated sum is read out with expectation(state), which evaluates ⟨s|A|s⟩ for a single-qubit product state |s⟩ in one masked pass over the terms. The state is either a uniform shorthand — "x+", "y+", "z+", the +1 eigenstate of that Pauli on every qubit — or a per-qubit label string of exactly num_qubits characters from 0 1 + - r l, qiskit’s Statevector.from_label alphabet, with 0/1 the Z eigenstates, +/- the X ones and r/l the Y ones. The default is "x+", which is the state most pages on this site read against.

from paulistrings import Circuit, PauliSum, truncation

observable = PauliSum.from_strings(
    {"XIII": 0.25, "IXII": 0.25, "IIXI": 0.25, "IIIX": 0.25}, num_qubits=4
)
circuit = Circuit(4)
circuit.rz(0.3, 0)
evolved = observable.propagate(circuit, truncation.coeff(1e-10), direction="heisenberg")

print(evolved.expectation("x+"))        # |++++>
print(evolved.expectation("z+").real)   # |0000>
print(evolved.expectation("0+1r").real) # |0>|+>|1>|+i> per qubit
(0.9888341222814014+0j)
0.0
0.25

The return type is Python complex; take .real for a Hermitian observable, and treat a non-negligible imaginary part as a sign the sum is not Hermitian rather than as noise to discard. A label of the wrong length, or a character outside the alphabet, is a ValueError that restates the rule.

The pass is cheap next to the propagation that produced the sum, and the state is the thing that is cheap to vary: one propagation, any number of product states. The observable is not: the propagated object is the observable, so a second observable is a second propagation, not a second pass. The label table is Measurement; First propagation is this readout on a full run.

Which state the label denotes

expectation does not know which direction produced its sum, so the meaning of the label is fixed by the direction you passed to propagate.

directionevolved.expectation(s) equalsso s denotes
"heisenberg"⟨s|U†OU|s⟩, the expectation of O in U|s⟩the input state, the one the circuit acts on
"forward"⟨s|UOU†|s⟩, the expectation of O in U†|s⟩the state at the circuit’s output end

Swapping the two is silent: both return a number of the right size and type, and nothing raises. One rx rotation on Z, read against the Y eigenstate "r", shows the two answers differing by a sign:

z0 = PauliSum.from_strings({"Z": 1.0}, num_qubits=1)
rotate = Circuit(1)
rotate.rx(0.7, 0)
print(z0.propagate(rotate, direction="heisenberg").expectation("r").real)
print(z0.propagate(rotate, direction="forward").expectation("r").real)
0.644217687237691
-0.644217687237691

Most physics questions — a state prepared, a circuit run, an observable measured — are the Heisenberg row, and the label is the prepared state. The worked example with both directions on the same circuit is Direction, and the table with the engine’s view of each direction is Direction semantics.

Stabilizer states

A product-state label cannot express an entangled state; expectation_stabilizer(generators) reads the sum against any stabilizer state — Bell, GHZ, cluster, the output of any Clifford circuit — given as a list of exactly num_qubits signed generators. Each is a Pauli string in the from_strings alphabet with an optional sign prefix, "+XX", "-ZZ", or bare "ZZ" for +; the generators must pairwise commute and be independent, and anything else is a ValueError.

bell = PauliSum.from_strings({"YY": 1.0}, num_qubits=2)
print(bell.expectation_stabilizer(["XX", "ZZ"]).real)     # |Φ+>, YY = -XX·ZZ
print(bell.expectation_stabilizer(["-XX", "+ZZ"]).real)   # flip one sign, flip the state
-1.0
1.0

Rather than write generators by hand, interop.stabilizers_from_stim reads them from a Clifford circuit’s output state — a stim circuit, program text, tableau or tableau simulator — in the sign convention this library shares with stim. It imports stim lazily on first call — install the examples or bench extra from Installation to use it. num_qubits= pads the register on the right with +Z generators, leaving those qubits in |0⟩, so a stim circuit on a few qubits can be read against a wider sum.

import stim
from paulistrings import interop

generators = interop.stabilizers_from_stim(stim.Circuit("H 0\nCNOT 0 1"))
print(generators, bell.expectation_stabilizer(generators).real)
print(interop.stabilizers_from_stim(stim.Circuit("H 0\nCNOT 0 1"), num_qubits=3))
['+XX', '+ZZ'] -1.0
['+XXI', '+ZZI', '+IIZ']

The cost is O(terms · num_qubits² / 64) after a one-off O(num_qubits³ / 64) reduction of the generators, so a few times a product-state pass per term and still negligible next to the propagation. This is the split B7 is built on: stim prepares a 36-qubit cluster state in polynomial time, and Pauli propagation handles the non-Clifford tail it cannot touch. Signatures and the generator rules are under Measurement.

Overlaps and the identity coefficient

Two sums on the same register have a Hilbert–Schmidt overlap a.overlap(b) = tr(a† b) / 2^n, which is Σ conj(a_P) b_P over the strings they share, since distinct Pauli strings are orthogonal. Its two everyday uses are the norm Σ|c|² as a.overlap(a), introduced under Inspecting a sum, and comparing two propagated results term for term without decoding either. identity_coefficient() is the coefficient of I…I, or 0 when the sum has no identity term, which equals tr(O) / 2^n and hence the expectation of O in the maximally mixed state.

truncated = observable.propagate(circuit, truncation.coeff(1e-1), direction="heisenberg")
print(evolved.overlap(evolved).real, truncated.overlap(evolved).real)
print(evolved.identity_coefficient())
0.25 0.24454173796592743
0j

A num_qubits mismatch between the two sums is a ValueError; there is no implicit padding. Both signatures are under Measurement.

Echo read-outs

An echo experiment perturbs the evolved observable A by V = ⊗_q exp(-iδ G_q) and reads 2^-n tr(A V† A V). The library has no echo read-out of its own: to_arrays() hands the propagated sum to numpy, and Showcase B8 computes the exact echo and its diagonal approximation there, per rank under MPI.

From a number to a result

A single expectation value at a single cutoff is a number, not a result. The truncation that made the sum tractable deleted terms carrying signs, so the value can sit on either side of the truth and need not move monotonically as the cutoff tightens — No variational bound is the statement and the measured cases. What turns the number into a result is Validating a result: sweep the cutoff, read the retained norm alongside, apply the plateau criterion, and score against an exact reference wherever one is affordable.

See it in use