diff --git a/qiskit_experiments/library/quantum_volume/__init__.py b/qiskit_experiments/library/quantum_volume/__init__.py index 35173c0477..c14cb8d741 100644 --- a/qiskit_experiments/library/quantum_volume/__init__.py +++ b/qiskit_experiments/library/quantum_volume/__init__.py @@ -35,7 +35,17 @@ :template: autosummary/analysis.rst QuantumVolumeAnalysis + + +Plotter +======= + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/plotter.rst + + QuantumVolumePlotter """ from .qv_experiment import QuantumVolume -from .qv_analysis import QuantumVolumeAnalysis +from .qv_analysis import QuantumVolumeAnalysis, QuantumVolumePlotter diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 730b4bb020..92bc1a207a 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -15,17 +15,130 @@ import math import warnings -from typing import Optional +from typing import List import numpy as np import uncertainties from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.curve_analysis.visualization import plot_scatter, plot_errorbar from qiskit_experiments.framework import ( BaseAnalysis, AnalysisResultData, Options, ) +from qiskit_experiments.visualization import BasePlotter, MplDrawer + + +class QuantumVolumePlotter(BasePlotter): + """Plotter for QuantumVolumeAnalysis + + .. note:: + + This plotter only supports one series, named ``hops``, which it expects + to have an ``individual`` data key containing the individual heavy + output probabilities for each circuit in the experiment. Additional + series will be ignored. + """ + + @classmethod + def expected_series_data_keys(cls) -> List[str]: + """Returns the expected series data keys supported by this plotter. + + Data Keys: + individual: Heavy-output probability fraction for each individual circuit + """ + return ["individual"] + + @classmethod + def expected_supplementary_data_keys(cls) -> List[str]: + """Returns the expected figures data keys supported by this plotter. + + Data Keys: + depth: The depth of the quantun volume circuits used in the experiment + """ + return ["depth"] + + def set_supplementary_data(self, **data_kwargs): + """Sets supplementary data for the plotter. + + Args: + data_kwargs: See :meth:`expected_supplementary_data_keys` for the + expected supplementary data keys. + """ + # Hook method to capture the depth for inclusion in the plot title + if "depth" in data_kwargs: + self.set_figure_options( + figure_title=( + f"Quantum Volume experiment for depth {data_kwargs['depth']}" + " - accumulative hop" + ), + ) + super().set_supplementary_data(**data_kwargs) + + @classmethod + def _default_figure_options(cls) -> Options: + options = super()._default_figure_options() + options.xlabel = "Number of Trials" + options.ylabel = "Heavy Output Probability" + options.figure_title = "Quantum Volume experiment - accumulative hop" + options.series_params = { + "hop": {"color": "gray", "symbol": "."}, + "threshold": {"color": "black", "linestyle": "dashed", "linewidth": 1}, + "hop_cumulative": {"color": "r"}, + "hop_twosigma": {"color": "lightgray"}, + } + return options + + @classmethod + def _default_options(cls) -> Options: + options = super()._default_options() + options.style["figsize"] = (6.4, 4.8) + options.style["axis_label_size"] = 14 + options.style["symbol_size"] = 2 + return options + + def _plot_figure(self): + (hops,) = self.data_for("hops", ["individual"]) + trials = np.arange(1, 1 + len(hops)) + hop_accumulative = np.cumsum(hops) / trials + hop_twosigma = 2 * (hop_accumulative * (1 - hop_accumulative) / trials) ** 0.5 + + self.drawer.line( + trials, + hop_accumulative, + name="hop_cumulative", + label="Cumulative HOP", + legend=True, + ) + self.drawer.hline( + 2 / 3, + name="threshold", + label="Threshold", + legend=True, + ) + self.drawer.scatter( + trials, + hops, + name="hop", + label="Individual HOP", + legend=True, + linewidth=1.5, + ) + self.drawer.filled_y_area( + trials, + hop_accumulative - hop_twosigma, + hop_accumulative + hop_twosigma, + alpha=0.5, + legend=True, + name="hop_twosigma", + label="2σ", + ) + + self.drawer.set_figure_options( + ylim=( + max(hop_accumulative[-1] - 4 * hop_twosigma[-1], 0), + min(hop_accumulative[-1] + 4 * hop_twosigma[-1], 1), + ), + ) class QuantumVolumeAnalysis(BaseAnalysis): @@ -49,10 +162,12 @@ def _default_options(cls) -> Options: Analysis Options: plot (bool): Set ``True`` to create figure for fit result. ax (AxesSubplot): Optional. A matplotlib axis object to draw. + plotter (BasePlotter): Plotter object to use for figure generation. """ options = super()._default_options() options.plot = True options.ax = None + options.plotter = QuantumVolumePlotter(MplDrawer()) return options def _run_analysis(self, experiment_data): @@ -77,8 +192,9 @@ def _run_analysis(self, experiment_data): hop_result, qv_result = self._calc_quantum_volume(heavy_output_prob_exp, depth, num_trials) if self.options.plot: - ax = self._format_plot(hop_result, ax=self.options.ax) - figures = [ax.get_figure()] + self.options.plotter.set_series_data("hops", individual=hop_result.extra["HOPs"]) + self.options.plotter.set_supplementary_data(depth=hop_result.extra["depth"]) + figures = [self.options.plotter.figure()] else: figures = None return [hop_result, qv_result], figures @@ -238,73 +354,3 @@ def _calc_quantum_volume(self, heavy_output_prob_exp, depth, trials): }, ) return hop_result, qv_result - - @staticmethod - def _format_plot( - hop_result: AnalysisResultData, ax: Optional["matplotlib.pyplot.AxesSubplot"] = None - ): - """Format the QV plot - - Args: - hop_result: the heavy output probability analysis result. - ax: matplotlib axis to add plot to. - - Returns: - AxesSubPlot: the matplotlib axes containing the plot. - """ - trials = hop_result.extra["trials"] - heavy_probs = hop_result.extra["HOPs"] - trial_list = np.arange(1, trials + 1) # x data - - hop_accumulative = np.cumsum(heavy_probs) / trial_list - two_sigma = 2 * (hop_accumulative * (1 - hop_accumulative) / trial_list) ** 0.5 - - # Plot individual HOP as scatter - ax = plot_scatter( - trial_list, - heavy_probs, - ax=ax, - s=3, - zorder=3, - label="Individual HOP", - ) - # Plot accumulative HOP - ax.plot(trial_list, hop_accumulative, color="r", label="Cumulative HOP") - - # Plot two-sigma shaded area - ax = plot_errorbar( - trial_list, - hop_accumulative, - two_sigma, - ax=ax, - fmt="none", - ecolor="lightgray", - elinewidth=20, - capsize=0, - alpha=0.5, - label="2$\\sigma$", - ) - # Plot 2/3 success threshold - ax.axhline(2 / 3, color="k", linestyle="dashed", linewidth=1, label="Threshold") - - ax.set_ylim( - max(hop_accumulative[-1] - 4 * two_sigma[-1], 0), - min(hop_accumulative[-1] + 4 * two_sigma[-1], 1), - ) - - ax.set_xlabel("Number of Trials", fontsize=14) - ax.set_ylabel("Heavy Output Probability", fontsize=14) - - ax.set_title( - "Quantum Volume experiment for depth " - + str(hop_result.extra["depth"]) - + " - accumulative hop", - fontsize=14, - ) - - # Re-arrange legend order - handles, labels = ax.get_legend_handles_labels() - handles = [handles[1], handles[2], handles[0], handles[3]] - labels = [labels[1], labels[2], labels[0], labels[3]] - ax.legend(handles, labels) - return ax diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index ea63e0afd2..d9fb1af075 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -403,6 +403,30 @@ def line( options: Valid options for the drawer backend API. """ + @abstractmethod + def hline( + self, + y_value: float, + name: Optional[SeriesName] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Draw a horizontal line. + + Args: + y_value: Y value for 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. + options: Valid options for the drawer backend API. + """ + @abstractmethod def filled_y_area( self, diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index 2a175744a7..2d6698e60f 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -120,6 +120,37 @@ def line( """ self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) + # pylint: disable=unused-argument + def hline( + self, + y_value: float, + name: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Draw a horizontal line. + + .. note:: + + This method was added to fulfill the + :class:`~qiskit_experiments.visualization.BaseDrawer` interface, + but it is not supported for this class since there was no + equivalent in + :class:`~qiskit_experiments.curve_analysis.visualization.BaseCurveDrawer`. + + Args: + y_value: Y value for line. + name: Name of this series. + label: Unsupported label option + legend: Unsupported legend option + options: Additional options + """ + warnings.warn( + "hline is not supported by the LegacyCurveCompatDrawer", + UserWarning, + ) + # pylint: disable=unused-argument def filled_y_area( self, diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index d2e6956c15..6ab12bfaaa 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -436,13 +436,34 @@ def line( draw_ops = { "color": color, - "linestyle": "-", - "linewidth": 2, + "linestyle": series_params.get("linestyle", "-"), + "linewidth": series_params.get("linewidth", 2), } self._update_label_in_options(draw_ops, name, label, legend) draw_ops.update(**options) self._get_axis(axis).plot(x_data, y_data, **draw_ops) + def hline( + self, + y_value: float, + name: Optional[SeriesName] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + series_params = self.figure_options.series_params.get(name, {}) + axis = series_params.get("canvas", None) + color = series_params.get("color", self._get_default_color(name)) + + draw_ops = { + "color": color, + "linestyle": series_params.get("linestyle", "-"), + "linewidth": series_params.get("linewidth", 2), + } + self._update_label_in_options(draw_ops, name, label, legend) + draw_ops.update(**options) + self._get_axis(axis).axhline(y_value, **draw_ops) + def filled_y_area( self, x_data: Sequence[float], diff --git a/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml b/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml new file mode 100644 index 0000000000..e0f6acbd3f --- /dev/null +++ b/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml @@ -0,0 +1,17 @@ +--- +features: + - | + An :meth:`~qiskit_experiments.visualization.BasePlotter.hline` method was + added to :class:`~qiskit_experiments.visualization.BasePlotter` for + generating horizontal lines. See `#1348 + `__. + - | + The + :class:`~qiskit_experiments.library.quantum_volume.QuantumVolumeAnalysis` + analysis class was updated to use + :class:`~qiskit_experiments.library.quantum_volume.QuantumVolumePlotter` + for its figure generation. The appearance of the figure should be the same + as in previous + releases, but now it is easier to customize the figure by setting options + on the plotter object. See `#1348 + `__. diff --git a/test/base.py b/test/base.py index fc73f5665e..eda6c5d177 100644 --- a/test/base.py +++ b/test/base.py @@ -125,11 +125,7 @@ def setUpClass(cls): # ``QiskitTestCase`` sets all warnings to be treated as an error by # default. # pylint: disable=invalid-name - allow_deprecationwarning_message = [ - # TODO: Remove in 0.6, when submodule `.curve_analysis.visualization` is removed. - r".*Plotting and drawing functionality has been moved", - r".*Legacy drawers from `.curve_analysis.visualization are deprecated", - ] + allow_deprecationwarning_message = [] for msg in allow_deprecationwarning_message: warnings.filterwarnings("default", category=DeprecationWarning, message=msg) diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index ee451370e6..f6c23c055c 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -75,6 +75,17 @@ def line( """Does nothing.""" pass + def hline( + self, + y_value: float, + name: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, + **options, + ): + """Does nothing.""" + pass + def filled_y_area( self, x_data: Sequence[float],