From d54c3f92ea12fbf5b891225c4115104be19da3b8 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 Also, add an hline method to BaseDrawer and expose linewidth and linestyle as series options. --- .../library/quantum_volume/qv_analysis.py | 166 ++++++++++-------- .../visualization/drawers/base_drawer.py | 24 +++ .../visualization/drawers/mpl_drawer.py | 25 ++- test/library/quantum_volume/test_qv.py | 30 ++-- 4 files changed, 157 insertions(+), 88 deletions(-) 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/test/library/quantum_volume/test_qv.py b/test/library/quantum_volume/test_qv.py index eca6c4ce03..1a092f30e2 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).block_for_results() + 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).block_for_results() qv_result = exp_data.analysis_results(1) self.assertTrue( qv_result.extra["success"] is False and qv_result.value == 1, @@ -171,7 +175,7 @@ def test_qv_failure_insufficient_hop(self): exp_data = ExperimentData(experiment=qv_exp, backend=backend) exp_data.add_data(insufficient_hop_data) - qv_exp.analysis.run(exp_data) + qv_exp.analysis.run(exp_data).block_for_results() qv_result = exp_data.analysis_results(1) self.assertTrue( qv_result.extra["success"] is False and qv_result.value == 1, @@ -198,7 +202,7 @@ def test_qv_failure_insufficient_confidence(self): exp_data = ExperimentData(experiment=qv_exp, backend=backend) exp_data.add_data(insufficient_confidence_data) - qv_exp.analysis.run(exp_data) + qv_exp.analysis.run(exp_data).block_for_results() qv_result = exp_data.analysis_results(1) self.assertTrue( qv_result.extra["success"] is False and qv_result.value == 1, @@ -222,7 +226,7 @@ def test_qv_success(self): exp_data = ExperimentData(experiment=qv_exp, backend=backend) exp_data.add_data(successful_data) - qv_exp.analysis.run(exp_data) + qv_exp.analysis.run(exp_data).block_for_results() results_json_file = "qv_result_moderate_noise_300_trials.json" with open(os.path.join(dir_name, results_json_file), "r", encoding="utf-8") as json_file: successful_results = json.load(json_file, cls=ExperimentDecoder)