From d91b7f69c6224ab28044cd30b5915cf2a80b7618 Mon Sep 17 00:00:00 2001 From: Will Shanks Date: Fri, 1 Dec 2023 14:09:06 -0500 Subject: [PATCH] Convert QVAnalysis to use BasePlotter 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. --- .../library/quantum_volume/qv_analysis.py | 166 ++++++++++-------- .../visualization/drawers/base_drawer.py | 24 +++ .../visualization/drawers/mpl_drawer.py | 25 ++- .../notes/qvplotter-04efe280aaa9d555.yaml | 16 ++ test/base.py | 3 - test/library/quantum_volume/test_qv.py | 24 +-- test/visualization/mock_drawer.py | 11 ++ 7 files changed, 181 insertions(+), 88 deletions(-) create mode 100644 releasenotes/notes/qvplotter-04efe280aaa9d555.yaml diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 730b4bb020..dc1e8f0b1a 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -15,7 +15,7 @@ import math import warnings -from typing import Optional +from typing import List, Optional import numpy as np import uncertainties @@ -26,6 +26,94 @@ AnalysisResultData, Options, ) +from qiskit_experiments.visualization import BasePlotter, MplDrawer + + +class QVPlotter(BasePlotter): + @classmethod + def expected_series_data_keys(cls) -> List[str]: + return ["hops"] + + @classmethod + def expected_supplementary_data_keys(cls) -> List[str]: + return ["depth"] + + def set_supplementary_data(self, **data_kwargs): + 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): + series = self.series[0] + hops, = self.data_for(series, ["hops"]) + 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): @@ -53,6 +141,7 @@ def _default_options(cls) -> Options: options = super()._default_options() options.plot = True options.ax = None + options.plotter = QVPlotter(MplDrawer()) return options def _run_analysis(self, experiment_data): @@ -77,8 +166,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", hops=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 +328,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/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 8ddf696919..ae720df4c9 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -427,13 +427,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..1ddb2c68ad --- /dev/null +++ b/releasenotes/notes/qvplotter-04efe280aaa9d555.yaml @@ -0,0 +1,16 @@ +--- +features: + - | + An :meth:`~qiskit_experiments.visualization.BasePlotter.hline` method was + added to :class:`~qiskit_experiments.visualization.BasePlotter` for + generating horizontal lines. + - | + The + :class:`~qiskit_experiments.library.quantum_volume.QuantumVolumeAnalysis` + analysis class was updated to use + :class:`~qiskit_experiments.visualization.BasePlotter` for its figure + generation. The appearance of the figure should be the same as in previous + releases. It is easier to customize the figure by setting options on the + plotter object, but currently the user must consult the plotter code as the + options are not documented. See `#1348 + `__. diff --git a/test/base.py b/test/base.py index 028b549b7b..8c8dd20b82 100644 --- a/test/base.py +++ b/test/base.py @@ -106,9 +106,6 @@ def setUpClass(cls): # 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", ] for msg in allow_deprecationwarning_message: warnings.filterwarnings("default", category=DeprecationWarning, message=msg) diff --git a/test/library/quantum_volume/test_qv.py b/test/library/quantum_volume/test_qv.py index eca6c4ce03..2dbbbfe91a 100644 --- a/test/library/quantum_volume/test_qv.py +++ b/test/library/quantum_volume/test_qv.py @@ -13,6 +13,7 @@ """ A Tester for the Quantum Volume experiment """ +import warnings from test.base import QiskitExperimentsTestCase import json import os @@ -106,15 +107,17 @@ def test_qv_sigma_decreasing(self): qv_exp = QuantumVolume(range(num_of_qubits), seed=SEED) # set number of trials to a low number to make the test faster - qv_exp.set_experiment_options(trials=2) - expdata1 = qv_exp.run(backend) - self.assertExperimentDone(expdata1) - result_data1 = expdata1.analysis_results(0) - expdata2 = qv_exp.run(backend, analysis=None) - self.assertExperimentDone(expdata2) - expdata2.add_data(expdata1.data()) - qv_exp.analysis.run(expdata2) - result_data2 = expdata2.analysis_results(0) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Must use at least 100 trials") + qv_exp.set_experiment_options(trials=2) + expdata1 = qv_exp.run(backend) + self.assertExperimentDone(expdata1) + result_data1 = expdata1.analysis_results(0) + expdata2 = qv_exp.run(backend, analysis=None) + self.assertExperimentDone(expdata2) + expdata2.add_data(expdata1.data()) + qv_exp.analysis.run(expdata2) + result_data2 = expdata2.analysis_results(0) self.assertTrue(result_data1.extra["trials"] == 2, "number of trials is incorrect") self.assertTrue( @@ -145,7 +148,8 @@ def test_qv_failure_insufficient_trials(self): exp_data = ExperimentData(experiment=qv_exp, backend=backend) exp_data.add_data(insufficient_trials_data) - qv_exp.analysis.run(exp_data) + with self.assertWarns(UserWarning): + qv_exp.analysis.run(exp_data) qv_result = exp_data.analysis_results(1) self.assertTrue( qv_result.extra["success"] is False and qv_result.value == 1, 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],