Dataikuで散布図のweb app作成からプラグイン化まで


[この記事](https://qiita.com/ayoyo/items/ffd2bfbf6a8c7f01ecd6)で既存のサンプルwebappをプラグイン化するという話をしたのですが、web app作成から書いてある記事を公式サイトでみつけたので、web app作成～プラグイン化までをやってみました。

Dataiku version 8.0.2つかいました。


#bokeh散布図のweb app作成
参考：https://knowledge.dataiku.com/latest/courses/advanced-code/web-apps/bokeh.html

##前準備
まず、以下のプロジェクトをインポートします。
[+New Project] > [DSS Tutorials] > [General Topics] > [Haiku Starter]

この中のOrders_enriched_preparedデータセットが使いたいだけなので、ネットからダウンロードしてきても良いです。
https://downloads.dataiku.com/public/website-additional-assets/data/Orders_enriched_prepared.csv


##bokeh webappの作成
空のbokeh webappを作成します。
[</>]>[+New Webapp]>[Code Webapp]>[Bokeh]>[An emptt Bokeh app]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/f537e77f-2ec7-9a08-c51c-411e147127fa.png)


Editタブでコーディングして、Viewタブで出力を見るようです。まだ何も設定してないので、Viewタブはからっぽ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/dcb95ab0-1b62-1fb7-98ec-9684fac8f167.png)

EditタブでもPreviewみながらできるようです。ここでLogとかもみながらコーディングする感じなのかな。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/c0818eda-f437-5ebb-a526-6cd05208a925.png)

Pythonコードに以下を記載。ageとtotalで散布図を描くプログラムです。

```python
# ライブラリインポート
from bokeh.io import curdoc
from bokeh.layouts import row, widgetbox
from bokeh.models import ColumnDataSource
from bokeh.models.widgets import Slider, TextInput, Select
from bokeh.plotting import figure
import dataiku
import pandas as pd

# 入力パラメタの設定
input_dataset = "Orders_enriched_prepared"
x_column = "age"
y_column = "total"
time_column = "order_date_year"
cat_column = "tshirt_category"

#入力データをデータフレームに変換
mydataset = dataiku.Dataset(input_dataset)
df = mydataset.get_dataframe()

#x軸とy軸の値を取得し、可視化用にデータを整形
x = df[x_column]
y = df[y_column]
source = ColumnDataSource(data=dict(x=x, y=y))

# 描画の設定
plot = figure(plot_height=400, plot_width=400, title=y_column+" by "+x_column,
              tools="crosshair,pan,reset,save,wheel_zoom",
              x_range=[min(x), max(x)], y_range=[min(y),max(y)])

plot.scatter('x', 'y', source=source)

# レイアウトと出力の設定
inputs = widgetbox()

curdoc().add_root(row(inputs, plot, width=800))
```

[SAVE]クリックで、Preview画面に散布図が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/7dedfd73-2063-bcb8-b5ff-deb4c67b1614.png)


## widgetsの設定
散布図をカスタマイズできるようにします。
さきほどのコードの「#レイアウトと出力の設定」の直前に、widgetsを定義したコードを追記します。

```python
# widgetsの設定
text = TextInput(title="Title", value=y_column+" by "+x_column)
time = df[time_column]
min_year = Slider(title="Time start", value=min(time), start=min(time), end=max(time), step=1)
max_year = Slider(title="Time max", value=max(time), start=min(time), end=max(time), step=1)
cat_categories = df[cat_column].unique().tolist()
cat_categories.insert(0,'All')
category = Select(title="Category", value="All", options=cat_categories)

# レイアウトと出力の設定
inputs = widgetbox()

curdoc().add_root(row(inputs, plot, width=800))
```

ちなみに、以下のようなwidgetsを設定しています。

 - text:タイトルを設定
 - min_year,max_year:スライドバーで表示する
期間を設定
 - category:Tshirtカテゴリを選択


さらにupdateとCallbackの設定をします。
以下のコードを、先ほど追加したコードの後に追加します。

