Skip to content

Commit b3561b4

Browse files
ddddddannieliottrosenbergNoureldinYosri
authored
Add a tool for measuring expectation values of Pauli strings with readout error mitigation (#7067)
* Modify the shuffle_circuit measurement and allow it to handle a list of qubits as input * Add a new tool for measuring expectation values of Pauli strings with readout error mitigation * Allow the measure_pauli_strings to also return the SingleQubitReadoutCalibrationResult. Besides, fixed some lints * 1. Allow shuffle_circuits_with_readout_benchmarking to take 0 for num_random_bitstrings, and add a test to cover the situation. The design is the run_shuffled_with_readout_benchmarking method will return a empty SingleQubitReadoutCalibrationResult if no calibration is actually done. 2. Allow measure_pauli_strings to take num_random_bitstrings = 0. In this case, no mitigation is actually done, and the mitigated result and unmitigated result are the same. Also add a test to handle this situation. 3. Make the return type of measure_pauli_strings a data class. * Fix a issue that caused calibration_results lookup failure * Fix: Pauli I was incorrectly treated as Z in expectation calculation * 1. Added some codes to multiply the result of measuring the PauliString by its coefficient 2. Added a function to validate the input. Also added some tests for the validation function. 3. Fixed lint * Fix a broken test * pauli_string.qubits returns the self.keys which are already unique. Thus remove the set() acts on the pauli_string.qubits * Fix type and coverage check. Besides, adds a input validation check to validate the input pauli coefficient must be real. * Address comments by @NoureldinYosri * Fix the coverage check * Fix lint line too long --------- Co-authored-by: eliottrosenberg <[email protected]> Co-authored-by: Noureldin <[email protected]>
1 parent eddb281 commit b3561b4

File tree

5 files changed

+1084
-93
lines changed

5 files changed

+1084
-93
lines changed

cirq-core/cirq/contrib/paulistring/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,7 @@
4242
)
4343

