当我做 hackathon 时我在做什么 (2)

书接上文: 当我做 hackathon 时我在做什么(1)

前文中提到,我做的第二个项目是个可视化的项目,名字叫 deneb。deneb 是天鹅座的一等星,也是夏季大三角和北十字两个星群的端点之一。deneb 是对 vega-lite 的封装,受 同样封装了 vega-ltie,深得我喜爱的 Python 的库 altair 的启发。嗯,deneb – vega – altair,聪明的你一定想到了我为什么起这样一个名字:

为什么是 vega-lite?

在数据可视化这块,我自己走了不少弯路。我最早的启蒙工具是 matplotlib [1],它很容易上手,照着例子很快就能做出还算不错的图表。后来我发现了基于 matplotlib 的 seaborn [2],提供了对统计相关的图表一个高阶的抽象,很多在 matplotlib 下很多行代码才能表达出来的图表,seaborn 一两行就搞定,非常给力。之后,因为希望做出来的图表可以有更多的交互,我又转向了 plotly [3]。plotly 使用起来更加简单,但其背后的思路和 matplotlib 一脉相承:你需要定义 fig,描述你需要绘制哪种类型的图表,x 轴,y 轴数据等信息。plotly 之所以能够交互,是因为其背后是一套 javascript 库,最终渲染出来的是一段 html 代码。如果你需要能够对可视化的图表做简单的动画,plotly 也能胜任。

我一度以为 plotly 是我的真命天子,直到有一天我敲开了 altair [4] 这个潘多拉魔盒。

altair 让我了解到其背后的 vega-lite [5],以及 vega-lite 背后的那本被称作 GG(The Grammar of Graphics)的旷世奇书。这本书的作者是 Leland Wilkinson,是数据可视化领域的大牛,他的著作影响了一代人。如果你对 GG 感兴趣,可以 youtube 里搜索 Leland 的大名,看看他对自己思想的解读。

为啥我说 GG 是旷世奇书呢?因为仅仅看了一些介绍,以及书中思想的一些片段,我就受益匪浅,感觉对数据可视化的认知提升了一个级别。比如 GG 里提到,「饼图是极坐标下的柱状图」。你品,你仔细品。

我们平时做可视化,首先接触的是各种图表的分类,但 Leland 认为:

Taxonomies of charts are harmful, just like goto in programming languages.

他觉得我们在做数据分析的时候,更多是一种探索,而分类是反探索的,因为当你用某种类型的图表来表达数据的时候,你已经对如何分析数据有了先入为主的看法。

那么什么是图表呢?Leland 认为:函数(Graph)在有限的的作用域下(Frame)通过美感(Aesthetic)表达出来,就是图表(Graphic)。

具体如何表达呢?通过组合坐标系,方面,统计方式,形状,标度,美感,再加上数据本身,共同作用出一个合适的图表:

这种方式打破了传统图表的分类法,更贴近如何去探索数据本身。

我很喜欢这里的 Aesthetics。图表是数据的视觉编码,好的视觉编码一定是要具备美感。美感可以通过大小,颜色等方面表达出来,其中最重要的表达手段,或者说视觉通道就是颜色。颜色可以描述变量的模式/规律,可以做类别标注,也可以起高亮和强调的作用。

GG 这本书除了把这些概念介绍地很透彻,还对图形的表达做了完整的形式化表述,也正因为如此,很多工具直接在 GG 的基础上进行开发,比如 R 里的 ggplot。vega 受 GG 和 ggplot2 的启发诞生,随后更加精简,更受大家欢迎的 vega-lite 又在 vega 的基础上产生。受 vega-lite 的影响,altair 开始崛起,而我受 altair 的影响,萌发了在 Elixir 下复刻 altair 的想法。

好了,关于 GG 的故事就先讲这么多,等我通读完这本大部头后,有空可以单开一文讲讲我对可视化的认知。

如何在 Elixir 上「复刻」一个 Altair

