書接上文:當我做 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 主要有這樣幾種對象:
下面是一個最簡單的 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 上的封裝。我認為封裝有幾層:
傳遞給 deneb 要繪製的數據,和繪製這個數據所用的 vega-lite 表達,deneb 將其組合成一個可以展示的 JSON 數據。
傳遞給 deneb 要繪製的數據,和繪製這個數據所用的 elixir structs,deneb 將其組合併翻譯成一個可以展示的 JSON 數據。
在 2 的基礎上進一步封裝,讓每個域都有其 Elixir 語法。
在 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 自己的語法表現力足夠且並不複雜。三來對於使用者而言,了解 vega-lite 的語法對他們非常有必要。因為最終 altair / deneb 這樣的工具是趕不上 vega-lite 的發展的,總會有滯後(比如現在 altair 還不支持 vega-lite 4.9 的新功能),所以用戶在極端情況下還是需要掌握 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 的支持。我在實現 ExPolars 時,使用的就是 IElixir + Jupyter Notebook 來展示功能。
然而,IElixir 實現了基本的消息通訊,但有些細節似乎沒有測試過。比如對 html 片段的支持。這也是為什麼我在做 ExPolars 時, 在 Jupyter notebook 裡,一切操作都正常,因為那些輸出都是簡單的 text;而當我想輸出 deneb 生成的包含 vega-lite spec 的 html 片段時,IElixir 就無法正常工作了。
既然我定位到問題可能出在 html 上,那麼,問題的解決並不麻煩。我只需在合適的地方加入列印,看 IElixir 的輸出,一步步縮小問題的範圍即可。最後,我成功解決了問題,並給 IElixir 的作者提交了一個 PR(還有什麼比一個對已有開源項目的 PR 更能彰顯 OSS-a-thon 的意義的?):
享受勝利的喜悅當第一張圖表輸出到 Jupyter notebook 的輸出框裡時,我激動地跳了起來。一旁搭樂高的小貝茫然地看著我,不知所措中就被我掄起來往空中拋了三次。然後我又趴在地上示意她騎大馬,待她坐定繞著三樓的空地蜿蜿蜒蜒走了一圈才心滿意足。
隨後的幾個小時,就是查漏補缺,即興發揮的時刻。我為 ExPolars 提供了 plot_single,plot_repeat 和 plot_by_type 幾個快速生成圖表的功能,對標 pandas 的 df.plot 功能。比如,一行代碼實現下面的可視化:
以及,一行代碼實現上文中的 candlestick:
注意看這幅圖,它是兩個 chart 組合而成的,還使用了 selection 來提供交互。用戶在選擇小圖的時候,大圖會隨之而動。
嗯。開森。
參考資料我的 hackathon 項目:
tyrchen/ex_polars
tyrchen/deneb
感興趣的同學可以關注。本文中提到的其它項目:
[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: