Source code for qis.plots.time_series

"""
line plots of a frame against its date index, with summary statistics in the legend.

``plot_time_series`` is the base panel most of the package draws through: one line per column,
the index on the horizontal axis, ``x_date_freq`` and ``date_format`` setting tick frequency
and label format. ``plot_time_series_2ax`` puts one frame on the left axis and another on the
right, tagging the legend entries "(left)" and "(right)" so the two scales cannot be confused.

What separates these from a plain matplotlib line plot is the legend: ``legend_stats`` appends
statistics computed on the plotted window and ``desc_table_type`` replaces the legend with a
descriptive table, so the numbers describe the data drawn rather than a wider sample. Shared
arguments are in ``qis/docs/plotting_kwargs.md``. Long-form data with an explicit grouping
column is drawn by ``qis.plots.lineplot``, not here.
"""
# packages
import warnings
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from typing import List, Union, Tuple, Optional, Dict
# qis
import qis.utils.struct_ops as sop
import qis.plots.utils as put
from qis.plots.utils import LegendStats, TrendLine, LastLabel
from qis.perfstats.desc_table import compute_desc_table, DescTableType


[docs] def plot_time_series(df: Union[pd.Series, pd.DataFrame], linestyles: List[str] = None, linewidth: float = 1.0, x_date_freq: Optional[str] = 'YE', date_format: str = '%d-%b-%y', legend_title: str = None, legend_loc: Optional[Union[str, bool]] = 'upper left', last_label: LastLabel = LastLabel.NONE, sort_by_value_stretch_factor: float = 1.0, trend_line: Optional[TrendLine] = TrendLine.NONE, trend_line_colors: List[str] = None, legend_stats: LegendStats = LegendStats.AVG_LAST, desc_table_type: DescTableType = DescTableType.NONE, legend_labels: List[str] = None, indices_for_shaded_areas: Dict[str, Tuple[int, int]] = None, xlabel: str = None, ylabel: str = None, var_format: Optional[str] = '{:,.2f}', markers: List[str] = False, title: Union[str, bool] = None, fontsize: int = 10, markersize: int = None, colors: List[str] = None, x_limits: Tuple[Optional[float], Optional[float]] = None, y_limits: Tuple[Optional[float], Optional[float]] = None, is_log: bool = False, ax: plt.Subplot = None, **kwargs ) -> Optional[plt.Figure]: """ plot columns of a DataFrame against its index, with summary statistics in the legend. The general time-series plot the rest of the package builds on. Its distinguishing argument is ``legend_stats``: the legend carries statistics computed on the plotted window, so the numbers beside a series always describe the data actually drawn. Arguments shared with every ``plot_*`` function — ``ax``, ``title``, ``var_format``, ``x_date_freq``, ``fontsize``, ``colors``, ``xlabel``, ``ylabel``, ``legend_loc``, ``markersize``, ``y_limits`` — are documented in ``qis/docs/plotting_kwargs.md``. Args: df: values indexed by date, one line per column. A Series is plotted as one line linestyles: matplotlib line styles, one per column linewidth: line width in points x_date_freq: tick frequency on the date axis date_format: strftime format for the tick labels legend_title: heading above the legend entries last_label: whether to annotate the last point of each line, and with what sort_by_value_stretch_factor: vertical spread applied when last labels are sorted by value, to keep them from overlapping trend_line: trend line drawn through each series trend_line_colors: colours for those trend lines, defaulting to the series colours legend_stats: which statistics each legend entry reports; see :class:`LegendStats` desc_table_type: descriptive statistics table drawn beside the plot legend_labels: replace the column names in the legend. Statistics from ``legend_stats`` are appended to these indices_for_shaded_areas: name to (start, end) positional index pairs, shaded to mark episodes. Positional, not dates, so it survives an irregular index markers: marker style per column, or False for lines only is_log: log scale on the vertical axis x_limits: (low, high) on the date axis, either end may be None Returns: the figure drawn on, or None when ``ax`` was supplied or ``df`` is empty """ if ax is None: fig, ax = plt.subplots() else: fig = None if df.empty: warnings.warn('df is empty: no data to plot') return fig data1 = df.copy() if isinstance(data1, pd.DataFrame): pass elif isinstance(data1, pd.Series): data1 = data1.to_frame() else: raise TypeError(f"unsuported data type {type(data1)}") columns = data1.columns if colors is None: colors = put.get_n_colors(n=len(columns), **kwargs) sns.lineplot(data=data1, palette=colors, dashes=False, markers=markers, markersize=markersize, linewidth=linewidth, ax=ax) # add tredlines if trend_line_colors is None: trend_line_colors = colors if trend_line in [TrendLine.ZERO_SHADOWS, TrendLine.ABOVE_ZERO_SHADOWS]: for column, color in zip(columns, trend_line_colors): x0 = data1.index[0] x1 = data1.index[-1] y0 = 0 y1 = 0 x = [x0, x1] y = [y0, y1] ax.plot(x, y, color='black', linestyle='-', transform=ax.transData, lw=linewidth) x_ = (data1.index - data1.index[0]).days slope = (y1 - y0) / (x_[-1] - x_[0]) y_line = [slope * x + y0 for x in x_] y = data1[column] if trend_line == TrendLine.ZERO_SHADOWS: ax.fill_between(data1.index, y, y_line, where=y_line >= y, facecolor=color, interpolate=True, alpha=0.2, lw=linewidth) else: ax.fill_between(data1.index, y, y_line, where=y_line <= y, facecolor=color, interpolate=True, alpha=0.2, lw=linewidth) elif trend_line in [TrendLine.AVERAGE, TrendLine.AVERAGE_SHADOWS]: for column, color in zip(columns, trend_line_colors): data2 = data1[column].dropna() # exclude nans from showing the average lines if data2.empty: # skip this columns from ere continue else: average = np.nanmean(data2.to_numpy()) x0 = data2.index[0] x1 = data2.index[-1] y0 = average y1 = average x = [x0, x1] y = [y0, y1] ax.plot(x, y, color=color, linestyle=':', transform=ax.transData, linewidth=linewidth) if trend_line == TrendLine.AVERAGE_SHADOWS: x_ = (data2.index - data2.index[0]).days slope = (y1 - y0) / (x_[-1] - x_[0]) y_line = [slope * x + y0 for x in x_] y = data2.to_numpy() ax.fill_between(data2.index, y, y_line, where=y_line >= y, facecolor=color, interpolate=True, alpha=0.2, lw=linewidth) elif trend_line in [TrendLine.TREND_LINE, TrendLine.TREND_LINE_SHADOWS]: for column, color in zip(columns, trend_line_colors): y = data1[column].dropna() if not y.empty: x0 = y.first_valid_index() x1 = y.index[-1] y0 = y[x0] y1 = y.iloc[-1] x = [x0, x1] y = [y0, y1] ax.plot(x, y, color=color, linestyle='--', transform=ax.transData, linewidth=linewidth) if trend_line == TrendLine.TREND_LINE_SHADOWS: x_ = (data1.index - data1.index[0]).days slope = (y1 - y0) / (x_[-1] - x_[0]) y_line = [slope * x + y0 for x in x_] y = data1[column] ax.fill_between(data1.index, y, y_line, where=y_line >= y, facecolor=color, interpolate=True, alpha=0.2, lw=linewidth) # add last labels if last_label in [LastLabel.AVERAGE_VALUE, LastLabel.AVERAGE_VALUE_SORTED]: average_dict = {} for column, color in zip(columns, colors): data2 = data1[column].dropna() # exclude nans from showing the average lines if data2.empty: # skip this columns from ere continue else: average = np.nanmean(data2.to_numpy()) if var_format is not None: average_str = var_format.format(average) else: average_str = average y1 = average variable_label = f"{column}, average={average_str}" average_dict.update({y1: [variable_label, color]}) x1 = data1.index[-1] ymin, ymax = ax.get_ylim() mid = sort_by_value_stretch_factor * (ymax - ymin) if last_label == LastLabel.AVERAGE_VALUE_SORTED: pivot_dict = sorted(average_dict) locs = np.linspace(pivot_dict[0], sort_by_value_stretch_factor*mid, len(pivot_dict), endpoint=True) else: pivot_dict = average_dict locs = [dict for dict in pivot_dict] for key, loc in zip(pivot_dict, locs): ax.annotate(average_dict[key][0], xy=(x1, key), xytext=(x1, loc), fontsize=fontsize, weight='normal', color=average_dict[key][1], textcoords='data', ha='left', va='bottom', bbox={'boxstyle': 'round,pad=0.5', 'fc': average_dict[key][1], 'alpha': 0.1}, arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0')) elif last_label in [LastLabel.LAST_VALUE, LastLabel.LAST_VALUE_SORTED]: last_dict = {} for column, color in zip(columns, colors): y = data1[column].dropna() if last_label == LastLabel.LAST_VALUE: if var_format is not None: if len(y.index) > 0: variable_str = var_format.format(y.iloc[-1]) else: variable_str = 'nan' else: variable_str = y.iloc[-1] variable_label = f"{column}, last = {variable_str}" if len(y.index) > 0: y1 = y.iloc[-1] else: y1 = np.nan last_dict.update({y1: [variable_label, color]}) # plot dicts sorted by last value x1 = data1.index[-1] ymin, ymax = ax.get_ylim() mid = sort_by_value_stretch_factor * (ymax - ymin) if last_label == LastLabel.LAST_VALUE_SORTED: pivot_dict = sorted(last_dict) locs = np.linspace(pivot_dict[0], sort_by_value_stretch_factor*mid, len(pivot_dict), endpoint=True) else: pivot_dict = last_dict locs = [dict for dict in pivot_dict] for key, loc in zip(pivot_dict,locs): ax.annotate(last_dict[key][0], xy=(x1, key), xytext=(x1, loc), fontsize=fontsize, weight ='normal', color = last_dict[key][1], textcoords='data', ha='left', va='bottom', bbox={'boxstyle': 'round,pad=0.5', 'fc': last_dict[key][1], 'alpha': 0.1}, arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0')) if indices_for_shaded_areas is not None: if len(data1.columns) == 1: warnings.warn(f"cannot do indices_for_shaded_areas = {indices_for_shaded_areas} for dataframe with one columns ") else: for col, indxs in indices_for_shaded_areas.items(): y0 = data1.iloc[:, indxs[0]] y1 = data1.iloc[:, indxs[1]] ax.fill_between(data1.index, y0, y1, where=y0 >= y1, facecolor=col, alpha=0.2, interpolate=True) if legend_loc is not None: if legend_labels is None: if desc_table_type != DescTableType.NONE: # use get_legend_with_stats_table stats_table = compute_desc_table(df=data1, desc_table_type=desc_table_type, var_format=var_format) put.set_legend_with_stats_table(stats_table=stats_table, ax=ax, colors=colors, legend_loc=legend_loc, fontsize=fontsize, **kwargs) else: # generate legend_labels legend_labels = put.get_legend_lines(data=data1, legend_stats=legend_stats, var_format=var_format) if legend_labels is not None: put.set_legend(ax=ax, labels=legend_labels, colors=colors, legend_loc=legend_loc, legend_title=legend_title, fontsize=fontsize, **kwargs) else: ax.legend().set_visible(False) if linestyles is not None: put.set_linestyles(ax=ax, linestyles=linestyles) if x_date_freq is not None and isinstance(data1.index, pd.DatetimeIndex): put.set_date_on_axis(data=data1, ax=ax, x_date_freq=x_date_freq, date_format=date_format, fontsize=fontsize, **kwargs) if y_limits is not None: put.set_y_limits(ax=ax, y_limits=y_limits) if x_limits is not None: put.set_x_limits(ax=ax, x_limits=x_limits) if var_format is not None: put.set_ax_ticks_format(ax=ax, fontsize=fontsize, xvar_format=None, yvar_format=var_format, **kwargs) if isinstance(data1.index, pd.DatetimeIndex): put.set_ax_tick_labels(ax=ax, skip_y_axis=True, fontsize=fontsize, **kwargs) put.set_ax_xy_labels(ax=ax, xlabel=xlabel, ylabel=ylabel, fontsize=fontsize, **kwargs) put.set_spines(ax=ax, **kwargs) if title is not None: put.set_title(ax=ax, title=title, fontsize=fontsize, **kwargs) if is_log: ax.set_yscale('log') return fig
[docs] def plot_time_series_2ax(df1: Union[pd.Series, pd.DataFrame], df2: Union[pd.Series, pd.DataFrame], legend_loc: Optional[str] = 'upper left', legend_stats: LegendStats = LegendStats.NONE, legend_stats2: LegendStats = LegendStats.NONE, title: Optional[str] = None, var_format: str = '{:,.0f}', var_format_yax2: str = '{:,.0f}', ylabel1: str = None, ylabel2: str = None, x_date_freq: Union[str, None] = 'YE', legend_labels: List[str] = None, linestyles: List[str] = None, linestyles_ax2: List[str] = None, y_limits: Tuple[Optional[float], Optional[float]] = None, y_limits_ax2: Tuple[Optional[float], Optional[float]] = None, trend_line1: put.TrendLine = put.TrendLine.NONE, trend_line2: put.TrendLine = put.TrendLine.NONE, yvar_major_ticks1: np.ndarray = None, yvar_major_ticks2: np.ndarray = None, colors: List[str] = None, fontsize: int = 10, x_rotation: int = 90, is_logs: Tuple[bool, bool] = (False, False), ax: plt.Subplot = None, **kwargs ) -> plt.Figure: if ax is None: fig, ax = plt.subplots() else: fig = None if df1.empty or df2.empty: warnings.warn('df1 or df2 is empty: no data to plot') return fig if isinstance(df1, pd.Series): df1 = df1.to_frame() if isinstance(df2, pd.Series): df2 = df2.to_frame() ncols1 = len(df1.columns) ncols2 = len(df2.columns) if colors is None: colors = put.get_n_colors(n=ncols1 + ncols2, **kwargs) ax_twin = ax.twinx() plot_time_series(df=df1, legend_loc=None, colors=colors[:ncols1], var_format=None, linestyles=linestyles, trend_line=trend_line1, x_date_freq=x_date_freq, is_log=is_logs[0], y_limits=y_limits, ylabel=ylabel1, fontsize=fontsize, ax=ax, **kwargs) plot_time_series(df=df2, legend_loc=None, colors=colors[ncols1:], var_format=None, linestyles=linestyles_ax2, trend_line=trend_line2, x_date_freq=x_date_freq, is_log=is_logs[1], y_limits=y_limits_ax2, ylabel=ylabel2, fontsize=fontsize, ax=ax_twin, **kwargs) ax.tick_params(axis='x', which='both', bottom=False) put.set_ax_ticks_format(ax=ax, fontsize=fontsize, xvar_format=None, yvar_format=var_format, set_ticks=False, yvar_major_ticks=yvar_major_ticks1, x_rotation=x_rotation, **kwargs) put.set_ax_ticks_format(ax=ax_twin, fontsize=fontsize, xvar_format=None, yvar_format=var_format_yax2, set_ticks=False, yvar_major_ticks=yvar_major_ticks2, x_rotation=x_rotation, **kwargs) if legend_loc is not None: if legend_labels is None: df1.columns = [f"{x} (left)" for x in df1.columns] df2.columns = [f"{x} (right)" for x in df2.columns] legend_labels1 = put.get_legend_lines(data=df1, legend_stats=legend_stats, var_format=var_format) legend_labels2 = put.get_legend_lines(data=df2, legend_stats=legend_stats2, var_format=var_format_yax2) legend_labels = sop.to_flat_list(legend_labels1 + legend_labels2) put.set_legend(ax=ax, labels=legend_labels, colors=colors, fontsize=fontsize, legend_loc=legend_loc, **kwargs) if title is not None: put.set_title(ax=ax, title=title, fontsize=fontsize) return fig