Primitives#

For running several cut circuits in one call, QCut has two primitives shaped like FiQCI-EMS’s estimator and sampler: CutEstimator gives expectation values and CutQuasiSampler quasi-probability distributions. Each is made once for a backend, and its run() submits every circuit and hands back a job at once, without waiting for results.

They take cut circuits, as get_locations_and_subcircuits() or find_cuts() make them, and each runs with the options it was made with.

Estimating expectation values#

Continuing the example from Cutting circuits, here with two observables on the hand-cut circuit and a second, unrelated circuit whose cuts are found automatically:

from QCut import CutEstimator

ghz = QuantumCircuit(6)
ghz.h(0)
for qubit in range(5):
    ghz.cx(qubit, qubit + 1)
found_ghz = find_cuts(ghz, options=CutOptions(finder_num_partitions=2))

estimator = CutEstimator(sim)
job = estimator.run(
    [cut_circuit, found_ghz], [["IIIIZ", "IIZII"], "ZZZZZZ"], shots=4096
)
print(job.result().expectation_values())

[array([0.9227, 0.3818]), array(1.0001)]

The observables pair with the circuits the way FiQCI’s estimator pairs them:

  • One circuit takes its observables exactly as given.

  • With a list of circuits, a list of observables has one entry per circuit, each an observable or a list of them, as above.

  • Anything else is a single observable used for every circuit.

Each entry of the result belongs to one input circuit, in the order given. result[i].expectation_values is shaped like that circuit’s observables, result[i].standard_errors bounds their shot noise as estimate_standard_errors() does, and result[i].raw is the RawResult they were estimated from. job.expectation_values(i), job.standard_errors(i), job.raw_results() are shortcuts for them.

Reconstructing distributions#

Continuing the example from Cutting circuits, here with the same marked_circuit. The sampler reconstructs the distribution over the qubits the circuit measured at its end. Measure before cutting: splitting strips the final measurements, and the cut circuit remembers where they were.

from QCut import CutQuasiSampler

measured = marked_circuit.copy()
measured.measure_all()

sampler = CutQuasiSampler(sim)
job = sampler.run(ck.get_locations_and_subcircuits(measured), shots=4096)
distribution = job.result()[0]

print(distribution.quasi_probabilities.top(4))

{'10000': 0.2252, '00000': 0.1881, '01000': 0.1184, '11000': 0.1178}

The bitstrings follow Qiskit’s convention, classical bit zero rightmost, whatever qubit it was measured from. Every classical bit has to be measured into exactly once.

quasi_probabilities is a QuasiProbabilities, so everything in probability distributions applies, and it never expands the whole distribution unless asked. quasi_dist() gives a Qiskit QuasiDistribution instead, which does expand all 2**n outcomes unless top limits it:

distribution.quasi_dist(top=3)

{16: 0.2252, 0: 0.1881, 8: 0.1184}

result[i].standard_error bounds the shot noise of each quasi-probability, see probability distributions, and job.standard_errors(i) is a shortcut for it. The raw results are accessible from the job as job.raw_results().

Binding parameters#

Both primitives take parameter_values, a {parameter or name: value} dict bound into the cut circuit before its experiment is built. It pairs with the circuits the way the observables do. A sweep is the same circuit listed once per set of values:

thetas = np.linspace(0, np.pi, 5)
job = estimator.run(
    [cut_circuit] * len(thetas),
    "ZZZZZ",
    parameter_values=[{theta: t} for t in thetas],
)

Each entry of the result is one point of the sweep. A circuit with a parameter left unbound is refused before anything is submitted.

Running on a device#

The primitives do not transpile, much as Qiskit’s primitives take circuits already written for their target. On a backend with a coupling map, transpile each cut circuit for that same backend first:

fake = IQMFakeAdonis()

transpiled = [ck.transpile_subcircuits(c, fake) for c in [cut_circuit, found]]
job = CutEstimator(fake).run(transpiled, observables)

A cut circuit transpiled for a different backend object, or not transpiled at all, is refused there, before anything is submitted. A backend without a coupling map, such as an ideal simulator or a V2 sampler, runs the subcircuits as they are.

What a run costs#

estimate() takes the same arguments as run() and submits nothing:

print(estimator.estimate([cut_circuit, found_ghz], [["IIIIZ", "IIZII"], "ZZZZZZ"], shots=4096))

Experiment will run 41 circuits in 2 jobs with a total of 167936 shots. See the returned object for the breakdown.

The totals cover the whole run, and indexing gives one circuit’s share, as a RunEstimate. It is exact wherever estimate_run() is.

The job#

The job answers for every circuit at once. job_ids(), status(), cancel() and wait_for_final_state() work as on a Qiskit job, statuses() and partial_results() give one entry per circuit, and job.jobs holds each circuit’s own QCutJob. It also exposes the raw results of the executed job as job.raw_results()

Each circuit is submitted in batches of its own, so circuits from different inputs never share a backend job.