#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# plot.py
"""Basic plotting class objects for DVHA-Stats based on matplotlib"""
#
# Copyright (c) 2020 Dan Cutright
# This file is part of DVHA-Stats, released under a MIT license.
# See the file LICENSE included with this distribution, also
# available at https://github.com/cutright/DVHA-Stats
from matplotlib import pyplot as plt
import numpy as np
FIGURE_COUNT = 1
[docs]class Chart:
"""Base class for charts
Parameters
----------
title : str, optional
Set the title suptitle
fig_init : bool
Automatically call pyplot.figure, store in Chart.figure
"""
def __init__(self, title=None, fig_init=True):
"""Initialization of Chart base class"""
self.title = title
self.figure = plt.figure(get_new_figure_num()) if fig_init else None
if title and fig_init:
self.figure.suptitle(title, fontsize=16)
[docs] def show(self):
"""Display this figure"""
self.activate()
plt.show()
[docs] def activate(self):
"""Activate this figure"""
plt.figure(self.figure.number)
[docs] def close(self):
"""Close this figure"""
plt.close(self.figure.number)
[docs]class Plot(Chart):
"""Generic plotting class with matplotlib
Parameters
----------
y : np.ndarray, list
The y data to be plotted (1-D only)
x : np.ndarray, list, optional
Optionally specify the x-axis values. Otherwise index+1 is used.
show : bool
Automatically plot the data if True
title : str
Set the plot title
xlabel : str
Set the x-axis title
ylabel : str
Set the y-axis title
line : bool
Plot the data as a line series
line_color : str, optional
Specify the line color
line_width : float, int
Specify the line width
line_style : str
Specify the line style
scatter : bool
Plot the data as a scatter plot (circles)
scatter_color : str, optional
Specify the scatter plot circle color
"""
def __init__(
self,
y,
x=None,
show=True,
title="Chart",
xlabel="Independent Variable",
ylabel="Dependent Variable",
line=True,
line_color=None,
line_width=1.0,
line_style="-",
scatter=True,
scatter_color=None,
):
"""Initialization of a general Plot class object"""
Chart.__init__(self, title=title)
self.x = np.linspace(1, len(y), len(y)) if x is None else np.array(x)
self.y = np.array(y) if not isinstance(y, np.ndarray) else y
self.show = show
self.xlabel = xlabel
self.ylabel = ylabel
self.line = line
self.line_color = line_color
self.line_width = line_width
self.line_style = line_style
self.scatter = scatter
self.scatter_color = scatter_color
self.activate()
self.__add_labels()
self.__add_data()
if show:
plt.show()
def __add_labels(self):
"""Set the x and y axes labels to figure"""
plt.xlabel(self.xlabel)
plt.ylabel(self.ylabel)
def __add_data(self):
"""Add scatter and/or line data to figure"""
if self.scatter:
self.add_scatter()
if self.line:
self.add_default_line()
[docs] def add_scatter(self):
"""Add scatter data to figure"""
self.activate()
plt.scatter(self.x, self.y, color=self.scatter_color)
[docs] def add_default_line(self):
"""Add line data to figure"""
self.activate()
plt.plot(
self.x,
self.y,
color=self.line_color,
linewidth=self.line_width,
linestyle=self.line_style,
)
[docs] def add_line(
self, y, x=None, line_color=None, line_width=None, line_style=None
):
"""Add another line with the provided data
Parameters
----------
y : np.ndarray, list
The y data to be plotted (1-D only)
x: np.ndarray, list, optional
Optionally specify the x-axis values. Otherwise index+1 is used.
line_color: str, optional
Specify the line color
line_width: float, int
Specify the line width
line_style : str
Specify the line style
"""
self.activate()
plt.plot(
np.linspace(1, len(y), len(y)) if x is None else x,
y,
color=line_color,
linewidth=line_width,
linestyle=line_style,
)
[docs]class ControlChart(Plot):
"""ControlChart class object
Parameters
----------
y : np.ndarray, list
Charting data
out_of_control : np.ndarray, list
The indices of y that are out-of-control
center_line : float, np.ndarray
The center line value (e.g., np.mean(y))
lcl : float, optional
The lower control limit (LCL). Line omitted if lcl is None.
ucl : float, optional
The upper control limit (UCL). Line omitted if ucl is None.
title: str
Set the plot title
xlabel: str
Set the x-axis title
ylabel: str
Set the y-axis title
line_color: str, optional
Specify the line color
line_width: float, int
Specify the line width
kwargs : any
Any additional keyword arguments applicable to the Plot class
"""
def __init__(
self,
y,
out_of_control,
center_line,
lcl=None,
ucl=None,
title="Control Chart",
xlabel="Observation",
ylabel="Charting Variable",
line_color="black",
line_width=0.75,
center_line_color="black",
center_line_width=1.0,
center_line_style="--",
limit_line_color="red",
limit_line_width=1.0,
limit_line_style="--",
**kwargs
):
"""Initialization of a ControlChart plot class object"""
self.center_line = center_line
self.lcl = lcl
self.ucl = ucl
self.out_of_control = out_of_control
self.center_line_color = center_line_color
self.center_line_width = center_line_width
self.center_line_style = center_line_style
self.limit_line_color = limit_line_color
self.limit_line_width = limit_line_width
self.limit_line_style = limit_line_style
kwargs["title"] = title
kwargs["xlabel"] = xlabel
kwargs["ylabel"] = ylabel
kwargs["line_color"] = line_color
kwargs["line_width"] = line_width
Plot.__init__(self, y, **kwargs)
self.__add_cc_data()
self.__add_table_with_limits()
if self.show:
plt.show()
def __set_y_scatter_data(self):
"""Add circles colored by out-of-control status"""
include = np.full(len(self.y), True)
for i in self.out_of_control:
include[i] = False
self.ic = {"x": self.x[include], "y": self.y[include]}
self.ooc = {"x": self.x[~include], "y": self.y[~include]}
def __add_cc_data(self):
"""Add center line and upper/lower control limit lines"""
self.add_control_limit_line(self.ucl)
self.add_control_limit_line(self.lcl)
self.add_center_line()
def __add_table_with_limits(self):
"""Add tables with center line and upper/lower control limit values"""
self.activate()
plt.subplots_adjust(bottom=0.25)
plt.table(
cellText=self.__table_text,
cellLoc="center",
colLabels=["Center Line", "LCL", "UCL"],
loc="bottom",
bbox=[0.0, -0.31, 1, 0.12],
)
@property
def __table_text(self):
"""Get text to pass into matplotlib table creation"""
props = ["center_line", "lcl", "ucl"]
text = []
for prop in props:
value = getattr(self, prop)
if isinstance(value, float):
formatter = ["E", "f"][9999 >= abs(float(value)) >= 0.1]
text.append(("%%0.3%s" % formatter) % value)
else:
text.append(str(value))
return [text]
[docs] def add_scatter(self):
"""Set scatter data, add in- and out-of-control circles"""
self.activate()
self.__set_y_scatter_data()
plt.scatter(self.ic["x"], self.ic["y"], color=self.scatter_color)
plt.scatter(self.ooc["x"], self.ooc["y"], color="red")
[docs] def add_control_limit_line(
self, limit, color=None, line_width=None, line_style=None
):
"""Add a control limit line to plot"""
self.activate()
color = self.limit_line_color if color is None else color
line_width = (
self.limit_line_width if line_width is None else line_width
)
line_style = (
self.limit_line_style if line_style is None else line_style
)
if limit is not None:
plt.plot(
[1, len(self.x)],
[limit] * 2,
color=color,
linewidth=line_width,
linestyle=line_style,
)
[docs] def add_center_line(self, color=None, line_width=None, line_style=None):
"""Add the center line to the plot"""
self.activate()
color = self.center_line_color if color is None else color
line_width = (
self.center_line_width if line_width is None else line_width
)
line_style = (
self.center_line_style if line_style is None else line_style
)
plt.plot(
[1, len(self.x)],
[self.center_line] * 2,
color=color,
linewidth=line_width,
linestyle=line_style,
)
[docs]class HeatMap(Chart):
"""Create a heat map using matplotlib.pyplot.matshow
Parameters
----------
X : np.ndarray
Input data (2-D) with N rows of observations and
p columns of variables.
xlabels : list, optional
Optionally set the variable names with a list of str
ylabels : list, optional
Optionally set the variable names with a list of str
title : str, optional
Set the title suptitle
cmap : str
matplotlib compatible color map
show : bool
Automatically show the figure
"""
def __init__(
self,
X,
xlabels=None,
ylabels=None,
title=None,
cmap="viridis",
show=True,
):
"""Initialization of a HeatMap Chart object"""
Chart.__init__(self, title=title)
self.X = X
self.x_labels = range(X.shape[1]) if xlabels is None else xlabels
self.y_labels = range(X.shape[0]) if ylabels is None else ylabels
plt.matshow(X, cmap=cmap, fignum=self.figure.number)
plt.colorbar()
self.__set_ticks()
if show:
self.show()
def __set_ticks(self):
"""Set tick labels based on x and y labels"""
plt.xticks(
range(self.X.shape[1]), self.x_labels, rotation=30, ha="left"
)
plt.yticks(range(self.X.shape[0]), self.y_labels, rotation=30)
[docs]class PCAFeatureMap(HeatMap):
"""Specialized Heat Map for PCA feature evaluation
Parameters
----------
X : np.ndarray
Input data (2-D) with N rows of observations and
p columns of variables.
features : list, optional
Optionally set the feature names with a list of str
title : str, optional
Set the title suptitle
cmap : str
matplotlib compatible color map
show : bool
Automatically show the figure
"""
def __init__(
self,
X,
features=None,
cmap="viridis",
show=True,
title="PCA Feature Heat Map",
):
"""Initialization of a HeatMap Chart object"""
HeatMap.__init__(
self,
X,
xlabels=features,
ylabels=self.get_comp_labels(X.shape[0]),
cmap=cmap,
show=show,
title=title,
)
[docs] def get_comp_labels(self, n_components):
"""Get ylabels for HeatMap"""
return [
"%s Comp" % (self.get_ordinal(n + 1)) for n in range(n_components)
]
[docs] @staticmethod
def get_ordinal(n):
"""Convert number to its ordinal (e.g., 1 to 1st)
Parameters
----------
n : int
Number to be converted to ordinal
Returns
----------
str
the ordinal of n
"""
return "%d%s" % (
n,
"tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
)
[docs]class DistributionChart(Chart):
"""Distribution plotting class object (base for histogram / boxplot
Parameters
----------
data : array-like
Input array (1-D or 2-D)
title : str
Set the plot title
xlabel : str
Set the x-axis title
ylabel : str
Set the y-axis title
kwargs : any
Any keyword argument may be set per matplotlib histogram:
https://matplotlib.org/3.3.1/api/_as_gen/matplotlib.pyplot.hist.html
"""
def __init__(
self, data, title="Chart", xlabel="Bins", ylabel="Counts", **kwargs
):
"""Initialization of Histogram class"""
self.title = title
Chart.__init__(self, title=self.title, fig_init=False)
self.data = np.array(data)
self.xlabel = xlabel
self.ylabel = ylabel
self.kwargs = kwargs
def _set_title(self):
"""Set the figure title"""
self.figure.suptitle(self.title, fontsize=16)
def _add_labels(self):
"""Set the x and y axes labels to figure"""
plt.xlabel(self.xlabel)
plt.ylabel(self.ylabel)
[docs]class Histogram(DistributionChart):
"""Histogram plotting class object
Parameters
----------
data : array-like
Input array (1-D)
bins : int, sequence, str
default: rcParams["hist.bins"] (default: 10)
If bins is an integer, it defines the number of equal-width
bins in the range.
If bins is a sequence, it defines the bin edges, including the
left edge of the first bin and the right edge of the last bin;
in this case, bins may be unequally spaced. All but the last
(righthand-most) bin is half-open. In other words, if bins is:
[1, 2, 3, 4]
then the first bin is [1, 2) (including 1, but excluding 2) and
the second [2, 3). The last bin, however, is [3, 4], which
includes 4.
If bins is a string, it is one of the binning strategies supported
by numpy.histogram_bin_edges: 'auto', 'fd', 'doane', 'scott',
'stone', 'rice', 'sturges', or 'sqrt'.
title : str
Set the plot title
xlabel : str
Set the x-axis title
ylabel : str
Set the y-axis title
kwargs : any
Any keyword argument may be set per matplotlib histogram:
https://matplotlib.org/3.3.1/api/_as_gen/matplotlib.pyplot.hist.html
"""
def __init__(
self,
data,
bins=10,
title="Histogram",
xlabel="Bins",
ylabel="Counts",
**kwargs
):
"""Initialization of Histogram class"""
self.bins = bins
DistributionChart.__init__(
self, data, title=title, xlabel=xlabel, ylabel=ylabel, **kwargs
)
self.__set_hist_data()
self._set_title()
self._add_labels()
def __set_hist_data(self):
"""Generate histogram data and add to figure"""
self.figure, self.axes = plt.subplots()
self.axes.hist(self.data, bins=self.bins, **self.kwargs)
[docs]class BoxPlot(DistributionChart):
"""Box and Whisker plotting class object
Parameters
----------
data : array-like
Input array (1-D or 2-D)
title : str, optional
Set the plot title
xlabel : str, optional
Set the x-axis title
xlabels : array-like, optional
Set the xtick labels (e.g., variable names for each box plot)
ylabel : str, optional
Set the y-axis title
kwargs : any, optional
Any keyword argument may be set per matplotlib histogram:
https://matplotlib.org/3.3.1/api/_as_gen/matplotlib.pyplot.boxplot.html
"""
def __init__(
self,
data,
title="Box and Whisker",
xlabel="",
ylabel="",
xlabels=None,
**kwargs
):
"""Initialization of Histogram class"""
self.xlabels = xlabels
DistributionChart.__init__(
self, data, title=title, xlabel=xlabel, ylabel=ylabel, **kwargs
)
self.__set_boxplot_data()
self._set_title()
self._add_labels()
self.__set_ticks()
def __set_boxplot_data(self):
"""Generate boxplot data and add to figure"""
self.figure, self.axes = plt.subplots()
self.axes.boxplot(self.data)
def __set_ticks(self):
"""Set tick labels based on variable names"""
if self.xlabels is not None:
if len(self.data.shape) == 2:
length = self.data.shape[1]
else:
length = 1
plt.xticks(
range(1, length + 1),
self.xlabels,
rotation=30,
ha="left",
)