Source code for qiskit_experiments.curve_analysis.visualization.mpl_drawer
# 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.
"""Curve drawer for matplotlib backend."""
from typing import Optional, Sequence, Tuple
import numpy as np
from matplotlib.axes import Axes
from matplotlib.cm import tab10
from matplotlib.figure import Figure
from matplotlib.markers import MarkerStyle
from matplotlib.ticker import Formatter, ScalarFormatter
from qiskit.utils import detach_prefix
from qiskit.utils.deprecation import deprecate_func
from qiskit_experiments.framework.matplotlib import get_non_gui_ax
from .base_drawer import BaseCurveDrawer
[docs]
class MplCurveDrawer(BaseCurveDrawer):
"""Curve drawer for MatplotLib backend."""
DefaultMarkers = MarkerStyle.filled_markers
DefaultColors = tab10.colors
@deprecate_func(
since="0.5",
additional_msg="Plotting and drawing of analysis figures has been moved to the new "
"`qiskit_experiments.visualization` module.",
removal_timeline="after 0.6",
package_name="qiskit-experiments",
)
def __init__(self):
super().__init__()
class PrefixFormatter(Formatter):
"""Matplotlib axis formatter to detach prefix.
If a value is, e.g., x=1000.0 and the factor is 1000, then it will be shown
as 1.0 in the ticks and its unit will be shown with the prefactor 'k'
in the axis label.
"""
def __init__(self, factor: float):
self.factor = factor
def __call__(self, x, pos=None):
return self.fix_minus(f"{x * self.factor:.3g}")
[docs]
def initialize_canvas(self):
# Create axis if empty
if not self.options.axis:
axis = get_non_gui_ax()
figure = axis.get_figure()
figure.set_size_inches(*self.options.figsize)
else:
axis = self.options.axis
n_rows, n_cols = self.options.subplots
n_subplots = n_cols * n_rows
if n_subplots > 1:
# Add inset axis. User may provide a single axis object via the analysis option,
# while this analysis tries to draw its result in multiple canvases,
# especially when the analysis consists of multiple curves.
# Inset axis is experimental implementation of matplotlib 3.0 so maybe unstable API.
# This draws inset axes with shared x and y axis.
inset_ax_h = 1 / n_rows
inset_ax_w = 1 / n_cols
for i in range(n_rows):
for j in range(n_cols):
# x0, y0, width, height
bounds = [
inset_ax_w * j,
1 - inset_ax_h * (i + 1),
inset_ax_w,
inset_ax_h,
]
sub_ax = axis.inset_axes(bounds, transform=axis.transAxes, zorder=1)
if j != 0:
# remove y axis except for most-left plot
sub_ax.set_yticklabels([])
else:
# this axis locates at left, write y-label
if self.options.ylabel:
label = self.options.ylabel
if isinstance(label, list):
# Y label can be given as a list for each sub axis
label = label[i]
sub_ax.set_ylabel(label, fontsize=self.options.axis_label_size)
if i != n_rows - 1:
# remove x axis except for most-bottom plot
sub_ax.set_xticklabels([])
else:
# this axis locates at bottom, write x-label
if self.options.xlabel:
label = self.options.xlabel
if isinstance(label, list):
# X label can be given as a list for each sub axis
label = label[j]
sub_ax.set_xlabel(label, fontsize=self.options.axis_label_size)
if j == 0 or i == n_rows - 1:
# Set label size for outer axes where labels are drawn
sub_ax.tick_params(labelsize=self.options.tick_label_size)
sub_ax.grid()
# Remove original axis frames
axis.axis("off")
else:
axis.set_xlabel(self.options.xlabel, fontsize=self.options.axis_label_size)
axis.set_ylabel(self.options.ylabel, fontsize=self.options.axis_label_size)
axis.tick_params(labelsize=self.options.tick_label_size)
axis.grid()
self._axis = axis
[docs]
def format_canvas(self):
if self._axis.child_axes:
# Multi canvas mode
all_axes = self._axis.child_axes
else:
all_axes = [self._axis]
# Add data labels if there are multiple labels registered per sub_ax.
for sub_ax in all_axes:
_, labels = sub_ax.get_legend_handles_labels()
if len(labels) > 1:
sub_ax.legend()
# Format x and y axis
for ax_type in ("x", "y"):
# Get axis formatter from drawing options
if ax_type == "x":
lim = self.options.xlim
unit = self.options.xval_unit
else:
lim = self.options.ylim
unit = self.options.yval_unit
# Compute data range from auto scale
if not lim:
v0 = np.nan
v1 = np.nan
for sub_ax in all_axes:
if ax_type == "x":
this_v0, this_v1 = sub_ax.get_xlim()
else:
this_v0, this_v1 = sub_ax.get_ylim()
v0 = np.nanmin([v0, this_v0])
v1 = np.nanmax([v1, this_v1])
lim = (v0, v1)
# Format axis number notation
if unit:
# If value is specified, automatically scale axis magnitude
# and write prefix to axis label, i.e. 1e3 Hz -> 1 kHz
maxv = max(np.abs(lim[0]), np.abs(lim[1]))
try:
scaled_maxv, prefix = detach_prefix(maxv, decimal=3)
prefactor = scaled_maxv / maxv
except ValueError:
prefix = ""
prefactor = 1
formatter = MplCurveDrawer.PrefixFormatter(prefactor)
units_str = f" [{prefix}{unit}]"
else:
# Use scientific notation with 3 digits, 1000 -> 1e3
formatter = ScalarFormatter()
formatter.set_scientific(True)
formatter.set_powerlimits((-3, 3))
units_str = ""
for sub_ax in all_axes:
if ax_type == "x":
ax = getattr(sub_ax, "xaxis")
tick_labels = sub_ax.get_xticklabels()
else:
ax = getattr(sub_ax, "yaxis")
tick_labels = sub_ax.get_yticklabels()
if tick_labels:
# Set formatter only when tick labels exist
ax.set_major_formatter(formatter)
if units_str:
# Add units to label if both exist
label_txt_obj = ax.get_label()
label_str = label_txt_obj.get_text()
if label_str:
label_txt_obj.set_text(label_str + units_str)
# Auto-scale all axes to the first sub axis
if ax_type == "x":
# get_shared_y_axes() is immutable from matplotlib>=3.6.0. Must use Axis.sharey()
# instead, but this can only be called once per axis. Here we call sharey on all axes in
# a chain, which should have the same effect.
if len(all_axes) > 1:
for ax1, ax2 in zip(all_axes[1:], all_axes[0:-1]):
ax1.sharex(ax2)
all_axes[0].set_xlim(lim)
else:
# get_shared_y_axes() is immutable from matplotlib>=3.6.0. Must use Axis.sharey()
# instead, but this can only be called once per axis. Here we call sharey on all axes in
# a chain, which should have the same effect.
if len(all_axes) > 1:
for ax1, ax2 in zip(all_axes[1:], all_axes[0:-1]):
ax1.sharey(ax2)
all_axes[0].set_ylim(lim)
# Add title
if self.options.figure_title is not None:
self._axis.set_title(
label=self.options.figure_title,
fontsize=self.options.axis_label_size,
)
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.
Returns:
Corresponding axis object.
Raises:
IndexError: When axis index is specified but no inset axis is found.
"""
if index is not None:
try:
return self._axis.child_axes[index]
except IndexError as ex:
raise IndexError(
f"Canvas index {index} is out of range. "
f"Only {len(self._axis.child_axes)} subplots are initialized."
) from ex
else:
return self._axis
def _get_default_color(self, name: str) -> Tuple[float, ...]:
"""A helper method to get default color for the curve.
Args:
name: Name of the curve.
Returns:
Default color available in matplotlib.
"""
if name not in self._curves:
self._curves.append(name)
ind = self._curves.index(name) % len(self.DefaultColors)
return self.DefaultColors[ind]
def _get_default_marker(self, name: str) -> str:
"""A helper method to get default marker for the scatter plot.
Args:
name: Name of the curve.
Returns:
Default marker available in matplotlib.
"""
if name not in self._curves:
self._curves.append(name)
ind = self._curves.index(name) % len(self.DefaultMarkers)
return self.DefaultMarkers[ind]
[docs]
def draw_raw_data(
self,
x_data: Sequence[float],
y_data: Sequence[float],
name: Optional[str] = None,
**options,
):
curve_opts = self.options.plot_options.get(name, {})
marker = curve_opts.get("symbol", self._get_default_marker(name))
axis = curve_opts.get("canvas", None)
draw_options = {
"color": "grey",
"marker": marker,
"alpha": 0.8,
"zorder": 2,
}
draw_options.update(**options)
self._get_axis(axis).scatter(x_data, y_data, **draw_options)
[docs]
def draw_formatted_data(
self,
x_data: Sequence[float],
y_data: Sequence[float],
y_err_data: Sequence[float],
name: Optional[str] = None,
**options,
):
curve_opts = self.options.plot_options.get(name, {})
axis = curve_opts.get("canvas", None)
color = curve_opts.get("color", self._get_default_color(name))
marker = curve_opts.get("symbol", self._get_default_marker(name))
draw_ops = {
"color": color,
"marker": marker,
"markersize": 9,
"alpha": 0.8,
"zorder": 4,
"linestyle": "",
}
draw_ops.update(**options)
if name:
draw_ops["label"] = name
if not np.all(np.isfinite(y_err_data)):
y_err_data = None
self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops)
[docs]
def draw_fit_line(
self,
x_data: Sequence[float],
y_data: Sequence[float],
name: Optional[str] = None,
**options,
):
curve_opts = self.options.plot_options.get(name, {})
axis = curve_opts.get("canvas", None)
color = curve_opts.get("color", self._get_default_color(name))
draw_ops = {
"color": color,
"zorder": 5,
"linestyle": "-",
"linewidth": 2,
}
draw_ops.update(**options)
self._get_axis(axis).plot(x_data, y_data, **draw_ops)
[docs]
def draw_confidence_interval(
self,
x_data: Sequence[float],
y_ub: Sequence[float],
y_lb: Sequence[float],
name: Optional[str] = None,
**options,
):
curve_opts = self.options.plot_options.get(name, {})
axis = curve_opts.get("canvas", None)
color = curve_opts.get("color", self._get_default_color(name))
draw_ops = {
"zorder": 3,
"alpha": 0.1,
"color": color,
}
draw_ops.update(**options)
self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops)
[docs]
def draw_fit_report(
self,
description: str,
**options,
):
bbox_props = {
"boxstyle": "square, pad=0.3",
"fc": "white",
"ec": "black",
"lw": 1,
"alpha": 0.8,
}
bbox_props.update(**options)
report_handler = self._axis.text(
*self.options.fit_report_rpos,
s=description,
ha="center",
va="top",
size=self.options.fit_report_text_size,
transform=self._axis.transAxes,
zorder=6,
)
report_handler.set_bbox(bbox_props)
@property
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
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
collecting all child axes drawings.
"""
return self._axis.get_figure()