diff --git a/docs/source/guide/executors.md b/docs/source/guide/executors.md index 11bb2ee9e8..a578e60cce 100644 --- a/docs/source/guide/executors.md +++ b/docs/source/guide/executors.md @@ -30,7 +30,9 @@ 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.QPROGRAM`s and outputs a sequence of `mitiq.QuantumResult`s. -**The function must be [annotated](https://peps.python.org/pep-3107/) to tell Mitiq which type of `QuantumResult` it returns. Functions with no annotations are assumed to return `float`s.** +```{warning} +To avoid confusion and invalid results, the executor function must be [annotated](https://peps.python.org/pep-3107/) to tell Mitiq which type of `QuantumResult` it returns. Functions without annotations are assumed to return `float`s. +``` 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. diff --git a/docs/source/guide/observables.md b/docs/source/guide/observables.md index 6979119408..3978265c75 100644 --- a/docs/source/guide/observables.md +++ b/docs/source/guide/observables.md @@ -128,8 +128,8 @@ obs.expectation(circuit, execute=mitiq_cirq.sample_bitstrings) In error mitigation techniques, you can provide an observable to specify the expectation value to mitigate. -```{admonition} Note: -When specifying an `Observable`, you must ensure that the return type of the executor function is `MeasurementResultLike` or `DensityMatrixLike`. +```{warning} +As note in the [executor documentation](./executors.md#the-input-function), the executor must be annotated with the appropriate type hinting for the return type. Additionally, when specifying an `Observable`, you must ensure that the return type of the executor function is `MeasurementResultLike` or `DensityMatrixLike`. ``` ```{code-cell} ipython3 diff --git a/mitiq/executor/executor.py b/mitiq/executor/executor.py index 7ace20c78c..1c965ce365 100644 --- a/mitiq/executor/executor.py +++ b/mitiq/executor/executor.py @@ -40,6 +40,8 @@ FloatLike = [ None, # Untyped executors are assumed to return floats. float, + np.float32, + np.float64, Iterable[float], List[float], Sequence[float], @@ -149,6 +151,29 @@ def evaluate( "Expected observable to be hermitian. Continue with caution." ) + # Check executor and observable compatability with type hinting + # If FloatLike is specified as a return and observable is used + if self._executor_return_type in FloatLike and observable is not None: + if self._executor_return_type is not None: + raise ValueError( + "When using a float like result, measurements should be " + "included manually and an observable should not be " + "used." + ) + elif observable is None: + # Type hinted as DensityMatrixLik but no observable is set + if self._executor_return_type in DensityMatrixLike: + raise ValueError( + "When using a density matrix like result, an observable " + "is required." + ) + # Type hinted as MeasurementResulteLike but no observable is set + elif self._executor_return_type in MeasurementResultLike: + raise ValueError( + "When using a measurement, or bitstring, like result, an " + "observable is required." + ) + # Get all required circuits to run. if ( observable is not None @@ -160,30 +185,43 @@ def evaluate( for circuit_with_measurements in observable.measure_in(circuit) ] result_step = observable.ngroups - elif ( - observable is not None - and self._executor_return_type not in MeasurementResultLike - and self._executor_return_type not in DensityMatrixLike - ): - raise ValueError( - """Executor and observable are not compatible. Executors - returning expectation values as float must be used with - observable=None""" - ) else: all_circuits = circuits result_step = 1 # Run all required circuits. - all_results = self.run(all_circuits, force_run_all, **kwargs) + try: + all_results = self.run(all_circuits, force_run_all, **kwargs) + except Exception: + if observable is not None and self._executor_return_type is None: + all_circuits = [ + circuit_with_measurements + for circuit in circuits + for circuit_with_measurements in observable.measure_in( + circuit + ) + ] + all_results = self.run(all_circuits, force_run_all, **kwargs) + else: + raise + + # check returned type + manual_return_type = None + if all_results: + if isinstance(all_results[0], Sequence): + manual_return_type = type(all_results[0][0]) + elif isinstance(all_results[0], Iterable): + manual_return_type = type(next(iter(all_results[0]))) + else: + manual_return_type = type(all_results[0]) # Parse the results. - if self._executor_return_type in FloatLike: + if manual_return_type in FloatLike: results = np.real_if_close( cast(Sequence[float], all_results) ).tolist() - elif self._executor_return_type in DensityMatrixLike: + elif manual_return_type in DensityMatrixLike: observable = cast(Observable, observable) all_results = cast(List[npt.NDArray[np.complex64]], all_results) results = [ @@ -191,7 +229,7 @@ def evaluate( for density_matrix in all_results ] - elif self._executor_return_type in MeasurementResultLike: + elif manual_return_type in MeasurementResultLike: observable = cast(Observable, observable) all_results = cast(List[MeasurementResult], all_results) results = [ @@ -203,8 +241,8 @@ def evaluate( else: raise ValueError( - f"Could not parse executed results from executor with type" - f" {self._executor_return_type}." + f"Could not parse executed results from executor with type " + f"{manual_return_type}." ) return results diff --git a/mitiq/executor/tests/test_executor.py b/mitiq/executor/tests/test_executor.py index f38006d03e..aa18697608 100644 --- a/mitiq/executor/tests/test_executor.py +++ b/mitiq/executor/tests/test_executor.py @@ -12,6 +12,7 @@ import numpy as np import pyquil import pytest +from qiskit import QuantumCircuit from mitiq import MeasurementResult from mitiq.executor.executor import Executor @@ -37,7 +38,7 @@ def executor_batched_unique(circuits) -> List[float]: return [executor_serial_unique(circuit) for circuit in circuits] -def executor_serial_unique(circuit): +def executor_serial_unique(circuit) -> float: return float(len(circuit)) @@ -58,21 +59,29 @@ def executor_pyquil_batched(programs) -> List[float]: # Serial / batched executors which return measurements. -def executor_measurements(circuit) -> MeasurementResult: +def executor_measurements(circuit): + return sample_bitstrings(circuit, noise_level=(0,)) + + +def executor_measurements_typed(circuit) -> MeasurementResult: return sample_bitstrings(circuit, noise_level=(0,)) def executor_measurements_batched(circuits) -> List[MeasurementResult]: - return [executor_measurements(circuit) for circuit in circuits] + return [executor_measurements_typed(circuit) for circuit in circuits] # Serial / batched executors which return density matrices. -def executor_density_matrix(circuit) -> np.ndarray: +def executor_density_matrix(circuit): + return compute_density_matrix(circuit, noise_level=(0,)) + + +def executor_density_matrix_typed(circuit) -> np.ndarray: return compute_density_matrix(circuit, noise_level=(0,)) def executor_density_matrix_batched(circuits) -> List[np.ndarray]: - return [executor_density_matrix(circuit) for circuit in circuits] + return [executor_density_matrix_typed(circuit) for circuit in circuits] def test_executor_simple(): @@ -86,7 +95,7 @@ def test_executor_is_batched_executor(): assert Executor.is_batched_executor(executor_batched) assert not Executor.is_batched_executor(executor_serial_typed) assert not Executor.is_batched_executor(executor_serial) - assert not Executor.is_batched_executor(executor_measurements) + assert not Executor.is_batched_executor(executor_measurements_typed) assert Executor.is_batched_executor(executor_measurements_batched) @@ -96,7 +105,7 @@ def test_executor_non_hermitian_observable(): q = cirq.LineQubit(0) circuits = [cirq.Circuit(cirq.I.on(q)), cirq.Circuit(cirq.X.on(q))] - executor = Executor(executor_measurements) + executor = Executor(executor_measurements_typed) with pytest.warns(UserWarning, match="hermitian"): executor.evaluate(circuits, obs) @@ -199,12 +208,14 @@ def test_run_executor_preserves_order(s, b): ) def test_executor_evaluate_float(execute): q = cirq.LineQubit(0) - circuits = [cirq.Circuit(cirq.X(q)), cirq.Circuit(cirq.H(q), cirq.Z(q))] + circuits = [ + cirq.Circuit(cirq.X(q), cirq.M(q)), + cirq.Circuit(cirq.H(q), cirq.Z(q), cirq.M(q)), + ] executor = Executor(execute) - results = executor.evaluate(circuits) - assert np.allclose(results, [1, 2]) + executor.evaluate(circuits) if execute is executor_serial_unique: assert executor.calls_to_executor == 2 @@ -212,40 +223,11 @@ def test_executor_evaluate_float(execute): assert executor.calls_to_executor == 1 assert executor.executed_circuits == circuits - assert executor.quantum_results == [1, 2] + assert executor.quantum_results == [2, 3] @pytest.mark.parametrize( - "execute", - [ - executor_batched, - executor_batched_unique, - executor_serial_unique, - executor_serial_typed, - executor_serial, - executor_pyquil_batched, - ], -) -@pytest.mark.parametrize( - "obs", - [ - PauliString("X"), - PauliString("XZ"), - PauliString("Z"), - ], -) -def test_executor_observable_compatibility_check(execute, obs): - q = cirq.LineQubit(0) - circuits = [cirq.Circuit(cirq.X(q)), cirq.Circuit(cirq.H(q), cirq.Z(q))] - - executor = Executor(execute) - - with pytest.raises(ValueError, match="are not compatible"): - executor.evaluate(circuits, obs) - - -@pytest.mark.parametrize( - "execute", [executor_measurements, executor_measurements_batched] + "execute", [executor_measurements_typed, executor_measurements_batched] ) def test_executor_evaluate_measurements(execute): obs = Observable(PauliString("Z")) @@ -258,24 +240,24 @@ def test_executor_evaluate_measurements(execute): results = executor.evaluate(circuits, obs) assert np.allclose(results, [1, -1]) - if execute is executor_measurements: + if execute is executor_measurements_typed: assert executor.calls_to_executor == 2 else: assert executor.calls_to_executor == 1 assert executor.executed_circuits[0] == circuits[0] + cirq.measure(q) assert executor.executed_circuits[1] == circuits[1] + cirq.measure(q) - assert executor.quantum_results[0] == executor_measurements( + assert executor.quantum_results[0] == executor_measurements_typed( circuits[0] + cirq.measure(q) ) - assert executor.quantum_results[1] == executor_measurements( + assert executor.quantum_results[1] == executor_measurements_typed( circuits[1] + cirq.measure(q) ) assert len(executor.quantum_results) == len(circuits) @pytest.mark.parametrize( - "execute", [executor_density_matrix, executor_density_matrix_batched] + "execute", [executor_density_matrix_typed, executor_density_matrix_batched] ) def test_executor_evaluate_density_matrix(execute): obs = Observable(PauliString("Z")) @@ -288,16 +270,88 @@ def test_executor_evaluate_density_matrix(execute): results = executor.evaluate(circuits, obs) assert np.allclose(results, [1, -1]) - if execute is executor_density_matrix: + if execute is executor_density_matrix_typed: assert executor.calls_to_executor == 2 else: assert executor.calls_to_executor == 1 assert executor.executed_circuits == circuits assert np.allclose( - executor.quantum_results[0], executor_density_matrix(circuits[0]) + executor.quantum_results[0], executor_density_matrix_typed(circuits[0]) ) assert np.allclose( - executor.quantum_results[1], executor_density_matrix(circuits[1]) + executor.quantum_results[1], executor_density_matrix_typed(circuits[1]) ) assert len(executor.quantum_results) == len(circuits) + + +def test_executor_float_with_observable_typed(): + obs = Observable(PauliString("Z")) + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X.on(q)) + executor = Executor(executor_serial_typed) + with pytest.raises( + ValueError, + match="When using a float like result", + ): + executor.evaluate(circuit, obs) + + +def test_executor_measurements_without_observable_typed(): + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X.on(q)) + executor = Executor(executor_measurements_typed) + with pytest.raises( + ValueError, + match="When using a measurement, or bitstring, like result", + ): + executor.evaluate(circuit) + + +def test_executor_density_matrix_without_observable_typed(): + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X.on(q)) + executor = Executor(executor_density_matrix_typed) + with pytest.raises( + ValueError, + match="When using a density matrix like result", + ): + executor.evaluate(circuit) + + +def test_executor_float_not_typed(): + executor = Executor(executor_serial) + executor_typed = Executor(executor_serial_typed) + qcirc = QuantumCircuit(1) + qcirc.h(0) + assert executor.evaluate(qcirc) == executor_typed.evaluate(qcirc) + + +def test_executor_density_matrix_not_typed(): + obs = Observable(PauliString("Z")) + executor = Executor(executor_density_matrix) + executor_typed = Executor(executor_density_matrix_typed) + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X.on(q)) + assert np.allclose( + executor.evaluate(circuit, obs), executor_typed.evaluate(circuit, obs) + ) + + +def test_executor_measurements_not_typed(): + obs = Observable(PauliString("Z")) + executor = Executor(executor_measurements) + executor_typed = Executor(executor_measurements_typed) + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X.on(q)) + assert executor.evaluate(circuit, obs) == executor_typed.evaluate( + circuit, obs + ) + + +def test_executor_empty_return(): + executor = Executor(list) + qcirc = QuantumCircuit(1) + qcirc.h(0) + with pytest.raises(ValueError, match="Could not parse executed results"): + executor.evaluate(qcirc) diff --git a/mitiq/observable/pauli.py b/mitiq/observable/pauli.py index 56d4e3a960..c85db01ca3 100644 --- a/mitiq/observable/pauli.py +++ b/mitiq/observable/pauli.py @@ -282,11 +282,26 @@ def _measure_in( basis_rotations = set() support = set() + qubits_with_measurements = set[cirq.Qid]() + + # Find any existing measurement gates in the circuit + for _, op, _ in circuit.findall_operations_with_gate_type( + cirq.MeasurementGate + ): + qubits_with_measurements.update(op.qubits) + for pauli in paulis.elements: basis_rotations.update(pauli._basis_rotations()) support.update(pauli._qubits_to_measure()) measured = circuit + basis_rotations + cirq.measure(*sorted(support)) + if support & qubits_with_measurements: + raise ValueError( + f"More than one measaurement found for qubit: " + f"{support & qubits_with_measurements}. Only a single " + f"measurement is allowed per qubit." + ) + # Transform circuit back to original qubits. reverse_qubit_map = dict(zip(qubit_map.values(), qubit_map.keys())) return measured.transform_qubits(lambda q: reverse_qubit_map[q]) diff --git a/mitiq/observable/tests/test_pauli.py b/mitiq/observable/tests/test_pauli.py index 2cf00eda5d..ccc5aff9bb 100644 --- a/mitiq/observable/tests/test_pauli.py +++ b/mitiq/observable/tests/test_pauli.py @@ -137,6 +137,16 @@ def test_pauli_measure_in_bad_qubits_error(): pauli.measure_in(circuit) +def test_pauli_measure_in_multi_measurements_per_qubit(): + n = 4 + pauli = PauliString(spec="Z" * n) + circuit = cirq.Circuit(cirq.H.on_each(cirq.LineQubit.range(n))) + # add a measurement to qubit 0 and 1 + circuit = circuit + cirq.measure(cirq.LineQubit(0), cirq.LineQubit(1)) + with pytest.raises(ValueError, match="More than one measaurement"): + pauli.measure_in(circuit) + + def test_can_be_measured_with_single_qubit(): pauli = PauliString(spec="Z")