PythonのウェブフレームワークDashで手持ちのお小遣いファイルを可視化する

## 前置き

手持ちのファイルをサッと可視化したいなんてニーズはよくあります。そういう時にサッとできる環境だと、ノンプログラマーの人もデータが活用でき、根拠のない勘と根性の世界がデータに基づいた勘と根性の世界に変えられます。     
     
今回は身近な事例として、次のような提携のお小遣いのファイルをアップロードできる（pic1）Dashアプリケーションを作成しました。date列には日付、variable列には支出項目、value列は金額が入っています。残念ながら私はお小遣い帳をつけていないので、[今回は日本の家計調査のデータ](https://www.stat.go.jp/data/kakei/index.html)を使っています。

![pic1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/2ed7f998-8f36-0665-fb02-395367f113d1.png)

最終的に作成したアプリケーションは次のようなものです。ファイルアップロードツールをクリックするとファイルが選択でき、上のような3つの項目を持つお小遣いデータであれば、グラフが作成され項目を選択して描画することもできます。

<a href="https://gyazo.com/09c3462c0ee2cf39f495b4e8afd22393"><img src="https://i.gyazo.com/09c3462c0ee2cf39f495b4e8afd22393.gif" alt="Image from Gyazo" width="1600"/></a>

こんなもの作るの大量のコードを書かないとダメなんでしょ！と思われるかもしれません。しかし、アプリケーション自体のコードの長さはblackで整形して92行です。

実際のアプリのURL：　https://okodukai.herokuapp.com/　（無料で動かしているので起動にちょっと時間が必要です）
使っているファイル: https://github.com/mazarimono/okodukai_upload/blob/master/data/kakei_chosa_long.csv

## 作成環境

作成環境は次のようになります。

Windows10
Python 3.8.3
dash 1.13.4
plotly 4.8.2
pandas 1.0.4

## 作り方

Dashのアプリケーションの作成方法は、以前の記事を参照ください。

https://qiita.com/OgawaHideyuki/items/6df65fbbc688f52eb82c

Dash自体はReactのPythonラッパーのようなパッケージで、特徴としてPlotlyというグラフパッケージを用いて、データの可視化が簡単にできるという点が挙げられます。

コンポーネントを組み合わせてレイアウトを作成し、コールバックを使ってそれらのコンポーネントをインタラテクィブに動作させます。

## 今回のアプリケーションで重要なコンポーネント

お小遣いアップロードアプリで重要なのは次の2点です。

- ファイルをアップロードするところ
- データの保持

これにはそれぞれUploadコンポーネントとStoreコンポーネントを活用します。

### Uploadコンポーネント

Uploadコンポーネントはファイルをアップロードするためのコンポーネントで、アプリケーション上では次のように表示されます。

![pic2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/2520ace8-7b79-9127-4082-b7984c22e206.png)

コードは次のようになります。

```upload.py
dcc.Upload(
            id="my_okodukai",
            children=html.Div(["お小遣いのファイルを", html.A("csvかexcelで頂戴！")]),
            style=upload_style,
        ),
```

### Storeコンポーネント

Storeコンポーネントはレイアウトには表示されませんが、アップロードされたデータを保持するために使います。このアプリケーションではファイルがアップロードされると、それがStoreコンポーネントに渡され、ドロップダウンで表示項目の選択が更新されるためにそのデータが呼び出され、グラフが更新されます。

**アプリの起動時**
![pic3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/fdda7b82-e08a-ba00-8414-17156175de35.png)

**ファイルをアップする**
![pic4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/e58ce2a5-ceeb-2218-5136-3dbb4c3f2e17.png)

## コールバック

各コンポーネントをつなげ、それぞれを動的に動作させるのがコールバックです。今回のアプリの場合次のような二つのコールバックを活用します。

- UploadコンポーネントにアップされたデータがCSVファイルもしくはエクセルファイルであればデータフレームとする。それを活用してドロップダウンの選択肢を作成する。そしてデータをStoreコンポーネントに預ける（１）
- Storeコンポーネントにあるデータを読み込んだデータを用いて、ドロップダウンの選択に応じたデータを作成し、グラフに描画する（２）

（１）のコールバックのコードは次のようになります。prevent_initial_callは起動時のコールバックの発動を防ぎます。これにより、ファイルがアップされたときにのみこのコールバックが起動します。

```f-callback
@app.callback(
    [
        Output("my_dropdown", "options"),
        Output("my_dropdown", "value"),
        Output("tin_man", "data"),
    ],
    [Input("my_okodukai", "contents")],
    [State("my_okodukai", "filename")],
    prevent_initial_call=True,
)
def update_dropdown(contents, filename):
    df = parse_content(contents, filename)
    options = [{"label": name, "value": name} for name in df["variable"].unique()]
    select_value = df["variable"].unique()[0]
    df_dict = df.to_dict("records")
    return options, [select_value], df_dict
```

parse_content関数はつぎのようになります。簡単に説明するとファイル名が.csvで終わっているとCSVファイルとして読み込み、xlsがふくまれるとエクセルファイルとして読み込みます。utf-8以外の対応はしておりません。また、エクセルファイルにはxlsとxlsxがあるようなので、こんな形にしています。

```

def parse_content(contents, filename):
    content_type, content_string = contents.split(",")
    decoded = base64.b64decode(content_string)

    try:
        if filename.endswith(".csv"):
            df = pd.read_csv(io.StringIO(decoded.decode("utf-8")))
        elif "xls" in filename:
            df = pd.read_excel(io.BytesIO(decoded))
    except Exception as e:
        print(e)
        return html.Div(["ファイルの読み込みでエラーが発生しました"])

    return df
```


（２）のコールバックは次のようになります。こちらもアプリ起動時のコールバックの発動を防ぎ、ドロップダウンが更新されると、データを呼び出しグラフを更新します。

```s-callback

@app.callback(
    Output("my_okodukai_graph", "figure"),
    [Input("my_dropdown", "value")],
    [State("tin_man", "data")],
    prevent_initial_call=True,
)
def update_graph(selected_values, data):
    df = pd.DataFrame(data)
    df_selected = df[df["variable"].isin(selected_values)]
    return px.line(df_selected, x="date", y="value", color="variable")
```

## 少し日本の家計調査を見る

なんかこのままで終わるのもあれなので、日本の家計調査を見てみます。現時点では5月までのデータとなります。

まず消費全体を表す消費支出を見ると、4，5月にドカンと低下しています。

![pic5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/a2e4e2a3-6312-cb82-029d-393b9314ec7a.png)

そういや牛乳の消費を促したら「効果があった、ありがとうございました」なんてのを見たけどどうだったのかとみると、ほんとにあったようです。私も子供のころに戻り、牛乳をたくさん飲んでいました。禁酒のおともに牛乳！！！

![pic6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/526096c3-438d-9dac-0dff-a1472ac78876.png)

まぁコロナ禍のいろいろは、みなさま何か面白い傾向がございましたら、コメント欄などにいただけると幸いです。ちょっと無料枠なせいか、動作が遅いですがその辺はちょっと我慢していただいて・・・。私が興味があったのは米かパン、どっちにお金使ってんねんってところです。私は断然米派なのですが、うちの娘たちはパンしか食べません。なんでや・・・とおもっていましたが・・・   

![pic7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85433/ba42293b-badb-8934-e075-e8652007e11c.png)

私の方がやはり置いてきぼりな種族だったようです。魚と肉もそんな感じで肉の方が多くなっていますが、私は肉派なのでその気持ちは分かります。

## まとめ

以上のような感じで定型の手持ちのファイルをすぐに可視化するツールが簡単に作成できます。もっといろいろ対応できるようにしてくれよって要望もありますが、その辺りはデータの保存なんかの観点からの切込みが必要だったりするのかなぁと思っています。     

自分の生活もDXを！！という感じでしょうか？
      
アプリケーションURL: https://okodukai.herokuapp.com/
github: https://github.com/mazarimono/okodukai_upload

githubのコードは、app_heroku.pyがヘロクにあげる際のものとなります。

アプリケーションのコード

```app.py

import base64
import io

import dash
import dash_core_components as dcc
import dash_html_components as html
import pandas as pd
import plotly.express as px

from dash.dependencies import Input, Output, State

upload_style = {
    "width": "50%",
    "height": "120px",
    "lineHeight": "60px",
    "borderWidth": "1px",
    "borderStyle": "dashed",
    "borderRadius": "5px",
    "textAlign": "center",
    "margin": "10px",
    "margin": "3% auto",
}
external_stylesheets = ["https://codepen.io/chriddyp/pen/bWLwgP.css"]
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.config.suppress_callback_exceptions = True

app.layout = html.Div(
    [
        dcc.Upload(
            id="my_okodukai",
            children=html.Div(["お小遣いのファイルを", html.A("csvかexcelで頂戴！")]),
            style=upload_style,
        ),
        dcc.Dropdown(
            id="my_dropdown", multi=True, style={"width": "75%", "margin": "auto"}
        ),
        dcc.Graph(id="my_okodukai_graph"),
        dcc.Store(id="tin_man", storage_type="memory"),
    ]
)


def parse_content(contents, filename):
    content_type, content_string = contents.split(",")
    decoded = base64.b64decode(content_string)

    try:
        if filename.endswith(".csv"):
            df = pd.read_csv(io.StringIO(decoded.decode("utf-8")))
        elif "xls" in filename:
            df = pd.read_excel(io.BytesIO(decoded))
    except Exception as e:
        print(e)
        return html.Div(["ファイルの読み込みでエラーが発生しました"])

    return df

@app.callback(
    [
        Output("my_dropdown", "options"),
        Output("my_dropdown", "value"),
        Output("tin_man", "data"),
    ],
    [Input("my_okodukai", "contents")],
    [State("my_okodukai", "filename")],
    prevent_initial_call=True,
)
def update_dropdown(contents, filename):
    df = parse_content(contents, filename)
    options = [{"label": name, "value": name} for name in df["variable"].unique()]
    select_value = df["variable"].unique()[0]
    df_dict = df.to_dict("records")
    return options, [select_value], df_dict

@app.callback(
    Output("my_okodukai_graph", "figure"),
    [Input("my_dropdown", "value")],
    [State("tin_man", "data")],
    prevent_initial_call=True,
)
def update_graph(selected_values, data):
    df = pd.DataFrame(data)
    df_selected = df[df["variable"].isin(selected_values)]
    return px.line(df_selected, x="date", y="value", color="variable")

if __name__=="__main__":
    app.run_server(debug=True)

```