在做这次 hackathon 之前,我已经有了还算丰富的 altair 的使用经验,但我并未太多研究 vega-lite 本身。所以在做 deneb 的过程,其实就是我自己学习 vega-lite,然后把 vega-lite 的代码用 Elixir 封装起来的一个过程。vega-lite 主要有这样几种对象:

  • mark:这是属于 Geometric Objects 范畴的东西,就是你用什么图形来表述数据。比如 “bar”。
  • encoding:其中包含了坐标系和 axis / color / size 的声明,属于 Coordinate System / Aesthetics 范畴的东西。encoding 中也可以声明部分 statistics 范畴的东西。
  • transform:在视图层对数据的各种处理,属于 Statistics 范畴的东西。
  • facet/layer/concat/repeat:视图层的各种组合,属于 Facets 范畴的东西。
  • selection:定义了互动相关的操作。

下面是一个最简单的 vega-lite 的代码,完全由 JSON 表述:

{
  "$schema": "https://vega.github.io/schema/vega-lite/v4.json",
  "description": "A simple bar chart with embedded data.",
  "data": {
    "values": [
      {"a": "A", "b": 28}, {"a": "B", "b": 55}, {"a": "C", "b": 43},
      {"a": "D", "b": 91}, {"a": "E", "b": 81}, {"a": "F", "b": 53},
      {"a": "G", "b": 19}, {"a": "H", "b": 87}, {"a": "I", "b": 52}
    ]
  },
  "mark": "bar",
  "encoding": {
    "x": {"field": "a", "type": "nominal", "axis": {"labelAngle": 0}},
    "y": {"field": "b", "type": "quantitative"}
  }
}

所以,对于 deneb 来说,就是提供优雅的接口把 Elixir struct 翻译成 vega-lite 里的 JSON object。为了达到这个目标,我们需要提供对 vega-lite 语法在 Elixir 上的封装。我认为封装有几层:

  1. 传递给 deneb 要绘制的数据,和绘制这个数据所用的 vega-lite 表达,deneb 将其组合成一个可以展示的 JSON 数据。
  2. 传递给 deneb 要绘制的数据,和绘制这个数据所用的 elixir structs,deneb 将其组合并翻译成一个可以展示的 JSON 数据。
  3. 在 2 的基础上进一步封装,让每个域都有其 Elixir 语法。
  4. 在 3 的基础上提供数据校验和足够清晰的出错信息。

在 altair 接口中,已经完全没有 vega-lite 的表达式了,取而代之是对应的 Python 表达式,如果用户撰写的代码有误,Altair 能够清晰地展示错误,帮你定位问题。所以altair 实现到了第四级。然而 altair 付出的代价是四万七千行 Python 代码。就算我脑子里有个 Python-to-Elixir 的代码转换器可以逐行翻译,让我抄四万多行代码一天也抄不完。

所以,我打算一步步来。先实现第一层,让 deneb 用最小的代价跑起来。比如上面的那段代码,对应的 Elixir 代码如下:

%{
    mark: "bar",
    encoding: {
      x: %{field: "a", type: "nominal", axis: %{labelAngle: 0}},
      y: %{field: "b", type: "quantitative"}
    }
}
|> Chart.new()
|> Deneb.to_json(data)

有了这个基础,我再一步步把几个主要对象映射到 Elixir,最终形成这样的代码:

:bar
|> Mark.new()
|> Chart.new(Encoding.new(%{
      x: %{field: "a", type: "nominal", axis: %{labelAngle: 0}},
      y: %{field: "b", type: "quantitative"}
}))
|> Deneb.to_json(data)

是不是感觉两个变化并不大?但这些对象内部有一些校验,保证输入的正确性。

我虽然很喜欢使用 altair,但学会了 altair 并不能保证我同时会写 vega-lite 语法,因为 altair 自己已经成为一个厚重的 DSL,完全包裹住了 vega-lite。这其实对学习 vega-lite 不够友好。

