Pythonで描くサンキーダイアグラム

この記事は[データ可視化Advent Calendar 2020](https://qiita.com/advent-calendar/2020/data-visualization)の10日目です。筆者の[PyData Fukuoka](https://pydatafukuoka.connpass.com/event/176637/)での[LT](https://ytknzw.github.io/PyData/%E3%82%B5%E3%83%B3%E3%82%AD%E3%83%BC%E3%83%80%E3%82%A4%E3%82%A2%E3%82%B0%E3%83%A9%E3%83%A0%E3%81%AE%E3%82%B9%E3%82%B9%E3%83%A1_PyData_Fukuoka_20200607.pdf)の内容を元にしています。

## TL;DR
サンキーダイアグラムは、量を伴う、時間・空間・状態の遷移の表現に最適です。PythonではPlotlyとHoloViewsに実装されています。この記事では、HoloViewsを用いた例を示します。

## サンキーダイアグラムとは
サンキーダイアグラムはフローチャートに似た有向グラフで（下図[^1]）、ネットワーク図の一種と見なすことができます。以下の性質があり、量を伴う、時間・空間・状態の遷移の表現に最適[^2]です。「サンキー」はこのような形式のグラフをほぼ最初に用いた人の名前です。

- ノード間の関係性と順序 = 流れの⽅向
- ⽮印（線）の幅 = 流量

![Sankeysteam.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412319/1af9261b-75a1-22eb-66be-a775bfe2f874.png)

## Pythonでの実装
筆者の知る限り、Pythonのデータ可視化パッケージでサンキーダイアグラムをサポートしているものとしては、[HoloViews](https://holoviews.org/reference/elements/bokeh/Sankey.html)と[Plotly](https://plotly.com/python/sankey-diagram/)の2つがあります。いずれも、矢印ではなく線でノードをつなぐので、一見すると無向グラフのようですが、左から右へという向きがあるようです。

- HoloViews
    - `holoviews.Sankey`
    - バックエンド：BokehとMatplotlib
- Plotly
    - `plotly.graph_objects.Sankey`

Plotlyには[いい本](https://www.asakura.co.jp/books/isbn/978-4-254-12258-9/)があるので、ここではHoloViewsを使用してサンキーダイアグラムを描いてみます。

## 準備
### インストール
`conda`や`pip`でインストールできます。ご自身の環境に合わせて選んで下さい。[公式サイト](https://holoviews.org/install.html)によると`conda`の場合は以下のコマンドが良いようです。

```
conda install -c pyviz holoviews bokeh
```

```
pip install holoviews
```

### import
ここではホバーなど動的なグラフを作成するためにbokehを使います。matplotlibを用いて静的なグラフを描画することもできます。

```Python
from bokeh.models import HoverTool
import holoviews as hv
from holoviews import opts, dim

hv.extension('bokeh')
```

## データ
今回サンキーダイアグラムの作成に使用するのは、[UN Comtrade](https://comtrade.un.org/)という国連の貿易統計部門がまとめているデータで、2018年のスパークリングワインの輸出入に関するものです（参考：[American Association of Wine EconomistsのTwitter投稿](https://twitter.com/wineecon/status/1260214505965060102)）。APIでも入手可能ですが、その説明は本題ではないので、以下のようにして用意します。

```Python
# 上位30ペアのみ（単位：キロトン）
sparklingwine = [
    ["Europe", "France", "North America", "USA", 736],
    ["Europe", "France", "Europe", "UK", 520],
    ["Europe", "Italy", "Europe", "UK", 513],
    ["Europe", "Italy", "North America", "USA", 394],
    ["Europe", "France", "Asia", "Singapore", 337],
    ["Europe", "France", "Europe", "Germany", 317],
    ["Asia", "Singapore", "Asia", "Japan", 225],
    ["Europe", "France", "Asia", "Japan", 207],
    ["Europe", "France", "Europe", "Belgium", 193],
    ["Europe", "France", "Europe", "Italy", 190],
    ["Europe", "France", "Europe", "Switzerland", 132],
    ["Europe", "Italy", "Europe", "Germany", 121],
    ["Europe", "France", "Europe", "Spain", 103],
    ["Europe", "France", "Oceania", "Australia", 103],
    ["Europe", "Spain", "North America", "USA", 90],
    ["Europe", "Spain", "Europe", "Germany", 85],
    ["Europe", "France", "Europe", "Sweden", 81],
    ["Europe", "France", "North America", "Canada", 78],
    ["Asia", "Singapore", "Oceania", "Australia", 72],
    ["Europe", "France", "Europe", "Netherlands", 71],
    ["Europe", "Italy", "Europe", "Switzerland", 71],
    ["Europe", "Spain", "Europe", "Belgium", 70],
    ["Europe", "Spain", "Europe", "UK", 65],
    ["Europe", "Italy", "Europe", "France", 63],
    ["Europe", "France", "Middle East", "UAE", 59],
    ["Europe", "Italy", "Europe", "Russia", 56],
    ["Europe", "Italy", "Europe", "Belgium", 54],
    ["Europe", "Italy", "Europe", "Sweden", 50],
    ["Europe", "Spain", "Europe", "France", 44],
    ["Europe", "Italy", "North America", "Canada", 43],
    ["Europe", "Italy", "Asia", "Japan", 40],
    ["Europe", "Spain", "Asia", "Japan", 39],
    ["Europe", "France", "Asia", "Hong Kong", 39],
    ["Europe", "France", "Europe", "Denmark", 38],
    ["Europe", "France", "Europe", "Austria", 37],
    ["Europe", "Netherlands", "Asia", "Japan", 34],
]
sparklingwine_df = pd.DataFrame(sparklingwine).rename(
    {0: "region_exp", 1: "country_exp", 2: "region_imp", 3: "country_imp", 4: "value"},
    axis="columns",
)
```
HoloViewsのサンキーダイアグラムは循環参照ができないので、輸出国と輸入国を区別するために輸出国の名前の後ろに`Exp`を付与します（シンガポールとオランダを除いている訳は完成したグラフを見ると分かります）。

```Python
def set_exp(df):
    if df.country_exp in ["Singapore", "Netherlands"]:
        return df.country_exp
    else:
        return df.country_exp + " Exp"

sparklingwine_df = sparklingwine_df.assign(
    country_exp=lambda x: x.apply(set_exp, axis=1)
)
```

## 描画
### ホバー（bokehの機能）
サンキーダイアグラムの「線」の部分にマウスを当てた時にグラフの上に表示される内容を指定します。ここでは、輸出国、輸入国、量を表示させます。名前と値のペアをタプルにし、値を`@カラム名`と指定するとDataFrameのカラムから値を取得できます。

```Python
hover = HoverTool(
    tooltips=[
        ("From", "@country_exp"),
        ("To", "@country_imp"),
        ("Value [kt]", "@value"),
    ]
)
```
### グラフ本体
`hv.Sankey`クラスにデータセット`sparklingwine_df`、始点ノードと終点ノードのカラム名`kdims`、流量（線の太さ）`vdims`を指定します。`label`はグラフのタイトルです。

更に、`opts`メソッドを使って、サイズ（`width`と`height`）や色（`cmap`と`edge_color`）などを指定します。ここでは輸出国ごとに線を塗り分けるため、```edge_color=dim("country_exp").str()```としています。

```Python
sparklingwine_sankey = hv.Sankey(
    sparklingwine_df,
    kdims=["country_exp", "country_imp"],
    vdims=["value"],
    label="World's Top Sparkling Wine Trade Routes in 2018 (Value in million USD)",
).opts(
    width=900,
    height=800,
    cmap="glasbey_hv",
    edge_line_alpha=0,
    edge_color=dim("country_exp").str(),
    tools=[hover],
    node_sort=True,
) * hv.Text(
    500,
    -15,
    "Based on AAWE's post on Facebook https://www.facebook.com/wineecon/posts/3557583497601764/",
    fontsize=10,
)
```
グラフオブジェクト`sparklingwine_sankey`を呼び出すと、グラフが表示されます。シンガポールとオランダは貿易の中継地点であり、日本の輸入量においてシンガポール経由が大きな割合を占めていることが分かります。
![sparklinkwine_sankey.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412319/9f7652b0-f95c-31ad-a320-0bb661cd90b4.png)
### 出力
`hv.save`関数を用いて、htmlやpngとして出力することができます。

```Python
hv.save(sparklingwine_sankey, 'sparklingwine_sankey.html')
```

```Python
hv.save(sparklingwine_sankey, 'sparklinkwine_sankey.png', dpi=600)
```

## まとめ
サンキーダイアグラムは、量を伴う、時間・空間・状態の遷移の表現に最適です。PythonではPlotlyとHoloViewsに実装されていて、この記事ではHoloViewsを用いた例を示しました。是非使ってみて下さい！


[^1]: https://en.wikipedia.org/wiki/Sankey_diagram
[^2]: 例えば、ECサイト訪問者が購入に至るまでの過程を人数を含めて示す場合などです。