Defining observables#

Observables are given the way Qiskit’s estimator takes them, written on the qubits of the original uncut circuit. get_experiment_circuits(), run(), run_cut_circuit() and CutEstimator all take them the same way.

from QCut import CutEstimator
from qiskit.quantum_info import SparsePauliOp, SparseObservable

sim = AerSimulator()
fake = IQMFakeAdonis()

Pauli strings#

A Pauli label spans the whole uncut circuit, with qubit 0 rightmost as in Qiskit:

"IIIIZ"   # Z on qubit 0
"ZIIII"   # Z on qubit 4
"IIIXX"   # X on qubits 0 and 1

The values come back shaped like the observables, so a list of four gives four values and a nested list a nested array:

observables = [["IIIIZ", "IIIZI"], ["IIZII", "IZIII"]]   # values of shape (2, 2)

SparsePauliOp#

A weighted sum of Paulis. from_sparse_list() names only the qubits each term acts on, which is easier than writing out labels on a wide circuit:

op = SparsePauliOp.from_sparse_list(
    [("ZZ", [0, 1], 0.5), ("XX", [3, 4], 0.5)], num_qubits=5
)

A {label: coefficient} dict is the same thing written out, here {"IIIZZ": 0.5, "XXIII": 0.5}.

SparseObservable#

On Qiskit 2.1 or newer, a SparseObservable can also carry projectors: 0, 1, +, -, r and l. QCut expands each into Paulis, \(|0\rangle\langle 0| = (I + Z)/2\) and so on, so the probability that qubits 0 and 1 both read 0 is:

so = SparseObservable.from_sparse_list([("00", [0, 1], 1.0)], num_qubits=5)

A term on \(k\) projectors becomes \(2^k\) Pauli terms, all measured in one setting, up to 16 projectors per term. For the whole distribution over many qubits, use qubits or CutQuasiSampler instead, see probability distributions.

All three in one run, on the cut circuit from Cutting circuits:

job = CutEstimator(sim).run(cut_circuit, ["IIIIZ", op, so], shots=8192)
print(job.expectation_values(0))

[0.9247 0.8691 0.8501], against exact values of [0.9211 0.8642 0.8476].

No layout to apply#

With Qiskit’s own estimator, an observable for a transpiled circuit has to be moved onto the physical qubits the transpiler chose, with observable.apply_layout(layout). QCut does not need this. Observables stay on the uncut circuit’s qubits throughout: QCut keeps track of which subcircuit each qubit went to and, once transpiled, which physical qubit holds it, and measures through both. The same observables work before and after transpilation:

transpiled = ck.transpile_subcircuits(cut_circuit, fake)
job = CutEstimator(fake).run(transpiled, ["IIIIZ", op, so])

Do not apply a layout yourself. On a device larger than the circuit, the observable then spans the device rather than the circuit and is refused:

ValueError: observable 'IIIIIIIIIIIZIIIIZIII' spans 20 qubits, but every observable must match the number of qubits in the original uncut circuit (5). If a transpiled layout was applied to it with apply_layout(), pass it as written for the uncut circuit instead. QCut handles the layout itself.

On a device the same size as the circuit it is accepted and silently measures other qubits. On IQMFakeAdonis, the XX on qubits 3 and 4 above became an XX on qubits 2 and 4.