CUBE SUGAR CONTAINER

技術系のこと書きます。

kind (Kubernetes IN Docker) を使ってみる

今回は Kubernetes の開発で使われている公式ツールの kind を使ってみる。 このツールを使うと Docker のコンテナを使って Kubernetes のクラスタが素早く簡単に構築できる。

使った環境は次のとおり。

$ sw_vers                        
ProductName:    macOS
ProductVersion: 11.6
BuildVersion:   20G165
$ uname -m
arm64
$ kind version                             
kind v0.11.1 go1.16.4 darwin/arm64
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.5", GitCommit:"aea7bbadd2fc0cd689de94a54e5b7b758869d691", GitTreeState:"clean", BuildDate:"2021-09-15T21:10:45Z", GoVersion:"go1.16.8", Compiler:"gc", Platform:"darwin/arm64"}
Server Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.1", GitCommit:"5e58841cce77d4bc13713ad2b91fa0d961e69192", GitTreeState:"clean", BuildDate:"2021-05-21T23:06:30Z", GoVersion:"go1.16.4", Compiler:"gc", Platform:"linux/arm64"}
$ docker version
Client:
 Cloud integration: 1.0.17
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:55:20 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:53:48 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

もくじ

下準備

はじめに Docker Desktop をインストールしておく。

$ brew install --cask docker

Docker を起動する。

$ open /Applications/Docker.app

docker version コマンドを実行する。 クライアントとサーバのバージョンが正しく表示されることを確認する。

$ docker version
Client:
 Cloud integration: 1.0.17
 Version:           20.10.8
 API version:       1.41
 Go version:        go1.16.6
 Git commit:        3967b7d
 Built:             Fri Jul 30 19:55:20 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.8
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.6
  Git commit:       75249d8
  Built:            Fri Jul 30 19:53:48 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.9
  GitCommit:        e25210fe30a0a703442421b0f60afac609f950a3
 runc:
  Version:          1.0.1
  GitCommit:        v1.0.1-0-g4144b63
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

続いて kind をインストールする。 macOS なら Homebrew で入る。

$ brew install kind

これで kind コマンドが使えるようになる。

$ kind version   
kind v0.11.1 go1.16.4 darwin/arm64

Kubernetes クラスタを作る

kind を使って Kubernetes のクラスタを作るには kind create cluster コマンドを使う。 最初に実行したときは Kubernetes のノード用のイメージファイルをダウンロードするので時間がかかる。

$ kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.21.1) 🖼 
 ✓ Preparing nodes 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️ 
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Not sure what to do next? 😅  Check out https://kind.sigs.k8s.io/docs/user/quick-start/

コマンドの実行が完了すると、デフォルトの名前 (kind) でクラスタができる。

$ kind get clusters
kind

kubectl コマンドの設定ファイルも自動で作られるので、すぐ使える状態になってる。

$  kubectl config get-contexts
CURRENT   NAME        CLUSTER     AUTHINFO    NAMESPACE
*         kind-kind   kind-kind   kind-kind   
$ kubectl config view --minify
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: DATA+OMITTED
    server: https://127.0.0.1:49921
  name: kind-kind
contexts:
- context:
    cluster: kind-kind
    user: kind-kind
  name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
  user:
    client-certificate-data: REDACTED
    client-key-data: REDACTED

デフォルトではシングルノード構成でクラスタができる。

$ kubectl get nodes
NAME                 STATUS   ROLES                  AGE    VERSION
kind-control-plane   Ready    control-plane,master   103s   v1.21.1

docker container list コマンドを使って稼働しているコンテナを見ると Kubernetes のコントロールプレーンのコンテナが見える。

$ docker container list
CONTAINER ID   IMAGE                  COMMAND                  CREATED         STATUS         PORTS                       NAMES
eec5aa7fa35d   kindest/node:v1.21.1   "/usr/local/bin/entr…"   2 minutes ago   Up 2 minutes   127.0.0.1:49921->6443/tcp   kind-control-plane

コンテナの中身を覗くと、必要なサービスが色々と立ち上がっているようだ。

$ docker container exec -it eec5aa7fa35d ss -tlnp
State     Recv-Q    Send-Q       Local Address:Port        Peer Address:Port    Process                                                                         
LISTEN    0         4096            172.18.0.2:2379             0.0.0.0:*        users:(("etcd",pid=675,fd=6))                                                  
LISTEN    0         4096             127.0.0.1:2379             0.0.0.0:*        users:(("etcd",pid=675,fd=5))                                                  
LISTEN    0         4096            172.18.0.2:2380             0.0.0.0:*        users:(("etcd",pid=675,fd=3))                                                  
LISTEN    0         4096             127.0.0.1:2381             0.0.0.0:*        users:(("etcd",pid=675,fd=10))                                                 
LISTEN    0         4096             127.0.0.1:10257            0.0.0.0:*        users:(("kube-controller",pid=607,fd=7))                                       
LISTEN    0         4096             127.0.0.1:10259            0.0.0.0:*        users:(("kube-scheduler",pid=546,fd=7))                                        
LISTEN    0         4096             127.0.0.1:42015            0.0.0.0:*        users:(("containerd",pid=183,fd=13))                                           
LISTEN    0         4096             127.0.0.1:10248            0.0.0.0:*        users:(("kubelet",pid=722,fd=23))                                              
LISTEN    0         4096             127.0.0.1:10249            0.0.0.0:*        users:(("kube-proxy",pid=957,fd=16))                                           
LISTEN    0         1024            127.0.0.11:33609            0.0.0.0:*                                                                                       
LISTEN    0         4096                     *:6443                   *:*        users:(("kube-apiserver",pid=593,fd=7))                                        
LISTEN    0         4096                     *:10256                  *:*        users:(("kube-proxy",pid=957,fd=18))                                           
LISTEN    0         4096                     *:10250                  *:*        users:(("kubelet",pid=722,fd=27))

ちなみに、推奨される環境としては Docker のランタイムに 6 ~ 8GB 以上のメモリを割り当てることが望ましいらしい。

以下のコマンドで、今のランタイムがどれくらい使えるか確認できる。 デフォルトでは 2GB になっている可能性があるので必要に応じて割り当てを増やそう。

$ docker stats --no-stream
CONTAINER ID   NAME                 CPU %     MEM USAGE / LIMIT   MEM %     NET I/O           BLOCK I/O         PIDS
eec5aa7fa35d   kind-control-plane   132.22%   358.5MiB / 5.8GiB   6.04%     2.98kB / 1.34kB   18.1MB / 65.5kB   125

複数のクラスタを作る・壊す

特定の名前でクラスタを作りたいときは --name オプションを指定する。

$ kind create cluster --name kind-2

これで、2 つのクラスタができた。

$ kind get clusters
kind
kind-2

kubectl にも複数のクラスタが登録されている。

$ kubectl config get-contexts
CURRENT   NAME          CLUSTER       AUTHINFO      NAMESPACE
          kind-kind     kind-kind     kind-kind     
*         kind-kind-2   kind-kind-2   kind-kind-2   

クラスタを消すときは kind delete cluster コマンドを使う。

$ kind delete cluster --name kind-2

消したら、コンテキストを元のクラスタに切り替えておく。

$ kubectl config use-context kind-kind
Switched to context "kind-kind".
$ kubectl config current-context      
kind-kind

自前のコンテナイメージを使って Pod を立ち上げてみる

次は、試しに自前のコンテナイメージを使って Pod を立ち上げてみよう。

とりあえず、適当に WSGI でホスト名を返すアプリケーションを用意する。

$ cat << 'EOF' > server.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
from wsgiref.simple_server import make_server


def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    uname = os.uname()
    nodename = uname.nodename
    msg = f'Hello, World! by {nodename=}\n'
    return [msg.encode('ascii')]


def main():
    with make_server('', 8080, application) as httpd:
        print("Serving on port 8080...")
        httpd.serve_forever()

if __name__ == '__main__':
    main()
EOF

上記のアプリケーションを組み込んだ Dockerfile を用意する。

$ cat << 'EOF' > Dockerfile
FROM python:3.9

EXPOSE 8080

COPY server.py .

CMD python3 server.py
EOF

Docker イメージをビルドする。

$ docker build -t example/helloworld:0.1 .

手元のイメージは kind load docker-image コマンドを使ってクラスタに登録できる。

$ kind load docker-image example/helloworld:0.1

次のように自前のイメージを使って Pod を立ち上げるマニフェストファイルを用意する。 ポイントは imagePullPolicy: Never で、これで必ずローカルのイメージが使われるようになる。

$ cat << 'EOF' > helloworld.yaml
apiVersion: v1
kind: Pod
metadata:
  name: helloworld-pod
spec:
  containers:
  - name: helloworld-server
    image: example/helloworld:0.1
    imagePullPolicy: Never
EOF

マニフェストファイルを適用する。

$ kubectl apply -f helloworld.yaml
pod/helloworld-pod created

すると、次のように Pod が立ち上がる。

$ kubectl get pods
NAME             READY   STATUS    RESTARTS   AGE
helloworld-pod   1/1     Running   0          12s

ポートフォワーディングで Pod の TCP:8080 ポートを引き出してみよう。

$ kubectl port-forward helloworld-pod 8080:8080
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

別のターミナルからリクエストを送ると、ちゃんと HTTP のレスポンスが得られる。

$ curl http://localhost:8080
Hello, World! by nodename='helloworld-pod'

kubectl exec コマンドで Pod に入っても、ちゃんとプロセスが稼働していることが確認できる。

$ kubectl exec -it helloworld-pod -- /bin/bash
root@helloworld-pod:/# uname -a
Linux helloworld-pod 5.10.47-linuxkit #1 SMP PREEMPT Sat Jul 3 21:50:16 UTC 2021 aarch64 GNU/Linux
root@helloworld-pod:/# apt-get update && apt-get -y install iproute2
root@helloworld-pod:/# ss -tlnp
State                   Recv-Q                  Send-Q                                   Local Address:Port                                   Peer Address:Port                 Process                 
LISTEN                  0                       5                                              0.0.0.0:8080                                        0.0.0.0:*                     users:(("python3",pid=8,fd=3))
root@helloworld-pod:/# exit

マルチノードクラスタを作ってみる

kind の良いところは手軽にマルチノードクラスタも作れるところ。

一旦、シングルノードのクラスタは削除しておく。

$ kind delete cluster

そして、以下のような kind 用の設定ファイルを用意する。 これでコントロールプレーンとワーカーノード x2 のクラスタが作れる。

$ cat << 'EOF' > kind-multi-node-cluster.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker
EOF

設定ファイルを使ってクラスタを作る。

$ kind create cluster \                      
    --name kind-multi \
    --config kind-multi-node-cluster.yaml
Creating cluster "kind-multi" ...
 ✓ Ensuring node image (kindest/node:v1.21.1) 🖼 
 ✓ Preparing nodes 📦 📦 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️ 
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
 ✓ Joining worker nodes 🚜 
Set kubectl context to "kind-kind-multi"
You can now use your cluster with:

kubectl cluster-info --context kind-kind-multi

Have a nice day! 👋

すると、次のように 3 つのノードから成るクラスタができる。

$ kubectl get nodes                            
NAME                       STATUS   ROLES                  AGE   VERSION
kind-multi-control-plane   Ready    control-plane,master   53s   v1.21.1
kind-multi-worker          Ready    <none>                 24s   v1.21.1
kind-multi-worker2         Ready    <none>                 24s   v1.21.1

確認すると、Docker コンテナも 3 つ稼働している。

$ docker container list
CONTAINER ID   IMAGE                  COMMAND                  CREATED              STATUS              PORTS                       NAMES
bcb2f337b802   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute                               kind-multi-worker
eb32f4375353   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute                               kind-multi-worker2
22da177bed79   kindest/node:v1.21.1   "/usr/local/bin/entr…"   About a minute ago   Up About a minute   127.0.0.1:50566->6443/tcp   kind-multi-control-plane

自前のイメージで Deployment を立ち上げてみる

マルチノードのクラスタを使って遊んでいこう。 試しに、複数の Pod を持った Deployment を作ってみる。

まずは先ほどビルドした Docker イメージをマルチノードのクラスタに登録する。

$ kind load docker-image example/helloworld:0.1 \
    --name kind-multi

そして、次のように Deployment 用のマニフェストファイルを用意する。 これで、Deployment x1 / ReplicaSet x1 / Pod x2 のリソースができる。

$ cat << 'EOF' > deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: helloworld-deployment
  labels:
    app: helloworld
spec:
  replicas: 2
  selector:
    matchLabels:
      app: helloworld
  template:
    metadata:
      labels:
        app: helloworld
    spec:
      containers:
      - name: helloworld-server
        image: example/helloworld:0.1
        imagePullPolicy: Never
        ports:
        - containerPort: 8080
EOF

マニフェストファイルを適用する。

$ kubectl apply -f deployment.yaml 
deployment.apps/helloworld-deployment created

すると、次のようにそれぞれのオブジェクトができた。 Pod を見ると、ちゃんとそれぞれのワーカーで動作している様子が確認できる。

$ kubectl get deployments         
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
helloworld-deployment   2/2     2            2           12s
$ kubectl get replicaset 
NAME                              DESIRED   CURRENT   READY   AGE
helloworld-deployment-d857c4cb6   2         2         2       25s
$ kubectl get pods      
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   1/1     Running   0          44s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          44s

Pod に HTTP でアクセスする

続いては上記の Pod に HTTP でアクセスしてみる。 kubectl expose deployment コマンドで Service オブジェクトを作る。

$ kubectl expose deployment helloworld-deployment
service/helloworld-deployment exposed

これでアクセスするための IP アドレス (CLUSTER-IP) が割り当てられた。

$ kubectl get services -l app=helloworld            
NAME                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
helloworld-deployment   ClusterIP   10.96.190.89   <none>        8080/TCP   102s

正し、この IP アドレスはクラスタ内部向けなので、Kind を実行しているホストからだと直接はアクセスできない。

$ ping -c 3 10.96.190.89  
PING 10.96.190.89 (10.96.190.89): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1

--- 10.96.190.89 ping statistics ---
3 packets transmitted, 0 packets received, 100.0% packet loss

そこで、代わりにクラスタのコンテナ経由でアクセスしてみよう。

$ docker container exec -it kind-multi-control-plane curl http://10.96.190.89:8080
Hello, World! by nodename='helloworld-deployment-d857c4cb6-zcfl7'
$ docker container exec -it kind-multi-control-plane curl http://10.96.190.89:8080
Hello, World! by nodename='helloworld-deployment-d857c4cb6-8tvzk'

ちゃんと、それぞれの Pod がリクエストを捌いていることが確認できた。

ちなみに、デフォルトでは --type=LoadBalancer な Service は作れないようだ。

$ kubectl delete service helloworld-deployment
$ kubectl expose deployment helloworld-deployment --type=LoadBalancer
service/helloworld-deployment exposed
$ kubectl get services -l app=helloworld                             
NAME                    TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
helloworld-deployment   LoadBalancer   10.96.93.210   <pending>     8080:30640/TCP   20s

上記のように、状態が <pending> となっており、IP アドレスが割り振られない。

確認が済んだら Service を削除しておこう。

$ kubectl delete service helloworld-deployment

オートヒーリングを確認する

続いてはオートヒーリングの様子を観察するために Pod を壊してみよう。

まずは今の Pod の状態をかくにんしておく。

$ kubectl get pods -l app=helloworld          
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   1/1     Running   0          8m9s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          8m9s

ここで、Pod を片方削除してみよう。

$ kubectl delete pods helloworld-deployment-d857c4cb6-8tvzk
pod "helloworld-deployment-d857c4cb6-8tvzk" deleted

別のターミナルから Pod の状態を確認すると、指定した Pod が削除されて新たに別の Pod が作られる様子が見える。

$ kubectl get pods -l app=helloworld
NAME                                    READY   STATUS        RESTARTS   AGE
helloworld-deployment-d857c4cb6-8tvzk   0/1     Terminating   0          9m29s
helloworld-deployment-d857c4cb6-ndprj   1/1     Running       0          34s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running       0          9m29s

最終的には以下のように Pod が 2 つの状態で安定する。

$ kubectl get pods -l app=helloworld
NAME                                    READY   STATUS    RESTARTS   AGE
helloworld-deployment-d857c4cb6-ndprj   1/1     Running   0          45s
helloworld-deployment-d857c4cb6-zcfl7   1/1     Running   0          9m40s

また、ログを確認すると Pod が作り直されていることがわかる。

$ kubectl describe replicaset helloworld-deployment | tail
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Events:
  Type    Reason            Age    From                   Message
  ----    ------            ----   ----                   -------
  Normal  SuccessfulCreate  11m    replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-8tvzk
  Normal  SuccessfulCreate  11m    replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-zcfl7
  Normal  SuccessfulCreate  2m49s  replicaset-controller  Created pod: helloworld-deployment-d857c4cb6-ndprj

そんな感じで。

Python: Keras でカスタムメトリックを扱う

今回は Keras に組み込みで用意されていない独自の評価指標 (カスタムメトリック) を扱う方法について書いてみる。

なお、Keras でカスタムメトリックを定義する方法については、以下の公式ドキュメントに記載がある。

keras.io

使った環境は次のとおり。 Keras にはスタンドアロン版ではなく TensorFlow 組み込みのもの (tf.keras) を使った。

$ sw_vers  
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V                                                       
Python 3.7.7
$ python -c "import tensorflow as tf; print(tf.__version__)"
2.2.0
$ python -c "import tensorflow as tf; print(tf.keras.__version__)"
2.3.0-tf

下準備

まずは TensorFlow をインストールしておく

$ pip install tensorflow

注意点について

Keras のメトリックを定義したオブジェクトには、正解と予測した内容が TensorFlow の Tensor オブジェクトとして渡される。 つまり、一般的な機械学習でおなじみの NumPy 配列のようには扱えない点に注意が必要となる。 そのような事情があるので、カスタムメトリックを計算するときは、はじめに REPL を使ってインタラクティブに動作を確認した方がわかりやすい。

ここでは、そのやり方について書いてみる。 はじめに、Python のインタプリタ (REPL) を起動しよう。

$ python

そして、TensorFlow のパッケージをインポートする。

>>> import tensorflow as tf
>>> from tensorflow.keras import backend as K

正解ラベルと、モデルが出力する予測を模したオブジェクトを次のように用意する。 今回の例は、多値分類問題のラベルを模している。

>>> y_true = tf.constant([[0., 0., 1.], [0., 1., 0.]])
>>> y_pred = tf.constant([[0., 0., 1.], [1., 0., 0.]])

このようにすると、TensorFlow 2 では NumPy の配列として中身が確認できる。

>>> y_true
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [0., 1., 0.]], dtype=float32)>
>>> y_pred
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [1., 0., 0.]], dtype=float32)>

このオブジェクトを使って計算方法の動作確認をしていく。

Recall を計算してみる

試しに Recall を計算してみよう。

正解と予測の Tensor で積を取って、両者が一致している部分だけ残す。

>>> y_true * y_pred
<tf.Tensor: shape=(2, 3), dtype=float32, numpy=
array([[0., 0., 1.],
       [0., 0., 0.]], dtype=float32)>

残っている部分を足し合わせれば True Positive の要素数になる。

>>> true_positives = K.sum(y_true * y_pred)
>>> true_positives
<tf.Tensor: shape=(), dtype=float32, numpy=1.0>

正解ラベル部分を足し合わせれば、すべての Positive な要素数が得られる。

>>> total_positives = K.sum(y_true)
>>> total_positives
<tf.Tensor: shape=(), dtype=float32, numpy=2.0>

あとは、 割ってやれば Recall のスコアが得られるという寸法。

>>> recall = true_positives / total_positives
>>> recall
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>

Accuracy を計算してみる

もうひとつの例として Accuracy も計算してみる。

はじめに、正解ラベルと予測ラベルのインデックスを取り出す。

>>> y_true_argmax = K.argmax(y_true)
>>> y_pred_argmax = K.argmax(y_pred)
>>> y_true_argmax
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 1])>
>>> y_pred_argmax
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([2, 0])>

両者が一致しているものを調べる。

>>> y_matched = K.equal(y_true_argmax, y_pred_argmax)
>>> y_matched
<tf.Tensor: shape=(2,), dtype=bool, numpy=array([ True, False])>

あとは平均を計算すれば Accuracy になる。

>>> accuracy = K.mean(y_matched)
>>> accuracy
<tf.Tensor: shape=(), dtype=float32, numpy=0.5>

もちろん、ここで示したのはあくまで一例で、色々な計算方法が考えられる。

カスタムメトリックを定義して組み込む (Stateless)

それでは、実際に前述した処理を使ってカスタムメトリックを定義してみよう。 とはいえ、やることは正解と予測の Tensor を受け取ってメトリックをまた Tensor として返す関数を用意するだけ。 あとは、それを tf.keras.models.Model#compile() メソッドで metrics 引数に渡してやれば良い。

以下のサンプルコードでは MNIST データセットを MLP で予測するときに、カスタムメトリックとして Recall と Accuracy を定義して使っている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from tensorflow.keras import backend as K
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Model
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.callbacks import EarlyStopping


def custom_recall(y_true, y_pred):
    """正解と予測の Tensor から Recall を計算する関数"""
    true_positives = K.sum(y_true * y_pred)
    total_positives = K.sum(y_true)
    return true_positives / (total_positives + K.epsilon())  # ゼロ除算対策


def custom_accuracy(y_true, y_pred):
    """正解と予測の Tensor から Accuracy を計算する関数"""
    y_true_argmax = K.argmax(y_true)
    y_pred_argmax = K.argmax(y_pred)
    y_matched = K.equal(y_true_argmax, y_pred_argmax)
    return K.mean(y_matched)


def main():
    # load dataset
    (x_train, y_train), (x_test, y_test) = mnist.load_data()

    # one-hot encode
    y_train = to_categorical(y_train)
    y_test = to_categorical(y_test)

    # flatten
    image_height, image_width = x_train.shape[1:]
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # min-max normalize
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # Multi Layer Perceptron
    inputs = Input(shape=(image_height * image_width,))
    x = Dense(64, activation='relu')(inputs)
    x = Dense(64, activation='relu')(x)
    num_of_classes = y_train.shape[1]
    outputs = Dense(num_of_classes, activation='softmax')(x)

    callbacks = [
        # 検証データに対する Recall が 10 エポック改善しないときは学習を打ち切る
        EarlyStopping(monitor='val_custom_recall',
                      patience=10,
                      verbose=1,
                      mode='max'),
    ]
    model = Model(inputs=inputs, outputs=outputs)
    model.compile(optimizer='adam',
                  loss='categorical_crossentropy',
                  # カスタムメトリックを計算する関数を登録する
                  metrics=['accuracy', custom_recall, custom_accuracy],
                  )

    model.fit(x_train, y_train,
              batch_size=8192,
              epochs=1000,
              verbose=1,
              # ホールドアウトデータを検証データとして用いる
              validation_data=(x_test, y_test),
              callbacks=callbacks)


if __name__ == '__main__':
    main()

上記を保存して実行してみよう。 ちゃんと、custom_ から始まる名前でカスタムメトリックが学習過程のログに登場していることがわかる。

$ python stateless.py
...
Epoch 1/1000
8/8 [==============================] - 0s 46ms/step - loss: 2.1424 - accuracy: 0.3151 - custom_recall: 0.1247 - custom_accuracy: 0.3321 - val_loss: 1.8506 - val_accuracy: 0.5677 - val_custom_recall: 0.1703 - val_custom_accuracy: 0.5829
Epoch 2/1000
8/8 [==============================] - 0s 25ms/step - loss: 1.6449 - accuracy: 0.6475 - custom_recall: 0.2220 - custom_accuracy: 0.6544 - val_loss: 1.3144 - val_accuracy: 0.7322 - val_custom_recall: 0.3227 - val_custom_accuracy: 0.7484
Epoch 3/1000
8/8 [==============================] - 0s 31ms/step - loss: 1.1343 - accuracy: 0.7647 - custom_recall: 0.3867 - custom_accuracy: 0.7668 - val_loss: 0.8659 - val_accuracy: 0.8126 - val_custom_recall: 0.5142 - val_custom_accuracy: 0.8298
...
Epoch 207/1000
8/8 [==============================] - 0s 28ms/step - loss: 0.0086 - accuracy: 0.9991 - custom_recall: 0.9927 - custom_accuracy: 0.9991 - val_loss: 0.1126 - val_accuracy: 0.9738 - val_custom_recall: 0.9718 - val_custom_accuracy: 0.9769
Epoch 208/1000
8/8 [==============================] - 0s 28ms/step - loss: 0.0087 - accuracy: 0.9991 - custom_recall: 0.9927 - custom_accuracy: 0.9992 - val_loss: 0.1131 - val_accuracy: 0.9736 - val_custom_recall: 0.9716 - val_custom_accuracy: 0.9766
Epoch 209/1000
8/8 [==============================] - 0s 26ms/step - loss: 0.0086 - accuracy: 0.9992 - custom_recall: 0.9929 - custom_accuracy: 0.9992 - val_loss: 0.1140 - val_accuracy: 0.9743 - val_custom_recall: 0.9717 - val_custom_accuracy: 0.9774
Epoch 00209: early stopping

ただ、上記を見ると組み込みの accuracy と、自分で定義した custom_accuracy の値が一致していない。

カスタムメトリックを定義して組み込む (Stateful)

先ほどの例で組み込みのメトリックと自前のメトリックが一致しなかった理由は、カスタムメトリックを定義する方法に Stateless と Stateful という 2 つのやり方があるため。 組み込みの accuracy は Stateful なやり方で定義されている一方で、先ほど自分で定義した custom_accuracy は Stateless だったので値がズレてしまった。 あらかじめ断っておくと、値がズレているからといって計算が間違っているわけではない。

それでは、次は Stateful なやり方でカスタムメトリックを定義する方法を試してみよう。 Stateful なやり方では、tensorflow.keras.metrics.Metric を継承して必要なメソッドを実装することでメトリックを計算する。

以下のサンプルコードでは Stateful なやり方で Recall と Accuracy を計算している。 Stateful という名のとおり、tensorflow.keras.metrics.Metric では累積的に与えられる正解と予測のラベルからメトリックを計算することになる。 具体的には、update_state() メソッドで正解と予測ラベルが与えられて、結果を result() メソッドから得る。 そして、状態をリセットしたいときには reset_states() メソッドが呼ばれる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import tensorflow as tf
from tensorflow.keras.metrics import Metric
from tensorflow.keras import backend as K
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Model
from tensorflow.keras.utils import to_categorical
from tensorflow.keras.callbacks import EarlyStopping


class RecallMetric(Metric):
    """ステートフルに Recall を計算するクラス"""

    def __init__(self, name='custom_recall', *args, **kwargs):
        super().__init__(name=name, *args, **kwargs)
        # 状態を貯めておく変数を用意する
        self.true_positives = tf.Variable(0.)
        self.total_positives = tf.Variable(0.)

    def update_state(self, y_true, y_pred, sample_weight=None):
        """新しく正解と予測が追加で与えられたときの処理"""
        true_positives = K.sum(y_true * y_pred)
        total_positives = K.sum(y_true)

        self.true_positives.assign_add(true_positives)
        self.total_positives.assign_add(total_positives)

    def result(self):
        """現時点の状態から計算されるメトリックを返す"""
        return self.true_positives / (self.total_positives + K.epsilon())

    def reset_states(self):
        """状態をリセットするときに呼ばれるコールバック"""
        self.true_positives.assign(0.)
        self.total_positives.assign(0.)


class AccuracyMetric(Metric):
    """ステートフルに Accuracy を計算するクラス"""

    def __init__(self, name='custom_accuracy', *args, **kwargs):
        super().__init__(name=name, *args, **kwargs)

        self.matched = tf.Variable(0.)
        self.unmatched = tf.Variable(0.)

    def update_state(self, y_true, y_pred, sample_weight=None):
        y_true_argmax = K.argmax(y_true)
        y_pred_argmax = K.argmax(y_pred)

        y_matched = K.sum(K.cast(K.equal(y_true_argmax, y_pred_argmax), dtype='float32'))
        y_unmatched = K.sum(K.cast(K.not_equal(y_true_argmax, y_pred_argmax), dtype='float32'))

        self.matched.assign_add(y_matched)
        self.unmatched.assign_add(y_unmatched)

    def result(self):
        return self.matched / (self.matched + self.unmatched)

    def reset_states(self):
        self.matched.assign(0.)
        self.unmatched.assign(0.)


def main():
    # load dataset
    (x_train, y_train), (x_test, y_test) = mnist.load_data()

    # one-hot encode
    y_train = to_categorical(y_train)
    y_test = to_categorical(y_test)

    # flatten
    image_height, image_width = x_train.shape[1:]
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # min-max normalize
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # Multi Layer Perceptron
    inputs = Input(shape=(image_height * image_width,))
    x = Dense(64, activation='relu')(inputs)
    x = Dense(64, activation='relu')(x)
    num_of_classes = y_train.shape[1]
    outputs = Dense(num_of_classes, activation='softmax')(x)

    callbacks = [
        # 検証データに対する Recall が 10 エポック改善しないときは学習を打ち切る
        EarlyStopping(monitor='val_custom_recall',
                      patience=10,
                      verbose=1,
                      mode='max'),
    ]
    model = Model(inputs=inputs, outputs=outputs)
    model.compile(optimizer='adam',
                  loss='categorical_crossentropy',
                  # カスタムメトリックを計算するオブジェクトを登録する
                  metrics=['accuracy', RecallMetric(), AccuracyMetric()],
                  )

    model.fit(x_train, y_train,
              batch_size=8192,
              epochs=1000,
              verbose=1,
              # ホールドアウトデータを検証データとして用いる
              validation_data=(x_test, y_test),
              callbacks=callbacks)


if __name__ == '__main__':
    main()

実際に、上記を実行してみよう。

$ python stateful.py
...
Epoch 1/1000
8/8 [==============================] - 0s 46ms/step - loss: 2.2027 - accuracy: 0.2119 - custom_recall: 0.1161 - custom_accuracy: 0.2119 - val_loss: 1.9262 - val_accuracy: 0.4457 - val_custom_recall: 0.1544 - val_custom_accuracy: 0.4457
Epoch 2/1000
8/8 [==============================] - 0s 25ms/step - loss: 1.7486 - accuracy: 0.5403 - custom_recall: 0.1948 - custom_accuracy: 0.5403 - val_loss: 1.4170 - val_accuracy: 0.6929 - val_custom_recall: 0.2810 - val_custom_accuracy: 0.6929
Epoch 3/1000
8/8 [==============================] - 0s 31ms/step - loss: 1.2274 - accuracy: 0.7467 - custom_recall: 0.3455 - custom_accuracy: 0.7467 - val_loss: 0.9200 - val_accuracy: 0.8056 - val_custom_recall: 0.4659 - val_custom_accuracy: 0.8056
...
Epoch 178/1000
8/8 [==============================] - 0s 26ms/step - loss: 0.0147 - accuracy: 0.9979 - custom_recall: 0.9888 - custom_accuracy: 0.9979 - val_loss: 0.1057 - val_accuracy: 0.9717 - val_custom_recall: 0.9662 - val_custom_accuracy: 0.9717
Epoch 179/1000
8/8 [==============================] - 0s 27ms/step - loss: 0.0145 - accuracy: 0.9980 - custom_recall: 0.9889 - custom_accuracy: 0.9980 - val_loss: 0.1055 - val_accuracy: 0.9714 - val_custom_recall: 0.9664 - val_custom_accuracy: 0.9714
Epoch 180/1000
8/8 [==============================] - 0s 27ms/step - loss: 0.0143 - accuracy: 0.9980 - custom_recall: 0.9891 - custom_accuracy: 0.9980 - val_loss: 0.1063 - val_accuracy: 0.9728 - val_custom_recall: 0.9663 - val_custom_accuracy: 0.9728
Epoch 00180: early stopping

今度は組み込みで用意されている accuracy の値と、自前で定義した custom_accuracy の値が一致していることがわかる。 ちなみに、Stateless と Stateful ではメトリックを計算するデータの区間が異なるために値が微妙にズレる。

まとめ

  • Keras のカスタムメトリックには正解と予測のラベルが Tensor オブジェクトとして渡される
  • カスタムメトリックを定義するには Stateless と Stateful なやり方がある
  • Stateless では正解と予測のラベルを受け取る関数を定義する
  • Stateful では tensorflow.keras.metrics.Metric クラスを継承して正解と予測のラベルからの計算結果をためこむ

PythonとKerasによるディープラーニング

PythonとKerasによるディープラーニング

  • 作者:Francois Chollet
  • 発売日: 2018/05/28
  • メディア: 単行本(ソフトカバー)

Python: gensim の FAST_VERSION 定数の意味について

Python の gensim には自然言語処理 (NLP) に関する様々な実装がある。 そして、その中のいくつかのモジュールには FAST_VERSION という定数が定義されている。 この定数は環境によって異なる値を取って、値によってパフォーマンスが大きく異なる場合がある。

今回は、この数値が何を表しているかについて調べた。 結論から先に述べると、この定数は次のような対応関係にある。

  • -1
    • Cython で書かれた拡張モジュールが使えない
  • 0
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が倍精度浮動小数点型 (double) を返す
  • 1
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が単精度浮動小数点型 (float) を返す
  • 2
    • Cython で書かれた拡張モジュールが使える
    • BLAS のドット積を計算する関数 (dsdot()) が使えない
      • 代わりに Cython でループする

なお、パフォーマンス的には 0 > 1 > 2 > -1 だと考えられる。 また、これはあくまで「現時点で既知の値に関しては」なので、将来的に変わったり異なるモジュールは異なる意味になる可能性がある。

使った環境は次のとおり。

$ sw_vers               
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V                                                                       
Python 3.7.7
$ pip list | grep -i gensim                                           
gensim          3.8.3

下準備

とりあえず gensim をインストールしておく。

$ pip install gensim

gensim の FAST_VERSION について

たとえば、Word2Vec や fastText のモジュールに FAST_VERSION という定数が定義されている。 Word2Vec のものについて、定数の値を追ってみよう。 はじめに次の場所で、特定のモジュールがインポートできないときに -1 を取る。

gensim/word2vec.py at 3.8.3 · RaRe-Technologies/gensim · GitHub

インポートしようとしたモジュールでは Cython の拡張モジュールが使われている。 定数を初期化しているのは以下の関数で、それぞれの意味について記述されている。

gensim/word2vec_inner.pyx at 3.8.3 · RaRe-Technologies/gensim · GitHub

意味は前述したとおりで、0 または 1 なら Cython の拡張モジュールで BLAS の dsdot() 関数が使える。 2 のときは使えないので、代わりにドット積を計算するのに Cython のループを使うことになる。

Cython の拡張モジュールが使えない場合 (FAST_VERSION == -1) のパフォーマンス

試しに、Cython の拡張モジュールが使えない環境を用意した。

$ python -c "import gensim;print(gensim.models.word2vec.FAST_VERSION)" 2>/dev/null
-1
$ python -c "import gensim;print(gensim.models.fasttext.FAST_VERSION)" 2>/dev/null
-1

Text8 コーパスを使って Word2Vec を学習させてみよう。 はじめに、コーパスをダウンロードして展開する。

$ wget http://mattmahoney.net/dc/text8.zip
$ unzip text8.zip

次のようなベンチマーク用のモジュールを用意する。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from gensim.models.word2vec import Text8Corpus
from gensim.models import Word2Vec


def main():
    # 学習の過程をログに残す
    log_format = "%(threadName)s - %(name)s - %(levelname)s - %(message)s"
    logging.basicConfig(format=log_format, level=logging.INFO)

    # Text8 コーパスを Word2Vec で学習する
    corpus = Text8Corpus('text8')
    Word2Vec(
        corpus,
        sg=1,  # Skip-gram タスクを最適化する
        hs=1,  # 損失の計算に Hierarchical Softmax を使う
        negative=5,  # ポジティブサンプルに対するネガティブサンプル (ノイジーワード) の比率
        size=50,  # 埋め込む次元数
        iter=3,  # 学習エポック数
    )


if __name__ == "__main__":
    main()

上記に適用な名前をつけたら実行してみよう。 なお、実行が完了まで見守る必要はない。 学習時に出力されるスループットさえ確認できれば良い。

$ python benchmark.py 
unable to import 'smart_open.gcs', disabling that module
MainThread - gensim.models.base_any2vec - WARNING - consider setting layer size to a multiple of 4 for greater performance
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/gensim/models/base_any2vec.py:743: UserWarning: C extension not loaded, training will be slow. Install a C compiler and reinstall gensim for fast training.
  "C extension not loaded, training will be slow. "
MainThread - gensim.models.word2vec - INFO - collecting all words and their counts
MainThread - gensim.models.word2vec - INFO - PROGRESS: at sentence #0, processed 0 words, keeping 0 word types
...
MainThread - gensim.models.base_any2vec - INFO - training model with 3 workers on 71290 vocabulary and 50 features, using sg=1 hs=1 sample=0.001 negative=5 window=5
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.06% examples, 151 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.18% examples, 447 words/s, in_qsize 6, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.24% examples, 309 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 0.29% examples, 379 words/s, in_qsize 5, out_qsize 0

上記を見ると、だいたい 300 ~ 400 words/s くらいかな? ちなみに、実行直後には「C 拡張が使えないので遅くなるよ」という旨の警告が表示されている。

Cython の拡張モジュールが使える場合 (FAST_VERSION == 0) のパフォーマンス

続いては拡張モジュールが使える場合で、特に BLAS の dsdot() 関数が倍精度浮動小数点型 (double) を返す環境を用意した。

$ python -c "import gensim;print(gensim.models.word2vec.FAST_VERSION)" 2>/dev/null
0

この環境は、たとえば C コンパイラや OpenBLAS が使える環境でソースコードから gensim をビルドすればできると思う。たぶん。

$ xcode-select --install
$ brew install openblas
$ pip install -U --no-binary gensim gensim

この環境でも、先ほどのベンチマーク用の実行してみよう。

$ python benchmark.py                                                             
unable to import 'smart_open.gcs', disabling that module
MainThread - gensim.models.base_any2vec - WARNING - consider setting layer size to a multiple of 4 for greater performance
MainThread - gensim.models.word2vec - INFO - collecting all words and their counts
MainThread - gensim.models.word2vec - INFO - PROGRESS: at sentence #0, processed 0 words, keeping 0 word types
...
MainThread - gensim.models.base_any2vec - INFO - training model with 3 workers on 71290 vocabulary and 50 features, using sg=1 hs=1 sample=0.001 negative=5 window=5
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 1.47% examples, 177603 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 3.06% examples, 187492 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 4.64% examples, 189657 words/s, in_qsize 5, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 6.23% examples, 190525 words/s, in_qsize 5, out_qsize 0
...

すると、こちらの環境では 180k ~ 190k words/s のスループットが出ている。 Cython の拡張モジュールを使う場合と使わない場合で、だいたい 500 倍の違いが出た。

おまけ: Word2Vec や fastText の学習時に CPU の論理コアを使い切る

ちなみに現行の Word2Vec や fastText を学習させるときはワーカープロセスの数が 3 で固定されている。 そのため CPU のコア数が多い環境では、そこが学習のボトルネックになる。

CPU のコアを使い切りたいときは、multiprocessing モジュールをインポートして...

import multiprocessing

Word2Vec や FastText の引数として workers に環境の論理コア数を指定する。

    workers=multiprocessing.cpu_count(),  # 論理コア数分のワーカープロセスを使って学習する

同じようにベンチマークを実行してみよう。

$ python benchmark.py
...
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 2.41% examples, 294657 words/s, in_qsize 16, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 5.11% examples, 312823 words/s, in_qsize 15, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 8.00% examples, 323193 words/s, in_qsize 15, out_qsize 0
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 10.58% examples, 321791 words/s, in_qsize 15, out_qsize 1
MainThread - gensim.models.base_any2vec - INFO - EPOCH 1 - PROGRESS: at 13.35% examples, 324695 words/s, in_qsize 15, out_qsize 0
...

今回の環境は CPU の論理コアが 8 あるので、さらにスループットを上げることができた。

めでたしめでたし。

Python: gensim を使った Word Embedding の内省的評価について

以下の書籍では、Word Embedding の評価方法として内省的評価 (intrinsic evaluation) と外省的評価 (extrinsic evaluation) という 2 つのやり方が紹介されている。 内省的評価では、人間が判断した単語間の類似度や、単語の持つ意味を使ったアナロジーを、Word Embedding が適切に表現できているかを評価する。 それに対し、外省的評価では Word Embedding を応用する先となる最終的な目的を表したタスクを使って評価する。

今回は、gensim に用意されている内省的評価の仕組みがどんなことをやっているのか気になって調べた内容を書いてみる。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V
Python 3.7.7

下準備

はじめに、gensim をインストールしておく。

$ pip install gensim

続いて、評価したい Pretrained Word Embeddings を用意する。 今回は Facebook の公開している fastText を選んだ。 なお、評価用データとして gensim に組み込みで用意されている英語のコーパスを使うので、ひとまず英語を使ったものにする。 このファイルはサイズが 6GB ほどあるので結構な時間がかかる。

$ wget https://dl.fbaipublicfiles.com/fasttext/vectors-wiki/wiki.en.vec

ダウンロードできたら Python のインタプリタを起動しておく。

$ python

先ほどダウンロードした Pretrained Word Embeddings を gensim から読み込む。

>>> import gensim
>>> model = gensim.models.KeyedVectors.load_word2vec_format('wiki.en.vec', binary=False)

ちなみに、この操作にもかなりの時間 (数分以上) がかかるので気長に待つ。

内省的評価に使うデータのフォーマットについて

gensim には、内省的評価をするための API として単語間類似度を使ったものとアナロジータスクがサポートされている。 そして、英語のコーパスに関してはラベル付きデータも組み込みで提供されている。 ひとまず、どういったフォーマットになっているか紹介しておく。

単語間類似度

はじめに単語間類似度について。 単語間類似度は、2 つの単語について、どれだけ類似しているかを人間の主観で評価したもの。 単語間類似度を評価するためのデータとして、gensim では wordsim353.tsv というデータが組み込みで提供されている。

ファイルの場所は次のようにして gensim.test.utils.datapath() を使って得られる。

$ python -c "from gensim.test.utils import datapath; print(datapath('wordsim353.tsv'))" 2>/dev/null
/Users/amedama/.virtualenvs/py37/lib/python3.7/site-packages/gensim/test/test_data/wordsim353.tsv

先頭について確認すると、こんな感じ。 タブ区切りで、ある単語と別の単語の類似度が浮動小数点で記述されている。 シャープ (#) からはじまる行は、単なるコメントなので読み飛ばして構わない。

$ head $(python -c "from gensim.test.utils import datapath; print(datapath('wordsim353.tsv'))" 2>/dev/null)
# The WordSimilarity-353 Test Collection (http://www.cs.technion.ac.il/~gabr/resources/data/wordsim353/)
# Word 1   Word 2  Human (mean)
love    sex 6.77
tiger   cat 7.35
tiger   tiger   10.00
book    paper   7.46
computer    keyboard    7.62
computer    internet    7.58
plane   car 5.77
train   car 6.31

アナロジータスク

もうひとつのアナロジータスクでは、特定の単語の分散表現に別の単語を足したり引いたりして目当ての単語となるかどうかを評価する。 アナロジータスクを評価するためのデータとして、gensim では questions-words.txt というデータが組み込みで提供されている。

先頭について確認すると以下のとおり。 基本的に、スペース区切りで 4 つの単語が並んでいる。 1 列目の単語から 2 列目の単語を引いて、3 列目の単語を足したとき 4 列目の単語になるかを評価することになる。 先頭のコロン (:) からはじまる行については、タスクのジャンルを表している。 ちなみに、タスクのジャンルのことはセクションと呼ぶようだ。

$ head $(python -c "from gensim.test.utils import datapath; print(datapath('questions-words.txt'))" 2>/dev/null)
: capital-common-countries
Athens Greece Baghdad Iraq
Athens Greece Bangkok Thailand
Athens Greece Beijing China
Athens Greece Berlin Germany
Athens Greece Bern Switzerland
Athens Greece Cairo Egypt
Athens Greece Canberra Australia
Athens Greece Hanoi Vietnam
Athens Greece Havana Cuba

単語間類似度を使った評価

それでは、実際に単語間類似度を使った評価を試してみよう。 単語間類似度は、WordEmbeddingsKeyedVectors#evaluate_word_pairs() を使って評価する。

>>> from gensim.test.utils import datapath
>>> similarities = model.evaluate_word_pairs(datapath('wordsim353.tsv'))

得られる結果はタプルで、最初の要素がピアソンの相関係数になっている。 中身もまたタプルになってるけど、先頭が相関係数で二番目は「相関がないこと」を帰無仮説とした仮説検定の p-value らしい。

>>> similarities[0]
(0.6987675497727828, 5.237592231656601e-53)

次の要素はスピアマンの相関係数で、実装には scipy を使っているようだ。

>>> similarities[1]
SpearmanrResult(correlation=0.7388081960366618, pvalue=3.98104844873057e-62)

評価用のデータに記述された単語間の類似度を表す点数と、Word Embedding が出した単語ベクトル間のコサイン類似度の相関が高いほど、より優れていると評価することになる。

アナロジータスクを使った評価

続いてアナロジータスクを使った評価を試してみる。 アナロジータスクは WordEmbeddingsKeyedVectors#evaluate_word_analogies() を使って評価する。

>>> analogy_scores = model.evaluate_word_analogies(datapath('questions-words.txt'))

上記では、前述したとおり 1 列目の単語から 2 列目の単語を引いて、3 列目の単語を出した結果が 4 列目の単語になるか評価している。 ただし、ピンポイントで一致しなくともコーパスの中でベクトルが最も似ている TOP5 の中にさえ入っていれば正解としているようだ。

メソッドの返り値として得られるのは、こちらもタプルとなっている。 最初の要素は、正解ラベルが TOP5 に入ったか否かの二値で評価した Accuracy となっている。 この値が高いほど、より優れた Word Embedding と捉えることになる。

>>> analogy_scores[0]
0.7492042304138001

次の要素はリストで、これはアナロジータスクをセクションごとに正解したデータと不正解したデータで分けたもの。 questions-words.txt は 15 のセクションに分かれているらしい。

>>> type(analogy_scores[1])
<class 'list'>
>>> len(analogy_scores[1])
15

それぞれの要素は辞書になっている。

>>> analogy_scores[1][0].keys()
dict_keys(['section', 'correct', 'incorrect'])

内容を確認すると、セクションの名前や正解したタスク、正解できなかったタスクが入っている。

>>> analogy_scores[1][0]['section']
'capital-common-countries'
>>> from pprint import pprint
>>> pprint(analogy_scores[1][0]['correct'][:10])
[('ATHENS', 'GREECE', 'BAGHDAD', 'IRAQ'),
 ('ATHENS', 'GREECE', 'BANGKOK', 'THAILAND'),
 ('ATHENS', 'GREECE', 'BEIJING', 'CHINA'),
 ('ATHENS', 'GREECE', 'BERLIN', 'GERMANY'),
 ('ATHENS', 'GREECE', 'BERN', 'SWITZERLAND'),
 ('ATHENS', 'GREECE', 'CAIRO', 'EGYPT'),
 ('ATHENS', 'GREECE', 'CANBERRA', 'AUSTRALIA'),
 ('ATHENS', 'GREECE', 'HANOI', 'VIETNAM'),
 ('ATHENS', 'GREECE', 'HAVANA', 'CUBA'),
 ('ATHENS', 'GREECE', 'HELSINKI', 'FINLAND')]
>>> pprint(analogy_scores[1][0]['incorrect'][:10])
[('ATHENS', 'GREECE', 'LONDON', 'ENGLAND'),
 ('BAGHDAD', 'IRAQ', 'CANBERRA', 'AUSTRALIA'),
 ('BAGHDAD', 'IRAQ', 'LONDON', 'ENGLAND'),
 ('BANGKOK', 'THAILAND', 'LONDON', 'ENGLAND'),
 ('BEIJING', 'CHINA', 'LONDON', 'ENGLAND'),
 ('BERN', 'SWITZERLAND', 'LONDON', 'ENGLAND'),
 ('CAIRO', 'EGYPT', 'LONDON', 'ENGLAND'),
 ('CANBERRA', 'AUSTRALIA', 'LONDON', 'ENGLAND'),
 ('HANOI', 'VIETNAM', 'LONDON', 'ENGLAND'),
 ('HANOI', 'VIETNAM', 'BERLIN', 'GERMANY')]

最初の要素で示されている Accuracy の値を、次の要素を使って検算してみよう。

>>> from itertools import chain
>>> correct = len(list(chain.from_iterable(s['correct'] for s in analogy_scores[1])))
>>> incorrect = len(list(chain.from_iterable(s['incorrect'] for s in analogy_scores[1])))
>>> correct / (correct + incorrect)
0.7492042304138001

上記のとおり、ちゃんと値が一致した。

いじょう。

Python: 使わない変数を "_" (アンダースコア) に代入するイディオム

Python には、使わない変数であることを明確に示すためにアンダースコアに代入するというイディオムがある。 今回は、そのイディオムについてあらためて紹介してみる。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V     
Python 3.7.7

イディオムを使わない場合

たとえば、以下のサンプルコードでは、関数 f() がタプルを返す。 この関数の返り値のうち、最初の要素は使わない場合を考えてみよう。 イディオムを使わない場合、添字を使って取り出すことになる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def f():
    """タプルを返す関数"""
    return ('foo', 'bar')


def main():
    # 関数を呼び出す
    ret = f()
    # 必要な要素を添字で取り出す
    i_want_this_one = ret[1]
    # 取り出した変数を使って何かする
    ...


if __name__ == '__main__':
    main()

イディオムを使う場合

一方で、イディオムを使ったサンプルコードが次のとおり。 タプルを展開しつつ、使わない要素に関してはアンダースコアに代入している。 このイディオムを使うことで、ソースコードを読む相手に「この要素は処理で使わない」ことを明確に示すことができる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-


def f():
    """タプルを返す関数"""
    return ('foo', 'bar')


def main():
    # 使わない要素はアンダースコアに代入してしまう
    _, i_want_this_one = f()
    # 取り出した変数を使って何かする
    ...


if __name__ == '__main__':
    main()

Python には変数のアクセス制御をするための構文がない。 そのため、慣例的に名前を使ってアクセス範囲を示す文化がある。 アンダースコアは、一般的に外部からアクセスしてほしくないプライベートに相当するオブジェクトの名前の先頭に付与される。 それの応用に近い形で、アンダースコアに使わない変数を代入するイディオムが広まったのだと考えられる。

注意点

ところで、このイディオムは Python の REPL (Read-Eval-Print Loop: 対話的実行環境) では使わない方が良い。 なぜなら、REPL ではアンダースコアが「最後の評価されたオブジェクト」を表す変数になっているため。

実際に動作を確認するために Python の REPL を起動しよう。

$ python

そして、何か適当なオブジェクトを評価させる。 ここでは整数値の 42 という値を使った。

>>> 42
42

それでは、アンダースコアを参照してみよう。 すると、先ほど評価されたオブジェクトが代入されていることがわかる。

>>> _
42

まとめ

  • Python には使わない変数を "_" (アンダースコア) に代入するイディオムがある
  • ソースコードを読む相手に「この要素は使わない」ことを明確に示せる
  • REPL を使うときはアンダースコアが別の用途で使われるため注意が必要になる

MySQL の InnoDB でトランザクション分離レベルの違いを試す

今回は MySQL の InnoDB を使ってトランザクション分離レベル (Transaction Isolation Level) の違いを試してみる。 トランザクション分離レベルは、SQL を実装したシステムの ACID 特性において I (Isolation) に対応する概念となっている。 利用する分離レベルによって、複数のトランザクション間でデータの一貫性に関する振る舞いが変化する。 なお、この概念は ANSI SQL で定義されているもので、MySQL に固有というわけではない。

InnoDB では、次の 4 種類のトランザクション分離レベルがサポートされている。 下にいくほど、より厳格にトランザクションを分離できる一方で、上にいくほど処理のオーバーヘッドは少ない。 言いかえると、トランザクション分離レベルを切り替えることでパフォーマンスと一貫性のバランスを調整できる。 なお、デフォルトでは REPEATABLE READ が用いられる。

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

トランザクション分離レベルを落とすと、複数のトランザクション間で分離が不十分になる現象が生じる。 古典的な定義において、生じる現象には次のようなものがある 1

  • ダーティーリード (Dirty Read)
  • ノンリピータブルリード (Non-repeatable Read / Fuzzy Read)
  • ファントムリード (Phantom Read)

トランザクション分離レベルと現象には、次のような関係性がある。 表で「X」になっている項目は、起こる可能性があることを示す。 ただし、MySQL の InnoDB では、例外的に REPEATABLE READ でもファントムリードが生じない。

ダーティーリード ノンリピータブルリード ファントムリード
READ UNCOMMITTED X X X
READ COMMITTED - X X
REPEATABLE READ - - X 1
SERIALIZABLE - - -

今回、検証に使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ mysql --version
mysql  Ver 8.0.19 for osx10.14 on x86_64 (Homebrew)

下準備

まずは Homebrew を使って MySQL をインストールしておく。

$ brew install mysql

インストールしたら、次に MySQL のサービスを開始する。

$ brew services start mysql
$ brew services list | grep mysql   
mysql   started amedama /Users/amedama/Library/LaunchAgents/homebrew.mxcl.mysql.plist

MySQL クライアントを起動して、MySQL サーバにログインする。

$ mysql -u root

ログインできたら、サンプルとして使うデータベースを用意する。

mysql> CREATE DATABASE example;
Query OK, 1 row affected (0.01 sec)

mysql> USE example;
Database changed

続いて、サンプルとして使うテーブルを用意する。

mysql> CREATE TABLE users (
    ->     id INTEGER PRIMARY KEY,
    ->     name VARCHAR(32) NOT NULL
    -> );
Query OK, 0 rows affected (0.02 sec)

mysql> DESC users;
+-------+-------------+------+-----+---------+-------+
| Field | Type        | Null | Key | Default | Extra |
+-------+-------------+------+-----+---------+-------+
| id    | int         | NO   | PRI | NULL    |       |
| name  | varchar(32) | NO   |     | NULL    |       |
+-------+-------------+------+-----+---------+-------+
2 rows in set (0.01 sec)

テーブルにレコードを 1 つ追加しておく。

mysql> INSERT INTO users VALUES (1, "Alice");
Query OK, 1 row affected (0.01 sec)

データベースエンジンを確認する

続いて、テーブルで利用されているデータベースエンジンが InnoDB になっていることを確認する。

mysql> SELECT
    ->     TABLE_NAME,
    ->     ENGINE
    -> FROM
    ->     information_schema.TABLES
    -> WHERE
    ->     TABLE_SCHEMA = "example";
+------------+--------+
| TABLE_NAME | ENGINE |
+------------+--------+
| users      | InnoDB |
+------------+--------+
1 row in set (0.00 sec)

もし InnoDB でなければデータベースエンジンを変更しておく。

mysql> ALTER TABLE users ENGINE = "InnoDB";
Query OK, 0 rows affected (0.21 sec)
Records: 0  Duplicates: 0  Warnings: 0

なお、テーブルを作る段階でエンジンを指定することもできる。

トランザクション分離レベルの変更について

トランザクション分離レベルはグローバル変数とセッション変数で管理されている。 一時的に変更するときはセッション変数を変更すれば良い。

mysql> SELECT
    ->     @@global.transaction_isolation,
    ->     @@session.transaction_isolation;
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ                | REPEATABLE-READ                 |
+--------------------------------+---------------------------------+
1 row in set (0.00 sec)

ここではセッション変数を使って変更する。 たとえば、READ UNCOMMITTED に変更するときは次のようにする。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT
    ->     @@global.transaction_isolation,
    ->     @@session.transaction_isolation;
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ                | READ-UNCOMMITTED                |
+--------------------------------+---------------------------------+
1 row in set (0.00 sec)

プロンプトを 2 つ用意する

ここからは複数のトランザクションを扱うので、ターミナルを 2 つ用意してどちらも MySQL サーバにログインしておく。

それぞれのプロンプトを区別するために、次のようにして表示を切りかえる。 ひとつ目は "mysql1" という名前にする。

mysql> PROMPT mysql1> 
PROMPT set to 'mysql1> '

もうひとつは "mysql2" にしておく。

mysql> PROMPT mysql2> 
PROMPT set to 'mysql2> '

ダーティーリード

前置きが長くなったけど、ここから実際の検証に入る。 ダーティーリードを一言でいうと、あるトランザクションでコミットしていない変更が、他のトランザクションから見えてしまうというもの。

あらかじめ、両方のプロンプトのトランザクション分離レベルを READ UNCOMMITTED に変更しておく。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Query OK, 0 rows affected (0.00 sec)

両方のプロンプトでトランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

片方のトランザクションで、既存のレコードのカラムを変更してみよう。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Bob'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

そして、もう一方のトランザクションから読み取ってみる。 すると、先ほど "mysql1" でレコードに加えた変更が "mysql2" から見えてしまっている。 この現象をダーティーリードという。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+------+
| id | name |
+----+------+
|  1 | Bob  |
+----+------+
1 row in set (0.00 sec)

振る舞いを確認できたら、両方のトランザクションをロールバックしておく。

mysql1> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

ノンリピータブルリード

続いてはノンリピータブルリード、もしくはファジーリードと呼ばれる現象について。 この現象を一言で表すと、あるトランザクションでコミットした変更が、他のトランザクションから見えてしまうというもの。

はじめに、プロンプトのトランザクション分離レベルを READ COMMITTED に変更しておく。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.00 sec)

そして、トランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

片方のトランザクションで、先ほどと同じようにレコードに変更を加えてみよう。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Carol'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

この時点では、変更はコミットされていない。 もう一方のトランザクションから変更は見えていないので、今回はダーティーリードは生じていない。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

それでは、トランザクションをコミットしてみよう。

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

すると、もう一方のトランザクションから変更が見えてしまった。 この現象をノンリピータブルリード、またはファジーリードという。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Carol |
+----+-------+
1 row in set (0.00 sec)

コミットしていない方のトランザクションはロールバックしておこう。

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

ファントムリード

続いてはファントムリードについて。 この現象を一言で表すと、あるトランザクションでコミットしたレコードの追加や削除が、別のトランザクションから見えてしまうというもの。

なお、ファントムリードは、本来であればトランザクション分離レベルが REPEATABLE READ 以下のときに生じる。 しかし、MySQL の InnoDB では REPEATABLE READ でも例外的にファントムリードが生じない。 そのため、この検証は先ほどに引き続き READ COMMITTED を使って行う。

はじめに、トランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

そして、片方のトランザクションでレコードを削除する。

mysql1> DELETE
    -> FROM
    ->     users
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)

トランザクションはコミットされていない。 この時点では、もう一方のトランザクションからレコードの削除は見えていない。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Carol |
+----+-------+
1 row in set (0.00 sec)

それでは、トランザクションをコミットしてみよう。

mysql1> COMMIT;
Query OK, 0 rows affected (0.01 sec)

すると、もう一方のトランザクションからレコードが見えなくなった。 つまり、あるトランザクションでレコードを削除した内容が、別のトランザクションから見えてしまっている。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
Empty set (0.00 sec)

動作が確認できたらコミットしていないトランザクションをロールバックしておこう。

mysql2> ROLLBACK;
Query OK, 0 rows affected (0.00 sec)

REPEATABLE READ のときの挙動を確認しておく

念のため、トランザクション分離レベルを REPEATABLE READ にしたときの振る舞いも確認しておこう。

両方のプロンプトのトランザクション分離レベルを REPEATABLE READ に変更する。

mysql1> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

mysql2> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)

サンプルとなるレコードをあらためて追加しておく。

mysql1> INSERT INTO users VALUES (1, "Alice");
Query OK, 1 row affected (0.00 sec)

ダーティーリードが生じないことを確認する

両方のプロンプトでトランザクションを開始する。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql2> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

一方のプロンプトからレコードのカラムに変更を加える。

mysql1> UPDATE
    ->     users
    -> SET
    ->     name = 'Bob'
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

この時点でレコードを確認してもカラムは変更されていない。 つまり、ダーティーリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

ノンリピータブルリードが生じないことを確認する

先ほどの状況から、変更を加えたトランザクションをコミットする。

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

この状態でもう一方のトランザクションから確認してもレコードのカラムは変更されていない。 つまり、ノンリピータブルリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

ファントムリードが生じないことを確認する

トランザクションをあらためて開始した上でレコードを削除してコミットする。

mysql1> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql1> DELETE
    -> FROM
    ->     users
    -> WHERE
    ->     id = 1;
Query OK, 1 row affected (0.00 sec)

mysql1> COMMIT;
Query OK, 0 rows affected (0.00 sec)

この状況でもう一方のトランザクションから確認してもレコードは存在しているように見える。 つまり、ファントムリードが生じていないことがわかる。

mysql2> SELECT
    ->     *
    -> FROM
    ->     users;
+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+
1 row in set (0.00 sec)

参考

https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdfwww.microsoft.com

dev.mysql.com


  1. ただし、ANSI SQL における定義の曖昧さから、厳密にはより様々な現象が生じることが知られている。

  2. MySQL の InnoDB では生じない。

Python: 学習済み機械学習モデルの特性を PDP で把握する

機械学習を用いるタスクで、モデルの解釈可能性 (Interpretability) が重要となる場面がある。 今回は、モデルの解釈可能性を得る手法のひとつとして PDP (Partial Dependence Plot: 部分従属プロット) を扱ってみる。 PDP を使うと、モデルにおいて説明変数と目的変数がどのような関係にあるか理解する上で助けになることがある。 なお、今回は PDP を計算・描画するのに専用のライブラリは使わず、自分で実装してみることにした。

使った環境は次のとおり。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G4032
$ python -V          
Python 3.7.7

下準備

まずは、あらかじめ必要なパッケージをインストールしておく。

$ pip install scikit-learn pandas matplotlib

題材とするデータセットとモデルについて

今回は scikit-learn 付属の糖尿病 (Diabetes) データセットを題材として話を進める。 このデータセットは、患者の情報が説明変数で、糖尿病の進行具合が目的変数となっている。

たとえば、これをランダムフォレストで学習させることを考えてみよう。 次のサンプルコードでは、モデルにデータセットを学習させた上で、特徴量の重要度をグラフにプロットしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import numpy as np
from matplotlib import pyplot as plt
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor


def main():
    # 糖尿病データセットを読み込む
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target

    # ランダムフォレストを学習させる
    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    # ジニ不純度を元に計算した重要度を得る
    importances = clf.feature_importances_
    feature_names = np.array(dataset.feature_names)
    sorted_indices = np.argsort(importances)[::-1]

    # 重要度をプロットする
    plt.bar(feature_names[sorted_indices],
            importances[sorted_indices])
    plt.show()


if __name__ == '__main__':
    main()

上記をファイルに保存して実行してみよう。

$ python giniimp.py

すると、次のような棒グラフが得られる。 モデルにおいて、s5bmi といった特徴量が重要視されていることがわかる。

f:id:momijiame:20200507173935p:plain
Gini / Split Importance

なお、ここで計算している重要度は決定木系のモデルに特有のもの。 具体的には、決定木がデータを分割する上でジニ不純度をうまく下げることのできた特徴量を重要なものと判断している。 一般的には Gini Importance や Split Importance と呼ばれている。

この他にも、モデルに依存しない特徴量の重要度として Permutation Importance などが知られている。

blog.amedama.jp

BMI に着目してみる

先ほど計算した重要度では、モデルが特定の特徴量を予測する上で重要視していることがわかった。 しかし、その特徴量が予測する上でどのように重要とされているのかはわからない。

ここでは、先ほど重要とされた BMI にひとまず着目してみよう。 まずは、BMI に含まれる値を確認しておく。 次のサンプルコードでは、BMI に含まれる値をヒストグラムにプロットしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import pandas as pd
from sklearn import datasets
from matplotlib import pyplot as plt


def main():
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target

    # 特定カラムのヒストグラムを描く
    df = pd.DataFrame(X, columns=dataset.feature_names)
    df['bmi'].plot.hist()

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python bmihist.py

すると、次のようなグラフが得られる。

f:id:momijiame:20200507174148p:plain
BMI のヒストグラム

上記のグラフから、BMI は -0.1 ~ +0.15 前後の範囲に値を持つことがわかった。

特定の行 (患者) について BMI を変化させて予測を観察する

続いて、今回扱う PDP の根底となる考え方を説明する。 それは、ある行 (患者) において他の条件は固定したまま特定の説明変数を変化させたとき、予測がどのように変化するかを観察するというもの。

今回であれば、その他の特徴量は固定したまま、BMI の値を色々と変化させたとき、予測がどう変化するかを観察する。 次のサンプルコードでは、先頭の 1 行 (1 人の患者) について BMI を 10 段階で変化させながらモデルの予測を折れ線グラフにプロットしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor
from matplotlib import pyplot as plt


def main():
    # データセットを読み込む
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    # あらかじめ解釈したいモデルを学習させておく
    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    # 特定のカラムが取りうる値を等間隔で計算する
    resolution = 10  # 間隔数
    target_column = 'bmi'  # 対象とするカラム名
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # 例として先頭の行を取り出す
    target_row = df.iloc[0]

    # 特定のカラムの値を入れかえながらモデルに予測させる
    y_preds = np.zeros(resolution)
    for trial, candidate_value in enumerate(candidate_values):
        target_row[target_column] = candidate_value
        y_preds[trial] = clf.predict([target_row])

    # 予測させた結果をプロットする

    plt.plot(candidate_values, y_preds)
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python icehead.py

すると、次のようなグラフが得られる。 どうやら BMI の値が大きくなると、概ね病気の進行具合も進んでいると判断されるようだ。

f:id:momijiame:20200507175751p:plain
先頭行の ICE Plot

とはいえ、一人の患者だけを見て判断するのは早計なはず。 もっと、たくさんの行も確認して、より大局的な傾向を把握したい。

次のサンプルコードでは、全データの 10% をサンプリングした上で先ほどと同じことをしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd
from sklearn import datasets
from matplotlib import pyplot as plt
from sklearn.ensemble import RandomForestRegressor


def main():
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    resolution = 10
    target_column = 'bmi'
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # いくつかの行について、特定のカラムの値を入れかえながらモデルに予測させる
    sampling_factor = 0.1
    sampled_df = df.sample(frac=sampling_factor,
                           random_state=42)
    y_preds = np.zeros((len(sampled_df), resolution))
    for index, (_, target_row) in enumerate(sampled_df.iterrows()):
        for trial, candidate_value in enumerate(candidate_values):
            target_row[target_column] = candidate_value
            y_preds[index][trial] = clf.predict([target_row])

    # 予測させた結果をプロットする
    for y_pred_row in y_preds:
        plt.plot(candidate_values, y_pred_row, color='b')
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python ice.py

すると、次のようなグラフが得られる。 やはり、概ね BMI が大きくなると病気の進行具合も進んでいると判断できるようだ。

f:id:momijiame:20200507175821p:plain
いくつかの行に対する ICE Plot

上記の手法を ICE (Individual Conditional Expectation: 個別条件付き期待値) Plot という。

ICE の要約統計量をグラフにプロットする

ICE Plot を使うことで、個別の行について説明変数と目的変数の関係性を把握できる。 一方で、たくさん折れ線グラフがあるとぶっちゃけ見にくい。 そこで、平均値などの要約統計量をグラフにプロットしてみよう。

次のサンプルコードでは ICE の平均値と、バラつきを確認するために 1 SD (Standard Division: 標準偏差) を描画している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from sklearn import datasets
from sklearn.ensemble import RandomForestRegressor


def main():
    dataset = datasets.load_diabetes()
    X, y = dataset.data, dataset.target
    df = pd.DataFrame(X, columns=dataset.feature_names)

    clf = RandomForestRegressor(n_estimators=100,
                                random_state=42)
    clf.fit(X, y)

    resolution = 10
    target_column = 'bmi'
    min_, max_ = df[target_column].quantile([0, 1])
    candidate_values = np.linspace(min_, max_, resolution)

    # いくつかの行について、特定のカラムの値を入れかえながらモデルに予測させる
    sampling_factor = 0.5
    sampled_df = df.sample(frac=sampling_factor)
    y_preds = np.zeros((len(sampled_df), resolution))
    for index, (_, target_row) in enumerate(sampled_df.iterrows()):
        for trial, candidate_value in enumerate(candidate_values):
            target_row[target_column] = candidate_value
            y_preds[index][trial] = clf.predict([target_row])

    # 予測させた結果をプロットする
    mean_y_preds = y_preds.mean(axis=0)  # 平均
    sd_y_preds = y_preds.std(axis=0)  # 標準偏差
    # 平均 ± 1 SD を折れ線グラフにする
    plt.plot(candidate_values, mean_y_preds)
    plt.fill_between(candidate_values,
                     mean_y_preds - sd_y_preds,
                     mean_y_preds + sd_y_preds,
                     alpha=0.5)
    plt.xlabel('factor')
    plt.ylabel('target')
    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python pdp.py

すると、次のようなグラフが得られる。 ICE をたくさんプロットするよりも見やすい。 そして、このグラフこそ、今回の本題である PDP というらしい。

f:id:momijiame:20200507175849p:plain
PDP

同じ要領で、他の特徴量についても PDP をプロットしていくことで説明変数と目的変数の関係性を把握する。

補足

なお、scikit-learn には PDP を描画するための専用の API もある。 今回は勉強がてら自分で書いてみたけど、普段はこちらを使う方が手っ取り早いはず。

scikit-learn.org

いじょう。

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術