Matplotlib 图表设计指南
今天我们不讨论 matplotlib 怎么使用,而是从另一个角度来谈谈,如何用 matplotlib 制作一副美观与高可读性的图表。
python 有很多很好看的图表库,matplotlib 应该是最强大的一个,对于算法和数据科学家来说,他们可以用简单的几句话绘制出复杂的图形。然而我们也必须承认,要用 matplotlib 来“设计”出一个具有高可读性与美观的图表还是有一定难度的——尽管你可以用 seaborn 之类更高层的库来改变一些默认样式与颜色,然而我觉得仅仅改变颜色的话,距离一副好的图表还有很大的距离。
设计师 Go Ando 分享了他的设计笔记: データ視覚化のデザイン #1|Go Ando / THE GUILD|note ,讲解了如何设计更直观优美的图表,而我们今天就来跟着 Go Ando 的指引来重新设计我们的 matplotlib 代码。我们将以以下三个图表的改造为例子:
更直观的柱状图
我们可以看到,上图左边是用表格进行展示的,而右边是用柱状图进行展示,柱状图可以有效地表达数字之间的大小关系。而颜色和数字的逗号分隔符对于数字大小的识别也有很大的帮助。现在我们用 matplotlib 来重现这样的柱状图:
combatpower = pd.DataFrame([530000, 120000, 10000],
index=['弗利萨', '杰纽', '克林'],
columns=['战斗力'])
combatpower.plot.barh()
这是用 matplotlib 绘制的默认的柱状图样式,你会发现这样的展示非常不直观。我们罗列一下要做的事情:
- 去掉图例标识
- 缩小每个柱之间的距离
- 将 y 轴逆序
- 消除四周的边框
- 去除 x 和 y 轴无用的刻度
- 去掉 x 轴无用的标签
- 增加 y 轴标签尺寸
- 在右侧显示实际值
好了让我们动手写一下:
# 去掉图例 legend=False
# 缩小柱间距 width=0.8
fig, ax = plt.subplots(figsize=(6, 3))
combatpower.plot.barh(legend=False, ax=ax, width=0.8)
# 逆序显示 y 轴
ax.invert_yaxis()
# 去除四周的边框 (spine.set_visible(False))
[spine.set_visible(False) for spine in ax.spines.values()]
# 也可以用下面的语句
# sides = ['left', 'right', 'top', 'bottom']
# [ax.spines[side].set_visible(False) for side in sides]
# 去除 x 和 y 轴之间无用的刻度 tick
# 去除 x 轴上无用的标签
ax.tick_params(bottom=False, left=False, labelbottom=False)
# 增加 y 轴标签的尺寸
ax.tick_params(axis='y', labelsize='x-large')
# 右侧实际的值展示
vmax = combatpower['战斗力'].max()
for i, value in enumerate(combatpower['战斗力']):
ax.text(value + vmax * 0.02, i, f'{value:,}', fontsize = 'x-large', va = 'center', color = 'C0')
plt.title('龙珠战斗力', fontsize=18)
plt.show()
知识点解说
柱状图的 width 参数
在 barh() 函数中我们可以用 width 来指定每个柱子宽度和总宽度占比值,在 matplotlib 中,默认是 0.8,而 pandas 默认是 0.45。我们在这里是调用 pandas 直接生成柱状图,当柱状图横向显示的时候,行间距不宜过宽,所以我们手动设置了 0.8。
边框线条 ax.spines 设置
正如 文档 说明的,图表的四周边框叫做 spines,在 matplotlib 中被存储在一个 OrderDict 的字典中,它的用法和普通 Dict 是相同的。我使用 Dict.values 来迭代边框,然后依次设置为不可见属性,你也可以用 set_color 达到同样的效果。
ax.spines
> OrderedDict([('left', <matplotlib.spines.Spine at 0x10bc6d080>),
('right', <matplotlib.spines.Spine at 0x10bc6d940>),
('bottom', <matplotlib.spines.Spine at 0x10bc6dc18>),
('top', <matplotlib.spines.Spine at 0x10bc4a358>)])
使用 tick_params 调整 tick 刻度和属性
我们要用 ax.tick_params 调整 tick 刻度以及 tick_label 的属性,而不是直接访问属性进行设置,所以你需要参阅官方文档看看应该怎么调整修改目标,包含 x 轴,y 轴,主轴,次轴等等。
ax.text 的用法
在柱状图右侧是显示值的部分,为了保证能放下文字,所以我们留白了一部分。并且其实光看数字,我们人眼很难看清数字的位数,所以我们使用 python 3.6 的 f-string 的特性,在数字中加入逗号以提升可读性。
'CN' 颜色表示法
在 matplotlib 的 2.0 版本中,引入了这种新的颜色表示方法,matplotlib 的默认颜色可以这么获取:
print(plt.rcParams['axes.prop_cycle'].by_key()['color'])
> [(0.8941176470588236, 0.10196078431372549, 0.10980392156862745), (0.21568627450980393, 0.49411764705882355, 0.7215686274509804), (0.30196078431372547, 0.6862745098039216, 0.2901960784313726), (0.596078431372549, 0.3058823529411765, 0.6392156862745098), (1.0, 0.4980392156862745, 0.0), (1.0, 1.0, 0.2), (0.6509803921568628, 0.33725490196078434, 0.1568627450980392), (0.9686274509803922, 0.5058823529411764, 0.7490196078431373), (0.6, 0.6, 0.6)]
你可以使用 C0,C1,C2 这样的名称来指代第 1,2,3 个颜色,然后在传递 color 参数的时候使用它们。
提高线性图的可读性
我们先来看一下好的线图与坏的线图之间究竟有什么区别:
在上边可以看到好的线图第一个特点是图例与数据时有机结合起来的,你不需要频繁地在线条和图例中进行切换。
在人脑的短暂记忆容量是非常有限的,过多的不必要的信息关联将影像人的认知能力。如果你需要只展示上图中的通用电气(GM)的数据,那么可以灰化其他数据:
现在你对一个好的折线图已经有一个大致想法了,让我们来试着用 matplotlib 来制作这一个图表:
carsales = pd.DataFrame([[970, 1010, 1015, 1008],
[975, 1020, 1002, 1035],
[975, 985, 995, 999]],
index=['Toyota', 'VW', 'GM'], columns=[2013, 2014, 2015, 2016])
carsales
现在让我们先来可视化一下数据,由于这个表格的不是以时间为索引的,所以我们需要先转置一下。
carsales.T.plot()
这个图表的可读性还不够,我们应该进行以下的修改:
- 调整线条颜色为三原色,提高对比度
- 去掉图例
- 增加线宽
- 显示 y 轴标签
- 更改 x 轴与 y 轴的显示范围
- 更改 y 轴标签的颜色
- 更改 x 轴与 y 轴的刻度位置
- 更改 x 轴与 y 轴的标签颜色
- 在 y 轴上显示网格
- 擦除左右边框
- 更改底部边框
- 在线条右侧显示名称
# 对 tick 位置,label 的设置参考:
# https://matplotlib.org/api/ticker_api.html
from matplotlib.ticker import MultipleLocator
# 调整线条颜色为三原色
# Set1 https://matplotlib.org/examples/color/colormaps_reference.html
# https://stackoverflow.com/questions/46148193/how-to-set-default-matplotlib-axis-colour-cycle
from cycler import cycler
c = plt.get_cmap('Set1').colors
plt.rcParams['axes.prop_cycle'] = cycler(color=c)
fig, ax = plt.subplots(figsize=(5, 3))
# 去掉图例
# 增大线宽
carsales.T.plot(ax=ax, linewidth=4, legend=False)
# 显示 y 轴的标签
# 调整 x 轴和 y 轴的显示范围
year = carsales.columns.values
ax.set(ylabel='售卖台数(万)', ylim=(950, 1050), xlim=(year.min(), year.max()))
# 调整 y 轴标签的颜色
ax.yaxis.label.set_color('gray')
# x 轴和 y 轴刻度位置
ax.yaxis.set_major_locator(MultipleLocator(50))
ax.xaxis.set_major_locator(MultipleLocator(1))
# 去掉 x 与 y 上的刻度标记
ax.tick_params(bottom=False, left=False)
# 调整 x 轴与 y 轴的颜色
ax.tick_params(axis='y', colors='gray') # colorではなくcolors
ax.tick_params(axis='x', colors='dimgray') # colorではなくcolors
# y 轴显示网格
ax.grid(axis='y')
# 去掉右左上的边框
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['top'].set_visible(False)
# 更改下边框的颜色
ax.spines['bottom'].set_color('dimgray')
# 在线的最右侧显示标签
for i, name in enumerate(carsales.index.values):
ax.text(year.max()+0.03, ax.lines[i].get_data()[1][-1], name,
color=f'C{i}', fontsize='large', va='center')
plt.show()
颜色的改变对于图表可读性有非常大的提升,而标签位置也让人看了非常直观。
现在我们再实现另一个情况:聚焦某条记录。
我们的做法是:
- 暂时以灰色绘制所有颜色
- 在想要关注的线条上增加颜色
- 在想要关注的标签上加粗加颜色
fig, ax = plt.subplots(figsize=(5, 3))
# 全部设置为灰色
carsales.T.plot(ax=ax, linewidth=4, legend=False, color='lightgray')
# 获取 ax.lines 数组
# GM 是最后的一条线,所以给最后一条线加颜色
ax.lines[-1].set_color('limegreen')
# 显示 y 轴的标签
# 调整 x 轴和 y 轴的显示范围
year = carsales.columns.values
ax.set(ylabel='售卖台数(万)', ylim=(950, 1050), xlim=(year.min(), year.max()))
# 调整 y 轴标签的颜色
ax.yaxis.label.set_color('gray')
# x 轴和 y 轴刻度位置
ax.yaxis.set_major_locator(MultipleLocator(50))
ax.xaxis.set_major_locator(MultipleLocator(1))
# 去掉 x 与 y 上的刻度标记
ax.tick_params(bottom=False, left=False)
# 调整 x 轴与 y 轴的颜色
ax.tick_params(axis='y', colors='gray') # colorではなくcolors
ax.tick_params(axis='x', colors='dimgray') # colorではなくcolors
# y 轴显示网格
ax.grid(axis='y')
# 去掉右左上的边框
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['top'].set_visible(False)
# 使用 for 循环找到对应标签,并更改样式
for i, name in enumerate(carsales.index.values):
if name == 'GM':
color = 'limegreen'
size = 'x-large'
else:
color = 'gray'
size = 'large'
ax.text(year.max()+0.03, ax.lines[i].get_data()[1][-1], name,
color=color, fontsize=size, va='center')
plt.show()
知识点解说
使用 color cycle 设置颜色
color cycle 是用来为多个线条自动设置颜色的颜色循环。使用 Cycle r对象,便可以从第一种颜色开始按顺序重复使用大于等于设定数量的颜色的数据。默认情况下会设置十种颜色的
tab10
,若要更改此选项,请执行以下命令。
plt.rcParams['axes.prop_cycle'] = cycler(color=颜色名单)
除了 tab10,Set1 之类的颜色,还有很多可以选择,想了解更多有关详细信息,请参阅此文章 https:// matplotlib.org/tutorial s/colors/colormaps.html 。
ax.set() 的使用
如果你需要设置大量的属性值,你一定会发现你的
ax.set_*
语句不可避免地拥挤在一起,这时候你可以用 ax.set() 来一次性解决他们。你可以把
ax.set_hoge=fuga
这样的表达式转为
ax.set({'hoge':fuga})
类似格式的表达式,节约了很多代码呢。
matplotlib 会为每个对象保持用于绘制的位置和表观数值作为属性。换句话说,你可以通过 Line2D 对象获得xy格式的数据和颜色,这些数据和颜色成为绘制线的基础。
使用
Line2D.get_data
接口获得表单数据
这回是这么用:
ax.lines[i].get_data()[1][-1]
通过在下标访问的第i个 ax.lines 对象,并通过 get_data 获得 XY 数据,我们使用的最后一个 Y 数据的值来得到 ax.text 的位置。
不要再倾斜刻度标签了
当 x 轴的横坐标是大段的日期时,你的图表末日可能就来了,你会发现你的横坐标根本显示不过来所有信息,所以 matpotlib 把他们都竖直绘制了。
mau = np.linspace(450, 990, 12) + np.random.randint(-50, 50, 12)
timeindex = pd.date_range('2017/5', periods=12, freq='MS')
# 注意如果设置为`freq='M' 那么日期点会选在月末
mau = pd.Series(mau, index=timeindex, name='MAU')
mau.plot.bar()
上面这个图是用 pandas 的默认样式绘制的,花到无法直视。我们也可以用 matplotlib 的默认样式来绘制:
plt.bar(mau.index, mau, width=10)
虽然你可以加上参数让它倾斜一定角度,或者是略去年份以减少 x 轴标签的宽度,但是显然我们可以采取一些更好的策略:
- 更改线条颜色和宽度
- 显示 y 轴标签,指定颜色
- 更改 y 轴上刻度线的位置
- 擦除多余的刻度线
- 更改 x 轴和 y 轴的颜色
- 在 y 轴上显示网格
- 去掉左右和上边框
- 更改下边框颜色
- 使 x 轴上的刻度线仅为月份
- 显示每年第一个月的年份
import matplotlib.dates as mdates
fig, ax = plt.subplots(figsize=(5, 3))
# 更改柱的颜色
ax.bar(mau.index, mau, width=20, color='coral', zorder=2, align='center')
# 指定 y 轴的颜色
ax.set_ylabel(mau.name, color='gray')
# 更改 y 轴上的刻度线位置
ax.yaxis.set_major_locator(MultipleLocator(500))
# 擦除多余的刻度线
ax.tick_params(bottom=False, left=False)
# x 轴和 y 轴的标签颜色
ax.tick_params(axis='x', colors='dimgray')
ax.tick_params(axis='y', colors='gray')
# y 轴显示网格
ax.grid(axis='y')
# 清除左右和上边框线
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
# 改变下边框的颜色
ax.spines['bottom'].set_color('dimgray')
# ax.xaxis_date() #这句话不是必要的
# 让 x 轴只显示月份
ax.xaxis.set_major_formatter(mdates.DateFormatter('%-m'))
# 设置 x 轴刻度
ax.xaxis.set_major_locator(mdates.MonthLocator())
# 首先绘制一遍图表,后面再绘制年份
fig.canvas.draw()
# 显示各个年第一个月的年份标签
for key, gr in mau.groupby(mau.index.year):
i = np.where(mau.index == gr.index[0])[0][0]