Seaborn 所绘制的可视化图片比较适合用于研究报告、学术论文等对于交互性要求较低的场景,但对于有的读者来说,可能会希望绘制出来的可视化图片除了兼具美观之外,还能够具备类似如今互联网站点上的各种 UI 的交互行为。

要知道这类交互式的可视化呈现除了设计素材之外,还需要具备前端工程师(Front-end Engineer)的经验知识,才能设计出兼具美观与交互的展示效果。

所以在类似的需求之下。像 Bokeh 这样的可交互的可视化第三方库逐渐进入到人们的视野。

Bokeh 是一个能够帮我们快速创建适用于 Web 浏览器的交互式可视化图形库,它不仅可以绘制像 Seaborn 那样的静态二维图表,同时还能构建复杂的数据看板(也称仪表盘,Dashborad),在这个过程中无需我们编写任何 JavaScript 前端代码

不过需要注意的是,Bokeh 它不单单只是出于可视化的需要,因为它还朝着结合现有或构建类似于 Web 应用而被设计,所以在使用上相比于 Seaborn 而言会相对复杂一些。

但好在 Bokeh 的官方文档编写得可以说是十分优秀。不仅包含了由浅入深渐进式的快速上手教程,还包含了详细的 API 说明,这有效降低上手难度的同时,又可以满足使用者们基本的使用需求。当然 Bokeh 也存在了高级的使用技巧,比如可以同前端代码相交互的部分,这都可以在官方文档上找到相应的说明。

同样的,在使用前我们可以通过 pip 命令对其进行安装:

# Windows
pip install bokeh

# macOS
pip3 install bokeh

在使用 Bokeh 的一个基本思路就是:先指定画布,再绘制图形,最后再对画布和图形增添亿点点细节。这个过程其实和我们在使用 Photoshop 或其他美颜软件时,对原图裁剪、滤镜调色再精修是异曲同工的步骤。

本小节笔者会以 Bokeh 官方文档中化学《元素周期表》的可视化绘制示例进行讲解(部分代码略有修改),同时为了体现展示效果,会辅以 Jupyter Notebook 相关截图展示渲染结果,最终得到以下图像:

数据准备

首先我们需要 Bokeh 库下可能会使用到的相关模块或部分:

from bokeh.io import output_notebook, show  # 1
from bokeh.models import ColumnDataSource  # 2
from bokeh.plotting import figure  # 3
from bokeh.sampledata.periodic_table import elements  # 4
from bokeh.transform import dodge, factor_cmap  # 5

以上部分说明:

  1. 引入 bokeh.io 模块下的 output_notebook() 函数开启将图片渲染至 Jupyter Notebook 的设置,而 show() 函数用于展示渲染图片;
  2. 引入 bokeh.models 模块下的 ColumnDataSource 类用于构建基于 Pandas 中 DataFrame 对象的绘图数据源;
  3. 引入 bokeh.plotting 模块下的 figure() 函数用于构建 Bokeh 画布对象;
  4. 引入 bokeh.sampledata.periodic_table 模块下的 elements 函数用于获取示例的元数周期表数据;
  5. 引入 bokeh.transform 模块下的 dodge()factor_cmap() 函数用于设置图形的展示效果,前者即设置图像元素的坐标偏移,后者即设置图像元素的颜色映射。

接着我们需要简单准备一下与可视化相关的数据。

首先是 X 与 Y 坐标,元素周期表用 X 轴表示元素族(目前共 18 个),而用 Y 轴表示周期(目前共 7 个),这里我们直接用列表进行代替:

periods = ["I", "II", "III", "IV", "V", "VI", "VII"]
groups = [str(x) for x in range(1, 19)]

其次我们需要对 elements 数据集进行处理:

  1. 将当中周期、元素族字段类型转换成字符串类型(方便作为分类变量处理);
  2. 剔除元素周期表中特殊的两个元素。

因为 Bokeh 中的示例代码已经被处理成 Pandas 中的 DataFrame 对象,所以我们可以直接进行处理即可:

>>> elements.head()
   atomic number symbol       name atomic mass      CPK  ...  density                 metal  year discovered group  period
0              1      H   Hydrogen     1.00794  #FFFFFF  ...  0.00009              nonmetal             1766     1       1
1              2     He     Helium    4.002602  #D9FFFF  ...  0.00000             noble gas             1868    18       1
2              3     Li    Lithium       6.941  #CC80FF  ...  0.54000          alkali metal             1817     1       2
3              4     Be  Beryllium    9.012182  #C2FF00  ...  1.85000  alkaline earth metal             1798     2       2
4              5      B      Boron      10.811  #FFB5B5  ...  2.46000             metalloid             1807    13       2
>>> df = (
...     elements.copy()  # 1
...     .astype({"atomic mass": "str", "group": "str"})  # 2
...     .assign(
...         period=lambda d: [periods[x - 1] for x in d.period]  # 3
...     )
...     .query("""
...         group != '-' \
...         and not symbol.str.contains('L[ru]')
...     """)  # 4
... )
>>>
>>> df.head()
   atomic number symbol       name atomic mass      CPK  ...  density                 metal  year discovered group  period
0              1      H   Hydrogen     1.00794  #FFFFFF  ...  0.00009              nonmetal             1766     1       I
1              2     He     Helium    4.002602  #D9FFFF  ...  0.00000             noble gas             1868    18       I
2              3     Li    Lithium       6.941  #CC80FF  ...  0.54000          alkali metal             1817     1      II
3              4     Be  Beryllium    9.012182  #C2FF00  ...  1.85000  alkaline earth metal             1798     2      II
4              5      B      Boron      10.811  #FFB5B5  ...  2.46000             metalloid             1807    13      II
>>> df.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 88 entries, 0 to 117
Data columns (total 21 columns):
 #   Column                    Non-Null Count  Dtype
---  ------                    --------------  -----
 0   atomic number             88 non-null     int64
 1   symbol                    88 non-null     object
 2   name                      88 non-null     object
 3   atomic mass               88 non-null     object
 4   CPK                       88 non-null     object
 5   electronic configuration  88 non-null     object
 6   electronegativity         67 non-null     float64
 7   atomic radius             69 non-null     float64
 8   ion radius                67 non-null     object
 9   van der Waals radius      37 non-null     float64
 10  IE-1                      73 non-null     float64
 11  EA                        70 non-null     float64
 12  standard state            73 non-null     object
 13  bonding type              73 non-null     object
 14  melting point             71 non-null     float64
 15  boiling point             71 non-null     float64
 16  density                   71 non-null     float64
 17  metal                     88 non-null     object
 18  year discovered           88 non-null     object
 19  group                     88 non-null     object
 20  period                    88 non-null     object
dtypes: float64(8), int64(1), object(12)
memory usage: 15.1+ KB

以上操作详解:

  1. 基于 elements 数据调用 DataFrame.copy() 方法复制一个副本后进行操作;
  2. 使用 DataFrame.astype() 方法将数据集中的 atomic massgroup 两个字段都转换成字符串类型以供后续绘图使用;
  3. 调用 DataFrame.assign() 方法将原有的 period 字段中的数字值转换成罗马数字表示;
  4. 调用 DataFrame.query() 方法过滤掉没有元素族归属(即 group != '-')且不 symbol 符号名不为 LrLu 这两个元素的数据。

创建画布

数据源准备得差不多了之后,就可以开始进入到用 Bokeh 进行绘制的阶段,但在这之前让我们把遗留的一些前期工作完成:

color_mapper = {  # 1
    "alkali metal": "#a6cee3",
    "alkaline earth metal": "#1f78b4",
    "metal": "#d93b43",
    "halogen": "#999d9a",
    "metalloid": "#e08d49",
    "noble gas": "#eaeaea",
    "nonmetal": "#f1d4Af",
    "transition metal": "#599d7A",
}

source = ColumnDataSource(df)  # 2
p = figure(  # 3
    width=900,
    height=500,
    title="Periodic table (omitting LA and AC series)",
    x_range=groups,
    y_range=list(reversed(periods)),
    toolbar_location=None,
    tools="hover",
)

以上操作详解:

  1. 创建一个字典变量 color_mapper,用于存储不同字段的颜色映射,里面的颜色值取自 RGB 颜色码;
  2. 使用将前面处理好 df 数据作为参数传入到 ColumnDataSource 中,构建一个数据源对象用于绘图时自动引用当中的数据;
  3. 使用 figure() 函数创建一个画布对象,当中主要是预先设定整个图片的尺寸、标题、X 和 Y 轴的坐标范围、交互式相关的设置。

基于数据绘制图像元素

前期工作准备就绪之后就可以进行初步绘制。这里是通过对 p 这一画布对象调用 rect() 方法为每个元素周期数据绘制矩形图像:

p.rect(
    x="group",
    y="period",
    width=0.95,
    height=0.95,
    source=source,
    fill_alpha=0.6,
    legend_field="metal",
    color=factor_cmap(
        "metal",
        palette=list(color_mapper.values()),
        factors=list(color_mapper.keys())
    ),
)

这里直接将前面处理好的 source 数据集对象传入当中之后,Bokeh 底层会自动为我们解析并处理数据点的映射、大小、可见度、颜色映射等,最终的效果如下所示:

之后我们再为这个图像增添亿点点细节,即新增元素标签、将图例位置调整、去掉网格线等。

新增元素标签

简单而言,新增的元素标签主要有那么四个部分:

  1. 原子序数。对应 atomic number 字段,在左上角位置;
  2. 元素名简写。对应 symbol 字段,黑色粗体,位于中心位置;
  3. 原子质量。对应 atomic mass 字段,在元素名简写下方位置;
  4. 元素名全称。对应 name 字段,在元素图形的最底部位置。

以上标签元素都可以通过画布对象的 text() 方法绘制,这里需要传入对应标签的 XY 坐标以及相关参数,这里直接给出相应的代码:

class Property(dict):  # 1
    def __init__(
        self,
        y,
        x=None,
        source=source,
        text_align="left",
        text_baseline="middle",
        text_font_size="16px",
        text_font_style="normal",
    ) -> None:
        super().__init__(
            y=y,
            x=x or dodge("group", -0.4, range=p.x_range),
            source=source,
            text_align=text_align,
            text_baseline=text_baseline,
            text_font_size={"value": text_font_size},
            text_font_style=text_font_style,
        )

props = {  # 2
    "atomic number": Property(
        y=dodge("period", 0.3, range=p.y_range), text_font_size="11px"
    ),
    "symbol": Property(y="period", text_font_style="bold"),
    "atomic mass": Property(y=dodge("period", -0.2, range=p.y_range), text_font_size="7px"),
    "name": Property(y=dodge("period", -0.35, range=p.y_range), text_font_size="7px"),
}

for name, prop in props.items():  # 3
    p.text(text=name, **prop)

以上操作详解:

  1. 为了避免重复编码,这里我们抽象创建一个名为 Property 的数据类型,该数据类型继承自字典(即可以作为字典使用),用于保存与参数相关的设置;除了 Y 轴坐标的 y 参数需要传递之外,其他参数均有对应的默认值:
    1. x 参数默认为由前面引入的 bokeh.transform 模块下的 dodge() 函数统一基于 group 字段生成的偏移坐标,避免标签溢出图形元素边界;
    2. source 参数默认为前面生成的数据集对象;
    3. 其余 text_ 相关的参数用于控制标签文本的展示效果,对齐、字号、字体样式等;
  2. 创建一个字典变量 props,利用字典键值对的特性以数据集的字段名称为标签,并将 Property 对象作为值保存,方便后续引用;
  3. 调用字典的 items() 方法,循环遍历 props 当中元素标签及其参数,并将其中的键(即 name)作为画布对象 text() 方法的 text 参数,然后再传递其他参数。

最后在图像上会看到相应的元素标签(为了精简截图内容,前面的部分代码已隐藏):

其他细节微调

到现在为止,整个元素周期表的可视化已初具雏形,但在细节上还有不太完整的地方:

  • 右侧遮挡其他元素的图例需要变换一下展示形式;
  • 图像仍然留存相关的 X 和 Y 轴坐标或刻度线显得整张表有些「出戏」;
  • 周期表上有两个特殊的元素族有缺失但没补全(左下角的空白部分)等。

所以我们还要再稍微对细节进行更多的调整。

首先我们先将缺失的两个特殊元素族再通过画布对象的 text() 方法直接绘制补全到图像上:

p.text(
    x=["3", "3"],
    y=["VI", "VII"],
    text=["LA", "AC"],
    text_align="center",
    text_baseline="middle",
)

以上参数和前面添加元素标签的代码类似,就不赘述,最后在图像上会以 text 参数中的字符串代替空白的部分:

接着我们可以通过修改属性的形式,直接对画布对象 p 的对应属性进行重置,只要是目前图上能被肉眼所见的属性,我们都可以随时修改。但读者不需要担心会记不住有哪些属性,因为正如笔者在本小节最开始所强调的那样,Bokeh 的官方文档记录得十分详细,我们可以直接在参考的 API 文档中找到关于画布对象所有能被修改的属性、调用的方法,而本例的属性调整代码如下所示:

p.outline_line_color = None  # 1
p.grid.grid_line_color = None  # 2
p.axis.axis_line_color = None  # 3
p.axis.major_tick_line_color = None  # 4
p.axis.major_label_standoff = 0  # 5
p.legend.orientation = "horizontal"  # 6
p.legend.location = "top_center"  # 7

