CUBE SUGAR CONTAINER

技術系のこと書きます。

Ubuntu 18.04 LTS に OpenCL (NVIDIA CUDA Runtime) をインストールする

OpenCL は、CPU や GPU など様々なプラットフォームを抽象化して並列計算に用いるためのフレームワーク。 今回は Ubuntu 18.04 LTS + NVIDIA Tesla T4 の環境に OpenCL の NVIDIA CUDA ランタイムをインストールしてみる。

使った環境は次の通り。

$ 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-1033-gcp
$ nvidia-smi
Tue Jun  4 13:22:36 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 418.67       Driver Version: 418.67       CUDA Version: 10.1     |
|-------------------------------+----------------------+----------------------+
| 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 T4            On   | 00000000:00:04.0 Off |                    0 |
| N/A   58C    P8    10W /  70W |      0MiB / 15079MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

下準備

利用できる OpenCL のランタイムを確認するために clinfo パッケージをインストールしておく。

$ sudo apt-get update
$ sudo apt-get -y install clinfo

最初の状態では、次の通り利用できるランタイムが存在しない。

$ clinfo
Number of platforms                               0

NVIDIA CUDA をインストールする

実は OpenCL は NVIDIA CUDA をインストールすると自動的に使えるようになる。 なので NVIDIA CUDA をインストールしていく。

NVIDIA CUDA のインストール用 deb ファイルは以下から取得できる。

developer.nvidia.com

ちなみに、使う GPU のアーキテクチャ (Compute Capability) によってサポートされている CUDA のバージョンが異なる点に注意しよう。

Web サイトからファイルをダウンロードする。

$ sudo apt-get -y install wget
$ wget http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/cuda-repo-ubuntu1804_10.1.168-1_amd64.deb

システムに CUDA のリポジトリを登録する。

$ sudo dpkg -i cuda-repo-ubuntu1804_10.1.168-1_amd64.deb
$ sudo apt-key adv --fetch-keys https://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/7fa2af80.pub
$ sudo apt-get update

CUDA をインストールする。

$ sudo apt-get -y install cuda

インストールが終わったらマシンを再起動する。

$ sudo shutdown -r now

マシンが再起動してきたら、nvidia-smi コマンドで GPU の状況が確認できるはず。

$ nvidia-smi
Tue Jun  4 13:20:22 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 418.67       Driver Version: 418.67       CUDA Version: 10.1     |
|-------------------------------+----------------------+----------------------+
| 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 T4            On   | 00000000:00:04.0 Off |                    0 |
| N/A   76C    P0    32W /  70W |      0MiB / 15079MiB |      6%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

また、clinfo コマンドを実行すると次のようにプラットフォームが認識されるはず。 現時点での OpenCL の最新バージョンは 2.1 なので、それに比べると 1.2 はちょっと古い。

$ clinfo | grep "Number of platforms" -A 3
Number of platforms                               1
  Platform Name                                   NVIDIA CUDA
  Platform Vendor                                 NVIDIA Corporation
  Platform Version                                OpenCL 1.2 CUDA 10.1.152

とはいえ、これで OpenCL を使った並列計算が NVIDIA の GPU を使って実行できる。

いじょう。

Ubuntu 16.04 LTS に OpenCL (Intel CPU Runtime) をインストールする

OpenCL は、CPU や GPU など様々なプラットフォームを抽象化して並列計算に用いるためのフレームワーク。 今回は Ubuntu 16.04 LTS の環境に Intel の CPU ランタイムをインストールしてみる。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.6 LTS"
$ uname -r
4.4.0-148-generic

下準備

利用できる OpenCL のランタイムを確認するために clinfo パッケージをインストールしておく。

$ sudo apt-get -y install clinfo

初期の状態では何も利用できない。

$ clinfo
Number of platforms                               0

Intel CPU Runtime をインストールする

最初に、インストールに必要なパッケージをインストールしておく。

$ sudo apt-get -y install lsb-core

Intel CPU Runtime は以下の Web サイトからダウンロードする。

