diff --git a/docs/conf.py b/docs/conf.py index 5b2b90a979..9c12751e54 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -82,6 +82,7 @@ nbsphinx_thumbnails = { "manuals/verification/quantum_volume": "_images/quantum_volume_2_0.png", "manuals/measurement/readout_mitigation": "_images/readout_mitigation_4_0.png", + "manuals/verification/mirror_rb": "_images/mirror_rb_1_0.png", "manuals/verification/randomized_benchmarking": "_images/randomized_benchmarking_3_1.png", "manuals/measurement/restless_measurements": "_images/restless_shots.png", "manuals/verification/state_tomography": "_images/state_tomography_3_0.png", @@ -159,6 +160,7 @@ intersphinx_mapping = { "matplotlib": ("https://matplotlib.org/stable/", None), "qiskit": ("https://qiskit.org/documentation/", None), + "pygsti": ("https://pygsti.readthedocs.io/en/latest/", None), "uncertainties": ("https://pythonhosted.org/uncertainties", None), "qiskit_ibm_provider": ("https://qiskit.org/documentation/partners/qiskit_ibm_provider", None), } diff --git a/docs/manuals/verification/images/pygsti-data-pygsti-transpiled-circ.png b/docs/manuals/verification/images/pygsti-data-pygsti-transpiled-circ.png new file mode 100644 index 0000000000..9718be96b7 Binary files /dev/null and b/docs/manuals/verification/images/pygsti-data-pygsti-transpiled-circ.png differ diff --git a/docs/manuals/verification/images/pygsti-data-qiskit-transpiled-circ.png b/docs/manuals/verification/images/pygsti-data-qiskit-transpiled-circ.png new file mode 100644 index 0000000000..0edd3a7a6d Binary files /dev/null and b/docs/manuals/verification/images/pygsti-data-qiskit-transpiled-circ.png differ diff --git a/docs/manuals/verification/mirror_rb.rst b/docs/manuals/verification/mirror_rb.rst new file mode 100644 index 0000000000..2a6222f8dc --- /dev/null +++ b/docs/manuals/verification/mirror_rb.rst @@ -0,0 +1,286 @@ +Mirror Randomized Benchmarking +============================== + +Mirror randomized benchmarking (mirror RB) is a randomized benchmarking protocol +where layers of gates are sampled from a distribution and then run on a set of +qubits along with their mirror inverses. A randomized Clifford mirror circuit +[1]_, which we will be running in this manual, is a specific type of mirror RB +and consists of: + +- random n-qubit Clifford layers and their inverses sampled according to some + distribution :math:`\Omega` over a layer set :math:`\mathbb{L}`, + +- uniformly random one-qubit Paulis between these layers, and + +- a layer of uniformly random one-qubit Cliffords at the beginning and the end + of the circuit. + +Note that the random n-qubit Clifford layers can be realized with only one-qubit +Cliffords and a two-qubit gate such as CX, which twirl the local errors +sufficiently to produce a useful metric of gate infidelity. This is in contrast +to standard RB, which requires the implementation of n-qubit Cliffords that have +much more overhead for large n. As a result, mirror RB is more scalable than +standard RB and is suitable for characterizing crosstalk errors over a large +number of qubits in a quantum device. Mirror RB can also be generalized to +universal gatesets beyond the Cliffords [2]_. + +Output metrics +-------------- + +In standard and interleaved RB, :math:`n`-qubit circuits of varying lengths :math:`\ell` +that compose to the identity are run on a device, and the **success probability** +:math:`P`, the probability that the circuit's output bit string equals the input bit +string, is estimated for each circuit length by running several circuits at each length. +The :math:`P`-versus-:math:`\ell` curve is fit to the function :math:`A\alpha^\ell + b`, +and the error per Clifford (EPC) (the average infidelity) is estimated using + +.. math:: + + r = \frac{\left(2^n - 1\right)p}{2^n}. + +Our implementation of MRB computes additional values in addition to the +success probability that have been seen in the literature and ``pyGSTi``. +Specifically, we compute the **adjusted success probability** + +.. math:: + + P_0 = \sum_{k=0}^n \left(-\frac{1}{2}\right)^k h_k, + +where :math:`h_k` is the probability of the actual output bit string being Hamming +distance :math:`k` away from the expected output bit string (note :math:`h_0 = P`). We +also compute the **effective polarization**, which is fitted and visualized by default: + +.. math:: + + S = \frac{4^n P_0}{4^n - 1} - \frac{1}{4^n - 1}. + +In [1]_, the function :math:`A\alpha^\ell` (without a baseline) is fit to the +effective polarizations to find entanglement infidelities. + +In Qiskit Experiments, mirror RB analysis results include the following: + +- ``alpha``: the depolarizing parameter. The user can select which of :math:`P, P_0, S` + to fit, and the corresponding :math:`\alpha` will be provided. + +- ``EPC``: the expectation of the average gate infidelity of a layer sampled + according to :math:`\Omega`. + +- ``EI``: the expectation of the entanglement infidelity of a layer sampled + according to :math:`\Omega`. + +Note that the ``EPC`` :math:`\epsilon_a` and the ``EI`` :math:`\epsilon_e` are +related by + +.. math:: + + \epsilon_e = \left(1 + \frac{1}{2^n}\right) \epsilon_a, + +where :math:`n` is the number of qubits (see [2]_). + + +Running a mirror RB experiment +------------------------------ + +The distribution for sampling layers, :math:`\Omega`, must be specified by the user when +instantiating a mirror RB experiment. A commonly used :math:`\Omega` is one generated by +the **edge grab** algorithm [3]_. The Clifford layers in :math:`\mathbb{L}` are +constructed from a gate set consisting of one-qubit Clifford gates and a single +two-qubit Clifford gate (e.g., CX) that can be applied to any two connected qubits. The +user can specify an expected two-qubit gate density :math:`\xi \in \left[0, +\frac{1}{2}\right]`, and each intermediate Clifford layer will have approximately +:math:`n \xi` CXs on average. + +Even though a :class:`.MirrorRB` experiment can be instantiated without a +backend, the backend must be specified when the circuits are sampled because +sampling algorithms containing two-qubit gates need to know the backend's +connectivity. To use your own :math:`\Omega`, you must implement your own +subclass of the abstract :class:`.BaseSampler` class, but in this manual we will +use the built-in :class:`.EdgeGrabSampler`. Here's how to instantiate and run +the experiment: + +.. jupyter-execute:: + + import numpy as np + from qiskit_experiments.library import MirrorRB + from qiskit_experiments.library.randomized_benchmarking.sampling_utils import EdgeGrabSampler + + from qiskit_aer import AerSimulator + from qiskit.providers.fake_provider import FakeParisV2 + + backend = AerSimulator.from_backend(FakeParisV2()) + + lengths = np.arange(2, 810, 200) + num_samples = 5 + seed = 1010 + qubits = (0,1) + + exp_2q = MirrorRB(qubits, lengths, backend=backend, num_samples=num_samples, seed=seed) + expdata_2q = exp_2q.run(backend).block_for_results() + results_2q = expdata_2q.analysis_results() + +.. jupyter-execute:: + + display(expdata_2q.figure(0)) + for result in results_2q: + print(result) + +Selecting the analyzed quantity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can set what you want to use as the metric for fitting by setting the +``analyzed_quantity`` analysis option. Here's an example of plotting the success +probability instead of the default: + +.. jupyter-execute:: + + lengths = np.arange(2,202,50) + num_samples = 5 + seed = 42 + qubits = (0,) + + exp = MirrorRB(qubits, lengths, backend=backend, num_samples=num_samples, seed=seed) + + # select analyzed_quantity, can also be "Adjusted Success Probability" or "Effective Polarization" + exp.analysis.set_options(analyzed_quantity="Success Probability") + + # y-axis label must be set separately + exp.analysis.options.plotter.set_figure_options( + ylabel="Success Probability", + ) + expdata = exp.run(backend).block_for_results() + results = expdata.analysis_results() + +.. jupyter-execute:: + + display(expdata.figure(0)) + for result in results: + print(result) + + +Mirror RB user options +~~~~~~~~~~~~~~~~~~~~~~ + +There are several options that change the composition of the mirror RB circuit layers. + +- ``pauli_randomize`` (default ``True``): if ``True``, put layers of uniformly + random Paulis between the intermediate sampled layers + +- ``start_end_clifford`` (default ``True``): if ``True``, begin the circuit with + uniformly random one-qubit Cliffords and end the circuit with their inverses + +- ``inverting_pauli_layer`` (default ``False``): if ``True``, add a layer of + Paulis at the end of the circuit to set the output to + :math:`\left\vert0\right\rangle^{\otimes n}`, up to a global phase + +The default settings produce the circuits in Ref. [1]_. + +Let's look at how these options change the circuit. First, the default with Pauli layers +between Cliffords and single-qubit Cliffords at the start and end: + +.. jupyter-execute:: + + exp = MirrorRB((0,1,2), + lengths=[2], + seed=100, + backend=backend, + num_samples=1) + exp.circuits()[0].decompose().remove_final_measurements(inplace=False).draw("mpl") + +And now with the start and end Clifford layers turned off and the inverting Pauli layer added at the end: + +.. jupyter-execute:: + + exp = MirrorRB((0,1,2), + lengths=[2], + seed=100, + backend=backend, + num_samples=1, + start_end_clifford=False, + pauli_randomize=True, + inverting_pauli_layer=True) + exp.circuits()[0].decompose().remove_final_measurements(inplace=False).draw("mpl") + +Another important option is ``two_qubit_gate_density`` (default ``0.2``). This is the +expected fraction of two-qubit gates in the circuit, not accounting for the optional +constant number of Clifford and Pauli layers at the start and end. This means that given +the same ``two_qubit_gate_density``, if ``pauli_randomize`` is off, the concentration of +two-qubit gates in the Clifford layers will be halved so that the overall density doesn't +change. We'll demonstrate this by first leaving ``pauli_randomize`` on: + +.. jupyter-execute:: + + # choose a linear string on this backend for ease of visualization + exp = MirrorRB((0,1,2,3,5,8,11,14), + lengths=[2], + two_qubit_gate_density=0.5, + seed=100, + backend=backend, + num_samples=1, + start_end_clifford=False) + exp.circuits()[0].remove_final_measurements(inplace=False).draw("mpl") + +And now we remove the Pauli layers to see that the CX density in the Clifford layers +has decreased: + +.. jupyter-execute:: + + exp = MirrorRB((0,1,2,3,5,8,11,14), + lengths=[2], + two_qubit_gate_density=0.5, + pauli_randomize=False, + seed=100, + backend=backend, + num_samples=1, + start_end_clifford=False) + exp.circuits()[0].remove_final_measurements(inplace=False).draw("mpl") + +Note that the edge grab algorithm is probabilistic and only tends to the exact two +qubit gate density asymptotically, so you may not get layers fully packed with +two-qubit gates even if you specify a density of 1. + +The default interface of :class:`MirrorRB` only allows the above options in +addition to passing ``sampler_opts`` to your sampler upon instantiation. If you +decide to choose a custom gate set or implement your own sampler, note that the +validity of fitting the effective polarizaton as a function of circuit length to +obtain average gate infidelity depends on a list of assumptions [1]_ that may no +longer be valid. + +Mirror RB implementation in ``pyGSTi`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :mod:`pygsti` implementation of mirror RB, +:class:`~.pygsti.protocols.rb.MirrorRBDesign`, can be used for testing and comparison. +We note however that ``pyGSTi`` transpiles circuits slightly differently, producing +small discrepancies in fit parameters between the two codes. To illustrate, consider the +two circuits below, both of which were generated in ``pyGSTi``. This first circuit was +transpiled in ``pyGSTi``: + +.. image:: images/pygsti-data-pygsti-transpiled-circ.png + +This second circuit was transpiled in Qiskit: + +.. image:: images/pygsti-data-qiskit-transpiled-circ.png + +Note the different implementations of the same Clifford on +qubit 0 in the fifth layer. + +References +---------- + +.. [1] Timothy Proctor, Stefan Seritan, Kenneth Rudinger, Erik Nielsen, Robin + Blume-Kohout, Kevin Young, *Scalable randomized benchmarking of quantum + computers using mirror circuits*, https://arxiv.org/pdf/2112.09853.pdf + +.. [2] Hines, Jordan, et al. *Demonstrating scalable randomized benchmarking of + universal gate sets*, https://arxiv.org/abs/2207.07272 + +.. [3] Timothy Proctor, Kenneth Rudinger, Kevin Young, Erik Nielsen, and Robin + Blume-Kohout, *Measuring the Capabilities of Quantum Computers*, + https://arxiv.org/pdf/2008.11294.pdf + + +See also +-------- + +* API documentation: :mod:`.MirrorRB` +* Experiment manual: :doc:`/manuals/verification/randomized_benchmarking` diff --git a/qiskit_experiments/library/__init__.py b/qiskit_experiments/library/__init__.py index 4a4126f82b..fadcd49773 100644 --- a/qiskit_experiments/library/__init__.py +++ b/qiskit_experiments/library/__init__.py @@ -36,6 +36,7 @@ ~randomized_benchmarking.StandardRB ~randomized_benchmarking.InterleavedRB + ~randomized_benchmarking.MirrorRB ~tomography.TomographyExperiment ~tomography.StateTomography ~tomography.ProcessTomography @@ -183,7 +184,7 @@ class instance to manage parameters and pulse schedules. ZZRamsey, MultiStateDiscrimination, ) -from .randomized_benchmarking import StandardRB, InterleavedRB +from .randomized_benchmarking import StandardRB, InterleavedRB, MirrorRB from .tomography import ( TomographyExperiment, StateTomography, diff --git a/qiskit_experiments/library/randomized_benchmarking/__init__.py b/qiskit_experiments/library/randomized_benchmarking/__init__.py index 95d4fc5e89..3e2e5396b6 100644 --- a/qiskit_experiments/library/randomized_benchmarking/__init__.py +++ b/qiskit_experiments/library/randomized_benchmarking/__init__.py @@ -25,6 +25,7 @@ StandardRB InterleavedRB + MirrorRB Analysis @@ -36,15 +37,26 @@ RBAnalysis InterleavedRBAnalysis + MirrorRBAnalysis + +Utilities +========= .. autosummary:: :toctree: ../stubs/ RBUtils + BaseSampler + EdgeGrabSampler + SingleQubitSampler + """ from .standard_rb import StandardRB from .interleaved_rb_experiment import InterleavedRB +from .mirror_rb_experiment import MirrorRB +from .sampling_utils import BaseSampler, EdgeGrabSampler, SingleQubitSampler from .rb_analysis import RBAnalysis from .interleaved_rb_analysis import InterleavedRBAnalysis +from .mirror_rb_analysis import MirrorRBAnalysis from .clifford_utils import CliffordUtils from .rb_utils import RBUtils diff --git a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py index dbf7a27c8f..9a721f21f2 100644 --- a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py +++ b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py @@ -299,6 +299,18 @@ def _unpack_num(num, sig): return res +def compute_target_bitstring(circuit: QuantumCircuit) -> str: + """For a Pauli circuit C, which consists only of Clifford gates, compute C|0>. + Args: + circuit: A Pauli QuantumCircuit. + Returns: + Target bitstring. + """ + # target string has a 1 for each True in the stabilizer half of the phase vector + target = "".join(["1" if phase else "0" for phase in Clifford(circuit).stab_phase[::-1]]) + return target + + # Constant mapping from 1Q single Clifford gate to 1Q Clifford numerical identifier. # This table must be generated using `data.generate_clifford_data.gen_cliff_single_1q_gate_map`, or, # equivalently, correspond to the ordering implicitly defined by CliffUtils.clifford_1_qubit_circuit. diff --git a/qiskit_experiments/library/randomized_benchmarking/mirror_rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_analysis.py new file mode 100644 index 0000000000..5ded55dece --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_analysis.py @@ -0,0 +1,314 @@ +# 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. +""" +Mirror RB analysis class. +""" +from typing import List, Union +import numpy as np +from uncertainties import unumpy as unp +from scipy.spatial.distance import hamming + +import qiskit_experiments.curve_analysis as curve +from qiskit_experiments.framework import AnalysisResultData, ExperimentData +from qiskit_experiments.data_processing import DataProcessor +from qiskit_experiments.data_processing.data_action import DataAction + +from .rb_analysis import RBAnalysis + + +class MirrorRBAnalysis(RBAnalysis): + r"""A class to analyze mirror randomized benchmarking experiment. + + # section: overview + This analysis takes a series for Mirror RB curve fitting. + From the fit :math:`\alpha` value this analysis estimates the mean entanglement infidelity (EI) + and the error per Clifford (EPC), also known as the average gate infidelity (AGI). + + The EPC (AGI) estimate is obtained using the equation + + .. math:: + + EPC = \frac{2^n - 1}{2^n}\left(1 - \alpha\right) + + where :math:`n` is the number of qubits (width of the circuit). + + The EI is obtained using the equation + + .. math:: + + EI = \frac{4^n - 1}{4^n}\left(1 - \alpha\right) + + The fit :math:`\alpha` parameter can be fit using one of the following three quantities + plotted on the y-axis: + + Success Probabilities (:math:`p`): The proportion of shots that return the correct bitstring + + Adjusted Success Probabilities (:math:`p_0`): + + .. math:: + + p_0 = \sum_{k = 0}^n \left(-\frac{1}{2}\right)^k h_k + + where :math:`h_k` is the probability of observing a bitstring of Hamming distance of k from the + correct bitstring + + Effective Polarizations (:math:`S`): + + .. math:: + + S = \frac{4^n}{4^n-1}\left(\sum_{k=0}^n\left(-\frac{1}{2}\right)^k h_k\right)-\frac{1}{4^n-1} + + # section: fit_model + The fit is based on the following decay functions: + + .. 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` (for success probability) or :math:`(1/4)^n` + (for adjusted success probability and effective polarization). + bounds: [0, 1] + defpar \alpha: + desc: Depolarizing parameter. + init_guess: Determined by :func:`~rb_decay` with standard RB curve. + bounds: [0, 1] + + # section: reference + .. ref_arxiv:: 1 2112.09853 + + """ + + @classmethod + def _default_options(cls): + """Default analysis options. + + Analysis Options: + analyzed_quantity (str): Set the metric to plot on the y-axis. Must be one of + "Effective Polarization" (default), "Success Probability", or "Adjusted + Success Probability". + gate_error_ratio (Optional[Dict[str, float]]): A dictionary with gate name keys + and error ratio values used when calculating EPG from the estimated EPC. + The default value will use standard gate error ratios. + If you don't know accurate error ratio between your basis gates, + you can skip analysis of EPGs by setting this options to ``None``. + epg_1_qubit (List[AnalysisResult]): Analysis results from previous RB experiments + for individual single qubit gates. If this is provided, EPC of + 2Q RB is corrected to exclude the depolarization of underlying 1Q channels. + """ + default_options = super()._default_options() + + # Set labels of axes + default_options.plotter.set_figure_options( + xlabel="Clifford Length", + ylabel="Effective Polarization", + ) + + # Plot all (adjusted) success probabilities + default_options.plot_raw_data = True + + # Exponential decay parameter + default_options.result_parameters = ["alpha"] + + # Default gate error ratio for calculating EPG + default_options.gate_error_ratio = "default" + + # By default, EPG for single qubits aren't set + default_options.epg_1_qubit = None + + # By default, effective polarization is plotted (see arXiv:2112.09853). We can + # also plot success probability or adjusted success probability (see PyGSTi). + # Do this by setting options to "Success Probability" or "Adjusted Success Probability" + default_options.analyzed_quantity = "Effective Polarization" + + default_options.set_validator( + field="analyzed_quantity", + validator_value=[ + "Success Probability", + "Adjusted Success Probability", + "Effective Polarization", + ], + ) + + return default_options + + def _generate_fit_guesses( + self, + user_opt: curve.FitOptions, + curve_data: curve.CurveData, + ) -> Union[curve.FitOptions, List[curve.FitOptions]]: + """Create algorithmic guess with 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)) + num_qubits = len(self._physical_qubits) + + # Initialize guess for baseline and amplitude based on infidelity type + b_guess = 1 / 4**num_qubits + if self.options.analyzed_quantity == "Success Probability": + b_guess = 1 / 2**num_qubits + + mirror_curve = curve_data.get_subset_of("rb_decay") + alpha_mirror = curve.guess.rb_decay(mirror_curve.x, mirror_curve.y, b=b_guess) + a_guess = (curve_data.y[0] - b_guess) / (alpha_mirror ** curve_data.x[0]) + + user_opt.p0.set_if_empty(b=b_guess, a=a_guess, alpha=alpha_mirror) + + return user_opt + + def _create_analysis_results( + self, + fit_data: curve.FitData, + quality: str, + **metadata, + ) -> List[AnalysisResultData]: + """Create analysis results for important fit parameters. Besides the + default standard RB parameters, Entanglement Infidelity (EI) is also calculated. + + 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) + + # nrb is calculated for both EPC and EI per the equations in the docstring + ei_nrb = 4**num_qubits + ei_scale = (ei_nrb - 1) / ei_nrb + ei = ei_scale * (1 - fit_data.ufloat_params["alpha"]) + + outcomes.append( + AnalysisResultData( + name="EI", value=ei, chisq=fit_data.reduced_chisq, quality=quality, extra=metadata + ) + ) + + return outcomes + + def _initialize(self, experiment_data: ExperimentData): + """Initialize curve analysis by setting up the data processor for Mirror + RB data. + + Args: + experiment_data: Experiment data to analyze. + """ + super()._initialize(experiment_data) + + num_qubits = len(self._physical_qubits) + target_bs = [] + for circ_result in experiment_data.data(): + if circ_result["metadata"]["inverting_pauli_layer"] is True: + target_bs.append("0" * num_qubits) + else: + target_bs.append(circ_result["metadata"]["target"]) + + self.set_options( + data_processor=DataProcessor( + input_key="counts", + data_actions=[ + _ComputeQuantities( + analyzed_quantity=self.options.analyzed_quantity, + num_qubits=num_qubits, + target_bs=target_bs, + ) + ], + ) + ) + + +class _ComputeQuantities(DataAction): + """Data processing node for computing useful mirror RB quantities from raw results.""" + + def __init__( + self, + num_qubits, + target_bs, + analyzed_quantity: str = "Effective Polarization", + validate: bool = True, + ): + """ + Args: + num_qubits: Number of qubits. + quantity: The quantity to calculate. + validate: If set to False the DataAction will not validate its input. + """ + super().__init__(validate) + self._num_qubits = num_qubits + self._analyzed_quantity = analyzed_quantity + self._target_bs = target_bs + + def _process(self, data: np.ndarray): + # Arrays to store the y-axis data and uncertainties + y_data = [] + y_data_unc = [] + + for i, circ_result in enumerate(data): + target_bs = self._target_bs[i] + + # h[k] = proportion of shots that are Hamming distance k away from target bitstring + hamming_dists = np.zeros(self._num_qubits + 1) + for bitstring, count in circ_result.items(): + # Compute success probability + success_prob = 0.0 + if bitstring == target_bs: + success_prob = count / sum(circ_result.values()) + success_prob_unc = np.sqrt(success_prob * (1 - success_prob)) + if self._analyzed_quantity == "Success Probability": + y_data.append(success_prob) + y_data_unc.append(success_prob_unc) + + # Compute hamming distance proportions + target_bs_to_list = [int(char) for char in target_bs] + actual_bs_to_list = [int(char) for char in bitstring] + k = int(round(hamming(target_bs_to_list, actual_bs_to_list) * self._num_qubits)) + hamming_dists[k] += count / sum(circ_result.values()) + + # Compute hamming distance uncertainties + hamming_dist_unc = np.sqrt(hamming_dists * (1 - hamming_dists)) + + # Compute adjusted success probability and standard deviation + adjusted_success_prob = 0.0 + adjusted_success_prob_unc = 0.0 + for k in range(self._num_qubits + 1): + adjusted_success_prob += (-0.5) ** k * hamming_dists[k] + adjusted_success_prob_unc += (0.5) ** k * hamming_dist_unc[k] ** 2 + adjusted_success_prob_unc = np.sqrt(adjusted_success_prob_unc) + if self._analyzed_quantity == "Adjusted Success Probability": + y_data.append(adjusted_success_prob) + y_data_unc.append(adjusted_success_prob_unc) + + # Compute effective polarization and standard deviation (arXiv:2112.09853v1) + pol_factor = 4**self._num_qubits + pol = pol_factor / (pol_factor - 1) * adjusted_success_prob - 1 / (pol_factor - 1) + pol_unc = np.sqrt(pol_factor / (pol_factor - 1)) * adjusted_success_prob_unc + if self._analyzed_quantity == "Effective Polarization": + y_data.append(pol) + y_data_unc.append(pol_unc) + + return unp.uarray(y_data, y_data_unc) diff --git a/qiskit_experiments/library/randomized_benchmarking/mirror_rb_experiment.py b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_experiment.py new file mode 100644 index 0000000000..b504ab270a --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_experiment.py @@ -0,0 +1,423 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +""" +Mirror RB Experiment class. +""" +import warnings +from typing import Union, Iterable, Optional, List, Sequence, Tuple +from numbers import Integral +import itertools +import numpy as np +from numpy.random import Generator, BitGenerator, SeedSequence + +from qiskit.circuit import QuantumCircuit, Instruction, Barrier +from qiskit.quantum_info.operators import Pauli +from qiskit.providers.backend import Backend +from qiskit.providers.options import Options +from qiskit.exceptions import QiskitError +from qiskit.transpiler import CouplingMap +from qiskit.circuit.library import CXGate, CYGate, CZGate, ECRGate, SwapGate + +from .standard_rb import StandardRB, SequenceElementType +from .mirror_rb_analysis import MirrorRBAnalysis +from .clifford_utils import ( + compute_target_bitstring, + inverse_1q, + _clifford_1q_int_to_instruction, +) +from .sampling_utils import ( + EdgeGrabSampler, + SingleQubitSampler, + GateInstruction, + GateDistribution, + GenericClifford, + GenericPauli, +) + +# two qubit gates that are their own inverse +_self_adjoint_gates = [CXGate, CYGate, CZGate, ECRGate, SwapGate] + + +class MirrorRB(StandardRB): + """An experiment to measure gate infidelity using mirrored circuit + layers sampled from a defined distribution. + + # section: overview + Mirror randomized benchmarking (mirror RB) estimates the average error rate of + quantum gates using layers of gates sampled from a distribution that are then + inverted in the second half of the circuit. + + The default mirror RB experiment generates circuits of layers of Cliffords, + consisting of single-qubit Cliffords and a two-qubit gate such as CX, + interleaved with layers of Pauli gates and capped at the start and end by a + layer of single-qubit Cliffords. The second half of the Clifford layers are the + inverses of the first half of Clifford layers. This algorithm has a lot less + overhead than the standard randomized benchmarking, which requires + n-qubit Clifford gates, and so it can be used for benchmarking gates on + 10s of or even 100+ noisy qubits. + + After running the circuits on a backend, various quantities (success + probability, adjusted success probability, and effective polarization) + are computed and used to fit an exponential decay curve and calculate + the EPC (error per Clifford, also referred to as the average gate + infidelity) and entanglement infidelity (see references for more info). + + # section: analysis_ref + :class:`MirrorRBAnalysis` + + # section: manual + :doc:`/manuals/verification/mirror_rb` + + # section: reference + .. ref_arxiv:: 1 2112.09853 + .. ref_arxiv:: 2 2008.11294 + .. ref_arxiv:: 3 2204.07568 + + """ + + sampler_map = {"edge_grab": EdgeGrabSampler, "single_qubit": SingleQubitSampler} + + # pylint: disable=dangerous-default-value + def __init__( + self, + physical_qubits: Sequence[int], + lengths: Iterable[int], + start_end_clifford: bool = True, + pauli_randomize: bool = True, + sampling_algorithm: str = "edge_grab", + two_qubit_gate_density: float = 0.2, + two_qubit_gate: Instruction = CXGate(), + num_samples: int = 3, + sampler_opts: Optional[dict] = {}, + backend: Optional[Backend] = None, + seed: Optional[Union[int, SeedSequence, BitGenerator, Generator]] = None, + full_sampling: bool = False, + inverting_pauli_layer: bool = False, + ): + """Initialize a mirror randomized benchmarking experiment. + + Args: + physical_qubits: A list of physical qubits for the experiment. + lengths: A list of RB sequences lengths. + sampling_algorithm: The sampling algorithm to use for generating + circuit layers. Defaults to "edge_grab" which uses :class:`.EdgeGrabSampler`. + start_end_clifford: If True, begin the circuit with uniformly random 1-qubit + Cliffords and end the circuit with their inverses. + pauli_randomize: If True, surround each sampled circuit layer with layers of + uniformly random 1-qubit Paulis. + two_qubit_gate_density: Expected proportion of qubit sites with two-qubit + gates over all circuit layers (not counting optional layers at the start + and end). Only has effect if the default sampler + :class:`.EdgeGrabSampler` is used. + two_qubit_gate: The two-qubit gate to use. Defaults to + :class:`~qiskit.circuit.library.CXGate`. Only has effect if the + default sampler :class:`.EdgeGrabSampler` is used. + num_samples: Number of samples to generate for each sequence length. + sampler_opts: Optional dictionary of keyword arguments to pass to the sampler. + backend: Optional, the backend to run the experiment on. + 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:`circuits` is called. + full_sampling: If True all Cliffords are independently sampled for + all lengths. If False for sample of lengths longer sequences are + constructed by appending additional Clifford samples to shorter + sequences. + inverting_pauli_layer: If True, a layer of Pauli gates is appended at the + end of the circuit to set all qubits to 0. + + Raises: + QiskitError: if an odd length or a negative two qubit gate density is provided + """ + + if not all(length % 2 == 0 for length in lengths): + raise QiskitError("All lengths must be even") + + super().__init__( + physical_qubits, + lengths, + backend=backend, + num_samples=num_samples, + seed=seed, + full_sampling=full_sampling, + ) + + self.set_experiment_options( + sampling_algorithm=sampling_algorithm, + sampler_opts=sampler_opts, + start_end_clifford=start_end_clifford, + pauli_randomize=pauli_randomize, + two_qubit_gate=two_qubit_gate, + two_qubit_gate_density=two_qubit_gate_density, + inverting_pauli_layer=inverting_pauli_layer, + ) + + self._distribution = self.sampler_map.get(sampling_algorithm)(seed=seed, **sampler_opts) + self.analysis = MirrorRBAnalysis() + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default mirror RB experiment options. + + Experiment Options: + sampling_algorithm (str): Name of sampling algorithm to use. + start_end_clifford (bool): Whether to begin the circuit with uniformly random 1-qubit + Cliffords and end the circuit with their inverses. + pauli_randomize (bool): Whether to surround each inner Clifford layer with + layers of uniformly random 1-qubit Paulis. + inverting_pauli_layer (bool): Whether to append a layer of Pauli gates at the + end of the circuit to set all qubits to 0. + sampler_opts (dict): The keyword arguments to pass to the sampler. + two_qubit_gate_density (float): Expected proportion of qubit sites with two-qubit + gates over all circuit layers (not counting optional layers at the start + and end). Only has effect if the default sampler + :class:`.EdgeGrabSampler` is used. + two_qubit_gate (:class:`.Instruction`): The two-qubit gate to use. Defaults to + :class:`~qiskit.circuit.library.CXGate`. Only has effect if the + default sampler :class:`.EdgeGrabSampler` is used. + num_samples (int): Number of samples to generate for each sequence length. + """ + options = super()._default_experiment_options() + options.update_options( + sampling_algorithm="edge_grab", + start_end_clifford=True, + pauli_randomize=True, + two_qubit_gate_density=0.2, + two_qubit_gate=CXGate(), + sampler_opts={}, + inverting_pauli_layer=False, + ) + options.set_validator(field="two_qubit_gate_density", validator_value=(0, 1)) + + return options + + def circuits(self) -> List[QuantumCircuit]: + """Return a list of Mirror RB circuits. + + Returns: + A list of :class:`QuantumCircuit`. + """ + sequences = self._sample_sequences() + circuits = self._sequences_to_circuits(sequences) + + return circuits + + def _set_distribution_options(self): + """Set the coupling map and gate distribution of the sampler + based on experiment options. This method is currently implemented + for the default "edge_grab" sampler.""" + + if self.experiment_options.sampling_algorithm != "edge_grab": + raise QiskitError( + "Unsupported sampling algorithm provided. You must implement" + "a custom `_set_distribution_options` method." + ) + + self._distribution.seed = self.experiment_options.seed + + # Coupling map is full connectivity by default. If backend has a coupling map, + # get backend coupling map and create coupling map for physical qubits converted + # to qubits 0, 1...n + if self.backend and self._backend_data.coupling_map: + coupling_map = CouplingMap(self._backend_data.coupling_map) + else: + coupling_map = CouplingMap.from_full(len(self.physical_qubits)) + + self._distribution.coupling_map = coupling_map.reduce(self.physical_qubits) + + # Adjust the density based on whether the pauli layers are in + if self.experiment_options.pauli_randomize: + adjusted_2q_density = self.experiment_options.two_qubit_gate_density * 2 + else: + adjusted_2q_density = self.experiment_options.two_qubit_gate_density + + if adjusted_2q_density > 1: + warnings.warn("Two-qubit gate density is too high, capping at 1.") + adjusted_2q_density = 1 + + self._distribution.gate_distribution = [ + GateDistribution(prob=adjusted_2q_density, op=self.experiment_options.two_qubit_gate), + GateDistribution(prob=1 - adjusted_2q_density, op=GenericClifford(1)), + ] + + def _sample_sequences(self) -> List[Sequence[SequenceElementType]]: + """Sample layers of mirror RB using the provided distribution and user options. + + First, layers are sampled using the distribution, then Pauli-dressed if + ``pauli_randomize`` is ``True``. The inverse of the resulting circuit is + appended to the end. If ``start_end_clifford`` is ``True``, then cliffords are added + to the beginning and end. If ``inverting_pauli_layer`` is ``True``, a Pauli + layer will be appended at the end to set the output bitstring to all zeros. + + Returns: + A list of mirror RB sequences. Each element is a list of layers with length + matching the corresponding element in ``lengths``. The layers are made up + of tuples in the format ((one or more qubit indices), gate). Single-qubit + Cliffords are represented by integers for speed. + + Raises: + QiskitError: If no backend is provided. + """ + if not self._backend: + raise QiskitError("A backend must be provided for circuit generation.") + + self._set_distribution_options() + + # Sequence of lengths to sample for + if not self.experiment_options.full_sampling: + seqlens = (max(self.experiment_options.lengths),) + else: + seqlens = self.experiment_options.lengths + + if self.experiment_options.pauli_randomize: + pauli_sampler = SingleQubitSampler(seed=self.experiment_options.seed) + pauli_sampler.gate_distribution = [GateDistribution(prob=1, op=GenericPauli(1))] + + if self.experiment_options.start_end_clifford: + clifford_sampler = SingleQubitSampler(seed=self.experiment_options.seed) + clifford_sampler.gate_distribution = [GateDistribution(prob=1, op=GenericClifford(1))] + + sequences = [] + + for _ in range(self.experiment_options.num_samples): + for seqlen in seqlens: + seq = [] + + # Sample the first half of the mirror layers + layers = list( + self._distribution( + qubits=range(self.num_qubits), + length=seqlen // 2, + ) + ) + + if not self.experiment_options.full_sampling: + build_seq_lengths = self.experiment_options.lengths + + seq.extend(layers) + + # Add the second half mirror layers + for i in range(len(list(layers))): + seq.append(self._inverse_layer(layers[-i - 1])) + + # Interleave random Paulis if set by user + if self.experiment_options.pauli_randomize: + pauli_layers = list(pauli_sampler(range(self.num_qubits), length=seqlen + 1)) + seq = list(itertools.chain(*zip(pauli_layers[:-1], seq))) + seq.append(pauli_layers[-1]) + if not self.experiment_options.full_sampling: + build_seq_lengths = [length * 2 + 1 for length in build_seq_lengths] + + # Add start and end cliffords if set by user + if self.experiment_options.start_end_clifford: + clifford_layers = list(clifford_sampler(range(self.num_qubits), length=1)) + seq.insert(0, clifford_layers[0]) + seq.append(self._inverse_layer(clifford_layers[0])) + if not self.experiment_options.full_sampling: + build_seq_lengths = [length + 2 for length in build_seq_lengths] + + if self.experiment_options.full_sampling: + sequences.append(seq) + + # Construct the rest of the sequences from the longest if `full_sampling` is + # off + if not self.experiment_options.full_sampling: + for real_length in build_seq_lengths: + sequences.append(seq[: real_length // 2] + seq[-real_length // 2 :]) + + return sequences + + def _sequences_to_circuits( + self, sequences: List[Sequence[SequenceElementType]] + ) -> List[QuantumCircuit]: + """Convert Mirror RB sequences into mirror circuits. + + Args: + sequences: List of sequences whose elements are full circuit layers. + + Returns: + A list of RB circuits. + """ + basis_gates = self._get_basis_gates() + circuits = [] + + for i, seq in enumerate(sequences): + circ = QuantumCircuit(self.num_qubits) + # Hack to get target bitstrings until qiskit-terra#9475 is resolved + circ_target = QuantumCircuit(self.num_qubits) + for layer in seq: + for elem in layer: + circ.append(self._to_instruction(elem.op, basis_gates), elem.qargs) + circ_target.append(self._to_instruction(elem.op), elem.qargs) + circ.append(Barrier(self.num_qubits), circ.qubits) + circ.metadata = { + "xval": self.experiment_options.lengths[i % len(self.experiment_options.lengths)], + "target": compute_target_bitstring(circ_target), + "inverting_pauli_layer": self.experiment_options.inverting_pauli_layer, + } + + if self.experiment_options.inverting_pauli_layer: + # Get target bitstring (ideal bitstring outputted by the circuit) + target = circ.metadata["target"] + + # Pauli gates to apply to each qubit to reset each to the state 0. + # E.g., if the ideal bitstring is 01001, the Pauli label is IXIIX, + # which sets all qubits to 0 (up to a global phase) + label = "".join(["X" if char == "1" else "I" for char in target]) + circ.append(Pauli(label), list(range(self._num_qubits))) + + circ.measure_all() + circuits.append(circ) + return circuits + + def _to_instruction( + self, + elem: SequenceElementType, + basis_gates: Optional[Tuple[str, ...]] = None, + ) -> Instruction: + """Convert the sampled object to an instruction.""" + if isinstance(elem, Integral): + return _clifford_1q_int_to_instruction(elem, basis_gates) + elif isinstance(elem, Instruction): + return elem + elif getattr(elem, "to_instruction", None): + return elem.to_instruction() + else: + return elem() + + def _inverse_layer( + self, layer: List[Tuple[GateInstruction, ...]] + ) -> List[Tuple[GateInstruction, ...]]: + """Generates the inverse layer of a Clifford mirror RB layer by inverting the + single-qubit Cliffords and keeping the two-qubit gate identical. See + :class:`.BaseSampler` for the format of the layer. + + Args: + layer: The input layer. + + Returns: + The layer that performs the inverse operation to the input layer. + + Raises: + QiskitError: If the layer has invalid format. + """ + inverse_layer = [] + for elem in layer: + if len(elem.qargs) == 1 and np.issubdtype(type(elem.op), int): + inverse_layer.append(GateInstruction(elem.qargs, inverse_1q(elem.op))) + elif len(elem.qargs) == 2 and elem.op in _self_adjoint_gates: + inverse_layer.append(elem) + else: + try: + inverse_layer.append(GateInstruction(elem.qargs, elem.op.inverse())) + except TypeError as exc: + raise QiskitError("Invalid layer supplied.") from exc + return tuple(inverse_layer) diff --git a/qiskit_experiments/library/randomized_benchmarking/sampling_utils.py b/qiskit_experiments/library/randomized_benchmarking/sampling_utils.py new file mode 100644 index 0000000000..6069e9e83d --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/sampling_utils.py @@ -0,0 +1,424 @@ +# 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. +""" +Utilities for sampling layers in randomized benchmarking experiments +""" + +import warnings +import math +from abc import ABC, abstractmethod +from typing import Optional, Union, List, Tuple, Sequence, NamedTuple, Dict, Iterator +from collections import defaultdict +from numpy.random import Generator, default_rng, BitGenerator, SeedSequence +import numpy as np + +from qiskit.circuit import Instruction +from qiskit.circuit.gate import Gate +from qiskit.exceptions import QiskitError +from qiskit.transpiler import CouplingMap + +from .clifford_utils import CliffordUtils, _CLIFF_SINGLE_GATE_MAP_1Q + + +class GenericClifford(Gate): + """Representation of a generic multi-qubit Clifford gate for sampling.""" + + def __init__(self, n_qubits): + super().__init__("generic_clifford", n_qubits, []) + + +class GenericPauli(Gate): + """Representation of a generic multi-qubit Pauli gate for sampling.""" + + def __init__(self, n_qubits): + super().__init__("generic_pauli", n_qubits, []) + + +class GateInstruction(NamedTuple): + """Named tuple class for sampler output.""" + + # the list of qubits to apply the operation on + qargs: tuple + # the operation to apply + op: Instruction + + +class GateDistribution(NamedTuple): + """Named tuple class for sampler input distribution.""" + + # probability with which to sample the instruction + prob: float + # the instruction to include in sampling + op: Instruction + + +class BaseSampler(ABC): + """Base class for samplers that generate circuit layers based on a defined + algorithm and gate set. Subclasses must implement the ``__call__()`` method + which outputs a number of circuit layers.""" + + def __init__( + self, + seed: Optional[Union[int, SeedSequence, BitGenerator, Generator]] = None, + ) -> None: + """Initializes the sampler. + + Args: + seed: Seed for random generation. + gate_distribution: The gate distribution for sampling. + """ + self.seed = seed + + @property + def seed(self) -> Union[int, SeedSequence, BitGenerator, Generator]: + """The seed for random generation.""" + return self._rng + + @seed.setter + def seed(self, seed) -> None: + self._rng = default_rng(seed) + + @property + def gate_distribution(self) -> List[GateDistribution]: + """The gate distribution for sampling. The distribution is a list of + ``GateDistribution`` named tuples with field names ``(prob, op)``, where + the probabilites must sum to 1 and ``op`` is the Instruction instance to + be sampled. An example distribution for the edge grab sampler is + + .. parsed-literal:: + [(0.8, GenericClifford(1)), (0.2, CXGate())] + """ + return self._gate_distribution + + @gate_distribution.setter + def gate_distribution(self, dist: List[GateDistribution]) -> None: + """Set the distribution of gates used in the sampler. + + Args: + dist: A list of tuples with format ``(probability, gate)``. + """ + # cast to named tuple + try: + dist = [GateDistribution(*elem) for elem in dist] + except TypeError as exc: + raise TypeError( + "The gate distribution should be a sequence of (prob, op) tuples." + ) from exc + if sum(list(zip(*dist))[0]) != 1: + raise QiskitError("Gate distribution probabilities must sum to 1.") + for gate in dist: + if not isinstance(gate.op, Instruction): + raise TypeError( + "The only allowed gates in the distribution are Instruction instances." + ) + self._gate_distribution = dist + + def _probs_by_gate_size(self, distribution: Sequence[GateDistribution]) -> Dict: + """Return a list of gates and their probabilities indexed by the size of the gate.""" + + gate_probs = defaultdict(list) + + for gate in distribution: + if gate.op.name == "generic_clifford": + if gate.op.num_qubits == 1: + gateset = list(range(CliffordUtils.NUM_CLIFFORD_1_QUBIT)) + probs = [ + gate.prob / CliffordUtils.NUM_CLIFFORD_1_QUBIT + ] * CliffordUtils.NUM_CLIFFORD_1_QUBIT + elif gate.op.num_qubits == 2: + gateset = list(range(CliffordUtils.NUM_CLIFFORD_2_QUBIT)) + probs = [ + gate.prob / CliffordUtils.NUM_CLIFFORD_2_QUBIT + ] * CliffordUtils.NUM_CLIFFORD_2_QUBIT + else: + raise QiskitError( + "Generic Cliffords larger than 2-qubit are not currently supported." + ) + elif gate.op.name == "generic_pauli": + if gate.op.num_qubits == 1: + gateset = [ + _CLIFF_SINGLE_GATE_MAP_1Q[("id", (0,))], + _CLIFF_SINGLE_GATE_MAP_1Q[("x", (0,))], + _CLIFF_SINGLE_GATE_MAP_1Q[("y", (0,))], + _CLIFF_SINGLE_GATE_MAP_1Q[("z", (0,))], + ] + probs = [gate.prob / len(gateset)] * len(gateset) + else: + raise QiskitError( + "Generic Paulis larger than 1-qubit are not currently supported." + ) + else: + gateset = [gate.op] + probs = [gate.prob] + if len(gate_probs[gate.op.num_qubits]) == 0: + gate_probs[gate.op.num_qubits] = [gateset, probs] + else: + gate_probs[gate.op.num_qubits][0].extend(gateset) + gate_probs[gate.op.num_qubits][1].extend(probs) + return gate_probs + + @abstractmethod + def __call__(self, qubits: Sequence, length: int = 1) -> Iterator[Tuple[GateInstruction, ...]]: + """Samplers should define this method such that it returns sampled layers + given the input parameters. Each layer is represented by a list of + ``GateInstruction`` namedtuples, where ``GateInstruction.op`` is the gate to be + applied and ``GateInstruction.qargs`` is the tuple of qubit indices to + apply the gate to. + + Args: + qubits: A sequence of qubits to generate layers for. + length: The number of layers to generate. Defaults to 1. + + Returns: + A generator of layers consisting of GateInstruction objects. + """ + raise NotImplementedError + + +class SingleQubitSampler(BaseSampler): + """A sampler that samples layers of random single-qubit gates from a specified gate set.""" + + @BaseSampler.gate_distribution.setter + def gate_distribution(self, dist: List[GateDistribution]) -> None: + """Set the distribution of gates used in the sampler. + + Args: + dist: A list of tuples with format ``(probability, gate)``. + """ + super(SingleQubitSampler, type(self)).gate_distribution.fset(self, dist) + + gateset = self._probs_by_gate_size(self.gate_distribution) + if not math.isclose(sum(gateset[1][1]), 1): + raise QiskitError( + "The distribution for SingleQubitSampler should be all single qubit gates." + ) + + def __call__( + self, + qubits: Sequence, + length: int = 1, + ) -> Iterator[Tuple[GateInstruction]]: + """Samples random single-qubit gates from the specified gate set. The + input gate distribution must consist solely of single qubit gates. + + Args: + qubits: A sequence of qubits to generate layers for. + length: The length of the sequence to output. + + Returns: + A ``length``-long iterator of :class:`qiskit.circuit.QuantumCircuit` + layers over ``qubits``. Each layer is represented by a list of + ``GateInstruction`` tuples where ``GateInstruction.op`` is the gate + to be applied and ``GateInstruction.qargs`` is the tuple of qubit + indices to apply the gate to. Single-qubit Cliffords are represented + by integers for speed. + """ + + gateset = self._probs_by_gate_size(self._gate_distribution) + + samples = self._rng.choice( + np.array(gateset[1][0], dtype=object), + size=(length, len(qubits)), + p=gateset[1][1], + ) + + for samplelayer in samples: + yield tuple(GateInstruction(*ins) for ins in zip(((j,) for j in qubits), samplelayer)) + + +class EdgeGrabSampler(BaseSampler): + r"""A sampler that uses the edge grab algorithm [1] for sampling gate layers. + + The edge grab sampler, given a list of :math:`w` qubits, their connectivity + graph, and the desired two-qubit gate density :math:`\xi_s`, outputs a layer + as follows: + + 1. Begin with the empty set :math:`E` and :math:`E_r`, the set of all edges + in the connectivity graph. Select an edge from :math:`E_r` at random and + add it to :math:`E`, removing all edges that share a qubit with the edge + from :math:`E_r`. + + 2. Select edges from :math:`E` with the probability :math:`w\xi/2|E|`. These + edges will have two-qubit gates in the output layer. + + | + + This produces a layer with an expected two-qubit gate density :math:`\xi`. In the + default mirror RB configuration where these layers are dressed with single-qubit + Pauli layers, this means the overall expected two-qubit gate density will be + :math:`\xi_s/2=\xi`. The actual density will converge to :math:`\xi_s` as the + circuit size increases. + + .. ref_arxiv:: 1 2008.11294 + + """ + + @BaseSampler.gate_distribution.setter + def gate_distribution(self, dist: List[GateDistribution]) -> None: + """Set the distribution of gates used in the sampler. + + Args: + dist: A list of tuples with format ``(probability, gate)``. + """ + super(EdgeGrabSampler, type(self)).gate_distribution.fset(self, dist) + + gateset = self._probs_by_gate_size(self.gate_distribution) + + try: + norm1q = sum(gateset[1][1]) + norm2q = sum(gateset[2][1]) + except IndexError as exc: + raise QiskitError( + "The edge grab sampler requires 1-qubit and 2-qubit gates to be specified." + ) from exc + if not np.isclose(norm1q + norm2q, 1): + raise QiskitError("The edge grab sampler only supports 1- and 2-qubit gates.") + + def __init__( + self, + gate_distribution=None, + coupling_map: Optional[Union[List[List[int]], CouplingMap]] = None, + seed: Optional[Union[int, SeedSequence, BitGenerator, Generator]] = None, + ) -> None: + """Initializes the sampler. + + Args: + seed: Seed for random generation. + gate_distribution: The gate distribution for sampling. + coupling_map: The coupling map between the qubits. + """ + super().__init__(seed) + self._gate_distribution = gate_distribution + self.coupling_map = coupling_map + + @property + def coupling_map(self) -> CouplingMap: + """The coupling map of the system to sample over.""" + return self._coupling_map + + @coupling_map.setter + def coupling_map(self, coupling_map: Union[List[List[int]], CouplingMap]) -> None: + try: + self._coupling_map = CouplingMap(coupling_map) + except (ValueError, TypeError) as exc: + raise TypeError("Invalid coupling map provided.") from exc + + def __call__( + self, + qubits: Sequence, + length: int = 1, + ) -> Iterator[Tuple[GateInstruction]]: + """Sample layers using the edge grab algorithm. + + Args: + qubits: A sequence of qubits to generate layers for. + length: The length of the sequence to output. + + Raises: + Warning: If the coupling map has no connectivity or + ``two_qubit_gate_density`` is too high. + TypeError: If invalid gate set(s) are specified. + QiskitError: If the coupling map is invalid. + + Returns: + A ``length``-long iterator of :class:`qiskit.circuit.QuantumCircuit` + layers over ``num_qubits`` qubits. Each layer is represented by a + list of ``GateInstruction`` named tuples which are in the format + (qargs, gate). Single-qubit Cliffords are represented by integers + for speed. Here's an example with the default choice of Cliffords + for the single-qubit gates and CXs for the two-qubit gates: + + .. parsed-literal:: + (((1, 2), CXGate()), ((0,), 12), ((3,), 20)) + + This represents a layer where the 12th Clifford is performed on qubit 0, + a CX is performed with control qubit 1 and target qubit 2, and the 20th + Clifford is performed on qubit 3. + + """ + num_qubits = len(qubits) + gateset = self._probs_by_gate_size(self._gate_distribution) + norm1q = sum(gateset[1][1]) + norm2q = sum(gateset[2][1]) + + two_qubit_gate_density = norm2q / (norm1q + norm2q) + + for _ in range(length): + all_edges = self.coupling_map.get_edges()[ + : + ] # make copy of coupling map from which we pop edges + selected_edges = [] + while all_edges: + rand_edge = all_edges.pop(self._rng.integers(len(all_edges))) + selected_edges.append( + rand_edge + ) # move random edge from all_edges to selected_edges + old_all_edges = all_edges[:] + all_edges = [] + # only keep edges in all_edges that do not share a vertex with rand_edge + for edge in old_all_edges: + if rand_edge[0] not in edge and rand_edge[1] not in edge: + all_edges.append(edge) + + two_qubit_prob = 0 + try: + # need to divide by 2 since each two-qubit gate spans two lattice sites + two_qubit_prob = num_qubits * two_qubit_gate_density / 2 / len(selected_edges) + except ZeroDivisionError: + warnings.warn("Device has no connectivity. All gates will be single-qubit.") + if two_qubit_prob > 1 and not np.isclose(two_qubit_prob, 1): + warnings.warn( + "Mean number of two-qubit gates is higher than the number of selected edges. " + + "Actual density of two-qubit gates will likely be lower than input density." + ) + + put_1q_gates = set(qubits) + # put_1q_gates is a list of qubits that aren't assigned to a 2-qubit gate + # 1-qubit gates will be assigned to these edges + layer = [] + for edge in selected_edges: + if self._rng.random() < two_qubit_prob: + # with probability two_qubit_prob, place a two-qubit gate from the + # gate set on edge in selected_edges + if len(gateset[2][0]) == 1: + layer.append(GateInstruction(tuple(edge), gateset[2][0][0])) + else: + layer.append( + GateInstruction( + tuple(edge), + self._rng.choice( + np.array(gateset[2][0], dtype=Instruction), + p=[x / norm2q for x in gateset[2][1]], + ), + ), + ) + # remove these qubits from put_1q_gates + put_1q_gates.remove(edge[0]) + put_1q_gates.remove(edge[1]) + for q in put_1q_gates: + if sum(gateset[1][1]) > 0: + layer.append( + GateInstruction( + (q,), + self._rng.choice( + np.array(gateset[1][0], dtype=Instruction), + p=[x / norm1q for x in gateset[1][1]], + ), + ), + ) + else: # edge case of two qubit density of 1 where we still fill gaps + layer.append( + GateInstruction( + (q,), self._rng.choice(np.array(gateset[1][0], dtype=Instruction)) + ), + ) + yield tuple(layer) diff --git a/releasenotes/notes/mirror-rb-ec4d695a9a923971.yaml b/releasenotes/notes/mirror-rb-ec4d695a9a923971.yaml new file mode 100644 index 0000000000..9953864b88 --- /dev/null +++ b/releasenotes/notes/mirror-rb-ec4d695a9a923971.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + Added a new experiment class :class:`.MirrorRB`. This class implements + mirror randomized benchmarking, a variant of randomized benchmarking that measures + the fidelity of user-defined ensembles of randomized mirror circuits. + - | + Added a base class that samples circuit layers for randomized benchmarking experiments, + :class:`.BaseSampler`. The edge grab sampler :class:`.EdgeGrabSampler` and a single + qubit gate sampler :class:`.SingleQubitSampler` are implemented. \ No newline at end of file diff --git a/test/library/randomized_benchmarking/test_clifford_utils.py b/test/library/randomized_benchmarking/test_clifford_utils.py index 000ed3c36c..8256055ef4 100644 --- a/test/library/randomized_benchmarking/test_clifford_utils.py +++ b/test/library/randomized_benchmarking/test_clifford_utils.py @@ -44,6 +44,9 @@ _layer_indices_from_num, _CLIFFORD_LAYER, ) +from qiskit_experiments.library.randomized_benchmarking.clifford_utils import ( + compute_target_bitstring, +) @ddt @@ -195,3 +198,17 @@ def test_num_from_layer(self): circ.compose(_CLIFFORD_LAYER[layer][idx], inplace=True) layered = Clifford(circ) self.assertEqual(standard, layered) + + def test_target_bitstring(self): + """Test if correct target bitstring is returned.""" + qc = QuantumCircuit(9) + qc.z(0) + qc.y(1) + qc.y(2) + qc.z(3) + qc.y(4) + qc.x(7) + qc.y(8) + expected_tb = compute_target_bitstring(qc) + actual_tb = "110010110" + self.assertEqual(expected_tb, actual_tb) diff --git a/test/library/randomized_benchmarking/test_mirror_rb.py b/test/library/randomized_benchmarking/test_mirror_rb.py new file mode 100644 index 0000000000..9170305309 --- /dev/null +++ b/test/library/randomized_benchmarking/test_mirror_rb.py @@ -0,0 +1,667 @@ +# 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 mirror randomized benchmarking experiments.""" +from test.base import QiskitExperimentsTestCase +from test.library.randomized_benchmarking.mixin import RBTestMixin + +import copy + +from ddt import ddt, data + +from qiskit.circuit.library import CXGate, ECRGate +from qiskit.exceptions import QiskitError +from qiskit.providers.fake_provider import FakeManilaV2 +from qiskit.transpiler.basepasses import TransformationPass +from qiskit.transpiler import Layout, PassManager, CouplingMap +from qiskit.transpiler.exceptions import TranspilerError + +from qiskit_aer import AerSimulator +from qiskit_aer.noise import NoiseModel, depolarizing_error + +from qiskit_experiments.library import randomized_benchmarking as rb + + +from qiskit_experiments.library.randomized_benchmarking.clifford_utils import ( + compute_target_bitstring, +) + + +class NonlocalCXDepError(TransformationPass): + """Transpiler pass for simulating nonlocal errors in a quantum device""" + + def __init__(self, coupling_map, initial_layout=None): + """Maps a DAGCircuit onto a `coupling_map` using swap gates. + Args: + coupling_map (CouplingMap): Directed graph represented a coupling map. + initial_layout (Layout): initial layout of qubits in mapping + """ + super().__init__() + self.coupling_map = coupling_map + self.initial_layout = initial_layout + + def decr_dep_param(self, q, q_1, q_2, coupling_map): + """Helper function to generate a one-qubit depolarizing channel whose + parameter depends on coupling map distance in a backend""" + d = min(coupling_map.distance(q, q_1), coupling_map.distance(q, q_2)) + return 0.0035 * 0.999**d + + def run(self, dag): + """Runs the NonlocalCXDepError pass on `dag` + + Args: + dag (DAGCircuit): DAG to map. + + Returns: + DAGCircuit: A mapped DAG. + + Raises: + TranspilerError: initial layout and coupling map do not have the + same size + """ + + if self.initial_layout is None: + if self.property_set["layout"]: + self.initial_layout = self.property_set["layout"] + else: + self.initial_layout = Layout.generate_trivial_layout(*dag.qregs.values()) + + if len(dag.qubits) != len(self.initial_layout): + raise TranspilerError("The layout does not match the amount of qubits in the DAG") + + if len(self.coupling_map.physical_qubits) != len(self.initial_layout): + raise TranspilerError( + "Mappers require to have the layout to be the same size as the coupling map" + ) + + canonical_register = dag.qregs["q"] + trivial_layout = Layout.generate_trivial_layout(canonical_register) + current_layout = trivial_layout.copy() + + subdags = [] + for layer in dag.layers(): + graph = layer["graph"] + cxs = graph.op_nodes(op=CXGate) + if len(cxs) > 0: + for cx in cxs: + qubit_1 = current_layout[cx.qargs[0]] + qubit_2 = current_layout[cx.qargs[1]] + for qubit in range(dag.num_qubits()): + dep_param = self.decr_dep_param(qubit, qubit_1, qubit_2, self.coupling_map) + graph.apply_operation_back( + depolarizing_error(dep_param, 1).to_instruction(), + qargs=[canonical_register[qubit]], + cargs=[], + ) + subdags.append(graph) + + err_dag = dag.copy_empty_like() + for subdag in subdags: + err_dag.compose(subdag) + + return err_dag + + +class NoiseSimulator(AerSimulator): + """Quantum device simulator that has nonlocal CX errors""" + + def run(self, circuits, validate=False, parameter_binds=None, **run_options): + """Applies transpiler pass NonlocalCXDepError to circuits run on this backend""" + pm = PassManager() + cm = CouplingMap(couplinglist=self.configuration().coupling_map) + pm.append([NonlocalCXDepError(cm)]) + noise_circuits = pm.run(circuits) + return super().run( + noise_circuits, validate=validate, parameter_binds=parameter_binds, **run_options + ) + + +@ddt +class TestMirrorRB(QiskitExperimentsTestCase, RBTestMixin): + """Test for mirror RB.""" + + seed = 123 + + def setUp(self): + """Setup the tests.""" + super().setUp() + self.backend = FakeManilaV2() + + self.basis_gates = ["sx", "rz", "cx"] + + self.transpiler_options = { + "basis_gates": self.basis_gates, + } + + def test_return_same_circuit(self): + """Test if setting the same seed returns the same circuits.""" + lengths = [10, 20] + exp1 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=lengths, + seed=self.seed, + backend=self.backend, + ) + + exp2 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=lengths, + seed=self.seed, + backend=self.backend, + ) + + circs1 = exp1.circuits() + circs2 = exp2.circuits() + + for circ1, circ2 in zip(circs1, circs2): + self.assertEqual(circ1.decompose(), circ2.decompose()) + + def test_full_sampling(self): + """Test if full sampling generates different circuits.""" + exp1 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=[10, 20], + seed=self.seed, + backend=self.backend, + num_samples=1, + full_sampling=True, + ) + + exp2 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=[10, 20], + seed=self.seed, + backend=self.backend, + num_samples=1, + full_sampling=False, + ) + + circs1 = exp1.circuits() + circs2 = exp2.circuits() + + self.assertNotEqual(circs1[0].decompose(), circs2[0].decompose()) + + # fully sampled circuits are regenerated while other is just built on + # top of previous length + self.assertNotEqual(circs1[1].decompose(), circs2[1].decompose()) + + def test_zero_2q_gate_density(self): + """Test that there are no two-qubit gates when the two-qubit gate + density is set to 0.""" + exp = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=[40], + seed=self.seed, + backend=self.backend, + num_samples=1, + two_qubit_gate_density=0, + ) + circ = exp.circuits()[0].decompose() + for datum in circ.data: + inst_name = datum[0].name + self.assertNotEqual("cx", inst_name) + + def test_max_2q_gate_density(self): + """Test that every intermediate Clifford layer is filled with two-qubit + gates when the two-qubit gate density is set to 0.5, its maximum value + (assuming an even number of qubits and a backend coupling map with full + connectivity).""" + backend = AerSimulator(coupling_map=CouplingMap.from_full(4).get_edges()) + exp = rb.MirrorRB( + physical_qubits=(0, 1, 2, 3), + lengths=[40], + seed=self.seed, + backend=backend, + num_samples=1, + two_qubit_gate_density=0.5, + ) + circ = exp.circuits()[0].decompose() + num_cxs = 0 + for datum in circ.data: + if datum[0].name == "cx": + num_cxs += 1 + self.assertEqual(80, num_cxs) + + def test_start_end_clifford(self): + """Test that the number of layers is correct depending on whether + start_end_clifford is set to True or False by counting the number of barriers.""" + exp = rb.MirrorRB( + physical_qubits=(0,), + lengths=[2], + seed=self.seed, + backend=self.backend, + num_samples=1, + start_end_clifford=True, + pauli_randomize=False, + two_qubit_gate_density=0.2, + inverting_pauli_layer=False, + ) + circ = exp.circuits()[0] + num_barriers = 0 + for datum in circ.data: + if datum[0].name == "barrier": + num_barriers += 1 + self.assertEqual(5, num_barriers) + + def test_pauli_randomize(self): + """Test that the number of layers is correct depending on whether + pauli_randomize is set to True or False by counting the number of barriers.""" + exp = rb.MirrorRB( + physical_qubits=(0,), + lengths=[2], + seed=self.seed, + backend=self.backend, + num_samples=1, + start_end_clifford=False, + pauli_randomize=True, + two_qubit_gate_density=0.2, + inverting_pauli_layer=False, + ) + circ = exp.circuits()[0] + num_barriers = 0 + for datum in circ.data: + if datum[0].name == "barrier": + num_barriers += 1 + self.assertEqual(6, num_barriers) + + def test_inverting_pauli_layer(self): + """Test that a circuit with an inverting Pauli layer at the end generates + an all-zero output.""" + exp = rb.MirrorRB( + physical_qubits=(0, 1, 2), + lengths=[2], + seed=self.seed, + backend=self.backend, + num_samples=3, + start_end_clifford=True, + pauli_randomize=True, + two_qubit_gate_density=0.2, + inverting_pauli_layer=True, + ) + self.assertEqual( + compute_target_bitstring(exp.circuits()[0].remove_final_measurements(inplace=False)), + "000", + ) + + def test_experiment_config(self): + """Test converting to and from config works""" + exp = rb.MirrorRB([0], lengths=[10, 20, 30], seed=123, backend=self.backend) + loaded_exp = rb.MirrorRB.from_config(exp.config()) + self.assertNotEqual(exp, loaded_exp) + self.assertTrue(self.json_equiv(exp, loaded_exp)) + + def test_roundtrip_serializable(self): + """Test round trip JSON serialization""" + exp = rb.MirrorRB([0], lengths=[10, 20, 30], seed=123, two_qubit_gate=ECRGate()) + self.assertRoundTripSerializable(exp, self.json_equiv) + + def test_analysis_config(self): + """ "Test converting analysis to and from config works""" + analysis = rb.RBAnalysis() + loaded = rb.RBAnalysis.from_config(analysis.config()) + self.assertNotEqual(analysis, loaded) + self.assertEqual(analysis.config(), loaded.config()) + + 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 = rb.MirrorRB( + physical_qubits=(1, 2), + two_qubit_gate_density=0.5, + lengths=[4], + num_samples=4, + backend=my_backend, + seed=self.seed, + ) + transpiled = exp._transpiled_circuits() + for qc in transpiled: + 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) + + +@ddt +class TestRunMirrorRB(QiskitExperimentsTestCase, RBTestMixin): + """Class for testing execution of mirror RB experiments.""" + + seed = 123 + + def setUp(self): + """Setup the tests.""" + super().setUp() + + # depolarizing error + self.p1q = 0.02 + self.p2q = 0.10 + self.pvz = 0.0 + + # basis gates + self.basis_gates = ["sx", "rz", "cx", "id"] + + # setup noise model + sx_error = depolarizing_error(self.p1q, 1) + rz_error = depolarizing_error(self.pvz, 1) + cx_error = depolarizing_error(self.p2q, 2) + + noise_model = NoiseModel() + noise_model.add_all_qubit_quantum_error(sx_error, "sx") + noise_model.add_all_qubit_quantum_error(rz_error, "rz") + noise_model.add_all_qubit_quantum_error(cx_error, "cx") + + self.noise_model = noise_model + self.basis_gates = noise_model.basis_gates + + # Need level1 for consecutive gate cancellation for reference EPC value calculation + self.transpiler_options = { + "basis_gates": self.basis_gates, + "optimization_level": 1, + } + + # Aer simulator + self.backend = AerSimulator( + noise_model=noise_model, + seed_simulator=123, + coupling_map=AerSimulator.from_backend(FakeManilaV2()).configuration().coupling_map, + ) + + def test_single_qubit(self): + """Test single qubit mirror RB.""" + exp = rb.MirrorRB( + physical_qubits=(0,), + lengths=list(range(2, 300, 40)), + seed=self.seed, + backend=self.backend, + num_samples=20, + ) + exp.analysis.set_options(gate_error_ratio=None) + exp.set_transpile_options(**self.transpiler_options) + + expdata = exp.run() + self.assertExperimentDone(expdata) + + # Given we have gate number per Clifford n_gpc, we can compute EPC as + # EPC = 1 - (1 - r)^n_gpc + # where r is gate error of SX gate, i.e. dep-parameter divided by 2. + # We let transpiler use SX and RZ. + # The number of physical gate per Clifford will distribute + # from 0 to 2, i.e. arbitrary U gate can be decomposed into up to 2 SX with RZs. + # We may want to expect the average number of SX is (0 + 1 + 2) / 3 = 1.0. + # But for mirror RB, we must also add the SX gate number per Pauli n_gpp, + # which is 2 for X and Y gates and 0 for I and Z gates (average = 1.0). So the + # formula should be EPC = 1 - (1 - r)^(n_gpc + n_gpp) = 1 - (1 - r)^2 + epc = expdata.analysis_results("EPC") + epc_expected = 1 - (1 - 1 / 2 * self.p1q) ** 2.0 + + self.assertAlmostEqual(epc.value.n, epc_expected, delta=3 * epc.value.std_dev) + + def test_two_qubit(self): + """Test two qubit RB.""" + two_qubit_gate_density = 0.2 + exp = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=list(range(2, 80, 16)), + seed=self.seed, + backend=self.backend, + num_samples=20, + two_qubit_gate_density=two_qubit_gate_density, + ) + exp.analysis.set_options(gate_error_ratio=None) + exp.set_transpile_options(**self.transpiler_options) + + expdata = exp.run() + self.assertExperimentDone(expdata) + + # Given a two qubit gate density xi and an n qubit circuit, a Clifford + # layer has n*xi two-qubit gates. Obviously a Pauli has no two-qubit + # gates, so on aveage, a Clifford + Pauli layer has n*xi two-qubit gates + # and 2*n - 2*n*xi one-qubit gates (two layers have 2*n lattice sites, + # 2*n*xi of which are occupied by two-qubit gates). For two-qubit + # mirrored RB, the average infidelity is ((2^2 - 1)/2^2 = 3/4) times + # the two-qubit depolarizing parameter + epc = expdata.analysis_results("EPC") + cx_factor = (1 - 3 * self.p2q / 4) ** (2 * two_qubit_gate_density) + sx_factor = (1 - self.p1q / 2) ** (2 * 2 * (1 - two_qubit_gate_density)) + epc_expected = 1 - cx_factor * sx_factor + self.assertAlmostEqual(epc.value.n, epc_expected, delta=3 * epc.value.std_dev) + + def test_two_qubit_nonlocal_noise(self): + """Test for 2 qubit Mirrored RB with a nonlocal noise model""" + # depolarizing error + p1q = 0.0 + p2q = 0.01 + pvz = 0.0 + + # setup noise model + sx_error = depolarizing_error(p1q, 1) + rz_error = depolarizing_error(pvz, 1) + cx_error = depolarizing_error(p2q, 2) + + noise_model = NoiseModel() + noise_model.add_all_qubit_quantum_error(sx_error, "sx") + noise_model.add_all_qubit_quantum_error(rz_error, "rz") + noise_model.add_all_qubit_quantum_error(cx_error, "cx") + + basis_gates = ["id", "sx", "rz", "cx"] + + transpiler_options = { + "basis_gates": basis_gates, + } + noise_backend = NoiseSimulator( + noise_model=noise_model, + seed_simulator=123, + coupling_map=CouplingMap.from_grid(2, 1).get_edges(), + ) + + two_qubit_gate_density = 0.2 + exp = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=list(range(2, 110, 20)), + seed=self.seed, + backend=noise_backend, + num_samples=30, + two_qubit_gate_density=two_qubit_gate_density, + ) + exp.analysis.set_options(gate_error_ratio=None) + exp.set_transpile_options(**transpiler_options) + expdata = exp.run(noise_backend) + self.assertExperimentDone(expdata) + + epc = expdata.analysis_results("EPC") + # Compared to expected EPC in two-qubit test without nonlocal noise above, + # we include an extra factor for the nonlocal CX error. This nonlocal + # error is modeled by a one-qubit depolarizing channel on each qubit after + # each CX, so the expected number of one-qubit depolarizing channels + # induced by CXs is (number of CXs) * (number of qubits) = (two qubit gate + # density) * (number of qubits) * (number of qubits). + num_q = 2 + cx_factor = (1 - 3 * p2q / 4) ** (num_q * two_qubit_gate_density) + sx_factor = (1 - p1q / 2) ** (2 * num_q * (1 - two_qubit_gate_density)) + cx_nonlocal_factor = (1 - 0.0035 / 2) ** (num_q * num_q * two_qubit_gate_density) + epc_expected = 1 - cx_factor * sx_factor * cx_nonlocal_factor + self.assertAlmostEqual(epc.value.n, epc_expected, delta=3 * epc.value.std_dev) + + def test_three_qubit_nonlocal_noise(self): + """Test three-qubit mirror RB on a nonlocal noise model""" + # depolarizing error + p1q = 0.001 + p2q = 0.01 + pvz = 0.0 + + # setup noise modelle + sx_error = depolarizing_error(p1q, 1) + rz_error = depolarizing_error(pvz, 1) + cx_error = depolarizing_error(p2q, 2) + + noise_model = NoiseModel() + noise_model.add_all_qubit_quantum_error(sx_error, "sx") + noise_model.add_all_qubit_quantum_error(rz_error, "rz") + noise_model.add_all_qubit_quantum_error(cx_error, "cx") + + basis_gates = ["id", "sx", "rz", "cx"] + + transpiler_options = { + "basis_gates": basis_gates, + } + noise_backend = NoiseSimulator( + noise_model=noise_model, + seed_simulator=123, + coupling_map=CouplingMap.from_grid(3, 3).get_edges(), + ) + + two_qubit_gate_density = 0.2 + exp = rb.MirrorRB( + physical_qubits=(0, 1, 2), + lengths=list(range(2, 110, 50)), + seed=self.seed, + backend=noise_backend, + num_samples=20, + two_qubit_gate_density=two_qubit_gate_density, + ) + exp.analysis.set_options(gate_error_ratio=None) + exp.set_transpile_options(**transpiler_options) + expdata = exp.run(noise_backend) + self.assertExperimentDone(expdata) + + epc = expdata.analysis_results("EPC") + # The expected EPC was computed in simulations not presented here. + # Method: + # 1. Sample N Clifford layers according to the edgegrab algorithm + # in clifford_utils. + # 2. Transpile these into SX, RZ, and CX gates. + # 3. Replace each SX and CX with one- and two-qubit depolarizing + # channels, respectively, and remove RZ gates. + # 4. Use qiskit.quantum_info.average_gate_fidelity on these N layers + # to compute 1 - EPC for each layer, and average over the N layers. + epc_expected = 0.0124 + self.assertAlmostEqual(epc.value.n, epc_expected, delta=3 * epc.value.std_dev) + + def test_add_more_circuit_yields_lower_variance(self): + """Test variance reduction with larger number of sampling.""" + exp1 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=list(range(2, 30, 4)), + seed=self.seed, + backend=self.backend, + num_samples=3, + inverting_pauli_layer=False, + ) + exp1.analysis.set_options(gate_error_ratio=None) + exp1.set_transpile_options(**self.transpiler_options) + expdata1 = exp1.run() + self.assertExperimentDone(expdata1) + + exp2 = rb.MirrorRB( + physical_qubits=(0, 1), + lengths=list(range(2, 30, 4)), + seed=456, + backend=self.backend, + num_samples=10, + inverting_pauli_layer=False, + ) + exp2.analysis.set_options(gate_error_ratio=None) + exp2.set_transpile_options(**self.transpiler_options) + expdata2 = exp2.run() + self.assertExperimentDone(expdata2) + + self.assertLess( + expdata2.analysis_results("EPC").value.s, + expdata1.analysis_results("EPC").value.s, + ) + + @data( + { + "physical_qubits": [3, 3], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # repeated qubits + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 6, -8, 10], + "num_samples": 1, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # negative length + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": -4, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # negative number of samples + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 0, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # zero samples + { + "physical_qubits": [0, 1], + "lengths": [2, 6, 6, 6, 10], + "num_samples": 2, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # repeated lengths + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 5, 8, 10], + "num_samples": 2, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # odd length + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "two_qubit_gate_density": -0.1, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # negative two-qubit gate density + ) + def test_invalid_configuration(self, configs): + """Test raise error when creating experiment with invalid configs.""" + self.assertRaises((QiskitError, ValueError), rb.MirrorRB, **configs) + + @data( + { + "physical_qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "backend": None, + }, # no backend + ) + def test_no_backend(self, configs): + """Test raise error when no backend is provided for sampling circuits.""" + mirror_exp = rb.MirrorRB(**configs) + self.assertRaises(QiskitError, mirror_exp.run) + + def test_expdata_serialization(self): + """Test serializing experiment data works.""" + exp = rb.MirrorRB( + physical_qubits=(0,), + lengths=list(range(2, 200, 50)), + seed=self.seed, + backend=self.backend, + inverting_pauli_layer=False, + ) + exp.set_transpile_options(**self.transpiler_options) + expdata = exp.run() + self.assertExperimentDone(expdata) + self.assertRoundTripSerializable(expdata, check_func=self.experiment_data_equiv) + self.assertRoundTripPickle(expdata, check_func=self.experiment_data_equiv) diff --git a/test/library/randomized_benchmarking/test_sampling_utils.py b/test/library/randomized_benchmarking/test_sampling_utils.py new file mode 100644 index 0000000000..0f340904bc --- /dev/null +++ b/test/library/randomized_benchmarking/test_sampling_utils.py @@ -0,0 +1,101 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. + +""" +Tests for RB sampling utils. +""" + +from test.base import QiskitExperimentsTestCase +from ddt import ddt, data + +from qiskit.circuit.library import XGate, CXGate, CCXGate +from qiskit.exceptions import QiskitError + +from qiskit_experiments.library.randomized_benchmarking.sampling_utils import ( + SingleQubitSampler, + EdgeGrabSampler, + GenericClifford, +) +from qiskit_experiments.library.randomized_benchmarking.clifford_utils import CliffordUtils + + +@ddt +class TestSamplingUtils(QiskitExperimentsTestCase): + """Tests for the Sampler classes.""" + + seed = 1 + + def test_gate_distribution(self): + """Test the gate distribution is calculated correctly.""" + sampler = SingleQubitSampler(seed=self.seed) + sampler.gate_distribution = [(0.8, GenericClifford(1)), (0.2, XGate())] + dist = sampler._probs_by_gate_size(sampler.gate_distribution) + self.assertEqual(len(dist[1][0]), 25) + for i in dist[1][0]: + self.assertTrue(i == XGate() or 0 <= i < CliffordUtils.NUM_CLIFFORD_1_QUBIT) + + def test_1q_custom_gate(self): + """Test that the single qubit sampler works with custom gates.""" + sampler = SingleQubitSampler(seed=self.seed) + sampler.gate_distribution = [(1, XGate())] + layer = list(sampler((0,), 3)) + self.assertEqual( + layer, + [(((0,), XGate()),), (((0,), XGate()),), (((0,), XGate()),)], + ) + + def test_1q_cliffords(self): + """Test that the single qubit sampler can generate clifford layers.""" + sampler = SingleQubitSampler(seed=self.seed) + sampler.gate_distribution = [(1, GenericClifford(1))] + layer = sampler((0,), 3) + for i in layer: + self.assertTrue(i[0][1] < CliffordUtils.NUM_CLIFFORD_1_QUBIT and i[0][1] >= 0) + + def test_edgegrab(self): + """Test that the edge grab sampler behaves as expected.""" + sampler = EdgeGrabSampler(seed=self.seed) + sampler.gate_distribution = [(0.5, GenericClifford(1)), (0.5, CXGate())] + layer = sampler((0,), 3) + for i in layer: + self.assertTrue( + 0 <= i[0].op < CliffordUtils.NUM_CLIFFORD_1_QUBIT or i[0].op == CXGate() + ) + + def test_edgegrab_all_2q(self): + """Test that the edge grab sampler behaves as expected when two qubit density is + 1.""" + sampler = EdgeGrabSampler(seed=self.seed) + sampler.gate_distribution = [(0, GenericClifford(1)), (1, CXGate())] + sampler.coupling_map = [[k, k + 1] for k in range(9)] + layer = sampler(range(10), 3) + for i in layer: + self.assertTrue(i[0].op == CXGate()) + + def test_invalid_1q_distribution(self): + """Test that the single qubit sampler rejects incorrect + distributions.""" + sampler = SingleQubitSampler(seed=self.seed) + with self.assertRaises(QiskitError): + sampler.gate_distribution = [(0.8, GenericClifford(1)), (0.2, CXGate())] + + @data( + [(1, GenericClifford(2))], + [(0.5, GenericClifford(2)), (0.5, GenericClifford(3))], + [(0.2, GenericClifford(2)), (0.3, XGate()), (0.5, CCXGate())], + ) + def test_invalid_edgegrab_distribution(self, distribution): + """Test that the edge grab sampler rejects incorrect + distributions.""" + sampler = EdgeGrabSampler(seed=self.seed) + with self.assertRaises(QiskitError): + sampler.gate_distribution = distribution