Tablecloth のDataset を Vega-Lite で可視化する時の Datetimeのあつかいについて
Clojureで Vega-Lite を使って可視化した時に、色々つまづいたのでここにメモしておきます。
Vega-Liteの基本構造
Vega-Liteで可視化するには、JSON形式で「データの見た目をどうするか?」を記述して渡します。主な構成要素は以下の通りです。
-
data
: どのデータを使うか -
mark
: グラフの種類(line, bar, など) -
encoding
:データをグラフの要素にどう対応させるか。(x軸、y軸、色など)。
たとえば、Line Chart with Point Markers | Vega-Lite の JSON を例に説明すると、
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json", // 仕様のバージョン
"description": "Stock prices of 5 Tech Companies over Time.", // グラフの説明
"data": {"url": "data/stocks.csv"}, // データソース(CSVファイル)
"mark": {
"type": "line", // 折れ線グラフ
"point": true // 折れ線に点を追加
},
"encoding": { // データと見た目の対応
"x": {"timeUnit": "year", "field": "date"}, // x軸:日付から年だけ抽出
"y": {"aggregate": "mean", "field": "price", "type": "quantitative"}, // y軸:価格の平均
"color": {"field": "symbol", "type": "nominal"} // 色:企業ごと
}
}
という具合になります。
(実際のデータはこちら: https://github.com/vega/vega/blob/main/docs/data/stocks.csv)
encoding
がクセを感じる部分なのでもう少し説明します。
-
x軸
-
"field": "date"
: データのdate
列をデータとして使う -
"timeUnit": "year"
: 日時データから年だけ取り出してX軸に使う。- vega-lite は時間のパースと変換を自動で行う。大体のフォーマットには対応できるが2025年3月3日といった特殊な例の場合はフォーマットを明示的に指定する。例:
"data": {"values": [{"date": "2025年3月3日"}], "format": {"parse": {"date": "%Y年%m月%d日"}}}
- vega-lite は時間のパースと変換を自動で行う。大体のフォーマットには対応できるが2025年3月3日といった特殊な例の場合はフォーマットを明示的に指定する。例:
-
-
y軸
-
"field": "price"
: データのprice
列をデータとして使う -
"aggregate": "mean"
:price
の平均を自動計算してy軸にプロット -
"type": "quantitative"
: データは数値ですよ、と教える。
-
Vega-Lite の注意点
Vega-Liteは、"data": {"values": [...]}
で渡されるデータが基本的にJSON形式(文字列や数値などプリミティブ型)を想定しています。
new Date()
のようなオブジェクトが直接渡されても、JSON.stringify
される過程で文字列(例: "2000-01-01T00:00:00Z")に変換されます。
(これを知らなかったので下に続く tablecloth/dataset
につまづきました 😮💨)
Tablecloth の dataset について
Tableclothは、Clojureでデータ操作を簡単に扱うことができるライブラリで、tech.ml.dataset の上に構築されています。
CSVを直接 dataset に変換してくれるなど便利な機能が満載なのですが、完璧ではありません。
たとえば Vega-Lite のExampleで使った以下のファイルを
tc/dataset
で dataset に変換した時、日付データはパースしてくれません。
(-> (tc/dataset "https://raw.githubusercontent.com/vega/vega/refs/heads/main/docs/data/stocks.csv" {:key-fn keyword})
(tc/info :columns))
https://raw.githubusercontent.com/vega/vega/refs/heads/main/docs/data/stocks.csv :column info [3 4]:
| :categorical? | :name | :datatype | :n-elems |
| ------------- | ------- | --------- | -------: |
| true | :symbol | :string | 560 |
| true | :date | :string | 560 |
| | :price | :float64 | 560 |
仕方ないので日付データを yyyy-mm-dd
に変換したものを作って
tc/dataset
で dataset に変換するとパースしてくれます。
(-> (tc/dataset "https://gist.githubusercontent.com/shinseitaro/543b7c398400ced1ed87b8edd685c170/raw/73ee2bd7e93cda41a80ddfc55f053f26dceb7c36/stock-2.csv" {:key-fn keyword})
(tc/info :columns))
| :categorical? | :name | :datatype | :n-elems |
| ------------- | ------- | ------------------ | -------: |
| true | :symbol | :string | 560 |
| | :date | :packed-local-date | 560 |
| | :price | :float64 | 560 |
ただし、変換してくれるがゆえに、tablecloth + Vega-Lite を使って可視化する時に逆に困ることになりました🫠
date の対処方法
MMM d yyyy のフォーマットのファイルを dataset で使う
この場合は Vega-Lite の例に従ってかけばOKです。ただし、:data
にわたすデータはマップに変換する必要があります。(tc/rows DS :as-maps)
を使えばかんたんにできます。
(ns core
(:require [tablecloth.api :as tc]
[scicloj.kindly.v4.kind :as kind]))
(def data
(-> (tc/dataset "https://raw.githubusercontent.com/vega/vega/refs/heads/main/docs/data/stocks.csv"
{:key-fn keyword})
(tc/rows :as-maps)))
(-> {:data {:values data}
:mark {:type "line"
:point true}
:encoding {:x {:field "date" :timeUnit "year"}
:y {:field "price" :aggregate "mean" :type "quantitative"}
:color {:field "symbol" :type "nominal"}}}
kind/vega-lite)
date型に変換されたデータを使う
tc/dataset
が変換してくれたデータを使って上記と同じことをしたい場合 date 列をプリミティブなデータに変えて渡す必要があります。しかも、今回の場合、株価を年ごと group-by して平均を出す必要もあります。
ちょっと頭がクラクラしますが、コードをみたら更にクラクラすると思います。
(def data
(-> (tc/dataset "https://gist.githubusercontent.com/shinseitaro/543b7c398400ced1ed87b8edd685c170/raw/73ee2bd7e93cda41a80ddfc55f053f26dceb7c36/stock-2.csv"
{:key-fn keyword})
(tc/group-by (juxt :symbol #(dt/long-temporal-field :years (% :date))))
(tc/aggregate #(dfn/mean (% :price)))
(tc/rename-columns {:$group-name-0 :symbol
:$group-name-1 :year})
(tc/rows :as-maps)))
(-> {:data {:values data}
:mark {:type "line"
:point true}
:encoding {:x {:field "year"}
:y {:field "summary" :aggregate "mean"}
:color {:field "symbol" :type "nominal"}}}
kind/vega-lite)
この juxt
の使い方!!もちろん私が考えた訳ではありません!!!ここにありました。
data は
[{:symbol "MSFT", :year 2000, "summary" 29.673333333333332}
{:symbol "MSFT", :year 2001, "summary" 25.3475}...]
このような型になりますので、:y
に :field "summary"
を渡しています。
date型に変換されたデータでも同様にできた!
(2025/03/17更新)
すみません。↑のようにへんにこねくり回さなくても、普通に出来ました。
(def data (-> (tc/dataset "https://gist.githubusercontent.com/shinseitaro/543b7c398400ced1ed87b8edd685c170/raw/73ee2bd7e93cda41a80ddfc55f053f26dceb7c36/stock-2.csv"
{:key-fn keyword
:dataset-name "stock"})
(tc/rows :as-maps)))
(-> {:data {:values data}
:mark {:type "line"
:point true}
:encoding {:x {:field "date"
:timeUnit "year"}
:y {:field "price"
:aggregate "mean"
:type "quantitative"}
:color {:field "symbol"
:type "nominal"}}}
kind/vega-lite)
まとめ
TableclothのDatetime型がVega-Liteにそのまま渡ると描画されない問題は、JSONの文字列化が鍵でした。すべては Vega-Liteが扱うのは基本的にJSON形式(文字列) ということを知らなかった起きたつまづきでした。今回は grok に助けてもらって解決できて良かったです。ありがとうAIさん。
(2025/03/17 更新 : いや、今更新したようにdata型を渡してもできることがわかりました。すみません。一週間ウソついてました。)
可視化は pandas を使っているころからそんなに得意ではなかったのですが、仕事上絶対必要なのでがんばります。
ま、つまづいたおかげで頭おかしい(褒めてる) juxt
の使い方を発見できて良かったです。
Discussion