From 32f02b1e932f41de4938981239b994ebdd51f0c5 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Wed, 31 Jan 2024 02:53:43 -0500 Subject: [PATCH] Convert QVAnalysis to use BasePlotter (#1348) Add an hline method to BaseDrawer and expose linewidth and linestyle as series options. Catch expected warnings about insufficient trials in analysis tests. Remove filters preventing test failures when using the deprecated visualization APIs. --------- Co-authored-by: Conrad Haupt --- .../library/quantum_volume/__init__.py | 12 +- .../library/quantum_volume/qv_analysis.py | 194 +++++++++++------- .../visualization/drawers/base_drawer.py | 24 +++ .../drawers/legacy_curve_compat_drawer.py | 31 +++ .../visualization/drawers/mpl_drawer.py | 25 ++- .../notes/qvplotter-04efe280aaa9d555.yaml | 17 ++ test/base.py | 6 +- test/visualization/mock_drawer.py | 11 + 8 files changed, 238 insertions(+), 82 deletions(-) create mode 100644 releasenotes/notes/qvplotter-04efe280aaa9d555.yaml 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],