Python Bokehを使って、csvファイルのデータ可視化ツール(インタラクティブ・軽量・どこでも動く)を作成する

# はじめに
PythonのライブラリBokehを使って、ソフト開発の現場で役に立ちそうな時系列データ可視化ツールを作成したので記事にしました。

## こんな動作をします
<img width="300" alt="HowToUse.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/701220/ae3a915c-c247-7c2b-da0e-ce2b2a54bd4f.gif">

ツールはHTMLで記述されています。このHTMLを生成するコードは下記です。

https://github.com/SKMAAX/BokehCsvGraphViewr

ツールを触ってみるには下記を活用ください。

https://skmaax.github.io/BokehCsvGraphViewer/CsvGraphViewer.html

読み込ませるファイルはgithub内のsample.csvを使ってください。
<details><summary>もしくは、↓のデータを4000行ぐらい(だいたいでOKです)まで伸ばしてcsvファイルを適当に作ってください。yは数値ならなんでも良いですが、xは行番号を入れる必要があります。</summary><div>

```
x,y1,y2,y3,y4,y5,y6
1,0,0,0,0,0,0
2,0,0,0,0,0,0
3,0,0,0,0,0,0
4,0,0,0,0,0,0
5,0,0,0,0,0,0
6,0,0,0,0,0,0
7,0,0,0,0,0,0
8,0,0,0,0,0,0
9,0,0,0,0,0,0
10,0,0,0,0,0,0
```
</div></details>

## 作った動機
CSVファイルに吐き出した時系列データを、サクッと内容確認したい、という状況はあらゆる場面でよくあると思います。
皆様はこういったときにどうやってデータを可視化しているでしょうか？

私は以前から、csvファイルの中身をグラフ描画することに関して下記の不満がありました。

 1. 即座に描画できない
 2. 拡大縮小が容易にできない
 3. オフライン運用のPCなど、アプリインストールができない環境で描画できない
 
<!---特に、多くの方がやるのが、エクセルにcsvファイルの中身をコピペしてのグラフ作成だと思います。
しかし、拡大縮小が容易にきず、描画までに数アクション必要で多数のファイルの中身をサクッと見るのには向いていません。

また、組み込み系の環境では、ネットワークに接続されていない、アプリケーションを勝手にインストールしてはいけないような開発用PCが存在したりします(USBメモリは挿せる)。そのPC上でCSVファイルの中身を描画したい欲求が強くありました。
ログ描画の専用アプリを作ってもいいのですが、ログのフォーマットが少しでも変わったりするとメンテが大変です。 --->

私の周囲では、Excelにcsvファイルの中身をコピペしてのグラフ作成がスタンダードです。しかし、トラブルシュートや機能評価時にそれではあまりに効率が悪いと常々感じていました。個人的にはPython、MATLAB等のデータ可視化環境があるのですが、そうした環境は個々人に依存しています。また組込系ではよくあると思われる、オフライン運用のPCでそのような環境を整備することは現実的ではありません。

PythonのBokehというライブラリでは、もともと各種グラフの拡大・縮小が容易にできます。しかも、各種ウィジェットが充実していて、それらをHTML化して配布できることを知りました。これを活用して、上記不満が解消できるような描画ツールが作れるのではと思ったのがきっかけです。HTML化してあれば、各種ブラウザのポータブル版を活用することで比較的どこでもグラフ描画が行えるのではと思いました。

# Bokehについて
インタラクティブなデータ可視化のためのPythonライブラリです。
詳細は他記事に譲るとして、とりあえず下記ギャラリーを眺めるだけでも夢が広がります。ものによっては、ブラウザ上でぐりぐり動かすことができます。
また、インタラクティブに実行できるチュートリアルが充実しています。

https://docs.bokeh.org/en/latest/docs/gallery.html