```python
# widgetsの設定
text = TextInput(title="Title", value=y_column+" by "+x_column)
time = df[time_column]
min_year = Slider(title="Time start", value=min(time), start=min(time), end=max(time), step=1)
max_year = Slider(title="Time max", value=max(time), start=min(time), end=max(time), step=1)
cat_categories = df[cat_column].unique().tolist()
cat_categories.insert(0,'All')
category = Select(title="Category", value="All", options=cat_categories)

#updateとCallbackの設定
def update_title(attrname, old, new):
    #タイトルが変更されるとこの関数が呼び出され、plot.title.textを上書きする
    plot.title.text = text.value

# on_change methodで、タイトルの変更を感知
text.on_change('value', update_title)

def update_data(attrname, old, new):
    # スライドバーや選択widget(Tシャツカテゴリ)が変更されるとこの関数が呼び出される。
    # 選択した値に応じてdfをフィルタリングする。
    category_value = category.value
    selected = df[(time>=min_year.value) & (time<=max_year.value)]
    if (category_value != "All"):
        selected = selected[selected[cat_column].str.contains(category_value)==True]
    # Generate the new plot
    x = selected[x_column]
    y = selected[y_column]
    source.data = dict(x=x, y=y)
    
# on_change methodで、スライドバー等の変更を感知
for w in [min_year, max_year, category]:
    w.on_change('value', update_data)
    
# レイアウトと出力の設定
inputs = widgetbox()

curdoc().add_root(row(inputs, plot, width=800))
```

最後に、inputs=widgetbox()を以下のように置き換える。

```python
# レイアウトと出力の設定
inputs = widgetbox(text, min_year, max_year, category)

curdoc().add_root(row(inputs, plot, width=800))
```

[save]クリックでPreviewにwidgetsの設定が反映される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/140ae6ed-9dd5-b6df-2cdd-69c695a1934b.png)


作成したwidgetsは、[ACTIONS]>[Publish]で、ダッシュボードに載せることも可能。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/bbc88264-607b-7dbc-1714-b2aa2afd8b43.png)

ダッシュボードのViewモードでは、widgetsをいじって散布図を変化させることができます。これは便利。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/37c6e5ab-59f9-5221-4f75-511fc8efe078.png)

参考までに、ここまでのプログラム全体像を以下に記載。

```python
# ライブラリインポート
from bokeh.io import curdoc
from bokeh.layouts import row, widgetbox
from bokeh.models import ColumnDataSource
from bokeh.models.widgets import Slider, TextInput, Select
from bokeh.plotting import figure
import dataiku
import pandas as pd

# 入力パラメタの設定
input_dataset = "Orders_enriched_prepared"
x_column = "age"
y_column = "total"
time_column = "order_date_year"
cat_column = "tshirt_category"

#入力データをデータフレームに変換
mydataset = dataiku.Dataset(input_dataset)
df = mydataset.get_dataframe()

#x軸とy軸の値を取得し、可視化用にデータを整形
x = df[x_column]
y = df[y_column]
source = ColumnDataSource(data=dict(x=x, y=y))

# 描画の設定
plot = figure(plot_height=400, plot_width=400, title=y_column+" by "+x_column,
              tools="crosshair,pan,reset,save,wheel_zoom",
              x_range=[min(x), max(x)], y_range=[min(y),max(y)])

plot.scatter('x', 'y', source=source)

# widgetsの設定
text = TextInput(title="Title", value=y_column+" by "+x_column)
time = df[time_column]
min_year = Slider(title="Time start", value=min(time), start=min(time), end=max(time), step=1)
max_year = Slider(title="Time max", value=max(time), start=min(time), end=max(time), step=1)
cat_categories = df[cat_column].unique().tolist()
cat_categories.insert(0,'All')
category = Select(title="Category", value="All", options=cat_categories)

#updateとCallbackの設定
def update_title(attrname, old, new):
    #タイトルが変更されるとこの関数が呼び出され、plot.title.textを上書きする
    plot.title.text = text.value

# on_change methodで、タイトルの変更を感知
text.on_change('value', update_title)

def update_data(attrname, old, new):
    # スライドバーや選択widget(Tシャツカテゴリ)が変更されるとこの関数が呼び出される。
    # 選択した値に応じてdfをフィルタリングする。
    category_value = category.value
    selected = df[(time>=min_year.value) & (time<=max_year.value)]
    if (category_value != "All"):
        selected = selected[selected[cat_column].str.contains(category_value)==True]
    # Generate the new plot
    x = selected[x_column]
    y = selected[y_column]
    source.data = dict(x=x, y=y)
    
# on_change methodで、スライドバー等の変更を感知
for w in [min_year, max_year, category]:
    w.on_change('value', update_data)
    
# レイアウトと出力の設定
inputs = widgetbox(text, min_year, max_year, category)

curdoc().add_root(row(inputs, plot, width=800))
```



#plugin化
今まで作成した散布図web appをプラグイン化します。
参考：https://academy.dataiku.com/plugin-development/513345

以下の記事の前準備が終わっているものとします。
https://qiita.com/ayoyo/items/ffd2bfbf6a8c7f01ecd6

