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.