diff --git a/docs/manuals/measurement/readout_mitigation.rst b/docs/manuals/measurement/readout_mitigation.rst index cc145b47f1..1a6b8d54d7 100644 --- a/docs/manuals/measurement/readout_mitigation.rst +++ b/docs/manuals/measurement/readout_mitigation.rst @@ -106,7 +106,7 @@ Mitigation example .. jupyter-execute:: qc = QuantumCircuit(num_qubits) - qc.h(0) + qc.sx(0) for i in range(1, num_qubits): qc.cx(i - 1, i) qc.measure_all() diff --git a/qiskit_experiments/library/randomized_benchmarking/__init__.py b/qiskit_experiments/library/randomized_benchmarking/__init__.py index 371f0a3679..6ec3daa157 100644 --- a/qiskit_experiments/library/randomized_benchmarking/__init__.py +++ b/qiskit_experiments/library/randomized_benchmarking/__init__.py @@ -25,6 +25,7 @@ StandardRB InterleavedRB + LayerFidelity Analysis @@ -36,6 +37,7 @@ RBAnalysis InterleavedRBAnalysis + LayerFidelityAnalysis Synthesis ========= @@ -61,3 +63,5 @@ from .clifford_utils import CliffordUtils from .rb_utils import RBUtils from .clifford_synthesis import RBDefaultCliffordSynthesis +from .layer_fidelity import LayerFidelity +from .layer_fidelity_analysis import LayerFidelityAnalysis diff --git a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py index a081db7006..666f8b0cc9 100644 --- a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py +++ b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py @@ -46,6 +46,7 @@ _valid_sparse_indices = _clifford_compose_2q_data["valid_sparse_indices"] # map a clifford number to the index of _CLIFFORD_COMPOSE_2Q_DENSE _clifford_num_to_dense_index = {idx: ii for ii, idx in enumerate(_valid_sparse_indices)} +_CLIFFORD_TENSOR_1Q = np.load(f"{_DATA_FOLDER}/clifford_tensor_1q.npz")["table"] # Transpilation utilities def _transpile_clifford_circuit( @@ -486,7 +487,11 @@ def inverse_1q(num: Integral) -> Integral: def num_from_1q_circuit(qc: QuantumCircuit) -> Integral: - """Convert a given 1-qubit Clifford circuit to the corresponding integer.""" + """Convert a given 1-qubit Clifford circuit to the corresponding integer. + + Note: The circuit must consist of gates in :const:`_CLIFF_SINGLE_GATE_MAP_1Q`, + RZGate, Delay and Barrier. + """ num = 0 for inst in qc: rhs = _num_from_1q_gate(op=inst.operation) @@ -497,7 +502,7 @@ def num_from_1q_circuit(qc: QuantumCircuit) -> Integral: def _num_from_1q_gate(op: Instruction) -> int: """ Convert a given 1-qubit clifford operation to the corresponding integer. - Note that supported operations are limited to ones in :const:`CLIFF_SINGLE_GATE_MAP_1Q` or Rz gate. + Note that supported operations are limited to ones in :const:`_CLIFF_SINGLE_GATE_MAP_1Q` or Rz gate. Args: op: operation to be converted. @@ -556,7 +561,11 @@ def inverse_2q(num: Integral) -> Integral: def num_from_2q_circuit(qc: QuantumCircuit) -> Integral: - """Convert a given 2-qubit Clifford circuit to the corresponding integer.""" + """Convert a given 2-qubit Clifford circuit to the corresponding integer. + + Note: The circuit must consist of gates in :const:`_CLIFF_SINGLE_GATE_MAP_2Q`, + RZGate, Delay and Barrier. + """ lhs = 0 for rhs in _clifford_2q_nums_from_2q_circuit(qc): lhs = _CLIFFORD_COMPOSE_2Q_DENSE[lhs, _clifford_num_to_dense_index[rhs]] @@ -568,7 +577,7 @@ def _num_from_2q_gate( ) -> int: """ Convert a given 1-qubit clifford operation to the corresponding integer. - Note that supported operations are limited to ones in `CLIFF_SINGLE_GATE_MAP_2Q` or Rz gate. + Note that supported operations are limited to ones in `_CLIFF_SINGLE_GATE_MAP_2Q` or Rz gate. Args: op: operation of instruction to be converted. @@ -730,3 +739,8 @@ def _layer_indices_from_num(num: Integral) -> Tuple[Integral, Integral, Integral idx1 = num % _NUM_LAYER_1 idx0 = num // _NUM_LAYER_1 return idx0, idx1, idx2 + + +def _tensor_1q_nums(first: Integral, second: Integral) -> Integral: + """Return the 2-qubit Clifford integer that is the tensor product of 1-qubit Cliffords.""" + return _CLIFFORD_TENSOR_1Q[first, second] diff --git a/qiskit_experiments/library/randomized_benchmarking/data/clifford_tensor_1q.npz b/qiskit_experiments/library/randomized_benchmarking/data/clifford_tensor_1q.npz new file mode 100644 index 0000000000..7725682f70 Binary files /dev/null and b/qiskit_experiments/library/randomized_benchmarking/data/clifford_tensor_1q.npz differ diff --git a/qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py b/qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py index 7ebd45dcab..5193dc35d8 100644 --- a/qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py +++ b/qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py @@ -59,7 +59,7 @@ def _hash_cliff(cliff): def gen_clifford_inverse_1q(): - """Generate table data for integer 1Q Clifford inversion""" + """Generate data for integer 1Q Clifford inversion table""" invs = np.empty(NUM_CLIFFORD_1Q, dtype=int) for i, cliff_i in _CLIFF_1Q.items(): invs[i] = _TO_INT_1Q[_hash_cliff(cliff_i.adjoint())] @@ -68,7 +68,7 @@ def gen_clifford_inverse_1q(): def gen_clifford_compose_1q(): - """Generate table data for integer 1Q Clifford composition.""" + """Generate data for integer 1Q Clifford composition table""" products = np.empty((NUM_CLIFFORD_1Q, NUM_CLIFFORD_1Q), dtype=int) for i, cliff_i in _CLIFF_1Q.items(): for j, cliff_j in _CLIFF_1Q.items(): @@ -83,7 +83,7 @@ def gen_clifford_compose_1q(): def gen_clifford_inverse_2q(): - """Generate table data for integer 2Q Clifford inversion""" + """Generate data for integer 2Q Clifford inversion table""" invs = np.empty(NUM_CLIFFORD_2Q, dtype=int) for i, cliff_i in _CLIFF_2Q.items(): invs[i] = _TO_INT_2Q[_hash_cliff(cliff_i.adjoint())] @@ -191,6 +191,16 @@ def gen_cliff_single_2q_gate_map(): return table +def gen_clifford_tensor_1q(): + """Generate data for 2Q integer Clifford table of the tensor product of 1Q integer Cliffords.""" + products = np.empty((NUM_CLIFFORD_1Q, NUM_CLIFFORD_1Q), dtype=int) + for i, cliff_i in _CLIFF_1Q.items(): + for j, cliff_j in _CLIFF_1Q.items(): + cliff = cliff_i.tensor(cliff_j) + products[i, j] = _TO_INT_2Q[_hash_cliff(cliff)] + return products + + if __name__ == "__main__": if _CLIFF_SINGLE_GATE_MAP_1Q != gen_cliff_single_1q_gate_map(): raise Exception( @@ -212,3 +222,5 @@ def gen_cliff_single_2q_gate_map(): table=_CLIFFORD_COMPOSE_2Q_DENSE, valid_sparse_indices=valid_sparse_indices, ) + + np.savez_compressed("clifford_tensor_1q.npz", table=gen_clifford_tensor_1q()) diff --git a/qiskit_experiments/library/randomized_benchmarking/layer_fidelity.py b/qiskit_experiments/library/randomized_benchmarking/layer_fidelity.py new file mode 100644 index 0000000000..40c5247f87 --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/layer_fidelity.py @@ -0,0 +1,478 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Layer Fidelity RB Experiment class. +""" +import functools +import logging +from collections import defaultdict +from typing import Union, Iterable, Optional, List, Sequence, Tuple, Dict + +from numpy.random import Generator, default_rng +from numpy.random.bit_generator import BitGenerator, SeedSequence + +from qiskit.circuit import QuantumCircuit, CircuitInstruction, Barrier, Gate +from qiskit.circuit.library import get_standard_gate_name_mapping +from qiskit.exceptions import QiskitError +from qiskit.providers import BackendV2Converter +from qiskit.providers.backend import Backend, BackendV1, BackendV2 +from qiskit.quantum_info import Clifford +from qiskit.pulse.instruction_schedule_map import CalibrationPublisher + +from qiskit_experiments.framework import BaseExperiment, Options +from qiskit_experiments.framework.configs import ExperimentConfig +from qiskit_experiments.framework.restless_mixin import RestlessMixin + +from .clifford_utils import ( + CliffordUtils, + DEFAULT_SYNTHESIS_METHOD, + compose_1q, + compose_2q, + inverse_1q, + inverse_2q, + num_from_2q_circuit, + _tensor_1q_nums, + _clifford_1q_int_to_instruction, + _clifford_2q_int_to_instruction, + _decompose_clifford_ops, +) +from .layer_fidelity_analysis import LayerFidelityAnalysis + +LOG = logging.getLogger(__name__) + + +GATE_NAME_MAP = get_standard_gate_name_mapping() +NUM_1Q_CLIFFORD = CliffordUtils.NUM_CLIFFORD_1_QUBIT + + +class LayerFidelity(BaseExperiment, RestlessMixin): + r"""A holistic benchmarking experiment to characterize the full quality of the devices at scale. + + # section: overview + Layer Fidelity (LF) is a method to estimate the fidelity of + a connecting set of two-qubit gates over :math:`N` qubits by measuring gate errors + using simultaneous direct randomized benchmarking (RB) in disjoint layers. + LF can easily be expressed as a layer size independent quantity, error per layered gate (EPLG): + :math:`EPLG = 1 - LF^{1/N_{2Q}}` where :math:`N_{2Q}` is number of 2-qubit gates in the layers. + + Each of the 2-qubit (or 1-qubit) direct RBs yields the decaying probabilities + to get back to the ground state for an increasing sequence length (i.e. number of layers), + fits the exponential curve to estimate the decay rate, and calculates + the process fidelity of the subsystem from the rate. + LF is calculated as the product of the 2-qubit (or 1-qubit) process fidelities. + See Ref. [1] for details. + + # section: analysis_ref + :class:`LayerFidelityAnalysis` + + # section: reference + .. ref_arxiv:: 1 2311.05933 + """ + + def __init__( + self, + physical_qubits: Sequence[int], + two_qubit_layers: Sequence[Sequence[Tuple[int, int]]], + lengths: Iterable[int], + backend: Optional[Backend] = None, + num_samples: int = 6, + seed: Optional[Union[int, SeedSequence, BitGenerator, Generator]] = None, + two_qubit_gate: Optional[str] = None, + one_qubit_basis_gates: Optional[Sequence[str]] = None, + ): + """Initialize a layer fidelity experiment. + + Args: + physical_qubits: List of physical qubits for the experiment. + two_qubit_layers: List of two-qubit gate layers to run on. Each two-qubit + gate layer must be given as a list of directed qubit pairs. + lengths: A list of layer lengths (the number of depth points). + backend: The backend to run the experiment on. Note that either ``backend`` or + ``two_qubit_gate`` and ``one_qubit_basis_gates`` must be set at instantiation. + num_samples: Number of samples (i.e. circuits) to generate for each layer length. + seed: Optional, seed used to initialize ``numpy.random.default_rng``. + when generating circuits. The ``default_rng`` will be initialized + with this seed value every time :meth:~.LayerFidelity.circuits` is called. + two_qubit_gate: Optional, 2q-gate name (e.g. "cx", "cz", "ecr") + of which the two qubit layers consist. + If not specified (but ``backend`` is supplied), + one of 2q-gates supported in the backend is automatically set. + one_qubit_basis_gates: Optional, 1q-gates to use for implementing 1q-Clifford operations. + If not specified (but ``backend`` is supplied), + all 1q-gates supported in the backend are automatically set. + + Raises: + QiskitError: If any invalid argument is supplied. + """ + # Compute full layers + full_layers = [] + for two_q_layer in two_qubit_layers: + qubits_in_layer = {q for qpair in two_q_layer for q in qpair} + if len(qubits_in_layer) != 2 * len(two_q_layer): + raise QiskitError("two_qubit_layers have a layer with gates on non-disjoint qubits") + for q in qubits_in_layer: + if q not in physical_qubits: + raise QiskitError(f"Qubit {q} in two_qubit_layers is not in physical_qubits") + layer = two_q_layer + [(q,) for q in physical_qubits if q not in qubits_in_layer] + full_layers.append(layer) + + # Initialize base experiment + super().__init__( + physical_qubits, analysis=LayerFidelityAnalysis(full_layers), backend=backend + ) + # assert isinstance(backend, BackendV2) + + # Verify parameters + if len(set(lengths)) != len(lengths): + raise QiskitError(f"The lengths list {lengths} should not contain duplicate elements.") + if num_samples <= 0: + raise QiskitError(f"The number of samples {num_samples} should be positive.") + + if two_qubit_gate is None: + if self.backend is None: + raise QiskitError("two_qubit_gate or backend must be supplied.") + # Try to set default two_qubit_gate from backend + for op in self.backend.target.operations: + if isinstance(op, Gate) and op.num_qubits == 2: + two_qubit_gate = op.name + LOG.info("%s is set for two_qubit_gate", op.name) + break + if not two_qubit_gate: + raise QiskitError("two_qubit_gate is not provided and failed to set from backend.") + else: + if self.backend is None and two_qubit_gate not in GATE_NAME_MAP: + raise QiskitError(f"Unknown two_qubit_gate: {two_qubit_gate}.") + + if one_qubit_basis_gates is None: + if self.backend is None: + raise QiskitError("one_qubit_basis_gates or backend must be supplied.") + # Try to set default one_qubit_basis_gates from backend + one_qubit_basis_gates = [] + for op in self.backend.target.operations: + if isinstance(op, Gate) and op.num_qubits == 1: + one_qubit_basis_gates.append(op.name) + LOG.info("%s is set for one_qubit_basis_gates", str(one_qubit_basis_gates)) + if not one_qubit_basis_gates: + raise QiskitError( + "one_qubit_basis_gates is not provided and failed to set from backend." + ) + else: + if self.backend is None: + for gate in one_qubit_basis_gates: + if gate not in GATE_NAME_MAP: + raise QiskitError(f"Unknown gate in one_qubit_basis_gates: {gate}.") + + # Set configurable options + self.set_experiment_options( + lengths=sorted(lengths), + num_samples=num_samples, + seed=seed, + two_qubit_layers=two_qubit_layers, + two_qubit_gate=two_qubit_gate, + one_qubit_basis_gates=tuple(one_qubit_basis_gates), + ) + + # Verify two_qubit_gate and one_qubit_basis_gates + self.__validate_basis_gates() + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + two_qubit_layers (List[List[Tuple[int, int]]]): List of two-qubit gate layers to run on. + Each two-qubit gate layer must be given as a list of directed qubit pairs. + lengths (List[int]): A list of layer lengths. + num_samples (int): Number of samples to generate for each layer length. + seed (None or int or SeedSequence or BitGenerator or Generator): A seed + used to initialize ``numpy.random.default_rng`` when generating circuits. + The ``default_rng`` will be initialized with this seed value every time + :meth:`circuits` is called. + two_qubit_gate (str): Two-qubit gate name (e.g. "cx", "cz", "ecr") + of which the two qubit layers consist. + one_qubit_basis_gates (Tuple[str]): One-qubit gates to use for implementing 1q Cliffords. + clifford_synthesis_method (str): The name of the Clifford synthesis plugin to use + for building circuits of RB sequences. + """ + options = super()._default_experiment_options() + options.update_options( + lengths=None, + num_samples=None, + seed=None, + two_qubit_layers=None, + two_qubit_gate=None, + one_qubit_basis_gates=None, + clifford_synthesis_method=DEFAULT_SYNTHESIS_METHOD, + ) + return options + + def set_experiment_options(self, **fields): + """Set the experiment options. + + Args: + fields: The fields to update the options + + Raises: + AttributeError: If the field passed in is not a supported options + """ + for field in fields: + if field in {"two_qubit_layers"}: + if ( + hasattr(self._experiment_options, field) + and self._experiment_options[field] is not None + ): + raise AttributeError(f"Options field {field} is not allowed to update.") + super().set_experiment_options(**fields) + + @classmethod + def _default_transpile_options(cls) -> Options: + """Default transpiler options for transpiling RB circuits.""" + return Options(optimization_level=1) + + def set_transpile_options(self, **fields): + """Transpile options is not supported for LayerFidelity experiments. + + Raises: + QiskitError: If `set_transpile_options` is called. + """ + raise QiskitError( + "Custom transpile options is not supported for LayerFidelity experiments." + ) + + def _set_backend(self, backend: Backend): + """Set the backend V2 for RB experiments since RB experiments only support BackendV2. + If BackendV1 is provided, it is converted to V2 and stored. + """ + if isinstance(backend, BackendV1): + super()._set_backend(BackendV2Converter(backend, add_delay=True)) + else: + super()._set_backend(backend) + self.__validate_basis_gates() + + def __validate_basis_gates(self) -> None: + if not self.backend: + return + opts = self.experiment_options + # validate two_qubit_gate if it is set + if opts.two_qubit_gate: + if opts.two_qubit_gate not in self.backend.target.operation_names: + raise QiskitError(f"two_qubit_gate {opts.two_qubit_gate} is not in backend.target") + for two_q_layer in opts.two_qubit_layers: + for qpair in two_q_layer: + if not self.backend.target.instruction_supported(opts.two_qubit_gate, qpair): + raise QiskitError(f"{opts.two_qubit_gate}{qpair} is not in backend.target") + # validate one_qubit_basis_gates if it is set + for gate in opts.one_qubit_basis_gates or []: + if gate not in self.backend.target.operation_names: + raise QiskitError(f"{gate} in one_qubit_basis_gates is not in backend.target") + for gate in opts.one_qubit_basis_gates or []: + for q in self.physical_qubits: + if not self.backend.target.instruction_supported(gate, (q,)): + raise QiskitError(f"{gate}({q}) is not in backend.target") + + def __residual_qubits(self, two_qubit_layer): + qubits_in_layer = {q for qpair in two_qubit_layer for q in qpair} + return [q for q in self.physical_qubits if q not in qubits_in_layer] + + def circuits(self) -> List[QuantumCircuit]: + r"""Return a list of physical circuits to measure layer fidelity. + + Returns: + A list of :class:`QuantumCircuit`\s. + """ + return list(self.circuits_generator()) + + def circuits_generator(self) -> Iterable[QuantumCircuit]: + r"""Return a generator of physical circuits to measure layer fidelity. + + Returns: + A generator of :class:`QuantumCircuit`\s. + """ + opts = self.experiment_options + residal_qubits_by_layer = [self.__residual_qubits(layer) for layer in opts.two_qubit_layers] + rng = default_rng(seed=opts.seed) + # define functions and variables for speed + _to_gate_1q = functools.partial( + _clifford_1q_int_to_instruction, + basis_gates=opts.one_qubit_basis_gates, + synthesis_method=opts.clifford_synthesis_method, + ) + _to_gate_2q = functools.partial( + _clifford_2q_int_to_instruction, + basis_gates=(opts.two_qubit_gate,) + opts.one_qubit_basis_gates, + coupling_tuple=((0, 1),), + synthesis_method=opts.clifford_synthesis_method, + ) + if self.backend: + gate2q = self.backend.target.operation_from_name(opts.two_qubit_gate) + else: + gate2q = GATE_NAME_MAP[opts.two_qubit_gate] + gate2q_cliff = num_from_2q_circuit(Clifford(gate2q).to_circuit()) + # Circuit generation + num_qubits = max(self.physical_qubits) + 1 + for i_sample in range(opts.num_samples): + for i_set, (two_qubit_layer, one_qubits) in enumerate( + zip(opts.two_qubit_layers, residal_qubits_by_layer) + ): + num_2q_gates = len(two_qubit_layer) + num_1q_gates = len(one_qubits) + composite_qubits = two_qubit_layer + [(q,) for q in one_qubits] + composite_clbits = [(2 * c, 2 * c + 1) for c in range(num_2q_gates)] + composite_clbits.extend( + [(c,) for c in range(2 * num_2q_gates, 2 * num_2q_gates + num_1q_gates)] + ) + for length in opts.lengths: + circ = QuantumCircuit(num_qubits, num_qubits) + barrier_inst = CircuitInstruction(Barrier(num_qubits), circ.qubits) + self.__circuit_body( + circ, + length, + two_qubit_layer, + one_qubits, + rng, + _to_gate_1q, + _to_gate_2q, + gate2q, + gate2q_cliff, + barrier_inst, + ) + # add the measurements + circ._append(barrier_inst) + for qubits, clbits in zip(composite_qubits, composite_clbits): + circ.measure(qubits, clbits) + # store composite structure in metadata + circ.metadata = { + "experiment_type": "BatchExperiment", + "composite_metadata": [ + { + "experiment_type": "ParallelExperiment", + "composite_index": list(range(len(composite_qubits))), + "composite_metadata": [ + { + "experiment_type": "SubLayerFidelity", + "physical_qubits": qpair, + "sample": i_sample, + "xval": length, + } + for qpair in two_qubit_layer + ] + + [ + { + "experiment_type": "SubLayerFidelity", + "physical_qubits": (q,), + "sample": i_sample, + "xval": length, + } + for q in one_qubits + ], + "composite_qubits": composite_qubits, + "composite_clbits": composite_clbits, + } + ], + "composite_index": [i_set], + } + yield circ + + @staticmethod + def __circuit_body( + circ, + length, + two_qubit_layer, + one_qubits, + rng, + _to_gate_1q, + _to_gate_2q, + gate2q, + gate2q_cliff, + barrier_inst, + ): + # initialize cliffords and a ciruit (0: identity clifford) + cliffs_2q = [0] * len(two_qubit_layer) + cliffs_1q = [0] * len(one_qubits) + for _ in range(length): + # sample random 1q-Clifford layer + for j, qpair in enumerate(two_qubit_layer): + # sample product of two 1q-Cliffords as 2q interger Clifford + samples = rng.integers(NUM_1Q_CLIFFORD, size=2) + cliffs_2q[j] = compose_2q(cliffs_2q[j], _tensor_1q_nums(*samples)) + # For Clifford 1 (x) Clifford 2, in its circuit representation, + # Clifford 1 acts on the 2nd qubit and Clifford 2 acts on the 1st qubit. + # That's why the qpair is reversed here. + for sample, q in zip(samples, reversed(qpair)): + circ._append(_to_gate_1q(sample), (circ.qubits[q],), ()) + for k, q in enumerate(one_qubits): + sample = rng.integers(NUM_1Q_CLIFFORD) + cliffs_1q[k] = compose_1q(cliffs_1q[k], sample) + circ._append(_to_gate_1q(sample), (circ.qubits[q],), ()) + circ._append(barrier_inst) + # add two qubit gates + for j, qpair in enumerate(two_qubit_layer): + circ._append(gate2q, tuple(circ.qubits[q] for q in qpair), ()) + cliffs_2q[j] = compose_2q(cliffs_2q[j], gate2q_cliff) + # TODO: add dd if necessary + for k, q in enumerate(one_qubits): + # TODO: add dd if necessary + pass + circ._append(barrier_inst) + # add the last inverse + for j, qpair in enumerate(two_qubit_layer): + inv = inverse_2q(cliffs_2q[j]) + circ._append(_to_gate_2q(inv), tuple(circ.qubits[q] for q in qpair), ()) + for k, q in enumerate(one_qubits): + inv = inverse_1q(cliffs_1q[k]) + circ._append(_to_gate_1q(inv), (circ.qubits[q],), ()) + return circ + + def _transpiled_circuits(self) -> List[QuantumCircuit]: + """Return a list of experiment circuits, transpiled.""" + transpiled = [_decompose_clifford_ops(circ) for circ in self.circuits()] + # Set custom calibrations provided in backend + if isinstance(self.backend, BackendV2): + instructions = [] # (op_name, qargs) for each element where qargs mean qubit tuple + for two_qubit_layer in self.experiment_options.two_qubit_layers: + for qpair in two_qubit_layer: + instructions.append((self.experiment_options.two_qubit_gate, tuple(qpair))) + for q in self.physical_qubits: + for gate_1q in self.experiment_options.one_qubit_basis_gates: + instructions.append((gate_1q, (q,))) + + common_calibrations = defaultdict(dict) + for op_name, qargs in instructions: + inst_prop = self.backend.target[op_name].get(qargs, None) + if inst_prop is None: + continue + schedule = inst_prop.calibration + if schedule is None: + continue + publisher = schedule.metadata.get("publisher", CalibrationPublisher.QISKIT) + if publisher != CalibrationPublisher.BACKEND_PROVIDER: + common_calibrations[op_name][(qargs, tuple())] = schedule + + for circ in transpiled: + circ.calibrations = common_calibrations + + return transpiled + + def _metadata(self): + metadata = super()._metadata() + metadata["two_qubit_layers"] = self.experiment_options.two_qubit_layers + return metadata + + @classmethod + def from_config(cls, config: Union[ExperimentConfig, Dict]) -> "LayerFidelity": + """Initialize an experiment from experiment config""" + if isinstance(config, dict): + config = ExperimentConfig(**dict) + ret = cls(*config.args, **config.kwargs) + if config.run_options: + ret.set_run_options(**config.run_options) + return ret diff --git a/qiskit_experiments/library/randomized_benchmarking/layer_fidelity_analysis.py b/qiskit_experiments/library/randomized_benchmarking/layer_fidelity_analysis.py new file mode 100644 index 0000000000..8508863ad8 --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/layer_fidelity_analysis.py @@ -0,0 +1,353 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Analysis classes for Layer Fidelity RB. +""" +from typing import List, Tuple, Union + +import logging +import traceback +import lmfit +import numpy as np + +import qiskit_experiments.curve_analysis as curve +import qiskit_experiments.database_service.device_component as device +from qiskit_experiments.exceptions import AnalysisError +from qiskit_experiments.framework import CompositeAnalysis, AnalysisResultData, ExperimentData +from qiskit_experiments.framework.containers import FigureType, ArtifactData + +LOG = logging.getLogger(__name__) + + +class _ProcessFidelityAnalysis(curve.CurveAnalysis): + r"""A class to estimate process fidelity from one of 1Q/2Q simultaneous direct RB experiments + + # section: overview + This analysis takes only a single series. + This series is fit by the exponential decay function. + From the fit :math:`\alpha` value this analysis estimates the process fidelity: + .. math:: F = \frac{1+(d^2-1)\alpha}{d^2} + + # section: fit_model + .. math:: + + F(x) = a \alpha^x + b + + # section: fit_parameters + defpar a: + desc: Height of decay curve. + init_guess: Determined by :math:`1 - b`. + bounds: [0, 1] + defpar b: + desc: Base line. + init_guess: Determined by :math:`(1/2)^n` where :math:`n` is number of qubit. + bounds: [0, 1] + defpar \alpha: + desc: Depolarizing parameter. + init_guess: Determined by :func:`~.guess.rb_decay`. + bounds: [0, 1] + + # section: reference + .. ref_arxiv:: 1 2311.05933 + """ + + def __init__(self, physical_qubits): + super().__init__( + models=[ + lmfit.models.ExpressionModel( + expr="a * alpha ** x + b", + name="rb_decay", + ) + ] + ) + self._physical_qubits = physical_qubits + self.set_options( + outcome="0" * len(physical_qubits), + figure_names="DirectRB_Q" + "_Q".join(map(str, physical_qubits)) + ".svg", + ) + self.plotter.set_figure_options( + figure_title=f"Simultaneous Direct RB on Qubit{physical_qubits}", + ) + + @classmethod + def _default_options(cls): + """Default analysis options.""" + default_options = super()._default_options() + default_options.plotter.set_figure_options( + xlabel="Layers", + ylabel="Ground State Population", + ) + default_options.plot_raw_data = True + default_options.result_parameters = ["alpha"] + default_options.average_method = "sample" + + return default_options + + def _generate_fit_guesses( + self, + user_opt: curve.FitOptions, + curve_data: curve.ScatterTable, + ) -> Union[curve.FitOptions, List[curve.FitOptions]]: + """Create algorithmic initial fit guess from analysis options and curve data. + + Args: + user_opt: Fit options filled with user provided guess and bounds. + curve_data: Formatted data collection to fit. + + Returns: + List of fit options that are passed to the fitter function. + """ + user_opt.bounds.set_if_empty( + a=(0, 1), + alpha=(0, 1), + b=(0, 1), + ) + + b_guess = 1 / 2 ** len(self._physical_qubits) + alpha_guess = curve.guess.rb_decay(curve_data.x, curve_data.y, b=b_guess) + a_guess = (curve_data.y[0] - b_guess) / (alpha_guess ** curve_data.x[0]) + + user_opt.p0.set_if_empty( + b=b_guess, + a=a_guess, + alpha=alpha_guess, + ) + + return user_opt + + def _create_analysis_results( + self, + fit_data: curve.CurveFitResult, + quality: str, + **metadata, + ) -> List[AnalysisResultData]: + """Create analysis results for important fit parameters. + + Args: + fit_data: Fit outcome. + quality: Quality of fit outcome. + + Returns: + List of analysis result data. + """ + outcomes = super()._create_analysis_results(fit_data, quality, **metadata) + num_qubits = len(self._physical_qubits) + d = 2**num_qubits + + # Calculate process fidelity + alpha = fit_data.ufloat_params["alpha"] + pf = (1 + (d * d - 1) * alpha) / (d * d) + + quality, reason = self._evaluate_quality_with_reason(fit_data) + + metadata["qubits"] = self._physical_qubits + metadata["reason"] = reason + metadata.update(fit_data.params) + outcomes.append( + AnalysisResultData( + name="ProcessFidelity", + value=pf, + chisq=fit_data.reduced_chisq, + quality=quality, + extra=metadata, + ) + ) + return outcomes + + def _run_analysis( + self, experiment_data: ExperimentData + ) -> Tuple[List[Union[AnalysisResultData, ArtifactData]], List[FigureType]]: + try: + return super()._run_analysis(experiment_data) + except Exception: # pylint: disable=broad-except + LOG.error( + "%s(%s) failed: %s", + self.__class__.__name__, + str(self._physical_qubits), + traceback.format_exc(), + ) + failed_result = AnalysisResultData( + name="ProcessFidelity", + value=None, + quality="bad", + extra={"qubits": self._physical_qubits, "reason": "analysis_failure"}, + ) + return [failed_result], [] + + def _get_experiment_components(self, experiment_data: ExperimentData): + """Set physical qubits to the experiment components.""" + return [device.Qubit(qubit) for qubit in self._physical_qubits] + + def _evaluate_quality_with_reason( + self, + fit_data: curve.CurveFitResult, + ) -> Tuple[str, Union[str, None]]: + """Evaluate quality of the fit result and the reason if it is no good. + + Args: + fit_data: Fit outcome. + + Returns: + Pair of strings that represent quality ("good" or "bad") and its reason if "bad". + """ + # Too large SPAM + y_intercept = fit_data.params["a"] + fit_data.params["b"] + if y_intercept < 0.7: + return "bad", "large_spam" + # Convergence to a bad value (probably due to bad readout) + ideal_limit = 1 / (2 ** len(self._physical_qubits)) + if fit_data.params["b"] <= 0 or abs(fit_data.params["b"] - ideal_limit) > 0.3: + return "bad", "biased_tail" + # Too good fidelity (negative decay) + if fit_data.params["alpha"] < 0: + return "bad", "negative_decay" + # Large residual errors in terms of reduced Chi-square + if fit_data.reduced_chisq > 3.0: + return "bad", "large_chisq" + # Too good Chi-square + if fit_data.reduced_chisq == 0: + return "bad", "zero_chisq" + return "good", None + + +class _SingleLayerFidelityAnalysis(CompositeAnalysis): + """A class to estimate a process fidelity per disjoint layer.""" + + def __init__(self, layer, analyses=None): + if analyses: + if len(layer) != len(analyses): + raise AnalysisError("'analyses' must have the same length with 'layer'") + else: + analyses = [_ProcessFidelityAnalysis(qubits) for qubits in layer] + + super().__init__(analyses, flatten_results=True) + self._layer = layer + + def _run_analysis( + self, experiment_data: ExperimentData + ) -> Tuple[List[Union[AnalysisResultData, ArtifactData]], List[FigureType]]: + try: + # Run composite analysis and extract sub-experiments results + analysis_results, figures = super()._run_analysis(experiment_data) + # Calculate single layer fidelity from process fidelities of subsystems + pf_results = [res for res in analysis_results if res.name == "ProcessFidelity"] + pfs = [res.value for res in pf_results] + slf = np.prod(pfs) + quality_slf = "good" if all(sub.quality == "good" for sub in pf_results) else "bad" + slf_result = AnalysisResultData( + name="SingleLF", + value=slf, + quality=quality_slf, + extra={"qubits": [q for qubits in self._layer for q in qubits]}, + ) + # Return combined results + analysis_results = [slf_result] + analysis_results + return analysis_results, figures + except Exception: # pylint: disable=broad-except + LOG.error("%s failed: %s", self.__class__.__name__, traceback.format_exc()) + failed_result = AnalysisResultData( + name="SingleLF", + value=None, + quality="bad", + extra={ + "qubits": [q for qubits in self._layer for q in qubits], + "reason": "analysis_failure", + }, + ) + return [failed_result] + analysis_results, figures + + def _get_experiment_components(self, experiment_data: ExperimentData): + """Set physical qubits to the experiment components.""" + return [device.Qubit(q) for qubits in self._layer for q in qubits] + + +class LayerFidelityAnalysis(CompositeAnalysis): + r"""A class to analyze layer fidelity experiments. + + # section: overview + It estimates Layer Fidelity and EPLG (error per layered gate) + by fitting the exponential curve to estimate the decay rate, hence the process fidelity, + for each 2-qubit (or 1-qubit) direct randomized benchmarking result. + See Ref. [1] for details. + + # section: reference + .. ref_arxiv:: 1 2311.05933 + """ + + def __init__(self, layers, analyses=None): + if analyses: + if len(layers) != len(analyses): + raise AnalysisError("'analyses' must have the same length with 'layers'") + else: + analyses = [_SingleLayerFidelityAnalysis(a_layer) for a_layer in layers] + + super().__init__(analyses, flatten_results=True) + self.num_layers = len(layers) + self.num_2q_gates = sum(1 if len(qs) == 2 else 0 for lay in layers for qs in lay) + + def _run_analysis( + self, experiment_data: ExperimentData + ) -> Tuple[List[Union[AnalysisResultData, ArtifactData]], List[FigureType]]: + r"""Run analysis for Layer Fidelity experiment. + + It invokes :meth:`CompositeAnalysis._run_analysis` that will recursively invoke + ``_run_analysis`` of the sub-experiments (1Q/2Q simultaneous direct RBs for each layer). + Based on the results, it computes Layer Fidelity and EPLG (error per layered gate). + + Args: + experiment_data: the experiment data to analyze. + + Returns: + A pair ``(analysis_results, figures)`` where ``analysis_results`` + is a list of :class:`AnalysisResultData` objects, and ``figures`` + is a list of any figures for the experiment. + If an analysis fails, an analysis result with ``None`` value will be returned. + """ + try: + # Run composite analysis and extract sub-experiments results + analysis_results, figures = super()._run_analysis(experiment_data) + # Calculate full layer fidelity from single layer fidelities + slf_results = [res for res in analysis_results if res.name == "SingleLF"] + slfs = [res.value for res in slf_results] + lf = np.prod(slfs) + quality_lf = "good" if all(sub.quality == "good" for sub in slf_results) else "bad" + lf_result = AnalysisResultData( + name="LF", + value=lf, + quality=quality_lf, + ) + eplg = 1 - (lf ** (1 / self.num_2q_gates)) + eplg_result = AnalysisResultData( + name="EPLG", + value=eplg, + quality=quality_lf, + ) + # Return combined results + analysis_results = [lf_result, eplg_result] + analysis_results + return analysis_results, figures + except Exception: # pylint: disable=broad-except + LOG.error("%s failed: %s", self.__class__.__name__, traceback.format_exc()) + failed_results = [ + AnalysisResultData( + name="LF", + value=None, + quality="bad", + extra={"reason": "analysis_failure"}, + ), + AnalysisResultData( + name="EPLG", + value=None, + quality="bad", + extra={"reason": "analysis_failure"}, + ), + ] + return failed_results + analysis_results, figures diff --git a/releasenotes/notes/layer-fidelity-1e09dea9e5b69515.yaml b/releasenotes/notes/layer-fidelity-1e09dea9e5b69515.yaml new file mode 100644 index 0000000000..b72f42a615 --- /dev/null +++ b/releasenotes/notes/layer-fidelity-1e09dea9e5b69515.yaml @@ -0,0 +1,25 @@ +--- +features: + - | + Add a new experiment class :class:`.LayerFidelity` to measure + `layer fidelity and EPLG (error per layered gate) `_, + which is a holistic benchmark to characterize the full quality of the devices at scale. + + It has an experimental feature: its :meth:`circuits` + exceptionally returns circuits on physical qubits (not virtual qubits as usual). + Its analysis class :class:`.LayerFidelityAnalysis` returns :class:`.AnalysisResultData` + which contains several ``extra`` entries to help additional analyses: e.g. + ``qubits`` to ease the query of subanalysis results and + ``reason`` to tell users why the ``quality`` of the analysis was ``"bad"``. + + For example, the syntax for pulling out the individual fidelities looks like below. + + .. code-block:: python + + df = exp_data.analysis_results(dataframe=True) + df[(df.name=="ProcessFidelity") & (df.qubits==(59, 60))].value + + See `an example notebook + `_ + for more examples such as how to select a best possible qubit chain to measure and + how to plot EPLG as a function of (sub)chain length. \ No newline at end of file diff --git a/test/library/randomized_benchmarking/test_layer_fidelity.py b/test/library/randomized_benchmarking/test_layer_fidelity.py new file mode 100644 index 0000000000..00f7ea28fb --- /dev/null +++ b/test/library/randomized_benchmarking/test_layer_fidelity.py @@ -0,0 +1,219 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Test for layer fidelity experiments.""" +from test.base import QiskitExperimentsTestCase +from test.library.randomized_benchmarking.mixin import RBTestMixin +import copy +import numpy as np +from ddt import ddt, data, unpack + +from qiskit.circuit.library import SXGate +from qiskit.exceptions import QiskitError +from qiskit.pulse import Schedule +from qiskit_ibm_runtime.fake_provider import FakeManilaV2 +from qiskit_experiments.library.randomized_benchmarking import LayerFidelity, LayerFidelityAnalysis + + +@ddt +class TestLayerFidelity(QiskitExperimentsTestCase, RBTestMixin): + """Test for LayerFidelity without running the experiments.""" + + # ### Tests for configuration ### + def test_experiment_config(self): + """Test converting to and from config works""" + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + loaded_exp = LayerFidelity.from_config(exp.config()) + self.assertNotEqual(exp, loaded_exp) + self.assertEqualExtended(exp, loaded_exp) + + def test_invalid_two_qubit_layers(self): + """Test raise error when creating experiment with invalid configs.""" + valid_kwargs = { + "lengths": [10, 20, 30], + "two_qubit_gate": "cx", + "one_qubit_basis_gates": ["rz", "sx", "x"], + } + # not disjoit + with self.assertRaises(QiskitError): + LayerFidelity( + physical_qubits=(0, 1, 2, 3), two_qubit_layers=[[(0, 1), (1, 2)]], **valid_kwargs + ) + # no 2q-gate on the qubits (FakeManilaV2 has no cx gate on (0, 3)) + with self.assertRaises(QiskitError): + LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(0, 3)]], + backend=FakeManilaV2(), + **valid_kwargs, + ) + + def test_roundtrip_serializable(self): + """Test round trip JSON serialization""" + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + self.assertRoundTripSerializable(exp, strict_type=False) + + def test_circuit_roundtrip_serializable(self): + """Test circuits round trip JSON serialization""" + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + self.assertRoundTripSerializable(exp._transpiled_circuits()) + + def test_analysis_config(self): + """ "Test converting analysis to and from config works""" + analysis = LayerFidelityAnalysis(layers=[[(1, 0), (2, 3)], [(1, 2), (0,), (3,)]]) + loaded = LayerFidelityAnalysis.from_config(analysis.config()) + self.assertNotEqual(analysis, loaded) + self.assertEqual(analysis.config(), loaded.config()) + + # ### Tests for circuit generation ### + @data( + [(1, 2), [[(1, 2)]]], + [(1, 3, 4), [[(3, 4)]]], + [(4, 3, 2, 1, 0), [[(0, 1), (3, 2)], [(1, 2), (3, 4)]]], + ) + @unpack + def test_generate_circuits(self, qubits, two_qubit_layers): + """Test RB circuit generation""" + exp = LayerFidelity( + physical_qubits=qubits, + two_qubit_layers=two_qubit_layers, + lengths=[1, 2, 3], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + circuits = exp.circuits() + self.assertAllIdentity(circuits) + + def test_return_same_circuit_for_same_config(self): + """Test if setting the same seed returns the same circuits.""" + exp1 = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + + exp2 = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + two_qubit_gate="cx", + one_qubit_basis_gates=["rz", "sx", "x"], + ) + + circs1 = exp1.circuits() + circs2 = exp2.circuits() + + self.assertEqual(circs1[0].decompose(), circs2[0].decompose()) + self.assertEqual(circs1[1].decompose(), circs2[1].decompose()) + self.assertEqual(circs1[2].decompose(), circs2[2].decompose()) + + # ### Tests for transpiled circuit generation ### + def test_calibrations_via_custom_backend(self): + """Test if calibrations given as custom backend show up in transpiled circuits.""" + qubits = (2,) + my_sched = Schedule(name="custom_sx_gate") + my_backend = copy.deepcopy(FakeManilaV2()) + my_backend.target["sx"][qubits].calibration = my_sched + + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[10, 20, 30], + seed=42, + backend=my_backend, + ) + transpiled = exp._transpiled_circuits() + for qc in transpiled: + self.assertTrue(qc.calibrations) + self.assertTrue(qc.has_calibration_for((SXGate(), [qc.qubits[q] for q in qubits], []))) + self.assertEqual(qc.calibrations["sx"][(qubits, tuple())], my_sched) + + def test_backend_with_directed_basis_gates(self): + """Test if correct circuits are generated from backend with directed basis gates.""" + my_backend = copy.deepcopy(FakeManilaV2()) + del my_backend.target["cx"][(1, 2)] # make cx on {1, 2} one-sided + + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(2, 1)]], + lengths=[10, 20, 30], + seed=42, + num_samples=1, + backend=my_backend, + ) + transpiled = exp._transpiled_circuits() + for qc in transpiled[3:]: # check only the second layer + self.assertTrue(qc.count_ops().get("cx", 0) > 0) + expected_qubits = (qc.qubits[2], qc.qubits[1]) + for inst in qc: + if inst.operation.name == "cx": + self.assertEqual(inst.qubits, expected_qubits) + + +class TestRunLayerFidelity(QiskitExperimentsTestCase, RBTestMixin): + """Test for running LayerFidelity on noisy simulator.""" + + def test_run_layer_fidelity(self): + """Test layer fidelity RB. Use default basis gates.""" + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[1, 4, 16, 64, 256], + seed=42, + backend=FakeManilaV2(), + ) + expdata = exp.run() + self.assertExperimentDone(expdata) + + lf = expdata.analysis_results("LF").value.n + slfs = [res.value.n for res in expdata.analysis_results("SingleLF")] + self.assertAlmostEqual(lf, np.prod(slfs)) + + def test_expdata_serialization(self): + """Test serializing experiment data works.""" + exp = LayerFidelity( + physical_qubits=(0, 1, 2, 3), + two_qubit_layers=[[(1, 0), (2, 3)], [(1, 2)]], + lengths=[1, 4, 16, 64, 256], + seed=42, + backend=FakeManilaV2(), + ) + expdata = exp.run() + self.assertExperimentDone(expdata) + self.assertRoundTripSerializable(expdata) + self.assertRoundTripPickle(expdata)