プロジェクトの以下にアクセスし、web app作成画面を表示します。
[</>]>[+New Webapp]>[Code Webapp]>[sample-webapp]

[ACTIONS]>[Plugin]をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/44506dee-9163-f428-13ac-c04b6c36a959.png)

Existing dev pluginを選択し、Plugin idとしてtest pluginを選択。（ここで出てこない場合は、[この記事の前準備](https://qiita.com/ayoyo/items/ffd2bfbf6a8c7f01ecd6)ができていない。）
New plugin webapp idにcustom-scatterplotと記載して[CONVERT]をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/c1530352-8e5c-4eec-5192-133380177a93.png)


画面が切り替わって、以下のような画面が表示されます。これは、pluginの開発用画面となります。散布図web appをプラグイン化したことで、自動的にプラグインのひな形が作成されました。ちなみに[Apps]>[Plugins]>[Development]>[custom-scatterplot]でも辿り着けます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/1e2aa779-e808-c424-a35b-f4e14aa210af.png)

左側にこのプラグインのディレクトリ構造が示されています。今回のプラグインはtest-plugin/webapps/custom-scatterplot以下にファイルが置かれていることがわかります。ちなみに、test-plugin/webapps/custom-histgramは、[この記事](https://qiita.com/ayoyo/items/ffd2bfbf6a8c7f01ecd6)を実施したときのプラグインです。同じプラグインID(test-plugin)で作成したので、同じwebappsディレクトリ以下に置かれたようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/491e30f6-bd0d-dfbc-a002-489fa8156c44.png)


## webapp.jsonの書き換え

プラグイン化するため、内部でベタ書きで呼んでいるデータセット名やカラム名を変更していきます。
webapp.jsonのparamを以下のように書き換えます。

```json
"params": [
    {
        "name": "input_dataset",
        "type": "DATASET",
        "label": "Dataset",
        "description": "The dataset used to populate the web app",
        "mandatory": true,
        "canSelectForeign": true
    },
    {
        "name": "x_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "X-Axis Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "y_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Y-Axis Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "time_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Time Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "cat_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Categorical Column",
        "description": "",
        "mandatory": true
    }
],
```

rolesのコメントアウトを外します。

```json
    "roles": [
         {"type": "DATASET", "targetParamsKey": "input_dataset"} 
    ]
```

これでパラメタの設定ができました。

## backend.pyの書き換え
次にpythonコードの方を書き換えていきます。
backend.pyの「#入力パラメタの設定」を以下のように書き換えます。

```python
# 入力パラメタの設定
input_dataset = get_webapp_config()['input_dataset']
x_column = get_webapp_config()['x_column']
y_column = get_webapp_config()['y_column']
time_column = get_webapp_config()['time_column']
cat_column = get_webapp_config()['cat_column']
```

これで書き換え完了です。
[SAVE ALL]をクリックし、[ACTIONS]>[Reload this plugin]をクリック。

これでプラグインができたので、動作確認します。

先ほどのプロジェクトのフローに戻って、Orders_enriched_preparedデータセットを選択。
Webappsのところに、custom scatterplotが、無い。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/0e524aee-d297-8817-a6d0-42d22de388ae.png)


ブラウザをリロードしたらでてきました、Custom scatterplot。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/c76ff70d-1d54-8dd7-ed67-ee08fbf69c70.png)

Custom scatterplotを選択し、Weapp nameをデフォルトのままにCREATE。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/4a8e70bc-55fa-c2c9-11d9-2543ec204fd2.png)

Viewタブには何もでてこないので、Settingsタブで設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/4426509d-6f85-2562-2157-a2bc7f8bff07.png)

値を設定して、[SAVE AND VIEW WEBAPP]をクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/bdc4eaa7-c10d-ee54-4469-3a36e2961887.png)

先ほどと同じようなwidgetsつきの散布図が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106215/769677c2-2e74-b1ec-170b-ff066c6ca6f8.png)


Settingsからデータセットや選択項目を変えることで、UIからwebappをカスタマイズすることができるようです。

参考までに、webapp.jsonとbackend.pyの全貌を載せておきます。

```json:weapp.json
// This file is the descriptor for webapp custom-scatterplot
{
    "meta": {
        // label: name of the webapp as displayed, should be short
        "label": "Custom scatterplot",
        // description: longer string to help end users understand what this webapp does
        "description": "",
        // icon: must be one of the FontAwesome 3.2.1 icons, complete list here at https://fontawesome.com/v3.2.1/icons/
        "icon": "icon-puzzle-piece"
    },

    "baseType": "BOKEH", // WARNING: do not change
    "hasBackend": "true",
    "noJSSecurity": "true",
    "standardWebAppLibraries": null,

    /* The field "params" holds a list of all the params
       for wich the user will be prompted for values in the Settings tab of the webapp.

       The available parameter types include:
       STRING, STRINGS, INT, DOUBLE, BOOLEAN, SELECT, MULTISELECT, MAP, TEXTAREA, PRESET, DATASET, DATASET_COLUMN, MANAGED_FOLDER

       For the full list and for more details, see the documentation: https://doc.dataiku.com/dss/latest/plugins/reference/params.html
    */
"params": [
    {
        "name": "input_dataset",
        "type": "DATASET",
        "label": "Dataset",
        "description": "The dataset used to populate the web app",
        "mandatory": true,
        "canSelectForeign": true
    },
    {
        "name": "x_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "X-Axis Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "y_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Y-Axis Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "time_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Time Column",
        "description": "",
        "mandatory": true
    },
    {
        "name": "cat_column",
        "type": "DATASET_COLUMN",
        "datasetParamName": "input_dataset",
        "label": "Categorical Column",
        "description": "",
        "mandatory": true
    }
],

    /* roles define where this webapp will appear in DSS GUI. They are used to pre-fill a macro parameter with context.

       Each role consists of:
        - type: where the macro will be shown
            * DATASET, DATASETS, SAVED_MODEL, MANAGED_FOLDER (a button to create webapps will be shown in the corresponding "action" menu)
        - targetParamsKey(s): name of the parameter(s) that will be filled with the selected object
    */
    "roles": [
         {"type": "DATASET", "targetParamsKey": "input_dataset"} 
    ]
}

```


```python:backend.py
from dataiku.customwebapp import *

# Access the parameters that end-users filled in using webapp config
# For example, for a parameter called "input_dataset"
# input_dataset = get_webapp_config()["input_dataset"]

# ライブラリインポート
from bokeh.io import curdoc
from bokeh.layouts import row, widgetbox
from bokeh.models import ColumnDataSource
from bokeh.models.widgets import Slider, TextInput, Select
from bokeh.plotting import figure
import dataiku
import pandas as pd

# 入力パラメタの設定
input_dataset = get_webapp_config()['input_dataset']
x_column = get_webapp_config()['x_column']
y_column = get_webapp_config()['y_column']
time_column = get_webapp_config()['time_column']
cat_column = get_webapp_config()['cat_column']

#入力データをデータフレームに変換
mydataset = dataiku.Dataset(input_dataset)
df = mydataset.get_dataframe()

#x軸とy軸の値を取得し、可視化用にデータを整形
x = df[x_column]
y = df[y_column]
source = ColumnDataSource(data=dict(x=x, y=y))

# 描画の設定
plot = figure(plot_height=400, plot_width=400, title=y_column+" by "+x_column,
              tools="crosshair,pan,reset,save,wheel_zoom",
              x_range=[min(x), max(x)], y_range=[min(y),max(y)])

plot.scatter('x', 'y', source=source)

# widgetsの設定
text = TextInput(title="Title", value=y_column+" by "+x_column)
time = df[time_column]
min_year = Slider(title="Time start", value=min(time), start=min(time), end=max(time), step=1)
max_year = Slider(title="Time max", value=max(time), start=min(time), end=max(time), step=1)
cat_categories = df[cat_column].unique().tolist()
cat_categories.insert(0,'All')
category = Select(title="Category", value="All", options=cat_categories)

#updateとCallbackの設定
def update_title(attrname, old, new):
    #タイトルが変更されるとこの関数が呼び出され、plot.title.textを上書きする
    plot.title.text = text.value

# on_change methodで、タイトルの変更を感知
text.on_change('value', update_title)

def update_data(attrname, old, new):
    # スライドバーや選択widget(Tシャツカテゴリ)が変更されるとこの関数が呼び出される。
    # 選択した値に応じてdfをフィルタリングする。
    category_value = category.value
    selected = df[(time>=min_year.value) & (time<=max_year.value)]
    if (category_value != "All"):
        selected = selected[selected[cat_column].str.contains(category_value)==True]
    # Generate the new plot
    x = selected[x_column]
    y = selected[y_column]
    source.data = dict(x=x, y=y)
    
# on_change methodで、スライドバー等の変更を感知
for w in [min_year, max_year, category]:
    w.on_change('value', update_data)
    
# レイアウトと出力の設定
inputs = widgetbox(text, min_year, max_year, category)

curdoc().add_root(row(inputs, plot, width=800))
```