Executors#

Error mitigation methods can involve running many circuits. The mitiq.Executor class is a tool for efficiently running many circuits and storing the results.

from mitiq import Executor, Observable, PauliString, QPROGRAM, QuantumResult

The input function#

To instantiate an Executor, provide a function which either:

  1. Inputs a mitiq.QPROGRAM and outputs a mitiq.QuantumResult.

  2. Inputs a sequence of mitiq.QPROGRAMs and outputs a sequence of mitiq.QuantumResults.

The function must be annotated to tell Mitiq which type of QuantumResult it returns. Functions with no annotations are assumed to return floats.

A QPROGRAM is “something which a quantum computer inputs” and a QuantumResult is “something which a quantum computer outputs.” The latter is canonically a bitstring for real quantum hardware, but can be other objects for testing, e.g. a density matrix.

print(QPROGRAM)
typing.Union[cirq.circuits.circuit.Circuit, pyquil.quil.Program, qiskit.circuit.quantumcircuit.QuantumCircuit, braket.circuits.circuit.Circuit, pennylane.tape.tape.QuantumTape, qibo.models.circuit.Circuit]
print(QuantumResult)
typing.Union[float, mitiq.typing.MeasurementResult, numpy.ndarray]

Creating an Executor#

The function mitiq_cirq.compute_density_matrix inputs a Cirq circuit and returns a density matrix as an np.ndarray.

import inspect

from mitiq.interface import mitiq_cirq

print(inspect.getfullargspec(mitiq_cirq.compute_density_matrix).annotations["return"])
numpy.ndarray[typing.Any, numpy.dtype[numpy.complex64]]

We can instantiate an Executor with it as follows.

executor = Executor(mitiq_cirq.compute_density_matrix)

Running circuits#

When first created, the executor hasn’t been called yet and has no executed circuits and no computed results in memory.

print("Calls to executor:", executor.calls_to_executor)
print("\nExecuted circuits:\n", *executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *executor.quantum_results, sep="\n")
Calls to executor: 0

Executed circuits:


Quantum results:

To run a circuit of sequence of circuits, use the Executor.evaluate method.

import cirq

q = cirq.LineQubit(0)
circuit = cirq.Circuit(cirq.H.on(q))

obs = Observable(PauliString("Z"))

results = executor.evaluate(circuit, obs)
print("Results:", results)
Results: [0.010000020265579224]

The executor has now been called and has results in memory. Note that mitiq_cirq.compute_density_matrix simulates the circuit with noise by default, so the resulting state (density matrix) is noisy.

print("Calls to executor:", executor.calls_to_executor)
print("\nExecuted circuits:\n", *executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *executor.quantum_results, sep="\n")
Calls to executor: 1

Executed circuits:

0: ───H───

Quantum results:

[[0.505     +0.j 0.49749368+0.j]
 [0.49749368+0.j 0.49499997+0.j]]

The interface for running a sequence of circuits is the same.

circuits = [cirq.Circuit(pauli.on(q)) for pauli in (cirq.X, cirq.Y, cirq.Z)]

results = executor.evaluate(circuits, obs)
print("Results:", results)
Results: [-0.9800000190734863, -0.9800000190734863, 1.0]

In addition to the results of running these circuits we have the full history.

print("Calls to executor:", executor.calls_to_executor)
print("\nExecuted circuits:\n", *executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *executor.quantum_results, sep="\n")
Calls to executor: 4

Executed circuits:

0: ───H───
0: ───X───
0: ───Y───
0: ───Z───

Quantum results:

[[0.505     +0.j 0.49749368+0.j]
 [0.49749368+0.j 0.49499997+0.j]]
[[0.01+0.j 0.  +0.j]
 [0.  +0.j 0.99+0.j]]
[[0.01+0.j 0.  +0.j]
 [0.  +0.j 0.99+0.j]]
[[1.+0.j 0.+0.j]
 [0.+0.j 0.+0.j]]

Batched execution#

Notice in the above output that the executor has been called once for each circuit it has executed. This is because mitiq_cirq.compute_density_matrix inputs one circuit and outputs one QuantumResult.

Several quantum computing services allow running a sequence, or “batch,” of circuits at once. This is important for error mitigation when running many circuits to speed up the computation.

To define a batched executor, annotate it with Sequence[T], List[T], Tuple[T], or Iterable[T] where T is a QuantumResult. Here is an example:

from typing import List

import numpy as np


def batch_compute_density_matrix(circuits: List[cirq.Circuit]) -> List[np.ndarray]:
    return [mitiq_cirq.compute_density_matrix(circuit) for circuit in circuits]


batched_executor = Executor(batch_compute_density_matrix, max_batch_size=10)

You can check if Mitiq detected the ability to batch as follows.

batched_executor.can_batch
True

Now when running a batch of circuits, the executor will be called as few times as possible.

circuits = [cirq.Circuit(pauli.on(q)) for pauli in (cirq.X, cirq.Y, cirq.Z)]

results = batched_executor.evaluate(circuits, obs)

print("Results:", results)
print("\nCalls to executor:", batched_executor.calls_to_executor)
print("\nExecuted circuits:\n", *batched_executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *batched_executor.quantum_results, sep="\n")
Results: [-0.9800000190734863, -0.9800000190734863, 1.0]

Calls to executor: 1

Executed circuits:

0: ───X───
0: ───Y───
0: ───Z───

Quantum results:

[[0.01+0.j 0.  +0.j]
 [0.  +0.j 0.99+0.j]]
[[0.01+0.j 0.  +0.j]
 [0.  +0.j 0.99+0.j]]
[[1.+0.j 0.+0.j]
 [0.+0.j 0.+0.j]]

Using Executors in error mitigation techniques#

You can provide a function or an Executor to the executor argument of error mitigation techniques, but providing an Executor is strongly recommended for seeing the history of results.

from mitiq import zne
batched_executor = Executor(batch_compute_density_matrix, max_batch_size=10)

zne_value = zne.execute_with_zne(
    cirq.Circuit(cirq.H.on(q) for _ in range(6)), 
    executor=batched_executor, 
    observable=obs
)
print(f"ZNE value: {zne_value :g}")
ZNE value: 0.999972
print("Calls to executor:", batched_executor.calls_to_executor)
print("\nExecuted circuits:\n", *batched_executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *batched_executor.quantum_results, sep="\n")
Calls to executor: 1

Executed circuits:

0: ───H───H───H───H───H───H───
0: ───H───H───H───H───H───H───H───H───H───H───H───H───
0: ───H───H───H───H───H───H───H───H───H───H───H───H───H───H───H───H───H───H───

Quantum results:

[[0.992667  +0.j 0.01470262+0.j]
 [0.01470265+0.j 0.00733284+0.j]]
[[0.98565793+0.j 0.02875498+0.j]
 [0.02875507+0.j 0.01434151+0.j]]
[[0.97895914+0.j 0.0421859 +0.j]
 [0.04218622+0.j 0.02104016+0.j]]

Defining an Executor that returns measurement outcomes (bitstrings)#

In the previous examples we have shown executors that return the density matrix of the final state. This is possible only for classical simulations. The typical result of a real quantum computation is instead a list of bitstrings corresponding to the (“0” or “1”) outcomes obtained when measuring each qubit in the computational basis. In Mitiq this type of quantum backend is captured by an Executor that returns a MeasurementResult object.

For example, here is an example of a Cirq executor function that returns raw measurement outcomes:

from mitiq import MeasurementResult

def noisy_sampler(circuit, noise_level=0.1, shots=1000) -> MeasurementResult:
    circuit_to_run = circuit.with_noise(cirq.depolarize(noise_level))
    simulator = cirq.DensityMatrixSimulator()
    result = simulator.run(circuit_to_run, repetitions=shots)
    bitstrings = np.column_stack(list(result.measurements.values()))
    qubit_indices = tuple(
            int(q[2:-1])  # Extract index from "q(index)" string
            for k in result.measurements.keys()
            for q in k.split(",")
    )
    return MeasurementResult(bitstrings, qubit_indices)
# Circuit with measurements to test the noisy_sampler function
circuit_with_measurements = circuit.copy()
circuit_with_measurements.append(cirq.measure(*circuit.all_qubits()))

print("Circuit to execute:", circuit_with_measurements)
noisy_sampler(circuit_with_measurements)
Circuit to execute:
 0: ───H───M───
MeasurementResult: {'nqubits': 1, 'qubit_indices': (0,), 'shots': 1000, 'counts': {'1': 484, '0': 516}}

The rest of the Mitiq workflow is the same as in the case of a density matrix executor. For example:

executor = Executor(noisy_sampler)
obs = Observable(PauliString("X"))
results = executor.evaluate(circuit, obs)

print("Results:", results)
print("Calls to executor:", executor.calls_to_executor)
print("\nExecuted circuits:\n", *executor.executed_circuits, sep="\n")
print("\nQuantum results:\n", *executor.quantum_results, sep="\n")
Results: [(0.752+0j)]
Calls to executor: 1

Executed circuits:

0: ───H───Y^-0.5───M───

Quantum results:

MeasurementResult: {'nqubits': 1, 'qubit_indices': (0,), 'shots': 1000, 'counts': {'0': 876, '1': 124}}