Bokehが素晴らしすぎてわずか130行で対話的可視化ツールを作ってしまった話

#はじめに
[前回](https://qiita.com/kimisyo/items/47012adb3f58c2661c09) matplotlibで4種類の可視化手法(PCA, tSNE, MDS, UMAP)の実行結果を表示するコマンドを作成した。
しかし、グラフの点にフォーカスをあてどのデータかを表示したり（いわいるhoverというやつ）、色設定する項目を動的に変更できないという不満があったので、かねてから注目していたBokehを使って実現した。BokehのAPIが想像以上に素晴らしく、たった一日で、しかもわずか130行のコードにより、当初目的としていた可視化ツールを作ることができたのでその一部始終をここに残しておく。

# 作りたいもののイメージと実現方法
BokehのWidgetを使って以下を実現する。
- 画面上でinputファイル(CSV)、可視化手法を指定して可視化を行うことができる。CSVファイルの書式は前回と同じフォーマット。
- 指定したCSVファイルの項目に応じてグラフ上の点の色が異なるようにする。項目は動的に変更可能とする。
- グラフの点にフォーカスをあてると、IDや特定の値を表示する。
- CSVファイルの内容をグリッドの形式で表示し、グラフの点と選択が連動するとよい。
　（グラフで点を選択した場合、グリッドの対応する点も選択状態になる。逆もしかり）

要するにグラフをみて、このあたりに固まっているデータ群って、どんな感じのデータだっけ？というのを対話的にやりたいわけで。

# できあがったもの
これが起動画面。
右側の「Input CSV」テキストボックスに解析したいCSVファイルを設定し、その下のラジオボタンで実行したい可視化手法を指定し、「Execute」ボタンをクリックすると、可視化処理が走り、終わったら左側にグラフが表示され、右下のデータグリッドにCSVファイルのデータが表示される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/329816/ec1fdfa8-34fe-f180-4671-08aa249579dd.png)


これが実行結果。グラフの操作（拡大、縮小や選択）は、グラフの右側にあるアイコンでできる。
また、「Color」セレクトボックスを変更すると、その項目により色分けする。（デフォルトはCSVの一列目の目的変数）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/329816/cc24966c-8eb4-caa3-8349-e9f434f8dd61.png)


そして、感心した機能がこれ。グラフの点を選択すると（下図の場合、真ん中付近の点をまとめて選択している）、それに連動して右のデータグリッドの対応する行に色がついた状態なる。ちなみにデータグリッド（Bokehではデータテーブルというのか）は、ソート等一通りの機能を備えている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/329816/9cf8ecb2-dc57-e5e9-154e-ee289ba68778.png)

以下からソースコード等を説明する。

# 実行環境

- Windows10
- Anaconda
- Python3.7
- Bokeh 1.2
- scikit-learn 0.21.2
- umap-learn 0.3.9

# ソースコード

これが130行のコード。解説は後ろに記載。

```python
import pandas as pd
from sklearn.manifold import TSNE, MDS
from sklearn.decomposition import PCA
import umap
import bokeh
from bokeh.events import ButtonClick
from bokeh.models import CustomJS, HoverTool, ColumnDataSource
from bokeh.models.widgets import Button, Select, RadioGroup, TextInput, TableColumn, DataTable
from bokeh.layouts import Column, Row
from bokeh.io import curdoc
from bokeh.plotting import figure
from bokeh.transform import linear_cmap


def main():

    # color selectが変更された時の処理
    def color_select_callback(attr, old, new):

        # color_selectで選択された項目の値に対応したmapperを用意
        mapper = linear_cmap(field_name=new, palette=bokeh.palettes.Viridis256,
                             low=min(df[new].values), high=max(df[new].values))

        # 新たなmapperでグラフを再描画
        p.circle(x="0", y="1", source=source, line_color=mapper, color=mapper)

    # execute button がクリックされた時の処理　
    def execute_button_callback(event):

        # テキストエリアに指定された入力ファイル読み込み
        global df
        df = pd.read_csv(csv_input.value, index_col=0)

        # 1列目がTargetと仮定(color_selectのデフォルト)
        df_target = df.iloc[:, 0]
        df_data = df.iloc[:, 1:]

        # 2列目以降のデータで可視化のための解析開始
        if method_radio_group.active == 0:
            pca = PCA(n_components=2)
            result = pca.fit_transform(df_data)
        elif method_radio_group.active == 1:
            tsne_model = TSNE(n_components=2)
            result = tsne_model.fit_transform(df_data)
        elif method_radio_group.active == 2:
            mds = MDS(n_jobs=4)
            result = mds.fit_transform(df_data)
        elif method_radio_group.active == 3:
            result = umap.UMAP().fit_transform(df_data)

        # Dictionaryに解析結果を格納
        dict = {"0": result[:, 0], "1": result[:, 1]}

        # データフレームのID(index)をDictionaryに格納
        dict["ID"] = df.index.values

        # データテーブルに登録する列情報の設定
        columns = list()
        columns.append(TableColumn(field="ID", title="ID", width=100))

        for column in df.columns:
            dict[column] = df[column].values
            columns.append(TableColumn(field=column, title=column, width=100))

        # Dictionaryをソースと紐づける
        source.data = dict

        # データテーブルの列を設定する
        data_table.columns = columns
        target_column = df.columns.values[0]

        # color_selectで選択された項目の値に対応したmapperを用意
        mapper = linear_cmap(field_name=target_column, palette=bokeh.palettes.Viridis256,
                             low=min(df[target_column].values), high=max(df[target_column].values))

        # 新たなmapperでグラフを再描画
        p.circle(x="0", y="1", source=source, line_color=mapper, color=mapper)

        # hoverの設定(indexとIDを表示)
        hover = HoverTool(tooltips=[
            ("index", "$index"),
            ("ID", "@ID"),
            (target_column, "@"+target_column),
        ])
        p.add_tools(hover)

        color_select.options = list(df.columns)

        # リセット　(https://github.com/bokeh/bokeh/issues/5071　を参考に入れてみたが現状動作しない)
        CustomJS(args=dict(p=p), code="""
            p.reset.emit()
        """)

    # データソースの初期設定
    source = ColumnDataSource(data=dict(length=[], width=[]))
    source.data = {"0": [], "1": []}

    # CSVファイル設定テキストボックス
    csv_input = TextInput(value="default.csv", title="Input CSV")

    # 可視化手法選択ラジオボタン
    method_radio_group = RadioGroup(
        labels=["PCA", "tSNE", "MDS", "UMAP"], active=3)

    # 解析実行ボタン
    execute_button = Button(label="Execute", button_type="success")
    execute_button.on_event(ButtonClick, execute_button_callback)

    # グラフ初期設定
    p = figure(tools="pan,box_zoom,lasso_select,box_select,poly_select,tap,wheel_zoom,reset,save,zoom_in",
               title="Analyze Result", plot_width=1000, plot_height=800)
    p.circle(x="0", y="1", source=source)

    # 色設定項目用選択セレクトボックス
    color_select = Select(title="color:", value="0", options=[])
    color_select.on_change("value", color_select_callback)

    # データ表示データテーブル
    data_table = DataTable(source=source, columns=[], width=600, height=500, fit_columns=False)

    operation_area = Column(csv_input, method_radio_group, execute_button, color_select, data_table)

    layout = Row(p, operation_area)
    curdoc().add_root(layout)


main()
```

