-
Notifications
You must be signed in to change notification settings - Fork 1k
/
direct_fidelity_estimation.py
515 lines (412 loc) · 18.4 KB
/
direct_fidelity_estimation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# pylint: disable=wrong-or-nonexistent-copyright-notice
"""Implements direct fidelity estimation.
Fidelity between the desired pure state rho and the actual state sigma is
defined as:
F(rho, sigma) = Tr (rho sigma)
It is a unit-less measurement between 0.0 and 1.0. The following two papers
independently described a faster way to estimate its value:
Direct Fidelity Estimation from Few Pauli Measurements
https://arxiv.org/abs/1104.4695
Practical characterization of quantum devices without tomography
https://arxiv.org/abs/1104.3835
This code implements the algorithm proposed for an example circuit (defined in
the function build_circuit()) and a noise (defines in the variable noise).
"""
import argparse
import itertools
import math
import random
import sys
from dataclasses import dataclass
from typing import cast, List, Optional, Tuple
import cirq
import duet
import numpy as np
from cirq.sim import clifford
def build_circuit() -> Tuple[cirq.Circuit, List[cirq.Qid]]:
# Builds an arbitrary circuit to test. Do not include a measurement gate.
# The circuit need not be Clifford, but if it is, simulations will be
# faster.
qubits: List[cirq.Qid] = cast(List[cirq.Qid], cirq.LineQubit.range(3))
circuit: cirq.Circuit = cirq.Circuit(
cirq.CNOT(qubits[0], qubits[2]),
cirq.Z(qubits[0]),
cirq.H(qubits[2]),
cirq.CNOT(qubits[2], qubits[1]),
cirq.X(qubits[0]),
cirq.X(qubits[1]),
cirq.CNOT(qubits[0], qubits[2]),
)
print('Circuit used:')
print(circuit)
return circuit, qubits
def compute_characteristic_function(
pauli_string: cirq.PauliString, qubits: List[cirq.Qid], density_matrix: np.ndarray
):
n_qubits = len(qubits)
d = 2**n_qubits
qubit_map = dict(zip(qubits, range(n_qubits)))
# rho_i or sigma_i in https://arxiv.org/abs/1104.3835
trace = pauli_string.expectation_from_density_matrix(density_matrix, qubit_map)
assert np.isclose(trace.imag, 0.0, atol=1e-6)
trace = trace.real
prob = trace * trace / d # Pr(i) in https://arxiv.org/abs/1104.3835
return trace, prob
async def estimate_characteristic_function(
circuit: cirq.Circuit,
pauli_string: cirq.PauliString,
sampler: cirq.Sampler,
samples_per_term: int,
) -> float:
"""Estimates the characteristic function using a (noisy) circuit simulator.
Args:
circuit: The circuit to run the simulation on.
pauli_string: The Pauli string.
sampler: Either a noisy simulator or an engine.
samples_per_term: An integer greater than 0, the number of samples.
Returns:
The estimated characteristic function.
"""
p = cirq.PauliSumCollector(
circuit=circuit, observable=pauli_string, samples_per_term=samples_per_term
)
await p.collect_async(sampler=sampler)
sigma_i = p.estimated_energy()
assert np.isclose(sigma_i.imag, 0.0, atol=1e-6)
sigma_i = sigma_i.real
return sigma_i
def _randomly_sample_from_stabilizer_bases(
stabilizer_basis: List[cirq.DensePauliString], n_measured_operators: int, n_qubits: int
):
"""Randomly creates Pauli states by including or not the given stabilizer basis.
Args:
stabilizer_basis: A list of Pauli strings that is the stabilizer basis
to sample from.
n_measured_operators: The total number of Pauli measurements, or None to
explore each Pauli state once.
n_qubits: An integer that is the number of qubits.
Returns:
A list of Pauli strings that is the Pauli states built.
"""
dense_pauli_strings = []
for _ in range(n_measured_operators):
# Build the Pauli string as a random sample of the basis elements.
dense_pauli_string = cirq.DensePauliString.eye(n_qubits)
for stabilizer in stabilizer_basis:
if np.random.randint(2) == 1:
dense_pauli_string *= stabilizer
dense_pauli_strings.append(dense_pauli_string)
return dense_pauli_strings
def _enumerate_all_from_stabilizer_bases(
stabilizer_basis: List[cirq.DensePauliString], n_qubits: int
):
"""Return the list of Pauli state that are spanned by the given Pauli basis.
Args:
stabilizer_basis: A list of Pauli strings that is the stabilizer basis
to build all the Pauli strings.
n_qubits: An integer that is the number of qubits.
Returns:
A list of Pauli strings that is the Pauli states built.
"""
dense_pauli_strings = []
for coefficients in itertools.product([False, True], repeat=n_qubits):
dense_pauli_string = cirq.DensePauliString.eye(n_qubits)
for keep, stabilizer in zip(coefficients, stabilizer_basis):
if keep:
dense_pauli_string *= stabilizer
dense_pauli_strings.append(dense_pauli_string)
return dense_pauli_strings
@dataclass
class PauliTrace:
"""Holder of a description fo Pauli states.
This class contains the Pauli states as described on page 2 of: https://arxiv.org/abs/1104.3835
"""
# Pauli string.
P_i: cirq.PauliString
# Coefficient of the ideal pure state expanded in the Pauli basis scaled by
# sqrt(dim H), formally defined at bottom of left column of page 2.
rho_i: float
# A probability (between 0.0 and 1.0) that is the relevance distribution,
# formally defined at top of right column of page 2.
Pr_i: float
def _estimate_pauli_traces_clifford(
n_qubits: int,
stabilizer_basis: List[cirq.DensePauliString],
n_measured_operators: Optional[int],
) -> List[PauliTrace]:
"""Estimates the Pauli traces in case the circuit is Clifford.
When we have a Clifford circuit, there are 2**n Pauli traces that have probability 1/2**n
and all the other traces have probability 0. In addition, there is a fast
way to compute find out what the traces are. See the documentation of
cirq.CliffordTableau for more detail. This function uses the speedup to sample
the Pauli states with non-zero probability.
Args:
n_qubits: An integer that is the number of qubits.
stabilizer_basis: The basis of the Pauli states with non-zero probability.
n_measured_operators: The total number of Pauli measurements, or None to
explore each Pauli state once.
Returns:
A list of Pauli states (represented as tuples of Pauli string, rho_i,
and probability.
"""
# When the circuit consists of Clifford gates only, we can sample the
# Pauli states more efficiently as described on page 4 of:
# https://arxiv.org/abs/1104.4695
d = 2**n_qubits
if n_measured_operators is not None:
dense_pauli_strings = _randomly_sample_from_stabilizer_bases(
stabilizer_basis, n_measured_operators, n_qubits
)
assert len(dense_pauli_strings) == n_measured_operators
else:
dense_pauli_strings = _enumerate_all_from_stabilizer_bases(stabilizer_basis, n_qubits)
assert len(dense_pauli_strings) == 2**n_qubits
pauli_traces: List[PauliTrace] = []
for dense_pauli_string in dense_pauli_strings:
# The code below is equivalent to getting the state vectors from the
# Clifford tableau and then calling compute_characteristic_function()
# on the results (albeit with a wave function instead of a density
# matrix). It is, however, unnecessary to do so. Instead we directly
# obtain the scalar rho_i.
rho_i = dense_pauli_string.coefficient
assert np.isclose(rho_i.imag, 0.0, atol=1e-6)
rho_i = rho_i.real
dense_pauli_string *= rho_i
assert np.isclose(abs(rho_i), 1.0, atol=1e-6)
Pr_i = 1.0 / d
pauli_traces.append(PauliTrace(P_i=dense_pauli_string.sparse(), rho_i=rho_i, Pr_i=Pr_i))
return pauli_traces
def _estimate_pauli_traces_general(
qubits: List[cirq.Qid], circuit: cirq.Circuit, n_measured_operators: Optional[int]
) -> List[PauliTrace]:
"""Estimates the Pauli traces in case the circuit is not Clifford.
In this case we cannot use the speedup implemented in the function
_estimate_pauli_traces_clifford() above, and so do a slow, density matrix
simulation.
Args:
qubits: The list of qubits.
circuit: The (non Clifford) circuit.
n_measured_operators: The total number of Pauli measurements, or None to
explore each Pauli state once.
Returns:
A list of Pauli states (represented as tuples of Pauli string, rho_i,
and probability.
"""
n_qubits = len(qubits)
dense_simulator = cirq.DensityMatrixSimulator()
# rho in https://arxiv.org/abs/1104.3835
clean_density_matrix = dense_simulator.simulate(circuit).final_density_matrix
all_operators = itertools.product([cirq.I, cirq.X, cirq.Y, cirq.Z], repeat=n_qubits)
if n_measured_operators is not None:
dense_operators = random.sample(tuple(all_operators), n_measured_operators)
else:
dense_operators = list(all_operators)
pauli_traces: List[PauliTrace] = []
for P_i in dense_operators:
pauli_string: cirq.PauliString[cirq.Qid] = cirq.PauliString(dict(zip(qubits, P_i)))
rho_i, Pr_i = compute_characteristic_function(pauli_string, qubits, clean_density_matrix)
pauli_traces.append(PauliTrace(P_i=pauli_string, rho_i=rho_i, Pr_i=Pr_i))
return pauli_traces
def _estimate_std_devs_clifford(fidelity: float, n: int) -> Tuple[Optional[float], float]:
"""Estimates the standard deviation of the measurement for Clifford circuits.
Args:
fidelity: The measured fidelity
n: the number of measurements
Returns:
The standard deviation (estimated from the fidelity and a maximum bound
on the variance regardless of what the true fidelity is)
"""
# We use the Bhatia Davis inequality to estimate the variance of the
# fidelity. This gives that:
# Var[\hat{F}] <= (1 - F) . F / N
# StdDev[\hat{F}] <= \sqrt{(1 - F) . F / N}
#
# By further using the fact that 0 <= F <= 1 we get:
# StdDev[\hat{F}] <= \frac{1}{2 \sqrt{N}}
# Because of the noisiness of the simulation, the estimated fidelity can be
# outside the [0, 1] range. If that is the case, we just do not use it to
# compute the estimate.
in_range = fidelity >= 0 and fidelity <= 1.0
std_dev_estimate = math.sqrt((1.0 - fidelity) * fidelity / n) if in_range else None
std_dev_bound = 0.5 / math.sqrt(n)
return std_dev_estimate, std_dev_bound
@dataclass
class Result:
"""Contains the results of a trial, either by simulator or actual run."""
# The Pauli trace that was measured
pauli_trace: PauliTrace
# Coefficient of the measured/simulated pure state expanded in the Pauli
# basis scaled by sqrt(dim H), formally defined at bottom of left column of
# second page of https://arxiv.org/abs/1104.3835
sigma_i: float
@dataclass
class DFEIntermediateResult:
"""A container for debug and run data returned from `direct_fidelity_estimation`.
This is useful when running a long-computation on an actual computer, which is expensive.
This allows theses runs to be more easily debugged offline.
"""
# If the circuit is Clifford, the Clifford tableau from which we can extract
# a list of Pauli strings for a basis of the stabilizers.
clifford_tableau: Optional[cirq.CliffordTableau]
# The list of Pauli traces we can sample from.
pauli_traces: List[PauliTrace]
# Measurement results from sampling the circuit.
trial_results: List[Result]
# Standard deviations (estimate based on fidelity and bound)
std_dev_estimate: Optional[float]
std_dev_bound: Optional[float]
def direct_fidelity_estimation(
circuit: cirq.Circuit,
qubits: List[cirq.Qid],
sampler: cirq.Sampler,
n_measured_operators: Optional[int],
samples_per_term: int,
):
"""Perform a direct fidelity estimation.
This implementation of direct fidelity estimation, is that of 'Direct Fidelity
Estimation from Few Pauli Measurements' https://arxiv.org/abs/1104.4695 and
'Practical characterization of quantum devices without tomography'
https://arxiv.org/abs/1104.3835.
Args:
circuit: The circuit to run the simulation on.
qubits: The list of qubits.
sampler: Either a noisy simulator or an engine.
n_measured_operators: The total number of Pauli measurements, or None to
explore each Pauli state once.
samples_per_term: if set to 0, we use the 'sampler' parameter above as
a noise (must be of type cirq.DensityMatrixSimulator) and
simulate noise in the circuit. If greater than 0, we instead use the
'sampler' parameter directly to estimate the characteristic
function.
Returns:
The estimated fidelity and a log of the run.
Raises:
TypeError: If the circuit is not made up entirely of Clifford gates.
"""
# n_measured_operators is upper-case N in https://arxiv.org/abs/1104.3835
# Number of qubits, lower-case n in https://arxiv.org/abs/1104.3835
n_qubits = len(qubits)
clifford_circuit = True
clifford_tableau = cirq.CliffordTableau(n_qubits)
try:
for gate in circuit.all_operations():
tableau_args = clifford.CliffordTableauSimulationState(
tableau=clifford_tableau, qubits=qubits
)
cirq.act_on(gate, tableau_args)
except TypeError:
clifford_circuit = False
# Computes for every \hat{P_i} of https://arxiv.org/abs/1104.3835
# estimate rho_i and Pr(i). We then collect tuples (rho_i, Pr(i), \hat{Pi})
# inside the variable 'pauli_traces'.
if clifford_circuit:
# The stabilizers_basis variable only contains basis vectors. For
# example, if we have n=3 qubits, then we should have 2**n=8 Pauli
# states that we can sample, but the basis will still have 3 entries. We
# must flip a coin for each, whether or not to include them.
stabilizer_basis: List[cirq.DensePauliString] = clifford_tableau.stabilizers()
pauli_traces = _estimate_pauli_traces_clifford(
n_qubits, stabilizer_basis, n_measured_operators
)
else:
pauli_traces = _estimate_pauli_traces_general(qubits, circuit, n_measured_operators)
p = np.asarray([x.Pr_i for x in pauli_traces])
if n_measured_operators is None:
# Since we enumerate all the possible traces, the probs should add to 1.
assert np.isclose(np.sum(p), 1.0, atol=1e-6)
p /= np.sum(p)
fidelity = 0.0
if samples_per_term == 0:
# sigma in https://arxiv.org/abs/1104.3835
if not isinstance(sampler, cirq.DensityMatrixSimulator):
raise TypeError(
'sampler is not a cirq.DensityMatrixSimulator but samples_per_term is zero.'
)
noisy_density_matrix = sampler.simulate(circuit).final_density_matrix
if clifford_circuit and n_measured_operators is None:
# In case the circuit is Clifford and we compute an exhaustive list of
# Pauli traces, instead of sampling we can simply enumerate them because
# they all have the same probability.
measured_pauli_traces = pauli_traces
else:
# Otherwise, randomly sample as per probability.
measured_pauli_traces = np.random.choice(
pauli_traces, size=len(pauli_traces), p=p # type: ignore[arg-type]
).tolist()
trial_results: List[Result] = []
for pauli_trace in measured_pauli_traces:
measure_pauli_string: cirq.PauliString = pauli_trace.P_i
rho_i = pauli_trace.rho_i
if samples_per_term > 0:
sigma_i = duet.run(
estimate_characteristic_function,
circuit,
measure_pauli_string,
sampler,
samples_per_term,
)
else:
sigma_i, _ = compute_characteristic_function(
measure_pauli_string, qubits, noisy_density_matrix
)
trial_results.append(Result(pauli_trace=pauli_trace, sigma_i=sigma_i))
fidelity += sigma_i / rho_i
estimated_fidelity = fidelity / len(pauli_traces)
std_dev_estimate: Optional[float]
std_dev_bound: Optional[float]
if clifford_circuit:
std_dev_estimate, std_dev_bound = _estimate_std_devs_clifford(
estimated_fidelity, len(measured_pauli_traces)
)
else:
std_dev_estimate, std_dev_bound = None, None
dfe_intermediate_result = DFEIntermediateResult(
clifford_tableau=clifford_tableau if clifford_circuit else None,
pauli_traces=pauli_traces,
trial_results=trial_results,
std_dev_estimate=std_dev_estimate,
std_dev_bound=std_dev_bound,
)
return estimated_fidelity, dfe_intermediate_result
def parse_arguments(args):
"""Helper function that parses the given arguments."""
parser = argparse.ArgumentParser('Direct fidelity estimation.')
# TODO: Offer some guidance on how to set this flag. Maybe have an
# option to do an exhaustive sample and do numerical studies to know which
# choice is the best.
# Github issue: https://github.com/quantumlib/Cirq/issues/2802
parser.add_argument(
'--n_measured_operators',
default=10,
type=int,
help='Numbers of measured operators (Pauli strings). '
'If the circuit is Clifford, these operators are '
'computed by sampling for the basis of stabilizers. If '
'the circuit is not Clifford, this is a random sample '
'all the possible operators. If the value of this '
'parameter is None, we enumerate all the operators '
'which is 2**n_qubit for Clifford circuits and '
'4**n_qubits otherwise.',
)
parser.add_argument(
'--samples_per_term',
default=0,
type=int,
help='Number of samples per trial or 0 if no sampling.',
)
return vars(parser.parse_args(args))
def main(*, n_measured_operators: Optional[int], samples_per_term: int):
circuit, qubits = build_circuit()
noise = cirq.ConstantQubitNoiseModel(cirq.depolarize(0.1))
print(f'Noise model: {noise}')
noisy_simulator = cirq.DensityMatrixSimulator(noise=noise)
estimated_fidelity, _ = direct_fidelity_estimation(
circuit,
qubits,
noisy_simulator,
n_measured_operators=n_measured_operators,
samples_per_term=samples_per_term,
)
print(f'Estimated fidelity: {estimated_fidelity:f}')
if __name__ == '__main__':
main(**parse_arguments(sys.argv[1:]))