4444
from cirq.contrib.paulistring.optimize import optimized_circuit as optimized_circuit
45+
46+
from cirq.contrib.paulistring.pauli_string_measurement_with_readout_mitigation import (
47+
measure_pauli_strings as measure_pauli_strings,
48+
)
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
# Copyright 2025 The Cirq Developers
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Tools for measuring expectation values of Pauli strings with readout error mitigation."""
15+
import time
16+
from typing import List, Union, Dict, Optional, Tuple
17+
import attrs
18+
19+
import numpy as np
20+
21+
from cirq import ops, circuits, work
22+
from cirq.contrib.shuffle_circuits import run_shuffled_with_readout_benchmarking
23+
from cirq.experiments import SingleQubitReadoutCalibrationResult
24+
from cirq.experiments.readout_confusion_matrix import TensoredConfusionMatrices
25+
from cirq.study import ResultDict
26+
27+
28+
@attrs.frozen
29+
class PauliStringMeasurementResult:
30+
"""Result of measuring a Pauli string.
31+
32+
Attributes:
33+
pauli_string: The Pauli string that is measured.
34+
mitigated_expectation: The error-mitigated expectation value of the Pauli string.
35+
mitigated_stddev: The standard deviation of the error-mitigated expectation value.
36+
unmitigated_expectation: The unmitigated expectation value of the Pauli string.
37+
unmitigated_stddev: The standard deviation of the unmitigated expectation value.
38+
calibration_result: The calibration result for single-qubit readout errors.
39+
"""
40+
41+
pauli_string: ops.PauliString
42+
mitigated_expectation: float
43+
mitigated_stddev: float
44+
unmitigated_expectation: float
45+
unmitigated_stddev: float
46+
calibration_result: Optional[SingleQubitReadoutCalibrationResult] = None
47+
48+
49+
@attrs.frozen
50+
class CircuitToPauliStringsMeasurementResult:
51+
"""Result of measuring Pauli strings on a circuit.
52+
53+
Attributes:
54+
circuit: The circuit that is measured.
55+
results: A list of PauliStringMeasurementResult objects.
56+
"""
57+
58+
circuit: circuits.FrozenCircuit
59+
results: List[PauliStringMeasurementResult]
60+
61+
62+
def _validate_input(
63+
circuits_to_pauli: Dict[circuits.FrozenCircuit, list[ops.PauliString]],
64+
pauli_repetitions: int,
65+
readout_repetitions: int,
66+
num_random_bitstrings: int,
67+
rng_or_seed: Union[np.random.Generator, int],
68+
):
69+
if not circuits_to_pauli:
70+
raise ValueError("Input circuits must not be empty.")
71+
72+
for circuit in circuits_to_pauli.keys():
73+
if not isinstance(circuit, circuits.FrozenCircuit):
74+
raise TypeError("All keys in 'circuits_to_pauli' must be FrozenCircuit instances.")
75+
76+
for pauli_strings in circuits_to_pauli.values():
77+
for pauli_str in pauli_strings:
78+
if not isinstance(pauli_str, ops.PauliString):
79+
raise TypeError(
80+
f"All elements in the Pauli string lists must be cirq.PauliString "
81+
f"instances, got {type(pauli_str)}."
82+
)
83+
84+
if all(q == ops.I for q in pauli_str):
85+
raise ValueError(
86+
"Empty Pauli strings or Pauli strings consisting"
87+
"only of Pauli I are not allowed. Please provide"
88+
"valid input Pauli strings."
89+
)
90+
if pauli_str.coefficient.imag != 0:
91+
raise ValueError(
92+
"Cannot compute expectation value of a non-Hermitian PauliString. "
93+
"Coefficient must be real."
94+
)
95+
96+
# Check rng is a numpy random generator
97+
if not isinstance(rng_or_seed, np.random.Generator) and not isinstance(rng_or_seed, int):
98+
raise ValueError("Must provide a numpy random generator or a seed")
99+
100+
# Check pauli_repetitions is bigger than 0
101+
if pauli_repetitions <= 0:
102+
raise ValueError("Must provide non-zero pauli_repetitions.")
103+
104+
# Check num_random_bitstrings is bigger than or equal to 0
105+
if num_random_bitstrings < 0:
106+
raise ValueError("Must provide zero or more num_random_bitstrings.")
107+
108+
# Check readout_repetitions is bigger than 0
109+
if readout_repetitions <= 0:
110+
raise ValueError("Must provide non-zero readout_repetitions for readout calibration.")
111+
112+
113+
def _pauli_string_to_basis_change_ops(
114+
pauli_string: ops.PauliString, qid_list: list[ops.Qid]
115+
) -> List[ops.Operation]:
116+
"""Creates operations to change to the eigenbasis of the given Pauli string.
117+
118+
This function constructs a list of ops.Operation that performs basis changes
119+
necessary to measure the given pauli_string in the computational basis.
120+
121+
Args:
122+
pauli_string: The Pauli string to diagonalize.
123+
qid_list: An ordered list of the qubits in the circuit.
124+
125+
Returns:
126+
A list of Operations that, when applied before measurement in the
127+
computational basis, effectively measures in the eigenbasis of
128+
pauli_strings.
129+
"""
130+
operations = []
131+
for qubit in qid_list: # Iterate over ALL qubits in the circuit
132+
if qubit in pauli_string:
133+
pauli_op = pauli_string[qubit]
134+
if pauli_op == ops.X:
135+
operations.append(ops.ry(-np.pi / 2)(qubit)) # =cirq.H
136+
elif pauli_op == ops.Y:
137+
operations.append(ops.rx(np.pi / 2)(qubit))
138+
# If pauli_op is Z or I, no operation needed
139+
return operations
140+
141+
142+
def _build_one_qubit_confusion_matrix(e0: float, e1: float) -> np.ndarray:
143+
"""Builds a 2x2 confusion matrix for a single qubit.
144+
145+
Args:
146+
e0: the 0->1 readout error rate.
147+
e1: the 1->0 readout error rate.
148+
149+
Returns:
150+
A 2x2 NumPy array representing the confusion matrix.
151+
"""
152+
return np.array([[1 - e0, e1], [e0, 1 - e1]])
153+
154+
155+
def _build_many_one_qubits_confusion_matrix(
156+
qubits_to_error: SingleQubitReadoutCalibrationResult,
157+
) -> list[np.ndarray]:
158+
"""Builds a list of confusion matrices from calibration results.
159+
160+
This function iterates through the calibration results for each qubit and
161+
constructs a list of single-qubit confusion matrices.
162+
163+
Args:
164+
qubits_to_error: An object containing calibration results for
165+
single-qubit readout errors, including zero-state and one-state errors
166+
for each qubit.
167+
168+
Returns:
169+
A list of NumPy arrays, where each array is a 2x2 confusion matrix
170+
for a qubit. The order of matrices corresponds to the order of qubits
171+
in the calibration results (alphabetical order by qubit name).
172+
"""
173+
cms: list[np.ndarray] = []
174+
175+
for qubit in sorted(qubits_to_error.zero_state_errors.keys()):
176+
e0 = qubits_to_error.zero_state_errors[qubit]
177+
e1 = qubits_to_error.one_state_errors[qubit]
178+
cms.append(_build_one_qubit_confusion_matrix(e0, e1))
179+
return cms
180+
181+
182+
def _build_many_one_qubits_empty_confusion_matrix(qubits_length: int) -> list[np.ndarray]:
183+
"""Builds a list of empty confusion matrices"""
184+
return [_build_one_qubit_confusion_matrix(0, 0) for _ in range(qubits_length)]
185+
186+
187+
def _process_pauli_measurement_results(
188+
qubits: List[ops.Qid],
189+
pauli_strings: List[ops.PauliString],
190+
circuit_results: List[ResultDict],
191+
calibration_results: Dict[Tuple[ops.Qid, ...], SingleQubitReadoutCalibrationResult],
192+
pauli_repetitions: int,
193+
timestamp: float,
194+
disable_readout_mitigation: bool = False,
195+
) -> List[PauliStringMeasurementResult]:
196+
"""Calculates both error-mitigated expectation values and unmitigated expectation values
197+
from measurement results.
198+
199+
This function takes the results from shuffled readout benchmarking and:
200+
1. Constructs a tensored confusion matrix for error mitigation.
201+
2. Mitigates readout errors for each Pauli string measurement.
202+
3. Calculates and returns both error-mitigated and unmitigated expectation values.
203+
204+
Args:
205+
qubits: Qubits to build confusion matrices for. In a sorted order.
206+
pauli_strings: The list of PauliStrings that are measured.
207+
circuit_results: A list of ResultDict obtained
208+
from running the Pauli measurement circuits.
209+
confusion_matrices: A list of confusion matrices from calibration results.
210+
pauli_repetitions: The number of repetitions used for Pauli string measurements.
211+
timestamp: The timestamp of the calibration results.
212+
disable_readout_mitigation: If set to True, returns no error-mitigated error
213+
expectation values.
214+
215+
Returns:
216+
A list of PauliStringMeasurementResult.
217+
"""
218+
219+
pauli_measurement_results: List[PauliStringMeasurementResult] = []
220+
221+
for pauli_index, circuit_result in enumerate(circuit_results):
222+
measurement_results = circuit_result.measurements["m"]
223+
224+
pauli_string = pauli_strings[pauli_index]
225+
qubits_sorted = sorted(pauli_string.qubits)
226+
qubit_indices = [qubits.index(q) for q in qubits_sorted]
227+
228+
confusion_matrices = (
229+
_build_many_one_qubits_confusion_matrix(calibration_results[tuple(qubits_sorted)])
230+
if disable_readout_mitigation is False
231+
else _build_many_one_qubits_empty_confusion_matrix(len(qubits_sorted))
232+
)
233+
tensored_cm = TensoredConfusionMatrices(
234+
confusion_matrices,
235+
[[q] for q in qubits_sorted],
236+
repetitions=pauli_repetitions,
237+
timestamp=timestamp,
238+
)
239+
240+
# Create a mask for the relevant qubits in the measurement results
241+
relevant_bits = measurement_results[:, qubit_indices]
242+
243+
# Calculate the mitigated expectation.
244+
raw_mitigated_values, raw_d_m = tensored_cm.readout_mitigation_pauli_uncorrelated(
245+
qubits_sorted, relevant_bits
246+
)
247+
mitigated_values_with_coefficient = raw_mitigated_values * pauli_string.coefficient.real
248+
d_m_with_coefficient = raw_d_m * abs(pauli_string.coefficient.real)
249+
250+
# Calculate the unmitigated expectation.
251+
parity = np.sum(relevant_bits, axis=1) % 2
252+
raw_unmitigated_values = 1 - 2 * np.mean(parity)
253+
raw_d_unmit = 2 * np.sqrt(np.mean(parity) * (1 - np.mean(parity)) / pauli_repetitions)
254+
unmitigated_value_with_coefficient = raw_unmitigated_values * pauli_string.coefficient
255+
d_unmit_with_coefficient = raw_d_unmit * abs(pauli_string.coefficient)
256+
257+
pauli_measurement_results.append(
258+
PauliStringMeasurementResult(
259+
pauli_string=pauli_strings[pauli_index],
260+
mitigated_expectation=mitigated_values_with_coefficient,
261+
mitigated_stddev=d_m_with_coefficient,
262+
unmitigated_expectation=unmitigated_value_with_coefficient,
263+
unmitigated_stddev=d_unmit_with_coefficient,
264+
calibration_result=(
265+
calibration_results[tuple(qubits_sorted)]
266+
if disable_readout_mitigation is False
267+
else None
268+
),
269+
)
270+
)
271+
272+
return pauli_measurement_results
273+
274+
275+
def measure_pauli_strings(
276+
circuits_to_pauli: Dict[circuits.FrozenCircuit, list[ops.PauliString]],
277+
sampler: work.Sampler,
278+
pauli_repetitions: int,
279+
readout_repetitions: int,
280+
num_random_bitstrings: int,
281+
rng_or_seed: Union[np.random.Generator, int],
282+
) -> List[CircuitToPauliStringsMeasurementResult]:
283+
"""Measures expectation values of Pauli strings on given circuits with/without
284+
readout error mitigation.
285+
286+
This function takes a list of circuits and corresponding List[Pauli string] to measure.
287+
For each circuit-List[Pauli string] pair, it:
288+
1. Constructs circuits to measure the Pauli string expectation value by
289+
adding basis change moments and measurement operations.
290+
2. Runs shuffled readout benchmarking on these circuits to calibrate readout errors.
291+
3. Mitigates readout errors using the calibrated confusion matrices.
292+
4. Calculates and returns both error-mitigated and unmitigatedexpectation values for
293+
each Pauli string.
294+
295+
Args:
296+
circuits_to_pauli: A dictionary mapping circuits to a list of Pauli strings
297+
to measure.
298+
sampler: The sampler to use.
299+
pauli_repetitions: The number of repetitions for each circuit when measuring
300+
Pauli strings.
301+
readout_repetitions: The number of repetitions for readout calibration
302+
in the shuffled benchmarking.
303+
num_random_bitstrings: The number of random bitstrings to use in shuffled
304+
benchmarking.
305+
rng_or_seed: A random number generator or seed for the shuffled benchmarking.
306+
307+
Returns:
308+
A list of CircuitToPauliStringsMeasurementResult objects, where each object contains:
309+
- The circuit that was measured.
310+
- A list of PauliStringMeasurementResult objects.
311+
- The calibration result for single-qubit readout errors.
312+
"""
313+
314+
_validate_input(
315+
circuits_to_pauli,
316+
pauli_repetitions,
317+
readout_repetitions,
318+
num_random_bitstrings,
319+
rng_or_seed,
320+
)
321+
322+
# Extract unique qubit tuples from input pauli strings
323+
unique_qubit_tuples = set()
324+
for pauli_strings in circuits_to_pauli.values():
325+
for pauli_string in pauli_strings:
326+
unique_qubit_tuples.add(tuple(sorted(pauli_string.qubits)))
327+
# qubits_list is a list of qubit tuples
328+
qubits_list = sorted(unique_qubit_tuples)
329+
330+
# Build the basis-change circuits for each Pauli string
331+
pauli_measurement_circuits = list[circuits.Circuit]()
332+
for input_circuit, pauli_strings in circuits_to_pauli.items():
333+
qid_list = list(sorted(input_circuit.all_qubits()))
334+
basis_change_circuits = []
335+
input_circuit_unfrozen = input_circuit.unfreeze()
336+
for pauli_string in pauli_strings:
337+
basis_change_circuit = (
338+
input_circuit_unfrozen
339+
+ _pauli_string_to_basis_change_ops(pauli_string, qid_list)
340+
+ ops.measure(*qid_list, key="m")
341+
)
342+
basis_change_circuits.append(basis_change_circuit)
343+
pauli_measurement_circuits.extend(basis_change_circuits)
344+
345+
# Run shuffled benchmarking for readout calibration
346+
circuits_results, calibration_results = run_shuffled_with_readout_benchmarking(
347+
input_circuits=pauli_measurement_circuits,
348+
sampler=sampler,
349+
circuit_repetitions=pauli_repetitions,
350+
rng_or_seed=rng_or_seed,
351+
qubits=[list(qubits) for qubits in qubits_list],
352+
num_random_bitstrings=num_random_bitstrings,
353+
readout_repetitions=readout_repetitions,
354+
)
355+
356+
# Process the results to calculate expectation values
357+
results: List[CircuitToPauliStringsMeasurementResult] = []
358+
circuit_result_index = 0
359+
for input_circuit, pauli_strings in circuits_to_pauli.items():
360+
qubits_in_circuit = tuple(sorted(input_circuit.all_qubits()))
361+
362+
disable_readout_mitigation = False if num_random_bitstrings != 0 else True
363+
pauli_measurement_results = _process_pauli_measurement_results(
364+
list(qubits_in_circuit),
365+
pauli_strings,
366+
circuits_results[circuit_result_index : circuit_result_index + len(pauli_strings)],
367+
calibration_results,
368+
pauli_repetitions,
369+
time.time(),
370+
disable_readout_mitigation,
371+
)
372+
results.append(
373+
CircuitToPauliStringsMeasurementResult(
374+
circuit=input_circuit, results=pauli_measurement_results
375+
)
376+
)
377+
378+
circuit_result_index += len(pauli_strings)
379+
return results

0 commit comments

Comments
 (0)