diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0008875fed..cea2caf262 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.7, 3.8, 3.9, "3.10"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] os: ["ubuntu-latest", "macOS-latest", "windows-latest"] steps: - name: Print Concurrency Group diff --git a/.pylintrc b/.pylintrc index 8562476456..0992dd8f63 100644 --- a/.pylintrc +++ b/.pylintrc @@ -297,7 +297,7 @@ ignore-mixin-members=yes # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. -ignored-modules=matplotlib.cm,numpy.random,retworkx +ignored-modules=matplotlib.cm,numpy.random,rustworkx # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of diff --git a/MANIFEST.in b/MANIFEST.in index 1b30164d89..e90b69375d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include LICENSE.txt include requirements.txt include qiskit_experiments/VERSION.txt +include qiskit_experiments/library/randomized_benchmarking/data/*.npz +exclude qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py diff --git a/docs/_ext/autodoc_visualization.py b/docs/_ext/autodoc_visualization.py new file mode 100644 index 0000000000..938c11dcc9 --- /dev/null +++ b/docs/_ext/autodoc_visualization.py @@ -0,0 +1,99 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021, 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. + +""" +Documentation extension for visualization classes. +""" + +from typing import Any + +from docs._ext.custom_styles.styles import VisualizationDocstring +from qiskit.exceptions import QiskitError +from qiskit_experiments.visualization import BasePlotter, BaseDrawer +from sphinx.application import Sphinx +from sphinx.ext.autodoc import ClassDocumenter + + +class VisualizationDocumenter(ClassDocumenter): + """Sphinx extension for the custom documentation of the standard visualization classes.""" + + objtype = "visualization" # Must be overwritten by subclasses. + directivetype = "class" + priority = 10 + ClassDocumenter.priority + option_spec = dict(ClassDocumenter.option_spec) + + def add_content(self, more_content: Any, no_docstring: bool = False) -> None: + sourcename = self.get_sourcename() + try: + class_doc, init_doc = self.get_doc() + except ValueError: + raise QiskitError( + f"Documentation of {self.name} doesn't match with the expected format." + "Please run sphinx build without using the visualization template." + ) + + # format visualization class documentation into the visualization style + class_doc_parser = VisualizationDocstring( + target_cls=self.object, + docstring_lines=class_doc, + config=self.env.app.config, + indent=self.content_indent, + ) + + # write introduction + for i, line in enumerate(self.process_doc(class_doc_parser.generate_class_docs())): + self.add_line(line, sourcename, i) + self.add_line("", sourcename) + + # write init method documentation + self.add_line(".. rubric:: Initialization", sourcename) + self.add_line("", sourcename) + for i, line in enumerate(self.process_doc([init_doc])): + self.add_line(line, sourcename, i) + self.add_line("", sourcename) + + # method and attributes + if more_content: + for line, src in zip(more_content.data, more_content.items): + self.add_line(line, src[0], src[1]) + + +class PlotterDocumenter(VisualizationDocumenter): + """Sphinx extension for the custom documentation of plotter classes.""" + + objtype = "plotter" + + @classmethod + def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any) -> bool: + return isinstance(member, BasePlotter) + + +class DrawerDocumenter(VisualizationDocumenter): + """Sphinx extension for the custom documentation of drawer classes.""" + + objtype = "drawer" + + @classmethod + def can_document_member(cls, member: Any, membername: str, isattr: bool, parent: Any) -> bool: + return isinstance(member, BaseDrawer) + + +def setup(app: Sphinx): + # Add plotter documenter + existing_documenter = app.registry.documenters.get(PlotterDocumenter.objtype) + if existing_documenter is None or not issubclass(existing_documenter, PlotterDocumenter): + app.add_autodocumenter(PlotterDocumenter, override=True) + + # Add drawer documenter + existing_documenter = app.registry.documenters.get(DrawerDocumenter.objtype) + if existing_documenter is None or not issubclass(existing_documenter, DrawerDocumenter): + app.add_autodocumenter(DrawerDocumenter, override=True) diff --git a/docs/_ext/autoref.py b/docs/_ext/autoref.py index 6a4c42a4ae..3c8f2cefd1 100644 --- a/docs/_ext/autoref.py +++ b/docs/_ext/autoref.py @@ -30,6 +30,7 @@ class WebSite(Directive): .. ref_website:: qiskit-experiments, https://github.com/Qiskit/qiskit-experiments """ + required_arguments = 1 optional_arguments = 0 final_argument_whitespace = True @@ -67,6 +68,7 @@ class Arxiv(Directive): If an article is not found, no journal information will be shown. """ + required_arguments = 2 optional_arguments = 0 final_argument_whitespace = False diff --git a/docs/_ext/custom_styles/formatter.py b/docs/_ext/custom_styles/formatter.py index 16a70e3421..d32fa413f9 100644 --- a/docs/_ext/custom_styles/formatter.py +++ b/docs/_ext/custom_styles/formatter.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2021, 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 @@ -215,3 +215,35 @@ def format_fit_parameters(self, lines: List[str]) -> List[str]: format_lines.append("") return format_lines + +class VisualizationSectionFormatter(DocstringSectionFormatter): + """Formatter for visualization classes.""" + + @_check_no_indent + def format_opts(self, lines: List[str]) -> List[str]: + """Format options section.""" + + format_lines = [ + ".. rubric:: Options", + "", + "The following can be set using :meth:`set_options`.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_figure_opts(self, lines: List[str]) -> List[str]: + """Format figure options section.""" + format_lines = [ + ".. rubric:: Figure Options", + "", + "The following can be set using :meth:`set_figure_options`.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines diff --git a/docs/_ext/custom_styles/styles.py b/docs/_ext/custom_styles/styles.py index adbc5f9b55..3b37c45c89 100644 --- a/docs/_ext/custom_styles/styles.py +++ b/docs/_ext/custom_styles/styles.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2021, 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 @@ -21,12 +21,14 @@ from qiskit_experiments.framework.base_analysis import BaseAnalysis from qiskit_experiments.framework.base_experiment import BaseExperiment +from qiskit_experiments.visualization import BaseDrawer, BasePlotter from sphinx.config import Config as SphinxConfig from .formatter import ( ExperimentSectionFormatter, AnalysisSectionFormatter, DocstringSectionFormatter, + VisualizationSectionFormatter, ) from .section_parsers import load_standard_section, load_fit_parameters from .utils import ( @@ -310,3 +312,85 @@ def _extra_sections(self, sectioned_docstring: Dict[str, List[str]]): option_desc.append("No option available for this analysis.") sectioned_docstring["analysis_opts"] = option_desc + + +class VisualizationDocstring(QiskitExperimentDocstring): + """Documentation parser for visualization classes' introductions.""" + + __sections__ = { + "header": load_standard_section, + "warning": load_standard_section, + "overview": load_standard_section, + "reference": load_standard_section, + "tutorial": load_standard_section, + "opts": None, # For standard options + "figure_opts": None, # For figure options + "example": load_standard_section, + "note": load_standard_section, + "see_also": load_standard_section, + } + + __formatter__ = VisualizationSectionFormatter + + def __init__( + self, + target_cls: Union[BaseDrawer, BasePlotter], + docstring_lines: Union[str, List[str]], + config: SphinxConfig, + indent: str = "", + ): + """Create new parser and parse formatted docstring.""" + super().__init__(target_cls, docstring_lines, config, indent) + + def _extra_sections(self, sectioned_docstring: Dict[str, List[str]]): + """Generate extra sections.""" + # add options + option_desc = [] + figure_option_desc = [] + + docs_config = copy.copy(self._config) + docs_config.napoleon_custom_sections = [ + ("options", "args"), + ("figure options", "args"), + ] + + # Generate options docs + option = _generate_options_documentation( + current_class=self._target_cls, + method_name="_default_options", + config=docs_config, + indent=self._indent, + ) + if option: + option_desc.extend(option) + option_desc.append("") + option_desc.extend( + _format_default_options( + defaults=self._target_cls._default_options().__dict__, + indent=self._indent, + ) + ) + else: + option_desc.append("No options available.") + + # Generate figure options docs + figure_option = _generate_options_documentation( + current_class=self._target_cls, + method_name="_default_figure_options", + config=docs_config, + indent=self._indent, + ) + if figure_option: + figure_option_desc.extend(figure_option) + figure_option_desc.append("") + figure_option_desc.extend( + _format_default_options( + defaults=self._target_cls._default_figure_options().__dict__, + indent=self._indent, + ) + ) + else: + figure_option_desc.append("No figure options available.") + + sectioned_docstring["opts"] = option_desc + sectioned_docstring["figure_opts"] = figure_option_desc diff --git a/docs/_templates/autosummary/drawer.rst b/docs/_templates/autosummary/drawer.rst new file mode 100644 index 0000000000..a17d57f6da --- /dev/null +++ b/docs/_templates/autosummary/drawer.rst @@ -0,0 +1,49 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. autodrawer:: {{ objname }} + :no-members: + :no-inherited-members: + :no-special-members: + + {% block attributes_summary %} + {% if attributes %} + + .. rubric:: Attributes + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block methods_summary %} + {% if methods %} + + .. rubric:: Methods + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% for item in inherited_members %} + {%- if item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + + {% endif %} + {% endblock %} diff --git a/docs/_templates/autosummary/plotter.rst b/docs/_templates/autosummary/plotter.rst new file mode 100644 index 0000000000..83e19addbe --- /dev/null +++ b/docs/_templates/autosummary/plotter.rst @@ -0,0 +1,50 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. autoplotter:: {{ objname }} + :no-members: + :no-inherited-members: + :no-special-members: + + {% block attributes_summary %} + {% if attributes %} + + .. rubric:: Attributes + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block methods_summary %} + {% if methods %} + + .. rubric:: Methods + + .. autosummary:: + :toctree: ../stubs/ + + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% for item in inherited_members %} + {%- if item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + + {% endif %} + {% endblock %} diff --git a/docs/conf.py b/docs/conf.py index 1dd2755641..a2bf64b712 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2018. +# (C) Copyright IBM 2018, 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 @@ -100,6 +100,7 @@ "autoref", "autodoc_experiment", "autodoc_analysis", + "autodoc_visualization", "jupyter-execute-checkenv", ] html_static_path = ["_static"] @@ -112,6 +113,9 @@ exclude_patterns = ["_build", "**.ipynb_checkpoints"] nbsphinx_thumbnails = {} +# Add `data keys` and `style parameters` alias. Needed for `expected_*_data_keys` methods in +# visualization module and `default_style` method in `PlotStyle` respectively. +napoleon_custom_sections = [("data keys", "params_style"), ("style parameters", "params_style")] # ----------------------------------------------------------------------------- # Autosummary diff --git a/docs/tutorials/data_processing.rst b/docs/tutorials/data_processing.rst new file mode 100644 index 0000000000..339c561754 --- /dev/null +++ b/docs/tutorials/data_processing.rst @@ -0,0 +1,186 @@ +Data processing +=============== + +In this tutorial we describe how to manipulate the different +types of data that quantum computers can return. +The tutorial covers key aspects of the ``data_processing`` package +such as how to initialize an instance of ``DataProcessor`` and how +to create the ``DataAction`` nodes that process the data. + +Data types on IBM Quantum backends +---------------------------------- + +IBM Quantum backends can return different types of data. There is +counts data and IQ data [1], referred to as level 2 and level 1 data, +respectively. Level 2 data corresponds +to a dictionary with bit-strings as keys and the number of +times the bit-string was measured as a value. Importantly +for some experiments, the backends can return a lower data level +known as IQ data. Here, I and Q stand +for in phase and quadrature. The IQ are points in the complex plane +corresponding to a time integrated measurement signal which is +reflected or transmitted through the readout resonator depending +on the setup. IQ data can be returned as "single" or "averaged" data. +Here, single means that the outcome of each single shot is returned +while average only returns the average of the IQ points over the +measured shots. The type of data that an experiment should return +is specified by the ``run_options`` of an experiment. + +Processing data of different types +---------------------------------- + +An experiment should work with the different data levels. +Crucially, the analysis, such as a curve analysis, expects the +same data format no matter the run options of the experiment. +Transforming the data returned by the backend into the format +that the analysis accepts is done by the ``data_processing`` library. +The key class here is the ``DataProcessor``. It is initialized from +two arguments. The first, is the ``input_key`` which is typically +"memory" or "counts" and identifies the key in the experiment data +where the data is located. The second argument ``data_actions`` +is a list of ``nodes`` where each node performs a processing step +of the data processor. Crucially, the output of one node in the +list is the input to the next node in the list. + +To illustrate the data processing module we consider an example +in which we measure a rabi oscillation with different data levels. +The code below sets up the Rabi experiment. + + +.. jupyter-execute:: + + import numpy as np + + from qiskit import pulse + from qiskit.circuit import Parameter + + from qiskit_experiments.test.pulse_backend import SingleTransmonTestBackend + from qiskit_experiments.data_processing import DataProcessor, nodes + from qiskit_experiments.library import Rabi + + with pulse.build() as sched: + pulse.play( + pulse.Gaussian(160, Parameter("amp"), sigma=40), + pulse.DriveChannel(0) + ) + + backend = SingleTransmonTestBackend() + + exp = Rabi( + qubit=0, + backend=backend, + schedule=sched, + amplitudes=np.linspace(-0.1, 0.1, 21) + ) + +We now run the Rabi experiment twice, once with level 1 data and +once with level 2 data. Here, we manually configure two data +processors but note that typically you do not need to do this +yourself. We begin with single-shot IQ data. + +.. jupyter-execute:: + + data_nodes = [nodes.SVD(), nodes.AverageData(axis=1), nodes.MinMaxNormalize()] + iq_processor = DataProcessor("memory", data_nodes) + exp.analysis.set_options(data_processor=iq_processor) + + exp_data = exp.run(meas_level=1, meas_return="single").block_for_results() + + display(exp_data.figure(0)) + +Since we requested IQ data we set the input key to "memory" which is +the key under which the data is located in the experiment data. The +``iq_processor`` contains three nodes. The first node ``SVD`` is a +singular value decomposition which projects the two-dimensional IQ +data on its main axis. The second node averages the single-shot +data. The output is a single float per quantum circuit. Finally, +the last node ``MinMaxNormalize`` normalizes the measured signal to +the interval [0, 1]. The ``iq_dataprocessor`` is then set as an option +of the analysis class. For those who are wondering what single-shot IQ +data looks like we plot the data returned by the zeroth and sixth circuit +in the code block below. + +.. jupyter-execute:: + + %matplotlib inline + + from qiskit_experiments.visualization import IQPlotter, MplDrawer + + plotter = IQPlotter(MplDrawer()) + + for idx in [0, 6]: + plotter.set_series_data( + f"Circuit {idx}", + points=np.array(exp_data.data(idx)["memory"]).squeeze(), + ) + + plotter.figure() + +Now we turn to counts data and see how the +data processor needs to be changed. + +.. jupyter-execute:: + + data_nodes = [nodes.Probability(outcome="1")] + count_processor = DataProcessor("counts", data_nodes) + exp.analysis.set_options(data_processor=count_processor) + + exp_data = exp.run(meas_level=2).block_for_results() + + display(exp_data.figure(0)) + +Now, the ``input_key`` is "counts" since that is the key under which the counts +data is saved in instances of ``ExperimentData``. The list of nodes +comprises a single data action which converts the counts to an estimation +of the probability of measuring the outcome "1". + +Writing data actions +--------------------- + +The nodes in a data processor are all sub-classes of ``DataAction``. +Users who wish to write their own data actions must (i) sub-class +``DataAction`` and (ii) implement the internal ``_process`` method +called by instances of ``DataProcessor``. This method is the +processing step that the node implements. It takes a numpy array as +input and returns the processed numpy array as output. This output +serves as the input for the next node in the data processing chain. +Here, the input and output numpy arrays can have a different shape. + +In addition to the standard ``DataAction`` the data processing package +also supports trainable data actions as subclasses of ``TrainableDataAction``. +These nodes must first be trained on the data before they can +process the data. An example of a ``TrainableDataAction`` is the +``SVD`` node which must first learn the main axis of the data before +it can project a data point onto this axis. To implement trainable nodes +developers must also implement the ``train`` method. This method is +called when ``DataProcessor.train`` is called. + +Conclusion +---------- + +In this tutorial you learnt about the data processing module in Qiskit +Experiments. Data is processed by data processors that +call a list of nodes each acting once on the data. Data +processing connects the data returned by the backend to the data that +the analysis classes need. Typically, you will not need to implement +the data processing yourself since Qiskit Experiments has built-in +methods that determine the correct instance of ``DataProcessor`` for +your data. More advanced data processing includes, for example, handling +restless measurements [2, 3], see also the ``Restless Measurements`` tutorial. + +References +~~~~~~~~~~ + +[1] Thomas Alexander, Naoki Kanazawa, Daniel J. Egger, Lauren Capelluto, +Christopher J. Wood, Ali Javadi-Abhari, David McKay, Qiskit Pulse: +Programming Quantum Computers Through the Cloud with Pulses, Quantum +Science and Technology **5**, 044006 (2020). https://arxiv.org/abs/2004.06755 + +[2] Caroline Tornow, Naoki Kanazawa, William E. Shanks, Daniel J. Egger, +Minimum quantum run-time characterization and calibration via restless +measurements with dynamic repetition rates, Physics Review Applied **17**, +064061 (2022). https://arxiv.org/abs/2202.06981 + +[3] Max Werninghaus, Daniel J. Egger, Stefan Filipp, High-speed calibration and +characterization of superconducting quantum processors without qubit reset, +PRX Quantum 2, 020324 (2021). https://arxiv.org/abs/2010.06576 \ No newline at end of file diff --git a/docs/tutorials/pygsti-data-pygsti-transpiled-circ.png b/docs/tutorials/pygsti-data-pygsti-transpiled-circ.png new file mode 100644 index 0000000000..9718be96b7 Binary files /dev/null and b/docs/tutorials/pygsti-data-pygsti-transpiled-circ.png differ diff --git a/docs/tutorials/pygsti-data-qiskit-transpiled-circ.png b/docs/tutorials/pygsti-data-qiskit-transpiled-circ.png new file mode 100644 index 0000000000..0edd3a7a6d Binary files /dev/null and b/docs/tutorials/pygsti-data-qiskit-transpiled-circ.png differ diff --git a/docs/tutorials/randomized_benchmarking.rst b/docs/tutorials/randomized_benchmarking.rst index 55b39ca08a..edb209d4ce 100644 --- a/docs/tutorials/randomized_benchmarking.rst +++ b/docs/tutorials/randomized_benchmarking.rst @@ -11,10 +11,15 @@ See `Qiskit Textbook `__ for an explanation on the RB method, which is based on Ref. [1, 2]. +.. jupyter-execute:: + :hide-code: + + %matplotlib inline + .. jupyter-execute:: import numpy as np - from qiskit_experiments.library import StandardRB, InterleavedRB + from qiskit_experiments.library import StandardRB, InterleavedRB, MirrorRB from qiskit_experiments.framework import ParallelExperiment, BatchExperiment import qiskit.circuit.library as circuits @@ -304,6 +309,208 @@ Running a 2-qubit interleaved RB experiment print(result) +Mirror RB experiment +-------------------- + +Mirror RB is a RB protocol that is more scalable to larger numbers of qubits, +and as such, it can be used to detect crosstalk errors in a quantum device. A +randomized Clifford mirror circuit consists of + +- random layers of one- and two-qubit Cliffords and their inverses sampled + according to some distribution :math:`\Omega` over a layer set + :math:`\mathbb{L}`, + +- uniformly random Paulis between these layers, and + +- a layer of uniformly random one-qubit Cliffords at the beginning and the end + of the circuit. + +Even though a `MirrorRB` experiment can be instantiated without a backend, the +backend must be specified when the circuits are sampled because :math:`\Omega` +depends on the backend's connectivity. + +In standard and interleaved RB, $n$-qubit circuits of varying lengths +:math:`\ell` that compose to the identity are run on a device, and the +**success probability** $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** + +.. math:: + + S = \frac{4^n P_0}{4^n - 1} - \frac{1}{4^n - 1}. + +In [6], 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 Ref. [7]). + + +Running a one-qubit mirror RB experiment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + lengths = np.arange(2, 810, 200) + num_samples = 30 + seed = 1010 + qubits = (0,) + + # Run a MRB experiment on qubit 0 + exp_1q = MirrorRB(qubits, lengths, backend=backend, num_samples=num_samples, seed=seed) + expdata_1q = exp_1q.run(backend).block_for_results() + results_1q = expdata_1q.analysis_results() + +.. jupyter-execute:: + + # View result data + print("Gate error ratio: %s" % expdata_1q.experiment.analysis.options.gate_error_ratio) + display(expdata_1q.figure(0)) + for result in results_1q: + print(result) + + +Running a two-qubit mirror RB experiment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In MRB experiments with :math:`n > 1` qubits, intermediate Clifford layers +are sampled according to the **edge grab** algorithm [7]. 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. + +.. jupyter-execute:: + + # Two-qubit circuit example + exp_2q_circ = MirrorRB((0,1), lengths=[4], backend=backend, num_samples=1, seed=1010, two_qubit_gate_density=.4) + qc2 = exp_2q_circ.circuits()[0].decompose()#gates_to_decompose=['Clifford*','circuit*']) + qc2.draw() + +.. jupyter-execute:: + + lengths = np.arange(2, 810, 200) + num_samples = 30 + seed = 1011 + qubits = (0,1) + + # Run a MRB experiment on 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:: + + # View result data + print("Gate error ratio: %s" % expdata_2q.experiment.analysis.options.gate_error_ratio) + display(expdata_2q.figure(0)) + for result in results_2q: + print(result) + + +Selecting :math:`y`-axis values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + lengths = [2, 52, 102, 152] + num_samples = 30 + seed = 42 + qubits = (0,) + + exp = MirrorRB(qubits, lengths, backend=backend, num_samples=num_samples, seed=seed) + # select y-axis + exp.analysis.set_options(y_axis="Success Probability") # or "Adjusted Success Probability" or "Effective Polarization" + # y-axis label must be set separately + exp.analysis.options.curve_drawer.set_options( + # xlabel="Clifford Length", + 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 +~~~~~~~~~~~~~~~~~~~~~~ + +Circuit generation options can be specified when a ``MirrorRB`` experiment +object is instantiated: + +- ``local_clifford`` (default ``True``): if ``True``, begin the circuit with + uniformly random one-qubit Cliffords and end the circuit with their inverses + +- ``pauli_randomize`` (default ``True``): if ``True``, put layers of uniformly + random Paulis between the intermediate Clifford layers + +- ``two_qubit_gate_density`` (default ``0.2``): expected fraction of two-qubit + gates in each intermediate Clifford layer + +- ``inverting_pauli_layer`` (default ``False``): if ``True``, put 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 + + +Mirror RB implementation in ``pyGSTi`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``pyGSTi`` implementation of Mirror RB 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``. The first circuit was transpiled in ``pyGSTi``, + +.. image:: pygsti-data-pygsti-transpiled-circ.png + +and the second was transpiled in Qiskit. + +.. image:: pygsti-data-qiskit-transpiled-circ.png + +Note the different implementations of the same Clifford on +qubit 0 in the fifth layer. + Running a simultaneous RB experiment ------------------------------------ @@ -369,6 +576,14 @@ A. Ohki, Mark B. Ketchen, and M. Steffen, *Characterization of addressability by simultaneous randomized benchmarking*, https://arxiv.org/pdf/1204.6308 +[6] 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 + +[7] 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 + .. jupyter-execute:: import qiskit.tools.jupyter diff --git a/docs/tutorials/restless_measurements.rst b/docs/tutorials/restless_measurements.rst index e84c488499..79663d31e3 100644 --- a/docs/tutorials/restless_measurements.rst +++ b/docs/tutorials/restless_measurements.rst @@ -195,12 +195,12 @@ References [1] Max Werninghaus, Daniel J. Egger, Stefan Filipp, High-speed calibration and characterization of superconducting quantum processors without qubit reset, -PRX Quantum 2, 020324 (2021). +PRX Quantum **2**, 020324 (2021). https://arxiv.org/abs/2010.06576 [2] Caroline Tornow, Naoki Kanazawa, William E. Shanks, Daniel J. Egger, Minimum quantum run-time characterization and calibration via restless -measurements with dynamic repetition rates, -https://arxiv.org/abs/2202.06981 +measurements with dynamic repetition rates, Physics Review Applied **17**, +064061 (2022). https://arxiv.org/abs/2202.06981 [3] Andrew Wack, Hanhee Paik, Ali Javadi-Abhari, Petar Jurcevic, Ismael Faro, Jay M. Gambetta, Blake R. Johnson, Quality, Speed, and Scale: three key diff --git a/pyproject.toml b/pyproject.toml index b12fd96717..4469aaeaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,4 +3,4 @@ requires = ["setuptools", "wheel"] [tool.black] line-length = 100 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] diff --git a/qiskit_experiments/calibration_management/calibration_utils.py b/qiskit_experiments/calibration_management/calibration_utils.py index 5f1eef1e1f..c489268fd5 100644 --- a/qiskit_experiments/calibration_management/calibration_utils.py +++ b/qiskit_experiments/calibration_management/calibration_utils.py @@ -15,7 +15,7 @@ from typing import Optional, Set, Tuple from functools import lru_cache import re -import retworkx as rx +import rustworkx as rx from qiskit.circuit import ParameterExpression, Parameter from qiskit.pulse import ScheduleBlock diff --git a/qiskit_experiments/calibration_management/calibrations.py b/qiskit_experiments/calibration_management/calibrations.py index 422370f361..d36bf34efb 100644 --- a/qiskit_experiments/calibration_management/calibrations.py +++ b/qiskit_experiments/calibration_management/calibrations.py @@ -20,7 +20,7 @@ import dataclasses import json import warnings -import retworkx as rx +import rustworkx as rx from qiskit.pulse import ( ScheduleBlock, diff --git a/qiskit_experiments/framework/composite/batch_experiment.py b/qiskit_experiments/framework/composite/batch_experiment.py index 534427335c..8667e9ebac 100644 --- a/qiskit_experiments/framework/composite/batch_experiment.py +++ b/qiskit_experiments/framework/composite/batch_experiment.py @@ -13,11 +13,11 @@ Batch Experiment class. """ -from typing import List, Optional -from collections import OrderedDict +from typing import List, Optional, Dict +from collections import OrderedDict, defaultdict from qiskit import QuantumCircuit -from qiskit.providers.backend import Backend +from qiskit.providers import Job, Backend, Options from .composite_experiment import CompositeExperiment, BaseExperiment from .composite_analysis import CompositeAnalysis @@ -127,3 +127,57 @@ def _remap_qubits(self, circuit, qubit_mapping): new_circuit.metadata = circuit.metadata new_circuit.append(circuit, qubit_mapping, list(range(num_clbits))) return new_circuit + + def _run_jobs_recursive( + self, circuits: List[QuantumCircuit], truncated_metadata: List[Dict], **run_options + ) -> List[Job]: + # The truncated metadata is a truncation of the original composite metadata. + # During the recursion, the current experiment (self) will be at the head of the truncated + # metadata. + + if self.experiment_options.separate_jobs: + # A dictionary that maps sub-experiments to their circuits + circs_by_subexps = defaultdict(list) + for circ_iter, (circ, tmd) in enumerate(zip(circuits, truncated_metadata)): + # For batch experiments the composite index is always a list of length 1, + # because unlike parallel experiment, each circuit originates from a single + # sub-experiment. + circ_index = tmd["composite_index"][0] + circs_by_subexps[circ_index].append(circ) + + # For batch experiments the composite metadata is always a list of length 1, + # because unlike parallel experiment, each circuit originates from a single + # sub-experiment. + truncated_metadata[circ_iter] = tmd["composite_metadata"][0] + + jobs = [] + for index, exp in enumerate(self.component_experiment()): + # Currently all the sub-experiments must use the same set of run options, + # even if they run in different jobs + if isinstance(exp, BatchExperiment): + new_jobs = exp._run_jobs_recursive( + circs_by_subexps[index], truncated_metadata, **run_options + ) + else: + new_jobs = exp._run_jobs(circs_by_subexps[index], **run_options) + jobs.extend(new_jobs) + else: + jobs = super()._run_jobs(circuits, **run_options) + + return jobs + + def _run_jobs(self, circuits: List[QuantumCircuit], **run_options) -> List[Job]: + truncated_metadata = [circ.metadata for circ in circuits] + jobs = self._run_jobs_recursive(circuits, truncated_metadata, **run_options) + return jobs + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + Experiment Options: + separate_jobs (Boolean): Whether to route different sub-experiments to different jobs. + """ + options = super()._default_experiment_options() + options.separate_jobs = False + return options diff --git a/qiskit_experiments/framework/composite/composite_experiment.py b/qiskit_experiments/framework/composite/composite_experiment.py index 131b24099f..2dac6df1b2 100644 --- a/qiskit_experiments/framework/composite/composite_experiment.py +++ b/qiskit_experiments/framework/composite/composite_experiment.py @@ -17,6 +17,7 @@ from abc import abstractmethod import warnings from qiskit.providers.backend import Backend +from qiskit_experiments.exceptions import QiskitError from qiskit_experiments.framework import BaseExperiment from qiskit_experiments.framework.base_analysis import BaseAnalysis from .composite_analysis import CompositeAnalysis @@ -149,6 +150,9 @@ def _finalize(self): # measurements this method should be updated to validate that all # sub-experiments have the same meas level and meas return types, # and update the composite experiment run option to that value. + # + # In addition, we raise an error if we detect inconsistencies in + # the usage of BatchExperiment separate_job experiment option. for i, subexp in enumerate(self._experiments): # Validate set and default run options in component experiment @@ -175,6 +179,14 @@ def _finalize(self): # they can be used by that sub experiments _finalize method. subexp.set_run_options(**dict(zip(overridden_keys, comp_vals))) + if not self.experiment_options.get( + "separate_jobs", False + ) and subexp.experiment_options.get("separate_jobs", False): + raise QiskitError( + "It is not allowed to request to separate jobs in a child experiment," + " if its parent does not separate jobs as well" + ) + # Call sub-experiments finalize method subexp._finalize() diff --git a/qiskit_experiments/library/__init__.py b/qiskit_experiments/library/__init__.py index c199f98044..46374cce5a 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.StateTomography ~tomography.ProcessTomography ~quantum_volume.QuantumVolume @@ -153,7 +154,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 StateTomography, ProcessTomography from .quantum_volume import QuantumVolume diff --git a/qiskit_experiments/library/characterization/analysis/local_readout_error_analysis.py b/qiskit_experiments/library/characterization/analysis/local_readout_error_analysis.py index 7282e19378..da3c99fdb9 100644 --- a/qiskit_experiments/library/characterization/analysis/local_readout_error_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/local_readout_error_analysis.py @@ -92,14 +92,16 @@ def _generate_matrices(self, data) -> List[np.array]: for k in range(num_qubits): matrix = np.zeros([2, 2], dtype=float) marginalized_counts = [] + shots = [] for i in range(2): - marginalized_counts.append(marginal_counts(counts[i], [k])) + marginal_cts = marginal_counts(counts[i], [k]) + marginalized_counts.append(marginal_cts) + shots.append(sum(marginal_cts.values())) + # matrix[i][j] is the probability of counting i for expected j for i in range(2): for j in range(2): - matrix[i][j] = marginalized_counts[j][str(i)] / sum( - marginalized_counts[j].values() - ) + matrix[i][j] = marginalized_counts[j].get(str(i), 0) / shots[j] matrices.append(matrix) return matrices diff --git a/qiskit_experiments/library/characterization/analysis/multi_state_discrimination_analysis.py b/qiskit_experiments/library/characterization/analysis/multi_state_discrimination_analysis.py index e87c680937..be39380b22 100644 --- a/qiskit_experiments/library/characterization/analysis/multi_state_discrimination_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/multi_state_discrimination_analysis.py @@ -16,13 +16,20 @@ import matplotlib import numpy as np -from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis from qiskit.providers.options import Options from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, ExperimentData from qiskit_experiments.data_processing import SkQDA +from qiskit_experiments.data_processing.exceptions import DataProcessorError from qiskit_experiments.visualization import BasePlotter, IQPlotter, MplDrawer, PlotStyle +try: + from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis + + HAS_SKLEARN = True +except ImportError: + HAS_SKLEARN = False + class MultiStateDiscriminationAnalysis(BaseAnalysis): r"""This class fits a multi-state discriminator to the data. @@ -38,6 +45,19 @@ class MultiStateDiscriminationAnalysis(BaseAnalysis): probability of measuring outcome :math:`i` given that state :math:`j` was prepared. """ + def __init__(self): + """Setup the analysis. + + Raises: + DataProcessorError: if sklearn is not installed. + """ + if not HAS_SKLEARN: + raise DataProcessorError( + f"SKlearn is needed to initialize an {self.__class__.__name__}." + ) + + super().__init__() + @classmethod def _default_options(cls) -> Options: """Return default analysis options. diff --git a/qiskit_experiments/library/characterization/correlated_readout_error.py b/qiskit_experiments/library/characterization/correlated_readout_error.py index 79bf7c52a6..dd99882624 100644 --- a/qiskit_experiments/library/characterization/correlated_readout_error.py +++ b/qiskit_experiments/library/characterization/correlated_readout_error.py @@ -12,8 +12,11 @@ """ Correlated readout error calibration experiment class. """ -from typing import Iterable, List +import warnings +from typing import Iterable, List, Optional from qiskit import QuantumCircuit +from qiskit.providers.backend import BackendV2, Backend +from qiskit.exceptions import QiskitError from qiskit_experiments.framework import BaseExperiment from qiskit_experiments.library.characterization.analysis.correlated_readout_error_analysis import ( CorrelatedReadoutErrorAnalysis, @@ -74,13 +77,46 @@ class CorrelatedReadoutError(BaseExperiment): .. ref_arxiv:: 1 2006.14044 """ - def __init__(self, qubits: Iterable[int]): + def __init__( + self, + physical_qubits: Optional[Iterable[int]] = None, + backend: Optional[Backend] = None, + qubits: Optional[Iterable[int]] = None, + ): """Initialize a correlated readout error characterization experiment. Args: - qubits: The qubits being characterized for readout error + physical_qubits: Optional, the backend qubits being characterized + for readout error. If None all qubits on the provided backend + will be characterized. + backend: Optional, the backend to characterize. + qubits: DEPRECATED, equivalent to ``physical_qubits``. + + Raises: + QiskitError: if args are not valid. """ - super().__init__(qubits) + # Deprecated qubits kwarg + if qubits is not None: + physical_qubits = qubits + warnings.warn( + "The `qubits` kwarg has been renamed to `physical_qubits`." + " It will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + if physical_qubits is None: + if backend is None: + raise QiskitError("`physical_qubits` and `backend` kwargs cannot both be None.") + num_qubits = 0 + if isinstance(backend, BackendV2): + num_qubits = backend.target.num_qubits + elif isinstance(backend, Backend): + num_qubits = backend.configuration().num_qubits + if num_qubits: + physical_qubits = range(num_qubits) + else: + raise QiskitError(f"Cannot infer backend qubits from backend {backend}") + super().__init__(physical_qubits, backend=backend) self.analysis = CorrelatedReadoutErrorAnalysis() def circuits(self) -> List[QuantumCircuit]: diff --git a/qiskit_experiments/library/characterization/local_readout_error.py b/qiskit_experiments/library/characterization/local_readout_error.py index 77efdfb5b1..e0998da0f9 100644 --- a/qiskit_experiments/library/characterization/local_readout_error.py +++ b/qiskit_experiments/library/characterization/local_readout_error.py @@ -12,8 +12,11 @@ """ Local readout error calibration experiment class. """ -from typing import Iterable, List +import warnings +from typing import Iterable, List, Optional from qiskit import QuantumCircuit +from qiskit.providers.backend import BackendV2, Backend +from qiskit.exceptions import QiskitError from qiskit_experiments.framework import BaseExperiment from qiskit_experiments.library.characterization.analysis.local_readout_error_analysis import ( LocalReadoutErrorAnalysis, @@ -63,13 +66,46 @@ class LocalReadoutError(BaseExperiment): .. ref_arxiv:: 1 2006.14044 """ - def __init__(self, qubits: Iterable[int]): + def __init__( + self, + physical_qubits: Optional[Iterable[int]] = None, + backend: Optional[Backend] = None, + qubits: Optional[Iterable[int]] = None, + ): """Initialize a local readout error characterization experiment. Args: - qubits: The qubits being characterized for readout error + physical_qubits: Optional, the backend qubits being characterized + for readout error. If None all qubits on the provided backend + will be characterized. + backend: Optional, the backend to characterize. + qubits: DEPRECATED, equivalent to ``physical_qubits``. + + Raises: + QiskitError: if args are not valid. """ - super().__init__(qubits) + # Deprecated qubits kwarg + if qubits is not None: + physical_qubits = qubits + warnings.warn( + "The `qubits` kwarg has been renamed to `physical_qubits`." + " It will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + if physical_qubits is None: + if backend is None: + raise QiskitError("`physical_qubits` and `backend` kwargs cannot both be None.") + num_qubits = 0 + if isinstance(backend, BackendV2): + num_qubits = backend.target.num_qubits + elif isinstance(backend, Backend): + num_qubits = backend.configuration().num_qubits + if num_qubits: + physical_qubits = range(num_qubits) + else: + raise QiskitError(f"Cannot infer backend qubits from backend {backend}") + super().__init__(physical_qubits, backend=backend) self.analysis = LocalReadoutErrorAnalysis() def circuits(self) -> List[QuantumCircuit]: diff --git a/qiskit_experiments/library/characterization/resonator_spectroscopy.py b/qiskit_experiments/library/characterization/resonator_spectroscopy.py index 03e5ef7712..fdec529ced 100644 --- a/qiskit_experiments/library/characterization/resonator_spectroscopy.py +++ b/qiskit_experiments/library/characterization/resonator_spectroscopy.py @@ -53,12 +53,24 @@ class ResonatorSpectroscopy(Spectroscopy): c: 1/════════════════════╩═ 0 - Side note: when doing readout resonator spectroscopy, each measured IQ point has a + Side note 1: when doing readout resonator spectroscopy, each measured IQ point has a frequency dependent phase. Close to the resonance, the IQ points start rotating around in the IQ plan. This effect must be accounted for in the data processing to produce a meaningful signal. The default data processing workflow will therefore reduce the two- dimensional IQ data to one-dimensional data using the magnitude of each IQ point. + Side node 2: when running readout resonator spectroscopy in a parallel experiment the + user will need to specify the memory slot to use. This can easily be done with the code + shown below. + + .. code:: + + specs = [] + for slot, qubit in enumerate(qubits): + specs.append(ResonatorSpectroscopy(qubit=qubit, backend=backend2, memory_slot=slot)) + + exp = ParallelExperiment(specs, backend=backend2) + # section: warning Some backends may not have the required functionality to properly support resonator spectroscopy experiments. The experiment may not work or the resulting resonance @@ -101,6 +113,9 @@ def _default_experiment_options(cls) -> Options: initial_circuit (QuantumCircuit): A single-qubit initial circuit to run before the measurement/spectroscopy pulse. The circuit must contain only a single qubit and zero classical bits. If None, no circuit is appended before the measurement. Defaults to None. + memory_slot (int): The memory slot that the acquire instruction uses in the pulse schedule. + The default value is ``0``. This argument allows the experiment to function in a + :class:`.ParallelExperiment`. """ options = super()._default_experiment_options() @@ -109,6 +124,7 @@ def _default_experiment_options(cls) -> Options: options.sigma = 60e-9 options.width = 360e-9 options.initial_circuit = None + options.memory_slot = 0 return options @@ -221,7 +237,7 @@ def _schedule(self) -> Tuple[pulse.ScheduleBlock, Parameter]: ), pulse.MeasureChannel(qubit), ) - pulse.acquire(duration, qubit, pulse.MemorySlot(0)) + pulse.acquire(duration, qubit, pulse.MemorySlot(self.experiment_options.memory_slot)) return schedule, freq_param diff --git a/qiskit_experiments/library/quantum_volume/qv_experiment.py b/qiskit_experiments/library/quantum_volume/qv_experiment.py index b7f896608e..2b8d5cf77f 100644 --- a/qiskit_experiments/library/quantum_volume/qv_experiment.py +++ b/qiskit_experiments/library/quantum_volume/qv_experiment.py @@ -14,8 +14,7 @@ """ from typing import Union, Sequence, Optional, List -from numpy.random import Generator, default_rng -from numpy.random.bit_generator import BitGenerator, SeedSequence +from numpy.random import Generator, default_rng, BitGenerator, SeedSequence try: from qiskit import Aer diff --git a/qiskit_experiments/library/randomized_benchmarking/__init__.py b/qiskit_experiments/library/randomized_benchmarking/__init__.py index 7ace98d104..0ded39635a 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,6 +37,7 @@ RBAnalysis InterleavedRBAnalysis + MirrorRBAnalysis .. autosummary:: :toctree: ../stubs/ @@ -44,7 +46,9 @@ """ from .rb_experiment import StandardRB from .interleaved_rb_experiment import InterleavedRB +from .mirror_rb_experiment import MirrorRB from .rb_analysis import RBAnalysis from .interleaved_rb_analysis import InterleavedRBAnalysis +from .mirror_rb_analysis import MirrorRBAnalysis from .rb_utils import RBUtils from .clifford_utils import CliffordUtils diff --git a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py index 985edbc0e4..5db5f18ec7 100644 --- a/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py +++ b/qiskit_experiments/library/randomized_benchmarking/clifford_utils.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2021, 2022. # # 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 @@ -13,28 +13,138 @@ Utilities for using the Clifford group in randomized benchmarking """ +import itertools +import os from functools import lru_cache from numbers import Integral -from typing import Optional, Union +from typing import Optional, Union, Tuple, Sequence +import numpy as np +import scipy.sparse from numpy.random import Generator, default_rng +import warnings +from typing import Optional, Union, Sequence, List +from qiskit.circuit import CircuitInstruction, Qubit from qiskit.circuit import Gate, Instruction from qiskit.circuit import QuantumCircuit, QuantumRegister -from qiskit.circuit.library import SdgGate, HGate, SGate +from qiskit.circuit.library import SdgGate, HGate, SGate, XGate, YGate, ZGate +from qiskit.compiler import transpile +from qiskit.exceptions import QiskitError from qiskit.quantum_info import Clifford, random_clifford +from qiskit_experiments.warnings import deprecated_function + + +_DATA_FOLDER = os.path.join(os.path.dirname(__file__), "data") + +_CLIFFORD_COMPOSE_1Q = np.load(f"{_DATA_FOLDER}/clifford_compose_1q.npz")["table"] +_CLIFFORD_INVERSE_1Q = np.load(f"{_DATA_FOLDER}/clifford_inverse_1q.npz")["table"] +_CLIFFORD_COMPOSE_2Q = scipy.sparse.load_npz(f"{_DATA_FOLDER}/clifford_compose_2q_sparse.npz") +_CLIFFORD_INVERSE_2Q = np.load(f"{_DATA_FOLDER}/clifford_inverse_2q.npz")["table"] + + +# Transpilation utilities +def _transpile_clifford_circuit( + circuit: QuantumCircuit, physical_qubits: Sequence[int] +) -> QuantumCircuit: + # Simplified transpile that only decomposes Clifford circuits and creates the layout. + return _apply_qubit_layout(_decompose_clifford_ops(circuit), physical_qubits=physical_qubits) + + +def _decompose_clifford_ops(circuit: QuantumCircuit) -> QuantumCircuit: + # Simplified QuantumCircuit.decompose, which decomposes only Clifford ops + # Note that the resulting circuit depends on the input circuit, + # that means the changes on the input circuit may affect the resulting circuit. + # For example, the resulting circuit shares the parameter_table of the input circuit, + res = circuit.copy_empty_like() + res._parameter_table = circuit._parameter_table + for inst in circuit: + if inst.operation.name.startswith("Clifford"): # Decompose + rule = inst.operation.definition.data + if len(rule) == 1 and len(inst.qubits) == len(rule[0].qubits): + if inst.operation.definition.global_phase: + res.global_phase += inst.operation.definition.global_phase + res._data.append( + CircuitInstruction( + operation=rule[0].operation, + qubits=inst.qubits, + clbits=inst.clbits, + ) + ) + else: + _circuit_compose(res, inst.operation.definition, qubits=inst.qubits) + else: # Keep the original instruction + res._data.append(inst) + return res + + +def _apply_qubit_layout(circuit: QuantumCircuit, physical_qubits: Sequence[int]) -> QuantumCircuit: + # Mapping qubits in circuit to physical qubits (layout) + res = QuantumCircuit(1 + max(physical_qubits), name=circuit.name, metadata=circuit.metadata) + res.add_bits(circuit.clbits) + for reg in circuit.cregs: + res.add_register(reg) + _circuit_compose(res, circuit, qubits=physical_qubits) + res._parameter_table = circuit._parameter_table + return res + + +def _circuit_compose( + self: QuantumCircuit, other: QuantumCircuit, qubits: Sequence[Union[Qubit, int]] +) -> QuantumCircuit: + # Simplified QuantumCircuit.compose with clbits=None, front=False, inplace=True, wrap=False + # without any validation, parameter_table/calibrations updates and copy of operations + # The input circuit `self` is changed inplace. + qubit_map = { + other.qubits[i]: (self.qubits[q] if isinstance(q, int) else q) for i, q in enumerate(qubits) + } + for instr in other: + self._data.append( + CircuitInstruction( + operation=instr.operation, + qubits=[qubit_map[q] for q in instr.qubits], + clbits=instr.clbits, + ), + ) + self.global_phase += other.global_phase + return self + + +def _truncate_inactive_qubits( + circ: QuantumCircuit, active_qubits: Sequence[Qubit] +) -> QuantumCircuit: + res = QuantumCircuit(active_qubits, name=circ.name, metadata=circ.metadata) + for inst in circ: + if all(q in active_qubits for q in inst.qubits): + res.append(inst) + res.calibrations = circ.calibrations + return res + + +def _synthesize_clifford_circuit( + circuit: QuantumCircuit, basis_gates: Tuple[str] +) -> QuantumCircuit: + # synthesizes clifford circuits using given basis gates, for use during + # custom transpilation during RB circuit generation. + return transpile(circuit, basis_gates=list(basis_gates), optimization_level=0) @lru_cache(maxsize=None) -def _clifford_1q_int_to_instruction(num: Integral) -> Instruction: - return CliffordUtils.clifford_1_qubit_circuit(num).to_instruction() +def _clifford_1q_int_to_instruction( + num: Integral, basis_gates: Optional[Tuple[str]] +) -> Instruction: + return CliffordUtils.clifford_1_qubit_circuit(num, basis_gates).to_instruction() @lru_cache(maxsize=11520) -def _clifford_2q_int_to_instruction(num: Integral) -> Instruction: - return CliffordUtils.clifford_2_qubit_circuit(num).to_instruction() +def _clifford_2q_int_to_instruction( + num: Integral, basis_gates: Optional[Tuple[str]] +) -> Instruction: + return CliffordUtils.clifford_2_qubit_circuit(num, basis_gates).to_instruction() +# The classes VGate and WGate are not actually used in the code - we leave them here to give +# a better understanding of the composition of the layers for 2-qubit Cliffords. class VGate(Gate): """V Gate used in Clifford synthesis.""" @@ -71,7 +181,7 @@ class CliffordUtils: NUM_CLIFFORD_1_QUBIT = 24 NUM_CLIFFORD_2_QUBIT = 11520 CLIFFORD_1_QUBIT_SIG = (2, 3, 4) - CLIFFORD_2_QUBIT_SIGS = [ + CLIFFORD_2_QUBIT_SIGS = [ # TODO: deprecate (2, 2, 3, 3, 4, 4), (2, 2, 3, 3, 3, 3, 4, 4), (2, 2, 3, 3, 3, 3, 4, 4), @@ -94,32 +204,75 @@ def clifford_2_qubit(cls, num): """ return Clifford(cls.clifford_2_qubit_circuit(num), validate=False) + @deprecated_function() + @classmethod def random_cliffords( - self, num_qubits: int, size: int = 1, rng: Optional[Union[int, Generator]] = None + cls, num_qubits: int, size: int = 1, rng: Optional[Union[int, Generator]] = None ): """Generate a list of random clifford elements""" - if num_qubits > 2: - return random_clifford(num_qubits, seed=rng) - if rng is None: rng = default_rng() - - if isinstance(rng, int): + elif isinstance(rng, int): rng = default_rng(rng) if num_qubits == 1: - samples = rng.integers(24, size=size) - return [Clifford(self.clifford_1_qubit_circuit(i), validate=False) for i in samples] - else: - samples = rng.integers(11520, size=size) - return [Clifford(self.clifford_2_qubit_circuit(i), validate=False) for i in samples] + samples = rng.integers(cls.NUM_CLIFFORD_1_QUBIT, size=size) + return [Clifford(cls.clifford_1_qubit_circuit(i), validate=False) for i in samples] + if num_qubits == 2: + samples = rng.integers(cls.NUM_CLIFFORD_2_QUBIT, size=size) + return [Clifford(cls.clifford_2_qubit_circuit(i), validate=False) for i in samples] + return [random_clifford(num_qubits, seed=rng) for _ in range(size)] + + @deprecated_function() + @classmethod def random_clifford_circuits( - self, num_qubits: int, size: int = 1, rng: Optional[Union[int, Generator]] = None + cls, num_qubits: int, size: int = 1, rng: Optional[Union[int, Generator]] = None ): """Generate a list of random clifford circuits""" - if num_qubits > 2: - return [random_clifford(num_qubits, seed=rng).to_circuit() for _ in range(size)] + if rng is None: + rng = default_rng() + elif isinstance(rng, int): + rng = default_rng(rng) + + if num_qubits == 1: + samples = rng.integers(cls.NUM_CLIFFORD_1_QUBIT, size=size) + return [cls.clifford_1_qubit_circuit(i) for i in samples] + if num_qubits == 2: + samples = rng.integers(cls.NUM_CLIFFORD_2_QUBIT, size=size) + return [cls.clifford_2_qubit_circuit(i) for i in samples] + + return [random_clifford(num_qubits, seed=rng).to_circuit() for _ in range(size)] + + def random_edgegrab_clifford_circuits( + self, + qubits: Sequence[int], + coupling_map: list, + two_qubit_gate_density: float = 0.2, + size: int = 1, + rng: Optional[Union[int, Generator]] = None, + ) -> List[QuantumCircuit]: + """Generate a list of random Clifford circuits sampled using the edgegrab algorithm + + Args: + qubits: Sequence of integers representing the physical qubits + coupling_map: List of edges, where an edge is a list of 2 integers + two_qubit_gate_density: :math:`1/2` times the expected fraction of qubits with CX gates + size: length of RB sequence + rng: Random seed + + Raises: + Warning: If device has no connectivity or two_qubit_gate_density is too high + + Returns: + List of QuantumCircuits + + Ref: arXiv:2008.11294v2 + """ + num_qubits = len(qubits) + # if circuit has one qubit, call random_clifford_circuits() + if num_qubits == 1: + return self.random_clifford_circuits(num_qubits, size, rng) if rng is None: rng = default_rng() @@ -127,20 +280,66 @@ def random_clifford_circuits( if isinstance(rng, int): rng = default_rng(rng) - if num_qubits == 1: - samples = rng.integers(24, size=size) - return [self.clifford_1_qubit_circuit(i) for i in samples] - else: - samples = rng.integers(11520, size=size) - return [self.clifford_2_qubit_circuit(i) for i in samples] + qc_list = [] + for _ in list(range(size)): + all_edges = coupling_map[:] # make copy of coupling map from which we pop edges + selected_edges = [] + while all_edges: + rand_edge = all_edges.pop(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) + + qr = QuantumRegister(num_qubits) + qc = QuantumCircuit(qr) + two_qubit_prob = 0 + try: + two_qubit_prob = num_qubits * two_qubit_gate_density / len(selected_edges) + except ZeroDivisionError: + warnings.warn( + "Device has no connectivity. All cliffords will be single-qubit Cliffords" + ) + if two_qubit_prob > 1: + warnings.warn( + "Mean number of two-qubit gates is higher than number of selected edges for CNOTs. " + + "Actual density of two-qubit gates will likely be lower than input density" + ) + selected_edges_logical = [ + [np.where(q == np.asarray(qubits))[0][0] for q in edge] for edge in selected_edges + ] + # selected_edges_logical is selected_edges with logical qubit labels rather than physical + # ones. Example: qubits = (8,4,5,3,7), selected_edges = [[4,8],[7,5]] + # ==> selected_edges_logical = [[1,0],[4,2]] + put_1_qubit_clifford = np.arange(num_qubits) + # put_1_qubit_clifford is a list of qubits that aren't assigned to a 2-qubit Clifford + # 1-qubit Clifford will be assigned to these edges + for edge in selected_edges_logical: + if rng.random() < two_qubit_prob: + # with probability two_qubit_prob, place CNOT on edge in selected_edges + qc.cx(edge[0], edge[1]) + # remove these qubits from put_1_qubit_clifford + put_1_qubit_clifford = np.setdiff1d(put_1_qubit_clifford, edge) + for q in put_1_qubit_clifford: + clifford1q = self.clifford_1_qubit_circuit(rng.integers(24)) + insts = [datum[0] for datum in clifford1q.data] + for inst in insts: + qc.compose(inst, [q], inplace=True) + qc_list.append(qc) + return qc_list @classmethod @lru_cache(maxsize=24) - def clifford_1_qubit_circuit(cls, num): + def clifford_1_qubit_circuit(cls, num, basis_gates: Optional[Tuple[str, ...]] = None): """Return the 1-qubit clifford circuit corresponding to `num` where `num` is between 0 and 23. """ - unpacked = cls._unpack_num(num, (2, 3, 4)) + unpacked = cls._unpack_num(num, cls.CLIFFORD_1_QUBIT_SIG) i, j, p = unpacked[0], unpacked[1], unpacked[2] qc = QuantumCircuit(1, name=f"Clifford-1Q({num})") if i == 1: @@ -156,63 +355,24 @@ def clifford_1_qubit_circuit(cls, num): if p == 3: qc.z(0) + if basis_gates: + qc = _synthesize_clifford_circuit(qc, basis_gates) + return qc @classmethod @lru_cache(maxsize=11520) - def clifford_2_qubit_circuit(cls, num): + def clifford_2_qubit_circuit(cls, num, basis_gates: Optional[Tuple[str, ...]] = None): """Return the 2-qubit clifford circuit corresponding to `num` where `num` is between 0 and 11519. """ - vals = cls._unpack_num_multi_sigs(num, cls.CLIFFORD_2_QUBIT_SIGS) qc = QuantumCircuit(2, name=f"Clifford-2Q({num})") - if vals[0] == 0 or vals[0] == 3: - (form, i0, i1, j0, j1, p0, p1) = vals - else: - (form, i0, i1, j0, j1, k0, k1, p0, p1) = vals - if i0 == 1: - qc.h(0) - if i1 == 1: - qc.h(1) - if j0 == 1: - qc.sxdg(0) - if j0 == 2: - qc.s(0) - if j1 == 1: - qc.sxdg(1) - if j1 == 2: - qc.s(1) - if form in (1, 2, 3): - qc.cx(0, 1) - if form in (2, 3): - qc.cx(1, 0) - if form == 3: - qc.cx(0, 1) - if form in (1, 2): - if k0 == 1: # V gate - qc.sdg(0) - qc.h(0) - if k0 == 2: # W gate - qc.h(0) - qc.s(0) - if k1 == 1: # V gate - qc.sdg(1) - qc.h(1) - if k1 == 2: # W gate - qc.h(1) - qc.s(1) - if p0 == 1: - qc.x(0) - if p0 == 2: - qc.y(0) - if p0 == 3: - qc.z(0) - if p1 == 1: - qc.x(1) - if p1 == 2: - qc.y(1) - if p1 == 3: - qc.z(1) + for layer, idx in enumerate(_layer_indices_from_num(num)): + if basis_gates: + layer_circ = _transformed_clifford_layer(layer, idx, basis_gates) + else: + layer_circ = _CLIFFORD_LAYER[layer][idx] + _circuit_compose(qc, layer_circ, qubits=(0, 1)) return qc @@ -229,16 +389,289 @@ def _unpack_num(num, sig): num //= k return res - @staticmethod - def _unpack_num_multi_sigs(num, sigs): - """Returns the result of `_unpack_num` on one of the - signatures in `sigs` - """ - for i, sig in enumerate(sigs): - sig_size = 1 - for k in sig: - sig_size *= k - if num < sig_size: - return [i] + CliffordUtils._unpack_num(num, sig) - num -= sig_size - return None + +# 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. +_CLIFF_SINGLE_GATE_MAP_1Q = { + ("id", (0,)): 0, + ("h", (0,)): 1, + ("sxdg", (0,)): 2, + ("s", (0,)): 4, + ("x", (0,)): 6, + ("sx", (0,)): 8, + ("y", (0,)): 12, + ("z", (0,)): 18, + ("sdg", (0,)): 22, +} +# Constant mapping from 2Q single Clifford gate to 2Q Clifford numerical identifier. +# This table must be generated using `data.generate_clifford_data.gen_cliff_single_2q_gate_map`, or, +# equivalently, correspond to the ordering defined by _layer_indices_from_num and _CLIFFORD_LAYER. +_CLIFF_SINGLE_GATE_MAP_2Q = { + ("id", (0,)): 0, + ("id", (1,)): 0, + ("h", (0,)): 5760, + ("h", (1,)): 2880, + ("sxdg", (0,)): 6720, + ("sxdg", (1,)): 3200, + ("s", (0,)): 7680, + ("s", (1,)): 3520, + ("x", (0,)): 4, + ("x", (1,)): 1, + ("sx", (0,)): 6724, + ("sx", (1,)): 3201, + ("y", (0,)): 8, + ("y", (1,)): 2, + ("z", (0,)): 12, + ("z", (1,)): 3, + ("sdg", (0,)): 7692, + ("sdg", (1,)): 3523, + ("cx", (0, 1)): 16, + ("cx", (1, 0)): 2336, + ("cz", (0, 1)): 368, + ("cz", (1, 0)): 368, +} + + +######## +# Functions for 1-qubit integer Clifford operations +def compose_1q(lhs: Integral, rhs: Integral) -> Integral: + """Return the composition of 1-qubit clifford integers.""" + return _CLIFFORD_COMPOSE_1Q[lhs, rhs] + + +def inverse_1q(num: Integral) -> Integral: + """Return the inverse of a 1-qubit clifford integer.""" + return _CLIFFORD_INVERSE_1Q[num] + + +def num_from_1q_circuit(qc: QuantumCircuit) -> Integral: + """Convert a given 1-qubit Clifford circuit to the corresponding integer.""" + num = 0 + for inst in qc: + rhs = _num_from_1q_gate(op=inst.operation) + num = _CLIFFORD_COMPOSE_1Q[num, rhs] + return num + + +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. + + Args: + op: operation to be converted. + + Returns: + An integer representing a Clifford consisting of a single operation. + + Raises: + QiskitError: if the input instruction is not a Clifford instruction. + QiskitError: if rz is given with a angle that is not Clifford. + """ + if op.name in {"delay", "barrier"}: + return 0 + try: + name = _deparameterized_name(op) + return _CLIFF_SINGLE_GATE_MAP_1Q[(name, (0,))] + except QiskitError as err: + raise QiskitError( + f"Parameterized instruction {op.name} could not be converted to integer Clifford" + ) from err + except KeyError as err: + raise QiskitError( + f"Instruction {op.name} could not be converted to integer Clifford" + ) from err + + +def _deparameterized_name(inst: Instruction) -> str: + if inst.name == "rz": + if np.isclose(inst.params[0], np.pi) or np.isclose(inst.params[0], -np.pi): + return "z" + elif np.isclose(inst.params[0], np.pi / 2): + return "s" + elif np.isclose(inst.params[0], -np.pi / 2): + return "sdg" + else: + raise QiskitError("Wrong param {} for rz in clifford".format(inst.params[0])) + + return inst.name + + +######## +# Functions for 2-qubit integer Clifford operations +def compose_2q(lhs: Integral, rhs: Integral) -> Integral: + """Return the composition of 2-qubit clifford integers.""" + num = lhs + for layer, idx in enumerate(_layer_indices_from_num(rhs)): + circ = _CLIFFORD_LAYER[layer][idx] + num = _compose_num_with_circuit_2q(num, circ) + return num + + +def inverse_2q(num: Integral) -> Integral: + """Return the inverse of a 2-qubit clifford integer.""" + return _CLIFFORD_INVERSE_2Q[num] + + +def num_from_2q_circuit(qc: QuantumCircuit) -> Integral: + """Convert a given 2-qubit Clifford circuit to the corresponding integer.""" + return _compose_num_with_circuit_2q(0, qc) + + +def _compose_num_with_circuit_2q(num: Integral, qc: QuantumCircuit) -> Integral: + """Compose a number that represents a Clifford, with a Clifford circuit, and return the + number that represents the resulting Clifford.""" + lhs = num + for inst in qc: + qubits = tuple(qc.find_bit(q).index for q in inst.qubits) + rhs = _num_from_2q_gate(op=inst.operation, qubits=qubits) + lhs = _CLIFFORD_COMPOSE_2Q[lhs, rhs] + return lhs + + +def _num_from_2q_gate( + op: Instruction, qubits: Optional[Union[Tuple[int, int], Tuple[int]]] = None +) -> 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. + + Args: + op: operation of instruction to be converted. + qubits: qubits to which the operation applies + + Returns: + An integer representing a Clifford consisting of a single operation. + + Raises: + QiskitError: if the input instruction is not a Clifford instruction. + QiskitError: if rz is given with a angle that is not Clifford. + """ + if op.name in {"delay", "barrier"}: + return 0 + + qubits = qubits or (0, 1) + try: + name = _deparameterized_name(op) + return _CLIFF_SINGLE_GATE_MAP_2Q[(name, qubits)] + except QiskitError as err: + raise QiskitError( + f"Parameterized instruction {op.name} could not be converted to integer Clifford" + ) from err + except KeyError as err: + raise QiskitError( + f"Instruction {op.name} on {qubits} could not be converted to integer Clifford" + ) from err + + +def _append_v_w(qc, vw0, vw1): + if vw0 == "v": + qc.sdg(0) + qc.h(0) + elif vw0 == "w": + qc.h(0) + qc.s(0) + if vw1 == "v": + qc.sdg(1) + qc.h(1) + elif vw1 == "w": + qc.h(1) + qc.s(1) + + +def _create_cliff_2q_layer_0(): + """Layer 0 consists of 0 or 1 H gates on each qubit, followed by 0/1/2 V gates on each qubit. + Number of Cliffords == 36.""" + circuits = [] + num_h = [0, 1] + v_w_gates = ["i", "v", "w"] + for h0, h1, v0, v1 in itertools.product(num_h, num_h, v_w_gates, v_w_gates): + qc = QuantumCircuit(2) + for _ in range(h0): + qc.h(0) + for _ in range(h1): + qc.h(1) + _append_v_w(qc, v0, v1) + circuits.append(qc) + return circuits + + +def _create_cliff_2q_layer_1(): + """Layer 1 consists of one of the following: + - nothing + - cx(0,1) followed by 0/1/2 V gates on each qubit + - cx(0,1), cx(1,0) followed by 0/1/2 V gates on each qubit + - cx(0,1), cx(1,0), cx(0,1) + Number of Cliffords == 20.""" + circuits = [QuantumCircuit(2)] # identity at the beginning + + v_w_gates = ["i", "v", "w"] + for v0, v1 in itertools.product(v_w_gates, v_w_gates): + qc = QuantumCircuit(2) + qc.cx(0, 1) + _append_v_w(qc, v0, v1) + circuits.append(qc) + + for v0, v1 in itertools.product(v_w_gates, v_w_gates): + qc = QuantumCircuit(2) + qc.cx(0, 1) + qc.cx(1, 0) + _append_v_w(qc, v0, v1) + circuits.append(qc) + + qc = QuantumCircuit(2) # swap at the end + qc.cx(0, 1) + qc.cx(1, 0) + qc.cx(0, 1) + circuits.append(qc) + return circuits + + +def _create_cliff_2q_layer_2(): + """Layer 2 consists of a Pauli gate on each qubit {Id, X, Y, Z}. + Number of Cliffords == 16.""" + circuits = [] + pauli = ("i", XGate(), YGate(), ZGate()) + for p0, p1 in itertools.product(pauli, pauli): + qc = QuantumCircuit(2) + if p0 != "i": + qc.append(p0, [0]) + if p1 != "i": + qc.append(p1, [1]) + circuits.append(qc) + return circuits + + +_CLIFFORD_LAYER = ( + _create_cliff_2q_layer_0(), + _create_cliff_2q_layer_1(), + _create_cliff_2q_layer_2(), +) +_NUM_LAYER_0 = 36 +_NUM_LAYER_1 = 20 +_NUM_LAYER_2 = 16 + + +@lru_cache(maxsize=None) +def _transformed_clifford_layer( + layer: int, index: Integral, basis_gates: Tuple[str, ...] +) -> QuantumCircuit: + # Return the index-th quantum circuit of the layer translated with the basis_gates. + # The result is cached for speed. + return _synthesize_clifford_circuit(_CLIFFORD_LAYER[layer][index], basis_gates) + + +def _num_from_layer_indices(triplet: Tuple[Integral, Integral, Integral]) -> Integral: + """Return the clifford number corresponding to the input triplet.""" + num = triplet[0] * _NUM_LAYER_1 * _NUM_LAYER_2 + triplet[1] * _NUM_LAYER_2 + triplet[2] + return num + + +def _layer_indices_from_num(num: Integral) -> Tuple[Integral, Integral, Integral]: + """Return the triplet of layer indices corresponding to the input number.""" + idx2 = num % _NUM_LAYER_2 + num = num // _NUM_LAYER_2 + idx1 = num % _NUM_LAYER_1 + idx0 = num // _NUM_LAYER_1 + return idx0, idx1, idx2 diff --git a/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_1q.npz b/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_1q.npz new file mode 100644 index 0000000000..5794227a06 Binary files /dev/null and b/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_1q.npz differ diff --git a/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_2q_sparse.npz b/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_2q_sparse.npz new file mode 100644 index 0000000000..19439b7be6 Binary files /dev/null and b/qiskit_experiments/library/randomized_benchmarking/data/clifford_compose_2q_sparse.npz differ diff --git a/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_1q.npz b/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_1q.npz new file mode 100644 index 0000000000..dedef02825 Binary files /dev/null and b/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_1q.npz differ diff --git a/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_2q.npz b/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_2q.npz new file mode 100644 index 0000000000..62a8314f15 Binary files /dev/null and b/qiskit_experiments/library/randomized_benchmarking/data/clifford_inverse_2q.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 new file mode 100644 index 0000000000..8da50467cb --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/data/generate_clifford_data.py @@ -0,0 +1,187 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +This file is a stand-alone script for generating the npz files in +``~qiskit_experiment.library.randomized_benchmarking.clifford_utils.data`` directory. + +The script relies on the values of ``_CLIFF_SINGLE_GATE_MAP_2Q`` +in :mod:`~qiskit_experiment.library.randomized_benchmarking.clifford_utils` +so they must be set correctly before running the script. + +Note: Terra >= 0.22 is required to run this script. +""" +import itertools + +import numpy as np +import scipy.sparse + +from qiskit.circuit import QuantumCircuit +from qiskit.circuit.library import ( + IGate, + HGate, + SXdgGate, + SGate, + XGate, + SXGate, + YGate, + ZGate, + SdgGate, + CXGate, + CZGate, +) +from qiskit.quantum_info.operators.symplectic import Clifford +from qiskit_experiments.library.randomized_benchmarking.clifford_utils import ( + CliffordUtils, + _CLIFF_SINGLE_GATE_MAP_1Q, + _CLIFF_SINGLE_GATE_MAP_2Q, +) + +NUM_CLIFFORD_1Q = CliffordUtils.NUM_CLIFFORD_1_QUBIT +NUM_CLIFFORD_2Q = CliffordUtils.NUM_CLIFFORD_2_QUBIT + + +def _hash_cliff(cliff): + """Produce a hashable value that is unique for each different Clifford. This should only be + used internally when the classes being hashed are under our control, because classes of this + type are mutable.""" + return np.packbits(cliff.tableau).tobytes() + + +_CLIFF_1Q = {i: CliffordUtils.clifford_1_qubit(i) for i in range(NUM_CLIFFORD_1Q)} +_TO_INT_1Q = {_hash_cliff(cliff): i for i, cliff in _CLIFF_1Q.items()} + + +def gen_clifford_inverse_1q(): + """Generate table data for integer 1Q Clifford inversion""" + 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())] + assert all(sorted(invs) == np.arange(0, NUM_CLIFFORD_1Q)) + return invs + + +def gen_clifford_compose_1q(): + """Generate table data for integer 1Q Clifford composition.""" + 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.compose(cliff_j) + products[i, j] = _TO_INT_1Q[_hash_cliff(cliff)] + assert all(sorted(products[i]) == np.arange(0, NUM_CLIFFORD_1Q)) + return products + + +_CLIFF_2Q = {i: CliffordUtils.clifford_2_qubit(i) for i in range(NUM_CLIFFORD_2Q)} +_TO_INT_2Q = {_hash_cliff(cliff): i for i, cliff in _CLIFF_2Q.items()} + + +def gen_clifford_inverse_2q(): + """Generate table data for integer 2Q Clifford inversion""" + 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())] + assert all(sorted(invs) == np.arange(0, NUM_CLIFFORD_2Q)) + return invs + + +def gen_clifford_compose_2q_gate(): + """Generate data for a 2Q Clifford composition table. + + Cliffords are represented as integers between 0 and 11519. Note that the full composition table + would require :math:`11520^2` elements and is therefore *NOT* generated, as that would take + more than 100MB. Instead, we sparsely populate the composition table only for RHS elements + from a specific set of basis gates defined by the values of ``_CLIFF_SINGLE_GATE_MAP_2Q``. + This is sufficient because when composing two arbitrary Cliffords, we can decompose the RHS + into these basis gates (which needs to be done anyways), and subsequently compute the product + in multiple steps using this sparse table. + """ + products = scipy.sparse.lil_matrix((NUM_CLIFFORD_2Q, NUM_CLIFFORD_2Q), dtype=int) + for lhs, cliff_lhs in _CLIFF_2Q.items(): + for rhs in _CLIFF_SINGLE_GATE_MAP_2Q.values(): + composed = cliff_lhs.compose(_CLIFF_2Q[rhs]) + products[lhs, rhs] = _TO_INT_2Q[_hash_cliff(composed)] + return products.tocsr() + + +_GATE_LIST_1Q = [ + IGate(), + HGate(), + SXdgGate(), + SGate(), + XGate(), + SXGate(), + YGate(), + ZGate(), + SdgGate(), +] + + +def gen_cliff_single_1q_gate_map(): + """ + Generates a dict mapping numbers to 1Q Cliffords + to be used as the value for ``_CLIFF_SINGLE_GATE_MAP_1Q`` + in :mod:`~qiskit_experiment.library.randomized_benchmarking.clifford_utils`. + Based on it, we build a mapping from every single-gate-clifford to its number. + The mapping actually looks like {(gate, (0, )): num}. + """ + table = {} + for gate in _GATE_LIST_1Q: + qc = QuantumCircuit(1) + qc.append(gate, [0]) + num = _TO_INT_1Q[_hash_cliff(Clifford(qc))] + table[(gate.name, (0,))] = num + + return table + + +def gen_cliff_single_2q_gate_map(): + """ + Generates a dict mapping numbers to 2Q Cliffords + to be used as the value for ``_CLIFF_SINGLE_GATE_MAP_2Q`` + in :mod:`~qiskit_experiment.library.randomized_benchmarking.clifford_utils`. + Based on it, we build a mapping from every single-gate-clifford to its number. + The mapping actually looks like {(gate, (0, 1)): num}. + """ + gate_list_2q = [ + CXGate(), + CZGate(), + ] + table = {} + for gate, qubit in itertools.product(_GATE_LIST_1Q, [0, 1]): + qc = QuantumCircuit(2) + qc.append(gate, [qubit]) + num = _TO_INT_2Q[_hash_cliff(Clifford(qc))] + table[(gate.name, (qubit,))] = num + + for gate, qubits in itertools.product(gate_list_2q, [(0, 1), (1, 0)]): + qc = QuantumCircuit(2) + qc.append(gate, qubits) + num = _TO_INT_2Q[_hash_cliff(Clifford(qc))] + table[(gate.name, qubits)] = num + + return table + + +if __name__ == "__main__": + if _CLIFF_SINGLE_GATE_MAP_1Q != gen_cliff_single_1q_gate_map(): + raise Exception( + "_CLIFF_SINGLE_GATE_MAP_1Q must be generated by gen_cliff_single_1q_gate_map()" + ) + np.savez_compressed("clifford_inverse_1q.npz", table=gen_clifford_inverse_1q()) + np.savez_compressed("clifford_compose_1q.npz", table=gen_clifford_compose_1q()) + + if _CLIFF_SINGLE_GATE_MAP_2Q != gen_cliff_single_2q_gate_map(): + raise Exception( + "_CLIFF_SINGLE_GATE_MAP_2Q must be generated by gen_cliff_single_2q_gate_map()" + ) + np.savez_compressed("clifford_inverse_2q.npz", table=gen_clifford_inverse_2q()) + scipy.sparse.save_npz("clifford_compose_2q_sparse.npz", gen_clifford_compose_2q_gate()) diff --git a/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_experiment.py b/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_experiment.py index 62b03dfa85..a0d039e75b 100644 --- a/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_experiment.py +++ b/qiskit_experiments/library/randomized_benchmarking/interleaved_rb_experiment.py @@ -12,19 +12,22 @@ """ Interleaved RB Experiment class. """ -from typing import Union, Iterable, Optional, List, Sequence +import warnings +from typing import Union, Iterable, Optional, List, Sequence, Tuple -from numpy.random import Generator -from numpy.random.bit_generator import BitGenerator, SeedSequence +from numpy.random import Generator, BitGenerator, SeedSequence -from qiskit import QuantumCircuit -from qiskit.circuit import Instruction -from qiskit.quantum_info import Clifford +from qiskit.circuit import QuantumCircuit, Instruction, Gate, Delay +from qiskit.compiler import transpile from qiskit.exceptions import QiskitError from qiskit.providers.backend import Backend - -from .rb_experiment import StandardRB, SequenceElementType +from qiskit.quantum_info import Clifford +from qiskit.transpiler.exceptions import TranspilerError +from qiskit_experiments.framework.backend_timing import BackendTiming +from .clifford_utils import _truncate_inactive_qubits +from .clifford_utils import num_from_1q_circuit, num_from_2q_circuit from .interleaved_rb_analysis import InterleavedRBAnalysis +from .rb_experiment import StandardRB, SequenceElementType class InterleavedRB(StandardRB): @@ -50,7 +53,7 @@ class InterleavedRB(StandardRB): def __init__( self, - interleaved_element: Union[QuantumCircuit, Instruction, Clifford], + interleaved_element: Union[QuantumCircuit, Gate, Delay, Clifford], qubits: Sequence[int], lengths: Iterable[int], backend: Optional[Backend] = None, @@ -62,12 +65,18 @@ def __init__( Args: interleaved_element: The element to interleave, - given either as a group element or as an instruction/circuit + given either as a Clifford element, gate, delay or circuit. + If the element contains any non-basis gates, + it will be transpiled with ``transpiled_options`` of this experiment. + If it is/contains a delay, its duration and unit must comply with + the timing constraints of the ``backend`` + (:class:`~qiskit_experiments.framework.backend_timing.BackendTiming` + is useful to obtain valid delays). + Parameterized circuits/instructions are not allowed. qubits: list of physical qubits for the experiment. lengths: A list of RB sequences lengths. backend: The backend to run the experiment on. - num_samples: Number of samples to generate for each - sequence length + num_samples: Number of samples to generate for each sequence length. seed: Optional, seed used to initialize ``numpy.random.default_rng``. when generating circuits. The ``default_rng`` will be initialized with this seed value everytime :meth:`circuits` is called. @@ -77,18 +86,51 @@ def __init__( Clifford samples to shorter sequences. Raises: - QiskitError: the interleaved_element is not convertible to Clifford object. + QiskitError: If the ``interleaved_element`` is invalid because: + * it has different number of qubits from the qubits argument + * it is not convertible to Clifford object + * it has an invalid delay (e.g. violating the timing constraints of the backend) """ + # Validations of interleaved_element + # - validate number of qubits of interleaved_element + if len(qubits) != interleaved_element.num_qubits: + raise QiskitError( + f"Mismatch in number of qubits between qubits ({len(qubits)})" + f" and interleaved element ({interleaved_element.num_qubits})." + ) + # - validate if interleaved_element is Clifford try: - self._interleaved_elem = Clifford(interleaved_element) + interleaved_clifford = Clifford(interleaved_element) except QiskitError as err: raise QiskitError( f"Interleaved element {interleaved_element.name} could not be converted to Clifford." ) from err - # Convert interleaved element to operation - self._interleaved_op = interleaved_element - if not isinstance(interleaved_element, Instruction): - self._interleaved_op = interleaved_element.to_instruction() + # - validate delays in interleaved_element + delay_ops = [] + if isinstance(interleaved_element, Delay): + delay_ops = [interleaved_element] + elif isinstance(interleaved_element, QuantumCircuit): + delay_ops = [delay.operation for delay in interleaved_element.get_instructions("delay")] + if delay_ops: + timing = BackendTiming(backend) + for delay_op in delay_ops: + if delay_op.unit != timing.delay_unit: + raise QiskitError( + f"Interleaved delay for backend {backend} must have time unit {timing.delay_unit}." + " Use BackendTiming to set valid duration and unit for delays." + ) + if timing.delay_unit == "dt": + valid_duration = timing.round_delay(samples=delay_op.duration) + if delay_op.duration != valid_duration: + raise QiskitError( + f"Interleaved delay duration {delay_op.duration}[dt] violates the timing" + f" constraints of the backend {backend}. It could be {valid_duration}[dt]." + " Use BackendTiming to set valid duration for delays." + ) + # Warnings + if isinstance(interleaved_element, QuantumCircuit) and interleaved_element.calibrations: + warnings.warn("Calibrations in interleaved circuit are ignored", UserWarning) + super().__init__( qubits, lengths, @@ -97,6 +139,16 @@ def __init__( seed=seed, full_sampling=full_sampling, ) + # Convert interleaved element to integer for speed in 1Q or 2Q case + if self.num_qubits == 1: + self._interleaved_cliff = num_from_1q_circuit(interleaved_clifford.to_circuit()) + elif self.num_qubits == 2: + self._interleaved_cliff = num_from_2q_circuit(interleaved_clifford.to_circuit()) + # Convert interleaved element to circuit for speed in 3Q or more case + else: + self._interleaved_cliff = interleaved_clifford.to_circuit() + self._interleaved_element = interleaved_element # Original interleaved element + self._interleaved_op = None # Transpiled interleaved element for speed self.analysis = InterleavedRBAnalysis() self.analysis.set_options(outcome="0" * self.num_qubits) @@ -105,13 +157,19 @@ def circuits(self) -> List[QuantumCircuit]: Returns: A list of :class:`QuantumCircuit`. + + Raises: + QiskitError: If the ``interleaved_element`` provided to the constructor + cannot be transpiled. """ + # Convert interleaved element to transpiled circuit operation and store it for speed + self.__set_up_interleaved_op() + # Build circuits of reference sequences reference_sequences = self._sample_sequences() reference_circuits = self._sequences_to_circuits(reference_sequences) for circ, seq in zip(reference_circuits, reference_sequences): circ.metadata = { - "experiment_type": self._type, "xval": len(seq), "group": "Clifford", "physical_qubits": self.physical_qubits, @@ -123,12 +181,11 @@ def circuits(self) -> List[QuantumCircuit]: new_seq = [] for elem in seq: new_seq.append(elem) - new_seq.append(self._interleaved_elem) + new_seq.append(self._interleaved_cliff) interleaved_sequences.append(new_seq) interleaved_circuits = self._sequences_to_circuits(interleaved_sequences) for circ, seq in zip(interleaved_circuits, reference_sequences): circ.metadata = { - "experiment_type": self._type, "xval": len(seq), # set length of the reference sequence "group": "Clifford", "physical_qubits": self.physical_qubits, @@ -136,8 +193,49 @@ def circuits(self) -> List[QuantumCircuit]: } return reference_circuits + interleaved_circuits - def _to_instruction(self, elem: SequenceElementType) -> Instruction: - if elem is self._interleaved_elem: + def _to_instruction( + self, elem: SequenceElementType, basis_gates: Optional[Tuple[str]] = None + ) -> Instruction: + if elem is self._interleaved_cliff: return self._interleaved_op - return super()._to_instruction(elem) + return super()._to_instruction(elem, basis_gates) + + def __set_up_interleaved_op(self) -> None: + # Convert interleaved element to transpiled circuit operation and store it for speed + self._interleaved_op = self._interleaved_element + basis_gates = self._get_basis_gates() + # Convert interleaved element to circuit + if isinstance(self._interleaved_op, Clifford): + self._interleaved_op = self._interleaved_op.to_circuit() + + if isinstance(self._interleaved_op, QuantumCircuit): + interleaved_circ = self._interleaved_op + elif isinstance(self._interleaved_op, Gate): + interleaved_circ = QuantumCircuit(self.num_qubits, name=self._interleaved_op.name) + interleaved_circ.append(self._interleaved_op, list(range(self.num_qubits))) + else: # Delay + interleaved_circ = [] + + if basis_gates and any(i.operation.name not in basis_gates for i in interleaved_circ): + # Transpile circuit with non-basis gates and remove idling qubits + try: + interleaved_circ = transpile( + interleaved_circ, self.backend, **vars(self.transpile_options) + ) + except TranspilerError as err: + raise QiskitError("Failed to transpile interleaved_element.") from err + interleaved_circ = _truncate_inactive_qubits( + interleaved_circ, active_qubits=interleaved_circ.qubits[: self.num_qubits] + ) + # Convert transpiled circuit to operation + if len(interleaved_circ) == 1: + self._interleaved_op = interleaved_circ.data[0].operation + else: + self._interleaved_op = interleaved_circ + + # Store interleaved operation as Instruction + if isinstance(self._interleaved_op, QuantumCircuit): + if not self._interleaved_op.name.startswith("Clifford"): + self._interleaved_op.name = f"Clifford-{self._interleaved_op.name}" + self._interleaved_op = self._interleaved_op.to_instruction() 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..0e2ca48c12 --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_analysis.py @@ -0,0 +1,666 @@ +# 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 analysis class. +""" +from collections import defaultdict +from typing import Dict, List, Sequence, Tuple, Union, Optional, TYPE_CHECKING + +import lmfit +from qiskit.exceptions import QiskitError + +import numpy as np +from scipy.spatial.distance import hamming + +import qiskit_experiments.curve_analysis as curve +from qiskit_experiments.exceptions import AnalysisError +from qiskit_experiments.framework import AnalysisResultData, ExperimentData +from qiskit_experiments.framework.analysis_result import AnalysisResult +from qiskit_experiments.data_processing.exceptions import DataProcessorError +from uncertainties import unumpy as unp # pylint: disable=wrong-import-order + +if TYPE_CHECKING: + from uncertainties import UFloat + +# A dictionary key of qubit aware quantum instruction; type alias for better readability +QubitGateTuple = Tuple[Tuple[int, ...], str] + + +class MirrorRBAnalysis(curve.CurveAnalysis): + 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: + + Fit model for mirror RB + + .. 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 + + """ + + def __init__(self): + super().__init__( + models=[ + lmfit.models.ExpressionModel( + expr="a * alpha ** x + b", name="mirror", data_sort_key={"mirror": True} + ) + ] + ) + self._gate_counts_per_clifford = None + self._physical_qubits = None + self._num_qubits = None + + @classmethod + def _default_options(cls): + """Default analysis options.""" + 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.y_axis = "Effective Polarization" + + return default_options + + def set_options(self, **fields): + if "y_axis" in fields: + if fields["y_axis"] not in [ + "Success Probability", + "Adjusted Success Probability", + "Effective Polarization", + ]: + raise QiskitError( + 'y_axis must be one of "Success Probability", "Adjusted Success Probability", ' + 'or "Effective Polarization"' + ) + super().set_options(**fields) + + 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)) + + # Initialize guess for baseline and amplitude based on infidelity type + b_guess = 1 / 4**self._num_qubits + if self.options.y_axis == "Success Probability": + b_guess = 1 / 2**self._num_qubits + + mirror_curve = curve_data.get_subset_of("mirror") + 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 _format_data( + self, + curve_data: curve.CurveData, + ) -> curve.CurveData: + """Postprocessing for the processed dataset. + + Args: + curve_data: Processed dataset created from experiment results. + + Returns: + Formatted data. + """ + # TODO Eventually move this to data processor, then create RB data processor. + + # take average over the same x value by keeping sigma + data_allocation, xdata, ydata, sigma, shots = curve.data_processing.multi_mean_xy_data( + series=curve_data.data_allocation, + xdata=curve_data.x, + ydata=curve_data.y, + sigma=curve_data.y_err, + shots=curve_data.shots, + method="sample", + ) + + # sort by x value in ascending order + data_allocation, xdata, ydata, sigma, shots = curve.data_processing.data_sort( + series=data_allocation, + xdata=xdata, + ydata=ydata, + sigma=sigma, + shots=shots, + ) + + return curve.CurveData( + x=xdata, + y=ydata, + y_err=sigma, + shots=shots, + data_allocation=data_allocation, + labels=curve_data.labels, + ) + + def _create_analysis_results( + self, + fit_data: curve.FitData, + 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) + + # nrb is calculated for both EPC and EI per the equations in the docstring + ei_nrb = 4**self._num_qubits + ei_scale = (ei_nrb - 1) / ei_nrb + epc_nrb = 2**self._num_qubits + epc_scale = (epc_nrb - 1) / epc_nrb + + alpha = fit_data.ufloat_params["alpha"] + + # Calculate EPC and EI per the equations in the docstring + epc = epc_scale * (1 - alpha) + ei = ei_scale * (1 - alpha) + + outcomes.append( + AnalysisResultData( + name="EPC", value=epc, chisq=fit_data.reduced_chisq, quality=quality, extra=metadata + ) + ) + outcomes.append( + AnalysisResultData( + name="EI", value=ei, chisq=fit_data.reduced_chisq, quality=quality, extra=metadata + ) + ) + + # Correction for 1Q depolarizing channel if EPGs are provided + if self.options.epg_1_qubit and num_qubits == 2: + epc = _exclude_1q_error( + epc=epc, + qubits=self._physical_qubits, + gate_counts_per_clifford=self._gate_counts_per_clifford, + extra_analyses=self.options.epg_1_qubit, + ) + outcomes.append( + AnalysisResultData( + name="EPC_corrected", + value=epc, + chisq=fit_data.reduced_chisq, + quality=quality, + extra=metadata, + ) + ) + + # Calculate EPG + if self._gate_counts_per_clifford is not None and self.options.gate_error_ratio: + epg_dict = _calculate_epg( + epc=epc, + qubits=self._physical_qubits, + gate_error_ratio=self.options.gate_error_ratio, + gate_counts_per_clifford=self._gate_counts_per_clifford, + ) + if epg_dict: + for gate, epg_val in epg_dict.items(): + outcomes.append( + AnalysisResultData( + name=f"EPG_{gate}", + value=epg_val, + chisq=fit_data.reduced_chisq, + quality=quality, + extra=metadata, + ) + ) + + return outcomes + + def _run_data_processing( + self, raw_data: List[Dict], models: List[lmfit.Model] + ) -> curve.CurveData: + """Manual data processing + + Args: + raw_data: Payload in the experiment data. + models: A list of LMFIT models that provide the model name and + optionally data sorting keys. + + Returns: + Processed data that will be sent to the formatter method. + + Raises: + DataProcessorError: When model is multi-objective function but + data sorting option is not provided. + DataProcessorError: When key for x values is not found in the metadata. + """ + x_key = self.options.x_key + + try: + xdata = np.asarray([datum["metadata"][x_key] for datum in raw_data], dtype=float) + except KeyError as ex: + raise DataProcessorError( + f"X value key {x_key} is not defined in circuit metadata." + ) from ex + + ydata = self._compute_polarizations_and_probabilities(raw_data) + shots = np.asarray([datum.get("shots", np.nan) for datum in raw_data]) + + def _matched(metadata, **filters): + try: + return all(metadata[key] == val for key, val in filters.items()) + except KeyError: + return False + + if len(models) == 1: + # all data belongs to the single model + data_allocation = np.full(xdata.size, 0, dtype=int) + else: + data_allocation = np.full(xdata.size, -1, dtype=int) + for idx, sub_model in enumerate(models): + try: + tags = sub_model.opts["data_sort_key"] + except KeyError as ex: + raise DataProcessorError( + f"Data sort options for model {sub_model.name} is not defined." + ) from ex + if tags is None: + continue + matched_inds = np.asarray( + [_matched(d["metadata"], **tags) for d in raw_data], dtype=bool + ) + data_allocation[matched_inds] = idx + + return curve.CurveData( + x=xdata, + y=unp.nominal_values(ydata), + y_err=unp.std_devs(ydata), + shots=shots, + data_allocation=data_allocation, + labels=[sub_model._name for sub_model in models], + ) + + def _compute_polarizations_and_probabilities(self, raw_data: List[Dict]) -> unp.uarray: + """Compute success probabilities, adjusted success probabilities, and + polarizations from raw results + + Args: + raw_data: List of raw results for each circuit + + Returns: + Unp array of either success probabiltiies, adjusted success probabilities, + or polarizations as specified by the user. + """ + + # Arrays to store the y-axis data and uncertainties + y_data = [] + y_data_unc = [] + target_bs = "0" * self._num_qubits + for circ_result in raw_data: + + # If there is no inverting Pauli layer at the end of the circuit, get the target bitstring + if not circ_result["metadata"]["inverting_pauli_layer"]: + target_bs = circ_result["metadata"]["target"] + + # 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["counts"].items(): + # Compute success probability + success_prob = 0.0 + if bitstring == target_bs: + success_prob = count / circ_result.get( + "shots", sum(circ_result["counts"].values()) + ) + success_prob_unc = np.sqrt(success_prob * (1 - success_prob)) + if self.options.y_axis == "Success Probability": + y_data.append(success_prob) + y_data_unc.append(success_prob_unc) + circ_result["metadata"]["success_probability"] = success_prob + circ_result["metadata"]["success_probability_stddev"] = 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 / circ_result.get( + "shots", sum(circ_result["counts"].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) + circ_result["metadata"]["adjusted_success_probability"] = adjusted_success_prob + circ_result["metadata"][ + "adjusted_success_probability_stddev" + ] = adjusted_success_prob_unc + if self.options.y_axis == "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 + circ_result["metadata"]["polarization"] = pol + circ_result["metadata"]["polarization_uncertainty"] = pol_unc + if self.options.y_axis == "Effective Polarization": + y_data.append(pol) + y_data_unc.append(pol_unc) + + return unp.uarray(y_data, y_data_unc) + + def _initialize( + self, + experiment_data: ExperimentData, + ): + """Initialize curve analysis with experiment data. + + This method is called ahead of other processing. + + Args: + experiment_data: Experiment data to analyze. + + Raises: + AnalysisError: When circuit metadata for ops count is missing. + """ + super()._initialize(experiment_data) + + if self.options.gate_error_ratio is not None: + # If gate error ratio is not False, EPG analysis is enabled. + # Here analysis prepares gate error ratio and gate counts for EPC to EPG conversion. + + # If gate count dictionary is not set it will compute counts from circuit metadata. + avg_gpc = defaultdict(float) + n_circs = len(experiment_data.data()) + for circ_result in experiment_data.data(): + try: + count_ops = circ_result["metadata"]["count_ops"] + except KeyError as ex: + raise AnalysisError( + "'count_ops' key is not found in the circuit metadata. " + "This analysis cannot compute error per gates. " + "Please disable this with 'gate_error_ratio=False'." + ) from ex + nclif = circ_result["metadata"]["xval"] + for (qinds, gate), count in count_ops: + formatted_key = tuple(sorted(qinds)), gate + avg_gpc[formatted_key] += count / nclif / n_circs + self._gate_counts_per_clifford = dict(avg_gpc) + + if self.options.gate_error_ratio == "default": + # Gate error dict is computed for gates appearing in counts dictionary + # Error ratio among gates is determined based on the predefined lookup table. + # This is not always accurate for every quantum backends. + gate_error_ratio = {} + for qinds, gate in self._gate_counts_per_clifford.keys(): + if set(qinds) != set(experiment_data.metadata["physical_qubits"]): + continue + gate_error_ratio[gate] = _lookup_epg_ratio(gate, len(qinds)) + self.set_options(gate_error_ratio=gate_error_ratio) + + # Get qubit number + self._physical_qubits = experiment_data.metadata["physical_qubits"] + self._num_qubits = len(experiment_data.metadata["physical_qubits"]) + + +def _lookup_epg_ratio(gate: str, n_qubits: int) -> Union[None, int]: + """A helper method to look-up preset gate error ratio for given basis gate name. + + In the table the error ratio is defined based on the count of + typical assembly gate in the gate decomposition. + For example, "u3" gate can be decomposed into two "sx" gates. + In this case, the ratio of "u3" gate error becomes 2. + + .. note:: + + This table is not aware of the actual waveform played on the hardware, + and the returned error ratio is just a guess. + To be precise, user can always set "gate_error_ratio" option of the experiment. + + Args: + gate: Name of the gate. + n_qubits: Number of qubits measured in the RB experiments. + + Returns: + Corresponding error ratio. + + Raises: + QiskitError: When number of qubit is more than three. + """ + + # Gate count in (X, SX)-based decomposition. VZ gate contribution is ignored. + # Amplitude or duration modulated pulse implementation is not considered. + standard_1q_ratio = { + "u1": 0.0, + "u2": 1.0, + "u3": 2.0, + "u": 2.0, + "p": 0.0, + "x": 1.0, + "y": 1.0, + "z": 0.0, + "t": 0.0, + "tdg": 0.0, + "s": 0.0, + "sdg": 0.0, + "sx": 1.0, + "sxdg": 1.0, + "rx": 2.0, + "ry": 2.0, + "rz": 0.0, + "id": 0.0, + "h": 1.0, + } + + # Gate count in (CX, CSX)-based decomposition, 1q gate contribution is ignored. + # Amplitude or duration modulated pulse implementation is not considered. + standard_2q_ratio = { + "swap": 3.0, + "rxx": 2.0, + "rzz": 2.0, + "cx": 1.0, + "cy": 1.0, + "cz": 1.0, + "ch": 1.0, + "crx": 2.0, + "cry": 2.0, + "crz": 2.0, + "csx": 1.0, + "cu1": 2.0, + "cp": 2.0, + "cu": 2.0, + "cu3": 2.0, + } + + if n_qubits == 1: + return standard_1q_ratio.get(gate, None) + + if n_qubits == 2: + return standard_2q_ratio.get(gate, None) + + raise QiskitError( + f"Standard gate error ratio for {n_qubits} qubit RB is not provided. " + "Please explicitly set 'gate_error_ratio' option of the experiment." + ) + + +def _calculate_epg( + epc: Union[float, "UFloat"], + qubits: Sequence[int], + gate_error_ratio: Dict[str, float], + gate_counts_per_clifford: Dict[QubitGateTuple, float], +) -> Dict[str, Union[float, "UFloat"]]: + """A helper mehtod to compute EPGs of basis gates from fit EPC value. + + Args: + epc: Error per Clifford. + qubits: List of qubits used in the experiment. + gate_error_ratio: A dictionary of assumed ratio of errors among basis gates. + gate_counts_per_clifford: Basis gate counts per Clifford gate. + + Returns: + A dictionary of gate errors keyed on the gate name. + """ + norm = 0 + for gate, r_epg in gate_error_ratio.items(): + formatted_key = tuple(sorted(qubits)), gate + norm += r_epg * gate_counts_per_clifford.get(formatted_key, 0.0) + + epgs = {} + for gate, r_epg in gate_error_ratio.items(): + epgs[gate] = r_epg * epc / norm + return epgs + + +def _exclude_1q_error( + epc: Union[float, "UFloat"], + qubits: Tuple[int, int], + gate_counts_per_clifford: Dict[QubitGateTuple, float], + extra_analyses: Optional[List[AnalysisResult]], +) -> Union[float, "UFloat"]: + """A helper method to exclude contribution of single qubit gates from 2Q EPC. + + Args: + epc: EPC from 2Q RB experiment. + qubits: Index of two qubits used for 2Q RB experiment. + gate_counts_per_clifford: Basis gate counts per 2Q Clifford gate. + extra_analyses: Analysis results containing depolarizing parameters of 1Q RB experiments. + + Returns: + Corrected 2Q EPC. + """ + # Extract EPC of non-measured qubits from previous experiments + epg_1qs = {} + for analyis_data in extra_analyses: + if ( + not analyis_data.name.startswith("EPG_") + or len(analyis_data.device_components) > 1 + or not str(analyis_data.device_components[0]).startswith("Q") + ): + continue + qind = analyis_data.device_components[0]._index + gate = analyis_data.name[4:] + formatted_key = (qind,), gate + epg_1qs[formatted_key] = analyis_data.value + + if not epg_1qs: + return epc + + # Convert 2Q EPC into depolarizing parameter alpha + alpha_c_2q = 1 - 4 / 3 * epc + + # Estimate composite alpha of 1Q channels + alpha_i = [1.0, 1.0] + for q_gate_tup, epg in epg_1qs.items(): + n_gate = gate_counts_per_clifford.get(q_gate_tup, 0.0) + aind = qubits.index(q_gate_tup[0][0]) + alpha_i[aind] *= (1 - 2 * epg) ** n_gate + alpha_c_1q = 1 / 5 * (alpha_i[0] + alpha_i[1] + 3 * alpha_i[0] * alpha_i[1]) + + # Corrected 2Q channel EPC + return 3 / 4 * (1 - (alpha_c_2q / alpha_c_1q)) 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..e79cae5218 --- /dev/null +++ b/qiskit_experiments/library/randomized_benchmarking/mirror_rb_experiment.py @@ -0,0 +1,361 @@ +# 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. +""" +from abc import ABC, abstractmethod +from typing import Union, Iterable, Optional, List, Sequence +from itertools import permutations +from numpy.random import Generator, BitGenerator, SeedSequence, default_rng + +from qiskit import QuantumCircuit, QiskitError +from qiskit.circuit import Instruction +from qiskit.quantum_info import Clifford, random_pauli, random_clifford +from qiskit.quantum_info.operators import Pauli +from qiskit.providers.backend import Backend +from qiskit.transpiler.basepasses import TransformationPass + +from .rb_experiment import StandardRB +from .mirror_rb_analysis import MirrorRBAnalysis +from .clifford_utils import CliffordUtils + + +class MirrorRBDistribution(ABC): + """Sampling distribution for the mirror randomized benchmarking experiment.""" + + def __init__(self, seed=None): + self.rng = default_rng(seed) + + @abstractmethod + def __call__(self, qubits, two_qubit_density, coupling_map, **params): + self.qubits = qubits + + +class RandomEdgeGrabDistribution(MirrorRBDistribution): + def __init__(self): + super().__init__(seed) + + def __call__(self, qubits, two_qubit_density, coupling_map, seed=None): + self.two_qubit_density = two_qubit_density + self.coupling_map = coupling_map + + ... + + +class DiscreteLayerDistribution(MirrorRBDistribution): + def __init__(self): + super().__init__(qubits, seed) + + def __call__(self, qubits, layers, probs=None, seed=None): + self.layers = list(layers) + self.probs = probs or [1 / len(layers)] * len(layers) + return rng.choice(self.layers, self.prob) + + +class MirrorRB(StandardRB): + """Mirror randomized benchmarking experiment. + + # section: overview + Mirror Randomized Benchmarking (RB) is a method to estimate the average + error-rate of quantum gates that is more scalable than other RB methods + and can thus detect crosstalk errors. + + A mirror RB experiment generates circuits of layers of Cliffords 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. 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. Find + more on adjusted success probability, effective polarization, and + entanglement infidelity in Refs. [1, 2, 3]. + + # section: analysis_ref + :py:class:`MirrorRBAnalysis` + + # section: reference + .. ref_arxiv:: 1 2112.09853 + .. ref_arxiv:: 2 2008.11294 + .. ref_arxiv:: 3 2204.07568 + + """ + + def __init__( + self, + qubits: Sequence[int], + lengths: Iterable[int], + local_clifford: bool = True, + pauli_randomize: bool = True, + two_qubit_gate_density: float = 0.2, + backend: Optional[Backend] = None, + num_samples: int = 3, + seed: Optional[Union[int, SeedSequence, BitGenerator, Generator]] = None, + full_sampling: bool = False, + inverting_pauli_layer: bool = False, + ): + """Initialize a mirror randomized benchmarking experiment. + + Args: + qubits: A list of physical qubits for the experiment. + lengths: A list of RB sequences lengths. + local_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 inner Clifford layer with + uniformly random Paulis. + two_qubit_gate_density: Expected proportion of qubits with CNOTs based on + the backend coupling map. + backend: The backend to run the experiment on. + num_samples: Number of samples to generate for each + sequence length. + seed: Optional, seed used to initialize ``numpy.random.default_rng``. + when generating circuits. The ``default_rng`` will be initialized + with this seed value everytime :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 (with + possibly a global phase) + + Raises: + QiskitError: if an odd length or a negative two qubit gate density is provided + """ + # All lengths must be even + if not all(length % 2 == 0 for length in lengths): + raise QiskitError("All lengths must be even") + + # Two-qubit density must be non-negative + if two_qubit_gate_density < 0: + raise QiskitError("Two-qubit gate density must be non-negative") + + super().__init__( + qubits, + lengths, + backend=backend, + num_samples=num_samples, + seed=seed, + full_sampling=full_sampling, + ) + + self._local_clifford = local_clifford + self._pauli_randomize = pauli_randomize + self._two_qubit_gate_density = two_qubit_gate_density + + # Will need to update these 2 lines below to fit with current rb experiment code + self._full_sampling = full_sampling + self._clifford_utils = CliffordUtils() + + # By default, the inverting Pauli layer at the end of the circuit is not added + self._inverting_pauli_layer = inverting_pauli_layer + + # Set analysis options + self.analysis = MirrorRBAnalysis() + + def _sample_circuits(self, lengths, rng) -> List[QuantumCircuit]: + """Sample Mirror RB circuits. + + Steps: + 1. Sample length/2 layers of random Cliffords + 2. Compute inverse of each layer in the first half of the circuit and append to circuit + 3. Sample the random paulis and interleave them between the Clifford layers + 4. Sample the 1-qubit local Clifford and add them to the beginning and end of the circuit + + Args: + lengths: List of lengths to run Mirror RB + rng: Generator seed + + Returns: + List of QuantumCircuits + + Raises: + QiskitError: if backend without a coupling map is provided + """ + + # Backend must have a coupling map + if not self._backend: + raise QiskitError("Must provide a backend") + + circuits = [] + lengths_half = [length // 2 for length in lengths] + + # Coupling map is full connectivity by default. If backend has a coupling map, + # get backend coupling map and create coupling map for physical qubits + coupling_map = list(permutations(range(max(self.physical_qubits) + 1), 2)) + if self._backend.configuration().coupling_map: + coupling_map = self._backend.configuration().coupling_map + experiment_coupling_map = [] + for edge in coupling_map: + if edge[0] in self.physical_qubits and edge[1] in self.physical_qubits: + experiment_coupling_map.append(edge) + + for length in lengths_half if self._full_sampling else [lengths_half[-1]]: + # Sample Clifford layer elements for first half of mirror circuit + elements = self._clifford_utils.random_edgegrab_clifford_circuits( + self.physical_qubits, + experiment_coupling_map, + self._two_qubit_gate_density, + length, + rng, + ) + + # Append inverses of Clifford elements to second half of circuit + for element in elements[::-1]: + elements.append(element.inverse()) + element_lengths = [len(elements)] if self._full_sampling else lengths + + # Interleave random Paulis if set by user + if self._pauli_randomize: + elements = self._pauli_dress(elements, rng) + element_lengths = [length * 2 + 1 for length in element_lengths] + + # Add start and end local cliffords if set by user + if self._local_clifford: + element_lengths = [length + 2 for length in element_lengths] + elements = self._start_end_cliffords(elements, rng) + mirror_circuits = self._generate_mirror(elements, element_lengths) + for circuit in mirror_circuits: + # Use "boolean arithmetic" to calculate xval correctly for each circuit + pauli_scale = self._pauli_randomize + 1 + clifford_const = self._local_clifford * 2 + circuit.metadata["xval"] = ( + circuit.metadata["xval"] - self._pauli_randomize - clifford_const + ) // pauli_scale + circuit.metadata["mirror"] = True + circuits += mirror_circuits + + # Append inverting Pauli layer at end of circuit if set by user + if self._inverting_pauli_layer: + for circuit in circuits: + # Get target bitstring (ideal bitstring outputted by the circuit) + target = circuit.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]) + circuit.remove_final_measurements() + circuit.append(Pauli(label), list(range(self._num_qubits))) + circuit.measure_all() + + return circuits + + def _pauli_dress(self, element_list: List, rng: Optional[Union[int, Generator]]) -> List: + """Interleaving layers of random Paulis inside the element list. + + Args: + element_list: The list of elements we add the interleaved Paulis to. + rng: (Seed for) random number generator + + Returns: + The new list of elements with the Paulis interleaved. + """ + # Generate random Pauli + rand_pauli = random_pauli(self._num_qubits, seed=rng).to_instruction() + rand_pauli_op = Clifford(rand_pauli) + new_element_list = [(rand_pauli, rand_pauli_op)] + for element in element_list: + new_element_list.append(element) + rand_pauli = random_pauli(self._num_qubits, seed=rng).to_instruction() + rand_pauli_op = Clifford(rand_pauli) + new_element_list.append((rand_pauli, rand_pauli_op)) + return new_element_list + + def _start_end_cliffords( + self, elements: Iterable[Clifford], rng: Optional[Union[int, Generator]] + ) -> List[QuantumCircuit]: + """Add a layer of uniformly random 1-qubit Cliffords to the beginning of the list + and its inverse to the end of the list + + Args: + element_list: The list of elements we add the Clifford layers to + rng: (Seed for) random number generator + + Returns: + The new list of elements with the start and end local (1-qubit) Cliffords. + """ + rand_clifford = [ + self._clifford_utils.random_cliffords(num_qubits=1, rng=rng)[0] + for _ in self.physical_qubits + ] + tensor_op = rand_clifford[0] + for cliff in rand_clifford[1:]: + tensor_op = tensor_op ^ cliff + tensor_circ = tensor_op.to_circuit() + + rand_clifford = random_clifford(self.num_qubits, seed=rng).to_circuit() + return [tensor_circ] + elements + [tensor_circ.inverse()] + + def _generate_mirror( + self, elements: Iterable[Clifford], lengths: Iterable[int] + ) -> List[QuantumCircuit]: + """Return the RB circuits constructed from the given element list with the second + half as the inverse of the first half + + Args: + elements: A list of Clifford elements + lengths: A list of RB sequences lengths. + + Returns: + A list of :class:`QuantumCircuit`s. + + Additional information: + The circuits are constructed iteratively; each circuit is obtained + by extending the previous circuit (without the inversion and measurement gates) + """ + qubits = list(range(self.num_qubits)) + circuits = [] + + circs = [QuantumCircuit(self.num_qubits) for _ in range(len(lengths))] + + for current_length, group_elt_circ in enumerate(elements[: (len(elements) // 2)]): + if isinstance(group_elt_circ, tuple): + group_elt_gate = group_elt_circ[0] + else: + group_elt_gate = group_elt_circ + + if not isinstance(group_elt_gate, Instruction): + group_elt_gate = group_elt_gate.to_instruction() + for circ in circs: + circ.barrier(qubits) + circ.append(group_elt_gate, qubits) + + double_current_length = ( + (current_length + 1) * 2 + 1 if len(elements) % 2 == 1 else (current_length + 1) * 2 + ) + if double_current_length in lengths: + rb_circ = circs.pop() + inv_start = ( + (-(current_length + 1) - 1) if len(elements) % 2 == 1 else -(current_length + 1) + ) + for inv in elements[inv_start:]: + if isinstance(inv, tuple): + group_elt_gate = inv[0] + else: + group_elt_gate = inv + + if not isinstance(group_elt_gate, Instruction): + group_elt_gate = group_elt_gate.to_instruction() + rb_circ.barrier(qubits) + rb_circ.append(group_elt_gate, qubits) + rb_circ.metadata = { + "experiment_type": self._type, + "xval": double_current_length, + "group": "Clifford", + "physical_qubits": self.physical_qubits, + "target": self._clifford_utils.compute_target_bitstring(rb_circ), + "inverting_pauli_layer": self._inverting_pauli_layer, + } + rb_circ.measure_all() + circuits.append(rb_circ) + return circuits diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py b/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py index 4dba2e5482..25584dd69d 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py @@ -13,32 +13,40 @@ Standard RB Experiment class. """ import logging +import functools from collections import defaultdict from numbers import Integral -from typing import Union, Iterable, Optional, List, Sequence +from typing import Union, Iterable, Optional, List, Sequence, Tuple import numpy as np -from numpy.random import Generator, default_rng -from numpy.random.bit_generator import BitGenerator, SeedSequence +from numpy.random import Generator, default_rng, BitGenerator, SeedSequence -from qiskit.circuit import QuantumCircuit, Instruction +from qiskit.circuit import QuantumCircuit, Instruction, Barrier from qiskit.exceptions import QiskitError -from qiskit.providers.backend import Backend +from qiskit.providers import BackendV2Converter +from qiskit.providers.backend import Backend, BackendV1, BackendV2 +from qiskit.pulse.instruction_schedule_map import CalibrationPublisher from qiskit.quantum_info import Clifford from qiskit.quantum_info.random import random_clifford +from qiskit.transpiler import CouplingMap from qiskit_experiments.framework import BaseExperiment, Options from qiskit_experiments.framework.restless_mixin import RestlessMixin from .clifford_utils import ( CliffordUtils, + compose_1q, + compose_2q, + inverse_1q, + inverse_2q, _clifford_1q_int_to_instruction, _clifford_2q_int_to_instruction, + _transpile_clifford_circuit, ) from .rb_analysis import RBAnalysis LOG = logging.getLogger(__name__) -SequenceElementType = Union[Clifford, Integral] +SequenceElementType = Union[Clifford, Integral, QuantumCircuit] class StandardRB(BaseExperiment, RestlessMixin): @@ -136,6 +144,15 @@ def _default_experiment_options(cls) -> Options: return options + def _set_backend(self, backend: Backend): + """Set the backend V2 for RB experiments since RB experiments only support BackendV2 + except for simulators. If BackendV1 is provided, it is converted to V2 and stored. + """ + if isinstance(backend, BackendV1) and "simulator" not in backend.name(): + super()._set_backend(BackendV2Converter(backend, add_delay=True)) + else: + super()._set_backend(backend) + def circuits(self) -> List[QuantumCircuit]: """Return a list of RB circuits. @@ -149,7 +166,6 @@ def circuits(self) -> List[QuantumCircuit]: # Add metadata for each circuit for circ, seq in zip(circuits, sequences): circ.metadata = { - "experiment_type": self._type, "xval": len(seq), "group": "Clifford", "physical_qubits": self.physical_qubits, @@ -176,14 +192,72 @@ def _sample_sequences(self) -> List[Sequence[SequenceElementType]]: return sequences + def _get_basis_gates(self) -> Optional[Tuple[str, ...]]: + """Get sorted basis gates to use in basis transformation during circuit generation. + + - Return None if this experiment is an RB with 3 or more qubits. + - Return None if no basis gates are supplied via ``backend`` or ``transpile_options``. + - Return None if all 2q-gates supported on the physical qubits of the backend are one-way + directed (e.g. cx(0, 1) is supported but cx(1, 0) is not supported). + + In all those case when None are returned, basis transformation will be skipped in the + circuit generation step (i.e. :meth:`circuits`) and it will be done in the successive + transpilation step (i.e. :meth:`_transpiled_circuits`) that calls :func:`transpile`. + + Returns: + Sorted basis gate names. + """ + # 3 or more qubits case: Return None (skip basis transformation in circuit generation) + if self.num_qubits > 2: + return None + + # 1 qubit case: Return all basis gates (or None if no basis gates are supplied) + if self.num_qubits == 1: + basis_gates = self.transpile_options.get("basis_gates", None) + if not basis_gates and self.backend: + if isinstance(self.backend, BackendV2): + basis_gates = self.backend.operation_names + elif isinstance(self.backend, BackendV1): + basis_gates = self.backend.configuration().basis_gates + return tuple(sorted(basis_gates)) if basis_gates else None + + def is_bidirectional(coupling_map): + return len(coupling_map.reduce(self.physical_qubits).get_edges()) == 2 + + # 2 qubits case: Return all basis gates except for one-way directed 2q-gates. + # Return None if there is no bidirectional 2q-gates in basis gates. + if self.num_qubits == 2: + basis_gates = self.transpile_options.get("basis_gates", []) + if not basis_gates and self.backend: + if isinstance(self.backend, BackendV2) and self.backend.target: + has_bidirectional_2q_gates = False + for op_name in self.backend.target: + if self.backend.target.operation_from_name(op_name).num_qubits == 2: + if is_bidirectional(self.backend.target.build_coupling_map(op_name)): + has_bidirectional_2q_gates = True + else: + continue + basis_gates.append(op_name) + if not has_bidirectional_2q_gates: + basis_gates = None + elif isinstance(self.backend, BackendV1): + cmap = self.backend.configuration().coupling_map + if cmap is None or is_bidirectional(CouplingMap(cmap)): + basis_gates = self.backend.configuration().basis_gates + return tuple(sorted(basis_gates)) if basis_gates else None + + return None + def _sequences_to_circuits( self, sequences: List[Sequence[SequenceElementType]] ) -> List[QuantumCircuit]: - """Convert a RB sequence into circuit and append the inverse to the end. + """Convert an RB sequence into circuit and append the inverse to the end. Returns: A list of RB circuits. """ + basis_gates = self._get_basis_gates() + # Circuit generation circuits = [] for i, seq in enumerate(sequences): if ( @@ -192,42 +266,41 @@ def _sequences_to_circuits( ): prev_elem, prev_seq = self.__identity_clifford(), [] - qubits = list(range(self.num_qubits)) circ = QuantumCircuit(self.num_qubits) - circ.barrier(qubits) for elem in seq: - circ.append(self._to_instruction(elem), qubits) - circ.barrier(qubits) + circ.append(self._to_instruction(elem, basis_gates), circ.qubits) + circ.append(Barrier(self.num_qubits), circ.qubits) # Compute inverse, compute only the difference from the previous shorter sequence - for elem in seq[len(prev_seq) :]: - prev_elem = self.__compose_clifford(prev_elem, elem) + prev_elem = self.__compose_clifford_seq(prev_elem, seq[len(prev_seq) :]) prev_seq = seq inv = self.__adjoint_clifford(prev_elem) - circ.append(self._to_instruction(inv), qubits) + circ.append(self._to_instruction(inv, basis_gates), circ.qubits) circ.measure_all() # includes insertion of the barrier before measurement circuits.append(circ) return circuits def __sample_sequence(self, length: int, rng: Generator) -> Sequence[SequenceElementType]: - # Sample a RB sequence with the given length. - # Return integer instead of Clifford object for 1 or 2 qubit case for speed + # Sample an RB sequence with the given length. + # Return integer instead of Clifford object for 1 or 2 qubits case for speed if self.num_qubits == 1: - return rng.integers(24, size=length) + return rng.integers(CliffordUtils.NUM_CLIFFORD_1_QUBIT, size=length) if self.num_qubits == 2: - return rng.integers(11520, size=length) + return rng.integers(CliffordUtils.NUM_CLIFFORD_2_QUBIT, size=length) + # Return circuit object instead of Clifford object for 3 or more qubits case for speed + return [random_clifford(self.num_qubits, rng).to_circuit() for _ in range(length)] - return [random_clifford(self.num_qubits, rng) for _ in range(length)] - - def _to_instruction(self, elem: SequenceElementType) -> Instruction: - # TODO: basis transformation in 1Q (and 2Q) cases for speed + def _to_instruction( + self, elem: SequenceElementType, basis_gates: Optional[Tuple[str, ...]] = None + ) -> Instruction: # Switching for speed up if isinstance(elem, Integral): if self.num_qubits == 1: - return _clifford_1q_int_to_instruction(elem) + return _clifford_1q_int_to_instruction(elem, basis_gates) if self.num_qubits == 2: - return _clifford_2q_int_to_instruction(elem) + return _clifford_2q_int_to_instruction(elem, basis_gates) + return elem.to_instruction() def __identity_clifford(self) -> SequenceElementType: @@ -235,37 +308,72 @@ def __identity_clifford(self) -> SequenceElementType: return 0 return Clifford(np.eye(2 * self.num_qubits)) - def __compose_clifford( - self, lop: SequenceElementType, rop: SequenceElementType + def __compose_clifford_seq( + self, base_elem: SequenceElementType, elements: Sequence[SequenceElementType] ) -> SequenceElementType: - # TODO: Speed up 1Q (and 2Q) cases using integer clifford composition - # Integer clifford composition has not yet supported - if self.num_qubits == 1: - if isinstance(lop, Integral): - lop = CliffordUtils.clifford_1_qubit(lop) - if isinstance(rop, Integral): - rop = CliffordUtils.clifford_1_qubit(rop) - if self.num_qubits == 2: - if isinstance(lop, Integral): - lop = CliffordUtils.clifford_2_qubit(lop) - if isinstance(rop, Integral): - rop = CliffordUtils.clifford_2_qubit(rop) - return lop.compose(rop) + if self.num_qubits <= 2: + return functools.reduce( + compose_1q if self.num_qubits == 1 else compose_2q, elements, base_elem + ) + # 3 or more qubits: compose Clifford from circuits for speed + circ = QuantumCircuit(self.num_qubits) + for elem in elements: + circ.compose(elem, inplace=True) + return base_elem.compose(Clifford.from_circuit(circ)) def __adjoint_clifford(self, op: SequenceElementType) -> SequenceElementType: - # TODO: Speed up 1Q and 2Q cases using integer clifford inversion - # Integer clifford inversion has not yet supported - if isinstance(op, Integral): - if self.num_qubits == 1: - return CliffordUtils.clifford_1_qubit(op).adjoint() - if self.num_qubits == 2: - return CliffordUtils.clifford_2_qubit(op).adjoint() + if self.num_qubits == 1: + return inverse_1q(op) + if self.num_qubits == 2: + return inverse_2q(op) + if isinstance(op, QuantumCircuit): + return Clifford.from_circuit(op).adjoint() return op.adjoint() def _transpiled_circuits(self) -> List[QuantumCircuit]: """Return a list of experiment circuits, transpiled.""" - # TODO: Custom transpilation (without calling transpile()) for 1Q and 2Q cases - transpiled = super()._transpiled_circuits() + has_custom_transpile_option = ( + not set(vars(self.transpile_options)).issubset({"basis_gates", "optimization_level"}) + or self.transpile_options.get("optimization_level", 0) != 0 + ) + has_no_undirected_2q_basis = self._get_basis_gates() is None + if self.num_qubits > 2 or has_custom_transpile_option or has_no_undirected_2q_basis: + transpiled = super()._transpiled_circuits() + else: + transpiled = [ + _transpile_clifford_circuit(circ, physical_qubits=self.physical_qubits) + for circ in self.circuits() + ] + # Set custom calibrations provided in backend + if isinstance(self.backend, BackendV2): + qargs_patterns = [self.physical_qubits] # for self.num_qubits == 1 + if self.num_qubits == 2: + qargs_patterns = [ + (self.physical_qubits[0],), + (self.physical_qubits[1],), + self.physical_qubits, + (self.physical_qubits[1], self.physical_qubits[0]), + ] + + instructions = [] # (op_name, qargs) for each element where qargs means qubit tuple + for qargs in qargs_patterns: + for op_name in self.backend.target.operation_names_for_qargs(qargs): + instructions.append((op_name, qargs)) + + 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 if self.analysis.options.get("gate_error_ratio", None) is None: # Gate errors are not computed, then counting ops is not necessary. diff --git a/qiskit_experiments/library/tomography/qpt_experiment.py b/qiskit_experiments/library/tomography/qpt_experiment.py index 52a3cb158e..5a3881c90b 100644 --- a/qiskit_experiments/library/tomography/qpt_experiment.py +++ b/qiskit_experiments/library/tomography/qpt_experiment.py @@ -16,10 +16,11 @@ from typing import Union, Optional, Iterable, List, Tuple, Sequence import numpy as np from qiskit.circuit import QuantumCircuit, Instruction +from qiskit.providers.backend import Backend from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.quantum_info import Choi, Operator, Statevector, DensityMatrix, partial_trace from qiskit_experiments.exceptions import QiskitError -from .tomography_experiment import TomographyExperiment +from .tomography_experiment import TomographyExperiment, TomographyAnalysis, BaseAnalysis from .qpt_analysis import ProcessTomographyAnalysis from . import basis @@ -52,28 +53,37 @@ class ProcessTomography(TomographyExperiment): def __init__( self, circuit: Union[QuantumCircuit, Instruction, BaseOperator], + backend: Optional[Backend] = None, + physical_qubits: Optional[Sequence[int]] = None, measurement_basis: basis.MeasurementBasis = basis.PauliMeasurementBasis(), + measurement_indices: Optional[Sequence[int]] = None, measurement_qubits: Optional[Sequence[int]] = None, preparation_basis: basis.PreparationBasis = basis.PauliPreparationBasis(), + preparation_indices: Optional[Sequence[int]] = None, preparation_qubits: Optional[Sequence[int]] = None, basis_indices: Optional[Iterable[Tuple[List[int], List[int]]]] = None, qubits: Optional[Sequence[int]] = None, + analysis: Union[BaseAnalysis, None, str] = "default", + target: Union[Statevector, DensityMatrix, None, str] = "default", ): """Initialize a quantum process tomography experiment. Args: circuit: the quantum process circuit. If not a quantum circuit it must be a class that can be appended to a quantum circuit. + backend: The backend to run the experiment on. + physical_qubits: Optional, the physical qubits for the initial state circuit. + If None this will be qubits [0, N) for an N-qubit circuit. measurement_basis: Tomography basis for measurements. If not specified the default basis is the :class:`~basis.PauliMeasurementBasis`. - measurement_qubits: Optional, the qubits to be measured. These should refer - to the logical qubits in the state circuit. If None all qubits - in the state circuit will be measured. + measurement_indices: Optional, the `physical_qubits` indices to be measured. + If None all circuit physical qubits will be measured. + measurement_qubits: DEPRECATED, equivalent to measurement_indices. preparation_basis: Tomography basis for measurements. If not specified the - default basis is the :class:`~basis.PauliPreparationBasis`. - preparation_qubits: Optional, the qubits to be prepared. These should refer - to the logical qubits in the process circuit. If None all qubits - in the process circuit will be prepared. + default basis is the :class:`~basis.PauliPreparationBasis`. + preparation_indices: Optional, the `physical_qubits` indices to be prepared. + If None all circuit physical qubits will be prepared. + preparation_qubits: DEPRECATED, equivalent to preparation_indices. basis_indices: Optional, a list of basis indices for generating partial tomography measurement data. Each item should be given as a pair of lists of preparation and measurement basis configurations @@ -81,21 +91,38 @@ def __init__( preparation basis index, and ``m[i]`` is the measurement basis index for qubit-i. If not specified full tomography for all indices of the preparation and measurement bases will be performed. - qubits: Optional, the physical qubits for the initial state circuit. + qubits: DEPRECATED, the physical qubits for the initial state circuit. + analysis: Optional, a custom analysis instance to use. If ``"default"`` + :class:`~.ProcessTomographyAnalysis` will be used. If None no analysis + instance will be set. + target: Optional, a custom quantum state target for computing the + state fidelity of the fitted density matrix during analysis. + If "default" the state will be inferred from the input circuit + if it contains no classical instructions. """ + if analysis == "default": + analysis = ProcessTomographyAnalysis() + super().__init__( circuit, + backend=backend, + physical_qubits=physical_qubits, measurement_basis=measurement_basis, + measurement_indices=measurement_indices, measurement_qubits=measurement_qubits, preparation_basis=preparation_basis, + preparation_indices=preparation_indices, preparation_qubits=preparation_qubits, basis_indices=basis_indices, qubits=qubits, - analysis=ProcessTomographyAnalysis(), + analysis=analysis, ) # Set target quantum channel - self.analysis.set_options(target=self._target_quantum_channel()) + if isinstance(self.analysis, TomographyAnalysis): + if target == "default": + target = self._target_quantum_channel() + self.analysis.set_options(target=target) def _target_quantum_channel(self) -> Union[Choi, Operator]: """Return the process tomography target""" @@ -116,8 +143,8 @@ def _target_quantum_channel(self) -> Union[Choi, Operator]: return None total_qubits = self._circuit.num_qubits - num_meas = total_qubits if self._meas_qubits is None else len(self._meas_qubits) - num_prep = total_qubits if self._prep_qubits is None else len(self._prep_qubits) + num_meas = total_qubits if not self._meas_indices else len(self._meas_indices) + num_prep = total_qubits if not self._prep_indices else len(self._prep_indices) # If all qubits are prepared or measurement we are done if num_meas == total_qubits and num_prep == total_qubits: diff --git a/qiskit_experiments/library/tomography/qst_experiment.py b/qiskit_experiments/library/tomography/qst_experiment.py index 5280e5b44c..63a0f1d612 100644 --- a/qiskit_experiments/library/tomography/qst_experiment.py +++ b/qiskit_experiments/library/tomography/qst_experiment.py @@ -14,11 +14,12 @@ """ from typing import Union, Optional, Iterable, List, Sequence +from qiskit.providers.backend import Backend from qiskit.circuit import QuantumCircuit, Instruction from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.quantum_info import Statevector, DensityMatrix, partial_trace from qiskit_experiments.exceptions import QiskitError -from .tomography_experiment import TomographyExperiment +from .tomography_experiment import TomographyExperiment, TomographyAnalysis, BaseAnalysis from .qst_analysis import StateTomographyAnalysis from . import basis @@ -50,27 +51,42 @@ class StateTomography(TomographyExperiment): def __init__( self, circuit: Union[QuantumCircuit, Instruction, BaseOperator, Statevector], + backend: Optional[Backend] = None, + physical_qubits: Optional[Sequence[int]] = None, measurement_basis: basis.MeasurementBasis = basis.PauliMeasurementBasis(), + measurement_indices: Optional[Sequence[int]] = None, measurement_qubits: Optional[Sequence[int]] = None, basis_indices: Optional[Iterable[List[int]]] = None, qubits: Optional[Sequence[int]] = None, + analysis: Union[BaseAnalysis, None, str] = "default", + target: Union[Statevector, DensityMatrix, None, str] = "default", ): """Initialize a quantum process tomography experiment. Args: circuit: the quantum process circuit. If not a quantum circuit it must be a class that can be appended to a quantum circuit. + backend: The backend to run the experiment on. + physical_qubits: Optional, the physical qubits for the initial state circuit. + If None this will be qubits [0, N) for an N-qubit circuit. measurement_basis: Tomography basis for measurements. If not specified the default basis is the :class:`~basis.PauliMeasurementBasis`. - measurement_qubits: Optional, the qubits to be measured. These should refer - to the logical qubits in the state circuit. If None all qubits - in the state circuit will be measured. + measurement_indices: Optional, the `physical_qubits` indices to be measured. + If None all circuit physical qubits will be measured. + measurement_qubits: DEPRECATED, equivalent to measurement_indices. basis_indices: Optional, a list of basis indices for generating partial tomography measurement data. Each item should be given as a list of measurement basis configurations ``[m[0], m[1], ...]`` where ``m[i]`` is the measurement basis index for qubit-i. If not specified full tomography for all indices of the measurement basis will be performed. - qubits: Optional, the physical qubits for the initial state circuit. + qubits: DEPRECATED, the physical qubits for the initial state circuit. + analysis: Optional, a custom analysis instance to use. If ``"default"`` + :class:`~.StateTomographyAnalysis` will be used. If None no analysis + instance will be set. + target: Optional, a custom quantum state target for computing the + state fidelity of the fitted density matrix during analysis. + If "default" the state will be inferred from the input circuit + if it contains no classical instructions. """ if isinstance(circuit, Statevector): # Convert to circuit using initialize instruction @@ -82,17 +98,26 @@ def __init__( # Add trivial preparation indices for base class basis_indices = [([], i) for i in basis_indices] + if analysis == "default": + analysis = StateTomographyAnalysis() + super().__init__( circuit, + backend=backend, + physical_qubits=physical_qubits, measurement_basis=measurement_basis, + measurement_indices=measurement_indices, measurement_qubits=measurement_qubits, basis_indices=basis_indices, qubits=qubits, - analysis=StateTomographyAnalysis(), + analysis=analysis, ) # Set target quantum state - self.analysis.set_options(target=self._target_quantum_state()) + if isinstance(self.analysis, TomographyAnalysis): + if target == "default": + target = self._target_quantum_state() + self.analysis.set_options(target=target) def _target_quantum_state(self) -> Union[Statevector, DensityMatrix]: """Return the state tomography target""" @@ -112,10 +137,10 @@ def _target_quantum_state(self) -> Union[Statevector, DensityMatrix]: # Circuit couldn't be simulated return None - if self._meas_qubits is None: + if not self._meas_indices: return state - non_meas_qargs = list(range(len(self._meas_qubits), self._circuit.num_qubits)) + non_meas_qargs = list(range(len(self._meas_indices), self._circuit.num_qubits)) if non_meas_qargs: # Trace over non-measured qubits state = partial_trace(state, non_meas_qargs) diff --git a/qiskit_experiments/library/tomography/tomography_experiment.py b/qiskit_experiments/library/tomography/tomography_experiment.py index b1039e79ce..d298663bc0 100644 --- a/qiskit_experiments/library/tomography/tomography_experiment.py +++ b/qiskit_experiments/library/tomography/tomography_experiment.py @@ -13,6 +13,7 @@ Quantum Tomography experiment """ +import warnings from typing import Union, Optional, Iterable, List, Tuple, Sequence from itertools import product from qiskit.circuit import QuantumCircuit, Instruction, ClassicalRegister @@ -20,7 +21,7 @@ from qiskit.providers.backend import Backend from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit_experiments.exceptions import QiskitError -from qiskit_experiments.framework import BaseExperiment, Options +from qiskit_experiments.framework import BaseExperiment, BaseAnalysis, Options from .basis import PreparationBasis, MeasurementBasis from .tomography_analysis import TomographyAnalysis @@ -54,13 +55,16 @@ def __init__( self, circuit: Union[QuantumCircuit, Instruction, BaseOperator], backend: Optional[Backend] = None, + physical_qubits: Optional[Sequence[int]] = None, measurement_basis: Optional[MeasurementBasis] = None, + measurement_indices: Optional[Sequence[int]] = None, measurement_qubits: Optional[Sequence[int]] = None, preparation_basis: Optional[PreparationBasis] = None, + preparation_indices: Optional[Sequence[int]] = None, preparation_qubits: Optional[Sequence[int]] = None, basis_indices: Optional[Iterable[Tuple[List[int], List[int]]]] = None, qubits: Optional[Sequence[int]] = None, - analysis: Optional[TomographyAnalysis] = None, + analysis: Union[BaseAnalysis, None, str] = "default", ): """Initialize a tomography experiment. @@ -68,27 +72,62 @@ def __init__( circuit: the quantum process circuit. If not a quantum circuit it must be a class that can be appended to a quantum circuit. backend: The backend to run the experiment on. - measurement_basis: Tomography basis for measurements. - measurement_qubits: Optional, the qubits to be measured. These should refer - to the logical qubits in the state circuit. - preparation_basis: Tomography basis for measurements. - preparation_qubits: Optional, the qubits to be prepared. These should refer - to the logical qubits in the process circuit. + physical_qubits: Optional, the physical qubits for the initial state circuit. + If None this will be qubits [0, N) for an N-qubit circuit. + measurement_basis: Tomography basis for measurements. If set to None + no tomography measurements will be performed. + measurement_indices: Optional, the `physical_qubits` indices to be + measured as specified by the `measurement_basis`. If None all + circuit physical qubits will be measured. + measurement_qubits: DEPRECATED, equivalent to measurement_indices. + preparation_basis: Tomography basis for measurements. If set to None + no tomography preparations will be performed. + preparation_indices: Optional, the `physical_qubits` indices to be + prepared as specified by the `preparation_basis`. If None all + circuit physical qubits will be prepared. + preparation_qubits: DEPRECATED, equivalent to preparation_indices. basis_indices: Optional, the basis elements to be measured. If None All basis elements will be measured. - qubits: Optional, the physical qubits for the initial state circuit. - analysis: Optional, analysis class to use for experiment. If None the default - tomography analysis will be used. + qubits: DEPRECATED, the physical qubits for the initial state circuit. + analysis: Optional, a custom analysis instance to use. If ``"default"`` + :class:`~.TomographyAnalysis` will be used. If None no analysis + instance will be set. Raises: QiskitError: if input params are invalid. """ + # Deprecated kwargs + if qubits is not None: + physical_qubits = qubits + warnings.warn( + "The `qubits` kwarg has been renamed to `physical_qubits`." + " It will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + if measurement_qubits is not None: + measurement_indices = measurement_qubits + warnings.warn( + "The `measurement_qubits` kwarg has been renamed to `measurement_indices`." + " It will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + if preparation_qubits is not None: + preparation_indices = preparation_qubits + warnings.warn( + "The `preparation_qubits` kwarg has been renamed to `preparation_indices`." + " It will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + # Initialize BaseExperiment - if qubits is None: - qubits = tuple(range(circuit.num_qubits)) - if analysis is None: + if physical_qubits is None: + physical_qubits = tuple(range(circuit.num_qubits)) + if analysis == "default": analysis = TomographyAnalysis() - super().__init__(qubits, analysis=analysis, backend=backend) + super().__init__(physical_qubits, analysis=analysis, backend=backend) # Get the target tomography circuit if isinstance(circuit, QuantumCircuit): @@ -102,39 +141,39 @@ def __init__( # Measurement basis and qubits self._meas_circ_basis = measurement_basis - if measurement_qubits: + if measurement_indices: # Convert logical qubits to physical qubits - self._meas_qubits = tuple(measurement_qubits) - self._meas_physical_qubits = tuple(self.physical_qubits[i] for i in self._meas_qubits) - for qubit in self._meas_qubits: + self._meas_indices = tuple(measurement_indices) + self._meas_physical_qubits = tuple(self.physical_qubits[i] for i in self._meas_indices) + for qubit in self._meas_indices: if qubit not in range(self.num_qubits): raise QiskitError( f"measurement qubit ({qubit}) is outside the range" f" of circuit qubits [0, {self.num_qubits})." ) elif measurement_basis: - self._meas_qubits = tuple(range(self.num_qubits)) + self._meas_indices = tuple(range(self.num_qubits)) self._meas_physical_qubits = self.physical_qubits else: - self._meas_qubits = tuple() + self._meas_indices = tuple() self._meas_physical_qubits = tuple() # Preparation basis and qubits self._prep_circ_basis = preparation_basis - if preparation_qubits: - self._prep_qubits = tuple(preparation_qubits) - self._prep_physical_qubits = tuple(self.physical_qubits[i] for i in self._prep_qubits) - for qubit in self._prep_qubits: + if preparation_indices: + self._prep_indices = tuple(preparation_indices) + self._prep_physical_qubits = tuple(self.physical_qubits[i] for i in self._prep_indices) + for qubit in self._prep_indices: if qubit not in range(self.num_qubits): raise QiskitError( f"preparation qubit ({qubit}) is outside the range" f" of circuit qubits [0, {self.num_qubits})." ) elif preparation_basis: - self._prep_qubits = tuple(range(self.num_qubits)) + self._prep_indices = tuple(range(self.num_qubits)) self._prep_physical_qubits = self.physical_qubits else: - self._prep_qubits = tuple() + self._prep_indices = tuple() self._prep_physical_qubits = tuple() # Configure experiment options @@ -142,19 +181,18 @@ def __init__( self.set_experiment_options(basis_indices=basis_indices) # Configure analysis basis options - analysis_options = {} - if measurement_basis: - analysis_options["measurement_basis"] = measurement_basis - if preparation_basis: - analysis_options["preparation_basis"] = preparation_basis - - self.analysis.set_options(**analysis_options) + if isinstance(self.analysis, TomographyAnalysis): + analysis_options = {} + if measurement_basis: + analysis_options["measurement_basis"] = measurement_basis + if preparation_basis: + analysis_options["preparation_basis"] = preparation_basis + self.analysis.set_options(**analysis_options) def circuits(self): - circ_qubits = self._circuit.qubits circ_clbits = self._circuit.clbits - meas_creg = ClassicalRegister((len(self._meas_qubits)), name="c_tomo") + meas_creg = ClassicalRegister((len(self._meas_indices)), name="c_tomo") template = QuantumCircuit( *self._circuit.qregs, *self._circuit.cregs, meas_creg, name=f"{self._type}" ) @@ -177,9 +215,9 @@ def circuits(self): if prep_element: # Add tomography preparation prep_circ = self._prep_circ_basis.circuit(prep_element, self._prep_physical_qubits) - circ.reset(self._prep_qubits) - circ.compose(prep_circ, self._prep_qubits, inplace=True) - circ.barrier(self._prep_qubits) + circ.reset(self._prep_indices) + circ.compose(prep_circ, self._prep_indices, inplace=True) + circ.barrier(self._prep_indices) # Add target circuit # Have to use compose since circuit.to_instruction has a bug @@ -189,8 +227,8 @@ def circuits(self): # Add tomography measurement if meas_element: meas_circ = self._meas_circ_basis.circuit(meas_element, self._meas_physical_qubits) - circ.barrier(self._meas_qubits) - circ.compose(meas_circ, self._meas_qubits, meas_clbits, inplace=True) + circ.barrier(self._meas_indices) + circ.compose(meas_circ, self._meas_indices, meas_clbits, inplace=True) # Add metadata circ.metadata = metadata @@ -230,8 +268,8 @@ def _permute_circuit(self) -> QuantumCircuit: respectively for the returned circuit. """ default_range = tuple(range(self.num_qubits)) - permute_meas = self._meas_qubits and self._meas_qubits != default_range - permute_prep = self._prep_qubits and self._prep_qubits != default_range + permute_meas = self._meas_indices and self._meas_indices != default_range + permute_prep = self._prep_indices and self._prep_indices != default_range if not permute_meas and not permute_prep: return self._circuit @@ -243,10 +281,10 @@ def _permute_circuit(self) -> QuantumCircuit: perm_circ = QuantumCircuit(total_qubits) # Apply permutation to put prep qubits as [0, ..., M-1] - if self._prep_qubits: - prep_qargs = list(self._prep_qubits) - if len(self._prep_qubits) != total_qubits: - prep_qargs += [i for i in range(total_qubits) if i not in self._prep_qubits] + if self._prep_indices: + prep_qargs = list(self._prep_indices) + if len(self._prep_indices) != total_qubits: + prep_qargs += [i for i in range(total_qubits) if i not in self._prep_indices] perm_circ.append(Permutation(total_qubits, prep_qargs).inverse(), range(total_qubits)) # Apply original circuit @@ -256,10 +294,10 @@ def _permute_circuit(self) -> QuantumCircuit: perm_circ = perm_circ.compose(self._circuit, range(total_qubits)) # Apply permutation to put meas qubits as [0, ..., M-1] - if self._meas_qubits: - meas_qargs = list(self._meas_qubits) - if len(self._meas_qubits) != total_qubits: - meas_qargs += [i for i in range(total_qubits) if i not in self._meas_qubits] + if self._meas_indices: + meas_qargs = list(self._meas_indices) + if len(self._meas_indices) != total_qubits: + meas_qargs += [i for i in range(total_qubits) if i not in self._meas_indices] perm_circ.append(Permutation(total_qubits, meas_qargs), range(total_qubits)) return perm_circ diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 36fa361ff2..7c7eb4cda4 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2021, 2022, 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 @@ -16,31 +16,33 @@ .. currentmodule:: qiskit_experiments.visualization -Visualization provides plotting functionality for creating figures from experiment and analysis results. -This includes plotter and drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. -Plotters inherit from :class:`BasePlotter` and define a type of figure that may be generated from -experiment or analysis data. For example, the results from :class:`CurveAnalysis` --- or any other -experiment where results are plotted against a single parameter (i.e., :math:`x`) --- can be plotted +Visualization provides plotting functionality for creating figures from experiment and +analysis results. This includes plotter and drawer classes to plot data in +:py:class:`CurveAnalysis` and its subclasses. Plotters inherit from :class:`BasePlotter` +and define a type of figure that may be generated from experiment or analysis data. For +example, the results from :class:`CurveAnalysis` --- or any other experiment where +results are plotted against a single parameter (i.e., :math:`x`) --- can be plotted using the :class:`CurvePlotter` class, which plots X-Y-like values. -These plotter classes act as a bridge (from the common bridge pattern in software development) between -analysis classes (or even users) and plotting backends such as Matplotlib. Drawers are the backends, with -a common interface defined in :class:`BaseDrawer`. Though Matplotlib is the only officially supported -plotting backend in Qiskit Experiments (i.e., through :class:`MplDrawer`), custom drawers can be -implemented by users to use alternative backends. As long as the backend is a subclass of -:class:`BaseDrawer`, and implements all the necessary functionality, all plotters should be able to -generate figures with the alternative backend. +These plotter classes act as a bridge (from the common bridge pattern in software +development) between analysis classes (or even users) and plotting backends such as +Matplotlib. Drawers are the backends, with a common interface defined in +:class:`BaseDrawer`. Though Matplotlib is the only officially supported plotting backend +in Qiskit Experiments (i.e., through :class:`MplDrawer`), custom drawers can be +implemented by users to use alternative backends. As long as the backend is a subclass +of :class:`BaseDrawer`, and implements all the necessary functionality, all plotters +should be able to generate figures with the alternative backend. -To collate style parameters together, plotters and drawers store instances of the :class:`PlotStyle` -class. These instances can be merged and updated, so that default styles can have their values -overwritten. +To collate style parameters together, plotters and drawers store instances of the +:class:`PlotStyle` class. These instances can be merged and updated, so that default +styles can have their values overwritten. Plotter Library -============== +=============== .. autosummary:: :toctree: ../stubs/ - :template: autosummary/class.rst + :template: autosummary/plotter.rst BasePlotter CurvePlotter @@ -51,7 +53,7 @@ .. autosummary:: :toctree: ../stubs/ - :template: autosummary/class.rst + :template: autosummary/drawer.rst BaseDrawer MplDrawer diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index edc9bac395..bc3109100e 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021, 2022. +# (C) Copyright IBM 2021, 2022, 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 @@ -28,96 +28,107 @@ class BaseDrawer(ABC): """Abstract class for the serializable Qiskit Experiments figure drawer. - A drawer may be implemented by different drawer backends such as matplotlib or Plotly. Sub-classes - that wrap these backends by subclassing :class:`BaseDrawer` must implement the following abstract - methods. + # section: overview - initialize_canvas + A drawer may be implemented by different drawer backends such as matplotlib or + Plotly. Sub-classes that wrap these backends by subclassing :class:`BaseDrawer` must + implement the following abstract methods. - This method should implement a protocol to initialize a drawer canvas with user input ``axis`` - object. Note that ``drawer`` supports visualization of experiment results in multiple canvases - tiled into N (row) x M (column) inset grids, which is specified in the option ``subplots``. By - default, this is N=1, M=1 and thus no inset grid will be initialized. The data points to draw - might be provided with a canvas number defined in :attr:`SeriesDef.canvas` which defaults to - ``None``, i.e. no-inset grids. + .. describe:: initialize_canvas - This method should first check the drawer options (:attr:`options`) for the axis object and - initialize the axis only when it is not provided by the options. Once axis is initialized, this - is set to the instance member ``self._axis``. + This method should implement a protocol to initialize a drawer canvas with user + input ``axis`` object. Note that ``drawer`` supports visualization of experiment + results in multiple canvases tiled into N (row) x M (column) inset grids, which + is specified in the option ``subplots``. By default, this is N=1, M=1 and thus + no inset grid will be initialized. The data points to draw might be provided + with a canvas number defined in :attr:`SeriesDef.canvas` which defaults to + ``None``, i.e. no-inset grids. - format_canvas + This method should first check the drawer options (:attr:`options`) for the axis + object and initialize the axis only when it is not provided by the options. Once + axis is initialized, this is set to the instance member ``self._axis``. - This method formats the appearance of the canvas. Typically, it updates axis and tick labels. - Note that the axis SI unit may be specified in the drawer figure_options. In this case, axis - numbers should be auto-scaled with the unit prefix. + .. describe:: format_canvas - Drawing Methods: + This method formats the appearance of the canvas. Typically, it updates axis and + tick labels. Note that the axis SI unit may be specified in the drawer + figure_options. In this case, axis numbers should be auto-scaled with the unit + prefix. - scatter + .. rubric:: Drawing Methods - This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars - in both the X and Y axes. + .. describe:: scatter - line + This method draws scatter points on the canvas, like a scatter-plot, with + optional error-bars in both the X and Y axes. - This method plots a line from provided X and Y values. + .. describe:: line - filled_y_area + This method plots a line from provided X and Y values. - This method plots a shaped region bounded by upper and lower Y-values. This method is - typically called with interpolated x and a pair of y values that represent the upper and - lower bound within certain confidence interval. If this is called multiple times, it may be - necessary to set the transparency so that overlapping regions can be distinguished. + .. describe:: filled_y_area - filled_x_area + This method plots a shaped region bounded by upper and lower Y-values. This + method is typically called with interpolated x and a pair of y values that + represent the upper and lower bound within certain confidence interval. If this + is called multiple times, it may be necessary to set the transparency so that + overlapping regions can be distinguished. - This method plots a shaped region bounded by upper and lower X-values, as a function of - Y-values. This method is a rotated analogue of :meth:`filled_y_area`. + .. describe:: filled_x_area - textbox + This method plots a shaped region bounded by upper and lower X-values, as a + function of Y-values. This method is a rotated analogue of + :meth:`filled_y_area`. - This method draws a text-box on the canvas, which is a rectangular region containing some - text. + .. describe:: textbox - Options and Figure Options - ========================== + This method draws a text-box on the canvas, which is a rectangular region + containing some text. - Drawers have both :attr:`options` and :attr:`figure_options` available to set parameters that define - how to draw and what is drawn, respectively. :class:`BasePlotter` is similar in that it also has - ``options`` and ``figure_options`. The former contains class-specific variables that define how an - instance behaves. The latter contains figure-specific variables that typically contain values that - are drawn on the canvas, such as text. For details on the difference between the two sets of options, - see the documentation for :class:`BasePlotter`. + .. rubric:: Legends - .. note:: - If a drawer instance is used with a plotter, then there is the potential for any figure-option - to be overwritten with their value from the plotter. This means that the drawer instance would - be modified indirectly when the :meth:`BasePlotter.figure` method is called. This must be kept - in mind when creating subclasses of :class:`BaseDrawer`. - - Legends - ======= - - Legends are generated based off of drawn graphics and their labels or names. These are managed by - individual drawer subclasses, and generated when the :meth:`format_canvas` method is called. Legend - entries are created when any drawing function is called with ``legend=True``. There are three - parameters in drawing functions that are relevant to legend generation: ``name``, ``label``, and - ``legend``. If a user would like the graphics drawn onto a canvas to be used as the graphical - component of a legend entry; they should set ``legend=True``. The legend entry label can be defined - in three locations: the ``label`` parameter of drawing functions, the ``"label"`` entry in - ``series_params``, and the ``name`` parameter of drawing functions. These three possible label - variables have a search hierarchy given by the order in the aforementioned list. If one of the label - variables is ``None``, the next is used. If all are ``None``, a legend entry is not generated for the - given series. + Legends are generated based off of drawn graphics and their labels or names. These + are managed by individual drawer subclasses, and generated when the + :meth:`format_canvas` method is called. Legend entries are created when any drawing + function is called with ``legend=True``. There are three parameters in drawing + functions that are relevant to legend generation: ``name``, ``label``, and + ``legend``. If a user would like the graphics drawn onto a canvas to be used as the + graphical component of a legend entry; they should set ``legend=True``. The legend + entry label can be defined in three locations: the ``label`` parameter of drawing + functions, the ``"label"`` entry in ``series_params``, and the ``name`` parameter of + drawing functions. These three possible label variables have a search hierarchy + given by the order in the aforementioned list. If one of the label variables is + ``None``, the next is used. If all are ``None``, a legend entry is not generated for + the given series. The recommended way to customize the legend entries is as follows: + 1. Set the labels in the ``series_params`` option, keyed on the series names. 2. Initialize the canvas. - 3. Call relevant drawing methods to create the figure. When calling the drawing method that - creates the graphic you would like to use in the legend, set ``legend=True``. For example, - ``drawer.scatter(...,legend=True)`` would use the scatter points as the legend graphics for - the given series. + 3. Call relevant drawing methods to create the figure. When calling the drawing + method that creates the graphic you would like to use in the legend, set + ``legend=True``. For example, ``drawer.scatter(...,legend=True)`` would use + the scatter points as the legend graphics for the given series. 4. Format the canvas and call :meth:`figure` to get the figure. + + .. rubric:: Options and Figure Options + + Drawers have both :attr:`options` and :attr:`figure_options` available to set + parameters that define how to draw and what is drawn, respectively. + :class:`BasePlotter` is similar in that it also has ``options`` and + ``figure_options``. The former contains class-specific variables that define how an + instance behaves. The latter contains figure-specific variables that typically + contain values that are drawn on the canvas, such as text. For details on the + difference between the two sets of options, see the documentation for + :class:`BasePlotter`. + + .. note:: + If a drawer instance is used with a plotter, then there is the potential for + any figure option to be overwritten with their value from the plotter. This + means that the drawer instance would be modified indirectly when the + :meth:`BasePlotter.figure` method is called. This must be kept in mind when + creating subclasses of :class:`BaseDrawer`. + """ def __init__(self): @@ -127,10 +138,10 @@ def __init__(self): # A set of changed options for serialization. self._set_options = set() - # Figure options which are typically updated by a plotter instance. Figure-options include the + # Figure options which are typically updated by a plotter instance. Figure options include the # axis labels, figure title, and a custom style instance. self._figure_options = self._default_figure_options() - # A set of changed figure-options for serialization. + # A set of changed figure options for serialization. self._set_figure_options = set() # The initialized axis/axes, set by `initialize_canvas`. @@ -145,9 +156,9 @@ def options(self) -> Options: def figure_options(self) -> Options: """Return the figure options. - These are typically updated by a plotter instance, and thus may change. It is recommended to set - figure options in a parent :class:`BasePlotter` instance that contains the :class:`BaseDrawer` - instance. + These are typically updated by a plotter instance, and thus may change. It is + recommended to set figure options in a parent :class:`BasePlotter` instance that + contains the :class:`BaseDrawer` instance. """ return self._figure_options @@ -155,14 +166,14 @@ def figure_options(self) -> Options: def _default_options(cls) -> Options: """Return default drawer options. - Drawer Options: + Options: axis (Any): Arbitrary object that can be used as a canvas. subplots (Tuple[int, int]): Number of rows and columns when the experimental result is drawn in the multiple windows. - default_style (PlotStyle): The default style for drawer. - This must contain all required style parameters for :class:`drawer`, as is defined in - :meth:`PlotStyle.default_style()`. Subclasses can add extra required style parameters by - overriding :meth:`_default_style`. + default_style (PlotStyle): The default style for drawer. This must contain + all required style parameters for :class:`drawer`, as is defined in + :meth:`PlotStyle.default_style()`. Subclasses can add extra required + style parameters by overriding :meth:`_default_style`. """ return Options( axis=None, @@ -179,36 +190,39 @@ def _default_figure_options(cls) -> Options: """Return default figure options. Figure Options: - xlabel (Union[str, List[str]]): X-axis label string of the output figure. - If there are multiple columns in the canvas, this could be a list of labels. - ylabel (Union[str, List[str]]): Y-axis label string of the output figure. - If there are multiple rows in the canvas, this could be a list of labels. - xlim (Tuple[float, float]): Min and max value of the horizontal axis. - If not provided, it is automatically scaled based on the input data points. - ylim (Tuple[float, float]): Min and max value of the vertical axis. - If not provided, it is automatically scaled based on the input data points. - xval_unit (str): Unit of x values. No scaling prefix is needed here as this is controlled by - ``xval_unit_scale``. + xlabel (Union[str, List[str]]): X-axis label string of the output figure. If + there are multiple columns in the canvas, this could be a list of labels. + ylabel (Union[str, List[str]]): Y-axis label string of the output figure. If + there are multiple rows in the canvas, this could be a list of labels. + xlim (Tuple[float, float]): Min and max value of the horizontal axis. If not + provided, it is automatically scaled based on the input data points. + ylim (Tuple[float, float]): Min and max value of the vertical axis. If not + provided, it is automatically scaled based on the input data points. + xval_unit (str): Unit of x values. No scaling prefix is needed here as this + is controlled by ``xval_unit_scale``. yval_unit (str): Unit of y values. See ``xval_unit`` for details. - xval_unit_scale (bool): Whether to add an SI unit prefix to ``xval_unit`` if needed. - For example, when the x values represent time and ``xval_unit="s"``, - ``xval_unit_scale=True`` adds an SI unit prefix to ``"s"`` based on X values of plotted - data. In the output figure, the prefix is automatically selected based on the maximum - value in this axis. If your x values are in [1e-3, 1e-4], they are displayed as [1 ms, 10 - ms]. By default, this option is set to ``True``. If ``False`` is provided, the axis - numbers will be displayed in the scientific notation. - yval_unit_scale (bool): Whether to add an SI unit prefix to ``yval_unit`` if needed. See - ``xval_unit_scale`` for details. - figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. - series_params (Dict[str, Dict[str, Any]]): A dictionary of parameters for each series. - This is keyed on the name for each series. Sub-dictionary is expected to have the - following three configurations, "canvas", "color", "symbol" and "label"; "canvas" is the - integer index of axis (when multi-canvas plot is set), "color" is the color of the drawn - graphics, "symbol" is the series marker style for scatter plots, and "label" is a user - provided series label that appears in the legend. - custom_style (PlotStyle): The style definition to use when drawing. This overwrites style - parameters in ``default_style`` in :attr:`options`. Defaults to an empty PlotStyle - instance (i.e., :code-block:`PlotStyle()`). + xval_unit_scale (bool): Whether to add an SI unit prefix to ``xval_unit`` if + needed. For example, when the x values represent time and + ``xval_unit="s"``, ``xval_unit_scale=True`` adds an SI unit prefix to + ``"s"`` based on X values of plotted data. In the output figure, the + prefix is automatically selected based on the maximum value in this + axis. If your x values are in [1e-3, 1e-4], they are displayed as [1 ms, + 10 ms]. By default, this option is set to ``True``. If ``False`` is + provided, the axis numbers will be displayed in the scientific notation. + yval_unit_scale (bool): Whether to add an SI unit prefix to ``yval_unit`` if + needed. See ``xval_unit_scale`` for details. + figure_title (str): Title of the figure. Defaults to None, i.e. nothing is + shown. + series_params (Dict[str, Dict[str, Any]]): A dictionary of parameters for + each series. This is keyed on the name for each series. Sub-dictionary + is expected to have the following three configurations, "canvas", + "color", "symbol" and "label"; "canvas" is the integer index of axis + (when multi-canvas plot is set), "color" is the color of the drawn + graphics, "symbol" is the series marker style for scatter plots, and + "label" is a user provided series label that appears in the legend. + custom_style (PlotStyle): The style definition to use when drawing. This + overwrites style parameters in ``default_style`` in :attr:`options`. + Defaults to an empty PlotStyle instance (i.e., ``PlotStyle()``). """ return Options( xlabel=None, @@ -249,7 +263,7 @@ def set_figure_options(self, **fields): fields: The fields to update the figure options Raises: - AttributeError: if an unknown figure-option is encountered. + AttributeError: if an unknown figure option is encountered. """ for field in fields: if not hasattr(self._figure_options, field): @@ -263,13 +277,14 @@ def set_figure_options(self, **fields): def style(self) -> PlotStyle: """The combined plot style for this drawer. - The returned style instance is a combination of :attr:`options.default_style` and - :attr:`figure_options.custom_style`. Style parameters set in ``custom_style`` override those set - in ``default_style``. If ``custom_style`` is not an instance of :class:`PlotStyle`, the returned - style is equivalent to ``default_style``. + The returned style instance is a combination of :attr:`options.default_style` + and :attr:`figure_options.custom_style`. Style parameters set in + ``custom_style`` override those set in ``default_style``. If ``custom_style`` is + not an instance of :class:`PlotStyle`, the returned style is equivalent to + ``default_style``. Returns: - PlotStyle: The plot style for this drawer. + The plot style for this drawer. """ if isinstance(self.figure_options.custom_style, PlotStyle): return PlotStyle.merge(self.options.default_style, self.figure_options.custom_style) @@ -286,21 +301,23 @@ def format_canvas(self): def label_for(self, name: Optional[SeriesName], label: Optional[SeriesName]) -> Optional[str]: """Get the legend label for the given series, with optional overrides. - This method determines the legend label for a series, with optional overrides ``label`` and the - ``"label"`` entry in the ``series_params`` option (see :attr:`options`). ``label`` is returned if - it is not ``None``, as this is the override with the highest priority. If it is ``None``, then - the drawer will look for a ``"label"`` entry in ``series_params`, for the series identified by - ``name``. If this entry doesn't exist, or is ``None``, then ``name`` is used as the label. If all - these options are ``None``, then ``None`` is returned; signifying that a legend entry for the - provided series should not be generated. Note that :meth:`label_for` will convert ``name`` to - :type:`str` when it is returned. + This method determines the legend label for a series, with optional overrides + ``label`` and the ``"label"`` entry in the ``series_params`` option (see + :attr:`options`). ``label`` is returned if it is not ``None``, as this is the + override with the highest priority. If it is ``None``, then the drawer will look + for a ``"label"`` entry in ``series_params``, for the series identified by + ``name``. If this entry doesn't exist, or is ``None``, then ``name`` is used as + the label. If all these options are ``None``, then ``None`` is returned; + signifying that a legend entry for the provided series should not be generated. + Note that :meth:`label_for` will convert ``name`` to ``str`` when it is + returned. Args: name: The name of the series. label: Optional label override. Returns: - Optional[str]: The legend entry label, or ``None``. + The legend entry label, or ``None``. """ if label is not None: return str(label) @@ -331,10 +348,11 @@ def scatter( name: Name of this series. label: Optional legend label to override ``name`` and ``series_params``. legend: Whether the drawn area must have a legend entry. Defaults to False. - The series label in the legend will be ``label`` if it is not None. If it is, then - ``series_params`` is searched for a "label" entry for the series identified by ``name``. - If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is - provided, then no legend entry is generated. + The series label in the legend will be ``label`` if it is not None. If + it is, then ``series_params`` is searched for a ``"label"`` entry for + the series identified by ``name``. If this is also ``None``, then + ``name`` is used as the fallback. If no ``name`` is provided, then no + legend entry is generated. options: Valid options for the drawer backend API. """ @@ -356,10 +374,11 @@ def line( name: Name of this series. label: Optional legend label to override ``name`` and ``series_params``. legend: Whether the drawn area must have a legend entry. Defaults to False. - The series label in the legend will be ``label`` if it is not None. If it is, then - ``series_params`` is searched for a "label" entry for the series identified by ``name``. - If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is - provided, then no legend entry is generated. + The series label in the legend will be ``label`` if it is not None. If + it is, then ``series_params`` is searched for a ``"label"`` entry for + the series identified by ``name``. If this is also ``None``, then + ``name`` is used as the fallback. If no ``name`` is provided, then no + legend entry is generated. options: Valid options for the drawer backend API. """ @@ -383,10 +402,11 @@ def filled_y_area( name: Name of this series. label: Optional legend label to override ``name`` and ``series_params``. legend: Whether the drawn area must have a legend entry. Defaults to False. - The series label in the legend will be ``label`` if it is not None. If it is, then - ``series_params`` is searched for a "label" entry for the series identified by ``name``. - If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is - provided, then no legend entry is generated. + The series label in the legend will be ``label`` if it is not None. If + it is, then ``series_params`` is searched for a ``"label"`` entry for + the series identified by ``name``. If this is also ``None``, then + ``name`` is used as the fallback. If no ``name`` is provided, then no + legend entry is generated. options: Valid options for the drawer backend API. """ @@ -410,10 +430,11 @@ def filled_x_area( name: Name of this series. label: Optional legend label to override ``name`` and ``series_params``. legend: Whether the drawn area must have a legend entry. Defaults to False. - The series label in the legend will be ``label`` if it is not None. If it is, then - ``series_params`` is searched for a "label" entry for the series identified by ``name``. - If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is - provided, then no legend entry is generated. + The series label in the legend will be ``label`` if it is not None. If + it is, then ``series_params`` is searched for a ``"label"`` entry for + the series identified by ``name``. If this is also ``None``, then + ``name`` is used as the fallback. If no ``name`` is provided, then no + legend entry is generated. options: Valid options for the drawer backend API. """ @@ -428,8 +449,8 @@ def textbox( Args: description: A string to be drawn inside a report box. - rel_pos: Relative position of the text-box. If None, the default ``textbox_rel_pos`` from - the style is used. + rel_pos: Relative position of the text-box. If None, the default + ``textbox_rel_pos`` from the style is used. options: Valid options for the drawer backend API. """ @@ -448,30 +469,36 @@ def image( """Draw an image of numerical values, series names, or RGB/A values. Args: - data: The two-/three-dimensional data defining an image. If ``data.dims==2``, then the pixel - colors are determined by ``cmap`` and ``cmap_use_series_colors``. If ``data.dims==3``, - then it is assumed that ``data`` contains either RGB or RGBA data; which requires the - third dimension to have length ``3`` or ``4`` respectively. For RGB/A data, the elements - of ``data`` must be floats or integers in the range 0-1 and 0-255 respectively. If the - data is two-dimensional, there is no limit on the range of the values if they are - numerical. If ``cmap_use_series_colors=True``, then ``data`` contains series names; which - can be strings or numerical values, as long as they are appropriate series-names. - extent: An optional tuple ``(x_min, x_max, y_min, y_max)`` which defines a rectangular region - within which the values inside ``data`` should be plotted. The units of ``extent`` are - the same as those of the X and Y axes for the axis. If None, the extent of the image is - taken as ``(0, data.shape[0], 0, data.shape[1])``. Default is None. - name: Name of this image. Used to lookup ``canvas`` and ``label`` in ``series_params``. + data: The two-/three-dimensional data defining an image. If + ``data.dims==2``, then the pixel colors are determined by ``cmap`` and + ``cmap_use_series_colors``. If ``data.dims==3``, then it is assumed that + ``data`` contains either RGB or RGBA data; which requires the third + dimension to have length ``3`` or ``4`` respectively. For RGB/A data, + the elements of ``data`` must be floats or integers in the range 0-1 and + 0-255 respectively. If the data is two-dimensional, there is no limit on + the range of the values if they are numerical. If + ``cmap_use_series_colors=True``, then ``data`` contains series names; + which can be strings or numerical values, as long as they are + appropriate series names. + extent: An optional tuple ``(x_min, x_max, y_min, y_max)`` which defines a + rectangular region within which the values inside ``data`` should be + plotted. The units of ``extent`` are the same as those of the X and Y + axes for the axis. If None, the extent of the image is taken as ``(0, + data.shape[0], 0, data.shape[1])``. Default is None. + name: Name of this image. Used to lookup ``canvas`` and ``label`` in + ``series_params``. label: An optional label for the colorbar, if ``colorbar=True``. - cmap: Optional colormap for assigning colors to the image values, if ``data`` is not an RGB/A - image. ``cmap`` must be a string or object instance which is recognized by the drawer. - Defaults to None. - cmap_use_series_colors: Whether to assign colors to the image based on series colors, - where the values inside ``data`` are series names. If - ``cmap_use_series_colors=True``,``cmap`` is ignored. This only works for two-dimensional - images as three-dimensional ``data`` contains explicit colors as RGB/A values. If - ``len(data.shape)=3``, ``cmap_use_series_colours`` is ignored. Defaults to False. - colorbar: Whether to add a bar showing the color-value mapping for the image. Defaults to - False. + cmap: Optional colormap for assigning colors to the image values, if + ``data`` is not an RGB/A image. ``cmap`` must be a string or object + instance which is recognized by the drawer. Defaults to None. + cmap_use_series_colors: Whether to assign colors to the image based on + series colors, where the values inside ``data`` are series names. If + ``cmap_use_series_colors=True``,``cmap`` is ignored. This only works for + two-dimensional images as three-dimensional ``data`` contains explicit + colors as RGB/A values. If ``len(data.shape)=3``, + ``cmap_use_series_colours`` is ignored. Defaults to False. + colorbar: Whether to add a bar showing the color-value mapping for the + image. Defaults to False. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index 728f28ff12..3f2764f6c7 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -32,15 +32,16 @@ class LegacyCurveCompatDrawer(BaseDrawer): """A compatibility wrapper for the legacy and deprecated :class:`BaseCurveDrawer`. - :mod:`qiskit_experiments.curve_analysis.visualization` is deprecated and will be replaced with the - new :mod:`qiskit_experiments.visualization` module. Analysis classes instead use subclasses of - :class:`BasePlotter` to generate figures. This class wraps the legacy :class:`BaseCurveDrawer` class - so it can be used by analysis classes, such as :class:`CurveAnalysis`, until it is removed. + :mod:`qiskit_experiments.curve_analysis.visualization` is deprecated and will be + replaced with the new :mod:`qiskit_experiments.visualization` module. Analysis + classes instead use subclasses of :class:`BasePlotter` to generate figures. This + class wraps the legacy :class:`BaseCurveDrawer` class so it can be used by analysis + classes, such as :class:`CurveAnalysis`, until it is removed. .. note:: - As :class:`BaseCurveDrawer` doesn't support customizing legend entries, the ``legend`` and - ``label`` parameters in drawing methods (such as :meth:`scatter`) are unsupported and - do nothing. + As :class:`BaseCurveDrawer` doesn't support customizing legend entries, the + ``legend`` and ``label`` parameters in drawing methods (such as + :meth:`scatter`) are unsupported and do nothing. """ def __init__(self, curve_drawer: BaseCurveDrawer): @@ -75,11 +76,14 @@ def scatter( Args: x_data: X values. y_data: Y values. - x_err: Unsupported as :class:`BaseCurveDrawer` doesn't support X errorbars. Defaults to None. + x_err: Unsupported as :class:`BaseCurveDrawer` doesn't support X errorbars. + Defaults to None. y_err: Optional error for Y values. name: Name of this series. - label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. - legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing + legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling + legend entries. options: Valid options for the drawer backend API. """ if x_err is not None: @@ -106,8 +110,10 @@ def line( x_data: X values. y_data: Fit Y values. name: Name of this series. - label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. - legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing + legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling + legend entries. options: Valid options for the drawer backend API. """ self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) @@ -130,8 +136,10 @@ def filled_y_area( y_ub: The upper boundary of Y values. y_lb: The lower boundary of Y values. name: Name of this series. - label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. - legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing + legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling + legend entries. options: Valid options for the drawer backend API. """ @@ -162,8 +170,9 @@ def textbox( Args: description: A string to be drawn inside a text box. - rel_pos: Unsupported as :class:`BaseCurveDrawer` doesn't support modifying the location of - text in :meth:`textbox` or :meth:`BaseCurveDrawer.draw_fit_report`. + rel_pos: Unsupported as :class:`BaseCurveDrawer` doesn't support modifying + the location of text in :meth:`textbox` or + :meth:`BaseCurveDrawer.draw_fit_report`. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index c5575ddb5c..fd24ef300b 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 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 @@ -60,7 +60,7 @@ def __call__(self, x: Any, pos: int = None) -> str: pos: the tick label position. Returns: - str: the formatted tick label. + The formatted tick label. """ return self.fix_minus("{:.3g}".format(x * self.factor)) @@ -242,7 +242,8 @@ def _get_axis(self, index: Optional[int] = None) -> Axes: """A helper method to get inset axis. Args: - index: Index of inset axis. If nothing is provided, it returns the entire axis. + index: Index of inset axis. If nothing is provided, it returns the entire + axis. Returns: Corresponding axis object. @@ -298,19 +299,22 @@ def _update_label_in_options( label: Optional[str] = None, legend: bool = False, ): - """Helper function to set the label entry in ``options`` based on given arguments. + """Helper function to set the label entry in ``options`` based on given + arguments. - This method uses :meth:`label_for` to get the label for the series identified by ``name``. If - :meth:`label_for` returns ``None``, then ``_update_label_in_options`` doesn't add a `"label"` - entry into ``options``. I.e., a label entry is added to ``options`` only if it is not ``None``. + This method uses :meth:`label_for` to get the label for the series identified by + ``name``. If :meth:`label_for` returns ``None``, then + ``_update_label_in_options`` doesn't add a `"label"` entry into ``options``. + I.e., a label entry is added to ``options`` only if it is not ``None``. Args: options: The options dictionary being modified. - name: The name of the series being labelled. Used as a fall-back label if ``label`` is None - and no label exists in ``series_params`` for this series. + name: The name of the series being labelled. Used as a fall-back label if + ``label`` is None and no label exists in ``series_params`` for this + series. label: Optional legend label to override ``name`` and ``series_params``. - legend: Whether a label entry should be added to ``options``. USed as an easy toggle to - disable adding a label entry. Defaults to False. + legend: Whether a label entry should be added to ``options``. Used as an + easy toggle to disable adding a label entry. Defaults to False. """ if legend: _label = self.label_for(name, label) @@ -472,11 +476,12 @@ def _series_names_to_cmap( ) -> Tuple[Colormap, Dict[str, float]]: """Create a :class:`Colormap` instance of series colours. - This method creates a :class:`Colormap` instance that can be used to plot an image of series - classifications: i.e., a 2D array of series names. The returned Colormap positions the series - colours, from :meth:`_get_default_color`, along the range :math:`0` to :math:`1`. The returned - dictionary contains mappings from series names (``Union[str, int, float]``) to floats which are - used to "sample" from the Colormap. + This method creates a :class:`Colormap` instance that can be used to plot an + image of series classifications: i.e., a 2D array of series names. The returned + Colormap positions the series colours, from :meth:`_get_default_color`, along + the range :math:`0` to :math:`1`. The returned dictionary contains mappings from + series names (``Union[str, int, float]``) to floats which are used to "sample" + from the Colormap. Example: .. code-block:: python @@ -489,7 +494,9 @@ def _series_names_to_cmap( cmap,cmap_map = self._series_names_to_cmap(series_names) # Convert classified data into float data. - data_float = np.vectorize(lambda x: cmap(cmap_map[x]))(data_classification) + data_float = np.vectorize( + lambda x: cmap(cmap_map[x]) + )(data_classification) # Plot float data with Colormap. plt.imshow(data_float, cmap=cmap, ...) @@ -498,9 +505,9 @@ def _series_names_to_cmap( series_names: List of series names. Returns: - tuple: a tuple ``(cmap, map)`` where ``cmap`` is a Matplotlib Colormap instance and ``map`` - is a dictionary that maps series names (dictionary keys) to floats (dictionary values) - that identify the series names' colours in ``cmap``. + A tuple ``(cmap, map)`` where ``cmap`` is a Matplotlib Colormap instance and + ``map`` is a dictionary that maps series names (dictionary keys) to floats + (dictionary values) that identify the series names' colours in ``cmap``. """ # Remove duplicates from series_names, just in-case. Use dict.fromkeys to preserve order and # remove duplicates. @@ -574,15 +581,15 @@ def image( def figure(self) -> Figure: """Return figure object handler to be saved in the database. - In the MatplotLib the ``Figure`` and ``Axes`` are different object. - User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. - For example, a user wants to combine two different experiment results in the - same figure, one can call ``pyplot.subplots`` with two rows and pass one of the + In the MatplotLib the ``Figure`` and ``Axes`` are different object. User can + pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. For + example, a user wants to combine two different experiment results in the same + figure, one can call ``pyplot.subplots`` with two rows and pass one of the generated two axes to each experiment drawer. Once all the experiments complete, the user will obtain the single figure collecting all experimental results. - Note that this method returns the entire figure object, rather than a single axis. - Thus, the experiment data saved in the database might have a figure + Note that this method returns the entire figure object, rather than a single + axis. Thus, the experiment data saved in the database might have a figure collecting all child axes drawings. """ return self._axis.get_figure() diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index cba6599bae..30688f3f9c 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 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 @@ -24,89 +24,104 @@ class BasePlotter(ABC): """An abstract class for the serializable figure plotters. - A plotter takes data from an experiment analysis class or experiment and plots a given figure using a - drawing backend. Sub-classes define the kind of figure created and the expected data. - - Data is split into series and supplementary data. Series data is grouped by series name - (:type:`Union[str, int, float]`). For :class:`CurveAnalysis`, this is the model name for a curve fit. - For series data associated with a single series name and supplementary data, data-values are - identified by a data-key (str). Different data per series and figure must have a different data-key - to avoid overwriting values. Experiment and analysis results can be passed to the plotter so - appropriate graphics can be drawn on the figure canvas. Series data is added to the plotter using - :meth:`set_series_data` whereas supplementary data is added using :meth:`set_supplementary_data`. - Series and supplementary data are retrieved using :meth:`data_for` and :attr:`supplementary_data` - respectively. - - Series data contains values to be plotted on a canvas, such that the data can be grouped into subsets - identified by their series name. Series names can be thought of as legend labels for the plotted - data, and as curve names for a curve-fit. Supplementary data is not associated with a series or curve - and is instead only associated with the figure. Examples include analysis reports or other text that - is drawn onto the figure canvas. - - Options and Figure Options - ========================== - - Plotters have both :attr:`options` and :attr:`figure_options` available to set parameters that define - how to plot and what is plotted. :class:`BaseDrawer` is similar in that it also has ``options`` and - ``figure_options`. The former contains class-specific variables that define how an instance behaves. - The latter contains figure-specific variables that typically contain values that are drawn on the + # section: overview + + A plotter takes data from an experiment analysis class or experiment and plots a + given figure using a drawing backend. Sub-classes define the kind of figure created + and the expected data. + + Data is split into series and supplementary data. Series data is grouped by series + name (``Union[str, int, float]``). For :class:`CurveAnalysis`, this is the model + name for a curve fit. For series data associated with a single series name and + supplementary data, data values are identified by a data key (str). Different data + per series and figure must have a different data key to avoid overwriting values. + Experiment and analysis results can be passed to the plotter so appropriate graphics + can be drawn on the figure canvas. Series data is added to the plotter using + :meth:`set_series_data` whereas supplementary data is added using + :meth:`set_supplementary_data`. Series and supplementary data are retrieved using + :meth:`data_for` and :attr:`supplementary_data` respectively. + + Series data contains values to be plotted on a canvas, such that the data can be + grouped into subsets identified by their series name. Series names can be thought of + as legend labels for the plotted data, and as curve names for a curve fit. + Supplementary data is not associated with a series or curve and is instead only + associated with the figure. Examples include analysis reports or other text that is + drawn onto the figure canvas. + + .. rubric:: Options and Figure Options + + Plotters have both :attr:`options` and :attr:`figure_options` available to set + parameters that define how to plot and what is plotted. :class:`BaseDrawer` is + similar in that it also has ``options`` and ``figure_options``. The former contains + class-specific variables that define how an instance behaves. The latter contains + figure-specific variables that typically contain values that are drawn on the canvas, such as text. - For example, :class:`BasePlotter` has an ``axis`` option that can be set to the canvas on which the - figure should be drawn. This changes how the plotter works in that it changes where the figure is - drawn. :class:`BasePlotter` has an ``xlabel`` figure-option that can be set to change the text drawn - next to the X-axis in the final figure. As the value of this option will be drawn on the figure, it - is a figure-option. - - As plotters need a drawer to generate a figure, and the drawer needs to know what to draw, - figure-options are passed to :attr:`drawer` when the :meth:`figure` method is called. Any - figure-options that are defined in both the plotters :attr:`figure_options` attribute and the drawers - ``figure_options`` attribute are copied to the drawer: i.e., :meth:`BaseDrawer.set_figure_options` is - called for each common figure-option, setting the value of the option to the value stored in the + For example, :class:`BasePlotter` has an ``axis`` option that can be set to the + canvas on which the figure should be drawn. This changes how the plotter works in + that it changes where the figure is drawn. :class:`BasePlotter` has an ``xlabel`` + figure option that can be set to change the text drawn next to the X-axis in the + final figure. As the value of this option will be drawn on the figure, it is a + figure option. + + As plotters need a drawer to generate a figure, and the drawer needs to know what to + draw, figure options are passed to :attr:`drawer` when the :meth:`figure` method is + called. Any figure options that are defined in both the plotters + :attr:`figure_options` attribute and the drawers ``figure_options`` attribute are + copied to the drawer: i.e., :meth:`BaseDrawer.set_figure_options` is called for each + common figure option, setting the value of the option to the value stored in the plotter. .. note:: - If a figure-option called "foo" is not set in the drawers figure-options (:attr:`~BaseDrawer. - figure_options`), but is set in the plotters figure-options (:attr:`figure_options`), it will - not be copied over to the drawer when the :meth:`figure` method is called. This means that some - figure-options from the plotter may be unused by the drawer. :class:`BasePlotter` and its - subclasses filter these options before setting them in the drawer as subclasses of - :class:`BaseDrawer` may add additional figure-options. To make validation easier and the code - cleaner, the :meth:`figure` method conducts this check before setting figure-options in the - drawer. - - Example: - .. code-block:: python - plotter = MyPlotter(MyDrawer()) - - # MyDrawer contains the following figure_options with default values. - plotter.drawer.figure_options.xlabel - plotter.drawer.figure_options.ylabel - - # MyDrawer does NOT contain the following figure-option - # plotter.drawer.figure_options.unknown_variable # Raises an error as it does not exist in - # `plotter.drawer`. - - # If we set the following figure-options, they will be set in the drawer. - plotter.set_figure_options(xlabel="Frequency", ylabel="Fidelity") - - # During a call to `plotter.figure()`, the drawer figure-options are updated. - # The following values would be returned from the drawer. - plotter.drawer.figure_options.xlabel # returns "Frequency" - plotter.drawer.figure_options.ylabel # returns "Fidelity" - - # If we set the following option and figure-option will NOT be set in the drawer. - plotter.set_options(plot_fit=False) # Example plotter option - plotter.set_figure_options(unknown_variable=5e9) # Example figure-option - - # As `plot_fit` is not a figure-option, it is not set in the drawer. - plotter.drawer.options.plot_fit # Would raise an error if no default exists, or return a - # different value to `plotter.options.plot_fit`. - - # As `unknown_variable` is not set in the drawers figure-options, it is not set during a call - # to the `figure()` method. - # plotter.drawer.figure_options.unknown_variable # Raises an error as it does not exist - # in `plotter.drawer.figure_options`. + If a figure option called "foo" is not set in the drawer's figure options + (:attr:`BaseDrawer.figure_options`) but is set in the plotter's figure options + (:attr:`figure_options`), it will not be copied over to the drawer when the + :meth:`figure` method is called. This means that some figure options from the + plotter may be unused by the drawer. :class:`BasePlotter` and its subclasses + filter these options before setting them in the drawer, as subclasses of + :class:`BaseDrawer` may add additional figure options. To make validation + easier and the code cleaner, the :meth:`figure` method conducts this check + before setting figure options in the drawer. + + .. rubric:: Example + + .. code-block:: python + + plotter = MyPlotter(MyDrawer()) + + # MyDrawer contains the following figure_options with default values. + plotter.drawer.figure_options.xlabel + plotter.drawer.figure_options.ylabel + + # MyDrawer does NOT contain the following figure option + # plotter.drawer.figure_options.unknown_variable # Raises an error as it + # does not exist in + # `drawer.figure_options`. + + # If we set the following figure options, they will be set in the drawer as + # they are defined in `plotter.drawer.figure_options`. + plotter.set_figure_options(xlabel="Frequency", ylabel="Fidelity") + + # During a call to `plotter.figure()`, the drawer's figure options are updated. + # The following values would then be returned from the drawer. + plotter.drawer.figure_options.xlabel # returns "Frequency" + plotter.drawer.figure_options.ylabel # returns "Fidelity" + + # If we set the following option and figure option, they will NOT be set in the + # drawer as the drawer doesn't contain default values for these option names. + plotter.set_options(plot_fit=False) # Example plotter option + plotter.set_figure_options(unknown_variable=5e9) # Example figure option + + # As `plot_fit` is not a figure option, it is not set in the drawer. + plotter.drawer.options.plot_fit # Would raise an error if no default + # exists, or return a different value to + # `plotter.options.plot_fit`. + + # As `unknown_variable` is not set in the drawer's figure options, it is not set + # during a # call to the `figure()` method. + # plotter.drawer.figure_options.unknown_variable # Raises an error as it + # does not exist in + # `drawer.figure_options`. """ def __init__(self, drawer: BaseDrawer): @@ -130,16 +145,18 @@ def __init__(self, drawer: BaseDrawer): # Figure options that have changed, for serialization. self._set_figure_options = set() - # The drawer backend to use for plotting. - self.drawer = drawer + # The drawer backend to use for plotting. Docstring provided as drawer is not a @property. + self.drawer: BaseDrawer = drawer + """The drawer to use when plotting.""" @property def supplementary_data(self) -> Dict[str, Any]: - """Additional data for the figure being plotted, that isn't associated with a series. + """Additional data for the figure being plotted, that isn't associated with a + series. - Supplementary data includes text, fit reports, or other data that is associated with the figure - but not an individual series. It is typically data additional to the direct results of an - experiment. + Supplementary data includes text, fit reports, or other data that is associated + with the figure but not an individual series. It is typically data additional to + the direct results of an experiment. """ return self._supplementary_data @@ -148,13 +165,14 @@ def series_data(self) -> Dict[SeriesName, Dict[str, Any]]: """Data for series being plotted. Series data includes data such as scatter points, interpolated fit values, and - standard-deviations. Series data is grouped by series-name (:type:`Union[str, int, float]`) and - then by a data-key (:type:`str`). Though series data can be accessed through :meth:`series_data`, - it is recommended to access them with :meth:`data_for` and :meth:`data_exists_for` as they allow - for easier access to nested values and can handle multiple data-keys in one query. + standard deviations. Series data is grouped by series name (``Union[str, int, + float]``) and then by a data key (``str``). Though series data can be accessed + through :attr:`series_data`, it is recommended to access them with + :meth:`data_for` and :meth:`data_exists_for` as they allow for easier access to + nested values and can handle multiple data keys in one query. Returns: - dict: A dictionary containing series data. + A dictionary containing series data. """ return self._series_data @@ -164,15 +182,16 @@ def series(self) -> List[SeriesName]: return list(self._series_data.keys()) def data_keys_for(self, series_name: SeriesName) -> List[str]: - """Returns a list of data-keys for the given series. + """Returns a list of data keys for the given series. Args: - series_name: The series name for which to return the data-keys, i.e., the types of data for - each series. + series_name: The series name for which to return the data keys, i.e., the + types of data for each series. Returns: - list: The list of data-keys for data in the plotter associated with the given series. If the - series has not been added to the plotter, an empty list is returned. + The list of data keys for data in the plotter associated with the given + series. If the series has not been added to the plotter, an empty list is + returned. """ return list(self._series_data.get(series_name, [])) @@ -181,34 +200,39 @@ def data_for( ) -> Tuple[Optional[Any]]: """Returns data associated with the given series. - The returned tuple contains the data, associated with ``data_keys``, in the same orders as they - are provided. For example, + The returned tuple contains the data, associated with ``data_keys``, in the same + orders as they are provided. For example, + + .. code-block:: python - .. code-example::python plotter.set_series_data("seriesA", x=data.x, y=data.y, yerr=data.yerr) # The following calls are equivalent. - x, y, yerr = plotter.series_data_for("seriesA", ["x", "y", "yerr"]) + x, y, yerr = plotter.data_for("seriesA", ["x", "y", "yerr"]) x, y, yerr = data.x, data.y, data.yerr - :meth:`data_for` is intended to be used by sub-classes of :class:`BasePlotter` when plotting in - the :meth:`_plot_figure` method. + # Retrieving a single data key returns a tuple. Note the comma after ``x``. + x, = plotter.data_for("seriesA", "x") + + :meth:`data_for` is intended to be used by sub-classes of :class:`BasePlotter` + when plotting in the :meth:`_plot_figure` method. Args: series_name: The series name for the given series. - data_keys: List of data-keys for the data to be returned. If a single data-key is given as a - string, it is wrapped in a list. + data_keys: List of data keys for the data to be returned. If a single + data key is given as a string, it is wrapped in a list. Returns: - tuple: A tuple of data associated with the given series, identified by ``data_keys``. If no - data has been set for a data-key, None is returned for the associated tuple entry. + A tuple of data associated with the given series, identified by + ``data_keys``. If no data has been set for a data key, None is returned for + the associated tuple entry. """ - # We may be given a single data-key, but we need a list for the rest of the function. + # We may be given a single data key, but we need a list for the rest of the function. if not isinstance(data_keys, list): data_keys = [data_keys] - # The series doesn't exist in the plotter data, return None for each data-key in the output. + # The series doesn't exist in the plotter data, return None for each data key in the output. if series_name not in self._series_data: return (None,) * len(data_keys) @@ -217,21 +241,22 @@ def data_for( def set_series_data(self, series_name: SeriesName, **data_kwargs): """Sets data for the given series. - Note that if data has already been assigned for the given series and data-key, it will be - overwritten with the new values. ``set_series_data`` will warn if the data-key is unexpected; - i.e., not within those returned by :meth:`expected_series_data_keys`. + Note that if data has already been assigned for the given series and data key, + it will be overwritten with the new values. ``set_series_data`` will warn if the + data key is unexpected; i.e., not within those returned by + :meth:`expected_series_data_keys`. Args: series_name: The name of the given series. - data_kwargs: The data to be added, where the keyword is the data-key. + data_kwargs: The data to be added, where the keyword is the data key. """ - # Warn if the data-keys are not expected. + # Warn if the data keys are not expected. unknown_data_keys = [ data_key for data_key in data_kwargs if data_key not in self.expected_series_data_keys() ] for unknown_data_key in unknown_data_keys: warnings.warn( - f"{self.__class__.__name__} encountered an unknown data-key {unknown_data_key}. It may " + f"{self.__class__.__name__} encountered an unknown data key {unknown_data_key}. It may " "not be used by the plotter class." ) @@ -244,8 +269,8 @@ def clear_series_data(self, series_name: Optional[SeriesName] = None): """Clear series data for this plotter. Args: - series_name: The series name identifying which data should be cleared. If None, all series - data is cleared. Defaults to None. + series_name: The series name identifying which data should be cleared. If + None, all series data is cleared. Defaults to None. """ if series_name is None: self._series_data = {} @@ -255,17 +280,18 @@ def clear_series_data(self, series_name: Optional[SeriesName] = None): def set_supplementary_data(self, **data_kwargs): """Sets supplementary data for the plotter. - Supplementary data differs from series data in that it is not associate with a series name. Fit - reports are examples of supplementary data as they contain fit results from an analysis class, - such as the "goodness" of a curve-fit. + Supplementary data differs from series data in that it is not associate with a + series name. Fit reports are examples of supplementary data as they contain fit + results from an analysis class, such as the "goodness" of a curve fit. - Note that if data has already been assigned for the given data-key, it will be overwritten with - the new values. ``set_supplementary_data`` will warn if the data-key is unexpected; i.e., not - within those returned by :meth:`expected_supplementary_data_keys`. + Note that if data has already been assigned for the given data key, it will be + overwritten with the new values. ``set_supplementary_data`` will warn if the + data key is unexpected; i.e., not within those returned by + :meth:`expected_supplementary_data_keys`. """ - # Warn if any data-keys are not expected. + # Warn if any data keys are not expected. unknown_data_keys = [ data_key for data_key in data_kwargs @@ -273,7 +299,7 @@ def set_supplementary_data(self, **data_kwargs): ] for unknown_data_key in unknown_data_keys: warnings.warn( - f"{self.__class__.__name__} encountered an unknown data-key {unknown_data_key}. It may " + f"{self.__class__.__name__} encountered an unknown data key {unknown_data_key}. It may " "not be used by the plotter class." ) @@ -284,15 +310,15 @@ def clear_supplementary_data(self): self._supplementary_data = {} def data_exists_for(self, series_name: SeriesName, data_keys: Union[str, List[str]]) -> bool: - """Returns whether the given data-keys exist for the given series. + """Returns whether the given data keys exist for the given series. Args: series_name: The name of the given series. - data_keys: The data-keys to be checked. + data_keys: The data keys to be checked. Returns: - bool: True if all data-keys have values assigned for the given series. False if at least one - does not have a value assigned. + True if all data keys have values assigned for the given series. False if at + least one does not have a value assigned. """ if not isinstance(data_keys, list): data_keys = [data_keys] @@ -307,21 +333,24 @@ def data_exists_for(self, series_name: SeriesName, data_keys: Union[str, List[st def _plot_figure(self): """Generates a figure using :attr:`drawer` and :meth:`data`. - Sub-classes must override this function to plot data using the drawer. This function is called by - :meth:`figure` when :attr:`drawer` can be used to draw on the canvas. + Sub-classes must override this function to plot data using the drawer. This + function is called by :meth:`figure` when :attr:`drawer` can be used to draw on + the canvas. """ def figure(self) -> Any: - """Generates and returns a figure for the already provided series and supplementary data. + """Generates and returns a figure for the already provided series and + supplementary data. - :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. Before and after - calling :meth:`_plot_figure`; :func:`_configure_drawer`, :func:`initialize_canvas` and - :func:`format_canvas` are called on the drawer respectively. + :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. + Before and after calling :meth:`_plot_figure`; + :meth:`~BaseDrawer._configure_drawer`, :meth:`~BaseDrawer.initialize_canvas` and + :meth:`~BaseDrawer.format_canvas` are called on the drawer respectively. Returns: - Any: A figure generated by :attr:`drawer`, of the same type as ``drawer.figure``. + A figure generated by :attr:`drawer`, of the same type as ``drawer.figure``. """ - # Initialize drawer, to copy axis, subplots, style, and figure-options across. + # Initialize drawer, to copy axis, subplots, style, and figure options across. self._configure_drawer() # Initialize canvas, which creates subplots, assigns axis labels, etc. @@ -339,20 +368,20 @@ def figure(self) -> Any: @classmethod @abstractmethod def expected_series_data_keys(cls) -> List[str]: - """Returns the expected series data-keys supported by this plotter.""" + """Returns the expected series data keys supported by this plotter.""" @classmethod @abstractmethod def expected_supplementary_data_keys(cls) -> List[str]: - """Returns the expected supplementary data-keys supported by this plotter.""" + """Returns the expected supplementary data keys supported by this plotter.""" @property def options(self) -> Options: """Options for the plotter. - Options for a plotter modify how the class generates a figure. This includes an optional axis - object, being the drawer canvas. Make sure verify whether the option you want to set is in - :attr:`options` or :attr:`figure_options`. + Options for a plotter modify how the class generates a figure. This includes an + optional axis object, being the drawer canvas. Make sure verify whether the + option you want to set is in :attr:`options` or :attr:`figure_options`. """ return self._options @@ -360,9 +389,10 @@ def options(self) -> Options: def figure_options(self) -> Options: """Figure options for the plotter and its drawer. - Figure options differ from normal options (:attr:`options`) in that the plotter passes figure - options on to the drawer when creating a figure (when :meth:`figure` is called). This way - :attr:`drawer` can draw an appropriate figure. An example of a figure option is the x-axis label. + Figure options differ from normal options (:attr:`options`) in that the plotter + passes figure options on to the drawer when creating a figure (when + :meth:`figure` is called). This way :attr:`drawer` can draw an appropriate + figure. An example of a figure option is the x-axis label. """ return self._figure_options @@ -375,8 +405,9 @@ def _default_options(cls) -> Options: subplots (Tuple[int, int]): Number of rows and columns when the experimental result is drawn in the multiple windows. style (PlotStyle): The style definition to use when plotting. - This overwrites figure-option `custom_style` set in :attr:`drawer`. The default is an - empty style object, and such the default :attr:`drawer` plotting style will be used. + This overwrites figure option `custom_style` set in :attr:`drawer`. The + default is an empty style object, and such the default :attr:`drawer` + plotting style will be used. """ return Options( axis=None, @@ -390,31 +421,38 @@ def _default_figure_options(cls) -> Options: Figure Options: xlabel (Union[str, List[str]]): X-axis label string of the output figure. - If there are multiple columns in the canvas, this could be a list of labels. + If there are multiple columns in the canvas, this could be a list of + labels. ylabel (Union[str, List[str]]): Y-axis label string of the output figure. - If there are multiple rows in the canvas, this could be a list of labels. + If there are multiple rows in the canvas, this could be a list of + labels. xlim (Tuple[float, float]): Min and max value of the horizontal axis. - If not provided, it is automatically scaled based on the input data points. + If not provided, it is automatically scaled based on the input data + points. ylim (Tuple[float, float]): Min and max value of the vertical axis. - If not provided, it is automatically scaled based on the input data points. - xval_unit (str): Unit of x values. No scaling prefix is needed here as this is controlled by - ``xval_unit_scale``. + If not provided, it is automatically scaled based on the input data + points. + xval_unit (str): Unit of x values. No scaling prefix is needed here as this + is controlled by ``xval_unit_scale``. yval_unit (str): Unit of y values. See ``xval_unit`` for details. - xval_unit_scale (bool): Whether to add an SI unit prefix to ``xval_unit`` if needed. - For example, when the x values represent time and ``xval_unit="s"``, - ``xval_unit_scale=True`` adds an SI unit prefix to ``"s"`` based on X values of plotted - data. In the output figure, the prefix is automatically selected based on the maximum - value in this axis. If your x values are in [1e-3, 1e-4], they are displayed as [1 ms, 10 - ms]. By default, this option is set to ``True``. If ``False`` is provided, the axis - numbers will be displayed in the scientific notation. - yval_unit_scale (bool): Whether to add an SI unit prefix to ``yval_unit`` if needed. See - ``xval_unit_scale`` for details. - figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. - series_params (Dict[SeriesName, Dict[str, Any]]): A dictionary of plot parameters for each - series. This is keyed on the name for each series. Sub-dictionary is expected to have - following three configurations, "canvas", "color", and "symbol"; "canvas" is the integer - index of axis (when multi-canvas plot is set), "color" is the color of the curve, and - "symbol" is the marker style of the curve for scatter plots. + xval_unit_scale (bool): Whether to add an SI unit prefix to ``xval_unit`` if + needed. For example, when the x values represent time and + ``xval_unit="s"``, ``xval_unit_scale=True`` adds an SI unit prefix to + ``"s"`` based on X values of plotted data. In the output figure, the + prefix is automatically selected based on the maximum value in this + axis. If your x values are in [1e-3, 1e-4], they are displayed as [1 ms, + 10 ms]. By default, this option is set to ``True``. If ``False`` is + provided, the axis numbers will be displayed in the scientific notation. + yval_unit_scale (bool): Whether to add an SI unit prefix to ``yval_unit`` if + needed. See ``xval_unit_scale`` for details. + figure_title (str): Title of the figure. Defaults to None, i.e. nothing is + shown. + series_params (Dict[SeriesName, Dict[str, Any]]): A dictionary of plot + parameters for each series. This is keyed on the name for each series. + Sub-dictionary is expected to have following three configurations, + "canvas", "color", and "symbol"; "canvas" is the integer index of axis + (when multi-canvas plot is set), "color" is the color of the curve, and + "symbol" is the marker Style of the curve for scatter plots. """ return Options( xlabel=None, @@ -453,7 +491,7 @@ def set_figure_options(self, **fields): fields: The fields to update in figure options. """ # Don't check if any option in fields already exists (like with `set_options`), as figure options - # are passed to `.drawer` which may have other figure-options. Any figure-option that isn't set + # are passed to `.drawer` which may have other figure options. Any figure option that isn't set # in `.drawer.figure_options` won't be set anyway. Setting `.drawer.figure_options` only occurs # in `.figure()`, so we can't compare to `.drawer.figure_options` now as `.drawer` may be changed # between now and the call to `.figure()`. @@ -465,14 +503,15 @@ def _configure_drawer(self): The following actions are taken: 1. ``axis``, ``subplots``, and ``style`` are passed to :attr:`drawer`. - 2. ``figure_options`` in :attr:`drawer` are updated based on values set in the plotter - :attr:`figure_options` - - These steps are different as all figure-options could be passed to :attr:`drawer`, if the drawer - already has a figure-option with the same name. ``axis``, ``subplots``, and ``style`` are the - only plotter options (from :attr:`options`) passed to :attr:`drawer` in - :meth:`_configure_drawer`. This is done as these options make more sense as an option for a - plotter, given the interface of :class:`BasePlotter`. + 2. ``figure_options`` in :attr:`drawer` are updated based on values set in + the plotter :attr:`figure_options` + + These steps are different as all figure options could be passed to + :attr:`drawer`, if the drawer already has a figure option with the same name. + ``axis``, ``subplots``, and ``style`` are the only plotter options (from + :attr:`options`) passed to :attr:`drawer` in :meth:`_configure_drawer`. This is + done as these options make more sense as an option for a plotter, given the + interface of :class:`BasePlotter`. """ ## Axis, subplots, and style if self.options.axis: @@ -485,13 +524,13 @@ def _configure_drawer(self): _drawer_figure_options = self.drawer.figure_options.__dict__ _plotter_figure_options = self.figure_options.__dict__ - # If an option exists in drawer.figure_options AND in self.figure_options, set the drawers - # figure-option value to that from the plotter. + # If an option exists in drawer.figure_options AND in self.figure_options, set the drawer's + # figure option value to that from the plotter. for opt_key in _drawer_figure_options: if opt_key in _plotter_figure_options: _drawer_figure_options[opt_key] = _plotter_figure_options[opt_key] - # Use drawer.set_figure_options so figure-options are serialized. + # Use drawer.set_figure_options so figure options are serialized. self.drawer.set_figure_options(**_drawer_figure_options) def config(self) -> Dict: diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 0168f07d82..cf793972ca 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 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 @@ -9,7 +9,7 @@ # 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. -"""Plotter for curve-fits, specifically from :class:`CurveAnalysis`.""" +"""Plotter for curve fits, specifically from :class:`CurveAnalysis`.""" from typing import List from uncertainties import UFloat @@ -23,17 +23,18 @@ class CurvePlotter(BasePlotter): """A plotter class to plot results from :class:`CurveAnalysis`. - :class:`CurvePlotter` plots results from curve-fits, which includes: - Raw results as a scatter plot. - Processed results with standard-deviations/confidence intervals. - Interpolated fit-results from the curve analysis. - Confidence interval for the fit-results. - A report on the performance of the fit. + ``CurvePlotter`` plots results from curve fits, which includes + + - Raw results as a scatter plot. + - Processed results with standard deviations/confidence intervals. + - Interpolated fit results from the curve analysis. + - Confidence interval for the fit results. + - A report on the performance of the fit. """ @classmethod def expected_series_data_keys(cls) -> List[str]: - """Returns the expected series data-keys supported by this plotter. + """Returns the expected series data keys supported by this plotter. Data Keys: x: X-values for raw results. @@ -41,10 +42,10 @@ def expected_series_data_keys(cls) -> List[str]: x_formatted: X-values for processed results. y_formatted: Y-values for processed results. Goes with ``x_formatted``. y_formatted_err: Error in ``y_formatted``, to be plotted as error-bars. - x_interp: Interpolated X-values for a curve-fit. + x_interp: Interpolated X-values for a curve fit. y_interp: Y-values corresponding to the fit for ``y_interp`` X-values. - y_interp_err: The standard-deviations of the fit for each X-value in ``y_interp``. - This data-key relates to the option ``plot_sigma``. + y_interp_err: The standard deviations of the fit for each X-value in + ``y_interp``. This data key relates to the option ``plot_sigma``. """ return [ "x", @@ -59,20 +60,21 @@ def expected_series_data_keys(cls) -> List[str]: @classmethod def expected_supplementary_data_keys(cls) -> List[str]: - """Returns the expected figures data-keys supported by this plotter. + """Returns the expected figures data keys supported by this plotter. This plotter generates a single text box, i.e. fit report, by digesting the - provided supplementary data. The style and position of the report is controlled by - ``textbox_rel_pos`` and ``textbox_text_size`` style parameters in :class:`PlotStyle`. + provided supplementary data. The style and position of the report is controlled + by ``textbox_rel_pos`` and ``textbox_text_size`` style parameters in + :class:`PlotStyle`. Data Keys: - primary_results: A list of :class:`.AnalysisResultData` object to be shown in - the fit report window. Typically, these are fit parameter values or + primary_results: A list of :class:`.AnalysisResultData` objects to be shown + in the fit report window. Typically, these are fit parameter values or secondary quantities computed from multiple fit parameters. - fit_red_chi: The best reduced-chi squared value of the fit curves. If - the fit consists of multiple sub-fits, this will be a dictionary - keyed on the analysis name. Otherwise, this is a single float value - of a particular analysis. + fit_red_chi: The best reduced-chi squared value of the fit curves. If the + fit consists of multiple sub-fits, this will be a dictionary keyed on + the analysis name. Otherwise, this is a single float value of a + particular analysis. """ return [ "primary_results", @@ -84,11 +86,11 @@ def _default_options(cls) -> Options: """Return curve-plotter specific default plotter options. Options: - plot_sigma (List[Tuple[float, float]]): A list of two number tuples - showing the configuration to write confidence intervals for the fit curve. - The first argument is the relative sigma (n_sigma), and the second argument is - the transparency of the interval plot in ``[0, 1]``. - Multiple n_sigma intervals can be drawn for the same curve. + plot_sigma (List[Tuple[float, float]]): A list of two number tuples showing + the configuration to write confidence intervals for the fit curve. The + first argument is the relative sigma (n_sigma), and the second argument + is the transparency of the interval plot in ``[0, 1]``. Multiple n_sigma + intervals can be drawn for the same curve. """ options = super()._default_options() @@ -97,11 +99,13 @@ def _default_options(cls) -> Options: @classmethod def _default_figure_options(cls) -> Options: - """Return curve-plotter specific default figure options. + r"""Return curve-plotter specific default figure options. Figure Options: - report_red_chi2_label (str): The label for the reduced-chi squared entry of the fit - report. Defaults to "reduced-$\\chi^2$`. + report_red_chi2_label (str): The label for the reduced-chi squared entry of + the fit report. Defaults to the Python string literal + ``"reduced-$\\chi^2$"``, corresponding to the formatted string + reduced-:math:`\chi^2`. """ fig_opts = super()._default_figure_options() fig_opts.report_red_chi2_label = "reduced-$\\chi^2$" @@ -109,7 +113,7 @@ def _default_figure_options(cls) -> Options: return fig_opts def _plot_figure(self): - """Plots a curve-fit figure.""" + """Plots a curve fit figure.""" for ser in self.series: # Scatter plot with error-bars plotted_formatted_data = False @@ -167,9 +171,10 @@ def _plot_figure(self): def _write_report(self) -> str: """Write fit report with supplementary_data. - Subclass can override this method to customize fit report. By default, this writes important fit - parameters and chi-squared value of the fit in the fit report. The ``report_red_chi2_label`` - figure-option controls the label for the chi-squared entries in the report. + Subclass can override this method to customize fit report. By default, this + writes important fit parameters and chi-squared value of the fit in the fit + report. The ``report_red_chi2_label`` figure option controls the label for the + chi-squared entries in the report. Returns: Fit report. diff --git a/qiskit_experiments/visualization/plotters/iq_plotter.py b/qiskit_experiments/visualization/plotters/iq_plotter.py index ad344b1c2b..9058f18809 100644 --- a/qiskit_experiments/visualization/plotters/iq_plotter.py +++ b/qiskit_experiments/visualization/plotters/iq_plotter.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 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 @@ -26,13 +26,14 @@ class IQPlotter(BasePlotter): """A plotter class to plot IQ data. - :class:`IQPlotter` plots results from experiments which used measurement-level 1, i.e. IQ data. This - class also supports plotting predictions from a discriminator (subclass of - :class:`BaseDiscriminator`), which is used to classify IQ results into labels. The discriminator - labels are matched with the series-names to generate an image of the predictions. Points that are - misclassified by the discriminator are flagged in the figure (see ``flag_misclassified`` - :attr:`option`). A canonical application of :class:`IQPlotter` is for classification of - single-qubit readout for different prepared states. + :class:`IQPlotter` plots results from experiments which used measurement-level 1, + i.e. IQ data. This class also supports plotting predictions from a discriminator + (subclass of :class:`BaseDiscriminator`), which is used to classify IQ results into + labels. The discriminator labels are matched with the series names to generate an + image of the predictions. Points that are misclassified by the discriminator are + flagged in the figure (see ``flag_misclassified`` :attr:`option`). A canonical + application of :class:`IQPlotter` is for classification of single-qubit readout for + different prepared states. Example: .. code-block:: python @@ -40,8 +41,9 @@ class also supports plotting predictions from a discriminator (subclass of # Create plotter plotter = IQPlotter(MplDrawer()) - # Iterate over results, one per prepared state. Add points and centroid to plotter and set - # label for prepared states as |n> where n is the prepared-state number. + # Iterate over results, one per prepared state. Add points and centroid to + # plotter and set label for prepared states as |n> where n is the + # prepared-state number. series_params = {} for res in results: # Get IQ points from result memory. @@ -63,7 +65,8 @@ class also supports plotting predictions from a discriminator (subclass of ... # Optional: Add trained discriminator. discrim = MyIQDiscriminator() - discrim.fit(train_data,train_labels) # Labels are the same as series-names. + # Discriminator labels are the same as series names. + discrim.fit(train_data, train_labels) plotter.set_supplementary_data(discriminator=discrim) ... # Plot figure. @@ -72,7 +75,7 @@ class also supports plotting predictions from a discriminator (subclass of @classmethod def expected_series_data_keys(cls) -> List[str]: - """Returns the expected series data-keys supported by this plotter. + """Returns the expected series data keys supported by this plotter. Data Keys: points: Single-shot IQ data. @@ -85,16 +88,17 @@ def expected_series_data_keys(cls) -> List[str]: @classmethod def expected_supplementary_data_keys(cls) -> List[str]: - """Returns the expected figures data-keys supported by this plotter. + """Returns the expected figures data keys supported by this plotter. Data Keys: - discriminator: A trained discriminator that classifies IQ points. If provided, the - predictions of the discriminator will be sampled to generate a background image, - indicating the regions for each predicted outcome. The predictions are assumed to be - series names (:type:`Union[str, int, float]`). The generated image allows viewers to see - how well the discriminator classifies the provided series data. Must be a subclass of - :class:`BaseDiscriminator`. See :attr:`options` for ways to control the generation of the - discriminator prediction image. + discriminator: A trained discriminator that classifies IQ points. If + provided, the predictions of the discriminator will be sampled to + generate a background image, indicating the regions for each predicted + outcome. The predictions are assumed to be series names (``Union[str, + int, float]``). The generated image allows viewers to see how well the + discriminator classifies the provided series data. Must be a subclass of + :class:`BaseDiscriminator`. See :attr:`options` for ways to control the + generation of the discriminator prediction image. fidelity: A float representing the fidelity of the discrimination. """ return [ @@ -106,9 +110,9 @@ def _compute_extent(self) -> Optional[ExtentTuple]: """Computes the extent tuple of the data being plotted. Returns: - Optional[tuple]: The tuple ``(x_min, x_max, y_min, y_max)``, defining a rectangle containing - all the data for this plotter. If the plotter contains no data, ``None`` is returned - instead. + The tuple ``(x_min, x_max, y_min, y_max)``, defining a rectangle containing + all the data for this plotter. If the plotter contains no data, ``None`` is + returned instead. """ ext_calc = DataExtentCalculator( multiplier=self.options.discriminator_multiplier, @@ -141,9 +145,9 @@ def _compute_discriminator_image( """Compute the array/image sampled from the discriminator predictions. Returns: - tuple: The tuple ``(img, extent)`` where ``img`` is an optional 2D NumPy array of - predictions and ``extent`` is a tuple of the extent ``(x_min, x_max, y_min, y_max)`` of - the image. + The tuple ``(img, extent)`` where ``img`` is an optional 2D NumPy array of + predictions and ``extent`` is a tuple of the extent ``(x_min, x_max, y_min, + y_max)`` of the image. """ # If the discriminator is not provided, cannot compute the image. if "discriminator" not in self.supplementary_data: @@ -191,32 +195,35 @@ def _default_options(cls) -> Options: """Return iq-plotter specific default plotter options. Options: - plot_discriminator (bool): Whether to plot an image showing the predictions of the - ``discriminator`` entry in :attr:`supplementary_data``. If True, the "discriminator" - supplementary data entry must be set. - discriminator_multiplier (float): The multiplier to use when computing the extent of - the discriminator plot. The range of the series data is taken as the base value and - multiplied by ``discriminator_extent_multiplier`` to compute the extent of the - discriminator predictions. Defaults to 1.1. - discriminator_aspect_ratio (float): The aspect ratio of the extent of the discriminator - predictions, being ``width/height``. Defaults to ``1`` for a square region. - discriminator_max_resolution (int): The number of pixels to use for the largest edge of the - discriminator extent, used when sampling the discriminator to create the prediction - image. Defaults to 1024. - discriminator_alpha (float): The transparency of the discriminator prediction image. Defaults - to 0.2 (i.e., 20%). - discriminator_extent (Optional[ExtentTuple]): An optional tuple defining the extent of the - image created by sampling from the discriminator. If ``None``, the extent tuple is - computed using ``discriminator_multiplier``, ``discriminator_aspect_ratio``, and the + plot_discriminator (bool): Whether to plot an image showing the predictions + of the ``discriminator`` entry in :attr:`supplementary_data``. If True, + the "discriminator" supplementary data entry must be set. + discriminator_multiplier (float): The multiplier to use when computing the + extent of the discriminator plot. The range of the series data is taken + as the base value and multiplied by ``discriminator_extent_multiplier`` + to compute the extent of the discriminator predictions. Defaults to 1.1. + discriminator_aspect_ratio (float): The aspect ratio of the extent of the + discriminator predictions, being ``width/height``. Defaults to ``1`` for + a square region. + discriminator_max_resolution (int): The number of pixels to use for the + largest edge of the discriminator extent, used when sampling the + discriminator to create the prediction image. Defaults to 1024. + discriminator_alpha (float): The transparency of the discriminator + prediction image. Defaults to 0.2 (i.e., 20%). + discriminator_extent (Optional[ExtentTuple]): An optional tuple defining the + extent of the image created by sampling from the discriminator. If + ``None``, the extent tuple is computed using + ``discriminator_multiplier``, ``discriminator_aspect_ratio``, and the series-data ``points`` and ``centroid``. Defaults to ``None``. - flag_misclassified (bool): Whether to mark misclassified IQ values from all ``points`` series - data, based on whether their series-name is not the same as the prediction from the - discriminator provided as supplementary data. If ``discriminator`` is not provided, - ``flag_misclassified`` has no effect. Defaults to True. - misclassified_symbol (str): Symbol for misclassified points, as a drawer-compatible string. - Defaults to "x". - misclassified_color (str | tuple): Color for misclassified points, as a drawer-compatible - string or RGB tuple. Defaults to "r". + flag_misclassified (bool): Whether to mark misclassified IQ values from all + ``points`` series data, based on whether their series name is not the + same as the prediction from the discriminator provided as supplementary + data. If ``discriminator`` is not provided, ``flag_misclassified`` has + no effect. Defaults to True. + misclassified_symbol (str): Symbol for misclassified points, as a + drawer-compatible string. Defaults to "x". + misclassified_color (str | tuple): Color for misclassified points, as a + drawer-compatible string or RGB tuple. Defaults to "r". """ options = super()._default_options() @@ -248,14 +255,15 @@ def _misclassified_points(self, series_name: str, points: np.ndarray) -> Optiona """Returns a list of IQ coordinates for points that are misclassified by the discriminator. Args: - series_name: The series-name to use as the expected discriminator label. If the discriminator - returns a prediction that doesn't equal ``series_name``, it is marked as misclassified. + series_name: The series name to use as the expected discriminator label. If + the discriminator returns a prediction that doesn't equal + ``series_name``, it is marked as misclassified. points: The list of points to check for misclassification. Returns: - Optional[np.ndarray]: A NumPy array of IQ points, being those that were misclassified by the - discriminator. If the discriminator isn't set and trained, then `None` is returned. The array - may be empty. + A NumPy array of IQ points, being those that were misclassified by the + discriminator. If the discriminator isn't set and trained, then `None` is + returned. The array may be empty. """ # Check if we have a discriminator, and if it is trained. If not, return None. if "discriminator" not in self.supplementary_data: @@ -344,8 +352,8 @@ def _plot_figure(self): def _write_report(self) -> str: """Write fidelity report with supplementary_data. - Subclass can override this method to customize fidelity report. - By default, this writes the fidelity of the discriminator in the fidelity report. + Subclass can override this method to customize fidelity report. By default, this + writes the fidelity of the discriminator in the fidelity report. Returns: Fidelity report. diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index e9cfea4502..ab11e5815e 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2022, 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 @@ -17,14 +17,16 @@ class PlotStyle(dict): """A stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. - This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`. The default style for - Qiskit Experiments is defined in :meth:`default_style`. Style parameters are stored as dictionary - entries, grouped by graphics or figure component. For example, style parameters relating to textboxes - have the prefix ``textbox_``. For default style parameter names and their values, see the + This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`. The + default style for Qiskit Experiments is defined in :meth:`default_style`. Style + parameters are stored as dictionary entries, grouped by graphics or figure + component. For example, style parameters relating to textboxes have the prefix + ``textbox_``. For default style parameter names and their values, see the :meth:`default_style` method. - Example: + .. rubric:: Example .. code-block:: python + # Create custom style custom_style = PlotStyle( { @@ -34,33 +36,41 @@ class PlotStyle(dict): } ) - # Create full style, using PEP448 to combine with default style. + # Create full style instance by combining with default style. full_style = PlotStyle.merge(PlotStyle.default_style(), custom_style) - # Query style parameters - full_style["legend_loc"] # Returns "upper right" - full_style["axis_label_size"] # Returns the value provided in ``PlotStyle.default_style()`` + ## Query style parameters + # Returns "upper right" + full_style["legend_loc"] + # Returns the value provided in ``PlotStyle.default_style()`` + full_style["axis_label_size"] """ @classmethod def default_style(cls) -> "PlotStyle": + # Ignore pylint warnings as `Style Parameters` doesn't contain method parameters. + # pylint: disable=differing-param-doc,differing-type-doc """The default style across Qiskit Experiments. + The following is a description of the default style parameters are what they are + used for. + Style Parameters: - figsize (Tuple[int,int]): The size of the figure ``(width, height)``, in inches. - legend_loc (Optional[str]): The location of the legend in axis coordinates. If None, location - is automatically determined by the drawer. + figsize (Tuple[int,int]): The size of the figure ``(width, height)``, in + inches. + legend_loc (Optional[str]): The location of the legend in axis coordinates. + If None, location is automatically determined by the drawer. tick_label_size (int): The font size for tick labels. axis_label_size (int): The font size for axis labels. - symbol_size (float): The size of symbols for points/markers, proportional to the area of the - drawn graphic. + symbol_size (float): The size of symbols for points/markers, proportional to + the area of the drawn graphic. errorbar_capsize (float): The size of end-caps for error-bars. - textbox_rel_pos (Tuple[float,float]): The relative position ``(horizontal, vertical)`` of - textboxes, as a percentage of the canvas dimensions. + textbox_rel_pos (Tuple[float,float]): The relative position ``(horizontal, + vertical)`` of textboxes, as a percentage of the canvas dimensions. textbox_text_size (int): The font size for textboxes. Returns: - PlotStyle: The default plot style used by Qiskit Experiments. + The default plot style used by Qiskit Experiments. """ style = { # size of figure (width, height) @@ -86,14 +96,14 @@ def default_style(cls) -> "PlotStyle": def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": """Merge ``style2`` into ``style1`` as a new PlotStyle instance. - This method merges an additional style ``style2`` into a base instance ``style1``, returning the - merged style instance instead of modifying the inputs. + This method merges an additional style ``style2`` into a base instance + ``style1``, returning the merged style instance instead of modifying the inputs. Args: style1: Base PlotStyle instance. style2: Additional PlotStyle instance. Returns: - PlotStyle: merged style instance. + Merged style instance. """ return PlotStyle({**style1, **style2}) diff --git a/qiskit_experiments/visualization/utils.py b/qiskit_experiments/visualization/utils.py index f137927a14..ba0b5c4267 100644 --- a/qiskit_experiments/visualization/utils.py +++ b/qiskit_experiments/visualization/utils.py @@ -21,31 +21,35 @@ class DataExtentCalculator: """A class to handle computing the extent of two-dimensional data. - This class makes computing the extent of two-dimensional data easier, especially for adding images - to plots where the extent of the axes (i.e., axis limits) are not known prior to figure generation. - An instance of this class can be used to compute an extent tuple which is larger than the registered - data, and thus has a margin that can exceed the axis-limits or be used as the axis limits without - cropping additional data-points from a figure. An extent tuple ``(x_min, x_max, y_min, y_max)`` - contains the minimum and maximum values for the X and Y dimensions. - - Data is registered with a :class:`DataExtentCalculator` so that the computed extent covers all - values in the data array. The extent tuple is computed as follows: - 1. The maximum and minimum values for input data is stored whenever new data arrays are - registered. This is the data-extent: the minimum-area bounding box that contains all - registered data. - 2. The data-extent is enlarged/shrunk by scaling its width and height by :attr:`multiplier`. - 3. If :attr:`aspect_ratio` is not ``None``, the scaled extent tuple is extended in one of the - dimensions so that the output extent tuple is larger and the target aspect ratio is achieved. + This class makes computing the extent of two-dimensional data easier, especially for + adding images to plots where the extent of the axes (i.e., axis limits) are not + known prior to figure generation. An instance of this class can be used to compute + an extent tuple which is larger than the registered data, and thus has a margin that + can exceed the axis-limits or be used as the axis limits without cropping additional + data-points from a figure. An extent tuple ``(x_min, x_max, y_min, y_max)`` contains + the minimum and maximum values for the X and Y dimensions. + + Data is registered with a :class:`DataExtentCalculator` so that the computed extent + covers all values in the data array. The extent tuple is computed as follows: + 1. The maximum and minimum values for input data is stored whenever new data + arrays are registered. This is the data-extent: the minimum-area bounding box + that contains all registered data. + 2. The data-extent is enlarged/shrunk by scaling its width and height by + :attr:`multiplier`. + 3. If :attr:`aspect_ratio` is not ``None``, the scaled extent tuple is extended + in one of the dimensions so that the output extent tuple is larger and the + target aspect ratio is achieved. """ def __init__(self, multiplier: float = 1.0, aspect_ratio: Optional[float] = 1.0): """Create an extent calculator. Args: - multiplier: The factor by which to scale the extent of the data when computing the output - extent tuple. Defaults to 1.0, - aspect_ratio: An optional target aspect ratio for the output extent tuple. If None, the - extent tuple is not extended to achieve a given aspect ratio. Defaults to None. + multiplier: The factor by which to scale the extent of the data when + computing the output extent tuple. Defaults to 1.0. + aspect_ratio: An optional target aspect ratio for the output extent tuple. + If None, the extent tuple is not extended to achieve a given aspect + ratio. Defaults to None. """ self._multiplier = multiplier self._aspect_ratio = aspect_ratio @@ -56,7 +60,7 @@ def multiplier(self) -> float: """The multiplier by which to scale the data-extent. Returns: - float: The multiplier for the computed extent. + The multiplier for the computed extent. """ return self._multiplier @@ -64,11 +68,12 @@ def multiplier(self) -> float: def aspect_ratio(self) -> Optional[float]: """The target aspect ratio. - If None, the :class:`DataExtentCalculator` instance will not modify the computed extent tuple to - achieve a given aspect ratio; instead only scale by :attr:`multiplier`. + If None, the :class:`DataExtentCalculator` instance will not modify the computed + extent tuple to achieve a given aspect ratio; instead only scale by + :attr:`multiplier`. Returns: - Optional[float]: The target aspect ratio of the computed extent tuple. + The target aspect ratio of the computed extent tuple. """ return self._aspect_ratio @@ -76,19 +81,23 @@ def register_data(self, data: Union[List, np.ndarray], dim: Optional[int] = None r"""Register data to modify the resulting extent tuple. Args: - data: Array or list of data values to use when calculating the extent tuple. If a list is - given, it is converted into an array using :meth:`numpy.asarray`. If the array has the - shape ``(m, 2)``, then there are ``m`` values in two dimensions (being the second - dimension of ``data``). If the array has the shape ``(m,)`` or ``(m, 1)``, then ``dim`` - must be set to the index of the dimension for which this data is associated. - dim: Optional dimension index if a one-dimensional array is provided (i.e., `X=0` and `Y=1`). - If None, then ``data`` must have the shape ``(m, 2)``. Defaults to None. + data: Array or list of data values to use when calculating the extent tuple. + If a list is given, it is converted into an array using + :meth:`numpy.asarray`. If the array has the shape ``(m, 2)``, then there + are ``m`` values in two dimensions (being the second dimension of + ``data``). If the array has the shape ``(m,)`` or ``(m, 1)``, then + ``dim`` must be set to the index of the dimension for which this data is + associated. + dim: Optional dimension index if a one-dimensional array is provided (i.e., + `X=0` and `Y=1`). If None, then ``data`` must have the shape ``(m, 2)``. + Defaults to None. Raises: QiskitError: if the data is not two-dimensional and ``dim`` is not set. - QiskitError: if the data does not contain one-dimensional values when ``dim`` is set. + QiskitError: if the data does not contain one-dimensional values when + ``dim`` is set. QiskitError: if ``dim`` is not an index for two-dimensions: i.e., - :math:`0\leq{}\text{dim}<2`. + :math:`0\leq{}\text{dim}<2`. """ data = np.asarray(data) if dim is None and (len(data.shape) != 2 or (len(data.shape) == 2 and data.shape[1] != 2)): @@ -130,8 +139,8 @@ def _range(cls, extent: np.ndarray) -> np.ndarray: extent: The extent array for the range. Returns: - np.ndarray: the array ``[x_range, y_range]`` where ``x_range`` and ``y_range`` are the ranges - for their respective dimensions. + The array ``[x_range, y_range]`` where ``x_range`` and ``y_range`` are the + ranges for their respective dimensions. """ return np.diff( extent, @@ -146,8 +155,8 @@ def _midpoint(cls, extent: np.ndarray) -> np.ndarray: extent: The extent array for the midpoint. Returns: - np.ndarray: the array ``[x, y]`` where ``x`` and ``y`` are the midpoints for their respective - dimensions. + The array ``[x, y]`` where ``x`` and ``y`` are the midpoints for their + respective dimensions. """ return np.mean( extent, @@ -165,8 +174,8 @@ def _extent_from_range_midpoint( midpoint: The midpoint of the extent. Returns: - np.ndarray: an extent array with range and midpoint corresponding to ``extent_range`` and - ``midpoint``. + An extent array with range and midpoint corresponding to ``extent_range`` + and ``midpoint``. """ radii = extent_range.flatten() / 2 new_extent = np.zeros((2, 2)) @@ -180,20 +189,23 @@ def _extent_with_aspect_ratio( ) -> np.ndarray: """Expand ``extent`` to have an aspect ratio defined by ``target_aspect_ratio``. - This method extends ``extent`` along one of the two dimensions so that its aspect ratio is equal - to ``target_aspect_ratio``. This is done by computing the ratio of the actual and target aspect - ratios, computing the dimension along which to extend ``extent``, and recomputing the extent - array based on scaled ranges to achieve the target aspect ratio. If the target ratio is ``None``, - nothing is done. If extended, the region defined by the output array is always larger than the - input array. + This method extends ``extent`` along one of the two dimensions so that its + aspect ratio is equal to ``target_aspect_ratio``. This is done by computing the + ratio of the actual and target aspect ratios, computing the dimension along + which to extend ``extent``, and recomputing the extent array based on scaled + ranges to achieve the target aspect ratio. If the target ratio is ``None``, + nothing is done. If extended, the region defined by the output array is always + larger than the input array. Args: extent: The extent array to expand. - target_aspect_ratio: Optional target aspect ratio, being :math:`\text{width}/\text{height}` - for the extent array. If None, ``extent`` is not extended. Defaults to None. + target_aspect_ratio: Optional target aspect ratio, being + :math:`\text{width}/\text{height}` for the extent array. If None, + ``extent`` is not extended. Defaults to None. Returns: - np.ndarray: ``extent``, extended to have an aspect ratio defined by ``target_aspect_ratio``. + ``extent`` extended to have an aspect ratio defined by + ``target_aspect_ratio``. """ if target_aspect_ratio is None: return extent @@ -225,12 +237,12 @@ def extent(self) -> ExtentTuple: """An extent array for the registered data, multiplier, and aspect ratio. Raises: - QiskitError: if the resulting extent tuple is not finite. This can occur if no data was - registered before calling :meth:`extent`. + QiskitError: if the resulting extent tuple is not finite. This can occur if + no data was registered before calling :meth:`extent`. Returns: - tuple: the extent tuple for the registered data, scaled by ``multiplier``, and then extended - to achieve the set aspect ratio. + The extent tuple for the registered data, scaled by ``multiplier``, and then + extended to achieve the set aspect ratio. """ if not np.all(np.isfinite(self._data_extent)): is_infinite = np.argwhere(np.invert(np.isfinite(self._data_extent))) diff --git a/releasenotes/notes/0.4/randomized_benchmarking-de55fda43765c34c.yaml b/releasenotes/notes/0.4/randomized_benchmarking-de55fda43765c34c.yaml index 2942065e4b..06295a358e 100644 --- a/releasenotes/notes/0.4/randomized_benchmarking-de55fda43765c34c.yaml +++ b/releasenotes/notes/0.4/randomized_benchmarking-de55fda43765c34c.yaml @@ -1,4 +1,11 @@ --- +features: + - | + A new experiment class :class:`qiskit_experiments.library.MirrorRB` is + introduced. This class implements mirror randomized benchmarking (RB), a version + of RB that uses mirror circuits. It is more scalable than other RB protocols and + can consequently be used to detect crosstalk errors. + fixes: - | Initial guess function for the randomized benchmarking analysis :func:`.rb_decay` has been diff --git a/releasenotes/notes/fix-992-1d3ab6862e7578ac.yaml b/releasenotes/notes/fix-992-1d3ab6862e7578ac.yaml new file mode 100644 index 0000000000..84722589c8 --- /dev/null +++ b/releasenotes/notes/fix-992-1d3ab6862e7578ac.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + Fixes bug in the :class:`~.LocalReadoutError` experiment where analysis + would fail when run on an ideal simulator with no readout error. + + See Issue #992 `_ + for additional details. diff --git a/releasenotes/notes/py311-49f08e1e0350c6b7.yaml b/releasenotes/notes/py311-49f08e1e0350c6b7.yaml new file mode 100644 index 0000000000..3beda0875d --- /dev/null +++ b/releasenotes/notes/py311-49f08e1e0350c6b7.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + qiskit-experiments has been marked as compatible with Python 3.11 in the + package metadata. qiskit-experiments currently tests against Python 3.7, + 3.8, 3.9, 3.10, and 3.11. diff --git a/releasenotes/notes/rb_using_transpiled_cliffords-cd1376000a2379c4.yaml b/releasenotes/notes/rb_using_transpiled_cliffords-cd1376000a2379c4.yaml new file mode 100644 index 0000000000..3f7df2e706 --- /dev/null +++ b/releasenotes/notes/rb_using_transpiled_cliffords-cd1376000a2379c4.yaml @@ -0,0 +1,8 @@ +--- +other: + - | + Improved the performance of circuit generation in 1Q/2Q RB experiments (about 10x speedup). + That is mainly achieved by the following two updates in their implementation: + - Custom transpilation of circuits (mapping circuits to physical qubits without using transpile), + - Integer-based Clifford operations (especially sparse lookup table with triplet decomposition + for 2Q Clifford circuits). diff --git a/releasenotes/notes/readout-error-c95b99ae5a6ba7ac.yaml b/releasenotes/notes/readout-error-c95b99ae5a6ba7ac.yaml new file mode 100644 index 0000000000..be670b3bab --- /dev/null +++ b/releasenotes/notes/readout-error-c95b99ae5a6ba7ac.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Adds a ``backend`` init kwarg to the :class:`.LocalReadoutError` and + :class:`.CorrelatedReadoutError` experiments, and makes the + ``physical_qubits`` kwarg optional. If a backend is supplied without + specifying phyiscal qubits, the experiment will be initialized on all + qubits for the backend. +deprecations: + - | + The ``qubits`` kwarg of the following experiments has been deprecated and + renamed to ``physical_qubits``: :class:`.LocalReadoutError`, + :class:`.CorrelatedReadoutError`. diff --git a/releasenotes/notes/separate-jobs-686711fba530820d.yaml b/releasenotes/notes/separate-jobs-686711fba530820d.yaml new file mode 100644 index 0000000000..9ac10175b9 --- /dev/null +++ b/releasenotes/notes/separate-jobs-686711fba530820d.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + A new experiment option for batch experiments, called `separate_jobs`. If set to `True`, then circuits of different sub-experiments are routed to different jobs. Default value is `False`. diff --git a/releasenotes/notes/tomography-b091ce13d6983bc1.yaml b/releasenotes/notes/tomography-b091ce13d6983bc1.yaml new file mode 100644 index 0000000000..ecda89cfaf --- /dev/null +++ b/releasenotes/notes/tomography-b091ce13d6983bc1.yaml @@ -0,0 +1,25 @@ +--- +features: + - | + Adds ``backend``, ``analysis``, and ``target`` init kwargs to the + :class:`~.StateTomography` and :class:`~.ProcessTomography` experiments. + These allow specifying intended backend, a custom analysis class, or a + custom target for fidelity calculations, when initializing the experiments. +upgrade: + - | + Renames the ``qubits``, ``measurement_qubits``, and ``preparation_qubits`` + init kwargs of :class:`~.StateTomography`, + :class:`~.ProcessTomography`, and :class:`~.TomographyExperiment` to + ``physical_qubits``, ``measurement_indices`` and ``preparation_indices`` + respectively. This is to make the intended use of these kwargs more clear + as the measurement and preparation args refer to the index of circuit + qubits in the physical qubits list, not the physical qubit values + themselves. +deprecations: + - | + Renames the ``qubits``, ``measurement_qubits``, and ``preparation_qubits`` + init kwargs of :class:`~.StateTomography`, + :class:`~.ProcessTomography`, and :class:`~.TomographyExperiment` have + been deprecated. They have been replaced with kwargs ``physical_qubits``, + ``measurement_indices`` and ``preparation_indices`` respectively. The + renamed kwargs have the same functionality as the deprecated kwargs. diff --git a/releasenotes/notes/update-number-to-2q-clifford-mapping-c28f1f29b0205d57.yaml b/releasenotes/notes/update-number-to-2q-clifford-mapping-c28f1f29b0205d57.yaml new file mode 100644 index 0000000000..50cfc23470 --- /dev/null +++ b/releasenotes/notes/update-number-to-2q-clifford-mapping-c28f1f29b0205d57.yaml @@ -0,0 +1,23 @@ +--- +fixes: + - | + Fix a bug where :meth:`.CliffordUtils.random_cliffords` always returned a single Clifford + ignoring the ``size`` arguments. Now it returns a list of Cliffords with ``size``. + - | + Fix a bug where both :meth:`.CliffordUtils.random_cliffords` and + :meth:`.CliffordUtils.random_clifford_circuits` with integer ``seed`` + returned a list of the same circuits (sampled with a common seed). + Now it returns a list of circuits sampled with different seeds. +deprecations: + - | + Two helper methods of :meth:`.CliffordUtils.random_cliffords` and + :meth:`.CliffordUtils.random_clifford_circuits` have been deprecated. +other: + - | + :meth:`.CliffordUtils.clifford_2_qubit` (and :meth:`.CliffordUtils.clifford_2_qubit_circuit`) + changed its mapping between integers and 2Q Cliffords. + That means 2Q RB experiments may sample different set of circuits from before + even if exactly the same arguments are used for their construction. + - | + Removed unnecessary ``Barrier`` instructions in front of circuits generated by + :class:`.StandardRB` and :class:`.InterleavedRB`. diff --git a/requirements-dev.txt b/requirements-dev.txt index 38093d85f3..5f0a767362 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,7 +5,7 @@ pylint==2.7.1 jinja2==3.0.3 Sphinx>=1.8.3 qiskit-sphinx-theme>=1.6 -sphinx-autodoc-typehints +sphinx-autodoc-typehints<=1.20.2 pygments>=2.4 reno>=3.4.0 sphinx-panels @@ -18,4 +18,4 @@ cvxpy>=1.1.15 pylatexenc # Pin `importlib-metadata` because of a bug relating to version 5.0.0. See #931 for more. importlib-metadata==4.13.0;python_version<'3.8' -scikit-learn \ No newline at end of file +scikit-learn diff --git a/setup.py b/setup.py index 7173d64154..ef9e8465d2 100755 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Scientific/Engineering", ], keywords="qiskit sdk quantum", diff --git a/test/base.py b/test/base.py index 41915c2b8d..f8903264e1 100644 --- a/test/base.py +++ b/test/base.py @@ -19,6 +19,20 @@ import warnings from typing import Any, Callable, Optional +# Temporary work around for https://github.com/Qiskit/qiskit-terra/issues/9291 +# The class decorator / method wrapper in qiskit does not handle +# __init_subclass__ properly. Python 3.11.1 added an __init_subclass__ to +# TestCase. It's not that important so as a temporary hack we just drop it. +from unittest import TestCase + +if "__init_subclass__" in TestCase.__dict__: + del TestCase.__init_subclass__ +if not hasattr(TestCase, "_test_cleanups"): + TestCase._test_cleanups = [] +if not hasattr(TestCase, "_classSetupFailed"): + TestCase._classSetupFailed = False +# pylint: disable=wrong-import-position + import numpy as np import uncertainties from lmfit import Model diff --git a/test/calibration/test_calibration_utils.py b/test/calibration/test_calibration_utils.py index a9e7ba1b1f..b7f5d3e22f 100644 --- a/test/calibration/test_calibration_utils.py +++ b/test/calibration/test_calibration_utils.py @@ -13,7 +13,7 @@ """Class to test utility functions for calibrations.""" from test.base import QiskitExperimentsTestCase -import retworkx as rx +import rustworkx as rx from qiskit.circuit import Parameter import qiskit.pulse as pulse diff --git a/test/framework/test_composite.py b/test/framework/test_composite.py index e940e75cb4..ae5750ee3c 100644 --- a/test/framework/test_composite.py +++ b/test/framework/test_composite.py @@ -26,6 +26,7 @@ from qiskit_ibm_experiment import IBMExperimentService +from qiskit_experiments.exceptions import QiskitError from qiskit_experiments.test.utils import FakeJob from qiskit_experiments.test.fake_backend import FakeBackend from qiskit_experiments.framework import ( @@ -811,3 +812,46 @@ def test_batch_transpile_options_integrated(self): self.assertEqual(expdata.child_data(0).analysis_results(0).value, 8) self.assertEqual(expdata.child_data(1).child_data(0).analysis_results(0).value, 16) self.assertEqual(expdata.child_data(1).child_data(1).analysis_results(0).value, 4) + + def test_separate_jobs(self): + """Test the separate_job experiment option""" + + backend = FakeBackend() + + class Experiment(FakeExperiment): + """Fake Experiment to test the separate_job experiment option""" + + def circuits(self): + """Generate fake circuits""" + qc = QuantumCircuit(1) + qc.measure_all() + return [qc] + + exp = Experiment([0]) + + # test separate_jobs=False + batch_exp = BatchExperiment([exp, exp]) + batch_data = batch_exp.run(backend) + self.assertExperimentDone(batch_data) + job_ids = batch_data.job_ids + self.assertEqual(len(job_ids), 1) + + # test separate_jobs=True + batch_exp.set_experiment_options(separate_jobs=True) + batch_data = batch_exp.run(backend) + self.assertExperimentDone(batch_data) + job_ids = batch_data.job_ids + self.assertEqual(len(job_ids), 2) + + # test a forbidden nested case, where a parent sets separate_jobs + # to False while the child sets it to True + meta_exp = BatchExperiment([batch_exp]) + with self.assertRaises(QiskitError): + meta_exp.run(backend) + + # test a valid nested case + meta_exp.set_experiment_options(separate_jobs=True) + meta_expdata = meta_exp.run(backend) + self.assertExperimentDone(meta_expdata) + job_ids = meta_expdata.job_ids + self.assertEqual(len(job_ids), 2) diff --git a/test/library/characterization/test_readout_error.py b/test/library/characterization/test_readout_error.py index 8338561600..8616ab86db 100644 --- a/test/library/characterization/test_readout_error.py +++ b/test/library/characterization/test_readout_error.py @@ -21,15 +21,48 @@ from qiskit.quantum_info.operators.predicates import matrix_equal from qiskit.providers.fake_provider import FakeParisV2 from qiskit_ibm_experiment import IBMExperimentService +from qiskit_aer import AerSimulator from qiskit_experiments.library.characterization import LocalReadoutError, CorrelatedReadoutError from qiskit_experiments.framework import ExperimentData from qiskit_experiments.framework import ParallelExperiment from qiskit_experiments.framework.json import ExperimentEncoder, ExperimentDecoder -class TestRedoutError(QiskitExperimentsTestCase): +class TestReadoutError(QiskitExperimentsTestCase): """Test Readout Error experiments""" + def test_local_analysis_ideal(self): + """Tests local mitigator generation from ideal data""" + num_qubits = 3 + # Note: using num_qubits instead of n_qubits kwarg here raises an Aer exception + backend = AerSimulator(n_qubits=num_qubits) + exp = LocalReadoutError(backend=backend) + expdata = exp.run(backend) + self.assertExperimentDone(expdata) + mitigator = expdata.analysis_results(0).value + + qubits = list(range(num_qubits)) + self.assertEqual(mitigator._num_qubits, num_qubits) + self.assertEqual(list(mitigator._qubits), qubits) + for qubit in qubits: + self.assertTrue(matrix_equal(mitigator.assignment_matrix([qubit]), np.eye(2))) + self.assertTrue(matrix_equal([np.eye(2) for _ in qubits], mitigator._assignment_mats)) + + def test_correlated_analysis_ideal(self): + """Tests local mitigator generation from ideal data""" + num_qubits = 2 + # Note: using num_qubits instead of n_qubits kwarg here raises an Aer exception + backend = AerSimulator(n_qubits=num_qubits) + exp = CorrelatedReadoutError(backend=backend) + expdata = exp.run(backend) + self.assertExperimentDone(expdata) + mitigator = expdata.analysis_results(0).value + + qubits = list(range(num_qubits)) + self.assertEqual(mitigator._num_qubits, num_qubits) + self.assertEqual(list(mitigator._qubits), qubits) + self.assertTrue(matrix_equal(mitigator.assignment_matrix(qubits), np.eye(2**num_qubits))) + def test_local_analysis(self): """Tests local mitigator generation from experimental data""" qubits = [0, 1, 2] diff --git a/test/library/randomized_benchmarking/test_clifford_utils.py b/test/library/randomized_benchmarking/test_clifford_utils.py new file mode 100644 index 0000000000..000ed3c36c --- /dev/null +++ b/test/library/randomized_benchmarking/test_clifford_utils.py @@ -0,0 +1,197 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +""" +A Tester for the Clifford utilities +""" +from test.base import QiskitExperimentsTestCase + +import numpy as np +from ddt import ddt +from numpy.random import default_rng + +from qiskit import QuantumCircuit +from qiskit.circuit.library import ( + IGate, + XGate, + YGate, + ZGate, + HGate, + SGate, + SdgGate, + SXGate, + RZGate, +) +from qiskit.quantum_info import Operator, Clifford +from qiskit_experiments.library.randomized_benchmarking.clifford_utils import ( + CliffordUtils, + num_from_1q_circuit, + num_from_2q_circuit, + compose_1q, + compose_2q, + inverse_1q, + inverse_2q, + _num_from_layer_indices, + _layer_indices_from_num, + _CLIFFORD_LAYER, +) + + +@ddt +class TestCliffordUtils(QiskitExperimentsTestCase): + """A test for the Clifford manipulations, including number to and from Clifford mapping""" + + basis_gates = ["rz", "sx", "cx"] + seed = 123 + + def test_clifford_1_qubit_generation(self): + """Verify 1-qubit clifford indeed generates the correct group""" + clifford_dicts = [ + {"stabilizer": ["+Z"], "destabilizer": ["+X"]}, + {"stabilizer": ["+X"], "destabilizer": ["+Z"]}, + {"stabilizer": ["+Y"], "destabilizer": ["+X"]}, + {"stabilizer": ["+X"], "destabilizer": ["+Y"]}, + {"stabilizer": ["+Z"], "destabilizer": ["+Y"]}, + {"stabilizer": ["+Y"], "destabilizer": ["+Z"]}, + {"stabilizer": ["-Z"], "destabilizer": ["+X"]}, + {"stabilizer": ["+X"], "destabilizer": ["-Z"]}, + {"stabilizer": ["-Y"], "destabilizer": ["+X"]}, + {"stabilizer": ["+X"], "destabilizer": ["-Y"]}, + {"stabilizer": ["-Z"], "destabilizer": ["-Y"]}, + {"stabilizer": ["-Y"], "destabilizer": ["-Z"]}, + {"stabilizer": ["-Z"], "destabilizer": ["-X"]}, + {"stabilizer": ["-X"], "destabilizer": ["-Z"]}, + {"stabilizer": ["+Y"], "destabilizer": ["-X"]}, + {"stabilizer": ["-X"], "destabilizer": ["+Y"]}, + {"stabilizer": ["-Z"], "destabilizer": ["+Y"]}, + {"stabilizer": ["+Y"], "destabilizer": ["-Z"]}, + {"stabilizer": ["+Z"], "destabilizer": ["-X"]}, + {"stabilizer": ["-X"], "destabilizer": ["+Z"]}, + {"stabilizer": ["-Y"], "destabilizer": ["-X"]}, + {"stabilizer": ["-X"], "destabilizer": ["-Y"]}, + {"stabilizer": ["+Z"], "destabilizer": ["-Y"]}, + {"stabilizer": ["-Y"], "destabilizer": ["+Z"]}, + ] + cliffords = [Clifford.from_dict(i) for i in clifford_dicts] + for n in range(24): + clifford = CliffordUtils.clifford_1_qubit(n) + self.assertEqual(clifford, cliffords[n]) + + def test_number_to_clifford_mapping_single_gate(self): + """Test that the methods num_from_1q_clifford_single_gate and + clifford_1_qubit_circuit perform the reverse operations from each other""" + transpiled_cliff_list = [ + SXGate(), + RZGate(np.pi), + RZGate(-np.pi), + RZGate(np.pi / 2), + RZGate(-np.pi / 2), + ] + general_cliff_list = [ + IGate(), + HGate(), + SdgGate(), + SGate(), + XGate(), + SXGate(), + YGate(), + ZGate(), + ] + for inst in transpiled_cliff_list + general_cliff_list: + qc_from_inst = QuantumCircuit(1) + qc_from_inst.append(inst, [0]) + num = num_from_1q_circuit(qc_from_inst) + qc_from_num = CliffordUtils.clifford_1_qubit_circuit(num) + self.assertTrue(Operator(qc_from_num).equiv(Operator(qc_from_inst))) + + def test_number_to_clifford_mapping_2q(self): + """Test if num -> circuit -> num round-trip succeeds for 2Q Cliffords.""" + for i in range(CliffordUtils.NUM_CLIFFORD_2_QUBIT): + qc = CliffordUtils.clifford_2_qubit_circuit(i) + num = num_from_2q_circuit(qc) + self.assertEqual(i, num) + + def test_compose_by_num_1q(self): + """Compare compose using num and Clifford to compose using two Cliffords, for a single qubit""" + num_tests = 50 + rng = default_rng(seed=self.seed) + for _ in range(num_tests): + num1 = rng.integers(CliffordUtils.NUM_CLIFFORD_1_QUBIT) + num2 = rng.integers(CliffordUtils.NUM_CLIFFORD_1_QUBIT) + cliff1 = CliffordUtils.clifford_1_qubit(num1) + cliff2 = CliffordUtils.clifford_1_qubit(num2) + clifford_expected = cliff1.compose(cliff2) + clifford_from_num = CliffordUtils.clifford_1_qubit(compose_1q(num1, num2)) + clifford_from_circuit = Clifford(cliff1.to_circuit().compose(cliff2.to_circuit())) + self.assertEqual(clifford_expected, clifford_from_num) + self.assertEqual(clifford_expected, clifford_from_circuit) + + def test_compose_by_num_2q(self): + """Compare compose using num and Clifford to compose using two Cliffords, for two qubits""" + num_tests = 100 + rng = default_rng(seed=self.seed) + for _ in range(num_tests): + num1 = rng.integers(CliffordUtils.NUM_CLIFFORD_2_QUBIT) + num2 = rng.integers(CliffordUtils.NUM_CLIFFORD_2_QUBIT) + cliff1 = CliffordUtils.clifford_2_qubit(num1) + cliff2 = CliffordUtils.clifford_2_qubit(num2) + clifford_expected = cliff1.compose(cliff2) + clifford_from_num = CliffordUtils.clifford_2_qubit(compose_2q(num1, num2)) + clifford_from_circuit = Clifford(cliff1.to_circuit().compose(cliff2.to_circuit())) + self.assertEqual(clifford_expected, clifford_from_num) + self.assertEqual(clifford_expected, clifford_from_circuit) + + def test_inverse_by_num_1q(self): + """Compare inverse using num to inverse using Clifford""" + num_tests = 24 + for num in range(num_tests): + cliff = CliffordUtils.clifford_1_qubit(num) + clifford_expected = cliff.adjoint() + clifford_from_num = CliffordUtils.clifford_1_qubit(inverse_1q(num)) + clifford_from_circuit = Clifford(cliff.to_circuit().inverse()) + self.assertEqual(clifford_expected, clifford_from_num) + self.assertEqual(clifford_expected, clifford_from_circuit) + + def test_inverse_by_num_2q(self): + """Compare inverse using num to inverse using Clifford""" + num_tests = 100 + rng = default_rng(seed=self.seed) + for _ in range(num_tests): + num = rng.integers(CliffordUtils.NUM_CLIFFORD_2_QUBIT) + cliff = CliffordUtils.clifford_2_qubit(num) + clifford_expected = cliff.adjoint() + clifford_from_num = CliffordUtils.clifford_2_qubit(inverse_2q(num)) + clifford_from_circuit = Clifford(cliff.to_circuit().inverse()) + self.assertEqual(clifford_expected, clifford_from_num) + self.assertEqual(clifford_expected, clifford_from_circuit) + + def test_num_layered_circuit_num_round_trip(self): + """Test if num -> circuit with layers -> num round-trip succeeds for 2Q Cliffords.""" + for i in range(CliffordUtils.NUM_CLIFFORD_2_QUBIT): + self.assertEqual(i, compose_2q(0, i)) + + def test_mapping_layers_to_num(self): + """Test the mapping from numbers to layer indices""" + for i in range(CliffordUtils.NUM_CLIFFORD_2_QUBIT): + indices = _layer_indices_from_num(i) + reverse_i = _num_from_layer_indices(indices) + self.assertEqual(i, reverse_i) + + def test_num_from_layer(self): + """Check if 2Q clifford from standard/layered circuit has a common integer representation.""" + for i in range(CliffordUtils.NUM_CLIFFORD_2_QUBIT): + standard = CliffordUtils.clifford_2_qubit(i) + circ = QuantumCircuit(2) + for layer, idx in enumerate(_layer_indices_from_num(i)): + circ.compose(_CLIFFORD_LAYER[layer][idx], inplace=True) + layered = Clifford(circ) + self.assertEqual(standard, layered) diff --git a/test/library/randomized_benchmarking/test_randomized_benchmarking.py b/test/library/randomized_benchmarking/test_randomized_benchmarking.py index a42c16be39..2fecc0261e 100644 --- a/test/library/randomized_benchmarking/test_randomized_benchmarking.py +++ b/test/library/randomized_benchmarking/test_randomized_benchmarking.py @@ -11,209 +11,141 @@ # that they have been altered from the originals. """Test for randomized benchmarking experiments.""" - from test.base import QiskitExperimentsTestCase +import copy + import numpy as np + from ddt import ddt, data, unpack -from qiskit.circuit import Delay, QuantumCircuit -from qiskit.circuit.library import SXGate, CXGate, TGate, XGate + +from qiskit.circuit import Delay, QuantumCircuit, Parameter, Gate +from qiskit.circuit.library import SXGate, CXGate, TGate, CZGate from qiskit.exceptions import QiskitError -from qiskit.quantum_info import Clifford +from qiskit.providers.fake_provider import FakeManila, FakeManilaV2, FakeWashington, FakeParis +from qiskit.pulse import Schedule, InstructionScheduleMap +from qiskit.quantum_info import Operator +from qiskit.transpiler.basepasses import TransformationPass +from qiskit.transpiler import Layout, PassManager, CouplingMap + 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.database_service.exceptions import ExperimentEntryNotFound +from qiskit_experiments.framework.composite import ParallelExperiment +from qiskit_experiments.library import randomized_benchmarking as rb -class RBTestCase(QiskitExperimentsTestCase): - """Base test case for randomized benchmarking defining a common noise model.""" - - 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"] - - # 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 - - # 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) +class RBTestMixin: + """Mixin for RB tests.""" def assertAllIdentity(self, circuits): """Test if all experiment circuits are identity.""" for circ in circuits: num_qubits = circ.num_qubits - iden = Clifford(np.eye(2 * num_qubits, dtype=bool)) - + qc_iden = QuantumCircuit(num_qubits) circ.remove_final_measurements() - - self.assertEqual( - Clifford(circ), iden, f"Circuit {circ.name} doesn't result in the identity matrix." - ) + self.assertTrue(Operator(circ).equiv(Operator(qc_iden))) @ddt -class TestStandardRB(RBTestCase): - """Test for standard RB.""" - - def test_single_qubit(self): - """Test single qubit RB.""" - exp = rb.StandardRB( - qubits=(0,), - lengths=list(range(1, 300, 30)), - seed=123, - backend=self.backend, - ) - exp.analysis.set_options(gate_error_ratio=None) - exp.set_transpile_options(**self.transpiler_options) - self.assertAllIdentity(exp.circuits()) +class TestStandardRB(QiskitExperimentsTestCase, RBTestMixin): + """Test for StandardRB without running the experiments.""" - 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. - epc = expdata.analysis_results("EPC") + def setUp(self): + """Setup the tests.""" + super().setUp() + self.backend = FakeManilaV2() - epc_expected = 1 - (1 - 1 / 2 * self.p1q) ** 1.0 - self.assertAlmostEqual(epc.value.n, epc_expected, delta=0.1 * epc_expected) + # ### Tests for configuration ### + @data( + {"qubits": [3, 3], "lengths": [1, 3, 5, 7, 9], "num_samples": 1, "seed": 100}, + {"qubits": [0, 1], "lengths": [1, 3, 5, -7, 9], "num_samples": 1, "seed": 100}, + {"qubits": [0, 1], "lengths": [1, 3, 5, 7, 9], "num_samples": -4, "seed": 100}, + {"qubits": [0, 1], "lengths": [1, 3, 5, 7, 9], "num_samples": 0, "seed": 100}, + {"qubits": [0, 1], "lengths": [1, 5, 5, 5, 9], "num_samples": 2, "seed": 100}, + ) + def test_invalid_configuration(self, configs): + """Test raise error when creating experiment with invalid configs.""" + self.assertRaises(QiskitError, rb.StandardRB, **configs) - def test_two_qubit(self): - """Test two qubit RB.""" - exp = rb.StandardRB( - qubits=(0, 1), - lengths=list(range(1, 30, 3)), - seed=123, - backend=self.backend, - ) - exp.analysis.set_options(gate_error_ratio=None) - exp.set_transpile_options(**self.transpiler_options) - self.assertAllIdentity(exp.circuits()) + def test_experiment_config(self): + """Test converting to and from config works""" + exp = rb.StandardRB(qubits=(0,), lengths=[10, 20, 30], seed=123) + loaded_exp = rb.StandardRB.from_config(exp.config()) + self.assertNotEqual(exp, loaded_exp) + self.assertTrue(self.json_equiv(exp, loaded_exp)) - expdata = exp.run() - self.assertExperimentDone(expdata) + def test_roundtrip_serializable(self): + """Test round trip JSON serialization""" + exp = rb.StandardRB(qubits=(0,), lengths=[10, 20, 30], seed=123) + self.assertRoundTripSerializable(exp, self.json_equiv) - # Given CX error is dominant and 1q error can be negligible. - # Arbitrary SU(4) can be decomposed with (0, 1, 2, 3) CX gates, the expected - # average number of CX gate per Clifford is 1.5. - # Since this is two qubit RB, the dep-parameter is factored by 3/4. - epc = expdata.analysis_results("EPC") + 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()) - # Allow for 50 percent tolerance since we ignore 1q gate contribution - epc_expected = 1 - (1 - 3 / 4 * self.p2q) ** 1.5 - self.assertAlmostEqual(epc.value.n, epc_expected, delta=0.5 * epc_expected) + # ### Tests for circuit generation ### + @data([[3], 4], [[4, 7], 5], [[0, 1, 2], 3]) + @unpack + def test_generate_circuits(self, qubits, length): + """Test RB circuit generation""" + exp = rb.StandardRB(qubits=qubits, lengths=[length], num_samples=1) + circuits = exp.circuits() + self.assertAllIdentity(circuits) - def test_add_more_circuit_yields_lower_variance(self): - """Test variance reduction with larger number of sampling.""" + def test_return_same_circuit(self): + """Test if setting the same seed returns the same circuits.""" exp1 = rb.StandardRB( qubits=(0, 1), - lengths=list(range(1, 30, 3)), + lengths=[10, 20, 30], seed=123, backend=self.backend, - num_samples=3, ) - exp1.analysis.set_options(gate_error_ratio=None) - exp1.set_transpile_options(**self.transpiler_options) - expdata1 = exp1.run() - self.assertExperimentDone(expdata1) exp2 = rb.StandardRB( qubits=(0, 1), - lengths=list(range(1, 30, 3)), - seed=456, + lengths=[10, 20, 30], + seed=123, backend=self.backend, - num_samples=5, - ) - 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, ) - def test_poor_experiment_result(self): - """Test edge case that tail of decay is not sampled. - - This is a special case that fit outcome is very sensitive to initial guess. - Perhaps generated initial guess is close to a local minima. - """ - from qiskit.providers.fake_provider import FakeVigoV2 - - backend = FakeVigoV2() - exp = rb.StandardRB( - qubits=(0,), - lengths=[100, 200, 300, 400], - seed=123, - backend=backend, - num_samples=5, - ) - exp.set_transpile_options(basis_gates=["x", "sx", "rz"], optimization_level=1) - # Simulator seed must be fixed. This can be set via run option with FakeBackend. - # pylint: disable=no-member - exp.set_run_options(seed_simulator=456) - expdata = exp.run() - self.assertExperimentDone(expdata) + circs1 = exp1.circuits() + circs2 = exp2.circuits() - overview = expdata.analysis_results(0).value - # This yields bad fit due to poor data points, but still fit is not completely off. - self.assertLess(overview.reduced_chisq, 10) + self.assertEqual(circs1[0].decompose(), circs2[0].decompose()) + self.assertEqual(circs1[1].decompose(), circs2[1].decompose()) + self.assertEqual(circs1[2].decompose(), circs2[2].decompose()) - def test_return_same_circuit(self): - """Test if setting the same seed returns the same circuits.""" + def test_full_sampling_single_qubit(self): + """Test if full sampling generates different circuits.""" exp1 = rb.StandardRB( - qubits=(0, 1), + qubits=(0,), lengths=[10, 20, 30], seed=123, backend=self.backend, + full_sampling=False, ) - exp2 = rb.StandardRB( - qubits=(0, 1), + qubits=(0,), lengths=[10, 20, 30], seed=123, backend=self.backend, + full_sampling=True, ) - 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()) - def test_full_sampling(self): + # fully sampled circuits are regenerated while other is just built on top of previous length + self.assertNotEqual(circs1[1].decompose(), circs2[1].decompose()) + self.assertNotEqual(circs1[2].decompose(), circs2[2].decompose()) + + def test_full_sampling_2_qubits(self): """Test if full sampling generates different circuits.""" exp1 = rb.StandardRB( qubits=(0, 1), @@ -240,56 +172,135 @@ def test_full_sampling(self): self.assertNotEqual(circs1[1].decompose(), circs2[1].decompose()) self.assertNotEqual(circs1[2].decompose(), circs2[2].decompose()) - @data( - {"qubits": [3, 3], "lengths": [1, 3, 5, 7, 9], "num_samples": 1, "seed": 100}, - {"qubits": [0, 1], "lengths": [1, 3, 5, -7, 9], "num_samples": 1, "seed": 100}, - {"qubits": [0, 1], "lengths": [1, 3, 5, 7, 9], "num_samples": -4, "seed": 100}, - {"qubits": [0, 1], "lengths": [1, 3, 5, 7, 9], "num_samples": 0, "seed": 100}, - {"qubits": [0, 1], "lengths": [1, 5, 5, 5, 9], "num_samples": 2, "seed": 100}, - ) - def test_invalid_configuration(self, configs): - """Test raise error when creating experiment with invalid configs.""" - self.assertRaises(QiskitError, rb.StandardRB, **configs) + # ### Tests for transpiled circuit generation ### + def test_calibrations_via_transpile_options(self): + """Test if calibrations given as transpile_options show up in transpiled circuits.""" + qubits = (2,) + my_sched = Schedule(name="custom_sx_gate") + my_inst_map = InstructionScheduleMap() + my_inst_map.add(SXGate(), qubits, my_sched) + + exp = rb.StandardRB( + qubits=qubits, lengths=[3], num_samples=4, backend=self.backend, seed=123 + ) + exp.set_transpile_options(inst_map=my_inst_map) + 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_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(self.backend) + my_backend.target["sx"][qubits].calibration = my_sched + + exp = rb.StandardRB(qubits=qubits, lengths=[3], num_samples=4, 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(self.backend) + del my_backend.target["cx"][(1, 2)] # make cx on {1, 2} one-sided + + exp = rb.StandardRB(qubits=(1, 2), lengths=[3], num_samples=4, backend=my_backend) + 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 TestInterleavedRB(QiskitExperimentsTestCase, RBTestMixin): + """Test for InterleavedRB without running the experiments.""" + + def setUp(self): + """Setup the tests.""" + super().setUp() + self.backend = FakeManila() + self.backend_with_timing_constraint = FakeWashington() + + # ### Tests for configuration ### + def test_non_clifford_interleaved_element(self): + """Verifies trying to run interleaved RB with non Clifford element throws an exception""" + with self.assertRaises(QiskitError): + rb.InterleavedRB( + interleaved_element=TGate(), # T gate is not Clifford, this should fail + qubits=[0], + lengths=[1, 2, 3, 5, 8, 13], + ) + + @data([5, "dt"], [1e-7, "s"], [32, "ns"]) + @unpack + def test_interleaving_delay_with_invalid_duration(self, duration, unit): + """Raise if delay with invalid duration is given as interleaved_element""" + with self.assertRaises(QiskitError): + rb.InterleavedRB( + interleaved_element=Delay(duration, unit=unit), + qubits=[0], + lengths=[1, 2, 3], + backend=self.backend_with_timing_constraint, + ) def test_experiment_config(self): """Test converting to and from config works""" - exp = rb.StandardRB(qubits=(0,), lengths=[10, 20, 30], seed=123) - loaded_exp = rb.StandardRB.from_config(exp.config()) + exp = rb.InterleavedRB( + interleaved_element=SXGate(), + qubits=(0,), + lengths=[10, 20, 30], + seed=123, + ) + loaded_exp = rb.InterleavedRB.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.StandardRB(qubits=(0,), lengths=[10, 20, 30], seed=123) + exp = rb.InterleavedRB( + interleaved_element=SXGate(), qubits=(0,), lengths=[10, 20, 30], seed=123 + ) 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()) + analysis = rb.InterleavedRBAnalysis() + loaded = rb.InterleavedRBAnalysis.from_config(analysis.config()) self.assertNotEqual(analysis, loaded) self.assertEqual(analysis.config(), loaded.config()) - def test_expdata_serialization(self): - """Test serializing experiment data works.""" - exp = rb.StandardRB( - qubits=(0,), - lengths=list(range(1, 200, 50)), - seed=123, - backend=self.backend, - ) - 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) + # ### Tests for circuit generation ### + class ThreeQubitGate(Gate): + """A 3-qubit Clifford gate for tests""" + def __init__(self): + super().__init__("3q-gate", 3, []) -@ddt -class TestInterleavedRB(RBTestCase): - """Test for interleaved RB.""" + def _define(self): + qc = QuantumCircuit(3, name=self.name) + qc.cx(0, 1) + qc.x(2) + self.definition = qc + + @data([SXGate(), [3], 4], [CXGate(), [4, 7], 5], [ThreeQubitGate(), [0, 1, 2], 3]) + @unpack + def test_generate_interleaved_circuits(self, interleaved_element, qubits, length): + """Test interleaved circuit generation""" + exp = rb.InterleavedRB( + interleaved_element=interleaved_element, qubits=qubits, lengths=[length], num_samples=1 + ) + circuits = exp.circuits() + self.assertAllIdentity(circuits) - @data([XGate(), [3], 4], [CXGate(), [4, 7], 5]) + @data([SXGate(), [3], 4], [CXGate(), [4, 7], 5]) @unpack def test_interleaved_structure(self, interleaved_element, qubits, length): """Verifies that when generating an interleaved circuit, it will be @@ -309,134 +320,791 @@ def test_interleaved_structure(self, interleaved_element, qubits, length): std_idx = 0 int_idx = 0 for _ in range(num_cliffords): - # barrier - self.assertEqual(c_std[std_idx][0].name, "barrier") - self.assertEqual(c_int[int_idx][0].name, "barrier") # clifford - self.assertEqual(c_std[std_idx + 1], c_int[int_idx + 1]) - # for interleaved circuit: barrier + interleaved element - self.assertEqual(c_int[int_idx + 2][0].name, "barrier") - self.assertEqual(c_int[int_idx + 3][0].name, interleaved_element.name) + self.assertEqual(c_std[std_idx], c_int[int_idx]) + # barrier + self.assertEqual(c_std[std_idx + 1][0].name, "barrier") + self.assertEqual(c_int[std_idx + 1][0].name, "barrier") + # for interleaved circuit: interleaved element + barrier + self.assertEqual(c_int[int_idx + 2][0].name, interleaved_element.name) + self.assertEqual(c_int[int_idx + 3][0].name, "barrier") std_idx += 2 int_idx += 4 - def test_single_qubit(self): - """Test single qubit IRB.""" + def test_preserve_interleaved_circuit_element(self): + """Interleaved RB should not change a given interleaved circuit during RB circuit generation.""" + interleaved_circ = QuantumCircuit(2, name="bell_with_delay") + interleaved_circ.h(0) + interleaved_circ.delay(1.0e-7, 0, unit="s") + interleaved_circ.cx(0, 1) + exp = rb.InterleavedRB( - interleaved_element=SXGate(), + interleaved_element=interleaved_circ, qubits=[2, 1], lengths=[1], num_samples=1 + ) + circuits = exp.circuits() + # Get the first interleaved operation in the interleaved RB sequence: + # 0: clifford, 1: barrier, 2: interleaved + actual = circuits[1][2].operation + self.assertEqual(interleaved_circ.count_ops(), actual.definition.count_ops()) + + def test_interleaving_delay(self): + """Test delay instruction can be interleaved.""" + # See qiskit-experiments/#727 for details + from qiskit_experiments.framework.backend_timing import BackendTiming + + timing = BackendTiming(self.backend) + exp = rb.InterleavedRB( + interleaved_element=Delay(timing.round_delay(time=1.0e-7)), + qubits=[0], + lengths=[1], + num_samples=1, + seed=1234, # This seed gives a 2-gate clifford + backend=self.backend, + ) + int_circs = exp.circuits()[1] + self.assertEqual(int_circs.count_ops().get("delay", 0), 1) + self.assertAllIdentity([int_circs]) + + def test_interleaving_circuit_with_delay(self): + """Test circuit with delay can be interleaved.""" + delay_qc = QuantumCircuit(2) + delay_qc.delay(160, [0]) + delay_qc.x(1) + + exp = rb.InterleavedRB( + interleaved_element=delay_qc, + qubits=[1, 2], + lengths=[1], + num_samples=1, + seed=1234, + backend=self.backend, + ) + int_circ = exp.circuits()[1] + self.assertAllIdentity([int_circ]) + + def test_interleaving_parameterized_circuit(self): + """Fail if parameterized circuit is interleaved but after assigned it may be interleaved.""" + qubits = (2,) + theta = Parameter("theta") + phi = Parameter("phi") + lam = Parameter("lambda") + cliff_circ_with_param = QuantumCircuit(1) + cliff_circ_with_param.rz(theta, 0) + cliff_circ_with_param.sx(0) + cliff_circ_with_param.rz(phi, 0) + cliff_circ_with_param.sx(0) + cliff_circ_with_param.rz(lam, 0) + + with self.assertRaises(QiskitError): + rb.InterleavedRB( + interleaved_element=cliff_circ_with_param, + qubits=qubits, + lengths=[3], + num_samples=4, + backend=self.backend, + ) + + # # TODO: Enable after Clifford supports creation from circuits with rz + # # parameters must be assigned before initializing InterleavedRB + # param_map = {theta: np.pi / 2, phi: -np.pi / 2, lam: np.pi / 2} + # cliff_circ_with_param.assign_parameters(param_map, inplace=True) + # + # exp = rb.InterleavedRB( + # interleaved_element=cliff_circ_with_param, + # qubits=qubits, + # lengths=[3], + # num_samples=4, + # backend=self.backend, + # ) + # circuits = exp.circuits() + # for qc in circuits: + # self.assertEqual(qc.num_parameters, 0) + + # ### Tests for transpiled circuit generation ### + def test_interleaved_circuit_is_decomposed(self): + """Test if interleaved circuit is decomposed in transpiled circuits.""" + delay_qc = QuantumCircuit(2) + delay_qc.delay(160, [0]) + delay_qc.x(1) + + exp = rb.InterleavedRB( + interleaved_element=delay_qc, + qubits=[1, 2], + lengths=[3], + num_samples=1, + seed=1234, + backend=self.backend, + ) + transpiled = exp._transpiled_circuits() + for qc in transpiled: + self.assertTrue(all(not inst.operation.name.startswith("circuit") for inst in qc)) + self.assertTrue(all(not inst.operation.name.startswith("Clifford") for inst in qc)) + + def test_interleaving_cnot_gate_with_non_supported_direction(self): + """Test if cx(0, 1) can be interleaved for backend that support only cx(1, 0).""" + my_backend = FakeManilaV2() + del my_backend.target["cx"][(0, 1)] # make support only cx(1, 0) + + exp = rb.InterleavedRB( + interleaved_element=CXGate(), + qubits=(0, 1), + lengths=[3], + num_samples=4, + backend=my_backend, + ) + transpiled = exp._transpiled_circuits() + for qc in transpiled: + self.assertTrue(qc.count_ops().get("cx", 0) > 0) + expected_qubits = (qc.qubits[1], qc.qubits[0]) + for inst in qc: + if inst.operation.name == "cx": + self.assertEqual(inst.qubits, expected_qubits) + + +class RBRunTestCase(QiskitExperimentsTestCase, RBTestMixin): + """Base test case for running RB experiments defining a common noise model.""" + + def setUp(self): + """Setup the tests.""" + super().setUp() + + # depolarizing error + self.p1q = 0.02 + self.p2q = 0.10 + self.pvz = 0.0 + self.pcz = 0.15 + + # basis gates + self.basis_gates = ["rz", "sx", "cx"] + + # setup noise model + sx_error = depolarizing_error(self.p1q, 1) + rz_error = depolarizing_error(self.pvz, 1) + cx_error = depolarizing_error(self.p2q, 2) + cz_error = depolarizing_error(self.pcz, 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") + noise_model.add_all_qubit_quantum_error(cz_error, "cz") + + 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(FakeManila()).configuration().coupling_map, + ) + + +def decr_dep_param(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 + + +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 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 = 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.""" + + def setUp(self): + """Setup the tests.""" + super().setUp() + self.backend = FakeParis() + + self.basis_gates = ["sx", "rz", "cx"] + + self.transpiler_options = { + "basis_gates": self.basis_gates, + "optimization_level": 1, + } + + def test_single_qubit(self): + """Test single qubit mirror RB.""" + exp = rb.MirrorRB( qubits=(0,), - lengths=list(range(1, 300, 30)), - seed=123, + lengths=list(range(2, 300, 20)), + seed=124, backend=self.backend, + num_samples=30, ) + # exp.analysis.set_options(gate_error_ratio=None) exp.set_transpile_options(**self.transpiler_options) self.assertAllIdentity(exp.circuits()) expdata = exp.run() self.assertExperimentDone(expdata) - # Since this is interleaved, we can directly compare values, i.e. n_gpc = 1 + # 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 / 2 * self.p1q + epc_expected = 1 - (1 - 1 / 2 * self.p1q) ** 2.0 self.assertAlmostEqual(epc.value.n, epc_expected, delta=0.1 * epc_expected) def test_two_qubit(self): - """Test two qubit IRB.""" - exp = rb.InterleavedRB( - interleaved_element=CXGate(), + """Test two qubit RB.""" + two_qubit_gate_density = 0.2 + exp = rb.MirrorRB( qubits=(0, 1), - lengths=list(range(1, 30, 3)), + lengths=list(range(2, 300, 20)), seed=123, backend=self.backend, + num_samples=30, + two_qubit_gate_density=two_qubit_gate_density, ) + exp.analysis.set_options(gate_error_ratio=None) exp.set_transpile_options(**self.transpiler_options) self.assertAllIdentity(exp.circuits()) expdata = exp.run() self.assertExperimentDone(expdata) - # Since this is interleaved, we can directly compare values, i.e. n_gpc = 1 + # 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") - epc_expected = 3 / 4 * self.p2q + 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=0.1 * epc_expected) - def test_non_clifford_interleaved_element(self): - """Verifies trying to run interleaved RB with non Clifford element throws an exception""" - qubits = 1 - lengths = [1, 4, 6, 9, 13, 16] - interleaved_element = TGate() # T gate is not Clifford, this should fail - self.assertRaises( - QiskitError, - rb.InterleavedRB, - interleaved_element=interleaved_element, - qubits=qubits, + 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"] + # Need level1 for consecutive gate cancellation for reference EPC value calculation + transpiler_options = { + "basis_gates": basis_gates, + "optimization_level": 1, + } + # Coupling map is 3 x 3 lattice + 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( + qubits=(0, 1), + lengths=list(range(2, 110, 20)), + seed=123, + 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) + self.assertAllIdentity(exp.circuits()) + 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=0.1 * epc_expected) + + def test_three_qubit_nonlocal_noise(self): + """Test three-qubit mirrored 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"] + # Need level1 for consecutive gate cancellation for reference EPC value calculation + transpiler_options = { + "basis_gates": basis_gates, + "optimization_level": 1, + } + 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( + qubits=(0, 1, 2), + lengths=list(range(2, 110, 50)), + seed=123, + 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) + self.assertAllIdentity(exp.circuits()) + 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=0.2 * epc_expected) + + def test_add_more_circuit_yields_lower_variance(self): + """Test variance reduction with larger number of sampling.""" + exp1 = rb.MirrorRB( + qubits=(0, 1), + lengths=list(range(2, 30, 4)), + seed=123, + 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( + 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, + ) + + def test_return_same_circuit(self): + """Test if setting the same seed returns the same circuits.""" + lengths = [10, 20] + exp1 = rb.MirrorRB( + qubits=(0, 1), lengths=lengths, + seed=123, + backend=self.backend, ) - def test_interleaving_delay(self): - """Test delay instruction can be interleaved.""" - # See qiskit-experiments/#727 for details - interleaved_element = Delay(10, unit="us") - exp = rb.InterleavedRB( - interleaved_element, - qubits=[0], - lengths=[1], + exp2 = rb.MirrorRB( + qubits=(0, 1), + lengths=lengths, + seed=123, + 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( + qubits=(0, 1), + lengths=[10, 20], + seed=123, + backend=self.backend, num_samples=1, + full_sampling=True, ) - # Not raises an error - _, int_circ = exp.circuits() - # barrier, clifford, barrier, "delay", barrier, ... - self.assertEqual(int_circ.data[3][0], interleaved_element) + exp2 = rb.MirrorRB( + qubits=(0, 1), + lengths=[10, 20], + seed=123, + backend=self.backend, + num_samples=1, + full_sampling=False, + ) - def test_interleaving_circuit_with_delay(self): - """Test circuit with delay can be interleaved.""" - delay_qc = QuantumCircuit(2) - delay_qc.delay(10, [0], unit="us") - delay_qc.x(1) + circs1 = exp1.circuits() + circs2 = exp2.circuits() - exp = rb.InterleavedRB( - interleaved_element=delay_qc, qubits=[1, 2], lengths=[1], seed=123, num_samples=1 + 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_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) + exp = rb.MirrorRB(qubits=[0], lengths=[2], backend=self.backend) + expected_tb = exp._clifford_utils.compute_target_bitstring(qc) + actual_tb = "110010110" + self.assertEqual(expected_tb, actual_tb) + + 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( + qubits=(0, 1), + lengths=[40], + seed=124, + 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( + qubits=(0, 1, 2, 3), + lengths=[40], + seed=125, + 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_local_clifford(self): + """Test that the number of layers is correct depending on whether + local_clifford is set to True or False by counting the number of barriers.""" + exp = rb.MirrorRB( + qubits=(0,), + lengths=[2], + seed=126, + backend=self.backend, + num_samples=1, + local_clifford=True, + pauli_randomize=False, + two_qubit_gate_density=0.2, + inverting_pauli_layer=False, ) - _, int_circ = exp.circuits() + 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( + qubits=(0,), + lengths=[2], + seed=126, + backend=self.backend, + num_samples=1, + local_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 (i.e., + a layer of Paulis before the final measurement that restores the output + to |0>^num_qubits up to a global phase) composes to the identity (up to + a global phase)""" + exp = rb.MirrorRB( + qubits=(0, 1, 2), + lengths=[2], + seed=127, + backend=self.backend, + num_samples=3, + local_clifford=True, + pauli_randomize=True, + two_qubit_gate_density=0.2, + inverting_pauli_layer=True, + ) + self.assertAllIdentity(exp.circuits()) + + @data( + { + "qubits": [3, 3], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # repeated qubits + { + "qubits": [0, 1], + "lengths": [2, 4, 6, -8, 10], + "num_samples": 1, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # negative length + { + "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 + { + "qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 0, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # zero samples + { + "qubits": [0, 1], + "lengths": [2, 6, 6, 6, 10], + "num_samples": 2, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # repeated lengths + { + "qubits": [0, 1], + "lengths": [2, 4, 5, 8, 10], + "num_samples": 2, + "seed": 100, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # odd length + { + "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, rb.MirrorRB, **configs) + + @data( + { + "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) - qc = QuantumCircuit(2) - qc.x(1) - expected_inversion = Clifford(int_circ.data[1][0]).compose(qc).adjoint() - # barrier, clifford, barrier, "interleaved circuit", barrier, inversion, ... - self.assertEqual(expected_inversion, Clifford(int_circ.data[5][0])) + @data( + { + "qubits": [0, 4], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "backend": AerSimulator.from_backend(FakeManila()), + }, # Uncoupled qubits to test edgegrab algorithm warning + { + "qubits": [0, 1], + "lengths": [2, 4, 6, 8, 10], + "num_samples": 1, + "seed": 100, + "two_qubit_gate_density": 0.6, + "backend": AerSimulator(coupling_map=[[0, 1], [1, 0]]), + }, # High two-qubit gate density warning + ) + def test_warnings(self, configs): + """Test raise warnings when creating experiment.""" + mirror_exp = rb.MirrorRB(**configs) + self.assertWarns(Warning, mirror_exp.run) def test_experiment_config(self): """Test converting to and from config works""" - exp = rb.InterleavedRB( - interleaved_element=SXGate(), qubits=(0,), lengths=[10, 20, 30], seed=123 - ) - loaded_exp = rb.InterleavedRB.from_config(exp.config()) + exp = rb.MirrorRB(qubits=(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.InterleavedRB( - interleaved_element=SXGate(), qubits=(0,), lengths=[10, 20, 30], seed=123 - ) + exp = rb.MirrorRB(qubits=(0,), lengths=[10, 20, 30], seed=123) self.assertRoundTripSerializable(exp, self.json_equiv) def test_analysis_config(self): """ "Test converting analysis to and from config works""" - analysis = rb.InterleavedRBAnalysis() - loaded = rb.InterleavedRBAnalysis.from_config(analysis.config()) + analysis = rb.RBAnalysis() + loaded = rb.RBAnalysis.from_config(analysis.config()) self.assertNotEqual(analysis, loaded) self.assertEqual(analysis.config(), loaded.config()) def test_expdata_serialization(self): """Test serializing experiment data works.""" - exp = rb.InterleavedRB( - interleaved_element=SXGate(), + exp = rb.MirrorRB( qubits=(0,), - lengths=list(range(1, 200, 50)), + lengths=list(range(2, 200, 50)), seed=123, backend=self.backend, + inverting_pauli_layer=False, ) exp.set_transpile_options(**self.transpiler_options) expdata = exp.run() @@ -451,7 +1119,6 @@ class TestEPGAnalysis(QiskitExperimentsTestCase): EPG and depolarizing probability p are assumed to have following relationship EPG = (2^n - 1) / 2^n · p - This p is provided to the Aer noise model, thus we verify EPG computation by comparing the value with the depolarizing probability. """ @@ -462,10 +1129,14 @@ def setUp(self): # Setup noise model, including more gate for complicated EPG computation # Note that 1Q channel error is amplified to check 1q channel correction mechanism - x_error = depolarizing_error(0.04, 1) - h_error = depolarizing_error(0.02, 1) - s_error = depolarizing_error(0.00, 1) - cx_error = depolarizing_error(0.08, 2) + self.p_x = 0.04 + self.p_h = 0.02 + self.p_s = 0.0 + self.p_cx = 0.09 + x_error = depolarizing_error(self.p_x, 1) + h_error = depolarizing_error(self.p_h, 1) + s_error = depolarizing_error(self.p_s, 1) + cx_error = depolarizing_error(self.p_cx, 2) noise_model = NoiseModel() noise_model.add_all_qubit_quantum_error(x_error, "x") @@ -529,8 +1200,8 @@ def test_default_epg_ratio(self): # H and X gate EPG are assumed to be the same, so this underestimate X and overestimate H self.assertEqual(h_epg.value.n, x_epg.value.n) - self.assertLess(x_epg.value.n, 0.04 * 0.5) - self.assertGreater(h_epg.value.n, 0.02 * 0.5) + self.assertLess(x_epg.value.n, self.p_x * 0.5) + self.assertGreater(h_epg.value.n, self.p_h * 0.5) def test_no_epg(self): """Calculate no EPGs.""" @@ -558,12 +1229,11 @@ def test_with_custom_epg_ratio(self): h_epg = result.analysis_results("EPG_h") x_epg = result.analysis_results("EPG_x") - self.assertAlmostEqual(x_epg.value.n, 0.04 * 0.5, delta=0.005) - self.assertAlmostEqual(h_epg.value.n, 0.02 * 0.5, delta=0.005) + self.assertAlmostEqual(x_epg.value.n, self.p_x * 0.5, delta=0.005) + self.assertAlmostEqual(h_epg.value.n, self.p_h * 0.5, delta=0.005) def test_2q_epg(self): """Compute 2Q EPG without correction. - Since 1Q gates are designed to have comparable EPG with CX gate, this will overestimate the error of CX gate. """ @@ -574,7 +1244,7 @@ def test_2q_epg(self): cx_epg = result.analysis_results("EPG_cx") - self.assertGreater(cx_epg.value.n, 0.08 * 0.75) + self.assertGreater(cx_epg.value.n, self.p_cx * 0.75) def test_2q_epg_with_correction(self): """Check that 2Q EPG with 1Q depolarization correction gives a better (smaller) result than @@ -605,7 +1275,7 @@ def test_2q_epg_with_correction(self): result_2qrb = analysis_2qrb.run(self.expdata_2qrb) self.assertExperimentDone(result_2qrb) cx_epg_corrected = result_2qrb.analysis_results("EPG_cx") - self.assertLess( - np.abs(cx_epg_corrected.value.n - 0.08 * 0.75), np.abs(cx_epg_raw.value.n - 0.08 * 0.75) + np.abs(cx_epg_corrected.value.n - self.p_cx * 0.75), + np.abs(cx_epg_raw.value.n - self.p_cx * 0.75), ) diff --git a/test/library/randomized_benchmarking/test_rb_utils.py b/test/library/randomized_benchmarking/test_rb_utils.py index c9163e287d..671e629967 100644 --- a/test/library/randomized_benchmarking/test_rb_utils.py +++ b/test/library/randomized_benchmarking/test_rb_utils.py @@ -31,7 +31,6 @@ CZGate, SwapGate, ) -from qiskit.quantum_info import Clifford import qiskit_experiments.library.randomized_benchmarking as rb from qiskit_experiments.framework import AnalysisResultData @@ -174,843 +173,3 @@ def test_coherence_limit(self): self.assertAlmostEqual(oneq_coherence_err, 0.00049975, 6, "Error: 1Q Coherence Limit") self.assertAlmostEqual(twoq_coherence_err, 0.00597, 5, "Error: 2Q Coherence Limit") - - def test_clifford_1_qubit_generation(self): - """Verify 1-qubit clifford indeed generates the correct group""" - clifford_dicts = [ - {"stabilizer": ["+Z"], "destabilizer": ["+X"]}, - {"stabilizer": ["+X"], "destabilizer": ["+Z"]}, - {"stabilizer": ["+Y"], "destabilizer": ["+X"]}, - {"stabilizer": ["+X"], "destabilizer": ["+Y"]}, - {"stabilizer": ["+Z"], "destabilizer": ["+Y"]}, - {"stabilizer": ["+Y"], "destabilizer": ["+Z"]}, - {"stabilizer": ["-Z"], "destabilizer": ["+X"]}, - {"stabilizer": ["+X"], "destabilizer": ["-Z"]}, - {"stabilizer": ["-Y"], "destabilizer": ["+X"]}, - {"stabilizer": ["+X"], "destabilizer": ["-Y"]}, - {"stabilizer": ["-Z"], "destabilizer": ["-Y"]}, - {"stabilizer": ["-Y"], "destabilizer": ["-Z"]}, - {"stabilizer": ["-Z"], "destabilizer": ["-X"]}, - {"stabilizer": ["-X"], "destabilizer": ["-Z"]}, - {"stabilizer": ["+Y"], "destabilizer": ["-X"]}, - {"stabilizer": ["-X"], "destabilizer": ["+Y"]}, - {"stabilizer": ["-Z"], "destabilizer": ["+Y"]}, - {"stabilizer": ["+Y"], "destabilizer": ["-Z"]}, - {"stabilizer": ["+Z"], "destabilizer": ["-X"]}, - {"stabilizer": ["-X"], "destabilizer": ["+Z"]}, - {"stabilizer": ["-Y"], "destabilizer": ["-X"]}, - {"stabilizer": ["-X"], "destabilizer": ["-Y"]}, - {"stabilizer": ["+Z"], "destabilizer": ["-Y"]}, - {"stabilizer": ["-Y"], "destabilizer": ["+Z"]}, - ] - cliffords = [Clifford.from_dict(i) for i in clifford_dicts] - utils = rb.CliffordUtils() - for n in range(24): - clifford = utils.clifford_1_qubit(n) - self.assertEqual(clifford, cliffords[n]) - - def test_clifford_2_qubit_generation(self): - """Verify 2-qubit clifford indeed generates the correct group""" - utils = rb.CliffordUtils() - pauli_free_elements = [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 576, - 577, - 578, - 579, - 580, - 581, - 582, - 583, - 584, - 585, - 586, - 587, - 588, - 589, - 590, - 591, - 592, - 593, - 594, - 595, - 596, - 597, - 598, - 599, - 600, - 601, - 602, - 603, - 604, - 605, - 606, - 607, - 608, - 609, - 610, - 611, - 612, - 613, - 614, - 615, - 616, - 617, - 618, - 619, - 620, - 621, - 622, - 623, - 624, - 625, - 626, - 627, - 628, - 629, - 630, - 631, - 632, - 633, - 634, - 635, - 636, - 637, - 638, - 639, - 640, - 641, - 642, - 643, - 644, - 645, - 646, - 647, - 648, - 649, - 650, - 651, - 652, - 653, - 654, - 655, - 656, - 657, - 658, - 659, - 660, - 661, - 662, - 663, - 664, - 665, - 666, - 667, - 668, - 669, - 670, - 671, - 672, - 673, - 674, - 675, - 676, - 677, - 678, - 679, - 680, - 681, - 682, - 683, - 684, - 685, - 686, - 687, - 688, - 689, - 690, - 691, - 692, - 693, - 694, - 695, - 696, - 697, - 698, - 699, - 700, - 701, - 702, - 703, - 704, - 705, - 706, - 707, - 708, - 709, - 710, - 711, - 712, - 713, - 714, - 715, - 716, - 717, - 718, - 719, - 720, - 721, - 722, - 723, - 724, - 725, - 726, - 727, - 728, - 729, - 730, - 731, - 732, - 733, - 734, - 735, - 736, - 737, - 738, - 739, - 740, - 741, - 742, - 743, - 744, - 745, - 746, - 747, - 748, - 749, - 750, - 751, - 752, - 753, - 754, - 755, - 756, - 757, - 758, - 759, - 760, - 761, - 762, - 763, - 764, - 765, - 766, - 767, - 768, - 769, - 770, - 771, - 772, - 773, - 774, - 775, - 776, - 777, - 778, - 779, - 780, - 781, - 782, - 783, - 784, - 785, - 786, - 787, - 788, - 789, - 790, - 791, - 792, - 793, - 794, - 795, - 796, - 797, - 798, - 799, - 800, - 801, - 802, - 803, - 804, - 805, - 806, - 807, - 808, - 809, - 810, - 811, - 812, - 813, - 814, - 815, - 816, - 817, - 818, - 819, - 820, - 821, - 822, - 823, - 824, - 825, - 826, - 827, - 828, - 829, - 830, - 831, - 832, - 833, - 834, - 835, - 836, - 837, - 838, - 839, - 840, - 841, - 842, - 843, - 844, - 845, - 846, - 847, - 848, - 849, - 850, - 851, - 852, - 853, - 854, - 855, - 856, - 857, - 858, - 859, - 860, - 861, - 862, - 863, - 864, - 865, - 866, - 867, - 868, - 869, - 870, - 871, - 872, - 873, - 874, - 875, - 876, - 877, - 878, - 879, - 880, - 881, - 882, - 883, - 884, - 885, - 886, - 887, - 888, - 889, - 890, - 891, - 892, - 893, - 894, - 895, - 896, - 897, - 898, - 899, - 5760, - 5761, - 5762, - 5763, - 5764, - 5765, - 5766, - 5767, - 5768, - 5769, - 5770, - 5771, - 5772, - 5773, - 5774, - 5775, - 5776, - 5777, - 5778, - 5779, - 5780, - 5781, - 5782, - 5783, - 5784, - 5785, - 5786, - 5787, - 5788, - 5789, - 5790, - 5791, - 5792, - 5793, - 5794, - 5795, - 5796, - 5797, - 5798, - 5799, - 5800, - 5801, - 5802, - 5803, - 5804, - 5805, - 5806, - 5807, - 5808, - 5809, - 5810, - 5811, - 5812, - 5813, - 5814, - 5815, - 5816, - 5817, - 5818, - 5819, - 5820, - 5821, - 5822, - 5823, - 5824, - 5825, - 5826, - 5827, - 5828, - 5829, - 5830, - 5831, - 5832, - 5833, - 5834, - 5835, - 5836, - 5837, - 5838, - 5839, - 5840, - 5841, - 5842, - 5843, - 5844, - 5845, - 5846, - 5847, - 5848, - 5849, - 5850, - 5851, - 5852, - 5853, - 5854, - 5855, - 5856, - 5857, - 5858, - 5859, - 5860, - 5861, - 5862, - 5863, - 5864, - 5865, - 5866, - 5867, - 5868, - 5869, - 5870, - 5871, - 5872, - 5873, - 5874, - 5875, - 5876, - 5877, - 5878, - 5879, - 5880, - 5881, - 5882, - 5883, - 5884, - 5885, - 5886, - 5887, - 5888, - 5889, - 5890, - 5891, - 5892, - 5893, - 5894, - 5895, - 5896, - 5897, - 5898, - 5899, - 5900, - 5901, - 5902, - 5903, - 5904, - 5905, - 5906, - 5907, - 5908, - 5909, - 5910, - 5911, - 5912, - 5913, - 5914, - 5915, - 5916, - 5917, - 5918, - 5919, - 5920, - 5921, - 5922, - 5923, - 5924, - 5925, - 5926, - 5927, - 5928, - 5929, - 5930, - 5931, - 5932, - 5933, - 5934, - 5935, - 5936, - 5937, - 5938, - 5939, - 5940, - 5941, - 5942, - 5943, - 5944, - 5945, - 5946, - 5947, - 5948, - 5949, - 5950, - 5951, - 5952, - 5953, - 5954, - 5955, - 5956, - 5957, - 5958, - 5959, - 5960, - 5961, - 5962, - 5963, - 5964, - 5965, - 5966, - 5967, - 5968, - 5969, - 5970, - 5971, - 5972, - 5973, - 5974, - 5975, - 5976, - 5977, - 5978, - 5979, - 5980, - 5981, - 5982, - 5983, - 5984, - 5985, - 5986, - 5987, - 5988, - 5989, - 5990, - 5991, - 5992, - 5993, - 5994, - 5995, - 5996, - 5997, - 5998, - 5999, - 6000, - 6001, - 6002, - 6003, - 6004, - 6005, - 6006, - 6007, - 6008, - 6009, - 6010, - 6011, - 6012, - 6013, - 6014, - 6015, - 6016, - 6017, - 6018, - 6019, - 6020, - 6021, - 6022, - 6023, - 6024, - 6025, - 6026, - 6027, - 6028, - 6029, - 6030, - 6031, - 6032, - 6033, - 6034, - 6035, - 6036, - 6037, - 6038, - 6039, - 6040, - 6041, - 6042, - 6043, - 6044, - 6045, - 6046, - 6047, - 6048, - 6049, - 6050, - 6051, - 6052, - 6053, - 6054, - 6055, - 6056, - 6057, - 6058, - 6059, - 6060, - 6061, - 6062, - 6063, - 6064, - 6065, - 6066, - 6067, - 6068, - 6069, - 6070, - 6071, - 6072, - 6073, - 6074, - 6075, - 6076, - 6077, - 6078, - 6079, - 6080, - 6081, - 6082, - 6083, - 10944, - 10945, - 10946, - 10947, - 10948, - 10949, - 10950, - 10951, - 10952, - 10953, - 10954, - 10955, - 10956, - 10957, - 10958, - 10959, - 10960, - 10961, - 10962, - 10963, - 10964, - 10965, - 10966, - 10967, - 10968, - 10969, - 10970, - 10971, - 10972, - 10973, - 10974, - 10975, - 10976, - 10977, - 10978, - 10979, - ] - cliffords = [] - for n in pauli_free_elements: - clifford = utils.clifford_2_qubit(n) - phase = clifford.table.phase - for i in range(4): - self.assertFalse(phase[i]) - for other_clifford in cliffords: - self.assertNotEqual(clifford, other_clifford) - cliffords.append(clifford) - - pauli_check_elements_list = [ - [0, 36, 72, 108, 144, 180, 216, 252, 288, 324, 360, 396, 432, 468, 504, 540], - [ - 576, - 900, - 1224, - 1548, - 1872, - 2196, - 2520, - 2844, - 3168, - 3492, - 3816, - 4140, - 4464, - 4788, - 5112, - 5436, - ], - [ - 5760, - 6084, - 6408, - 6732, - 7056, - 7380, - 7704, - 8028, - 8352, - 8676, - 9000, - 9324, - 9648, - 9972, - 10296, - 10620, - ], - [ - 10944, - 10980, - 11016, - 11052, - 11088, - 11124, - 11160, - 11196, - 11232, - 11268, - 11304, - 11340, - 11376, - 11412, - 11448, - 11484, - ], - ] - for pauli_check_elements in pauli_check_elements_list: - phases = [] - table = None - for n in pauli_check_elements: - clifford = utils.clifford_2_qubit(n) - if table is None: - table = clifford.table.array - else: - self.assertTrue(np.all(table == clifford.table.array)) - phase = tuple(clifford.table.phase) - for other_phase in phases: - self.assertNotEqual(phase, other_phase) - phases.append(phase) diff --git a/test/library/tomography/test_composite_tomography.py b/test/library/tomography/test_composite_tomography.py index d75c88e7cc..6e1e86ce06 100644 --- a/test/library/tomography/test_composite_tomography.py +++ b/test/library/tomography/test_composite_tomography.py @@ -26,7 +26,7 @@ class TestCompositeTomography(QiskitExperimentsTestCase): """Test composite tomography experiments""" def test_batch_qst_exp(self): - """Test batch state tomography experiment with measurement_qubits kwarg""" + """Test batch state tomography experiment with measurement_indices kwarg""" # Subsystem unitaries seed = 1111 nq = 3 @@ -42,7 +42,7 @@ def test_batch_qst_exp(self): targets = [] for i in range(nq): targets.append(qi.Statevector(ops[i].to_instruction())) - exps.append(StateTomography(circuit, measurement_qubits=[i])) + exps.append(StateTomography(circuit, measurement_indices=[i])) # Run batch experiments backend = AerSimulator(seed_simulator=9000) @@ -80,7 +80,7 @@ def test_parallel_qst_exp(self): exps = [] targets = [] for i in range(nq): - exps.append(StateTomography(ops[i], qubits=[i])) + exps.append(StateTomography(ops[i], physical_qubits=[i])) targets.append(qi.Statevector(ops[i].to_instruction())) # Run batch experiments @@ -108,7 +108,7 @@ def test_parallel_qst_exp(self): target_fid = qi.state_fidelity(state, targets[i], validate=False) self.assertAlmostEqual(fid, target_fid, places=6, msg="result fidelity is incorrect") - def test_batch_qpt_exp_with_measurement_qubits(self): + def test_batch_qpt_exp_with_measurement_indices(self): """Test batch process tomography experiment with kwargs""" seed = 1111 nq = 3 @@ -124,7 +124,9 @@ def test_batch_qpt_exp_with_measurement_qubits(self): targets = [] for i in range(nq): targets.append(ops[i]) - exps.append(ProcessTomography(circuit, measurement_qubits=[i], preparation_qubits=[i])) + exps.append( + ProcessTomography(circuit, measurement_indices=[i], preparation_indices=[i]) + ) # Run batch experiments backend = AerSimulator(seed_simulator=9000) @@ -160,7 +162,7 @@ def test_parallel_qpt_exp(self): exps = [] targets = [] for i in range(nq): - exps.append(ProcessTomography(ops[i], qubits=[i])) + exps.append(ProcessTomography(ops[i], physical_qubits=[i])) targets.append(ops[i]) # Run batch experiments diff --git a/test/library/tomography/test_process_tomography.py b/test/library/tomography/test_process_tomography.py index 80091dfc82..fde44c8d8c 100644 --- a/test/library/tomography/test_process_tomography.py +++ b/test/library/tomography/test_process_tomography.py @@ -21,6 +21,7 @@ from qiskit_aer import AerSimulator from qiskit_experiments.library import ProcessTomography from qiskit_experiments.library.tomography import ProcessTomographyAnalysis +from qiskit_experiments.database_service import ExperimentEntryNotFound from .tomo_utils import FITTERS, filter_results, teleport_circuit, teleport_bell_circuit @@ -67,6 +68,19 @@ def test_full_qpt_random_unitary(self, num_qubits): fid, target_fid, places=6, msg=f"{fitter} result fidelity is incorrect" ) + def test_full_qpt_analysis_none(self): + """Test QPT experiment without analysis""" + seed = 4321 + shots = 1000 + # Generate tomography data without analysis + backend = AerSimulator(seed_simulator=seed, shots=shots) + target = qi.random_unitary(2, seed=seed) + exp = ProcessTomography(target, backend=backend, analysis=None) + self.assertEqual(exp.analysis, None) + expdata = exp.run() + self.assertExperimentDone(expdata) + self.assertFalse(expdata.analysis_results()) + def test_cvxpy_gaussian_lstsq_cx(self): """Test fitter with high fidelity threshold""" seed = 1234 @@ -100,7 +114,7 @@ def test_cvxpy_gaussian_lstsq_cx(self): ) @ddt.data([0], [1], [2], [0, 1], [1, 0], [0, 2], [2, 0], [1, 2], [2, 1]) - def test_exp_measurement_preparation_qubits(self, qubits): + def test_exp_measurement_preparation_indices(self, qubits): """Test subset measurement process tomography generation""" # Subsystem unitaries seed = 1111 @@ -113,7 +127,7 @@ def test_exp_measurement_preparation_qubits(self, qubits): circ.append(op, [i]) num_meas = len(qubits) - exp = ProcessTomography(circ, measurement_qubits=qubits, preparation_qubits=qubits) + exp = ProcessTomography(circ, measurement_indices=qubits, preparation_indices=qubits) tomo_circuits = exp.circuits() # Check correct number of circuits are generated @@ -150,7 +164,7 @@ def test_asymmetric_qubits(self, prep_qubit, meas_qubit): circ.append(op, [i]) exp = ProcessTomography( - circ, measurement_qubits=[meas_qubit], preparation_qubits=[prep_qubit] + circ, measurement_indices=[meas_qubit], preparation_indices=[prep_qubit] ) backend = AerSimulator(seed_simulator=9000) expdata = exp.run(backend, shots=5000) @@ -182,7 +196,7 @@ def test_asymmetric_dimensions(self, prep_qubits, meas_qubits): circ.append(op, [i]) exp = ProcessTomography( - circ, measurement_qubits=meas_qubits, preparation_qubits=prep_qubits + circ, measurement_indices=meas_qubits, preparation_indices=prep_qubits ) backend = AerSimulator(seed_simulator=9000) expdata = exp.run(backend, shots=5000) @@ -224,7 +238,7 @@ def test_full_exp_meas_prep_qubits(self, qubits): # Run backend = AerSimulator(seed_simulator=9000) - exp = ProcessTomography(circ, measurement_qubits=qubits, preparation_qubits=qubits) + exp = ProcessTomography(circ, measurement_indices=qubits, preparation_indices=qubits) expdata = exp.run(backend) self.assertExperimentDone(expdata) results = expdata.analysis_results() @@ -250,7 +264,7 @@ def test_qpt_teleport(self, flatten_creg): # Teleport qubit 0 -> 2 backend = AerSimulator(seed_simulator=9000) exp = ProcessTomography( - teleport_circuit(flatten_creg), measurement_qubits=[2], preparation_qubits=[0] + teleport_circuit(flatten_creg), measurement_indices=[2], preparation_indices=[0] ) expdata = exp.run(backend, shots=1000) self.assertExperimentDone(expdata) @@ -274,8 +288,8 @@ def test_qpt_teleport_bell(self, flatten_creg): backend = AerSimulator(seed_simulator=9000) exp = ProcessTomography( teleport_bell_circuit(flatten_creg), - measurement_qubits=[2, 3], - preparation_qubits=[0, 3], + measurement_indices=[2, 3], + preparation_indices=[0, 3], ) expdata = exp.run(backend, shots=1000) self.assertExperimentDone(expdata) @@ -300,7 +314,9 @@ def test_qpt_teleport_bell(self, flatten_creg): def test_experiment_config(self): """Test converting to and from config works""" - exp = ProcessTomography(teleport_circuit(), measurement_qubits=[2], preparation_qubits=[0]) + exp = ProcessTomography( + teleport_circuit(), measurement_indices=[2], preparation_indices=[0] + ) loaded_exp = ProcessTomography.from_config(exp.config()) self.assertNotEqual(exp, loaded_exp) self.assertTrue(self.json_equiv(exp, loaded_exp)) @@ -320,3 +336,21 @@ def test_expdata_serialization(self): self.assertExperimentDone(expdata) self.assertRoundTripPickle(expdata, check_func=self.experiment_data_equiv) self.assertRoundTripSerializable(expdata, check_func=self.experiment_data_equiv) + + def test_target_none(self): + """Test setting target=None disables fidelity calculation.""" + seed = 4343 + backend = AerSimulator(seed_simulator=seed) + target = qi.random_unitary(2, seed=seed) + exp = ProcessTomography(target, backend=backend, target=None) + expdata = exp.run() + self.assertExperimentDone(expdata) + state = expdata.analysis_results("state").value + self.assertTrue( + isinstance(state, qi.Choi), + msg="Fitted state is not Choi matrix", + ) + with self.assertRaises( + ExperimentEntryNotFound, msg="process_fidelity should not exist when target=None" + ): + expdata.analysis_results("process_fidelity") diff --git a/test/library/tomography/test_state_tomography.py b/test/library/tomography/test_state_tomography.py index b3b8c144a3..81f1fd0b6b 100644 --- a/test/library/tomography/test_state_tomography.py +++ b/test/library/tomography/test_state_tomography.py @@ -23,6 +23,7 @@ from qiskit_experiments.library import StateTomography from qiskit_experiments.library.tomography import StateTomographyAnalysis +from qiskit_experiments.database_service import ExperimentEntryNotFound from .tomo_utils import FITTERS, filter_results, teleport_circuit, teleport_bell_circuit @@ -71,12 +72,25 @@ def test_full_qst(self, num_qubits): fid, target_fid, places=6, msg=f"{fitter} result fidelity is incorrect" ) + def test_full_qst_analysis_none(self): + """Test QST experiment""" + seed = 4321 + shots = 1000 + # Generate tomography data without analysis + backend = AerSimulator(seed_simulator=seed, shots=shots) + target = qi.random_statevector(2, seed=seed) + exp = StateTomography(target, backend=backend, analysis=None) + self.assertEqual(exp.analysis, None) + expdata = exp.run() + self.assertExperimentDone(expdata) + self.assertFalse(expdata.analysis_results()) + @ddt.data(True, False) def test_qst_teleport(self, flatten_creg): """Test subset state tomography generation""" # Teleport qubit 0 -> 2 backend = AerSimulator(seed_simulator=9000) - exp = StateTomography(teleport_circuit(flatten_creg), measurement_qubits=[2]) + exp = StateTomography(teleport_circuit(flatten_creg), measurement_indices=[2]) expdata = exp.run(backend) self.assertExperimentDone(expdata) results = expdata.analysis_results() @@ -99,7 +113,7 @@ def test_qst_teleport_bell(self, flatten_creg): """Test subset state tomography generation""" # Teleport qubit 0 -> 2 backend = AerSimulator(seed_simulator=9000) - exp = StateTomography(teleport_bell_circuit(flatten_creg), measurement_qubits=[2, 3]) + exp = StateTomography(teleport_bell_circuit(flatten_creg), measurement_indices=[2, 3]) expdata = exp.run(backend) self.assertExperimentDone(expdata) results = expdata.analysis_results() @@ -134,7 +148,7 @@ def test_qst_teleport_bell(self, flatten_creg): [2, 0, 1], [2, 1, 0], ) - def test_exp_circuits_measurement_qubits(self, meas_qubits): + def test_exp_circuits_measurement_indices(self, meas_qubits): """Test subset state tomography generation""" # Subsystem unitaries seed = 1111 @@ -147,7 +161,7 @@ def test_exp_circuits_measurement_qubits(self, meas_qubits): circ.append(op, [i]) num_meas = len(meas_qubits) - exp = StateTomography(circ, measurement_qubits=meas_qubits) + exp = StateTomography(circ, measurement_indices=meas_qubits) tomo_circuits = exp.circuits() # Check correct number of circuits are generated @@ -169,7 +183,7 @@ def test_exp_circuits_measurement_qubits(self, meas_qubits): self.assertGreater(fid, 0.99, msg="target_state is incorrect") @ddt.data([0], [1], [2], [0, 1], [1, 0], [0, 2], [2, 0], [1, 2], [2, 1]) - def test_full_exp_measurement_qubits(self, meas_qubits): + def test_full_exp_measurement_indices(self, meas_qubits): """Test subset state tomography generation""" # Subsystem unitaries seed = 1111 @@ -189,7 +203,7 @@ def test_full_exp_measurement_qubits(self, meas_qubits): # Run backend = AerSimulator(seed_simulator=9000) - exp = StateTomography(circ, measurement_qubits=meas_qubits) + exp = StateTomography(circ, measurement_indices=meas_qubits) expdata = exp.run(backend) self.assertExperimentDone(expdata) results = expdata.analysis_results() @@ -222,14 +236,34 @@ def test_expdata_serialization(self): def test_experiment_config(self): """Test converting to and from config works""" - exp = StateTomography(QuantumCircuit(3), measurement_qubits=[0, 2], qubits=[5, 7, 1]) + exp = StateTomography( + QuantumCircuit(3), measurement_indices=[0, 2], physical_qubits=[5, 7, 1] + ) loaded_exp = StateTomography.from_config(exp.config()) self.assertNotEqual(exp, loaded_exp) self.assertTrue(self.json_equiv(exp, loaded_exp)) def test_analysis_config(self): - """ "Test converting analysis to and from config works""" + """Test converting analysis to and from config works""" analysis = StateTomographyAnalysis() loaded = StateTomographyAnalysis.from_config(analysis.config()) self.assertNotEqual(analysis, loaded) self.assertEqual(analysis.config(), loaded.config()) + + def test_target_none(self): + """Test setting target=None disables fidelity calculation.""" + seed = 4343 + backend = AerSimulator(seed_simulator=seed) + target = qi.random_statevector(2, seed=seed) + exp = StateTomography(target, backend=backend, target=None) + expdata = exp.run() + self.assertExperimentDone(expdata) + state = expdata.analysis_results("state").value + self.assertTrue( + isinstance(state, qi.DensityMatrix), + msg="Fitted state is not density matrix", + ) + with self.assertRaises( + ExperimentEntryNotFound, msg="state_fidelity should not exist when target=None" + ): + expdata.analysis_results("state_fidelity") diff --git a/tox.ini b/tox.ini index 461419e287..59d04f95b6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.3.0 -envlist = py310,py39,py38,py37,lint +envlist = py311,py310,py39,py38,py37,lint isolated_build = true [testenv]