# 実行方法
bokehをインストールしていれば、上のコードを書いたファイルを指定して(以下例ではViewBok.py)、以下のようなコマンドを実行するとブラウザが起動し、画面が表示される。

```
bokeh serve --show ViewBok.py
```

# ソース解説
ソースをみて不明なところは参考文献を見てもらった方が早いとおもうが、一応説明。

1. 全ての基本となるData Sourceの定義を```source = ColumnDataSource(data=dict(length=[], width=[]))```　```source.data = {"0": [], "1": []}```で行う。これをグラフやデータテーブルが参照することで各部品が連動するわけだ。
2. 画面に並べる部品を定義する。TextInput、RadioGroup、Button、figure、Select、DataTableね。
3. 部品にイベントがある場合、イベントに応じた関数を設定。
このアプリの場合、ButtonとSelectの2つしかイベントはない。
Buttonの場合は、on_eventメソッドで、イベントハンドラ(画面上に書いたexecute_button_callback関数)と紐づける。
Selectの場合は、on_changeメソッドで、イベントハンドラ(画面上に書いたcolor_select_callback関数)と紐づける。
注意すべきは、それぞれイベントハンドラが受け取る引数の形がきまっているので、そこはドキュメントを読んだりして試行錯誤する必要がある。
4. 各イベントハンドラでやりたい処理を書く。
"Execute" Buttonが押された場合の説明をざっくりすると、
 1. まずCSVテキストボックスからファイルパスを読み取り、DataFrameで読み取って、ラジオボタンで選択された手法に応じた処理を実行する。
 2. 結果配列(X、Y）をDictionaryの対応するキーに格納する。このキーはグラフに表示する際の cp.circle(x="0", y="1", source=source)```のxとyで指定している値と合わせておけばOKだ。
 3. データテーブルに表示させる値も2と同じDictionaryに登録しておく。またデータテーブルの各列をTableColumnTableColumnとして定義し、それをリストにいれてdata_tableと紐づける。
 4.  ```source.data = dict```によってDictionaryとデータソースを紐づける。これによってデータとグラフ、データテーブルが紐づくのだ。 
 5. hoverやcolor_selectの設定（詳細省略)。
  　

# 今後の課題
- 解析が終わってグラフの再描画した後、解析手法によってスケールが変わるため、CustomJSを使ってリセットを実行しているが、うまく動いておらず、毎回手動でリセットを実行している。
- 化合物データで解析する場合、hoverに化合物の画像を表示したい。化合物の画像化はRDKitでできるとして、hoverへの画像表示も、参考に示したURLを参照すればできそう。
- X軸、Y軸に項目を指定して、Scatter Plotも表示できるようにしたい。
- データテーブルで選択された項目についてデータをダウンロードしたい。JavaScriptとの連動が必要？
- 複数のグラフをリンクさせることができるようなので、複数のグラフを並べて表示し、同じ点がいろいろなグラフのどこに位置するか、一目でわかるように表示させたい。
- リファレンスガイドをみると、ネットワークグラフも表示できるようなので試したい。
- アニメーション機能を触る（もはや利用シーンがイメージできないが、面白そうという理由だけ）


# 参考
- [Bokeh ユーザーガイド](https://bokeh.pydata.org/en/latest/docs/user_guide.html)
- [Bokeh - Configuring Plot Tools](https://bokeh.pydata.org/en/latest/docs/user_guide/tools.html)