Docker compose を用いた PostgreSQL と分析サーバーの連結

## 環境
macOS Catalina 10.15.1

## やってみたこと

* Dockerfile で DB サーバー (PostgreSQL) と分析サーバー (jupter notebook) のイメージを取得
* Docker compose で docker file を連結
* DB サーバーから取得したデータで K-Means 法を実施
* 分析データを DB サーバーへ保存

## フォルダの構成

docker-compose.yml が docker compose 起動時に起点となる。
このコードをもとに、他のファイルは参照される。

```
(root)
|--.env
|--run.ipynb
|--docker-compose.yml
|--Dockerfile-db
|--Dockerfile-jupyter
|--input_data
|  |--iris.csv
|--scripts
|  |--initdb
|  |  |--01_init_database.sql
|  |  |--02_init_table.sql
```
### docker-compose.yml
docker-compose.yml は、tab キーでインデントを作ると読み込まれないので注意。
インスタンスや環境変数を別のファイルに作ることで、イメージの追加や起動時の設定などを環境に合わせてカスタマイズできる。

```yml:docker-compose.yml
version: '3'
services: #生成されるインスタンスをそれぞれ定義
 db:
  env_file: ./.env #別のファイルで環境変数を定義
  build:
    context: .
    dockerfile: ./Dockerfile-db #別の dockerfile でイメージを定義
  volumes: #PostgreSQL の保存先を定義
   - ./pgdata_store:/var/lib/postgresql/data
   - "${SCRIPTS_SRC}:/opt/analysis"
  restart: always
  ports:
   - "5432:5432"
  networks: #jupyter と連結するために宣言
   - mlnet
  environment:
   - POSTGRES_USER=${DB_USER}
   - POSTGRES_PASSWORD=${DB_PASSWORD}

 jupyter:
  env_file: ./.env
  build:
   context: .
   dockerfile: ./Dockerfile-jupyter
  restart: always
  volumes:
   - "${SCRIPTS_SRC}:/opt/analysis"
  depends_on:
   - db
  ports:
   - "8888:8888"
  networks: #Postgres と連結するために宣言
   - mlnet

volumes:
 pgdata_store:
  driver: local
networks: #bridge はホストの任意のポートをコンテナのポートにマップできる。
 mlnet:
  driver: bridge
  ```
### .env

```:.env
# docker-compose.yml にて利用する環境変数を定義
COMPOSE_PROJECT_NAME=ml

# DB 接続情報
DB_HOST=db
DB_PORT=5432
DB_NAME=analysis
DB_USER=postgres
DB_PASSWORD=postgres

# volume mount
SCRIPTS_SRC=./

# Jupyter インスタンスにて利用する環境変数を定義
NOTEBOOK_PASSWORD=analysis

# タイムゾーンの設定
TZ=Asia/Tokyo

# 言語指定。文字化けを防ぐ。
LANG=ja_JP.utf8
```

### Dockerfile-db

```dockerfile:Dockerfile-db
# 公式イメージを取得
FROM postgres:11.2-alpine

# Docker 起動時に実行される scripts をコンテナのディレクトリへコピー
COPY scripts/initdb/* /docker-entrypoint-initdb.d/ 
```

`` scripts/initdb/* `` は ``01_init_database.sql`` （database の生成）および ``02_init_table.sql`` （データ定義言語:DDL の設定）を指す。
ファイル名に数字の prefix を付けることで、その数字の順番に実行されるようになる。

#### 01_init_database.sql

```sql:01_init_database.sql
CREATE DATABASE "analysis" encoding "UTF8";
```
#### 02_init_table.sql

```sql:02_init_table.sql

# 01_init_database.sql で作成した analysis database に入る
\c analysis

# 作成済みの table を消しておく
DROP TABLE IF EXISTS iris_input;
DROP TABLE IF EXISTS iris_cluster;

# 入力データ用 table を作成する
CREATE TABLE iris_input(
 id serial NOT NULL,
 data_version varchar(16) NOT NULL,
 sepal length numeric NOT NULL,
 sepal width numeric NOT NULL,
 petal length numeric NOT NULL,
 petal width numeric NOT NULL,
 create_timestamp TIMESTAMP NOT NULL,
 primary key(id, data_version)
);

# 分析済みデータ用 table を作成する
CREATE TABLE iris_cluster(
 id serial NOT NULL,
 data_version varchar(16) NOT NULL,
 cluster_id numeric NOT NULL,
 pca1 numeric NOT NULL,
 pca2 numeric NOT NULL,
 create_timestamp TIMESTAMP NOT NULL,
 primary key(id, data_version)
);
```
初期設定の table に入力できるのは、ここで設定した column のみ。
存在しない column は修正・追加する必要がある。
column 名を間違えると table に入力できない。

### Dockerfile-jupyter

```dockerfile:Dockerfile-jupyter
FROM jupyter/scipy-notebook:65761486d5d3

USER root

# pip で必要なパッケージをインストールするために OS のパッケージマネージャーを最新化
RUN apt-get update

RUN apt-get install -y python3-dev libpq-dev

# Python の開発ツールと PostgreSQL のパッケージをインストール
RUN pip install --upgrade pip \
 && pip install ipython-sql \
 && pip install psycopg2 \
 && pip install sqlalchemy

# Jupyter アクセス時のパスワードと、Jupyter 実行時のルートディレクトリを設定
CMD jupyter notebook --allow-root --NotebookApp.token="$NOTEBOOK_PASSWORD" /opt/analysis
```
## コンテナの起動

この節のコマンドはターミナルで実行する。
ルートディレクトリで次のコマンドを実行し docker-compose.yml から docker image を作成する。

```terminal:ターミナル
docker-compose build 
```

次のコマンドで docker インスタンスを起動する。

```terminal:ターミナル
docker-compose up -d
```

次のコマンドでインスタンスの起動状態を確認する。

```terminal:ターミナル
docker-compose ps
```

以下のように ml_db_1 と ml_jupyter_1 の state が Up となっていれば成功している。

```terminal:ターミナル
  Name                  Command               State           Ports         
------------------------------------------------------------------------------
ml_db_1        docker-entrypoint.sh postgres    Up      0.0.0.0:5432->5432/tcp
ml_jupyter_1   tini -g -- /bin/sh -c jupy ...   Up      0.0.0.0:8888->8888/tcp
```


ブラウザで `` http://localhost:8888`` にアクセスすると、jupyter notebook のログイン画面が表示される。以下 jupyter notebook で作業する。

## データの取得

jupyter notebook でアイリスのがく（sepal）と花びら（petal）のデータを取得する。

```python:run.ipynb
from sklearn.datasets import load_iris
import pandas as pd

from sklearn.datasets import load_iris
import pandas as pd

ds = load_iris()
df = pd.DataFrame(ds.data, columns = ds.feature_names)
df['species'] = ds.target
df.loc[df['species'] == 0, 'species'] = "setosa"
df.loc[df['species'] == 1, 'species'] = "versicolor"
df.loc[df['species'] == 2, 'species'] = "virginica"
df = df.reset_index()
df = df.rename(columns = {'sepal length (cm)':'sepal_length', 'sepal width (cm)':'sepal_width', 'petal length (cm)':'petal_length', 'petal width (cm)':'petal_width', 'index':'id'})
df['data_version']=1.0
df.head()
```
以上のコードを実行すると、次のような dataframe が得られる。
![lkCS1dL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/612858/46373d80-f31c-3a81-bcdc-7edf7065c2d7.png)

初期データなので data_version 1.0 である。これ以後に追加されたデータは ver. 2.0 とする。
次のコマンドで csv ファイルとして保存する。

```python:run.ipynb
df.to_csv('./input_data/iris.csv')
```

## PostgreSQL への入力

先ほど取得した csv ファイルを読み込む。

```python:run.ipynb
import pandas as pd
iris = pd.read_csv('./input_data/iris.csv', index_col=0)

iris.head()
```

保存前に次のコマンドで現在時刻を取得しておく。データをいつ保存したのかを明記しておく。

```python:run.ipynb
iris['create_timestamp']= pd.datetime.now()
```

PostgreSQL との接続ドライバーである engine を取得する。
pgconfig は、os パッケージの機能を利用して .env に登録した環境変数から読み込むようにしている。

```python:run.ipynb
from sqlalchemy import create_engine
import os

pgconfig = {'host':os.environ['DB_HOST'],
            'port': os.environ['DB_PORT'],
            'database': os.environ['DB_NAME'],
            'user': os.environ['DB_USER'],
            'password': os.environ['DB_PASSWORD']
            }

engine = create_engine('postgresql+psycopg2://{user}:{password}@{host}:{port}/{database}'.format(**pgconfig))
engine
```

次の engine が得られていれば成功である。

```python:run.ipynb
Engine(postgresql+psycopg2://postgres:***@db:5432/analysis)
```

次のコードで、取得したデータを PostgreSQL に保存する。

```python:run.ipynb
from sqlalchemy import create_engine

# database に登録する column を選ぶ。
# 02_init_table.sql で定義した column 名と一致させること。
columns = ['id',
           'data_version',
           'sepal_length',
           'sepal_width',
           'petal_length',
           'petal_width',
           'create_timestamp']

# if_exists を replace にすると先行データと置換される。
iris[columns].to_sql('iris_input', engine, if_exists='append', index=False)
```

## PostgreSQL からの読み込み

Jupyter notebook に以下のコードを打ち database と接続する。

```python:run.ipynb
# SQL の拡張機能を呼び出す
%load_ext sql

# DB 接続に必要な engine を取得
dsl = 'postgres://{user}:{password}@{host}:{port}/{database}'.format(**pgconfig)

# sql に接続
%sql $dsl
```
以下のコードで接続情報を読み込む。

```python:run.ipynb
# PostgreSQL の table を pandas の dataframe として取得することを定義
%config SqlMagic.autopandas = True

# iris_inout table の全 column を取得
df_input = %sql select * from iris_input
df_input.head()
```

以下のような dataframe が得られていれば成功している。
![](https://i.imgur.com/aZsBY6d.png)

## K-means 法

Dataframe から分析対象の特徴量（アイリスのサイズデータ）を抽出する。

```python:run.ipynb
df_src = df_input[['sepal_length', 'sepal_width', 'petal_length', 'petal_width']]
df_src.head()
```

sklearn ライブラリで K-mean 法を実施する。
n_cluster でクラスタ数を決める。今回は 3 クラスタに分割する。

```python:run.ipynb
from sklearn.cluster import KMeans
kms = KMeans(n_clusters=3, random_state=1)
clusters = kms.fit_predict(df_src)
clusters
```

次のように、0,1,2 にクラスタリングしていれば成功している。

```python:run.ipynb
array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
       1, 1, 1, 1, 1, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 2, 2, 2, 0, 2, 2, 2,
       2, 2, 2, 0, 0, 2, 2, 2, 2, 0, 2, 0, 2, 0, 2, 2, 0, 0, 2, 2, 2, 2,
       2, 0, 2, 2, 2, 2, 0, 2, 2, 2, 0, 2, 2, 2, 0, 2, 2, 0], dtype=int32)
```

主成分分析（PCA)により、4特徴量を2次元に圧縮する。

```python:run.ipynb
from sklearn.decomposition import PCA
pca = PCA(n_components=2, random_state=1)
pca_values = pca.fit_transform(df_src)
```

2次元データとクラスタリング結果を ``df_plot`` にまとめ、 seanborn を使って散布図にプロットする。

```python:run.ipynb
%matplotlib inline
import seaborn as sns

df_plot = df_pca.assign(cluster_id=clusters)
sns.lmplot(x='pca1', y='pca2', data=df_plot, hue='cluster_id',markers=['o','s','+'], fit_reg=False)
```
次のように描画できていれば成功している。
![YpncU7v.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/612858/ae5a3efb-b04b-adf3-c07f-ae3c68798c39.png)


``Fit_reg`` を True にすると線形回帰が実施される。今回は関係ないため False に設定している。

### エルボー法

k-mean 法ではクラスタ数が多すぎると過学習が生じる。適切なクラスタ数を決めるために、エルボー法が用いられることがある。
次のコードで、クラスタ数 1~10 における距離の総和を``dist_sums`` というリストにまとめる。

```python:run.ipynb
# エルボー法のコード
from sklearn.cluster import KMeans

dist_sums = []
for cluster_cnt in range(1,10):
    kms = KMeans(n_clusters=cluster_cnt,random_state=1)
    kms.fit_predict(df_pca)
    dist_sums.append(kms.inertia_)
```
次のコードで可視化する。

```python:run.ipynb
# 結果の可視化
%matplotlib inline
import matplotlib.pyplot as plt

plt.plot(range(1,10), dist_sums)
plt.xlabel('Num Cluster')
plt.ylabel('Distance')
```
次のような描画が成功していれば成功している。
![](https://i.imgur.com/VmmyPfQ.png)
距離の総和の減少速度が急激に小さくなるクラスタ数4以上では過学習が生じていると考えられる。今回はクラスター数3が適切であった。

## 分析データの保存

分析結果に id と data_version を加える。

```python:run.ipynb
df_save_target = df_plot.assign(id=df_input[['id']], data_version=df_input[['data_version']])
```

分析結果を PostgreSQL へ保存する。

```python:run.ipynb
df_save_target[['id','data_version','pca1','pca2','cluster_id']].to_sql('iris_cluster',engine, if_exists='append', index=False)
```

## データベース Table の管理コマンド

Jupyter notebook でデータベースを管理できる。

* iris_cluster の先頭10サンプルにおける pca1、pca2 を取得

```python:run.ipynb
%sql select pca1,pca2 from iris_cluster limit 10;
```
* numeric 型の test column を追加。デフォルト値は NULL

```python:run.ipynb
%sql ALTER TABLE iris_input ADD test numeric NULL;
```

* timestamp 型の test2 column を追加。デフォルト値は現在時刻

```python:run.ipynb
%sql ALTER TABLE iris_input ADD test2 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
```

* iris_input の sepal_length が 3 未満のサンプルを取得

```python:run.ipynb
%sql SELECT * FROM iris_input WHERE 3 > sepal_width limit 10;
```
## 参考
参考：[データ収集からWebアプリ開発まで実践で学ぶ機械学習活用ガイド](https://book.mynavi.jp/supportsite/detail/9784839969226.html) の chapter 4