好看的图表千篇一律,能交互的可视化数据万里挑一
数据可视化不仅在我们的工作中可以起到重要作用,而且也能像艺术一样赏心悦目。借助多种可视化工具,如 ggplots 和 matplotlib,我们可以创建出有趣的可视化。之前我们也分享过 如何用 Python 创建 9 种常见的可视化图表 :
不过用它们我们很难制作出交互式图表,比如当我们想以 3D 方式展示数据的时候,我们就无法从多个角度查看数据。这个时候交互式可视化就能为我们解决这个问题,不仅能让我们以互动的形式获取数据传递的信息,也能让数据可视化更美观更有逼格,如图所示:
今天我们就分享一下如何用 Python 和 Bokeh 库制作交互式可视化图表。首先我们会用 Bokeh 生成一些简单的可视化图表,熟悉它的操作,然后用它创建交互式直方图。
Bokeh 基本原理
Bokeh 的主要理念就是通过每次创建一个图层的方式来创建交互式图形。我们首先会创建一个图表,然后为图表添加叫做“Glyphs”的元素。对于用过 ggplot 的人来说,Bokeh 中的 Glyphs 基本上就跟 ggplot 里每次往图形里添加的 geom 一样。根据不同用途,Glyphs 可以有不同的形状: 圆圈、线条、条形、弧形 等等。
我们先用方块和圆圈制作一个基本的图表,展示一下 Bokeh 的理念。首先,我们用 figure 方法制作一个图形,然后通过调用合适的方法和输入数据将我们的 Glyphs(也就是方块和圆圈)应用到图表上。最后我们展示出图表。
# bokeh 基本原理
from bokeh.plotting import figure
from bokeh.io import show, output_notebook
# 用labels创建空的图形
p = figure(plot_width = 600, plot_height = 600,
title = 'Example Glyphs',
x_axis_label = 'X', y_axis_label = 'Y')
# 样本数据
squares_x = [1, 3, 4, 5, 8]
squares_y = [8, 7, 3, 1, 10]
circles_x = [9, 12, 4, 3, 15]
circles_y = [8, 4, 11, 6, 10]
# 添加方块Glyph
p.square(squares_x, squares_y, size = 12, color = 'navy', alpha = 0.6)
# 添加圆圈Glyph
p.circle(circles_x, circles_y, size = 12, color = 'red')
# 设定在notebook中输出图形
output_notebook()
# 展示图形
show(p)
上述代码会生成如下略显简陋的图表:
当然我们使用任何绘图库都能很容易的创建这样的一幅图表,但 Bokeh 上还有一些免费的可配置工具,比如平移、放大缩小、选择和保存图形等,当我们想深度分析数据时,使用起来非常方便。
本文我们会用到纽约机场航班数据,记录了 2013 年纽约机场大约 30 万次航班的数据( https:// cran.r-project.org/web/ packages/nycflights13/nycflights13.pdf )。现在我们展示航班延误数据。
在制作图表之前,我们先加载数据,并做个简单的检查(加粗部分为输出):
# 将数据从CSV读取为数据帧
flights = pd.read_csv('../data/flights.csv', index_col=0)
# 为感兴趣的列总结数据状况
flights['arr_delay'].describe()
count 327346.000000
mean 6.895377
std 44.633292
min -86.000000
25% -17.000000
50% -5.000000
75% 14.000000
max 1272.000000
从对数据的总结中可以看到:我们一共有 327,346 个航班,最短延误时间为 -86 分钟(也就是提前了 86 分钟),最长延误时间为 1272 分钟,居然延误了 21 小时!有大约 75% 的航班只晚点了 14 分钟,所以我们可以假设超过 1000 分钟的数字可能为异常值(不是说它们不合理,只是太极端了)。在我们的直方图中,会重点关注延误时间在 -60 分钟和 +120 分钟之间的航班。
在我们初次可视化一个单独的变量时,通常会选择直方图,因为它能直观的展示数据的分布状况。在我们要创建的直方图中,X 轴的位置表示航班延误了多少分钟,条形的高度表示对应的航班数量。虽然 Bokeh 并没有内置的直方图 Glyph,但我们可以使用方块 Glyphs 自己创建,它能让我们指明每个条形的底部、顶部和左右边缘。
我们会使用 Numpy 的 histogram 函数为条形创建数据,它可以计算每个指定条形中的数据点数量。我们会使用 5 分钟长度的条形,也就是说函数会计算每 5 分钟延误间隔的航班数量。在生成数据后,我们将其放在 Pandas 数据帧中,以一个对象保存所有数据。这步的操作方法见这里: https:// pandas.pydata.org/panda s-docs/stable/dsintro.html 。 虽然这里的代码对理解 Bokeh 来说不是很重要,但熟悉使用 Numpy 和 Pandas 在数据科学中还是很重要的。
"""Bins will be five minutes in width, so the number of bins
is (length of interval / 5). Limit delays to [-60, +120] minutes using the range."""
arr_hist, edges = np.histogram(flights['arr_delay'],
bins = int(180/5),
range = [-60, 120])
# 将信息放入数据帧
delays = pd.DataFrame({'arr_delay': arr_hist,
'left': edges[:-1],
'right': edges[1:]})
我们的数据会如下所示:
其中 flight 列表示从 left 列至 right 列每个延误间隔中的航班数量计数。在这里我们会制作一个新的 Bokeh 图表,添加一个方块 Glyph,指明合适的参数:
# 创建空白图表
p = figure(plot_height = 600, plot_width = 600,
title = 'Histogram of Arrival Delays',
x_axis_label = 'Delay (min)]',
y_axis_label = 'Number of Flights')
# 添加一个方块Glyph
p.quad(bottom=0, top=delays['flights'],
left=delays['left'], right=delays['right'],
fill_color='red', line_color='black')
# 展示图表
show(p)
生成这张图形的大部分工作主要是数据格式编排,这在数据科学中很常见。从我们的图形中可以看到航班延误几乎呈轻微的正偏态分布,并在右侧出现厚尾现象。
用 Python 创建基本的直方图,还有很多简单的方法,比如用 matplotlib 只需几行代码就能生成同样的图形。不过,使用 Bokeh 制作图形时,有很多工具能让我们很容易的和数据之间产生交互。
添加交互性
首先我们给图形添加一种被动型交互,也就是查看图形的人可以筛选哪些数据不予展示。被动型交互通常扮演着“检察员”的角色,因为可以让人们更详细的“调查”数据。其中一种比较有用的被动型交互就是让用户的鼠标滑过数据点时,会浮现信息提示框,这在 Bokeh 中称为HoverTool( https:// bokeh.pydata.org/en/lat est/docs/user_guide/tools.html )。
要想添加信息提示框,我们需要将来自数据帧中的数据源改为 ColumnDataSource(Bokeh 中的一个重要概念)。这是一个对象,可以特别用于绘制包括数据和几种方法及属性的图形。ColumnDataSource 能让我们为图形添加注释和交互性,也能从 Pandas 数据帧中构造。实际数据会保存在一个目录中,可以通过 ColumnDataSource 的数据属性访问。这里我们创建来自我们的数据帧中的数据源,查看和数据帧的列对应的数据目录的 key。
# 导入ColumnDataSource 类
from bokeh.models import ColumnDataSource
# 将数据帧转换为column data source
src = ColumnDataSource(delays)
src.data.keys()
dict_keys(['flights', 'left', 'right', 'index'])
在我们用 ColumnDataSource 添加 Glyphs 时,我们会将 ColumnDataSource 作为 source 参数输入,并用字符串引用列名:
# 这次添加一个带有数据源的方块Glyph
p.quad(source = src, bottom=0, top='flights',
left='left', right='right',
fill_color='red', line_color='black')
注意代码是如何仅用一行字符串而不是之前的 df['column'] 格式,就引用了具体的数据列,比如“flight”“left”和“right”。
Bokeh 中的 HoverTool
刚看时 HoverTool 的语法看着可能会有点复杂,不过熟悉了之后你会发现很简单。我们传递我们的 HoverTool 实例以 Python 元组的形式传入一列 tooltips,在 Python 元组中第一个元素为数据标签,第二个元素会引用我们想突出强调的具体数据。我们可以用$引用图表的属性,比如 X 轴或 Y 轴位置,或用 @ 引用我们数据源中的具体字段。这可能听起来有点困惑,所以下面举例示范如何用 HoverTool 做到这两点:
# Hover tool 用@引用我们自己的数据域
# 用$引用图形中的位置
h = HoverTool(tooltips = [('Delay Interval Left ', '@left'),
('(x,y)', '($x, $y)')])
这里,我们用 @ 引用 ColumnDataSource 中的 left 数据域(和原始数据帧中的 left 列对应),用 $ 引用游标的(x,y)位置,结果如下:
(x,y)位置是鼠标在图形上的位置,但对我们的直方图来说并不是很有用,因为我们是要找到某个给定条形中的航班数量(出现在条形上方)。为了修正这一点,我们会变更我们的 tooltip 实例来引用正确的列。将 tooltip 中显示的数据进行格式编排会令人比较苦恼,我们可以在数据帧中以正确的格式化创建另一个列。例如,假如我想让我的 tooltip 展示某个给定条形的全部间隔,我可以在数据帧中创建一个格式化后的列:
# 添加一个列显示每个间隔的范围
delays['f_interval'] = ['%d to %d minutes' % (left, right) for left, right in zip(delays['left'], delays['right'])]
然后我们将该数据帧转换为一个 ColumnDataSource,并在 HoverTool 调用中访问这个列。下面的代码就是用了一个引用了两个格式化列的 HoverTool 创建图形,并将该工具添加至图形:
# 创建空白图形
p = figure(plot_height = 600, plot_width = 600,
title = 'Histogram of Arrival Delays',
x_axis_label = 'Delay (min)]',
y_axis_label = 'Number of Flights')
# 这次创建一个带数据源的方块Glyph
p.quad(bottom=0, top='flights', left='left', right='right', source=src,
fill_color='red', line_color='black', fill_alpha = 0.75,
hover_fill_alpha = 1.0, hover_fill_color = 'navy')
# 添加一个hover tool 引用格式化的数据列
hover = HoverTool(tooltips = [('Delay', '@f_interval'),
('Num of Flights', '@f_flights')])
# 为图形设定风格
p = style(p)
# 为图形添加hover tool
p.add_tools(hover)
# 显示图形
show(p)
在 Bokeh 中,我们通过将元素添加至原始图形中的方式将元素包含在图形中。注意在 p.quad 的 Glyph 调用中,还有一些额外元素,比如 hover_fill_alpha和hover_fill_color,它们可以改变当我们用鼠标滑过条形时 Glyph 的外观。我们还可以用 style 函数添加图形风格。
至于图形的美感,我们通常可以写一个可以应用到任何图形的函数。最终图形如下所示:
我们用鼠标滑过不同的条形时,就会得到每个条形所对应的详细统计数据:延误间隔和间隔内航班数量。如果觉得自己制作的图表不错,可以将其保存为 HTML 文件分享出去:
# 导入savings 函数
from bokeh.io import output_file
# 指定输出文件并保存
output_file('hist.html')
show(p)
创建交互式直方图
我们制作好了一幅基本的直方图,但谈不上高逼格。虽然别人在图形可以看到航班延误的分布状况,然而除此外并没有特别惊艳的地方。
如果我们想创建引人注目的可视化,我们可以让他人通过和图表之间的互动自行探究数据。比如,在我们创建的这个直方图中,一个很有价值的交互功能就是人们能够选择具体的航线进行比较,或者能在菜单选项中更改条形的宽度以更详细的查看数据。我们用 Bokeh 就能实现这些功能。
主动型互动
Bokeh 中有两种交互方式:被动型交互和主动型交互。我们前面分享了如何为图表添加简单的被动型交互功能,也就是说能让用户更详细的查看图表,但是却不能改变图表展示的信息。
那么第二种交互式图形之所以被称为主动型交互,是因为它可以改变在图形上实际展示的数据,比如选择数据的子集(例如具体的航线)和改变多项式回归拟合的角度等等。在 Bokeh 中有很多种类型的主动型交互,但这里我们重点关注叫做 Widget(即窗口小部件)的交互功能,它是可以用鼠标点击的元素,从而让我们可以控制图形的某些方面。
主动型交互式可视化会让人们觉得看数据就像是一种享受,因为能让我们按自己的方式探索数据,也比被动型可视化更能让我们感知和总结数据信息。夸奖的话这里不再多说,直接瞅瞅怎么用 Bokeh 制作主动型交互式可视化。
交互概要
如果为图形添加主动型交互,我们需要用到一些函数。对于 Bokeh 中的 Widget 交互来说,主要有 3 个实现函数:
- Make_dataset() 格式化被展示的具体数据
- Make_plot() 利用规定数据绘制图形
- Update()根据用户的选择更新图形。
格式化数据
在制作图形前,我们需要准备好后面会被展示的数据。对于我们的交互式直方图来说,我们会为用户提供三种可控的参数:
- 展示的航空公司(在代码中称为carriers)
- 图形中的展示范围,例如:-60 到+120分钟
- 直方图条形的宽度,默认为5分钟
至于为图形准备数据集的函数,我们需要能够指明每个参数。我们加载所有相关的数据并进行检查,展示一下如何在 make_dataset 函数中转换数据。
在该数据集中,每一行是一个单独的航班。arr_delay 列是航班延误的多好分钟(负数表示提前到达)。在前面我们简单处理过数据,得知一共有 327,236 个航班,最小延误时间为 -86 分钟,最长延误时间为 +1272 分钟。在 make_dataset 函数中,我们想根据数据帧中的 name 列选择航线,并根据 arr_delay 列限制航班。
为了准备直方图使用的数据,我们会用 Numpy 的 histogram 函数,它会计算每个条形中的数据点数量。在我们的例子中,也就是每个指明延误间隔中的航班数量。在前面我们制作了所有航班的直方图,但这里我们会制作出每个航空公司的直方图。由于每个航空公司的航班数量变化很大,我们可以以百分比的形式展示延误航班。这样,图形中条形的高度就表示某个航空公司的延误航班的比例。为了能将计数转变为百分比,我们会将计数除以航空公司的总计数。
下面是准备数据集的全部代码。函数取一列我们想包含在内的 carriers(即航空公司),需要绘制的最短和最长延误时间,以及以分钟为单位的指明条形宽度。
def make_dataset(carrier_list, range_start = -60, range_end = 120, bin_width = 5):
# 检查以确保范围起始值小于结束值!
assert range_start < range_end, "Start must be less than end!"
by_carrier = pd.DataFrame(columns=['proportion', 'left', 'right',
'f_proportion', 'f_interval',
'name', 'color'])
range_extent = range_end - range_start
# 循环访问所有航空公司
for i, carrier_name in enumerate(carrier_list):
# 航空公司的子集
subset = flights[flights['name'] == carrier_name]
# 创建一个指定了条形和范围的直方图
arr_hist, edges = np.histogram(subset['arr_delay'],
bins = int(range_extent / bin_width),
range = [range_start, range_end])
# 将计数除以总数并获取百分比并创建 df
arr_df = pd.DataFrame({'proportion': arr_hist / np.sum(arr_hist),
'left': edges[:-1], 'right': edges[1:] })
# 格式化百分比
arr_df['f_proportion'] = ['%0.5f' % proportion for proportion in arr_df['proportion']]
# 格式化间隔
arr_df['f_interval'] = ['%d to %d minutes' % (left, right) for left,
right in zip(arr_df['left'], arr_df['right'])]
# 为labels分配航空公司
arr_df['name'] = carrier_name
# 为每个航空公司分配不同的颜色
arr_df['color'] = Category20_16[i]
# 添加到全部数据帧
by_carrier = by_carrier.append(arr_df)
# 全部数据帧
by_carrier = by_carrier.sort_values(['name', 'left'])
# 将数据帧转换为column data source
return ColumnDataSource(by_carrier)
虽然我们本文是在讲解如何使用 Bokeh,但是没有格式化的数据,是没法制作图形的,因此我们将这部分代码也展示了出来。
函数运行于所有航空公司的结果如下:
提示一下,我们是用 Bokeh 的方块 Glyph 来制作直方图,所以我们需要提供 Glyph 的左侧、右侧和顶部(底部会固定为 0)。它们分别在 left,right 和 proportion列。Color 列会给每个航空公司分配一个唯一的颜色,f_ 列会为信息提示框提供格式处理后的文本数据。
下一个实现函数为 make_plot,它会接受 ColumnDataSource(bokeh 中一种用于绘图的对象类型),返回图形对象:
def make_plot(src):
# 带有正确labels的空白图形
p = figure(plot_width = 700, plot_height = 700,
title = 'Histogram of Arrival Delays by Carrier',
x_axis_label = 'Delay (min)', y_axis_label = 'Proportion')
# 方块 glyphs 以创建直方图
p.quad(source = src, bottom = 0, top = 'proportion', left = 'left', right = 'right',
color = 'color', fill_alpha = 0.7, hover_fill_color = 'color', legend = 'name',
hover_fill_alpha = 1.0, line_color = 'black')
# Hover tool with vline mode
hover = HoverTool(tooltips=[('Carrier', '@name'),
('Delay', '@f_interval'),
('Proportion', '@f_proportion')],
mode='vline')
p.add_tools(hover)
# Styling
p = style(p)
return p
如果我们传入一个有所有航空公司的数据源,代码会输出如下图形:
该直方图太过拥挤,因为在同一张图上绘制了 16 家航空公司!如果有太多重叠信息,我们没法比较航空公司。幸好,我们可以添加 widget,让图形更简洁,也更容易比较信息。
创建 widget 交互
我们用 Bokeh 创建好了基本图形后,通过 widget 添加交互功能就比较容易了。我们想要的第一个 widget 就是一个选择工具箱,可以让人们选择需要显示的航空公司。这里的控制操作会是一个有很多选项的勾选框,在 Bokeh 中称为 CheckboxGroup。我们导入 CheckboxGroup 类并创建一个有两个参数的实例,以此制作选择工具。实例的两个参数为:labels:我们想紧贴每个条形显示的值;active:查看的初始条形。创建含有所有航空公司的CheckboxGroup 的代码如下:
from bokeh.models.widgets import CheckboxGroup
# Create the checkbox selection element, available carriers is a
# list of all airlines in the data
carrier_selection = CheckboxGroup(labels=available_carriers,
active = [0, 1])
Bokeh 中勾选框的 labels 必须为字符串,而 active 的值则为整数。这意味着在图形中,AirTran Airways Corporation’映射到的 active 值为 0,Alaska Airlines Inc.’映射到的active 值为 1。假如我们想将选择的勾选框匹配到航空公司,我们需要确保能找到与所选整数 active 值相关的字符串名字。我们可以用 widget 的 .labels 和 .active 属性做到这点:
# 从选定值中选取航空公司
[carrier_selection.labels[i] for i in carrier_selection.active]
['AirTran Airways Corporation', 'Alaska Airlines Inc.']
在制作好选择 widget 后,我们需要将所选航空公司的勾选框和图形中显示的信息连起来。这一步我们使用 CheckboxGroup的.on_change 方法以及我们定义的一个 update 函数来完成。Update 函数会一直取 3 个实参:attr,old 和 new,并根据用户的选择控制操作来更新图形。我们改变图形中展示的数据,其实就是通过改变我们在 make_plot 函数中传入 Glyphs 的数据源来实现的。这里听起来可能有点抽象,我们就举一个 update 函数的下例子,展示它如何改变直方图,显示哪些航线:
# Update 函数取3个默认函数
def update(attr, old, new):
# Get the list of carriers for the graph
carriers_to_plot = [carrier_selection.labels[i] for i in
carrier_selection.active]
# 根据选定的航空公司和之前定义的make_dataset函数
# 制定一个新的数据集
new_src = make_dataset(carriers_to_plot,
range_start = -60,
range_end = 120,
bin_width = 5)
# 更新方块 glpyhs 中使用的数据源
src.data.update(new_src.data)
这里我们根据 CheckboxGroup 中的选定的航空公司,检索航空公司的列表以进行展示。该列表会传递到 make_dataset 函数中,后者会返回一个新列数据源。我们调用 src.data.update 和传入来自新数据源的数据来更新 Glyphs 中所用的数据源。最终,我们会用 .on_change 方法,将 carrier_selection 这个 widget 中的改动和 update 函数连起来。
# Link a change in selected buttons to the update function
carrier_selection.on_change('active', update)
每当选择或未选择一个不同的航空公司,上述操作都会调用 update 函数。最终结果是只有和所选航空公司对应的 Glyphs 才会在直方图上画出来,如下所示:
更多交互功能
现在我们已经知道了为图表创建一个交互操作的基本流程,那么我们接着就可以增加更多的元素。我们每次创建一个 widget,就会写一个 update 函数来改变图形上展示的数据,并使用 .on_change 方法将 update 函数和 widget 相连。我们也可以重新编写 update 函数从 widget 提取我们需要的值,通过这种方法用同一 update 函数增加多个元素。
下面我们实际操作一下,为图形添加另外两个控制操作:添加一个滚动条,可以让我们选择直方图中条形的宽度;添加一个范围滚动条,可以让我们设置航班的最长和最短延误时间。 创建这两个 widget 的新的 update 函数如下:
# 滚动条用来选择条形宽度,值为选定数字
binwidth_select = Slider(start = 1, end = 30,
step = 1, value = 5,
title = 'Delay Width (min)')
# 当值变化时更新图形
binwidth_select.on_change('value', update)
# 范围滚动条用来改变直方图中的最大和最小值。
range_select = RangeSlider(start = -60, end = 180, value = (-60, 120),
step = 5, title = 'Delay Range (min)')
# 当值变化时更新图形
range_select.on_change('value', update)
# 说明全部3种控制操作的update函数
def update(attr, old, new):
# 找到选定的航空公司
carriers_to_plot = [carrier_selection.labels[i] for i in carrier_selection.active]
# 将条形宽度改为选型的值
bin_width = binwidth_select.value
# 范围滚动条的值为一个元组(start,end)
range_start = range_select.value[0]
range_end = range_select.value[1]
# 创建新的ColumnDataSource
new_src = make_dataset(carriers_to_plot,
range_start = range_start,
range_end = range_end,
bin_width = bin_width)
# 更新图形上的数据
src.data.update(new_src.data)
标准的滚动条和范围滚动条如下所示:
如果有需要,我们也可以用 update 函数改变图形的其它方面,比如可以改变名称的文本信息来匹配条形的宽度:
# Change plot title to match selection
bin_width = binwidth_select.value
p.title.text = 'Delays with %d Minute Bin Width' % bin_width
在 Bokeh 中还有很多类型的交互功能,但是我们现在创建的这三个操控功能就完全能让我们“把玩”图形和图形互动了!
整合图形
我们交互式图形的所有元素都已准备就绪,我们有 3 个必需的函数:make_dataset,make_plot 和 update函数,它们可以根据用户的操作和相应的 widget,自行更改图形。现在我们定义一个图形布局,将所有的元素整合在一个图上。
from bokeh.layouts import column, row, WidgetBox
from bokeh.models import Panel
from bokeh.models.widgets import Tabs
# Put controls in a single element
controls = WidgetBox(carrier_selection, binwidth_select, range_select)