# コード
```Python:BokehCsvGraphViewer.ipynb
from bokeh.layouts import row, column
from bokeh.models import Div, ColumnDataSource, CustomJS, FileInput, RangeTool, Range1d, HoverTool
from bokeh.plotting import figure, save, output_file
from bokeh.palettes import Category10

output_file("CsvGraphViewer.html", mode='inline') # オフラインで使うためinline設定
ColumnList = ["x","y1","y2","y3","y4","y5","y6"] # 列ラベル(TODO:これもCSVファイルの列ラベルから更新できるはず…)
initlist = [[0] for i in range(len(ColumnList))] # [[0],[0],・・・,[0]]

dict_data = dict(zip(ColumnList,initlist))
source = ColumnDataSource(data=dict_data)

colors = list(Category10.values())[5] # 適当なカラーパレットを選択

# マウスオーバーしたときに表示される項目の設定
tooltips = [
    ("TimeCnt", "@x"),
    ("(y1,y2)", "(@y1,@y2)"),
    ("(y3,y4)", "(@y3,@y4)"),
    ("(y5,y6)", "(@y5,@y6)"),
]

# 描画領域の設定
plot = figure(
    plot_width=400, 
    plot_height=400,
    x_range = Range1d(0,4096), # 横軸データ点数4096点 (TODO:これもCSVファイルの内容から更新できるはず…)
    tooltips=tooltips,
    )

# グラフの描画
for i in range(len(ColumnList)-1):
    plot.line('x', list(dict_data.keys())[i+1], source=source, line_width=2, line_alpha=0.6, legend_label=list(dict_data.keys())[i+1], color=colors[i])
    #plot.circle('x', list(dict_data.keys())[i+1], source=source, color=colors[i]) #ドット表示する場合

plot.left[0].formatter.use_scientific = False # 指数表記にしない
plot.legend.click_policy="hide" # 凡例をクリックするとグラフ表示/非表示
#plot.add_tools(HoverTool()) # マウスオーバーしたときに、詳細をポップアップ表示 → tooltipsで与えているので、不要

# RangeTool(横方向の拡大縮小ができる)の設定
range_plot = figure(
    plot_height=200,
    plot_width=plot.width,
    y_range=plot.y_range,
    toolbar_location=None,
  )

range_plot.line('x', 'y3', source=source) # RangeToolにはy3を表示させる
range_rool = RangeTool(x_range=plot.x_range)
range_plot.add_tools(range_rool)

fi_label = Div(text='CsvGraphViewer') # divタグウィジェット
fi = FileInput() # ファイル選択ウィジェット

# ファイル選択時に実行されるコールバック関数の記述
callback = CustomJS(
    args=dict(source=source), # コールバック関数に渡すデータソース
    code=""" // コールバック関数の記述(JavaScript)
    Papa.parse(atob(cb_obj.value), {
        delimiter: ',',
        header: true,
        dynamicTyping: true,
        worker: true,
        complete: function (results) {

            // csvデータを格納するアキュムレータの初期化
            const acc = results.meta.fields.reduce((acc, f) => {
                acc[f] = [];
                return acc;
            }, {}); 

            // csvを1行データ(row)ごとに読み取り、さらに列(k)ごとに読み取ってaccに積み上げていく
            // 最終行まで終わったら、積み終わったaccを更新後データとしてsorce.dataに渡す。
            source.data = results.data.reduce((acc,row,index) => {
                for (const k in acc) {
                    acc[k].push(row[k]);
                    }
                return acc;
            }, acc); 

        }
    });
""")

# ファイル選択の内容が変わったらcallbackが実行されるようにセット
# (同一ファイルを再選択してもcallbackは起動しない)
fi.js_on_change('value', callback)

# JavaScript内で使っているCSVパーサPapaParse用のテンプレート。CSVファイル読み取り時ここにアクセスしに行く。
# papaparse.min.jsの内容をHTMLに転記すれば完全ローカル化できる。
template = """\
{% block preamble %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.1.0/papaparse.min.js"
        integrity="sha256-Fh801SO9gqegfUdkDxyzXzIUPWzO/Vatqj8uN+5xcL4="
        crossorigin="anonymous"></script>
{% endblock %}
"""

# 各ウィジェットの配置を設定
layout = column(row(fi_label, fi), plot, range_plot)

# PapaParseのテンプレートを使う関係上、show()でのスクリプト実行時のブラウザ起動はできない。
# 本スクリプトではhtmlファイルの保存のみを行う。
save(layout, template=template)
```
## コードについて補足
ほぼコメントを入れたとおりですが、いくつか補足します。

