Skip to content

Estimate an expectation value

examples/estimate_expectation.py

Measure an observable’s expectation value. A sampler answers with counts; an estimator answers with the average of an operator on the state a circuit prepares — a single number per observable. Variational algorithms such as VQE and QAOA are optimisers wrapped around that number, so this is the shape those workloads submit in.

from qubitra import Observable, PauliTerm, QubitraClient
# No `bit` declaration and no `measure`: the observables are what gets read.
BELL = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[2] q;
h q[0];
cx q[0], q[1];
"""

An estimator PUB’s circuit carries no measurement instructions, because measuring collapses the state whose expectation value is being read. The observables do the reading.

# A sparse Pauli representation: a term names the qubits it acts on rather than padding to
# the circuit's width, so a two-qubit term on a 26-qubit circuit is still two characters.
ZZ = Observable(num_qubits=2, terms=(PauliTerm(pauli="ZZ", qubits=(0, 1), coefficient=1.0),))
ZI = Observable(num_qubits=2, terms=(PauliTerm(pauli="Z", qubits=(0,), coefficient=1.0),))

Each PauliTerm names the qubits it acts on: ZI is written as a single-character "Z" on qubit 0, and the identity on qubit 1 is implied. The form stays two characters however wide the circuit is.

The two answers say something checkable about entanglement. Z⊗Z is +1 on 00 and 11 and −1 on 01 and 10; a Bell pair is only ever found in the correlated outcomes, so ⟨ZZ⟩ = +1. Z⊗I reads the first qubit alone, which on its own is an even coin, so ⟨ZI⟩ = 0.

# One circuit, two observables — a single PUB. `values` comes back index-aligned
# with the list, so values[0] is ZZ and values[1] is ZI.
job = client.jobs.submit(
backend_id=backend_id,
circuit=BELL,
observables=[ZZ, ZI],
shots=SHOTS,
name="bell-pair expectation",
)

Passing observables makes the PUB an estimator. Both observables read the same prepared state, in one job.

result = client.jobs.result(job.id)
# `values` reads the first PUB, which is the only one here. A job of several PUBs
# reads `result.pubs[i]` instead.
values = result.values or ()
if len(values) != len(EXPECTED):
print(f"expected {len(EXPECTED)} values, got {len(values)}", file=sys.stderr)
return 1
print()
for (label, expected), value in zip(EXPECTED.items(), values, strict=True):
# An exact simulator computes the expectation from the state vector rather than
# sampling, so it lands on the analytic value; a noisy backend will not.
print(f" <{label}> {value:+.4f} (expected {expected:+.1f})")
return 0

values is index-aligned with the observables list: values[0] is ⟨ZZ⟩ and values[1] is ⟨ZI⟩.

Terminal window
QUBITRA_API_KEY=qpk_... python examples/estimate_expectation.py

Output from a live run, trimmed to the values:

<ZZ> +1.0000 (expected +1.0)
<ZI> +0.0000 (expected +0.0)

Perfect correlation with no local bias — the two numbers that define a Bell pair. The default backend computes expectations from the state vector, so the values land exactly on the analytic answers.

  • Primitives and PUBs — the estimator, observables, and how results align with what you submitted.