TL;DR 跳到 方法 2 的解决方案 以查看最佳解决方案,或跳到最后一个示例以查看具有单个 pandas 线图的解决方案。在所有三个示例中,仅使用 4-6 行代码突出显示周末,其余的用于格式化和重现性。
方法和工具
我知道有两种方法可以在时间序列图上突出显示周末,它们可以通过循环子图数组应用于单个图和小倍数。此答案提供了突出显示周末的解决方案,但可以轻松调整它们以适用于任何重复出现的时间段。
方法一:根据数据框索引高亮
此方法遵循问题中的代码逻辑以及链接线程中的答案。不幸的是,当周末出现在月底时,会出现问题,绘制整个周末所需的索引号超出了产生错误的索引范围。通过计算两个时间戳之间的时间差并将其添加到 DatetimeIndex 的每个时间戳中,在下面进一步显示的解决方案中解决了此问题以突出显示周末。
但仍然存在两个问题,i) 此方法不适用于频率超过一天的时间序列,以及 ii) 基于频率小于每小时(如 15 分钟)的时间序列将需要绘制许多多边形这会损害性能。出于这些原因,此处提供此方法是出于文档的目的,我建议改用方法 2。
方法二:基于x轴单位高亮
此方法使用 x 轴单位,即自时间原点 (1970-01-01) 以来的天数,独立于绘制的时间序列数据来识别周末,这使其比方法更灵活1. 仅针对每个完整的周末一天绘制亮点,对于下面的示例(根据 Jupyter Notebook 中的%%timeit 测试),这比方法 1 快两倍。这是我推荐使用的方法。
matplotlib 中可用于实现这两种方法的工具
axvspan link demo, link API(用于方法1的解决方案)
broken_barhlink demo,link API
fill_between link demo, link API(用于方法2的解决方案)
BrokenBarHCollection.span_wherelink demo,link API
在我看来,fill_between 和 BrokenBarHCollection.span_where 本质上是相同的。两者都提供了方便的 where 参数,该参数在下面进一步介绍的方法 2 的解决方案中使用。
解决方案
这是一个可重复的示例数据集,用于说明这两种方法,使用频率为 6 小时。请注意,数据框仅包含一年的数据,因此可以简单地使用 df[df.index.month == month] 选择每月数据来绘制每个子图。如果您要处理多年的 DatetimeIndex,则需要对此进行调整。
导入用于所有 3 个示例的包并为前 2 个示例创建数据集
import numpy as np # v 1.19.2
import pandas as pd # v 1.1.3
import matplotlib.pyplot as plt # v 3.3.2
import matplotlib.dates as mdates # used only for method 2
# Create sample dataset
rng = np.random.default_rng(seed=1) # random number generator
dti = pd.date_range('2018-01-01 00:00', '2018-12-31 23:59', freq='6H')
consumption = rng.integers(1000, 2000, size=dti.size)
df = pd.DataFrame(dict(consumption=consumption), index=dti)
方法一的解决方案:基于数据框索引的高亮显示
在此解决方案中,使用 axvspan 和每月数据帧的 DatetimeIndex df_month 突出显示周末。周末时间戳用df_month.index[df_month.weekday>=5].to_series()选择,超出索引范围的问题通过从DatetimeIndex的频率计算timedelta并添加到每个时间戳来解决。
当然,axvspan 也可以以比此处显示的更智能的方式使用,以便一次绘制每个周末的精彩片段,但我相信这会导致解决方案不够灵活,代码也比这里展示的要多在方法2的解决方案中。
# Draw and format subplots by looping through months and flattened array of axes
fig, axs = plt.subplots(4, 3, figsize=(10, 9), sharey=True)
for month, ax in zip(df.index.month.unique(), axs.flat):
# Select monthly data and plot it
df_month = df[df.index.month == month]
ax.plot(df_month.index, df_month['consumption'])
ax.set_ylim(0, 2500) # set limit similar to plot shown in question
# Draw vertical spans for weekends: computing the timedelta and adding it
# to the date solves the problem of exceeding the df_month.index
timedelta = pd.to_timedelta(df_month.index.freq)
weekends = df_month.index[df_month.index.weekday>=5].to_series()
for date in weekends:
ax.axvspan(date, date+timedelta, facecolor='k', edgecolor=None, alpha=.1)
# Format tick labels
ax.set_xticks(ax.get_xticks())
tk_labels = [pd.to_datetime(tk, unit='D').strftime('%d') for tk in ax.get_xticks()]
ax.set_xticklabels(tk_labels, rotation=0, ha='center')
# Add x labels for months
ax.set_xlabel(df_month.index[0].month_name().upper(), labelpad=5)
ax.xaxis.set_label_position('top')
# Add title and edit spaces between subplots
year = df.index[0].year
freq = df_month.index.freqstr
title = f'{year} consumption displayed for each month with a {freq} frequency'
fig.suptitle(title.upper(), y=0.95, fontsize=12)
fig.subplots_adjust(wspace=0.1, hspace=0.5)
fig.text(0.5, 0.99, 'Weekends are highlighted by using the DatetimeIndex',
ha='center', fontsize=14, weight='semibold');
如您所见,周末亮点在数据结束的地方结束,如 3 月份所示。如果使用 DatetimeIndex 设置 x 轴范围,这当然不会引起注意。
方法 2 的解决方案:基于 x 轴单位的高亮显示
此解决方案使用 x 轴限制以天为单位计算绘图所涵盖的时间范围,这是 matplotlib dates 使用的单位。计算 weekends 掩码,然后将其传递给 fill_between 绘图函数的 where 参数。掩码的True 值作为右排他处理,因此在这种情况下,必须包括星期一才能绘制到星期一 00:00 之前的亮点。因为绘制这些高光可能会在周末发生在边界附近时改变 x 轴范围,所以绘制后 x 轴范围将设置回原始值。
请注意,fill_between 必须提供 y1 和 y2 参数。出于某种原因,使用默认 y 轴限制会在图框与周末亮点的顶部和底部之间留下一个小间隙。在这里,y 限制设置为 0 和 2500 只是为了创建一个类似于问题中的示例,但对于一般情况应使用以下示例:ax.set_ylim(*ax.get_ylim())。
# Draw and format subplots by looping through months and flattened array of axes
fig, axs = plt.subplots(4, 3, figsize=(10, 9), sharey=True)
for month, ax in zip(df.index.month.unique(), axs.flat):
# Select monthly data and plot it
df_month = df[df.index.month == month]
ax.plot(df_month.index, df_month['consumption'])
ax.set_ylim(0, 2500) # set limit like plot shown in question, or use next line
# ax.set_ylim(*ax.get_ylim())
# Highlight weekends based on the x-axis units, regardless of the DatetimeIndex
xmin, xmax = ax.get_xlim()
days = np.arange(np.floor(xmin), np.ceil(xmax)+2)
weekends = [(dt.weekday()>=5)|(dt.weekday()==0) for dt in mdates.num2date(days)]
ax.fill_between(days, *ax.get_ylim(), where=weekends, facecolor='k', alpha=.1)
ax.set_xlim(xmin, xmax) # set limits back to default values
# Create appropriate ticks with matplotlib date tick locator and formatter
tick_loc = mdates.MonthLocator(bymonthday=np.arange(1, 31, step=5))
ax.xaxis.set_major_locator(tick_loc)
tick_fmt = mdates.DateFormatter('%d')
ax.xaxis.set_major_formatter(tick_fmt)
# Add x labels for months
ax.set_xlabel(df_month.index[0].month_name().upper(), labelpad=5)
ax.xaxis.set_label_position('top')
# Add title and edit spaces between subplots
year = df.index[0].year
freq = df_month.index.freqstr
title = f'{year} consumption displayed for each month with a {freq} frequency'
fig.suptitle(title.upper(), y=0.95, fontsize=12)
fig.subplots_adjust(wspace=0.1, hspace=0.5)
fig.text(0.5, 0.99, 'Weekends are highlighted by using the x-axis units',
ha='center', fontsize=14, weight='semibold');
如您所见,无论数据在哪里开始和结束,周末总是被完全突出显示。
方法 2 的解决方案的附加示例,包含每月时间序列和熊猫图
此图可能没有多大意义,但它说明了方法 2 的灵活性以及如何使其与 pandas 线图兼容。请注意,示例数据集使用月份开始频率,以便默认刻度与数据点对齐。
# Create sample dataset with a month start frequency
rng = np.random.default_rng(seed=1) # random number generator
dti = pd.date_range('2018-01-01 00:00', '2018-06-30 23:59', freq='MS')
consumption = rng.integers(1000, 2000, size=dti.size)
df = pd.DataFrame(dict(consumption=consumption), index=dti)
# Draw pandas plot: x_compat=True converts the pandas x-axis units to matplotlib
# date units
ax = df.plot(x_compat=True, figsize=(10, 4), legend=None)
ax.set_ylim(0, 2500) # set limit similar to plot shown in question, or use next line
# ax.set_ylim(*ax.get_ylim())
# Highlight weekends based on the x-axis units, regardless of the DatetimeIndex
xmin, xmax = ax.get_xlim()
days = np.arange(np.floor(xmin), np.ceil(xmax)+2)
weekends = [(dt.weekday()>=5)|(dt.weekday()==0) for dt in mdates.num2date(days)]
ax.fill_between(days, *ax.get_ylim(), where=weekends, facecolor='k', alpha=.1)
ax.set_xlim(xmin, xmax) # set limits back to default values
# Additional formatting
ax.figure.autofmt_xdate(rotation=0, ha='center')
ax.set_title('2018 consumption by month'.upper(), pad=15, fontsize=12)
ax.figure.text(0.5, 1.05, 'Weekends are highlighted by using the x-axis units',
ha='center', fontsize=14, weight='semibold');
您可以在我发布的here 和here 的答案中找到此解决方案的更多示例。
参考文献:this answer by Nipun Batra、this answer by BenB、matplotlib.dates