### inline設定
下記のinline設定はネットワーク環境が無いところでも使う想定で与えました。これにより、HTMLファイルのサイズ自体は大きくなりますが、cdn.pydata.orgにアクセスすることなく描画が行われます。逆にいうと、この「mode='inline'」を外すとHTMLファイルの軽量化が行えます。
```Python
output_file("CsvGraphViewer.html", mode='inline') # オフラインで使うためinline設定
```

### FileInputウィジェット
今回は１ファイルを選択する前提でしたが、複数ファイル選択などもできます。下記のように記述すると、FileInputオブジェクトのメンバ変数value(ファイルの中身を格納する)がリストとなります。[(公式ドキュメントも参照のこと)](https://docs.bokeh.org/en/latest/docs/reference/models/widgets/inputs.html#bokeh.models.FileInput)

```Python
fi = FileInput(multiple=True)
```

### callback関数

Bokehは各種callback関数を記述することで、インタラクティブなアプリケーションを容易に作成できます。Bokehサーバーを起動すればPythonで容易に記述できるようですが、HTML化にあたってはcallback関数をJavaScriptで記述する必要があるようです。そのため、on_change()ではなくjs_on_change()の方を使いました。

私自身はJavaScriptに触れたこともないようなレベルでしたが、下記の内容とCtrl+Shift+Iに助けられ何とかなりました。

https://stackoverflow.com/questions/61356082/load-csv-file-into-static-python-bokeh-web-app-with-javascript-papa-parse


下記の部分で、FileInputオブジェクトのメンバ変数value(ファイルの中身)が変わったことをトリガにcallback関数が起動します。ファイルが何かしら選択されたらcallback起動、としたかったのですが、それに対応するeventが見当たりませんでした。そのため、現状だと中身が異なるcsvファイルを読み込んだらcallback起動、としています。

```Python
fi.js_on_change('value', callback)
```

## オフラインで動作させるために
このコードで生成されたHTMLでは、CSVのパースにPapaParseを使っています。そのままだとPapaParseのCDNにアクセスしないと使えず、オフラインの環境だとファイル選択後に下記のようなエラーがでます。

<img width="600" alt="コメント 2023-01-14 131437.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/701220/688acc97-ede6-f578-12f9-a0db72c2ad48.png">

そこで、生成されたHTML内のPapaParse部を直接 https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.1.0/papaparse.min.js からとってきた内容で上書きしてオフライン版 : CsvGraphViewer_offline.htmlを作成しています。

## その他できること
冒頭で見せた動き以外にも
 - 第2縦軸を分ける
 - 複数ファイルを読み込んで複数のFigureに表示させる
 - 複数のFigureを表示し、描画範囲を連動させる

などのことは軽微なコード変更で簡単にできます。

下記もできるとは思うので、もし余裕があれいつか更新します。
 - ファイル内の列ラベルを読み取って、凡例を更新(x,y1~6という固定ラベルを無くす)
 - ファイル内の行数を読み取って、X軸の範囲を更新(4096、の固定X範囲を無くす)

# おわりに
Python Bokehを使って、csvファイルを読み込んでグラフ化するHTMLの生成スクリプトを作りました(どこでも動く、は盛ってますね)。もし参考になれば、自由に改変して活用いただければと思います。便利な使い方等があれば教えてください。

なお、作った後にPlotJugglerなるものの存在を知りました。自分の用途ではこれで良かったような気もしますが、色々カスタマイズして配布しやすいのでまあ作った甲斐はあったかなと。Bokehは見た目もキレイですし。

https://www.plotjuggler.io/

ご質問、間違い等あればご指摘お願いいたします(不慣れなことをしたので、何かやらかしていないか不安です)。
ご覧いただきありがとうございました。