software.intel.com

上記からはいくつかのプラットフォーム向けのランタイムがダウンロードできる。 その中でも Intel® Xeon® Processor or Intel® Core™ Processor を選択する。

ダウンロードしたファイルを解凍する。

$ tar xvf l_opencl_p_18.1.0.015.tgz
$ cd l_opencl_p_18.1.0.015/

インストールスクリプトを実行する。

$ sudo sh install.sh

次のような手順で選択すればインストールできる。

5 (Installation) -> q (pager) -> accept -> 2 (I do NOT consent to the collection of my Information) -> 1 (Accept configuration and begin installation [ default ])

インストールが完了したら、先ほどと同じように clinfo コマンドを実行する。 次のように CPU Runtime が表示されれば上手くいってる。

$ clinfo | grep -A 3 "Number of platforms"
Number of platforms                               1
  Platform Name                                   Intel(R) CPU Runtime for OpenCL(TM) Applications
  Platform Vendor                                 Intel(R) Corporation
  Platform Version                                OpenCL 2.1 LINUX

いじょう。

Python: scikit-learn と色々な TF-IDF の定義について

自然言語処理にあまり馴染みがないのもあって、試しに TF-IDF (Term Frequency - Inverse Document Frequency) を自分で実装してみることにした。 その過程で知ったことについて書き残しておく。 端的に書いてしまうと、TF-IDF の定義は色々とある、ということ。

TF-IDF というのは、コーパス (全文書) に含まれる単語の重要度を評価するための手法。 その名の通り、文書単位で見た単語の頻度 (Term Frequency) と、コーパス単位で見た単語の頻度 (Inverse Document Frequency) を元に計算する。 これは例えば、ある文書の中で頻出する単語 (Term Frequency が高い) があれば、その単語はその文書の中では重要と考えられる。 しかし、その単語が別の文書でも同様によく登場する (Inverse Document Frequency が低い) のであれば、全体から見るとありふれた単語なので実は大して重要ではなくなる。

今回、TF-IDF を実装するにあたって scikit-learn の出力結果をお手本にすることにした。 しかし、やってみるとなかなか結果が一致しない。 原因を探るべくドキュメントやソースコードやチケットシステムを確認していくと、色々と事情が分かってきた。

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

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

下準備

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

$ pip install scikit-learn pandas

scikit-learn の TF-IDF を試す

まずはお手本となる scikit-learn の動作を確認しておこう。 scikit-learn の TF-IDF の実装は TfidfVectorizer にある。 コーパスは scikit-learn の例をそのまま使うことにした。 TF-IDF を計算する上で本来は必要となるはずの BoW (Bag of Words) については別途 CountVectorizer で集計している。

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

import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer


def main():
    # データフレームを表示するときカラムを省略しない
    pd.set_option('display.max_columns', None)
    # 浮動小数点を表示するときは小数点以下 2 桁で揃える
    pd.options.display.float_format = '{:0.2f}'.format

    # 取り扱うコーパス
    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    # 単語の数をカウントする
    count_vectorizer = CountVectorizer()
    X_count = count_vectorizer.fit_transform(corpus)

    # 見やすさのために表示するときは pandas のデータフレームにする
    df = pd.DataFrame(data=X_count.toarray(),
                      columns=count_vectorizer.get_feature_names())
    print('--- BoW (Bag of Words) ---')
    print(df)

    # scikit-learn の TF-IDF 実装
    tfidf_vectorizer = TfidfVectorizer()
    X_tfidf = tfidf_vectorizer.fit_transform(corpus)

    # IDF を表示する
    print('--- IDF (Inverse Document Frequency) ---')
    df = pd.DataFrame(data=[tfidf_vectorizer.idf_],
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)

    # TF-IDF を表示する
    print('--- TF-IDF ---')
    df = pd.DataFrame(data=X_tfidf.toarray(),
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

実行結果は次の通り。 BoW (Bag of Words) は各文書に含まれる単語のカウント、IDF (Inverse Document Frequency) はコーパスにおける単語の珍しさを示す。 例えば and は文書 2 にしか含まれていないので珍しいけど、is は全ての文書に含まれているので珍しくない。 それが IDF の 1.921.00 という数値にあらわれている。 最終的に and は文書 2 で 0.51 という重要度で評価されており、is は文書によってバラつきがあるものの 0.27 ~ 0.38 と評価されている。 TF (Term Frequency) については TfidfVectorizer に取得するインターフェースが見当たらなかった。

$ python sktfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.92      1.22   1.51 1.00 1.92    1.92 1.00   1.92  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38
1 0.00      0.69   0.00 0.28 0.00    0.54 0.28   0.00  0.28
2 0.51      0.00   0.00 0.27 0.51    0.00 0.27   0.51  0.27
3 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38

なお、一般的には andis といったどんな文書でも頻出する類の単語はストップワード (Stop Words) といって処理の対象外とする場合が多い。

自分で実装してみる

続いては TF-IDF を自分で実装してみよう。 TF-IDF の定義については、検索して上位にくるもの (日本語版 Wikipedia など) を用いた。 具体的には次の通り。

 TF(t_i, d_j) = \frac{文書 d_j に登場する単語 t_i の数}{文書 d_j に登場する全ての単語の数}

 IDF(t_i) = \log{\frac{全文書の数}{単語 t_i が含まれる文書の数}}

 TFIDF(t_i, d_j) = TF(t_i, d_j) \cdot IDF(t_i)

そして、上記の定義にもとづいたサンプルコードが次の通り。

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

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # TF を計算してるところ (行方向の処理)
    print('--- TF (Term Frequency) ---')
    # 文書に含まれる単語の数をカウントする
    number_of_words = np.sum(bow, axis=1, keepdims=True)
    # 文書の中での単語の頻度を計算する
    tf = bow / number_of_words
    df = pd.DataFrame(tf,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # IDF を計算してるところ (列方向の処理)
    print('--- IDF (Inverse Document Frequency) ---')
    # 文書の数をカウントする
    number_of_docs = len(corpus)
    # その単語が一つでも含まれる文書の数をカウントする
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0)
    # 単語の珍しさを計算する
    idf = np.log(number_of_docs / number_of_docs_contain_word)
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    # TF-IDF を計算してるところ
    print('--- TF-IDF ---')
    # TF と IDF をかける
    tfidf = tf * idf
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。 先ほどと見比べると分かる通り、IDF と TF-IDF の値が一致していない。 なお、TF については取得するインターフェースがなかったので比較できない。

$ python mytfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- TF (Term Frequency) ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
1 0.00      0.33   0.00 0.17 0.00    0.17 0.17   0.00  0.17
2 0.17      0.00   0.00 0.17 0.17    0.00 0.17   0.17  0.17
3 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.39      0.29   0.69 0.00 1.39    1.39 0.00   1.39  0.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.06   0.14 0.00 0.00    0.00 0.00   0.00  0.00
1 0.00      0.10   0.00 0.00 0.00    0.23 0.00   0.00  0.00
2 0.23      0.00   0.00 0.00 0.23    0.00 0.00   0.23  0.00
3 0.00      0.06   0.14 0.00 0.00    0.00 0.00   0.00  0.00

なんとか結果を一致させてみる

さて、scikit-learn の実装と Wikipedia などを参考にした実装の結果が一致しないことが分かった。 一体何がまずかったのだろうか。 実は、scikit-learn のデフォルトの TF-IDF と結果を一致させるには定義を次のように変更する必要がある。

 TF(t_i, d_j) = \frac{文書 d_j に登場する単語 t_i の数}{文書 d_j に登場する全ての単語の数}

 IDF(t_i) = \log{\frac{全文書の数 + 1}{単語 t_i が含まれる文書の数 + 1}} + 1

 TFIDF(t_i, d_j) = || TF(t_i, d_j) \cdot IDF(t_i) ||_2

先ほどからの変更点は二つある。 まず、一つ目は IDF を計算するときに分子、分母、そして最終的な結果に 1 を加えている。 そして二つ目は TF-IDF を計算するときに結果を L2 で正規化している。

上記にもとづいて変更を加えたサンプルコードが次の通り。

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

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.preprocessing import normalize


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF (Term Frequency) ---')
    number_of_words = np.sum(bow, axis=1, keepdims=True)
    tf = bow / number_of_words
    df = pd.DataFrame(tf,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- IDF (Inverse Document Frequency) ---')
    # 文書の数に 1 を足す
    number_of_docs = len(corpus) + 1
    # 単語が含まれる文書の数にも 1 を足す
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0) + 1
    # 最終的な IDF にも 1 を足す
    idf = np.log(number_of_docs / number_of_docs_contain_word) + 1
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    # 結果を L2 で正規化する
    tfidf = normalize(tf * idf)
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python compatfidf.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- TF (Term Frequency) ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
1 0.00      0.33   0.00 0.17 0.00    0.17 0.17   0.00  0.17
2 0.17      0.00   0.00 0.17 0.17    0.00 0.17   0.17  0.17
3 0.00      0.20   0.20 0.20 0.00    0.00 0.20   0.00  0.20
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 1.92      1.22   1.51 1.00 1.92    1.92 1.00   1.92  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38
1 0.00      0.69   0.00 0.28 0.00    0.54 0.28   0.00  0.28
2 0.51      0.00   0.00 0.27 0.51    0.00 0.27   0.51  0.27
3 0.00      0.47   0.58 0.38 0.00    0.00 0.38   0.00  0.38

今度はちゃんと IDF と TF-IDF が scikit-learn の出力した内容と揃っている。

色々な TF-IDF の定義がある

さて、ここまで見てきた通り同じコーパスなのに scikit-learn の実装と Wikipedia の定義で、二つの異なる結果が得られた。 どちらが正しい TF-IDF なんだろう、という疑問が湧くかもしれないけど、実はどちらも間違ってはいない。 というのも TF-IDF には色々な定義があるため。 この点は、以下のブログや英語版の Wikipedia の TF-IDF のページで詳しく解説されている。

yukinoi.hatenablog.com

en.wikipedia.org

どの TF-IDF の定義も、コーパスに含まれる単語の重要度を評価するという目的自体は変わらない。 ただ、その目的に至るまでの道のりが違っている。 ちなみに scikit-learn の TF-IDF の定義については、そのものずばりなものはおそらく上記にも載っていない。

たとえば IDF の定義は、全ての単語が含まれる仮想的な文書があるようにするところが IDF Smooth に近い。 これはゼロ除算が発生するのを防ぐのに有効な手法だと思う。 ただし、対数の外側でも 1 を加えるところは scikit-learn のオリジナルだと思う。

 IDF(t_i) = \log{\frac{全文書の数 + 1}{単語 t_i が含まれる文書の数 + 1}} + 1

この件はチケットも切られているんだけど、過去の経緯でそうなってるみたいで後方互換性を考えて直さないことになったっぽい。

github.com

最終的な結果を L2 で正規化するところも、ドキュメントを読むと分かる。

 TFIDF(t_i, d_j) = || TF(t_i, d_j) \cdot IDF(t_i) ||_2

L2 正規化と Smooth をしない

実は先ほどの Smooth と L2 正規化は TfidfVectorizer のオプションで無効にできる。

無効にしたサンプルコードは次の通り。

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

import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    X_count = count_vectorizer.fit_transform(corpus)

    df = pd.DataFrame(data=X_count.toarray(),
                      columns=count_vectorizer.get_feature_names())
    print('--- BoW (Bag of Words) ---')
    print(df)

    # 正規化しないし Smooth もしない
    tfidf_vectorizer = TfidfVectorizer(norm=None,
                                       smooth_idf=False)
    X_tfidf = tfidf_vectorizer.fit_transform(corpus)

    print('--- IDF (Inverse Document Frequency) ---')
    df = pd.DataFrame(data=[tfidf_vectorizer.idf_],
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    df = pd.DataFrame(data=X_tfidf.toarray(),
                      columns=tfidf_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python nol2smooth.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 2.39      1.29   1.69 1.00 2.39    2.39 1.00   2.39  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00
1 0.00      2.58   0.00 1.00 0.00    2.39 1.00   0.00  1.00
2 2.39      0.00   0.00 1.00 2.39    0.00 1.00   2.39  1.00
3 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00

結果が先ほどと変わっていることが分かる。

L2 正規化と Smooth をしないパターンを自前で実装する

L2 正規化と Smooth をしないパターンについても、結果が揃う形で実装してみよう。

サンプルコードは次の通り。 IDF の分子と分母に 1 を加えないことで、全ての単語が含まれる仮想的な文書を想定していない。 また、TF-IDF を計算するところで TF として BoW をそのまま使っている。 これは TF の中でも Raw Frequency と呼ばれる定義のようだ。

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

import numpy as np
import pandas as pd

from sklearn.feature_extraction.text import CountVectorizer


def main():
    pd.set_option('display.max_columns', None)
    pd.options.display.float_format = '{:0.2f}'.format

    corpus = [
        'This is the first document.',
        'This document is the second document.',
        'And this is the third one.',
        'Is this the first document?',
    ]

    count_vectorizer = CountVectorizer()
    bow = count_vectorizer.fit_transform(corpus).toarray()
    print('--- BoW (Bag of Words) ---')
    df = pd.DataFrame(bow,
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- IDF (Inverse Document Frequency) ---')
    # 全ての単語が含まれる仮想的な文書を加えない
    number_of_docs = len(corpus)
    number_of_docs_contain_word = np.count_nonzero(bow, axis=0)
    idf = np.log(number_of_docs / number_of_docs_contain_word) + 1
    df = pd.DataFrame([idf],
                      columns=count_vectorizer.get_feature_names())
    print(df)

    print('--- TF-IDF ---')
    # BoW をそのまま使う (TF = Raw Frequency)
    tfidf = bow * idf
    df = pd.DataFrame(tfidf,
                      columns=count_vectorizer.get_feature_names())
    print(df)


if __name__ == '__main__':
    main()

上記を実行してみる。

$ python mynol2smooth.py 
--- BoW (Bag of Words) ---
   and  document  first  is  one  second  the  third  this
0    0         1      1   1    0       0    1      0     1
1    0         2      0   1    0       1    1      0     1
2    1         0      0   1    1       0    1      1     1
3    0         1      1   1    0       0    1      0     1
--- IDF (Inverse Document Frequency) ---
   and  document  first   is  one  second  the  third  this
0 2.39      1.29   1.69 1.00 2.39    2.39 1.00   2.39  1.00
--- TF-IDF ---
   and  document  first   is  one  second  the  third  this
0 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00
1 0.00      2.58   0.00 1.00 0.00    2.39 1.00   0.00  1.00
2 2.39      0.00   0.00 1.00 2.39    0.00 1.00   2.39  1.00
3 0.00      1.29   1.69 1.00 0.00    0.00 1.00   0.00  1.00

ちゃんと結果が揃っている。

まとめ

軽い気持ちで TF-IDF を実装してみようと思ったら、思ったより面白かった。 TF-IDF は定義が色々とあるので、実装によって返す値が全然違う可能性がある。 scikit-learn の実装は過去の経緯も一部あるけど、デフォルトで便利なように工夫されていると感じた。

いじょう。

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 を使ってみる

(2019-09-22 追記) NVIDIA-Docker2 は現在では非推奨 (Deprecated) な方法となっています。 代わりに NVIDIA Container Toolkit を使ってください。

blog.amedama.jp


ニューラルネットワークに代表される機械学習の一部のアルゴリズムでは、学習する上で大量の行列演算を必要とする。 そこで、行列演算を高速化する目的で 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 倍になっていることを考えれば当たり前だけどエラーになる。

いじょう。