所以,我认为 deneb 实现到第 2 层至第 3 层的封装和抽象就足够了。一来是留给我的时间不多了,二来我觉得过于厚重的封装不是那么有必要,vega-lite 自己的语法表现力足够且并不复杂。

有了基础的 deneb 的实现,接下来就是如何把生成的 vega-lite JSON 展示成图表。我需要定义一个 Viewer,用于将 JSON 数据放入一段 javascript 中,然后加载到 html 页面中。我参考了 altair_viewer,实现得不费吹灰之力。至此,用户想生成一个复杂的图形,比如证券分析里经常使用的蜡烛图,可以用几行代码轻松表述:

难道就这么简单?

当然,事情绝对不会那么简单,brick wall 总是会不期而至的。

第五次撞墙:IElixir 和 jupyter notebook

完成 ex_polars 就像打完我自己的淮海战役一样,做 deneb 的过程是摧枯拉朽,几乎不费太大的力气。一切开发妥当后,我在 Jupyter notebook 上运行我心心念念的第一个最简单的柱状图,结果,jupyter notebook 没有任何输出。我查看 chrome 的 console error,没有任何报错,这下麻烦了,如果在这里卡住,那真的就是功亏一篑啦。毕竟,一个无法支持 notebook 的可视化库,还好意思说自己为 data science 所生?

Jupyter Notebook 本不支持 Elixir,但它充分考虑了语言级别的扩展性,提供了一个 ZeroMQ 接口和 kernel 交互消息,因此,其它语言可以实现对应的 ZMQ 接口,和 Jupyter 通信。下图展示了 IPython Kernel 如何跟 Jupyter 通讯的:

Elixir 生态圈里有个 IElixir,仿照 IPython,做了对 Jupyter 的支持。IElixir 实现了基本的消息通讯,但有些细节似乎没有测试过。比如对 html 片段的支持。这也是为什么我在做 ExPolars 时, 在 Jupyter notebook 里,一切操作都正常,因为那些输出都是简单的 text;而当我想输出 deneb 生成的包含 vega-lite spec 的 html 片段时,IElixir 就无法正常工作了。

既然我定位到问题可能出在 html 上,那么,问题的解决并不麻烦。我只需在合适的地方加入打印,看 IElixir 的输出,一步步缩小问题的范围即可。最后,我成功解决了问题,并给 IElixir 的作者提交了一个 PR(还有什么比这个 PR 更能彰显 OSS-a-thon 的意义的?):

享受胜利的喜悦

当第一张图表输出到 Jupyter notebook 的输出框里时,我激动地跳了起来。一旁搭乐高的小贝茫然地看着我,不知所措中就被我抡起来往空中抛了三次。然后我又趴着示意她骑大马,绕着三楼的空地蜿蜿蜒蜒走了一圈。

随后的几个小时,就是查漏补缺,即兴发挥的时刻。我为 ExPolars 提供了 plot_singleplot_repeatplot_by_type 几个快速生成图表的功能,对标 pandas 的 df.plot 功能。比如,一行代码实现下面的可视化:

以及,一行代码实现上文中的 candlestick:

注意看这幅图,它是两个 chart 组合而成的,还使用了 selection 来提供交互。用户在选择小图的时候,大图会随之而动。

参考资料

我的 hackathon 项目:

我的 hackathon 项目:

tyrchen/ex_polars github.com
tyrchen/deneb github.com

感兴趣的同学可以关注。本文中提到的其它项目:

[1] matplotlib: matplotlib.org

[2] seaborn: seaborn.pydata.org

[3] plotly: plotly.com

[4] altair: altair-viz.github.io

[5] vega-lite: vega.github.io/vega-lite

贤者时刻

四天的 hackathon 结束后,我无比满意四天前的我的选择。因为这个选择,让我一次又一次遇见新鲜。世间一切,都是遇见,就像冷遇见暖,就有了雨,春遇到冬,有了岁月;天遇见地,有了永恒;人遇见了人,有了生命。

献上一曲小宝最近弹的 Arabesque: