CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: scikit-learn の Dummy{Classifier,Regressor} を試してみる

つい最近 scikit-learn に DummyClassifier と DummyRegressor という実装があることを知ったので試してみた。 これらの実装は、説明変数の内容は使わず、主に目的変数の内容を代わりに使って、その名の通りダミーの結果を返す。 特定のデータセットと評価指標を使ったときの、最低ラインの性能を確認するのに便利そう。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.5
BuildVersion:   18F132
$ python -V
Python 3.7.3

下準備

まずは scikit-learn をインストールしておく。

$ pip install scikit-learn

DummyClassifier

DummyClassifier は、その名の通りダミーの分類器となる。 使い方は一般的な scikit-learn の分類器と何ら違いはない。 違いがあるとすれば与えた教師データに含まれる説明変数を学習しないという点。

教師データの目的変数の確率分布を再現する

動作を確認するためのサンプルコードとして以下を用意した。 デフォルトでは、教師データに含まれる目的変数の確率分布を再現するように動作する。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    # ダミーの分類器 (デフォルトの strategy は 'stratified')
    clf = DummyClassifier()

    # 教師データの説明変数 (無視される)
    X_train = np.arange(6)
    # 教師データの目的変数
    y_train = np.array([0, 1, 2, 2, 1, 2])

    # モデルを教師データで学習する
    clf.fit(X_train, y_train)

    # 検証データの説明変数を元に推論する
    X_test = np.arange(6, 12)
    y_pred = clf.predict(X_test)
    y_pred_proba = clf.predict_proba(X_test)

    # 推論結果 (教師データのクラスの確率分布を元にランダム)
    print('Prediction:', y_pred)
    print('Prediction (probability):')
    print(y_pred_proba)

    # 各クラスの頻度
    print('Class probabilities:', clf.class_prior_)


if __name__ == '__main__':
    main()

上記を実行してみよう。 教師データとして与えた目的変数は 0 が 1 件、1 が 2 件、2 が 3 件だった。 得られる結果は毎回異なるものの、今回は 0 が 1 件、1 が 1 件、2 が 4 件と、わずかに異なっている。 また、predict_proba() メソッドから得られる確率は一つが 1.0 で残りが 0.0 となっている。

$ python dummy.py 
Prediction: [2 2 1 2 2 0]
Prediction (probability):
[[1. 0. 0.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]]
Class probabilities: [0.16666667 0.33333333 0.5       ]

試しに、もっとたくさんの検証用データを予測させてみよう。 数が多くなれば、大数の法則で教師データの確率分布に近づくはずだ。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    clf = DummyClassifier()

    X_train = np.arange(6)
    y_train = np.array([0, 1, 2, 2, 1, 2])

    clf.fit(X_train, y_train)

    # 大数の法則
    X_test = np.arange(6, 100000)
    y_pred = clf.predict(X_test)

    # 推論した結果の頻度を確認する
    values, count = np.unique(y_pred, return_counts=True)
    print('Prediction frequency', count / np.sum(count))

    # 教師データの頻度
    print('Class probabilities:', clf.class_prior_)


if __name__ == '__main__':
    main()

上記を実行してみる。 今度は理論値にかなり近い結果が得られた。

$ python dummy.py
Prediction frequency [0.16611997 0.33404004 0.49983999]
Class probabilities: [0.16666667 0.33333333 0.5       ]

最頻値を常に返す

DummyClassifier は、返す値を色々とカスタマイズできる。 例えば、最頻値を常に返したいときはインスタンス化するときの strategy オプションに 'most_frequent' を指定する。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    clf = DummyClassifier(strategy='most_frequent')

    X_train = np.arange(6)
    y_train = np.array([0, 1, 2, 2, 1, 2])

    clf.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = clf.predict(X_test)
    y_pred_proba = clf.predict_proba(X_test)

    # 推論結果 (常に最も多いクラスを返す)
    print('Prediction:', y_pred)
    print('Prediction (probability):')
    print(y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。 たしかに、最頻値である 2 が常に返されるようになっている。

$ python dummy.py 
Prediction: [2 2 2 2 2 2]
Prediction (probability):
[[0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]
 [0. 0. 1.]]

最頻値を常に返す (確率については教師データに準拠する)

'most_frequent' に近いものの、確率の返し方が異なるのが 'prior' となる。 こちらでは predict_proba() メソッドの返す結果が、元の確率分布にもとづいたものになる。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    clf = DummyClassifier(strategy='prior')

    X_train = np.arange(6)
    # 目的変数
    y_train = np.array([0, 1, 2, 2, 1, 2])

    clf.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = clf.predict(X_test)
    y_pred_proba = clf.predict_proba(X_test)

    # 推論結果 (常に最も多いクラスを返す)
    print('Prediction:', y_pred)
    print('Prediction (probability):')
    print(y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。 たしかに predict() メソッドが最頻値を返す点は変わらないものの、predict_proba() の結果が変わっている。

$ python dummy.py 
Prediction: [2 2 2 2 2 2]
Prediction (probability):
[[0.16666667 0.33333333 0.5       ]
 [0.16666667 0.33333333 0.5       ]
 [0.16666667 0.33333333 0.5       ]
 [0.16666667 0.33333333 0.5       ]
 [0.16666667 0.33333333 0.5       ]
 [0.16666667 0.33333333 0.5       ]]

指定した固定値を返す

任意の定数を返したいときは 'constant' を指定する。 以下では例として常に 1 を返している。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    clf = DummyClassifier(strategy='constant', constant=1)

    X_train = np.arange(6)
    y_train = np.array([0, 1, 2, 2, 1, 2])

    clf.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = clf.predict(X_test)
    y_pred_proba = clf.predict_proba(X_test)

    # 推論結果 (ユーザが指定したクラスを常に返す)
    print('Prediction:', y_pred)
    print('Prediction (probability):')
    print(y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。 たしかに、常に 1 が返っている。

$ python dummy.py 
Prediction: [1 1 1 1 1 1]
Prediction (probability):
[[0. 1. 0.]
 [0. 1. 0.]
 [0. 1. 0.]
 [0. 1. 0.]
 [0. 1. 0.]
 [0. 1. 0.]]

ランダム (一様分布) に返す

また、目的変数の元の確率分布に依存せず、一様分布にしたいときは 'uniform' を指定する。

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

import numpy as np
from sklearn.dummy import DummyClassifier


def main():
    clf = DummyClassifier(strategy='uniform')

    X_train = np.arange(6)
    y_train = np.array([0, 1, 2, 2, 1, 2])

    clf.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = clf.predict(X_test)
    y_pred_proba = clf.predict_proba(X_test)

    # 推論結果 (クラスを一様分布として返す)
    print('Prediction:', y_pred)
    print('Prediction (probability):')
    print(y_pred_proba)


if __name__ == '__main__':
    main()

上記を実行してみよう。 predict() の結果は試行回数が少ないので偏ってしまっているが predict_proba() は全てのクラスが均等な値になっている。

$ python dummy.py 
Prediction: [2 2 2 2 0 0]
Prediction (probability):
[[0.33333333 0.33333333 0.33333333]
 [0.33333333 0.33333333 0.33333333]
 [0.33333333 0.33333333 0.33333333]
 [0.33333333 0.33333333 0.33333333]
 [0.33333333 0.33333333 0.33333333]
 [0.33333333 0.33333333 0.33333333]]

DummyRegressor

同様に回帰問題であれば DummyRegressor を用いる。

平均値を常に返す

DummyRegressor はデフォルトで目的変数の平均値を常に返すようになっている。

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

import numpy as np
from sklearn.dummy import DummyRegressor


def main():
    # ダミーの回帰 (デフォルトの strategy は 'mean' なので平均を返す)
    reg = DummyRegressor()

    # 教師データの説明変数 (無視される)
    X_train = np.arange(6)
    # 教師データの目的変数
    y_train = np.array([-1.0, 0.0, 1.0, 2.0, 4.0, 8.0])

    reg.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = reg.predict(X_test)

    # 推論した結果の頻度を確認する
    print('Prediction', y_pred)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python dummy.py 
Prediction [2.33333333 2.33333333 2.33333333 2.33333333 2.33333333 2.33333333]

中央値を常に返す

DummyRegressor も返す値をカスタマイズできる。 例えば中央値を返したいのであれば 'median' を指定する。

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

import numpy as np
from sklearn.dummy import DummyRegressor


def main():
    # ダミーの回帰 (常に中央値を返す)
    reg = DummyRegressor(strategy='median')

    X_train = np.arange(6)
    y_train = np.array([-1.0, 0.0, 1.0, 2.0, 4.0, 8.0])

    reg.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = reg.predict(X_test)

    print('Prediction', y_pred)


if __name__ == '__main__':
    main()

上記を実行してみよう。 ちゃんと中央値を返している。

$ python dummy.py 
Prediction [1.5 1.5 1.5 1.5 1.5 1.5]

指定した値を常に返す

特定の値を返したいときは 'constant' を指定する。 以下では例として -999 を返している。

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

import numpy as np
from sklearn.dummy import DummyRegressor


def main():
    # ダミーの回帰 (常に指定した値を返す)
    reg = DummyRegressor(strategy='constant', constant=-999)

    X_train = np.arange(6)
    y_train = np.array([-1.0, 0.0, 1.0, 2.0, 4.0, 8.0])

    reg.fit(X_train, y_train)

    X_test = np.arange(6, 12)
    y_pred = reg.predict(X_test)

    print('Prediction', y_pred)


if __name__ == '__main__':
    main()

上記を実行してみよう。 ちゃんと指定した値を返している。

$ python dummy.py 
Prediction [-999 -999 -999 -999 -999 -999]

まとめ

機械学習において最低ラインの性能というのは使うデータセットと評価指標に依存する。 得られた結果がどれくらい良いものなのかを検討する上で、最低ラインの性能は確認しておきたいもの。 そんなとき Dummy{Classifier,Regressor} を使うと、既存のデータパイプラインのコードを流用して確認できるので便利そうだ。

Ubuntu 18.04 LTS で NVIDIA-Docker2 を使ってみる

ニューラルネットワークに代表される機械学習の一部のアルゴリズムでは、学習する上で大量の行列演算を必要とする。 そこで、行列演算を高速化する目的で GPGPU を利用することが一般的になっている。 そして、この分野では機械学習のライブラリが GPGPU の API として NVIDIA の CUDA を使うのがほとんどデファクトになっている。

また、一般的に機械学習のライブラリはサポートしている CUDA のバージョンに制約を設けている。 つまり、ライブラリのバージョンが新しくなると古い CUDA のサポートは落とされて、少しずつ新しいものに移っていく。 ようするに、利用するライブラリのバージョンを上げるときには、同時に CUDA のバージョンも上げる必要に迫られる。 しかしながら、既に動いている CUDA のバージョンをアップデートするというのは、結構面倒な作業ともいえる。

そこで活躍するのが今回利用する NVIDIA-Docker2 となる。 NVIDIA-Docker2 を使うと Docker のコンテナから CUDA 経由で GPU が利用できるようになる。 また、ホストには NVIDIA のグラフィックスドライバしかインストールする必要がない。 CUDA に関しては Docker のコンテナ内にインストールされている。 つまり、ライブラリをアップデートするときは利用するコンテナを切り替えるだけで良い。

ただし、注意点として CUDA はグラフィックスドライバのバージョンにも制約がある。 そこについてはホスト側の作業が必要になってしまうが、CUDA と一緒に入れ替えるよりはぐっと楽ができると思う。

ところで、NVIDIA-Docker は以前は 1.x 系が使われていた。 1.x 系の使い方に関しては、このブログでも紹介したことがある。

blog.amedama.jp

1.x と 2.x 系の違いは、コンテナを管理する上で利用するコマンドが異なるところ。 1.x 系では docker コマンドをラップした nvidia-docker というコマンドが提供されていた。 それに対して 2.x 系では docker コマンドをそのまま利用できる。 その代わりとしてコンテナの実行時に --runtime=nvidia というオプションをつけることになる。

今回使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.2 LTS"
$ uname -r
4.15.0-1032-gcp
$ nvidia-smi
Sun May 26 21:31:15 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 430.14       Driver Version: 430.14       CUDA Version: 10.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla K80           Off  | 00000000:00:04.0 Off |                    0 |
| N/A   35C    P0    72W / 149W |      0MiB / 11441MiB |    100%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

ここからは NVIDIA-Docker2 を利用するまでの手順を説明していく。

NVIDIA Graphics Driver をインストールする

まずはホストに NVIDIA の提供するグラフィックスドライバをインストールする。 なお、CUDA はバージョンによってサポートしているグラフィックスドライバのバージョンが異なる。 そのため、利用したい CUDA のバージョンに合わせてインストールするバージョンを選ばなければいけない。

CUDA がサポートしているグラフィックスドライバのバージョンについては以下に対応表がある。

github.com

なお、GPU のアーキテクチャにも利用できるグラフィックスドライバのバージョンは制約を受ける。 古い GPU を利用している場合には注意しよう。 GPU のアーキテクチャは Compute Capability という名称でも呼ばれており、以下のページで確認できる。

developer.nvidia.com

どの CUDA を利用するかは、利用したい機械学習のライブラリに依存する。 例えば TensorFlow が動作を確認している CUDA のバージョンについては以下に対応表がある。

www.tensorflow.org

今回は、現時点で最新の CUDA 10.1 を利用することを想定してドライバのバージョンに 418.39 以上のものを選んでみる。 利用する GPU は Tesla K80 なので Compute Capability は 3.7 となり、こちらも問題ない。

まずはグラフィックスドライバのリポジトリを追加する。

$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt update

これでパッケージシステムからインストールできるグラフィックスドライバが増える。

$ apt-cache search nvidia-driver
nvidia-384 - Transitional package for nvidia-driver-390
nvidia-384-dev - Transitional package for nvidia-driver-390
...(snip)...
xserver-xorg-video-nvidia-415 - NVIDIA binary Xorg driver
xserver-xorg-video-nvidia-418 - NVIDIA binary Xorg driver
xserver-xorg-video-nvidia-430 - NVIDIA binary Xorg driver

使っている GPU で利用できるドライバについては ubuntu-drivers-common をインストールして確認すると良い。

$ sudo apt-get -y install ubuntu-drivers-common

インストールできるドライバの一覧が ubuntu-drivers devices コマンドで確認できる。

$ ubuntu-drivers devices
== /sys/devices/pci0000:00/0000:00:04.0 ==
modalias : pci:v000010DEd0000102Dsv000010DEsd0000106Cbc03sc02i00
vendor   : NVIDIA Corporation
model    : GK210GL [Tesla K80]
manual_install: True
driver   : nvidia-driver-418 - third-party free
driver   : nvidia-driver-390 - distro non-free
driver   : nvidia-driver-396 - third-party free
driver   : nvidia-driver-410 - third-party free
driver   : nvidia-driver-415 - third-party free
driver   : nvidia-driver-430 - third-party free recommended
driver   : xserver-xorg-video-nouveau - distro free builtin

推奨 (recommended) になっているドライバに関しては以下のようにインストールできる。

$ sudo ubuntu-drivers autoinstall

もちろん、バージョンを指定してインストールするときは apt-get を使えば良い。 今回であれば 418.39 以上の条件を満たす 430 をインストールしておこう。

$ sudo apt-get -y install nvidia-driver-430

Docker CE (Community Edition) をインストールする

続いて Docker CE をインストールする。

もし、既にインストールされている Docker 関連のパッケージがあればアンインストールしておこう。

$ sudo apt-get remove docker docker-engine docker.io containerd runc

続いて、Docker CE のリポジトリを登録していく。 まずは必要なパッケージ類をインストールする。

$ sudo apt-get -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

続いて GPG 鍵をインストールする。

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

そしてリポジトリを追加する。

$ sudo add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

パッケージインデックスを更新したら Docker CE 関連のパッケージをインストールする。

$ sudo apt-get update
$ sudo apt-get -y install docker-ce docker-ce-cli containerd.io

インストールできたら docker version コマンドでクライアントとサーバが正常に稼働していることを確認しておこう。

$ sudo docker version
Client:
 Version:           18.09.6
 API version:       1.39
 Go version:        go1.10.8
 Git commit:        481bc77
 Built:             Sat May  4 02:35:57 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.6
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.8
  Git commit:       481bc77
  Built:            Sat May  4 01:59:36 2019
  OS/Arch:          linux/amd64
  Experimental:     false

また、動作確認用のコンテナを実行しておく。

$ sudo docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete 
Digest: sha256:0e11c388b664df8a27a901dce21eb89f11d8292f7fca1b3e3c4321bf7897bffe
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.
...

上記のようなメッセージが表示されれば良い。

NVIDIA-Docker2 をインストールする

続いては今回のメインとなる NVIDIA-Docker2 をインストールする。

まずは GPG 鍵をインストールしておく。

$ curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -

続いて NVIDIA-Docker2 のリポジトリを登録する。

$ distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
$ curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | \
  sudo tee /etc/apt/sources.list.d/nvidia-docker.list

パッケージインデックスを更新したら NVIDIA-Docker2 をインストールする。

$ sudo apt-get update
$ sudo apt-get -y install nvidia-docker2

インストールできたら Docker のデーモンを再起動する。

$ sudo pkill -SIGHUP dockerd

これで NVIDIA-Docker2 を利用する準備ができた。

Docker コンテナを起動する

それではコンテナを起動してみよう。 NVIDIA-Docker2 を利用するときは、基本的には NVIDIA がリリースしているコンテナイメージを使うことになる。 利用できるイメージは以下で確認できる。

hub.docker.com

今回は CUDA 10.1 と cuDNN 7 をサポートしたコンテナとして nvidia/cuda:10.1-cudnn7-devel を使ってみよう。

次のようにしてコンテナを起動する。 ポイントは --runtime=nvidia を指定しているところで、これでコンテナから GPU を利用できるようになる。

$ sudo docker run --runtime=nvidia --rm -it nvidia/cuda:10.1-cudnn7-devel /bin/bash

コンテナ内で、GPU の状態を確認する nvidia-smi コマンドを実行してみよう。

root@ec7a7325a727:/# nvidia-smi
Sun May 26 20:48:28 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 430.14       Driver Version: 430.14       CUDA Version: 10.2     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla K80           Off  | 00000000:00:04.0 Off |                    0 |
| N/A   33C    P0    81W / 149W |      0MiB / 11441MiB |    100%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

上記のように、GPU の状態が表示されれば上手くいっている。 はて、CUDA Version が 10.2 になっているのはどうしてだろうか。

参考

今回は、次のようにして Google Compute Engine で環境を用意した。

$ gcloud compute instances create gce-gpu-k80 \
  --preemptible \
  --zone us-central1-a \
  --machine-type n1-standard-2 \
  --accelerator type=nvidia-tesla-k80,count=1 \
  --maintenance-policy TERMINATE \
  --restart-on-failure \
  --image-project ubuntu-os-cloud \
  --image-family ubuntu-1804-lts

blog.amedama.jp

いじょう。

coreutils の *sum を使ってワンライナーでハッシュ値を検証する

何処からかファイルをダウンロードしたときは、念のためハッシュ値が合っているか確認する場合があると思う。 今回は、そんなハッシュ値の検証をワンライナーでやる方法について。 シェルスクリプトとかで使うと便利だと思う。

動作確認に使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.2 LTS"
$ uname -r
4.15.0-50-generic

下準備

たぶん既に入ってるけど coreutils をインストールしておく。

$ sudo apt-get -y install coreutils

ちなみに macOS でも Homebrew で coreutils をインストールすれば同じようにいける。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ brew install coreutils

サンプルファイルを用意する

例として、次のようなファイルを用意する。

$ echo "Hello, World" > greet.txt

このファイルの MD5 のハッシュ値は次の通り。

$ md5sum greet.txt
9af2f8218b150c351ad802c6f3d66abe  greet.txt

ハッシュ値が一致するかチェックする

例えば、このファイルがいつの間にか改ざんされて中身が書き換わっていないか確認したいとする。 そんなときは記録しておいたハッシュ値とファイル名を md5sum コマンドに -c オプションと共に標準入力から渡す。

$ echo "9af2f8218b150c351ad802c6f3d66abe  greet.txt" | md5sum -c -
greet.txt: OK

すると、渡されたハッシュ値とファイル名を元に比較して一致しているかをチェックできる。

試しに、ファイルをちょっとばかり変更してみよう。

$ echo "Good bye, World" > greet.txt

これで、当然ながらハッシュ値は全く違ったものになる。

$ md5sum greet.txt 
92214ff18f0f6ba9620d271b91add216  greet.txt

この状況で、先ほどと同じハッシュ値と共に md5sum で検証してみる。

$ echo "9af2f8218b150c351ad802c6f3d66abe  greet.txt" | md5sum -c -
greet.txt: FAILED
md5sum: WARNING: 1 computed checksum did NOT match

ちゃんとエラーになった。

リターンコードについても非ゼロの値がセットされている。

$ echo $?
1

確認できたらファイルは元に戻しておく。

$ echo "Hello, World" > greet.txt

SHA 系でも試してみる。

念のため SHA 系のコマンドでも確認しておこう。

まずは sha1sum コマンドから。

$ sha1sum greet.txt
4ab299c8ad6ed14f31923dd94f8b5f5cb89dfb54  greet.txt
$ echo "4ab299c8ad6ed14f31923dd94f8b5f5cb89dfb54  greet.txt" | sha1sum -c -
greet.txt: OK

よさそう。

続いて sha256sum コマンドについても。

$ sha256sum greet.txt 
8663bab6d124806b9727f89bb4ab9db4cbcc3862f6bbf22024dfa7212aa4ab7d  greet.txt
$ echo "8663bab6d124806b9727f89bb4ab9db4cbcc3862f6bbf22024dfa7212aa4ab7d  greet.txt" | sha256sum -c -
greet.txt: OK

ばっちり。

複数のファイルを一度にチェックする

ちなみに複数のファイルを一度にチェックすることもできる。

例えばファイルを一つ追加しておく。

$ echo "Konnichiwa, Sekai" > aisatsu.txt

MD5 のハッシュ値は次の通り。

$ md5sum aisatsu.txt 
6656d68759745ed46727e0b42e4121b5  aisatsu.txt

複数のファイルを一度にチェックするときは、次のように複数行に渡って対応関係を渡せば良い。

$ cat << 'EOF' | md5sum -c -
9af2f8218b150c351ad802c6f3d66abe  greet.txt
6656d68759745ed46727e0b42e4121b5  aisatsu.txt
EOF
greet.txt: OK
aisatsu.txt: OK

いじょう。

Python: pytest-benchmark でベンチマークテストを書く

最近は Python のテストフレームワークとして pytest がデファクトになりつつある。 今回は、そんな pytest のプラグインの一つである pytest-benchmark を使ってベンチマークテストを書いてみることにする。

ここで、ベンチマークテストというのはプログラムの特定部位のパフォーマンスを計測するためのテストを指す。 ベンチマークテストを使うことで、チューニングの成果を定量的に把握したり、加えた変更によって別の場所で性能がデグレードしていないかを確かめることができる。

なお、チューニングする前のボトルネック探しについては別途プロファイラを使うのが良いと思う。

blog.amedama.jp

blog.amedama.jp

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V         
Python 3.7.3

下準備

まずはパッケージをインストールしておく。

$ pip install pytest-benchmark

まずは試してみる

すごく単純なベンチマークテストを書いて動きを確認してみよう。

一般的に pytest を使うときはプロジェクトのルートに tests というディレクトリを用意することが多い。

$ mkdir -p tests

そして、作成したディレクトリに test_ から始まるテストコードを記述したファイルを用意する。 以下のサンプルコードでは test_example.py という名前でベンチマークテストのファイルを用意している。 サンプルコードの中では something() という関数を仮想的なベンチマーク対象としている。 テスト自体は test_ から始まる関数として記述することが一般的で test_something_benchmark() という名前で定義している。 pytest-benchmark を使ったベンチマークテストでは引数に benchmark を指定すると、必要なオブジェクトがインジェクトされる。

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

import time

import pytest


def something(duration=0.1):
    """ベンチマークしたい対象"""
    time.sleep(duration)
    return True


def test_something_benchmark(benchmark):
    """ベンチマークを実施するテスト

    :param benchmark: pytest-benchmark がインジェクトするフィクスチャ
    """
    # テスト対象を引数として benchmark を実行する
    ret = benchmark(something)
    # 返り値を検証する
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])
EOF

あまり説明が長くなっても何なので実際に動かしてみよう。 実行は通常通りテストランナーである pytest コマンドを起動するだけ。

$ pytest
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 1 item                                                                                                                           

tests/test_example.py .                                                                                                              [100%]


--------------------------------------------------- benchmark: 1 tests --------------------------------------------------
Name (time in ms)                 Min       Max      Mean  StdDev    Median     IQR  Outliers     OPS  Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------
test_something_benchmark     100.2115  105.2357  102.0071  1.9180  101.5772  2.3150       2;0  9.8032      10           1
-------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
========================================================= 1 passed in 2.28 seconds =========================================================

見慣れた表示の中にベンチマークの結果として実行にかかった時間に関する統計量が表示されている。 表示からは、概ね一回の実行に 100ms 前後かかっていることが分かる。 これはテスト対象の something() がデフォルトで 100ms のスリープを入れることから直感にも則している。

実行回数などを制御する

先ほどは 1 回の試行 (iteration) でテスト対象 10 回の呼び出し (rounds) をしていた。

上記の回数を変更したいときは、次のように benchmark#pedantic() 関数を使う。

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

import time

import pytest


def something(duration=0.1):
    time.sleep(duration)
    return True


def test_something_benchmark(benchmark):
    # コードで実行内容を制御したいときは benchmark#pedantic() を使う
    ret = benchmark.pedantic(something,
                             kwargs={'duration': 0.0001},  # テスト対象に渡す引数 (キーワード付き)
                             rounds=100,  # テスト対象の呼び出し回数
                             iterations=10)  # 試行回数
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

上記を実行してみよう。 今度は 10 回の試行 (iteration) で各 100 回の呼び出し (rounds) になった。 なお、スリープする時間を短くしたにも関わらず数字が変わっていないように見えるが、単位がミリ秒からマイクロ秒に変化している。

$ pytest
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 1 item                                                                                                                           

tests/test_example.py .                                                                                                              [100%]


------------------------------------------------------ benchmark: 1 tests ------------------------------------------------------
Name (time in us)                 Min       Max      Mean   StdDev    Median     IQR  Outliers  OPS (Kops/s)  Rounds  Iterations
--------------------------------------------------------------------------------------------------------------------------------
test_something_benchmark     134.3719  266.8602  143.7150  15.9095  138.1588  8.6015       6;7        6.9582     100          10
--------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
========================================================= 1 passed in 0.19 seconds =========================================================

ベンチマークだけ実行する・スキップする

一般的に、ベンチマークテストは実行に時間がかかるものが多い。 通常のユニットテストと混ぜて実行してしまうと、全体のかかる時間が伸びて使い勝手が悪くなる恐れがある。 そうした場合のために pytest-benchmark はベンチマークテストだけ実行したりスキップしたりできる。

次のサンプルコードでは通常のユニットテストとベンチマークテストが混在している。

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

import time

import pytest


def something(duration=0.1):
    time.sleep(duration)
    return True


def test_something():
    """通常のテスト"""
    ret = something()
    assert ret


def test_something_benchmark(benchmark):
    """ベンチマークテスト"""
    ret = benchmark(something)
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

こうした状況下で、もしベンチマークテストを実行したくないときは --benchmark-skip オプションを指定してテストランナーを走らせよう。

$ pytest --benchmark-skip
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 2 items                                                                                                                          

tests/test_example.py .s                                                                                                             [100%]

=================================================== 1 passed, 1 skipped in 0.14 seconds ====================================================

ベンチマークテストがスキップされていることが分かる。

反対に、ベンチマークテストだけ実行したいときは、次のように --benchmark-only オプションを指定する。

$ pytest --benchmark-only
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 2 items                                                                                                                          

tests/test_example.py s.                                                                                                             [100%]


--------------------------------------------------- benchmark: 1 tests --------------------------------------------------
Name (time in ms)                 Min       Max      Mean  StdDev    Median     IQR  Outliers     OPS  Rounds  Iterations
-------------------------------------------------------------------------------------------------------------------------
test_something_benchmark     100.0697  105.2460  103.0371  2.1262  102.9510  4.5859       7;0  9.7052      10           1
-------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
=================================================== 1 passed, 1 skipped in 2.26 seconds ====================================================

特定のベンチマークだけ実行したい

前述した通りベンチマークテストは実行に時間がかかることが多い。 プロジェクトに数多くベンチマークテストがあるとピンポイントで走らせたくなることが多い。

例えば次のサンプルコードには二つの実行時間が異なるテストが書かれている。

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

import time

import pytest


def something(duration=0.1):
    time.sleep(duration)
    return True


def test_something_benchmark_quick(benchmark):
    ret = benchmark(something, duration=0.01)
    assert ret


def test_something_benchmark_slow(benchmark):
    ret = benchmark(something, duration=1.0)
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

上記のような状況で、毎回どちらも実行していては時間を浪費してしまう。

$ pytest                 
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 2 items                                                                                                                          

tests/test_example.py ..                                                                                                             [100%]


--------------------------------------------------------------------------------------------- benchmark: 2 tests ---------------------------------------------------------------------------------------------
Name (time in ms)                         Min                   Max                  Mean            StdDev                Median               IQR            Outliers      OPS            Rounds  Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_something_benchmark_quick        10.0496 (1.0)         12.7429 (1.0)         11.7022 (1.0)      0.9845 (1.0)         11.8118 (1.0)      1.8297 (1.0)          27;0  85.4542 (1.0)          79           1
test_something_benchmark_slow      1,000.7044 (99.58)    1,005.1836 (78.88)    1,002.3219 (85.65)    1.9021 (1.93)     1,001.7429 (84.81)    2.9902 (1.63)          1;0   0.9977 (0.01)          5           1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
========================================================= 2 passed in 9.03 seconds =========================================================

ピンポイントでテストを実行したいときは pytest の基本的な機能を使えば良い。 例えば、ファイル名やテストの関数名を元に実行する対象を絞りたいときは pytest コマンドで -k オプションを指定する。

$ pytest -k test_something_benchmark_quick
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 2 items / 1 deselected / 1 selected                                                                                              

tests/test_example.py .                                                                                                              [100%]


---------------------------------------------------- benchmark: 1 tests ----------------------------------------------------
Name (time in ms)                      Min      Max     Mean  StdDev   Median     IQR  Outliers      OPS  Rounds  Iterations
----------------------------------------------------------------------------------------------------------------------------
test_something_benchmark_quick     10.0454  12.7581  11.8572  0.9654  12.5086  1.6299      20;0  84.3367      90           1
----------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
================================================== 1 passed, 1 deselected in 2.12 seconds ==================================================

あるいは、次のように実行するモジュールとテスト名を指定する。

$ pytest tests/test_example.py::test_something_benchmark_quick
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench
plugins: benchmark-3.2.2
collected 1 item                                                                                                                           

tests/test_example.py .                                                                                                              [100%]


---------------------------------------------------- benchmark: 1 tests ----------------------------------------------------
Name (time in ms)                      Min      Max     Mean  StdDev   Median     IQR  Outliers      OPS  Rounds  Iterations
----------------------------------------------------------------------------------------------------------------------------
test_something_benchmark_quick     10.0478  12.7498  11.6334  1.0232  11.6050  2.0731      57;0  85.9594      91           1
----------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
========================================================= 1 passed in 2.11 seconds =========================================================

デフォルトではベンチマークテストが実行されないようにする

なお、オプションを毎回指定するのが面倒なときは pytest の設定ファイルを用意しておくと良い。 例えば次のように pytest.ini を用意しておくとデフォルトではベンチマークテストが実行されなくなる。

$ cat << 'EOF' > pytest.ini 
[pytest]
addopts =
    --benchmark-skip
EOF

オプションを何も付けずに実行すると、たしかにベンチマークテストが走らない。

$ pytest
=========================================================== test session starts ============================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench, inifile: pytest.ini
plugins: benchmark-3.2.2
collected 2 items                                                                                                                          

tests/test_example.py ss                                                                                                             [100%]

======================================================== 2 skipped in 0.02 seconds =========================================================

なお、ベンチマークを実行したいときは --benchmark-only オプションでオーバーライドできる。

$ pytest --benchmark-only                                               
======================================================================== test session starts =========================================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench, inifile: pytest.ini
plugins: benchmark-3.2.2
collected 2 items                                                                                                                                                    

tests/test_example.py ..                                                                                                                                       [100%]


--------------------------------------------------------------------------------------------- benchmark: 2 tests ---------------------------------------------------------------------------------------------
Name (time in ms)                         Min                   Max                  Mean            StdDev                Median               IQR            Outliers      OPS            Rounds  Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_something_benchmark_quick        10.0553 (1.0)         12.7456 (1.0)         11.4637 (1.0)      1.0267 (1.37)        11.5810 (1.0)      2.2422 (2.07)         48;0  87.2316 (1.0)          84           1
test_something_benchmark_slow      1,003.0797 (99.76)    1,004.7923 (78.83)    1,003.6242 (87.55)    0.7500 (1.0)      1,003.1852 (86.62)    1.0816 (1.0)           1;0   0.9964 (0.01)          5           1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
====================================================================== 2 passed in 9.06 seconds ======================================================================

表示する統計量を絞る

デフォルトでは結構色々な統計量が表示されるけど、正直そんなに細かくいらないという感じもある。 そういうときは --benchmark-column オプションを使って必要なものだけに絞れる。

以下では試しに平均 (mean)、標準偏差 (stddev)、最小 (min)、最大 (max)だけ表示させてみた。

$ pytest --benchmark-only --benchmark-column=mean,stddev,min,max
======================================================================== test session starts =========================================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench, inifile: pytest.ini
plugins: benchmark-3.2.2
collected 2 items                                                                                                                                                    

tests/test_example.py ..                                                                                                                                       [100%]


------------------------------------------------- benchmark: 2 tests ------------------------------------------------
Name (time in ms)                        Mean            StdDev                   Min                   Max          
---------------------------------------------------------------------------------------------------------------------
test_something_benchmark_quick        11.4288 (1.0)      1.0626 (1.0)         10.1003 (1.0)         12.7667 (1.0)    
test_something_benchmark_slow      1,002.7103 (87.74)    1.9938 (1.88)     1,000.2403 (99.03)    1,005.2477 (78.74)  
---------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
====================================================================== 2 passed in 9.04 seconds ======================================================================

表示される順番を変更する

pytest-benchmark では、デフォルトでテストの項目が平均実行時間 (mean) にもとづいて昇順ソートされる。 大抵の場合はデフォルトのままで問題ないはず。 とはいえ、念のため変更する方法についても確認しておく。

以下はテストごとに実行にかかる時間の分散を変更している。 テストの実行時間は対数正規分布にもとづいたランダムな時間になる。 ただし test_something_benchmark_high_stddev()test_something_benchmark_low_stddev() よりもかかる時間の分散が大きくなるように設定している。

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

import time
import random
from functools import partial

import pytest


def something(duration_func):
    time.sleep(duration_func())
    return True


def test_something_benchmark_high_stddev(benchmark):
    f = partial(random.lognormvariate, 0.01, 0.1)
    ret = benchmark(something, duration_func=f)
    assert ret


def test_something_benchmark_low_stddev(benchmark):
    f = partial(random.lognormvariate, 0.1, 0.01)
    ret = benchmark(something, duration_func=f)
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

上記で、試しに実行時間の標準偏差 (stddev) にもとづいたソートにしてみよう。 ソートの順番を変更するには --benchmark-sort オプションでソートに使いたいカラムを指定する。

$ pytest --benchmark-only --benchmark-column=mean,stddev,min,max --benchmark-sort=stddev
======================================================================== test session starts =========================================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench, inifile: pytest.ini
plugins: benchmark-3.2.2
collected 2 items                                                                                                                                                    

tests/test_example.py ..                                                                                                                                       [100%]


---------------------------------------------------- benchmark: 2 tests ----------------------------------------------------
Name (time in ms)                              Mean             StdDev                   Min                   Max          
----------------------------------------------------------------------------------------------------------------------------
test_something_benchmark_low_stddev      1,118.5442 (1.12)     11.9663 (1.0)      1,101.5850 (1.22)     1,132.8983 (1.03)   
test_something_benchmark_high_stddev     1,002.3322 (1.0)      75.4297 (6.30)       900.1284 (1.0)      1,099.5439 (1.0)    
----------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean
===================================================================== 2 passed in 15.86 seconds ======================================================================

上記を見ると、たしかに実行時間の標準偏差にもとづいて昇順ソートされている。

ある時点に比べてテストのパフォーマンスが低下していないか確認する

よくあるニーズとして、ある時点に比べてパフォーマンスが低下していないか確認したいというものがある。 pytest-benchmark では、もちろんこれも確認できる。

まずはシンプルなテストを用意する。

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

import time

import pytest


def something():
    time.sleep(0.1)
    return True


def test_something_benchmark(benchmark):
    ret = benchmark(something)
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

テストを実行するときに --benchmark-autosave オプションをつけると結果が保存される。

$ pytest --benchmark-only --benchmark-autosave
...(snip)...
=========================== 1 passed in 3.56 seconds ===========================

結果は .benchmarks というディレクトリに JSON で保存される。

$ find .benchmarks
.benchmarks
.benchmarks/Darwin-CPython-3.7-64bit
.benchmarks/Darwin-CPython-3.7-64bit/0001_unversioned_20190520_123557.json

なお、複数回実行すると、その都度結果が記録されていく。

$ pytest --benchmark-only --benchmark-autosave
...(snip)...
=========================== 1 passed in 3.56 seconds ===========================
$ find .benchmarks
.benchmarks
.benchmarks/Darwin-CPython-3.7-64bit
.benchmarks/Darwin-CPython-3.7-64bit/0001_unversioned_20190520_123557.json
.benchmarks/Darwin-CPython-3.7-64bit/0002_unversioned_20190520_123739.json

ここで例えば、テストにかかる時間が 2 倍になるような変更をしてみよう。

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

import time

import pytest


def something():
    time.sleep(0.2)
    return True


def test_something_benchmark(benchmark):
    ret = benchmark(something)
    assert ret


if __name__ == '__main__':
    pytest.main(['-v', __file__])

この状況で、過去のベンチマークとパフォーマンスを比較してみる。 次のように --benchmark-compare オプションを使うと比較対象とするベンチマークを選べる。 また、--benchmark-compare-fail オプションを併用することで、パフォーマンスが低下したときに結果をエラーにできる。 ここでは mean:5% としているので、平均実行時間が 5% 悪化するとエラーになる。

$ pytest --benchmark-only --benchmark-compare=0002 --benchmark-compare-fail=mean:5%
Comparing against benchmarks from: Darwin-CPython-3.7-64bit/0002_unversioned_20190520_123739.json
======================================================================== test session starts =========================================================================
platform darwin -- Python 3.7.3, pytest-4.4.1, py-1.8.0, pluggy-0.11.0
benchmark: 3.2.2 (defaults: timer=time.perf_counter disable_gc=False min_rounds=5 min_time=0.000005 max_time=1.0 calibration_precision=10 warmup=False warmup_iterations=100000)
rootdir: /Users/amedama/Documents/temporary/pybench, inifile: pytest.ini
plugins: benchmark-3.2.2
collected 1 item                                                                                                                                                     

tests/test_example.py .                                                                                                                                        [100%]


--------------------------------------------------------------------------------------------- benchmark: 2 tests ---------------------------------------------------------------------------------------------
Name (time in ms)                                Min                 Max                Mean            StdDev              Median               IQR            Outliers     OPS            Rounds  Iterations
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
test_something_benchmark (0002_unversi)     100.1851 (1.0)      105.1182 (1.0)      102.8839 (1.0)      2.0469 (1.0)      103.4094 (1.0)      3.9875 (1.0)           5;0  9.7197 (1.0)          10           1
test_something_benchmark (NOW)              200.9856 (2.01)     205.1931 (1.95)     202.8940 (1.97)     2.1267 (1.04)     201.9794 (1.95)     4.0872 (1.02)          2;0  4.9287 (0.51)          5           1
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

Legend:
  Outliers: 1 Standard Deviation from Mean; 1.5 IQR (InterQuartile Range) from 1st Quartile and 3rd Quartile.
  OPS: Operations Per Second, computed as 1 / Mean

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
Performance has regressed:
    test_something_benchmark (0002_unversi) - Field 'mean' has failed PercentageRegressionCheck: 97.206794028 > 5.000000000
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
ERROR: Performance has regressed.

実行時間が 2 倍になっていることを考えれば当たり前だけどエラーになる。

いじょう。

Python: 条件分岐と真偽値周りの話

今回は Python の条件分岐と真偽値周りの話について。 ざっくりと内容をまとめると次の通り。

  • Python の条件分岐には真偽値以外のオブジェクトを渡せる
    • 意味的には組み込み関数 bool() にオブジェクトを渡すのと等価になる
  • ただし条件分岐に真偽値以外のオブジェクトを渡すと不具合を生みやすい
    • そのため、条件分岐には真偽値だけを渡すようにした方が良い
  • なお、オブジェクトを bool() に渡したときの振る舞いはオーバーライドできる
    • 特殊メソッド __bool__() を実装すれば良い

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V
Python 3.7.3

下準備

今回の説明は Python の REPL を使って進めていくので、あらかじめ起動しておく。

$ python

Python の条件分岐について

Python の条件分岐には真偽値 (bool ) 型以外のオブジェクトも渡せる。 例えば、次のコードは Python においてちゃんと動作する。 関数 f() における引数 x は bool 型である必要もない。

>>> def f(x):
...     # 引数が有効か無効かを判断するつもりの条件分岐
...     if x:
...         print('Valid')
...     else:
...         print('Invalid')
... 

では、上記の引数 x に色々なオブジェクトを渡すと、どのように振る舞うだろうか。 ちょっと見てみよう。

例えば真偽値型の True を渡してみる。 これは、当然ながら上のコードブロックに遷移する。

>>> f(True)
Valid

では、長さのある文字列だったら? これも、上のコードブロックに遷移する。

>>> f('Hello, World!')
Valid

非 0 の整数なら? これまた同様。

>>> f(1)
Valid

では、続いて None を渡してみよう。

>>> f(None)
Invalid

この場合は、下のコードブロックに遷移した。 なんとなく、ここまでは直感どおりに思える。

じゃあ長さのない文字列 (空文字) を渡したらどうなるだろう。

>>> f('')
Invalid

なんと、この場合は下のコードブロックに遷移してしまった。

整数としてゼロを渡した場合も同様。

>>> f(0)
Invalid

では、上記の不思議な振る舞いは一体何に由来するものだろうか。 実はオブジェクトを条件分岐に渡すとき、意味的には組み込み関数 bool() に渡すのと等価になる。

つまり、最初に示した関数 f() は、次のコードと等価ということになる。

>>> def f(x):
...     # オブジェクトの真偽値表現を組み込み関数 bool() で取得する
...     if bool(x):
...         print('Valid')
...     else:
...         print('Invalid')
... 

組み込み関数 bool() では、オブジェクトを真偽値として評価した場合の結果が得られる。 先ほど試したいくつかのオブジェクトを実際に渡してみよう。

>>> bool('')
False
>>> bool(' ')
True
>>> bool(0)
False
>>> bool(1)
True

上記で得られる返り値の内容は、先ほどの検証で得られた振る舞いと一致する。

このように、真偽値以外のオブジェクトを条件分岐に渡すと直感的でない振る舞いをすることがある。 コードの直感的でない振る舞いは不具合につながる。 また、コメントでもない限り、意図してそのコードにしているのかも分かりにくい。

PEP20: Zen of Python にある Explicit is better than implicit. を実践するのであれば、真偽値を渡すほうが良いと思う。 例えば、最初のコードで仮に「None か否か」を判定したいのであれば、次のようにした方が良いと考えられる。

>>> def f(x):
...     # オブジェクトが None か判定結果を真偽値として得る
...     if x is not None:
...         print('Valid')
...     else:
...         print('Invalid')
... 

... is not None は対象が None かそうでないかを真偽値で返すことになる。 解釈にブレが生じることはない。

>>> 'Hello, World!' is not None
True
>>> '' is not None
True
>>> 1 is not None
True
>>> 0 is not None
True
>>> None is not None
False

ちなみに、自分で定義したクラスのインスタンスが組み込み関数 bool() に渡されたときの振る舞いはオーバーライドできる。 具体的には特殊メソッド __bool__() を実装すれば良い。

以下のサンプルコードでは、クラス FizzBuzz に特殊メソッドを定義して振る舞いをオーバーライドしている。 このクラスのインスタンスは渡された整数の値によって組み込み関数 bool() から得られる結果を切り替える。

>>> class FizzBuzz(object):
...     """整数が 3 か 5 で割り切れる値か真偽値で確認できるクラス"""
...     def __init__(self, n):
...         self.n = n
...     def __bool__(self):
...         # Fizz
...         if self.n % 3 == 0:
...             return True
...         # Buzz
...         if self.n % 5 == 0:
...             return True
...         # Others
...         return False
... 

引数が 35 で割り切れるときに True を返し、それ以外は False になる。

>>> o = FizzBuzz(3)
>>> bool(o)
True
>>> o = FizzBuzz(5)
>>> bool(o)
True
>>> o = FizzBuzz(4)
>>> bool(o)
False

いじょう。

Python: seaborn を使った可視化を試してみる

今回は、Python の有名な可視化ライブラリである matplotlib のラッパーとして動作する seaborn を試してみる。 seaborn を使うと、よく必要になる割に matplotlib をそのまま使うと面倒なグラフが簡単に描ける。 毎回、使うときに検索することになるので備忘録を兼ねて。

使った環境は次の通り。

$ sw_vers  
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V
Python 3.7.3

下準備

下準備として seaborn をインストールしておく。

$ pip install seaborn

今回は Python のインタプリタ上で動作確認する。

$ python

まずは seaborn と matplotlib をインポートする。

>>> import seaborn as sns
>>> from matplotlib import pyplot as plt

グラフが見やすいようにスタイルを設定する。

>>> sns.set(style='darkgrid')

Relational plots

まずは seaborn の中で「Relational plots」というカテゴリに属するグラフから試していく。

scatter plot (散布図)

まずは散布図から。

動作確認のために "tips" という名前のデータセットを読み込む。 これは、レストランでの支払いに関するデータセットになっている。

>>> tips = sns.load_dataset('tips')
>>> type(tips)
<class 'pandas.core.frame.DataFrame'>
>>> tips.head()
   total_bill   tip     sex smoker  day    time  size
0       16.99  1.01  Female     No  Sun  Dinner     2
1       10.34  1.66    Male     No  Sun  Dinner     3
2       21.01  3.50    Male     No  Sun  Dinner     3
3       23.68  3.31    Male     No  Sun  Dinner     2
4       24.59  3.61  Female     No  Sun  Dinner     4

散布図を描くときは scatterplot() という関数を使う。

>>> sns.scatterplot(data=tips, x='total_bill', y='tip')
<matplotlib.axes._subplots.AxesSubplot object at 0x1166db390>

関数を呼び出したら pyplot.show() 関数を実行しよう。 なお、以降は plt.show() の実行については省略する。

>>> plt.show()

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

f:id:momijiame:20190429173201p:plain

上記では、支払い総額とチップの関係性を散布図で可視化している。 それなりに相関がありそうだ。

続いては、喫煙者と非喫煙者で傾向に差があるかどうか見てみよう。 一つのグラフの中で見比べるときは hue オプションを使うと良い。

>>> sns.scatterplot(data=tips, x='total_bill', y='tip', hue='smoker')
<matplotlib.axes._subplots.AxesSubplot object at 0x11e1949b0>

以下のようなグラフが得られる。

f:id:momijiame:20190429173328p:plain

hue オプション以外にも、一つのグラフの中で違うことを示すには stylesize といったオプションも使える。

例えば style を指定してみよう。

>>> sns.scatterplot(data=tips, x='total_bill', y='tip', style='smoker')
<matplotlib.axes._subplots.AxesSubplot object at 0x11c43e588>

すると、次のようにマーカーの形が変わる。

f:id:momijiame:20190429174820p:plain

同様に size を指定してみる。

>>> sns.scatterplot(data=tips, x='total_bill', y='tip', size='smoker')
<matplotlib.axes._subplots.AxesSubplot object at 0x11c4a2908>

すると、次のようにマーカーの大きさが変わる。

f:id:momijiame:20190429175000p:plain

もちろん、これらのオプションは混ぜて使うこともできる。 例えば喫煙者か非喫煙者か以外に、性別や時間 (ランチ・ディナー) について指定してみよう。

>>> sns.scatterplot(data=tips, x='total_bill', y='tip', hue='smoker', style='sex', size='time')
<matplotlib.axes._subplots.AxesSubplot object at 0x11da1a400>

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

f:id:momijiame:20190429175211p:plain

うん、まったく訳がわからない。 一つのグラフには情報を詰め込みすぎないように気をつけよう。 なお、ここまで使ってきた huestylesize といったオプションは別の API でも使える場合が多い。

また、relplot() 関数を使うと複数の散布図を扱うことができる。 relplot() 関数は scatterplot() 関数を、より一般化した API となっている。 散布図は kind='scatter' と指定することで扱える。 同時に col オプションを指定すると、そこに指定したカラムごとに別々のグラフが得られる。

>>> sns.relplot(data=tips, kind='scatter', x='total_bill', y='tip', col='smoker')
<seaborn.axisgrid.FacetGrid object at 0x102e0b0f0>

上記で得られるグラフが以下。 喫煙者は支払総額とチップの相関が非喫煙者に比べるとやや低いように見受けられる。

f:id:momijiame:20190429183315p:plain

実際に確認してみよう。

>>> tips.corr()['total_bill']['tip']
0.6757341092113642
>>> tips[tips.smoker == 'No'].corr()['total_bill']['tip']
0.822182625705083
>>> tips[tips.smoker == 'Yes'].corr()['total_bill']['tip']
0.4882179411628103

全体では相関係数が 0.675 だったのに対して非喫煙者で層化すると 0.822 となり喫煙者では 0.488 となった。

複数のグラフに分割すると、情報を詰め込みすぎて見にくいグラフになることを防げる。 試しに colhue を併用してみよう。

>>> sns.relplot(data=tips, kind='scatter', x='total_bill', y='tip', hue='time', col='smoker')
<seaborn.axisgrid.FacetGrid object at 0x11e5ee470>

以下のグラフでは喫煙者・非喫煙者でグラフを分けつつ、各グラフの中では時間によるチップ額の傾向を分けて示している。

f:id:momijiame:20190429184110p:plain

line plot (折れ線グラフ)

続いては折れ線グラフを試す。

動作確認のために "flights" というデータセットを読み込もう。 これは、飛行機の乗客数の推移を示している。

>>> flights = sns.load_dataset('flights')
>>> flights.head()
   year     month  passengers
0  1949   January         112
1  1949  February         118
2  1949     March         132
3  1949     April         129
4  1949       May         121

試しに 1 月の乗客の推移を年ごとに可視化してみよう。 折れ線グラフの描画には lineplot() 関数を使う。

>>> sns.lineplot(data=flights[flights.month == 'January'], x='year', y='passengers')
<matplotlib.axes._subplots.AxesSubplot object at 0x11c6c6b00>

上記から得られるグラフは次の通り。 乗客の数は右肩上がりのようだ。

f:id:momijiame:20190429184551p:plain

特定の月に限定しない場合についても確認しておこう。

>>> sns.lineplot(data=flights, x='year', y='passengers')
<matplotlib.axes._subplots.AxesSubplot object at 0x11e659940>

上記から得られたグラフが次の通り。 今度は実線の上下に範囲を指定するようなグラフになった。 これはデフォルトではブートストラップ信頼区間 (信頼係数 95%)を示しているらしい。

f:id:momijiame:20190429184751p:plain

ci オプションに sd を指定することで、標準偏差を用いた信頼区間にもできるようだ。 使うのは、分散が正規分布と仮定できる場合?

>>> sns.lineplot(data=flights, x='year', y='passengers', ci='sd')
<matplotlib.axes._subplots.AxesSubplot object at 0x11e69bb00>

f:id:momijiame:20190429185132p:plain

複数のグラフに分けて表示したいときは scatterplot() のときと同じように relplot() を使う。 ただし、kind には line を指定する。 また、数が多いときは横に並んでしまうので col_wrap を指定することで折り返すと良い。

>>> sns.relplot(data=flights, kind='line', x='year', y='passengers', col='month', col_wrap=4)
<seaborn.axisgrid.FacetGrid object at 0x11e631898>

f:id:momijiame:20190429191750p:plain

Categorical plots

続いては "Categorical plots" に分類されるグラフを見ていく。

動作確認のために "titanic" データセットを読み込む。 タイタニック号の沈没に関する乗客のデータセット。

>>> titanic = sns.load_dataset('titanic')
>>> titanic.head()
   survived  pclass     sex   age  sibsp  parch  ...    who adult_male deck  embark_town  alive  alone
0         0       3    male  22.0      1      0  ...    man       True  NaN  Southampton     no  False
1         1       1  female  38.0      1      0  ...  woman      False    C    Cherbourg    yes  False
2         1       3  female  26.0      0      0  ...  woman      False  NaN  Southampton    yes   True
3         1       1  female  35.0      1      0  ...  woman      False    C  Southampton    yes  False
4         0       3    male  35.0      0      0  ...    man       True  NaN  Southampton     no   True

[5 rows x 15 columns]

strip plot (ストリップチャート)

まずはストリップチャートから。

客室のグレードと年齢の関係性についてプロットしてみよう。

>>> sns.stripplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d434748>

f:id:momijiame:20190429193619p:plain

客室のグレードが高い方が年齢層が高め。

性別で層化してみる。

>>> sns.stripplot(data=titanic, x='pclass', y='age', hue='sex')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d58d6d8>

f:id:momijiame:20190429195744p:plain

混ざってしまって見にくいときは dodge オプションを True にすると良い。

>>> sns.stripplot(data=titanic, x='pclass', y='age', hue='sex', dodge=True)
<matplotlib.axes._subplots.AxesSubplot object at 0x11d48ca20>

f:id:momijiame:20190429195854p:plain

女性の方が、やや年齢層が低そう? 家族など、男性と一緒に来ている影響もあるだろうか。

生死で層化した場合についても見てみよう。 複数のグラフに分けたいときは catplot() 関数を使う。 その際、kind オプションには strip を指定する。 これは scatterplot()lineplot() で複数のグラフを扱うときに relplot() を使ったのと同じ考え方。

>>> sns.catplot(data=titanic, kind='strip', x='pclass', y='age', hue='survived', col='sex', dodge=True)
<seaborn.axisgrid.FacetGrid object at 0x11d47a4a8>

f:id:momijiame:20190429200224p:plain

あきらかに、一等客室と二等客室の女性は生き残りやすかったことが分かる。

swarm plot (スウォームチャート)

ストリップチャートは要素が重なっていたけど、重なりを除外したものがこちら。 swarmplot() 関数を使うことで描画できる。

>>> sns.swarmplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11daa6320>

f:id:momijiame:20190430132145p:plain

似たような値の数がどれくらいあるかは分かりやすいかも。

box plot (箱ひげ図)

これは多くの人に馴染みがあると思う。 箱ひげ図は boxplot() 関数を使って描画する。

>>> sns.boxplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d5bc7b8>

f:id:momijiame:20190430132250p:plain

最大値、第二四分位数、中央値、第三四分位数、最小値、外れ値を確認できる。 外れ値は第二、第三四分位数から 1.5 IQR (Interquartile Range) の外にあるものになる。

複数のグラフに分けて表示したいときは catplot() を使いつつ kind オプションに box を指定する。

>>> sns.catplot(data=titanic, kind='box', x='pclass', y='age', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11da78588>

f:id:momijiame:20190430132736p:plain

ストリップチャートやスウォームチャートに比べると、ざっくり内容を把握するには良い反面、個々の要素は細かく見ることができない。

violin plot (バイオリン図)

続いては箱ひげ図とスウォームチャートの中間みたいなバイオリン図。 バイオリン図は violinplot() を使って描く。

>>> sns.violinplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d70ebe0>

f:id:momijiame:20190430133302p:plain

バイオリンの内側については描き方がいくつか考えられる。 例えば inner オプションに stick を指定すると、以下のように個々の要素がどこにあるか示される。

>>> sns.violinplot(data=titanic, x='pclass', y='age', inner='stick')
<matplotlib.axes._subplots.AxesSubplot object at 0x11e572f60>

f:id:momijiame:20190430133839p:plain

あるいは、次のようにしてグラフを重ね合わせて自分で描いても良い。

>>> ax = sns.violinplot(data=titanic, x='pclass', y='age', inner=None)
>>> sns.stripplot(data=titanic, x='pclass', y='age', color='k', ax=ax)
<matplotlib.axes._subplots.AxesSubplot object at 0x11ec321d0>

f:id:momijiame:20190430133848p:plain

層化させたときの表示方法も複数ある。 hue オプション以外、特に何も指定しなければ次のようになる。 箱ひげ図などと同じ感じ。

>>> sns.violinplot(data=titanic, x='pclass', y='age', hue='survived')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d7e6780>

ここで、同時に split オプションに True を指定すると、次のように左右で表示が変わる。

>>> sns.violinplot(data=titanic, x='pclass', y='age', hue='survived', split=True)
<matplotlib.axes._subplots.AxesSubplot object at 0x11e94eb38>

f:id:momijiame:20190430133958p:plain

複数のグラフに分けるときは、これまでと同じように catplot() を指定する。 kind オプションには violin を指定する。

>>> sns.catplot(data=titanic, kind='violin', x='pclass', y='age', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11e127198>

f:id:momijiame:20190430135017p:plain

boxen plot (a.k.a letter value plot)

日本語の対応が不明なんだけど、箱ひげ図を改良したグラフ。 一般的には "letter value plot" と呼ばれているみたい。

seaborn では boxenplot() 関数を使って描く。

>>> sns.boxenplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x10cb33710>

f:id:momijiame:20190430141614p:plain

箱ひげ図よりも分布に関する情報の落ち方が少ないのがポイントらしい。

複数のグラフに分けるときは catplot() 関数で kind に boxen を指定する。

>>> sns.catplot(data=titanic, kind='boxen', x='pclass', y='age', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11e0a2d68>

f:id:momijiame:20190430142720p:plain

point plot

こちらも日本語の対応が分からない。 平均値と信頼区間だけの表示に絞られたシンプルなグラフ。

>>> sns.pointplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d459d30>

f:id:momijiame:20190430141847p:plain

シンプルがゆえに、層化すると統計的に有意か否かを示しやすいかも。 そういえば効果を示すときにこんなグラフ使ってるの見たことあるな。

>>> sns.pointplot(data=titanic, x='pclass', y='age', hue='sex')
<matplotlib.axes._subplots.AxesSubplot object at 0x11d4b2278>

f:id:momijiame:20190430141951p:plain

複数のグラフに分けるときは catplot() 関数で kind に point を指定する。

>>> sns.catplot(data=titanic, kind='point', x='pclass', y='age', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11d456080>

f:id:momijiame:20190430142623p:plain

barplot (棒グラフ)

馴染みのある棒グラフ。

>>> sns.barplot(data=titanic, x='pclass', y='age')
<matplotlib.axes._subplots.AxesSubplot object at 0x11e65d080>

f:id:momijiame:20190430142406p:plain

ひげはブートストラップ信頼区間を示している。

複数のグラフに分けるときは catplot() 関数で kind に bar を指定する。

>>> sns.catplot(data=titanic, kind='bar', x='pclass', y='age', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11d6eaac8>

f:id:momijiame:20190430142513p:plain

count plot

同じ棒グラフでも値のカウントに特価したのが、この countplot() 関数。 使うときは x 軸か y 軸の一軸だけを指定する。

>>> sns.countplot(data=titanic, x='pclass')
<matplotlib.axes._subplots.AxesSubplot object at 0x11da03978>

f:id:momijiame:20190430142827p:plain

比率などに焦点を絞って可視化するときに見やすい。

>>> sns.catplot(data=titanic, kind='count', x='pclass', hue='survived', col='sex')
<seaborn.axisgrid.FacetGrid object at 0x11e09a198>

f:id:momijiame:20190430142950p:plain

Distribution plots

続いては "Distribution plots" に分類されるグラフを見ていく。

動作確認用として "iris" データセットを読み込んでおく。

>>> iris = sns.load_dataset('iris')
>>> iris.head()
   sepal_length  sepal_width  petal_length  petal_width species
0           5.1          3.5           1.4          0.2  setosa
1           4.9          3.0           1.4          0.2  setosa
2           4.7          3.2           1.3          0.2  setosa
3           4.6          3.1           1.5          0.2  setosa
4           5.0          3.6           1.4          0.2  setosa

dist plot (ヒストグラム)

まずは馴染みの深いヒストグラムから。 ヒストグラムは distplot() 関数を使って描画する。

>>> sns.distplot(iris.petal_length)
<matplotlib.axes._subplots.AxesSubplot object at 0x11ee27160>

f:id:momijiame:20190430144346p:plain

階級の数は bins オプションで指定できる。

>>> sns.distplot(iris.petal_length, bins=10)
<matplotlib.axes._subplots.AxesSubplot object at 0x11e0ced68>

f:id:momijiame:20190430144513p:plain

kde plot

KDE (Kernel Density Estimation) はカーネル密度推定という。 分布から確率密度関数を推定するのに用いる。

>>> sns.kdeplot(iris.sepal_length)
<matplotlib.axes._subplots.AxesSubplot object at 0x11d34e160>

二軸で描画することもできる。

>>> sns.kdeplot(iris.petal_length, iris.petal_width, shade=True)
<matplotlib.axes._subplots.AxesSubplot object at 0x11c5832b0>

f:id:momijiame:20190430144735p:plain

rug plot

rug plot は値の登場する位置に特化したグラフ。

>>> sns.rugplot(iris.petal_length)
<matplotlib.axes._subplots.AxesSubplot object at 0x11c701ba8>

f:id:momijiame:20190430144857p:plain

どちらかというと、他のグラフと重ね合わせて使うものなのかな。

>>> ax = sns.distplot(iris.petal_length)
>>> sns.rugplot(iris.petal_length, ax=ax)
<matplotlib.axes._subplots.AxesSubplot object at 0x11e323c88>

f:id:momijiame:20190430144951p:plain

joint plot

joint plot は二つのグラフの組み合わせ。 デフォルトでは散布図とヒストグラムが同時に見られる。

>>> sns.jointplot(data=iris, x='petal_length', y='petal_width')
<seaborn.axisgrid.JointGrid object at 0x11c6d8320>

f:id:momijiame:20190430145048p:plain

kindkde を指定すると確率密度関数が見られる。

>>> sns.jointplot(data=iris, x='petal_length', y='petal_width', kind='kde')
<seaborn.axisgrid.JointGrid object at 0x11e6635c0>

f:id:momijiame:20190430145220p:plain

pair plot

pair plot は二軸の組み合わせについて可視化できる。

>>> sns.pairplot(data=iris)
<seaborn.axisgrid.PairGrid object at 0x11e6d6470>

f:id:momijiame:20190430145357p:plain

表示する次元を絞るときは vars オプションで指定する。

>>> sns.pairplot(data=iris, hue='species', vars=['petal_length', 'petal_width'])
<seaborn.axisgrid.PairGrid object at 0x11e565390>

f:id:momijiame:20190430145621p:plain

kind オプションに reg を指定すると線形回帰の結果も見られたりする。

>>> sns.pairplot(data=iris, hue='species', kind='reg')
<seaborn.axisgrid.PairGrid object at 0x11db1a668>

f:id:momijiame:20190430145612p:plain

Matrix plots

続いては "Matrix plots" に分類されるグラフを見ていく。

heat map (ヒートマップ)

まずはヒートマップから。 相関係数を確認するのに使うことが多いと思う。

>>> sns.heatmap(data=iris.corr())
<matplotlib.axes._subplots.AxesSubplot object at 0x11d8d2048>

f:id:momijiame:20190430145822p:plain

実際の値も一緒に描いたり、カラーマップを変更すると見やすくなる。

>>> sns.heatmap(data=iris.corr(), annot=True, cmap='bwr')
<matplotlib.axes._subplots.AxesSubplot object at 0x11da5cac8>

f:id:momijiame:20190430145916p:plain

まとめ

今回は searborn を使って色々なグラフを描いてみた。 seaborn は多くの API が共通のオプションを備えているため、それらを覚えるだけでなんとなく描けるようになるところが便利。

Python: 文字列を整形する方法について

Python には文字列を整形する方法がいくつかある。 ここでいう整形というのは、定数や変数を元にお目当ての文字列を手に入れるまでの作業を指す。 今回は、それぞれのやり方を紹介しつつメリット・デメリットについて見ていく。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226
$ python -V
Python 3.7.3

+ 演算子 (plus operator)

一番シンプルなのが、この + 演算子を使ったやり方だと思う。 文字列同士を + を使うことで連結できる。

>>> 'Hello, ' + 'World!'
'Hello, World!'

ただ、このやり方は文字列同士でないと使えない。 例えば文字列と整数をプラス演算子で連結しようとすると、以下のような例外になる。

>>> 'Hello, ' + 100
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str

そのため、連結する前には明示的に文字列にキャストする必要がある。

>>> 'Hello, ' + str(100)
'Hello, 100'

また、このやり方は上述の問題と併せて、長い文字列を作ろうとしたときにコードが煩雑になりがち。 一つか二つの要素の連結でなければ、別のやり方を検討した方が良いと思う。

% 演算子 (%-formatting)

続いては、以前は主流だった % 演算子を使ったやり方。

これは、書式指定子を埋め込んだ文字列に % 演算子を使って値を埋め込んでいくやり方。

>>> 'Hello, %s' % 'World!'
'Hello, World!'

複数の文字列を埋め込むときは、次のようにタプルで渡す。

>>> '%s, %s' % ('Hello', 'World!')
'Hello, World!'

このやり方の欠点は、タプルの扱いに難があること。 例えば、本当にタプルを渡したいときに次のような例外になる。

>>> t = ('this', 'is', 'a', 'tuple')
>>> '%s' % t
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: not all arguments converted during string formatting

問題を回避するためには、文字列に明示的にキャストしたり、タプルでタプルを包む必要がある。

>>> '%s' % str(t)
"('this', 'is', 'a', 'tuple')"
>>> '%s' % (t, )
"('this', 'is', 'a', 'tuple')"

% 演算子は、過去に利用が推奨されず将来的に削除される予定だった時期もあった (その後、色々あって撤回された)。 なので、以前から Python を積極的に利用している人たちの中では、過去の手法と捉えている人が多いと思う。 個人的にも % 演算子を使っているコードを見ると、やや古めかしいコードという印象を受ける。

string.Template

続いてはちょっと変化球的な string.Template を使うやり方。 これを使っている場面はほとんど見たことがない。 個人的にも、簡易的なテンプレートエンジン代わりに使ったことしかない。

あらかじめ string.Template をインポートする必要がある。 めんどくさいね。

>>> from string import Template

基本的には書式指定子を文字列に埋め込んで使う点は、先ほどの % 演算子を使う場合と同じ。

>>> s = Template('$greet, $message')
>>> s.substitute(greet='Hello', message='World!')
'Hello, World!'

単なる文字列の整形で使う場面はないかな。

str.format()

続いては、現在の主流といえる文字列の format() メソッド。 このやり方は Python 2.6 から導入された。

ここまで見てきたやり方と同じように、文字列に書式指定子を埋め込んで使う。

>>> 'Hello, {}'.format('World!')
'Hello, World!'

% 演算子にあったタプル周りの問題もない。

>>> '{}'.format(t)
"('this', 'is', 'a', 'tuple')"

同じ変数を複数回使いたいときもばっちり。

>>> '{0}, {0}, {1}'.format('Hello', 'World!')
'Hello, Hello, World!'

大抵は、上記のように空のブラケットや数字を使うよりも以下のように明示的に名前をつけると思う。

>>> '{a}, {b}'.format(a='Hello', b='World!')
'Hello, World!'

また、変数の数が多いときは、次のように辞書と組み合わせると可読性の低下を防げると思う。

>>> args = {
...   'a': 'Hello',
...   'b': 'World!'
... }
>>> '{a}, {b}'.format(**args)
'Hello, World!'

後述する f-string が使えない環境では、基本的にはこれを使っておけば良いと思う。

f-string

続いては Python 3.6 から導入された最も新しいやり方である f-string について。

このやり方ではスコープに定義済みの変数をそのまま文字列に埋め込める。

>>> greet, message = 'Hello', 'World!'
>>> f'{greet}, {message}'
'Hello, World!'

f-string には変数だけでなく、式を含めることもできる。

>>> f'1 + 1 = {1 + 1}'
'1 + 1 = 2'

ただ、あまり複雑な式を含めると可読性の低下につながるので注意しよう。

ちなみに f-string の導入前は str.format()locals() を組み合わせた以下のようなハックが知られていた。 locals() はローカルスコープで定義されている全ての変数を辞書で返す組み込み関数。

>>> greet, message = 'Hello', 'World!'
>>> '{greet}, {message}'.format(**locals())
'Hello, World!'

ちょっと乱暴なやり方といえる。

バージョンに制約があることを除けば f-string は使い勝手が良い。

まとめ

  • Python の文字列を整形する方法はいくつかある
  • 現在は str.format() と f-string が主流

参考

methane.hatenablog.jp

www.python.org