Qdislib — 06 · Sampling the output distribution

So far a cut gave back one expectation value <O>. Sometimes you want the whole output distribution {bitstring: probability} — to read out an image (application 1) or inspect the state. gate_cutting_sampling and wire_cutting_sampling reconstruct that full distribution from the pieces.

The trade-off: rebuilding a distribution carries a quasiprobability overhead, so it needs more shots than a plain expectation value for the same accuracy.

[ ]:
from qibo import models, gates


def example_circuit():
    """A small 5-qubit circuit: cuttable (CZ chain), non-trivial <ZZZZZ> ~= 0.44."""
    circuit = models.Circuit(5)
    circuit.add(gates.RY(0, 0.8))
    circuit.add(gates.CZ(0, 1))
    circuit.add(gates.RY(1, 0.5))
    circuit.add(gates.CZ(1, 2))
    circuit.add(gates.RY(2, 0.6))
    circuit.add(gates.CZ(2, 3))
    circuit.add(gates.RY(3, 0.4))
    circuit.add(gates.CZ(3, 4))
    circuit.add(gates.RX(4, 0.3))
    return circuit


circuit = example_circuit()
print(circuit.draw())
[ ]:
import numpy as np
import qibo
import Qdislib as qd

qibo.set_backend('numpy')


def exact_distribution(circuit):
    """The true output distribution |amplitude|**2 (qubit-0-first bitstrings)."""
    probs = np.abs(circuit().state()) ** 2
    n = circuit.nqubits
    return {format(i, f'0{n}b'): float(probs[i]) for i in range(2 ** n)}


def tvd(a, b):
    """Total-variation distance between two {bitstring: prob} distributions."""
    return 0.5 * sum(abs(a.get(k, 0.0) - b.get(k, 0.0)) for k in set(a) | set(b))

Expectation value vs full distribution

gate_cutting returns a single number; gate_cutting_sampling returns the whole distribution over output bitstrings.

[ ]:
circuit = example_circuit()
cut = qd.find_cut(circuit, gate_cut=True, wire_cut=False)

value = qd.gate_cutting(example_circuit(), cut, observables='ZZZZZ', shots=20000)
print('expectation <ZZZZZ>:', round(value, 3))

dist = qd.gate_cutting_sampling(example_circuit(), cut, shots=200_000, software='qibo')
top = sorted(dist.items(), key=lambda kv: -kv[1])[:4]
print('top bitstrings:   ', [(b, round(p, 3)) for b, p in top])

It matches the exact distribution

Reconstructing from the pieces reproduces the full statevector distribution, up to shot noise — the total-variation distance is ~0.

[ ]:
exact = exact_distribution(example_circuit())
print('gate-cut sampling TVD vs exact:', round(tvd(exact, dist), 3))

The three-step sampling workflow

Sampling has the same three-step form — generate the sampling subcircuits, sample_subcircuit each (a parallel COMPSs task under PyCOMPSs), then reconstruct the distribution:

[ ]:
subs = qd.gate_cutting_sampling_subcircuits(example_circuit(), cut, software='qibo')
sampled = [qd.sample_subcircuit(s, shots=200_000) for s in subs]
print('3-step TVD vs exact:', round(tvd(exact, subs.reconstruct(sampled)), 3))

Wire-cut sampling

Wire cuts reconstruct the distribution too — wire_cutting_sampling (and its wire_cutting_sampling_subcircuits three-step form):

[ ]:
wcut = qd.find_cut(example_circuit(), gate_cut=False, wire_cut=True)
wdist = qd.wire_cutting_sampling(example_circuit(), wcut, shots=200_000, software='qibo')
print('wire-cut sampling TVD vs exact:', round(tvd(exact, wdist), 3))

🔧 Your turn

Fewer shots ⇒ a noisier distribution. Re-run with shots=20_000 and watch the TVD grow — the quasiprobability overhead is why sampling wants more shots than an expectation value.

[ ]:
noisy = qd.gate_cutting_sampling(example_circuit(), cut, shots=20_000, software='qibo')
print('TVD at 20k shots:', round(tvd(exact, noisy), 3))

Next: 07 · Caching & performance — reuse repeated subcircuits with the semantic cache.