以上操作详解:

  1. 将画布对象的 outline_line_color 属性设置为 None,对应将画布对象的 边框线 颜色设置成无,即看不见的透明;
  2. 将画布对象的 grid 属性的 grid_line_color 属性设置为 None,对应将画布对象内横纵坐标构成的 网格线 颜色设置成无,同样是看不见的透明;
  3. 将画布对象的 axis 属性的 axis_line_color 属性设置为 None,对应将画布对象的 X 和 Y 轴线 颜色设置成无,同样是看不见的透明;
  4. 将画布对象的 axis 属性的 major_tick_line_color 属性设置为 None,对应将画布对象的 X 和 Y 轴刻度线 颜色设置成无,同样是看不见的透明;
  5. 将画布对象的 axis 属性的 major_label_standoff 属性设置为 0,对应将画布对象的 X 和 Y 轴刻度线与轴标签之间的间距 设置成 0,即标签与刻度线重合,但因刻度线已经变为透明,所以此时只显示轴标签;
  6. 将画布对象的 legend 属性的 orientation 属性设置为 horizontal,对应将画布对象的图例 方向 从原来默认的垂直排列设置成水平排列;
  7. 将画布对象的 legend 属性的 location 属性设置为 top_center,对应将画布对象的图例 位置 从原来默认的右上角设置成居中靠上,避免遮挡右上角部分的图像元素。

最后呈现的图像就如同最开始所示一样了。

不过到这还留有一个尾巴,那就是缺少了一些交互性。到目前为止,Bokeh 默认情况下会自动帮我们向图像元素添加对应的交互行为,比如当我们将鼠标悬停(Hovering)在对应的数据点时会出现对应的数据点信息:

可以看到上面已经能显示对应数据点的索引 index 和真实数据 data,这主要是因为我们实现将 DataFrame 对象转换成了 Bokeh 特定的 ColumnDataSource 数据集对象,因此在绘图时可以引用当中的信息。

所以在这最后的尾巴,我们可以在数据集的基础上调整,这里 Bokeh 允许我们对这种悬停展示的信息进行修改,即修改 hover.tooltips 属性:

p.hover.tooltips = [
    ("Name", "@name"),
    ("Atomic number", "@{atomic number}"),
    ("Atomic mass", "@{atomic mass}"),
    ("Type", "@metal"),
    ("Electronic configuration", "@{electronic configuration}"),
]

当中传入的是一个关于悬停展示信息的元组列表,其中元组的第一个元素为展示信息的字段,第二个元素则需要通过 @ 符号来表示引用字段,如果字段名中间存在空格,那么则需要 {} 花括号来覆盖。这里其实可以简单理解为 Python 中的字符串格式化即可。

直到这一步后才宣告整个元素周期表的可视化图形绘制完成。

小节

本栏目主要介绍了在 Python 中进行数据可视化的常见方式,即分别使用 Matplotlib、Pandas 中提供的绘图方法,以及 Seaborn 和 Bokeh 等高阶的第三方绘图库来实现。

其中 Pandas 和 Seaborn 都是对 Matplotlib 的封装,原因是我们在使用 Matplotlib 进行绘图时需要我们以「堆积木式」一步步地往图上绘制元素,虽然灵活性较高但也较为繁琐。而 Pandas 提供的封装之后的绘图方法可以让我们「All-in-One Pandas」,将数据处、探索、可视化等流程中的大部分操作都可以通过 Pandas 来搞定。

如果读者认为 Pandas 默认绘图的样式较为粗糙,那么可以使用 Seaborn 来绘制相对精致的可视化图形,Seaborn 在提供精美样式的同时,也将可能常用的绘图逻辑都封装成了一个个函数接口以方便使用。

不论是通过 Pandas 还是 Seaborn 绘制可视化图形,最后得到的图形对象依旧可以与 Matplotlib 进行交互,这样我们就可以通过封装的方法或接口绘制出基本的图形样式,如果还有进一步细化的地方则通过 Matplotlib 的相关方法来完善。

我们见识了 Seaborn 以及 Bokeh 在结合 Pandas 进行可视化的过程,可以看出当数字与图像相结合时所呈现出的信息是多么丰富。

除了像 Seaborn 和 Bokeh 这两个的第三方库之外,正如笔者所提及到的,Python 社区也有其他许多优秀的可视化库,如果对于可视化感兴趣的读者在学有余力的情况下可以自行探索一番。

本栏目也仅仅只是对如何使用 Python 进行数据可视化的一点介绍,关于可视化更多的内容难点往往不是在基本图形,而是在绘图细节上;但这种细节通常较为繁琐,也不可能通过短短篇幅就能面面俱到,因此读者如果感兴趣,还需要更进一步探索。

当然可视化的细节是琐碎而且费时费力的,就如同数据清洗一般。所以通常是在确保图像上的基本核心数据点没有问题的前提下,才会进行可视化的细节调整;如果过早陷入到对于展示细节的调整中就未免有些得不偿失。